Skip to content

Commit

Permalink
[SIEM] update url state between page if date is relative (#56813)
Browse files Browse the repository at this point in the history
* update url state between page if needed it like time range

* fix round up for to

* simplify type

* leftover cleanup

* we forget to update relative date when loading the page by a refresh

* pair with Garrett to make a minimal impact on the ux

* fix detetections tabs
  • Loading branch information
XavierM committed Feb 7, 2020
1 parent 6604f13 commit 82dd5bd
Show file tree
Hide file tree
Showing 9 changed files with 413 additions and 212 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,11 @@ export const LinkToPage = React.memo<LinkToPageProps>(({ match }) => (
component={RedirectToDetectionEnginePage}
exact
path={`${match.url}/:pageName(${SiemPageName.detections})`}
strict
/>
<Route
component={RedirectToDetectionEnginePage}
exact
path={`${match.url}/:pageName(${SiemPageName.detections})/:tabName(${DetectionEngineTab.alerts}|${DetectionEngineTab.signals})`}
strict
/>
<Route
component={RedirectToRulesPage}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const getSearch = (tab: SearchNavTab, urlState: TabNavigationProps): stri
replaceStateKeyInQueryString(
urlKey,
urlStateToReplace
)(getQueryStringFromLocation(myLocation))
)(getQueryStringFromLocation(myLocation.search))
);
},
{
Expand Down
137 changes: 121 additions & 16 deletions x-pack/legacy/plugins/siem/public/components/url_state/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,32 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { decode, encode, RisonValue } from 'rison-node';
import { Location } from 'history';
import { decode, encode } from 'rison-node';
import * as H from 'history';
import { QueryString } from 'ui/utils/query_string';
import { Query, esFilters } from 'src/plugins/data/public';

import { inputsSelectors, State, timelineSelectors } from '../../store';
import { isEmpty } from 'lodash/fp';
import { SiemPageName } from '../../pages/home/types';
import { inputsSelectors, State, timelineSelectors } from '../../store';
import { UrlInputsModel } from '../../store/inputs/model';
import { formatDate } from '../super_date_picker';
import { NavTab } from '../navigation/types';
import { CONSTANTS, UrlStateType } from './constants';
import { LocationTypes, UrlStateContainerPropTypes } from './types';
import {
LocationTypes,
UrlStateContainerPropTypes,
ReplaceStateInLocation,
Timeline,
UpdateUrlStateString,
} from './types';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const decodeRisonUrlState = (value: string | undefined): RisonValue | any | undefined => {
export const decodeRisonUrlState = <T>(value: string | undefined): T | null => {
try {
return value ? decode(value) : undefined;
return value ? ((decode(value) as unknown) as T) : null;
} catch (error) {
if (error instanceof Error && error.message.startsWith('rison decoder error')) {
return {};
return null;
}
throw error;
}
Expand All @@ -30,18 +38,16 @@ export const decodeRisonUrlState = (value: string | undefined): RisonValue | any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const encodeRisonUrlState = (state: any) => encode(state);

export const getQueryStringFromLocation = (location: Location) => location.search.substring(1);
export const getQueryStringFromLocation = (search: string) => search.substring(1);

export const getParamFromQueryString = (queryString: string, key: string): string | undefined => {
const queryParam = QueryString.decode(queryString)[key];
return Array.isArray(queryParam) ? queryParam[0] : queryParam;
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const replaceStateKeyInQueryString = <UrlState extends any>(
stateKey: string,
urlState: UrlState | undefined
) => (queryString: string) => {
export const replaceStateKeyInQueryString = <T>(stateKey: string, urlState: T) => (
queryString: string
): string => {
const previousQueryValues = QueryString.decode(queryString);
if (urlState == null || (typeof urlState === 'string' && urlState === '')) {
delete previousQueryValues[stateKey];
Expand All @@ -60,8 +66,11 @@ export const replaceStateKeyInQueryString = <UrlState extends any>(
});
};

export const replaceQueryStringInLocation = (location: Location, queryString: string): Location => {
if (queryString === getQueryStringFromLocation(location)) {
export const replaceQueryStringInLocation = (
location: H.Location,
queryString: string
): H.Location => {
if (queryString === getQueryStringFromLocation(location.search)) {
return location;
} else {
return {
Expand Down Expand Up @@ -173,3 +182,99 @@ export const makeMapStateToProps = () => {

return mapStateToProps;
};

export const updateTimerangeUrl = (
timeRange: UrlInputsModel,
isInitializing: boolean
): UrlInputsModel => {
if (timeRange.global.timerange.kind === 'relative') {
timeRange.global.timerange.from = formatDate(timeRange.global.timerange.fromStr);
timeRange.global.timerange.to = formatDate(timeRange.global.timerange.toStr, { roundUp: true });
}
if (timeRange.timeline.timerange.kind === 'relative' && isInitializing) {
timeRange.timeline.timerange.from = formatDate(timeRange.timeline.timerange.fromStr);
timeRange.timeline.timerange.to = formatDate(timeRange.timeline.timerange.toStr, {
roundUp: true,
});
}
return timeRange;
};

export const updateUrlStateString = ({
isInitializing,
history,
newUrlStateString,
pathName,
search,
updateTimerange,
urlKey,
}: UpdateUrlStateString): string => {
if (urlKey === CONSTANTS.appQuery) {
const queryState = decodeRisonUrlState<Query>(newUrlStateString);
if (queryState != null && queryState.query === '') {
return replaceStateInLocation({
history,
pathName,
search,
urlStateToReplace: '',
urlStateKey: urlKey,
});
}
} else if (urlKey === CONSTANTS.timerange && updateTimerange) {
const queryState = decodeRisonUrlState<UrlInputsModel>(newUrlStateString);
if (queryState != null && queryState.global != null) {
return replaceStateInLocation({
history,
pathName,
search,
urlStateToReplace: updateTimerangeUrl(queryState, isInitializing),
urlStateKey: urlKey,
});
}
} else if (urlKey === CONSTANTS.filters) {
const queryState = decodeRisonUrlState<esFilters.Filter[]>(newUrlStateString);
if (isEmpty(queryState)) {
return replaceStateInLocation({
history,
pathName,
search,
urlStateToReplace: '',
urlStateKey: urlKey,
});
}
} else if (urlKey === CONSTANTS.timeline) {
const queryState = decodeRisonUrlState<Timeline>(newUrlStateString);
if (queryState != null && queryState.id === '') {
return replaceStateInLocation({
history,
pathName,
search,
urlStateToReplace: '',
urlStateKey: urlKey,
});
}
}
return search;
};

export const replaceStateInLocation = <T>({
history,
urlStateToReplace,
urlStateKey,
pathName,
search,
}: ReplaceStateInLocation<T>) => {
const newLocation = replaceQueryStringInLocation(
{
hash: '',
pathname: pathName,
search,
state: '',
},
replaceStateKeyInQueryString(urlStateKey, urlStateToReplace)(getQueryStringFromLocation(search))
);
if (history) {
history.replace(newLocation);
}
return newLocation.search;
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import React from 'react';
import { HookWrapper } from '../../mock';
import { SiemPageName } from '../../pages/home/types';
import { RouteSpyState } from '../../utils/route/types';

import { CONSTANTS } from './constants';
import {
getMockPropsObj,
Expand All @@ -22,6 +21,7 @@ import {
} from './test_dependencies';
import { UrlStateContainerPropTypes } from './types';
import { useUrlStateHooks } from './use_url_state';
import { wait } from '../../lib/helpers';

let mockProps: UrlStateContainerPropTypes;

Expand All @@ -43,6 +43,12 @@ jest.mock('../search_bar', () => ({
},
}));

jest.mock('../super_date_picker', () => ({
formatDate: (date: string) => {
return 11223344556677;
},
}));

describe('UrlStateContainer', () => {
afterEach(() => {
jest.resetAllMocks();
Expand All @@ -63,19 +69,19 @@ describe('UrlStateContainer', () => {
mount(<HookWrapper hookProps={mockProps} hook={args => useUrlStateHooks(args)} />);

expect(mockSetRelativeRangeDatePicker.mock.calls[1][0]).toEqual({
from: 1558591200000,
from: 11223344556677,
fromStr: 'now-1d/d',
kind: 'relative',
to: 1558677599999,
to: 11223344556677,
toStr: 'now-1d/d',
id: 'global',
});

expect(mockSetRelativeRangeDatePicker.mock.calls[0][0]).toEqual({
from: 1558732849370,
from: 11223344556677,
fromStr: 'now-15m',
kind: 'relative',
to: 1558733749370,
to: 11223344556677,
toStr: 'now',
id: 'timeline',
});
Expand Down Expand Up @@ -155,4 +161,57 @@ describe('UrlStateContainer', () => {
});
});
});

describe('After Initialization, keep Relative Date up to date for global only on detections page', () => {
test.each(testCases)(
'%o',
async (page, namespaceLower, namespaceUpper, examplePath, type, pageName, detailName) => {
mockProps = getMockPropsObj({
page,
examplePath,
namespaceLower,
pageName,
detailName,
}).relativeTimeSearch.undefinedQuery;
const wrapper = mount(
<HookWrapper hookProps={mockProps} hook={args => useUrlStateHooks(args)} />
);

wrapper.setProps({
hookProps: getMockPropsObj({
page: CONSTANTS.hostsPage,
examplePath: '/hosts',
namespaceLower: 'hosts',
pageName: SiemPageName.hosts,
detailName: undefined,
}).relativeTimeSearch.undefinedQuery,
});
wrapper.update();
await wait();

if (CONSTANTS.detectionsPage === page) {
expect(mockSetRelativeRangeDatePicker.mock.calls[3][0]).toEqual({
from: 11223344556677,
fromStr: 'now-1d/d',
kind: 'relative',
to: 11223344556677,
toStr: 'now-1d/d',
id: 'global',
});

expect(mockSetRelativeRangeDatePicker.mock.calls[2][0]).toEqual({
from: 1558732849370,
fromStr: 'now-15m',
kind: 'relative',
to: 1558733749370,
toStr: 'now',
id: 'timeline',
});
} else {
// There is no change in url state, so that's expected we only have two actions
expect(mockSetRelativeRangeDatePicker.mock.calls.length).toEqual(2);
}
}
);
});
});
Loading

0 comments on commit 82dd5bd

Please sign in to comment.