diff --git a/x-pack/plugins/siem/public/components/url_state/__snapshots__/index.test.tsx.snap b/x-pack/plugins/siem/public/components/url_state/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..1ec61ad98e608 --- /dev/null +++ b/x-pack/plugins/siem/public/components/url_state/__snapshots__/index.test.tsx.snap @@ -0,0 +1,544 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UrlStateComponents UrlStateContainer mounts and renders 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/x-pack/plugins/siem/public/components/url_state/index.test.tsx b/x-pack/plugins/siem/public/components/url_state/index.test.tsx new file mode 100644 index 0000000000000..5840f2551b88d --- /dev/null +++ b/x-pack/plugins/siem/public/components/url_state/index.test.tsx @@ -0,0 +1,296 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mount, shallow } from 'enzyme'; +import toJson from 'enzyme-to-json'; +import * as React from 'react'; +import { Router } from 'react-router-dom'; +import { MockedProvider } from 'react-apollo/test-utils'; + +import { + isKqlForRoute, + UrlStateContainer, + UrlStateContainerLifecycle, + UrlStateContainerLifecycleProps, +} from './'; +import { mockGlobalState, TestProviders } from '../../mock'; +import { + createStore, + hostsModel, + networkModel, + State, + KueryFilterQuery, + SerializedFilterQuery, + KueryFilterModel, +} from '../../store'; +import { ActionCreator } from 'typescript-fsa'; +import { InputsModelId } from '../../store/inputs/model'; +import { wait } from '../../lib/helpers'; + +type Action = 'PUSH' | 'POP' | 'REPLACE'; +const pop: Action = 'POP'; +const location = { + pathname: '/network', + search: '', + state: '', + hash: '', +}; +const mockHistory = { + length: 2, + location, + action: pop, + push: jest.fn(), + replace: jest.fn(), + go: jest.fn(), + goBack: jest.fn(), + goForward: jest.fn(), + block: jest.fn(), + createHref: jest.fn(), + listen: jest.fn(), +}; + +const filterQuery: KueryFilterQuery = { + expression: 'host.name:"siem-es"', + kind: 'kuery', +}; + +const mockProps: UrlStateContainerLifecycleProps = { + history: mockHistory, + location, + indexPattern: { + fields: [ + { + name: '@timestamp', + searchable: true, + type: 'date', + aggregatable: true, + }, + ], + title: 'filebeat-*,packetbeat-*', + }, + urlState: { + timerange: { + global: { + kind: 'relative', + fromStr: 'now-24h', + toStr: 'now', + from: 1558048243696, + to: 1558134643697, + linkTo: ['timeline'], + }, + timeline: { + kind: 'relative', + fromStr: 'now-24h', + toStr: 'now', + from: 1558048243696, + to: 1558134643697, + linkTo: ['global'], + }, + }, + kqlQuery: [ + { + filterQuery, + type: networkModel.NetworkType.page, + model: KueryFilterModel.network, + }, + ], + }, + setAbsoluteTimerange: (jest.fn() as unknown) as ActionCreator<{ + from: number; + fromStr: undefined; + id: InputsModelId; + to: number; + toStr: undefined; + }>, + setHostsKql: (jest.fn() as unknown) as ActionCreator<{ + filterQuery: SerializedFilterQuery; + hostsType: hostsModel.HostsType; + }>, + setNetworkKql: (jest.fn() as unknown) as ActionCreator<{ + filterQuery: SerializedFilterQuery; + networkType: networkModel.NetworkType; + }>, + setRelativeTimerange: (jest.fn() as unknown) as ActionCreator<{ + from: number; + fromStr: string; + id: InputsModelId; + to: number; + toStr: string; + }>, + toggleTimelineLinkTo: (jest.fn() as unknown) as ActionCreator<{ + linkToId: InputsModelId; + }>, +}; + +describe('UrlStateComponents', () => { + describe('UrlStateContainer', () => { + const state: State = mockGlobalState; + + let store = createStore(state); + + beforeEach(() => { + store = createStore(state); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + test('mounts and renders', () => { + const wrapper = mount( + + + + + + + + ); + const urlStateComponents = wrapper.find('[data-test-subj="urlStateComponents"]'); + urlStateComponents.exists(); + expect(toJson(wrapper)).toMatchSnapshot(); + }); + test('componentDidUpdate - timerange redux state updates the url', async () => { + const wrapper = shallow(); + + const newUrlState = { + timerange: { + timeline: { + kind: 'relative', + fromStr: 'now-24h', + toStr: 'now', + from: 1558048243696, + to: 1558134643697, + linkTo: ['global'], + }, + }, + }; + + wrapper.setProps({ urlState: newUrlState }); + wrapper.update(); + await wait(1000); + expect(mockHistory.replace.mock.calls[1][0]).toStrictEqual({ + hash: '', + pathname: '/network', + search: + '?timerange=(global:(linkTo:!(timeline),timerange:(from:0,fromStr:now-24h,kind:relative,to:1,toStr:now)),timeline:(linkTo:(from:0,fromStr:now-24h,kind:relative,to:1,toStr:now),timerange:!(global)))', + state: '', + }); + }); + test('componentDidUpdate - kql query redux state updates the url', async () => { + const wrapper = shallow(); + + const newUrlState = { + kqlQuery: [ + { + filterQuery, + type: networkModel.NetworkType.details, + model: KueryFilterModel.network, + }, + ], + }; + + wrapper.setProps({ urlState: newUrlState }); + wrapper.update(); + await wait(1000); + expect(mockHistory.replace.mock.calls[1][0]).toStrictEqual({ + hash: '', + pathname: '/network', + search: + "?kqlQuery=(filterQuery:(expression:'host.name:%22siem-es%22',kind:kuery),model:network,type:details)", + state: '', + }); + }); + }); + describe('isKqlForRoute', () => { + test('host page and host page kuery', () => { + const result = isKqlForRoute('/hosts', { + filterQuery: { + expression: 'host.name:"siem-kibana"', + kind: 'kuery', + }, + model: KueryFilterModel.hosts, + type: hostsModel.HostsType.page, + }); + expect(result).toBeTruthy(); + }); + test('host page and host details kuery', () => { + const result = isKqlForRoute('/hosts', { + filterQuery: { + expression: 'host.name:"siem-kibana"', + kind: 'kuery', + }, + model: KueryFilterModel.hosts, + type: hostsModel.HostsType.details, + }); + expect(result).toBeFalsy(); + }); + test('host details and host details kuery', () => { + const result = isKqlForRoute('/hosts/siem-kibana', { + filterQuery: { + expression: 'host.name:"siem-kibana"', + kind: 'kuery', + }, + model: KueryFilterModel.hosts, + type: hostsModel.HostsType.details, + }); + expect(result).toBeTruthy(); + }); + test('host details and host page kuery', () => { + const result = isKqlForRoute('/hosts/siem-kibana', { + filterQuery: { + expression: 'host.name:"siem-kibana"', + kind: 'kuery', + }, + model: KueryFilterModel.hosts, + type: hostsModel.HostsType.page, + }); + expect(result).toBeFalsy(); + }); + test('network page and network page kuery', () => { + const result = isKqlForRoute('/network', { + filterQuery: { + expression: 'network.name:"siem-kibana"', + kind: 'kuery', + }, + model: KueryFilterModel.network, + type: networkModel.NetworkType.page, + }); + expect(result).toBeTruthy(); + }); + test('network page and network details kuery', () => { + const result = isKqlForRoute('/network', { + filterQuery: { + expression: 'network.name:"siem-kibana"', + kind: 'kuery', + }, + model: KueryFilterModel.network, + type: networkModel.NetworkType.details, + }); + expect(result).toBeFalsy(); + }); + test('network details and network details kuery', () => { + const result = isKqlForRoute('/network/ip/10.100.7.198', { + filterQuery: { + expression: 'network.name:"siem-kibana"', + kind: 'kuery', + }, + model: KueryFilterModel.network, + type: networkModel.NetworkType.details, + }); + expect(result).toBeTruthy(); + }); + test('network details and network page kuery', () => { + const result = isKqlForRoute('/network/ip/123.234.34', { + filterQuery: { + expression: 'network.name:"siem-kibana"', + kind: 'kuery', + }, + model: KueryFilterModel.network, + type: networkModel.NetworkType.page, + }); + expect(result).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/siem/public/components/url_state/index.tsx b/x-pack/plugins/siem/public/components/url_state/index.tsx new file mode 100644 index 0000000000000..5df5de0f73283 --- /dev/null +++ b/x-pack/plugins/siem/public/components/url_state/index.tsx @@ -0,0 +1,439 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { History, Location } from 'history'; +import { get, throttle } from 'lodash/fp'; +import React from 'react'; +import { pure } from 'recompose'; +import { connect } from 'react-redux'; +import { Route, RouteProps } from 'react-router-dom'; +import { decode, encode, RisonValue } from 'rison-node'; + +import { QueryString } from 'ui/utils/query_string'; +import { ActionCreator } from 'typescript-fsa'; +import { StaticIndexPattern } from 'ui/index_patterns'; +import { hostsActions, inputsActions, networkActions } from '../../store/actions'; +import { + hostsModel, + hostsSelectors, + inputsSelectors, + KueryFilterModel, + KueryFilterQuery, + networkModel, + networkSelectors, + SerializedFilterQuery, + State, +} from '../../store'; +import { + InputsModelId, + TimeRangeKinds, + UrlInputsModel, + UrlTimeRange, +} from '../../store/inputs/model'; +import { convertKueryToElasticSearchQuery } from '../../lib/keury'; + +interface KqlQueryHosts { + filterQuery: KueryFilterQuery | null; + model: KueryFilterModel.hosts; + type: hostsModel.HostsType; +} + +interface KqlQueryNetwork { + filterQuery: KueryFilterQuery | null; + model: KueryFilterModel.network; + type: networkModel.NetworkType; +} + +export type KqlQuery = KqlQueryHosts | KqlQueryNetwork; + +interface UrlState { + kqlQuery: KqlQuery[]; + timerange: UrlInputsModel; +} +type KeyUrlState = keyof UrlState; + +interface UrlStateProps { + indexPattern: StaticIndexPattern; + mapToUrlState?: (value: any) => UrlState; + onChange?: (urlState: UrlState, previousUrlState: UrlState) => void; + onInitialize?: (urlState: UrlState) => void; + urlState: UrlState; +} + +interface UrlStateDispatchProps { + setHostsKql: ActionCreator<{ + filterQuery: SerializedFilterQuery; + hostsType: hostsModel.HostsType; + }>; + setNetworkKql: ActionCreator<{ + filterQuery: SerializedFilterQuery; + networkType: networkModel.NetworkType; + }>; + setAbsoluteTimerange: ActionCreator<{ + from: number; + fromStr: undefined; + id: InputsModelId; + to: number; + toStr: undefined; + }>; + setRelativeTimerange: ActionCreator<{ + from: number; + fromStr: string; + id: InputsModelId; + to: number; + toStr: string; + }>; + toggleTimelineLinkTo: ActionCreator<{ + linkToId: InputsModelId; + }>; +} + +interface TimerangeActions { + absolute: ActionCreator<{ + from: number; + fromStr: undefined; + id: InputsModelId; + to: number; + toStr: undefined; + }>; + relative: ActionCreator<{ + from: number; + fromStr: string; + id: InputsModelId; + to: number; + toStr: string; + }>; +} + +interface KqlActions { + hosts: ActionCreator<{ + filterQuery: SerializedFilterQuery; + hostsType: hostsModel.HostsType; + }>; + network: ActionCreator<{ + filterQuery: SerializedFilterQuery; + networkType: networkModel.NetworkType; + }>; +} + +interface UrlStateMappedToActionsType { + kqlQuery: KqlActions; + timerange: TimerangeActions; +} + +type UrlStateContainerProps = UrlStateProps & UrlStateDispatchProps; + +interface UrlStateRouterProps { + history: History; + location: Location; +} + +export type UrlStateContainerLifecycleProps = UrlStateRouterProps & UrlStateContainerProps; + +export const isKqlForRoute = (pathname: string, kql: KqlQuery): boolean => { + const trailingPath = pathname.match(/([^\/]+$)/); + if (trailingPath !== null) { + if ( + (trailingPath[0] === 'hosts' && + kql.model === 'hosts' && + kql.type === hostsModel.HostsType.page) || + (trailingPath[0] === 'network' && + kql.model === 'network' && + kql.type === networkModel.NetworkType.page) || + (pathname.match(/hosts\/.*?/) && + kql.model === 'hosts' && + kql.type === hostsModel.HostsType.details) || + (pathname.match(/network\/ip\/.*?/) && + kql.model === 'network' && + kql.type === networkModel.NetworkType.details) + ) { + return true; + } + } + return false; +}; + +export class UrlStateContainerLifecycle extends React.Component { + public render() { + return null; + } + + public componentDidUpdate({ + location: prevLocation, + urlState: prevUrlState, + }: UrlStateContainerLifecycleProps) { + const { history, location, urlState } = this.props; + if (JSON.stringify(urlState) !== JSON.stringify(prevUrlState)) { + Object.keys(urlState).forEach((key: string) => { + if ('kqlQuery' === key || 'timerange' === key) { + const urlKey: KeyUrlState = key; + if (JSON.stringify(urlState[urlKey]) !== JSON.stringify(prevUrlState[urlKey])) { + if (urlKey === 'kqlQuery') { + urlState.kqlQuery.forEach((value: KqlQuery, index: number) => { + if ( + JSON.stringify(urlState.kqlQuery[index]) !== + JSON.stringify(prevUrlState.kqlQuery[index]) + ) { + this.replaceStateInLocation(urlState.kqlQuery[index], 'kqlQuery'); + } + }); + } else { + this.replaceStateInLocation(urlState[urlKey], urlKey); + } + } + } + }); + } + + if (history.action === 'POP' && location !== prevLocation) { + this.handleLocationChange(prevLocation, location); + } + } + + public componentDidMount() { + const { location } = this.props; + this.handleInitialize(location); + } + + private replaceStateInLocation = throttle( + 1000, + (urlState: UrlInputsModel | KqlQuery | KqlQuery[], urlStateKey: string) => { + const { history, location } = this.props; + const newLocation = replaceQueryStringInLocation( + location, + replaceStateKeyInQueryString(urlStateKey, urlState)(getQueryStringFromLocation(location)) + ); + + if (newLocation !== location) { + history.replace(newLocation); + } + } + ); + + private urlStateMappedToActions: UrlStateMappedToActionsType = { + kqlQuery: { + hosts: this.props.setHostsKql, + network: this.props.setNetworkKql, + }, + timerange: { + absolute: this.props.setAbsoluteTimerange, + relative: this.props.setRelativeTimerange, + }, + }; + + private handleInitialize = (location: Location) => { + Object.keys(this.urlStateMappedToActions).forEach((key: string) => { + if ('kqlQuery' === key || 'timerange' === key) { + const urlKey: KeyUrlState = key; + const newUrlStateString = getParamFromQueryString( + getQueryStringFromLocation(location), + urlKey + ); + if (newUrlStateString) { + if (urlKey === 'timerange') { + const timerangeStateData: UrlInputsModel = decodeRisonUrlState(newUrlStateString); + const globalId: InputsModelId = 'global'; + const globalRange: UrlTimeRange = timerangeStateData.global; + const globalType: TimeRangeKinds = get('global.kind', timerangeStateData); + if (globalType) { + if (globalRange.linkTo.length === 0) { + this.props.toggleTimelineLinkTo({ linkToId: 'global' }); + } + // @ts-ignore + this.urlStateMappedToActions.timerange[timelineType]({ + ...globalRange, + id: globalId, + }); + } + const timelineId: InputsModelId = 'timeline'; + const timelineRange: UrlTimeRange = timerangeStateData.timeline; + const timelineType: TimeRangeKinds = get('timeline.kind', timerangeStateData); + if (timelineType) { + if (timelineRange.linkTo.length === 0) { + this.props.toggleTimelineLinkTo({ linkToId: 'timeline' }); + } + // @ts-ignore + this.urlStateMappedToActions.timerange[timelineType]({ + ...timelineRange, + id: timelineId, + }); + } + } + if (urlKey === 'kqlQuery') { + const kqlQueryStateData: KqlQuery = decodeRisonUrlState(newUrlStateString); + if (isKqlForRoute(location.pathname, kqlQueryStateData)) { + const filterQuery = { + query: kqlQueryStateData.filterQuery, + serializedQuery: convertKueryToElasticSearchQuery( + kqlQueryStateData.filterQuery ? kqlQueryStateData.filterQuery.expression : '', + this.props.indexPattern + ), + }; + if (kqlQueryStateData.model === 'hosts') { + this.urlStateMappedToActions.kqlQuery.hosts({ + filterQuery, + hostsType: kqlQueryStateData.type, + }); + } + if (kqlQueryStateData.model === 'network') { + this.urlStateMappedToActions.kqlQuery.network({ + filterQuery, + networkType: kqlQueryStateData.type, + }); + } + } + } + } else { + this.replaceStateInLocation(this.props.urlState[urlKey], urlKey); + } + } + }); + }; + + private handleLocationChange = (prevLocation: Location, newLocation: Location) => { + const { onChange, mapToUrlState } = this.props; + + if (!onChange || !mapToUrlState) { + return; + } + + const previousUrlStateString = getParamFromQueryString( + getQueryStringFromLocation(prevLocation), + 'urlStateKey' + ); + const newUrlStateString = getParamFromQueryString( + getQueryStringFromLocation(newLocation), + 'urlStateKey' + ); + + if (previousUrlStateString !== newUrlStateString) { + const previousUrlState = mapToUrlState(decodeRisonUrlState(previousUrlStateString)); + const newUrlState = mapToUrlState(decodeRisonUrlState(newUrlStateString)); + + if (typeof newUrlState !== 'undefined') { + onChange(newUrlState, previousUrlState); + } + } + }; +} + +export const UrlStateComponents = pure(props => ( + > + {({ history, location }) => ( + + )} + +)); + +const makeMapStateToProps = () => { + const getInputsSelector = inputsSelectors.inputsSelector(); + const getHostsFilterQueryAsKuery = hostsSelectors.hostsFilterQueryAsKuery(); + const getNetworkFilterQueryAsKuery = networkSelectors.networkFilterQueryAsKuery(); + const mapStateToProps = (state: State) => { + const inputState = getInputsSelector(state); + const { linkTo: globalLinkTo, timerange: globalTimerange } = inputState.global; + const { linkTo: timelineLinkTo, timerange: timelineTimerange } = inputState.timeline; + + return { + urlState: { + timerange: { + global: { + timerange: globalTimerange, + linkTo: globalLinkTo, + }, + timeline: { + timerange: timelineLinkTo, + linkTo: timelineTimerange, + }, + }, + kqlQuery: [ + { + filterQuery: getHostsFilterQueryAsKuery(state, hostsModel.HostsType.details), + type: hostsModel.HostsType.details, + model: KueryFilterModel.hosts, + }, + { + filterQuery: getHostsFilterQueryAsKuery(state, hostsModel.HostsType.page), + type: hostsModel.HostsType.page, + model: KueryFilterModel.hosts, + }, + { + filterQuery: getNetworkFilterQueryAsKuery(state, networkModel.NetworkType.details), + type: networkModel.NetworkType.details, + model: KueryFilterModel.network, + }, + { + filterQuery: getNetworkFilterQueryAsKuery(state, networkModel.NetworkType.page), + type: networkModel.NetworkType.page, + model: KueryFilterModel.network, + }, + ], + }, + }; + }; + + return mapStateToProps; +}; +export const UrlStateContainer = connect( + makeMapStateToProps, + { + setAbsoluteTimerange: inputsActions.setAbsoluteRangeDatePicker, + setHostsKql: hostsActions.applyHostsFilterQuery, + setNetworkKql: networkActions.applyNetworkFilterQuery, + setRelativeTimerange: inputsActions.setRelativeRangeDatePicker, + toggleTimelineLinkTo: inputsActions.toggleTimelineLinkTo, + } + // @ts-ignore +)(UrlStateComponents); + +export const decodeRisonUrlState = (value: string | undefined): RisonValue | any | undefined => { + try { + return value ? decode(value) : undefined; + } catch (error) { + if (error instanceof Error && error.message.startsWith('rison decoder error')) { + return {}; + } + throw error; + } +}; + +const encodeRisonUrlState = (state: any) => encode(state); + +export const getQueryStringFromLocation = (location: Location) => location.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; +}; + +export const replaceStateKeyInQueryString = ( + stateKey: string, + urlState: UrlState | undefined +) => (queryString: string) => { + const previousQueryValues = QueryString.decode(queryString); + const encodedUrlState = + typeof urlState !== 'undefined' ? encodeRisonUrlState(urlState) : undefined; + return QueryString.encode({ + ...previousQueryValues, + [stateKey]: encodedUrlState, + }); +}; + +const replaceQueryStringInLocation = (location: Location, queryString: string): Location => { + if (queryString === getQueryStringFromLocation(location)) { + return location; + } else { + return { + ...location, + search: `?${queryString}`, + }; + } +}; diff --git a/x-pack/plugins/siem/public/pages/hosts/host_details.tsx b/x-pack/plugins/siem/public/pages/hosts/host_details.tsx index b8dce62ccd234..e3bcadac73afa 100644 --- a/x-pack/plugins/siem/public/pages/hosts/host_details.tsx +++ b/x-pack/plugins/siem/public/pages/hosts/host_details.tsx @@ -35,6 +35,7 @@ import { hostsModel, hostsSelectors, State } from '../../store'; import { HostsKql } from './kql'; import * as i18n from './translations'; +import { UrlStateContainer } from '../../components/url_state'; const basePath = chrome.getBasePath(); const type = hostsModel.HostsType.details; @@ -65,6 +66,7 @@ const HostDetailsComponent = pure( + ; -describe('Hosts', () => { - describe('rendering', () => { - beforeEach(() => { - localSource = cloneDeep(mocksSource); - }); +type Action = 'PUSH' | 'POP' | 'REPLACE'; +const pop: Action = 'POP'; +const location = { + pathname: '/network', + search: '', + state: '', + hash: '', +}; +const mockHistory = { + length: 2, + location, + action: pop, + push: jest.fn(), + replace: jest.fn(), + go: jest.fn(), + goBack: jest.fn(), + goForward: jest.fn(), + block: jest.fn(), + createHref: jest.fn(), + listen: jest.fn(), +}; - test('it renders the Setup Instructions text when no index is available', async () => { - localSource[0].result.data.source.status.auditbeatIndicesExist = false; - localSource[0].result.data.source.status.filebeatIndicesExist = false; - localSource[0].result.data.source.status.winlogbeatIndicesExist = false; - const wrapper = mount( - - +// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769 +/* eslint-disable no-console */ +const originalError = console.error; + +describe('Hosts - rendering', () => { + beforeAll(() => { + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); + beforeEach(() => { + localSource = cloneDeep(mocksSource); + }); + + test('it renders the Setup Instructions text when no index is available', async () => { + localSource[0].result.data.source.status.auditbeatIndicesExist = false; + localSource[0].result.data.source.status.filebeatIndicesExist = false; + localSource[0].result.data.source.status.winlogbeatIndicesExist = false; + const wrapper = mount( + + + - - - ); - // Why => https://github.com/apollographql/react-apollo/issues/1711 - await new Promise(resolve => setTimeout(resolve)); - wrapper.update(); - expect(wrapper.text()).toContain(i18n.SETUP_INSTRUCTIONS); - }); + + + + ); + // Why => https://github.com/apollographql/react-apollo/issues/1711 + await new Promise(resolve => setTimeout(resolve)); + wrapper.update(); + expect(wrapper.text()).toContain(i18n.SETUP_INSTRUCTIONS); + }); - test('it renders the Setup Instructions text when auditbeat index is not available', async () => { - localSource[0].result.data.source.status.auditbeatIndicesExist = false; - localSource[0].result.data.source.status.filebeatIndicesExist = true; - localSource[0].result.data.source.status.winlogbeatIndicesExist = true; - const wrapper = mount( - - + test('it renders the Setup Instructions text when auditbeat index is not available', async () => { + localSource[0].result.data.source.status.auditbeatIndicesExist = false; + localSource[0].result.data.source.status.filebeatIndicesExist = true; + localSource[0].result.data.source.status.winlogbeatIndicesExist = true; + const wrapper = mount( + + + - - - ); - // Why => https://github.com/apollographql/react-apollo/issues/1711 - await new Promise(resolve => setTimeout(resolve)); - wrapper.update(); - expect(wrapper.text()).toContain(i18n.SETUP_INSTRUCTIONS); - }); + + + + ); + // Why => https://github.com/apollographql/react-apollo/issues/1711 + await new Promise(resolve => setTimeout(resolve)); + wrapper.update(); + expect(wrapper.text()).toContain(i18n.SETUP_INSTRUCTIONS); + }); - test('it DOES NOT render the Setup Instructions text when auditbeat index is available', async () => { - localSource[0].result.data.source.status.auditbeatIndicesExist = true; - localSource[0].result.data.source.status.filebeatIndicesExist = false; - localSource[0].result.data.source.status.winlogbeatIndicesExist = false; - const wrapper = mount( - - + test('it DOES NOT render the Setup Instructions text when auditbeat index is available', async () => { + localSource[0].result.data.source.status.auditbeatIndicesExist = true; + localSource[0].result.data.source.status.filebeatIndicesExist = false; + localSource[0].result.data.source.status.winlogbeatIndicesExist = false; + const wrapper = mount( + + + - - - ); - // Why => https://github.com/apollographql/react-apollo/issues/1711 - await new Promise(resolve => setTimeout(resolve)); - wrapper.update(); - expect(wrapper.text()).not.toContain(i18n.SETUP_INSTRUCTIONS); - }); + + + + ); + // Why => https://github.com/apollographql/react-apollo/issues/1711 + await new Promise(resolve => setTimeout(resolve)); + wrapper.update(); + expect(wrapper.text()).not.toContain(i18n.SETUP_INSTRUCTIONS); }); }); diff --git a/x-pack/plugins/siem/public/pages/hosts/hosts.tsx b/x-pack/plugins/siem/public/pages/hosts/hosts.tsx index 91b6fba2f7a17..59fe4e8b0afd9 100644 --- a/x-pack/plugins/siem/public/pages/hosts/hosts.tsx +++ b/x-pack/plugins/siem/public/pages/hosts/hosts.tsx @@ -36,6 +36,7 @@ import { hostsModel, hostsSelectors, State } from '../../store'; import { HostsKql } from './kql'; import * as i18n from './translations'; +import { UrlStateContainer } from '../../components/url_state'; const basePath = chrome.getBasePath(); @@ -59,6 +60,7 @@ const HostsComponent = pure(({ filterQuery }) => ( + ( + ; -describe('Network', () => { - describe('rendering', () => { - beforeEach(() => { - localSource = cloneDeep(mocksSource); - }); +type Action = 'PUSH' | 'POP' | 'REPLACE'; +const pop: Action = 'POP'; +const location = { + pathname: '/network', + search: '', + state: '', + hash: '', +}; +const mockHistory = { + length: 2, + location, + action: pop, + push: jest.fn(), + replace: jest.fn(), + go: jest.fn(), + goBack: jest.fn(), + goForward: jest.fn(), + block: jest.fn(), + createHref: jest.fn(), + listen: jest.fn(), +}; +// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769 +/* eslint-disable no-console */ +const originalError = console.error; - test('it renders the Setup Instructions text when no index is available', async () => { - localSource[0].result.data.source.status.auditbeatIndicesExist = false; - localSource[0].result.data.source.status.filebeatIndicesExist = false; - localSource[0].result.data.source.status.winlogbeatIndicesExist = false; - const wrapper = mount( - - +describe('rendering - rendering', () => { + beforeAll(() => { + console.error = jest.fn(); + }); + afterAll(() => { + console.error = originalError; + }); + beforeEach(() => { + localSource = cloneDeep(mocksSource); + }); + beforeEach(() => { + localSource = cloneDeep(mocksSource); + }); + + test('it renders the Setup Instructions text when no index is available', async () => { + localSource[0].result.data.source.status.auditbeatIndicesExist = false; + localSource[0].result.data.source.status.filebeatIndicesExist = false; + localSource[0].result.data.source.status.winlogbeatIndicesExist = false; + const wrapper = mount( + + + - - - ); - // Why => https://github.com/apollographql/react-apollo/issues/1711 - await new Promise(resolve => setTimeout(resolve)); - wrapper.update(); - expect(wrapper.text()).toContain(i18n.SETUP_INSTRUCTIONS); - }); + + + + ); + // Why => https://github.com/apollographql/react-apollo/issues/1711 + await new Promise(resolve => setTimeout(resolve)); + wrapper.update(); + expect(wrapper.text()).toContain(i18n.SETUP_INSTRUCTIONS); + }); - test('it renders the Setup Instructions text when filebeat index is not available', async () => { - localSource[0].result.data.source.status.auditbeatIndicesExist = true; - localSource[0].result.data.source.status.filebeatIndicesExist = false; - localSource[0].result.data.source.status.winlogbeatIndicesExist = true; - const wrapper = mount( - - + test('it renders the Setup Instructions text when filebeat index is not available', async () => { + localSource[0].result.data.source.status.auditbeatIndicesExist = true; + localSource[0].result.data.source.status.filebeatIndicesExist = false; + localSource[0].result.data.source.status.winlogbeatIndicesExist = true; + const wrapper = mount( + + + - - - ); - // Why => https://github.com/apollographql/react-apollo/issues/1711 - await new Promise(resolve => setTimeout(resolve)); - wrapper.update(); - expect(wrapper.text()).toContain(i18n.SETUP_INSTRUCTIONS); - }); + + + + ); + // Why => https://github.com/apollographql/react-apollo/issues/1711 + await new Promise(resolve => setTimeout(resolve)); + wrapper.update(); + expect(wrapper.text()).toContain(i18n.SETUP_INSTRUCTIONS); + }); - test('it DOES NOT render the Setup Instructions text when filebeat index is available', async () => { - localSource[0].result.data.source.status.auditbeatIndicesExist = false; - localSource[0].result.data.source.status.filebeatIndicesExist = true; - localSource[0].result.data.source.status.winlogbeatIndicesExist = false; - const wrapper = mount( - - + test('it DOES NOT render the Setup Instructions text when filebeat index is available', async () => { + localSource[0].result.data.source.status.auditbeatIndicesExist = false; + localSource[0].result.data.source.status.filebeatIndicesExist = true; + localSource[0].result.data.source.status.winlogbeatIndicesExist = false; + const wrapper = mount( + + + - - - ); - // Why => https://github.com/apollographql/react-apollo/issues/1711 - await new Promise(resolve => setTimeout(resolve)); - wrapper.update(); - expect(wrapper.text()).not.toContain(i18n.SETUP_INSTRUCTIONS); - }); + + + + ); + // Why => https://github.com/apollographql/react-apollo/issues/1711 + await new Promise(resolve => setTimeout(resolve)); + wrapper.update(); + expect(wrapper.text()).not.toContain(i18n.SETUP_INSTRUCTIONS); }); }); diff --git a/x-pack/plugins/siem/public/pages/network/network.tsx b/x-pack/plugins/siem/public/pages/network/network.tsx index 6718c3aaeb488..69569287fb952 100644 --- a/x-pack/plugins/siem/public/pages/network/network.tsx +++ b/x-pack/plugins/siem/public/pages/network/network.tsx @@ -29,6 +29,7 @@ import { networkModel, networkSelectors, State } from '../../store'; import { NetworkKql } from './kql'; import * as i18n from './translations'; +import { UrlStateContainer } from '../../components/url_state'; const basePath = chrome.getBasePath(); @@ -49,6 +50,7 @@ const NetworkComponent = pure(({ filterQuery }) => ( + hosts => (hosts.filterQuery ? hosts.filterQuery.query.expression : null) ); +export const hostsFilterQueryAsKuery = () => + createSelector( + selectHosts, + hosts => (hosts.filterQuery ? hosts.filterQuery.query : null) + ); + export const hostsFilterQueryAsJson = () => createSelector( selectHosts, diff --git a/x-pack/plugins/siem/public/store/inputs/model.ts b/x-pack/plugins/siem/public/store/inputs/model.ts index 895b97e7c44af..8193acdd87050 100644 --- a/x-pack/plugins/siem/public/store/inputs/model.ts +++ b/x-pack/plugins/siem/public/store/inputs/model.ts @@ -22,8 +22,12 @@ interface RelativeTimeRange { export type InputsModelId = 'global' | 'timeline'; +export type TimeRangeKinds = 'absolute' | 'relative'; + export type TimeRange = AbsoluteTimeRange | RelativeTimeRange; +export type UrlTimeRange = AbsoluteTimeRange & LinkTo | RelativeTimeRange & LinkTo; + export interface Policy { kind: 'manual' | 'interval'; duration: number; // in ms @@ -43,7 +47,15 @@ export interface InputsRange { linkTo: InputsModelId[]; } +interface LinkTo { + linkTo: InputsModelId[]; +} + export interface InputsModel { global: InputsRange; timeline: InputsRange; } +export interface UrlInputsModel { + global: TimeRange & LinkTo; + timeline: TimeRange & LinkTo; +} diff --git a/x-pack/plugins/siem/public/store/inputs/selectors.ts b/x-pack/plugins/siem/public/store/inputs/selectors.ts index 4573adbb822a5..3d093a26aa034 100644 --- a/x-pack/plugins/siem/public/store/inputs/selectors.ts +++ b/x-pack/plugins/siem/public/store/inputs/selectors.ts @@ -16,10 +16,11 @@ const selectGlobal = (state: State): InputsRange => state.inputs.global; const selectTimeline = (state: State): InputsRange => state.inputs.timeline; -export const inpustSelector = createSelector( - selectInputs, - inputs => inputs -); +export const inputsSelector = () => + createSelector( + selectInputs, + inputs => inputs + ); export const globalTimeRangeSelector = createSelector( selectGlobal, diff --git a/x-pack/plugins/siem/public/store/model.ts b/x-pack/plugins/siem/public/store/model.ts index 7bc8d065e8685..68fd40d9f7c19 100644 --- a/x-pack/plugins/siem/public/store/model.ts +++ b/x-pack/plugins/siem/public/store/model.ts @@ -10,12 +10,17 @@ export { hostsModel } from './hosts'; export { dragAndDropModel } from './drag_and_drop'; export { networkModel } from './network'; +export enum KueryFilterModel { + hosts = 'hosts', + network = 'network', +} + export interface KueryFilterQuery { kind: 'kuery'; expression: string; } export interface SerializedFilterQuery { - query: KueryFilterQuery; + query: KueryFilterQuery | null; serializedQuery: string; } diff --git a/x-pack/plugins/siem/public/store/network/selectors.ts b/x-pack/plugins/siem/public/store/network/selectors.ts index 48124fa2cb7a3..b242582fd39bb 100644 --- a/x-pack/plugins/siem/public/store/network/selectors.ts +++ b/x-pack/plugins/siem/public/store/network/selectors.ts @@ -39,6 +39,12 @@ export const networkFilterQueryAsJson = () => network => (network.filterQuery ? network.filterQuery.serializedQuery : null) ); +export const networkFilterQueryAsKuery = () => + createSelector( + selectNetworkByType, + network => (network.filterQuery ? network.filterQuery.query : null) + ); + export const networkFilterQueryDraft = () => createSelector( selectNetworkByType,