diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index 4aa6244e1b24e..d8882f74ee3b1 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -93,6 +93,7 @@ export { replaceUrlHashQuery, } from './state_management/url'; export type { + IStateStorage, IStateSyncConfig, ISyncStateRef, IKbnUrlStateStorage, diff --git a/x-pack/plugins/infra/common/time/time_key.ts b/x-pack/plugins/infra/common/time/time_key.ts index 42b14625d22a9..efc5a8e7b8517 100644 --- a/x-pack/plugins/infra/common/time/time_key.ts +++ b/x-pack/plugins/infra/common/time/time_key.ts @@ -6,14 +6,23 @@ */ import { ascending, bisector } from 'd3-array'; +import * as rt from 'io-ts'; import { pick } from 'lodash'; -export interface TimeKey { - time: number; - tiebreaker: number; - gid?: string; - fromAutoReload?: boolean; -} +export const minimalTimeKeyRT = rt.type({ + time: rt.number, + tiebreaker: rt.number, +}); +export type MinimalTimeKey = rt.TypeOf; + +export const timeKeyRT = rt.intersection([ + minimalTimeKeyRT, + rt.partial({ + gid: rt.string, + fromAutoReload: rt.boolean, + }), +]); +export type TimeKey = rt.TypeOf; export interface UniqueTimeKey extends TimeKey { gid: string; @@ -95,3 +104,7 @@ export const getNextTimeKey = (timeKey: TimeKey) => ({ time: timeKey.time, tiebreaker: timeKey.tiebreaker + 1, }); + +export const isSameTimeKey = (firstKey: TimeKey | null, secondKey: TimeKey | null): boolean => + firstKey === secondKey || + (firstKey != null && secondKey != null && compareTimeKeys(firstKey, secondKey) === 0); diff --git a/x-pack/plugins/infra/public/apps/logs_app.tsx b/x-pack/plugins/infra/public/apps/logs_app.tsx index 0b345150daf8d..9e5722e316b00 100644 --- a/x-pack/plugins/infra/public/apps/logs_app.tsx +++ b/x-pack/plugins/infra/public/apps/logs_app.tsx @@ -19,6 +19,7 @@ import { LogsPage } from '../pages/logs'; import { InfraClientStartDeps, InfraClientStartExports } from '../types'; import { CommonInfraProviders, CoreProviders } from './common_providers'; import { prepareMountElement } from './common_styles'; +import { KbnUrlStateStorageFromRouterProvider } from '../utils/kbn_url_state_context'; export const renderApp = ( core: CoreStart, @@ -69,11 +70,16 @@ const LogsApp: React.FC<{ triggersActionsUI={plugins.triggersActionsUi} > - - - {uiCapabilities?.logs?.show && } - - + + + + {uiCapabilities?.logs?.show && } + + + diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/index.ts b/x-pack/plugins/infra/public/containers/logs/log_position/index.ts index 41d284caf9425..e4e6ad6c54deb 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_position/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_position/index.ts @@ -6,4 +6,6 @@ */ export * from './log_position_state'; -export * from './with_log_position_url_state'; +export * from './replace_log_position_in_query_string'; +export * from './use_log_position'; +export type { LogPositionUrlState } from './use_log_position_url_state_sync'; diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.test.ts b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.test.ts new file mode 100644 index 0000000000000..b87dca28fc048 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.test.ts @@ -0,0 +1,287 @@ +/* + * 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 moment from 'moment'; +import { createInitialLogPositionState, updateStateFromUrlState } from './log_position_state'; + +describe('function createInitialLogPositionState', () => { + it('initializes state without url and timefilter', () => { + const initialState = createInitialLogPositionState({ + initialStateFromUrl: null, + initialStateFromTimefilter: null, + now: getTestMoment().toDate(), + }); + + expect(initialState).toMatchInlineSnapshot(` + Object { + "latestPosition": null, + "refreshInterval": Object { + "pause": true, + "value": 5000, + }, + "targetPosition": null, + "timeRange": Object { + "expression": Object { + "from": "now-1d", + "to": "now", + }, + "lastChangedCompletely": 1640995200000, + }, + "timestamps": Object { + "endTimestamp": 1640995200000, + "lastChangedTimestamp": 1640995200000, + "startTimestamp": 1640908800000, + }, + "visiblePositions": Object { + "endKey": null, + "middleKey": null, + "pagesAfterEnd": Infinity, + "pagesBeforeStart": Infinity, + "startKey": null, + }, + } + `); + }); + + it('initializes state from complete url state', () => { + const initialState = createInitialLogPositionState({ + initialStateFromUrl: { + start: 'now-2d', + end: 'now-1d', + position: { + time: getTestMoment().subtract(36, 'hours').valueOf(), + tiebreaker: 0, + }, + streamLive: false, + }, + initialStateFromTimefilter: null, + now: getTestMoment().toDate(), + }); + + expect(initialState).toMatchInlineSnapshot(` + Object { + "latestPosition": Object { + "tiebreaker": 0, + "time": 1640865600000, + }, + "refreshInterval": Object { + "pause": true, + "value": 5000, + }, + "targetPosition": Object { + "tiebreaker": 0, + "time": 1640865600000, + }, + "timeRange": Object { + "expression": Object { + "from": "now-2d", + "to": "now-1d", + }, + "lastChangedCompletely": 1640995200000, + }, + "timestamps": Object { + "endTimestamp": 1640908800000, + "lastChangedTimestamp": 1640995200000, + "startTimestamp": 1640822400000, + }, + "visiblePositions": Object { + "endKey": null, + "middleKey": null, + "pagesAfterEnd": Infinity, + "pagesBeforeStart": Infinity, + "startKey": null, + }, + } + `); + }); + + it('initializes state from from url state with just a time range', () => { + const initialState = createInitialLogPositionState({ + initialStateFromUrl: { + start: 'now-2d', + end: 'now-1d', + }, + initialStateFromTimefilter: null, + now: getTestMoment().toDate(), + }); + + expect(initialState).toMatchInlineSnapshot(` + Object { + "latestPosition": null, + "refreshInterval": Object { + "pause": true, + "value": 5000, + }, + "targetPosition": null, + "timeRange": Object { + "expression": Object { + "from": "now-2d", + "to": "now-1d", + }, + "lastChangedCompletely": 1640995200000, + }, + "timestamps": Object { + "endTimestamp": 1640908800000, + "lastChangedTimestamp": 1640995200000, + "startTimestamp": 1640822400000, + }, + "visiblePositions": Object { + "endKey": null, + "middleKey": null, + "pagesAfterEnd": Infinity, + "pagesBeforeStart": Infinity, + "startKey": null, + }, + } + `); + }); + + it('initializes state from from url state with just a position', () => { + const initialState = createInitialLogPositionState({ + initialStateFromUrl: { + position: { + time: getTestMoment().subtract(36, 'hours').valueOf(), + }, + }, + initialStateFromTimefilter: null, + now: getTestMoment().toDate(), + }); + + expect(initialState).toMatchInlineSnapshot(` + Object { + "latestPosition": Object { + "tiebreaker": 0, + "time": 1640865600000, + }, + "refreshInterval": Object { + "pause": true, + "value": 5000, + }, + "targetPosition": Object { + "tiebreaker": 0, + "time": 1640865600000, + }, + "timeRange": Object { + "expression": Object { + "from": "2021-12-30T11:00:00.000Z", + "to": "2021-12-30T13:00:00.000Z", + }, + "lastChangedCompletely": 1640995200000, + }, + "timestamps": Object { + "endTimestamp": 1640869200000, + "lastChangedTimestamp": 1640995200000, + "startTimestamp": 1640862000000, + }, + "visiblePositions": Object { + "endKey": null, + "middleKey": null, + "pagesAfterEnd": Infinity, + "pagesBeforeStart": Infinity, + "startKey": null, + }, + } + `); + }); +}); + +describe('function updateStateFromUrlState', () => { + it('applies a new target position that is within the date range', () => { + const initialState = createInitialTestState(); + const newState = updateStateFromUrlState({ + position: { + time: initialState.timestamps.startTimestamp + 1, + tiebreaker: 2, + }, + })(initialState); + + expect(newState).toEqual({ + ...initialState, + targetPosition: { + time: initialState.timestamps.startTimestamp + 1, + tiebreaker: 2, + }, + latestPosition: { + time: initialState.timestamps.startTimestamp + 1, + tiebreaker: 2, + }, + }); + }); + + it('applies a new partial target position that is within the date range', () => { + const initialState = createInitialTestState(); + const newState = updateStateFromUrlState({ + position: { + time: initialState.timestamps.startTimestamp + 1, + }, + })(initialState); + + expect(newState).toEqual({ + ...initialState, + targetPosition: { + time: initialState.timestamps.startTimestamp + 1, + tiebreaker: 0, + }, + latestPosition: { + time: initialState.timestamps.startTimestamp + 1, + tiebreaker: 0, + }, + }); + }); + + it('rejects a target position that is outside the date range', () => { + const initialState = createInitialTestState(); + const newState = updateStateFromUrlState({ + position: { + time: initialState.timestamps.startTimestamp - 1, + }, + })(initialState); + + expect(newState).toEqual({ + ...initialState, + targetPosition: null, + latestPosition: null, + }); + }); + + it('applies a new time range and updates timestamps', () => { + const initialState = createInitialTestState(); + const updateDate = getTestMoment().add(1, 'hour').toDate(); + const newState = updateStateFromUrlState( + { + start: 'now-2d', + end: 'now-1d', + }, + updateDate + )(initialState); + + expect(newState).toEqual({ + ...initialState, + timeRange: { + expression: { + from: 'now-2d', + to: 'now-1d', + }, + lastChangedCompletely: updateDate.valueOf(), + }, + timestamps: { + startTimestamp: moment(updateDate).subtract(2, 'day').valueOf(), + endTimestamp: moment(updateDate).subtract(1, 'day').valueOf(), + lastChangedTimestamp: updateDate.valueOf(), + }, + }); + }); +}); + +const getTestMoment = () => moment.utc('2022-01-01T00:00:00.000Z'); + +const createInitialTestState = () => + createInitialLogPositionState({ + initialStateFromUrl: null, + initialStateFromTimefilter: null, + now: getTestMoment().toDate(), + }); diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts index 521a5bf8562fc..cd5f11346c56a 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts @@ -5,249 +5,304 @@ * 2.0. */ -import { useState, useMemo, useEffect, useCallback } from 'react'; -import createContainer from 'constate'; -import useSetState from 'react-use/lib/useSetState'; -import useInterval from 'react-use/lib/useInterval'; -import { TimeKey } from '../../../../common/time'; -import { datemathToEpochMillis, isValidDatemath } from '../../../utils/datemath'; -import { useKibanaTimefilterTime } from '../../../hooks/use_kibana_timefilter_time'; - -type TimeKeyOrNull = TimeKey | null; - -interface DateRange { - startDateExpression: string; - endDateExpression: string; - startTimestamp: number; - endTimestamp: number; - timestampsLastUpdate: number; - lastCompleteDateRangeExpressionUpdate: number; -} +import { RefreshInterval } from '@kbn/data-plugin/public'; +import { TimeRange } from '@kbn/es-query'; +import { createStateContainer } from '@kbn/kibana-utils-plugin/public'; +import { identity, pipe } from 'fp-ts/lib/function'; +import produce, { Draft, original } from 'immer'; +import moment, { DurationInputObject } from 'moment'; +import { isSameTimeKey, MinimalTimeKey, pickTimeKey, TimeKey } from '../../../../common/time'; +import { datemathToEpochMillis } from '../../../utils/datemath'; +import { TimefilterState } from '../../../utils/timefilter_state_storage'; +import { LogPositionUrlState } from './use_log_position_url_state_sync'; interface VisiblePositions { - startKey: TimeKeyOrNull; - middleKey: TimeKeyOrNull; - endKey: TimeKeyOrNull; + startKey: TimeKey | null; + middleKey: TimeKey | null; + endKey: TimeKey | null; pagesAfterEnd: number; pagesBeforeStart: number; } -export interface LogPositionStateParams { - isInitialized: boolean; - targetPosition: TimeKeyOrNull; - isStreaming: boolean; - firstVisiblePosition: TimeKeyOrNull; - pagesBeforeStart: number; - pagesAfterEnd: number; - visibleMidpoint: TimeKeyOrNull; - visibleMidpointTime: number | null; - visibleTimeInterval: { start: number; end: number } | null; - startDateExpression: string; - endDateExpression: string; - startTimestamp: number | null; - endTimestamp: number | null; - timestampsLastUpdate: number; - lastCompleteDateRangeExpressionUpdate: number; +export interface LogPositionState { + timeRange: { + expression: TimeRange; + lastChangedCompletely: number; + }; + timestamps: { + startTimestamp: number; + endTimestamp: number; + lastChangedTimestamp: number; + }; + refreshInterval: RefreshInterval; + latestPosition: TimeKey | null; + targetPosition: TimeKey | null; + visiblePositions: VisiblePositions; } -export interface LogPositionCallbacks { - initialize: () => void; - jumpToTargetPosition: (pos: TimeKeyOrNull) => void; - jumpToTargetPositionTime: (time: number) => void; - reportVisiblePositions: (visPos: VisiblePositions) => void; - startLiveStreaming: () => void; - stopLiveStreaming: () => void; - updateDateRange: (newDateRage: Partial) => void; +export interface InitialLogPositionArguments { + initialStateFromUrl: LogPositionUrlState | null; + initialStateFromTimefilter: TimefilterState | null; + now?: Date; } -const DESIRED_BUFFER_PAGES = 2; - -const useVisibleMidpoint = (middleKey: TimeKeyOrNull, targetPosition: TimeKeyOrNull) => { - // Of the two dependencies `middleKey` and `targetPosition`, return - // whichever one was the most recently updated. This allows the UI controls - // to display a newly-selected `targetPosition` before loading new data; - // otherwise the previous `middleKey` would linger in the UI for the entirety - // of the loading operation, which the user could perceive as unresponsiveness - const [store, update] = useState({ - middleKey, - targetPosition, - currentValue: middleKey || targetPosition, - }); - useEffect(() => { - if (middleKey !== store.middleKey) { - update({ targetPosition, middleKey, currentValue: middleKey }); - } else if (targetPosition !== store.targetPosition) { - update({ targetPosition, middleKey, currentValue: targetPosition }); - } - }, [middleKey, targetPosition]); // eslint-disable-line react-hooks/exhaustive-deps +/** + * Initial state + */ - return store.currentValue; +const initialTimeRangeExpression: TimeRange = { + from: 'now-1d', + to: 'now', }; -const TIME_DEFAULTS = { from: 'now-1d', to: 'now' }; -const STREAMING_INTERVAL = 5000; +const initialRefreshInterval: RefreshInterval = { + pause: true, + value: 5000, +}; -export const useLogPositionState: () => LogPositionStateParams & LogPositionCallbacks = () => { - const [getTime, setTime] = useKibanaTimefilterTime(TIME_DEFAULTS); - const { from: start, to: end } = getTime(); +const initialVisiblePositions: VisiblePositions = { + endKey: null, + middleKey: null, + startKey: null, + pagesBeforeStart: Infinity, + pagesAfterEnd: Infinity, +}; - const DEFAULT_DATE_RANGE = { - startDateExpression: start, - endDateExpression: end, - }; +export const createInitialLogPositionState = ({ + initialStateFromUrl, + initialStateFromTimefilter, + now, +}: InitialLogPositionArguments): LogPositionState => { + const nowTimestamp = now?.valueOf() ?? Date.now(); + + return pipe( + { + timeRange: { + expression: initialTimeRangeExpression, + lastChangedCompletely: nowTimestamp, + }, + timestamps: { + startTimestamp: datemathToEpochMillis(initialTimeRangeExpression.from, 'down', now) ?? 0, + endTimestamp: datemathToEpochMillis(initialTimeRangeExpression.to, 'up', now) ?? 0, + lastChangedTimestamp: nowTimestamp, + }, + refreshInterval: initialRefreshInterval, + targetPosition: null, + latestPosition: null, + visiblePositions: initialVisiblePositions, + }, + initialStateFromUrl != null + ? initializeStateFromUrlState(initialStateFromUrl, now) + : initialStateFromTimefilter != null + ? updateStateFromTimefilterState(initialStateFromTimefilter, now) + : identity + ); +}; - // Flag to determine if `LogPositionState` has been fully initialized. - // - // When the page loads, there might be initial state in the URL. We want to - // prevent the entries from showing until we have processed that initial - // state. That prevents double fetching. - const [isInitialized, setInitialized] = useState(false); - const initialize = useCallback(() => { - setInitialized(true); - }, [setInitialized]); - - const [targetPosition, jumpToTargetPosition] = useState(null); - const [isStreaming, setIsStreaming] = useState(false); - const [visiblePositions, reportVisiblePositions] = useState({ - endKey: null, - middleKey: null, - startKey: null, - pagesBeforeStart: Infinity, - pagesAfterEnd: Infinity, +export const createLogPositionStateContainer = (initialArguments: InitialLogPositionArguments) => + createStateContainer(createInitialLogPositionState(initialArguments), { + updateTimeRange: (state: LogPositionState) => (timeRange: Partial) => + updateTimeRange(timeRange)(state), + updateRefreshInterval: + (state: LogPositionState) => (refreshInterval: Partial) => + updateRefreshInterval(refreshInterval)(state), + startLiveStreaming: (state: LogPositionState) => () => + updateRefreshInterval({ pause: false })(state), + stopLiveStreaming: (state: LogPositionState) => () => + updateRefreshInterval({ pause: true })(state), + jumpToTargetPosition: (state: LogPositionState) => (targetPosition: TimeKey | null) => + updateTargetPosition(targetPosition)(state), + jumpToTargetPositionTime: (state: LogPositionState) => (time: number) => + updateTargetPosition({ time })(state), + reportVisiblePositions: (state: LogPositionState) => (visiblePositions: VisiblePositions) => + updateVisiblePositions(visiblePositions)(state), }); - // We group the `startDate` and `endDate` values in the same object to be able - // to set both at the same time, saving a re-render - const [dateRange, setDateRange] = useSetState({ - ...DEFAULT_DATE_RANGE, - startTimestamp: datemathToEpochMillis(DEFAULT_DATE_RANGE.startDateExpression)!, - endTimestamp: datemathToEpochMillis(DEFAULT_DATE_RANGE.endDateExpression, 'up')!, - timestampsLastUpdate: Date.now(), - lastCompleteDateRangeExpressionUpdate: Date.now(), - }); +/** + * Common updaters + */ - useEffect(() => { - if (isInitialized) { - if ( - TIME_DEFAULTS.from !== dateRange.startDateExpression || - TIME_DEFAULTS.to !== dateRange.endDateExpression - ) { - setTime({ from: dateRange.startDateExpression, to: dateRange.endDateExpression }); - } - } - }, [isInitialized, dateRange.startDateExpression, dateRange.endDateExpression, setTime]); +const updateVisiblePositions = (visiblePositions: VisiblePositions) => + produce((draftState) => { + draftState.visiblePositions = visiblePositions; - const { startKey, middleKey, endKey, pagesBeforeStart, pagesAfterEnd } = visiblePositions; + updateLatestPositionDraft(draftState); + }); - const visibleMidpoint = useVisibleMidpoint(middleKey, targetPosition); +const updateTargetPosition = (targetPosition: Partial | null) => + produce((draftState) => { + if (targetPosition?.time != null) { + draftState.targetPosition = { + time: targetPosition.time, + tiebreaker: targetPosition.tiebreaker ?? 0, + }; + } else { + draftState.targetPosition = null; + } - const visibleTimeInterval = useMemo( - () => (startKey && endKey ? { start: startKey.time, end: endKey.time } : null), - [startKey, endKey] - ); + updateLatestPositionDraft(draftState); + }); - // Allow setting `startDate` and `endDate` separately, or together - const updateDateRange = useCallback( - (newDateRange: Partial) => { - // Prevent unnecessary re-renders - if (!('startDateExpression' in newDateRange) && !('endDateExpression' in newDateRange)) { - return; - } +const updateLatestPositionDraft = (draftState: Draft) => { + const previousState = original(draftState); + const previousVisibleMiddleKey = previousState?.visiblePositions?.middleKey ?? null; + const previousTargetPosition = previousState?.targetPosition ?? null; - const nextStartDateExpression = - newDateRange.startDateExpression || dateRange.startDateExpression; - const nextEndDateExpression = newDateRange.endDateExpression || dateRange.endDateExpression; + if (!isSameTimeKey(previousVisibleMiddleKey, draftState.visiblePositions.middleKey)) { + draftState.latestPosition = draftState.visiblePositions.middleKey; + } else if (!isSameTimeKey(previousTargetPosition, draftState.targetPosition)) { + draftState.latestPosition = draftState.targetPosition; + } +}; - if (!isValidDatemath(nextStartDateExpression) || !isValidDatemath(nextEndDateExpression)) { - return; +const updateTimeRange = (timeRange: Partial, now?: Date) => + produce((draftState) => { + const newFrom = timeRange?.from; + const newTo = timeRange?.to; + const nowTimestamp = now?.valueOf() ?? Date.now(); + + // Update expression and timestamps + if (newFrom != null) { + draftState.timeRange.expression.from = newFrom; + const newStartTimestamp = datemathToEpochMillis(newFrom, 'down', now); + if (newStartTimestamp != null) { + draftState.timestamps.startTimestamp = newStartTimestamp; + draftState.timestamps.lastChangedTimestamp = nowTimestamp; } - - // Dates are valid, so the function cannot return `null` - const nextStartTimestamp = datemathToEpochMillis(nextStartDateExpression)!; - const nextEndTimestamp = datemathToEpochMillis(nextEndDateExpression, 'up')!; - - // Reset the target position if it doesn't fall within the new range. - if ( - targetPosition && - (nextStartTimestamp > targetPosition.time || nextEndTimestamp < targetPosition.time) - ) { - jumpToTargetPosition(null); + } + if (newTo != null) { + draftState.timeRange.expression.to = newTo; + const newEndTimestamp = datemathToEpochMillis(newTo, 'up', now); + if (newEndTimestamp != null) { + draftState.timestamps.endTimestamp = newEndTimestamp; + draftState.timestamps.lastChangedTimestamp = nowTimestamp; } - - setDateRange((prevState) => ({ - ...newDateRange, - startTimestamp: nextStartTimestamp, - endTimestamp: nextEndTimestamp, - timestampsLastUpdate: Date.now(), - // NOTE: Complete refers to the last time an update was requested with both expressions. These require a full refresh (unless streaming). Timerange expansion - // and pagination however do not. - lastCompleteDateRangeExpressionUpdate: - 'startDateExpression' in newDateRange && 'endDateExpression' in newDateRange - ? Date.now() - : prevState.lastCompleteDateRangeExpressionUpdate, - })); - }, - [setDateRange, dateRange, targetPosition] - ); - - // `endTimestamp` update conditions - useEffect(() => { - if (dateRange.endDateExpression !== 'now') { - return; } - - // User is close to the bottom edge of the scroll. - if (visiblePositions.pagesAfterEnd <= DESIRED_BUFFER_PAGES) { - setDateRange({ - endTimestamp: datemathToEpochMillis(dateRange.endDateExpression, 'up')!, - timestampsLastUpdate: Date.now(), - }); + if (newFrom != null && newTo != null) { + draftState.timeRange.lastChangedCompletely = nowTimestamp; } - }, [dateRange.endDateExpression, visiblePositions, setDateRange]); - const startLiveStreaming = useCallback(() => { - setIsStreaming(true); - jumpToTargetPosition(null); - updateDateRange({ startDateExpression: 'now-1d', endDateExpression: 'now' }); - }, [updateDateRange]); + // Reset the target position if it doesn't fall within the new range. + if ( + draftState.targetPosition != null && + (draftState.timestamps.startTimestamp > draftState.targetPosition.time || + draftState.timestamps.endTimestamp < draftState.targetPosition.time) + ) { + draftState.targetPosition = null; - const stopLiveStreaming = useCallback(() => { - setIsStreaming(false); - }, []); - - useInterval( - () => updateDateRange({ startDateExpression: 'now-1d', endDateExpression: 'now' }), - isStreaming ? STREAMING_INTERVAL : null - ); + updateLatestPositionDraft(draftState); + } + }); - const state = { - isInitialized, - targetPosition, - isStreaming, - firstVisiblePosition: startKey, - pagesBeforeStart, - pagesAfterEnd, - visibleMidpoint, - visibleMidpointTime: visibleMidpoint ? visibleMidpoint.time : null, - visibleTimeInterval, - ...dateRange, - }; +const updateRefreshInterval = + (refreshInterval: Partial) => (state: LogPositionState) => + pipe( + state, + produce((draftState) => { + if (refreshInterval.pause != null) { + draftState.refreshInterval.pause = refreshInterval.pause; + } + if (refreshInterval.value != null) { + draftState.refreshInterval.value = refreshInterval.value; + } + + if (!draftState.refreshInterval.pause) { + draftState.targetPosition = null; + + updateLatestPositionDraft(draftState); + } + }), + (currentState) => { + if (!currentState.refreshInterval.pause) { + return updateTimeRange(initialTimeRangeExpression)(currentState); + } else { + return currentState; + } + } + ); - const callbacks = { - initialize, - jumpToTargetPosition, - jumpToTargetPositionTime: useCallback( - (time: number) => jumpToTargetPosition({ tiebreaker: 0, time }), - [jumpToTargetPosition] - ), - reportVisiblePositions, - startLiveStreaming, - stopLiveStreaming, - updateDateRange, - }; +/** + * URL state helpers + */ - return { ...state, ...callbacks }; -}; +export const getUrlState = (state: LogPositionState): LogPositionUrlState => ({ + streamLive: !state.refreshInterval.pause, + start: state.timeRange.expression.from, + end: state.timeRange.expression.to, + position: state.latestPosition ? pickTimeKey(state.latestPosition) : null, +}); + +export const initializeStateFromUrlState = + (urlState: LogPositionUrlState | null, now?: Date) => + (state: LogPositionState): LogPositionState => + pipe( + state, + updateTargetPosition(urlState?.position ?? null), + updateTimeRange( + { + from: urlState?.start ?? getTimeRangeStartFromPosition(urlState?.position), + to: urlState?.end ?? getTimeRangeEndFromPosition(urlState?.position), + }, + now + ), + updateRefreshInterval({ pause: !urlState?.streamLive }) + ); + +export const updateStateFromUrlState = + (urlState: LogPositionUrlState | null, now?: Date) => + (state: LogPositionState): LogPositionState => + pipe( + state, + updateTargetPosition(urlState?.position ?? null), + updateTimeRange( + { + from: urlState?.start, + to: urlState?.end, + }, + now + ), + updateRefreshInterval({ pause: !urlState?.streamLive }) + ); + +/** + * Timefilter helpers + */ -export const [LogPositionStateProvider, useLogPositionStateContext] = - createContainer(useLogPositionState); +export const getTimefilterState = (state: LogPositionState): TimefilterState => ({ + timeRange: state.timeRange.expression, + refreshInterval: state.refreshInterval, +}); + +export const updateStateFromTimefilterState = + (timefilterState: TimefilterState | null, now?: Date) => + (state: LogPositionState): LogPositionState => + pipe( + state, + updateTimeRange( + { + from: timefilterState?.timeRange?.from, + to: timefilterState?.timeRange?.to, + }, + now + ), + updateRefreshInterval({ + pause: timefilterState?.refreshInterval?.pause, + value: Math.max(timefilterState?.refreshInterval?.value ?? 0, initialRefreshInterval.value), + }) + ); + +const defaultTimeRangeFromPositionOffset: DurationInputObject = { hours: 1 }; + +const getTimeRangeStartFromPosition = ( + position: Partial | null | undefined +): string | undefined => + position?.time != null + ? moment(position.time).subtract(defaultTimeRangeFromPositionOffset).toISOString() + : undefined; + +const getTimeRangeEndFromPosition = ( + position: Partial | null | undefined +): string | undefined => + position?.time != null + ? moment(position.time).add(defaultTimeRangeFromPositionOffset).toISOString() + : undefined; diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_timefilter_state.ts b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_timefilter_state.ts new file mode 100644 index 0000000000000..35c2a2f367c4e --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_timefilter_state.ts @@ -0,0 +1,55 @@ +/* + * 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 { INullableBaseStateContainer, syncState } from '@kbn/kibana-utils-plugin/public'; +import { useCallback, useState } from 'react'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; +import { + createTimefilterStateStorage, + TimefilterState, + timefilterStateStorageKey, +} from '../../../utils/timefilter_state_storage'; + +export const useLogPositionTimefilterStateSync = () => { + const { + services: { + data: { + query: { + timefilter: { timefilter }, + }, + }, + }, + } = useKibanaContextForPlugin(); + + const [timefilterStateStorage] = useState(() => createTimefilterStateStorage({ timefilter })); + + const [initialStateFromTimefilter] = useState(() => + timefilterStateStorage.get(timefilterStateStorageKey) + ); + + const startSyncingWithTimefilter = useCallback( + (stateContainer: INullableBaseStateContainer) => { + timefilterStateStorage.set(timefilterStateStorageKey, stateContainer.get()); + + const { start, stop } = syncState({ + storageKey: timefilterStateStorageKey, + stateContainer, + stateStorage: timefilterStateStorage, + }); + + start(); + + return stop; + }, + [timefilterStateStorage] + ); + + return { + initialStateFromTimefilter, + startSyncingWithTimefilter, + }; +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/replace_log_position_in_query_string.ts b/x-pack/plugins/infra/public/containers/logs/log_position/replace_log_position_in_query_string.ts new file mode 100644 index 0000000000000..e447c2c1436d2 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_position/replace_log_position_in_query_string.ts @@ -0,0 +1,24 @@ +/* + * 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 { replaceStateKeyInQueryString } from '../../../utils/url_state'; +import { LogPositionUrlState, LOG_POSITION_URL_STATE_KEY } from './use_log_position_url_state_sync'; + +const ONE_HOUR = 3600000; + +export const replaceLogPositionInQueryString = (time: number) => + Number.isNaN(time) + ? (value: string) => value + : replaceStateKeyInQueryString(LOG_POSITION_URL_STATE_KEY, { + position: { + time, + tiebreaker: 0, + }, + end: new Date(time + ONE_HOUR).toISOString(), + start: new Date(time - ONE_HOUR).toISOString(), + streamLive: false, + }); diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/use_log_position.ts b/x-pack/plugins/infra/public/containers/logs/log_position/use_log_position.ts new file mode 100644 index 0000000000000..61e543b6b96ea --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_position/use_log_position.ts @@ -0,0 +1,201 @@ +/* + * 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 createContainer from 'constate'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import useInterval from 'react-use/lib/useInterval'; +import useThrottle from 'react-use/lib/useThrottle'; +import { TimeKey } from '../../../../common/time'; +import { withReduxDevTools } from '../../../utils/state_container_devtools'; +import { TimefilterState } from '../../../utils/timefilter_state_storage'; +import { useObservableState } from '../../../utils/use_observable'; +import { wrapStateContainer } from '../../../utils/wrap_state_container'; +import { + createLogPositionStateContainer, + getTimefilterState, + getUrlState, + LogPositionState, + updateStateFromTimefilterState, + updateStateFromUrlState, +} from './log_position_state'; +import { useLogPositionTimefilterStateSync } from './log_position_timefilter_state'; +import { LogPositionUrlState, useLogPositionUrlStateSync } from './use_log_position_url_state_sync'; + +type TimeKeyOrNull = TimeKey | null; + +interface DateRange { + startDateExpression: string; + endDateExpression: string; + startTimestamp: number; + endTimestamp: number; + timestampsLastUpdate: number; + lastCompleteDateRangeExpressionUpdate: number; +} + +interface VisiblePositions { + startKey: TimeKeyOrNull; + middleKey: TimeKeyOrNull; + endKey: TimeKeyOrNull; + pagesAfterEnd: number; + pagesBeforeStart: number; +} + +export type LogPositionStateParams = DateRange & { + targetPosition: TimeKeyOrNull; + isStreaming: boolean; + firstVisiblePosition: TimeKeyOrNull; + pagesBeforeStart: number; + pagesAfterEnd: number; + visibleMidpoint: TimeKeyOrNull; + visibleMidpointTime: number | null; + visibleTimeInterval: { start: number; end: number } | null; +}; + +export interface LogPositionCallbacks { + jumpToTargetPosition: (pos: TimeKeyOrNull) => void; + jumpToTargetPositionTime: (time: number) => void; + reportVisiblePositions: (visPos: VisiblePositions) => void; + startLiveStreaming: () => void; + stopLiveStreaming: () => void; + updateDateRange: UpdateDateRangeFn; +} + +type UpdateDateRangeFn = ( + newDateRange: Partial> +) => void; + +const DESIRED_BUFFER_PAGES = 2; +const RELATIVE_END_UPDATE_DELAY = 1000; + +export const useLogPositionState: () => LogPositionStateParams & LogPositionCallbacks = () => { + const { initialStateFromUrl, startSyncingWithUrl } = useLogPositionUrlStateSync(); + const { initialStateFromTimefilter, startSyncingWithTimefilter } = + useLogPositionTimefilterStateSync(); + + const [logPositionStateContainer] = useState(() => + withReduxDevTools( + createLogPositionStateContainer({ + initialStateFromUrl, + initialStateFromTimefilter, + }), + { + name: 'logPosition', + } + ) + ); + + useEffect(() => { + return startSyncingWithUrl( + wrapStateContainer({ + wrapGet: getUrlState, + wrapSet: updateStateFromUrlState, + })(logPositionStateContainer) + ); + }, [logPositionStateContainer, startSyncingWithUrl]); + + useEffect(() => { + return startSyncingWithTimefilter( + wrapStateContainer({ + wrapGet: getTimefilterState, + wrapSet: updateStateFromTimefilterState, + })(logPositionStateContainer) + ); + }, [logPositionStateContainer, startSyncingWithTimefilter, startSyncingWithUrl]); + + const { latestValue: latestLogPositionState } = useObservableState( + logPositionStateContainer.state$, + () => logPositionStateContainer.get() + ); + + const dateRange = useMemo( + () => getLegacyDateRange(latestLogPositionState), + [latestLogPositionState] + ); + + const { targetPosition, visiblePositions } = latestLogPositionState; + + const isStreaming = useMemo( + () => !latestLogPositionState.refreshInterval.pause, + [latestLogPositionState] + ); + + const updateDateRange = useCallback( + (newDateRange: Partial>) => + logPositionStateContainer.transitions.updateTimeRange({ + from: newDateRange.startDateExpression, + to: newDateRange.endDateExpression, + }), + [logPositionStateContainer] + ); + + const visibleTimeInterval = useMemo( + () => + visiblePositions.startKey && visiblePositions.endKey + ? { start: visiblePositions.startKey.time, end: visiblePositions.endKey.time } + : null, + [visiblePositions.startKey, visiblePositions.endKey] + ); + + // `endTimestamp` update conditions + const throttledPagesAfterEnd = useThrottle( + visiblePositions.pagesAfterEnd, + RELATIVE_END_UPDATE_DELAY + ); + useEffect(() => { + if (dateRange.endDateExpression !== 'now') { + return; + } + + // User is close to the bottom edge of the scroll. + if (throttledPagesAfterEnd <= DESIRED_BUFFER_PAGES) { + logPositionStateContainer.transitions.updateTimeRange({ to: 'now' }); + } + }, [dateRange.endDateExpression, throttledPagesAfterEnd, logPositionStateContainer]); + + useInterval( + () => logPositionStateContainer.transitions.updateTimeRange({ from: 'now-1d', to: 'now' }), + latestLogPositionState.refreshInterval.pause || + latestLogPositionState.refreshInterval.value <= 0 + ? null + : latestLogPositionState.refreshInterval.value + ); + + return { + // position state + targetPosition, + isStreaming, + ...dateRange, + + // visible positions state + firstVisiblePosition: visiblePositions.startKey, + pagesBeforeStart: visiblePositions.pagesBeforeStart, + pagesAfterEnd: visiblePositions.pagesAfterEnd, + visibleMidpoint: latestLogPositionState.latestPosition, + visibleMidpointTime: latestLogPositionState.latestPosition?.time ?? null, + visibleTimeInterval, + + // actions + jumpToTargetPosition: logPositionStateContainer.transitions.jumpToTargetPosition, + jumpToTargetPositionTime: logPositionStateContainer.transitions.jumpToTargetPositionTime, + reportVisiblePositions: logPositionStateContainer.transitions.reportVisiblePositions, + startLiveStreaming: logPositionStateContainer.transitions.startLiveStreaming, + stopLiveStreaming: logPositionStateContainer.transitions.stopLiveStreaming, + updateDateRange, + }; +}; + +export const [LogPositionStateProvider, useLogPositionStateContext] = + createContainer(useLogPositionState); + +const getLegacyDateRange = (logPositionState: LogPositionState): DateRange => ({ + endDateExpression: logPositionState.timeRange.expression.to, + endTimestamp: logPositionState.timestamps.endTimestamp, + lastCompleteDateRangeExpressionUpdate: logPositionState.timeRange.lastChangedCompletely, + startDateExpression: logPositionState.timeRange.expression.from, + startTimestamp: logPositionState.timestamps.startTimestamp, + timestampsLastUpdate: logPositionState.timestamps.lastChangedTimestamp, +}); diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/use_log_position_url_state_sync.ts b/x-pack/plugins/infra/public/containers/logs/log_position/use_log_position_url_state_sync.ts new file mode 100644 index 0000000000000..b9e6a8a5b3eb6 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_position/use_log_position_url_state_sync.ts @@ -0,0 +1,78 @@ +/* + * 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 { INullableBaseStateContainer, syncState } from '@kbn/kibana-utils-plugin/public'; +import { getOrElseW } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import * as rt from 'io-ts'; +import { useCallback, useState } from 'react'; +import { map } from 'rxjs/operators'; +import { minimalTimeKeyRT } from '../../../../common/time'; +import { datemathStringRT } from '../../../utils/datemath'; +import { useKbnUrlStateStorageFromRouterContext } from '../../../utils/kbn_url_state_context'; + +export const logPositionUrlStateRT = rt.partial({ + streamLive: rt.boolean, + position: rt.union([rt.partial(minimalTimeKeyRT.props), rt.null]), + start: datemathStringRT, + end: datemathStringRT, +}); + +export type LogPositionUrlState = rt.TypeOf; + +export const LOG_POSITION_URL_STATE_KEY = 'logPosition'; + +export const useLogPositionUrlStateSync = () => { + const urlStateStorage = useKbnUrlStateStorageFromRouterContext(); + + const [initialStateFromUrl] = useState(() => + pipe( + logPositionUrlStateRT.decode(urlStateStorage.get(LOG_POSITION_URL_STATE_KEY)), + getOrElseW(() => null) + ) + ); + + const startSyncingWithUrl = useCallback( + (stateContainer: INullableBaseStateContainer) => { + if (initialStateFromUrl == null) { + urlStateStorage.set(LOG_POSITION_URL_STATE_KEY, stateContainer.get(), { + replace: true, + }); + } + + const { start, stop } = syncState({ + storageKey: LOG_POSITION_URL_STATE_KEY, + stateContainer: { + state$: stateContainer.state$.pipe(map(logPositionUrlStateRT.encode)), + set: (value) => + stateContainer.set( + pipe( + logPositionUrlStateRT.decode(value), + getOrElseW(() => null) + ) + ), + get: () => logPositionUrlStateRT.encode(stateContainer.get()), + }, + stateStorage: { + ...urlStateStorage, + set: (key: string, state: State) => + urlStateStorage.set(key, state, { replace: true }), + }, + }); + + start(); + + return stop; + }, + [initialStateFromUrl, urlStateStorage] + ); + + return { + initialStateFromUrl, + startSyncingWithUrl, + }; +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx b/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx deleted file mode 100644 index d4d8075a2598f..0000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx +++ /dev/null @@ -1,151 +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, { useMemo } from 'react'; - -import { pickTimeKey } from '../../../../common/time'; -import { replaceStateKeyInQueryString, UrlStateContainer } from '../../../utils/url_state'; -import { useLogPositionStateContext, LogPositionStateParams } from './log_position_state'; -import { isValidDatemath, datemathToEpochMillis } from '../../../utils/datemath'; - -/** - * Url State - */ -export interface LogPositionUrlState { - position?: LogPositionStateParams['visibleMidpoint']; - streamLive: boolean; - start?: string; - end?: string; -} - -const ONE_HOUR = 3600000; - -export const WithLogPositionUrlState = () => { - const { - visibleMidpoint, - isStreaming, - jumpToTargetPosition, - startLiveStreaming, - stopLiveStreaming, - startDateExpression, - endDateExpression, - updateDateRange, - initialize, - } = useLogPositionStateContext(); - const urlState = useMemo( - () => ({ - position: visibleMidpoint ? pickTimeKey(visibleMidpoint) : null, - streamLive: isStreaming, - start: startDateExpression, - end: endDateExpression, - }), - [visibleMidpoint, isStreaming, startDateExpression, endDateExpression] - ); - return ( - { - if (!newUrlState) { - return; - } - - if (newUrlState.start || newUrlState.end) { - updateDateRange({ - startDateExpression: newUrlState.start, - endDateExpression: newUrlState.end, - }); - } - - if (newUrlState.position) { - jumpToTargetPosition(newUrlState.position); - } - - if (newUrlState.streamLive) { - startLiveStreaming(); - } else if (typeof newUrlState.streamLive !== 'undefined' && !newUrlState.streamLive) { - stopLiveStreaming(); - } - }} - onInitialize={(initialUrlState: LogPositionUrlState | undefined) => { - if (initialUrlState) { - const initialPosition = initialUrlState.position; - let initialStartDateExpression = initialUrlState.start; - let initialEndDateExpression = initialUrlState.end; - - if (!initialPosition) { - initialStartDateExpression = initialStartDateExpression || 'now-1d'; - initialEndDateExpression = initialEndDateExpression || 'now'; - } else { - const initialStartTimestamp = initialStartDateExpression - ? datemathToEpochMillis(initialStartDateExpression) - : undefined; - const initialEndTimestamp = initialEndDateExpression - ? datemathToEpochMillis(initialEndDateExpression, 'up') - : undefined; - - // Adjust the start-end range if the target position falls outside or if it's not set. - if (!initialStartTimestamp || initialStartTimestamp > initialPosition.time) { - initialStartDateExpression = new Date(initialPosition.time - ONE_HOUR).toISOString(); - } - - if (!initialEndTimestamp || initialEndTimestamp < initialPosition.time) { - initialEndDateExpression = new Date(initialPosition.time + ONE_HOUR).toISOString(); - } - - jumpToTargetPosition(initialPosition); - } - - if (initialStartDateExpression || initialEndDateExpression) { - updateDateRange({ - startDateExpression: initialStartDateExpression, - endDateExpression: initialEndDateExpression, - }); - } - - if (initialUrlState.streamLive) { - startLiveStreaming(); - } - } - - initialize(); - }} - /> - ); -}; - -const mapToUrlState = (value: any): LogPositionUrlState | undefined => - value - ? { - position: mapToPositionUrlState(value.position), - streamLive: mapToStreamLiveUrlState(value.streamLive), - start: mapToDate(value.start), - end: mapToDate(value.end), - } - : undefined; - -const mapToPositionUrlState = (value: any) => - value && typeof value.time === 'number' && typeof value.tiebreaker === 'number' - ? pickTimeKey(value) - : undefined; - -const mapToStreamLiveUrlState = (value: any) => (typeof value === 'boolean' ? value : false); - -const mapToDate = (value: any) => (isValidDatemath(value) ? value : undefined); -export const replaceLogPositionInQueryString = (time: number) => - Number.isNaN(time) - ? (value: string) => value - : replaceStateKeyInQueryString('logPosition', { - position: { - time, - tiebreaker: 0, - }, - end: new Date(time + ONE_HOUR).toISOString(), - start: new Date(time - ONE_HOUR).toISOString(), - streamLive: false, - }); diff --git a/x-pack/plugins/infra/public/pages/logs/stream/components/stream_live_button.tsx b/x-pack/plugins/infra/public/pages/logs/stream/components/stream_live_button.tsx new file mode 100644 index 0000000000000..d97a929aa3a16 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/stream/components/stream_live_button.tsx @@ -0,0 +1,31 @@ +/* + * 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 from 'react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +export const StreamLiveButton: React.FC<{ + isStreaming: boolean; + onStartStreaming: () => void; + onStopStreaming: () => void; +}> = ({ isStreaming, onStartStreaming, onStopStreaming }) => + isStreaming ? ( + + + + ) : ( + + + + ); diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx index 026119ff5c74c..9a726152d9f7c 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx @@ -16,7 +16,6 @@ import { LogHighlightsStateProvider } from '../../../containers/logs/log_highlig import { LogPositionStateProvider, useLogPositionStateContext, - WithLogPositionUrlState, } from '../../../containers/logs/log_position'; import { LogStreamProvider, useLogStreamContext } from '../../../containers/logs/log_stream'; import { LogViewConfigurationProvider } from '../../../containers/logs/log_view_configuration'; @@ -55,8 +54,7 @@ const ViewLogInContext: React.FC = ({ children }) => { const LogEntriesStateProvider: React.FC = ({ children }) => { const { logViewId } = useLogViewContext(); - const { startTimestamp, endTimestamp, targetPosition, isInitialized } = - useLogPositionStateContext(); + const { startTimestamp, endTimestamp, targetPosition } = useLogPositionStateContext(); const { filterQuery } = useLogFilterStateContext(); // Don't render anything if the date range is incorrect. @@ -64,12 +62,6 @@ const LogEntriesStateProvider: React.FC = ({ children }) => { return null; } - // Don't initialize the entries until the position has been fully intialized. - // See `` - if (!isInitialized) { - return null; - } - return ( { - diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx index cf30518f78ede..36c2349b471fd 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx @@ -5,20 +5,19 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; +import React, { useMemo } from 'react'; import { LogCustomizationMenu } from '../../../components/logging/log_customization_menu'; -import { LogDatepicker } from '../../../components/logging/log_datepicker'; import { LogHighlightsMenu } from '../../../components/logging/log_highlights_menu'; import { LogTextScaleControls } from '../../../components/logging/log_text_scale_controls'; import { LogTextWrapControls } from '../../../components/logging/log_text_wrap_controls'; import { useLogHighlightsStateContext } from '../../../containers/logs/log_highlights/log_highlights'; import { useLogPositionStateContext } from '../../../containers/logs/log_position'; import { useLogViewConfigurationContext } from '../../../containers/logs/log_view_configuration'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; import { useLogViewContext } from '../../../hooks/use_log_view'; +import { StreamLiveButton } from './components/stream_live_button'; export const LogsToolbar = () => { const { derivedDataView } = useLogViewContext(); @@ -39,83 +38,72 @@ export const LogsToolbar = () => { goToPreviousHighlight, goToNextHighlight, } = useLogHighlightsStateContext(); - const { - isStreaming, - startLiveStreaming, - stopLiveStreaming, - startDateExpression, - endDateExpression, - updateDateRange, - } = useLogPositionStateContext(); + const { isStreaming, startLiveStreaming, stopLiveStreaming } = useLogPositionStateContext(); + + const dataViews = useMemo( + () => (derivedDataView != null ? [derivedDataView] : undefined), + [derivedDataView] + ); return ( -
- - - - - - - - - - - - - - highlightTerm.length > 0).length > 0 - } - goToPreviousHighlight={goToPreviousHighlight} - goToNextHighlight={goToNextHighlight} - hasPreviousHighlight={hasPreviousHighlight} - hasNextHighlight={hasNextHighlight} + <> + + +
+ + + + + - - - - - - - -
+ +
+ + highlightTerm.length > 0).length > 0 + } + goToPreviousHighlight={goToPreviousHighlight} + goToNextHighlight={goToNextHighlight} + hasPreviousHighlight={hasPreviousHighlight} + hasNextHighlight={hasNextHighlight} + /> + + + + + +
+
+ ); }; - -const QueryBarFlexItem = euiStyled(EuiFlexItem)` - @media (min-width: 1200px) { - flex: 0 0 100% !important; - margin-left: 0 !important; - margin-right: 0 !important; - padding-left: 12px; - padding-right: 12px; - } -`; diff --git a/x-pack/plugins/infra/public/utils/datemath.ts b/x-pack/plugins/infra/public/utils/datemath.ts index 2ed4f68b7a934..68c77e3c0e7ed 100644 --- a/x-pack/plugins/infra/public/utils/datemath.ts +++ b/x-pack/plugins/infra/public/utils/datemath.ts @@ -6,6 +6,9 @@ */ import dateMath, { Unit } from '@kbn/datemath'; +import { chain } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import * as rt from 'io-ts'; const JS_MAX_DATE = 8640000000000000; @@ -14,8 +17,25 @@ export function isValidDatemath(value: string): boolean { return !!(parsedValue && parsedValue.isValid()); } -export function datemathToEpochMillis(value: string, round: 'down' | 'up' = 'down'): number | null { - const parsedValue = dateMath.parse(value, { roundUp: round === 'up' }); +export const datemathStringRT = new rt.Type( + 'datemath', + rt.string.is, + (value, context) => + pipe( + rt.string.validate(value, context), + chain((stringValue) => + isValidDatemath(stringValue) ? rt.success(stringValue) : rt.failure(stringValue, context) + ) + ), + String +); + +export function datemathToEpochMillis( + value: string, + round: 'down' | 'up' = 'down', + forceNow?: Date +): number | null { + const parsedValue = dateMath.parse(value, { roundUp: round === 'up', forceNow }); if (!parsedValue || !parsedValue.isValid()) { return null; } diff --git a/x-pack/plugins/infra/public/utils/kbn_url_state_context.ts b/x-pack/plugins/infra/public/utils/kbn_url_state_context.ts new file mode 100644 index 0000000000000..7a751e30f4082 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/kbn_url_state_context.ts @@ -0,0 +1,34 @@ +/* + * 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 { IToasts } from '@kbn/core-notifications-browser'; +import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public'; +import createContainer from 'constate'; +import { History } from 'history'; +import { useState } from 'react'; + +const useKbnUrlStateStorageFromRouter = ({ + history, + toastsService, +}: { + history: History; + toastsService: IToasts; +}) => { + const [urlStateStorage] = useState(() => + createKbnUrlStateStorage({ + history, + useHash: false, + useHashQuery: false, + ...withNotifyOnErrors(toastsService), + }) + ); + + return urlStateStorage; +}; + +export const [KbnUrlStateStorageFromRouterProvider, useKbnUrlStateStorageFromRouterContext] = + createContainer(useKbnUrlStateStorageFromRouter); diff --git a/x-pack/plugins/infra/public/utils/state_container_devtools.ts b/x-pack/plugins/infra/public/utils/state_container_devtools.ts new file mode 100644 index 0000000000000..c68936eca7921 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/state_container_devtools.ts @@ -0,0 +1,51 @@ +/* + * 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 { ReduxLikeStateContainer } from '@kbn/kibana-utils-plugin/public'; +import { EnhancerOptions } from 'redux-devtools-extension'; + +export const withReduxDevTools = >( + stateContainer: StateContainer, + config?: EnhancerOptions +): StateContainer => { + if (process.env.NODE_ENV !== 'production' && (window as any).__REDUX_DEVTOOLS_EXTENSION__) { + const devToolsExtension = (window as any).__REDUX_DEVTOOLS_EXTENSION__; + + const devToolsInstance = devToolsExtension.connect({ + ...config, + serialize: { + ...(typeof config?.serialize === 'object' ? config.serialize : {}), + replacer: (_key: string, value: unknown) => replaceReactSyntheticEvent(value), + }, + features: { + lock: false, + persist: false, + import: false, + jump: false, + skip: false, + reorder: false, + dispatch: false, + ...config?.features, + }, + }); + + devToolsInstance.init(stateContainer.getState()); + + stateContainer.addMiddleware(({ getState }) => (next) => (action) => { + devToolsInstance.send(action, getState()); + return next(action); + }); + } + + return stateContainer; +}; + +const isReactSyntheticEvent = (value: unknown) => + typeof value === 'object' && value != null && (value as any).nativeEvent instanceof Event; + +const replaceReactSyntheticEvent = (value: unknown) => + isReactSyntheticEvent(value) ? '[ReactSyntheticEvent]' : value; diff --git a/x-pack/plugins/infra/public/utils/timefilter_state_storage.ts b/x-pack/plugins/infra/public/utils/timefilter_state_storage.ts new file mode 100644 index 0000000000000..d990ec81649c1 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/timefilter_state_storage.ts @@ -0,0 +1,64 @@ +/* + * 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 { RefreshInterval, TimefilterContract } from '@kbn/data-plugin/public'; +import { TimeRange } from '@kbn/es-query'; +import { IStateStorage } from '@kbn/kibana-utils-plugin/public'; +import { map, merge, Observable, of } from 'rxjs'; + +export const timefilterStateStorageKey = 'timefilter'; +type TimefilterStateStorageKey = typeof timefilterStateStorageKey; + +interface ITimefilterStateStorage extends IStateStorage { + set(key: TimefilterStateStorageKey, state: TimefilterState): void; + set(key: string, state: State): void; + get(key: TimefilterStateStorageKey): TimefilterState | null; + get(key: string): State | null; + change$(key: TimefilterStateStorageKey): Observable; + change$(key: string): Observable; +} + +export interface TimefilterState { + timeRange?: TimeRange; + refreshInterval?: RefreshInterval; +} + +export const createTimefilterStateStorage = ({ + timefilter, +}: { + timefilter: TimefilterContract; +}): ITimefilterStateStorage => { + return { + set: (key, state) => { + if (key !== timefilterStateStorageKey) { + return; + } + + // TS doesn't narrow the overload arguments correctly + const { timeRange, refreshInterval } = state as TimefilterState; + + if (timeRange != null) { + timefilter.setTime(timeRange); + } + if (refreshInterval != null) { + timefilter.setRefreshInterval(refreshInterval); + } + }, + get: (key) => (key === timefilterStateStorageKey ? getTimefilterState(timefilter) : null), + change$: (key) => + key === timefilterStateStorageKey + ? merge(timefilter.getTimeUpdate$(), timefilter.getRefreshIntervalUpdate$()).pipe( + map(() => getTimefilterState(timefilter)) + ) + : of(null), + }; +}; + +const getTimefilterState = (timefilter: TimefilterContract): TimefilterState => ({ + timeRange: timefilter.getTime(), + refreshInterval: timefilter.getRefreshInterval(), +}); diff --git a/x-pack/plugins/infra/public/utils/wrap_state_container.ts b/x-pack/plugins/infra/public/utils/wrap_state_container.ts new file mode 100644 index 0000000000000..d6ec59ded314b --- /dev/null +++ b/x-pack/plugins/infra/public/utils/wrap_state_container.ts @@ -0,0 +1,23 @@ +/* + * 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 { BaseState, BaseStateContainer } from '@kbn/kibana-utils-plugin/public'; +import { map } from 'rxjs/operators'; + +export const wrapStateContainer = + ({ + wrapSet, + wrapGet, + }: { + wrapSet: (state: StateB | null) => (previousState: StateA) => StateA; + wrapGet: (state: StateA) => StateB; + }) => + (stateContainer: BaseStateContainer) => ({ + get: () => wrapGet(stateContainer.get()), + set: (value: StateB | null) => stateContainer.set(wrapSet(value)(stateContainer.get())), + state$: stateContainer.state$.pipe(map(wrapGet)), + }); diff --git a/x-pack/plugins/observability/public/components/shared/page_template/page_template.tsx b/x-pack/plugins/observability/public/components/shared/page_template/page_template.tsx index aad9aee7af23f..47a6649e098cd 100644 --- a/x-pack/plugins/observability/public/components/shared/page_template/page_template.tsx +++ b/x-pack/plugins/observability/public/components/shared/page_template/page_template.tsx @@ -98,6 +98,7 @@ export function ObservabilityPageTemplate({ strict: !entry.ignoreTrailingSlash, }) != null); const badgeLocalStorageId = `observability.nav_item_badge_visible_${entry.app}${entry.path}`; + const navId = entry.label.toLowerCase().split(' ').join('_'); return { id: `${sectionIndex}.${entryIndex}`, name: entry.isNewFeature ? ( @@ -107,7 +108,8 @@ export function ObservabilityPageTemplate({ ), href, isSelected, - 'data-nav-id': entry.label.toLowerCase().split(' ').join('_'), + 'data-nav-id': navId, + 'data-test-subj': `observability-nav-${entry.app}-${navId}`, onClick: (event) => { if (entry.onClick) { entry.onClick(event); diff --git a/x-pack/test/functional/apps/infra/logs_source_configuration.ts b/x-pack/test/functional/apps/infra/logs_source_configuration.ts index 38cc795034a22..24b7a538d77f6 100644 --- a/x-pack/test/functional/apps/infra/logs_source_configuration.ts +++ b/x-pack/test/functional/apps/infra/logs_source_configuration.ts @@ -54,9 +54,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.infraLogs.navigateToTab('settings'); await pageObjects.header.waitUntilLoadingHasFinished(); - const documentTitle = await browser.getTitle(); - expect(documentTitle).to.contain('Settings - Logs - Observability - Elastic'); + retry.try(async () => { + const documentTitle = await browser.getTitle(); + expect(documentTitle).to.contain('Settings - Logs - Observability - Elastic'); + }); }); it('can change the log indices to a pattern that matches nothing', async () => { diff --git a/x-pack/test/functional/page_objects/infra_logs_page.ts b/x-pack/test/functional/page_objects/infra_logs_page.ts index c1d20c2e977ad..0bcbff031005c 100644 --- a/x-pack/test/functional/page_objects/infra_logs_page.ts +++ b/x-pack/test/functional/page_objects/infra_logs_page.ts @@ -5,11 +5,10 @@ * 2.0. */ -// import moment from 'moment'; +import { FlyoutOptionsUrlState } from '@kbn/infra-plugin/public/containers/logs/log_flyout'; +import { LogPositionUrlState } from '@kbn/infra-plugin/public/containers/logs/log_position'; import querystring from 'querystring'; import { encode, RisonValue } from 'rison-node'; -import { LogPositionUrlState } from '@kbn/infra-plugin/public/containers/logs/log_position/with_log_position_url_state'; -import { FlyoutOptionsUrlState } from '@kbn/infra-plugin/public/containers/logs/log_flyout'; import { FtrProviderContext } from '../ftr_provider_context'; export interface TabsParams { diff --git a/x-pack/test/functional/page_objects/observability_page.ts b/x-pack/test/functional/page_objects/observability_page.ts index 07cceca4be122..0177939ec3d15 100644 --- a/x-pack/test/functional/page_objects/observability_page.ts +++ b/x-pack/test/functional/page_objects/observability_page.ts @@ -13,6 +13,10 @@ export function ObservabilityPageProvider({ getService, getPageObjects }: FtrPro const testSubjects = getService('testSubjects'); return { + async clickSolutionNavigationEntry(appId: string, navId: string) { + await testSubjects.click(`observability-nav-${appId}-${navId}`); + }, + async expectCreateCaseButtonEnabled() { const button = await testSubjects.find('createNewCaseBtn', 20000); const disabledAttr = await button.getAttribute('disabled'); @@ -54,5 +58,10 @@ export function ObservabilityPageProvider({ getService, getPageObjects }: FtrPro const text = await h2.getVisibleText(); expect(text).to.contain('Kibana feature privileges required'); }, + + async getDatePickerRangeText() { + const datePickerButton = await testSubjects.find('superDatePickerShowDatesButton'); + return await datePickerButton.getVisibleText(); + }, }; } diff --git a/x-pack/test/observability_functional/apps/observability/pages/alerts/state_synchronization.ts b/x-pack/test/observability_functional/apps/observability/pages/alerts/state_synchronization.ts index fe9751dc9c738..8f4bcbb237620 100644 --- a/x-pack/test/observability_functional/apps/observability/pages/alerts/state_synchronization.ts +++ b/x-pack/test/observability_functional/apps/observability/pages/alerts/state_synchronization.ts @@ -17,7 +17,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const find = getService('find'); const testSubjects = getService('testSubjects'); const observability = getService('observability'); - const pageObjects = getPageObjects(['common']); + const pageObjects = getPageObjects(['common', 'observability', 'timePicker']); before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); @@ -45,9 +45,14 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('should not sync URL state to shared time range on page load ', async () => { - await (await find.byLinkText('Stream')).click(); + await pageObjects.observability.clickSolutionNavigationEntry( + 'observability-overview', + 'overview' + ); + + const observabilityPageDateRange = await pageObjects.observability.getDatePickerRangeText(); - await assertLogsStreamPageTimeRange('Last 1 day'); + expect(observabilityPageDateRange).to.be('Last 15 minutes'); }); it('should apply defaults if URL state is missing', async () => { @@ -61,18 +66,29 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('should use shared time range if set', async () => { - await (await find.byLinkText('Stream')).click(); + await pageObjects.observability.clickSolutionNavigationEntry( + 'observability-overview', + 'overview' + ); await setTimeRangeToXDaysAgo(10); - await (await find.byLinkText('Alerts')).click(); + await pageObjects.observability.clickSolutionNavigationEntry( + 'observability-overview', + 'alerts' + ); expect(await observability.alerts.common.getTimeRange()).to.be('Last 10 days'); }); it('should set the shared time range', async () => { await setTimeRangeToXDaysAgo(100); - await (await find.byLinkText('Stream')).click(); + await pageObjects.observability.clickSolutionNavigationEntry( + 'observability-overview', + 'overview' + ); + + const observabilityPageDateRange = await pageObjects.observability.getDatePickerRangeText(); - await assertLogsStreamPageTimeRange('Last 100 days'); + expect(observabilityPageDateRange).to.be('Last 100 days'); }); async function assertAlertsPageState(expected: { @@ -90,18 +106,13 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(timeRange).to.be(expected.timeRange); } - async function assertLogsStreamPageTimeRange(expected: string) { - // Only handles relative time ranges - const datePickerButton = await testSubjects.find('superDatePickerShowDatesButton'); - const timerange = await datePickerButton.getVisibleText(); - expect(timerange).to.be(expected); - } - async function setTimeRangeToXDaysAgo(numberOfDays: number) { await (await testSubjects.find('superDatePickerToggleQuickMenuButton')).click(); - const numerOfDaysField = await find.byCssSelector('[aria-label="Time value"]'); - await numerOfDaysField.clearValueWithKeyboard(); - await numerOfDaysField.type(numberOfDays.toString()); + const numberField = await find.byCssSelector('[aria-label="Time value"]'); + await numberField.clearValueWithKeyboard(); + await numberField.type(numberOfDays.toString()); + const unitField = await find.byCssSelector('[aria-label="Time unit"]'); + await unitField.type('Days'); await find.clickByButtonText('Apply'); } });