resolve conflicts (#120633)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Steph Milovic 2021-12-07 23:40:15 -07:00 committed by GitHub
parent 3de8da3964
commit 3ed46c25e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 1713 additions and 618 deletions

View file

@ -39,7 +39,7 @@ export interface SortColumnTimeline {
export interface TimelinePersistInput {
columns: ColumnHeaderOptions[];
dataProviders?: DataProvider[];
dataViewId: string;
dataViewId: string | null; // null if legacy pre-8.0 timeline
dateRange?: {
start: string;
end: string;

View file

@ -12,6 +12,7 @@ import { SecurityPageName } from '../../../../common/constants';
import {
createSecuritySolutionStorageMock,
mockGlobalState,
mockIndexPattern,
SUB_PLUGINS_REDUCER,
TestProviders,
} from '../../../common/mock';
@ -36,6 +37,10 @@ jest.mock('../../../common/lib/kibana', () => {
};
});
jest.mock('../../../common/containers/source', () => ({
useFetchIndex: () => [false, { indicesExist: true, indexPatterns: mockIndexPattern }],
}));
jest.mock('react-reverse-portal', () => ({
InPortal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
OutPortal: ({ children }: { children: React.ReactNode }) => <>{children}</>,

View file

@ -24,10 +24,10 @@ export const SecuritySolutionBottomBar = React.memo(
({ onAppLeave }: { onAppLeave: (handler: AppLeaveHandler) => void }) => {
const [showTimeline] = useShowTimeline();
const { indicesExist } = useSourcererDataView(SourcererScopeName.timeline);
useResolveRedirect();
const { indicesExist, dataViewId } = useSourcererDataView(SourcererScopeName.timeline);
return indicesExist && showTimeline ? (
useResolveRedirect();
return (indicesExist || dataViewId === null) && showTimeline ? (
<>
<AutoSaveWarningMsg />
<Flyout timelineId={TimelineId.active} onAppLeave={onAppLeave} />

View file

@ -135,7 +135,7 @@ export const CaseView = React.memo(
timelineActions.createTimeline({
id: TimelineId.casePage,
columns: [],
dataViewId: '',
dataViewId: null,
indexNames: [],
expandedDetail: {},
show: false,

View file

@ -27,6 +27,10 @@ export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimar
z-index: 9900 !important;
min-width: 24px;
}
.euiPopover__panel.euiPopover__panel-isOpen.sourcererPopoverPanel {
// needs to appear under modal
z-index: 5900 !important;
}
.euiToolTip {
z-index: 9950 !important;
}

View file

@ -14,7 +14,7 @@ import {
EuiFormRow,
EuiFormRowProps,
} from '@elastic/eui';
import styled from 'styled-components';
import styled, { css } from 'styled-components';
import { sourcererModel } from '../../store/sourcerer';
@ -50,11 +50,25 @@ export const PopoverContent = styled.div`
export const StyledBadge = styled(EuiBadge)`
margin-left: 8px;
&,
.euiBadge__text {
cursor: pointer;
}
`;
export const Blockquote = styled.span`
${({ theme }) => css`
display: block;
border-color: ${theme.eui.euiColorDarkShade};
border-left: ${theme.eui.euiBorderThick};
margin: ${theme.eui.euiSizeS} 0 ${theme.eui.euiSizeS} ${theme.eui.euiSizeS};
padding: ${theme.eui.euiSizeS};
`}
`;
interface GetDataViewSelectOptionsProps {
dataViewId: string;
defaultDataView: sourcererModel.KibanaDataView;
defaultDataViewId: sourcererModel.KibanaDataView['id'];
isModified: boolean;
isOnlyDetectionAlerts: boolean;
kibanaDataViews: sourcererModel.KibanaDataView[];
@ -62,7 +76,7 @@ interface GetDataViewSelectOptionsProps {
export const getDataViewSelectOptions = ({
dataViewId,
defaultDataView,
defaultDataViewId,
isModified,
isOnlyDetectionAlerts,
kibanaDataViews,
@ -78,12 +92,12 @@ export const getDataViewSelectOptions = ({
</StyledBadge>
</span>
),
value: defaultDataView.id,
value: defaultDataViewId,
},
]
: kibanaDataViews.map(({ title, id }) => ({
inputDisplay:
id === defaultDataView.id ? (
id === defaultDataViewId ? (
<span data-test-subj="security-option-super">
<EuiIcon type="logoSecurity" size="s" /> {i18n.SECURITY_DEFAULT_DATA_VIEW_LABEL}
{isModified && id === dataViewId && (

View file

@ -19,8 +19,16 @@ import {
} from '../../mock';
import { createStore } from '../../store';
import { EuiSuperSelectOption } from '@elastic/eui/src/components/form/super_select/super_select_control';
import { waitFor } from '@testing-library/dom';
import { useSourcererDataView } from '../../containers/sourcerer';
const mockDispatch = jest.fn();
jest.mock('../../containers/sourcerer');
const mockUseUpdateDataView = jest.fn().mockReturnValue(() => true);
jest.mock('./use_update_data_view', () => ({
useUpdateDataView: () => mockUseUpdateDataView,
}));
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
@ -30,6 +38,15 @@ jest.mock('react-redux', () => {
};
});
jest.mock('../../../../../../../src/plugins/kibana_react/public', () => {
const original = jest.requireActual('../../../../../../../src/plugins/kibana_react/public');
return {
...original,
toMountPoint: jest.fn(),
};
});
const mockOptions = [
{ label: 'apm-*-transaction*', value: 'apm-*-transaction*' },
{ label: 'auditbeat-*', value: 'auditbeat-*' },
@ -57,12 +74,21 @@ const patternListNoSignals = patternList
.filter((p) => p !== mockGlobalState.sourcerer.signalIndexName)
.sort();
let store: ReturnType<typeof createStore>;
const sourcererDataView = {
indicesExist: true,
loading: false,
};
describe('Sourcerer component', () => {
const { storage } = createSecuritySolutionStorageMock();
beforeEach(() => {
store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
(useSourcererDataView as jest.Mock).mockReturnValue(sourcererDataView);
jest.clearAllMocks();
});
afterAll(() => {
jest.restoreAllMocks();
});
@ -215,7 +241,6 @@ describe('Sourcerer component', () => {
...mockGlobalState.sourcerer.sourcererScopes,
[SourcererScopeName.default]: {
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default],
loading: false,
selectedDataViewId: '1234',
selectedPatterns: ['filebeat-*'],
},
@ -267,7 +292,6 @@ describe('Sourcerer component', () => {
...mockGlobalState.sourcerer.sourcererScopes,
[SourcererScopeName.default]: {
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default],
loading: false,
selectedDataViewId: id,
selectedPatterns: patternListNoSignals.slice(0, 2),
},
@ -313,8 +337,6 @@ describe('Sourcerer component', () => {
...mockGlobalState.sourcerer.sourcererScopes,
[SourcererScopeName.timeline]: {
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
loading: false,
patternList,
selectedDataViewId: id,
selectedPatterns: patternList.slice(0, 2),
},
@ -355,7 +377,6 @@ describe('Sourcerer component', () => {
...mockGlobalState.sourcerer.sourcererScopes,
[SourcererScopeName.default]: {
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default],
loading: false,
selectedDataViewId: id,
selectedPatterns: patternListNoSignals.slice(0, 2),
},
@ -629,6 +650,7 @@ describe('timeline sourcerer', () => {
};
beforeAll(() => {
(useSourcererDataView as jest.Mock).mockReturnValue(sourcererDataView);
wrapper = mount(
<TestProviders store={store}>
<Sourcerer {...testProps} />
@ -713,6 +735,7 @@ describe('timeline sourcerer', () => {
};
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
wrapper = mount(
<TestProviders store={store}>
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
@ -754,6 +777,7 @@ describe('Sourcerer integration tests', () => {
const { storage } = createSecuritySolutionStorageMock();
beforeEach(() => {
(useSourcererDataView as jest.Mock).mockReturnValue(sourcererDataView);
store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
jest.clearAllMocks();
jest.restoreAllMocks();
@ -795,11 +819,15 @@ describe('No data', () => {
const { storage } = createSecuritySolutionStorageMock();
beforeEach(() => {
(useSourcererDataView as jest.Mock).mockReturnValue({
...sourcererDataView,
indicesExist: false,
});
store = createStore(mockNoIndicesState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
jest.clearAllMocks();
jest.restoreAllMocks();
});
test('Hide sourcerer', () => {
test('Hide sourcerer - default ', () => {
const wrapper = mount(
<TestProviders store={store}>
<Sourcerer {...defaultProps} />
@ -808,4 +836,123 @@ describe('No data', () => {
expect(wrapper.find(`[data-test-subj="sourcerer-trigger"]`).exists()).toEqual(false);
});
test('Hide sourcerer - detections ', () => {
const wrapper = mount(
<TestProviders store={store}>
<Sourcerer scope={sourcererModel.SourcererScopeName.detections} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="sourcerer-trigger"]`).exists()).toEqual(false);
});
test('Hide sourcerer - timeline ', () => {
const wrapper = mount(
<TestProviders store={store}>
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).exists()).toEqual(true);
});
});
describe('Update available', () => {
const { storage } = createSecuritySolutionStorageMock();
const state2 = {
...mockGlobalState,
sourcerer: {
...mockGlobalState.sourcerer,
kibanaDataViews: [
mockGlobalState.sourcerer.defaultDataView,
{
...mockGlobalState.sourcerer.defaultDataView,
id: '1234',
title: 'auditbeat-*',
patternList: ['auditbeat-*'],
},
{
...mockGlobalState.sourcerer.defaultDataView,
id: '12347',
title: 'packetbeat-*',
patternList: ['packetbeat-*'],
},
],
sourcererScopes: {
...mockGlobalState.sourcerer.sourcererScopes,
[SourcererScopeName.timeline]: {
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
loading: false,
patternList,
selectedDataViewId: null,
selectedPatterns: ['myFakebeat-*'],
missingPatterns: ['myFakebeat-*'],
},
},
},
};
let wrapper: ReactWrapper;
beforeEach(() => {
(useSourcererDataView as jest.Mock).mockReturnValue({
...sourcererDataView,
activePatterns: ['myFakebeat-*'],
});
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
wrapper = mount(
<TestProviders store={store}>
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
</TestProviders>
);
});
afterEach(() => {
jest.clearAllMocks();
});
test('Show Update available label', () => {
expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-badge"]`).exists()).toBeTruthy();
});
test('Show correct tooltip', () => {
expect(wrapper.find(`[data-test-subj="sourcerer-tooltip"]`).prop('content')).toEqual(
'myFakebeat-*'
);
});
test('Show UpdateDefaultDataViewModal', () => {
wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click');
wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click');
expect(wrapper.find(`UpdateDefaultDataViewModal`).prop('isShowing')).toEqual(true);
});
test('Show Add index pattern in UpdateDefaultDataViewModal', () => {
wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click');
wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click');
expect(wrapper.find(`button[data-test-subj="sourcerer-update-data-view"]`).text()).toEqual(
'Add index pattern'
);
});
test('Set all the index patterns from legacy timeline to sourcerer, after clicking on "Add index pattern"', async () => {
wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click');
wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click');
wrapper.find(`button[data-test-subj="sourcerer-update-data-view"]`).simulate('click');
await waitFor(() => wrapper.update());
expect(mockDispatch).toHaveBeenCalledWith(
sourcererActions.setSelectedDataView({
id: SourcererScopeName.timeline,
selectedDataViewId: 'security-solution',
selectedPatterns: ['myFakebeat-*'],
shouldValidateSelectedPatterns: false,
})
);
});
});

View file

@ -13,13 +13,12 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiOutsideClickDetector,
EuiPopover,
EuiPopoverTitle,
EuiSpacer,
EuiSuperSelect,
EuiToolTip,
} from '@elastic/eui';
import deepEqual from 'fast-deep-equal';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
@ -27,18 +26,13 @@ import * as i18n from './translations';
import { sourcererActions, sourcererModel, sourcererSelectors } from '../../store/sourcerer';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { checkIfIndicesExist } from '../../store/sourcerer/helpers';
import { usePickIndexPatterns } from './use_pick_index_patterns';
import {
FormRow,
getDataViewSelectOptions,
getTooltipContent,
PopoverContent,
ResetButton,
StyledBadge,
StyledButton,
StyledFormRow,
} from './helpers';
import { FormRow, PopoverContent, ResetButton, StyledButton, StyledFormRow } from './helpers';
import { TemporarySourcerer } from './temporary';
import { UpdateDefaultDataViewModal } from './update_default_data_view_modal';
import { useSourcererDataView } from '../../containers/sourcerer';
import { useUpdateDataView } from './use_update_data_view';
import { Trigger } from './trigger';
interface SourcererComponentProps {
scope: sourcererModel.SourcererScopeName;
@ -54,13 +48,24 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
defaultDataView,
kibanaDataViews,
signalIndexName,
sourcererScope: { selectedDataViewId, selectedPatterns, loading },
sourcererDataView,
sourcererScope: {
selectedDataViewId,
selectedPatterns,
missingPatterns: sourcererMissingPatterns,
},
} = useDeepEqualSelector((state) => sourcererScopeSelector(state, scopeId));
const indicesExist = useMemo(
() => checkIfIndicesExist({ scopeId, signalIndexName, sourcererDataView }),
[scopeId, signalIndexName, sourcererDataView]
const { activePatterns, indicesExist, loading } = useSourcererDataView(scopeId);
const [missingPatterns, setMissingPatterns] = useState<string[]>(
activePatterns && activePatterns.length > 0
? sourcererMissingPatterns.filter((p) => activePatterns.includes(p))
: []
);
useEffect(() => {
if (activePatterns && activePatterns.length > 0) {
setMissingPatterns(sourcererMissingPatterns.filter((p) => activePatterns.includes(p)));
}
}, [activePatterns, sourcererMissingPatterns]);
const [isOnlyDetectionAlertsChecked, setIsOnlyDetectionAlertsChecked] = useState(
isTimelineSourcerer && selectedPatterns.join() === signalIndexName
@ -68,15 +73,15 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
const isOnlyDetectionAlerts: boolean =
isDetectionsSourcerer || (isTimelineSourcerer && isOnlyDetectionAlertsChecked);
const [isPopoverOpen, setPopoverIsOpen] = useState(false);
const [dataViewId, setDataViewId] = useState<string>(selectedDataViewId ?? defaultDataView.id);
const [dataViewId, setDataViewId] = useState<string | null>(selectedDataViewId);
const {
allOptions,
dataViewSelectOptions,
isModified,
onChangeCombo,
renderOption,
selectableOptions,
selectedOptions,
setIndexPatternsByDataView,
} = usePickIndexPatterns({
@ -84,10 +89,12 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
defaultDataViewId: defaultDataView.id,
isOnlyDetectionAlerts,
kibanaDataViews,
missingPatterns,
scopeId,
selectedPatterns,
signalIndexName,
});
const onCheckboxChanged = useCallback(
(e) => {
setIsOnlyDetectionAlertsChecked(e.target.checked);
@ -96,20 +103,26 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
},
[defaultDataView.id, setIndexPatternsByDataView]
);
const isSavingDisabled = useMemo(() => selectedOptions.length === 0, [selectedOptions]);
const [expandAdvancedOptions, setExpandAdvancedOptions] = useState(false);
const [isShowingUpdateModal, setIsShowingUpdateModal] = useState(false);
const setPopoverIsOpenCb = useCallback(() => {
setPopoverIsOpen((prevState) => !prevState);
setExpandAdvancedOptions(false); // we always want setExpandAdvancedOptions collapsed by default when popover opened
}, []);
const onChangeDataView = useCallback(
(newSelectedDataView: string, newSelectedPatterns: string[]) => {
(
newSelectedDataView: string,
newSelectedPatterns: string[],
shouldValidateSelectedPatterns?: boolean
) => {
dispatch(
sourcererActions.setSelectedDataView({
id: scopeId,
selectedDataViewId: newSelectedDataView,
selectedPatterns: newSelectedPatterns,
shouldValidateSelectedPatterns,
})
);
},
@ -128,11 +141,14 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
setDataViewId(defaultDataView.id);
setIndexPatternsByDataView(defaultDataView.id);
setIsOnlyDetectionAlertsChecked(false);
setMissingPatterns([]);
}, [defaultDataView.id, setIndexPatternsByDataView]);
const handleSaveIndices = useCallback(() => {
const patterns = selectedOptions.map((so) => so.label);
onChangeDataView(dataViewId, patterns);
if (dataViewId != null) {
onChangeDataView(dataViewId, patterns);
}
setPopoverIsOpen(false);
}, [onChangeDataView, dataViewId, selectedOptions]);
@ -140,183 +156,220 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
setPopoverIsOpen(false);
setExpandAdvancedOptions(false);
}, []);
const trigger = useMemo(
() => (
<StyledButton
aria-label={i18n.DATA_VIEW}
data-test-subj={isTimelineSourcerer ? 'timeline-sourcerer-trigger' : 'sourcerer-trigger'}
flush="left"
iconSide="right"
iconType="arrowDown"
isLoading={loading}
onClick={setPopoverIsOpenCb}
title={i18n.DATA_VIEW}
>
{i18n.DATA_VIEW}
{isModified === 'modified' && <StyledBadge>{i18n.MODIFIED_BADGE_TITLE}</StyledBadge>}
{isModified === 'alerts' && (
<StyledBadge data-test-subj="sourcerer-alerts-badge">
{i18n.ALERTS_BADGE_TITLE}
</StyledBadge>
)}
</StyledButton>
),
[isTimelineSourcerer, loading, setPopoverIsOpenCb, isModified]
);
const dataViewSelectOptions = useMemo(
() =>
getDataViewSelectOptions({
dataViewId,
defaultDataView,
isModified: isModified === 'modified',
isOnlyDetectionAlerts,
kibanaDataViews,
}),
[dataViewId, defaultDataView, isModified, isOnlyDetectionAlerts, kibanaDataViews]
);
// deprecated timeline index pattern handlers
const onContinueUpdateDeprecated = useCallback(() => {
setIsShowingUpdateModal(false);
const patterns = selectedPatterns.filter((pattern) =>
defaultDataView.patternList.includes(pattern)
);
onChangeDataView(defaultDataView.id, patterns);
setPopoverIsOpen(false);
}, [defaultDataView.id, defaultDataView.patternList, onChangeDataView, selectedPatterns]);
const onUpdateDeprecated = useCallback(() => {
// are all the patterns in the default?
if (missingPatterns.length === 0) {
onContinueUpdateDeprecated();
} else {
// open modal
setIsShowingUpdateModal(true);
}
}, [missingPatterns, onContinueUpdateDeprecated]);
const [isTriggerDisabled, setIsTriggerDisabled] = useState(false);
const onOpenAndReset = useCallback(() => {
setPopoverIsOpen(true);
resetDataSources();
}, [resetDataSources]);
const updateDataView = useUpdateDataView(onOpenAndReset);
const onUpdateDataView = useCallback(async () => {
const isUiSettingsSuccess = await updateDataView(missingPatterns);
setIsShowingUpdateModal(false);
setPopoverIsOpen(false);
if (isUiSettingsSuccess) {
onChangeDataView(
defaultDataView.id,
// to be at this stage, activePatterns is defined, the ?? selectedPatterns is to make TS happy
activePatterns ?? selectedPatterns,
false
);
setIsTriggerDisabled(true);
}
}, [
activePatterns,
defaultDataView.id,
missingPatterns,
onChangeDataView,
selectedPatterns,
updateDataView,
]);
useEffect(() => {
setDataViewId((prevSelectedOption) =>
selectedDataViewId != null && !deepEqual(selectedDataViewId, prevSelectedOption)
? selectedDataViewId
: prevSelectedOption
);
setDataViewId(selectedDataViewId);
}, [selectedDataViewId]);
const tooltipContent = useMemo(
() =>
getTooltipContent({
isOnlyDetectionAlerts,
isPopoverOpen,
selectedPatterns,
signalIndexName,
}),
[isPopoverOpen, isOnlyDetectionAlerts, signalIndexName, selectedPatterns]
);
const buttonWithTooptip = useMemo(() => {
return tooltipContent ? (
<EuiToolTip position="top" content={tooltipContent} data-test-subj="sourcerer-tooltip">
{trigger}
</EuiToolTip>
) : (
trigger
);
}, [trigger, tooltipContent]);
const onOutsideClick = useCallback(() => {
setDataViewId(selectedDataViewId);
setMissingPatterns(sourcererMissingPatterns);
}, [selectedDataViewId, sourcererMissingPatterns]);
const onExpandAdvancedOptionsClicked = useCallback(() => {
setExpandAdvancedOptions((prevState) => !prevState);
}, []);
return indicesExist ? (
// always show sourcerer in timeline
return indicesExist || scopeId === SourcererScopeName.timeline ? (
<EuiPopover
data-test-subj={isTimelineSourcerer ? 'timeline-sourcerer-popover' : 'sourcerer-popover'}
button={buttonWithTooptip}
isOpen={isPopoverOpen}
panelClassName="sourcererPopoverPanel"
button={
<Trigger
activePatterns={activePatterns}
disabled={isTriggerDisabled}
isModified={isModified}
isOnlyDetectionAlerts={isOnlyDetectionAlerts}
isPopoverOpen={isPopoverOpen}
isTimelineSourcerer={isTimelineSourcerer}
loading={loading}
onClick={setPopoverIsOpenCb}
selectedPatterns={selectedPatterns}
signalIndexName={signalIndexName}
/>
}
closePopover={handleClosePopOver}
data-test-subj={isTimelineSourcerer ? 'timeline-sourcerer-popover' : 'sourcerer-popover'}
display="block"
repositionOnScroll
isOpen={isPopoverOpen}
ownFocus
repositionOnScroll
>
<PopoverContent>
<EuiPopoverTitle data-test-subj="sourcerer-title">
<>{i18n.SELECT_DATA_VIEW}</>
</EuiPopoverTitle>
{isOnlyDetectionAlerts && (
<EuiCallOut
data-test-subj="sourcerer-callout"
size="s"
iconType="iInCircle"
title={isTimelineSourcerer ? i18n.CALL_OUT_TIMELINE_TITLE : i18n.CALL_OUT_TITLE}
/>
)}
<EuiSpacer size="s" />
<EuiForm component="form">
{isTimelineSourcerer && (
<StyledFormRow>
<EuiCheckbox
id="sourcerer-alert-only-checkbox"
data-test-subj="sourcerer-alert-only-checkbox"
label={i18n.ALERTS_CHECKBOX_LABEL}
checked={isOnlyDetectionAlertsChecked}
onChange={onCheckboxChanged}
/>
</StyledFormRow>
)}
<StyledFormRow label={i18n.INDEX_PATTERNS_CHOOSE_DATA_VIEW_LABEL}>
<EuiSuperSelect
data-test-subj="sourcerer-select"
disabled={isOnlyDetectionAlerts}
fullWidth
onChange={onChangeSuper}
options={dataViewSelectOptions}
placeholder={i18n.INDEX_PATTERNS_CHOOSE_DATA_VIEW_LABEL}
valueOfSelected={dataViewId}
<EuiOutsideClickDetector onOutsideClick={onOutsideClick}>
<PopoverContent>
<EuiPopoverTitle data-test-subj="sourcerer-title">
<>{i18n.SELECT_DATA_VIEW}</>
</EuiPopoverTitle>
{isOnlyDetectionAlerts && (
<EuiCallOut
data-test-subj="sourcerer-callout"
iconType="iInCircle"
size="s"
title={isTimelineSourcerer ? i18n.CALL_OUT_TIMELINE_TITLE : i18n.CALL_OUT_TITLE}
/>
</StyledFormRow>
<EuiSpacer size="m" />
<StyledButton
color="text"
onClick={onExpandAdvancedOptionsClicked}
iconType={expandAdvancedOptions ? 'arrowDown' : 'arrowRight'}
data-test-subj="sourcerer-advanced-options-toggle"
>
{i18n.INDEX_PATTERNS_ADVANCED_OPTIONS_TITLE}
</StyledButton>
{expandAdvancedOptions && <EuiSpacer size="m" />}
<FormRow
label={i18n.INDEX_PATTERNS_LABEL}
$expandAdvancedOptions={expandAdvancedOptions}
helpText={isOnlyDetectionAlerts ? undefined : i18n.INDEX_PATTERNS_DESCRIPTIONS}
>
<EuiComboBox
data-test-subj="sourcerer-combo-box"
fullWidth
isDisabled={isOnlyDetectionAlerts}
onChange={onChangeCombo}
options={selectableOptions}
placeholder={i18n.PICK_INDEX_PATTERNS}
renderOption={renderOption}
selectedOptions={selectedOptions}
/>
</FormRow>
{!isDetectionsSourcerer && (
<StyledFormRow>
<EuiFlexGroup alignItems="center" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<ResetButton
aria-label={i18n.INDEX_PATTERNS_RESET}
data-test-subj="sourcerer-reset"
flush="left"
onClick={resetDataSources}
title={i18n.INDEX_PATTERNS_RESET}
>
{i18n.INDEX_PATTERNS_RESET}
</ResetButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={handleSaveIndices}
disabled={isSavingDisabled}
data-test-subj="sourcerer-save"
fill
fullWidth
size="s"
>
{i18n.SAVE_INDEX_PATTERNS}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</StyledFormRow>
)}
<EuiSpacer size="s" />
</EuiForm>
</PopoverContent>
{isModified === 'deprecated' || isModified === 'missingPatterns' ? (
<>
<TemporarySourcerer
activePatterns={activePatterns}
indicesExist={indicesExist}
isModified={isModified}
missingPatterns={missingPatterns}
onClick={resetDataSources}
onClose={setPopoverIsOpenCb}
onUpdate={isModified === 'deprecated' ? onUpdateDeprecated : onUpdateDataView}
selectedPatterns={selectedPatterns}
/>
<UpdateDefaultDataViewModal
isShowing={isShowingUpdateModal}
missingPatterns={missingPatterns}
onClose={() => setIsShowingUpdateModal(false)}
onContinue={onContinueUpdateDeprecated}
onUpdate={onUpdateDataView}
/>
</>
) : (
<EuiForm component="form">
<>
{isTimelineSourcerer && (
<StyledFormRow>
<EuiCheckbox
checked={isOnlyDetectionAlertsChecked}
data-test-subj="sourcerer-alert-only-checkbox"
id="sourcerer-alert-only-checkbox"
label={i18n.ALERTS_CHECKBOX_LABEL}
onChange={onCheckboxChanged}
/>
</StyledFormRow>
)}
{dataViewId && (
<StyledFormRow label={i18n.INDEX_PATTERNS_CHOOSE_DATA_VIEW_LABEL}>
<EuiSuperSelect
data-test-subj="sourcerer-select"
disabled={isOnlyDetectionAlerts}
fullWidth
onChange={onChangeSuper}
options={dataViewSelectOptions}
placeholder={i18n.INDEX_PATTERNS_CHOOSE_DATA_VIEW_LABEL}
valueOfSelected={dataViewId}
/>
</StyledFormRow>
)}
<EuiSpacer size="m" />
<StyledButton
color="text"
data-test-subj="sourcerer-advanced-options-toggle"
iconType={expandAdvancedOptions ? 'arrowDown' : 'arrowRight'}
onClick={onExpandAdvancedOptionsClicked}
>
{i18n.INDEX_PATTERNS_ADVANCED_OPTIONS_TITLE}
</StyledButton>
{expandAdvancedOptions && <EuiSpacer size="m" />}
<FormRow
$expandAdvancedOptions={expandAdvancedOptions}
helpText={isOnlyDetectionAlerts ? undefined : i18n.INDEX_PATTERNS_DESCRIPTIONS}
label={i18n.INDEX_PATTERNS_LABEL}
>
<EuiComboBox
data-test-subj="sourcerer-combo-box"
fullWidth
isDisabled={isOnlyDetectionAlerts}
onChange={onChangeCombo}
options={allOptions}
placeholder={i18n.PICK_INDEX_PATTERNS}
renderOption={renderOption}
selectedOptions={selectedOptions}
/>
</FormRow>
{!isDetectionsSourcerer && (
<StyledFormRow>
<EuiFlexGroup alignItems="center" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<ResetButton
aria-label={i18n.INDEX_PATTERNS_RESET}
data-test-subj="sourcerer-reset"
flush="left"
onClick={resetDataSources}
title={i18n.INDEX_PATTERNS_RESET}
>
{i18n.INDEX_PATTERNS_RESET}
</ResetButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={handleSaveIndices}
disabled={selectedOptions.length === 0}
data-test-subj="sourcerer-save"
fill
fullWidth
size="s"
>
{i18n.SAVE_INDEX_PATTERNS}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</StyledFormRow>
)}
</>
<EuiSpacer size="s" />
</EuiForm>
)}
</PopoverContent>
</EuiOutsideClickDetector>
</EuiPopover>
) : null;
});

View file

@ -0,0 +1,28 @@
/*
* 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 { EuiButton } from '@elastic/eui';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import { RELOAD_PAGE_TITLE } from './translations';
const StyledRefreshButton = styled(EuiButton)`
float: right;
`;
export const RefreshButton = React.memo(() => {
const onPageRefresh = useCallback(() => {
document.location.reload();
}, []);
return (
<StyledRefreshButton onClick={onPageRefresh} data-test-subj="page-refresh">
{RELOAD_PAGE_TITLE}
</StyledRefreshButton>
);
});
RefreshButton.displayName = 'RefreshButton';

View file

@ -0,0 +1,188 @@
/*
* 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 { FormattedMessage } from '@kbn/i18n-react';
import {
EuiCallOut,
EuiLink,
EuiText,
EuiTextColor,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiToolTip,
EuiIcon,
} from '@elastic/eui';
import React, { useMemo } from 'react';
import * as i18n from './translations';
import { Blockquote, ResetButton } from './helpers';
interface Props {
activePatterns?: string[];
indicesExist: boolean;
isModified: 'deprecated' | 'missingPatterns';
missingPatterns: string[];
onClick: () => void;
onClose: () => void;
onUpdate: () => void;
selectedPatterns: string[];
}
const translations = {
deprecated: {
title: i18n.CALL_OUT_DEPRECATED_TITLE,
update: i18n.UPDATE_INDEX_PATTERNS,
},
missingPatterns: {
title: i18n.CALL_OUT_MISSING_PATTERNS_TITLE,
update: i18n.ADD_INDEX_PATTERN,
},
};
export const TemporarySourcerer = React.memo<Props>(
({
activePatterns,
indicesExist,
isModified,
onClose,
onClick,
onUpdate,
selectedPatterns,
missingPatterns,
}) => {
const trigger = useMemo(
() => (
<EuiButton
data-test-subj="sourcerer-deprecated-update"
fill
fullWidth
onClick={onUpdate}
size="s"
disabled={!indicesExist}
>
{translations[isModified].update}
</EuiButton>
),
[indicesExist, isModified, onUpdate]
);
const buttonWithTooltip = useMemo(
() =>
!indicesExist ? (
<EuiToolTip position="top" content={i18n.NO_DATA} data-test-subj="sourcerer-tooltip">
{trigger}
</EuiToolTip>
) : (
trigger
),
[indicesExist, trigger]
);
const deadPatterns =
activePatterns && activePatterns.length > 0
? selectedPatterns.filter((p) => !activePatterns.includes(p))
: [];
return (
<>
<EuiCallOut
color="warning"
data-test-subj="sourcerer-deprecated-callout"
iconType="alert"
size="s"
title={translations[isModified].title}
/>
<EuiSpacer size="s" />
<EuiText size="s">
<EuiTextColor color="subdued">
<p>
{activePatterns && activePatterns.length > 0 ? (
<FormattedMessage
id="xpack.securitySolution.indexPatterns.currentPatterns"
defaultMessage="The active index patterns in this timeline are{tooltip}: {callout}"
values={{
tooltip:
deadPatterns.length > 0 ? (
<EuiToolTip
content={
<FormattedMessage
id="xpack.securitySolution.indexPatterns.noMatchData"
defaultMessage="The following index patterns are saved to this timeline but do not match any data streams, indices, or index aliases: {aliases}"
values={{
aliases: selectedPatterns
.filter((p) => !activePatterns.includes(p))
.join(', '),
}}
/>
}
>
<EuiIcon type="questionInCircle" title={i18n.INACTIVE_PATTERNS} />
</EuiToolTip>
) : null,
callout: <Blockquote>{activePatterns.join(', ')}</Blockquote>,
}}
/>
) : (
<FormattedMessage
id="xpack.securitySolution.indexPatterns.currentPatternsBad"
defaultMessage="The current index patterns in this timeline are: {callout}"
values={{
callout: <Blockquote>{selectedPatterns.join(', ')}</Blockquote>,
}}
/>
)}
{isModified === 'deprecated' && (
<FormattedMessage
id="xpack.securitySolution.indexPatterns.toggleToNewSourcerer"
defaultMessage="We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can recreate your temporary data view with the new data view selector. You can also manually select a data view {link}."
values={{
link: <EuiLink onClick={onClick}>{i18n.TOGGLE_TO_NEW_SOURCERER}</EuiLink>,
}}
/>
)}
{isModified === 'missingPatterns' && (
<>
<FormattedMessage
id="xpack.securitySolution.indexPatterns.missingPatterns.callout"
defaultMessage="Security Data View is missing the following index patterns: {callout}"
values={{
callout: <Blockquote>{missingPatterns.join(', ')}</Blockquote>,
}}
/>
<FormattedMessage
id="xpack.securitySolution.indexPatterns.missingPatterns.description"
defaultMessage="We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can add the missing index patterns to the Security Data View. You can also manually select a data view {link}."
values={{
link: <EuiLink onClick={onClick}>{i18n.TOGGLE_TO_NEW_SOURCERER}</EuiLink>,
}}
/>
</>
)}
</p>
</EuiTextColor>
</EuiText>
<EuiFlexGroup alignItems="center" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<ResetButton
aria-label={i18n.INDEX_PATTERNS_CLOSE}
data-test-subj="sourcerer-deprecated-close"
flush="left"
onClick={onClose}
title={i18n.INDEX_PATTERNS_CLOSE}
>
{i18n.INDEX_PATTERNS_CLOSE}
</ResetButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>{buttonWithTooltip}</EuiFlexItem>
</EuiFlexGroup>
</>
);
}
);
TemporarySourcerer.displayName = 'TemporarySourcerer';

View file

@ -11,6 +11,20 @@ export const CALL_OUT_TITLE = i18n.translate('xpack.securitySolution.indexPatter
defaultMessage: 'Data view cannot be modified on this page',
});
export const CALL_OUT_DEPRECATED_TITLE = i18n.translate(
'xpack.securitySolution.indexPatterns.callOutDeprecxatedTitle',
{
defaultMessage: 'This timeline uses a legacy data view selector',
}
);
export const CALL_OUT_MISSING_PATTERNS_TITLE = i18n.translate(
'xpack.securitySolution.indexPatterns.callOutMissingPatternsTitle',
{
defaultMessage: 'This timeline is out of date with the Security Data View',
}
);
export const CALL_OUT_TIMELINE_TITLE = i18n.translate(
'xpack.securitySolution.indexPatterns.callOutTimelineTitle',
{
@ -18,9 +32,42 @@ export const CALL_OUT_TIMELINE_TITLE = i18n.translate(
}
);
export const TOGGLE_TO_NEW_SOURCERER = i18n.translate(
'xpack.securitySolution.indexPatterns.toggleToNewSourcerer.link',
{
defaultMessage: 'here',
}
);
export const DATA_VIEW = i18n.translate('xpack.securitySolution.indexPatterns.dataViewLabel', {
defaultMessage: 'Data view',
});
export const UPDATE_DATA_VIEW = i18n.translate(
'xpack.securitySolution.indexPatterns.updateDataView',
{
defaultMessage:
'Would you like to add this index pattern to Security Data View? Otherwise, we can recreate the data view without the missing index patterns.',
}
);
export const UPDATE_SECURITY_DATA_VIEW = i18n.translate(
'xpack.securitySolution.indexPatterns.updateSecurityDataView',
{
defaultMessage: 'Update Security Data View',
}
);
export const CONTINUE_WITHOUT_ADDING = i18n.translate(
'xpack.securitySolution.indexPatterns.continue',
{
defaultMessage: 'Continue without adding',
}
);
export const ADD_INDEX_PATTERN = i18n.translate('xpack.securitySolution.indexPatterns.add', {
defaultMessage: 'Add index pattern',
});
export const MODIFIED_BADGE_TITLE = i18n.translate(
'xpack.securitySolution.indexPatterns.modifiedBadgeTitle',
{
@ -35,6 +82,13 @@ export const ALERTS_BADGE_TITLE = i18n.translate(
}
);
export const DEPRECATED_BADGE_TITLE = i18n.translate(
'xpack.securitySolution.indexPatterns.updateAvailableBadgeTitle',
{
defaultMessage: 'Update available',
}
);
export const SECURITY_DEFAULT_DATA_VIEW_LABEL = i18n.translate(
'xpack.securitySolution.indexPatterns.securityDefaultDataViewLabel',
{
@ -97,6 +151,14 @@ export const DISABLED_INDEX_PATTERNS = i18n.translate(
}
);
export const DISABLED_SOURCERER = i18n.translate('xpack.securitySolution.sourcerer.disabled', {
defaultMessage: 'The updates to the Data view require a page reload to take effect.',
});
export const UPDATE_INDEX_PATTERNS = i18n.translate('xpack.securitySolution.indexPatterns.update', {
defaultMessage: 'Update and recreate data view',
});
export const INDEX_PATTERNS_RESET = i18n.translate(
'xpack.securitySolution.indexPatterns.resetButton',
{
@ -104,6 +166,22 @@ export const INDEX_PATTERNS_RESET = i18n.translate(
}
);
export const INDEX_PATTERNS_CLOSE = i18n.translate(
'xpack.securitySolution.indexPatterns.closeButton',
{
defaultMessage: 'Close',
}
);
export const INACTIVE_PATTERNS = i18n.translate('xpack.securitySolution.indexPatterns.inactive', {
defaultMessage: 'Inactive index patterns',
});
export const NO_DATA = i18n.translate('xpack.securitySolution.indexPatterns.noData', {
defaultMessage:
"The index pattern on this timeline doesn't match any data streams, indices, or index aliases.",
});
export const PICK_INDEX_PATTERNS = i18n.translate(
'xpack.securitySolution.indexPatterns.pickIndexPatternsCombo',
{
@ -117,3 +195,24 @@ export const ALERTS_CHECKBOX_LABEL = i18n.translate(
defaultMessage: 'Show only detection alerts',
}
);
export const SUCCESS_TOAST_TITLE = i18n.translate(
'xpack.securitySolution.indexPatterns.successToastTitle',
{
defaultMessage: 'One or more settings require you to reload the page to take effect',
}
);
export const RELOAD_PAGE_TITLE = i18n.translate(
'xpack.securitySolution.indexPatterns.reloadPageTitle',
{
defaultMessage: 'Reload page',
}
);
export const FAILURE_TOAST_TITLE = i18n.translate(
'xpack.securitySolution.indexPatterns.failureToastTitle',
{
defaultMessage: 'Unable to update data view',
}
);

View file

@ -0,0 +1,116 @@
/*
* 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, { FC, memo, useMemo } from 'react';
import { EuiToolTip } from '@elastic/eui';
import * as i18n from './translations';
import { getTooltipContent, StyledBadge, StyledButton } from './helpers';
import { ModifiedTypes } from './use_pick_index_patterns';
interface Props {
activePatterns?: string[];
disabled: boolean;
isModified: ModifiedTypes;
isOnlyDetectionAlerts: boolean;
isPopoverOpen: boolean;
isTimelineSourcerer: boolean;
loading: boolean;
onClick: () => void;
selectedPatterns: string[];
signalIndexName: string | null;
}
export const TriggerComponent: FC<Props> = ({
activePatterns,
disabled,
isModified,
isOnlyDetectionAlerts,
isPopoverOpen,
isTimelineSourcerer,
loading,
onClick,
selectedPatterns,
signalIndexName,
}) => {
const badge = useMemo(() => {
switch (isModified) {
case 'modified':
return <StyledBadge>{i18n.MODIFIED_BADGE_TITLE}</StyledBadge>;
case 'alerts':
return (
<StyledBadge data-test-subj="sourcerer-alerts-badge">
{i18n.ALERTS_BADGE_TITLE}
</StyledBadge>
);
case 'deprecated':
return (
<StyledBadge color="warning" data-test-subj="sourcerer-deprecated-badge">
{i18n.DEPRECATED_BADGE_TITLE}
</StyledBadge>
);
case 'missingPatterns':
return (
<StyledBadge color="warning" data-test-subj="sourcerer-missingPatterns-badge">
{i18n.DEPRECATED_BADGE_TITLE}
</StyledBadge>
);
case '':
default:
return null;
}
}, [isModified]);
const trigger = useMemo(
() => (
<StyledButton
aria-label={i18n.DATA_VIEW}
data-test-subj={isTimelineSourcerer ? 'timeline-sourcerer-trigger' : 'sourcerer-trigger'}
flush="left"
iconSide="right"
iconType="arrowDown"
disabled={disabled}
isLoading={loading}
onClick={onClick}
title={i18n.DATA_VIEW}
>
{i18n.DATA_VIEW}
{!disabled && badge}
</StyledButton>
),
[disabled, badge, isTimelineSourcerer, loading, onClick]
);
const tooltipContent = useMemo(
() =>
disabled
? i18n.DISABLED_SOURCERER
: getTooltipContent({
isOnlyDetectionAlerts,
isPopoverOpen,
// if activePatterns, use because we are in the temporary sourcerer state
selectedPatterns: activePatterns ?? selectedPatterns,
signalIndexName,
}),
[
activePatterns,
disabled,
isOnlyDetectionAlerts,
isPopoverOpen,
selectedPatterns,
signalIndexName,
]
);
return tooltipContent ? (
<EuiToolTip position="top" content={tooltipContent} data-test-subj="sourcerer-tooltip">
{trigger}
</EuiToolTip>
) : (
trigger
);
};
export const Trigger = memo(TriggerComponent);

View file

@ -0,0 +1,96 @@
/*
* 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 { FormattedMessage } from '@kbn/i18n-react';
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiModal,
EuiModalBody,
EuiModalHeader,
EuiModalHeaderTitle,
EuiText,
EuiTextColor,
} from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
import * as i18n from './translations';
import { Blockquote, ResetButton } from './helpers';
interface Props {
isShowing: boolean;
missingPatterns: string[];
onClose: () => void;
onContinue: () => void;
onUpdate: () => void;
}
const MyEuiModal = styled(EuiModal)`
.euiModal__flex {
width: 60vw;
}
.euiCodeBlock {
height: auto !important;
max-width: 718px;
}
z-index: 99999999;
`;
export const UpdateDefaultDataViewModal = React.memo<Props>(
({ isShowing, onClose, onContinue, onUpdate, missingPatterns }) =>
isShowing ? (
<MyEuiModal onClose={onClose} data-test-subj="sourcerer-update-data-view-modal">
<EuiModalHeader>
<EuiModalHeaderTitle>
<h1>{i18n.UPDATE_SECURITY_DATA_VIEW}</h1>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiText size="s">
<EuiTextColor color="subdued">
<p>
<FormattedMessage
id="xpack.securitySolution.indexPatterns.missingPatterns"
defaultMessage="Security Data View is missing the following index patterns in order to recreate the previous timeline's data view: {callout}"
values={{
callout: <Blockquote>{missingPatterns.join(', ')}</Blockquote>,
}}
/>
{i18n.UPDATE_DATA_VIEW}
</p>
</EuiTextColor>
</EuiText>
<EuiFlexGroup alignItems="center" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<ResetButton
aria-label={i18n.CONTINUE_WITHOUT_ADDING}
data-test-subj="sourcerer-continue-close"
flush="left"
onClick={onContinue}
title={i18n.CONTINUE_WITHOUT_ADDING}
>
{i18n.CONTINUE_WITHOUT_ADDING}
</ResetButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="sourcerer-update-data-view"
fill
fullWidth
onClick={onUpdate}
size="s"
>
{i18n.ADD_INDEX_PATTERN}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalBody>
</MyEuiModal>
) : null
);
UpdateDefaultDataViewModal.displayName = 'UpdateDefaultDataViewModal';

View file

@ -6,29 +6,31 @@
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EuiComboBoxOptionOption } from '@elastic/eui';
import { EuiComboBoxOptionOption, EuiSuperSelectOption } from '@elastic/eui';
import { getScopePatternListSelection } from '../../store/sourcerer/helpers';
import { sourcererModel } from '../../store/sourcerer';
import { getPatternListWithoutSignals } from './helpers';
import { getDataViewSelectOptions, getPatternListWithoutSignals } from './helpers';
import { SourcererScopeName } from '../../store/sourcerer/model';
interface UsePickIndexPatternsProps {
dataViewId: string;
dataViewId: string | null;
defaultDataViewId: string;
isOnlyDetectionAlerts: boolean;
kibanaDataViews: sourcererModel.SourcererModel['kibanaDataViews'];
missingPatterns: string[];
scopeId: sourcererModel.SourcererScopeName;
selectedPatterns: string[];
signalIndexName: string | null;
}
export type ModifiedTypes = 'modified' | 'alerts' | '';
export type ModifiedTypes = 'modified' | 'alerts' | 'deprecated' | 'missingPatterns' | '';
interface UsePickIndexPatterns {
allOptions: Array<EuiComboBoxOptionOption<string>>;
dataViewSelectOptions: Array<EuiSuperSelectOption<string>>;
isModified: ModifiedTypes;
onChangeCombo: (newSelectedDataViewId: Array<EuiComboBoxOptionOption<string>>) => void;
renderOption: ({ value }: EuiComboBoxOptionOption<string>) => React.ReactElement;
selectableOptions: Array<EuiComboBoxOptionOption<string>>;
selectedOptions: Array<EuiComboBoxOptionOption<string>>;
setIndexPatternsByDataView: (newSelectedDataViewId: string, isAlerts?: boolean) => void;
}
@ -45,6 +47,7 @@ export const usePickIndexPatterns = ({
defaultDataViewId,
isOnlyDetectionAlerts,
kibanaDataViews,
missingPatterns,
scopeId,
selectedPatterns,
signalIndexName,
@ -54,42 +57,44 @@ export const usePickIndexPatterns = ({
[signalIndexName]
);
const { patternList, selectablePatterns } = useMemo(() => {
const { allPatterns, selectablePatterns } = useMemo<{
allPatterns: string[];
selectablePatterns: string[];
}>(() => {
if (isOnlyDetectionAlerts && signalIndexName) {
return {
patternList: [signalIndexName],
allPatterns: [signalIndexName],
selectablePatterns: [signalIndexName],
};
}
const theDataView = kibanaDataViews.find((dataView) => dataView.id === dataViewId);
return theDataView != null
? scopeId === sourcererModel.SourcererScopeName.default
? {
patternList: getPatternListWithoutSignals(
theDataView.title
.split(',')
// remove duplicates patterns from selector
.filter((pattern, i, self) => self.indexOf(pattern) === i),
signalIndexName
),
selectablePatterns: getPatternListWithoutSignals(
theDataView.patternList,
signalIndexName
),
}
: {
patternList: theDataView.title
.split(',')
// remove duplicates patterns from selector
.filter((pattern, i, self) => self.indexOf(pattern) === i),
selectablePatterns: theDataView.patternList,
}
: { patternList: [], selectablePatterns: [] };
if (theDataView == null) {
return {
allPatterns: [],
selectablePatterns: [],
};
}
const titleAsList = [...new Set(theDataView.title.split(','))];
return scopeId === sourcererModel.SourcererScopeName.default
? {
allPatterns: getPatternListWithoutSignals(titleAsList, signalIndexName),
selectablePatterns: getPatternListWithoutSignals(
theDataView.patternList,
signalIndexName
),
}
: {
allPatterns: titleAsList,
selectablePatterns: theDataView.patternList,
};
}, [dataViewId, isOnlyDetectionAlerts, kibanaDataViews, scopeId, signalIndexName]);
const selectableOptions = useMemo(
() => patternListToOptions(patternList, selectablePatterns),
[patternList, selectablePatterns]
const allOptions = useMemo(
() => patternListToOptions(allPatterns, selectablePatterns),
[allPatterns, selectablePatterns]
);
const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>(
isOnlyDetectionAlerts ? alertsOptions : patternListToOptions(selectedPatterns)
@ -111,37 +116,50 @@ export const usePickIndexPatterns = ({
);
const defaultSelectedPatternsAsOptions = useMemo(
() => getDefaultSelectedOptionsByDataView(dataViewId),
() => (dataViewId != null ? getDefaultSelectedOptionsByDataView(dataViewId) : []),
[dataViewId, getDefaultSelectedOptionsByDataView]
);
const [isModified, setIsModified] = useState<'modified' | 'alerts' | ''>('');
const [isModified, setIsModified] = useState<ModifiedTypes>(
dataViewId == null ? 'deprecated' : missingPatterns.length > 0 ? 'missingPatterns' : ''
);
const onSetIsModified = useCallback(
(patterns?: string[]) => {
(patterns: string[], id: string | null) => {
if (id == null) {
return setIsModified('deprecated');
}
if (missingPatterns.length > 0) {
return setIsModified('missingPatterns');
}
if (isOnlyDetectionAlerts) {
return setIsModified('alerts');
}
const modifiedPatterns = patterns != null ? patterns : selectedPatterns;
const isPatternsModified =
defaultSelectedPatternsAsOptions.length !== modifiedPatterns.length ||
defaultSelectedPatternsAsOptions.length !== patterns.length ||
!defaultSelectedPatternsAsOptions.every((option) =>
modifiedPatterns.find((pattern) => option.value === pattern)
patterns.find((pattern) => option.value === pattern)
);
return setIsModified(isPatternsModified ? 'modified' : '');
},
[defaultSelectedPatternsAsOptions, isOnlyDetectionAlerts, selectedPatterns]
[defaultSelectedPatternsAsOptions, isOnlyDetectionAlerts, missingPatterns.length]
);
// when scope updates, check modified to set/remove alerts label
useEffect(() => {
setSelectedOptions(
scopeId === SourcererScopeName.detections
? alertsOptions
: patternListToOptions(selectedPatterns)
);
onSetIsModified(selectedPatterns);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopeId, selectedPatterns]);
}, [selectedPatterns, scopeId]);
// when scope updates, check modified to set/remove alerts label
useEffect(() => {
onSetIsModified(
selectedOptions.map(({ label }) => label),
dataViewId
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataViewId, missingPatterns, scopeId, selectedOptions]);
const onChangeCombo = useCallback((newSelectedOptions) => {
setSelectedOptions(newSelectedOptions);
@ -156,11 +174,26 @@ export const usePickIndexPatterns = ({
setSelectedOptions(getDefaultSelectedOptionsByDataView(newSelectedDataViewId, isAlerts));
};
const dataViewSelectOptions = useMemo(
() =>
dataViewId != null
? getDataViewSelectOptions({
dataViewId,
defaultDataViewId,
isModified: isModified === 'modified',
isOnlyDetectionAlerts,
kibanaDataViews,
})
: [],
[dataViewId, defaultDataViewId, isModified, isOnlyDetectionAlerts, kibanaDataViews]
);
return {
allOptions,
dataViewSelectOptions,
isModified,
onChangeCombo,
renderOption,
selectableOptions,
selectedOptions,
setIndexPatternsByDataView,
};

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook } from '@testing-library/react-hooks';
import { useUpdateDataView } from './use_update_data_view';
import { useKibana } from '../../lib/kibana';
import * as i18n from './translations';
const mockAddSuccess = jest.fn();
const mockAddError = jest.fn();
const mockSet = jest.fn();
const mockPatterns = ['packetbeat-*', 'winlogbeat-*'];
jest.mock('../../hooks/use_app_toasts', () => {
const original = jest.requireActual('../../hooks/use_app_toasts');
return {
...original,
useAppToasts: () => ({
addSuccess: mockAddSuccess,
addError: mockAddError,
}),
};
});
jest.mock('../../lib/kibana');
jest.mock('../../../../../../../src/plugins/kibana_react/public', () => {
const original = jest.requireActual('../../../../../../../src/plugins/kibana_react/public');
return {
...original,
toMountPoint: jest.fn(),
};
});
describe('use_update_data_view', () => {
const mockError = jest.fn();
beforeEach(() => {
(useKibana as jest.Mock).mockImplementation(() => ({
services: {
uiSettings: {
get: () => mockPatterns,
set: mockSet.mockResolvedValue(true),
},
},
}));
jest.clearAllMocks();
});
test('Successful uiSettings updates with correct index pattern, and shows success toast', async () => {
const { result } = renderHook(() => useUpdateDataView(mockError));
const updateDataView = result.current;
const isUiSettingsSuccess = await updateDataView(['missing-*']);
expect(mockSet.mock.calls[0][1]).toEqual([...mockPatterns, 'missing-*'].sort());
expect(isUiSettingsSuccess).toEqual(true);
expect(mockAddSuccess).toHaveBeenCalled();
});
test('Failed uiSettings update returns false and shows error toast', async () => {
(useKibana as jest.Mock).mockImplementation(() => ({
services: {
uiSettings: {
get: () => mockPatterns,
set: mockSet.mockResolvedValue(false),
},
},
}));
const { result } = renderHook(() => useUpdateDataView(mockError));
const updateDataView = result.current;
const isUiSettingsSuccess = await updateDataView(['missing-*']);
expect(mockSet.mock.calls[0][1]).toEqual([...mockPatterns, 'missing-*'].sort());
expect(isUiSettingsSuccess).toEqual(false);
expect(mockAddError).toHaveBeenCalled();
expect(mockAddError.mock.calls[0][0]).toEqual(new Error(i18n.FAILURE_TOAST_TITLE));
});
test('Failed uiSettings throws error and shows error toast', async () => {
(useKibana as jest.Mock).mockImplementation(() => ({
services: {
uiSettings: {
get: jest.fn().mockImplementation(() => {
throw new Error('Uh oh bad times over here');
}),
set: mockSet.mockResolvedValue(true),
},
},
}));
const { result } = renderHook(() => useUpdateDataView(mockError));
const updateDataView = result.current;
const isUiSettingsSuccess = await updateDataView(['missing-*']);
expect(isUiSettingsSuccess).toEqual(false);
expect(mockAddError).toHaveBeenCalled();
expect(mockAddError.mock.calls[0][0]).toEqual(new Error('Uh oh bad times over here'));
});
});

View file

@ -0,0 +1,72 @@
/*
* 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 } from 'react';
import { EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useKibana } from '../../lib/kibana';
import { DEFAULT_INDEX_KEY } from '../../../../common/constants';
import { ensurePatternFormat } from '../../store/sourcerer/helpers';
import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public';
import * as i18n from './translations';
import { RefreshButton } from './refresh_button';
import { useAppToasts } from '../../hooks/use_app_toasts';
export const useUpdateDataView = (
onOpenAndReset: () => void
): ((missingPatterns: string[]) => Promise<boolean>) => {
const { uiSettings } = useKibana().services;
const { addSuccess, addError } = useAppToasts();
return useCallback(
async (missingPatterns: string[]): Promise<boolean> => {
const asyncSearch = async (): Promise<[boolean, Error | null]> => {
try {
const defaultPatterns = uiSettings.get<string[]>(DEFAULT_INDEX_KEY);
const uiSettingsIndexPattern = [...defaultPatterns, ...missingPatterns];
const isSuccess = await uiSettings.set(
DEFAULT_INDEX_KEY,
ensurePatternFormat(uiSettingsIndexPattern)
);
return [isSuccess, null];
} catch (e) {
return [false, e];
}
};
const [isUiSettingsSuccess, possibleError] = await asyncSearch();
if (isUiSettingsSuccess) {
addSuccess({
color: 'success',
title: toMountPoint(i18n.SUCCESS_TOAST_TITLE),
text: toMountPoint(<RefreshButton />),
iconType: undefined,
toastLifeTimeMs: 600000,
});
return true;
}
addError(possibleError !== null ? possibleError : new Error(i18n.FAILURE_TOAST_TITLE), {
title: i18n.FAILURE_TOAST_TITLE,
toastMessage: (
<>
<FormattedMessage
id="xpack.securitySolution.indexPatterns.failureToastText"
defaultMessage="Unexpected error occurred on update. If you would like to modify your data, you can manually select a data view {link}."
values={{
link: (
<EuiLink onClick={onOpenAndReset} data-test-subj="failureToastLink">
{i18n.TOGGLE_TO_NEW_SOURCERER}
</EuiLink>
),
}}
/>
</>
) as unknown as string,
});
return false;
},
[addError, addSuccess, onOpenAndReset, uiSettings]
);
};

View file

@ -64,7 +64,7 @@ export const useSetInitialStateFromUrl = () => {
dispatch(
sourcererActions.setSelectedDataView({
id: scope,
selectedDataViewId: sourcererState[scope]?.id ?? '',
selectedDataViewId: sourcererState[scope]?.id ?? null,
selectedPatterns: sourcererState[scope]?.selectedPatterns ?? [],
})
)

View file

@ -5,12 +5,16 @@
* 2.0.
*/
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { i18n } from '@kbn/i18n';
import { matchPath } from 'react-router-dom';
import { sourcererActions, sourcererSelectors } from '../../store/sourcerer';
import { SelectedDataView, SourcererScopeName } from '../../store/sourcerer/model';
import {
SelectedDataView,
SourcererDataView,
SourcererScopeName,
} from '../../store/sourcerer/model';
import { useUserInfo } from '../../../detections/components/user_info';
import { timelineSelectors } from '../../../timelines/store/timeline';
import {
@ -28,6 +32,7 @@ import { checkIfIndicesExist, getScopePatternListSelection } from '../../store/s
import { useAppToasts } from '../../hooks/use_app_toasts';
import { postSourcererDataView } from './api';
import { useDataView } from '../source/use_data_view';
import { useFetchIndex } from '../source';
export const useInitSourcerer = (
scopeId: SourcererScopeName.default | SourcererScopeName.detections = SourcererScopeName.default
@ -37,11 +42,14 @@ export const useInitSourcerer = (
const initialTimelineSourcerer = useRef(true);
const initialDetectionSourcerer = useRef(true);
const { loading: loadingSignalIndex, isSignalIndexExists, signalIndexName } = useUserInfo();
const getDefaultDataViewSelector = useMemo(
() => sourcererSelectors.defaultDataViewSelector(),
const getDataViewsSelector = useMemo(
() => sourcererSelectors.getSourcererDataViewsSelector(),
[]
);
const defaultDataView = useDeepEqualSelector(getDefaultDataViewSelector);
const { defaultDataView, signalIndexName: signalIndexNameSourcerer } = useDeepEqualSelector(
(state) => getDataViewsSelector(state)
);
const { addError } = useAppToasts();
@ -59,12 +67,6 @@ export const useInitSourcerer = (
}
}, [addError, defaultDataView.error]);
const getSignalIndexNameSelector = useMemo(
() => sourcererSelectors.signalIndexNameSelector(),
[]
);
const signalIndexNameSourcerer = useDeepEqualSelector(getSignalIndexNameSelector);
const getTimelineSelector = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const activeTimeline = useDeepEqualSelector((state) =>
getTimelineSelector(state, TimelineId.active)
@ -256,14 +258,26 @@ export const EXCLUDE_ELASTIC_CLOUD_INDEX = '-*elastic-cloud-logs-*';
export const useSourcererDataView = (
scopeId: SourcererScopeName = SourcererScopeName.default
): SelectedDataView => {
const sourcererScopeSelector = useMemo(() => sourcererSelectors.getSourcererScopeSelector(), []);
const { getDataViewsSelector, getSourcererDataViewSelector, getScopeSelector } = useMemo(
() => ({
getDataViewsSelector: sourcererSelectors.getSourcererDataViewsSelector(),
getSourcererDataViewSelector: sourcererSelectors.sourcererDataViewSelector(),
getScopeSelector: sourcererSelectors.scopeIdSelector(),
}),
[]
);
const {
signalIndexName,
sourcererDataView: selectedDataView,
sourcererScope: { selectedPatterns: scopeSelectedPatterns, loading },
}: sourcererSelectors.SourcererScopeSelector = useDeepEqualSelector((state) =>
sourcererScopeSelector(state, scopeId)
);
selectedDataView,
sourcererScope: { missingPatterns, selectedPatterns: scopeSelectedPatterns, loading },
}: sourcererSelectors.SourcererScopeSelector = useDeepEqualSelector((state) => {
const sourcererScope = getScopeSelector(state, scopeId);
return {
...getDataViewsSelector(state),
selectedDataView: getSourcererDataViewSelector(state, sourcererScope.selectedDataViewId),
sourcererScope,
};
});
const selectedPatterns = useMemo(
() =>
@ -273,40 +287,69 @@ export const useSourcererDataView = (
[scopeSelectedPatterns]
);
const [legacyPatterns, setLegacyPatterns] = useState<string[]>([]);
const [indexPatternsLoading, fetchIndexReturn] = useFetchIndex(legacyPatterns);
const legacyDataView: Omit<SourcererDataView, 'id'> & { id: string | null } = useMemo(
() => ({
...fetchIndexReturn,
runtimeMappings: {},
title: '',
id: selectedDataView?.id ?? null,
loading: indexPatternsLoading,
patternList: fetchIndexReturn.indexes,
indexFields: fetchIndexReturn.indexPatterns
.fields as SelectedDataView['indexPattern']['fields'],
}),
[fetchIndexReturn, indexPatternsLoading, selectedDataView]
);
useEffect(() => {
if (selectedDataView == null || missingPatterns.length > 0) {
// old way of fetching indices, legacy timeline
setLegacyPatterns(selectedPatterns);
} else {
setLegacyPatterns([]);
}
}, [missingPatterns, selectedDataView, selectedPatterns]);
const sourcererDataView = useMemo(
() =>
selectedDataView == null || missingPatterns.length > 0 ? legacyDataView : selectedDataView,
[legacyDataView, missingPatterns.length, selectedDataView]
);
const indicesExist = useMemo(
() => checkIfIndicesExist({ scopeId, signalIndexName, sourcererDataView: selectedDataView }),
[scopeId, signalIndexName, selectedDataView]
() =>
checkIfIndicesExist({
scopeId,
signalIndexName,
patternList: sourcererDataView.patternList,
}),
[scopeId, signalIndexName, sourcererDataView]
);
return useMemo(
() => ({
browserFields: selectedDataView.browserFields,
dataViewId: selectedDataView.id,
docValueFields: selectedDataView.docValueFields,
browserFields: sourcererDataView.browserFields,
dataViewId: sourcererDataView.id,
docValueFields: sourcererDataView.docValueFields,
indexPattern: {
fields: selectedDataView.indexFields,
fields: sourcererDataView.indexFields,
title: selectedPatterns.join(','),
},
indicesExist,
loading: loading || selectedDataView.loading,
runtimeMappings: selectedDataView.runtimeMappings,
loading: loading || sourcererDataView.loading,
runtimeMappings: sourcererDataView.runtimeMappings,
// all active & inactive patterns in DATA_VIEW
patternList: selectedDataView.title.split(','),
// selected patterns in DATA_VIEW
patternList: sourcererDataView.title.split(','),
// selected patterns in DATA_VIEW including filter
selectedPatterns: selectedPatterns.sort(),
// if we have to do an update to data view, tell us which patterns are active
...(legacyPatterns.length > 0 ? { activePatterns: sourcererDataView.patternList } : {}),
}),
[
selectedDataView.browserFields,
selectedDataView.id,
selectedDataView.docValueFields,
selectedDataView.indexFields,
selectedDataView.loading,
selectedDataView.runtimeMappings,
selectedDataView.title,
selectedPatterns,
indicesExist,
loading,
]
[sourcererDataView, selectedPatterns, indicesExist, loading, legacyPatterns.length]
);
};

View file

@ -1955,7 +1955,7 @@ export const mockTimelineModel: TimelineModel = {
columns: mockTimelineModelColumns,
defaultColumns: mockTimelineModelColumns,
dataProviders: [],
dataViewId: '',
dataViewId: null,
dateRange: {
end: '2020-03-18T13:52:38.929Z',
start: '2020-03-18T13:46:38.929Z',
@ -2092,7 +2092,7 @@ export const defaultTimelineProps: CreateTimelineProps = {
queryMatch: { field: '_id', operator: ':', value: '1' },
},
],
dataViewId: '',
dataViewId: null,
dateRange: { end: '2018-11-05T19:03:25.937Z', start: '2018-11-05T18:58:25.937Z' },
deletedEventIds: [],
description: '',

View file

@ -8,7 +8,7 @@
import { parseExperimentalConfigValue } from '../../../common/experimental_features';
import { SecuritySubPlugins } from '../../app/types';
import { createInitialState } from './reducer';
import { mockSourcererState } from '../mock';
import { mockIndexPattern, mockSourcererState } from '../mock';
import { useSourcererDataView } from '../containers/sourcerer';
import { useDeepEqualSelector } from '../hooks/use_selector';
import { renderHook } from '@testing-library/react-hooks';
@ -19,6 +19,12 @@ jest.mock('../lib/kibana', () => ({
get: jest.fn(() => ({ uiSettings: { get: () => ({ from: 'now-24h', to: 'now' }) } })),
},
}));
jest.mock('../containers/source', () => ({
useFetchIndex: () => [
false,
{ indexes: [], indicesExist: true, indexPatterns: mockIndexPattern },
],
}));
describe('createInitialState', () => {
describe('sourcerer -> default -> indicesExist', () => {
@ -40,20 +46,24 @@ describe('createInitialState', () => {
(useDeepEqualSelector as jest.Mock).mockClear();
});
test('indicesExist should be TRUE if configIndexPatterns is NOT empty', async () => {
test('indicesExist should be TRUE if patternList is NOT empty', async () => {
const { result } = renderHook(() => useSourcererDataView());
expect(result.current.indicesExist).toEqual(true);
});
test('indicesExist should be FALSE if configIndexPatterns is empty', () => {
test('indicesExist should be FALSE if patternList is empty', () => {
const state = createInitialState(mockPluginState, {
...defaultState,
defaultDataView: {
...defaultState.defaultDataView,
id: '',
title: '',
patternList: [],
},
kibanaDataViews: [
{
...defaultState.defaultDataView,
patternList: [],
},
],
});
(useDeepEqualSelector as jest.Mock).mockImplementation((cb) => cb(state));
const { result } = renderHook(() => useSourcererDataView());

View file

@ -6,9 +6,8 @@
*/
import actionCreatorFactory from 'typescript-fsa';
import { TimelineEventsType } from '../../../../common/types/timeline';
import { SourcererDataView, SourcererScopeName } from './model';
import { SelectedDataView, SourcererDataView, SourcererScopeName } from './model';
import { SecurityDataView } from '../../containers/sourcerer/api';
const actionCreator = actionCreatorFactory('x-pack/security_solution/local/sourcerer');
@ -39,8 +38,8 @@ export const setSourcererScopeLoading = actionCreator<{
export interface SelectedDataViewPayload {
id: SourcererScopeName;
selectedDataViewId: string;
selectedPatterns: string[];
eventType?: TimelineEventsType;
selectedDataViewId: SelectedDataView['dataViewId'];
selectedPatterns: SelectedDataView['selectedPatterns'];
shouldValidateSelectedPatterns?: boolean;
}
export const setSelectedDataView = actionCreator<SelectedDataViewPayload>('SET_SELECTED_DATA_VIEW');

View file

@ -69,7 +69,7 @@ describe('sourcerer store helpers', () => {
selectedPatterns: ['auditbeat-*'],
};
it('sets selectedPattern', () => {
const result = validateSelectedPatterns(mockGlobalState.sourcerer, payload);
const result = validateSelectedPatterns(mockGlobalState.sourcerer, payload, true);
expect(result).toEqual({
[SourcererScopeName.default]: {
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default],
@ -78,10 +78,14 @@ describe('sourcerer store helpers', () => {
});
});
it('sets to default when empty array is passed and scope is default', () => {
const result = validateSelectedPatterns(mockGlobalState.sourcerer, {
...payload,
selectedPatterns: [],
});
const result = validateSelectedPatterns(
mockGlobalState.sourcerer,
{
...payload,
selectedPatterns: [],
},
true
);
expect(result).toEqual({
[SourcererScopeName.default]: {
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default],
@ -90,11 +94,15 @@ describe('sourcerer store helpers', () => {
});
});
it('sets to default when empty array is passed and scope is detections', () => {
const result = validateSelectedPatterns(mockGlobalState.sourcerer, {
...payload,
id: SourcererScopeName.detections,
selectedPatterns: [],
});
const result = validateSelectedPatterns(
mockGlobalState.sourcerer,
{
...payload,
id: SourcererScopeName.detections,
selectedPatterns: [],
},
true
);
expect(result).toEqual({
[SourcererScopeName.detections]: {
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.detections],
@ -103,22 +111,21 @@ describe('sourcerer store helpers', () => {
},
});
});
it('sets to default when empty array is passed and scope is timeline', () => {
const result = validateSelectedPatterns(mockGlobalState.sourcerer, {
...payload,
id: SourcererScopeName.timeline,
selectedPatterns: [],
});
it('sets to empty when empty array is passed and scope is timeline', () => {
const result = validateSelectedPatterns(
mockGlobalState.sourcerer,
{
...payload,
id: SourcererScopeName.timeline,
selectedPatterns: [],
},
true
);
expect(result).toEqual({
[SourcererScopeName.timeline]: {
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
selectedDataViewId: dataView.id,
selectedPatterns: [
signalIndexName,
...mockGlobalState.sourcerer.defaultDataView.patternList.filter(
(p) => p !== signalIndexName
),
].sort(),
selectedPatterns: [],
},
});
});
@ -132,11 +139,15 @@ describe('sourcerer store helpers', () => {
defaultDataView: dataViewNoSignals,
kibanaDataViews: [dataViewNoSignals],
};
const result = validateSelectedPatterns(stateNoSignals, {
...payload,
id: SourcererScopeName.timeline,
selectedPatterns: [`${mockGlobalState.sourcerer.signalIndexName}`],
});
const result = validateSelectedPatterns(
stateNoSignals,
{
...payload,
id: SourcererScopeName.timeline,
selectedPatterns: [`${mockGlobalState.sourcerer.signalIndexName}`],
},
true
);
expect(result).toEqual({
[SourcererScopeName.timeline]: {
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
@ -147,19 +158,23 @@ describe('sourcerer store helpers', () => {
});
describe('handles missing dataViewId, 7.16 -> 8.0', () => {
it('selectedPatterns.length > 0 & all selectedPatterns exist in defaultDataView, set dataViewId to defaultDataView.id', () => {
const result = validateSelectedPatterns(mockGlobalState.sourcerer, {
...payload,
id: SourcererScopeName.timeline,
selectedDataViewId: '',
selectedPatterns: [
mockGlobalState.sourcerer.defaultDataView.patternList[3],
mockGlobalState.sourcerer.defaultDataView.patternList[4],
],
});
const result = validateSelectedPatterns(
mockGlobalState.sourcerer,
{
...payload,
id: SourcererScopeName.timeline,
selectedDataViewId: null,
selectedPatterns: [
mockGlobalState.sourcerer.defaultDataView.patternList[3],
mockGlobalState.sourcerer.defaultDataView.patternList[4],
],
},
true
);
expect(result).toEqual({
[SourcererScopeName.timeline]: {
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
selectedDataViewId: dataView.id,
selectedDataViewId: null,
selectedPatterns: [
mockGlobalState.sourcerer.defaultDataView.patternList[3],
mockGlobalState.sourcerer.defaultDataView.patternList[4],
@ -167,16 +182,20 @@ describe('sourcerer store helpers', () => {
},
});
});
it('selectedPatterns.length > 0 & a pattern in selectedPatterns does not exist in defaultDataView, set dataViewId to null', () => {
const result = validateSelectedPatterns(mockGlobalState.sourcerer, {
...payload,
id: SourcererScopeName.timeline,
selectedDataViewId: '',
selectedPatterns: [
mockGlobalState.sourcerer.defaultDataView.patternList[3],
'journalbeat-*',
],
});
it('selectedPatterns.length > 0 & some selectedPatterns do not exist in defaultDataView, set dataViewId to null', () => {
const result = validateSelectedPatterns(
mockGlobalState.sourcerer,
{
...payload,
id: SourcererScopeName.timeline,
selectedDataViewId: null,
selectedPatterns: [
mockGlobalState.sourcerer.defaultDataView.patternList[3],
'journalbeat-*',
],
},
true
);
expect(result).toEqual({
[SourcererScopeName.timeline]: {
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
@ -185,6 +204,7 @@ describe('sourcerer store helpers', () => {
mockGlobalState.sourcerer.defaultDataView.patternList[3],
'journalbeat-*',
],
missingPatterns: ['journalbeat-*'],
},
});
});

View file

@ -34,54 +34,58 @@ export const getScopePatternListSelection = (
}
};
export const ensurePatternFormat = (patternList: string[]): string[] =>
[
...new Set(
patternList.reduce((acc: string[], pattern: string) => [...pattern.split(','), ...acc], [])
),
].sort();
export const validateSelectedPatterns = (
state: SourcererModel,
payload: SelectedDataViewPayload
payload: SelectedDataViewPayload,
shouldValidateSelectedPatterns: boolean
): Partial<SourcererScopeById> => {
const { id, ...rest } = payload;
let dataView = state.kibanaDataViews.find((p) => p.id === rest.selectedDataViewId);
const dataView = state.kibanaDataViews.find((p) => p.id === rest.selectedDataViewId);
// dedupe because these could come from a silly url or pre 8.0 timeline
const dedupePatterns = [...new Set(rest.selectedPatterns)];
let selectedPatterns =
dataView != null
const dedupePatterns = ensurePatternFormat(rest.selectedPatterns);
let missingPatterns: string[] = [];
// check for missing patterns against default data view only
if (dataView == null || dataView.id === state.defaultDataView.id) {
const dedupeAllDefaultPatterns = ensurePatternFormat(
(dataView ?? state.defaultDataView).title.split(',')
);
missingPatterns = dedupePatterns.filter(
(pattern) => !dedupeAllDefaultPatterns.includes(pattern)
);
}
const selectedPatterns =
// shouldValidateSelectedPatterns is false when upgrading from
// legacy pre-8.0 timeline index patterns to data view.
shouldValidateSelectedPatterns && dataView != null && missingPatterns.length === 0
? dedupePatterns.filter(
(pattern) =>
// Typescript is being mean and telling me dataView could be undefined here
// so redoing the dataView != null check
(dataView != null && dataView.patternList.includes(pattern)) ||
// this is a hack, but sometimes signal index is deleted and is getting regenerated. it gets set before it is put in the dataView
state.signalIndexName == null ||
state.signalIndexName === pattern
)
: // 7.16 -> 8.0 this will get hit because dataView == null
: // don't remove non-existing patterns, they were saved in the first place in timeline
// but removed from the security data view
// or its a legacy pre-8.0 timeline
dedupePatterns;
if (selectedPatterns.length > 0 && dataView == null) {
// we have index patterns, but not a data view id
// find out if we have these index patterns in the defaultDataView
const areAllPatternsInDefault = selectedPatterns.every(
(pattern) => state.defaultDataView.title.indexOf(pattern) > -1
);
if (areAllPatternsInDefault) {
dataView = state.defaultDataView;
selectedPatterns = selectedPatterns.filter(
(pattern) => dataView != null && dataView.patternList.includes(pattern)
);
}
}
// TO DO: Steph/sourcerer If dataView is still undefined here, create temporary dataView
// and prompt user to go create this dataView
// currently UI will take the undefined dataView and default to defaultDataView anyways
// this is a "strategically merged" bug ;)
// https://github.com/elastic/security-team/issues/1921
return {
[id]: {
...state.sourcererScopes[id],
...rest,
selectedDataViewId: dataView?.id ?? null,
selectedPatterns,
...(isEmpty(selectedPatterns)
missingPatterns,
// if in timeline, allow for empty in case pattern was deleted
// need flow for this
...(isEmpty(selectedPatterns) && id !== SourcererScopeName.timeline
? {
selectedPatterns: getScopePatternListSelection(
dataView ?? state.defaultDataView,
@ -97,17 +101,17 @@ export const validateSelectedPatterns = (
};
interface CheckIfIndicesExistParams {
patternList: sourcererModel.SourcererDataView['patternList'];
scopeId: sourcererModel.SourcererScopeName;
signalIndexName: string | null;
sourcererDataView: sourcererModel.SourcererDataView;
}
export const checkIfIndicesExist = ({
patternList,
scopeId,
signalIndexName,
sourcererDataView,
}: CheckIfIndicesExistParams) =>
scopeId === SourcererScopeName.detections
? sourcererDataView.patternList.includes(`${signalIndexName}`)
? patternList.includes(`${signalIndexName}`)
: scopeId === SourcererScopeName.default
? sourcererDataView.patternList.filter((i) => i !== signalIndexName).length > 0
: sourcererDataView.patternList.length > 0;
? patternList.filter((i) => i !== signalIndexName).length > 0
: patternList.length > 0;

View file

@ -29,10 +29,15 @@ export interface SourcererScope {
id: SourcererScopeName;
/** is an update being made to the sourcerer data view */
loading: boolean;
/** selected data view id */
selectedDataViewId: string;
/** selected data view id, null if it is legacy index patterns*/
selectedDataViewId: string | null;
/** selected patterns within the data view */
selectedPatterns: string[];
/** if has length,
* id === SourcererScopeName.timeline
* selectedDataViewId === null OR defaultDataView.id
* saved timeline has pattern that is not in the default */
missingPatterns: string[];
}
export type SourcererScopeById = Record<SourcererScopeName, SourcererScope>;
@ -54,6 +59,7 @@ export interface KibanaDataView {
* DataView from Kibana + timelines/index_fields enhanced field data
*/
export interface SourcererDataView extends KibanaDataView {
id: string;
/** we need this for @timestamp data */
browserFields: BrowserFields;
/** we need this for @timestamp data */
@ -75,7 +81,7 @@ export interface SourcererDataView extends KibanaDataView {
*/
export interface SelectedDataView {
browserFields: SourcererDataView['browserFields'];
dataViewId: SourcererDataView['id'];
dataViewId: string | null; // null if legacy pre-8.0 timeline
docValueFields: SourcererDataView['docValueFields'];
/**
* DataViewBase with enhanced index fields used in timelines
@ -88,8 +94,10 @@ export interface SelectedDataView {
/** all active & inactive patterns from SourcererDataView['title'] */
patternList: string[];
runtimeMappings: SourcererDataView['runtimeMappings'];
/** all selected patterns from SourcererScope['selectedPatterns'] */
selectedPatterns: string[];
/** all selected patterns from SourcererScope['selectedPatterns'] */
selectedPatterns: SourcererScope['selectedPatterns'];
// active patterns when dataViewId == null
activePatterns?: string[];
}
/**
@ -97,7 +105,7 @@ export interface SelectedDataView {
*/
export interface SourcererModel {
/** default security-solution data view */
defaultDataView: SourcererDataView & { error?: unknown };
defaultDataView: SourcererDataView & { id: string; error?: unknown };
/** all Kibana data views, including security-solution */
kibanaDataViews: SourcererDataView[];
/** security solution signals index name */
@ -115,8 +123,9 @@ export type SourcererUrlState = Partial<{
export const initSourcererScope: Omit<SourcererScope, 'id'> = {
loading: false,
selectedDataViewId: '',
selectedDataViewId: null,
selectedPatterns: [],
missingPatterns: [],
};
export const initDataView = {
browserFields: EMPTY_BROWSER_FIELDS,

View file

@ -72,13 +72,17 @@ export const sourcererReducer = reducerWithInitialState(initialSourcererState)
}),
},
}))
.case(setSelectedDataView, (state, payload) => ({
...state,
sourcererScopes: {
...state.sourcererScopes,
...validateSelectedPatterns(state, payload),
},
}))
.case(setSelectedDataView, (state, payload) => {
const { shouldValidateSelectedPatterns = true, ...patternsInfo } = payload;
return {
...state,
sourcererScopes: {
...state.sourcererScopes,
...validateSelectedPatterns(state, patternsInfo, shouldValidateSelectedPatterns),
},
};
})
.case(setDataView, (state, dataView) => ({
...state,
...(dataView.id === state.defaultDataView.id

View file

@ -26,8 +26,11 @@ export const sourcererDefaultDataViewSelector = ({
sourcerer,
}: State): SourcererModel['defaultDataView'] => sourcerer.defaultDataView;
export const dataViewSelector = ({ sourcerer }: State, id: string): SourcererDataView =>
sourcerer.kibanaDataViews.find((dataView) => dataView.id === id) ?? sourcerer.defaultDataView;
export const dataViewSelector = (
{ sourcerer }: State,
id: string | null
): SourcererDataView | undefined =>
sourcerer.kibanaDataViews.find((dataView) => dataView.id === id);
export const sourcererScopeIdSelector = (
{ sourcerer }: State,
@ -54,29 +57,48 @@ export const sourcererDataViewSelector = () =>
createSelector(dataViewSelector, (dataView) => dataView);
export interface SourcererScopeSelector extends Omit<SourcererModel, 'sourcererScopes'> {
sourcererDataView: SourcererDataView;
selectedDataView: SourcererDataView | undefined;
sourcererScope: SourcererScope;
}
export const getSourcererScopeSelector = () => {
export const getSourcererDataViewsSelector = () => {
const getKibanaDataViewsSelector = kibanaDataViewsSelector();
const getDefaultDataViewSelector = defaultDataViewSelector();
const getSignalIndexNameSelector = signalIndexNameSelector();
const getSourcererDataViewSelector = sourcererDataViewSelector();
const getScopeSelector = scopeIdSelector();
return (state: State, scopeId: SourcererScopeName): SourcererScopeSelector => {
return (state: State): Omit<SourcererModel, 'sourcererScopes'> => {
const kibanaDataViews = getKibanaDataViewsSelector(state);
const defaultDataView = getDefaultDataViewSelector(state);
const signalIndexName = getSignalIndexNameSelector(state);
const scope = getScopeSelector(state, scopeId);
const sourcererDataView = getSourcererDataViewSelector(state, scope.selectedDataViewId);
return {
defaultDataView,
kibanaDataViews,
signalIndexName,
sourcererDataView,
};
};
};
/**
* Attn Future Developer
* Access sourcererScope.selectedPatterns from
* hook useSourcererDataView in `common/containers/sourcerer/index`
* in order to get exclude patterns for searches
* Access sourcererScope.selectedPatterns
* from this function for display purposes only
* */
export const getSourcererScopeSelector = () => {
const getDataViewsSelector = getSourcererDataViewsSelector();
const getSourcererDataViewSelector = sourcererDataViewSelector();
const getScopeSelector = scopeIdSelector();
return (state: State, scopeId: SourcererScopeName): SourcererScopeSelector => {
const dataViews = getDataViewsSelector(state);
const scope = getScopeSelector(state, scopeId);
const selectedDataView = getSourcererDataViewSelector(state, scope.selectedDataViewId);
return {
...dataViews,
selectedDataView,
sourcererScope: scope,
};
};

View file

@ -140,7 +140,7 @@ describe('alert actions', () => {
],
defaultColumns: defaultHeaders,
dataProviders: [],
dataViewId: '',
dataViewId: null,
dateRange: {
end: '2018-11-05T19:03:25.937Z',
start: '2018-11-05T18:58:25.937Z',

View file

@ -10,8 +10,7 @@ import { MemoryRouter } from 'react-router-dom';
import useResizeObserver from 'use-resize-observer/polyfilled';
import '../../../common/mock/match_media';
import { mockIndexPattern } from '../../../common/mock/index_pattern';
import { TestProviders } from '../../../common/mock/test_providers';
import { mockIndexPattern, TestProviders } from '../../../common/mock';
import { HostDetailsTabs } from './details_tabs';
import { HostDetailsTabsProps, SetAbsoluteRangeDatePicker } from './types';
import { hostDetailsPagePath } from '../types';
@ -25,7 +24,7 @@ jest.mock('../../../common/lib/kibana');
jest.mock('../../../common/components/url_state/normalize_time_range.ts');
jest.mock('../../../common/containers/source', () => ({
useWithSource: jest.fn().mockReturnValue({ indicesExist: true, indexPattern: mockIndexPattern }),
useFetchIndex: () => [false, { indicesExist: true, indexPatterns: mockIndexPattern }],
}));
jest.mock('../../../common/containers/use_global_time', () => ({

View file

@ -42,7 +42,13 @@ jest.mock('../../../common/lib/kibana', () => {
}),
};
});
jest.mock('../../../common/containers/sourcerer', () => {
return {
useSourcererDataView: () => ({
selectedPatterns: ['filebeat-*', 'packetbeat-*'],
}),
};
});
jest.mock('./index_patterns_missing_prompt', () => {
return {
IndexPatternsMissingPrompt: jest.fn(() => <div data-test-subj="IndexPatternsMissingPrompt" />),
@ -56,7 +62,6 @@ describe('EmbeddedMapComponent', () => {
{ id: '6f1eeb50-023d-11eb-bcb6-6ba0578012a9', title: 'filebeat-*' },
{ id: '28995490-023d-11eb-bcb6-6ba0578012a9', title: 'auditbeat-*' },
],
sourcererScope: { selectedPatterns: ['filebeat-*', 'packetbeat-*'] },
};
const mockCreateEmbeddable = {
destroyed: false,

View file

@ -32,6 +32,7 @@ import { getLayerList } from './map_config';
import { sourcererSelectors } from '../../../common/store/sourcerer';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
interface EmbeddableMapProps {
maintainRatio?: boolean;
@ -97,12 +98,15 @@ export const EmbeddedMapComponent = ({
const [, dispatchToaster] = useStateToaster();
const sourcererScopeSelector = useMemo(() => sourcererSelectors.getSourcererScopeSelector(), []);
const { kibanaDataViews, sourcererScope }: sourcererSelectors.SourcererScopeSelector =
useDeepEqualSelector((state) => sourcererScopeSelector(state, SourcererScopeName.default));
const getDataViewsSelector = useMemo(
() => sourcererSelectors.getSourcererDataViewsSelector(),
[]
);
const { kibanaDataViews } = useDeepEqualSelector((state) => getDataViewsSelector(state));
const { selectedPatterns } = useSourcererDataView(SourcererScopeName.default);
const [mapIndexPatterns, setMapIndexPatterns] = useState(
kibanaDataViews.filter((dataView) => sourcererScope.selectedPatterns.includes(dataView.title))
kibanaDataViews.filter((dataView) => selectedPatterns.includes(dataView.title))
);
// This portalNode provided by react-reverse-portal allows us re-parent the MapToolTip within our
@ -116,7 +120,7 @@ export const EmbeddedMapComponent = ({
useEffect(() => {
setMapIndexPatterns((prevMapIndexPatterns) => {
const newIndexPatterns = kibanaDataViews.filter((dataView) =>
sourcererScope.selectedPatterns.includes(dataView.title)
selectedPatterns.includes(dataView.title)
);
if (!deepEqual(newIndexPatterns, prevMapIndexPatterns)) {
if (newIndexPatterns.length === 0) {
@ -126,7 +130,7 @@ export const EmbeddedMapComponent = ({
}
return prevMapIndexPatterns;
});
}, [kibanaDataViews, sourcererScope.selectedPatterns]);
}, [kibanaDataViews, selectedPatterns]);
// Initial Load useEffect
useEffect(() => {
@ -159,7 +163,7 @@ export const EmbeddedMapComponent = ({
}
}
}
if (embeddable == null && sourcererScope.selectedPatterns.length > 0) {
if (embeddable == null && selectedPatterns.length > 0) {
setupEmbeddable();
}
@ -175,7 +179,7 @@ export const EmbeddedMapComponent = ({
query,
portalNode,
services.embeddable,
sourcererScope.selectedPatterns,
selectedPatterns,
setQuery,
startDate,
]);

View file

@ -114,11 +114,14 @@ export const useCreateFieldButton = (
timelineId: TimelineId
) => {
const scopeIdSelector = useMemo(() => sourcererSelectors.scopeIdSelector(), []);
const { selectedDataViewId } = useDeepEqualSelector((state) =>
const { missingPatterns, selectedDataViewId } = useDeepEqualSelector((state) =>
scopeIdSelector(state, sourcererScope)
);
const createFieldComponent = useMemo(() => {
return useMemo(() => {
if (selectedDataViewId == null || missingPatterns.length > 0) {
return;
}
// It receives onClick props from field browser in order to close the modal.
const CreateFieldButtonComponent: CreateFieldComponentType = ({ onClick }) => (
<CreateFieldButton
@ -129,7 +132,5 @@ export const useCreateFieldButton = (
);
return CreateFieldButtonComponent;
}, [selectedDataViewId, timelineId]);
return createFieldComponent;
}, [missingPatterns.length, selectedDataViewId, timelineId]);
};

View file

@ -12,7 +12,11 @@ import { waitFor } from '@testing-library/react';
import { AddTimelineButton } from './';
import { useKibana } from '../../../../common/lib/kibana';
import { TimelineId } from '../../../../../common/types/timeline';
import { mockOpenTimelineQueryResults, TestProviders } from '../../../../common/mock';
import {
mockIndexPattern,
mockOpenTimelineQueryResults,
TestProviders,
} from '../../../../common/mock';
import { getAllTimeline, useGetAllTimeline } from '../../../containers/all';
import { mockHistory, Router } from '../../../../common/mock/router';
@ -62,6 +66,10 @@ jest.mock('../../../../common/components/inspect', () => ({
InspectButtonContainer: jest.fn(({ children }) => <div>{children}</div>),
}));
jest.mock('../../../../common/containers/source', () => ({
useFetchIndex: () => [false, { indicesExist: true, indexPatterns: mockIndexPattern }],
}));
describe('AddTimelineButton', () => {
let wrapper: ReactWrapper;
const props = {

View file

@ -417,7 +417,6 @@ export const dispatchUpdateTimeline =
id: SourcererScopeName.timeline,
selectedDataViewId: timeline.dataViewId,
selectedPatterns: timeline.indexNames,
eventType: timeline.eventType,
})
);
}

View file

@ -112,6 +112,10 @@ jest.mock('../../../../common/lib/helpers/scheduler', () => ({
maxDelay: () => 3000,
}));
jest.mock('../../create_field_button', () => ({
useCreateFieldButton: () => <></>,
}));
describe('Body', () => {
const mount = useMountAppended();
const mockRefetch = jest.fn();

View file

@ -180,6 +180,7 @@ describe('Timeline', () => {
loading: true,
indexPattern: {},
selectedPatterns: [],
missingPatterns: [],
});
const wrapper = mount(
<TestProviders>

View file

@ -14,18 +14,23 @@ import '../../../common/mock/match_media';
import { mockBrowserFields, mockDocValueFields } from '../../../common/containers/source/mock';
import { TimelineId } from '../../../../common/types/timeline';
import {
createSecuritySolutionStorageMock,
kibanaObservable,
mockGlobalState,
mockIndexNames,
mockIndexPattern,
SUB_PLUGINS_REDUCER,
TestProviders,
} from '../../../common/mock';
import { StatefulTimeline, Props as StatefulTimelineOwnProps } from './index';
import { useTimelineEvents } from '../../containers/index';
import { useTimelineEvents } from '../../containers';
import { DefaultCellRenderer } from './cell_rendering/default_cell_renderer';
import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from './styles';
import { defaultRowRenderers } from './body/renderers';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { createStore } from '../../../common/store';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
jest.mock('../../containers/index', () => ({
useTimelineEvents: jest.fn(),
@ -91,6 +96,7 @@ describe('StatefulTimeline', () => {
rowRenderers: defaultRowRenderers,
timelineId: TimelineId.test,
};
const { storage } = createSecuritySolutionStorageMock();
beforeEach(() => {
jest.clearAllMocks();
@ -114,25 +120,6 @@ describe('StatefulTimeline', () => {
</TestProviders>
);
expect(wrapper.find('[data-test-subj="timeline"]')).toBeTruthy();
expect(mockDispatch).toBeCalledTimes(1);
});
test('data view updates, updates timeline', () => {
mockUseSourcererDataView.mockReturnValue({ ...mockDataView, selectedPatterns: mockIndexNames });
mount(
<TestProviders>
<StatefulTimeline {...props} />
</TestProviders>
);
expect(mockDispatch).toBeCalledTimes(2);
expect(mockDispatch).toHaveBeenNthCalledWith(2, {
payload: {
id: 'test',
dataViewId: mockDataView.dataViewId,
indexNames: mockIndexNames,
},
type: 'x-pack/security_solution/local/timeline/UPDATE_DATA_VIEW',
});
});
test(`it add attribute data-timeline-id in ${SELECTOR_TIMELINE_GLOBAL_CONTAINER}`, () => {
@ -150,4 +137,90 @@ describe('StatefulTimeline', () => {
.exists()
).toEqual(true);
});
test('on create timeline and timeline savedObjectId: null, sourcerer does not update timeline', () => {
mount(
<TestProviders>
<StatefulTimeline {...props} />
</TestProviders>
);
expect(mockDispatch).toBeCalledTimes(1);
expect(mockDispatch.mock.calls[0][0].payload.indexNames).toEqual(
mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline].selectedPatterns
);
});
test('sourcerer data view updates and timeline already matches the data view, no updates', () => {
mount(
<TestProviders
store={createStore(
{
...mockGlobalState,
timeline: {
...mockGlobalState.timeline,
timelineById: {
test: {
...mockGlobalState.timeline.timelineById.test,
savedObjectId: 'definitely-not-null',
indexNames:
mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline]
.selectedPatterns,
},
},
},
},
SUB_PLUGINS_REDUCER,
kibanaObservable,
storage
)}
>
<StatefulTimeline {...props} />
</TestProviders>
);
expect(mockDispatch).not.toHaveBeenCalled();
});
test('sourcerer data view updates, update timeline data view', () => {
mount(
<TestProviders
store={createStore(
{
...mockGlobalState,
timeline: {
...mockGlobalState.timeline,
timelineById: {
test: {
...mockGlobalState.timeline.timelineById.test,
savedObjectId: 'definitely-not-null',
},
},
},
sourcerer: {
...mockGlobalState.sourcerer,
sourcererScopes: {
...mockGlobalState.sourcerer.sourcererScopes,
[SourcererScopeName.timeline]: {
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
selectedPatterns: mockIndexNames,
},
},
},
},
SUB_PLUGINS_REDUCER,
kibanaObservable,
storage
)}
>
<StatefulTimeline {...props} />
</TestProviders>
);
expect(mockDispatch).toBeCalledTimes(1);
expect(mockDispatch).toHaveBeenNthCalledWith(1, {
payload: {
id: 'test',
dataViewId: mockDataView.dataViewId,
indexNames: mockIndexNames,
},
type: 'x-pack/security_solution/local/timeline/UPDATE_DATA_VIEW',
});
});
});

View file

@ -16,7 +16,6 @@ import { timelineActions, timelineSelectors } from '../../store/timeline';
import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
import { defaultHeaders } from './body/column_headers/default_headers';
import { CellValueElementProps } from './cell_rendering';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { FlyoutHeader, FlyoutHeaderPanel } from '../flyout/header';
import { TimelineType, TimelineId, RowRenderer } from '../../../../common/types/timeline';
@ -29,6 +28,7 @@ import { HideShowContainer, TimelineContainer } from './styles';
import { useTimelineFullScreen } from '../../../common/containers/use_full_screen';
import { EXIT_FULL_SCREEN_CLASS_NAME } from '../../../common/components/exit_full_screen';
import { useResolveConflict } from '../../../common/hooks/use_resolve_conflict';
import { sourcererSelectors } from '../../../common/store';
const TimelineTemplateBadge = styled.div`
background: ${({ theme }) => theme.eui.euiColorVis3_behindText};
@ -62,11 +62,14 @@ const StatefulTimelineComponent: React.FC<Props> = ({
const dispatch = useDispatch();
const containerElement = useRef<HTMLDivElement | null>(null);
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const { dataViewId, selectedPatterns } = useSourcererDataView(SourcererScopeName.timeline);
const scopeIdSelector = useMemo(() => sourcererSelectors.scopeIdSelector(), []);
const {
dataViewId: dataViewIdCurrent,
indexNames: selectedPatternsCurrent,
selectedPatterns: selectedPatternsSourcerer,
selectedDataViewId: selectedDataViewIdSourcerer,
} = useDeepEqualSelector((state) => scopeIdSelector(state, SourcererScopeName.timeline));
const {
dataViewId: selectedDataViewIdTimeline,
indexNames: selectedPatternsTimeline,
graphEventId,
savedObjectId,
timelineType,
@ -77,6 +80,7 @@ const StatefulTimelineComponent: React.FC<Props> = ({
getTimeline(state, timelineId) ?? timelineDefaults
)
);
const { timelineFullScreen } = useTimelineFullScreen();
useEffect(() => {
@ -85,8 +89,8 @@ const StatefulTimelineComponent: React.FC<Props> = ({
timelineActions.createTimeline({
id: timelineId,
columns: defaultHeaders,
dataViewId,
indexNames: selectedPatterns,
dataViewId: selectedDataViewIdSourcerer,
indexNames: selectedPatternsSourcerer,
expandedDetail: activeTimeline.getExpandedDetail(),
show: false,
})
@ -95,37 +99,40 @@ const StatefulTimelineComponent: React.FC<Props> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onDataViewChange = useCallback(() => {
const onSourcererChange = useCallback(() => {
if (
// timeline not initialized, so this must be initial state and not user change
!savedObjectId ||
selectedDataViewIdSourcerer == null ||
// initial state will get set on create
(dataViewIdCurrent === '' && selectedPatternsCurrent.length === 0) ||
(selectedDataViewIdTimeline === null && selectedPatternsTimeline.length === 0) ||
// don't update if no change
(dataViewIdCurrent === dataViewId &&
selectedPatternsCurrent.sort().join() === selectedPatterns.sort().join())
(selectedDataViewIdTimeline === selectedDataViewIdSourcerer &&
selectedPatternsTimeline.sort().join() === selectedPatternsSourcerer.sort().join())
) {
return;
}
dispatch(
timelineActions.updateDataView({
dataViewId: selectedDataViewIdSourcerer,
id: timelineId,
dataViewId,
indexNames: selectedPatterns,
indexNames: selectedPatternsSourcerer,
})
);
}, [
dataViewId,
dataViewIdCurrent,
dispatch,
selectedPatterns,
selectedPatternsCurrent,
savedObjectId,
selectedDataViewIdSourcerer,
selectedDataViewIdTimeline,
selectedPatternsSourcerer,
selectedPatternsTimeline,
timelineId,
]);
useEffect(() => {
onDataViewChange();
onSourcererChange();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataViewId, selectedPatterns]);
}, [selectedDataViewIdSourcerer, selectedPatternsSourcerer]);
const onSkipFocusBeforeEventsTable = useCallback(() => {
const exitFullScreenButton = containerElement.current?.querySelector<HTMLButtonElement>(

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { EuiButton, EuiButtonEmpty } from '@elastic/eui';
@ -19,10 +19,9 @@ import {
} from '../../../../../common/types/timeline';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { inputsActions, inputsSelectors } from '../../../../common/store/inputs';
import { sourcererActions } from '../../../../common/store/sourcerer';
import { sourcererActions, sourcererSelectors } from '../../../../common/store/sourcerer';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { appActions } from '../../../../common/store/app';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
interface Props {
timelineId?: string;
@ -32,7 +31,9 @@ interface Props {
export const useCreateTimeline = ({ timelineId, timelineType, closeGearMenu }: Props) => {
const dispatch = useDispatch();
const { dataViewId, selectedPatterns } = useSourcererDataView(SourcererScopeName.timeline);
const defaultDataViewSelector = useMemo(() => sourcererSelectors.defaultDataViewSelector(), []);
const { id: dataViewId, patternList: selectedPatterns } =
useDeepEqualSelector(defaultDataViewSelector);
const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen();
const globalTimeRange = useDeepEqualSelector(inputsSelectors.globalTimeRangeSelector);

View file

@ -191,6 +191,7 @@ describe('Timeline', () => {
loading: true,
indexPattern: {},
selectedPatterns: [],
missingPatterns: [],
});
const wrapper = mount(
<TestProviders>

View file

@ -190,6 +190,8 @@ export const QueryTabContentComponent: React.FC<Props> = ({
loading: loadingSourcerer,
indexPattern,
runtimeMappings,
// important to get selectedPatterns from useSourcererDataView
// in order to include the exclude filters in the search that are not stored in the timeline
selectedPatterns,
} = useSourcererDataView(SourcererScopeName.timeline);
const { uiSettings } = useKibana().services;

View file

@ -22,7 +22,7 @@ export const timelineDefaults: SubsetTimelineModel &
documentType: '',
defaultColumns: defaultHeaders,
dataProviders: [],
dataViewId: '',
dataViewId: null,
dateRange: { start, end },
deletedEventIds: [],
description: '',

View file

@ -89,7 +89,7 @@ describe('Epic Timeline', () => {
],
},
],
dataViewId: '',
dataViewId: null,
deletedEventIds: [],
description: '',
documentType: '',
@ -239,7 +239,7 @@ describe('Epic Timeline', () => {
},
},
],
dataViewId: '',
dataViewId: null,
dateRange: {
end: '2019-10-31T21:06:27.644Z',
start: '2019-10-30T21:06:27.644Z',

View file

@ -85,7 +85,7 @@ const basicTimeline: TimelineModel = {
columns: [],
defaultColumns: [],
dataProviders: [{ ...basicDataProvider }],
dataViewId: '',
dataViewId: null,
dateRange: {
start: '2020-07-07T08:20:18.966Z',
end: '2020-07-08T08:20:18.966Z',
@ -220,7 +220,7 @@ describe('Timeline', () => {
const update = addNewTimeline({
id: 'bar',
columns: defaultHeaders,
dataViewId: '',
dataViewId: null,
indexNames: [],
timelineById: timelineByIdMock,
timelineType: TimelineType.default,
@ -232,7 +232,7 @@ describe('Timeline', () => {
const update = addNewTimeline({
id: 'bar',
columns: timelineDefaults.columns,
dataViewId: '',
dataViewId: null,
indexNames: [],
timelineById: timelineByIdMock,
timelineType: TimelineType.default,
@ -250,7 +250,7 @@ describe('Timeline', () => {
const update = addNewTimeline({
id: 'bar',
columns: defaultHeaders,
dataViewId: '',
dataViewId: null,
indexNames: [],
timelineById: timelineByIdMock,
timelineType: TimelineType.default,

View file

@ -163,7 +163,7 @@ export const mockTemplate = {
and: [],
},
],
dataViewId: '',
dataViewId: null,
description: '',
eventType: 'all',
excludedRowRendererIds: [],

View file

@ -28,7 +28,10 @@ export const FieldBrowserWrappedComponent = (props: FieldBrowserWrappedComponent
return (
<Provider store={store}>
<I18nProvider>
<StatefulFieldsBrowser {...fieldsBrowseProps} />
<StatefulFieldsBrowser
data-test-ref="steph-loves-this-fields-browser"
{...fieldsBrowseProps}
/>
</I18nProvider>
</Provider>
);

View file

@ -9,10 +9,14 @@ import { mount } from 'enzyme';
import React from 'react';
import { TestProviders, mockBrowserFields, defaultHeaders } from '../../../../mock';
import { mockGlobalState } from '../../../../mock/global_state';
import { tGridActions } from '../../../../store/t_grid';
import { FieldsBrowser } from './field_browser';
import { createStore, State } from '../../../../types';
import { createSecuritySolutionStorageMock } from '../../../../mock/mock_local_storage';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
@ -21,26 +25,30 @@ jest.mock('react-redux', () => {
useDispatch: () => mockDispatch,
};
});
const timelineId = 'test';
const onHide = jest.fn();
const testProps = {
columnHeaders: [],
browserFields: mockBrowserFields,
filteredBrowserFields: mockBrowserFields,
searchInput: '',
isSearching: false,
onCategorySelected: jest.fn(),
onHide,
onSearchInputChange: jest.fn(),
restoreFocusTo: React.createRef<HTMLButtonElement>(),
selectedCategoryId: '',
timelineId,
};
const { storage } = createSecuritySolutionStorageMock();
describe('FieldsBrowser', () => {
const timelineId = 'test';
beforeEach(() => {
jest.resetAllMocks();
});
test('it renders the Close button', () => {
const wrapper = mount(
<TestProviders>
<FieldsBrowser
columnHeaders={[]}
browserFields={mockBrowserFields}
filteredBrowserFields={mockBrowserFields}
searchInput={''}
isSearching={false}
onCategorySelected={jest.fn()}
onHide={jest.fn()}
onSearchInputChange={jest.fn()}
restoreFocusTo={React.createRef<HTMLButtonElement>()}
selectedCategoryId={''}
timelineId={timelineId}
/>
<FieldsBrowser {...testProps} />
</TestProviders>
);
@ -48,22 +56,9 @@ describe('FieldsBrowser', () => {
});
test('it invokes the Close button', () => {
const onHide = jest.fn();
const wrapper = mount(
<TestProviders>
<FieldsBrowser
columnHeaders={[]}
browserFields={mockBrowserFields}
filteredBrowserFields={mockBrowserFields}
searchInput={''}
isSearching={false}
onCategorySelected={jest.fn()}
onHide={onHide}
onSearchInputChange={jest.fn()}
restoreFocusTo={React.createRef<HTMLButtonElement>()}
selectedCategoryId={''}
timelineId={timelineId}
/>
<FieldsBrowser {...testProps} />
</TestProviders>
);
@ -74,19 +69,7 @@ describe('FieldsBrowser', () => {
test('it renders the Reset Fields button', () => {
const wrapper = mount(
<TestProviders>
<FieldsBrowser
columnHeaders={[]}
browserFields={mockBrowserFields}
filteredBrowserFields={mockBrowserFields}
searchInput={''}
isSearching={false}
onCategorySelected={jest.fn()}
onHide={jest.fn()}
onSearchInputChange={jest.fn()}
restoreFocusTo={React.createRef<HTMLButtonElement>()}
selectedCategoryId={''}
timelineId={timelineId}
/>
<FieldsBrowser {...testProps} />
</TestProviders>
);
@ -123,23 +106,9 @@ describe('FieldsBrowser', () => {
});
test('it invokes onHide when the user clicks the Reset Fields button', () => {
const onHide = jest.fn();
const wrapper = mount(
<TestProviders>
<FieldsBrowser
columnHeaders={[]}
browserFields={mockBrowserFields}
filteredBrowserFields={mockBrowserFields}
searchInput={''}
isSearching={false}
onCategorySelected={jest.fn()}
onHide={onHide}
onSearchInputChange={jest.fn()}
restoreFocusTo={React.createRef<HTMLButtonElement>()}
selectedCategoryId={''}
timelineId={timelineId}
/>
<FieldsBrowser {...testProps} />
</TestProviders>
);
@ -151,19 +120,7 @@ describe('FieldsBrowser', () => {
test('it renders the search', () => {
const wrapper = mount(
<TestProviders>
<FieldsBrowser
columnHeaders={[]}
browserFields={mockBrowserFields}
filteredBrowserFields={mockBrowserFields}
searchInput={''}
isSearching={false}
onCategorySelected={jest.fn()}
onHide={jest.fn()}
onSearchInputChange={jest.fn()}
restoreFocusTo={React.createRef<HTMLButtonElement>()}
selectedCategoryId={''}
timelineId={timelineId}
/>
<FieldsBrowser {...testProps} />
</TestProviders>
);
@ -173,19 +130,7 @@ describe('FieldsBrowser', () => {
test('it renders the categories pane', () => {
const wrapper = mount(
<TestProviders>
<FieldsBrowser
columnHeaders={[]}
browserFields={mockBrowserFields}
filteredBrowserFields={mockBrowserFields}
searchInput={''}
isSearching={false}
onCategorySelected={jest.fn()}
onHide={jest.fn()}
onSearchInputChange={jest.fn()}
restoreFocusTo={React.createRef<HTMLButtonElement>()}
selectedCategoryId={''}
timelineId={timelineId}
/>
<FieldsBrowser {...testProps} />
</TestProviders>
);
@ -195,19 +140,7 @@ describe('FieldsBrowser', () => {
test('it renders the fields pane', () => {
const wrapper = mount(
<TestProviders>
<FieldsBrowser
columnHeaders={[]}
browserFields={mockBrowserFields}
filteredBrowserFields={mockBrowserFields}
searchInput={''}
isSearching={false}
onCategorySelected={jest.fn()}
onHide={jest.fn()}
onSearchInputChange={jest.fn()}
restoreFocusTo={React.createRef<HTMLButtonElement>()}
selectedCategoryId={''}
timelineId={timelineId}
/>
<FieldsBrowser {...testProps} />
</TestProviders>
);
@ -217,19 +150,7 @@ describe('FieldsBrowser', () => {
test('focuses the search input when the component mounts', () => {
const wrapper = mount(
<TestProviders>
<FieldsBrowser
columnHeaders={[]}
browserFields={mockBrowserFields}
filteredBrowserFields={mockBrowserFields}
searchInput={''}
isSearching={false}
onCategorySelected={jest.fn()}
onHide={jest.fn()}
onSearchInputChange={jest.fn()}
restoreFocusTo={React.createRef<HTMLButtonElement>()}
selectedCategoryId={''}
timelineId={timelineId}
/>
<FieldsBrowser {...testProps} />
</TestProviders>
);
@ -245,19 +166,7 @@ describe('FieldsBrowser', () => {
const wrapper = mount(
<TestProviders>
<FieldsBrowser
columnHeaders={[]}
browserFields={mockBrowserFields}
filteredBrowserFields={mockBrowserFields}
searchInput={''}
isSearching={false}
onCategorySelected={jest.fn()}
onHide={jest.fn()}
onSearchInputChange={onSearchInputChange}
restoreFocusTo={React.createRef<HTMLButtonElement>()}
selectedCategoryId={''}
timelineId={timelineId}
/>
<FieldsBrowser {...testProps} onSearchInputChange={onSearchInputChange} />
</TestProviders>
);
@ -272,25 +181,36 @@ describe('FieldsBrowser', () => {
expect(onSearchInputChange).toBeCalledWith(inputText);
});
test('it renders the CreateField button when createFieldComponent is provided', () => {
test('does not render the CreateField button when createFieldComponent is provided without a dataViewId', () => {
const MyTestComponent = () => <div>{'test'}</div>;
const wrapper = mount(
<TestProviders>
<FieldsBrowser
columnHeaders={[]}
browserFields={mockBrowserFields}
filteredBrowserFields={mockBrowserFields}
searchInput={''}
isSearching={false}
onCategorySelected={jest.fn()}
onHide={jest.fn()}
onSearchInputChange={jest.fn()}
restoreFocusTo={React.createRef<HTMLButtonElement>()}
selectedCategoryId={''}
timelineId={timelineId}
createFieldComponent={MyTestComponent}
/>
<FieldsBrowser {...testProps} createFieldComponent={MyTestComponent} />
</TestProviders>
);
expect(wrapper.find(MyTestComponent).exists()).toBeFalsy();
});
test('it renders the CreateField button when createFieldComponent is provided with a dataViewId', () => {
const state: State = {
...mockGlobalState,
timelineById: {
...mockGlobalState.timelineById,
test: {
...mockGlobalState.timelineById.test,
dataViewId: 'security-solution-default',
},
},
};
const store = createStore(state, storage);
const MyTestComponent = () => <div>{'test'}</div>;
const wrapper = mount(
<TestProviders store={store}>
<FieldsBrowser {...testProps} createFieldComponent={MyTestComponent} />
</TestProviders>
);

View file

@ -136,7 +136,9 @@ const FieldsBrowserComponent: React.FC<Props> = ({
}, [onHide, restoreFocusTo]);
const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []);
const { defaultColumns } = useDeepEqualSelector((state) => getManageTimeline(state, timelineId));
const { dataViewId, defaultColumns } = useDeepEqualSelector((state) =>
getManageTimeline(state, timelineId)
);
const onResetColumns = useCallback(() => {
onUpdateColumns(defaultColumns);
@ -209,7 +211,9 @@ const FieldsBrowserComponent: React.FC<Props> = ({
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{CreateField && <CreateField onClick={onHide} />}
{CreateField && dataViewId != null && dataViewId.length > 0 && (
<CreateField onClick={onHide} />
)}
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -18,7 +18,7 @@ export const mockGlobalState: TimelineState = {
end: '2020-07-08T08:20:18.966Z',
},
dataProviders: [],
dataViewId: '',
dataViewId: null,
deletedEventIds: [],
excludedRowRendererIds: [],
expandedDetail: {},

View file

@ -1552,7 +1552,7 @@ export const mockTgridModel: TGridModel = {
},
],
dataProviders: [],
dataViewId: '',
dataViewId: null,
defaultColumns: [],
queryFields: [],
dateRange: {

View file

@ -63,7 +63,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [
export const tGridDefaults: SubsetTGridModel = {
columns: defaultHeaders,
defaultColumns: defaultHeaders,
dataViewId: '',
dataViewId: null,
dateRange: { start: '', end: '' },
deletedEventIds: [],
excludedRowRendererIds: [],

View file

@ -50,7 +50,7 @@ export interface TGridModel extends TGridModelSettings {
end: string;
};
/** Kibana data view id **/
dataViewId: string;
dataViewId: string | null; // null if legacy pre-8.0 timeline
/** Events to not be rendered **/
deletedEventIds: string[];
/** This holds the view information for the flyout when viewing timeline in a consuming view (i.e. hosts page) or the side panel in the primary timeline view */

View file

@ -40,6 +40,7 @@ export const getManageTimelineById = () =>
createSelector(
selectTGridById,
({
dataViewId,
documentType,
defaultColumns,
isLoading,
@ -50,6 +51,7 @@ export const getManageTimelineById = () =>
selectAll,
title,
}) => ({
dataViewId,
documentType,
defaultColumns,
isLoading,