diff --git a/x-pack/package.json b/x-pack/package.json index 7f0b0a7a57e4d..5aede69e465f7 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -44,6 +44,7 @@ "@types/pngjs": "^3.3.1", "@types/prop-types": "^15.5.3", "@types/react": "^16.3.14", + "@types/react-datepicker": "^1.1.5", "@types/react-dom": "^16.0.5", "@types/react-redux": "^6.0.2", "@types/react-router-dom": "^4.2.6", diff --git a/x-pack/plugins/infra/common/time/time_key.ts b/x-pack/plugins/infra/common/time/time_key.ts index 11ffaf49a2558..1693e8775ccad 100644 --- a/x-pack/plugins/infra/common/time/time_key.ts +++ b/x-pack/plugins/infra/common/time/time_key.ts @@ -14,6 +14,12 @@ export interface TimeKey { export type Comparator = (firstValue: any, secondValue: any) => number; +export const isTimeKey = (value: any): value is TimeKey => + value && + typeof value === 'object' && + typeof value.time === 'number' && + typeof value.tiebreaker === 'number'; + export function compareTimeKeys( firstKey: TimeKey, secondKey: TimeKey, diff --git a/x-pack/plugins/infra/public/components/logging/log_jump_menu.tsx b/x-pack/plugins/infra/public/components/logging/log_jump_menu.tsx deleted file mode 100644 index 2b630d0936c70..0000000000000 --- a/x-pack/plugins/infra/public/components/logging/log_jump_menu.tsx +++ /dev/null @@ -1,113 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiButtonEmpty, EuiContextMenu, EuiPopover } from '@elastic/eui'; -import { timeDay, timeHour, timeMonth, timeWeek, timeYear } from 'd3-time'; -import * as React from 'react'; - -interface LogJumpMenuProps { - jumpToTime: (time: number) => any; -} - -interface LogJumpMenuState { - isShown: boolean; -} - -export class LogJumpMenu extends React.Component { - public readonly state = { - isShown: false, - }; - - public show = () => { - this.setState({ - isShown: true, - }); - }; - - public hide = () => { - this.setState({ - isShown: false, - }); - }; - - public toggleVisibility = () => { - this.setState(state => ({ - isShown: !state.isShown, - })); - }; - - public jumpToTime = (time: number) => { - this.hide(); - this.props.jumpToTime(time); - }; - - public getPanels = () => [ - { - id: 'jumpToPredefinedTargets', - items: [ - { - name: 'Now', - onClick: () => this.jumpToTime(Date.now()), - }, - { - name: 'Previous Hour', - onClick: () => this.jumpToTime(timeHour.floor(new Date()).getTime()), - }, - { - name: 'Previous Day', - onClick: () => this.jumpToTime(timeDay.floor(new Date()).getTime()), - }, - { - name: 'Previous Week', - onClick: () => this.jumpToTime(timeWeek.floor(new Date()).getTime()), - }, - { - name: 'Previous Month', - onClick: () => this.jumpToTime(timeMonth.floor(new Date()).getTime()), - }, - { - name: 'Previous Year', - onClick: () => this.jumpToTime(timeYear.floor(new Date()).getTime()), - }, - { - name: 'Custom Time', - panel: 'jumpToCustomTarget', - }, - ], - title: 'Jump to...', - }, - { - content:
form goes here
, - id: 'jumpToCustomTarget', - title: 'Custom time', - }, - ]; - - public render() { - const { isShown } = this.state; - - const menuButton = ( - - Jump to time - - ); - - return ( - - - - ); - } -} diff --git a/x-pack/plugins/infra/public/components/logging/log_live_stream_controls.tsx b/x-pack/plugins/infra/public/components/logging/log_live_stream_controls.tsx deleted file mode 100644 index 9b603595fa35b..0000000000000 --- a/x-pack/plugins/infra/public/components/logging/log_live_stream_controls.tsx +++ /dev/null @@ -1,57 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiButton } from '@elastic/eui'; -import * as React from 'react'; - -interface LogLiveStreamControlsProps { - className?: string; - isLiveStreaming: boolean; - enableLiveStreaming: () => any; - disableLiveStreaming: () => any; -} - -export class LogLiveStreamControls extends React.Component { - public startStreaming = () => { - this.props.enableLiveStreaming(); - }; - - public stopStreaming = () => { - this.props.disableLiveStreaming(); - }; - - public render() { - const { className, isLiveStreaming } = this.props; - - if (isLiveStreaming) { - return ( - - Stop streaming - - ); - } else { - return ( - - Stream live - - ); - } - } -} diff --git a/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx b/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx new file mode 100644 index 0000000000000..93b3b0e10c7a7 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx @@ -0,0 +1,76 @@ +/* + * 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 { EuiDatePicker, EuiFilterButton, EuiFilterGroup } from '@elastic/eui'; +import moment, { Moment } from 'moment'; +import React from 'react'; +import styled from 'styled-components'; + +const noop = () => undefined; + +interface LogTimeControlsProps { + currentTime: number; + disableLiveStreaming: () => any; + enableLiveStreaming: () => any; + isLiveStreaming: boolean; + jumpToTime: (time: number) => any; +} + +export class LogTimeControls extends React.PureComponent { + public render() { + const { currentTime, disableLiveStreaming, enableLiveStreaming, isLiveStreaming } = this.props; + + const currentMoment = moment(currentTime); + + if (isLiveStreaming) { + return ( + + + + + + Stop streaming + + + ); + } else { + return ( + + + + + + Stream live + + + ); + } + } + + private handleChangeDate = (date: Moment | null) => { + if (date !== null) { + this.props.jumpToTime(date.valueOf()); + } + }; +} + +const InlineWrapper = styled.div` + display: inline-block; +`; diff --git a/x-pack/plugins/infra/public/containers/logging_legacy/with_jump_menu_props.ts b/x-pack/plugins/infra/public/containers/logging_legacy/with_jump_menu_props.ts deleted file mode 100644 index c81015f1ee0ed..0000000000000 --- a/x-pack/plugins/infra/public/containers/logging_legacy/with_jump_menu_props.ts +++ /dev/null @@ -1,17 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { connect } from 'react-redux'; - -import { bindPlainActionCreators } from '../../utils/typed_redux'; -import { targetActions } from './state'; - -export const withJumpMenuProps = connect( - () => ({}), - bindPlainActionCreators({ - jumpToTime: targetActions.jumpToTime, - }) -); diff --git a/x-pack/plugins/infra/public/containers/logging_legacy/with_text_stream_scroll_state.tsx b/x-pack/plugins/infra/public/containers/logging_legacy/with_text_stream_scroll_state.tsx new file mode 100644 index 0000000000000..41d9b1081ad02 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logging_legacy/with_text_stream_scroll_state.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { isTimeKey, TimeKey } from '../../../common/time'; +import { + mapRisonAppLocationToState, + mapStateToRisonAppLocation, + withStateFromLocation, +} from '../../containers/with_state_from_location'; + +interface TextStreamScrollState { + target: TimeKey; +} + +const withTargetStateFromLocation = withStateFromLocation({ + mapLocationToState: mapRisonAppLocationToState(locationState => ({ + target: isTimeKey(locationState.target) + ? { + tiebreaker: locationState.target.tiebreaker, + time: locationState.target.time, + } + : { + tiebreaker: 0, + time: Date.now(), + }, + })), + mapStateToLocation: mapStateToRisonAppLocation(state => ({ + target: { + tiebreaker: state.target.tiebreaker, + time: state.target.time, + }, + })), +}); + +interface InjectedTextStreamScrollStateProps extends TextStreamScrollState { + jumpToTarget: (target: TimeKey) => any; + reportVisibleInterval: ( + params: { + pagesBeforeStart: number; + pagesAfterEnd: number; + startKey: TimeKey | null; + middleKey: TimeKey | null; + endKey: TimeKey | null; + } + ) => any; +} + +interface TextStreamScrollStateProps extends InjectedTextStreamScrollStateProps { + pushStateInLocation: (state: TextStreamScrollState) => void; + replaceStateInLocation: (state: TextStreamScrollState) => void; +} + +export const withTextStreamScrollState = < + WrappedComponentProps extends InjectedTextStreamScrollStateProps +>( + WrappedComponent: React.ComponentType +) => { + const wrappedName = WrappedComponent.displayName || WrappedComponent.name; + + return withTargetStateFromLocation( + class WithTextStreamScrollState extends React.PureComponent< + TextStreamScrollStateProps & WrappedComponentProps + > { + public static displayName = `WithStateFromLocation(${wrappedName})`; + + public componentDidMount() { + this.jumpToTarget(this.props.target); + } + + // public componentDidUpdate(prevProps: TextStreamScrollStateProps) { + // if (this.props.target !== prevProps.target) { + // this.jumpToTarget(this.props.target); + // } + // } + + public render() { + const { pushStateInLocation, replaceStateInLocation, ...otherProps } = this + .props as TextStreamScrollStateProps; + + return ( + + ); + } + + private jumpToTarget = (target: TimeKey) => { + this.props.jumpToTarget(target); + }; + + private reportVisibleInterval = (params: { + pagesBeforeStart: number; + pagesAfterEnd: number; + startKey: TimeKey | null; + middleKey: TimeKey | null; + endKey: TimeKey | null; + }) => { + if (params.middleKey) { + this.props.replaceStateInLocation({ + target: params.middleKey, + }); + } + return this.props.reportVisibleInterval(params); + }; + } + ); +}; diff --git a/x-pack/plugins/infra/public/containers/logging_legacy/with_live_stream_controls_props.ts b/x-pack/plugins/infra/public/containers/logging_legacy/with_time_controls_props.ts similarity index 73% rename from x-pack/plugins/infra/public/containers/logging_legacy/with_live_stream_controls_props.ts rename to x-pack/plugins/infra/public/containers/logging_legacy/with_time_controls_props.ts index ddee4f1f311cf..c5aff37f9a8b6 100644 --- a/x-pack/plugins/infra/public/containers/logging_legacy/with_live_stream_controls_props.ts +++ b/x-pack/plugins/infra/public/containers/logging_legacy/with_time_controls_props.ts @@ -8,10 +8,11 @@ import { connect } from 'react-redux'; import { isIntervalLoadingPolicy } from '../../utils/loading_state'; import { bindPlainActionCreators } from '../../utils/typed_redux'; -import { entriesActions, entriesSelectors, State } from './state'; +import { entriesActions, entriesSelectors, sharedSelectors, State, targetActions } from './state'; -export const withLiveStreamControlsProps = connect( +export const withTimeControlsProps = connect( (state: State) => ({ + currentTime: sharedSelectors.selectVisibleMidpointOrTargetTime(state), isLiveStreaming: isIntervalLoadingPolicy( entriesSelectors.selectEntriesEndLoadingState(state).policy ), @@ -19,5 +20,6 @@ export const withLiveStreamControlsProps = connect( bindPlainActionCreators({ disableLiveStreaming: entriesActions.stopLiveStreaming, enableLiveStreaming: entriesActions.startLiveStreaming, + jumpToTime: targetActions.jumpToTime, }) ); diff --git a/x-pack/plugins/infra/public/containers/with_state_from_location.tsx b/x-pack/plugins/infra/public/containers/with_state_from_location.tsx new file mode 100644 index 0000000000000..7f1d6baf7288f --- /dev/null +++ b/x-pack/plugins/infra/public/containers/with_state_from_location.tsx @@ -0,0 +1,127 @@ +/* + * 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 { Location } from 'history'; +import omit from 'lodash/fp/omit'; +import { parse as parseQueryString, stringify as stringifyQueryString } from 'querystring'; +import React from 'react'; +import { RouteComponentProps, withRouter } from 'react-router'; +import { decode_object, encode_object } from 'rison-node'; + +interface AnyObject { + [key: string]: any; +} + +type Omit = Pick>; + +interface WithStateFromLocationOptions { + mapLocationToState: (location: Location) => StateInLocation; + mapStateToLocation: (state: StateInLocation, location: Location) => Location; +} + +type InjectedPropsFromLocation = Partial & { + pushStateInLocation?: (state: StateInLocation) => void; + replaceStateInLocation?: (state: StateInLocation) => void; +}; + +export const withStateFromLocation = ({ + mapLocationToState, + mapStateToLocation, +}: WithStateFromLocationOptions) => < + WrappedComponentProps extends InjectedPropsFromLocation +>( + WrappedComponent: React.ComponentType +) => { + const wrappedName = WrappedComponent.displayName || WrappedComponent.name; + + return withRouter( + class WithStateFromLocation extends React.PureComponent< + RouteComponentProps<{}> & + Omit> + > { + public static displayName = `WithStateFromLocation(${wrappedName})`; + + public render() { + const { location } = this.props; + const otherProps = omit(['location', 'history', 'match', 'staticContext'], this.props); + + const stateFromLocation = mapLocationToState(location); + + return ( + + ); + } + + private pushStateInLocation = (state: StateInLocation) => { + const { history, location } = this.props; + + const newLocation = mapStateToLocation(state, this.props.location); + + if (newLocation !== location) { + history.push(newLocation); + } + }; + + private replaceStateInLocation = (state: StateInLocation) => { + const { history, location } = this.props; + + const newLocation = mapStateToLocation(state, this.props.location); + + if (newLocation !== location) { + history.replace(newLocation); + } + }; + } + ); +}; + +const decodeRisonAppState = (queryValues: { _a?: string }): AnyObject => { + try { + return queryValues && queryValues._a ? decode_object(queryValues._a) : {}; + } catch (error) { + if (error instanceof Error && error.message.startsWith('rison decoder error')) { + return {}; + } + throw error; + } +}; + +const encodeRisonAppState = (state: AnyObject) => ({ + _a: encode_object(state), +}); + +export const mapRisonAppLocationToState = ( + mapState: (risonAppState: AnyObject) => State = (state: AnyObject) => state as State +) => (location: Location): State => { + const queryValues = parseQueryString(location.search.substring(1)); + const decodedState = decodeRisonAppState(queryValues); + return mapState(decodedState); +}; + +export const mapStateToRisonAppLocation = ( + mapState: (state: State) => AnyObject = (state: State) => state +) => (state: State, location: Location): Location => { + const previousQueryValues = parseQueryString(location.search.substring(1)); + const previousState = decodeRisonAppState(previousQueryValues); + + const encodedState = encodeRisonAppState({ + ...previousState, + ...mapState(state), + }); + const newQueryValues = stringifyQueryString({ + ...previousQueryValues, + ...encodedState, + }); + return { + ...location, + search: `?${newQueryValues}`, + }; +}; diff --git a/x-pack/plugins/infra/public/pages/logs/logs.tsx b/x-pack/plugins/infra/public/pages/logs/logs.tsx index c67effc6e1c2b..e4fc409393dbb 100644 --- a/x-pack/plugins/infra/public/pages/logs/logs.tsx +++ b/x-pack/plugins/infra/public/pages/logs/logs.tsx @@ -19,8 +19,6 @@ import { AutoSizer } from '../../components/auto_sizer'; import { Toolbar } from '../../components/eui'; import { Header } from '../../components/header'; import { LogCustomizationMenu } from '../../components/logging/log_customization_menu'; -import { LogJumpMenu } from '../../components/logging/log_jump_menu'; -import { LogLiveStreamControls } from '../../components/logging/log_live_stream_controls'; import { LogMinimap } from '../../components/logging/log_minimap'; import { LogMinimapScaleControls } from '../../components/logging/log_minimap_scale_controls'; import { LogPositionText } from '../../components/logging/log_position_text'; @@ -29,30 +27,30 @@ import { LogStatusbar, LogStatusbarItem } from '../../components/logging/log_sta import { LogTextScaleControls } from '../../components/logging/log_text_scale_controls'; import { ScrollableLogTextStreamView } from '../../components/logging/log_text_stream'; import { LogTextWrapControls } from '../../components/logging/log_text_wrap_controls'; +import { LogTimeControls } from '../../components/logging/log_time_controls'; import { withLibs } from '../../containers/libs'; -import { State, targetActions } from '../../containers/logging_legacy/state'; -import { withJumpMenuProps } from '../../containers/logging_legacy/with_jump_menu_props'; -import { withLiveStreamControlsProps } from '../../containers/logging_legacy/with_live_stream_controls_props'; +import { State } from '../../containers/logging_legacy/state'; import { withLogSearchControlsProps } from '../../containers/logging_legacy/with_log_search_controls_props'; import { withMinimapProps } from '../../containers/logging_legacy/with_minimap_props'; import { withMinimapScaleControlsProps } from '../../containers/logging_legacy/with_minimap_scale_controls_props'; import { withStreamItems } from '../../containers/logging_legacy/with_stream_items'; import { withTextScaleControlsProps } from '../../containers/logging_legacy/with_text_scale_controls_props'; +import { withTextStreamScrollState } from '../../containers/logging_legacy/with_text_stream_scroll_state'; import { withTextWrapControlsProps } from '../../containers/logging_legacy/with_text_wrap_controls_props'; +import { withTimeControlsProps } from '../../containers/logging_legacy/with_time_controls_props'; import { withVisibleLogEntries } from '../../containers/logging_legacy/with_visible_log_entries'; -// TODO: split out containers - -const ConnectedLogJumpMenu = withJumpMenuProps(LogJumpMenu); -const ConnectedLogLiveStreamControls = withLiveStreamControlsProps(LogLiveStreamControls); const ConnectedLogMinimap = withMinimapProps(LogMinimap); const ConnectedLogMinimapScaleControls = withMinimapScaleControlsProps(LogMinimapScaleControls); const ConnectedLogPositionText = withVisibleLogEntries(LogPositionText); const ConnectedLogSearchControls = withLogSearchControlsProps(LogSearchControls); const ConnectedLogTextScaleControls = withTextScaleControlsProps(LogTextScaleControls); const ConnectedLogTextWrapControls = withTextWrapControlsProps(LogTextWrapControls); -const ConnectedScrollableLogTextStreamView = withStreamItems(ScrollableLogTextStreamView); +const ConnectedTimeControls = withTimeControlsProps(LogTimeControls); +const ConnectedScrollableLogTextStreamView = withStreamItems( + withTextStreamScrollState(ScrollableLogTextStreamView) +); interface LogsPageProps { libs: InfraFrontendLibs; @@ -81,10 +79,6 @@ export const LogsPage = withLibs( }; } - public componentDidMount() { - this.state.store.dispatch(targetActions.jumpToTime(Date.now())); - } - public componentDidUpdate(prevProps: LogsPageProps) { if (this.props.libs !== prevProps.libs) { this.state.libs.next(this.props.libs); @@ -101,9 +95,6 @@ export const LogsPage = withLibs( - - - @@ -112,7 +103,7 @@ export const LogsPage = withLibs( - + diff --git a/x-pack/plugins/infra/types/eui.d.ts b/x-pack/plugins/infra/types/eui.d.ts index 0977fdeabd995..f972c548de1f7 100644 --- a/x-pack/plugins/infra/types/eui.d.ts +++ b/x-pack/plugins/infra/types/eui.d.ts @@ -10,7 +10,15 @@ */ declare module '@elastic/eui' { - import { SFC } from 'react'; + import { Moment } from 'moment'; + import { + ChangeEventHandler, + MouseEventHandler, + ReactType, + Ref, + SFC, + } from 'react'; + import { ReactDatePickerProps } from 'react-datepicker'; export interface EuiBreadcrumbDefinition { text: React.ReactNode; @@ -35,4 +43,50 @@ declare module '@elastic/eui' { type EuiHeaderBreadcrumbsProps = EuiBreadcrumbsProps; export const EuiHeaderBreadcrumbs: React.SFC; + + type EuiDatePickerProps = CommonProps & + Pick< + ReactDatePickerProps, + Exclude< + keyof ReactDatePickerProps, + | 'monthsShown' + | 'showWeekNumbers' + | 'fixedHeight' + | 'dropdownMode' + | 'useShortMonthInDropdown' + | 'todayButton' + | 'timeCaption' + | 'disabledKeyboardNavigation' + | 'isClearable' + | 'withPortal' + | 'ref' + | 'placeholderText' + > + > & { + fullWidth?: boolean; + inputRef?: Ref; + injectTimes?: Moment[]; + isInvalid?: boolean; + isLoading?: boolean; + placeholder?: string; + shadow?: boolean; + }; + export const EuiDatePicker: React.SFC; + + type EuiFilterGroupProps = CommonProps; + export const EuiFilterGroup: React.SFC; + + type EuiFilterButtonProps = CommonProps & { + color?: ButtonColor; + href?: string; + iconSide?: ButtonIconSide; + iconType?: IconType; + isDisabled?: boolean; + isSelected?: boolean; + onClick: MouseEventHandler; + rel?: string; + target?: string; + type?: string; + }; + export const EuiFilterButton: React.SFC; } diff --git a/x-pack/plugins/infra/types/rison_node.d.ts b/x-pack/plugins/infra/types/rison_node.d.ts new file mode 100644 index 0000000000000..92458b5113c25 --- /dev/null +++ b/x-pack/plugins/infra/types/rison_node.d.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +// tslint:disable:variable-name + +declare module 'rison-node' { + export const decode_object: ( + input: string + ) => ResultObject; + + export const encode_object: ( + input: InputObject + ) => string; +} diff --git a/x-pack/yarn.lock b/x-pack/yarn.lock index bfc3ab79fd9fb..5c89eb4e501e8 100644 --- a/x-pack/yarn.lock +++ b/x-pack/yarn.lock @@ -276,6 +276,14 @@ version "15.5.3" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.5.3.tgz#bef071852dca2a2dbb65fecdb7bfb30cedae2de2" +"@types/react-datepicker@^1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@types/react-datepicker/-/react-datepicker-1.1.5.tgz#2070d5ab86eacf69f5e9573b1d69af9982841c02" + dependencies: + "@types/react" "*" + moment ">=2.14.0" + popper.js "^1.14.1" + "@types/react-dom@^16.0.5": version "16.0.6" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.0.6.tgz#f1a65a4e7be8ed5d123f8b3b9eacc913e35a1a3c" @@ -5645,7 +5653,7 @@ moment-timezone@^0.5.14: dependencies: moment ">= 2.9.0" -moment@2.22.2: +moment@2.22.2, moment@>=2.14.0: version "2.22.2" resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"