[SIEM] Adds Signals Table and additional configuration options to StatefulEventsViewer (#52044)

## Summary

This is `Part I` of `II` for adding the `Signals Table` to the main Detection Engine landing page ([meta issue](https://github.com/elastic/kibana/issues/50405)). Breaking into two parts as this contains additional configuration options to the `StatefulEventsViewer` which will be used as part of https://github.com/elastic/kibana/issues/51016.

`Part I` includes:
* `SignalsTable` component that displays signals from the default signals index `.siem-signals`
* Refactors `StatefulEventsViewer` to use `useFetchIndexPatterns` hook instead of `WithSource`
* Adds ability to specify `alias` to `ColumnHeader` when providing column names
* Adds the following new props to `StatefulEventsViewer`
  * `defaultIndices?: string[]` -- for specifying a different index than `siemDefaultIndex`
  * `headerFilterGroup?: React.ReactNode` -- for providing a component to display in the top right of the table (e.g. filter buttons, select, etc.)
  * `timelineTypeContext?: TimelineTypeContextProps` -- config for when creating a new table
    * `documentType?: string` -- user string for type of records displayed (e.g. Signals)
    * `footerText?: string` -- custom footer text for given document type
    * `showCheckboxes: boolean` -- whether or not to show selection checkboxes
    * `showRowRenderers: boolean` -- whether or not to show row renderers
    * `timelineType: TimelineType` -- type of Timeline for setting default columns
    * `title?: string` -- optional custom title
  * `utilityBar?: (totalCount: number) => React.ReactNode` -- optional param for providing your own custom `UtilityBar` instead of using the default `Showing xxx events`.

`Part II` will add support for selection and overflow/batch actions.

<img width="1548" alt="Screen Shot 2019-12-02 at 19 59 34" src="https://user-images.githubusercontent.com/2946766/70016801-89aa0c80-153e-11ea-9dbf-b7b8648fb260.png">

### Checklist

Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR.

- [x] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)
- [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)
  * Note: some placeholders were moved to their own files, and so some raw strings will still exist
- [ ] ~[Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~
- [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios
- [ ] ~This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~

### For maintainers

- [ ] ~This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~
- [ ] ~This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~
This commit is contained in:
Garrett Spong 2019-12-04 23:11:58 -07:00 committed by GitHub
parent e8f3fa91d9
commit f21d5ada5a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 765 additions and 348 deletions

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { defaultHeaders } from './default_headers';
import { SubsetTimelineModel, timelineDefaults } from '../../store/timeline/model';
export const eventsDefaultModel: SubsetTimelineModel = {
...timelineDefaults,
columns: defaultHeaders,
};

View file

@ -8,7 +8,7 @@ import { mount } from 'enzyme';
import React from 'react';
import { MockedProvider } from 'react-apollo/test-utils';
import { TestProviders } from '../../mock';
import { mockIndexPattern, TestProviders } from '../../mock';
import { mockUiSettings } from '../../mock/ui_settings';
import { wait } from '../../lib/helpers';
@ -16,6 +16,9 @@ import { mockEventViewerResponse } from './mock';
import { StatefulEventsViewer } from '.';
import { defaultHeaders } from './default_headers';
import { useKibanaCore } from '../../lib/compose/kibana_core';
import { useFetchIndexPatterns } from '../../containers/detection_engine/rules/fetch_index_patterns';
import { mockBrowserFields } from '../../containers/source/mock';
import { eventsDefaultModel } from './default_model';
jest.mock('../../lib/settings/use_kibana_ui_setting');
@ -25,6 +28,15 @@ mockUseKibanaCore.mockImplementation(() => ({
uiSettings: mockUiSettings,
}));
const mockUseFetchIndexPatterns: jest.Mock = useFetchIndexPatterns as jest.Mock;
jest.mock('../../containers/detection_engine/rules/fetch_index_patterns');
mockUseFetchIndexPatterns.mockImplementation(() => [
{
browserFields: mockBrowserFields,
indexPatterns: mockIndexPattern,
},
]);
const from = 1566943856794;
const to = 1566857456791;
@ -33,7 +45,12 @@ describe('EventsViewer', () => {
const wrapper = mount(
<TestProviders>
<MockedProvider mocks={mockEventViewerResponse} addTypename={false}>
<StatefulEventsViewer end={to} id={'test-stateful-events-viewer'} start={from} />
<StatefulEventsViewer
defaultModel={eventsDefaultModel}
end={to}
id={'test-stateful-events-viewer'}
start={from}
/>
</MockedProvider>
</TestProviders>
);
@ -53,7 +70,12 @@ describe('EventsViewer', () => {
const wrapper = mount(
<TestProviders>
<MockedProvider mocks={mockEventViewerResponse} addTypename={false}>
<StatefulEventsViewer end={to} id={'test-stateful-events-viewer'} start={from} />
<StatefulEventsViewer
defaultModel={eventsDefaultModel}
end={to}
id={'test-stateful-events-viewer'}
start={from}
/>
</MockedProvider>
</TestProviders>
);
@ -73,7 +95,12 @@ describe('EventsViewer', () => {
const wrapper = mount(
<TestProviders>
<MockedProvider mocks={mockEventViewerResponse} addTypename={false}>
<StatefulEventsViewer end={to} id={'test-stateful-events-viewer'} start={from} />
<StatefulEventsViewer
defaultModel={eventsDefaultModel}
end={to}
id={'test-stateful-events-viewer'}
start={from}
/>
</MockedProvider>
</TestProviders>
);
@ -94,7 +121,12 @@ describe('EventsViewer', () => {
const wrapper = mount(
<TestProviders>
<MockedProvider mocks={mockEventViewerResponse} addTypename={false}>
<StatefulEventsViewer end={to} id={'test-stateful-events-viewer'} start={from} />
<StatefulEventsViewer
defaultModel={eventsDefaultModel}
end={to}
id={'test-stateful-events-viewer'}
start={from}
/>
</MockedProvider>
</TestProviders>
);

View file

@ -26,13 +26,13 @@ import { Footer, footerHeight } from '../timeline/footer';
import { combineQueries } from '../timeline/helpers';
import { TimelineRefetch } from '../timeline/refetch_timeline';
import { isCompactFooter } from '../timeline/timeline';
import { ManageTimelineContext } from '../timeline/timeline_context';
import { ManageTimelineContext, TimelineTypeContextProps } from '../timeline/timeline_context';
import * as i18n from './translations';
import {
IIndexPattern,
Query,
esFilters,
esQuery,
IIndexPattern,
Query,
} from '../../../../../../../src/plugins/data/public';
const DEFAULT_EVENTS_VIEWER_HEIGHT = 500;
@ -48,6 +48,7 @@ interface Props {
dataProviders: DataProvider[];
end: number;
filters: esFilters.Filter[];
headerFilterGroup?: React.ReactNode;
height?: number;
id: string;
indexPattern: IIndexPattern;
@ -60,7 +61,9 @@ interface Props {
showInspect: boolean;
start: number;
sort: Sort;
timelineTypeContext: TimelineTypeContextProps;
toggleColumn: (column: ColumnHeader) => void;
utilityBar?: (totalCount: number) => React.ReactNode;
}
export const EventsViewer = React.memo<Props>(
@ -70,6 +73,7 @@ export const EventsViewer = React.memo<Props>(
dataProviders,
end,
filters,
headerFilterGroup,
height = DEFAULT_EVENTS_VIEWER_HEIGHT,
id,
indexPattern,
@ -82,7 +86,9 @@ export const EventsViewer = React.memo<Props>(
showInspect,
start,
sort,
timelineTypeContext,
toggleColumn,
utilityBar,
}) => {
const columnsHeader = isEmpty(columns) ? defaultHeaders : columns;
const core = useKibanaCore();
@ -116,6 +122,7 @@ export const EventsViewer = React.memo<Props>(
fields={columnsHeader.map(c => c.id)}
filterQuery={combinedQueries.filterQuery}
id={id}
indexPattern={indexPattern}
limit={itemsPerPage}
sortField={{
sortFieldId: sort.columnId,
@ -137,17 +144,29 @@ export const EventsViewer = React.memo<Props>(
<HeaderSection
id={id}
showInspect={showInspect}
subtitle={`${i18n.SHOWING}: ${totalCount.toLocaleString()} ${i18n.UNIT(
totalCount
)}`}
title={i18n.EVENTS}
/>
subtitle={
utilityBar
? undefined
: `${i18n.SHOWING}: ${totalCount.toLocaleString()} ${i18n.UNIT(
totalCount
)}`
}
title={timelineTypeContext?.title ?? i18n.EVENTS}
>
{headerFilterGroup}
</HeaderSection>
{utilityBar?.(totalCount)}
<div
data-test-subj={`events-container-loading-${loading}`}
style={{ width: `${width}px` }}
>
<ManageTimelineContext loading={loading} width={width}>
<ManageTimelineContext
loading={loading}
width={width}
type={timelineTypeContext}
>
<TimelineRefetch
id={id}
inputId="global"

View file

@ -10,11 +10,14 @@ import { MockedProvider } from 'react-apollo/test-utils';
import { useKibanaCore } from '../../lib/compose/kibana_core';
import { wait } from '../../lib/helpers';
import { TestProviders } from '../../mock';
import { mockIndexPattern, TestProviders } from '../../mock';
import { mockUiSettings } from '../../mock/ui_settings';
import { mockEventViewerResponse } from './mock';
import { StatefulEventsViewer } from '.';
import { useFetchIndexPatterns } from '../../containers/detection_engine/rules/fetch_index_patterns';
import { mockBrowserFields } from '../../containers/source/mock';
import { eventsDefaultModel } from './default_model';
jest.mock('../../lib/settings/use_kibana_ui_setting');
@ -24,6 +27,15 @@ mockUseKibanaCore.mockImplementation(() => ({
uiSettings: mockUiSettings,
}));
const mockUseFetchIndexPatterns: jest.Mock = useFetchIndexPatterns as jest.Mock;
jest.mock('../../containers/detection_engine/rules/fetch_index_patterns');
mockUseFetchIndexPatterns.mockImplementation(() => [
{
browserFields: mockBrowserFields,
indexPatterns: mockIndexPattern,
},
]);
const from = 1566943856794;
const to = 1566857456791;
@ -32,7 +44,12 @@ describe('StatefulEventsViewer', () => {
const wrapper = mount(
<TestProviders>
<MockedProvider mocks={mockEventViewerResponse} addTypename={false}>
<StatefulEventsViewer end={to} id={'test-stateful-events-viewer'} start={from} />
<StatefulEventsViewer
defaultModel={eventsDefaultModel}
end={to}
id={'test-stateful-events-viewer'}
start={from}
/>
</MockedProvider>
</TestProviders>
);
@ -52,7 +69,12 @@ describe('StatefulEventsViewer', () => {
const wrapper = mount(
<TestProviders>
<MockedProvider mocks={mockEventViewerResponse} addTypename={false}>
<StatefulEventsViewer end={to} id={'test-stateful-events-viewer'} start={from} />
<StatefulEventsViewer
defaultModel={eventsDefaultModel}
end={to}
id={'test-stateful-events-viewer'}
start={from}
/>
</MockedProvider>
</TestProviders>
);
@ -72,7 +94,12 @@ describe('StatefulEventsViewer', () => {
const wrapper = mount(
<TestProviders>
<MockedProvider mocks={mockEventViewerResponse} addTypename={false}>
<StatefulEventsViewer end={to} id={'test-stateful-events-viewer'} start={from} />
<StatefulEventsViewer
defaultModel={eventsDefaultModel}
end={to}
id={'test-stateful-events-viewer'}
start={from}
/>
</MockedProvider>
</TestProviders>
);

View file

@ -5,26 +5,35 @@
*/
import { isEqual } from 'lodash/fp';
import React, { useEffect, useState, useCallback } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { ActionCreator } from 'typescript-fsa';
import { WithSource } from '../../containers/source';
import chrome from 'ui/chrome';
import { inputsModel, inputsSelectors, State, timelineSelectors } from '../../store';
import { timelineActions, inputsActions } from '../../store/actions';
import { KqlMode, TimelineModel } from '../../store/timeline/model';
import { inputsActions, timelineActions } from '../../store/actions';
import { KqlMode, SubsetTimelineModel, TimelineModel } from '../../store/timeline/model';
import { ColumnHeader } from '../timeline/body/column_headers/column_header';
import { DataProvider } from '../timeline/data_providers/data_provider';
import { Sort } from '../timeline/body/sort';
import { OnChangeItemsPerPage } from '../timeline/events';
import { Query, esFilters } from '../../../../../../../src/plugins/data/public';
import { esFilters, Query } from '../../../../../../../src/plugins/data/public';
import { EventsViewer } from './events_viewer';
import { InputsModelId } from '../../store/inputs/constants';
import { useFetchIndexPatterns } from '../../containers/detection_engine/rules/fetch_index_patterns';
import { TimelineTypeContextProps } from '../timeline/timeline_context';
import { DEFAULT_INDEX_KEY } from '../../../common/constants';
export interface OwnProps {
defaultIndices?: string[];
defaultFilters?: esFilters.Filter[];
defaultModel: SubsetTimelineModel;
end: number;
id: string;
start: number;
headerFilterGroup?: React.ReactNode;
timelineTypeContext?: TimelineTypeContextProps;
utilityBar?: (totalCount: number) => React.ReactNode;
}
interface StateReduxProps {
@ -74,9 +83,12 @@ const StatefulEventsViewerComponent = React.memo<Props>(
createTimeline,
columns,
dataProviders,
defaultModel,
defaultIndices,
deleteEventQuery,
end,
filters,
headerFilterGroup,
id,
isLive,
itemsPerPage,
@ -86,10 +98,18 @@ const StatefulEventsViewerComponent = React.memo<Props>(
removeColumn,
start,
sort,
timelineTypeContext = {
showCheckboxes: false,
showRowRenderers: true,
},
updateItemsPerPage,
upsertColumn,
utilityBar,
}) => {
const [showInspect, setShowInspect] = useState(false);
const [{ browserFields, indexPatterns }] = useFetchIndexPatterns(
defaultIndices ?? chrome.getUiSettingsClient().get(DEFAULT_INDEX_KEY)
);
useEffect(() => {
if (createTimeline != null) {
@ -131,31 +151,30 @@ const StatefulEventsViewerComponent = React.memo<Props>(
const handleOnMouseLeave = useCallback(() => setShowInspect(false), []);
return (
<WithSource sourceId="default">
{({ indexPattern, browserFields }) => (
<div onMouseEnter={handleOnMouseEnter} onMouseLeave={handleOnMouseLeave}>
<EventsViewer
browserFields={browserFields}
columns={columns}
id={id}
dataProviders={dataProviders!}
end={end}
filters={filters}
indexPattern={indexPattern}
isLive={isLive}
itemsPerPage={itemsPerPage!}
itemsPerPageOptions={itemsPerPageOptions!}
kqlMode={kqlMode}
onChangeItemsPerPage={onChangeItemsPerPage}
query={query}
showInspect={showInspect}
start={start}
sort={sort!}
toggleColumn={toggleColumn}
/>
</div>
)}
</WithSource>
<div onMouseEnter={handleOnMouseEnter} onMouseLeave={handleOnMouseLeave}>
<EventsViewer
browserFields={browserFields ?? {}}
columns={columns}
id={id}
dataProviders={dataProviders!}
end={end}
filters={filters}
headerFilterGroup={headerFilterGroup}
indexPattern={indexPatterns ?? { fields: [], title: '' }}
isLive={isLive}
itemsPerPage={itemsPerPage!}
itemsPerPageOptions={itemsPerPageOptions!}
kqlMode={kqlMode}
onChangeItemsPerPage={onChangeItemsPerPage}
query={query}
showInspect={showInspect}
start={start}
sort={sort!}
timelineTypeContext={timelineTypeContext}
toggleColumn={toggleColumn}
utilityBar={utilityBar}
/>
</div>
);
},
(prevProps, nextProps) =>
@ -182,9 +201,9 @@ const makeMapStateToProps = () => {
const getGlobalQuerySelector = inputsSelectors.globalQuerySelector();
const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector();
const getEvents = timelineSelectors.getEventsByIdSelector();
const mapStateToProps = (state: State, { id }: OwnProps) => {
const mapStateToProps = (state: State, { id, defaultModel }: OwnProps) => {
const input: inputsModel.InputsRange = getInputsTimeline(state);
const events: TimelineModel = getEvents(state, id);
const events: TimelineModel = getEvents(state, id) ?? defaultModel;
const { columns, dataProviders, itemsPerPage, itemsPerPageOptions, kqlMode, sort } = events;
return {

View file

@ -5,7 +5,6 @@
*/
import { noop } from 'lodash/fp';
import { defaultIndexPattern } from '../../../default_index_pattern';
import { timelineQuery } from '../../containers/timeline/index.gql_query';
export const mockEventViewerResponse = [
@ -31,7 +30,7 @@ export const mockEventViewerResponse = [
sourceId: 'default',
pagination: { limit: 25, cursor: null, tiebreaker: null },
sortField: { sortFieldId: '@timestamp', direction: 'desc' },
defaultIndex: defaultIndexPattern,
defaultIndex: ['filebeat-*', 'auditbeat-*', 'packetbeat-*'],
inspect: false,
},
},

View file

@ -25,7 +25,7 @@ import { Properties } from '../../timeline/properties';
import { appActions, appModel } from '../../../store/app';
import { inputsActions } from '../../../store/inputs';
import { timelineActions } from '../../../store/actions';
import { TimelineModel } from '../../../store/timeline/model';
import { timelineDefaults, TimelineModel } from '../../../store/timeline/model';
import { DEFAULT_TIMELINE_WIDTH } from '../../timeline/body/helpers';
import { InputsModelId } from '../../../store/inputs/constants';
@ -129,7 +129,7 @@ const makeMapStateToProps = () => {
const getNotesByIds = appSelectors.notesByIdsSelector();
const getGlobalInput = inputsSelectors.globalSelector();
const mapStateToProps = (state: State, { timelineId }: OwnProps) => {
const timeline: TimelineModel = getTimeline(state, timelineId);
const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults;
const globalInput: inputsModel.InputsRange = getGlobalInput(state);
const {
dataProviders,

View file

@ -42,6 +42,7 @@ import {
} from './types';
import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants';
import { ColumnHeader } from '../timeline/body/column_headers/column_header';
import { timelineDefaults } from '../../store/timeline/model';
interface OwnProps<TCache = object> {
apolloClient: ApolloClient<TCache>;
@ -315,7 +316,7 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
const makeMapStateToProps = () => {
const getTimeline = timelineSelectors.getTimelineByIdSelector();
const mapStateToProps = (state: State) => {
const timeline = getTimeline(state, 'timeline-1');
const timeline = getTimeline(state, 'timeline-1') ?? timelineDefaults;
return {
timeline,

View file

@ -17,6 +17,7 @@ export interface ColumnHeader {
example?: string;
format?: string;
id: ColumnId;
label?: string;
placeholder?: string;
type?: string;
width: number;

View file

@ -67,6 +67,31 @@ describe('Header', () => {
).toEqual(columnHeader.id);
});
test('it renders the header text alias when label is provided', () => {
const label = 'Timestamp';
const headerWithLabel = { ...columnHeader, label };
const wrapper = mount(
<TestProviders>
<HeaderComponent
header={headerWithLabel}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={jest.fn()}
setIsResizing={jest.fn()}
sort={sort}
timelineId={timelineId}
/>
</TestProviders>
);
expect(
wrapper
.find(`[data-test-subj="header-text-${columnHeader.id}"]`)
.first()
.text()
).toEqual(label);
});
test('it renders a sort indicator', () => {
const headerSortable = { ...columnHeader, aggregatable: true };
const wrapper = mount(

View file

@ -50,7 +50,7 @@ const HeaderComp = React.memo<HeaderCompProps>(
data-test-subj="header-tooltip"
content={<HeaderToolTipContent header={header} />}
>
<>{header.id}</>
<>{header.label ?? header.id}</>
</EuiToolTip>
</TruncatableText>
@ -66,7 +66,7 @@ const HeaderComp = React.memo<HeaderCompProps>(
data-test-subj="header-tooltip"
content={<HeaderToolTipContent header={header} />}
>
<>{header.id}</>
<>{header.label ?? header.id}</>
</EuiToolTip>
</TruncatableText>
</EventsHeadingTitleSpan>

View file

@ -17,6 +17,7 @@ import { ColumnHeader } from '../column_headers/column_header';
import { DataDrivenColumns } from '../data_driven_columns';
import { eventHasNotes, getPinOnClick } from '../helpers';
import { ColumnRenderer } from '../renderers/column_renderer';
import { useTimelineTypeContext } from '../../timeline_context';
interface Props {
id: string;
@ -67,44 +68,48 @@ export const EventColumnView = React.memo<Props>(
timelineId,
toggleShowNotes,
updateNote,
}) => (
<EventsTrData data-test-subj="event-column-view">
<Actions
actionsColumnWidth={actionsColumnWidth}
associateNote={associateNote}
checked={false}
expanded={expanded}
data-test-subj="actions"
eventId={id}
eventIsPinned={isEventPinned}
getNotesByIds={getNotesByIds}
isEventViewer={isEventViewer}
loading={loading}
noteIds={eventIdToNoteIds[id] || emptyNotes}
onEventToggled={onEventToggled}
onPinClicked={getPinOnClick({
allowUnpinning: !eventHasNotes(eventIdToNoteIds[id]),
eventId: id,
onPinEvent,
onUnPinEvent,
isEventPinned,
})}
showCheckboxes={false}
showNotes={showNotes}
toggleShowNotes={toggleShowNotes}
updateNote={updateNote}
/>
}) => {
const timelineTypeContext = useTimelineTypeContext();
<DataDrivenColumns
_id={id}
columnHeaders={columnHeaders}
columnRenderers={columnRenderers}
data={data}
onColumnResized={onColumnResized}
timelineId={timelineId}
/>
</EventsTrData>
),
return (
<EventsTrData data-test-subj="event-column-view">
<Actions
actionsColumnWidth={actionsColumnWidth}
associateNote={associateNote}
checked={false}
expanded={expanded}
data-test-subj="actions"
eventId={id}
eventIsPinned={isEventPinned}
getNotesByIds={getNotesByIds}
isEventViewer={isEventViewer}
loading={loading}
noteIds={eventIdToNoteIds[id] || emptyNotes}
onEventToggled={onEventToggled}
onPinClicked={getPinOnClick({
allowUnpinning: !eventHasNotes(eventIdToNoteIds[id]),
eventId: id,
onPinEvent,
onUnPinEvent,
isEventPinned,
})}
showCheckboxes={timelineTypeContext.showCheckboxes}
showNotes={showNotes}
toggleShowNotes={toggleShowNotes}
updateNote={updateNote}
/>
<DataDrivenColumns
_id={id}
columnHeaders={columnHeaders}
columnRenderers={columnRenderers}
data={data}
onColumnResized={onColumnResized}
timelineId={timelineId}
/>
</EventsTrData>
);
},
(prevProps, nextProps) => {
return (
prevProps.id === nextProps.id &&

View file

@ -17,6 +17,7 @@ import {
getColumnWidthFromType,
getPinTooltip,
stringifyEvent,
SHOW_CHECK_BOXES_COLUMN_WIDTH,
} from './helpers';
describe('helpers', () => {
@ -258,8 +259,20 @@ describe('helpers', () => {
expect(getActionsColumnWidth(false)).toEqual(DEFAULT_ACTIONS_COLUMN_WIDTH);
});
test('returns the default actions column width + checkbox width when isEventViewer is false and showCheckboxes is true', () => {
expect(getActionsColumnWidth(false, true)).toEqual(
DEFAULT_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH
);
});
test('returns the events viewer actions column width when isEventViewer is true', () => {
expect(getActionsColumnWidth(true)).toEqual(EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH);
});
test('returns the events viewer actions column width + checkbox width when isEventViewer is true and showCheckboxes is true', () => {
expect(getActionsColumnWidth(true, true)).toEqual(
EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH
);
});
});
});

View file

@ -18,6 +18,8 @@ export const DEFAULT_ACTIONS_COLUMN_WIDTH = 115; // px;
* an events viewer, which has fewer actions than a regular events viewer
*/
export const EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH = 32; // px;
/** Additional column width to include when checkboxes are shown **/
export const SHOW_CHECK_BOXES_COLUMN_WIDTH = 32; // px;
/** The default minimum width of a column (when a width for the column type is not specified) */
export const DEFAULT_COLUMN_MIN_WIDTH = 180; // px
/** The default minimum width of a column of type `date` */
@ -93,5 +95,6 @@ export const getColumnHeaders = (
};
/** Returns the (fixed) width of the Actions column */
export const getActionsColumnWidth = (isEventViewer: boolean): number =>
isEventViewer ? EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH : DEFAULT_ACTIONS_COLUMN_WIDTH;
export const getActionsColumnWidth = (isEventViewer: boolean, showCheckboxes = false): number =>
(showCheckboxes ? SHOW_CHECK_BOXES_COLUMN_WIDTH : 0) +
(isEventViewer ? EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH : DEFAULT_ACTIONS_COLUMN_WIDTH);

View file

@ -27,6 +27,7 @@ import { getActionsColumnWidth } from './helpers';
import { ColumnRenderer } from './renderers/column_renderer';
import { RowRenderer } from './renderers/row_renderer';
import { Sort } from './sort';
import { useTimelineTypeContext } from '../timeline_context';
export interface BodyProps {
addNoteToEvent: AddNoteToEvent;
@ -80,9 +81,11 @@ export const Body = React.memo<BodyProps>(
toggleColumn,
updateNote,
}) => {
const timelineTypeContext = useTimelineTypeContext();
const columnWidths = columnHeaders.reduce(
(totalWidth, header) => totalWidth + header.width,
getActionsColumnWidth(isEventViewer)
getActionsColumnWidth(isEventViewer, timelineTypeContext.showCheckboxes)
);
return (
@ -94,7 +97,10 @@ export const Body = React.memo<BodyProps>(
style={{ minWidth: columnWidths + 'px' }}
>
<ColumnHeaders
actionsColumnWidth={getActionsColumnWidth(isEventViewer)}
actionsColumnWidth={getActionsColumnWidth(
isEventViewer,
timelineTypeContext.showCheckboxes
)}
browserFields={browserFields}
columnHeaders={columnHeaders}
isEventViewer={isEventViewer}
@ -110,7 +116,10 @@ export const Body = React.memo<BodyProps>(
/>
<Events
actionsColumnWidth={getActionsColumnWidth(isEventViewer)}
actionsColumnWidth={getActionsColumnWidth(
isEventViewer,
timelineTypeContext.showCheckboxes
)}
addNoteToEvent={addNoteToEvent}
browserFields={browserFields}
columnHeaders={columnHeaders}

View file

@ -30,7 +30,9 @@ import { Body } from './index';
import { columnRenderers, rowRenderers } from './renderers';
import { Sort } from './sort';
import { timelineActions, appActions } from '../../../store/actions';
import { TimelineModel } from '../../../store/timeline/model';
import { timelineDefaults, TimelineModel } from '../../../store/timeline/model';
import { plainRowRenderer } from './renderers/plain_row_renderer';
import { useTimelineTypeContext } from '../timeline_context';
interface OwnProps {
browserFields: BrowserFields;
@ -107,6 +109,8 @@ const StatefulBodyComponent = React.memo<StatefulBodyComponentProps>(
updateNote,
updateSort,
}) => {
const timelineTypeContext = useTimelineTypeContext();
const getNotesByIds = useCallback(
(noteIds: string[]): Note[] => appSelectors.getNotes(notesById, noteIds),
[notesById]
@ -167,7 +171,7 @@ const StatefulBodyComponent = React.memo<StatefulBodyComponentProps>(
onUpdateColumns={onUpdateColumns}
pinnedEventIds={pinnedEventIds}
range={range!}
rowRenderers={rowRenderers}
rowRenderers={timelineTypeContext.showRowRenderers ? rowRenderers : [plainRowRenderer]}
sort={sort}
toggleColumn={toggleColumn}
updateNote={onUpdateNote}
@ -202,7 +206,7 @@ const makeMapStateToProps = () => {
const getTimeline = timelineSelectors.getTimelineByIdSelector();
const getNotesByIds = appSelectors.notesByIdsSelector();
const mapStateToProps = (state: State, { browserFields, id }: OwnProps) => {
const timeline: TimelineModel = getTimeline(state, id);
const timeline: TimelineModel = getTimeline(state, id) ?? timelineDefaults;
const { columns, eventIdToNoteIds, pinnedEventIds } = timeline;
return {

View file

@ -27,6 +27,7 @@ import { OnChangeItemsPerPage, OnLoadMore } from '../events';
import { LastUpdatedAt } from './last_updated';
import * as i18n from './translations';
import { useTimelineTypeContext } from '../timeline_context';
const FixedWidthLastUpdated = styled.div<{ compact: boolean }>`
width: ${({ compact }) => (!compact ? 200 : 25)}px;
@ -110,43 +111,49 @@ export const EventsCountComponent = ({
itemsCount: number;
onClick: () => void;
serverSideEventCount: number;
}) => (
<h5>
<PopoverRowItems
className="footer-popover"
id="customizablePagination"
data-test-subj="timelineSizeRowPopover"
button={
<>
<EuiBadge data-test-subj="local-events-count" color="hollow">
{itemsCount}
<EuiButtonEmpty
size="s"
color="text"
iconType="arrowDown"
iconSide="right"
onClick={onClick}
/>
</EuiBadge>
{` ${i18n.OF} `}
</>
}
isOpen={isOpen}
closePopover={closePopover}
panelPaddingSize="none"
>
<EuiContextMenuPanel items={items} data-test-subj="timelinePickSizeRow" />
</PopoverRowItems>
<EuiToolTip content={`${serverSideEventCount} ${i18n.TOTAL_COUNT_OF_EVENTS}`}>
<ServerSideEventCount>
<EuiBadge color="hollow" data-test-subj="server-side-event-count">
{serverSideEventCount}
</EuiBadge>{' '}
{i18n.EVENTS}
</ServerSideEventCount>
</EuiToolTip>
</h5>
);
}) => {
const timelineTypeContext = useTimelineTypeContext();
return (
<h5>
<PopoverRowItems
className="footer-popover"
id="customizablePagination"
data-test-subj="timelineSizeRowPopover"
button={
<>
<EuiBadge data-test-subj="local-events-count" color="hollow">
{itemsCount}
<EuiButtonEmpty
size="s"
color="text"
iconType="arrowDown"
iconSide="right"
onClick={onClick}
/>
</EuiBadge>
{` ${i18n.OF} `}
</>
}
isOpen={isOpen}
closePopover={closePopover}
panelPaddingSize="none"
>
<EuiContextMenuPanel items={items} data-test-subj="timelinePickSizeRow" />
</PopoverRowItems>
<EuiToolTip
content={`${serverSideEventCount} ${timelineTypeContext.footerText ??
i18n.TOTAL_COUNT_OF_EVENTS}`}
>
<ServerSideEventCount>
<EuiBadge color="hollow" data-test-subj="server-side-event-count">
{serverSideEventCount}
</EuiBadge>{' '}
{timelineTypeContext.documentType ?? i18n.EVENTS}
</ServerSideEventCount>
</EuiToolTip>
</h5>
);
};
EventsCountComponent.displayName = 'EventsCountComponent';

View file

@ -14,7 +14,7 @@ import { esFilters } from '../../../../../../../src/plugins/data/public';
import { WithSource } from '../../containers/source';
import { inputsModel, inputsSelectors, State, timelineSelectors } from '../../store';
import { timelineActions } from '../../store/actions';
import { KqlMode, TimelineModel } from '../../store/timeline/model';
import { KqlMode, timelineDefaults, TimelineModel } from '../../store/timeline/model';
import { ColumnHeader } from './body/column_headers/column_header';
import { DataProvider, QueryOperator } from './data_providers/data_provider';
@ -315,7 +315,7 @@ const makeMapStateToProps = () => {
const getKqlQueryTimeline = timelineSelectors.getKqlFilterQuerySelector();
const getInputsTimeline = inputsSelectors.getTimelineSelector();
const mapStateToProps = (state: State, { id }: OwnProps) => {
const timeline: TimelineModel = getTimeline(state, id);
const timeline: TimelineModel = getTimeline(state, id) ?? timelineDefaults;
const input: inputsModel.InputsRange = getInputsTimeline(state);
const {
columns,

View file

@ -21,7 +21,7 @@ import {
inputsSelectors,
} from '../../../store';
import { timelineActions } from '../../../store/actions';
import { KqlMode, TimelineModel } from '../../../store/timeline/model';
import { KqlMode, timelineDefaults, TimelineModel } from '../../../store/timeline/model';
import { DispatchUpdateReduxTime, dispatchUpdateReduxTime } from '../../super_date_picker';
import { DataProvider } from '../data_providers/data_provider';
import { SearchOrFilter } from './search_or_filter';
@ -195,7 +195,7 @@ const makeMapStateToProps = () => {
const getInputsTimeline = inputsSelectors.getTimelineSelector();
const getInputsPolicy = inputsSelectors.getTimelinePolicySelector();
const mapStateToProps = (state: State, { timelineId }: OwnProps) => {
const timeline: TimelineModel = getTimeline(state, timelineId);
const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults;
const input: inputsModel.InputsRange = getInputsTimeline(state);
const policy: inputsModel.Policy = getInputsPolicy(state);
return {

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { createContext, useContext, useEffect, memo, useState } from 'react';
import React, { createContext, memo, useContext, useEffect, useState } from 'react';
const initTimelineContext = false;
export const TimelineContext = createContext<boolean>(initTimelineContext);
@ -14,30 +14,55 @@ const initTimelineWidth = 0;
export const TimelineWidthContext = createContext<number>(initTimelineWidth);
export const useTimelineWidthContext = () => useContext(TimelineWidthContext);
export interface TimelineTypeContextProps {
documentType?: string;
footerText?: string;
showCheckboxes: boolean;
showRowRenderers: boolean;
title?: string;
}
const initTimelineType: TimelineTypeContextProps = {
documentType: undefined,
footerText: undefined,
showCheckboxes: false,
showRowRenderers: true,
title: undefined,
};
export const TimelineTypeContext = createContext<TimelineTypeContextProps>(initTimelineType);
export const useTimelineTypeContext = () => useContext(TimelineTypeContext);
interface ManageTimelineContextProps {
children: React.ReactNode;
loading: boolean;
width: number;
type?: TimelineTypeContextProps;
}
// todo we need to refactor this as more complex context/reducer with useReducer
// to avoid so many Context, at least the separation of code is there now
export const ManageTimelineContext = memo<ManageTimelineContextProps>(
({ children, loading, width }) => {
({ children, loading, width, type = initTimelineType }) => {
const [myLoading, setLoading] = useState(initTimelineContext);
const [myWidth, setWidth] = useState(initTimelineWidth);
const [myType, setType] = useState(initTimelineType);
useEffect(() => {
setLoading(loading);
}, [loading]);
useEffect(() => {
setType(type);
}, [type]);
useEffect(() => {
setWidth(width);
}, [width]);
return (
<TimelineContext.Provider value={myLoading}>
<TimelineWidthContext.Provider value={myWidth}>{children}</TimelineWidthContext.Provider>
<TimelineWidthContext.Provider value={myWidth}>
<TimelineTypeContext.Provider value={myType}>{children}</TimelineTypeContext.Provider>
</TimelineWidthContext.Provider>
</TimelineContext.Provider>
);
}

View file

@ -16,15 +16,17 @@ import {
Rule,
} from './types';
import { throwIfNotOk } from '../../../hooks/api/api';
import { DETECTION_ENGINE_RULES_URL } from '../../../../common/constants';
/**
* Add provided Rule
*
* @param rule to add
* @param kbnVersion current Kibana Version to use for headers
* @param signal to cancel request
*/
export const addRule = async ({ rule, kbnVersion, signal }: AddRulesProps): Promise<NewRule> => {
const response = await fetch(`${chrome.getBasePath()}/api/detection_engine/rules`, {
const response = await fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}`, {
method: 'POST',
credentials: 'same-origin',
headers: {
@ -47,6 +49,7 @@ export const addRule = async ({ rule, kbnVersion, signal }: AddRulesProps): Prom
* @param pagination desired pagination options (e.g. page/perPage)
* @param id if specified, will return specific rule if exists
* @param kbnVersion current Kibana Version to use for headers
* @param signal to cancel request
*/
export const fetchRules = async ({
filterOptions = {
@ -75,8 +78,8 @@ export const fetchRules = async ({
const endpoint =
id != null
? `${chrome.getBasePath()}/api/detection_engine/rules?id="${id}"`
: `${chrome.getBasePath()}/api/detection_engine/rules/_find?${queryParams.join('&')}`;
? `${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}?id="${id}"`
: `${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}/_find?${queryParams.join('&')}`;
const response = await fetch(endpoint, {
method: 'GET',
@ -106,7 +109,7 @@ export const enableRules = async ({
kbnVersion,
}: EnableRulesProps): Promise<Rule[]> => {
const requests = ids.map(id =>
fetch(`${chrome.getBasePath()}/api/detection_engine/rules`, {
fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}`, {
method: 'PUT',
credentials: 'same-origin',
headers: {
@ -134,7 +137,7 @@ export const enableRules = async ({
export const deleteRules = async ({ ids, kbnVersion }: DeleteRulesProps): Promise<Rule[]> => {
// TODO: Don't delete if immutable!
const requests = ids.map(id =>
fetch(`${chrome.getBasePath()}/api/detection_engine/rules?id=${id}`, {
fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}?id=${id}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: {
@ -163,7 +166,7 @@ export const duplicateRules = async ({
kbnVersion,
}: DuplicateRulesProps): Promise<Rule[]> => {
const requests = rules.map(rule =>
fetch(`${chrome.getBasePath()}/api/detection_engine/rules`, {
fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}`, {
method: 'POST',
credentials: 'same-origin',
headers: {

View file

@ -8,7 +8,12 @@ import { isEmpty, get } from 'lodash/fp';
import { useEffect, useState, Dispatch, SetStateAction } from 'react';
import { IIndexPattern } from 'src/plugins/data/public';
import { getIndexFields, sourceQuery } from '../../../containers/source';
import {
BrowserFields,
getBrowserFields,
getIndexFields,
sourceQuery,
} from '../../../containers/source';
import { useStateToaster } from '../../../components/toasters';
import { errorToToaster } from '../../../components/ml/api/error_to_toaster';
import { SourceQuery } from '../../../graphql/types';
@ -16,20 +21,22 @@ import { useApolloClient } from '../../../utils/apollo_context';
import * as i18n from './translations';
interface FetchIndexPattern {
interface FetchIndexPatternReturn {
browserFields: BrowserFields | null;
isLoading: boolean;
indices: string[];
indicesExists: boolean;
indexPatterns: IIndexPattern | null;
}
type Return = [FetchIndexPattern, Dispatch<SetStateAction<string[]>>];
type Return = [FetchIndexPatternReturn, Dispatch<SetStateAction<string[]>>];
export const useFetchIndexPatterns = (): Return => {
export const useFetchIndexPatterns = (defaultIndices: string[] = []): Return => {
const apolloClient = useApolloClient();
const [indices, setIndices] = useState<string[]>([]);
const [indices, setIndices] = useState<string[]>(defaultIndices);
const [indicesExists, setIndicesExists] = useState(false);
const [indexPatterns, setIndexPatterns] = useState<IIndexPattern | null>(null);
const [browserFields, setBrowserFields] = useState<BrowserFields | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [, dispatchToaster] = useStateToaster();
@ -62,6 +69,7 @@ export const useFetchIndexPatterns = (): Return => {
setIndexPatterns(
getIndexFields(indices.join(), get('data.source.status.indexFields', result))
);
setBrowserFields(getBrowserFields(get('data.source.status.indexFields', result)));
}
},
error => {
@ -80,5 +88,5 @@ export const useFetchIndexPatterns = (): Return => {
};
}, [indices]);
return [{ isLoading, indices, indicesExists, indexPatterns }, setIndices];
return [{ browserFields, isLoading, indices, indicesExists, indexPatterns }, setIndices];
};

View file

@ -70,7 +70,7 @@ export const getIndexFields = memoizeOne(
: { fields: [], title }
);
const getBrowserFields = memoizeOne(
export const getBrowserFields = memoizeOne(
(fields: IndexField[]): BrowserFields =>
fields && fields.length > 0
? fields.reduce<BrowserFields>(

View file

@ -19,11 +19,12 @@ import {
TimelineEdges,
TimelineItem,
} from '../../graphql/types';
import { inputsModel, State, inputsSelectors } from '../../store';
import { inputsModel, inputsSelectors, State } from '../../store';
import { createFilter } from '../helpers';
import { QueryTemplate, QueryTemplateProps } from '../query_template';
import { timelineQuery } from './index.gql_query';
import { IIndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns';
export interface TimelineArgs {
events: TimelineItem[];
@ -44,6 +45,7 @@ export interface TimelineQueryReduxProps {
export interface OwnProps extends QueryTemplateProps {
children?: (args: TimelineArgs) => React.ReactNode;
id: string;
indexPattern?: IIndexPattern;
limit: number;
sortField: SortField;
fields: string[];
@ -67,6 +69,7 @@ class TimelineQueryComponent extends QueryTemplate<
const {
children,
id,
indexPattern,
isInspected,
limit,
fields,
@ -80,7 +83,8 @@ class TimelineQueryComponent extends QueryTemplate<
sourceId,
pagination: { limit, cursor: null, tiebreaker: null },
sortField,
defaultIndex: chrome.getUiSettingsClient().get(DEFAULT_INDEX_KEY),
defaultIndex:
indexPattern?.title.split(',') ?? chrome.getUiSettingsClient().get(DEFAULT_INDEX_KEY),
inspect: isInspected,
};
return (

View file

@ -4,15 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiButton,
EuiFilterButton,
EuiFilterGroup,
EuiPanel,
EuiSelect,
EuiSpacer,
} from '@elastic/eui';
import React, { useState } from 'react';
import { EuiButton, EuiPanel, EuiSelect, EuiSpacer } from '@elastic/eui';
import React from 'react';
import { StickyContainer } from 'react-sticky';
import { FiltersGlobal } from '../../components/filters_global';
@ -20,100 +13,12 @@ import { HeaderPage } from '../../components/header_page';
import { HeaderSection } from '../../components/header_section';
import { HistogramSignals } from '../../components/page/detection_engine/histogram_signals';
import { SiemSearchBar } from '../../components/search_bar';
import {
UtilityBar,
UtilityBarAction,
UtilityBarGroup,
UtilityBarSection,
UtilityBarText,
} from '../../components/detection_engine/utility_bar';
import { WrapperPage } from '../../components/wrapper_page';
import { indicesExistOrDataTemporarilyUnavailable, WithSource } from '../../containers/source';
import { SpyRoute } from '../../utils/route/spy_routes';
import { DetectionEngineEmptyPage } from './detection_engine_empty_page';
import * as i18n from './translations';
const OpenSignals = React.memo(() => {
return (
<>
<UtilityBar>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarText>{`${i18n.PANEL_SUBTITLE_SHOWING}: 7,712 signals`}</UtilityBarText>
</UtilityBarGroup>
<UtilityBarGroup>
<UtilityBarText>{'Selected: 20 signals'}</UtilityBarText>
<UtilityBarAction
iconSide="right"
iconType="arrowDown"
popoverContent={() => <p>{'Batch actions context menu here.'}</p>}
>
{'Batch actions'}
</UtilityBarAction>
<UtilityBarAction iconType="listAdd">
{'Select all signals on all pages'}
</UtilityBarAction>
</UtilityBarGroup>
<UtilityBarGroup>
<UtilityBarAction iconType="cross">{'Clear 7 filters'}</UtilityBarAction>
<UtilityBarAction iconType="cross">{'Clear aggregation'}</UtilityBarAction>
</UtilityBarGroup>
</UtilityBarSection>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarAction
iconSide="right"
iconType="arrowDown"
popoverContent={() => <p>{'Customize columns context menu here.'}</p>}
>
{'Customize columns'}
</UtilityBarAction>
<UtilityBarAction iconType="indexMapping">{'Aggregate data'}</UtilityBarAction>
</UtilityBarGroup>
</UtilityBarSection>
</UtilityBar>
{/* Michael: Open signals datagrid here. Talk to Chandler Prall about possibility of early access. If not possible, use basic table. */}
</>
);
});
const ClosedSignals = React.memo(() => {
return (
<>
<UtilityBar>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarText>{`${i18n.PANEL_SUBTITLE_SHOWING}: 7,712 signals`}</UtilityBarText>
</UtilityBarGroup>
</UtilityBarSection>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarAction
iconSide="right"
iconType="arrowDown"
popoverContent={() => <p>{'Customize columns context menu here.'}</p>}
>
{'Customize columns'}
</UtilityBarAction>
<UtilityBarAction iconType="indexMapping">{'Aggregate data'}</UtilityBarAction>
</UtilityBarGroup>
</UtilityBarSection>
</UtilityBar>
{/* Michael: Closed signals datagrid here. Talk to Chandler Prall about possibility of early access. If not possible, use basic table. */}
</>
);
});
import { SignalsTable } from './signals';
export const DetectionEngineComponent = React.memo(() => {
const sampleChartOptions = [
@ -129,9 +34,6 @@ export const DetectionEngineComponent = React.memo(() => {
{ text: 'Top users', value: 'users' },
];
const filterGroupOptions = ['open', 'closed'];
const [filterGroupState, setFilterGroupState] = useState(filterGroupOptions[0]);
return (
<>
<WithSource sourceId="default">
@ -164,28 +66,7 @@ export const DetectionEngineComponent = React.memo(() => {
<EuiSpacer />
<EuiPanel>
<HeaderSection title="All signals">
<EuiFilterGroup>
<EuiFilterButton
hasActiveFilters={filterGroupState === filterGroupOptions[0]}
onClick={() => setFilterGroupState(filterGroupOptions[0])}
withNext
>
{'Open signals'}
</EuiFilterButton>
<EuiFilterButton
hasActiveFilters={filterGroupState === filterGroupOptions[1]}
onClick={() => setFilterGroupState(filterGroupOptions[1])}
>
{'Closed signals'}
</EuiFilterButton>
</EuiFilterGroup>
</HeaderSection>
{filterGroupState === filterGroupOptions[0] ? <OpenSignals /> : <ClosedSignals />}
</EuiPanel>
<SignalsTable />
</WrapperPage>
</StickyContainer>
) : (

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import {
UtilityBar,
UtilityBarAction,
UtilityBarGroup,
UtilityBarSection,
UtilityBarText,
} from '../../../../../components/detection_engine/utility_bar';
import * as i18n from '../../translations';
export const ClosedSignals = React.memo<{ totalCount: number }>(({ totalCount }) => {
return (
<>
<UtilityBar>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarText>{`${i18n.PANEL_SUBTITLE_SHOWING}: ${totalCount} signals`}</UtilityBarText>
</UtilityBarGroup>
</UtilityBarSection>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarAction
iconSide="right"
iconType="arrowDown"
popoverContent={() => <p>{'Customize columns context menu here.'}</p>}
>
{'Customize columns'}
</UtilityBarAction>
<UtilityBarAction iconType="indexMapping">{'Aggregate data'}</UtilityBarAction>
</UtilityBarGroup>
</UtilityBarSection>
</UtilityBar>
{/* Michael: Closed signals datagrid here. Talk to Chandler Prall about possibility of early access. If not possible, use basic table. */}
</>
);
});

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import {
UtilityBar,
UtilityBarAction,
UtilityBarGroup,
UtilityBarSection,
UtilityBarText,
} from '../../../../../components/detection_engine/utility_bar';
import * as i18n from '../../translations';
export const OpenSignals = React.memo<{ totalCount: number }>(({ totalCount }) => {
return (
<>
<UtilityBar>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarText>{`${i18n.PANEL_SUBTITLE_SHOWING}: ${totalCount} signals`}</UtilityBarText>
</UtilityBarGroup>
<UtilityBarGroup>
<UtilityBarText>{'Selected: 20 signals'}</UtilityBarText>
<UtilityBarAction
iconSide="right"
iconType="arrowDown"
popoverContent={() => <p>{'Batch actions context menu here.'}</p>}
>
{'Batch actions'}
</UtilityBarAction>
<UtilityBarAction iconType="listAdd">
{'Select all signals on all pages'}
</UtilityBarAction>
</UtilityBarGroup>
</UtilityBarSection>
</UtilityBar>
{/* Michael: Open signals datagrid here. Talk to Chandler Prall about possibility of early access. If not possible, use basic table. */}
</>
);
});

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ColumnHeader } from '../../../components/timeline/body/column_headers/column_header';
import { defaultColumnHeaderType } from '../../../components/timeline/body/column_headers/default_headers';
import {
DEFAULT_COLUMN_MIN_WIDTH,
DEFAULT_DATE_COLUMN_MIN_WIDTH,
} from '../../../components/timeline/body/helpers';
import * as i18n from './translations';
export const signalsHeaders: ColumnHeader[] = [
{
columnHeaderType: defaultColumnHeaderType,
id: 'signal.rule.name',
label: i18n.SIGNALS_HEADERS_RULE,
width: DEFAULT_COLUMN_MIN_WIDTH,
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'signal.rule.type',
label: i18n.SIGNALS_HEADERS_METHOD,
width: 80,
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'signal.rule.severity',
label: i18n.SIGNALS_HEADERS_SEVERITY,
width: 80,
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'signal.rule.risk_score',
label: i18n.SIGNALS_HEADERS_RISK_SCORE,
width: 120,
},
{
category: 'event',
columnHeaderType: defaultColumnHeaderType,
id: 'event.action',
type: 'string',
aggregatable: true,
width: 140,
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'event.category',
width: 150,
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'host.name',
width: 120,
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'user.name',
width: 120,
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'source.ip',
width: 120,
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'destination.ip',
width: 120,
},
{
columnHeaderType: defaultColumnHeaderType,
id: '@timestamp',
width: DEFAULT_DATE_COLUMN_MIN_WIDTH,
},
];

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { signalsHeaders } from './default_headers';
import { SubsetTimelineModel, timelineDefaults } from '../../../store/timeline/model';
export const signalsDefaultModel: SubsetTimelineModel = {
...timelineDefaults,
columns: signalsHeaders,
};

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFilterButton, EuiFilterGroup } from '@elastic/eui';
import React, { useCallback, useState } from 'react';
import { OpenSignals } from './components/open_signals';
import { ClosedSignals } from './components/closed_signals';
import { GlobalTime } from '../../../containers/global_time';
import { StatefulEventsViewer } from '../../../components/events_viewer';
import * as i18n from './translations';
import { DEFAULT_SIGNALS_INDEX } from '../../../../common/constants';
import { signalsDefaultModel } from './default_model';
const SIGNALS_PAGE_TIMELINE_ID = 'signals-page';
const FILTER_OPEN = 'open';
const FILTER_CLOSED = 'closed';
export const SignalsTableFilterGroup = React.memo(
({ onFilterGroupChanged }: { onFilterGroupChanged: (filterGroup: string) => void }) => {
const [filterGroup, setFilterGroup] = useState(FILTER_OPEN);
return (
<EuiFilterGroup>
<EuiFilterButton
hasActiveFilters={filterGroup === FILTER_OPEN}
onClick={() => {
setFilterGroup(FILTER_OPEN);
onFilterGroupChanged(FILTER_OPEN);
}}
withNext
>
{'Open signals'}
</EuiFilterButton>
<EuiFilterButton
hasActiveFilters={filterGroup === FILTER_CLOSED}
onClick={() => setFilterGroup(FILTER_CLOSED)}
>
{'Closed signals'}
</EuiFilterButton>
</EuiFilterGroup>
);
}
);
export const SignalsTable = React.memo(() => {
const [filterGroup, setFilterGroup] = useState(FILTER_OPEN);
const onFilterGroupChangedCallback = useCallback(
(newFilterGroup: string) => {
setFilterGroup(newFilterGroup);
},
[setFilterGroup]
);
return (
<>
<GlobalTime>
{({ to, from, setQuery, deleteQuery, isInitializing }) => (
<StatefulEventsViewer
defaultIndices={[DEFAULT_SIGNALS_INDEX]}
defaultModel={signalsDefaultModel}
end={to}
headerFilterGroup={
<SignalsTableFilterGroup onFilterGroupChanged={onFilterGroupChangedCallback} />
}
id={SIGNALS_PAGE_TIMELINE_ID}
start={from}
timelineTypeContext={{
documentType: i18n.SIGNALS_DOCUMENT_TYPE,
footerText: i18n.TOTAL_COUNT_OF_SIGNALS,
showCheckboxes: true,
showRowRenderers: false,
title: i18n.SIGNALS_TABLE_TITLE,
}}
utilityBar={(totalCount: number) =>
filterGroup === FILTER_OPEN ? (
<OpenSignals totalCount={totalCount} />
) : (
<ClosedSignals totalCount={totalCount} />
)
}
/>
)}
</GlobalTime>
</>
);
});
SignalsTable.displayName = 'SignalsTable';

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const PAGE_TITLE = i18n.translate('xpack.siem.detectionEngine.pageTitle', {
defaultMessage: 'Detection engine',
});
export const PANEL_SUBTITLE_SHOWING = i18n.translate(
'xpack.siem.detectionEngine.panelSubtitleShowing',
{
defaultMessage: 'Showing',
}
);
export const SIGNALS_TABLE_TITLE = i18n.translate('xpack.siem.detectionEngine.signals.tableTitle', {
defaultMessage: 'All signals',
});
export const SIGNALS_DOCUMENT_TYPE = i18n.translate(
'xpack.siem.detectionEngine.signals.documentTypeTitle',
{
defaultMessage: 'Signals',
}
);
export const TOTAL_COUNT_OF_SIGNALS = i18n.translate(
'xpack.siem.detectionEngine.signals.totalCountOfSignalsTitle',
{
defaultMessage: 'signals match the search criteria',
}
);
export const SIGNALS_HEADERS_RULE = i18n.translate(
'xpack.siem.eventsViewer.signals.defaultHeaders.ruleTitle',
{
defaultMessage: 'Rule',
}
);
export const SIGNALS_HEADERS_METHOD = i18n.translate(
'xpack.siem.eventsViewer.signals.defaultHeaders.methodTitle',
{
defaultMessage: 'Method',
}
);
export const SIGNALS_HEADERS_SEVERITY = i18n.translate(
'xpack.siem.eventsViewer.signals.defaultHeaders.severityTitle',
{
defaultMessage: 'Severity',
}
);
export const SIGNALS_HEADERS_RISK_SCORE = i18n.translate(
'xpack.siem.eventsViewer.signals.defaultHeaders.riskScoreTitle',
{
defaultMessage: 'Risk Score',
}
);

View file

@ -12,6 +12,7 @@ import { manageQuery } from '../../../components/page/manage_query';
import { EventsOverTimeHistogram } from '../../../components/page/hosts/events_over_time';
import { EventsOverTimeQuery } from '../../../containers/events/events_over_time';
import { hostsModel } from '../../../store/hosts';
import { eventsDefaultModel } from '../../../components/events_viewer/default_model';
const HOSTS_PAGE_TIMELINE_ID = 'hosts-page';
const EventsOverTimeManage = manageQuery(EventsOverTimeHistogram);
@ -48,7 +49,12 @@ export const EventsQueryTabBody = ({
)}
</EventsOverTimeQuery>
<EuiSpacer size="l" />
<StatefulEventsViewer end={endDate} id={HOSTS_PAGE_TIMELINE_ID} start={startDate} />
<StatefulEventsViewer
defaultModel={eventsDefaultModel}
end={endDate}
id={HOSTS_PAGE_TIMELINE_ID}
start={startDate}
/>
</>
);
};

View file

@ -9,7 +9,6 @@ import { ColumnHeader } from '../../components/timeline/body/column_headers/colu
import { DataProvider } from '../../components/timeline/data_providers/data_provider';
import { DEFAULT_TIMELINE_WIDTH } from '../../components/timeline/body/helpers';
import { defaultHeaders } from '../../components/timeline/body/column_headers/default_headers';
import { defaultHeaders as eventsDefaultHeaders } from '../../components/events_viewer/default_headers';
import { Sort } from '../../components/timeline/body/sort';
import { Direction, PinnedEvent } from '../../graphql/types';
import { KueryFilterQuery, SerializedFilterQuery } from '../model';
@ -74,34 +73,37 @@ export interface TimelineModel {
version: string | null;
}
export const timelineDefaults: Readonly<Pick<
TimelineModel,
| 'columns'
| 'dataProviders'
| 'description'
| 'eventIdToNoteIds'
| 'filters'
| 'highlightedDropAndProviderId'
| 'historyIds'
| 'isFavorite'
| 'isLive'
| 'itemsPerPage'
| 'itemsPerPageOptions'
| 'kqlMode'
| 'kqlQuery'
| 'title'
| 'noteIds'
| 'pinnedEventIds'
| 'pinnedEventsSaveObject'
| 'dateRange'
| 'show'
| 'sort'
| 'width'
| 'isSaving'
| 'isLoading'
| 'savedObjectId'
| 'version'
>> = {
export type SubsetTimelineModel = Readonly<
Pick<
TimelineModel,
| 'columns'
| 'dataProviders'
| 'description'
| 'eventIdToNoteIds'
| 'highlightedDropAndProviderId'
| 'historyIds'
| 'isFavorite'
| 'isLive'
| 'itemsPerPage'
| 'itemsPerPageOptions'
| 'kqlMode'
| 'kqlQuery'
| 'title'
| 'noteIds'
| 'pinnedEventIds'
| 'pinnedEventsSaveObject'
| 'dateRange'
| 'show'
| 'sort'
| 'width'
| 'isSaving'
| 'isLoading'
| 'savedObjectId'
| 'version'
>
>;
export const timelineDefaults: SubsetTimelineModel & Pick<TimelineModel, 'filters'> = {
columns: defaultHeaders,
dataProviders: [],
description: '',
@ -137,31 +139,3 @@ export const timelineDefaults: Readonly<Pick<
width: DEFAULT_TIMELINE_WIDTH,
version: null,
};
export const eventsDefaults: Readonly<Pick<
TimelineModel,
| 'columns'
| 'dataProviders'
| 'description'
| 'eventIdToNoteIds'
| 'highlightedDropAndProviderId'
| 'historyIds'
| 'isFavorite'
| 'isLive'
| 'itemsPerPage'
| 'itemsPerPageOptions'
| 'kqlMode'
| 'kqlQuery'
| 'title'
| 'noteIds'
| 'pinnedEventIds'
| 'pinnedEventsSaveObject'
| 'dateRange'
| 'show'
| 'sort'
| 'width'
| 'isSaving'
| 'isLoading'
| 'savedObjectId'
| 'version'
>> = { ...timelineDefaults, columns: eventsDefaultHeaders };

View file

@ -9,8 +9,8 @@ import { createSelector } from 'reselect';
import { isFromKueryExpressionValid } from '../../lib/keury';
import { State } from '../reducer';
import { eventsDefaults, timelineDefaults, TimelineModel } from './model';
import { TimelineById, AutoSavedWarningMsg } from './types';
import { TimelineModel } from './model';
import { AutoSavedWarningMsg, TimelineById } from './types';
const selectTimelineById = (state: State): TimelineById => state.timeline.timelineById;
@ -37,11 +37,9 @@ export const getShowCallOutUnauthorizedMsg = () =>
export const getTimelines = () => timelineByIdSelector;
export const getTimelineByIdSelector = () =>
createSelector(selectTimeline, timeline => timeline || timelineDefaults);
export const getTimelineByIdSelector = () => createSelector(selectTimeline, timeline => timeline);
export const getEventsByIdSelector = () =>
createSelector(selectTimeline, timeline => timeline || eventsDefaults);
export const getEventsByIdSelector = () => createSelector(selectTimeline, timeline => timeline);
export const getKqlFilterQuerySelector = () =>
createSelector(selectTimeline, timeline =>