Skip to content

Commit

Permalink
[Security Solution] Alerts visualization free field selection (#120610)
Browse files Browse the repository at this point in the history
* Use EuiComboBox in place of a select for alert page visuals

* Remove console.log

* Better variable name in reduce function

* Remove comment

* Remove typo

* Fix linting/tests

* PR feedback

* Add missing mocks

* Add router mocks

* Rename getAggregatableFields
  • Loading branch information
kqualters-elastic authored Dec 15, 2021
1 parent 125f300 commit e768f1d
Show file tree
Hide file tree
Showing 12 changed files with 154 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import { AlertsCountAggregation } from './types';
jest.mock('../../../../common/lib/kibana');
const mockDispatch = jest.fn();

jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) };
});

jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
*/

import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../common/constants';
import type { AlertsStackByField } from '../common/types';

export const getAlertsCountQuery = (
stackByField: AlertsStackByField,
stackByField: string,
from: string,
to: string,
additionalFilters: Array<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import { TestProviders } from '../../../../common/mock';

import { AlertsCountPanel } from './index';

jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) };
});

describe('AlertsCountPanel', () => {
const defaultProps = {
signalIndexName: 'signalIndexName',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ import * as i18n from './translations';
import { AlertsCount } from './alerts_count';
import type { AlertsCountAggregation } from './types';
import { DEFAULT_STACK_BY_FIELD } from '../common/config';
import type { AlertsStackByField } from '../common/types';
import { KpiPanel, StackBySelect } from '../common/components';
import { KpiPanel, StackByComboBox } from '../common/components';
import { useInspectButton } from '../common/hooks';

export const DETECTIONS_ALERTS_COUNT_ID = 'detections-alerts-count';
Expand All @@ -39,8 +38,7 @@ export const AlertsCountPanel = memo<AlertsCountPanelProps>(

// create a unique, but stable (across re-renders) query id
const uniqueQueryId = useMemo(() => `${DETECTIONS_ALERTS_COUNT_ID}-${uuid.v4()}`, []);
const [selectedStackByOption, setSelectedStackByOption] =
useState<AlertsStackByField>(DEFAULT_STACK_BY_FIELD);
const [selectedStackByOption, setSelectedStackByOption] = useState(DEFAULT_STACK_BY_FIELD);

// TODO: Once we are past experimental phase this code should be removed
// const fetchMethod = useIsExperimentalFeatureEnabled('ruleRegistryEnabled')
Expand Down Expand Up @@ -99,7 +97,7 @@ export const AlertsCountPanel = memo<AlertsCountPanelProps>(
titleSize="s"
hideSubtitle
>
<StackBySelect selected={selectedStackByOption} onSelect={setSelectedStackByOption} />
<StackByComboBox selected={selectedStackByOption} onSelect={setSelectedStackByOption} />
</HeaderSection>
<AlertsCount
data={alertsData}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import moment from 'moment';

import type { HistogramData, AlertsAggregation, AlertsBucket, AlertsGroupBucket } from './types';
import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types';
import type { AlertsStackByField } from '../common/types';

const EMPTY_ALERTS_DATA: HistogramData[] = [];

Expand All @@ -33,7 +32,7 @@ export const formatAlertsData = (alertsData: AlertSearchResponse<{}, AlertsAggre
};

export const getAlertsHistogramQuery = (
stackByField: AlertsStackByField,
stackByField: string,
from: string,
to: string,
additionalFilters: Array<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jest.mock('react-router-dom', () => {
...originalModule,
createHref: jest.fn(),
useHistory: jest.fn(),
useLocation: jest.fn().mockReturnValue({ pathname: '' }),
};
});

Expand All @@ -37,9 +38,21 @@ jest.mock('../../../../common/lib/kibana/kibana_react', () => {
navigateToApp: mockNavigateToApp,
getUrlForApp: jest.fn(),
},
data: {
search: {
search: jest.fn(),
},
},
uiSettings: {
get: jest.fn(),
},
notifications: {
toasts: {
addWarning: jest.fn(),
addError: jest.fn(),
addSuccess: jest.fn(),
},
},
},
}),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { LinkButton } from '../../../../common/components/links';
import { SecurityPageName } from '../../../../app/types';
import { DEFAULT_STACK_BY_FIELD, PANEL_HEIGHT } from '../common/config';
import type { AlertsStackByField } from '../common/types';
import { KpiPanel, StackBySelect } from '../common/components';
import { KpiPanel, StackByComboBox } from '../common/components';

import { useInspectButton } from '../common/hooks';

Expand Down Expand Up @@ -109,7 +109,7 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
const [isInspectDisabled, setIsInspectDisabled] = useState(false);
const [defaultNumberFormat] = useUiSetting$<string>(DEFAULT_NUMBER_FORMAT);
const [totalAlertsObj, setTotalAlertsObj] = useState<AlertsTotal>(defaultTotalAlertsObj);
const [selectedStackByOption, setSelectedStackByOption] = useState<AlertsStackByField>(
const [selectedStackByOption, setSelectedStackByOption] = useState<string>(
onlyField == null ? defaultStackByOption : onlyField
);

Expand Down Expand Up @@ -276,10 +276,12 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
{showStackBy && (
<StackBySelect
selected={selectedStackByOption}
onSelect={setSelectedStackByOption}
/>
<>
<StackByComboBox
selected={selectedStackByOption}
onSelect={setSelectedStackByOption}
/>
</>
)}
{headerChildren != null && headerChildren}
</EuiFlexItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
* 2.0.
*/

import { EuiPanel, EuiSelect } from '@elastic/eui';
import { EuiPanel, EuiComboBox } from '@elastic/eui';
import styled from 'styled-components';
import React, { useCallback } from 'react';
import { PANEL_HEIGHT, MOBILE_PANEL_HEIGHT, alertsStackByOptions } from './config';
import type { AlertsStackByField } from './types';
import React, { useCallback, useMemo } from 'react';
import { PANEL_HEIGHT, MOBILE_PANEL_HEIGHT } from './config';
import { useStackByFields } from './hooks';
import * as i18n from './translations';

export const KpiPanel = styled(EuiPanel)<{ height?: number }>`
Expand All @@ -25,24 +25,45 @@ export const KpiPanel = styled(EuiPanel)<{ height?: number }>`
}
`;
interface StackedBySelectProps {
selected: AlertsStackByField;
onSelect: (selected: AlertsStackByField) => void;
selected: string;
onSelect: (selected: string) => void;
}

export const StackBySelect: React.FC<StackedBySelectProps> = ({ selected, onSelect }) => {
const setSelectedOptionCallback = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
onSelect(event.target.value as AlertsStackByField);
export const StackByComboBoxWrapper = styled.div`
width: 400px;
`;

export const StackByComboBox: React.FC<StackedBySelectProps> = ({ selected, onSelect }) => {
const onChange = useCallback(
(options) => {
if (options && options.length > 0) {
onSelect(options[0].value);
} else {
onSelect('');
}
},
[onSelect]
);

const selectedOptions = useMemo(() => {
return [{ label: selected, value: selected }];
}, [selected]);
const stackOptions = useStackByFields();
const singleSelection = useMemo(() => {
return { asPlainText: true };
}, []);
return (
<EuiSelect
onChange={setSelectedOptionCallback}
options={alertsStackByOptions}
prepend={i18n.STACK_BY_LABEL}
value={selected}
/>
<StackByComboBoxWrapper>
<EuiComboBox
aria-label={i18n.STACK_BY_ARIA_LABEL}
placeholder={i18n.STACK_BY_PLACEHOLDER}
prepend={i18n.STACK_BY_LABEL}
singleSelection={singleSelection}
sortMatchesBy="startsWith"
options={stackOptions}
selectedOptions={selectedOptions}
compressed
onChange={onChange}
/>
</StackByComboBoxWrapper>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@
* 2.0.
*/

import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { useInspectButton, UseInspectButtonParams } from './hooks';
import { useInspectButton, UseInspectButtonParams, useStackByFields } from './hooks';
import { mockBrowserFields } from '../../../../common/containers/source/mock';
import { TestProviders } from '../../../../common/mock';

jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) };
});

describe('hooks', () => {
describe('useInspectButton', () => {
Expand Down Expand Up @@ -43,4 +51,22 @@ describe('hooks', () => {
expect(mockDeleteQuery).toHaveBeenCalledWith({ id: defaultParams.uniqueQueryId });
});
});

describe('useStackByFields', () => {
jest.mock('../../../../common/containers/sourcerer', () => ({
useSourcererDataView: jest.fn().mockReturnValue({ browserFields: mockBrowserFields }),
}));
it('returns only aggregateable fields', () => {
const wrapper = ({ children }: { children: JSX.Element }) => (
<TestProviders>{children}</TestProviders>
);
const { result, unmount } = renderHook(() => useStackByFields(), { wrapper });
const aggregateableFields = result.current;
unmount();
expect(aggregateableFields?.find((field) => field.label === 'agent.id')).toBeTruthy();
expect(
aggregateableFields?.find((field) => field.label === 'nestedField.firstAttributes')
).toBe(undefined);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@
* 2.0.
*/

import { useEffect } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import type { BrowserField } from '../../../../../../timelines/common';
import type { GlobalTimeArgs } from '../../../../common/containers/use_global_time';
import { getScopeFromPath, useSourcererDataView } from '../../../../common/containers/sourcerer';
import { getAllFieldsByName } from '../../../../common/containers/source';

export interface UseInspectButtonParams extends Pick<GlobalTimeArgs, 'setQuery' | 'deleteQuery'> {
response: string;
Expand All @@ -15,6 +20,7 @@ export interface UseInspectButtonParams extends Pick<GlobalTimeArgs, 'setQuery'
uniqueQueryId: string;
loading: boolean;
}

/**
* * Add query to inspect button utility.
* * Delete query from inspect button utility when component unmounts
Expand Down Expand Up @@ -48,3 +54,30 @@ export const useInspectButton = ({
};
}, [setQuery, loading, response, request, refetch, uniqueQueryId, deleteQuery]);
};

function getAggregatableFields(fields: { [fieldName: string]: Partial<BrowserField> }) {
return Object.entries(fields).reduce<EuiComboBoxOptionOption[]>(
(filteredOptions: EuiComboBoxOptionOption[], [key, field]) => {
if (field.aggregatable === true) {
return [...filteredOptions, { label: key, value: key }];
} else {
return filteredOptions;
}
},
[]
);
}

export const useStackByFields = () => {
const { pathname } = useLocation();

const { browserFields } = useSourcererDataView(getScopeFromPath(pathname));
const allFields = useMemo(() => getAllFieldsByName(browserFields), [browserFields]);
const [stackByFieldOptions, setStackByFieldOptions] = useState(() =>
getAggregatableFields(allFields)
);
useEffect(() => {
setStackByFieldOptions(getAggregatableFields(allFields));
}, [allFields]);
return useMemo(() => stackByFieldOptions, [stackByFieldOptions]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,17 @@ export const STACK_BY_LABEL = i18n.translate(
defaultMessage: 'Stack by',
}
);

export const STACK_BY_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.detectionEngine.alerts.histogram.stackByOptions.stackByPlaceholder',
{
defaultMessage: 'Select a field to stack by',
}
);

export const STACK_BY_ARIA_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.alerts.histogram.stackByOptions.stackByAriaLabel',
{
defaultMessage: 'Stack the alerts histogram by a field value',
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { createStore, State } from '../../../common/store';
import { mockHistory, Router } from '../../../common/mock/router';
import { mockTimelines } from '../../../common/mock/mock_timelines_plugin';
import { mockBrowserFields } from '../../../common/containers/source/mock';

// Test will fail because we will to need to mock some core services to make the test work
// For now let's forget about SiemSearchBar and QueryBar
Expand Down Expand Up @@ -71,6 +72,9 @@ jest.mock('../../../common/lib/kibana', () => {
siem: { crud_alerts: true, read_alerts: true },
},
},
uiSettings: {
get: jest.fn(),
},
timelines: { ...mockTimelines },
data: {
query: {
Expand Down Expand Up @@ -113,6 +117,7 @@ describe('DetectionEnginePageComponent', () => {
(useSourcererDataView as jest.Mock).mockReturnValue({
indicesExist: true,
indexPattern: {},
browserFields: mockBrowserFields,
});
});

Expand Down

0 comments on commit e768f1d

Please sign in to comment.