diff --git a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts index 97bdad23beb240..419ee021a91896 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts @@ -12,11 +12,11 @@ export const LOG_ENTRIES_PATH = '/api/log_entries/entries'; export const logEntriesBaseRequestRT = rt.intersection([ rt.type({ sourceId: rt.string, - startDate: rt.number, - endDate: rt.number, + startTimestamp: rt.number, + endTimestamp: rt.number, }), rt.partial({ - query: rt.string, + query: rt.union([rt.string, rt.null]), size: rt.number, }), ]); @@ -31,7 +31,7 @@ export const logEntriesAfterRequestRT = rt.intersection([ rt.type({ after: rt.union([logEntriesCursorRT, rt.literal('first')]) }), ]); -export const logEntriesCenteredRT = rt.intersection([ +export const logEntriesCenteredRequestRT = rt.intersection([ logEntriesBaseRequestRT, rt.type({ center: logEntriesCursorRT }), ]); @@ -40,38 +40,39 @@ export const logEntriesRequestRT = rt.union([ logEntriesBaseRequestRT, logEntriesBeforeRequestRT, logEntriesAfterRequestRT, - logEntriesCenteredRT, + logEntriesCenteredRequestRT, ]); +export type LogEntriesBaseRequest = rt.TypeOf; +export type LogEntriesBeforeRequest = rt.TypeOf; +export type LogEntriesAfterRequest = rt.TypeOf; +export type LogEntriesCenteredRequest = rt.TypeOf; export type LogEntriesRequest = rt.TypeOf; -// JSON value -const valueRT = rt.union([rt.string, rt.number, rt.boolean, rt.object, rt.null, rt.undefined]); +export const logMessageConstantPartRT = rt.type({ + constant: rt.string, +}); +export const logMessageFieldPartRT = rt.type({ + field: rt.string, + value: rt.unknown, + highlights: rt.array(rt.string), +}); -export const logMessagePartRT = rt.union([ - rt.type({ - constant: rt.string, - }), - rt.type({ - field: rt.string, - value: valueRT, - highlights: rt.array(rt.string), - }), -]); +export const logMessagePartRT = rt.union([logMessageConstantPartRT, logMessageFieldPartRT]); -export const logColumnRT = rt.union([ - rt.type({ columnId: rt.string, timestamp: rt.number }), - rt.type({ - columnId: rt.string, - field: rt.string, - value: rt.union([rt.string, rt.undefined]), - highlights: rt.array(rt.string), - }), - rt.type({ - columnId: rt.string, - message: rt.array(logMessagePartRT), - }), -]); +export const logTimestampColumnRT = rt.type({ columnId: rt.string, timestamp: rt.number }); +export const logFieldColumnRT = rt.type({ + columnId: rt.string, + field: rt.string, + value: rt.unknown, + highlights: rt.array(rt.string), +}); +export const logMessageColumnRT = rt.type({ + columnId: rt.string, + message: rt.array(logMessagePartRT), +}); + +export const logColumnRT = rt.union([logTimestampColumnRT, logFieldColumnRT, logMessageColumnRT]); export const logEntryRT = rt.type({ id: rt.string, @@ -79,15 +80,20 @@ export const logEntryRT = rt.type({ columns: rt.array(logColumnRT), }); -export type LogMessagepart = rt.TypeOf; +export type LogMessageConstantPart = rt.TypeOf; +export type LogMessageFieldPart = rt.TypeOf; +export type LogMessagePart = rt.TypeOf; +export type LogTimestampColumn = rt.TypeOf; +export type LogFieldColumn = rt.TypeOf; +export type LogMessageColumn = rt.TypeOf; export type LogColumn = rt.TypeOf; export type LogEntry = rt.TypeOf; export const logEntriesResponseRT = rt.type({ data: rt.type({ entries: rt.array(logEntryRT), - topCursor: logEntriesCursorRT, - bottomCursor: logEntriesCursorRT, + topCursor: rt.union([logEntriesCursorRT, rt.null]), + bottomCursor: rt.union([logEntriesCursorRT, rt.null]), }), }); diff --git a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts index 516cd67f2764da..f6d61a7177b494 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts @@ -9,7 +9,7 @@ import { logEntriesBaseRequestRT, logEntriesBeforeRequestRT, logEntriesAfterRequestRT, - logEntriesCenteredRT, + logEntriesCenteredRequestRT, logEntryRT, } from './entries'; import { logEntriesCursorRT } from './common'; @@ -36,7 +36,7 @@ export const logEntriesHighlightsAfterRequestRT = rt.intersection([ ]); export const logEntriesHighlightsCenteredRequestRT = rt.intersection([ - logEntriesCenteredRT, + logEntriesCenteredRequestRT, highlightsRT, ]); diff --git a/x-pack/plugins/infra/common/http_api/log_entries/summary.ts b/x-pack/plugins/infra/common/http_api/log_entries/summary.ts index 4a2c0db0e995ea..6af4b7c592ab6a 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/summary.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/summary.ts @@ -10,8 +10,8 @@ export const LOG_ENTRIES_SUMMARY_PATH = '/api/log_entries/summary'; export const logEntriesSummaryRequestRT = rt.type({ sourceId: rt.string, - startDate: rt.number, - endDate: rt.number, + startTimestamp: rt.number, + endTimestamp: rt.number, bucketSize: rt.number, query: rt.union([rt.string, rt.undefined, rt.null]), }); diff --git a/x-pack/plugins/infra/public/components/logging/log_datepicker.tsx b/x-pack/plugins/infra/public/components/logging/log_datepicker.tsx new file mode 100644 index 00000000000000..e80f738eac6ba3 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_datepicker.tsx @@ -0,0 +1,73 @@ +/* + * 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, { useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSuperDatePicker, EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface LogDatepickerProps { + startDateExpression: string; + endDateExpression: string; + isStreaming: boolean; + onUpdateDateRange?: (range: { startDateExpression: string; endDateExpression: string }) => void; + onStartStreaming?: () => void; + onStopStreaming?: () => void; +} + +export const LogDatepicker: React.FC = ({ + startDateExpression, + endDateExpression, + isStreaming, + onUpdateDateRange, + onStartStreaming, + onStopStreaming, +}) => { + const handleTimeChange = useCallback( + ({ start, end, isInvalid }) => { + if (onUpdateDateRange && !isInvalid) { + onUpdateDateRange({ startDateExpression: start, endDateExpression: end }); + } + }, + [onUpdateDateRange] + ); + + return ( + + + + + + {isStreaming ? ( + + + + ) : ( + + + + )} + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx index 729689e65739ef..2bdb1f91a6dde0 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx @@ -10,10 +10,10 @@ import { max } from 'lodash'; import * as React from 'react'; import { euiStyled } from '../../../../../observability/public'; -import { SummaryBucket } from './types'; +import { LogEntriesSummaryBucket } from '../../../../common/http_api'; interface DensityChartProps { - buckets: SummaryBucket[]; + buckets: LogEntriesSummaryBucket[]; end: number; start: number; width: number; @@ -38,36 +38,36 @@ export const DensityChart: React.FC = ({ const xMax = max(buckets.map(bucket => bucket.entriesCount)) || 0; const xScale = scaleLinear() .domain([0, xMax]) - .range([0, width * (2 / 3)]); + .range([0, width]); - const path = area() + const path = area() .x0(xScale(0)) .x1(bucket => xScale(bucket.entriesCount)) - .y(bucket => yScale((bucket.start + bucket.end) / 2)) + .y0(bucket => yScale(bucket.start)) + .y1(bucket => yScale(bucket.end)) .curve(curveMonotoneY); - const pathData = path(buckets); - const highestPathCoord = String(pathData) - .replace(/[^.0-9,]/g, ' ') - .split(/[ ,]/) - .reduce((result, num) => (Number(num) > result ? Number(num) : result), 0); + const firstBucket = buckets[0]; + const lastBucket = buckets[buckets.length - 1]; + const pathBuckets = [ + // Make sure the graph starts at the count of the first point + { start, end: start, entriesCount: firstBucket.entriesCount }, + ...buckets, + // Make sure the line ends at the height of the last point + { start: lastBucket.end, end: lastBucket.end, entriesCount: lastBucket.entriesCount }, + // If the last point is not at the end of the minimap, make sure it doesn't extend indefinitely and goes to 0 + { start: end, end, entriesCount: 0 }, + ]; + const pathData = path(pathBuckets); + return ( - - - + + ); }; -const DensityChartNegativeBackground = euiStyled.rect` - fill: ${props => props.theme.eui.euiColorEmptyShade}; -`; - const DensityChartPositiveBackground = euiStyled.rect` fill: ${props => props.theme.darkMode diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx index 2e45bcea421094..975e83e0075ff9 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx @@ -13,6 +13,7 @@ interface HighlightedIntervalProps { getPositionOfTime: (time: number) => number; start: number; end: number; + targetWidth: number; width: number; target: number | null; } @@ -22,6 +23,7 @@ export const HighlightedInterval: React.FC = ({ end, getPositionOfTime, start, + targetWidth, width, target, }) => { @@ -35,14 +37,14 @@ export const HighlightedInterval: React.FC = ({ )} ); diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx index e3a7e5aa306336..c67674d198a3f9 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx @@ -13,42 +13,40 @@ import { DensityChart } from './density_chart'; import { HighlightedInterval } from './highlighted_interval'; import { SearchMarkers } from './search_markers'; import { TimeRuler } from './time_ruler'; -import { SummaryBucket, SummaryHighlightBucket } from './types'; +import { + LogEntriesSummaryBucket, + LogEntriesSummaryHighlightsBucket, +} from '../../../../common/http_api'; interface Interval { end: number; start: number; } -interface DragRecord { - startY: number; - currentY: number | null; -} - interface LogMinimapProps { className?: string; height: number; highlightedInterval: Interval | null; jumpToTarget: (params: LogEntryTime) => any; - intervalSize: number; - summaryBuckets: SummaryBucket[]; - summaryHighlightBuckets?: SummaryHighlightBucket[]; + summaryBuckets: LogEntriesSummaryBucket[]; + summaryHighlightBuckets?: LogEntriesSummaryHighlightsBucket[]; target: number | null; + start: number | null; + end: number | null; width: number; } interface LogMinimapState { target: number | null; - drag: DragRecord | null; - svgPosition: ClientRect; timeCursorY: number; } -function calculateYScale(target: number | null, height: number, intervalSize: number) { - const domainStart = target ? target - intervalSize / 2 : 0; - const domainEnd = target ? target + intervalSize / 2 : 0; +// Wide enough to fit "September" +const TIMERULER_WIDTH = 50; + +function calculateYScale(start: number | null, end: number | null, height: number) { return scaleLinear() - .domain([domainStart, domainEnd]) + .domain([start || 0, end || 0]) .range([0, height]); } @@ -58,103 +56,28 @@ export class LogMinimap extends React.Component = event => { + const minimapTop = event.currentTarget.getBoundingClientRect().top; + const clickedYPosition = event.clientY - minimapTop; - public handleClick = (event: MouseEvent) => { - if (!this.dragTargetArea) return; - const svgPosition = this.dragTargetArea.getBoundingClientRect(); - const clickedYPosition = event.clientY - svgPosition.top; const clickedTime = Math.floor(this.getYScale().invert(clickedYPosition)); - this.setState({ - drag: null, - }); - this.props.jumpToTarget({ - tiebreaker: 0, - time: clickedTime, - }); - }; - - private handleMouseDown: React.MouseEventHandler = event => { - const { clientY, target } = event; - if (target === this.dragTargetArea) { - const svgPosition = event.currentTarget.getBoundingClientRect(); - this.setState({ - drag: { - startY: clientY, - currentY: null, - }, - svgPosition, - }); - window.addEventListener('mousemove', this.handleDragMove); - } - window.addEventListener('mouseup', this.handleMouseUp); - }; - - private handleMouseUp = (event: MouseEvent) => { - window.removeEventListener('mousemove', this.handleDragMove); - window.removeEventListener('mouseup', this.handleMouseUp); - const { drag, svgPosition } = this.state; - if (!drag || !drag.currentY) { - this.handleClick(event); - return; - } - const getTime = (pos: number) => Math.floor(this.getYScale().invert(pos)); - const startYPosition = drag.startY - svgPosition.top; - const endYPosition = event.clientY - svgPosition.top; - const startTime = getTime(startYPosition); - const endTime = getTime(endYPosition); - const timeDifference = endTime - startTime; - const newTime = (this.props.target || 0) - timeDifference; - this.setState({ drag: null, target: newTime }); this.props.jumpToTarget({ tiebreaker: 0, - time: newTime, - }); - }; - - private handleDragMove = (event: MouseEvent) => { - const { drag } = this.state; - if (!drag) return; - this.setState({ - drag: { - ...drag, - currentY: event.clientY, - }, + time: clickedTime, }); }; public getYScale = () => { - const { target } = this.state; - const { height, intervalSize } = this.props; - return calculateYScale(target, height, intervalSize); + const { start, end, height } = this.props; + return calculateYScale(start, end, height); }; public getPositionOfTime = (time: number) => { - const { height, intervalSize } = this.props; - - const [minTime] = this.getYScale().domain(); - - return ((time - minTime) * height) / intervalSize; // + return this.getYScale()(time); }; private updateTimeCursor: React.MouseEventHandler = event => { @@ -166,6 +89,8 @@ export class LogMinimap extends React.Component - + + + - - - + {highlightedInterval ? ( ) : null} - - { - this.dragTargetArea = node; - }} - x={0} - y={0} - width={width / 3} - height={height} - /> + ); } } -const DragTargetArea = euiStyled.rect<{ isGrabbing: boolean }>` - fill: transparent; - cursor: ${({ isGrabbing }) => (isGrabbing ? 'grabbing' : 'grab')}; -`; - const MinimapBorder = euiStyled.line` stroke: ${props => props.theme.eui.euiColorMediumShade}; stroke-width: 1px; @@ -269,9 +170,9 @@ const TimeCursor = euiStyled.line` : props.theme.eui.euiColorDarkShade}; `; -const MinimapWrapper = euiStyled.svg<{ showOverscanBoundaries: boolean }>` - background: ${props => - props.showOverscanBoundaries ? props.theme.eui.euiColorMediumShade : 'transparent'}; +const MinimapWrapper = euiStyled.svg` + cursor: pointer; + fill: ${props => props.theme.eui.euiColorEmptyShade}; & ${TimeCursor} { visibility: hidden; } diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx index 8b87aa15f16f03..18d4a3bbfc8b3f 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx @@ -10,10 +10,9 @@ import * as React from 'react'; import { euiStyled, keyframes } from '../../../../../observability/public'; import { LogEntryTime } from '../../../../common/log_entry'; import { SearchMarkerTooltip } from './search_marker_tooltip'; -import { SummaryHighlightBucket } from './types'; - +import { LogEntriesSummaryHighlightsBucket } from '../../../../common/http_api'; interface SearchMarkerProps { - bucket: SummaryHighlightBucket; + bucket: LogEntriesSummaryHighlightsBucket; height: number; width: number; jumpToTarget: (target: LogEntryTime) => void; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx index ebdc390aef11b5..1e254d999036e5 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx @@ -10,10 +10,10 @@ import * as React from 'react'; import { LogEntryTime } from '../../../../common/log_entry'; import { SearchMarker } from './search_marker'; -import { SummaryHighlightBucket } from './types'; +import { LogEntriesSummaryHighlightsBucket } from '../../../../common/http_api'; interface SearchMarkersProps { - buckets: SummaryHighlightBucket[]; + buckets: LogEntriesSummaryHighlightsBucket[]; className?: string; end: number; start: number; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx new file mode 100644 index 00000000000000..af981105d17189 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +// The default d3-time-format is a bit strange for small ranges, so we will specify our own +export function getTimeLabelFormat(start: number, end: number): string | undefined { + const diff = Math.abs(end - start); + + // 15 seconds + if (diff < 15 * 1000) { + return ':%S.%L'; + } + + // 16 minutes + if (diff < 16 * 60 * 1000) { + return '%I:%M:%S'; + } + + // Use D3's default + return; +} diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx index b610737663e8db..454935c32fe1ed 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx @@ -8,6 +8,7 @@ import { scaleTime } from 'd3-scale'; import * as React from 'react'; import { euiStyled } from '../../../../../observability/public'; +import { getTimeLabelFormat } from './time_label_formatter'; interface TimeRulerProps { end: number; @@ -23,37 +24,19 @@ export const TimeRuler: React.FC = ({ end, height, start, tickCo .range([0, height]); const ticks = yScale.ticks(tickCount); - const formatTick = yScale.tickFormat(); - - const dateModLabel = (() => { - for (let i = 0; i < ticks.length; i++) { - const tickLabel = formatTick(ticks[i]); - if (!tickLabel[0].match(/[0-9]/)) { - return i % 12; - } - } - })(); + const formatTick = yScale.tickFormat(tickCount, getTimeLabelFormat(start, end)); return ( {ticks.map((tick, tickIndex) => { const y = yScale(tick); - const isLabeledTick = tickIndex % 12 === dateModLabel; - const tickStartX = isLabeledTick ? 0 : width / 3 - 4; + return ( - {isLabeledTick && ( - - {formatTick(tick)} - - )} - + + {formatTick(tick)} + + ); })} @@ -71,15 +54,11 @@ const TimeRulerTickLabel = euiStyled.text` pointer-events: none; `; -const TimeRulerGridLine = euiStyled.line<{ isDark: boolean }>` +const TimeRulerGridLine = euiStyled.line` stroke: ${props => - props.isDark - ? props.theme.darkMode - ? props.theme.eui.euiColorDarkestShade - : props.theme.eui.euiColorDarkShade - : props.theme.darkMode - ? props.theme.eui.euiColorDarkShade - : props.theme.eui.euiColorMediumShade}; + props.theme.darkMode + ? props.theme.eui.euiColorDarkestShade + : props.theme.eui.euiColorDarkShade}; stroke-opacity: 0.5; stroke-width: 1px; `; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/types.ts b/x-pack/plugins/infra/public/components/logging/log_minimap/types.ts deleted file mode 100644 index d8197935dafa72..00000000000000 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/types.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 { TimeKey } from '../../../../common/time'; - -export interface SummaryBucket { - start: number; - end: number; - entriesCount: number; -} - -export interface SummaryHighlightBucket extends SummaryBucket { - representativeKey: TimeKey; -} diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx deleted file mode 100644 index 41c6e554e603a6..00000000000000 --- a/x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx +++ /dev/null @@ -1,67 +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 { EuiFormRow, EuiRadioGroup } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import * as React from 'react'; - -interface IntervalSizeDescriptor { - label: string; - intervalSize: number; -} - -interface LogMinimapScaleControlsProps { - availableIntervalSizes: IntervalSizeDescriptor[]; - intervalSize: number; - setIntervalSize: (intervalSize: number) => any; -} - -export class LogMinimapScaleControls extends React.PureComponent { - public handleScaleChange = (intervalSizeDescriptorKey: string) => { - const { availableIntervalSizes, setIntervalSize } = this.props; - const [sizeDescriptor] = availableIntervalSizes.filter( - intervalKeyEquals(intervalSizeDescriptorKey) - ); - - if (sizeDescriptor) { - setIntervalSize(sizeDescriptor.intervalSize); - } - }; - - public render() { - const { availableIntervalSizes, intervalSize } = this.props; - const [currentSizeDescriptor] = availableIntervalSizes.filter(intervalSizeEquals(intervalSize)); - - return ( - - } - > - ({ - id: getIntervalSizeDescriptorKey(sizeDescriptor), - label: sizeDescriptor.label, - }))} - onChange={this.handleScaleChange} - idSelected={getIntervalSizeDescriptorKey(currentSizeDescriptor)} - /> - - ); - } -} - -const getIntervalSizeDescriptorKey = (sizeDescriptor: IntervalSizeDescriptor) => - `${sizeDescriptor.intervalSize}`; - -const intervalKeyEquals = (key: string) => (sizeDescriptor: IntervalSizeDescriptor) => - getIntervalSizeDescriptorKey(sizeDescriptor) === key; - -const intervalSizeEquals = (size: number) => (sizeDescriptor: IntervalSizeDescriptor) => - sizeDescriptor.intervalSize === size; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts index ca5ca9736b7b38..19e8108ee50e85 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts @@ -7,27 +7,27 @@ import { bisector } from 'd3-array'; import { compareToTimeKey, TimeKey } from '../../../../common/time'; -import { LogEntry, LogEntryHighlight } from '../../../utils/log_entry'; +import { LogEntry } from '../../../../common/http_api'; export type StreamItem = LogEntryStreamItem; export interface LogEntryStreamItem { kind: 'logEntry'; logEntry: LogEntry; - highlights: LogEntryHighlight[]; + highlights: LogEntry[]; } export function getStreamItemTimeKey(item: StreamItem) { switch (item.kind) { case 'logEntry': - return item.logEntry.key; + return item.logEntry.cursor; } } export function getStreamItemId(item: StreamItem) { switch (item.kind) { case 'logEntry': - return `${item.logEntry.key.time}:${item.logEntry.key.tiebreaker}:${item.logEntry.gid}`; + return `${item.logEntry.cursor.time}:${item.logEntry.cursor.tiebreaker}:${item.logEntry.id}`; } } diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx index 8c48d9e176d3b3..5598528c0e0f5c 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx @@ -6,144 +6,279 @@ /* eslint-disable max-classes-per-file */ -import { EuiButtonEmpty, EuiIcon, EuiProgress, EuiText } from '@elastic/eui'; -import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react'; +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiLoadingSpinner, + EuiButton, +} from '@elastic/eui'; +import { FormattedMessage, FormattedTime, FormattedRelative } from '@kbn/i18n/react'; import * as React from 'react'; +import { Unit } from '@elastic/datemath'; import { euiStyled } from '../../../../../observability/public'; +import { LogTextSeparator } from './log_text_separator'; +import { extendDatemath } from '../../../utils/datemath'; + +type Position = 'start' | 'end'; interface LogTextStreamLoadingItemViewProps { - alignment: 'top' | 'bottom'; + position: Position; + timestamp: number; // Either the top of the bottom's cursor timestamp + startDateExpression: string; + endDateExpression: string; className?: string; hasMore: boolean; isLoading: boolean; isStreaming: boolean; - lastStreamingUpdate: Date | null; - onLoadMore?: () => void; + onExtendRange?: (newDate: string) => void; + onStreamStart?: () => void; } +const TIMESTAMP_FORMAT = { + hour12: false, + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', +}; + export class LogTextStreamLoadingItemView extends React.PureComponent< LogTextStreamLoadingItemViewProps, {} > { public render() { const { - alignment, + position, + timestamp, + startDateExpression, + endDateExpression, className, hasMore, isLoading, isStreaming, - lastStreamingUpdate, - onLoadMore, + onExtendRange, + onStreamStart, } = this.props; - if (isStreaming) { - return ( - - - - - - - {lastStreamingUpdate ? ( - - - - - ), - }} - /> - - - ) : null} - - ); - } else if (isLoading) { - return ( - - - - - - ); - } else if (!hasMore) { - return ( - - - - - {onLoadMore ? ( - - - - ) : null} - - ); - } else { - return null; - } + const shouldShowCta = !hasMore && !isStreaming; + + const extra = ( + + {isLoading || isStreaming ? ( + + ) : shouldShowCta ? ( + + ) : null} + + ); + + return ( + + {position === 'start' ? extra : null} + + {position === 'end' ? extra : null} + + ); } } -interface ProgressEntryProps { - alignment: 'top' | 'bottom'; - className?: string; - color: 'subdued' | 'primary'; - isLoading: boolean; -} +const LoadingItemViewExtra = euiStyled(EuiFlexGroup)` + height: 40px; +`; -const ProgressEntry: React.FC = props => { - const { alignment, children, className, color, isLoading } = props; +const ProgressEntryWrapper = euiStyled.div<{ position: Position }>` + padding-left: ${props => props.theme.eui.euiSizeS}; + padding-top: ${props => + props.position === 'start' ? props.theme.eui.euiSizeL : props.theme.eui.euiSizeM}; + padding-bottom: ${props => + props.position === 'end' ? props.theme.eui.euiSizeL : props.theme.eui.euiSizeM}; +`; - // NOTE: styled-components seems to make all props in EuiProgress required, so this - // style attribute hacking replaces styled-components here for now until that can be fixed - // see: https://github.com/elastic/eui/issues/1655 - const alignmentStyle = - alignment === 'top' ? { top: 0, bottom: 'initial' } : { top: 'initial', bottom: 0 }; +type ProgressMessageProps = Pick< + LogTextStreamLoadingItemViewProps, + 'timestamp' | 'position' | 'isStreaming' +>; +const ProgressMessage: React.FC = ({ timestamp, position, isStreaming }) => { + const formattedTimestamp = + isStreaming && position === 'end' ? ( + + ) : ( + + ); - return ( - - + ) : isStreaming ? ( + + ) : ( + - {children} - + ); + + return ( + + {message} + ); }; -const ProgressEntryWrapper = euiStyled.div` - align-items: center; - display: flex; - min-height: ${props => props.theme.eui.euiSizeXXL}; - position: relative; -`; +const ProgressSpinner: React.FC<{ kind: 'streaming' | 'loading' }> = ({ kind }) => ( + <> + + + + + + {kind === 'streaming' ? ( + + ) : ( + + )} + + + +); -const ProgressMessage = euiStyled.div` - padding: 8px 16px; -`; +type ProgressCtaProps = Pick< + LogTextStreamLoadingItemViewProps, + 'position' | 'startDateExpression' | 'endDateExpression' | 'onExtendRange' | 'onStreamStart' +>; +const ProgressCta: React.FC = ({ + position, + startDateExpression, + endDateExpression, + onExtendRange, + onStreamStart, +}) => { + const rangeEdge = position === 'start' ? startDateExpression : endDateExpression; + + if (rangeEdge === 'now' && position === 'end') { + return ( + + + + ); + } + + const iconType = position === 'start' ? 'arrowUp' : 'arrowDown'; + const extendedRange = + position === 'start' + ? extendDatemath(startDateExpression, 'before', endDateExpression) + : extendDatemath(endDateExpression, 'after', startDateExpression); + if (!extendedRange || !('diffUnit' in extendedRange)) { + return null; + } + + return ( + { + if (typeof onExtendRange === 'function') { + onExtendRange(extendedRange.value); + } + }} + iconType={iconType} + size="s" + > + + + ); +}; + +const ProgressExtendMessage: React.FC<{ amount: number; unit: Unit }> = ({ amount, unit }) => { + switch (unit) { + case 'ms': + return ( + + ); + case 's': + return ( + + ); + case 'm': + return ( + + ); + case 'h': + return ( + + ); + case 'd': + return ( + + ); + case 'w': + return ( + + ); + case 'M': + return ( + + ); + case 'y': + return ( + + ); + default: + throw new TypeError('Unhandled unit: ' + unit); + } +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx index 5d295ca7e48176..5fc4606a774d57 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx @@ -8,15 +8,16 @@ import { mount } from 'enzyme'; import React from 'react'; import { EuiThemeProvider } from '../../../../../observability/public'; -import { LogEntryColumn } from '../../../utils/log_entry'; import { LogEntryFieldColumn } from './log_entry_field_column'; +import { LogColumn } from '../../../../common/http_api'; describe('LogEntryFieldColumn', () => { it('should output a
    when displaying an Array of values', () => { - const column: LogEntryColumn = { + const column: LogColumn = { columnId: 'TEST_COLUMN', field: 'TEST_FIELD', - value: JSON.stringify(['a', 'b', 'c']), + value: ['a', 'b', 'c'], + highlights: [], }; const component = mount( @@ -42,13 +43,14 @@ describe('LogEntryFieldColumn', () => { }); it('should output a text representation of a passed complex value', () => { - const column: LogEntryColumn = { + const column: LogColumn = { columnId: 'TEST_COLUMN', field: 'TEST_FIELD', - value: JSON.stringify({ + value: { lat: 1, lon: 2, - }), + }, + highlights: [], }; const component = mount( @@ -67,10 +69,11 @@ describe('LogEntryFieldColumn', () => { }); it('should output just text when passed a non-Array', () => { - const column: LogEntryColumn = { + const column: LogColumn = { columnId: 'TEST_COLUMN', field: 'TEST_FIELD', - value: JSON.stringify('foo'), + value: 'foo', + highlights: [], }; const component = mount( diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx index c6584f2fdbb6da..202108cda5ac07 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx @@ -8,14 +8,10 @@ import stringify from 'json-stable-stringify'; import React, { useMemo } from 'react'; import { euiStyled } from '../../../../../observability/public'; -import { - isFieldColumn, - isHighlightFieldColumn, - LogEntryColumn, - LogEntryHighlightColumn, -} from '../../../utils/log_entry'; +import { isFieldColumn, isHighlightFieldColumn } from '../../../utils/log_entry'; import { ActiveHighlightMarker, highlightFieldValue, HighlightMarker } from './highlighting'; import { LogEntryColumnContent } from './log_entry_column'; +import { LogColumn } from '../../../../common/http_api'; import { hoveredContentStyle, longWrappedContentStyle, @@ -25,8 +21,8 @@ import { } from './text_styles'; interface LogEntryFieldColumnProps { - columnValue: LogEntryColumn; - highlights: LogEntryHighlightColumn[]; + columnValue: LogColumn; + highlights: LogColumn[]; isActiveHighlight: boolean; isHighlighted: boolean; isHovered: boolean; @@ -41,9 +37,12 @@ export const LogEntryFieldColumn: React.FunctionComponent { - const value = useMemo(() => (isFieldColumn(columnValue) ? JSON.parse(columnValue.value) : null), [ - columnValue, - ]); + const value = useMemo(() => { + if (isFieldColumn(columnValue)) { + return columnValue.value; + } + return null; + }, [columnValue]); const formattedValue = Array.isArray(value) ? (
      {value.map((entry, i) => ( @@ -58,7 +57,7 @@ export const LogEntryFieldColumn: React.FunctionComponent ) : ( highlightFieldValue( - typeof value === 'object' && value != null ? stringify(value) : value, + typeof value === 'string' ? value : stringify(value), isHighlightFieldColumn(firstHighlight) ? firstHighlight.highlights : [], isActiveHighlight ? ActiveHighlightMarker : HighlightMarker ) diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx index 122f0fe472c6e6..5ad7cba6427d16 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx @@ -5,6 +5,7 @@ */ import React, { memo, useMemo } from 'react'; +import stringify from 'json-stable-stringify'; import { euiStyled } from '../../../../../observability/public'; import { @@ -12,9 +13,7 @@ import { isFieldSegment, isHighlightMessageColumn, isMessageColumn, - LogEntryColumn, - LogEntryHighlightColumn, - LogEntryMessageSegment, + isHighlightFieldSegment, } from '../../../utils/log_entry'; import { ActiveHighlightMarker, highlightFieldValue, HighlightMarker } from './highlighting'; import { LogEntryColumnContent } from './log_entry_column'; @@ -25,10 +24,11 @@ import { unwrappedContentStyle, WrapMode, } from './text_styles'; +import { LogColumn, LogMessagePart } from '../../../../common/http_api'; interface LogEntryMessageColumnProps { - columnValue: LogEntryColumn; - highlights: LogEntryHighlightColumn[]; + columnValue: LogColumn; + highlights: LogColumn[]; isActiveHighlight: boolean; isHighlighted: boolean; isHovered: boolean; @@ -72,28 +72,39 @@ const MessageColumnContent = euiStyled(LogEntryColumnContent) messageSegments.map((messageSegment, index) => formatMessageSegment( messageSegment, - highlights.map(highlight => - isHighlightMessageColumn(highlight) ? highlight.message[index].highlights : [] - ), + highlights.map(highlight => { + if (isHighlightMessageColumn(highlight)) { + const segment = highlight.message[index]; + if (isHighlightFieldSegment(segment)) { + return segment.highlights; + } + } + return []; + }), isActiveHighlight ) ); const formatMessageSegment = ( - messageSegment: LogEntryMessageSegment, + messageSegment: LogMessagePart, [firstHighlight = []]: string[][], // we only support one highlight for now isActiveHighlight: boolean ): React.ReactNode => { if (isFieldSegment(messageSegment)) { + const value = + typeof messageSegment.value === 'string' + ? messageSegment.value + : stringify(messageSegment.value); + return highlightFieldValue( - messageSegment.value, + value, firstHighlight, isActiveHighlight ? ActiveHighlightMarker : HighlightMarker ); diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx index e5e3740f420e85..ce264245d385be 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx @@ -7,12 +7,7 @@ import React, { memo, useState, useCallback, useMemo } from 'react'; import { euiStyled } from '../../../../../observability/public'; -import { - LogEntry, - LogEntryHighlight, - LogEntryHighlightColumn, - isTimestampColumn, -} from '../../../utils/log_entry'; +import { isTimestampColumn } from '../../../utils/log_entry'; import { LogColumnConfiguration, isTimestampLogColumnConfiguration, @@ -26,12 +21,13 @@ import { LogEntryDetailsIconColumn } from './log_entry_icon_column'; import { LogEntryMessageColumn } from './log_entry_message_column'; import { LogEntryTimestampColumn } from './log_entry_timestamp_column'; import { monospaceTextStyle } from './text_styles'; +import { LogEntry, LogColumn } from '../../../../common/http_api'; interface LogEntryRowProps { boundingBoxRef?: React.Ref; columnConfigurations: LogColumnConfiguration[]; columnWidths: LogEntryColumnWidths; - highlights: LogEntryHighlight[]; + highlights: LogEntry[]; isActiveHighlight: boolean; isHighlighted: boolean; logEntry: LogEntry; @@ -63,9 +59,9 @@ export const LogEntryRow = memo( setIsHovered(false); }, []); - const openFlyout = useCallback(() => openFlyoutWithItem?.(logEntry.gid), [ + const openFlyout = useCallback(() => openFlyoutWithItem?.(logEntry.id), [ openFlyoutWithItem, - logEntry.gid, + logEntry.id, ]); const logEntryColumnsById = useMemo( @@ -85,7 +81,7 @@ export const LogEntryRow = memo( const highlightsByColumnId = useMemo( () => highlights.reduce<{ - [columnId: string]: LogEntryHighlightColumn[]; + [columnId: string]: LogColumn[]; }>( (columnsById, highlight) => highlight.columns.reduce( diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx new file mode 100644 index 00000000000000..9cc91fa11e4edf --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx @@ -0,0 +1,21 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; + +/** + * Create a separator with a text on the right side + */ +export const LogTextSeparator: React.FC = ({ children }) => { + return ( + + {children} + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx index 6544a32ba414ce..2c389b47fa6cf2 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx @@ -54,6 +54,10 @@ interface ScrollableLogTextStreamViewProps { setFlyoutVisibility: (visible: boolean) => void; highlightedItem: string | null; currentHighlightKey: UniqueTimeKey | null; + startDateExpression: string; + endDateExpression: string; + updateDateRange: (range: { startDateExpression?: string; endDateExpression?: string }) => void; + startLiveStreaming: () => void; } interface ScrollableLogTextStreamViewState { @@ -90,7 +94,7 @@ export class ScrollableLogTextStreamView extends React.PureComponent< targetId: getStreamItemId(getStreamItemBeforeTimeKey(nextProps.items, nextProps.target!)), items: nextItems, }; - } else if (!nextProps.target || !hasItems) { + } else if (!hasItems) { return { target: null, targetId: null, @@ -129,9 +133,13 @@ export class ScrollableLogTextStreamView extends React.PureComponent< isLoadingMore, isReloading, isStreaming, - lastLoadedTime, scale, wrap, + startDateExpression, + endDateExpression, + lastLoadedTime, + updateDateRange, + startLiveStreaming, } = this.props; const { targetId, items, isScrollLocked } = this.state; const hasItems = items.length > 0; @@ -184,72 +192,88 @@ export class ScrollableLogTextStreamView extends React.PureComponent< isLocked={isScrollLocked} entriesCount={items.length} > - {registerChild => ( - <> - - {items.map((item, idx) => { - const currentTimestamp = item.logEntry.key.time; - let showDate = false; + {registerChild => + items.length > 0 ? ( + <> + + updateDateRange({ startDateExpression: newDateExpression }) + } + /> + {items.map((item, idx) => { + const currentTimestamp = item.logEntry.cursor.time; + let showDate = false; - if (idx > 0) { - const prevTimestamp = items[idx - 1].logEntry.key.time; - showDate = !moment(currentTimestamp).isSame(prevTimestamp, 'day'); - } + if (idx > 0) { + const prevTimestamp = items[idx - 1].logEntry.cursor.time; + showDate = !moment(currentTimestamp).isSame(prevTimestamp, 'day'); + } - return ( - - {showDate && } - - {itemMeasureRef => ( - - )} - - - ); - })} - - {isScrollLocked && ( - + {showDate && } + + {itemMeasureRef => ( + + )} + + + ); + })} + + updateDateRange({ endDateExpression: newDateExpression }) + } + onStreamStart={() => startLiveStreaming()} /> - )} - - )} + {isScrollLocked && ( + + )} + + ) : null + } )} @@ -275,14 +299,6 @@ export class ScrollableLogTextStreamView extends React.PureComponent< } }; - private handleLoadNewerItems = () => { - const { loadNewerItems } = this.props; - - if (loadNewerItems) { - loadNewerItems(); - } - }; - // this is actually a method but not recognized as such // eslint-disable-next-line @typescript-eslint/member-ordering private handleVisibleChildrenChange = callWithoutRepeats( 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 deleted file mode 100644 index 3653a6d6bbeaed..00000000000000 --- a/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx +++ /dev/null @@ -1,97 +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 { EuiDatePicker, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import moment, { Moment } from 'moment'; -import React from 'react'; -import { FixedDatePicker } from '../fixed_datepicker'; - -const noop = () => undefined; - -interface LogTimeControlsProps { - currentTime: number | null; - startLiveStreaming: () => any; - stopLiveStreaming: () => void; - isLiveStreaming: boolean; - jumpToTime: (time: number) => any; -} - -export class LogTimeControls extends React.PureComponent { - public render() { - const { currentTime, isLiveStreaming } = this.props; - - const currentMoment = currentTime ? moment(currentTime) : null; - if (isLiveStreaming) { - return ( - - - - - - - - - - - ); - } else { - return ( - - - - - - - - - - - ); - } - } - - private handleChangeDate = (date: Moment | null) => { - if (date !== null) { - this.props.jumpToTime(date.valueOf()); - } - }; - - private startLiveStreaming = () => { - this.props.startLiveStreaming(); - }; - - private stopLiveStreaming = () => { - this.props.stopLiveStreaming(); - }; -} diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts new file mode 100644 index 00000000000000..2a19a828924279 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts @@ -0,0 +1,28 @@ +/* + * 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 { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from '../../../../legacy_singletons'; + +import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; + +import { + LOG_ENTRIES_PATH, + LogEntriesRequest, + logEntriesRequestRT, + logEntriesResponseRT, +} from '../../../../../common/http_api'; + +export const fetchLogEntries = async (requestArgs: LogEntriesRequest) => { + const response = await npStart.http.fetch(LOG_ENTRIES_PATH, { + method: 'POST', + body: JSON.stringify(logEntriesRequestRT.encode(requestArgs)), + }); + + return pipe(logEntriesResponseRT.decode(response), fold(throwErrors(createPlainError), identity)); +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts deleted file mode 100644 index 83bae37c348d4d..00000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts +++ /dev/null @@ -1,64 +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 { ApolloClient } from 'apollo-client'; -import { TimeKey } from '../../../../common/time'; -import { logEntriesQuery } from '../../../graphql/log_entries.gql_query'; -import { useApolloClient } from '../../../utils/apollo_context'; -import { LogEntriesResponse } from '.'; - -const LOAD_CHUNK_SIZE = 200; - -type LogEntriesGetter = ( - client: ApolloClient<{}>, - countBefore: number, - countAfter: number -) => (params: { - sourceId: string; - timeKey: TimeKey | null; - filterQuery: string | null; -}) => Promise; - -const getLogEntries: LogEntriesGetter = (client, countBefore, countAfter) => async ({ - sourceId, - timeKey, - filterQuery, -}) => { - if (!timeKey) throw new Error('TimeKey is null'); - const result = await client.query({ - query: logEntriesQuery, - variables: { - sourceId, - timeKey: { time: timeKey.time, tiebreaker: timeKey.tiebreaker }, - countBefore, - countAfter, - filterQuery, - }, - fetchPolicy: 'no-cache', - }); - // Workaround for Typescript. Since we're removing the GraphQL API in another PR or two - // 7.6 goes out I don't think it's worth the effort to actually make this - // typecheck pass - const { source } = result.data as any; - const { logEntriesAround } = source; - return { - entries: logEntriesAround.entries, - entriesStart: logEntriesAround.start, - entriesEnd: logEntriesAround.end, - hasMoreAfterEnd: logEntriesAround.hasMoreAfter, - hasMoreBeforeStart: logEntriesAround.hasMoreBefore, - lastLoadedTime: new Date(), - }; -}; - -export const useGraphQLQueries = () => { - const client = useApolloClient(); - if (!client) throw new Error('Unable to get Apollo Client from context'); - return { - getLogEntriesAround: getLogEntries(client, LOAD_CHUNK_SIZE, LOAD_CHUNK_SIZE), - getLogEntriesBefore: getLogEntries(client, LOAD_CHUNK_SIZE, 0), - getLogEntriesAfter: getLogEntries(client, 0, LOAD_CHUNK_SIZE), - }; -}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts index 04412f5fdd8710..b9a5c4068e1669 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts @@ -5,12 +5,18 @@ */ import { useEffect, useState, useReducer, useCallback } from 'react'; import createContainer from 'constate'; -import { pick, throttle, omit } from 'lodash'; -import { useGraphQLQueries } from './gql_queries'; +import { pick, throttle } from 'lodash'; import { TimeKey, timeKeyIsBetween } from '../../../../common/time'; -import { InfraLogEntry } from './types'; +import { + LogEntriesResponse, + LogEntry, + LogEntriesRequest, + LogEntriesBaseRequest, +} from '../../../../common/http_api'; +import { fetchLogEntries } from './api/fetch_log_entries'; const DESIRED_BUFFER_PAGES = 2; +const LIVE_STREAM_INTERVAL = 5000; enum Action { FetchingNewEntries, @@ -20,6 +26,7 @@ enum Action { ReceiveEntriesAfter, ErrorOnNewEntries, ErrorOnMoreEntries, + ExpandRange, } type ReceiveActions = @@ -29,41 +36,46 @@ type ReceiveActions = interface ReceiveEntriesAction { type: ReceiveActions; - payload: LogEntriesResponse; + payload: LogEntriesResponse['data']; +} +interface ExpandRangeAction { + type: Action.ExpandRange; + payload: { before: boolean; after: boolean }; } interface FetchOrErrorAction { - type: Exclude; + type: Exclude; } -type ActionObj = ReceiveEntriesAction | FetchOrErrorAction; +type ActionObj = ReceiveEntriesAction | FetchOrErrorAction | ExpandRangeAction; type Dispatch = (action: ActionObj) => void; interface LogEntriesProps { + startTimestamp: number; + endTimestamp: number; + timestampsLastUpdate: number; filterQuery: string | null; timeKey: TimeKey | null; pagesBeforeStart: number | null; pagesAfterEnd: number | null; sourceId: string; - isAutoReloading: boolean; + isStreaming: boolean; jumpToTargetPosition: (position: TimeKey) => void; } -type FetchEntriesParams = Omit; +type FetchEntriesParams = Omit; type FetchMoreEntriesParams = Pick; -export interface LogEntriesResponse { - entries: InfraLogEntry[]; - entriesStart: TimeKey | null; - entriesEnd: TimeKey | null; - hasMoreAfterEnd: boolean; - hasMoreBeforeStart: boolean; - lastLoadedTime: Date | null; -} - -export type LogEntriesStateParams = { +export interface LogEntriesStateParams { + entries: LogEntriesResponse['data']['entries']; + topCursor: LogEntriesResponse['data']['topCursor'] | null; + bottomCursor: LogEntriesResponse['data']['bottomCursor'] | null; + centerCursor: TimeKey | null; isReloading: boolean; isLoadingMore: boolean; -} & LogEntriesResponse; + lastLoadedTime: Date | null; + hasMoreBeforeStart: boolean; + hasMoreAfterEnd: boolean; +} export interface LogEntriesCallbacks { fetchNewerEntries: () => Promise; @@ -75,32 +87,40 @@ export const logEntriesInitialCallbacks = { export const logEntriesInitialState: LogEntriesStateParams = { entries: [], - entriesStart: null, - entriesEnd: null, - hasMoreAfterEnd: false, - hasMoreBeforeStart: false, + topCursor: null, + bottomCursor: null, + centerCursor: null, isReloading: true, isLoadingMore: false, lastLoadedTime: null, + hasMoreBeforeStart: false, + hasMoreAfterEnd: false, }; -const cleanDuplicateItems = (entriesA: InfraLogEntry[], entriesB: InfraLogEntry[]) => { - const gids = new Set(entriesB.map(item => item.gid)); - return entriesA.filter(item => !gids.has(item.gid)); +const cleanDuplicateItems = (entriesA: LogEntry[], entriesB: LogEntry[]) => { + const ids = new Set(entriesB.map(item => item.id)); + return entriesA.filter(item => !ids.has(item.id)); }; const shouldFetchNewEntries = ({ prevParams, timeKey, filterQuery, - entriesStart, - entriesEnd, -}: FetchEntriesParams & LogEntriesStateParams & { prevParams: FetchEntriesParams }) => { - if (!timeKey) return false; - const shouldLoadWithNewFilter = filterQuery !== prevParams.filterQuery; + topCursor, + bottomCursor, + startTimestamp, + endTimestamp, +}: FetchEntriesParams & LogEntriesStateParams & { prevParams: FetchEntriesParams | undefined }) => { + const shouldLoadWithNewDates = prevParams + ? (startTimestamp !== prevParams.startTimestamp && + startTimestamp > prevParams.startTimestamp) || + (endTimestamp !== prevParams.endTimestamp && endTimestamp < prevParams.endTimestamp) + : true; + const shouldLoadWithNewFilter = prevParams ? filterQuery !== prevParams.filterQuery : true; const shouldLoadAroundNewPosition = - !entriesStart || !entriesEnd || !timeKeyIsBetween(entriesStart, entriesEnd, timeKey); - return shouldLoadWithNewFilter || shouldLoadAroundNewPosition; + timeKey && (!topCursor || !bottomCursor || !timeKeyIsBetween(topCursor, bottomCursor, timeKey)); + + return shouldLoadWithNewDates || shouldLoadWithNewFilter || shouldLoadAroundNewPosition; }; enum ShouldFetchMoreEntries { @@ -124,48 +144,105 @@ const useFetchEntriesEffect = ( dispatch: Dispatch, props: LogEntriesProps ) => { - const { getLogEntriesAround, getLogEntriesBefore, getLogEntriesAfter } = useGraphQLQueries(); - - const [prevParams, cachePrevParams] = useState(props); + const [prevParams, cachePrevParams] = useState(); const [startedStreaming, setStartedStreaming] = useState(false); - const runFetchNewEntriesRequest = async (override = {}) => { + const runFetchNewEntriesRequest = async (overrides: Partial = {}) => { + if (!props.startTimestamp || !props.endTimestamp) { + return; + } + dispatch({ type: Action.FetchingNewEntries }); + try { - const payload = await getLogEntriesAround({ - ...omit(props, 'jumpToTargetPosition'), - ...override, - }); + const commonFetchArgs: LogEntriesBaseRequest = { + sourceId: overrides.sourceId || props.sourceId, + startTimestamp: overrides.startTimestamp || props.startTimestamp, + endTimestamp: overrides.endTimestamp || props.endTimestamp, + query: overrides.filterQuery || props.filterQuery, + }; + + const fetchArgs: LogEntriesRequest = props.timeKey + ? { + ...commonFetchArgs, + center: props.timeKey, + } + : { + ...commonFetchArgs, + before: 'last', + }; + + const { data: payload } = await fetchLogEntries(fetchArgs); dispatch({ type: Action.ReceiveNewEntries, payload }); + + // Move position to the bottom if it's the first load. + // Do it in the next tick to allow the `dispatch` to fire + if (!props.timeKey && payload.bottomCursor) { + setTimeout(() => { + props.jumpToTargetPosition(payload.bottomCursor!); + }); + } else if ( + props.timeKey && + payload.topCursor && + payload.bottomCursor && + !timeKeyIsBetween(payload.topCursor, payload.bottomCursor, props.timeKey) + ) { + props.jumpToTargetPosition(payload.topCursor); + } } catch (e) { dispatch({ type: Action.ErrorOnNewEntries }); } }; const runFetchMoreEntriesRequest = async (direction: ShouldFetchMoreEntries) => { - dispatch({ type: Action.FetchingMoreEntries }); + if (!props.startTimestamp || !props.endTimestamp) { + return; + } const getEntriesBefore = direction === ShouldFetchMoreEntries.Before; - const timeKey = getEntriesBefore - ? state.entries[0].key - : state.entries[state.entries.length - 1].key; - const getMoreLogEntries = getEntriesBefore ? getLogEntriesBefore : getLogEntriesAfter; + + // Control that cursors are correct + if ((getEntriesBefore && !state.topCursor) || !state.bottomCursor) { + return; + } + + dispatch({ type: Action.FetchingMoreEntries }); + try { - const payload = await getMoreLogEntries({ ...props, timeKey }); + const commonFetchArgs: LogEntriesBaseRequest = { + sourceId: props.sourceId, + startTimestamp: props.startTimestamp, + endTimestamp: props.endTimestamp, + query: props.filterQuery, + }; + + const fetchArgs: LogEntriesRequest = getEntriesBefore + ? { + ...commonFetchArgs, + before: state.topCursor!, // We already check for nullity above + } + : { + ...commonFetchArgs, + after: state.bottomCursor, + }; + + const { data: payload } = await fetchLogEntries(fetchArgs); + dispatch({ type: getEntriesBefore ? Action.ReceiveEntriesBefore : Action.ReceiveEntriesAfter, payload, }); - return payload.entriesEnd; + + return payload.bottomCursor; } catch (e) { dispatch({ type: Action.ErrorOnMoreEntries }); } }; const fetchNewEntriesEffectDependencies = Object.values( - pick(props, ['sourceId', 'filterQuery', 'timeKey']) + pick(props, ['sourceId', 'filterQuery', 'timeKey', 'startTimestamp', 'endTimestamp']) ); const fetchNewEntriesEffect = () => { - if (props.isAutoReloading) return; + if (props.isStreaming && prevParams) return; if (shouldFetchNewEntries({ ...props, ...state, prevParams })) { runFetchNewEntriesRequest(); } @@ -177,7 +254,7 @@ const useFetchEntriesEffect = ( Object.values(pick(state, ['hasMoreBeforeStart', 'hasMoreAfterEnd'])), ]; const fetchMoreEntriesEffect = () => { - if (state.isLoadingMore || props.isAutoReloading) return; + if (state.isLoadingMore || props.isStreaming) return; const direction = shouldFetchMoreEntries(props, state); switch (direction) { case ShouldFetchMoreEntries.Before: @@ -191,30 +268,25 @@ const useFetchEntriesEffect = ( const fetchNewerEntries = useCallback( throttle(() => runFetchMoreEntriesRequest(ShouldFetchMoreEntries.After), 500), - [props, state.entriesEnd] + [props, state.bottomCursor] ); const streamEntriesEffectDependencies = [ - props.isAutoReloading, + props.isStreaming, state.isLoadingMore, state.isReloading, ]; const streamEntriesEffect = () => { (async () => { - if (props.isAutoReloading && !state.isLoadingMore && !state.isReloading) { + if (props.isStreaming && !state.isLoadingMore && !state.isReloading) { if (startedStreaming) { - await new Promise(res => setTimeout(res, 5000)); + await new Promise(res => setTimeout(res, LIVE_STREAM_INTERVAL)); } else { - const nowKey = { - tiebreaker: 0, - time: Date.now(), - }; - props.jumpToTargetPosition(nowKey); + const endTimestamp = Date.now(); + props.jumpToTargetPosition({ tiebreaker: 0, time: endTimestamp }); setStartedStreaming(true); if (state.hasMoreAfterEnd) { - runFetchNewEntriesRequest({ - timeKey: nowKey, - }); + runFetchNewEntriesRequest({ endTimestamp }); return; } } @@ -222,15 +294,41 @@ const useFetchEntriesEffect = ( if (newEntriesEnd) { props.jumpToTargetPosition(newEntriesEnd); } - } else if (!props.isAutoReloading) { + } else if (!props.isStreaming) { setStartedStreaming(false); } })(); }; + const expandRangeEffect = () => { + if (!prevParams || !prevParams.startTimestamp || !prevParams.endTimestamp) { + return; + } + + if (props.timestampsLastUpdate === prevParams.timestampsLastUpdate) { + return; + } + + const shouldExpand = { + before: props.startTimestamp < prevParams.startTimestamp, + after: props.endTimestamp > prevParams.endTimestamp, + }; + + dispatch({ type: Action.ExpandRange, payload: shouldExpand }); + }; + + const expandRangeEffectDependencies = [ + prevParams?.startTimestamp, + prevParams?.endTimestamp, + props.startTimestamp, + props.endTimestamp, + props.timestampsLastUpdate, + ]; + useEffect(fetchNewEntriesEffect, fetchNewEntriesEffectDependencies); useEffect(fetchMoreEntriesEffect, fetchMoreEntriesEffectDependencies); useEffect(streamEntriesEffect, streamEntriesEffectDependencies); + useEffect(expandRangeEffect, expandRangeEffectDependencies); return { fetchNewerEntries, checkForNewEntries: runFetchNewEntriesRequest }; }; @@ -249,44 +347,87 @@ export const useLogEntriesState: ( const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: ActionObj) => { switch (action.type) { case Action.ReceiveNewEntries: - return { ...prevState, ...action.payload, isReloading: false }; + return { + ...prevState, + ...action.payload, + centerCursor: getCenterCursor(action.payload.entries), + lastLoadedTime: new Date(), + isReloading: false, + + // Be optimistic. If any of the before/after requests comes empty, set + // the corresponding flag to `false` + hasMoreBeforeStart: true, + hasMoreAfterEnd: true, + }; case Action.ReceiveEntriesBefore: { - const prevEntries = cleanDuplicateItems(prevState.entries, action.payload.entries); - const newEntries = [...action.payload.entries, ...prevEntries]; - const { hasMoreBeforeStart, entriesStart, lastLoadedTime } = action.payload; + const newEntries = action.payload.entries; + const prevEntries = cleanDuplicateItems(prevState.entries, newEntries); + const entries = [...newEntries, ...prevEntries]; + const update = { - entries: newEntries, + entries, isLoadingMore: false, - hasMoreBeforeStart, - entriesStart, - lastLoadedTime, + hasMoreBeforeStart: newEntries.length > 0, + // Keep the previous cursor if request comes empty, to easily extend the range. + topCursor: newEntries.length > 0 ? action.payload.topCursor : prevState.topCursor, + centerCursor: getCenterCursor(entries), + lastLoadedTime: new Date(), }; + return { ...prevState, ...update }; } case Action.ReceiveEntriesAfter: { - const prevEntries = cleanDuplicateItems(prevState.entries, action.payload.entries); - const newEntries = [...prevEntries, ...action.payload.entries]; - const { hasMoreAfterEnd, entriesEnd, lastLoadedTime } = action.payload; + const newEntries = action.payload.entries; + const prevEntries = cleanDuplicateItems(prevState.entries, newEntries); + const entries = [...prevEntries, ...newEntries]; + const update = { - entries: newEntries, + entries, isLoadingMore: false, - hasMoreAfterEnd, - entriesEnd, - lastLoadedTime, + hasMoreAfterEnd: newEntries.length > 0, + // Keep the previous cursor if request comes empty, to easily extend the range. + bottomCursor: newEntries.length > 0 ? action.payload.bottomCursor : prevState.bottomCursor, + centerCursor: getCenterCursor(entries), + lastLoadedTime: new Date(), }; + return { ...prevState, ...update }; } case Action.FetchingNewEntries: - return { ...prevState, isReloading: true }; + return { + ...prevState, + isReloading: true, + entries: [], + topCursor: null, + bottomCursor: null, + centerCursor: null, + hasMoreBeforeStart: true, + hasMoreAfterEnd: true, + }; case Action.FetchingMoreEntries: return { ...prevState, isLoadingMore: true }; case Action.ErrorOnNewEntries: return { ...prevState, isReloading: false }; case Action.ErrorOnMoreEntries: return { ...prevState, isLoadingMore: false }; + + case Action.ExpandRange: { + const hasMoreBeforeStart = action.payload.before ? true : prevState.hasMoreBeforeStart; + const hasMoreAfterEnd = action.payload.after ? true : prevState.hasMoreAfterEnd; + + return { + ...prevState, + hasMoreBeforeStart, + hasMoreAfterEnd, + }; + } default: throw new Error(); } }; +function getCenterCursor(entries: LogEntry[]): TimeKey | null { + return entries.length > 0 ? entries[Math.floor(entries.length / 2)].cursor : null; +} + export const LogEntriesState = createContainer(useLogEntriesState); diff --git a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx index 5c1667a4b76804..267abe631c1423 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx @@ -19,7 +19,7 @@ export enum FlyoutVisibility { visible = 'visible', } -interface FlyoutOptionsUrlState { +export interface FlyoutOptionsUrlState { flyoutId?: string | null; flyoutVisibility?: string | null; surroundingLogsId?: string | null; diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts b/x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts new file mode 100644 index 00000000000000..030a9d180c7b5c --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from '../../../../legacy_singletons'; + +import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; + +import { + LOG_ENTRIES_HIGHLIGHTS_PATH, + LogEntriesHighlightsRequest, + logEntriesHighlightsRequestRT, + logEntriesHighlightsResponseRT, +} from '../../../../../common/http_api'; + +export const fetchLogEntriesHighlights = async (requestArgs: LogEntriesHighlightsRequest) => { + const response = await npStart.http.fetch(LOG_ENTRIES_HIGHLIGHTS_PATH, { + method: 'POST', + body: JSON.stringify(logEntriesHighlightsRequestRT.encode(requestArgs)), + }); + + return pipe( + logEntriesHighlightsResponseRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx index 2b19958a9b1a11..77018504437682 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx @@ -6,62 +6,47 @@ import { useEffect, useMemo, useState } from 'react'; -import { getNextTimeKey, getPreviousTimeKey, TimeKey } from '../../../../common/time'; -import { LogEntryHighlightsQuery } from '../../../graphql/types'; -import { DependencyError, useApolloClient } from '../../../utils/apollo_context'; -import { LogEntryHighlightsMap } from '../../../utils/log_entry'; +import { TimeKey } from '../../../../common/time'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; -import { logEntryHighlightsQuery } from './log_entry_highlights.gql_query'; - -export type LogEntryHighlights = LogEntryHighlightsQuery.Query['source']['logEntryHighlights']; +import { fetchLogEntriesHighlights } from './api/fetch_log_entries_highlights'; +import { LogEntry, LogEntriesHighlightsResponse } from '../../../../common/http_api'; export const useLogEntryHighlights = ( sourceId: string, sourceVersion: string | undefined, - startKey: TimeKey | null, - endKey: TimeKey | null, + startTimestamp: number | null, + endTimestamp: number | null, + centerPoint: TimeKey | null, + size: number, filterQuery: string | null, highlightTerms: string[] ) => { - const apolloClient = useApolloClient(); - const [logEntryHighlights, setLogEntryHighlights] = useState([]); + const [logEntryHighlights, setLogEntryHighlights] = useState< + LogEntriesHighlightsResponse['data'] + >([]); const [loadLogEntryHighlightsRequest, loadLogEntryHighlights] = useTrackedPromise( { cancelPreviousOn: 'resolution', createPromise: async () => { - if (!apolloClient) { - throw new DependencyError('Failed to load source: No apollo client available.'); - } - if (!startKey || !endKey || !highlightTerms.length) { + if (!startTimestamp || !endTimestamp || !centerPoint || !highlightTerms.length) { throw new Error('Skipping request: Insufficient parameters'); } - return await apolloClient.query< - LogEntryHighlightsQuery.Query, - LogEntryHighlightsQuery.Variables - >({ - fetchPolicy: 'no-cache', - query: logEntryHighlightsQuery, - variables: { - sourceId, - startKey: getPreviousTimeKey(startKey), // interval boundaries are exclusive - endKey: getNextTimeKey(endKey), // interval boundaries are exclusive - filterQuery, - highlights: [ - { - query: highlightTerms[0], - countBefore: 1, - countAfter: 1, - }, - ], - }, + return await fetchLogEntriesHighlights({ + sourceId, + startTimestamp, + endTimestamp, + center: centerPoint, + size, + query: filterQuery || undefined, + highlightTerms, }); }, onResolve: response => { - setLogEntryHighlights(response.data.source.logEntryHighlights); + setLogEntryHighlights(response.data); }, }, - [apolloClient, sourceId, startKey, endKey, filterQuery, highlightTerms] + [sourceId, startTimestamp, endTimestamp, centerPoint, size, filterQuery, highlightTerms] ); useEffect(() => { @@ -71,24 +56,31 @@ export const useLogEntryHighlights = ( useEffect(() => { if ( highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length && - startKey && - endKey + startTimestamp && + endTimestamp ) { loadLogEntryHighlights(); } else { setLogEntryHighlights([]); } - }, [endKey, filterQuery, highlightTerms, loadLogEntryHighlights, sourceVersion, startKey]); + }, [ + endTimestamp, + filterQuery, + highlightTerms, + loadLogEntryHighlights, + sourceVersion, + startTimestamp, + ]); const logEntryHighlightsById = useMemo( () => - logEntryHighlights.reduce( - (accumulatedLogEntryHighlightsById, { entries }) => { - return entries.reduce((singleHighlightLogEntriesById, entry) => { - const highlightsForId = singleHighlightLogEntriesById[entry.gid] || []; + logEntryHighlights.reduce>( + (accumulatedLogEntryHighlightsById, highlightData) => { + return highlightData.entries.reduce((singleHighlightLogEntriesById, entry) => { + const highlightsForId = singleHighlightLogEntriesById[entry.id] || []; return { ...singleHighlightLogEntriesById, - [entry.gid]: [...highlightsForId, entry], + [entry.id]: [...highlightsForId, entry], }; }, accumulatedLogEntryHighlightsById); }, diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx index a4a94851ad383d..941e89848131ba 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx @@ -6,39 +6,38 @@ import createContainer from 'constate'; import { useState, useContext } from 'react'; +import { useThrottle } from 'react-use'; import { useLogEntryHighlights } from './log_entry_highlights'; import { useLogSummaryHighlights } from './log_summary_highlights'; import { useNextAndPrevious } from './next_and_previous'; -import { useLogSummaryBufferInterval } from '../log_summary'; -import { LogViewConfiguration } from '../log_view_configuration'; import { LogPositionState } from '../log_position'; import { TimeKey } from '../../../../common/time'; +const FETCH_THROTTLE_INTERVAL = 3000; + +interface UseLogHighlightsStateProps { + sourceId: string; + sourceVersion: string | undefined; + centerCursor: TimeKey | null; + size: number; + filterQuery: string | null; +} + export const useLogHighlightsState = ({ sourceId, sourceVersion, - entriesStart, - entriesEnd, + centerCursor, + size, filterQuery, -}: { - sourceId: string; - sourceVersion: string | undefined; - entriesStart: TimeKey | null; - entriesEnd: TimeKey | null; - filterQuery: string | null; -}) => { +}: UseLogHighlightsStateProps) => { const [highlightTerms, setHighlightTerms] = useState([]); - const { visibleMidpoint, jumpToTargetPosition } = useContext(LogPositionState.Context); - const { intervalSize: summaryIntervalSize } = useContext(LogViewConfiguration.Context); - const { - start: summaryStart, - end: summaryEnd, - bucketSize: summaryBucketSize, - } = useLogSummaryBufferInterval( - visibleMidpoint ? visibleMidpoint.time : null, - summaryIntervalSize + const { visibleMidpoint, jumpToTargetPosition, startTimestamp, endTimestamp } = useContext( + LogPositionState.Context ); + const throttledStartTimestamp = useThrottle(startTimestamp, FETCH_THROTTLE_INTERVAL); + const throttledEndTimestamp = useThrottle(endTimestamp, FETCH_THROTTLE_INTERVAL); + const { logEntryHighlights, logEntryHighlightsById, @@ -46,8 +45,10 @@ export const useLogHighlightsState = ({ } = useLogEntryHighlights( sourceId, sourceVersion, - entriesStart, - entriesEnd, + throttledStartTimestamp, + throttledEndTimestamp, + centerCursor, + size, filterQuery, highlightTerms ); @@ -55,9 +56,8 @@ export const useLogHighlightsState = ({ const { logSummaryHighlights, loadLogSummaryHighlightsRequest } = useLogSummaryHighlights( sourceId, sourceVersion, - summaryStart, - summaryEnd, - summaryBucketSize, + throttledStartTimestamp, + throttledEndTimestamp, filterQuery, highlightTerms ); diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts index 81639aba411efb..41ee63bf0e23d4 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts @@ -10,13 +10,13 @@ import { debounce } from 'lodash'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; import { fetchLogSummaryHighlights } from './api/fetch_log_summary_highlights'; import { LogEntriesSummaryHighlightsResponse } from '../../../../common/http_api'; +import { useBucketSize } from '../log_summary/bucket_size'; export const useLogSummaryHighlights = ( sourceId: string, sourceVersion: string | undefined, - start: number | null, - end: number | null, - bucketSize: number, + startTimestamp: number | null, + endTimestamp: number | null, filterQuery: string | null, highlightTerms: string[] ) => { @@ -24,18 +24,20 @@ export const useLogSummaryHighlights = ( LogEntriesSummaryHighlightsResponse['data'] >([]); + const bucketSize = useBucketSize(startTimestamp, endTimestamp); + const [loadLogSummaryHighlightsRequest, loadLogSummaryHighlights] = useTrackedPromise( { cancelPreviousOn: 'resolution', createPromise: async () => { - if (!start || !end || !highlightTerms.length) { + if (!startTimestamp || !endTimestamp || !bucketSize || !highlightTerms.length) { throw new Error('Skipping request: Insufficient parameters'); } return await fetchLogSummaryHighlights({ sourceId, - startDate: start, - endDate: end, + startTimestamp, + endTimestamp, bucketSize, query: filterQuery, highlightTerms, @@ -45,7 +47,7 @@ export const useLogSummaryHighlights = ( setLogSummaryHighlights(response.data); }, }, - [sourceId, start, end, bucketSize, filterQuery, highlightTerms] + [sourceId, startTimestamp, endTimestamp, bucketSize, filterQuery, highlightTerms] ); const debouncedLoadSummaryHighlights = useMemo(() => debounce(loadLogSummaryHighlights, 275), [ @@ -57,7 +59,11 @@ export const useLogSummaryHighlights = ( }, [highlightTerms]); useEffect(() => { - if (highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length && start && end) { + if ( + highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length && + startTimestamp && + endTimestamp + ) { debouncedLoadSummaryHighlights(); } else { setLogSummaryHighlights([]); @@ -65,11 +71,11 @@ export const useLogSummaryHighlights = ( }, [ bucketSize, debouncedLoadSummaryHighlights, - end, filterQuery, highlightTerms, sourceVersion, - start, + startTimestamp, + endTimestamp, ]); return { diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx index 7557550883f113..689c30a52b5977 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx @@ -13,7 +13,7 @@ import { getLogEntryIndexBeforeTime, getUniqueLogEntryKey, } from '../../../utils/log_entry'; -import { LogEntryHighlights } from './log_entry_highlights'; +import { LogEntriesHighlightsResponse } from '../../../../common/http_api'; export const useNextAndPrevious = ({ highlightTerms, @@ -23,7 +23,7 @@ export const useNextAndPrevious = ({ }: { highlightTerms: string[]; jumpToTargetPosition: (target: TimeKey) => void; - logEntryHighlights: LogEntryHighlights | undefined; + logEntryHighlights: LogEntriesHighlightsResponse['data'] | undefined; visibleMidpoint: TimeKey | null; }) => { const [currentTimeKey, setCurrentTimeKey] = useState(null); 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 1a8274024bd265..5ac34e5df70ece 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 @@ -6,10 +6,20 @@ import { useState, useMemo, useEffect, useCallback } from 'react'; import createContainer from 'constate'; +import { useSetState } from 'react-use'; import { TimeKey } from '../../../../common/time'; +import { datemathToEpochMillis, isValidDatemath } from '../../../utils/datemath'; type TimeKeyOrNull = TimeKey | null; +interface DateRange { + startDateExpression: string; + endDateExpression: string; + startTimestamp: number; + endTimestamp: number; + timestampsLastUpdate: number; +} + interface VisiblePositions { startKey: TimeKeyOrNull; middleKey: TimeKeyOrNull; @@ -19,24 +29,35 @@ interface VisiblePositions { } export interface LogPositionStateParams { + isInitialized: boolean; targetPosition: TimeKeyOrNull; - isAutoReloading: boolean; + 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; } 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; } +const DEFAULT_DATE_RANGE = { startDateExpression: 'now-1d', endDateExpression: 'now' }; +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 @@ -60,8 +81,18 @@ const useVisibleMidpoint = (middleKey: TimeKeyOrNull, targetPosition: TimeKeyOrN }; export const useLogPositionState: () => LogPositionStateParams & LogPositionCallbacks = () => { + // 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 [isAutoReloading, setIsAutoReloading] = useState(false); + const [isStreaming, setIsStreaming] = useState(false); const [visiblePositions, reportVisiblePositions] = useState({ endKey: null, middleKey: null, @@ -70,6 +101,15 @@ export const useLogPositionState: () => LogPositionStateParams & LogPositionCall pagesAfterEnd: Infinity, }); + // 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(), + }); + const { startKey, middleKey, endKey, pagesBeforeStart, pagesAfterEnd } = visiblePositions; const visibleMidpoint = useVisibleMidpoint(middleKey, targetPosition); @@ -79,26 +119,87 @@ export const useLogPositionState: () => LogPositionStateParams & LogPositionCall [startKey, endKey] ); + // 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 nextStartDateExpression = + newDateRange.startDateExpression || dateRange.startDateExpression; + const nextEndDateExpression = newDateRange.endDateExpression || dateRange.endDateExpression; + + if (!isValidDatemath(nextStartDateExpression) || !isValidDatemath(nextEndDateExpression)) { + return; + } + + // 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); + } + + setDateRange({ + ...newDateRange, + startTimestamp: nextStartTimestamp, + endTimestamp: nextEndTimestamp, + timestampsLastUpdate: Date.now(), + }); + }, + [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(), + }); + } + }, [dateRange.endDateExpression, visiblePositions, setDateRange]); + const state = { + isInitialized, targetPosition, - isAutoReloading, + isStreaming, firstVisiblePosition: startKey, pagesBeforeStart, pagesAfterEnd, visibleMidpoint, visibleMidpointTime: visibleMidpoint ? visibleMidpoint.time : null, visibleTimeInterval, + ...dateRange, }; const callbacks = { + initialize, jumpToTargetPosition, jumpToTargetPositionTime: useCallback( (time: number) => jumpToTargetPosition({ tiebreaker: 0, time }), [jumpToTargetPosition] ), reportVisiblePositions, - startLiveStreaming: useCallback(() => setIsAutoReloading(true), [setIsAutoReloading]), - stopLiveStreaming: useCallback(() => setIsAutoReloading(false), [setIsAutoReloading]), + startLiveStreaming: useCallback(() => { + setIsStreaming(true); + jumpToTargetPosition(null); + updateDateRange({ startDateExpression: 'now-1d', endDateExpression: 'now' }); + }, [setIsStreaming, updateDateRange]), + stopLiveStreaming: useCallback(() => setIsStreaming(false), [setIsStreaming]), + updateDateRange, }; return { ...state, ...callbacks }; 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 index 221dac95ef5f07..0d3586f9376f38 100644 --- 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 @@ -9,31 +9,40 @@ import React, { useContext, useMemo } from 'react'; import { pickTimeKey } from '../../../../common/time'; import { replaceStateKeyInQueryString, UrlStateContainer } from '../../../utils/url_state'; import { LogPositionState, LogPositionStateParams } from './log_position_state'; +import { isValidDatemath, datemathToEpochMillis } from '../../../utils/datemath'; /** * Url State */ - -interface LogPositionUrlState { - position: LogPositionStateParams['visibleMidpoint'] | undefined; +export interface LogPositionUrlState { + position?: LogPositionStateParams['visibleMidpoint']; streamLive: boolean; + start?: string; + end?: string; } +const ONE_HOUR = 3600000; + export const WithLogPositionUrlState = () => { const { visibleMidpoint, - isAutoReloading, + isStreaming, jumpToTargetPosition, - jumpToTargetPositionTime, startLiveStreaming, stopLiveStreaming, + startDateExpression, + endDateExpression, + updateDateRange, + initialize, } = useContext(LogPositionState.Context); const urlState = useMemo( () => ({ position: visibleMidpoint ? pickTimeKey(visibleMidpoint) : null, - streamLive: isAutoReloading, + streamLive: isStreaming, + start: startDateExpression, + end: endDateExpression, }), - [visibleMidpoint, isAutoReloading] + [visibleMidpoint, isStreaming, startDateExpression, endDateExpression] ); return ( { urlStateKey="logPosition" mapToUrlState={mapToUrlState} onChange={(newUrlState: LogPositionUrlState | undefined) => { - if (newUrlState && newUrlState.position) { + if (!newUrlState) { + return; + } + + if (newUrlState.start || newUrlState.end) { + updateDateRange({ + startDateExpression: newUrlState.start, + endDateExpression: newUrlState.end, + }); + } + + if (newUrlState.position) { jumpToTargetPosition(newUrlState.position); } - if (newUrlState && newUrlState.streamLive) { + + if (newUrlState.streamLive) { startLiveStreaming(); - } else if ( - newUrlState && - typeof newUrlState.streamLive !== 'undefined' && - !newUrlState.streamLive - ) { + } else if (typeof newUrlState.streamLive !== 'undefined' && !newUrlState.streamLive) { stopLiveStreaming(); } }} onInitialize={(initialUrlState: LogPositionUrlState | undefined) => { - if (initialUrlState && initialUrlState.position) { - jumpToTargetPosition(initialUrlState.position); - } else { - jumpToTargetPositionTime(Date.now()); - } - if (initialUrlState && initialUrlState.streamLive) { - startLiveStreaming(); + 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(); }} /> ); @@ -73,6 +123,8 @@ const mapToUrlState = (value: any): LogPositionUrlState | undefined => ? { position: mapToPositionUrlState(value.position), streamLive: mapToStreamLiveUrlState(value.streamLive), + start: mapToDate(value.start), + end: mapToDate(value.end), } : undefined; @@ -83,6 +135,7 @@ const mapToPositionUrlState = (value: any) => 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 diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts new file mode 100644 index 00000000000000..e46b304156f830 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo } from 'react'; + +const SUMMARY_BUCKET_COUNT = 100; + +export function useBucketSize( + startTimestamp: number | null, + endTimestamp: number | null +): number | null { + const bucketSize = useMemo(() => { + if (!startTimestamp || !endTimestamp) { + return null; + } + return (endTimestamp - startTimestamp) / SUMMARY_BUCKET_COUNT; + }, [startTimestamp, endTimestamp]); + + return bucketSize; +} diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts index 20c4267000a250..dc0437fa75a314 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts @@ -5,5 +5,4 @@ */ export * from './log_summary'; -export * from './use_log_summary_buffer_interval'; export * from './with_summary'; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx index 2bbcc22b150e46..73d0e5efdf06b4 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx @@ -9,6 +9,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { useLogSummary } from './log_summary'; import { fetchLogSummary } from './api/fetch_log_summary'; +import { datemathToEpochMillis } from '../../../utils/datemath'; // Typescript doesn't know that `fetchLogSummary` is a jest mock. // We use a second variable with a type cast to help the compiler further down the line. @@ -21,20 +22,26 @@ describe('useLogSummary hook', () => { }); it('provides an empty list of buckets by default', () => { - const { result } = renderHook(() => useLogSummary('SOURCE_ID', null, 1000, null)); + const { result } = renderHook(() => useLogSummary('SOURCE_ID', null, null, null)); expect(result.current.buckets).toEqual([]); }); it('queries for new summary buckets when the source id changes', async () => { - const firstMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 1 }]); - const secondMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 2 }]); + const { startTimestamp, endTimestamp } = createMockDateRange(); + + const firstMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 1 }, + ]); + const secondMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 2 }, + ]); fetchLogSummaryMock .mockResolvedValueOnce(firstMockResponse) .mockResolvedValueOnce(secondMockResponse); const { result, waitForNextUpdate, rerender } = renderHook( - ({ sourceId }) => useLogSummary(sourceId, 100000, 1000, null), + ({ sourceId }) => useLogSummary(sourceId, startTimestamp, endTimestamp, null), { initialProps: { sourceId: 'INITIAL_SOURCE_ID' }, } @@ -63,15 +70,21 @@ describe('useLogSummary hook', () => { }); it('queries for new summary buckets when the filter query changes', async () => { - const firstMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 1 }]); - const secondMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 2 }]); + const { startTimestamp, endTimestamp } = createMockDateRange(); + + const firstMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 1 }, + ]); + const secondMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 2 }, + ]); fetchLogSummaryMock .mockResolvedValueOnce(firstMockResponse) .mockResolvedValueOnce(secondMockResponse); const { result, waitForNextUpdate, rerender } = renderHook( - ({ filterQuery }) => useLogSummary('SOURCE_ID', 100000, 1000, filterQuery), + ({ filterQuery }) => useLogSummary('SOURCE_ID', startTimestamp, endTimestamp, filterQuery), { initialProps: { filterQuery: 'INITIAL_FILTER_QUERY' }, } @@ -99,15 +112,17 @@ describe('useLogSummary hook', () => { expect(result.current.buckets).toEqual(secondMockResponse.data.buckets); }); - it('queries for new summary buckets when the midpoint time changes', async () => { + it('queries for new summary buckets when the start and end date changes', async () => { fetchLogSummaryMock .mockResolvedValueOnce(createMockResponse([])) .mockResolvedValueOnce(createMockResponse([])); + const firstRange = createMockDateRange(); const { waitForNextUpdate, rerender } = renderHook( - ({ midpointTime }) => useLogSummary('SOURCE_ID', midpointTime, 1000, null), + ({ startTimestamp, endTimestamp }) => + useLogSummary('SOURCE_ID', startTimestamp, endTimestamp, null), { - initialProps: { midpointTime: 100000 }, + initialProps: firstRange, } ); @@ -115,54 +130,21 @@ describe('useLogSummary hook', () => { expect(fetchLogSummaryMock).toHaveBeenCalledTimes(1); expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( expect.objectContaining({ - startDate: 98500, - endDate: 101500, - }) - ); - - rerender({ midpointTime: 200000 }); - await waitForNextUpdate(); - - expect(fetchLogSummaryMock).toHaveBeenCalledTimes(2); - expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - startDate: 198500, - endDate: 201500, + startTimestamp: firstRange.startTimestamp, + endTimestamp: firstRange.endTimestamp, }) ); - }); - it('queries for new summary buckets when the interval size changes', async () => { - fetchLogSummaryMock - .mockResolvedValueOnce(createMockResponse([])) - .mockResolvedValueOnce(createMockResponse([])); - - const { waitForNextUpdate, rerender } = renderHook( - ({ intervalSize }) => useLogSummary('SOURCE_ID', 100000, intervalSize, null), - { - initialProps: { intervalSize: 1000 }, - } - ); + const secondRange = createMockDateRange('now-20s', 'now'); - await waitForNextUpdate(); - expect(fetchLogSummaryMock).toHaveBeenCalledTimes(1); - expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - bucketSize: 10, - startDate: 98500, - endDate: 101500, - }) - ); - - rerender({ intervalSize: 2000 }); + rerender(secondRange); await waitForNextUpdate(); expect(fetchLogSummaryMock).toHaveBeenCalledTimes(2); expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( expect.objectContaining({ - bucketSize: 20, - startDate: 97000, - endDate: 103000, + startTimestamp: secondRange.startTimestamp, + endTimestamp: secondRange.endTimestamp, }) ); }); @@ -171,3 +153,12 @@ describe('useLogSummary hook', () => { const createMockResponse = ( buckets: Array<{ start: number; end: number; entriesCount: number }> ) => ({ data: { buckets, start: Number.NEGATIVE_INFINITY, end: Number.POSITIVE_INFINITY } }); + +const createMockDateRange = (startDate = 'now-10s', endDate = 'now') => { + return { + startDate, + endDate, + startTimestamp: datemathToEpochMillis(startDate)!, + endTimestamp: datemathToEpochMillis(endDate, 'up')!, + }; +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx index c39b7075af325f..94723125cc0ec8 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx @@ -7,34 +7,31 @@ import { useState } from 'react'; import { useCancellableEffect } from '../../../utils/cancellable_effect'; -import { useLogSummaryBufferInterval } from './use_log_summary_buffer_interval'; import { fetchLogSummary } from './api/fetch_log_summary'; import { LogEntriesSummaryResponse } from '../../../../common/http_api'; +import { useBucketSize } from './bucket_size'; export type LogSummaryBuckets = LogEntriesSummaryResponse['data']['buckets']; export const useLogSummary = ( sourceId: string, - midpointTime: number | null, - intervalSize: number, + startTimestamp: number | null, + endTimestamp: number | null, filterQuery: string | null ) => { const [logSummaryBuckets, setLogSummaryBuckets] = useState([]); - const { start: bufferStart, end: bufferEnd, bucketSize } = useLogSummaryBufferInterval( - midpointTime, - intervalSize - ); + const bucketSize = useBucketSize(startTimestamp, endTimestamp); useCancellableEffect( getIsCancelled => { - if (bufferStart === null || bufferEnd === null) { + if (startTimestamp === null || endTimestamp === null || bucketSize === null) { return; } fetchLogSummary({ sourceId, - startDate: bufferStart, - endDate: bufferEnd, + startTimestamp, + endTimestamp, bucketSize, query: filterQuery, }).then(response => { @@ -43,12 +40,12 @@ export const useLogSummary = ( } }); }, - [sourceId, filterQuery, bufferStart, bufferEnd, bucketSize] + [sourceId, filterQuery, startTimestamp, endTimestamp, bucketSize] ); return { buckets: logSummaryBuckets, - start: bufferStart, - end: bufferEnd, + start: startTimestamp, + end: endTimestamp, }; }; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts deleted file mode 100644 index 27af76b70f47a1..00000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts +++ /dev/null @@ -1,30 +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 { useMemo } from 'react'; - -const LOAD_BUCKETS_PER_PAGE = 100; -const UNKNOWN_BUFFER_INTERVAL = { - start: null, - end: null, - bucketSize: 0, -}; - -export const useLogSummaryBufferInterval = (midpointTime: number | null, intervalSize: number) => { - return useMemo(() => { - if (midpointTime === null || intervalSize <= 0) { - return UNKNOWN_BUFFER_INTERVAL; - } - - const halfIntervalSize = intervalSize / 2; - - return { - start: (Math.floor((midpointTime - halfIntervalSize) / intervalSize) - 0.5) * intervalSize, - end: (Math.ceil((midpointTime + halfIntervalSize) / intervalSize) + 0.5) * intervalSize, - bucketSize: intervalSize / LOAD_BUCKETS_PER_PAGE, - }; - }, [midpointTime, intervalSize]); -}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts index 4db0d2e645448c..14da2b47bcfa2d 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts @@ -5,14 +5,16 @@ */ import { useContext } from 'react'; +import { useThrottle } from 'react-use'; import { RendererFunction } from '../../../utils/typed_react'; import { Source } from '../../source'; -import { LogViewConfiguration } from '../log_view_configuration'; import { LogSummaryBuckets, useLogSummary } from './log_summary'; import { LogFilterState } from '../log_filter'; import { LogPositionState } from '../log_position'; +const FETCH_THROTTLE_INTERVAL = 3000; + export const WithSummary = ({ children, }: { @@ -22,15 +24,18 @@ export const WithSummary = ({ end: number | null; }>; }) => { - const { intervalSize } = useContext(LogViewConfiguration.Context); const { sourceId } = useContext(Source.Context); const { filterQuery } = useContext(LogFilterState.Context); - const { visibleMidpointTime } = useContext(LogPositionState.Context); + const { startTimestamp, endTimestamp } = useContext(LogPositionState.Context); + + // Keep it reasonably updated for the `now` case, but don't reload all the time when the user scrolls + const throttledStartTimestamp = useThrottle(startTimestamp, FETCH_THROTTLE_INTERVAL); + const throttledEndTimestamp = useThrottle(endTimestamp, FETCH_THROTTLE_INTERVAL); const { buckets, start, end } = useLogSummary( sourceId, - visibleMidpointTime, - intervalSize, + throttledStartTimestamp, + throttledEndTimestamp, filterQuery ); diff --git a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx index b6de1230d9a59c..5954cb834a11d1 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx @@ -45,35 +45,10 @@ describe('useLogViewConfiguration hook', () => { }); }); - describe('intervalSize state', () => { - it('has a default value', () => { - const { getLastHookValue } = mountHook(() => useLogViewConfiguration().intervalSize); - - expect(getLastHookValue()).toEqual(86400000); - }); - - it('can be updated', () => { - const { act, getLastHookValue } = mountHook(() => useLogViewConfiguration()); - - act(({ setIntervalSize }) => { - setIntervalSize(90000000); - }); - - expect(getLastHookValue().intervalSize).toEqual(90000000); - }); - }); - it('provides the available text scales', () => { const { getLastHookValue } = mountHook(() => useLogViewConfiguration().availableTextScales); expect(getLastHookValue()).toEqual(expect.any(Array)); expect(getLastHookValue().length).toBeGreaterThan(0); }); - - it('provides the available interval sizes', () => { - const { getLastHookValue } = mountHook(() => useLogViewConfiguration().availableIntervalSizes); - - expect(getLastHookValue()).toEqual(expect.any(Array)); - expect(getLastHookValue().length).toBeGreaterThan(0); - }); }); diff --git a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx index 8837078aa4a0df..e1351ad0b17ad9 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; import createContainer from 'constate'; import { useState } from 'react'; @@ -17,18 +16,12 @@ export const useLogViewConfiguration = () => { // text wrap const [textWrap, setTextWrap] = useState(true); - // minimap interval - const [intervalSize, setIntervalSize] = useState(1000 * 60 * 60 * 24); - return { - availableIntervalSizes, availableTextScales, setTextScale, setTextWrap, textScale, textWrap, - intervalSize, - setIntervalSize, }; }; @@ -39,42 +32,3 @@ export const LogViewConfiguration = createContainer(useLogViewConfiguration); */ export const availableTextScales: TextScale[] = ['large', 'medium', 'small']; - -export const availableIntervalSizes = [ - { - label: i18n.translate('xpack.infra.mapLogs.oneYearLabel', { - defaultMessage: '1 Year', - }), - intervalSize: 1000 * 60 * 60 * 24 * 365, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneMonthLabel', { - defaultMessage: '1 Month', - }), - intervalSize: 1000 * 60 * 60 * 24 * 30, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneWeekLabel', { - defaultMessage: '1 Week', - }), - intervalSize: 1000 * 60 * 60 * 24 * 7, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneDayLabel', { - defaultMessage: '1 Day', - }), - intervalSize: 1000 * 60 * 60 * 24, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneHourLabel', { - defaultMessage: '1 Hour', - }), - intervalSize: 1000 * 60 * 60, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneMinuteLabel', { - defaultMessage: '1 Minute', - }), - intervalSize: 1000 * 60, - }, -]; diff --git a/x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx b/x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx deleted file mode 100644 index 3f2b4d7cc16f91..00000000000000 --- a/x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx +++ /dev/null @@ -1,52 +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 React, { useContext, useMemo } from 'react'; - -import { UrlStateContainer } from '../../utils/url_state'; -import { LogViewConfiguration } from './log_view_configuration'; - -/** - * Url State - */ - -interface LogMinimapUrlState { - intervalSize?: number; -} - -export const WithLogMinimapUrlState = () => { - const { intervalSize, setIntervalSize } = useContext(LogViewConfiguration.Context); - - const urlState = useMemo(() => ({ intervalSize }), [intervalSize]); - - return ( - { - if (newUrlState && newUrlState.intervalSize) { - setIntervalSize(newUrlState.intervalSize); - } - }} - onInitialize={newUrlState => { - if (newUrlState && newUrlState.intervalSize) { - setIntervalSize(newUrlState.intervalSize); - } - }} - /> - ); -}; - -const mapToUrlState = (value: any): LogMinimapUrlState | undefined => - value - ? { - intervalSize: mapToIntervalSizeUrlState(value.intervalSize), - } - : undefined; - -const mapToIntervalSizeUrlState = (value: any) => - value && typeof value === 'number' ? value : undefined; diff --git a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts b/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts index 6da9cd7513cbab..5c0e245448ce5b 100644 --- a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts +++ b/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts @@ -6,12 +6,12 @@ import { useContext, useMemo } from 'react'; import { StreamItem, LogEntryStreamItem } from '../../components/logging/log_text_stream/item'; -import { LogEntry, LogEntryHighlight } from '../../utils/log_entry'; import { RendererFunction } from '../../utils/typed_react'; // deep inporting to avoid a circular import problem import { LogHighlightsState } from './log_highlights/log_highlights'; import { LogEntriesState, LogEntriesStateParams, LogEntriesCallbacks } from './log_entries'; import { UniqueTimeKey } from '../../../common/time'; +import { LogEntry } from '../../../common/http_api'; export const WithStreamItems: React.FunctionComponent<{ children: RendererFunction< @@ -30,7 +30,7 @@ export const WithStreamItems: React.FunctionComponent<{ logEntries.isReloading ? [] : logEntries.entries.map(logEntry => - createLogEntryStreamItem(logEntry, logEntryHighlightsById[logEntry.gid] || []) + createLogEntryStreamItem(logEntry, logEntryHighlightsById[logEntry.id] || []) ), [logEntries.entries, logEntries.isReloading, logEntryHighlightsById] @@ -46,7 +46,7 @@ export const WithStreamItems: React.FunctionComponent<{ const createLogEntryStreamItem = ( logEntry: LogEntry, - highlights: LogEntryHighlight[] + highlights: LogEntry[] ): LogEntryStreamItem => ({ kind: 'logEntry' as 'logEntry', logEntry, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx index 54609bcf8e2c23..023082154565cf 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx @@ -44,11 +44,8 @@ export const CategoryExampleMessage: React.FunctionComponent<{ { const { source, sourceId, version } = useContext(Source.Context); - const { intervalSize, textScale, textWrap } = useContext(LogViewConfiguration.Context); + const { textScale, textWrap } = useContext(LogViewConfiguration.Context); const { setFlyoutVisibility, flyoutVisible, @@ -44,17 +43,20 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { const { logSummaryHighlights } = useContext(LogHighlightsState.Context); const { applyLogFilterQuery } = useContext(LogFilterState.Context); const { - isAutoReloading, + isStreaming, targetPosition, visibleMidpointTime, visibleTimeInterval, reportVisiblePositions, jumpToTargetPosition, + startLiveStreaming, stopLiveStreaming, + startDateExpression, + endDateExpression, + updateDateRange, } = useContext(LogPositionState.Context); return ( <> - @@ -90,7 +92,7 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { hasMoreBeforeStart={hasMoreBeforeStart} isLoadingMore={isLoadingMore} isReloading={isReloading} - isStreaming={isAutoReloading} + isStreaming={isStreaming} items={items} jumpToTarget={jumpToTargetPosition} lastLoadedTime={lastLoadedTime} @@ -104,6 +106,10 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { setFlyoutVisibility={setFlyoutVisibility} highlightedItem={surroundingLogsId ? surroundingLogsId : null} currentHighlightKey={currentHighlightKey} + startDateExpression={startDateExpression} + endDateExpression={endDateExpression} + updateDateRange={updateDateRange} + startLiveStreaming={startLiveStreaming} /> )} @@ -113,14 +119,15 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { return ( - {({ buckets }) => ( + {({ buckets, start, end }) => ( {({ isReloading }) => ( { const LogEntriesStateProvider: React.FC = ({ children }) => { const { sourceId } = useContext(Source.Context); const { + startTimestamp, + endTimestamp, + timestampsLastUpdate, targetPosition, pagesBeforeStart, pagesAfterEnd, - isAutoReloading, + isStreaming, jumpToTargetPosition, + isInitialized, } = useContext(LogPositionState.Context); const { filterQuery } = useContext(LogFilterState.Context); + // Don't render anything if the date range is incorrect. + if (!startTimestamp || !endTimestamp) { + return null; + } + const entriesProps = { + startTimestamp, + endTimestamp, + timestampsLastUpdate, timeKey: targetPosition, pagesBeforeStart, pagesAfterEnd, filterQuery, sourceId, - isAutoReloading, + isStreaming, jumpToTargetPosition, }; + + // Don't initialize the entries until the position has been fully intialized. + // See `` + if (!isInitialized) { + return null; + } + return {children}; }; const LogHighlightsStateProvider: React.FC = ({ children }) => { const { sourceId, version } = useContext(Source.Context); - const [{ entriesStart, entriesEnd }] = useContext(LogEntriesState.Context); + const [{ topCursor, bottomCursor, centerCursor, entries }] = useContext(LogEntriesState.Context); const { filterQuery } = useContext(LogFilterState.Context); + const highlightsProps = { sourceId, sourceVersion: version, - entriesStart, - entriesEnd, + entriesStart: topCursor, + entriesEnd: bottomCursor, + centerCursor, + size: entries.length, filterQuery, }; return {children}; 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 000dfd1065f12f..2f9a76fd47490b 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 @@ -13,30 +13,22 @@ import { Toolbar } from '../../../components/eui'; import { LogCustomizationMenu } from '../../../components/logging/log_customization_menu'; import { LogHighlightsMenu } from '../../../components/logging/log_highlights_menu'; import { LogHighlightsState } from '../../../containers/logs/log_highlights/log_highlights'; -import { LogMinimapScaleControls } from '../../../components/logging/log_minimap_scale_controls'; import { LogTextScaleControls } from '../../../components/logging/log_text_scale_controls'; import { LogTextWrapControls } from '../../../components/logging/log_text_wrap_controls'; -import { LogTimeControls } from '../../../components/logging/log_time_controls'; import { LogFlyout } from '../../../containers/logs/log_flyout'; import { LogViewConfiguration } from '../../../containers/logs/log_view_configuration'; import { LogFilterState } from '../../../containers/logs/log_filter'; import { LogPositionState } from '../../../containers/logs/log_position'; import { Source } from '../../../containers/source'; import { WithKueryAutocompletion } from '../../../containers/with_kuery_autocompletion'; +import { LogDatepicker } from '../../../components/logging/log_datepicker'; export const LogsToolbar = () => { const { createDerivedIndexPattern } = useContext(Source.Context); const derivedIndexPattern = createDerivedIndexPattern('logs'); - const { - availableIntervalSizes, - availableTextScales, - intervalSize, - setIntervalSize, - setTextScale, - setTextWrap, - textScale, - textWrap, - } = useContext(LogViewConfiguration.Context); + const { availableTextScales, setTextScale, setTextWrap, textScale, textWrap } = useContext( + LogViewConfiguration.Context + ); const { filterQueryDraft, isFilterQueryDraftValid, @@ -55,12 +47,14 @@ export const LogsToolbar = () => { goToNextHighlight, } = useContext(LogHighlightsState.Context); const { - visibleMidpointTime, - isAutoReloading, - jumpToTargetPositionTime, + isStreaming, startLiveStreaming, stopLiveStreaming, + startDateExpression, + endDateExpression, + updateDateRange, } = useContext(LogPositionState.Context); + return ( @@ -94,11 +88,6 @@ export const LogsToolbar = () => { - { /> - { - startLiveStreaming(); - setSurroundingLogsId(null); - }} - stopLiveStreaming={stopLiveStreaming} + diff --git a/x-pack/plugins/infra/public/utils/datemath.test.ts b/x-pack/plugins/infra/public/utils/datemath.test.ts new file mode 100644 index 00000000000000..0f272733c5f972 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/datemath.test.ts @@ -0,0 +1,401 @@ +/* + * 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 { + isValidDatemath, + datemathToEpochMillis, + extendDatemath, + convertDate, + normalizeDate, +} from './datemath'; +import sinon from 'sinon'; + +describe('isValidDatemath()', () => { + it('Returns `false` for empty strings', () => { + expect(isValidDatemath('')).toBe(false); + }); + + it('Returns `false` for invalid strings', () => { + expect(isValidDatemath('wadus')).toBe(false); + expect(isValidDatemath('nowww-')).toBe(false); + expect(isValidDatemath('now-')).toBe(false); + expect(isValidDatemath('now-1')).toBe(false); + expect(isValidDatemath('now-1d/')).toBe(false); + }); + + it('Returns `true` for valid strings', () => { + expect(isValidDatemath('now')).toBe(true); + expect(isValidDatemath('now-1d')).toBe(true); + expect(isValidDatemath('now-1d/d')).toBe(true); + }); +}); + +describe('datemathToEpochMillis()', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers(Date.now()); + }); + + afterEach(() => { + clock.restore(); + }); + + it('Returns `0` for the dawn of time', () => { + expect(datemathToEpochMillis('1970-01-01T00:00:00+00:00')).toEqual(0); + }); + + it('Returns the current timestamp when `now`', () => { + expect(datemathToEpochMillis('now')).toEqual(Date.now()); + }); +}); + +describe('extendDatemath()', () => { + it('Returns `undefined` for invalid values', () => { + expect(extendDatemath('')).toBeUndefined(); + }); + + it('Keeps `"now"` stable', () => { + expect(extendDatemath('now')).toEqual({ value: 'now' }); + expect(extendDatemath('now', 'before')).toEqual({ value: 'now' }); + expect(extendDatemath('now', 'after')).toEqual({ value: 'now' }); + }); + + describe('moving before', () => { + describe('with a negative operator', () => { + it('doubles miliseconds', () => { + expect(extendDatemath('now-250ms')).toEqual({ + value: 'now-500ms', + diffAmount: 250, + diffUnit: 'ms', + }); + }); + + it('normalizes miliseconds', () => { + expect(extendDatemath('now-500ms')).toEqual({ + value: 'now-1s', + diffAmount: 500, + diffUnit: 'ms', + }); + }); + + it('doubles seconds', () => { + expect(extendDatemath('now-10s')).toEqual({ + value: 'now-20s', + diffAmount: 10, + diffUnit: 's', + }); + }); + + it('normalizes seconds', () => { + expect(extendDatemath('now-30s')).toEqual({ + value: 'now-1m', + diffAmount: 30, + diffUnit: 's', + }); + }); + + it('doubles minutes when amount is low', () => { + expect(extendDatemath('now-1m')).toEqual({ value: 'now-2m', diffAmount: 1, diffUnit: 'm' }); + expect(extendDatemath('now-2m')).toEqual({ value: 'now-4m', diffAmount: 2, diffUnit: 'm' }); + expect(extendDatemath('now-3m')).toEqual({ value: 'now-6m', diffAmount: 3, diffUnit: 'm' }); + }); + + it('adds half the minutes when the amount is high', () => { + expect(extendDatemath('now-20m')).toEqual({ + value: 'now-30m', + diffAmount: 10, + diffUnit: 'm', + }); + }); + + it('Adds half an hour when the amount is one hour', () => { + expect(extendDatemath('now-1h')).toEqual({ + value: 'now-90m', + diffAmount: 30, + diffUnit: 'm', + }); + }); + + it('Adds one hour when the amount more than one hour', () => { + expect(extendDatemath('now-2h')).toEqual({ + value: 'now-3h', + diffAmount: 1, + diffUnit: 'h', + }); + }); + + it('Adds one hour when the amount is one day', () => { + expect(extendDatemath('now-1d')).toEqual({ + value: 'now-25h', + diffAmount: 1, + diffUnit: 'h', + }); + }); + + it('Adds one day when the amount is more than one day', () => { + expect(extendDatemath('now-2d')).toEqual({ + value: 'now-3d', + diffAmount: 1, + diffUnit: 'd', + }); + expect(extendDatemath('now-3d')).toEqual({ + value: 'now-4d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Adds one day when the amount is one week', () => { + expect(extendDatemath('now-1w')).toEqual({ + value: 'now-8d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Adds one week when the amount is more than one week', () => { + expect(extendDatemath('now-2w')).toEqual({ + value: 'now-3w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Adds one week when the amount is one month', () => { + expect(extendDatemath('now-1M')).toEqual({ + value: 'now-5w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Adds one month when the amount is more than one month', () => { + expect(extendDatemath('now-2M')).toEqual({ + value: 'now-3M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Adds one month when the amount is one year', () => { + expect(extendDatemath('now-1y')).toEqual({ + value: 'now-13M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Adds one year when the amount is in years', () => { + expect(extendDatemath('now-2y')).toEqual({ + value: 'now-3y', + diffAmount: 1, + diffUnit: 'y', + }); + }); + }); + + describe('with a positive Operator', () => { + it('Halves miliseconds', () => { + expect(extendDatemath('now+250ms')).toEqual({ + value: 'now+125ms', + diffAmount: 125, + diffUnit: 'ms', + }); + }); + + it('Halves seconds', () => { + expect(extendDatemath('now+10s')).toEqual({ + value: 'now+5s', + diffAmount: 5, + diffUnit: 's', + }); + }); + + it('Halves minutes when the amount is low', () => { + expect(extendDatemath('now+2m')).toEqual({ value: 'now+1m', diffAmount: 1, diffUnit: 'm' }); + expect(extendDatemath('now+4m')).toEqual({ value: 'now+2m', diffAmount: 2, diffUnit: 'm' }); + expect(extendDatemath('now+6m')).toEqual({ value: 'now+3m', diffAmount: 3, diffUnit: 'm' }); + }); + + it('Decreases minutes in half ammounts when the amount is high', () => { + expect(extendDatemath('now+30m')).toEqual({ + value: 'now+20m', + diffAmount: 10, + diffUnit: 'm', + }); + }); + + it('Decreases half an hour when the amount is one hour', () => { + expect(extendDatemath('now+1h')).toEqual({ + value: 'now+30m', + diffAmount: 30, + diffUnit: 'm', + }); + }); + + it('Removes one hour when the amount is one day', () => { + expect(extendDatemath('now+1d')).toEqual({ + value: 'now+23h', + diffAmount: 1, + diffUnit: 'h', + }); + }); + + it('Removes one day when the amount is more than one day', () => { + expect(extendDatemath('now+2d')).toEqual({ + value: 'now+1d', + diffAmount: 1, + diffUnit: 'd', + }); + expect(extendDatemath('now+3d')).toEqual({ + value: 'now+2d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Removes one day when the amount is one week', () => { + expect(extendDatemath('now+1w')).toEqual({ + value: 'now+6d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Removes one week when the amount is more than one week', () => { + expect(extendDatemath('now+2w')).toEqual({ + value: 'now+1w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Removes one week when the amount is one month', () => { + expect(extendDatemath('now+1M')).toEqual({ + value: 'now+3w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Removes one month when the amount is more than one month', () => { + expect(extendDatemath('now+2M')).toEqual({ + value: 'now+1M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Removes one month when the amount is one year', () => { + expect(extendDatemath('now+1y')).toEqual({ + value: 'now+11M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Adds one year when the amount is in years', () => { + expect(extendDatemath('now+2y')).toEqual({ + value: 'now+1y', + diffAmount: 1, + diffUnit: 'y', + }); + }); + }); + }); +}); + +describe('convertDate()', () => { + it('returns same value if units are the same', () => { + expect(convertDate(1, 'h', 'h')).toEqual(1); + }); + + it('converts from big units to small units', () => { + expect(convertDate(1, 's', 'ms')).toEqual(1000); + expect(convertDate(1, 'm', 'ms')).toEqual(60000); + expect(convertDate(1, 'h', 'ms')).toEqual(3600000); + expect(convertDate(1, 'd', 'ms')).toEqual(86400000); + expect(convertDate(1, 'M', 'ms')).toEqual(2592000000); + expect(convertDate(1, 'y', 'ms')).toEqual(31536000000); + }); + + it('converts from small units to big units', () => { + expect(convertDate(1000, 'ms', 's')).toEqual(1); + expect(convertDate(60000, 'ms', 'm')).toEqual(1); + expect(convertDate(3600000, 'ms', 'h')).toEqual(1); + expect(convertDate(86400000, 'ms', 'd')).toEqual(1); + expect(convertDate(2592000000, 'ms', 'M')).toEqual(1); + expect(convertDate(31536000000, 'ms', 'y')).toEqual(1); + }); + + it('Handles days to years', () => { + expect(convertDate(1, 'y', 'd')).toEqual(365); + expect(convertDate(365, 'd', 'y')).toEqual(1); + }); + + it('Handles years to months', () => { + expect(convertDate(1, 'y', 'M')).toEqual(12); + expect(convertDate(12, 'M', 'y')).toEqual(1); + }); + + it('Handles days to months', () => { + expect(convertDate(1, 'M', 'd')).toEqual(30); + expect(convertDate(30, 'd', 'M')).toEqual(1); + }); + + it('Handles days to weeks', () => { + expect(convertDate(1, 'w', 'd')).toEqual(7); + expect(convertDate(7, 'd', 'w')).toEqual(1); + }); + + it('Handles weeks to years', () => { + expect(convertDate(1, 'y', 'w')).toEqual(52); + expect(convertDate(52, 'w', 'y')).toEqual(1); + }); +}); + +describe('normalizeDate()', () => { + it('keeps units under the conversion ratio the same', () => { + expect(normalizeDate(999, 'ms')).toEqual({ amount: 999, unit: 'ms' }); + expect(normalizeDate(59, 's')).toEqual({ amount: 59, unit: 's' }); + expect(normalizeDate(59, 'm')).toEqual({ amount: 59, unit: 'm' }); + expect(normalizeDate(23, 'h')).toEqual({ amount: 23, unit: 'h' }); + expect(normalizeDate(6, 'd')).toEqual({ amount: 6, unit: 'd' }); + expect(normalizeDate(3, 'w')).toEqual({ amount: 3, unit: 'w' }); + expect(normalizeDate(11, 'M')).toEqual({ amount: 11, unit: 'M' }); + }); + + it('Moves to the next unit for values equal to the conversion ratio', () => { + expect(normalizeDate(1000, 'ms')).toEqual({ amount: 1, unit: 's' }); + expect(normalizeDate(60, 's')).toEqual({ amount: 1, unit: 'm' }); + expect(normalizeDate(60, 'm')).toEqual({ amount: 1, unit: 'h' }); + expect(normalizeDate(24, 'h')).toEqual({ amount: 1, unit: 'd' }); + expect(normalizeDate(7, 'd')).toEqual({ amount: 1, unit: 'w' }); + expect(normalizeDate(4, 'w')).toEqual({ amount: 1, unit: 'M' }); + expect(normalizeDate(12, 'M')).toEqual({ amount: 1, unit: 'y' }); + }); + + it('keeps units slightly over the conversion ratio the same', () => { + expect(normalizeDate(1001, 'ms')).toEqual({ amount: 1001, unit: 'ms' }); + expect(normalizeDate(61, 's')).toEqual({ amount: 61, unit: 's' }); + expect(normalizeDate(61, 'm')).toEqual({ amount: 61, unit: 'm' }); + expect(normalizeDate(25, 'h')).toEqual({ amount: 25, unit: 'h' }); + expect(normalizeDate(8, 'd')).toEqual({ amount: 8, unit: 'd' }); + expect(normalizeDate(5, 'w')).toEqual({ amount: 5, unit: 'w' }); + expect(normalizeDate(13, 'M')).toEqual({ amount: 13, unit: 'M' }); + }); + + it('moves to the next unit for any value higher than twice the conversion ratio', () => { + expect(normalizeDate(2001, 'ms')).toEqual({ amount: 2, unit: 's' }); + expect(normalizeDate(121, 's')).toEqual({ amount: 2, unit: 'm' }); + expect(normalizeDate(121, 'm')).toEqual({ amount: 2, unit: 'h' }); + expect(normalizeDate(49, 'h')).toEqual({ amount: 2, unit: 'd' }); + expect(normalizeDate(15, 'd')).toEqual({ amount: 2, unit: 'w' }); + expect(normalizeDate(9, 'w')).toEqual({ amount: 2, unit: 'M' }); + expect(normalizeDate(25, 'M')).toEqual({ amount: 2, unit: 'y' }); + }); +}); diff --git a/x-pack/plugins/infra/public/utils/datemath.ts b/x-pack/plugins/infra/public/utils/datemath.ts new file mode 100644 index 00000000000000..50a9b6e4f69458 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/datemath.ts @@ -0,0 +1,266 @@ +/* + * 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 dateMath, { Unit } from '@elastic/datemath'; + +export function isValidDatemath(value: string): boolean { + const parsedValue = dateMath.parse(value); + return !!(parsedValue && parsedValue.isValid()); +} + +export function datemathToEpochMillis(value: string, round: 'down' | 'up' = 'down'): number | null { + const parsedValue = dateMath.parse(value, { roundUp: round === 'up' }); + if (!parsedValue || !parsedValue.isValid()) { + return null; + } + return parsedValue.valueOf(); +} + +type DatemathExtension = + | { + value: string; + diffUnit: Unit; + diffAmount: number; + } + | { value: 'now' }; + +const datemathNowExpression = /(\+|\-)(\d+)(ms|s|m|h|d|w|M|y)$/; + +/** + * Extend a datemath value + * @param value The value to extend + * @param {'before' | 'after'} direction Should the value move before or after in time + * @param oppositeEdge For absolute values, the value of the other edge of the range + */ +export function extendDatemath( + value: string, + direction: 'before' | 'after' = 'before', + oppositeEdge?: string +): DatemathExtension | undefined { + if (!isValidDatemath(value)) { + return undefined; + } + + // `now` cannot be extended + if (value === 'now') { + return { value: 'now' }; + } + + // The unit is relative + if (value.startsWith('now')) { + return extendRelativeDatemath(value, direction); + } else if (oppositeEdge && isValidDatemath(oppositeEdge)) { + return extendAbsoluteDatemath(value, direction, oppositeEdge); + } + + return undefined; +} + +function extendRelativeDatemath( + value: string, + direction: 'before' | 'after' +): DatemathExtension | undefined { + const [, operator, amount, unit] = datemathNowExpression.exec(value) || []; + if (!operator || !amount || !unit) { + return undefined; + } + + const mustIncreaseAmount = operator === '-' && direction === 'before'; + const parsedAmount = parseInt(amount, 10); + let newUnit: Unit = unit as Unit; + let newAmount: number; + + // Extend the amount + switch (unit) { + // For small units, always double or halve the amount + case 'ms': + case 's': + newAmount = mustIncreaseAmount ? parsedAmount * 2 : Math.floor(parsedAmount / 2); + break; + // For minutes, increase or decrease in doubles or halves, depending on + // the amount of minutes + case 'm': + let ratio; + const MINUTES_LARGE = 10; + if (mustIncreaseAmount) { + ratio = parsedAmount >= MINUTES_LARGE ? 0.5 : 1; + newAmount = parsedAmount + Math.floor(parsedAmount * ratio); + } else { + newAmount = + parsedAmount >= MINUTES_LARGE + ? Math.floor(parsedAmount / 1.5) + : parsedAmount - Math.floor(parsedAmount * 0.5); + } + break; + + // For hours, increase or decrease half an hour for 1 hour. Otherwise + // increase full hours + case 'h': + if (parsedAmount === 1) { + newAmount = mustIncreaseAmount ? 90 : 30; + newUnit = 'm'; + } else { + newAmount = mustIncreaseAmount ? parsedAmount + 1 : parsedAmount - 1; + } + break; + + // For the rest of units, increase or decrease one smaller unit for + // amounts of 1. Otherwise increase or decrease the unit + case 'd': + case 'w': + case 'M': + case 'y': + if (parsedAmount === 1) { + newUnit = dateMath.unitsDesc[dateMath.unitsDesc.indexOf(unit) + 1]; + newAmount = mustIncreaseAmount + ? convertDate(1, unit, newUnit) + 1 + : convertDate(1, unit, newUnit) - 1; + } else { + newAmount = mustIncreaseAmount ? parsedAmount + 1 : parsedAmount - 1; + } + break; + + default: + throw new TypeError('Unhandled datemath unit'); + } + + // normalize amount and unit (i.e. 120s -> 2m) + const { unit: normalizedUnit, amount: normalizedAmount } = normalizeDate(newAmount, newUnit); + + // How much have we changed the time? + const diffAmount = Math.abs(normalizedAmount - convertDate(parsedAmount, unit, normalizedUnit)); + // if `diffAmount` is not an integer after normalization, express the difference in the original unit + const shouldKeepDiffUnit = diffAmount % 1 !== 0; + + return { + value: `now${operator}${normalizedAmount}${normalizedUnit}`, + diffUnit: shouldKeepDiffUnit ? unit : newUnit, + diffAmount: shouldKeepDiffUnit ? Math.abs(newAmount - parsedAmount) : diffAmount, + }; +} + +function extendAbsoluteDatemath( + value: string, + direction: 'before' | 'after', + oppositeEdge: string +): DatemathExtension { + const valueTimestamp = datemathToEpochMillis(value)!; + const oppositeEdgeTimestamp = datemathToEpochMillis(oppositeEdge)!; + const actualTimestampDiff = Math.abs(valueTimestamp - oppositeEdgeTimestamp); + const normalizedDiff = normalizeDate(actualTimestampDiff, 'ms'); + const normalizedTimestampDiff = convertDate(normalizedDiff.amount, normalizedDiff.unit, 'ms'); + + const newValue = + direction === 'before' + ? valueTimestamp - normalizedTimestampDiff + : valueTimestamp + normalizedTimestampDiff; + + return { + value: new Date(newValue).toISOString(), + diffUnit: normalizedDiff.unit, + diffAmount: normalizedDiff.amount, + }; +} + +const CONVERSION_RATIOS: Record> = { + wy: [ + ['w', 52], // 1 year = 52 weeks + ['y', 1], + ], + w: [ + ['ms', 1000], + ['s', 60], + ['m', 60], + ['h', 24], + ['d', 7], // 1 week = 7 days + ['w', 4], // 1 month = 4 weeks = 28 days + ['M', 12], // 1 year = 12 months = 52 weeks = 364 days + ['y', 1], + ], + M: [ + ['ms', 1000], + ['s', 60], + ['m', 60], + ['h', 24], + ['d', 30], // 1 month = 30 days + ['M', 12], // 1 year = 12 months = 360 days + ['y', 1], + ], + default: [ + ['ms', 1000], + ['s', 60], + ['m', 60], + ['h', 24], + ['d', 365], // 1 year = 365 days + ['y', 1], + ], +}; + +function getRatioScale(from: Unit, to?: Unit) { + if ((from === 'y' && to === 'w') || (from === 'w' && to === 'y')) { + return CONVERSION_RATIOS.wy; + } else if (from === 'w' || to === 'w') { + return CONVERSION_RATIOS.w; + } else if (from === 'M' || to === 'M') { + return CONVERSION_RATIOS.M; + } else { + return CONVERSION_RATIOS.default; + } +} + +export function convertDate(value: number, from: Unit, to: Unit): number { + if (from === to) { + return value; + } + + const ratioScale = getRatioScale(from, to); + const fromIdx = ratioScale.findIndex(ratio => ratio[0] === from); + const toIdx = ratioScale.findIndex(ratio => ratio[0] === to); + + let convertedValue = value; + + if (fromIdx > toIdx) { + // `from` is the bigger unit. Multiply the value + for (let i = toIdx; i < fromIdx; i++) { + convertedValue *= ratioScale[i][1]; + } + } else { + // `from` is the smaller unit. Divide the value + for (let i = fromIdx; i < toIdx; i++) { + convertedValue /= ratioScale[i][1]; + } + } + + return convertedValue; +} + +export function normalizeDate(amount: number, unit: Unit): { amount: number; unit: Unit } { + // There is nothing after years + if (unit === 'y') { + return { amount, unit }; + } + + const nextUnit = dateMath.unitsAsc[dateMath.unitsAsc.indexOf(unit) + 1]; + const ratioScale = getRatioScale(unit, nextUnit); + const ratio = ratioScale.find(r => r[0] === unit)![1]; + + const newAmount = amount / ratio; + + // Exact conversion + if (newAmount === 1) { + return { amount: newAmount, unit: nextUnit }; + } + + // Might be able to go one unit more, so try again, rounding the value + // 7200s => 120m => 2h + // 7249s ~> 120m ~> 2h + if (newAmount >= 2) { + return normalizeDate(Math.round(newAmount), nextUnit); + } + + // Cannot go one one unit above. Return as it is + return { amount, unit }; +} diff --git a/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts b/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts index be6b8c40753ae9..bb528ee5b18c5c 100644 --- a/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts +++ b/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts @@ -8,23 +8,26 @@ import { bisector } from 'd3-array'; import { compareToTimeKey, getIndexAtTimeKey, TimeKey, UniqueTimeKey } from '../../../common/time'; import { InfraLogEntryFields } from '../../graphql/types'; - -export type LogEntry = InfraLogEntryFields.Fragment; - -export type LogEntryColumn = InfraLogEntryFields.Columns; -export type LogEntryMessageColumn = InfraLogEntryFields.InfraLogEntryMessageColumnInlineFragment; -export type LogEntryTimestampColumn = InfraLogEntryFields.InfraLogEntryTimestampColumnInlineFragment; -export type LogEntryFieldColumn = InfraLogEntryFields.InfraLogEntryFieldColumnInlineFragment; +import { + LogEntry, + LogColumn, + LogTimestampColumn, + LogFieldColumn, + LogMessageColumn, + LogMessagePart, + LogMessageFieldPart, + LogMessageConstantPart, +} from '../../../common/http_api'; export type LogEntryMessageSegment = InfraLogEntryFields.Message; export type LogEntryConstantMessageSegment = InfraLogEntryFields.InfraLogMessageConstantSegmentInlineFragment; export type LogEntryFieldMessageSegment = InfraLogEntryFields.InfraLogMessageFieldSegmentInlineFragment; -export const getLogEntryKey = (entry: { key: TimeKey }) => entry.key; +export const getLogEntryKey = (entry: { cursor: TimeKey }) => entry.cursor; -export const getUniqueLogEntryKey = (entry: { gid: string; key: TimeKey }): UniqueTimeKey => ({ - ...entry.key, - gid: entry.gid, +export const getUniqueLogEntryKey = (entry: { id: string; cursor: TimeKey }): UniqueTimeKey => ({ + ...entry.cursor, + gid: entry.id, }); const logEntryTimeBisector = bisector(compareToTimeKey(getLogEntryKey)); @@ -39,19 +42,17 @@ export const getLogEntryAtTime = (entries: LogEntry[], time: TimeKey) => { return entryIndex !== null ? entries[entryIndex] : null; }; -export const isTimestampColumn = (column: LogEntryColumn): column is LogEntryTimestampColumn => +export const isTimestampColumn = (column: LogColumn): column is LogTimestampColumn => column != null && 'timestamp' in column; -export const isMessageColumn = (column: LogEntryColumn): column is LogEntryMessageColumn => +export const isMessageColumn = (column: LogColumn): column is LogMessageColumn => column != null && 'message' in column; -export const isFieldColumn = (column: LogEntryColumn): column is LogEntryFieldColumn => +export const isFieldColumn = (column: LogColumn): column is LogFieldColumn => column != null && 'field' in column; -export const isConstantSegment = ( - segment: LogEntryMessageSegment -): segment is LogEntryConstantMessageSegment => 'constant' in segment; +export const isConstantSegment = (segment: LogMessagePart): segment is LogMessageConstantPart => + 'constant' in segment; -export const isFieldSegment = ( - segment: LogEntryMessageSegment -): segment is LogEntryFieldMessageSegment => 'field' in segment && 'value' in segment; +export const isFieldSegment = (segment: LogMessagePart): segment is LogMessageFieldPart => + 'field' in segment && 'value' in segment; diff --git a/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts b/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts index 3361faa23a124c..abb004911214b2 100644 --- a/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts +++ b/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts @@ -5,8 +5,14 @@ */ import { InfraLogEntryHighlightFields } from '../../graphql/types'; - -export type LogEntryHighlight = InfraLogEntryHighlightFields.Fragment; +import { + LogEntry, + LogColumn, + LogMessageColumn, + LogFieldColumn, + LogMessagePart, + LogMessageFieldPart, +} from '../../../common/http_api'; export type LogEntryHighlightColumn = InfraLogEntryHighlightFields.Columns; export type LogEntryHighlightMessageColumn = InfraLogEntryHighlightFields.InfraLogEntryMessageColumnInlineFragment; @@ -16,18 +22,14 @@ export type LogEntryHighlightMessageSegment = InfraLogEntryHighlightFields.Messa export type LogEntryHighlightFieldMessageSegment = InfraLogEntryHighlightFields.InfraLogMessageFieldSegmentInlineFragment; export interface LogEntryHighlightsMap { - [entryId: string]: LogEntryHighlight[]; + [entryId: string]: LogEntry[]; } -export const isHighlightMessageColumn = ( - column: LogEntryHighlightColumn -): column is LogEntryHighlightMessageColumn => column != null && 'message' in column; +export const isHighlightMessageColumn = (column: LogColumn): column is LogMessageColumn => + column != null && 'message' in column; -export const isHighlightFieldColumn = ( - column: LogEntryHighlightColumn -): column is LogEntryHighlightFieldColumn => column != null && 'field' in column; +export const isHighlightFieldColumn = (column: LogColumn): column is LogFieldColumn => + column != null && 'field' in column; -export const isHighlightFieldSegment = ( - segment: LogEntryHighlightMessageSegment -): segment is LogEntryHighlightFieldMessageSegment => +export const isHighlightFieldSegment = (segment: LogMessagePart): segment is LogMessageFieldPart => segment && 'field' in segment && 'highlights' in segment; diff --git a/x-pack/plugins/infra/server/graphql/index.ts b/x-pack/plugins/infra/server/graphql/index.ts index 82fef41db1a739..f5150972a3a652 100644 --- a/x-pack/plugins/infra/server/graphql/index.ts +++ b/x-pack/plugins/infra/server/graphql/index.ts @@ -6,14 +6,7 @@ import { rootSchema } from '../../common/graphql/root/schema.gql'; import { sharedSchema } from '../../common/graphql/shared/schema.gql'; -import { logEntriesSchema } from './log_entries/schema.gql'; import { sourceStatusSchema } from './source_status/schema.gql'; import { sourcesSchema } from './sources/schema.gql'; -export const schemas = [ - rootSchema, - sharedSchema, - logEntriesSchema, - sourcesSchema, - sourceStatusSchema, -]; +export const schemas = [rootSchema, sharedSchema, sourcesSchema, sourceStatusSchema]; diff --git a/x-pack/plugins/infra/server/graphql/log_entries/index.ts b/x-pack/plugins/infra/server/graphql/log_entries/index.ts deleted file mode 100644 index 21134862663ec2..00000000000000 --- a/x-pack/plugins/infra/server/graphql/log_entries/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export { createLogEntriesResolvers } from './resolvers'; diff --git a/x-pack/plugins/infra/server/graphql/log_entries/resolvers.ts b/x-pack/plugins/infra/server/graphql/log_entries/resolvers.ts deleted file mode 100644 index edbb736b2c4fdc..00000000000000 --- a/x-pack/plugins/infra/server/graphql/log_entries/resolvers.ts +++ /dev/null @@ -1,175 +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 { - InfraLogEntryColumn, - InfraLogEntryFieldColumn, - InfraLogEntryMessageColumn, - InfraLogEntryTimestampColumn, - InfraLogMessageConstantSegment, - InfraLogMessageFieldSegment, - InfraLogMessageSegment, - InfraSourceResolvers, -} from '../../graphql/types'; -import { InfraLogEntriesDomain } from '../../lib/domains/log_entries_domain'; -import { parseFilterQuery } from '../../utils/serialized_query'; -import { ChildResolverOf, InfraResolverOf } from '../../utils/typed_resolvers'; -import { QuerySourceResolver } from '../sources/resolvers'; - -export type InfraSourceLogEntriesAroundResolver = ChildResolverOf< - InfraResolverOf, - QuerySourceResolver ->; - -export type InfraSourceLogEntriesBetweenResolver = ChildResolverOf< - InfraResolverOf, - QuerySourceResolver ->; - -export type InfraSourceLogEntryHighlightsResolver = ChildResolverOf< - InfraResolverOf, - QuerySourceResolver ->; - -export const createLogEntriesResolvers = (libs: { - logEntries: InfraLogEntriesDomain; -}): { - InfraSource: { - logEntriesAround: InfraSourceLogEntriesAroundResolver; - logEntriesBetween: InfraSourceLogEntriesBetweenResolver; - logEntryHighlights: InfraSourceLogEntryHighlightsResolver; - }; - InfraLogEntryColumn: { - __resolveType( - logEntryColumn: InfraLogEntryColumn - ): - | 'InfraLogEntryTimestampColumn' - | 'InfraLogEntryMessageColumn' - | 'InfraLogEntryFieldColumn' - | null; - }; - InfraLogMessageSegment: { - __resolveType( - messageSegment: InfraLogMessageSegment - ): 'InfraLogMessageFieldSegment' | 'InfraLogMessageConstantSegment' | null; - }; -} => ({ - InfraSource: { - async logEntriesAround(source, args, { req }) { - const countBefore = args.countBefore || 0; - const countAfter = args.countAfter || 0; - - const { entriesBefore, entriesAfter } = await libs.logEntries.getLogEntriesAround( - req, - source.id, - args.key, - countBefore + 1, - countAfter + 1, - parseFilterQuery(args.filterQuery) - ); - - const hasMoreBefore = entriesBefore.length > countBefore; - const hasMoreAfter = entriesAfter.length > countAfter; - - const entries = [ - ...(hasMoreBefore ? entriesBefore.slice(1) : entriesBefore), - ...(hasMoreAfter ? entriesAfter.slice(0, -1) : entriesAfter), - ]; - - return { - start: entries.length > 0 ? entries[0].key : null, - end: entries.length > 0 ? entries[entries.length - 1].key : null, - hasMoreBefore, - hasMoreAfter, - filterQuery: args.filterQuery, - entries, - }; - }, - async logEntriesBetween(source, args, { req }) { - const entries = await libs.logEntries.getLogEntriesBetween( - req, - source.id, - args.startKey, - args.endKey, - parseFilterQuery(args.filterQuery) - ); - - return { - start: entries.length > 0 ? entries[0].key : null, - end: entries.length > 0 ? entries[entries.length - 1].key : null, - hasMoreBefore: true, - hasMoreAfter: true, - filterQuery: args.filterQuery, - entries, - }; - }, - async logEntryHighlights(source, args, { req }) { - const highlightedLogEntrySets = await libs.logEntries.getLogEntryHighlights( - req, - source.id, - args.startKey, - args.endKey, - args.highlights.filter(highlightInput => !!highlightInput.query), - parseFilterQuery(args.filterQuery) - ); - - return highlightedLogEntrySets.map(entries => ({ - start: entries.length > 0 ? entries[0].key : null, - end: entries.length > 0 ? entries[entries.length - 1].key : null, - hasMoreBefore: true, - hasMoreAfter: true, - filterQuery: args.filterQuery, - entries, - })); - }, - }, - InfraLogEntryColumn: { - __resolveType(logEntryColumn) { - if (isTimestampColumn(logEntryColumn)) { - return 'InfraLogEntryTimestampColumn'; - } - - if (isMessageColumn(logEntryColumn)) { - return 'InfraLogEntryMessageColumn'; - } - - if (isFieldColumn(logEntryColumn)) { - return 'InfraLogEntryFieldColumn'; - } - - return null; - }, - }, - InfraLogMessageSegment: { - __resolveType(messageSegment) { - if (isConstantSegment(messageSegment)) { - return 'InfraLogMessageConstantSegment'; - } - - if (isFieldSegment(messageSegment)) { - return 'InfraLogMessageFieldSegment'; - } - - return null; - }, - }, -}); - -const isTimestampColumn = (column: InfraLogEntryColumn): column is InfraLogEntryTimestampColumn => - 'timestamp' in column; - -const isMessageColumn = (column: InfraLogEntryColumn): column is InfraLogEntryMessageColumn => - 'message' in column; - -const isFieldColumn = (column: InfraLogEntryColumn): column is InfraLogEntryFieldColumn => - 'field' in column && 'value' in column; - -const isConstantSegment = ( - segment: InfraLogMessageSegment -): segment is InfraLogMessageConstantSegment => 'constant' in segment; - -const isFieldSegment = (segment: InfraLogMessageSegment): segment is InfraLogMessageFieldSegment => - 'field' in segment && 'value' in segment && 'highlights' in segment; diff --git a/x-pack/plugins/infra/server/graphql/log_entries/schema.gql.ts b/x-pack/plugins/infra/server/graphql/log_entries/schema.gql.ts deleted file mode 100644 index 945f2f85435e5d..00000000000000 --- a/x-pack/plugins/infra/server/graphql/log_entries/schema.gql.ts +++ /dev/null @@ -1,136 +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 gql from 'graphql-tag'; - -export const logEntriesSchema = gql` - "A segment of the log entry message that was derived from a field" - type InfraLogMessageFieldSegment { - "The field the segment was derived from" - field: String! - "The segment's message" - value: String! - "A list of highlighted substrings of the value" - highlights: [String!]! - } - - "A segment of the log entry message that was derived from a string literal" - type InfraLogMessageConstantSegment { - "The segment's message" - constant: String! - } - - "A segment of the log entry message" - union InfraLogMessageSegment = InfraLogMessageFieldSegment | InfraLogMessageConstantSegment - - "A special built-in column that contains the log entry's timestamp" - type InfraLogEntryTimestampColumn { - "The id of the corresponding column configuration" - columnId: ID! - "The timestamp" - timestamp: Float! - } - - "A special built-in column that contains the log entry's constructed message" - type InfraLogEntryMessageColumn { - "The id of the corresponding column configuration" - columnId: ID! - "A list of the formatted log entry segments" - message: [InfraLogMessageSegment!]! - } - - "A column that contains the value of a field of the log entry" - type InfraLogEntryFieldColumn { - "The id of the corresponding column configuration" - columnId: ID! - "The field name of the column" - field: String! - "The value of the field in the log entry" - value: String! - "A list of highlighted substrings of the value" - highlights: [String!]! - } - - "A column of a log entry" - union InfraLogEntryColumn = - InfraLogEntryTimestampColumn - | InfraLogEntryMessageColumn - | InfraLogEntryFieldColumn - - "A log entry" - type InfraLogEntry { - "A unique representation of the log entry's position in the event stream" - key: InfraTimeKey! - "The log entry's id" - gid: String! - "The source id" - source: String! - "The columns used for rendering the log entry" - columns: [InfraLogEntryColumn!]! - } - - "A highlighting definition" - input InfraLogEntryHighlightInput { - "The query to highlight by" - query: String! - "The number of highlighted documents to include beyond the beginning of the interval" - countBefore: Int! - "The number of highlighted documents to include beyond the end of the interval" - countAfter: Int! - } - - "A consecutive sequence of log entries" - type InfraLogEntryInterval { - "The key corresponding to the start of the interval covered by the entries" - start: InfraTimeKey - "The key corresponding to the end of the interval covered by the entries" - end: InfraTimeKey - "Whether there are more log entries available before the start" - hasMoreBefore: Boolean! - "Whether there are more log entries available after the end" - hasMoreAfter: Boolean! - "The query the log entries were filtered by" - filterQuery: String - "The query the log entries were highlighted with" - highlightQuery: String - "A list of the log entries" - entries: [InfraLogEntry!]! - } - - extend type InfraSource { - "A consecutive span of log entries surrounding a point in time" - logEntriesAround( - "The sort key that corresponds to the point in time" - key: InfraTimeKeyInput! - "The maximum number of preceding to return" - countBefore: Int = 0 - "The maximum number of following to return" - countAfter: Int = 0 - "The query to filter the log entries by" - filterQuery: String - ): InfraLogEntryInterval! - "A consecutive span of log entries within an interval" - logEntriesBetween( - "The sort key that corresponds to the start of the interval" - startKey: InfraTimeKeyInput! - "The sort key that corresponds to the end of the interval" - endKey: InfraTimeKeyInput! - "The query to filter the log entries by" - filterQuery: String - ): InfraLogEntryInterval! - "Sequences of log entries matching sets of highlighting queries within an interval" - logEntryHighlights( - "The sort key that corresponds to the start of the interval" - startKey: InfraTimeKeyInput! - "The sort key that corresponds to the end of the interval" - endKey: InfraTimeKeyInput! - "The query to filter the log entries by" - filterQuery: String - "The highlighting to apply to the log entries" - highlights: [InfraLogEntryHighlightInput!]! - ): [InfraLogEntryInterval!]! - } -`; diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index f058b9e52c75b8..fb9dd172bf6ede 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -7,7 +7,6 @@ import { IResolvers, makeExecutableSchema } from 'graphql-tools'; import { initIpToHostName } from './routes/ip_to_hostname'; import { schemas } from './graphql'; -import { createLogEntriesResolvers } from './graphql/log_entries'; import { createSourceStatusResolvers } from './graphql/source_status'; import { createSourcesResolvers } from './graphql/sources'; import { InfraBackendLibs } from './lib/infra_types'; @@ -34,7 +33,6 @@ import { initInventoryMetaRoute } from './routes/inventory_metadata'; export const initInfraServer = (libs: InfraBackendLibs) => { const schema = makeExecutableSchema({ resolvers: [ - createLogEntriesResolvers(libs) as IResolvers, createSourcesResolvers(libs) as IResolvers, createSourceStatusResolvers(libs) as IResolvers, ], diff --git a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts index f48c949329b040..3a5dff75f004e3 100644 --- a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts @@ -8,12 +8,11 @@ import { timeMilliseconds } from 'd3-time'; import * as runtimeTypes from 'io-ts'; -import { compact, first, get, has, zip } from 'lodash'; +import { compact, first, get, has } from 'lodash'; import { pipe } from 'fp-ts/lib/pipeable'; import { map, fold } from 'fp-ts/lib/Either'; import { identity, constant } from 'fp-ts/lib/function'; import { RequestHandlerContext } from 'src/core/server'; -import { compareTimeKeys, isTimeKey, TimeKey } from '../../../../common/time'; import { JsonObject, JsonValue } from '../../../../common/typed_json'; import { LogEntriesAdapter, @@ -27,8 +26,6 @@ import { InfraSourceConfiguration } from '../../sources'; import { SortedSearchHit } from '../framework'; import { KibanaFramework } from '../framework/kibana_framework_adapter'; -const DAY_MILLIS = 24 * 60 * 60 * 1000; -const LOOKUP_OFFSETS = [0, 1, 7, 30, 365, 10000, Infinity].map(days => days * DAY_MILLIS); const TIMESTAMP_FORMAT = 'epoch_millis'; interface LogItemHit { @@ -41,53 +38,13 @@ interface LogItemHit { export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { constructor(private readonly framework: KibanaFramework) {} - public async getAdjacentLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - direction: 'asc' | 'desc', - maxCount: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - if (maxCount <= 0) { - return []; - } - - const intervals = getLookupIntervals(start.time, direction); - - let documents: LogEntryDocument[] = []; - for (const [intervalStart, intervalEnd] of intervals) { - if (documents.length >= maxCount) { - break; - } - - const documentsInInterval = await this.getLogEntryDocumentsBetween( - requestContext, - sourceConfiguration, - fields, - intervalStart, - intervalEnd, - documents.length > 0 ? documents[documents.length - 1].key : start, - maxCount - documents.length, - filterQuery, - highlightQuery - ); - - documents = [...documents, ...documentsInInterval]; - } - - return direction === 'asc' ? documents : documents.reverse(); - } - public async getLogEntries( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, fields: string[], params: LogEntriesParams ): Promise { - const { startDate, endDate, query, cursor, size, highlightTerm } = params; + const { startTimestamp, endTimestamp, query, cursor, size, highlightTerm } = params; const { sortDirection, searchAfterClause } = processCursor(cursor); @@ -133,8 +90,8 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { { range: { [sourceConfiguration.fields.timestamp]: { - gte: startDate, - lte: endDate, + gte: startTimestamp, + lte: endTimestamp, format: TIMESTAMP_FORMAT, }, }, @@ -158,40 +115,19 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { return mapHitsToLogEntryDocuments(hits, sourceConfiguration.fields.timestamp, fields); } - /** @deprecated */ - public async getContainedLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - end: TimeKey, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - const documents = await this.getLogEntryDocumentsBetween( - requestContext, - sourceConfiguration, - fields, - start.time, - end.time, - start, - 10000, - filterQuery, - highlightQuery - ); - - return documents.filter(document => compareTimeKeys(document.key, end) < 0); - } - public async getContainedLogSummaryBuckets( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, - start: number, - end: number, + startTimestamp: number, + endTimestamp: number, bucketSize: number, filterQuery?: LogEntryQuery ): Promise { - const bucketIntervalStarts = timeMilliseconds(new Date(start), new Date(end), bucketSize); + const bucketIntervalStarts = timeMilliseconds( + new Date(startTimestamp), + new Date(endTimestamp), + bucketSize + ); const query = { allowNoIndices: true, @@ -229,8 +165,8 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { { range: { [sourceConfiguration.fields.timestamp]: { - gte: start, - lte: end, + gte: startTimestamp, + lte: endTimestamp, format: TIMESTAMP_FORMAT, }, }, @@ -288,112 +224,6 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { } return document; } - - private async getLogEntryDocumentsBetween( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: number, - end: number, - after: TimeKey | null, - maxCount: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - if (maxCount <= 0) { - return []; - } - - const sortDirection: 'asc' | 'desc' = start <= end ? 'asc' : 'desc'; - - const startRange = { - [sortDirection === 'asc' ? 'gte' : 'lte']: start, - }; - const endRange = - end === Infinity - ? {} - : { - [sortDirection === 'asc' ? 'lte' : 'gte']: end, - }; - - const highlightClause = highlightQuery - ? { - highlight: { - boundary_scanner: 'word', - fields: fields.reduce( - (highlightFieldConfigs, fieldName) => ({ - ...highlightFieldConfigs, - [fieldName]: {}, - }), - {} - ), - fragment_size: 1, - number_of_fragments: 100, - post_tags: [''], - pre_tags: [''], - highlight_query: highlightQuery, - }, - } - : {}; - - const searchAfterClause = isTimeKey(after) - ? { - search_after: [after.time, after.tiebreaker], - } - : {}; - - const query = { - allowNoIndices: true, - index: sourceConfiguration.logAlias, - ignoreUnavailable: true, - body: { - query: { - bool: { - filter: [ - ...createQueryFilterClauses(filterQuery), - { - range: { - [sourceConfiguration.fields.timestamp]: { - ...startRange, - ...endRange, - format: TIMESTAMP_FORMAT, - }, - }, - }, - ], - }, - }, - ...highlightClause, - ...searchAfterClause, - _source: fields, - size: maxCount, - sort: [ - { [sourceConfiguration.fields.timestamp]: sortDirection }, - { [sourceConfiguration.fields.tiebreaker]: sortDirection }, - ], - track_total_hits: false, - }, - }; - - const response = await this.framework.callWithRequest( - requestContext, - 'search', - query - ); - const hits = response.hits.hits; - const documents = hits.map(convertHitToLogEntryDocument(fields)); - - return documents; - } -} - -function getLookupIntervals(start: number, direction: 'asc' | 'desc'): Array<[number, number]> { - const offsetSign = direction === 'asc' ? 1 : -1; - const translatedOffsets = LOOKUP_OFFSETS.map(offset => start + offset * offsetSign); - const intervals = zip(translatedOffsets.slice(0, -1), translatedOffsets.slice(1)) as Array< - [number, number] - >; - return intervals; } function mapHitsToLogEntryDocuments( @@ -423,28 +253,6 @@ function mapHitsToLogEntryDocuments( }); } -/** @deprecated */ -const convertHitToLogEntryDocument = (fields: string[]) => ( - hit: SortedSearchHit -): LogEntryDocument => ({ - gid: hit._id, - fields: fields.reduce( - (flattenedFields, fieldName) => - has(hit._source, fieldName) - ? { - ...flattenedFields, - [fieldName]: get(hit._source, fieldName), - } - : flattenedFields, - {} as { [fieldName: string]: string | number | object | boolean | null } - ), - highlights: hit.highlight || {}, - key: { - time: hit.sort[0], - tiebreaker: hit.sort[1], - }, -}); - const convertDateRangeBucketToSummaryBucket = ( bucket: LogSummaryDateRangeBucket ): LogSummaryBucket => ({ diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index 2f71d56e1e0e31..9a2631e3c2f768 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import stringify from 'json-stable-stringify'; import { sortBy } from 'lodash'; import { RequestHandlerContext } from 'src/core/server'; @@ -18,13 +17,10 @@ import { LogEntriesCursor, LogColumn, } from '../../../../common/http_api'; -import { InfraLogEntry, InfraLogMessageSegment } from '../../../graphql/types'; import { InfraSourceConfiguration, InfraSources, SavedSourceConfigurationFieldColumnRuntimeType, - SavedSourceConfigurationMessageColumnRuntimeType, - SavedSourceConfigurationTimestampColumnRuntimeType, } from '../../sources'; import { getBuiltinRules } from './builtin_rules'; import { convertDocumentSourceToLogItemFields } from './convert_document_source_to_log_item_fields'; @@ -36,16 +32,16 @@ import { } from './message'; export interface LogEntriesParams { - startDate: number; - endDate: number; + startTimestamp: number; + endTimestamp: number; size?: number; query?: JsonObject; cursor?: { before: LogEntriesCursor | 'last' } | { after: LogEntriesCursor | 'first' }; highlightTerm?: string; } export interface LogEntriesAroundParams { - startDate: number; - endDate: number; + startTimestamp: number; + endTimestamp: number; size?: number; center: LogEntriesCursor; query?: JsonObject; @@ -67,7 +63,7 @@ export class InfraLogEntriesDomain { sourceId: string, params: LogEntriesAroundParams ) { - const { startDate, endDate, center, query, size, highlightTerm } = params; + const { startTimestamp, endTimestamp, center, query, size, highlightTerm } = params; /* * For odd sizes we will round this value down for the first half, and up @@ -80,8 +76,8 @@ export class InfraLogEntriesDomain { const halfSize = (size || LOG_ENTRIES_PAGE_SIZE) / 2; const entriesBefore = await this.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query, cursor: { before: center }, size: Math.floor(halfSize), @@ -101,8 +97,8 @@ export class InfraLogEntriesDomain { : { time: center.time - 1, tiebreaker: 0 }; const entriesAfter = await this.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query, cursor: { after: cursorAfter }, size: Math.ceil(halfSize), @@ -112,71 +108,6 @@ export class InfraLogEntriesDomain { return [...entriesBefore, ...entriesAfter]; } - /** @deprecated */ - public async getLogEntriesAround( - requestContext: RequestHandlerContext, - sourceId: string, - key: TimeKey, - maxCountBefore: number, - maxCountAfter: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise<{ entriesBefore: InfraLogEntry[]; entriesAfter: InfraLogEntry[] }> { - if (maxCountBefore <= 0 && maxCountAfter <= 0) { - return { - entriesBefore: [], - entriesAfter: [], - }; - } - - const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, - sourceId - ); - const messageFormattingRules = compileFormattingRules( - getBuiltinRules(configuration.fields.message) - ); - const requiredFields = getRequiredFields(configuration, messageFormattingRules); - - const documentsBefore = await this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - key, - 'desc', - Math.max(maxCountBefore, 1), - filterQuery, - highlightQuery - ); - const lastKeyBefore = - documentsBefore.length > 0 - ? documentsBefore[documentsBefore.length - 1].key - : { - time: key.time - 1, - tiebreaker: 0, - }; - - const documentsAfter = await this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - lastKeyBefore, - 'asc', - maxCountAfter, - filterQuery, - highlightQuery - ); - - return { - entriesBefore: (maxCountBefore > 0 ? documentsBefore : []).map( - convertLogDocumentToEntry(sourceId, configuration.logColumns, messageFormattingRules.format) - ), - entriesAfter: documentsAfter.map( - convertLogDocumentToEntry(sourceId, configuration.logColumns, messageFormattingRules.format) - ), - }; - } - public async getLogEntries( requestContext: RequestHandlerContext, sourceId: string, @@ -220,7 +151,7 @@ export class InfraLogEntriesDomain { return { columnId: column.fieldColumn.id, field: column.fieldColumn.field, - value: stringify(doc.fields[column.fieldColumn.field]), + value: doc.fields[column.fieldColumn.field], highlights: doc.highlights[column.fieldColumn.field] || [], }; } @@ -232,116 +163,6 @@ export class InfraLogEntriesDomain { return entries; } - /** @deprecated */ - public async getLogEntriesBetween( - requestContext: RequestHandlerContext, - sourceId: string, - startKey: TimeKey, - endKey: TimeKey, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, - sourceId - ); - const messageFormattingRules = compileFormattingRules( - getBuiltinRules(configuration.fields.message) - ); - const requiredFields = getRequiredFields(configuration, messageFormattingRules); - const documents = await this.adapter.getContainedLogEntryDocuments( - requestContext, - configuration, - requiredFields, - startKey, - endKey, - filterQuery, - highlightQuery - ); - const entries = documents.map( - convertLogDocumentToEntry(sourceId, configuration.logColumns, messageFormattingRules.format) - ); - return entries; - } - - /** @deprecated */ - public async getLogEntryHighlights( - requestContext: RequestHandlerContext, - sourceId: string, - startKey: TimeKey, - endKey: TimeKey, - highlights: Array<{ - query: string; - countBefore: number; - countAfter: number; - }>, - filterQuery?: LogEntryQuery - ): Promise { - const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, - sourceId - ); - const messageFormattingRules = compileFormattingRules( - getBuiltinRules(configuration.fields.message) - ); - const requiredFields = getRequiredFields(configuration, messageFormattingRules); - - const documentSets = await Promise.all( - highlights.map(async highlight => { - const highlightQuery = createHighlightQueryDsl(highlight.query, requiredFields); - const query = filterQuery - ? { - bool: { - filter: [filterQuery, highlightQuery], - }, - } - : highlightQuery; - const [documentsBefore, documents, documentsAfter] = await Promise.all([ - this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - startKey, - 'desc', - highlight.countBefore, - query, - highlightQuery - ), - this.adapter.getContainedLogEntryDocuments( - requestContext, - configuration, - requiredFields, - startKey, - endKey, - query, - highlightQuery - ), - this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - endKey, - 'asc', - highlight.countAfter, - query, - highlightQuery - ), - ]); - const entries = [...documentsBefore, ...documents, ...documentsAfter].map( - convertLogDocumentToEntry( - sourceId, - configuration.logColumns, - messageFormattingRules.format - ) - ); - - return entries; - }) - ); - - return documentSets; - } - public async getLogSummaryBucketsBetween( requestContext: RequestHandlerContext, sourceId: string, @@ -368,8 +189,8 @@ export class InfraLogEntriesDomain { public async getLogSummaryHighlightBucketsBetween( requestContext: RequestHandlerContext, sourceId: string, - start: number, - end: number, + startTimestamp: number, + endTimestamp: number, bucketSize: number, highlightQueries: string[], filterQuery?: LogEntryQuery @@ -396,8 +217,8 @@ export class InfraLogEntriesDomain { const summaryBuckets = await this.adapter.getContainedLogSummaryBuckets( requestContext, configuration, - start, - end, + startTimestamp, + endTimestamp, bucketSize, query ); @@ -445,17 +266,6 @@ interface LogItemHit { } export interface LogEntriesAdapter { - getAdjacentLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - direction: 'asc' | 'desc', - maxCount: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise; - getLogEntries( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, @@ -463,21 +273,11 @@ export interface LogEntriesAdapter { params: LogEntriesParams ): Promise; - getContainedLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - end: TimeKey, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise; - getContainedLogSummaryBuckets( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, - start: number, - end: number, + startTimestamp: number, + endTimestamp: number, bucketSize: number, filterQuery?: LogEntryQuery ): Promise; @@ -505,37 +305,6 @@ export interface LogSummaryBucket { topEntryKeys: TimeKey[]; } -/** @deprecated */ -const convertLogDocumentToEntry = ( - sourceId: string, - logColumns: InfraSourceConfiguration['logColumns'], - formatLogMessage: (fields: Fields, highlights: Highlights) => InfraLogMessageSegment[] -) => (document: LogEntryDocument): InfraLogEntry => ({ - key: document.key, - gid: document.gid, - source: sourceId, - columns: logColumns.map(logColumn => { - if (SavedSourceConfigurationTimestampColumnRuntimeType.is(logColumn)) { - return { - columnId: logColumn.timestampColumn.id, - timestamp: document.key.time, - }; - } else if (SavedSourceConfigurationMessageColumnRuntimeType.is(logColumn)) { - return { - columnId: logColumn.messageColumn.id, - message: formatLogMessage(document.fields, document.highlights), - }; - } else { - return { - columnId: logColumn.fieldColumn.id, - field: logColumn.fieldColumn.field, - highlights: document.highlights[logColumn.fieldColumn.field] || [], - value: stringify(document.fields[logColumn.fieldColumn.field] || null), - }; - } - }), -}); - const logSummaryBucketHasEntries = (bucket: LogSummaryBucket) => bucket.entriesCount > 0 && bucket.topEntryKeys.length > 0; diff --git a/x-pack/plugins/infra/server/routes/log_entries/entries.ts b/x-pack/plugins/infra/server/routes/log_entries/entries.ts index 93802468dd2672..f33dfa71fedcd7 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/entries.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/entries.ts @@ -38,13 +38,19 @@ export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) fold(throwErrors(Boom.badRequest), identity) ); - const { startDate, endDate, sourceId, query, size } = payload; + const { + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + sourceId, + query, + size, + } = payload; let entries; if ('center' in payload) { entries = await logEntries.getLogEntriesAround__new(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), center: payload.center, size, @@ -58,20 +64,22 @@ export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) } entries = await logEntries.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), cursor, size, }); } + const hasEntries = entries.length > 0; + return response.ok({ body: logEntriesResponseRT.encode({ data: { entries, - topCursor: entries[0].cursor, - bottomCursor: entries[entries.length - 1].cursor, + topCursor: hasEntries ? entries[0].cursor : null, + bottomCursor: hasEntries ? entries[entries.length - 1].cursor : null, }, }), }); diff --git a/x-pack/plugins/infra/server/routes/log_entries/highlights.ts b/x-pack/plugins/infra/server/routes/log_entries/highlights.ts index 8ee412d5acdd5f..2e581d96cab9c0 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/highlights.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/highlights.ts @@ -38,7 +38,7 @@ export const initLogEntriesHighlightsRoute = ({ framework, logEntries }: InfraBa fold(throwErrors(Boom.badRequest), identity) ); - const { startDate, endDate, sourceId, query, size, highlightTerms } = payload; + const { startTimestamp, endTimestamp, sourceId, query, size, highlightTerms } = payload; let entriesPerHighlightTerm; @@ -46,8 +46,8 @@ export const initLogEntriesHighlightsRoute = ({ framework, logEntries }: InfraBa entriesPerHighlightTerm = await Promise.all( highlightTerms.map(highlightTerm => logEntries.getLogEntriesAround__new(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), center: payload.center, size, @@ -66,8 +66,8 @@ export const initLogEntriesHighlightsRoute = ({ framework, logEntries }: InfraBa entriesPerHighlightTerm = await Promise.all( highlightTerms.map(highlightTerm => logEntries.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), cursor, size, diff --git a/x-pack/plugins/infra/server/routes/log_entries/summary.ts b/x-pack/plugins/infra/server/routes/log_entries/summary.ts index 3f5bc8e364a585..aa4421374ec124 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/summary.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/summary.ts @@ -36,13 +36,13 @@ export const initLogEntriesSummaryRoute = ({ framework, logEntries }: InfraBacke logEntriesSummaryRequestRT.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const { sourceId, startDate, endDate, bucketSize, query } = payload; + const { sourceId, startTimestamp, endTimestamp, bucketSize, query } = payload; const buckets = await logEntries.getLogSummaryBucketsBetween( requestContext, sourceId, - startDate, - endDate, + startTimestamp, + endTimestamp, bucketSize, parseFilterQuery(query) ); @@ -50,8 +50,8 @@ export const initLogEntriesSummaryRoute = ({ framework, logEntries }: InfraBacke return response.ok({ body: logEntriesSummaryResponseRT.encode({ data: { - start: startDate, - end: endDate, + start: startTimestamp, + end: endTimestamp, buckets, }, }), diff --git a/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts b/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts index 6c6f7a5a3dcd31..d92cddcdc415d5 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts @@ -39,13 +39,20 @@ export const initLogEntriesSummaryHighlightsRoute = ({ logEntriesSummaryHighlightsRequestRT.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const { sourceId, startDate, endDate, bucketSize, query, highlightTerms } = payload; + const { + sourceId, + startTimestamp, + endTimestamp, + bucketSize, + query, + highlightTerms, + } = payload; const bucketsPerHighlightTerm = await logEntries.getLogSummaryHighlightBucketsBetween( requestContext, sourceId, - startDate, - endDate, + startTimestamp, + endTimestamp, bucketSize, highlightTerms, parseFilterQuery(query) @@ -54,8 +61,8 @@ export const initLogEntriesSummaryHighlightsRoute = ({ return response.ok({ body: logEntriesSummaryHighlightsResponseRT.encode({ data: bucketsPerHighlightTerm.map(buckets => ({ - start: startDate, - end: endDate, + start: startTimestamp, + end: endTimestamp, buckets, })), }), diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1206fda3f75d02..5db34166b8fddd 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6409,7 +6409,6 @@ "xpack.infra.logs.analysisPage.unavailable.mlAppLink": "機械学習アプリ", "xpack.infra.logs.customizeLogs.customizeButtonLabel": "カスタマイズ", "xpack.infra.logs.customizeLogs.lineWrappingFormRowLabel": "改行", - "xpack.infra.logs.customizeLogs.minimapScaleFormRowLabel": "ミニマップスケール", "xpack.infra.logs.customizeLogs.textSizeFormRowLabel": "テキストサイズ", "xpack.infra.logs.customizeLogs.textSizeRadioGroup": "{textScale, select, small {小さい} 中くらい {Medium} 大きい {Large} その他の {{textScale}} }", "xpack.infra.logs.customizeLogs.wrapLongLinesSwitchLabel": "長い行を改行", @@ -6424,19 +6423,12 @@ "xpack.infra.logs.index.settingsTabTitle": "設定", "xpack.infra.logs.index.streamTabTitle": "ストリーム", "xpack.infra.logs.jumpToTailText": "最も新しいエントリーに移動", - "xpack.infra.logs.lastStreamingUpdateText": " 最終更新 {lastUpdateTime}", - "xpack.infra.logs.loadAgainButtonLabel": "再読み込み", - "xpack.infra.logs.loadingAdditionalEntriesText": "追加エントリーを読み込み中", - "xpack.infra.logs.noAdditionalEntriesFoundText": "追加エントリーが見つかりません", "xpack.infra.logs.scrollableLogTextStreamView.loadingEntriesLabel": "エントリーを読み込み中", "xpack.infra.logs.search.nextButtonLabel": "次へ", "xpack.infra.logs.search.previousButtonLabel": "前へ", "xpack.infra.logs.search.searchInLogsAriaLabel": "検索", "xpack.infra.logs.search.searchInLogsPlaceholder": "検索", "xpack.infra.logs.searchResultTooltip": "{bucketCount, plural, one {# 件のハイライトされたエントリー} other {# 件のハイライトされたエントリー}}", - "xpack.infra.logs.startStreamingButtonLabel": "ライブストリーム", - "xpack.infra.logs.stopStreamingButtonLabel": "ストリーム停止", - "xpack.infra.logs.streamingDescription": "新しいエントリーをストリーム中...", "xpack.infra.logs.streamingNewEntriesText": "新しいエントリーをストリーム中", "xpack.infra.logs.streamPage.documentTitle": "{previousTitle} | ストリーム", "xpack.infra.logsPage.noLoggingIndicesDescription": "追加しましょう!", @@ -6444,12 +6436,6 @@ "xpack.infra.logsPage.noLoggingIndicesTitle": "ログインデックスがないようです。", "xpack.infra.logsPage.toolbar.kqlSearchFieldAriaLabel": "ログエントリーを検索", "xpack.infra.logsPage.toolbar.kqlSearchFieldPlaceholder": "ログエントリーを検索中… (例: host.name:host-1)", - "xpack.infra.mapLogs.oneDayLabel": "1 日", - "xpack.infra.mapLogs.oneHourLabel": "1 時間", - "xpack.infra.mapLogs.oneMinuteLabel": "1 分", - "xpack.infra.mapLogs.oneMonthLabel": "1 か月", - "xpack.infra.mapLogs.oneWeekLabel": "1 週間", - "xpack.infra.mapLogs.oneYearLabel": "1 年", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "パーセント", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.sectionLabel": "CPU 使用状況", "xpack.infra.metricDetailPage.awsMetricsLayout.diskioBytesSection.readsSeriesLabel": "読み取り", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e13c80e0044d03..917cf0fe5f0dba 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6409,7 +6409,6 @@ "xpack.infra.logs.analysisPage.unavailable.mlAppLink": "Machine Learning 应用", "xpack.infra.logs.customizeLogs.customizeButtonLabel": "定制", "xpack.infra.logs.customizeLogs.lineWrappingFormRowLabel": "换行", - "xpack.infra.logs.customizeLogs.minimapScaleFormRowLabel": "迷你地图比例", "xpack.infra.logs.customizeLogs.textSizeFormRowLabel": "文本大小", "xpack.infra.logs.customizeLogs.textSizeRadioGroup": "{textScale, select, small {小} medium {Medium} large {Large} other {{textScale}} }", "xpack.infra.logs.customizeLogs.wrapLongLinesSwitchLabel": "长行换行", @@ -6424,19 +6423,12 @@ "xpack.infra.logs.index.settingsTabTitle": "设置", "xpack.infra.logs.index.streamTabTitle": "流式传输", "xpack.infra.logs.jumpToTailText": "跳到最近的条目", - "xpack.infra.logs.lastStreamingUpdateText": " 最后更新时间:{lastUpdateTime}", - "xpack.infra.logs.loadAgainButtonLabel": "重新加载", - "xpack.infra.logs.loadingAdditionalEntriesText": "正在加载其他条目", - "xpack.infra.logs.noAdditionalEntriesFoundText": "找不到其他条目", "xpack.infra.logs.scrollableLogTextStreamView.loadingEntriesLabel": "正在加载条目", "xpack.infra.logs.search.nextButtonLabel": "下一个", "xpack.infra.logs.search.previousButtonLabel": "上一页", "xpack.infra.logs.search.searchInLogsAriaLabel": "搜索", "xpack.infra.logs.search.searchInLogsPlaceholder": "搜索", "xpack.infra.logs.searchResultTooltip": "{bucketCount, plural, one {# 个高亮条目} other {# 个高亮条目}}", - "xpack.infra.logs.startStreamingButtonLabel": "实时流式传输", - "xpack.infra.logs.stopStreamingButtonLabel": "停止流式传输", - "xpack.infra.logs.streamingDescription": "正在流式传输新条目……", "xpack.infra.logs.streamingNewEntriesText": "正在流式传输新条目", "xpack.infra.logs.streamPage.documentTitle": "{previousTitle} | 流式传输", "xpack.infra.logsPage.noLoggingIndicesDescription": "让我们添加一些!", @@ -6444,12 +6436,6 @@ "xpack.infra.logsPage.noLoggingIndicesTitle": "似乎您没有任何日志索引。", "xpack.infra.logsPage.toolbar.kqlSearchFieldAriaLabel": "搜索日志条目", "xpack.infra.logsPage.toolbar.kqlSearchFieldPlaceholder": "搜索日志条目……(例如 host.name:host-1)", - "xpack.infra.mapLogs.oneDayLabel": "1 日", - "xpack.infra.mapLogs.oneHourLabel": "1 小时", - "xpack.infra.mapLogs.oneMinuteLabel": "1 分钟", - "xpack.infra.mapLogs.oneMonthLabel": "1 个月", - "xpack.infra.mapLogs.oneWeekLabel": "1 周", - "xpack.infra.mapLogs.oneYearLabel": "1 年", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "百分比", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.sectionLabel": "CPU 使用率", "xpack.infra.metricDetailPage.awsMetricsLayout.diskioBytesSection.readsSeriesLabel": "读取数", diff --git a/x-pack/test/api_integration/apis/infra/index.js b/x-pack/test/api_integration/apis/infra/index.js index fad387130e044c..f5bdf280c46d21 100644 --- a/x-pack/test/api_integration/apis/infra/index.js +++ b/x-pack/test/api_integration/apis/infra/index.js @@ -10,8 +10,8 @@ export default function({ loadTestFile }) { loadTestFile(require.resolve('./log_analysis')); loadTestFile(require.resolve('./log_entries')); loadTestFile(require.resolve('./log_entry_highlights')); - loadTestFile(require.resolve('./log_summary')); loadTestFile(require.resolve('./logs_without_millis')); + loadTestFile(require.resolve('./log_summary')); loadTestFile(require.resolve('./metrics')); loadTestFile(require.resolve('./sources')); loadTestFile(require.resolve('./waffle')); diff --git a/x-pack/test/api_integration/apis/infra/log_entries.ts b/x-pack/test/api_integration/apis/infra/log_entries.ts index 75e7750058a878..4f447d518a751d 100644 --- a/x-pack/test/api_integration/apis/infra/log_entries.ts +++ b/x-pack/test/api_integration/apis/infra/log_entries.ts @@ -5,8 +5,6 @@ */ import expect from '@kbn/expect'; -import { ascending, pairs } from 'd3-array'; -import gql from 'graphql-tag'; import { v4 as uuidv4 } from 'uuid'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -19,10 +17,11 @@ import { LOG_ENTRIES_PATH, logEntriesRequestRT, logEntriesResponseRT, + LogTimestampColumn, + LogFieldColumn, + LogMessageColumn, } from '../../../../plugins/infra/common/http_api'; -import { sharedFragments } from '../../../../plugins/infra/common/graphql/shared'; -import { InfraTimeKey } from '../../../../plugins/infra/public/graphql/types'; import { FtrProviderContext } from '../../ftr_provider_context'; const KEY_WITHIN_DATA_RANGE = { @@ -38,75 +37,12 @@ const LATEST_KEY_WITH_DATA = { tiebreaker: 5603910, }; -const logEntriesAroundQuery = gql` - query LogEntriesAroundQuery( - $timeKey: InfraTimeKeyInput! - $countBefore: Int = 0 - $countAfter: Int = 0 - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesAround( - key: $timeKey - countBefore: $countBefore - countAfter: $countAfter - filterQuery: $filterQuery - ) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - -const logEntriesBetweenQuery = gql` - query LogEntriesBetweenQuery( - $startKey: InfraTimeKeyInput! - $endKey: InfraTimeKeyInput! - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesBetween(startKey: $startKey, endKey: $endKey, filterQuery: $filterQuery) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - const COMMON_HEADERS = { 'kbn-xsrf': 'some-xsrf-token', }; export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const client = getService('infraOpsGraphQLClient'); const supertest = getService('supertest'); const sourceConfigurationService = getService('infraOpsSourceConfiguration'); @@ -126,8 +62,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, }) ) .expect(200); @@ -154,6 +90,42 @@ export default function({ getService }: FtrProviderContext) { expect(lastEntry.cursor.time <= KEY_WITHIN_DATA_RANGE.time).to.be(true); }); + it('Returns the default columns', async () => { + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, + center: KEY_WITHIN_DATA_RANGE, + }) + ) + .expect(200); + + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + + const entries = logEntriesResponse.data.entries; + const entry = entries[0]; + expect(entry.columns).to.have.length(3); + + const timestampColumn = entry.columns[0] as LogTimestampColumn; + expect(timestampColumn).to.have.property('timestamp'); + + const eventDatasetColumn = entry.columns[1] as LogFieldColumn; + expect(eventDatasetColumn).to.have.property('field'); + expect(eventDatasetColumn.field).to.be('event.dataset'); + expect(eventDatasetColumn).to.have.property('value'); + + const messageColumn = entry.columns[2] as LogMessageColumn; + expect(messageColumn).to.have.property('message'); + expect(messageColumn.message.length).to.be.greaterThan(0); + }); + it('Paginates correctly with `after`', async () => { const { body: firstPageBody } = await supertest .post(LOG_ENTRIES_PATH) @@ -161,8 +133,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, size: 10, }) ); @@ -177,9 +149,9 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, - after: firstPage.data.bottomCursor, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, + after: firstPage.data.bottomCursor!, size: 10, }) ); @@ -194,8 +166,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, size: 20, }) ); @@ -220,8 +192,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: KEY_WITHIN_DATA_RANGE.time, - endDate: LATEST_KEY_WITH_DATA.time, + startTimestamp: KEY_WITHIN_DATA_RANGE.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, before: 'last', size: 10, }) @@ -237,9 +209,9 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: KEY_WITHIN_DATA_RANGE.time, - endDate: LATEST_KEY_WITH_DATA.time, - before: lastPage.data.topCursor, + startTimestamp: KEY_WITHIN_DATA_RANGE.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, + before: lastPage.data.topCursor!, size: 10, }) ); @@ -254,8 +226,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: KEY_WITHIN_DATA_RANGE.time, - endDate: LATEST_KEY_WITH_DATA.time, + startTimestamp: KEY_WITHIN_DATA_RANGE.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, before: 'last', size: 20, }) @@ -281,8 +253,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: LATEST_KEY_WITH_DATA.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, center: KEY_WITHIN_DATA_RANGE, }) ) @@ -300,101 +272,31 @@ export default function({ getService }: FtrProviderContext) { expect(firstEntry.cursor.time >= EARLIEST_KEY_WITH_DATA.time).to.be(true); expect(lastEntry.cursor.time <= LATEST_KEY_WITH_DATA.time).to.be(true); }); - }); - }); - - describe('logEntriesAround', () => { - describe('with the default source', () => { - before(() => esArchiver.load('empty_kibana')); - after(() => esArchiver.unload('empty_kibana')); - - it('should return newer and older log entries when present', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countBefore: 100, - countAfter: 100, - }, - }); - expect(logEntriesAround).to.have.property('entries'); - expect(logEntriesAround.entries).to.have.length(200); - expect(isSorted(ascendingTimeKey)(logEntriesAround.entries)).to.equal(true); + it('Handles empty responses', async () => { + const startTimestamp = Date.now() + 1000; + const endTimestamp = Date.now() + 5000; - expect(logEntriesAround.hasMoreBefore).to.equal(true); - expect(logEntriesAround.hasMoreAfter).to.equal(true); - }); - - it('should indicate if no older entries are present', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: EARLIEST_KEY_WITH_DATA, - countBefore: 100, - countAfter: 100, - }, - }); - - expect(logEntriesAround.hasMoreBefore).to.equal(false); - expect(logEntriesAround.hasMoreAfter).to.equal(true); - }); - - it('should indicate if no newer entries are present', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: LATEST_KEY_WITH_DATA, - countBefore: 100, - countAfter: 100, - }, - }); - - expect(logEntriesAround.hasMoreBefore).to.equal(true); - expect(logEntriesAround.hasMoreAfter).to.equal(false); - }); + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + }) + ) + .expect(200); - it('should return the default columns', async () => { - const { - data: { - source: { - logEntriesAround: { - entries: [entry], - }, - }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countAfter: 1, - }, - }); + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); - expect(entry.columns).to.have.length(3); - expect(entry.columns[0]).to.have.property('timestamp'); - expect(entry.columns[0].timestamp).to.be.a('number'); - expect(entry.columns[1]).to.have.property('field'); - expect(entry.columns[1].field).to.be('event.dataset'); - expect(entry.columns[1]).to.have.property('value'); - expect(JSON.parse) - .withArgs(entry.columns[1].value) - .to.not.throwException(); - expect(entry.columns[2]).to.have.property('message'); - expect(entry.columns[2].message).to.be.an('array'); - expect(entry.columns[2].message.length).to.be.greaterThan(0); + expect(logEntriesResponse.data.entries).to.have.length(0); + expect(logEntriesResponse.data.topCursor).to.be(null); + expect(logEntriesResponse.data.bottomCursor).to.be(null); }); }); @@ -431,120 +333,48 @@ export default function({ getService }: FtrProviderContext) { }); after(() => esArchiver.unload('empty_kibana')); - it('should return the configured columns', async () => { - const { - data: { - source: { - logEntriesAround: { - entries: [entry], - }, - }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countAfter: 1, - }, - }); + it('returns the configured columns', async () => { + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, + center: KEY_WITHIN_DATA_RANGE, + }) + ) + .expect(200); - expect(entry.columns).to.have.length(4); - expect(entry.columns[0]).to.have.property('timestamp'); - expect(entry.columns[0].timestamp).to.be.a('number'); - expect(entry.columns[1]).to.have.property('field'); - expect(entry.columns[1].field).to.be('host.name'); - expect(entry.columns[1]).to.have.property('value'); - expect(JSON.parse) - .withArgs(entry.columns[1].value) - .to.not.throwException(); - expect(entry.columns[2]).to.have.property('field'); - expect(entry.columns[2].field).to.be('event.dataset'); - expect(entry.columns[2]).to.have.property('value'); - expect(JSON.parse) - .withArgs(entry.columns[2].value) - .to.not.throwException(); - expect(entry.columns[3]).to.have.property('message'); - expect(entry.columns[3].message).to.be.an('array'); - expect(entry.columns[3].message.length).to.be.greaterThan(0); - }); - }); - }); + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); - describe('logEntriesBetween', () => { - describe('with the default source', () => { - before(() => esArchiver.load('empty_kibana')); - after(() => esArchiver.unload('empty_kibana')); + const entries = logEntriesResponse.data.entries; + const entry = entries[0]; - it('should return log entries between the start and end keys', async () => { - const { - data: { - source: { logEntriesBetween }, - }, - } = await client.query({ - query: logEntriesBetweenQuery, - variables: { - startKey: EARLIEST_KEY_WITH_DATA, - endKey: KEY_WITHIN_DATA_RANGE, - }, - }); + expect(entry.columns).to.have.length(4); - expect(logEntriesBetween).to.have.property('entries'); - expect(logEntriesBetween.entries).to.not.be.empty(); - expect(isSorted(ascendingTimeKey)(logEntriesBetween.entries)).to.equal(true); - - expect( - ascendingTimeKey(logEntriesBetween.entries[0], { key: EARLIEST_KEY_WITH_DATA }) - ).to.be.above(-1); - expect( - ascendingTimeKey(logEntriesBetween.entries[logEntriesBetween.entries.length - 1], { - key: KEY_WITHIN_DATA_RANGE, - }) - ).to.be.below(1); - }); + const timestampColumn = entry.columns[0] as LogTimestampColumn; + expect(timestampColumn).to.have.property('timestamp'); - it('should return results consistent with logEntriesAround', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countBefore: 100, - countAfter: 100, - }, - }); + const hostNameColumn = entry.columns[1] as LogFieldColumn; + expect(hostNameColumn).to.have.property('field'); + expect(hostNameColumn.field).to.be('host.name'); + expect(hostNameColumn).to.have.property('value'); - const { - data: { - source: { logEntriesBetween }, - }, - } = await client.query({ - query: logEntriesBetweenQuery, - variables: { - startKey: { - time: logEntriesAround.start.time, - tiebreaker: logEntriesAround.start.tiebreaker - 1, - }, - endKey: { - time: logEntriesAround.end.time, - tiebreaker: logEntriesAround.end.tiebreaker + 1, - }, - }, - }); + const eventDatasetColumn = entry.columns[2] as LogFieldColumn; + expect(eventDatasetColumn).to.have.property('field'); + expect(eventDatasetColumn.field).to.be('event.dataset'); + expect(eventDatasetColumn).to.have.property('value'); - expect(logEntriesBetween).to.eql(logEntriesAround); + const messageColumn = entry.columns[3] as LogMessageColumn; + expect(messageColumn).to.have.property('message'); + expect(messageColumn.message.length).to.be.greaterThan(0); }); }); }); }); } - -const isSorted = (comparator: (first: Value, second: Value) => number) => ( - values: Value[] -) => pairs(values, comparator).every(order => order <= 0); - -const ascendingTimeKey = (first: { key: InfraTimeKey }, second: { key: InfraTimeKey }) => - ascending(first.key.time, second.key.time) || - ascending(first.key.tiebreaker, second.key.tiebreaker); diff --git a/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts b/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts index 0473ddffbe807f..94f9d31ae8923d 100644 --- a/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts +++ b/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts @@ -5,8 +5,6 @@ */ import expect from '@kbn/expect'; -import { ascending, pairs } from 'd3-array'; -import gql from 'graphql-tag'; import { pipe } from 'fp-ts/lib/pipeable'; import { identity } from 'fp-ts/lib/function'; @@ -21,21 +19,11 @@ import { } from '../../../../plugins/infra/common/http_api'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { sharedFragments } from '../../../../plugins/infra/common/graphql/shared'; -import { InfraTimeKey } from '../../../../plugins/infra/public/graphql/types'; const KEY_BEFORE_START = { time: new Date('2000-01-01T00:00:00.000Z').valueOf(), tiebreaker: -1, }; -const KEY_AFTER_START = { - time: new Date('2000-01-01T00:00:04.000Z').valueOf(), - tiebreaker: -1, -}; -const KEY_BEFORE_END = { - time: new Date('2000-01-01T00:00:06.001Z').valueOf(), - tiebreaker: 0, -}; const KEY_AFTER_END = { time: new Date('2000-01-01T00:00:09.001Z').valueOf(), tiebreaker: 0, @@ -48,7 +36,6 @@ const COMMON_HEADERS = { export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - const client = getService('infraOpsGraphQLClient'); describe('log highlight apis', () => { before(() => esArchiver.load('infra/simple_logs')); @@ -66,8 +53,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesHighlightsRequestRT.encode({ sourceId: 'default', - startDate: KEY_BEFORE_START.time, - endDate: KEY_AFTER_END.time, + startTimestamp: KEY_BEFORE_START.time, + endTimestamp: KEY_AFTER_END.time, highlightTerms: ['message of document 0'], }) ) @@ -116,8 +103,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesHighlightsRequestRT.encode({ sourceId: 'default', - startDate: KEY_BEFORE_START.time, - endDate: KEY_AFTER_END.time, + startTimestamp: KEY_BEFORE_START.time, + endTimestamp: KEY_AFTER_END.time, highlightTerms: ['generate_test_data/simple_logs'], }) ) @@ -152,8 +139,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesHighlightsRequestRT.encode({ sourceId: 'default', - startDate: KEY_BEFORE_START.time, - endDate: KEY_AFTER_END.time, + startTimestamp: KEY_BEFORE_START.time, + endTimestamp: KEY_AFTER_END.time, query: JSON.stringify({ multi_match: { query: 'host-a', type: 'phrase', lenient: true }, }), @@ -185,236 +172,5 @@ export default function({ getService }: FtrProviderContext) { }); }); }); - - describe('logEntryHighlights', () => { - describe('with the default source', () => { - before(() => esArchiver.load('empty_kibana')); - after(() => esArchiver.unload('empty_kibana')); - - it('should return log highlights in the built-in message column', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_BEFORE_START, - endKey: KEY_AFTER_END, - highlights: [ - { - query: 'message of document 0', - countBefore: 0, - countAfter: 0, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // ten bundles with one highlight each - expect(logEntryHighlightSet.entries).to.have.length(10); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([ - 'message', - 'of', - 'document', - '0', - ]); - } - }); - - // FLAKY: https://github.com/elastic/kibana/issues/47209 - it.skip('should return log highlights in a field column', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_BEFORE_START, - endKey: KEY_AFTER_END, - highlights: [ - { - query: 'generate_test_data/simple_logs', - countBefore: 0, - countAfter: 0, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // ten bundles with five highlights each - expect(logEntryHighlightSet.entries).to.have.length(50); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([ - 'generate_test_data/simple_logs', - ]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([]); - } - }); - - it('should apply the filter query in addition to the highlight query', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_BEFORE_START, - endKey: KEY_AFTER_END, - filterQuery: JSON.stringify({ - multi_match: { query: 'host-a', type: 'phrase', lenient: true }, - }), - highlights: [ - { - query: 'message', - countBefore: 0, - countAfter: 0, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // half of the documenst - expect(logEntryHighlightSet.entries).to.have.length(25); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([ - 'message', - 'message', - ]); - } - }); - - it('should return highlights outside of the interval when requested', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_AFTER_START, - endKey: KEY_BEFORE_END, - highlights: [ - { - query: 'message of document 0', - countBefore: 2, - countAfter: 2, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // three bundles with one highlight each plus two beyond each interval boundary - expect(logEntryHighlightSet.entries).to.have.length(3 + 4); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([ - 'message', - 'of', - 'document', - '0', - ]); - } - }); - }); - }); }); } - -const logEntryHighlightsQuery = gql` - query LogEntryHighlightsQuery( - $sourceId: ID = "default" - $startKey: InfraTimeKeyInput! - $endKey: InfraTimeKeyInput! - $filterQuery: String - $highlights: [InfraLogEntryHighlightInput!]! - ) { - source(id: $sourceId) { - id - logEntryHighlights( - startKey: $startKey - endKey: $endKey - filterQuery: $filterQuery - highlights: $highlights - ) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - entries { - ...InfraLogEntryHighlightFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryHighlightFields} -`; - -const isSorted = (comparator: (first: Value, second: Value) => number) => ( - values: Value[] -) => pairs(values, comparator).every(order => order <= 0); - -const ascendingTimeKey = (first: { key: InfraTimeKey }, second: { key: InfraTimeKey }) => - ascending(first.key.time, second.key.time) || - ascending(first.key.tiebreaker, second.key.tiebreaker); diff --git a/x-pack/test/api_integration/apis/infra/log_summary.ts b/x-pack/test/api_integration/apis/infra/log_summary.ts index 15e503f7b4a5a9..1f1b65fca6e5f0 100644 --- a/x-pack/test/api_integration/apis/infra/log_summary.ts +++ b/x-pack/test/api_integration/apis/infra/log_summary.ts @@ -38,9 +38,10 @@ export default function({ getService }: FtrProviderContext) { after(() => esArchiver.unload('infra/metrics_and_logs')); it('should return empty and non-empty consecutive buckets', async () => { - const startDate = EARLIEST_TIME_WITH_DATA; - const endDate = LATEST_TIME_WITH_DATA + (LATEST_TIME_WITH_DATA - EARLIEST_TIME_WITH_DATA); - const bucketSize = Math.ceil((endDate - startDate) / 10); + const startTimestamp = EARLIEST_TIME_WITH_DATA; + const endTimestamp = + LATEST_TIME_WITH_DATA + (LATEST_TIME_WITH_DATA - EARLIEST_TIME_WITH_DATA); + const bucketSize = Math.ceil((endTimestamp - startTimestamp) / 10); const { body } = await supertest .post(LOG_ENTRIES_SUMMARY_PATH) @@ -48,8 +49,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesSummaryRequestRT.encode({ sourceId: 'default', - startDate, - endDate, + startTimestamp, + endTimestamp, bucketSize, query: null, }) diff --git a/x-pack/test/api_integration/apis/infra/logs_without_millis.ts b/x-pack/test/api_integration/apis/infra/logs_without_millis.ts index 9295380cfbec17..642f4fb42d3240 100644 --- a/x-pack/test/api_integration/apis/infra/logs_without_millis.ts +++ b/x-pack/test/api_integration/apis/infra/logs_without_millis.ts @@ -5,8 +5,6 @@ */ import expect from '@kbn/expect'; -import { ascending, pairs } from 'd3-array'; -import gql from 'graphql-tag'; import { pipe } from 'fp-ts/lib/pipeable'; import { identity } from 'fp-ts/lib/function'; @@ -15,21 +13,18 @@ import { fold } from 'fp-ts/lib/Either'; import { createPlainError, throwErrors } from '../../../../plugins/infra/common/runtime_types'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { sharedFragments } from '../../../../plugins/infra/common/graphql/shared'; -import { InfraTimeKey } from '../../../../plugins/infra/public/graphql/types'; import { LOG_ENTRIES_SUMMARY_PATH, logEntriesSummaryRequestRT, logEntriesSummaryResponseRT, + LOG_ENTRIES_PATH, + logEntriesRequestRT, + logEntriesResponseRT, } from '../../../../plugins/infra/common/http_api/log_entries'; const COMMON_HEADERS = { 'kbn-xsrf': 'some-xsrf-token', }; -const KEY_WITHIN_DATA_RANGE = { - time: new Date('2019-01-06T00:00:00.000Z').valueOf(), - tiebreaker: 0, -}; const EARLIEST_KEY_WITH_DATA = { time: new Date('2019-01-05T23:59:23.000Z').valueOf(), tiebreaker: -1, @@ -38,153 +33,97 @@ const LATEST_KEY_WITH_DATA = { time: new Date('2019-01-06T23:59:23.000Z').valueOf(), tiebreaker: 2, }; +const KEY_WITHIN_DATA_RANGE = { + time: new Date('2019-01-06T00:00:00.000Z').valueOf(), + tiebreaker: 0, +}; export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const client = getService('infraOpsGraphQLClient'); const supertest = getService('supertest'); describe('logs without epoch_millis format', () => { before(() => esArchiver.load('infra/logs_without_epoch_millis')); after(() => esArchiver.unload('infra/logs_without_epoch_millis')); - it('logEntriesAround should return log entries', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countBefore: 1, - countAfter: 1, - }, + describe('/log_entries/summary', () => { + it('returns non-empty buckets', async () => { + const startTimestamp = EARLIEST_KEY_WITH_DATA.time; + const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive + const bucketSize = Math.ceil((endTimestamp - startTimestamp) / 10); + + const { body } = await supertest + .post(LOG_ENTRIES_SUMMARY_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesSummaryRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + bucketSize, + query: null, + }) + ) + .expect(200); + + const logSummaryResponse = pipe( + logEntriesSummaryResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + + expect( + logSummaryResponse.data.buckets.filter((bucket: any) => bucket.entriesCount > 0) + ).to.have.length(2); }); - - expect(logEntriesAround).to.have.property('entries'); - expect(logEntriesAround.entries).to.have.length(2); - expect(isSorted(ascendingTimeKey)(logEntriesAround.entries)).to.equal(true); - - expect(logEntriesAround.hasMoreBefore).to.equal(false); - expect(logEntriesAround.hasMoreAfter).to.equal(false); }); - it('logEntriesBetween should return log entries', async () => { - const { - data: { - source: { logEntriesBetween }, - }, - } = await client.query({ - query: logEntriesBetweenQuery, - variables: { - startKey: EARLIEST_KEY_WITH_DATA, - endKey: LATEST_KEY_WITH_DATA, - }, + describe('/log_entries/entries', () => { + it('returns log entries', async () => { + const startTimestamp = EARLIEST_KEY_WITH_DATA.time; + const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive + + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + }) + ) + .expect(200); + + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + expect(logEntriesResponse.data.entries).to.have.length(2); }); - expect(logEntriesBetween).to.have.property('entries'); - expect(logEntriesBetween.entries).to.have.length(2); - expect(isSorted(ascendingTimeKey)(logEntriesBetween.entries)).to.equal(true); - }); - - it('logSummaryBetween should return non-empty buckets', async () => { - const startDate = EARLIEST_KEY_WITH_DATA.time; - const endDate = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive - const bucketSize = Math.ceil((endDate - startDate) / 10); - - const { body } = await supertest - .post(LOG_ENTRIES_SUMMARY_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesSummaryRequestRT.encode({ - sourceId: 'default', - startDate, - endDate, - bucketSize, - query: null, - }) - ) - .expect(200); - - const logSummaryResponse = pipe( - logEntriesSummaryResponseRT.decode(body), - fold(throwErrors(createPlainError), identity) - ); - - expect( - logSummaryResponse.data.buckets.filter((bucket: any) => bucket.entriesCount > 0) - ).to.have.length(2); + it('returns log entries when centering around a point', async () => { + const startTimestamp = EARLIEST_KEY_WITH_DATA.time; + const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive + + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + center: KEY_WITHIN_DATA_RANGE, + }) + ) + .expect(200); + + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + expect(logEntriesResponse.data.entries).to.have.length(2); + }); }); }); } - -const logEntriesAroundQuery = gql` - query LogEntriesAroundQuery( - $timeKey: InfraTimeKeyInput! - $countBefore: Int = 0 - $countAfter: Int = 0 - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesAround( - key: $timeKey - countBefore: $countBefore - countAfter: $countAfter - filterQuery: $filterQuery - ) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - -const logEntriesBetweenQuery = gql` - query LogEntriesBetweenQuery( - $startKey: InfraTimeKeyInput! - $endKey: InfraTimeKeyInput! - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesBetween(startKey: $startKey, endKey: $endKey, filterQuery: $filterQuery) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - -const isSorted = (comparator: (first: Value, second: Value) => number) => ( - values: Value[] -) => pairs(values, comparator).every(order => order <= 0); - -const ascendingTimeKey = (first: { key: InfraTimeKey }, second: { key: InfraTimeKey }) => - ascending(first.key.time, second.key.time) || - ascending(first.key.tiebreaker, second.key.tiebreaker); diff --git a/x-pack/test/functional/apps/infra/constants.ts b/x-pack/test/functional/apps/infra/constants.ts index 947131a22d39bc..cd91867faf9dfa 100644 --- a/x-pack/test/functional/apps/infra/constants.ts +++ b/x-pack/test/functional/apps/infra/constants.ts @@ -22,5 +22,9 @@ export const DATES = { withData: '10/17/2018 7:58:03 PM', withoutData: '10/09/2018 10:00:00 PM', }, + stream: { + startWithData: '2018-10-17T19:42:22.000Z', + endWithData: '2018-10-17T19:57:21.000Z', + }, }, }; diff --git a/x-pack/test/functional/apps/infra/link_to.ts b/x-pack/test/functional/apps/infra/link_to.ts index da41bf285c3e4c..7e79f42ac94cb3 100644 --- a/x-pack/test/functional/apps/infra/link_to.ts +++ b/x-pack/test/functional/apps/infra/link_to.ts @@ -7,22 +7,29 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +const ONE_HOUR = 60 * 60 * 1000; + export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common']); const retry = getService('retry'); const browser = getService('browser'); + const timestamp = Date.now(); + const startDate = new Date(timestamp - ONE_HOUR).toISOString(); + const endDate = new Date(timestamp + ONE_HOUR).toISOString(); + + const traceId = '433b4651687e18be2c6c8e3b11f53d09'; + describe('Infra link-to', function() { this.tags('smoke'); it('redirects to the logs app and parses URL search params correctly', async () => { const location = { hash: '', pathname: '/link-to/logs', - search: 'time=1565707203194&filter=trace.id:433b4651687e18be2c6c8e3b11f53d09', + search: `time=${timestamp}&filter=trace.id:${traceId}`, state: undefined, }; - const expectedSearchString = - "logFilter=(expression:'trace.id:433b4651687e18be2c6c8e3b11f53d09',kind:kuery)&logPosition=(position:(tiebreaker:0,time:1565707203194),streamLive:!f)&sourceId=default"; + const expectedSearchString = `logFilter=(expression:'trace.id:${traceId}',kind:kuery)&logPosition=(end:'${endDate}',position:(tiebreaker:0,time:${timestamp}),start:'${startDate}',streamLive:!f)&sourceId=default`; const expectedRedirectPath = '/logs/stream?'; await pageObjects.common.navigateToUrlWithBrowserHistory( 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 ecad5a40ec42e9..f40c908f23c809 100644 --- a/x-pack/test/functional/apps/infra/logs_source_configuration.ts +++ b/x-pack/test/functional/apps/infra/logs_source_configuration.ts @@ -5,6 +5,7 @@ */ import expect from '@kbn/expect'; +import { DATES } from './constants'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -74,7 +75,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('renders the default log columns with their headers', async () => { - await logsUi.logStreamPage.navigateTo(); + await logsUi.logStreamPage.navigateTo({ + logPosition: { + start: DATES.metricsAndLogs.stream.startWithData, + end: DATES.metricsAndLogs.stream.endWithData, + }, + }); await retry.try(async () => { const columnHeaderLabels = await logsUi.logStreamPage.getColumnHeaderLabels(); @@ -108,7 +114,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('renders the changed log columns with their headers', async () => { - await logsUi.logStreamPage.navigateTo(); + await logsUi.logStreamPage.navigateTo({ + logPosition: { + start: DATES.metricsAndLogs.stream.startWithData, + end: DATES.metricsAndLogs.stream.endWithData, + }, + }); await retry.try(async () => { const columnHeaderLabels = await logsUi.logStreamPage.getColumnHeaderLabels(); 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 8f554729328bbb..10d86140fd1215 100644 --- a/x-pack/test/functional/page_objects/infra_logs_page.ts +++ b/x-pack/test/functional/page_objects/infra_logs_page.ts @@ -6,8 +6,21 @@ // import testSubjSelector from '@kbn/test-subj-selector'; // import moment from 'moment'; - +import querystring from 'querystring'; +import { encode, RisonValue } from 'rison-node'; import { FtrProviderContext } from '../ftr_provider_context'; +import { LogPositionUrlState } from '../../../../x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state'; +import { FlyoutOptionsUrlState } from '../../../../x-pack/plugins/infra/public/containers/logs/log_flyout'; + +export interface TabsParams { + stream: { + logPosition?: Partial; + flyoutOptions?: Partial; + }; + settings: never; + 'log-categories': any; + 'log-rate': any; +} export function InfraLogsPageProvider({ getPageObjects, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); @@ -18,8 +31,26 @@ export function InfraLogsPageProvider({ getPageObjects, getService }: FtrProvide await pageObjects.common.navigateToApp('infraLogs'); }, - async navigateToTab(logsUiTab: LogsUiTab) { - await pageObjects.common.navigateToUrlWithBrowserHistory('infraLogs', `/${logsUiTab}`); + async navigateToTab(logsUiTab: T, params?: TabsParams[T]) { + let qs = ''; + if (params) { + const parsedParams: Record = {}; + + for (const key in params) { + if (params.hasOwnProperty(key)) { + const value = (params[key] as unknown) as RisonValue; + parsedParams[key] = encode(value); + } + } + qs = '?' + querystring.stringify(parsedParams); + } + + await pageObjects.common.navigateToUrlWithBrowserHistory( + 'infraLogs', + `/${logsUiTab}`, + qs, + { ensureCurrentUrl: false } // Test runner struggles with `rison-node` escaped values + ); }, async getLogStream() { diff --git a/x-pack/test/functional/services/logs_ui/log_stream.ts b/x-pack/test/functional/services/logs_ui/log_stream.ts index ce37d2d5a60daa..75486534cf5ccc 100644 --- a/x-pack/test/functional/services/logs_ui/log_stream.ts +++ b/x-pack/test/functional/services/logs_ui/log_stream.ts @@ -6,6 +6,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper'; +import { TabsParams } from '../../page_objects/infra_logs_page'; export function LogStreamPageProvider({ getPageObjects, getService }: FtrProviderContext) { const pageObjects = getPageObjects(['infraLogs']); @@ -13,8 +14,8 @@ export function LogStreamPageProvider({ getPageObjects, getService }: FtrProvide const testSubjects = getService('testSubjects'); return { - async navigateTo() { - pageObjects.infraLogs.navigateToTab('stream'); + async navigateTo(params?: TabsParams['stream']) { + pageObjects.infraLogs.navigateToTab('stream', params); }, async getColumnHeaderLabels(): Promise {