[Security Solution] [Analyzer] Add independent date and index/dataview selector (#176364)

## Summary

Adds a date picker and index pattern selection component (the current
security solution wide version, sourcerer) to analyzer, independent from
the table that the analyzer was opened from. Date range will default to
1) the current range on the page, if 0 results are found in the tree api
request, 2) We retry the request with unbounded time, and set the date
picker to that value if that succeeds. If at any point in time when the
request is pending for either of those 2 requests, and the user uses the
new date picker to select a time, the previous request is cancelled and
a new one made with the user supplied time. Previous date picker values
are cleared on close. Index selection is updated when the user selects
the apply button in the sourcerer popover, and any modifications are
persisted across multiple different instances of analyzer.



![analyzer_date_index_picker](983b4401-13c0-4067-befa-6e783846108c)



### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Kevin Qualters 2024-02-14 14:31:30 -05:00 committed by GitHub
parent 5e56ad222a
commit 512967cc44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1304 additions and 798 deletions

View file

@ -192,6 +192,12 @@ export const allowedExperimentalValues = Object.freeze({
*/
timelineEsqlTabDisabled: false,
/*
* Disables date pickers and sourcerer in analyzer if needed.
*
*/
analyzerDatePickersAndSourcererDisabled: false,
/**
* Enables per-field rule diffs tab in the prebuilt rule upgrade flyout
*

View file

@ -659,4 +659,13 @@ describe('Sourcerer component', () => {
);
expect(pollForSignalIndexMock).toHaveBeenCalledTimes(1);
});
it('renders without a popover when analyzer is the scope', () => {
mount(
<TestProviders>
<Sourcerer scope={sourcererModel.SourcererScopeName.analyzer} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="sourcerer-popover"]`).exists()).toBeFalsy();
});
});

View file

@ -8,7 +8,6 @@
import {
EuiComboBox,
EuiForm,
EuiOutsideClickDetector,
EuiPopover,
EuiPopoverTitle,
EuiSpacer,
@ -23,6 +22,7 @@ import type { sourcererModel } from '../../store/sourcerer';
import { sourcererActions, sourcererSelectors } from '../../store/sourcerer';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import type { SourcererUrlState } from '../../store/sourcerer/model';
import type { ModifiedTypes } from './use_pick_index_patterns';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { usePickIndexPatterns } from './use_pick_index_patterns';
import { FormRow, PopoverContent, StyledButtonEmpty, StyledFormRow } from './helpers';
@ -39,6 +39,89 @@ export interface SourcererComponentProps {
scope: sourcererModel.SourcererScopeName;
}
interface SourcererPopoverProps {
showSourcerer: boolean;
activePatterns?: string[];
isTriggerDisabled: boolean;
isModified: ModifiedTypes;
isOnlyDetectionAlerts: boolean;
isPopoverOpen: boolean;
loading: boolean;
setPopoverIsOpenCb: () => void;
selectedPatterns: string[];
signalIndexName: string | null;
handleClosePopOver: () => void;
isTimelineSourcerer: boolean;
selectedDataViewId: string | null;
sourcererMissingPatterns: string[];
onUpdateDetectionAlertsChecked: () => void;
handleOutsideClick: () => void;
setMissingPatterns: (missingPatterns: string[]) => void;
setDataViewId: (dataViewId: string | null) => void;
scopeId: sourcererModel.SourcererScopeName;
children: React.ReactNode;
}
const SourcererPopover = React.memo<SourcererPopoverProps>(
({
showSourcerer,
activePatterns,
isTriggerDisabled,
isModified,
isOnlyDetectionAlerts,
isPopoverOpen,
loading,
setPopoverIsOpenCb,
selectedPatterns,
signalIndexName,
handleClosePopOver,
isTimelineSourcerer,
selectedDataViewId,
sourcererMissingPatterns,
onUpdateDetectionAlertsChecked,
setMissingPatterns,
setDataViewId,
scopeId,
children,
}) => {
if (!showSourcerer) {
return null;
} else if (scopeId === SourcererScopeName.analyzer) {
return <>{children}</>;
} else {
return (
<EuiPopover
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"
isOpen={isPopoverOpen}
ownFocus
repositionOnScroll
>
<>{children}</>
</EuiPopover>
);
}
}
);
SourcererPopover.displayName = 'SourcererPopover';
export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }) => {
const dispatch = useDispatch();
const isDetectionsSourcerer = scopeId === SourcererScopeName.detections;
@ -57,7 +140,6 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
missingPatterns: sourcererMissingPatterns,
},
} = useDeepEqualSelector((state) => sourcererScopeSelector(state, scopeId));
const { pollForSignalIndex } = useSignalHelpers();
useEffect(() => {
@ -188,9 +270,18 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
}, [dispatchChangeDataView, dataViewId, selectedOptions]);
const handleClosePopOver = useCallback(() => {
handleOutsideClick();
setDataViewId(selectedDataViewId);
setMissingPatterns(sourcererMissingPatterns);
onUpdateDetectionAlertsChecked();
setPopoverIsOpen(false);
setExpandAdvancedOptions(false);
}, []);
}, [
handleOutsideClick,
onUpdateDetectionAlertsChecked,
selectedDataViewId,
sourcererMissingPatterns,
]);
// deprecated timeline index pattern handlers
const onContinueUpdateDeprecated = useCallback(() => {
@ -247,137 +338,126 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
setDataViewId(selectedDataViewId);
}, [selectedDataViewId]);
const onOutsideClick = useCallback(() => {
setDataViewId(selectedDataViewId);
setMissingPatterns(sourcererMissingPatterns);
onUpdateDetectionAlertsChecked();
handleOutsideClick();
}, [
handleOutsideClick,
onUpdateDetectionAlertsChecked,
selectedDataViewId,
sourcererMissingPatterns,
]);
const onExpandAdvancedOptionsClicked = useCallback(() => {
setExpandAdvancedOptions((prevState) => !prevState);
}, []);
// always show sourcerer in timeline
return indicesExist || scopeId === SourcererScopeName.timeline ? (
<EuiPopover
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"
isOpen={isPopoverOpen}
ownFocus
repositionOnScroll
const showSourcerer = useMemo(() => {
return (
indicesExist || [SourcererScopeName.analyzer, SourcererScopeName.timeline].includes(scopeId)
);
}, [indicesExist, scopeId]);
return (
<SourcererPopover
showSourcerer={showSourcerer}
activePatterns={activePatterns}
isTriggerDisabled={isTriggerDisabled}
isModified={isModified}
isOnlyDetectionAlerts={isOnlyDetectionAlerts}
isPopoverOpen={isPopoverOpen}
isTimelineSourcerer={isTimelineSourcerer}
loading={loading}
handleOutsideClick={handleOutsideClick}
setPopoverIsOpenCb={setPopoverIsOpenCb}
selectedPatterns={selectedPatterns}
signalIndexName={signalIndexName}
handleClosePopOver={handleClosePopOver}
selectedDataViewId={selectedDataViewId}
sourcererMissingPatterns={sourcererMissingPatterns}
onUpdateDetectionAlertsChecked={onUpdateDetectionAlertsChecked}
setMissingPatterns={setMissingPatterns}
setDataViewId={setDataViewId}
scopeId={scopeId}
>
<EuiOutsideClickDetector onOutsideClick={onOutsideClick}>
<PopoverContent>
<EuiPopoverTitle data-test-subj="sourcerer-title">
<>{i18n.SELECT_DATA_VIEW}</>
</EuiPopoverTitle>
<SourcererCallout
isOnlyDetectionAlerts={isOnlyDetectionAlerts}
title={isTimelineSourcerer ? i18n.CALL_OUT_TIMELINE_TITLE : i18n.CALL_OUT_TITLE}
<PopoverContent>
<EuiPopoverTitle data-test-subj="sourcerer-title">
<>{i18n.SELECT_DATA_VIEW}</>
</EuiPopoverTitle>
<SourcererCallout
isOnlyDetectionAlerts={isOnlyDetectionAlerts}
title={isTimelineSourcerer ? i18n.CALL_OUT_TIMELINE_TITLE : i18n.CALL_OUT_TITLE}
/>
<EuiSpacer size="s" />
{(dataViewId === null && isModified === 'deprecated') ||
isModified === 'missingPatterns' ? (
<TemporarySourcerer
activePatterns={activePatterns}
indicesExist={indicesExist}
isModified={isModified}
isShowingUpdateModal={isShowingUpdateModal}
missingPatterns={missingPatterns}
onContinueWithoutUpdate={onContinueUpdateDeprecated}
onDismiss={setPopoverIsOpenCb}
onDismissModal={() => setIsShowingUpdateModal(false)}
onReset={resetDataSources}
onUpdateStepOne={isModified === 'deprecated' ? onUpdateDeprecated : onUpdateDataView}
onUpdateStepTwo={onUpdateDataView}
selectedPatterns={selectedPatterns}
/>
<EuiSpacer size="s" />
{(dataViewId === null && isModified === 'deprecated') ||
isModified === 'missingPatterns' ? (
<TemporarySourcerer
activePatterns={activePatterns}
indicesExist={indicesExist}
isModified={isModified}
isShowingUpdateModal={isShowingUpdateModal}
missingPatterns={missingPatterns}
onContinueWithoutUpdate={onContinueUpdateDeprecated}
onDismiss={setPopoverIsOpenCb}
onDismissModal={() => setIsShowingUpdateModal(false)}
onReset={resetDataSources}
onUpdateStepOne={isModified === 'deprecated' ? onUpdateDeprecated : onUpdateDataView}
onUpdateStepTwo={onUpdateDataView}
selectedPatterns={selectedPatterns}
/>
) : (
<EuiForm component="form">
<>
<AlertsCheckbox
isShow={isTimelineSourcerer}
checked={isOnlyDetectionAlertsChecked}
onChange={onCheckboxChanged}
/>
{dataViewId && (
<StyledFormRow label={i18n.INDEX_PATTERNS_CHOOSE_DATA_VIEW_LABEL}>
<EuiSuperSelect
data-test-subj="sourcerer-select"
isLoading={loadingIndexPatterns}
disabled={isOnlyDetectionAlerts}
fullWidth
onChange={onChangeDataView}
options={dataViewSelectOptions}
placeholder={i18n.INDEX_PATTERNS_CHOOSE_DATA_VIEW_LABEL}
valueOfSelected={dataViewId}
/>
</StyledFormRow>
)}
<EuiSpacer size="m" />
<StyledButtonEmpty
color="text"
data-test-subj="sourcerer-advanced-options-toggle"
iconType={expandAdvancedOptions ? 'arrowDown' : 'arrowRight'}
onClick={onExpandAdvancedOptionsClicked}
>
{i18n.INDEX_PATTERNS_ADVANCED_OPTIONS_TITLE}
</StyledButtonEmpty>
{expandAdvancedOptions && <EuiSpacer size="m" />}
<FormRow
isDisabled={loadingIndexPatterns}
$expandAdvancedOptions={expandAdvancedOptions}
helpText={isOnlyDetectionAlerts ? undefined : i18n.INDEX_PATTERNS_DESCRIPTIONS}
label={i18n.INDEX_PATTERNS_LABEL}
>
<EuiComboBox
data-test-subj="sourcerer-combo-box"
) : (
<EuiForm component="form">
<>
<AlertsCheckbox
isShow={isTimelineSourcerer}
checked={isOnlyDetectionAlertsChecked}
onChange={onCheckboxChanged}
/>
{dataViewId && (
<StyledFormRow label={i18n.INDEX_PATTERNS_CHOOSE_DATA_VIEW_LABEL}>
<EuiSuperSelect
data-test-subj="sourcerer-select"
isLoading={loadingIndexPatterns}
disabled={isOnlyDetectionAlerts}
fullWidth
isDisabled={isOnlyDetectionAlerts || loadingIndexPatterns}
onChange={onChangeIndexPatterns}
options={allOptions}
placeholder={i18n.PICK_INDEX_PATTERNS}
renderOption={renderOption}
selectedOptions={selectedOptions}
onChange={onChangeDataView}
options={dataViewSelectOptions}
placeholder={i18n.INDEX_PATTERNS_CHOOSE_DATA_VIEW_LABEL}
valueOfSelected={dataViewId}
/>
</FormRow>
</StyledFormRow>
)}
<SaveButtons
disableSave={selectedOptions.length === 0}
isShow={!isDetectionsSourcerer}
onReset={resetDataSources}
onSave={handleSaveIndices}
<EuiSpacer size="m" />
<StyledButtonEmpty
color="text"
data-test-subj="sourcerer-advanced-options-toggle"
iconType={expandAdvancedOptions ? 'arrowDown' : 'arrowRight'}
onClick={onExpandAdvancedOptionsClicked}
>
{i18n.INDEX_PATTERNS_ADVANCED_OPTIONS_TITLE}
</StyledButtonEmpty>
{expandAdvancedOptions && <EuiSpacer size="m" />}
<FormRow
isDisabled={loadingIndexPatterns}
$expandAdvancedOptions={expandAdvancedOptions}
helpText={isOnlyDetectionAlerts ? undefined : i18n.INDEX_PATTERNS_DESCRIPTIONS}
label={i18n.INDEX_PATTERNS_LABEL}
>
<EuiComboBox
data-test-subj="sourcerer-combo-box"
fullWidth
isDisabled={isOnlyDetectionAlerts || loadingIndexPatterns}
onChange={onChangeIndexPatterns}
options={allOptions}
placeholder={i18n.PICK_INDEX_PATTERNS}
renderOption={renderOption}
selectedOptions={selectedOptions}
/>
</>
<EuiSpacer size="s" />
</EuiForm>
)}
</PopoverContent>
</EuiOutsideClickDetector>
</EuiPopover>
) : null;
</FormRow>
<SaveButtons
disableSave={selectedOptions.length === 0}
isShow={!isDetectionsSourcerer}
onReset={resetDataSources}
onSave={handleSaveIndices}
/>
</>
<EuiSpacer size="s" />
</EuiForm>
)}
</PopoverContent>
</SourcererPopover>
);
});
Sourcerer.displayName = 'Sourcerer';

View file

@ -67,12 +67,16 @@ export const usePickIndexPatterns = ({
} = useKibana().services;
const isHookAlive = useRef(true);
const [loadingIndexPatterns, setLoadingIndexPatterns] = useState(false);
const alertsOptions = useMemo(
() => (signalIndexName ? patternListToOptions([signalIndexName]) : []),
[signalIndexName]
);
// anything that uses patternListToOptions should be memoized, as it always returns a new array
// TODO: fix that
const signalPatternListToOptions = useMemo(() => {
return signalIndexName ? patternListToOptions([signalIndexName]) : [];
}, [signalIndexName]);
const selectedPatternsAsOptions = useMemo(() => {
return patternListToOptions(selectedPatterns);
}, [selectedPatterns]);
const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>(
isOnlyDetectionAlerts ? alertsOptions : patternListToOptions(selectedPatterns)
isOnlyDetectionAlerts ? signalPatternListToOptions : selectedPatternsAsOptions
);
const [isModified, setIsModified] = useState<ModifiedTypes>(
dataViewId == null ? 'deprecated' : missingPatterns.length > 0 ? 'missingPatterns' : ''
@ -121,7 +125,7 @@ export const usePickIndexPatterns = ({
const getDefaultSelectedOptionsByDataView = useCallback(
(id: string, isAlerts: boolean = false): Array<EuiComboBoxOptionOption<string>> =>
scopeId === SourcererScopeName.detections || isAlerts
? alertsOptions
? signalPatternListToOptions
: patternListToOptions(
getScopePatternListSelection(
kibanaDataViews.find((dataView) => dataView.id === id),
@ -130,7 +134,7 @@ export const usePickIndexPatterns = ({
id === defaultDataViewId
)
),
[alertsOptions, kibanaDataViews, scopeId, signalIndexName, defaultDataViewId]
[signalPatternListToOptions, kibanaDataViews, scopeId, signalIndexName, defaultDataViewId]
);
const defaultSelectedPatternsAsOptions = useMemo(
@ -162,11 +166,10 @@ export const usePickIndexPatterns = ({
useEffect(() => {
setSelectedOptions(
scopeId === SourcererScopeName.detections
? alertsOptions
: patternListToOptions(selectedPatterns)
? signalPatternListToOptions
: selectedPatternsAsOptions
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedPatterns, scopeId]);
}, [selectedPatterns, scopeId, selectedPatternsAsOptions, signalPatternListToOptions]);
// when scope updates, check modified to set/remove alerts label
useEffect(() => {
onSetIsModified(
@ -200,7 +203,9 @@ export const usePickIndexPatterns = ({
if (isHookAlive.current) {
dispatch(sourcererActions.setDataView(dataView));
setSelectedOptions(
isOnlyDetectionAlerts ? alertsOptions : patternListToOptions(dataView.patternList)
isOnlyDetectionAlerts
? signalPatternListToOptions
: patternListToOptions(dataView.patternList)
);
}
} catch (err) {
@ -212,7 +217,7 @@ export const usePickIndexPatterns = ({
}
},
[
alertsOptions,
signalPatternListToOptions,
dispatch,
getDefaultSelectedOptionsByDataView,
isOnlyDetectionAlerts,
@ -243,8 +248,8 @@ export const usePickIndexPatterns = ({
}, []);
const handleOutsideClick = useCallback(() => {
setSelectedOptions(patternListToOptions(selectedPatterns));
}, [selectedPatterns]);
setSelectedOptions(selectedPatternsAsOptions);
}, [selectedPatternsAsOptions]);
return {
allOptions,

View file

@ -161,7 +161,7 @@ describe('Sourcerer Hooks', () => {
});
await waitForNextUpdate();
rerender();
expect(mockDispatch).toBeCalledTimes(2);
expect(mockDispatch).toBeCalledTimes(3);
expect(mockDispatch.mock.calls[0][0]).toEqual({
type: 'x-pack/security_solution/local/sourcerer/SET_DATA_VIEW_LOADING',
payload: { id: 'security-solution', loading: true },
@ -207,30 +207,30 @@ describe('Sourcerer Hooks', () => {
await waitForNextUpdate();
rerender();
await waitFor(() => {
expect(mockDispatch.mock.calls[2][0]).toEqual({
expect(mockDispatch.mock.calls[3][0]).toEqual({
type: 'x-pack/security_solution/local/sourcerer/SET_SOURCERER_SCOPE_LOADING',
payload: { loading: true },
});
expect(mockDispatch.mock.calls[3][0]).toEqual({
expect(mockDispatch.mock.calls[4][0]).toEqual({
type: 'x-pack/security_solution/local/sourcerer/SET_SIGNAL_INDEX_NAME',
payload: { signalIndexName: mockSourcererState.signalIndexName },
});
expect(mockDispatch.mock.calls[4][0]).toEqual({
expect(mockDispatch.mock.calls[5][0]).toEqual({
type: 'x-pack/security_solution/local/sourcerer/SET_DATA_VIEW_LOADING',
payload: {
id: mockSourcererState.defaultDataView.id,
loading: true,
},
});
expect(mockDispatch.mock.calls[5][0]).toEqual({
expect(mockDispatch.mock.calls[6][0]).toEqual({
type: 'x-pack/security_solution/local/sourcerer/SET_SOURCERER_DATA_VIEWS',
payload: mockNewDataViews,
});
expect(mockDispatch.mock.calls[6][0]).toEqual({
expect(mockDispatch.mock.calls[7][0]).toEqual({
type: 'x-pack/security_solution/local/sourcerer/SET_SOURCERER_SCOPE_LOADING',
payload: { loading: false },
});
expect(mockDispatch).toHaveBeenCalledTimes(7);
expect(mockDispatch).toHaveBeenCalledTimes(8);
expect(mockSearch).toHaveBeenCalledTimes(2);
});
});
@ -396,7 +396,7 @@ describe('Sourcerer Hooks', () => {
);
await waitForNextUpdate();
rerender();
expect(mockDispatch.mock.calls[2][0]).toEqual({
expect(mockDispatch.mock.calls[3][0]).toEqual({
type: 'x-pack/security_solution/local/sourcerer/SET_SELECTED_DATA_VIEW',
payload: {
id: 'detections',

View file

@ -207,6 +207,18 @@ export const useInitSourcerer = (
),
})
);
dispatch(
sourcererActions.setSelectedDataView({
id: SourcererScopeName.analyzer,
selectedDataViewId: defaultDataView.id,
selectedPatterns: getScopePatternListSelection(
defaultDataView,
SourcererScopeName.analyzer,
signalIndexName,
true
),
})
);
} else if (
signalIndexNameSourcerer != null &&
(activeTimeline == null || activeTimeline.savedObjectId == null) &&
@ -226,6 +238,18 @@ export const useInitSourcerer = (
),
})
);
dispatch(
sourcererActions.setSelectedDataView({
id: SourcererScopeName.analyzer,
selectedDataViewId: defaultDataView.id,
selectedPatterns: getScopePatternListSelection(
defaultDataView,
SourcererScopeName.analyzer,
signalIndexNameSourcerer,
true
),
})
);
}
}, [
activeTimeline,
@ -425,26 +449,26 @@ export const useSourcererDataView = (
[legacyDataView, missingPatterns.length, selectedDataView]
);
const indicesExist = useMemo(
() =>
loading || sourcererDataView.loading
? true
: checkIfIndicesExist({
scopeId,
signalIndexName,
patternList: sourcererDataView.patternList,
isDefaultDataViewSelected: sourcererDataView.id === defaultDataView.id,
}),
[
defaultDataView.id,
loading,
scopeId,
signalIndexName,
sourcererDataView.id,
sourcererDataView.loading,
sourcererDataView.patternList,
]
);
const indicesExist = useMemo(() => {
if (loading || sourcererDataView.loading) {
return true;
} else {
return checkIfIndicesExist({
scopeId,
signalIndexName,
patternList: sourcererDataView.patternList,
isDefaultDataViewSelected: sourcererDataView.id === defaultDataView.id,
});
}
}, [
defaultDataView.id,
loading,
scopeId,
signalIndexName,
sourcererDataView.id,
sourcererDataView.loading,
sourcererDataView.patternList,
]);
const browserFields = useCallback(() => {
const { browserFields: dataViewBrowserFields } = getDataViewStateFromIndexFields(

View file

@ -481,6 +481,16 @@ export const mockGlobalState: State = {
true
),
},
[SourcererScopeName.analyzer]: {
...mockSourcererState.sourcererScopes[SourcererScopeName.default],
selectedDataViewId: mockSourcererState.defaultDataView.id,
selectedPatterns: getScopePatternListSelection(
mockSourcererState.defaultDataView,
SourcererScopeName.default,
mockSourcererState.signalIndexName,
true
),
},
},
},
globalUrlParam: {},

View file

@ -26,6 +26,8 @@ const getPatternListFromScope = (
return signalIndexName != null ? [signalIndexName] : [];
case SourcererScopeName.timeline:
return sortWithExcludesAtEnd(patternList);
case SourcererScopeName.analyzer:
return sortWithExcludesAtEnd(patternList);
}
};

View file

@ -16,6 +16,7 @@ export enum SourcererScopeName {
default = 'default',
detections = 'detections',
timeline = 'timeline',
analyzer = 'analyzer',
}
/**
@ -189,5 +190,9 @@ export const initialSourcererState: SourcererModel = {
...initSourcererScope,
id: SourcererScopeName.timeline,
},
[SourcererScopeName.analyzer]: {
...initSourcererScope,
id: SourcererScopeName.analyzer,
},
},
};

View file

@ -191,6 +191,16 @@ export const userReloadedResolverNode = actionCreator<{
readonly nodeID: string;
}>('USER_RELOADED_RESOLVER_NODE');
export const userOverrodeDateRange = actionCreator<{
readonly id: string;
readonly timeRange: TimeFilters;
}>('USER_OVERRODE_DATE_RANGE');
export const userOverrodeSourcererSelection = actionCreator<{
readonly id: string;
readonly indices: string[];
}>('USER_OVERRODE_SOURCERER_SELECTION');
/**
* When the server returns an error after the app requests node data for a set of nodes.
*/

View file

@ -16,10 +16,27 @@ import { endpointSourceSchema, winlogSourceSchema } from '../../mocks/tree_schem
import type { NewResolverTree, ResolverSchema } from '../../../../common/endpoint/types';
import { ancestorsWithAncestryField, descendantsLimit } from '../../models/resolver_tree';
import { EMPTY_RESOLVER } from '../helpers';
import { serverReturnedResolverData } from './action';
import { serverReturnedResolverData, userOverrodeDateRange } from './action';
import { appReceivedNewExternalProperties } from '../actions';
type SourceAndSchemaFunction = () => { schema: ResolverSchema; dataSource: string };
jest.mock('../../../common/utils/default_date_settings', () => {
const original = jest.requireActual('../../../common/utils/default_date_settings');
return {
...original,
getTimeRangeSettings: () => ({ to: '', from: '' }),
};
});
jest.mock('../../../common/utils/normalize_time_range', () => {
const original = jest.requireActual('../../../common/utils/normalize_time_range');
return {
...original,
normalizeTimeRange: () => original.normalizeTimeRange(false),
};
});
/**
* Test the data reducer and selector.
*/
@ -187,6 +204,41 @@ describe('Resolver Data Middleware', () => {
});
});
describe('when a user sets a custom time range', () => {
beforeEach(() => {
const from = 'Sep 21, 2024 @ 20:49:13.452';
const to = 'Sep 21, 2024 @ 20:49:13.452';
dispatchTree(generatedTreeMetadata.formattedTree, winlogSourceSchema);
store.dispatch(
appReceivedNewExternalProperties({
id,
resolverComponentInstanceID: id,
locationSearch: '',
databaseDocumentID: id,
filters: {},
indices: ['index1'],
shouldUpdate: false,
})
);
store.dispatch(
userOverrodeDateRange({
id,
timeRange: { from, to },
})
);
});
it('should use that time over anything else', () => {
const params = selectors.treeParametersToFetch(store.getState()[id].data);
if (params?.filters !== undefined) {
const {
filters: { from, to },
} = params;
expect(from).toEqual('Sep 21, 2024 @ 20:49:13.452');
expect(to).toEqual('Sep 21, 2024 @ 20:49:13.452');
}
});
});
describe('when using winlog schema to layout the graph', () => {
beforeEach(() => {
dispatchTree(generatedTreeMetadata.formattedTree, winlogSourceSchema);

View file

@ -12,6 +12,7 @@ import * as treeFetcherParameters from '../../models/tree_fetcher_parameters';
import * as selectors from './selectors';
import * as nodeEventsInCategoryModel from './node_events_in_category_model';
import * as nodeDataModel from '../../models/node_data';
import { normalizeTimeRange } from '../../../common/utils/normalize_time_range';
import { initialAnalyzerState, immerCase } from '../helpers';
import { appReceivedNewExternalProperties } from '../actions';
import {
@ -29,6 +30,7 @@ import {
appRequestedCurrentRelatedEventData,
serverReturnedCurrentRelatedEventData,
serverFailedToReturnCurrentRelatedEventData,
userOverrodeDateRange,
} from './action';
export const dataReducer = reducerWithInitialState(initialAnalyzerState)
@ -247,6 +249,28 @@ export const dataReducer = reducerWithInitialState(initialAnalyzerState)
return draft;
})
)
.withHandling(
immerCase(userOverrodeDateRange, (draft, { id, timeRange: { from, to } }) => {
if (from && to) {
const state: Draft<DataState> = draft[id].data;
if (state.tree?.currentParameters !== undefined) {
state.tree = {
...state.tree,
currentParameters: {
...state.tree.currentParameters,
filters: {
from,
to,
},
},
};
}
const normalizedTimeRange = normalizeTimeRange({ from, to });
draft[id].data.overriddenTimeBounds = normalizedTimeRange;
}
return draft;
})
)
.withHandling(
immerCase(serverReturnedCurrentRelatedEventData, (draft, { id, relatedEvent }) => {
draft[id].data.currentRelatedEvent = {

View file

@ -50,6 +50,10 @@ export function detectedBounds(state: DataState) {
return state.detectedBounds;
}
export function overriddenTimeBounds(state: DataState) {
return state.overriddenTimeBounds;
}
/**
* If a request was made and it threw an error or returned a failure response code.
*/
@ -631,6 +635,28 @@ export const relatedEventCountOfTypeForNode: (
}
);
export const currentAppliedTimeRange = createSelector(
(state: DataState) => state.tree?.currentParameters?.filters,
(state: DataState) => state.tree?.lastResponse?.parameters?.filters,
detectedBounds,
overriddenTimeBounds,
// eslint-disable-next-line @typescript-eslint/no-shadow
function (currentFilters, lastFilters, detectedBounds, overriddenTimeBounds) {
if (overriddenTimeBounds) {
return overriddenTimeBounds;
} else if (detectedBounds) {
return {
from: detectedBounds.from,
to: detectedBounds.to,
};
} else if (lastFilters) {
return lastFilters;
} else if (currentFilters) {
return currentFilters;
}
}
);
/**
* Which view should show in the panel, as well as what parameters should be used.
* Calculated using the query string

View file

@ -43,6 +43,16 @@ export const translation = composeSelectors(cameraStateSelector, cameraSelectors
export const detectedBounds = composeSelectors(dataStateSelector, dataSelectors.detectedBounds);
export const overriddenTimeBounds = composeSelectors(
dataStateSelector,
dataSelectors.overriddenTimeBounds
);
export const currentAppliedTimeRange = composeSelectors(
dataStateSelector,
dataSelectors.currentAppliedTimeRange
);
/**
* A matrix that when applied to a Vector2 converts it from screen coordinates to world coordinates.
* See https://en.wikipedia.org/wiki/Orthographic_projection

View file

@ -315,6 +315,8 @@ export interface DataState {
readonly detectedBounds?: TimeFilters;
readonly overriddenTimeBounds?: TimeFilters;
readonly tree?: {
/**
* The parameters passed from the resolver properties

View file

@ -0,0 +1,118 @@
/*
* 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, memo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { i18n } from '@kbn/i18n';
import { EuiPopover, EuiPopoverTitle, EuiSuperDatePicker } from '@elastic/eui';
import type { ShortDate } from '@elastic/eui';
import { formatDate } from '../../../common/components/super_date_picker';
import { StyledEuiButtonIcon } from './styles';
import { useColors } from '../use_colors';
import * as selectors from '../../store/selectors';
import { userOverrodeDateRange } from '../../store/data/action';
import type { State } from '../../../common/store/types';
interface DurationRange {
end: ShortDate;
label?: string;
start: ShortDate;
}
const emptyRanges: DurationRange[] = [];
const nodeLegendButtonTitle = i18n.translate(
'xpack.securitySolution.resolver.graphControls.datePickerButtonTitle',
{
defaultMessage: 'Date Range Selection',
}
);
const dateRangeDescription = i18n.translate(
'xpack.securitySolution.resolver.graphControls.datePicker',
{
defaultMessage: 'date range selection',
}
);
export const DateSelectionButton = memo(
({
id,
closePopover,
setActivePopover,
isOpen,
}: {
id: string;
closePopover: () => void;
setActivePopover: (value: 'datePicker') => void;
isOpen: boolean;
}) => {
const dispatch = useDispatch();
const setAsActivePopover = useCallback(
() => setActivePopover('datePicker'),
[setActivePopover]
);
const colorMap = useColors();
const appliedBounds = useSelector((state: State) => {
return selectors.currentAppliedTimeRange(state.analyzer[id]);
});
const onTimeChange = useCallback(
({ start, end, isInvalid }) => {
if (!isInvalid) {
const isQuickSelection = start.includes('now') || end.includes('now');
const fromDate = formatDate(start);
let toDate = formatDate(end, { roundUp: true });
if (isQuickSelection) {
if (start === end) {
toDate = formatDate('now');
} else {
toDate = formatDate(end);
}
}
dispatch(userOverrodeDateRange({ id, timeRange: { from: fromDate, to: toDate } }));
}
},
[dispatch, id]
);
return (
<EuiPopover
button={
<StyledEuiButtonIcon
data-test-subj="resolver:graph-controls:date-picker-button"
size="m"
title={nodeLegendButtonTitle}
aria-label={nodeLegendButtonTitle}
onClick={setAsActivePopover}
iconType="calendar"
$backgroundColor={colorMap.graphControlsBackground}
$iconColor={colorMap.graphControls}
$borderColor={colorMap.graphControlsBorderColor}
/>
}
isOpen={isOpen}
closePopover={closePopover}
anchorPosition="leftCenter"
>
<EuiPopoverTitle style={{ textTransform: 'uppercase' }}>
{dateRangeDescription}
</EuiPopoverTitle>
<EuiSuperDatePicker
onTimeChange={onTimeChange}
start={appliedBounds?.from}
end={appliedBounds?.to}
showUpdateButton={false}
recentlyUsedRanges={emptyRanges}
width="auto"
/>
</EuiPopover>
);
}
);
DateSelectionButton.displayName = 'DateSelectionButton';

View file

@ -5,10 +5,10 @@
* 2.0.
*/
import { Simulator } from '../test_utilities/simulator';
import { noAncestorsTwoChildren } from '../data_access_layer/mocks/no_ancestors_two_children';
import { nudgeAnimationDuration } from '../store/camera/scaling_constants';
import '../test_utilities/extend_jest';
import { Simulator } from '../../test_utilities/simulator';
import { noAncestorsTwoChildren } from '../../data_access_layer/mocks/no_ancestors_two_children';
import { nudgeAnimationDuration } from '../../store/camera/scaling_constants';
import '../../test_utilities/extend_jest';
describe('graph controls: when relsover is loaded with an origin node', () => {
let simulator: Simulator;

View file

@ -0,0 +1,246 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo, useContext, useState } from 'react';
import { i18n } from '@kbn/i18n';
import type { EuiRangeProps } from '@elastic/eui';
import { EuiPanel, EuiIcon } from '@elastic/eui';
import { useSelector, useDispatch } from 'react-redux';
import { SideEffectContext } from '../side_effect_context';
import type { Vector2 } from '../../types';
import * as selectors from '../../store/selectors';
import { useColors } from '../use_colors';
import {
userClickedZoomIn,
userClickedZoomOut,
userSetZoomLevel,
userNudgedCamera,
userSetPositionOfCamera,
} from '../../store/camera/action';
import type { State } from '../../../common/store/types';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { SourcererButton } from './sourcerer_selection';
import { DateSelectionButton } from './date_picker';
import { StyledGraphControls, StyledGraphControlsColumn, StyledEuiRange } from './styles';
import { NodeLegend } from './legend';
import { SchemaInformation } from './schema';
export const GraphControls = React.memo(
({
id,
className,
}: {
/**
* Id that identify the scope of analyzer
*/
id: string;
/**
* A className string provided by `styled`
*/
className?: string;
}) => {
const dispatch = useDispatch();
const scalingFactor = useSelector((state: State) =>
selectors.scalingFactor(state.analyzer[id])
);
const { timestamp } = useContext(SideEffectContext);
const isDatePickerAndSourcererDisabled = useIsExperimentalFeatureEnabled(
'analyzerDatePickersAndSourcererDisabled'
);
const [activePopover, setPopover] = useState<
null | 'schemaInfo' | 'nodeLegend' | 'sourcererSelection' | 'datePicker'
>(null);
const colorMap = useColors();
const setActivePopover = useCallback(
(value) => {
if (value === activePopover) {
setPopover(null);
} else {
setPopover(value);
}
},
[setPopover, activePopover]
);
const closePopover = useCallback(() => setPopover(null), []);
const handleZoomAmountChange: EuiRangeProps['onChange'] = useCallback(
(event) => {
const valueAsNumber = parseFloat(
(event as React.ChangeEvent<HTMLInputElement>).target.value
);
if (isNaN(valueAsNumber) === false) {
dispatch(
userSetZoomLevel({
id,
zoomLevel: valueAsNumber,
})
);
}
},
[dispatch, id]
);
const handleCenterClick = useCallback(() => {
dispatch(userSetPositionOfCamera({ id, cameraView: [0, 0] }));
}, [dispatch, id]);
const handleZoomOutClick = useCallback(() => {
dispatch(userClickedZoomOut({ id }));
}, [dispatch, id]);
const handleZoomInClick = useCallback(() => {
dispatch(userClickedZoomIn({ id }));
}, [dispatch, id]);
const [handleNorth, handleEast, handleSouth, handleWest] = useMemo(() => {
const directionVectors: readonly Vector2[] = [
[0, 1],
[1, 0],
[0, -1],
[-1, 0],
];
return directionVectors.map((direction) => {
return () => {
dispatch(userNudgedCamera({ id, direction, time: timestamp() }));
};
});
}, [dispatch, timestamp, id]);
/* eslint-disable react/button-has-type */
return (
<StyledGraphControls
className={className}
$iconColor={colorMap.graphControls}
data-test-subj="resolver:graph-controls"
>
<StyledGraphControlsColumn>
<SchemaInformation
id={id}
closePopover={closePopover}
isOpen={activePopover === 'schemaInfo'}
setActivePopover={setActivePopover}
/>
<NodeLegend
id={id}
closePopover={closePopover}
isOpen={activePopover === 'nodeLegend'}
setActivePopover={setActivePopover}
/>
{!isDatePickerAndSourcererDisabled ? (
<>
<SourcererButton
id={id}
closePopover={closePopover}
isOpen={activePopover === 'sourcererSelection'}
setActivePopover={setActivePopover}
/>
<DateSelectionButton
id={id}
closePopover={closePopover}
isOpen={activePopover === 'datePicker'}
setActivePopover={setActivePopover}
/>
</>
) : null}
</StyledGraphControlsColumn>
<StyledGraphControlsColumn>
<EuiPanel className="panning-controls" paddingSize="none" hasBorder>
<div className="panning-controls-top">
<button
className="north-button"
data-test-subj="resolver:graph-controls:north-button"
title={i18n.translate('xpack.securitySolution.resolver.graphControls.north', {
defaultMessage: 'North',
})}
onClick={handleNorth}
>
<EuiIcon type="arrowUp" />
</button>
</div>
<div className="panning-controls-middle">
<button
className="west-button"
data-test-subj="resolver:graph-controls:west-button"
title={i18n.translate('xpack.securitySolution.resolver.graphControls.west', {
defaultMessage: 'West',
})}
onClick={handleWest}
>
<EuiIcon type="arrowLeft" />
</button>
<button
className="center-button"
data-test-subj="resolver:graph-controls:center-button"
title={i18n.translate('xpack.securitySolution.resolver.graphControls.center', {
defaultMessage: 'Center',
})}
onClick={handleCenterClick}
>
<EuiIcon type="bullseye" />
</button>
<button
className="east-button"
data-test-subj="resolver:graph-controls:east-button"
title={i18n.translate('xpack.securitySolution.resolver.graphControls.east', {
defaultMessage: 'East',
})}
onClick={handleEast}
>
<EuiIcon type="arrowRight" />
</button>
</div>
<div className="panning-controls-bottom">
<button
className="south-button"
data-test-subj="resolver:graph-controls:south-button"
title={i18n.translate('xpack.securitySolution.resolver.graphControls.south', {
defaultMessage: 'South',
})}
onClick={handleSouth}
>
<EuiIcon type="arrowDown" />
</button>
</div>
</EuiPanel>
<EuiPanel className="zoom-controls" paddingSize="none" hasBorder>
<button
title={i18n.translate('xpack.securitySolution.resolver.graphControls.zoomIn', {
defaultMessage: 'Zoom In',
})}
data-test-subj="resolver:graph-controls:zoom-in"
onClick={handleZoomInClick}
>
<EuiIcon type="plusInCircle" />
</button>
<StyledEuiRange
className="zoom-slider"
data-test-subj="resolver:graph-controls:zoom-slider"
min={0}
max={1}
step={0.01}
value={scalingFactor}
onChange={handleZoomAmountChange}
/>
<button
title={i18n.translate('xpack.securitySolution.resolver.graphControls.zoomOut', {
defaultMessage: 'Zoom Out',
})}
data-test-subj="resolver:graph-controls:zoom-out"
onClick={handleZoomOutClick}
>
<EuiIcon type="minusInCircle" />
</button>
</EuiPanel>
</StyledGraphControlsColumn>
</StyledGraphControls>
);
}
);
GraphControls.displayName = 'GraphControls';

View file

@ -0,0 +1,155 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { EuiPopover, EuiPopoverTitle } from '@elastic/eui';
import { useColors } from '../use_colors';
import { StyledDescriptionList } from '../panels/styles';
import { CubeForProcess } from '../panels/cube_for_process';
import { GeneratedText } from '../generated_text';
import {
StyledEuiDescriptionListTitle,
StyledEuiDescriptionListDescription,
StyledEuiButtonIcon,
COLUMN_WIDTH,
} from './styles';
// This component defines the cube legend that allows users to identify the meaning of the cubes
// Should be updated to be dynamic if and when non process based resolvers are possible
export const NodeLegend = ({
id,
closePopover,
setActivePopover,
isOpen,
}: {
id: string;
closePopover: () => void;
setActivePopover: (value: 'nodeLegend') => void;
isOpen: boolean;
}) => {
const setAsActivePopover = useCallback(() => setActivePopover('nodeLegend'), [setActivePopover]);
const colorMap = useColors();
const nodeLegendButtonTitle = i18n.translate(
'xpack.securitySolution.resolver.graphControls.nodeLegendButtonTitle',
{
defaultMessage: 'Node Legend',
}
);
return (
<EuiPopover
button={
<StyledEuiButtonIcon
data-test-subj="resolver:graph-controls:node-legend-button"
size="m"
title={nodeLegendButtonTitle}
aria-label={nodeLegendButtonTitle}
onClick={setAsActivePopover}
iconType="node"
$backgroundColor={colorMap.graphControlsBackground}
$iconColor={colorMap.graphControls}
$borderColor={colorMap.graphControlsBorderColor}
/>
}
isOpen={isOpen}
closePopover={closePopover}
anchorPosition="leftCenter"
>
<EuiPopoverTitle style={{ textTransform: 'uppercase' }}>
{i18n.translate('xpack.securitySolution.resolver.graphControls.nodeLegend', {
defaultMessage: 'legend',
})}
</EuiPopoverTitle>
<div
// Limit the width based on UX design
style={{ maxWidth: '212px' }}
>
<StyledDescriptionList
data-test-subj="resolver:graph-controls:node-legend"
type="column"
columnWidths={COLUMN_WIDTH}
align="left"
compressed
>
<>
<StyledEuiDescriptionListTitle data-test-subj="resolver:graph-controls:node-legend:title">
<CubeForProcess
id={id}
size="2.5em"
data-test-subj="resolver:node-detail:title-icon"
state="running"
/>
</StyledEuiDescriptionListTitle>
<StyledEuiDescriptionListDescription data-test-subj="resolver:graph-controls:node-legend:description">
<GeneratedText>
{i18n.translate(
'xpack.securitySolution.resolver.graphControls.runningProcessCube',
{
defaultMessage: 'Running Process',
}
)}
</GeneratedText>
</StyledEuiDescriptionListDescription>
<StyledEuiDescriptionListTitle data-test-subj="resolver:graph-controls:node-legend:title">
<CubeForProcess
id={id}
size="2.5em"
data-test-subj="resolver:node-detail:title-icon"
state="terminated"
/>
</StyledEuiDescriptionListTitle>
<StyledEuiDescriptionListDescription data-test-subj="resolver:graph-controls:node-legend:description">
<GeneratedText>
{i18n.translate(
'xpack.securitySolution.resolver.graphControls.terminatedProcessCube',
{
defaultMessage: 'Terminated Process',
}
)}
</GeneratedText>
</StyledEuiDescriptionListDescription>
<StyledEuiDescriptionListTitle data-test-subj="resolver:graph-controls:node-legend:title">
<CubeForProcess
id={id}
size="2.5em"
data-test-subj="resolver:node-detail:title-icon"
state="loading"
/>
</StyledEuiDescriptionListTitle>
<StyledEuiDescriptionListDescription data-test-subj="resolver:graph-controls:node-legend:description">
<GeneratedText>
{i18n.translate(
'xpack.securitySolution.resolver.graphControls.currentlyLoadingCube',
{
defaultMessage: 'Loading Process',
}
)}
</GeneratedText>
</StyledEuiDescriptionListDescription>
<StyledEuiDescriptionListTitle data-test-subj="resolver:graph-controls:node-legend:title">
<CubeForProcess
id={id}
size="2.5em"
data-test-subj="resolver:node-detail:title-icon"
state="error"
/>
</StyledEuiDescriptionListTitle>
<StyledEuiDescriptionListDescription data-test-subj="resolver:graph-controls:node-legend:description">
<GeneratedText>
{i18n.translate('xpack.securitySolution.resolver.graphControls.errorCube', {
defaultMessage: 'Error Process',
})}
</GeneratedText>
</StyledEuiDescriptionListDescription>
</>
</StyledDescriptionList>
</div>
</EuiPopover>
);
};

View file

@ -0,0 +1,129 @@
/*
* 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 { i18n } from '@kbn/i18n';
import {
EuiPopover,
EuiPopoverTitle,
EuiIconTip,
EuiDescriptionListDescription,
} from '@elastic/eui';
import { useSelector } from 'react-redux';
import * as selectors from '../../store/selectors';
import { useColors } from '../use_colors';
import { StyledDescriptionList } from '../panels/styles';
import { GeneratedText } from '../generated_text';
import type { State } from '../../../common/store/types';
import { StyledEuiDescriptionListTitle, StyledEuiButtonIcon, COLUMN_WIDTH } from './styles';
export const SchemaInformation = ({
id,
closePopover,
setActivePopover,
isOpen,
}: {
id: string;
closePopover: () => void;
setActivePopover: (value: 'schemaInfo' | null) => void;
isOpen: boolean;
}) => {
const colorMap = useColors();
const sourceAndSchema = useSelector((state: State) =>
selectors.resolverTreeSourceAndSchema(state.analyzer[id])
);
const setAsActivePopover = useCallback(() => setActivePopover('schemaInfo'), [setActivePopover]);
const schemaInfoButtonTitle = i18n.translate(
'xpack.securitySolution.resolver.graphControls.schemaInfoButtonTitle',
{
defaultMessage: 'Schema Information',
}
);
const unknownSchemaValue = i18n.translate(
'xpack.securitySolution.resolver.graphControls.unknownSchemaValue',
{
defaultMessage: 'Unknown',
}
);
return (
<EuiPopover
button={
<StyledEuiButtonIcon
data-test-subj="resolver:graph-controls:schema-info-button"
size="m"
title={schemaInfoButtonTitle}
aria-label={schemaInfoButtonTitle}
onClick={setAsActivePopover}
iconType="iInCircle"
$backgroundColor={colorMap.graphControlsBackground}
$iconColor={colorMap.graphControls}
$borderColor={colorMap.graphControlsBorderColor}
/>
}
isOpen={isOpen}
closePopover={closePopover}
anchorPosition="leftCenter"
>
<EuiPopoverTitle style={{ textTransform: 'uppercase' }}>
{i18n.translate('xpack.securitySolution.resolver.graphControls.schemaInfoTitle', {
defaultMessage: 'process tree',
})}
<EuiIconTip
content={i18n.translate(
'xpack.securitySolution.resolver.graphControls.schemaInfoTooltip',
{
defaultMessage: 'These are the fields used to create the process tree',
}
)}
position="right"
/>
</EuiPopoverTitle>
<div
// Limit the width based on UX design
style={{ maxWidth: '268px' }}
>
<StyledDescriptionList
data-test-subj="resolver:graph-controls:schema-info"
type="column"
columnWidths={COLUMN_WIDTH}
align="left"
compressed
>
<>
<StyledEuiDescriptionListTitle data-test-subj="resolver:graph-controls:schema-info:title">
{i18n.translate('xpack.securitySolution.resolver.graphControls.schemaSource', {
defaultMessage: 'source',
})}
</StyledEuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="resolver:graph-controls:schema-info:description">
<GeneratedText>{sourceAndSchema?.dataSource ?? unknownSchemaValue}</GeneratedText>
</EuiDescriptionListDescription>
<StyledEuiDescriptionListTitle data-test-subj="resolver:graph-controls:schema-info:title">
{i18n.translate('xpack.securitySolution.resolver.graphControls.schemaID', {
defaultMessage: 'id',
})}
</StyledEuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="resolver:graph-controls:schema-info:description">
<GeneratedText>{sourceAndSchema?.schema.id ?? unknownSchemaValue}</GeneratedText>
</EuiDescriptionListDescription>
<StyledEuiDescriptionListTitle data-test-subj="resolver:graph-controls:schema-info:title">
{i18n.translate('xpack.securitySolution.resolver.graphControls.schemaEdge', {
defaultMessage: 'edge',
})}
</StyledEuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="resolver:graph-controls:schema-info:description">
<GeneratedText>{sourceAndSchema?.schema.parent ?? unknownSchemaValue}</GeneratedText>
</EuiDescriptionListDescription>
</>
</StyledDescriptionList>
</div>
</EuiPopover>
);
};

View file

@ -0,0 +1,65 @@
/*
* 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, memo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiPopover } from '@elastic/eui';
import { StyledEuiButtonIcon } from './styles';
import { useColors } from '../use_colors';
import { Sourcerer } from '../../../common/components/sourcerer';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
const nodeLegendButtonTitle = i18n.translate(
'xpack.securitySolution.resolver.graphControls.sourcererButtonTitle',
{
defaultMessage: 'Data View Selection',
}
);
export const SourcererButton = memo(
({
id,
closePopover,
setActivePopover,
isOpen,
}: {
id: string;
closePopover: () => void;
setActivePopover: (value: 'sourcererSelection') => void;
isOpen: boolean;
}) => {
const setAsActivePopover = useCallback(
() => setActivePopover('sourcererSelection'),
[setActivePopover]
);
const colorMap = useColors();
return (
<EuiPopover
button={
<StyledEuiButtonIcon
data-test-subj="resolver:graph-controls:sourcerer-button"
size="m"
title={nodeLegendButtonTitle}
aria-label={nodeLegendButtonTitle}
onClick={setAsActivePopover}
iconType="indexSettings"
$backgroundColor={colorMap.graphControlsBackground}
$iconColor={colorMap.graphControls}
$borderColor={colorMap.graphControlsBorderColor}
/>
}
isOpen={isOpen}
closePopover={closePopover}
anchorPosition="leftCenter"
>
<Sourcerer scope={SourcererScopeName.analyzer} />
</EuiPopover>
);
}
);
SourcererButton.displayName = 'SourcererButton';

View file

@ -0,0 +1,94 @@
/*
* 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 type { EuiRangeProps } from '@elastic/eui';
import {
EuiRange,
EuiButtonIcon,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
} from '@elastic/eui';
import styled from 'styled-components';
export const COLUMN_WIDTH = ['fit-content(10em)', 'auto'];
// EuiRange is currently only horizontally positioned. This reorients the track to a vertical position
export const StyledEuiRange = styled(EuiRange)<EuiRangeProps>`
& .euiRangeTrack:after {
left: -65px;
transform: rotate(90deg);
}
`;
export interface StyledGraphControlProps {
$backgroundColor: string;
$iconColor: string;
$borderColor: string;
}
export const StyledGraphControlsColumn = styled.div`
display: flex;
flex-direction: column;
&:not(last-of-type) {
margin-right: 5px;
}
`;
export const StyledEuiDescriptionListTitle = styled(EuiDescriptionListTitle)`
text-transform: uppercase;
`;
export const StyledEuiDescriptionListDescription = styled(EuiDescriptionListDescription)`
lineheight: '2.2em'; // lineHeight to align center vertically
`;
export const StyledEuiButtonIcon = styled(EuiButtonIcon)<StyledGraphControlProps>`
background-color: ${(props) => props.$backgroundColor};
color: ${(props) => props.$iconColor};
border-color: ${(props) => props.$borderColor};
border-width: 1px;
border-style: solid;
border-radius: 4px;
width: 40px;
height: 40px;
&:not(last-of-type) {
margin-bottom: 7px;
}
`;
export const StyledGraphControls = styled.div<Partial<StyledGraphControlProps>>`
display: flex;
flex-direction: row;
position: absolute;
top: 5px;
right: 5px;
background-color: transparent;
color: ${(props) => props.$iconColor};
.zoom-controls {
display: flex;
flex-direction: column;
align-items: center;
padding: 5px 0px;
.zoom-slider {
width: 20px;
height: 150px;
margin: 5px 0px 2px 0px;
input[type='range'] {
width: 150px;
height: 20px;
transform-origin: 75px 75px;
transform: rotate(-90deg);
}
}
}
.panning-controls {
text-align: center;
}
`;

View file

@ -1,558 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo, useContext, useState } from 'react';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import type { EuiRangeProps } from '@elastic/eui';
import {
EuiRange,
EuiPanel,
EuiIcon,
EuiButtonIcon,
EuiPopover,
EuiPopoverTitle,
EuiIconTip,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
} from '@elastic/eui';
import { useSelector, useDispatch } from 'react-redux';
import { SideEffectContext } from './side_effect_context';
import type { Vector2 } from '../types';
import * as selectors from '../store/selectors';
import { useColors } from './use_colors';
import { StyledDescriptionList } from './panels/styles';
import { CubeForProcess } from './panels/cube_for_process';
import { GeneratedText } from './generated_text';
import {
userClickedZoomIn,
userClickedZoomOut,
userSetZoomLevel,
userNudgedCamera,
userSetPositionOfCamera,
} from '../store/camera/action';
import type { State } from '../../common/store/types';
// EuiRange is currently only horizontally positioned. This reorients the track to a vertical position
const StyledEuiRange = styled(EuiRange)<EuiRangeProps>`
& .euiRangeTrack:after {
left: -65px;
transform: rotate(90deg);
}
`;
interface StyledGraphControlProps {
$backgroundColor: string;
$iconColor: string;
$borderColor: string;
}
const StyledGraphControlsColumn = styled.div`
display: flex;
flex-direction: column;
&:not(last-of-type) {
margin-right: 5px;
}
`;
const COLUMN_WIDTH = ['fit-content(10em)', 'auto'];
const StyledEuiDescriptionListTitle = styled(EuiDescriptionListTitle)`
text-transform: uppercase;
`;
const StyledEuiDescriptionListDescription = styled(EuiDescriptionListDescription)`
lineheight: '2.2em'; // lineHeight to align center vertically
`;
const StyledEuiButtonIcon = styled(EuiButtonIcon)<StyledGraphControlProps>`
background-color: ${(props) => props.$backgroundColor};
color: ${(props) => props.$iconColor};
border-color: ${(props) => props.$borderColor};
border-width: 1px;
border-style: solid;
border-radius: 4px;
width: 40px;
height: 40px;
&:not(last-of-type) {
margin-bottom: 7px;
}
`;
const StyledGraphControls = styled.div<Partial<StyledGraphControlProps>>`
display: flex;
flex-direction: row;
position: absolute;
top: 5px;
right: 5px;
background-color: transparent;
color: ${(props) => props.$iconColor};
.zoom-controls {
display: flex;
flex-direction: column;
align-items: center;
padding: 5px 0px;
.zoom-slider {
width: 20px;
height: 150px;
margin: 5px 0px 2px 0px;
input[type='range'] {
width: 150px;
height: 20px;
transform-origin: 75px 75px;
transform: rotate(-90deg);
}
}
}
.panning-controls {
text-align: center;
}
`;
/**
* Controls for zooming, panning, and centering in Resolver
*/
// eslint-disable-next-line react/display-name
export const GraphControls = React.memo(
({
id,
className,
}: {
/**
* Id that identify the scope of analyzer
*/
id: string;
/**
* A className string provided by `styled`
*/
className?: string;
}) => {
const dispatch = useDispatch();
const scalingFactor = useSelector((state: State) =>
selectors.scalingFactor(state.analyzer[id])
);
const { timestamp } = useContext(SideEffectContext);
const [activePopover, setPopover] = useState<null | 'schemaInfo' | 'nodeLegend'>(null);
const colorMap = useColors();
const setActivePopover = useCallback(
(value) => {
if (value === activePopover) {
setPopover(null);
} else {
setPopover(value);
}
},
[setPopover, activePopover]
);
const closePopover = useCallback(() => setPopover(null), []);
const handleZoomAmountChange: EuiRangeProps['onChange'] = useCallback(
(event) => {
const valueAsNumber = parseFloat(
(event as React.ChangeEvent<HTMLInputElement>).target.value
);
if (isNaN(valueAsNumber) === false) {
dispatch(
userSetZoomLevel({
id,
zoomLevel: valueAsNumber,
})
);
}
},
[dispatch, id]
);
const handleCenterClick = useCallback(() => {
dispatch(userSetPositionOfCamera({ id, cameraView: [0, 0] }));
}, [dispatch, id]);
const handleZoomOutClick = useCallback(() => {
dispatch(userClickedZoomOut({ id }));
}, [dispatch, id]);
const handleZoomInClick = useCallback(() => {
dispatch(userClickedZoomIn({ id }));
}, [dispatch, id]);
const [handleNorth, handleEast, handleSouth, handleWest] = useMemo(() => {
const directionVectors: readonly Vector2[] = [
[0, 1],
[1, 0],
[0, -1],
[-1, 0],
];
return directionVectors.map((direction) => {
return () => {
dispatch(userNudgedCamera({ id, direction, time: timestamp() }));
};
});
}, [dispatch, timestamp, id]);
/* eslint-disable react/button-has-type */
return (
<StyledGraphControls
className={className}
$iconColor={colorMap.graphControls}
data-test-subj="resolver:graph-controls"
>
<StyledGraphControlsColumn>
<SchemaInformation
id={id}
closePopover={closePopover}
isOpen={activePopover === 'schemaInfo'}
setActivePopover={setActivePopover}
/>
<NodeLegend
id={id}
closePopover={closePopover}
isOpen={activePopover === 'nodeLegend'}
setActivePopover={setActivePopover}
/>
</StyledGraphControlsColumn>
<StyledGraphControlsColumn>
<EuiPanel className="panning-controls" paddingSize="none" hasBorder>
<div className="panning-controls-top">
<button
className="north-button"
data-test-subj="resolver:graph-controls:north-button"
title={i18n.translate('xpack.securitySolution.resolver.graphControls.north', {
defaultMessage: 'North',
})}
onClick={handleNorth}
>
<EuiIcon type="arrowUp" />
</button>
</div>
<div className="panning-controls-middle">
<button
className="west-button"
data-test-subj="resolver:graph-controls:west-button"
title={i18n.translate('xpack.securitySolution.resolver.graphControls.west', {
defaultMessage: 'West',
})}
onClick={handleWest}
>
<EuiIcon type="arrowLeft" />
</button>
<button
className="center-button"
data-test-subj="resolver:graph-controls:center-button"
title={i18n.translate('xpack.securitySolution.resolver.graphControls.center', {
defaultMessage: 'Center',
})}
onClick={handleCenterClick}
>
<EuiIcon type="bullseye" />
</button>
<button
className="east-button"
data-test-subj="resolver:graph-controls:east-button"
title={i18n.translate('xpack.securitySolution.resolver.graphControls.east', {
defaultMessage: 'East',
})}
onClick={handleEast}
>
<EuiIcon type="arrowRight" />
</button>
</div>
<div className="panning-controls-bottom">
<button
className="south-button"
data-test-subj="resolver:graph-controls:south-button"
title={i18n.translate('xpack.securitySolution.resolver.graphControls.south', {
defaultMessage: 'South',
})}
onClick={handleSouth}
>
<EuiIcon type="arrowDown" />
</button>
</div>
</EuiPanel>
<EuiPanel className="zoom-controls" paddingSize="none" hasBorder>
<button
title={i18n.translate('xpack.securitySolution.resolver.graphControls.zoomIn', {
defaultMessage: 'Zoom In',
})}
data-test-subj="resolver:graph-controls:zoom-in"
onClick={handleZoomInClick}
>
<EuiIcon type="plusInCircle" />
</button>
<StyledEuiRange
className="zoom-slider"
data-test-subj="resolver:graph-controls:zoom-slider"
min={0}
max={1}
step={0.01}
value={scalingFactor}
onChange={handleZoomAmountChange}
/>
<button
title={i18n.translate('xpack.securitySolution.resolver.graphControls.zoomOut', {
defaultMessage: 'Zoom Out',
})}
data-test-subj="resolver:graph-controls:zoom-out"
onClick={handleZoomOutClick}
>
<EuiIcon type="minusInCircle" />
</button>
</EuiPanel>
</StyledGraphControlsColumn>
</StyledGraphControls>
);
/* eslint-enable react/button-has-type */
}
);
const SchemaInformation = ({
id,
closePopover,
setActivePopover,
isOpen,
}: {
id: string;
closePopover: () => void;
setActivePopover: (value: 'schemaInfo' | null) => void;
isOpen: boolean;
}) => {
const colorMap = useColors();
const sourceAndSchema = useSelector((state: State) =>
selectors.resolverTreeSourceAndSchema(state.analyzer[id])
);
const setAsActivePopover = useCallback(() => setActivePopover('schemaInfo'), [setActivePopover]);
const schemaInfoButtonTitle = i18n.translate(
'xpack.securitySolution.resolver.graphControls.schemaInfoButtonTitle',
{
defaultMessage: 'Schema Information',
}
);
const unknownSchemaValue = i18n.translate(
'xpack.securitySolution.resolver.graphControls.unknownSchemaValue',
{
defaultMessage: 'Unknown',
}
);
return (
<EuiPopover
button={
<StyledEuiButtonIcon
data-test-subj="resolver:graph-controls:schema-info-button"
size="m"
title={schemaInfoButtonTitle}
aria-label={schemaInfoButtonTitle}
onClick={setAsActivePopover}
iconType="iInCircle"
$backgroundColor={colorMap.graphControlsBackground}
$iconColor={colorMap.graphControls}
$borderColor={colorMap.graphControlsBorderColor}
/>
}
isOpen={isOpen}
closePopover={closePopover}
anchorPosition="leftCenter"
>
<EuiPopoverTitle style={{ textTransform: 'uppercase' }}>
{i18n.translate('xpack.securitySolution.resolver.graphControls.schemaInfoTitle', {
defaultMessage: 'process tree',
})}
<EuiIconTip
content={i18n.translate(
'xpack.securitySolution.resolver.graphControls.schemaInfoTooltip',
{
defaultMessage: 'These are the fields used to create the process tree',
}
)}
position="right"
/>
</EuiPopoverTitle>
<div
// Limit the width based on UX design
style={{ maxWidth: '268px' }}
>
<StyledDescriptionList
data-test-subj="resolver:graph-controls:schema-info"
type="column"
columnWidths={COLUMN_WIDTH}
align="left"
compressed
>
<>
<StyledEuiDescriptionListTitle data-test-subj="resolver:graph-controls:schema-info:title">
{i18n.translate('xpack.securitySolution.resolver.graphControls.schemaSource', {
defaultMessage: 'source',
})}
</StyledEuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="resolver:graph-controls:schema-info:description">
<GeneratedText>{sourceAndSchema?.dataSource ?? unknownSchemaValue}</GeneratedText>
</EuiDescriptionListDescription>
<StyledEuiDescriptionListTitle data-test-subj="resolver:graph-controls:schema-info:title">
{i18n.translate('xpack.securitySolution.resolver.graphControls.schemaID', {
defaultMessage: 'id',
})}
</StyledEuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="resolver:graph-controls:schema-info:description">
<GeneratedText>{sourceAndSchema?.schema.id ?? unknownSchemaValue}</GeneratedText>
</EuiDescriptionListDescription>
<StyledEuiDescriptionListTitle data-test-subj="resolver:graph-controls:schema-info:title">
{i18n.translate('xpack.securitySolution.resolver.graphControls.schemaEdge', {
defaultMessage: 'edge',
})}
</StyledEuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="resolver:graph-controls:schema-info:description">
<GeneratedText>{sourceAndSchema?.schema.parent ?? unknownSchemaValue}</GeneratedText>
</EuiDescriptionListDescription>
</>
</StyledDescriptionList>
</div>
</EuiPopover>
);
};
// This component defines the cube legend that allows users to identify the meaning of the cubes
// Should be updated to be dynamic if and when non process based resolvers are possible
const NodeLegend = ({
id,
closePopover,
setActivePopover,
isOpen,
}: {
id: string;
closePopover: () => void;
setActivePopover: (value: 'nodeLegend') => void;
isOpen: boolean;
}) => {
const setAsActivePopover = useCallback(() => setActivePopover('nodeLegend'), [setActivePopover]);
const colorMap = useColors();
const nodeLegendButtonTitle = i18n.translate(
'xpack.securitySolution.resolver.graphControls.nodeLegendButtonTitle',
{
defaultMessage: 'Node Legend',
}
);
return (
<EuiPopover
button={
<StyledEuiButtonIcon
data-test-subj="resolver:graph-controls:node-legend-button"
size="m"
title={nodeLegendButtonTitle}
aria-label={nodeLegendButtonTitle}
onClick={setAsActivePopover}
iconType="node"
$backgroundColor={colorMap.graphControlsBackground}
$iconColor={colorMap.graphControls}
$borderColor={colorMap.graphControlsBorderColor}
/>
}
isOpen={isOpen}
closePopover={closePopover}
anchorPosition="leftCenter"
>
<EuiPopoverTitle style={{ textTransform: 'uppercase' }}>
{i18n.translate('xpack.securitySolution.resolver.graphControls.nodeLegend', {
defaultMessage: 'legend',
})}
</EuiPopoverTitle>
<div
// Limit the width based on UX design
style={{ maxWidth: '212px' }}
>
<StyledDescriptionList
data-test-subj="resolver:graph-controls:node-legend"
type="column"
columnWidths={COLUMN_WIDTH}
align="left"
compressed
>
<>
<StyledEuiDescriptionListTitle data-test-subj="resolver:graph-controls:node-legend:title">
<CubeForProcess
id={id}
size="2.5em"
data-test-subj="resolver:node-detail:title-icon"
state="running"
/>
</StyledEuiDescriptionListTitle>
<StyledEuiDescriptionListDescription data-test-subj="resolver:graph-controls:node-legend:description">
<GeneratedText>
{i18n.translate(
'xpack.securitySolution.resolver.graphControls.runningProcessCube',
{
defaultMessage: 'Running Process',
}
)}
</GeneratedText>
</StyledEuiDescriptionListDescription>
<StyledEuiDescriptionListTitle data-test-subj="resolver:graph-controls:node-legend:title">
<CubeForProcess
id={id}
size="2.5em"
data-test-subj="resolver:node-detail:title-icon"
state="terminated"
/>
</StyledEuiDescriptionListTitle>
<StyledEuiDescriptionListDescription data-test-subj="resolver:graph-controls:node-legend:description">
<GeneratedText>
{i18n.translate(
'xpack.securitySolution.resolver.graphControls.terminatedProcessCube',
{
defaultMessage: 'Terminated Process',
}
)}
</GeneratedText>
</StyledEuiDescriptionListDescription>
<StyledEuiDescriptionListTitle data-test-subj="resolver:graph-controls:node-legend:title">
<CubeForProcess
id={id}
size="2.5em"
data-test-subj="resolver:node-detail:title-icon"
state="loading"
/>
</StyledEuiDescriptionListTitle>
<StyledEuiDescriptionListDescription data-test-subj="resolver:graph-controls:node-legend:description">
<GeneratedText>
{i18n.translate(
'xpack.securitySolution.resolver.graphControls.currentlyLoadingCube',
{
defaultMessage: 'Loading Process',
}
)}
</GeneratedText>
</StyledEuiDescriptionListDescription>
<StyledEuiDescriptionListTitle data-test-subj="resolver:graph-controls:node-legend:title">
<CubeForProcess
id={id}
size="2.5em"
data-test-subj="resolver:node-detail:title-icon"
state="error"
/>
</StyledEuiDescriptionListTitle>
<StyledEuiDescriptionListDescription data-test-subj="resolver:graph-controls:node-legend:description">
<GeneratedText>
{i18n.translate('xpack.securitySolution.resolver.graphControls.errorCube', {
defaultMessage: 'Error Process',
})}
</GeneratedText>
</StyledEuiDescriptionListDescription>
</>
</StyledDescriptionList>
</div>
</EuiPopover>
);
};

View file

@ -46,7 +46,6 @@ interface ProcessTableView {
/**
* The "default" view for the panel: A list of all the processes currently in the graph.
*/
// eslint-disable-next-line react/display-name
export const NodeList = memo(({ id }: { id: string }) => {
const columns = useMemo<Array<EuiBasicTableColumn<ProcessTableView>>>(
() => [
@ -85,6 +84,7 @@ export const NodeList = memo(({ id }: { id: string }) => {
const processTableView: ProcessTableView[] = useSelector(
useCallback(
(state: State) => {
// console.log('lol WAT');
const { processNodePositions } = selectors.layout(state.analyzer[id]);
const view: ProcessTableView[] = [];
for (const treeNode of processNodePositions.keys()) {
@ -139,7 +139,8 @@ export const NodeList = memo(({ id }: { id: string }) => {
);
});
// eslint-disable-next-line react/display-name
NodeList.displayName = 'NodeList';
const NodeDetailLink = memo(
({ id, name, nodeID }: { id: string; name?: string; nodeID: string }) => {
const isOrigin = useSelector((state: State) => {
@ -211,7 +212,8 @@ const NodeDetailLink = memo(
}
);
// eslint-disable-next-line react/display-name
NodeDetailLink.displayName = 'NodeDetailLink';
const NodeDetailTimestamp = memo(
({ eventDate, id }: { eventDate: string | number | undefined; id: string }) => {
const formattedDate = useFormattedDate(eventDate);
@ -235,3 +237,5 @@ const NodeDetailTimestamp = memo(
);
}
);
NodeDetailTimestamp.displayName = 'NodeDetailTimestamp';

View file

@ -12,7 +12,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { useResolverQueryParamCleaner } from './use_resolver_query_params_cleaner';
import * as selectors from '../store/selectors';
import { EdgeLine } from './edge_line';
import { GraphControls } from './graph_controls';
import { GraphControls } from './controls';
import { ProcessEventDot } from './process_event_dot';
import { useCamera } from './use_camera';
import { SymbolDefinitions } from './symbol_definitions';

View file

@ -7,6 +7,8 @@
import { useRef, useEffect } from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { clearResolver } from '../store/actions';
import { parameterName } from '../store/parameter_name';
/**
* Cleanup any query string keys that were added by this Resolver instance.
@ -21,6 +23,7 @@ export function useResolverQueryParamCleaner(id: string) {
*/
const searchRef = useRef<string>();
searchRef.current = useLocation().search;
const dispatch = useDispatch();
const history = useHistory();
@ -46,6 +49,7 @@ export function useResolverQueryParamCleaner(id: string) {
urlSearchParams.delete(oldResolverKey);
const relativeURL = { search: urlSearchParams.toString() };
history.replace(relativeURL);
dispatch(clearResolver({ id }));
};
}, [resolverKey, history]);
}, [resolverKey, history, dispatch, id]);
}

View file

@ -13,16 +13,10 @@ import {
useGlobalFullScreen,
useTimelineFullScreen,
} from '../../../common/containers/use_full_screen';
import {
createMockStore,
mockGlobalState,
mockIndexNames,
TestProviders,
} from '../../../common/mock';
import { createMockStore, mockGlobalState, TestProviders } from '../../../common/mock';
import { TimelineId } from '../../../../common/types/timeline';
import { GraphOverlay } from '.';
import { useStateSyncingActions } from '../../../resolver/view/use_state_syncing_actions';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { TableId } from '@kbn/securitysolution-data-table';
jest.mock('../../../common/containers/use_full_screen', () => ({
@ -135,7 +129,7 @@ describe('GraphOverlay', () => {
);
expect(useStateSyncingActionsMock.mock.calls[0][0].indices).toEqual(
mockGlobalState.sourcerer.defaultDataView.patternList
mockGlobalState.sourcerer.sourcererScopes.analyzer.selectedPatterns
);
});
});
@ -177,47 +171,6 @@ describe('GraphOverlay', () => {
expect(overlayContainer).toHaveStyleRule('width', '100%');
});
test('it gets index pattern from Timeline data view', () => {
const mockedDefaultDataViewPattern = 'default-dataview-pattern';
render(
<TestProviders
store={createMockStore({
...mockGlobalState,
timeline: {
...mockGlobalState.timeline,
timelineById: {
[timelineId]: {
...mockGlobalState.timeline.timelineById[timelineId],
graphEventId: 'definitely-not-null',
},
},
},
sourcerer: {
...mockGlobalState.sourcerer,
defaultDataView: {
...mockGlobalState.sourcerer.defaultDataView,
patternList: [mockedDefaultDataViewPattern],
},
sourcererScopes: {
...mockGlobalState.sourcerer.sourcererScopes,
[SourcererScopeName.timeline]: {
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
selectedPatterns: mockIndexNames,
},
},
},
})}
>
<GraphOverlay SessionView={<div />} Navigation={<div />} scopeId={timelineId} />
</TestProviders>
);
expect(useStateSyncingActionsMock.mock.calls[0][0].indices).toEqual([
...mockIndexNames.sort(),
mockedDefaultDataViewPattern,
]);
});
test('it renders session view controls', () => {
(useGlobalFullScreen as jest.Mock).mockReturnValue({
globalFullScreen: false,

View file

@ -18,7 +18,6 @@ jest.mock('react-router-dom', () => {
const defaultDataViewPattern = 'test-dataview-patterns';
const timelinePattern = 'test-timeline-patterns';
const alertsPagePatterns = '.siem-signals-spacename';
const pathname = '/alerts';
const store = createMockStore({
...mockGlobalState,
@ -30,12 +29,25 @@ const store = createMockStore({
},
sourcererScopes: {
...mockGlobalState.sourcerer.sourcererScopes,
[SourcererScopeName.timeline]: {
[SourcererScopeName.analyzer]: {
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
selectedPatterns: [timelinePattern],
},
},
},
inputs: {
...mockGlobalState.inputs,
timeline: {
...mockGlobalState.inputs.timeline,
timerange: {
kind: 'relative',
fromStr: 'now/d',
toStr: 'now/d',
from: '2024-01-07T08:20:18.966Z',
to: '2024-01-08T08:20:18.966Z',
},
},
},
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
@ -44,18 +56,23 @@ const wrapper = ({ children }: { children: React.ReactNode }) => (
describe('useTimelineDataFilters', () => {
describe('on alerts page', () => {
it('returns default data view patterns and alerts page patterns when isActiveTimelines is falsy', () => {
const isActiveTimelines = false;
const { result } = renderHook(() => useTimelineDataFilters(isActiveTimelines), { wrapper });
it('uses the same selected patterns throughout the app', () => {
const { result } = renderHook(() => useTimelineDataFilters(false), { wrapper });
const { result: timelineResult } = renderHook(() => useTimelineDataFilters(true), {
wrapper,
});
expect(result.current.selectedPatterns).toEqual([alertsPagePatterns, defaultDataViewPattern]);
expect(result.current.selectedPatterns).toEqual(timelineResult.current.selectedPatterns);
});
it('returns default data view patterns and timelinePatterns when isActiveTimelines is truthy', () => {
const isActiveTimelines = true;
const { result } = renderHook(() => useTimelineDataFilters(isActiveTimelines), { wrapper });
it('allows the other parts of the query to remain unique', () => {
const { result } = renderHook(() => useTimelineDataFilters(false), { wrapper });
const { result: timelineResult } = renderHook(() => useTimelineDataFilters(true), {
wrapper,
});
expect(result.current.selectedPatterns).toEqual([timelinePattern, defaultDataViewPattern]);
expect(result.current.from !== timelineResult.current.from).toBeTruthy();
expect(result.current.to !== timelineResult.current.to).toBeTruthy();
});
});
});

View file

@ -7,13 +7,13 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useDeepEqualSelector } from '../../common/hooks/use_selector';
import {
isLoadingSelector,
startSelector,
endSelector,
} from '../../common/components/super_date_picker/selectors';
import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features';
import { SourcererScopeName } from '../../common/store/sourcerer/model';
import { useSourcererDataView, getScopeFromPath } from '../../common/containers/sourcerer';
import { sourcererSelectors } from '../../common/store';
@ -22,6 +22,9 @@ export function useTimelineDataFilters(isActiveTimelines: boolean) {
const getStartSelector = useMemo(() => startSelector(), []);
const getEndSelector = useMemo(() => endSelector(), []);
const getIsLoadingSelector = useMemo(() => isLoadingSelector(), []);
const isDatePickerAndSourcererDisabled = useIsExperimentalFeatureEnabled(
'analyzerDatePickersAndSourcererDisabled'
);
const shouldUpdate = useDeepEqualSelector((state) => {
if (isActiveTimelines) {
@ -62,10 +65,21 @@ export function useTimelineDataFilters(isActiveTimelines: boolean) {
: [...new Set([...nonTimelinePatterns, ...defaultDataView.patternList])];
}, [isActiveTimelines, timelinePatterns, nonTimelinePatterns, defaultDataView.patternList]);
return {
const { selectedPatterns: analyzerPatterns } = useSourcererDataView(SourcererScopeName.analyzer);
return useMemo(() => {
return {
selectedPatterns: isDatePickerAndSourcererDisabled ? selectedPatterns : analyzerPatterns,
from,
to,
shouldUpdate,
};
}, [
selectedPatterns,
from,
to,
shouldUpdate,
};
isDatePickerAndSourcererDisabled,
analyzerPatterns,
]);
}