From fe4c164681e92ef5bf0c28f7ab3dfe00a5aacd6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez?= Date: Thu, 19 Mar 2020 17:19:21 +0100 Subject: [PATCH 01/32] [Logs UI] Use the Super date picker in the log stream (#54280) --- .../common/http_api/log_entries/entries.ts | 72 ++-- .../common/http_api/log_entries/highlights.ts | 4 +- .../common/http_api/log_entries/summary.ts | 4 +- .../components/logging/log_datepicker.tsx | 73 ++++ .../logging/log_minimap/density_chart.tsx | 42 +- .../log_minimap/highlighted_interval.tsx | 6 +- .../logging/log_minimap/log_minimap.tsx | 191 ++------- .../logging/log_minimap/search_marker.tsx | 5 +- .../logging/log_minimap/search_markers.tsx | 4 +- .../log_minimap/time_label_formatter.tsx | 23 + .../logging/log_minimap/time_ruler.tsx | 43 +- .../components/logging/log_minimap/types.ts | 17 - .../logging/log_minimap_scale_controls.tsx | 67 --- .../logging/log_text_stream/item.ts | 8 +- .../log_text_stream/loading_item_view.tsx | 349 ++++++++++----- .../log_entry_field_column.test.tsx | 19 +- .../log_entry_field_column.tsx | 23 +- .../log_entry_message_column.tsx | 35 +- .../logging/log_text_stream/log_entry_row.tsx | 16 +- .../log_text_stream/log_text_separator.tsx | 21 + .../scrollable_log_text_stream_view.tsx | 162 +++---- .../components/logging/log_time_controls.tsx | 97 ----- .../logs/log_entries/api/fetch_log_entries.ts | 28 ++ .../logs/log_entries/gql_queries.ts | 64 --- .../containers/logs/log_entries/index.ts | 301 +++++++++---- .../public/containers/logs/log_flyout.tsx | 2 +- .../api/fetch_log_entries_highlights.ts | 31 ++ .../log_highlights/log_entry_highlights.tsx | 80 ++-- .../logs/log_highlights/log_highlights.tsx | 50 +-- .../log_highlights/log_summary_highlights.ts | 26 +- .../logs/log_highlights/next_and_previous.tsx | 4 +- .../logs/log_position/log_position_state.ts | 111 ++++- .../with_log_position_url_state.tsx | 95 ++++- .../logs/log_summary/bucket_size.ts | 23 + .../containers/logs/log_summary/index.ts | 1 - .../logs/log_summary/log_summary.test.tsx | 89 ++-- .../logs/log_summary/log_summary.tsx | 23 +- .../use_log_summary_buffer_interval.ts | 30 -- .../logs/log_summary/with_summary.ts | 15 +- .../logs/log_view_configuration.test.tsx | 25 -- .../logs/log_view_configuration.tsx | 46 -- .../containers/logs/with_log_minimap.tsx | 52 --- .../containers/logs/with_stream_items.ts | 6 +- .../category_example_message.tsx | 10 +- .../pages/logs/stream/page_logs_content.tsx | 21 +- .../pages/logs/stream/page_providers.tsx | 32 +- .../public/pages/logs/stream/page_toolbar.tsx | 45 +- .../infra/public/utils/datemath.test.ts | 401 ++++++++++++++++++ x-pack/plugins/infra/public/utils/datemath.ts | 266 ++++++++++++ .../infra/public/utils/log_entry/log_entry.ts | 41 +- .../utils/log_entry/log_entry_highlight.ts | 26 +- x-pack/plugins/infra/server/graphql/index.ts | 9 +- .../infra/server/graphql/log_entries/index.ts | 7 - .../server/graphql/log_entries/resolvers.ts | 175 -------- .../server/graphql/log_entries/schema.gql.ts | 136 ------ x-pack/plugins/infra/server/infra_server.ts | 2 - .../log_entries/kibana_log_entries_adapter.ts | 218 +--------- .../log_entries_domain/log_entries_domain.ts | 263 +----------- .../server/routes/log_entries/entries.ts | 22 +- .../server/routes/log_entries/highlights.ts | 10 +- .../server/routes/log_entries/summary.ts | 10 +- .../routes/log_entries/summary_highlights.ts | 17 +- .../translations/translations/ja-JP.json | 14 - .../translations/translations/zh-CN.json | 14 - .../test/api_integration/apis/infra/index.js | 2 +- .../api_integration/apis/infra/log_entries.ts | 392 +++++------------ .../apis/infra/log_entry_highlights.ts | 256 +---------- .../api_integration/apis/infra/log_summary.ts | 11 +- .../apis/infra/logs_without_millis.ts | 221 ++++------ .../test/functional/apps/infra/constants.ts | 4 + x-pack/test/functional/apps/infra/link_to.ts | 13 +- .../apps/infra/logs_source_configuration.ts | 15 +- .../page_objects/infra_logs_page.ts | 37 +- .../functional/services/logs_ui/log_stream.ts | 5 +- 74 files changed, 2354 insertions(+), 2724 deletions(-) create mode 100644 x-pack/plugins/infra/public/components/logging/log_datepicker.tsx create mode 100644 x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx delete mode 100644 x-pack/plugins/infra/public/components/logging/log_minimap/types.ts delete mode 100644 x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx create mode 100644 x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx delete mode 100644 x-pack/plugins/infra/public/components/logging/log_time_controls.tsx create mode 100644 x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts delete mode 100644 x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts create mode 100644 x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts create mode 100644 x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts delete mode 100644 x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts delete mode 100644 x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx create mode 100644 x-pack/plugins/infra/public/utils/datemath.test.ts create mode 100644 x-pack/plugins/infra/public/utils/datemath.ts delete mode 100644 x-pack/plugins/infra/server/graphql/log_entries/index.ts delete mode 100644 x-pack/plugins/infra/server/graphql/log_entries/resolvers.ts delete mode 100644 x-pack/plugins/infra/server/graphql/log_entries/schema.gql.ts 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 3855dd72dcdfd2..03bfb089d8bd06 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 030b27c2342cc8..682ac4c0bba103 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 a34cd89eb32629..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', - ]); - } - }); - - // https://github.com/elastic/kibana/issues/49959 - 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 { From d5ed93ee635b93573eb8a367e9e22ba597d367a5 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Thu, 19 Mar 2020 10:27:41 -0600 Subject: [PATCH 02/32] [SIEM] [Cases] Case closed and add user email (#60463) --- .../siem/public/containers/case/types.ts | 7 +- .../public/containers/case/use_get_case.tsx | 2 + .../containers/case/use_update_case.tsx | 4 +- .../components/all_cases/__mock__/index.tsx | 14 +- .../case/components/all_cases/columns.tsx | 50 +++-- .../pages/case/components/all_cases/index.tsx | 18 +- .../case/components/all_cases/translations.ts | 6 - .../case/components/case_status/index.tsx | 105 +++++++++++ .../components/case_view/__mock__/index.tsx | 50 ++--- .../components/case_view/actions.test.tsx | 65 +++++++ .../case/components/case_view/actions.tsx | 75 ++++++++ .../case/components/case_view/index.test.tsx | 95 ++++------ .../pages/case/components/case_view/index.tsx | 171 +++++------------- .../case/components/case_view/translations.ts | 16 ++ .../case/components/user_list/index.test.tsx | 40 ++++ .../pages/case/components/user_list/index.tsx | 28 ++- .../siem/public/pages/case/translations.ts | 10 + x-pack/plugins/case/common/api/cases/case.ts | 2 + x-pack/plugins/case/common/api/user.ts | 1 + .../routes/api/__fixtures__/authc_mock.ts | 6 +- .../api/__fixtures__/mock_saved_objects.ts | 50 +++++ .../api/cases/comments/patch_comment.ts | 4 +- .../api/cases/configure/patch_configure.ts | 4 +- .../api/cases/configure/post_configure.ts | 4 +- .../routes/api/cases/find_cases.test.ts | 2 +- .../routes/api/cases/patch_cases.test.ts | 50 ++++- .../server/routes/api/cases/patch_cases.ts | 30 ++- .../plugins/case/server/routes/api/types.ts | 2 +- .../plugins/case/server/routes/api/utils.ts | 20 +- .../case/server/saved_object_types/cases.ts | 22 +++ .../server/saved_object_types/comments.ts | 6 + 31 files changed, 692 insertions(+), 267 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 65d94865bf00ca..5b6ff8438be8c9 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -18,6 +18,8 @@ export interface Comment { export interface Case { id: string; + closedAt: string | null; + closedBy: ElasticUser | null; comments: Comment[]; commentIds: string[]; createdAt: string; @@ -59,12 +61,13 @@ export interface AllCases extends CasesStatus { export enum SortFieldCase { createdAt = 'createdAt', - updatedAt = 'updatedAt', + closedAt = 'closedAt', } export interface ElasticUser { - readonly username: string; + readonly email?: string | null; readonly fullName?: string | null; + readonly username: string; } export interface FetchCasesProps { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index a179b6f546b9bc..b70195e2c126f5 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -49,6 +49,8 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { }; const initialData: Case = { id: '', + closedAt: null, + closedBy: null, createdAt: '', comments: [], commentIds: [], diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index afcbe20fa791ad..987620469901bd 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -5,7 +5,7 @@ */ import { useReducer, useCallback } from 'react'; - +import { cloneDeep } from 'lodash/fp'; import { CaseRequest } from '../../../../../../plugins/case/common/api'; import { errorToToaster, useStateToaster } from '../../components/toasters'; @@ -47,7 +47,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => ...state, isLoading: false, isError: false, - caseData: action.payload, + caseData: cloneDeep(action.payload), updateKey: null, }; case 'FETCH_FAILURE': diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 0fe8daafcb30ab..5d00b770b3ca9c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -13,6 +13,8 @@ export const useGetCasesMockState: UseGetCasesState = { countOpenCases: 0, cases: [ { + closedAt: null, + closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic' }, @@ -27,6 +29,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '362a5c10-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:13.328Z', createdBy: { username: 'elastic' }, @@ -41,6 +45,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:11.328Z', createdBy: { username: 'elastic' }, @@ -55,6 +61,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: '2020-02-13T19:44:13.328Z', + closedBy: { username: 'elastic' }, id: '31890e90-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:05.563Z', createdBy: { username: 'elastic' }, @@ -64,11 +72,13 @@ export const useGetCasesMockState: UseGetCasesState = { status: 'closed', tags: ['phishing'], title: 'Uh oh', - updatedAt: null, - updatedBy: null, + updatedAt: '2020-02-13T19:44:13.328Z', + updatedBy: { username: 'elastic' }, version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '2f5b3210-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:01.901Z', createdBy: { username: 'elastic' }, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 5859e6bbce263f..b9e1113c486ad9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -36,7 +36,8 @@ const Spacer = styled.span` const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); export const getCasesColumns = ( - actions: Array> + actions: Array>, + filterStatus: string ): CasesColumns[] => [ { name: i18n.NAME, @@ -113,22 +114,39 @@ export const getCasesColumns = ( render: (comments: Case['commentIds']) => renderStringField(`${comments.length}`, `case-table-column-commentCount`), }, - { - field: 'createdAt', - name: i18n.OPENED_ON, - sortable: true, - render: (createdAt: Case['createdAt']) => { - if (createdAt != null) { - return ( - - ); + filterStatus === 'open' + ? { + field: 'createdAt', + name: i18n.OPENED_ON, + sortable: true, + render: (createdAt: Case['createdAt']) => { + if (createdAt != null) { + return ( + + ); + } + return getEmptyTagValue(); + }, } - return getEmptyTagValue(); - }, - }, + : { + field: 'closedAt', + name: i18n.CLOSED_ON, + sortable: true, + render: (closedAt: Case['closedAt']) => { + if (closedAt != null) { + return ( + + ); + } + return getEmptyTagValue(); + }, + }, { name: 'Actions', actions, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index 9f836bd043c9dc..9a84dd07b0af44 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -71,8 +71,8 @@ const ProgressLoader = styled(EuiProgress)` const getSortField = (field: string): SortFieldCase => { if (field === SortFieldCase.createdAt) { return SortFieldCase.createdAt; - } else if (field === SortFieldCase.updatedAt) { - return SortFieldCase.updatedAt; + } else if (field === SortFieldCase.closedAt) { + return SortFieldCase.closedAt; } return SortFieldCase.createdAt; }; @@ -206,17 +206,25 @@ export const AllCases = React.memo(() => { } setQueryParams(newQueryParams); }, - [setQueryParams, queryParams] + [queryParams] ); const onFilterChangedCallback = useCallback( (newFilterOptions: Partial) => { + if (newFilterOptions.status && newFilterOptions.status === 'closed') { + setQueryParams({ ...queryParams, sortField: SortFieldCase.closedAt }); + } else if (newFilterOptions.status && newFilterOptions.status === 'open') { + setQueryParams({ ...queryParams, sortField: SortFieldCase.createdAt }); + } setFilters({ ...filterOptions, ...newFilterOptions }); }, - [filterOptions, setFilters] + [filterOptions, queryParams] ); - const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions), [actions]); + const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions, filterOptions.status), [ + actions, + filterOptions.status, + ]); const memoizedPagination = useMemo( () => ({ pageIndex: queryParams.page - 1, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts index 27532e57166e1d..8f79b78ef7568b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts @@ -60,9 +60,3 @@ export const CLOSED = i18n.translate('xpack.siem.case.caseTable.closed', { export const DELETE = i18n.translate('xpack.siem.case.caseTable.delete', { defaultMessage: 'Delete', }); -export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', { - defaultMessage: 'Reopen case', -}); -export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', { - defaultMessage: 'Close case', -}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx new file mode 100644 index 00000000000000..9dbd71ea3e34c2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx @@ -0,0 +1,105 @@ +/* + * 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 styled, { css } from 'styled-components'; +import { + EuiBadge, + EuiButtonToggle, + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import * as i18n from '../case_view/translations'; +import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; +import { CaseViewActions } from '../case_view/actions'; + +const MyDescriptionList = styled(EuiDescriptionList)` + ${({ theme }) => css` + & { + padding-right: ${theme.eui.euiSizeL}; + border-right: ${theme.eui.euiBorderThin}; + } + `} +`; + +interface CaseStatusProps { + 'data-test-subj': string; + badgeColor: string; + buttonLabel: string; + caseId: string; + caseTitle: string; + icon: string; + isLoading: boolean; + isSelected: boolean; + status: string; + title: string; + toggleStatusCase: (status: string) => void; + value: string | null; +} +const CaseStatusComp: React.FC = ({ + 'data-test-subj': dataTestSubj, + badgeColor, + buttonLabel, + caseId, + caseTitle, + icon, + isLoading, + isSelected, + status, + title, + toggleStatusCase, + value, +}) => { + const onChange = useCallback(e => toggleStatusCase(e.target.checked ? 'closed' : 'open'), [ + toggleStatusCase, + ]); + return ( + + + + + + {i18n.STATUS} + + + {status} + + + + + {title} + + + + + + + + + + + + + + + + + + + ); +}; + +export const CaseStatus = React.memo(CaseStatusComp); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index 53cc1f80b5c108..e11441eac3a9d2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -10,6 +10,8 @@ import { Case } from '../../../../../containers/case/types'; export const caseProps: CaseProps = { caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', initialData: { + closedAt: null, + closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], comments: [ @@ -20,6 +22,7 @@ export const caseProps: CaseProps = { createdBy: { fullName: 'Steph Milovic', username: 'smilovic', + email: 'notmyrealemailfool@elastic.co', }, updatedAt: '2020-02-20T23:06:33.798Z', updatedBy: { @@ -29,7 +32,7 @@ export const caseProps: CaseProps = { }, ], createdAt: '2020-02-13T19:44:23.627Z', - createdBy: { fullName: null, username: 'elastic' }, + createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' }, description: 'Security banana Issue', status: 'open', tags: ['defacement'], @@ -41,35 +44,22 @@ export const caseProps: CaseProps = { version: 'WzQ3LDFd', }, }; - -export const data: Case = { - id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', - commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], - comments: [ - { - comment: 'Solve this fast!', - id: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', - createdAt: '2020-02-20T23:06:33.798Z', - createdBy: { - fullName: 'Steph Milovic', - username: 'smilovic', - }, - updatedAt: '2020-02-20T23:06:33.798Z', - updatedBy: { - username: 'elastic', - }, - version: 'WzQ3LDFd', +export const caseClosedProps: CaseProps = { + ...caseProps, + initialData: { + ...caseProps.initialData, + closedAt: '2020-02-20T23:06:33.798Z', + closedBy: { + username: 'elastic', }, - ], - createdAt: '2020-02-13T19:44:23.627Z', - createdBy: { username: 'elastic', fullName: null }, - description: 'Security banana Issue', - status: 'open', - tags: ['defacement'], - title: 'Another horrible breach!!', - updatedAt: '2020-02-19T15:02:57.995Z', - updatedBy: { - username: 'elastic', + status: 'closed', }, - version: 'WzQ3LDFd', +}; + +export const data: Case = { + ...caseProps.initialData, +}; + +export const dataClosed: Case = { + ...caseClosedProps.initialData, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx new file mode 100644 index 00000000000000..4e1e5ba753c364 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx @@ -0,0 +1,65 @@ +/* + * 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 { mount } from 'enzyme'; +import { CaseViewActions } from './actions'; +import { TestProviders } from '../../../../mock'; +import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; +jest.mock('../../../../containers/case/use_delete_cases'); +const useDeleteCasesMock = useDeleteCases as jest.Mock; + +describe('CaseView actions', () => { + const caseTitle = 'Cool title'; + const caseId = 'cool-id'; + const handleOnDeleteConfirm = jest.fn(); + const handleToggleModal = jest.fn(); + const dispatchResetIsDeleted = jest.fn(); + const defaultDeleteState = { + dispatchResetIsDeleted, + handleToggleModal, + handleOnDeleteConfirm, + isLoading: false, + isError: false, + isDeleted: false, + isDisplayConfirmDeleteModal: false, + }; + beforeEach(() => { + jest.resetAllMocks(); + useDeleteCasesMock.mockImplementation(() => defaultDeleteState); + }); + it('clicking trash toggles modal', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy(); + + wrapper + .find('button[data-test-subj="property-actions-ellipses"]') + .first() + .simulate('click'); + wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click'); + expect(handleToggleModal).toHaveBeenCalled(); + }); + it('toggle delete modal and confirm', () => { + useDeleteCasesMock.mockImplementation(() => ({ + ...defaultDeleteState, + isDisplayConfirmDeleteModal: true, + })); + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy(); + wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click'); + expect(handleOnDeleteConfirm.mock.calls[0][0]).toEqual([caseId]); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx new file mode 100644 index 00000000000000..88a717ac5fa6a9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx @@ -0,0 +1,75 @@ +/* + * 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, { useMemo } from 'react'; + +import { Redirect } from 'react-router-dom'; +import * as i18n from './translations'; +import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; +import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import { SiemPageName } from '../../../home/types'; +import { PropertyActions } from '../property_actions'; + +interface CaseViewActions { + caseId: string; + caseTitle: string; +} + +const CaseViewActionsComponent: React.FC = ({ caseId, caseTitle }) => { + // Delete case + const { + handleToggleModal, + handleOnDeleteConfirm, + isDeleted, + isDisplayConfirmDeleteModal, + } = useDeleteCases(); + + const confirmDeleteModal = useMemo( + () => ( + + ), + [isDisplayConfirmDeleteModal] + ); + // TO DO refactor each of these const's into their own components + const propertyActions = useMemo( + () => [ + { + iconType: 'trash', + label: i18n.DELETE_CASE, + onClick: handleToggleModal, + }, + { + iconType: 'popout', + label: 'View ServiceNow incident', + onClick: () => null, + }, + { + iconType: 'importAction', + label: 'Update ServiceNow incident', + onClick: () => null, + }, + ], + [handleToggleModal] + ); + + if (isDeleted) { + return ; + } + return ( + <> + + {confirmDeleteModal} + + ); +}; + +export const CaseViewActions = React.memo(CaseViewActionsComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 15d6cf7cf7317a..ec18bdb2bf9abe 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -7,15 +7,13 @@ import React from 'react'; import { mount } from 'enzyme'; import { CaseComponent } from './'; -import * as updateHook from '../../../../containers/case/use_update_case'; -import * as deleteHook from '../../../../containers/case/use_delete_cases'; -import { caseProps, data } from './__mock__'; +import { caseProps, caseClosedProps, data, dataClosed } from './__mock__'; import { TestProviders } from '../../../../mock'; +import { useUpdateCase } from '../../../../containers/case/use_update_case'; +jest.mock('../../../../containers/case/use_update_case'); +const useUpdateCaseMock = useUpdateCase as jest.Mock; describe('CaseView ', () => { - const handleOnDeleteConfirm = jest.fn(); - const handleToggleModal = jest.fn(); - const dispatchResetIsDeleted = jest.fn(); const updateCaseProperty = jest.fn(); /* eslint-disable no-console */ // Silence until enzyme fixed to use ReactTestUtils.act() @@ -28,15 +26,17 @@ describe('CaseView ', () => { }); /* eslint-enable no-console */ + const defaultUpdateCaseState = { + caseData: data, + isLoading: false, + isError: false, + updateKey: null, + updateCaseProperty, + }; + beforeEach(() => { jest.resetAllMocks(); - jest.spyOn(updateHook, 'useUpdateCase').mockReturnValue({ - caseData: data, - isLoading: false, - isError: false, - updateKey: null, - updateCaseProperty, - }); + useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); }); it('should render CaseComponent', () => { @@ -69,6 +69,7 @@ describe('CaseView ', () => { .first() .text() ).toEqual(data.createdBy.username); + expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false); expect( wrapper .find(`[data-test-subj="case-view-createdAt"]`) @@ -82,6 +83,30 @@ describe('CaseView ', () => { .prop('raw') ).toEqual(data.description); }); + it('should show closed indicators in header when case is closed', () => { + useUpdateCaseMock.mockImplementation(() => ({ + ...defaultUpdateCaseState, + caseData: dataClosed, + })); + const wrapper = mount( + + + + ); + expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); + expect( + wrapper + .find(`[data-test-subj="case-view-closedAt"]`) + .first() + .prop('value') + ).toEqual(dataClosed.closedAt); + expect( + wrapper + .find(`[data-test-subj="case-view-status"]`) + .first() + .text() + ).toEqual(dataClosed.status); + }); it('should dispatch update state when button is toggled', () => { const wrapper = mount( @@ -92,7 +117,7 @@ describe('CaseView ', () => { wrapper .find('input[data-test-subj="toggle-case-status"]') - .simulate('change', { target: { value: false } }); + .simulate('change', { target: { checked: true } }); expect(updateCaseProperty).toBeCalledWith({ updateKey: 'status', @@ -133,46 +158,4 @@ describe('CaseView ', () => { .prop('source') ).toEqual(data.comments[0].comment); }); - - it('toggle delete modal and cancel', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy(); - - wrapper - .find( - '[data-test-subj="case-view-actions"] button[data-test-subj="property-actions-ellipses"]' - ) - .first() - .simulate('click'); - wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click'); - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy(); - wrapper.find('button[data-test-subj="confirmModalCancelButton"]').simulate('click'); - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy(); - }); - - it('toggle delete modal and confirm', () => { - jest.spyOn(deleteHook, 'useDeleteCases').mockReturnValue({ - dispatchResetIsDeleted, - handleToggleModal, - handleOnDeleteConfirm, - isLoading: false, - isError: false, - isDeleted: false, - isDisplayConfirmDeleteModal: true, - }); - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy(); - wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click'); - expect(handleOnDeleteConfirm.mock.calls[0][0]).toEqual([caseProps.caseId]); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 82216e88a091e7..dce7bde2225c92 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -5,26 +5,14 @@ */ import React, { useCallback, useMemo } from 'react'; -import { - EuiBadge, - EuiButtonToggle, - EuiDescriptionList, - EuiDescriptionListDescription, - EuiDescriptionListTitle, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; -import styled, { css } from 'styled-components'; -import { Redirect } from 'react-router-dom'; +import styled from 'styled-components'; import * as i18n from './translations'; import { Case } from '../../../../containers/case/types'; -import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; import { getCaseUrl } from '../../../../components/link_to'; import { HeaderPage } from '../../../../components/header_page'; import { EditableTitle } from '../../../../components/header_page/editable_title'; -import { PropertyActions } from '../property_actions'; import { TagList } from '../tag_list'; import { useGetCase } from '../../../../containers/case/use_get_case'; import { UserActionTree } from '../user_action_tree'; @@ -33,23 +21,13 @@ import { useUpdateCase } from '../../../../containers/case/use_update_case'; import { WrapperPage } from '../../../../components/wrapper_page'; import { getTypedPayload } from '../../../../containers/case/utils'; import { WhitePageWrapper } from '../wrappers'; -import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; -import { SiemPageName } from '../../../home/types'; -import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import { useBasePath } from '../../../../lib/kibana'; +import { CaseStatus } from '../case_status'; interface Props { caseId: string; } -const MyDescriptionList = styled(EuiDescriptionList)` - ${({ theme }) => css` - & { - padding-right: ${theme.eui.euiSizeL}; - border-right: ${theme.eui.euiBorderThin}; - } - `} -`; - const MyWrapper = styled(WrapperPage)` padding-bottom: 0; `; @@ -64,6 +42,8 @@ export interface CaseProps { } export const CaseComponent = React.memo(({ caseId, initialData }) => { + const basePath = window.location.origin + useBasePath(); + const caseLink = `${basePath}/app/siem#/case/${caseId}`; const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData); // Update Fields @@ -107,58 +87,44 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => return null; } }, - [updateCaseProperty, caseData.status] - ); - const toggleStatusCase = useCallback( - e => onUpdateField('status', e.target.checked ? 'open' : 'closed'), - [onUpdateField] + [caseData.status] ); - const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); - - // Delete case - const { - handleToggleModal, - handleOnDeleteConfirm, - isDeleted, - isDisplayConfirmDeleteModal, - } = useDeleteCases(); - - const confirmDeleteModal = useMemo( - () => ( - - ), - [isDisplayConfirmDeleteModal] + const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); + const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]); + + const caseStatusData = useMemo( + () => + caseData.status === 'open' + ? { + 'data-test-subj': 'case-view-createdAt', + value: caseData.createdAt, + title: i18n.CASE_OPENED, + buttonLabel: i18n.CLOSE_CASE, + status: caseData.status, + icon: 'checkInCircleFilled', + badgeColor: 'secondary', + isSelected: false, + } + : { + 'data-test-subj': 'case-view-closedAt', + value: caseData.closedAt, + title: i18n.CASE_CLOSED, + buttonLabel: i18n.REOPEN_CASE, + status: caseData.status, + icon: 'magnet', + badgeColor: 'danger', + isSelected: true, + }, + [caseData.closedAt, caseData.createdAt, caseData.status] + ); + const emailContent = useMemo( + () => ({ + subject: i18n.EMAIL_SUBJECT(caseData.title), + body: i18n.EMAIL_BODY(caseLink), + }), + [caseData.title] ); - // TO DO refactor each of these const's into their own components - const propertyActions = [ - { - iconType: 'trash', - label: 'Delete case', - onClick: handleToggleModal, - }, - { - iconType: 'popout', - label: 'View ServiceNow incident', - onClick: () => null, - }, - { - iconType: 'importAction', - label: 'Update ServiceNow incident', - onClick: () => null, - }, - ]; - - if (isDeleted) { - return ; - } - return ( <> @@ -177,51 +143,13 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => } title={caseData.title} > - - - - - - {i18n.STATUS} - - - {caseData.status} - - - - - {i18n.CASE_OPENED} - - - - - - - - - - - - - - - - - - + @@ -237,6 +165,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => @@ -250,7 +179,6 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => - {confirmDeleteModal} ); }); @@ -273,4 +201,5 @@ export const CaseView = React.memo(({ caseId }: Props) => { return ; }); +CaseComponent.displayName = 'CaseComponent'; CaseView.displayName = 'CaseView'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index 82b5e771e21513..e5fa3bff51f852 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -55,3 +55,19 @@ export const STATUS = i18n.translate('xpack.siem.case.caseView.statusLabel', { export const CASE_OPENED = i18n.translate('xpack.siem.case.caseView.caseOpened', { defaultMessage: 'Case opened', }); + +export const CASE_CLOSED = i18n.translate('xpack.siem.case.caseView.caseClosed', { + defaultMessage: 'Case closed', +}); + +export const EMAIL_SUBJECT = (caseTitle: string) => + i18n.translate('xpack.siem.case.caseView.emailSubject', { + values: { caseTitle }, + defaultMessage: 'SIEM Case - {caseTitle}', + }); + +export const EMAIL_BODY = (caseUrl: string) => + i18n.translate('xpack.siem.case.caseView.emailBody', { + values: { caseUrl }, + defaultMessage: 'Case reference: {caseUrl}', + }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx new file mode 100644 index 00000000000000..51acb3b810d92e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx @@ -0,0 +1,40 @@ +/* + * 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 { shallow } from 'enzyme'; +import { UserList } from './'; +import * as i18n from '../case_view/translations'; + +describe('UserList ', () => { + const title = 'Case Title'; + const caseLink = 'http://reddit.com'; + const user = { username: 'username', fullName: 'Full Name', email: 'testemail@elastic.co' }; + const open = jest.fn(); + beforeAll(() => { + window.open = open; + }); + beforeEach(() => { + jest.resetAllMocks(); + }); + it('triggers mailto when email icon clicked', () => { + const wrapper = shallow( + + ); + wrapper.find('[data-test-subj="user-list-email-button"]').simulate('click'); + expect(open).toBeCalledWith( + `mailto:${user.email}?subject=${i18n.EMAIL_SUBJECT(title)}&body=${i18n.EMAIL_BODY(caseLink)}`, + '_blank' + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx index abb49122dc1421..74a1b98c29eefa 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { EuiButtonIcon, EuiText, @@ -17,6 +17,10 @@ import styled, { css } from 'styled-components'; import { ElasticUser } from '../../../../containers/case/types'; interface UserListProps { + email: { + subject: string; + body: string; + }; headline: string; users: ElasticUser[]; } @@ -31,8 +35,11 @@ const MyFlexGroup = styled(EuiFlexGroup)` `} `; -const renderUsers = (users: ElasticUser[]) => { - return users.map(({ fullName, username }, key) => ( +const renderUsers = ( + users: ElasticUser[], + handleSendEmail: (emailAddress: string | undefined | null) => void +) => { + return users.map(({ fullName, username, email }, key) => ( @@ -50,7 +57,8 @@ const renderUsers = (users: ElasticUser[]) => { {}} // TO DO + data-test-subj="user-list-email-button" + onClick={handleSendEmail.bind(null, email)} // TO DO iconType="email" aria-label="email" /> @@ -59,12 +67,20 @@ const renderUsers = (users: ElasticUser[]) => { )); }; -export const UserList = React.memo(({ headline, users }: UserListProps) => { +export const UserList = React.memo(({ email, headline, users }: UserListProps) => { + const handleSendEmail = useCallback( + (emailAddress: string | undefined | null) => { + if (emailAddress && emailAddress != null) { + window.open(`mailto:${emailAddress}?subject=${email.subject}&body=${email.body}`, '_blank'); + } + }, + [email.subject] + ); return (

      {headline}

      - {renderUsers(users)} + {renderUsers(users, handleSendEmail)}
      ); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 6ef412d408ae5d..341a34240fe499 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -30,6 +30,16 @@ export const OPENED_ON = i18n.translate('xpack.siem.case.caseView.openedOn', { defaultMessage: 'Opened on', }); +export const CLOSED_ON = i18n.translate('xpack.siem.case.caseView.closedOn', { + defaultMessage: 'Closed on', +}); +export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', { + defaultMessage: 'Reopen case', +}); +export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', { + defaultMessage: 'Close case', +}); + export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', { defaultMessage: 'Reporter', }); diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 68a222cb656ed0..6f58e2702ec5bd 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -24,6 +24,8 @@ export const CaseAttributesRt = rt.intersection([ CaseBasicRt, rt.type({ comment_ids: rt.array(rt.string), + closed_at: rt.union([rt.string, rt.null]), + closed_by: rt.union([UserRT, rt.null]), created_at: rt.string, created_by: UserRT, updated_at: rt.union([rt.string, rt.null]), diff --git a/x-pack/plugins/case/common/api/user.ts b/x-pack/plugins/case/common/api/user.ts index ed44791c4e04d2..651cd08f08a021 100644 --- a/x-pack/plugins/case/common/api/user.ts +++ b/x-pack/plugins/case/common/api/user.ts @@ -7,6 +7,7 @@ import * as rt from 'io-ts'; export const UserRT = rt.type({ + email: rt.union([rt.undefined, rt.string]), full_name: rt.union([rt.undefined, rt.string]), username: rt.string, }); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts index 17a25184826378..c08dae1dc18b49 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts @@ -13,7 +13,11 @@ function createAuthenticationMock({ authc.getCurrentUser.mockReturnValue( currentUser !== undefined ? currentUser - : ({ username: 'awesome', full_name: 'Awesome D00d' } as AuthenticatedUser) + : ({ + email: 'd00d@awesome.com', + username: 'awesome', + full_name: 'Awesome D00d', + } as AuthenticatedUser) ); return authc; } diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 1e1965f83ff684..5aa8b93f17b080 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -12,10 +12,13 @@ export const mockCases: Array> = [ type: 'cases', id: 'mock-id-1', attributes: { + closed_at: null, + closed_by: null, comment_ids: ['mock-comment-1'], created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, description: 'This is a brand new case of a bad meanie defacing data', @@ -25,6 +28,7 @@ export const mockCases: Array> = [ updated_at: '2019-11-25T21:54:48.952Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -36,10 +40,13 @@ export const mockCases: Array> = [ type: 'cases', id: 'mock-id-2', attributes: { + closed_at: null, + closed_by: null, comment_ids: [], created_at: '2019-11-25T22:32:00.900Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, description: 'Oh no, a bad meanie destroying data!', @@ -49,6 +56,7 @@ export const mockCases: Array> = [ updated_at: '2019-11-25T22:32:00.900Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -60,10 +68,13 @@ export const mockCases: Array> = [ type: 'cases', id: 'mock-id-3', attributes: { + closed_at: null, + closed_by: null, comment_ids: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', @@ -73,6 +84,39 @@ export const mockCases: Array> = [ updated_at: '2019-11-25T22:32:17.947Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + references: [], + updated_at: '2019-11-25T22:32:17.947Z', + version: 'WzUsMV0=', + }, + { + type: 'cases', + id: 'mock-id-4', + attributes: { + closed_at: '2019-11-25T22:32:17.947Z', + closed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comment_ids: [], + created_at: '2019-11-25T22:32:17.947Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'Oh no, a bad meanie going LOLBins all over the place!', + title: 'Another bad one', + status: 'closed', + tags: ['LOLBins'], + updated_at: '2019-11-25T22:32:17.947Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -100,11 +144,13 @@ export const mockCaseComments: Array> = [ created_at: '2019-11-25T21:55:00.177Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, updated_at: '2019-11-25T21:55:00.177Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -126,11 +172,13 @@ export const mockCaseComments: Array> = [ created_at: '2019-11-25T21:55:14.633Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, updated_at: '2019-11-25T21:55:14.633Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -153,11 +201,13 @@ export const mockCaseComments: Array> = [ created_at: '2019-11-25T22:32:30.608Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, updated_at: '2019-11-25T22:32:30.608Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index 0166ba89eb76c8..c14a94e84e51c3 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -56,14 +56,14 @@ export function initPatchCommentApi({ caseService, router }: RouteDeps) { } const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const updatedComment = await caseService.patchComment({ client: context.core.savedObjects.client, commentId: query.id, updatedAttributes: { comment: query.comment, updated_at: new Date().toISOString(), - updated_by: { full_name, username }, + updated_by: { email, full_name, username }, }, version: query.version, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index 1da1161ab01d19..1542394fc438db 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -49,7 +49,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout } const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const updateDate = new Date().toISOString(); const patch = await caseConfigureService.patch({ @@ -58,7 +58,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout updatedAttributes: { ...queryWithoutVersion, updated_at: updateDate, - updated_by: { full_name, username }, + updated_by: { email, full_name, username }, }, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index a22dd8437e5087..c839d36dcf4dfb 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -43,7 +43,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route ); } const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const creationDate = new Date().toISOString(); const post = await caseConfigureService.post({ @@ -51,7 +51,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route attributes: { ...query, created_at: creationDate, - created_by: { full_name, username }, + created_by: { email, full_name, username }, updated_at: null, updated_by: null, }, diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts index 7ce37d2569e573..8fafb1af0eb826 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts @@ -34,6 +34,6 @@ describe('GET all cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.cases).toHaveLength(3); + expect(response.payload.cases).toHaveLength(4); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 7ab7212d2f436e..19ff7f0734a777 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -25,7 +25,7 @@ describe('PATCH cases', () => { toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), })); }); - it(`Patch a case`, async () => { + it(`Close a case`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', method: 'patch', @@ -50,17 +50,61 @@ describe('PATCH cases', () => { expect(response.status).toEqual(200); expect(response.payload).toEqual([ { + closed_at: '2019-11-25T21:54:48.952Z', + closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, comment_ids: ['mock-comment-1'], comments: [], created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'elastic', username: 'elastic' }, + created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', status: 'closed', tags: ['defacement'], title: 'Super Bad Security Issue', updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { full_name: 'Awesome D00d', username: 'awesome' }, + updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, + version: 'WzE3LDFd', + }, + ]); + }); + it(`Open a case`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'patch', + body: { + cases: [ + { + id: 'mock-id-4', + status: 'open', + version: 'WzUsMV0=', + }, + ], + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload).toEqual([ + { + closed_at: null, + closed_by: null, + comment_ids: [], + comments: [], + created_at: '2019-11-25T22:32:17.947Z', + created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, + description: 'Oh no, a bad meanie going LOLBins all over the place!', + id: 'mock-id-4', + status: 'open', + tags: ['LOLBins'], + title: 'Another bad one', + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', }, ]); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 3fd8c2a1627ab9..4aa0d8daf5b34a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -37,10 +37,23 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { client: context.core.savedObjects.client, caseIds: query.cases.map(q => q.id), }); + let nonExistingCases: CasePatchRequest[] = []; const conflictedCases = query.cases.filter(q => { const myCase = myCases.saved_objects.find(c => c.id === q.id); + + if (myCase && myCase.error) { + nonExistingCases = [...nonExistingCases, q]; + return false; + } return myCase == null || myCase?.version !== q.version; }); + if (nonExistingCases.length > 0) { + throw Boom.notFound( + `These cases ${nonExistingCases + .map(c => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } if (conflictedCases.length > 0) { throw Boom.conflict( `These cases ${conflictedCases @@ -60,18 +73,31 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { }); if (updateFilterCases.length > 0) { const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ client: context.core.savedObjects.client, cases: updateFilterCases.map(thisCase => { const { id: caseId, version, ...updateCaseAttributes } = thisCase; + let closedInfo = {}; + if (updateCaseAttributes.status && updateCaseAttributes.status === 'closed') { + closedInfo = { + closed_at: updatedDt, + closed_by: { email, full_name, username }, + }; + } else if (updateCaseAttributes.status && updateCaseAttributes.status === 'open') { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } return { caseId, updatedAttributes: { ...updateCaseAttributes, + ...closedInfo, updated_at: updatedDt, - updated_by: { full_name, username }, + updated_by: { email, full_name, username }, }, version, }; diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index eac259cc69c5a6..7af3e7b70d96fa 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -14,7 +14,7 @@ export interface RouteDeps { } export enum SortFieldCase { + closedAt = 'closed_at', createdAt = 'created_at', status = 'status', - updatedAt = 'updated_at', } diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 27ee6fc58e20a5..19dbb024d1e0be 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -26,18 +26,22 @@ import { SortFieldCase } from './types'; export const transformNewCase = ({ createdDate, - newCase, + email, full_name, + newCase, username, }: { createdDate: string; - newCase: CaseRequest; + email?: string; full_name?: string; + newCase: CaseRequest; username: string; }): CaseAttributes => ({ + closed_at: newCase.status === 'closed' ? createdDate : null, + closed_by: newCase.status === 'closed' ? { email, full_name, username } : null, comment_ids: [], created_at: createdDate, - created_by: { full_name, username }, + created_by: { email, full_name, username }, updated_at: null, updated_by: null, ...newCase, @@ -46,18 +50,20 @@ export const transformNewCase = ({ interface NewCommentArgs { comment: string; createdDate: string; + email?: string; full_name?: string; username: string; } export const transformNewComment = ({ comment, createdDate, + email, full_name, username, }: NewCommentArgs): CommentAttributes => ({ comment, created_at: createdDate, - created_by: { full_name, username }, + created_by: { email, full_name, username }, updated_at: null, updated_by: null, }); @@ -133,9 +139,9 @@ export const sortToSnake = (sortField: string): SortFieldCase => { case 'createdAt': case 'created_at': return SortFieldCase.createdAt; - case 'updatedAt': - case 'updated_at': - return SortFieldCase.updatedAt; + case 'closedAt': + case 'closed_at': + return SortFieldCase.closedAt; default: return SortFieldCase.createdAt; } diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 2aa64528739b10..8eab040b9ca9cd 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -14,6 +14,22 @@ export const caseSavedObjectType: SavedObjectsType = { namespaceAgnostic: false, mappings: { properties: { + closed_at: { + type: 'date', + }, + closed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, comment_ids: { type: 'keyword', }, @@ -28,6 +44,9 @@ export const caseSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, description: { @@ -53,6 +72,9 @@ export const caseSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, }, diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index 51c31421fec2fd..f52da886e7611b 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -28,6 +28,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { username: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, updated_at: { @@ -41,6 +44,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, }, From 304b322a47cb6f9d697a8516da23497764bb8a32 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 19 Mar 2020 17:32:39 +0100 Subject: [PATCH 03/32] [Console] Refactor and cleanup of public and server (#60513) * Clean up use of ace in autocomplete in public Remove ace from lib/autocomplete.ts and set up hooking up of ace in legacy_core_editor. Also remove use of ace mocks in tests. * Added TODO in lib/kb (console public) * Server-side cleanup Refactored the loading of spec into a new SpecDefinitionsService. In this way, state can be contained inside of the service as much as possible. Also converted all JS spec to TS and updated the Console plugin contract so that processors (which alter loaded spec) happen at plugin "start" phase. * Fix types * Small refactor - Updated naming of argument variable in registerAutocompleter - Refactored the SpecDefinitionsService to handle binding of it's own functions --- .../legacy_core_editor/legacy_core_editor.ts | 56 ++++++- .../__tests__/integration.test.js | 145 +++++++++-------- .../models/sense_editor/sense_editor.ts | 1 + .../url_autocomplete.test.js | 1 - .../url_params.test.js | 4 - .../public/lib/autocomplete/autocomplete.ts | 71 ++------- .../public/lib/autocomplete/body_completer.js | 1 - .../console/public/lib/autocomplete/engine.js | 2 +- src/plugins/console/public/lib/kb/kb.js | 4 + .../console/public/types/core_editor.ts | 12 ++ src/plugins/console/server/index.ts | 2 +- src/plugins/console/server/lib/index.ts | 2 +- .../server/lib/spec_definitions/api.js | 72 --------- .../console/server/lib/spec_definitions/es.js | 47 ------ .../{js/query/index.js => index.ts} | 2 +- .../js/{aggregations.js => aggregations.ts} | 28 ++-- .../js/{aliases.js => aliases.ts} | 8 +- .../js/{document.js => document.ts} | 12 +- .../js/{filter.js => filter.ts} | 10 +- .../js/{globals.js => globals.ts} | 11 +- .../{index.d.ts => js/index.ts} | 35 ++-- .../js/{ingest.js => ingest.ts} | 15 +- .../js/{mappings.js => mappings.ts} | 11 +- .../js/query/{dsl.js => dsl.ts} | 21 ++- .../{server.js => js/query/index.ts} | 8 +- .../js/query/{templates.js => templates.ts} | 12 ++ .../js/{reindex.js => reindex.ts} | 9 +- .../js/{search.js => search.ts} | 14 +- .../js/{settings.js => settings.ts} | 9 +- .../js/{shared.js => shared.ts} | 1 + .../server/lib/spec_definitions/json/index.js | 59 ------- src/plugins/console/server/plugin.ts | 26 +-- .../api/console/spec_definitions/index.ts | 24 ++- .../index.js => services/index.ts} | 8 +- .../services/spec_definitions_service.ts | 150 ++++++++++++++++++ src/plugins/console/server/types.ts | 5 + .../console_extensions/server/plugin.ts | 24 +-- 37 files changed, 497 insertions(+), 425 deletions(-) rename src/plugins/console/public/lib/autocomplete/{__tests__ => __jest__}/url_autocomplete.test.js (99%) rename src/plugins/console/public/lib/autocomplete/{__tests__ => __jest__}/url_params.test.js (95%) delete mode 100644 src/plugins/console/server/lib/spec_definitions/api.js delete mode 100644 src/plugins/console/server/lib/spec_definitions/es.js rename src/plugins/console/server/lib/spec_definitions/{js/query/index.js => index.ts} (94%) rename src/plugins/console/server/lib/spec_definitions/js/{aggregations.js => aggregations.ts} (95%) rename src/plugins/console/server/lib/spec_definitions/js/{aliases.js => aliases.ts} (80%) rename src/plugins/console/server/lib/spec_definitions/js/{document.js => document.ts} (85%) rename src/plugins/console/server/lib/spec_definitions/js/{filter.js => filter.ts} (94%) rename src/plugins/console/server/lib/spec_definitions/js/{globals.js => globals.ts} (85%) rename src/plugins/console/server/lib/spec_definitions/{index.d.ts => js/index.ts} (54%) rename src/plugins/console/server/lib/spec_definitions/js/{ingest.js => ingest.ts} (96%) rename src/plugins/console/server/lib/spec_definitions/js/{mappings.js => mappings.ts} (96%) rename src/plugins/console/server/lib/spec_definitions/js/query/{dsl.js => dsl.ts} (97%) rename src/plugins/console/server/lib/spec_definitions/{server.js => js/query/index.ts} (89%) rename src/plugins/console/server/lib/spec_definitions/js/query/{templates.js => templates.ts} (97%) rename src/plugins/console/server/lib/spec_definitions/js/{reindex.js => reindex.ts} (88%) rename src/plugins/console/server/lib/spec_definitions/js/{search.js => search.ts} (92%) rename src/plugins/console/server/lib/spec_definitions/js/{settings.js => settings.ts} (90%) rename src/plugins/console/server/lib/spec_definitions/js/{shared.js => shared.ts} (94%) delete mode 100644 src/plugins/console/server/lib/spec_definitions/json/index.js rename src/plugins/console/server/{lib/spec_definitions/index.js => services/index.ts} (81%) create mode 100644 src/plugins/console/server/services/spec_definitions_service.ts diff --git a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts index 47947e985092bc..fc419b0f10dcae 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts +++ b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts @@ -18,9 +18,17 @@ */ import ace from 'brace'; -import { Editor as IAceEditor } from 'brace'; +import { Editor as IAceEditor, IEditSession as IAceEditSession } from 'brace'; import $ from 'jquery'; -import { CoreEditor, Position, Range, Token, TokensProvider, EditorEvent } from '../../../types'; +import { + CoreEditor, + Position, + Range, + Token, + TokensProvider, + EditorEvent, + AutoCompleterFunction, +} from '../../../types'; import { AceTokensProvider } from '../../../lib/ace_token_provider'; import * as curl from '../sense_editor/curl'; import smartResize from './smart_resize'; @@ -354,4 +362,48 @@ export class LegacyCoreEditor implements CoreEditor { } } } + + registerAutocompleter(autocompleter: AutoCompleterFunction): void { + // Hook into Ace + + // disable standard context based autocompletion. + // @ts-ignore + ace.define('ace/autocomplete/text_completer', ['require', 'exports', 'module'], function( + require: any, + exports: any + ) { + exports.getCompletions = function( + innerEditor: any, + session: any, + pos: any, + prefix: any, + callback: any + ) { + callback(null, []); + }; + }); + + const langTools = ace.acequire('ace/ext/language_tools'); + + langTools.setCompleters([ + { + identifierRegexps: [ + /[a-zA-Z_0-9\.\$\-\u00A2-\uFFFF]/, // adds support for dot character + ], + getCompletions: ( + DO_NOT_USE_1: IAceEditor, + DO_NOT_USE_2: IAceEditSession, + pos: { row: number; column: number }, + prefix: string, + callback: (...args: any[]) => void + ) => { + const position: Position = { + lineNumber: pos.row + 1, + column: pos.column + 1, + }; + autocompleter(position, prefix, callback); + }, + }, + ]); + } } diff --git a/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js b/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js index 1a09b6b00da9c8..c5a0c2ebddf718 100644 --- a/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js +++ b/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js @@ -84,93 +84,90 @@ describe('Integration', () => { changeListener: function() {}, }; // mimic auto complete - senseEditor.autocomplete._test.getCompletions( - senseEditor, - null, - { row: cursor.lineNumber - 1, column: cursor.column - 1 }, - '', - function(err, terms) { - if (testToRun.assertThrows) { - done(); - return; - } + senseEditor.autocomplete._test.getCompletions(senseEditor, null, cursor, '', function( + err, + terms + ) { + if (testToRun.assertThrows) { + done(); + return; + } - if (err) { - throw err; - } + if (err) { + throw err; + } - if (testToRun.no_context) { - expect(!terms || terms.length === 0).toBeTruthy(); - } else { - expect(terms).not.toBeNull(); - expect(terms.length).toBeGreaterThan(0); - } + if (testToRun.no_context) { + expect(!terms || terms.length === 0).toBeTruthy(); + } else { + expect(terms).not.toBeNull(); + expect(terms.length).toBeGreaterThan(0); + } - if (!terms || terms.length === 0) { - done(); - return; - } + if (!terms || terms.length === 0) { + done(); + return; + } - if (testToRun.autoCompleteSet) { - const expectedTerms = _.map(testToRun.autoCompleteSet, function(t) { - if (typeof t !== 'object') { - t = { name: t }; - } - return t; - }); - if (terms.length !== expectedTerms.length) { - expect(_.pluck(terms, 'name')).toEqual(_.pluck(expectedTerms, 'name')); - } else { - const filteredActualTerms = _.map(terms, function(actualTerm, i) { - const expectedTerm = expectedTerms[i]; - const filteredTerm = {}; - _.each(expectedTerm, function(v, p) { - filteredTerm[p] = actualTerm[p]; - }); - return filteredTerm; - }); - expect(filteredActualTerms).toEqual(expectedTerms); + if (testToRun.autoCompleteSet) { + const expectedTerms = _.map(testToRun.autoCompleteSet, function(t) { + if (typeof t !== 'object') { + t = { name: t }; } + return t; + }); + if (terms.length !== expectedTerms.length) { + expect(_.pluck(terms, 'name')).toEqual(_.pluck(expectedTerms, 'name')); + } else { + const filteredActualTerms = _.map(terms, function(actualTerm, i) { + const expectedTerm = expectedTerms[i]; + const filteredTerm = {}; + _.each(expectedTerm, function(v, p) { + filteredTerm[p] = actualTerm[p]; + }); + return filteredTerm; + }); + expect(filteredActualTerms).toEqual(expectedTerms); } + } - const context = terms[0].context; - const { - cursor: { lineNumber, column }, - } = testToRun; - senseEditor.autocomplete._test.addReplacementInfoToContext( - context, - { lineNumber, column }, - terms[0].value - ); + const context = terms[0].context; + const { + cursor: { lineNumber, column }, + } = testToRun; + senseEditor.autocomplete._test.addReplacementInfoToContext( + context, + { lineNumber, column }, + terms[0].value + ); - function ac(prop, propTest) { - if (typeof testToRun[prop] !== 'undefined') { - if (propTest) { - propTest(context[prop], testToRun[prop], prop); - } else { - expect(context[prop]).toEqual(testToRun[prop]); - } + function ac(prop, propTest) { + if (typeof testToRun[prop] !== 'undefined') { + if (propTest) { + propTest(context[prop], testToRun[prop], prop); + } else { + expect(context[prop]).toEqual(testToRun[prop]); } } + } - function posCompare(actual, expected) { - expect(actual.lineNumber).toEqual(expected.lineNumber + lineOffset); - expect(actual.column).toEqual(expected.column); - } - - function rangeCompare(actual, expected, name) { - posCompare(actual.start, expected.start, name + '.start'); - posCompare(actual.end, expected.end, name + '.end'); - } + function posCompare(actual, expected) { + expect(actual.lineNumber).toEqual(expected.lineNumber + lineOffset); + expect(actual.column).toEqual(expected.column); + } - ac('prefixToAdd'); - ac('suffixToAdd'); - ac('addTemplate'); - ac('textBoxPosition', posCompare); - ac('rangeToReplace', rangeCompare); - done(); + function rangeCompare(actual, expected, name) { + posCompare(actual.start, expected.start, name + '.start'); + posCompare(actual.end, expected.end, name + '.end'); } - ); + + ac('prefixToAdd'); + ac('suffixToAdd'); + ac('addTemplate'); + ac('textBoxPosition', posCompare); + ac('rangeToReplace', rangeCompare); + done(); + }); }); } diff --git a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts b/src/plugins/console/public/application/models/sense_editor/sense_editor.ts index f559f5dfcd707e..b1444bdf2bbab4 100644 --- a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts +++ b/src/plugins/console/public/application/models/sense_editor/sense_editor.ts @@ -44,6 +44,7 @@ export class SenseEditor { coreEditor, parser: this.parser, }); + this.coreEditor.registerAutocompleter(this.autocomplete.getCompletions); this.coreEditor.on( 'tokenizerUpdate', this.highlightCurrentRequestsAndUpdateActionBar.bind(this) diff --git a/src/plugins/console/public/lib/autocomplete/__tests__/url_autocomplete.test.js b/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js similarity index 99% rename from src/plugins/console/public/lib/autocomplete/__tests__/url_autocomplete.test.js rename to src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js index 40fcd551fb6f76..0758a756955662 100644 --- a/src/plugins/console/public/lib/autocomplete/__tests__/url_autocomplete.test.js +++ b/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import '../../../application/models/sense_editor/sense_editor.test.mocks'; const _ = require('lodash'); import { diff --git a/src/plugins/console/public/lib/autocomplete/__tests__/url_params.test.js b/src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js similarity index 95% rename from src/plugins/console/public/lib/autocomplete/__tests__/url_params.test.js rename to src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js index ce2a2553b19eee..72fce53c4f1fef 100644 --- a/src/plugins/console/public/lib/autocomplete/__tests__/url_params.test.js +++ b/src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js @@ -16,10 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import '../../../application/models/sense_editor/sense_editor.test.mocks'; -import 'brace'; -import 'brace/mode/javascript'; -import 'brace/mode/json'; const _ = require('lodash'); import { UrlParams } from '../../autocomplete/url_params'; import { populateContext } from '../../autocomplete/engine'; diff --git a/src/plugins/console/public/lib/autocomplete/autocomplete.ts b/src/plugins/console/public/lib/autocomplete/autocomplete.ts index e09024ccfc8596..d4f10ff4e42771 100644 --- a/src/plugins/console/public/lib/autocomplete/autocomplete.ts +++ b/src/plugins/console/public/lib/autocomplete/autocomplete.ts @@ -18,9 +18,9 @@ */ import _ from 'lodash'; -import ace, { Editor as AceEditor, IEditSession } from 'brace'; import { i18n } from '@kbn/i18n'; +// TODO: All of these imports need to be moved to the core editor so that it can inject components from there. import { getTopLevelUrlCompleteComponents, getEndpointBodyCompleteComponents, @@ -39,7 +39,7 @@ import { createTokenIterator } from '../../application/factories'; import { Position, Token, Range, CoreEditor } from '../../types'; -let LAST_EVALUATED_TOKEN: any = null; +let lastEvaluatedToken: any = null; function isUrlParamsToken(token: any) { switch ((token || {}).type) { @@ -889,7 +889,7 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor if (!currentToken) { if (pos.lineNumber === 1) { - LAST_EVALUATED_TOKEN = null; + lastEvaluatedToken = null; return; } currentToken = { position: { column: 0, lineNumber: 0 }, value: '', type: '' }; // empty row @@ -902,26 +902,26 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor if (parser.isEmptyToken(nextToken)) { // Empty line, or we're not on the edge of current token. Save the current position as base currentToken.position.column = pos.column; - LAST_EVALUATED_TOKEN = currentToken; + lastEvaluatedToken = currentToken; } else { nextToken.position.lineNumber = pos.lineNumber; - LAST_EVALUATED_TOKEN = nextToken; + lastEvaluatedToken = nextToken; } return; } - if (!LAST_EVALUATED_TOKEN) { - LAST_EVALUATED_TOKEN = currentToken; + if (!lastEvaluatedToken) { + lastEvaluatedToken = currentToken; return; // wait for the next typing. } if ( - LAST_EVALUATED_TOKEN.position.column !== currentToken.position.column || - LAST_EVALUATED_TOKEN.position.lineNumber !== currentToken.position.lineNumber || - LAST_EVALUATED_TOKEN.value === currentToken.value + lastEvaluatedToken.position.column !== currentToken.position.column || + lastEvaluatedToken.position.lineNumber !== currentToken.position.lineNumber || + lastEvaluatedToken.value === currentToken.value ) { // not on the same place or nothing changed, cache and wait for the next time - LAST_EVALUATED_TOKEN = currentToken; + lastEvaluatedToken = currentToken; return; } @@ -935,7 +935,7 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor return; } - LAST_EVALUATED_TOKEN = currentToken; + lastEvaluatedToken = currentToken; editor.execCommand('startAutocomplete'); }, 100); @@ -947,17 +947,7 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor } } - function getCompletions( - DO_NOT_USE: AceEditor, - DO_NOT_USE_SESSION: IEditSession, - pos: { row: number; column: number }, - prefix: string, - callback: (...args: any[]) => void - ) { - const position: Position = { - lineNumber: pos.row + 1, - column: pos.column + 1, - }; + function getCompletions(position: Position, prefix: string, callback: (...args: any[]) => void) { try { const context = getAutoCompleteContext(editor, position); if (!context) { @@ -1028,39 +1018,12 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor editor.on('changeSelection', editorChangeListener); - // Hook into Ace - - // disable standard context based autocompletion. - // @ts-ignore - ace.define('ace/autocomplete/text_completer', ['require', 'exports', 'module'], function( - require: any, - exports: any - ) { - exports.getCompletions = function( - innerEditor: any, - session: any, - pos: any, - prefix: any, - callback: any - ) { - callback(null, []); - }; - }); - - const langTools = ace.acequire('ace/ext/language_tools'); - - langTools.setCompleters([ - { - identifierRegexps: [ - /[a-zA-Z_0-9\.\$\-\u00A2-\uFFFF]/, // adds support for dot character - ], - getCompletions, - }, - ]); - return { + getCompletions, + // TODO: This needs to be cleaned up _test: { - getCompletions, + getCompletions: (_editor: any, _editSession: any, pos: any, prefix: any, callback: any) => + getCompletions(pos, prefix, callback), addReplacementInfoToContext, addChangeListener: () => editor.on('changeSelection', editorChangeListener), removeChangeListener: () => editor.off('changeSelection', editorChangeListener), diff --git a/src/plugins/console/public/lib/autocomplete/body_completer.js b/src/plugins/console/public/lib/autocomplete/body_completer.js index e23a58780a362a..1aa315c50b9bf1 100644 --- a/src/plugins/console/public/lib/autocomplete/body_completer.js +++ b/src/plugins/console/public/lib/autocomplete/body_completer.js @@ -115,7 +115,6 @@ class ScopeResolver extends SharedComponent { next: [], }; const components = this.resolveLinkToComponents(context, editor); - _.each(components, function(component) { const componentResult = component.match(token, context, editor); if (componentResult && componentResult.next) { diff --git a/src/plugins/console/public/lib/autocomplete/engine.js b/src/plugins/console/public/lib/autocomplete/engine.js index f4df8af871ebac..7b64d91c953742 100644 --- a/src/plugins/console/public/lib/autocomplete/engine.js +++ b/src/plugins/console/public/lib/autocomplete/engine.js @@ -43,7 +43,7 @@ export function wrapComponentWithDefaults(component, defaults) { const tracer = function() { if (window.engine_trace) { - console.log.call(console, arguments); + console.log.call(console, ...arguments); } }; diff --git a/src/plugins/console/public/lib/kb/kb.js b/src/plugins/console/public/lib/kb/kb.js index 053b82bd81d0a5..ef921fa7f476e3 100644 --- a/src/plugins/console/public/lib/kb/kb.js +++ b/src/plugins/console/public/lib/kb/kb.js @@ -146,6 +146,10 @@ function loadApisFromJson( return api; } +// TODO: clean up setting up of active API and use of jQuery. +// This function should be attached to a class that holds the current state, not setup +// when the file is required. Also, jQuery should not be used to make network requests +// like this, it looks like a minor security issue. export function setActiveApi(api) { if (!api) { $.ajax({ diff --git a/src/plugins/console/public/types/core_editor.ts b/src/plugins/console/public/types/core_editor.ts index 79dc3ca74200b2..b71f4fff44ca5f 100644 --- a/src/plugins/console/public/types/core_editor.ts +++ b/src/plugins/console/public/types/core_editor.ts @@ -29,6 +29,12 @@ export type EditorEvent = | 'change' | 'changeSelection'; +export type AutoCompleterFunction = ( + pos: Position, + prefix: string, + callback: (...args: any[]) => void +) => void; + export interface Position { /** * The line number, not zero-indexed. @@ -256,4 +262,10 @@ export interface CoreEditor { * Register a keyboard shortcut and provide a function to be called. */ registerKeyboardShortcut(opts: { keys: any; fn: () => void; name: string }): void; + + /** + * Register a completions function that will be called when the editor + * detects a change + */ + registerAutocompleter(autocompleter: AutoCompleterFunction): void; } diff --git a/src/plugins/console/server/index.ts b/src/plugins/console/server/index.ts index b603deee12e23d..62e5bd6bf8d950 100644 --- a/src/plugins/console/server/index.ts +++ b/src/plugins/console/server/index.ts @@ -21,7 +21,7 @@ import { PluginConfigDescriptor, PluginInitializerContext } from 'kibana/server' import { ConfigType, config as configSchema } from './config'; import { ConsoleServerPlugin } from './plugin'; -export { ConsoleSetup } from './types'; +export { ConsoleSetup, ConsoleStart } from './types'; export const plugin = (ctx: PluginInitializerContext) => new ConsoleServerPlugin(ctx); diff --git a/src/plugins/console/server/lib/index.ts b/src/plugins/console/server/lib/index.ts index 2347084b73a667..0c8fc125874cf0 100644 --- a/src/plugins/console/server/lib/index.ts +++ b/src/plugins/console/server/lib/index.ts @@ -22,4 +22,4 @@ export { ProxyConfigCollection } from './proxy_config_collection'; export { proxyRequest } from './proxy_request'; export { getElasticsearchProxyConfig } from './elasticsearch_proxy_config'; export { setHeaders } from './set_headers'; -export { addProcessorDefinition, addExtensionSpecFilePath, loadSpec } from './spec_definitions'; +export { jsSpecLoaders } from './spec_definitions'; diff --git a/src/plugins/console/server/lib/spec_definitions/api.js b/src/plugins/console/server/lib/spec_definitions/api.js deleted file mode 100644 index 9c3835013bce98..00000000000000 --- a/src/plugins/console/server/lib/spec_definitions/api.js +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; - -class Api { - constructor(name) { - this.globalRules = {}; - this.endpoints = {}; - this.name = name; - } - - addGlobalAutocompleteRules = (parentNode, rules) => { - this.globalRules[parentNode] = rules; - }; - - addEndpointDescription = (endpoint, description = {}) => { - let copiedDescription = {}; - if (this.endpoints[endpoint]) { - copiedDescription = { ...this.endpoints[endpoint] }; - } - let urlParamsDef; - _.each(description.patterns || [], function(p) { - if (p.indexOf('{indices}') >= 0) { - urlParamsDef = urlParamsDef || {}; - urlParamsDef.ignore_unavailable = '__flag__'; - urlParamsDef.allow_no_indices = '__flag__'; - urlParamsDef.expand_wildcards = ['open', 'closed']; - } - }); - - if (urlParamsDef) { - description.url_params = _.extend(description.url_params || {}, copiedDescription.url_params); - _.defaults(description.url_params, urlParamsDef); - } - - _.extend(copiedDescription, description); - _.defaults(copiedDescription, { - id: endpoint, - patterns: [endpoint], - methods: ['GET'], - }); - - this.endpoints[endpoint] = copiedDescription; - }; - - asJson() { - return { - name: this.name, - globals: this.globalRules, - endpoints: this.endpoints, - }; - } -} - -export default Api; diff --git a/src/plugins/console/server/lib/spec_definitions/es.js b/src/plugins/console/server/lib/spec_definitions/es.js deleted file mode 100644 index fc24a64f8a6f44..00000000000000 --- a/src/plugins/console/server/lib/spec_definitions/es.js +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Api from './api'; -import { getSpec } from './json'; -import { register } from './js/ingest'; -const ES = new Api('es'); - -export const loadSpec = () => { - const spec = getSpec(); - - // adding generated specs - Object.keys(spec).forEach(endpoint => { - ES.addEndpointDescription(endpoint, spec[endpoint]); - }); - - // adding globals and custom API definitions - require('./js/aliases')(ES); - require('./js/aggregations')(ES); - require('./js/document')(ES); - require('./js/filter')(ES); - require('./js/globals')(ES); - register(ES); - require('./js/mappings')(ES); - require('./js/settings')(ES); - require('./js/query')(ES); - require('./js/reindex')(ES); - require('./js/search')(ES); -}; - -export default ES; diff --git a/src/plugins/console/server/lib/spec_definitions/js/query/index.js b/src/plugins/console/server/lib/spec_definitions/index.ts similarity index 94% rename from src/plugins/console/server/lib/spec_definitions/js/query/index.js rename to src/plugins/console/server/lib/spec_definitions/index.ts index cbe4e7ed2dd5f4..7c70c406d8c225 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/query/index.js +++ b/src/plugins/console/server/lib/spec_definitions/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { queryDsl as default } from './dsl'; +export { jsSpecLoaders } from './js'; diff --git a/src/plugins/console/server/lib/spec_definitions/js/aggregations.js b/src/plugins/console/server/lib/spec_definitions/js/aggregations.ts similarity index 95% rename from src/plugins/console/server/lib/spec_definitions/js/aggregations.js rename to src/plugins/console/server/lib/spec_definitions/js/aggregations.ts index 629e143aa2b43c..1170c9edd2366f 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/aggregations.js +++ b/src/plugins/console/server/lib/spec_definitions/js/aggregations.ts @@ -16,8 +16,9 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -/*eslint camelcase: 0*/ +/* eslint-disable @typescript-eslint/camelcase */ const significantTermsArgs = { __template: { field: '', @@ -77,7 +78,7 @@ const simple_pipeline = { }, buckets_path: '', format: '', - gap_policy: gap_policy, + gap_policy, }; const rules = { '*': { @@ -461,7 +462,7 @@ const rules = { }, buckets_path: '', format: '', - gap_policy: gap_policy, + gap_policy, window: 5, model: { __one_of: ['simple', 'linear', 'ewma', 'holt', 'holt_winters'] }, settings: { @@ -485,7 +486,7 @@ const rules = { lag: 7, }, lag: 7, - gap_policy: gap_policy, + gap_policy, buckets_path: '', format: '', }, @@ -496,7 +497,7 @@ const rules = { }, buckets_path: {}, format: '', - gap_policy: gap_policy, + gap_policy, script: '', }, bucket_selector: { @@ -505,7 +506,7 @@ const rules = { script: '', }, buckets_path: {}, - gap_policy: gap_policy, + gap_policy, script: '', }, bucket_sort: { @@ -515,7 +516,7 @@ const rules = { sort: ['{field}'], from: 0, size: 0, - gap_policy: gap_policy, + gap_policy, }, matrix_stats: { __template: { @@ -526,8 +527,11 @@ const rules = { }, }; const { terms, histogram, date_histogram } = rules['*']; -export default function(api) { - api.addGlobalAutocompleteRules('aggregations', rules); - api.addGlobalAutocompleteRules('aggs', rules); - api.addGlobalAutocompleteRules('groupByAggs', { '*': { terms, histogram, date_histogram } }); -} + +export const aggs = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('aggregations', rules); + specService.addGlobalAutocompleteRules('aggs', rules); + specService.addGlobalAutocompleteRules('groupByAggs', { + '*': { terms, histogram, date_histogram }, + }); +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/aliases.js b/src/plugins/console/server/lib/spec_definitions/js/aliases.ts similarity index 80% rename from src/plugins/console/server/lib/spec_definitions/js/aliases.js rename to src/plugins/console/server/lib/spec_definitions/js/aliases.ts index f46713fb8dd3f2..c7d51b70ab3e33 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/aliases.js +++ b/src/plugins/console/server/lib/spec_definitions/js/aliases.ts @@ -16,15 +16,17 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -export default function(api) { +/* eslint-disable @typescript-eslint/camelcase */ +export const aliases = (specService: SpecDefinitionsService) => { const aliasRules = { filter: {}, routing: '1', search_routing: '1,2', index_routing: '1', }; - api.addGlobalAutocompleteRules('aliases', { + specService.addGlobalAutocompleteRules('aliases', { '*': aliasRules, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/document.js b/src/plugins/console/server/lib/spec_definitions/js/document.ts similarity index 85% rename from src/plugins/console/server/lib/spec_definitions/js/document.js rename to src/plugins/console/server/lib/spec_definitions/js/document.ts index 2bdaa2ec2af9b4..f8214faab26819 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/document.js +++ b/src/plugins/console/server/lib/spec_definitions/js/document.ts @@ -16,9 +16,11 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -export default function(api) { - api.addEndpointDescription('update', { +/* eslint-disable @typescript-eslint/camelcase */ +export const document = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('update', { data_autocomplete_rules: { script: { // populated by a global rule @@ -29,7 +31,7 @@ export default function(api) { }, }); - api.addEndpointDescription('put_script', { + specService.addEndpointDescription('put_script', { methods: ['POST', 'PUT'], patterns: ['_scripts/{lang}/{id}', '_scripts/{lang}/{id}/_create'], url_components: { @@ -40,7 +42,7 @@ export default function(api) { }, }); - api.addEndpointDescription('termvectors', { + specService.addEndpointDescription('termvectors', { data_autocomplete_rules: { fields: ['{field}'], offsets: { __one_of: [false, true] }, @@ -68,4 +70,4 @@ export default function(api) { }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/filter.js b/src/plugins/console/server/lib/spec_definitions/js/filter.ts similarity index 94% rename from src/plugins/console/server/lib/spec_definitions/js/filter.js rename to src/plugins/console/server/lib/spec_definitions/js/filter.ts index bf669cff788e8a..27e02f7cf18375 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/filter.js +++ b/src/plugins/console/server/lib/spec_definitions/js/filter.ts @@ -16,8 +16,10 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -const filters = {}; +/* eslint-disable @typescript-eslint/camelcase */ +const filters: Record = {}; filters.and = { __template: { @@ -324,6 +326,6 @@ filters.nested = { _name: '', }; -export default function(api) { - api.addGlobalAutocompleteRules('filter', filters); -} +export const filter = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('filter', filters); +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/globals.js b/src/plugins/console/server/lib/spec_definitions/js/globals.ts similarity index 85% rename from src/plugins/console/server/lib/spec_definitions/js/globals.js rename to src/plugins/console/server/lib/spec_definitions/js/globals.ts index 316a76c8c94342..32e1957f74d0ba 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/globals.js +++ b/src/plugins/console/server/lib/spec_definitions/js/globals.ts @@ -16,7 +16,9 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; +/* eslint-disable @typescript-eslint/camelcase */ const highlightOptions = { boundary_chars: {}, boundary_max_scan: 20, @@ -48,8 +50,9 @@ const highlightOptions = { }, tags_schema: {}, }; -export default function(api) { - api.addGlobalAutocompleteRules('highlight', { + +export const globals = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('highlight', { ...highlightOptions, fields: { '{field}': { @@ -60,7 +63,7 @@ export default function(api) { }, }); - api.addGlobalAutocompleteRules('script', { + specService.addGlobalAutocompleteRules('script', { __template: { source: 'SCRIPT', }, @@ -70,4 +73,4 @@ export default function(api) { lang: '', params: {}, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/index.d.ts b/src/plugins/console/server/lib/spec_definitions/js/index.ts similarity index 54% rename from src/plugins/console/server/lib/spec_definitions/index.d.ts rename to src/plugins/console/server/lib/spec_definitions/js/index.ts index da0125a186c152..234ccd22aaa8b3 100644 --- a/src/plugins/console/server/lib/spec_definitions/index.d.ts +++ b/src/plugins/console/server/lib/spec_definitions/js/index.ts @@ -17,15 +17,30 @@ * under the License. */ -export declare function addProcessorDefinition(...args: any[]): any; +import { SpecDefinitionsService } from '../../../services'; -export declare function resolveApi(): object; +import { aggs } from './aggregations'; +import { aliases } from './aliases'; +import { document } from './document'; +import { filter } from './filter'; +import { globals } from './globals'; +import { ingest } from './ingest'; +import { mappings } from './mappings'; +import { settings } from './settings'; +import { query } from './query'; +import { reindex } from './reindex'; +import { search } from './search'; -export declare function addExtensionSpecFilePath(...args: any[]): any; - -/** - * A function that synchronously reads files JSON from disk and builds - * the autocomplete structures served to the client. This must be called - * after any extensions have been loaded. - */ -export declare function loadSpec(): any; +export const jsSpecLoaders: Array<(registry: SpecDefinitionsService) => void> = [ + aggs, + aliases, + document, + filter, + globals, + ingest, + mappings, + settings, + query, + reindex, + search, +]; diff --git a/src/plugins/console/server/lib/spec_definitions/js/ingest.js b/src/plugins/console/server/lib/spec_definitions/js/ingest.ts similarity index 96% rename from src/plugins/console/server/lib/spec_definitions/js/ingest.js rename to src/plugins/console/server/lib/spec_definitions/js/ingest.ts index edc9cc7b3e45cc..1182dc075f42f2 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/ingest.js +++ b/src/plugins/console/server/lib/spec_definitions/js/ingest.ts @@ -17,6 +17,9 @@ * under the License. */ +import { SpecDefinitionsService } from '../../../services'; + +/* eslint-disable @typescript-eslint/camelcase */ const commonPipelineParams = { on_failure: [], ignore_failure: { @@ -427,27 +430,23 @@ const pipelineDefinition = { version: 123, }; -export const register = api => { +export const ingest = (specService: SpecDefinitionsService) => { // Note: this isn't an actual API endpoint. It exists so the forEach processor's "processor" field // may recursively use the autocomplete rules for any processor. - api.addEndpointDescription('_processor', { + specService.addEndpointDescription('_processor', { data_autocomplete_rules: processorDefinition, }); - api.addEndpointDescription('ingest.put_pipeline', { + specService.addEndpointDescription('ingest.put_pipeline', { methods: ['PUT'], patterns: ['_ingest/pipeline/{id}'], data_autocomplete_rules: pipelineDefinition, }); - api.addEndpointDescription('ingest.simulate', { + specService.addEndpointDescription('ingest.simulate', { data_autocomplete_rules: { pipeline: pipelineDefinition, docs: [], }, }); }; - -export const addProcessorDefinition = processor => { - processorDefinition.__one_of.push(processor); -}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/mappings.js b/src/plugins/console/server/lib/spec_definitions/js/mappings.ts similarity index 96% rename from src/plugins/console/server/lib/spec_definitions/js/mappings.js rename to src/plugins/console/server/lib/spec_definitions/js/mappings.ts index 5884d14d4dc8b1..8491bc17a2ff6d 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/mappings.js +++ b/src/plugins/console/server/lib/spec_definitions/js/mappings.ts @@ -17,12 +17,15 @@ * under the License. */ -const _ = require('lodash'); +import _ from 'lodash'; + +import { SpecDefinitionsService } from '../../../services'; import { BOOLEAN } from './shared'; -export default function(api) { - api.addEndpointDescription('put_mapping', { +/* eslint-disable @typescript-eslint/camelcase */ +export const mappings = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('put_mapping', { priority: 10, // collides with put doc by id data_autocomplete_rules: { __template: { @@ -249,4 +252,4 @@ export default function(api) { }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/query/dsl.js b/src/plugins/console/server/lib/spec_definitions/js/query/dsl.ts similarity index 97% rename from src/plugins/console/server/lib/spec_definitions/js/query/dsl.js rename to src/plugins/console/server/lib/spec_definitions/js/query/dsl.ts index 16b952fe0fe4ff..d6e5030fb6928f 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/query/dsl.js +++ b/src/plugins/console/server/lib/spec_definitions/js/query/dsl.ts @@ -18,6 +18,9 @@ */ import _ from 'lodash'; + +import { SpecDefinitionsService } from '../../../../services'; + import { spanFirstTemplate, spanNearTemplate, @@ -32,6 +35,8 @@ import { rangeTemplate, regexpTemplate, } from './templates'; + +/* eslint-disable @typescript-eslint/camelcase */ const matchOptions = { cutoff_frequency: 0.001, query: '', @@ -57,6 +62,7 @@ const matchOptions = { prefix_length: 1, minimum_should_match: 1, }; + const innerHits = { docvalue_fields: ['FIELD'], from: {}, @@ -84,6 +90,7 @@ const innerHits = { __one_of: ['true', 'false'], }, }; + const SPAN_QUERIES_NO_FIELD_MASK = { // TODO add one_of for objects span_first: { @@ -115,6 +122,7 @@ const SPAN_QUERIES_NO_FIELD_MASK = { __scope_link: '.span_within', }, }; + const SPAN_QUERIES = { ...SPAN_QUERIES_NO_FIELD_MASK, field_masking_span: { @@ -165,13 +173,14 @@ const DECAY_FUNC_DESC = { decay: 0.5, }, }; + const SCORING_FUNCS = { script_score: { __template: { script: "_score * doc['f'].value", }, script: { - //populated by a global rule + // populated by a global rule }, }, boost_factor: 2.0, @@ -204,8 +213,8 @@ const SCORING_FUNCS = { }, }; -export function queryDsl(api) { - api.addGlobalAutocompleteRules('query', { +export const query = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('query', { match: { __template: { FIELD: 'TEXT', @@ -631,7 +640,7 @@ export function queryDsl(api) { filter: {}, boost: 2.0, script: { - //populated by a global rule + // populated by a global rule }, }, ], @@ -695,7 +704,7 @@ export function queryDsl(api) { script: "_score * doc['f'].value", }, script: { - //populated by a global rule + // populated by a global rule }, }, wrapper: { @@ -705,4 +714,4 @@ export function queryDsl(api) { query: '', }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/server.js b/src/plugins/console/server/lib/spec_definitions/js/query/index.ts similarity index 89% rename from src/plugins/console/server/lib/spec_definitions/server.js rename to src/plugins/console/server/lib/spec_definitions/js/query/index.ts index cb855958d403a5..f4f896fd7814cc 100644 --- a/src/plugins/console/server/lib/spec_definitions/server.js +++ b/src/plugins/console/server/lib/spec_definitions/js/query/index.ts @@ -17,10 +17,4 @@ * under the License. */ -import es from './es'; - -export function resolveApi() { - return { - es: es.asJson(), - }; -} +export { query } from './dsl'; diff --git a/src/plugins/console/server/lib/spec_definitions/js/query/templates.js b/src/plugins/console/server/lib/spec_definitions/js/query/templates.ts similarity index 97% rename from src/plugins/console/server/lib/spec_definitions/js/query/templates.js rename to src/plugins/console/server/lib/spec_definitions/js/query/templates.ts index 9b6311bf5712e4..60192f81fec803 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/query/templates.js +++ b/src/plugins/console/server/lib/spec_definitions/js/query/templates.ts @@ -17,23 +17,28 @@ * under the License. */ +/* eslint-disable @typescript-eslint/camelcase */ export const regexpTemplate = { FIELD: 'REGEXP', }; + export const fuzzyTemplate = { FIELD: {}, }; + export const prefixTemplate = { FIELD: { value: '', }, }; + export const rangeTemplate = { FIELD: { gte: 10, lte: 20, }, }; + export const spanFirstTemplate = { match: { span_term: { @@ -42,6 +47,7 @@ export const spanFirstTemplate = { }, end: 3, }; + export const spanNearTemplate = { clauses: [ { @@ -55,11 +61,13 @@ export const spanNearTemplate = { slop: 12, in_order: false, }; + export const spanTermTemplate = { FIELD: { value: 'VALUE', }, }; + export const spanNotTemplate = { include: { span_term: { @@ -76,6 +84,7 @@ export const spanNotTemplate = { }, }, }; + export const spanOrTemplate = { clauses: [ { @@ -87,6 +96,7 @@ export const spanOrTemplate = { }, ], }; + export const spanContainingTemplate = { little: { span_term: { @@ -118,6 +128,7 @@ export const spanContainingTemplate = { }, }, }; + export const spanWithinTemplate = { little: { span_term: { @@ -149,6 +160,7 @@ export const spanWithinTemplate = { }, }, }; + export const wildcardTemplate = { FIELD: { value: 'VALUE', diff --git a/src/plugins/console/server/lib/spec_definitions/js/reindex.js b/src/plugins/console/server/lib/spec_definitions/js/reindex.ts similarity index 88% rename from src/plugins/console/server/lib/spec_definitions/js/reindex.js rename to src/plugins/console/server/lib/spec_definitions/js/reindex.ts index 45163d2b3c4c30..862a4323f7bf3a 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/reindex.js +++ b/src/plugins/console/server/lib/spec_definitions/js/reindex.ts @@ -17,8 +17,11 @@ * under the License. */ -export default function(api) { - api.addEndpointDescription('reindex', { +import { SpecDefinitionsService } from '../../../services'; + +/* eslint-disable @typescript-eslint/camelcase */ +export const reindex = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('reindex', { methods: ['POST'], patterns: ['_reindex'], data_autocomplete_rules: { @@ -62,4 +65,4 @@ export default function(api) { script: { __scope_link: 'GLOBAL.script' }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/search.js b/src/plugins/console/server/lib/spec_definitions/js/search.ts similarity index 92% rename from src/plugins/console/server/lib/spec_definitions/js/search.js rename to src/plugins/console/server/lib/spec_definitions/js/search.ts index 19ce30d9929a59..e319870d7be5cd 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/search.js +++ b/src/plugins/console/server/lib/spec_definitions/js/search.ts @@ -16,9 +16,11 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -export default function(api) { - api.addEndpointDescription('search', { +/* eslint-disable @typescript-eslint/camelcase */ +export const search = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('search', { priority: 10, // collides with get doc by id data_autocomplete_rules: { query: { @@ -191,7 +193,7 @@ export default function(api) { }, }); - api.addEndpointDescription('search_template', { + specService.addEndpointDescription('search_template', { data_autocomplete_rules: { template: { __one_of: [{ __scope_link: 'search' }, { __scope_link: 'GLOBAL.script' }], @@ -200,18 +202,18 @@ export default function(api) { }, }); - api.addEndpointDescription('render_search_template', { + specService.addEndpointDescription('render_search_template', { data_autocomplete_rules: { __one_of: [{ source: { __scope_link: 'search' } }, { __scope_link: 'GLOBAL.script' }], params: {}, }, }); - api.addEndpointDescription('_search/template/{id}', { + specService.addEndpointDescription('_search/template/{id}', { data_autocomplete_rules: { template: { __scope_link: 'search', }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/settings.js b/src/plugins/console/server/lib/spec_definitions/js/settings.ts similarity index 90% rename from src/plugins/console/server/lib/spec_definitions/js/settings.js rename to src/plugins/console/server/lib/spec_definitions/js/settings.ts index 26cd0987c34a5b..88c58e618533bc 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/settings.js +++ b/src/plugins/console/server/lib/spec_definitions/js/settings.ts @@ -16,11 +16,12 @@ * specific language governing permissions and limitations * under the License. */ - +import { SpecDefinitionsService } from '../../../services'; import { BOOLEAN } from './shared'; -export default function(api) { - api.addEndpointDescription('put_settings', { +/* eslint-disable @typescript-eslint/camelcase */ +export const settings = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('put_settings', { data_autocomplete_rules: { refresh_interval: '1s', number_of_shards: 1, @@ -71,4 +72,4 @@ export default function(api) { }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/shared.js b/src/plugins/console/server/lib/spec_definitions/js/shared.ts similarity index 94% rename from src/plugins/console/server/lib/spec_definitions/js/shared.js rename to src/plugins/console/server/lib/spec_definitions/js/shared.ts index ace189e2d09138..a884e1aebe2e70 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/shared.js +++ b/src/plugins/console/server/lib/spec_definitions/js/shared.ts @@ -17,6 +17,7 @@ * under the License. */ +/* eslint-disable @typescript-eslint/camelcase */ export const BOOLEAN = Object.freeze({ __one_of: [true, false], }); diff --git a/src/plugins/console/server/lib/spec_definitions/json/index.js b/src/plugins/console/server/lib/spec_definitions/json/index.js deleted file mode 100644 index 19f075e897dbbc..00000000000000 --- a/src/plugins/console/server/lib/spec_definitions/json/index.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import glob from 'glob'; -import { join, basename } from 'path'; -import { readFileSync } from 'fs'; -import { merge } from 'lodash'; - -const extensionSpecFilePaths = []; -function _getSpec(dirname = __dirname) { - const generatedFiles = glob.sync(join(dirname, 'generated', '*.json')); - const overrideFiles = glob.sync(join(dirname, 'overrides', '*.json')); - - return generatedFiles.reduce((acc, file) => { - const overrideFile = overrideFiles.find(f => basename(f) === basename(file)); - const loadedSpec = JSON.parse(readFileSync(file, 'utf8')); - if (overrideFile) { - merge(loadedSpec, JSON.parse(readFileSync(overrideFile, 'utf8'))); - } - const spec = {}; - Object.entries(loadedSpec).forEach(([key, value]) => { - if (acc[key]) { - // add time to remove key collision - spec[`${key}${Date.now()}`] = value; - } else { - spec[key] = value; - } - }); - - return { ...acc, ...spec }; - }, {}); -} -export function getSpec() { - const result = _getSpec(); - extensionSpecFilePaths.forEach(extensionSpecFilePath => { - merge(result, _getSpec(extensionSpecFilePath)); - }); - return result; -} - -export function addExtensionSpecFilePath(extensionSpecFilePath) { - extensionSpecFilePaths.push(extensionSpecFilePath); -} diff --git a/src/plugins/console/server/plugin.ts b/src/plugins/console/server/plugin.ts index 1954918f4d74f6..85b728ea838912 100644 --- a/src/plugins/console/server/plugin.ts +++ b/src/plugins/console/server/plugin.ts @@ -21,20 +21,18 @@ import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/serv import { readLegacyEsConfig } from '../../../legacy/core_plugins/console_legacy'; -import { - ProxyConfigCollection, - addExtensionSpecFilePath, - addProcessorDefinition, - loadSpec, -} from './lib'; +import { ProxyConfigCollection } from './lib'; +import { SpecDefinitionsService } from './services'; import { ConfigType } from './config'; import { registerProxyRoute } from './routes/api/console/proxy'; import { registerSpecDefinitionsRoute } from './routes/api/console/spec_definitions'; -import { ESConfigForProxy, ConsoleSetup } from './types'; +import { ESConfigForProxy, ConsoleSetup, ConsoleStart } from './types'; -export class ConsoleServerPlugin implements Plugin { +export class ConsoleServerPlugin implements Plugin { log: Logger; + specDefinitionsService = new SpecDefinitionsService(); + constructor(private readonly ctx: PluginInitializerContext) { this.log = this.ctx.logger.get(); } @@ -72,15 +70,19 @@ export class ConsoleServerPlugin implements Plugin { router, }); - registerSpecDefinitionsRoute({ router }); + registerSpecDefinitionsRoute({ + router, + services: { specDefinitions: this.specDefinitionsService }, + }); return { - addExtensionSpecFilePath, - addProcessorDefinition, + ...this.specDefinitionsService.setup(), }; } start() { - loadSpec(); + return { + ...this.specDefinitionsService.start(), + }; } } diff --git a/src/plugins/console/server/routes/api/console/spec_definitions/index.ts b/src/plugins/console/server/routes/api/console/spec_definitions/index.ts index 88bc250bbfce60..5c7e679cd0d350 100644 --- a/src/plugins/console/server/routes/api/console/spec_definitions/index.ts +++ b/src/plugins/console/server/routes/api/console/spec_definitions/index.ts @@ -17,12 +17,30 @@ * under the License. */ import { IRouter, RequestHandler } from 'kibana/server'; -import { resolveApi } from '../../../../lib/spec_definitions'; +import { SpecDefinitionsService } from '../../../../services'; -export const registerSpecDefinitionsRoute = ({ router }: { router: IRouter }) => { +interface SpecDefinitionsRouteResponse { + es: { + name: string; + globals: Record; + endpoints: Record; + }; +} + +export const registerSpecDefinitionsRoute = ({ + router, + services, +}: { + router: IRouter; + services: { specDefinitions: SpecDefinitionsService }; +}) => { const handler: RequestHandler = async (ctx, request, response) => { + const specResponse: SpecDefinitionsRouteResponse = { + es: services.specDefinitions.asJson(), + }; + return response.ok({ - body: resolveApi(), + body: specResponse, headers: { 'Content-Type': 'application/json', }, diff --git a/src/plugins/console/server/lib/spec_definitions/index.js b/src/plugins/console/server/services/index.ts similarity index 81% rename from src/plugins/console/server/lib/spec_definitions/index.js rename to src/plugins/console/server/services/index.ts index abf55639fbee8d..c8dfeccd23070a 100644 --- a/src/plugins/console/server/lib/spec_definitions/index.js +++ b/src/plugins/console/server/services/index.ts @@ -17,10 +17,4 @@ * under the License. */ -export { addProcessorDefinition } from './js/ingest'; - -export { addExtensionSpecFilePath } from './json'; - -export { loadSpec } from './es'; - -export { resolveApi } from './server'; +export { SpecDefinitionsService } from './spec_definitions_service'; diff --git a/src/plugins/console/server/services/spec_definitions_service.ts b/src/plugins/console/server/services/spec_definitions_service.ts new file mode 100644 index 00000000000000..39a8d5094bd5ca --- /dev/null +++ b/src/plugins/console/server/services/spec_definitions_service.ts @@ -0,0 +1,150 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _, { merge } from 'lodash'; +import glob from 'glob'; +import { basename, join, resolve } from 'path'; +import { readFileSync } from 'fs'; + +import { jsSpecLoaders } from '../lib'; + +const PATH_TO_OSS_JSON_SPEC = resolve(__dirname, '../lib/spec_definitions/json'); + +export class SpecDefinitionsService { + private readonly name = 'es'; + + private readonly globalRules: Record = {}; + private readonly endpoints: Record = {}; + private readonly extensionSpecFilePaths: string[] = []; + + private hasLoadedSpec = false; + + public addGlobalAutocompleteRules(parentNode: string, rules: any) { + this.globalRules[parentNode] = rules; + } + + public addEndpointDescription(endpoint: string, description: any = {}) { + let copiedDescription: any = {}; + if (this.endpoints[endpoint]) { + copiedDescription = { ...this.endpoints[endpoint] }; + } + let urlParamsDef: any; + _.each(description.patterns || [], function(p) { + if (p.indexOf('{indices}') >= 0) { + urlParamsDef = urlParamsDef || {}; + urlParamsDef.ignore_unavailable = '__flag__'; + urlParamsDef.allow_no_indices = '__flag__'; + urlParamsDef.expand_wildcards = ['open', 'closed']; + } + }); + + if (urlParamsDef) { + description.url_params = _.extend(description.url_params || {}, copiedDescription.url_params); + _.defaults(description.url_params, urlParamsDef); + } + + _.extend(copiedDescription, description); + _.defaults(copiedDescription, { + id: endpoint, + patterns: [endpoint], + methods: ['GET'], + }); + + this.endpoints[endpoint] = copiedDescription; + } + + public asJson() { + return { + name: this.name, + globals: this.globalRules, + endpoints: this.endpoints, + }; + } + + public addExtensionSpecFilePath(path: string) { + this.extensionSpecFilePaths.push(path); + } + + public addProcessorDefinition(processor: any) { + if (!this.hasLoadedSpec) { + throw new Error( + 'Cannot add a processor definition because spec definitions have not loaded!' + ); + } + this.endpoints._processor!.data_autocomplete_rules.__one_of.push(processor); + } + + public setup() { + return { + addExtensionSpecFilePath: this.addExtensionSpecFilePath.bind(this), + }; + } + + public start() { + if (!this.hasLoadedSpec) { + this.loadJsonSpec(); + this.loadJSSpec(); + this.hasLoadedSpec = true; + return { + addProcessorDefinition: this.addProcessorDefinition.bind(this), + }; + } else { + throw new Error('Service has already started!'); + } + } + + private loadJSONSpecInDir(dirname: string) { + const generatedFiles = glob.sync(join(dirname, 'generated', '*.json')); + const overrideFiles = glob.sync(join(dirname, 'overrides', '*.json')); + + return generatedFiles.reduce((acc, file) => { + const overrideFile = overrideFiles.find(f => basename(f) === basename(file)); + const loadedSpec = JSON.parse(readFileSync(file, 'utf8')); + if (overrideFile) { + merge(loadedSpec, JSON.parse(readFileSync(overrideFile, 'utf8'))); + } + const spec: any = {}; + Object.entries(loadedSpec).forEach(([key, value]) => { + if (acc[key]) { + // add time to remove key collision + spec[`${key}${Date.now()}`] = value; + } else { + spec[key] = value; + } + }); + + return { ...acc, ...spec }; + }, {} as any); + } + + private loadJsonSpec() { + const result = this.loadJSONSpecInDir(PATH_TO_OSS_JSON_SPEC); + this.extensionSpecFilePaths.forEach(extensionSpecFilePath => { + merge(result, this.loadJSONSpecInDir(extensionSpecFilePath)); + }); + + Object.keys(result).forEach(endpoint => { + this.addEndpointDescription(endpoint, result[endpoint]); + }); + } + + private loadJSSpec() { + jsSpecLoaders.forEach(addJsSpec => addJsSpec(this)); + } +} diff --git a/src/plugins/console/server/types.ts b/src/plugins/console/server/types.ts index adafcd4d305269..4f026555ada7b7 100644 --- a/src/plugins/console/server/types.ts +++ b/src/plugins/console/server/types.ts @@ -25,6 +25,11 @@ export type ConsoleSetup = ReturnType extends Prom ? U : ReturnType; +/** @public */ +export type ConsoleStart = ReturnType extends Promise + ? U + : ReturnType; + /** @internal */ export interface ESConfigForProxy { hosts: string[]; diff --git a/x-pack/plugins/console_extensions/server/plugin.ts b/x-pack/plugins/console_extensions/server/plugin.ts index f4c41aa0a0ad54..8c2cb4d0db42b5 100644 --- a/x-pack/plugins/console_extensions/server/plugin.ts +++ b/x-pack/plugins/console_extensions/server/plugin.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { join } from 'path'; -import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; +import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; -import { ConsoleSetup } from '../../../../src/plugins/console/server'; +import { ConsoleSetup, ConsoleStart } from '../../../../src/plugins/console/server'; import { processors } from './spec/ingest/index'; @@ -14,19 +14,25 @@ interface SetupDependencies { console: ConsoleSetup; } +interface StartDependencies { + console: ConsoleStart; +} + +const CONSOLE_XPACK_JSON_SPEC_PATH = join(__dirname, 'spec/'); + export class ConsoleExtensionsServerPlugin implements Plugin { log: Logger; constructor(private readonly ctx: PluginInitializerContext) { this.log = this.ctx.logger.get(); } - setup( - core: CoreSetup, - { console: { addProcessorDefinition, addExtensionSpecFilePath } }: SetupDependencies - ) { - addExtensionSpecFilePath(join(__dirname, 'spec/')); + setup(core: CoreSetup, { console: { addExtensionSpecFilePath } }: SetupDependencies) { + addExtensionSpecFilePath(CONSOLE_XPACK_JSON_SPEC_PATH); + this.log.debug(`Added extension path to ${CONSOLE_XPACK_JSON_SPEC_PATH}...`); + } + + start(core: CoreStart, { console: { addProcessorDefinition } }: StartDependencies) { processors.forEach(processor => addProcessorDefinition(processor)); - this.log.debug('Installed console autocomplete extensions.'); + this.log.debug('Added processor definition extensions.'); } - start() {} } From a0730f795152ad251ef277e7f5c8de8c42040dd1 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Thu, 19 Mar 2020 16:42:53 +0000 Subject: [PATCH 04/32] [ML] Fixing file data visualizer override arguments (#60627) --- .../datavisualizer/file_based/components/utils/utils.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js index 3bf128f84aa78a..39cd25ba87d8c1 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js @@ -66,6 +66,10 @@ export function createUrlOverrides(overrides, originalSettings) { ) { formattedOverrides.format = originalSettings.format; } + + if (Array.isArray(formattedOverrides.column_names)) { + formattedOverrides.column_names = formattedOverrides.column_names.join(); + } } if (formattedOverrides.format === '' && originalSettings.format === 'semi_structured_text') { @@ -82,11 +86,6 @@ export function createUrlOverrides(overrides, originalSettings) { formattedOverrides.column_names = ''; } - // escape grok pattern as it can contain bad characters - if (formattedOverrides.grok_pattern !== '') { - formattedOverrides.grok_pattern = encodeURIComponent(formattedOverrides.grok_pattern); - } - if (formattedOverrides.lines_to_sample === '') { formattedOverrides.lines_to_sample = overrides.linesToSample; } From fcf439625ba6934dcedd31338178c391d8270364 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Thu, 19 Mar 2020 12:50:05 -0400 Subject: [PATCH 05/32] [Uptime] Add Alerting UI (#57919) * WIP trying things. Add new alert type for Uptime. Add defensive checks to alert executor. Move status check code to dedicated adapter function. Clean up code. * Port adapter function to dedicated file. * WIP. * Working on parameter selection. * Selector expressions working. * Working on actions. * Change anchor prop for popovers. * Reference migrated alerting plugin. * Clean up code for draft. * Add button to expose flyout. Clean up some client code. * Add test for requests function, add support for filters. * Reorganize and clean up files. * Add location and filter support to monitor status request function. * Add tests for monitor status request function. * Specify default action group id in alert registration. * Extract repeated string value to a constant. * Move test file to server in NP plugin. * Update imports after NP migration. * Fix UI bug that caused incorrect location selections in alert creation. * Change alert expression language to clarify meaning. * Add ability for user to select timerange units. * Add code that fixes active item highlighting. * Add better default value for active index selection. * Introduce dedicated field number component. * Add message to status check alert. * Add tests for context message. * Formalize alert action group definitions. * Extract monitor id squashing from context message generator. * Write test for monitor ID uniqueness function. * Add alert state creator function and tests. * Update action group id value. * Add tests for alert factory and executor function. * Rename alert context props to be more domain-specific. * Clean up unnecessary type markup. * Clean up alert ui controls file. * Better organize new registration code. * Simplify some logic code. * Clean up bootstrap code. * Add unit tests for alert type. * Delete temporary test code from triggers_actions_ui. * Rename a test file. * Add some comments to annotate a file. * Add io-ts type checking to alert create validation and alert executor. * Add translation of plaintext content string. * Further simplify monitor status alert validation. * Add io-ts type checking to alert params. * Update a comment. * Prefer inline snapshots to more error-prone assertions. * Clean up and comment request function. * Rename a symbol. * Fix broken types in reducer file and add a test. * Fix a validation logic error and add tests. * Delete unused import. * Delete obsolete dependency. * Fix function call to have correct parameters. * Fixing some import weirdness. * Reintroduce accidentally-deleted code. * Delete unneeded require from legacy entry file. * Remove unneeded connected component. * Update flyout controls for new interface and delete connected components. * Remove unneeded require from app index file. * Introduce data-test-subj attributes to various components to assist with functional tests. * Introduce functional test helpers for alert flyout. * Add functional test arch and a test for alerting UI to ES SSL test suite. * Add explicit exports to module index. * Reorganize file to keep interfaces closer to their implementations. * Move create alert button to better position. * Clean up a file. * Update a functional test attribute, clean up a file, rename a selector, add tests. * Add a comment. * Make better default alert message, translate messages, add/update tests. * Fix broken type. * Update obsolete snapshot. * Introduce mock provider to tests and update snapshots. * Reduce a strange type to `any`. * Add alert flyout button connected component. * Add alert flyout wrapper connected component. * Create connected component for alert monitor status alert. * Clean up index files. * Update i18nrc file to cover translation in server plugin code. * Fix broken imports. * Update test snapshots. * Prefer more descriptive type. * Prefer more descriptive type. * Prefer built-in React propType to custom. * Prefer simpler validation. * Add whitespace to clean up file. * Extract function and write tests. * Simplify validation function. * Add navigate to alerting button. * Move context item inside the items list. * Clean up alert creation component. * Update type check parsing and error messaging, and update snapshot/test assertions. * Update broken snapshot. * Update README for running functional tests. * Update functional test service to reflect improved UX. * Fix broken type that resulted from a mistake during a merge resolution. * Add spacer between alert title and kuery bar. * Update the id and name of our alert type because it was never changed from placeholder value. * Rename alert keys. * Fix broken unit tests. * Add aria-labels to alert UI. * Implement design feedback. * Fix broken test snapshots. * Add missing props to unit tests to staisfy updated types. Co-authored-by: Elastic Machine --- x-pack/.i18nrc.json | 2 +- x-pack/legacy/plugins/uptime/README.md | 10 + .../plugins/uptime/common/constants/alerts.ts | 19 + .../plugins/uptime/common/constants/index.ts | 1 + .../uptime/common/constants/index_names.ts | 1 - .../common/runtime_types/alerts/index.ts | 12 + .../runtime_types/alerts/status_check.ts | 39 ++ .../uptime/common/runtime_types/index.ts | 1 + x-pack/legacy/plugins/uptime/index.ts | 2 +- .../plugins/uptime/public/apps/index.ts | 5 +- .../plugins/uptime/public/apps/plugin.ts | 2 + .../connected/alerts/alert_monitor_status.tsx | 43 ++ .../components/connected/alerts/index.ts | 9 + .../alerts/toggle_alert_flyout_button.tsx | 19 + .../alerts/uptime_alerts_flyout_wrapper.tsx | 34 + .../public/components/connected/index.ts | 1 + .../kuerybar/kuery_bar_container.tsx | 2 +- .../__tests__/alert_monitor_status.test.tsx | 179 ++++++ .../alerts/alert_monitor_status.tsx | 431 +++++++++++++ .../components/functional/alerts/index.ts | 10 + .../alerts/toggle_alert_flyout_button.tsx | 79 +++ .../alerts/uptime_alerts_context_provider.tsx | 38 ++ .../alerts/uptime_alerts_flyout_wrapper.tsx | 30 + .../public/components/functional/index.ts | 6 + .../functional/kuery_bar/kuery_bar.tsx | 6 + .../functional/kuery_bar/typeahead/index.js | 3 +- .../ping_list/__tests__/ping_list.test.tsx | 3 +- .../framework/new_platform_adapter.tsx | 16 + .../__tests__/monitor_status.test.ts | 181 ++++++ .../uptime/public/lib/alert_types/index.ts | 14 + .../public/lib/alert_types/monitor_status.tsx | 71 +++ .../__snapshots__/page_header.test.tsx.snap | 66 ++ .../pages/__tests__/page_header.test.tsx | 54 +- .../plugins/uptime/public/pages/overview.tsx | 8 +- .../uptime/public/pages/page_header.tsx | 4 + .../plugins/uptime/public/state/actions/ui.ts | 2 + .../__tests__/__snapshots__/ui.test.ts.snap | 3 + .../state/reducers/__tests__/ui.test.ts | 34 +- .../uptime/public/state/reducers/ui.ts | 12 +- .../state/selectors/__tests__/index.test.ts | 1 + .../uptime/public/state/selectors/index.ts | 9 + .../plugins/uptime/public/uptime_app.tsx | 15 +- x-pack/plugins/uptime/kibana.json | 2 +- x-pack/plugins/uptime/server/kibana.index.ts | 2 +- .../lib/adapters/framework/adapter_types.ts | 2 + .../lib/alerts/__tests__/status_check.test.ts | 587 ++++++++++++++++++ .../plugins/uptime/server/lib/alerts/index.ts | 10 + .../uptime/server/lib/alerts/status_check.ts | 234 +++++++ .../plugins/uptime/server/lib/alerts/types.ts | 11 + .../__tests__/get_monitor_status.test.ts | 553 +++++++++++++++++ .../server/lib/requests/get_monitor_status.ts | 150 +++++ .../uptime/server/lib/requests/index.ts | 2 + .../server/lib/requests/uptime_requests.ts | 3 + x-pack/plugins/uptime/server/uptime_server.ts | 12 +- .../functional/page_objects/uptime_page.ts | 40 +- x-pack/test/functional/services/uptime.ts | 94 ++- .../apps/uptime/alert_flyout.ts | 78 +++ .../apps/uptime/index.ts | 27 + x-pack/test/functional_with_es_ssl/config.ts | 5 +- 59 files changed, 3245 insertions(+), 44 deletions(-) create mode 100644 x-pack/legacy/plugins/uptime/common/constants/alerts.ts create mode 100644 x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts create mode 100644 x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts create mode 100644 x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts create mode 100644 x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts create mode 100644 x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts create mode 100644 x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx create mode 100644 x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts create mode 100644 x-pack/plugins/uptime/server/lib/alerts/index.ts create mode 100644 x-pack/plugins/uptime/server/lib/alerts/status_check.ts create mode 100644 x-pack/plugins/uptime/server/lib/alerts/types.ts create mode 100644 x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts create mode 100644 x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/uptime/index.ts diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 1564eb94a69039..d568e9b951d289 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -42,7 +42,7 @@ "xpack.transform": "plugins/transform", "xpack.triggersActionsUI": "plugins/triggers_actions_ui", "xpack.upgradeAssistant": "plugins/upgrade_assistant", - "xpack.uptime": "legacy/plugins/uptime", + "xpack.uptime": ["plugins/uptime", "legacy/plugins/uptime"], "xpack.watcher": "plugins/watcher" }, "translations": [ diff --git a/x-pack/legacy/plugins/uptime/README.md b/x-pack/legacy/plugins/uptime/README.md index 308f78ecdc3689..2ed0e2fc77cbc8 100644 --- a/x-pack/legacy/plugins/uptime/README.md +++ b/x-pack/legacy/plugins/uptime/README.md @@ -62,3 +62,13 @@ You can login with username `elastic` and password `changeme` by default. If you want to freeze a UI or API test you can include an async call like `await new Promise(r => setTimeout(r, 1000 * 60))` to freeze the execution for 60 seconds if you need to click around or check things in the state that is loaded. + +#### Running --ssl tests + +Some of our tests require there to be an SSL connection between Kibana and Elasticsearch. + +We can run these tests like described above, but with some special config. + +`node scripts/functional_tests_server.js --config=test/functional_with_es_ssl/config.ts` + +`node scripts/functional_test_runner.js --config=test/functional_with_es_ssl/config.ts` diff --git a/x-pack/legacy/plugins/uptime/common/constants/alerts.ts b/x-pack/legacy/plugins/uptime/common/constants/alerts.ts new file mode 100644 index 00000000000000..c0db9ae3098439 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/constants/alerts.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +interface ActionGroupDefinition { + id: string; + name: string; +} + +type ActionGroupDefinitions = Record; + +export const ACTION_GROUP_DEFINITIONS: ActionGroupDefinitions = { + MONITOR_STATUS: { + id: 'xpack.uptime.alerts.actionGroups.monitorStatus', + name: 'Uptime Down Monitor', + }, +}; diff --git a/x-pack/legacy/plugins/uptime/common/constants/index.ts b/x-pack/legacy/plugins/uptime/common/constants/index.ts index 0425fc19a7b455..19f2de3c6f0f40 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/index.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { ACTION_GROUP_DEFINITIONS } from './alerts'; export { CHART_FORMAT_LIMITS } from './chart_format_limits'; export { CLIENT_DEFAULTS } from './client_defaults'; export { CONTEXT_DEFAULTS } from './context_defaults'; diff --git a/x-pack/legacy/plugins/uptime/common/constants/index_names.ts b/x-pack/legacy/plugins/uptime/common/constants/index_names.ts index e9c6b1e1106ab1..9f33d280a12688 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/index_names.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/index_names.ts @@ -6,5 +6,4 @@ export const INDEX_NAMES = { HEARTBEAT: 'heartbeat-8*', - HEARTBEAT_STATES: 'heartbeat-states-8*', }; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts new file mode 100644 index 00000000000000..ee284249c38c04 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts @@ -0,0 +1,12 @@ +/* + * 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 { + StatusCheckAlertStateType, + StatusCheckAlertState, + StatusCheckExecutorParamsType, + StatusCheckExecutorParams, +} from './status_check'; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts new file mode 100644 index 00000000000000..bc234b268df271 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts @@ -0,0 +1,39 @@ +/* + * 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 * as t from 'io-ts'; + +export const StatusCheckAlertStateType = t.intersection([ + t.partial({ + currentTriggerStarted: t.string, + firstTriggeredAt: t.string, + lastTriggeredAt: t.string, + lastResolvedAt: t.string, + }), + t.type({ + firstCheckedAt: t.string, + lastCheckedAt: t.string, + isTriggered: t.boolean, + }), +]); + +export type StatusCheckAlertState = t.TypeOf; + +export const StatusCheckExecutorParamsType = t.intersection([ + t.partial({ + filters: t.string, + }), + t.type({ + locations: t.array(t.string), + numTimes: t.number, + timerange: t.type({ + from: t.string, + to: t.string, + }), + }), +]); + +export type StatusCheckExecutorParams = t.TypeOf; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts index 58f79abcf91ecd..82fc9807300ede 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './alerts'; export * from './common'; export * from './monitor'; export * from './overview_filters'; diff --git a/x-pack/legacy/plugins/uptime/index.ts b/x-pack/legacy/plugins/uptime/index.ts index feecef58578950..f52ad8ce867b61 100644 --- a/x-pack/legacy/plugins/uptime/index.ts +++ b/x-pack/legacy/plugins/uptime/index.ts @@ -14,7 +14,7 @@ export const uptime = (kibana: any) => configPrefix: 'xpack.uptime', id: PLUGIN.ID, publicDir: resolve(__dirname, 'public'), - require: ['kibana', 'elasticsearch', 'xpack_main'], + require: ['alerting', 'kibana', 'elasticsearch', 'xpack_main'], uiExports: { app: { description: i18n.translate('xpack.uptime.pluginDescription', { diff --git a/x-pack/legacy/plugins/uptime/public/apps/index.ts b/x-pack/legacy/plugins/uptime/public/apps/index.ts index d322c35364d1a3..d58bf8398fcdea 100644 --- a/x-pack/legacy/plugins/uptime/public/apps/index.ts +++ b/x-pack/legacy/plugins/uptime/public/apps/index.ts @@ -8,8 +8,9 @@ import { npSetup } from 'ui/new_platform'; import { Plugin } from './plugin'; import 'uiExports/embeddableFactories'; -new Plugin({ +const plugin = new Plugin({ opaqueId: Symbol('uptime'), env: {} as any, config: { get: () => ({} as any) }, -}).setup(npSetup); +}); +plugin.setup(npSetup); diff --git a/x-pack/legacy/plugins/uptime/public/apps/plugin.ts b/x-pack/legacy/plugins/uptime/public/apps/plugin.ts index 2204d7e4097ddf..eec49418910f8b 100644 --- a/x-pack/legacy/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/legacy/plugins/uptime/public/apps/plugin.ts @@ -36,6 +36,7 @@ export class Plugin { public setup(setup: SetupObject) { const { core, plugins } = setup; const { home } = plugins; + home.featureCatalogue.register({ category: FeatureCatalogueCategory.DATA, description: PLUGIN.DESCRIPTION, @@ -45,6 +46,7 @@ export class Plugin { showOnHomePage: true, title: PLUGIN.TITLE, }); + core.application.register({ id: PLUGIN.ID, euiIconType: 'uptimeApp', diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx new file mode 100644 index 00000000000000..1529ab6db88753 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx @@ -0,0 +1,43 @@ +/* + * 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 { useSelector } from 'react-redux'; +import { DataPublicPluginSetup } from 'src/plugins/data/public'; +import { selectMonitorStatusAlert } from '../../../state/selectors'; +import { AlertMonitorStatusComponent } from '../../functional/alerts/alert_monitor_status'; + +interface Props { + autocomplete: DataPublicPluginSetup['autocomplete']; + enabled: boolean; + numTimes: number; + setAlertParams: (key: string, value: any) => void; + timerange: { + from: string; + to: string; + }; +} + +export const AlertMonitorStatus = ({ + autocomplete, + enabled, + numTimes, + setAlertParams, + timerange, +}: Props) => { + const { filters, locations } = useSelector(selectMonitorStatusAlert); + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts new file mode 100644 index 00000000000000..87179a96fc0b2d --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { AlertMonitorStatus } from './alert_monitor_status'; +export { ToggleAlertFlyoutButton } from './toggle_alert_flyout_button'; +export { UptimeAlertsFlyoutWrapper } from './uptime_alerts_flyout_wrapper'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx new file mode 100644 index 00000000000000..43b0be45365a13 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx @@ -0,0 +1,19 @@ +/* + * 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 { useDispatch } from 'react-redux'; +import { ToggleAlertFlyoutButtonComponent } from '../../functional'; +import { setAlertFlyoutVisible } from '../../../state/actions'; + +export const ToggleAlertFlyoutButton = () => { + const dispatch = useDispatch(); + return ( + dispatch(setAlertFlyoutVisible(value))} + /> + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx new file mode 100644 index 00000000000000..b547f8b076f931 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { UptimeAlertsFlyoutWrapperComponent } from '../../functional'; +import { setAlertFlyoutVisible } from '../../../state/actions'; +import { selectAlertFlyoutVisibility } from '../../../state/selectors'; + +interface Props { + alertTypeId?: string; + canChangeTrigger?: boolean; +} + +export const UptimeAlertsFlyoutWrapper = ({ alertTypeId, canChangeTrigger }: Props) => { + const dispatch = useDispatch(); + const setAddFlyoutVisiblity = (value: React.SetStateAction) => + // @ts-ignore the value here is a boolean, and it works with the action creator function + dispatch(setAlertFlyoutVisible(value)); + + const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility); + + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/index.ts b/x-pack/legacy/plugins/uptime/public/components/connected/index.ts index baa961ddc87d25..7e442cbe850baa 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/connected/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { AlertMonitorStatus, ToggleAlertFlyoutButton, UptimeAlertsFlyoutWrapper } from './alerts'; export { PingHistogram } from './charts/ping_histogram'; export { Snapshot } from './charts/snapshot_container'; export { KueryBar } from './kuerybar/kuery_bar_container'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx index a42f96962b95e4..132ae57b5154f6 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx @@ -8,7 +8,7 @@ import { connect } from 'react-redux'; import { AppState } from '../../../state'; import { selectIndexPattern } from '../../../state/selectors'; import { getIndexPattern } from '../../../state/actions'; -import { KueryBarComponent } from '../../functional'; +import { KueryBarComponent } from '../../functional/kuery_bar/kuery_bar'; const mapStateToProps = (state: AppState) => ({ ...selectIndexPattern(state) }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx new file mode 100644 index 00000000000000..af8d17d1fc2428 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx @@ -0,0 +1,179 @@ +/* + * 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 { + selectedLocationsToString, + AlertFieldNumber, + handleAlertFieldNumberChange, +} from '../alert_monitor_status'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; + +describe('alert monitor status component', () => { + describe('handleAlertFieldNumberChange', () => { + let mockSetIsInvalid: jest.Mock; + let mockSetFieldValue: jest.Mock; + + beforeEach(() => { + mockSetIsInvalid = jest.fn(); + mockSetFieldValue = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sets a valid number', () => { + handleAlertFieldNumberChange( + // @ts-ignore no need to implement this entire type here + { target: { value: '23' } }, + false, + mockSetIsInvalid, + mockSetFieldValue + ); + expect(mockSetIsInvalid).not.toHaveBeenCalled(); + expect(mockSetFieldValue).toHaveBeenCalledTimes(1); + expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 23, + ], + ] + `); + }); + + it('sets invalid for NaN value', () => { + handleAlertFieldNumberChange( + // @ts-ignore no need to implement this entire type here + { target: { value: 'foo' } }, + false, + mockSetIsInvalid, + mockSetFieldValue + ); + expect(mockSetIsInvalid).toHaveBeenCalledTimes(1); + expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + true, + ], + ] + `); + expect(mockSetFieldValue).not.toHaveBeenCalled(); + }); + + it('sets invalid to false when a valid value is received and invalid is true', () => { + handleAlertFieldNumberChange( + // @ts-ignore no need to implement this entire type here + { target: { value: '23' } }, + true, + mockSetIsInvalid, + mockSetFieldValue + ); + expect(mockSetIsInvalid).toHaveBeenCalledTimes(1); + expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + false, + ], + ] + `); + expect(mockSetFieldValue).toHaveBeenCalledTimes(1); + expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 23, + ], + ] + `); + }); + }); + + describe('AlertFieldNumber', () => { + it('responds with correct number value when a valid number is specified', () => { + const mockValueHandler = jest.fn(); + const component = mountWithIntl( + + ); + component.find('input').simulate('change', { target: { value: '45' } }); + expect(mockValueHandler).toHaveBeenCalled(); + expect(mockValueHandler.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 45, + ], + ] + `); + }); + + it('does not set an invalid number value', () => { + const mockValueHandler = jest.fn(); + const component = mountWithIntl( + + ); + component.find('input').simulate('change', { target: { value: 'not a number' } }); + expect(mockValueHandler).not.toHaveBeenCalled(); + expect(mockValueHandler.mock.calls).toEqual([]); + }); + + it('does not set a number value less than 1', () => { + const mockValueHandler = jest.fn(); + const component = mountWithIntl( + + ); + component.find('input').simulate('change', { target: { value: '0' } }); + expect(mockValueHandler).not.toHaveBeenCalled(); + expect(mockValueHandler.mock.calls).toEqual([]); + }); + }); + + describe('selectedLocationsToString', () => { + it('generates a formatted string for a valid list of options', () => { + const locations = [ + { + checked: 'on', + label: 'fairbanks', + }, + { + checked: 'on', + label: 'harrisburg', + }, + { + checked: undefined, + label: 'orlando', + }, + ]; + expect(selectedLocationsToString(locations)).toEqual('fairbanks, harrisburg'); + }); + + it('generates a formatted string for a single item', () => { + expect(selectedLocationsToString([{ checked: 'on', label: 'fairbanks' }])).toEqual( + 'fairbanks' + ); + }); + + it('returns an empty string when no valid options are available', () => { + expect(selectedLocationsToString([{ checked: 'off', label: 'harrisburg' }])).toEqual(''); + }); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx new file mode 100644 index 00000000000000..5143e1c9639048 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx @@ -0,0 +1,431 @@ +/* + * 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, { useState, useEffect } from 'react'; +import { + EuiExpression, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiSelectable, + EuiSpacer, + EuiSwitch, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { DataPublicPluginSetup } from 'src/plugins/data/public'; +import { KueryBar } from '../../connected/kuerybar/kuery_bar_container'; + +interface AlertFieldNumberProps { + 'aria-label': string; + 'data-test-subj': string; + disabled: boolean; + fieldValue: number; + setFieldValue: React.Dispatch>; +} + +export const handleAlertFieldNumberChange = ( + e: React.ChangeEvent, + isInvalid: boolean, + setIsInvalid: React.Dispatch>, + setFieldValue: React.Dispatch> +) => { + const num = parseInt(e.target.value, 10); + if (isNaN(num) || num < 1) { + setIsInvalid(true); + } else { + if (isInvalid) setIsInvalid(false); + setFieldValue(num); + } +}; + +export const AlertFieldNumber = ({ + 'aria-label': ariaLabel, + 'data-test-subj': dataTestSubj, + disabled, + fieldValue, + setFieldValue, +}: AlertFieldNumberProps) => { + const [isInvalid, setIsInvalid] = useState(false); + + return ( + handleAlertFieldNumberChange(e, isInvalid, setIsInvalid, setFieldValue)} + disabled={disabled} + value={fieldValue} + isInvalid={isInvalid} + /> + ); +}; + +interface AlertExpressionPopoverProps { + 'aria-label': string; + content: React.ReactElement; + description: string; + 'data-test-subj': string; + id: string; + value: string; +} + +const AlertExpressionPopover: React.FC = ({ + 'aria-label': ariaLabel, + content, + 'data-test-subj': dataTestSubj, + description, + id, + value, +}) => { + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen(!isOpen)} + value={value} + /> + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + > + {content} + + ); +}; + +export const selectedLocationsToString = (selectedLocations: any[]) => + // create a nicely-formatted description string for all `on` locations + selectedLocations + .filter(({ checked }) => checked === 'on') + .map(({ label }) => label) + .sort() + .reduce((acc, cur) => { + if (acc === '') { + return cur; + } + return acc + `, ${cur}`; + }, ''); + +interface AlertMonitorStatusProps { + autocomplete: DataPublicPluginSetup['autocomplete']; + enabled: boolean; + filters: string; + locations: string[]; + numTimes: number; + setAlertParams: (key: string, value: any) => void; + timerange: { + from: string; + to: string; + }; +} + +export const AlertMonitorStatusComponent: React.FC = props => { + const { filters, locations } = props; + const [numTimes, setNumTimes] = useState(5); + const [numMins, setNumMins] = useState(15); + const [allLabels, setAllLabels] = useState(true); + + // locations is an array of `Option[]`, but that type doesn't seem to be exported by EUI + const [selectedLocations, setSelectedLocations] = useState( + locations.map(location => ({ + 'aria-label': i18n.translate('xpack.uptime.alerts.locationSelectionItem.ariaLabel', { + defaultMessage: 'Location selection item for "{location}"', + values: { + location, + }, + }), + disabled: allLabels, + label: location, + })) + ); + const [timerangeUnitOptions, setTimerangeUnitOptions] = useState([ + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.secondsOption.ariaLabel', + { + defaultMessage: '"Seconds" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.secondsOption', + key: 's', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.seconds', { + defaultMessage: 'seconds', + }), + }, + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.minutesOption.ariaLabel', + { + defaultMessage: '"Minutes" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.minutesOption', + checked: 'on', + key: 'm', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.minutes', { + defaultMessage: 'minutes', + }), + }, + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.hoursOption.ariaLabel', + { + defaultMessage: '"Hours" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.hoursOption', + key: 'h', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.hours', { + defaultMessage: 'hours', + }), + }, + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.daysOption.ariaLabel', + { + defaultMessage: '"Days" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.daysOption', + key: 'd', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.days', { + defaultMessage: 'days', + }), + }, + ]); + + const { setAlertParams } = props; + + useEffect(() => { + setAlertParams('numTimes', numTimes); + }, [numTimes, setAlertParams]); + + useEffect(() => { + const timerangeUnit = timerangeUnitOptions.find(({ checked }) => checked === 'on')?.key ?? 'm'; + setAlertParams('timerange', { from: `now-${numMins}${timerangeUnit}`, to: 'now' }); + }, [numMins, timerangeUnitOptions, setAlertParams]); + + useEffect(() => { + if (allLabels) { + setAlertParams('locations', []); + } else { + setAlertParams( + 'locations', + selectedLocations.filter(l => l.checked === 'on').map(l => l.label) + ); + } + }, [selectedLocations, setAlertParams, allLabels]); + + useEffect(() => { + setAlertParams('filters', filters); + }, [filters, setAlertParams]); + + return ( + <> + + + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.numTimesExpression" + description="any monitor is down >" + id="ping-count" + value={`${numTimes} times`} + /> + + + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeValueExpression" + description="within" + id="timerange" + value={`last ${numMins}`} + /> + + + + +
      + +
      +
      + { + if (newOptions.reduce((acc, { checked }) => acc || checked === 'on', false)) { + setTimerangeUnitOptions(newOptions); + } + }} + singleSelection={true} + listProps={{ + showIcons: true, + }} + > + {list => list} + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeUnitExpression" + description="" + id="timerange-unit" + value={ + timerangeUnitOptions.find(({ checked }) => checked === 'on')?.label.toLowerCase() ?? + '' + } + /> +
      +
      + + {selectedLocations.length === 0 && ( + + )} + {selectedLocations.length > 0 && ( + + + { + setAllLabels(!allLabels); + setSelectedLocations( + selectedLocations.map((l: any) => ({ + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.monitorStatus.locationSelection', + { + defaultMessage: 'Select the location {location}', + values: { + location: l, + }, + } + ), + ...l, + 'data-test-subj': `xpack.uptime.alerts.monitorStatus.locationSelection.${l.label}LocationOption`, + disabled: !allLabels, + })) + ); + }} + /> + + + setSelectedLocations(e)} + > + {location => location} + + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.locationsSelectionExpression" + description="from" + id="locations" + value={ + selectedLocations.length === 0 || allLabels + ? 'any location' + : selectedLocationsToString(selectedLocations) + } + /> + )} + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts new file mode 100644 index 00000000000000..275333b60c5ee0 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts @@ -0,0 +1,10 @@ +/* + * 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 { AlertMonitorStatusComponent } from './alert_monitor_status'; +export { ToggleAlertFlyoutButtonComponent } from './toggle_alert_flyout_button'; +export { UptimeAlertsContextProvider } from './uptime_alerts_context_provider'; +export { UptimeAlertsFlyoutWrapperComponent } from './uptime_alerts_flyout_wrapper'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx new file mode 100644 index 00000000000000..99853a9f775ec8 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; + +interface Props { + setAlertFlyoutVisible: (value: boolean) => void; +} + +export const ToggleAlertFlyoutButtonComponent = ({ setAlertFlyoutVisible }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const kibana = useKibana(); + + return ( + setIsOpen(!isOpen)} + > + + + } + closePopover={() => setIsOpen(false)} + isOpen={isOpen} + ownFocus + > + setAlertFlyoutVisible(true)} + > + + , + + + , + ]} + /> + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx new file mode 100644 index 00000000000000..a174a7d9c0ea46 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx @@ -0,0 +1,38 @@ +/* + * 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 { AlertsContextProvider } from '../../../../../../../plugins/triggers_actions_ui/public'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; + +export const UptimeAlertsContextProvider: React.FC = ({ children }) => { + const { + services: { + data: { fieldFormats }, + http, + charts, + notifications, + triggers_actions_ui: { actionTypeRegistry, alertTypeRegistry }, + uiSettings, + }, + } = useKibana(); + + return ( + + {children} + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx new file mode 100644 index 00000000000000..13705e7d192933 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx @@ -0,0 +1,30 @@ +/* + * 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 { AlertAdd } from '../../../../../../../plugins/triggers_actions_ui/public'; + +interface Props { + alertFlyoutVisible: boolean; + alertTypeId?: string; + canChangeTrigger?: boolean; + setAlertFlyoutVisibility: React.Dispatch>; +} + +export const UptimeAlertsFlyoutWrapperComponent = ({ + alertFlyoutVisible, + alertTypeId, + canChangeTrigger, + setAlertFlyoutVisibility, +}: Props) => ( + +); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts index daba13d8df6418..8d0352e01d40ed 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts @@ -4,6 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +export { + ToggleAlertFlyoutButtonComponent, + UptimeAlertsContextProvider, + UptimeAlertsFlyoutWrapperComponent, +} from './alerts'; +export * from './alerts'; export { DonutChart } from './charts/donut_chart'; export { KueryBarComponent } from './kuery_bar/kuery_bar'; export { MonitorCharts } from './monitor_charts'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx index 2f5ccc2adf313a..63aceed2be6362 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx @@ -33,14 +33,18 @@ function convertKueryToEsQuery(kuery: string, indexPattern: IIndexPattern) { } interface Props { + 'aria-label': string; autocomplete: DataPublicPluginSetup['autocomplete']; + 'data-test-subj': string; loadIndexPattern: () => void; indexPattern: IIndexPattern | null; loading: boolean; } export function KueryBarComponent({ + 'aria-label': ariaLabel, autocomplete: autocompleteService, + 'data-test-subj': dataTestSubj, loadIndexPattern, indexPattern, loading, @@ -119,6 +123,8 @@ export function KueryBarComponent({ return ( -
      +
      { @@ -205,7 +204,7 @@ describe('PingList component', () => { loading={false} data={{ allPings }} onPageCountChange={jest.fn()} - onSelectedLocationChange={(loc: EuiComboBoxOptionOption[]) => {}} + onSelectedLocationChange={(_loc: any[]) => {}} onSelectedStatusChange={jest.fn()} pageSize={30} selectedOption="down" diff --git a/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx b/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx index a377b9ed1507b4..a2f3328b986128 100644 --- a/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx +++ b/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx @@ -10,6 +10,7 @@ import ReactDOM from 'react-dom'; import { get } from 'lodash'; import { i18n as i18nFormatter } from '@kbn/i18n'; import { PluginsSetup } from 'ui/new_platform/new_platform'; +import { alertTypeInitializers } from '../../alert_types'; import { UptimeApp, UptimeAppProps } from '../../../uptime_app'; import { getIntegratedAppAvailability } from './capabilities_adapter'; import { @@ -32,15 +33,30 @@ export const getKibanaFrameworkAdapter = ( http: { basePath }, i18n, } = core; + + const { + data: { autocomplete }, + // TODO: after NP migration we can likely fix this typing problem + // @ts-ignore we don't control this type + triggers_actions_ui, + } = plugins; + + alertTypeInitializers.forEach(init => + triggers_actions_ui.alertTypeRegistry.register(init({ autocomplete })) + ); + let breadcrumbs: ChromeBreadcrumb[] = []; core.chrome.getBreadcrumbs$().subscribe((nextBreadcrumbs?: ChromeBreadcrumb[]) => { breadcrumbs = nextBreadcrumbs || []; }); + const { apm, infrastructure, logs } = getIntegratedAppAvailability( capabilities, INTEGRATED_SOLUTIONS ); + const canSave = get(capabilities, 'uptime.save', false); + const props: UptimeAppProps = { basePath: basePath.get(), canSave, diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts b/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts new file mode 100644 index 00000000000000..6323ee3951e21f --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts @@ -0,0 +1,181 @@ +/* + * 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 { validate, initMonitorStatusAlertType } from '../monitor_status'; + +describe('monitor status alert type', () => { + describe('validate', () => { + let params: any; + + beforeEach(() => { + params = { + locations: [], + numTimes: 5, + timerange: { + from: 'now-15m', + to: 'now', + }, + }; + }); + + it(`doesn't throw on empty set`, () => { + expect(validate({})).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/locations: Array", + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number", + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }", + ], + }, + } + `); + }); + + describe('timerange', () => { + it('is undefined', () => { + delete params.timerange; + expect(validate(params)).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }", + ], + }, + } + `); + }); + + it('is missing `from` or `to` value', () => { + expect( + validate({ + ...params, + timerange: {}, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }/from: string", + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }/to: string", + ], + }, + } + `); + }); + + it('is invalid timespan', () => { + expect( + validate({ + ...params, + timerange: { + from: 'now', + to: 'now-15m', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "invalidTimeRange": "Time range start cannot exceed time range end", + }, + } + `); + }); + + it('has unparse-able `from` value', () => { + expect( + validate({ + ...params, + timerange: { + from: 'cannot parse this to a date', + to: 'now', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "timeRangeStartValueNaN": "Specified time range \`from\` is an invalid value", + }, + } + `); + }); + + it('has unparse-able `to` value', () => { + expect( + validate({ + ...params, + timerange: { + from: 'now-15m', + to: 'cannot parse this to a date', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "timeRangeEndValueNaN": "Specified time range \`to\` is an invalid value", + }, + } + `); + }); + }); + + describe('numTimes', () => { + it('is missing', () => { + delete params.numTimes; + expect(validate(params)).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number", + ], + }, + } + `); + }); + + it('is NaN', () => { + expect(validate({ ...params, numTimes: `this isn't a number` })).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value \\"this isn't a number\\" supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number", + ], + }, + } + `); + }); + + it('is < 1', () => { + expect(validate({ ...params, numTimes: 0 })).toMatchInlineSnapshot(` + Object { + "errors": Object { + "invalidNumTimes": "Number of alert check down times must be an integer greater than 0", + }, + } + `); + }); + }); + }); + + describe('initMonitorStatusAlertType', () => { + expect(initMonitorStatusAlertType({ autocomplete: {} })).toMatchInlineSnapshot(` + Object { + "alertParamsExpression": [Function], + "defaultActionMessage": "{{context.message}} + {{context.completeIdList}}", + "iconClass": "uptimeApp", + "id": "xpack.uptime.alerts.monitorStatus", + "name": "Uptime Monitor Status", + "validate": [Function], + } + `); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts b/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts new file mode 100644 index 00000000000000..f764505a6d683e --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +// TODO: after NP migration is complete we should be able to remove this lint ignore comment +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertTypeModel } from '../../../../../../plugins/triggers_actions_ui/public/types'; +import { initMonitorStatusAlertType } from './monitor_status'; + +export type AlertTypeInitializer = (dependenies: { autocomplete: any }) => AlertTypeModel; + +export const alertTypeInitializers: AlertTypeInitializer[] = [initMonitorStatusAlertType]; diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx new file mode 100644 index 00000000000000..effbb59539d169 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx @@ -0,0 +1,71 @@ +/* + * 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 { PathReporter } from 'io-ts/lib/PathReporter'; +import React from 'react'; +import DateMath from '@elastic/datemath'; +import { isRight } from 'fp-ts/lib/Either'; +import { + AlertTypeModel, + ValidationResult, + // TODO: this typing issue should be resolved after NP migration + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../plugins/triggers_actions_ui/public/types'; +import { AlertTypeInitializer } from '.'; +import { StatusCheckExecutorParamsType } from '../../../common/runtime_types'; +import { AlertMonitorStatus } from '../../components/connected/alerts'; + +export const validate = (alertParams: any): ValidationResult => { + const errors: Record = {}; + const decoded = StatusCheckExecutorParamsType.decode(alertParams); + + /* + * When the UI initially loads, this validate function is called with an + * empty set of params, we don't want to type check against that. + */ + if (!isRight(decoded)) { + errors.typeCheckFailure = 'Provided parameters do not conform to the expected type.'; + errors.typeCheckParsingMessage = PathReporter.report(decoded); + } + + if (isRight(decoded)) { + const { numTimes, timerange } = decoded.right; + const { from, to } = timerange; + const fromAbs = DateMath.parse(from)?.valueOf(); + const toAbs = DateMath.parse(to)?.valueOf(); + if (!fromAbs || isNaN(fromAbs)) { + errors.timeRangeStartValueNaN = 'Specified time range `from` is an invalid value'; + } + if (!toAbs || isNaN(toAbs)) { + errors.timeRangeEndValueNaN = 'Specified time range `to` is an invalid value'; + } + + // the default values for this test will pass, we only want to specify an error + // in the case that `from` is more recent than `to` + if ((fromAbs ?? 0) > (toAbs ?? 1)) { + errors.invalidTimeRange = 'Time range start cannot exceed time range end'; + } + + if (numTimes < 1) { + errors.invalidNumTimes = 'Number of alert check down times must be an integer greater than 0'; + } + } + + return { errors }; +}; + +export const initMonitorStatusAlertType: AlertTypeInitializer = ({ + autocomplete, +}): AlertTypeModel => ({ + id: 'xpack.uptime.alerts.monitorStatus', + name: 'Uptime Monitor Status', + iconClass: 'uptimeApp', + alertParamsExpression: params => { + return ; + }, + validate, + defaultActionMessage: '{{context.message}}\n{{context.completeIdList}}', +}); diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap index 5906a77f554411..30e15ba132996e 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap @@ -14,6 +14,39 @@ Array [ TestingHeading
      +
      +
      +
      + +
      +
      +
      @@ -130,6 +163,39 @@ Array [ TestingHeading
      +
      +
      +
      + +
      +
      +
      ,
      { const simpleBreadcrumbs: ChromeBreadcrumb[] = [ @@ -21,22 +22,26 @@ describe('PageHeader', () => { it('shallow renders with breadcrumbs and the date picker', () => { const component = renderWithRouter( - + + + ); expect(component).toMatchSnapshot('page_header_with_date_picker'); }); it('shallow renders with breadcrumbs without the date picker', () => { const component = renderWithRouter( - + + + ); expect(component).toMatchSnapshot('page_header_no_date_picker'); }); @@ -45,13 +50,15 @@ describe('PageHeader', () => { const [getBreadcrumbs, core] = mockCore(); mountWithRouter( - - - + + + + + ); @@ -62,6 +69,19 @@ describe('PageHeader', () => { }); }); +const MockReduxProvider = ({ children }: { children: React.ReactElement }) => ( + + {children} + +); + const mockCore: () => [() => ChromeBreadcrumb[], any] = () => { let breadcrumbObj: ChromeBreadcrumb[] = []; const get = () => { diff --git a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx index af9b8bf0464169..f9184e2a0587fa 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx @@ -83,7 +83,13 @@ export const OverviewPageComponent = ({ autocomplete, indexPattern, setEsKueryFi - + diff --git a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx index b0fb2d0ed7869b..56d9ae2d5caa66 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx @@ -13,6 +13,7 @@ import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; import { useUrlParams } from '../hooks'; import { UptimeUrlParams } from '../lib/helper'; +import { ToggleAlertFlyoutButton } from '../components/connected'; interface PageHeaderProps { headingText: string; @@ -60,6 +61,9 @@ export const PageHeader = ({ headingText, breadcrumbs, datePicker = true }: Page

      {headingText}

      + + + {datePickerComponent}
      diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts index d15d601737b2d6..4885f974dbbd49 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts @@ -12,6 +12,8 @@ export interface PopoverState { export type UiPayload = PopoverState & string & number & Map; +export const setAlertFlyoutVisible = createAction('TOGGLE ALERT FLYOUT'); + export const setBasePath = createAction('SET BASE PATH'); export const triggerAppRefresh = createAction('REFRESH APP'); diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap index 5d03c0058c3c1d..1dc4e45606c607 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap @@ -2,6 +2,7 @@ exports[`ui reducer adds integration popover status to state 1`] = ` Object { + "alertFlyoutVisible": false, "basePath": "", "esKuery": "", "integrationsPopoverOpen": Object { @@ -14,6 +15,7 @@ Object { exports[`ui reducer sets the application's base path 1`] = ` Object { + "alertFlyoutVisible": false, "basePath": "yyz", "esKuery": "", "integrationsPopoverOpen": null, @@ -23,6 +25,7 @@ Object { exports[`ui reducer updates the refresh value 1`] = ` Object { + "alertFlyoutVisible": false, "basePath": "abc", "esKuery": "", "integrationsPopoverOpen": null, diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts index 417095b64ba2d7..3c134366347aa3 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { setBasePath, toggleIntegrationsPopover, triggerAppRefresh } from '../../actions'; +import { + setBasePath, + toggleIntegrationsPopover, + triggerAppRefresh, + setAlertFlyoutVisible, +} from '../../actions'; import { uiReducer } from '../ui'; import { Action } from 'redux-actions'; @@ -14,6 +19,7 @@ describe('ui reducer', () => { expect( uiReducer( { + alertFlyoutVisible: false, basePath: 'abc', esKuery: '', integrationsPopoverOpen: null, @@ -32,6 +38,7 @@ describe('ui reducer', () => { expect( uiReducer( { + alertFlyoutVisible: false, basePath: '', esKuery: '', integrationsPopoverOpen: null, @@ -47,6 +54,7 @@ describe('ui reducer', () => { expect( uiReducer( { + alertFlyoutVisible: false, basePath: 'abc', esKuery: '', integrationsPopoverOpen: null, @@ -56,4 +64,28 @@ describe('ui reducer', () => { ) ).toMatchSnapshot(); }); + + it('updates the alert flyout value', () => { + const action = setAlertFlyoutVisible(true) as Action; + expect( + uiReducer( + { + alertFlyoutVisible: false, + basePath: '', + esKuery: '', + integrationsPopoverOpen: null, + lastRefresh: 125, + }, + action + ) + ).toMatchInlineSnapshot(` + Object { + "alertFlyoutVisible": true, + "basePath": "", + "esKuery": "", + "integrationsPopoverOpen": null, + "lastRefresh": 125, + } + `); + }); }); diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts index bb5bd22085ac68..702d314250521f 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts @@ -12,19 +12,22 @@ import { setEsKueryString, triggerAppRefresh, UiPayload, + setAlertFlyoutVisible, } from '../actions/ui'; export interface UiState { - integrationsPopoverOpen: PopoverState | null; + alertFlyoutVisible: boolean; basePath: string; esKuery: string; + integrationsPopoverOpen: PopoverState | null; lastRefresh: number; } const initialState: UiState = { - integrationsPopoverOpen: null, + alertFlyoutVisible: false, basePath: '', esKuery: '', + integrationsPopoverOpen: null, lastRefresh: Date.now(), }; @@ -35,6 +38,11 @@ export const uiReducer = handleActions( integrationsPopoverOpen: action.payload as PopoverState, }), + [String(setAlertFlyoutVisible)]: (state, action: Action) => ({ + ...state, + alertFlyoutVisible: action.payload ?? !state.alertFlyoutVisible, + }), + [String(setBasePath)]: (state, action: Action) => ({ ...state, basePath: action.payload as string, diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts index de446418632b8d..b1da995709f937 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -35,6 +35,7 @@ describe('state selectors', () => { loading: false, }, ui: { + alertFlyoutVisible: false, basePath: 'yyz', esKuery: '', integrationsPopoverOpen: null, diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts index 4767c25e8f52f0..7b5a5ddf8d3ca7 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts @@ -46,6 +46,15 @@ export const selectDurationLines = ({ monitorDuration }: AppState) => { return monitorDuration; }; +export const selectAlertFlyoutVisibility = ({ ui: { alertFlyoutVisible } }: AppState) => + alertFlyoutVisible; + +export const selectMonitorStatusAlert = ({ indexPattern, overviewFilters, ui }: AppState) => ({ + filters: ui.esKuery, + indexPattern: indexPattern.index_pattern, + locations: overviewFilters.filters.locations, +}); + export const indexStatusSelector = ({ indexStatus }: AppState) => { return indexStatus; }; diff --git a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx index 09156db9ca7d23..fa2998532d145a 100644 --- a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx +++ b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx @@ -23,6 +23,8 @@ import { CommonlyUsedRange } from './components/functional/uptime_date_picker'; import { store } from './state'; import { setBasePath } from './state/actions'; import { PageRouter } from './routes'; +import { UptimeAlertsFlyoutWrapper } from './components/connected'; +import { UptimeAlertsContextProvider } from './components/functional/alerts'; import { kibanaService } from './state/kibana_service'; export interface UptimeAppColors { @@ -99,11 +101,14 @@ const Application = (props: UptimeAppProps) => { - -
      - -
      -
      + + +
      + + +
      +
      +
      diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index dd61716325afc8..603cfac316b2d7 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -2,7 +2,7 @@ "configPath": ["xpack"], "id": "uptime", "kibanaVersion": "kibana", - "requiredPlugins": ["features", "licensing", "usageCollection"], + "requiredPlugins": ["alerting", "features", "licensing", "usageCollection"], "server": true, "ui": false, "version": "8.0.0" diff --git a/x-pack/plugins/uptime/server/kibana.index.ts b/x-pack/plugins/uptime/server/kibana.index.ts index c7ac3a70c0494c..2c1f34aa8a8e76 100644 --- a/x-pack/plugins/uptime/server/kibana.index.ts +++ b/x-pack/plugins/uptime/server/kibana.index.ts @@ -55,5 +55,5 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor }, }); - initUptimeServer(libs); + initUptimeServer(server, libs, plugins); }; diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index 8dde6050d5d36c..6fc488e949e9c2 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -31,6 +31,8 @@ export interface UptimeCoreSetup { export interface UptimeCorePlugins { features: PluginSetupContract; + alerting: any; + elasticsearch: any; usageCollection: UsageCollectionSetup; } diff --git a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts new file mode 100644 index 00000000000000..8a11270a4740a2 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts @@ -0,0 +1,587 @@ +/* + * 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 { + contextMessage, + uniqueMonitorIds, + updateState, + statusCheckAlertFactory, + fullListByIdAndLocation, +} from '../status_check'; +import { GetMonitorStatusResult } from '../../requests'; +import { AlertType } from '../../../../../alerting/server'; +import { IRouter } from 'kibana/server'; +import { UMServerLibs } from '../../lib'; +import { UptimeCoreSetup } from '../../adapters'; + +/** + * The alert takes some dependencies as parameters; these are things like + * kibana core services and plugins. This function helps reduce the amount of + * boilerplate required. + * @param customRequests client tests can use this paramter to provide their own request mocks, + * so we don't have to mock them all for each test. + */ +const bootstrapDependencies = (customRequests?: any) => { + const route: IRouter = {} as IRouter; + // these server/libs parameters don't have any functionality, which is fine + // because we aren't testing them here + const server: UptimeCoreSetup = { route }; + const libs: UMServerLibs = { requests: {} } as UMServerLibs; + libs.requests = { ...libs.requests, ...customRequests }; + return { server, libs }; +}; + +/** + * This function aims to provide an easy way to give mock props that will + * reduce boilerplate for tests. + * @param params the params received at alert creation time + * @param services the core services provided by kibana/alerting platforms + * @param state the state the alert maintains + */ +const mockOptions = ( + params = { numTimes: 5, locations: [], timerange: { from: 'now-15m', to: 'now' } }, + services = { callCluster: 'mockESFunction' }, + state = {} +): any => ({ + params, + services, + state, +}); + +describe('status check alert', () => { + describe('executor', () => { + it('does not trigger when there are no monitors down', async () => { + expect.assertions(4); + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([]); + const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs); + // @ts-ignore the executor can return `void`, but ours never does + const state: Record = await alert.executor(mockOptions()); + + expect(state).not.toBeUndefined(); + expect(state?.isTriggered).toBe(false); + expect(mockGetter).toHaveBeenCalledTimes(1); + expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "callES": "mockESFunction", + "locations": Array [], + "numTimes": 5, + "timerange": Object { + "from": "now-15m", + "to": "now", + }, + }, + ] + `); + }); + + it('triggers when monitors are down and provides expected state', async () => { + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([ + { + monitor_id: 'first', + location: 'harrisburg', + count: 234, + status: 'down', + }, + { + monitor_id: 'first', + location: 'fairbanks', + count: 234, + status: 'down', + }, + ]); + const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs); + const mockInstanceFactory = jest.fn(); + const mockReplaceState = jest.fn(); + const mockScheduleActions = jest.fn(); + mockInstanceFactory.mockReturnValue({ + replaceState: mockReplaceState, + scheduleActions: mockScheduleActions, + }); + const options = mockOptions(); + options.services = { + ...options.services, + alertInstanceFactory: mockInstanceFactory, + }; + // @ts-ignore the executor can return `void`, but ours never does + const state: Record = await alert.executor(options); + expect(mockGetter).toHaveBeenCalledTimes(1); + expect(mockInstanceFactory).toHaveBeenCalledTimes(1); + expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "callES": "mockESFunction", + "locations": Array [], + "numTimes": 5, + "timerange": Object { + "from": "now-15m", + "to": "now", + }, + }, + ] + `); + expect(mockReplaceState).toHaveBeenCalledTimes(1); + expect(mockReplaceState.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "monitors": Array [ + Object { + "count": 234, + "location": "fairbanks", + "monitor_id": "first", + "status": "down", + }, + Object { + "count": 234, + "location": "harrisburg", + "monitor_id": "first", + "status": "down", + }, + ], + }, + ] + `); + expect(mockScheduleActions).toHaveBeenCalledTimes(1); + expect(mockScheduleActions.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "xpack.uptime.alerts.actionGroups.monitorStatus", + Object { + "completeIdList": "first from fairbanks; first from harrisburg; ", + "message": "Down monitor: first", + "server": Object { + "route": Object {}, + }, + }, + ] + `); + }); + }); + + describe('fullListByIdAndLocation', () => { + it('renders a list of all monitors', () => { + const statuses: GetMonitorStatusResult[] = [ + { + location: 'harrisburg', + monitor_id: 'first', + status: 'down', + count: 34, + }, + { + location: 'fairbanks', + monitor_id: 'second', + status: 'down', + count: 23, + }, + { + location: 'fairbanks', + monitor_id: 'first', + status: 'down', + count: 23, + }, + { + location: 'harrisburg', + monitor_id: 'second', + status: 'down', + count: 34, + }, + ]; + expect(fullListByIdAndLocation(statuses)).toMatchInlineSnapshot( + `"first from fairbanks; first from harrisburg; second from fairbanks; second from harrisburg; "` + ); + }); + + it('renders a list of monitors when greater than limit', () => { + const statuses: GetMonitorStatusResult[] = [ + { + location: 'fairbanks', + monitor_id: 'second', + status: 'down', + count: 23, + }, + { + location: 'fairbanks', + monitor_id: 'first', + status: 'down', + count: 23, + }, + { + location: 'harrisburg', + monitor_id: 'first', + status: 'down', + count: 34, + }, + { + location: 'harrisburg', + monitor_id: 'second', + status: 'down', + count: 34, + }, + ]; + expect(fullListByIdAndLocation(statuses.slice(0, 2), 1)).toMatchInlineSnapshot( + `"first from fairbanks; ...and 1 other monitor/location"` + ); + }); + + it('renders expected list of monitors when limit difference > 1', () => { + const statuses: GetMonitorStatusResult[] = [ + { + location: 'fairbanks', + monitor_id: 'second', + status: 'down', + count: 23, + }, + { + location: 'harrisburg', + monitor_id: 'first', + status: 'down', + count: 34, + }, + { + location: 'harrisburg', + monitor_id: 'second', + status: 'down', + count: 34, + }, + { + location: 'harrisburg', + monitor_id: 'third', + status: 'down', + count: 34, + }, + { + location: 'fairbanks', + monitor_id: 'third', + status: 'down', + count: 23, + }, + { + location: 'fairbanks', + monitor_id: 'first', + status: 'down', + count: 23, + }, + ]; + expect(fullListByIdAndLocation(statuses, 4)).toMatchInlineSnapshot( + `"first from fairbanks; first from harrisburg; second from fairbanks; second from harrisburg; ...and 2 other monitors/locations"` + ); + }); + }); + + describe('alert factory', () => { + let alert: AlertType; + + beforeEach(() => { + const { server, libs } = bootstrapDependencies(); + alert = statusCheckAlertFactory(server, libs); + }); + + it('creates an alert with expected params', () => { + // @ts-ignore the `props` key here isn't described + expect(Object.keys(alert.validate?.params?.props ?? {})).toMatchInlineSnapshot(` + Array [ + "filters", + "numTimes", + "timerange", + "locations", + ] + `); + }); + + it('contains the expected static fields like id, name, etc.', () => { + expect(alert.id).toBe('xpack.uptime.alerts.monitorStatus'); + expect(alert.name).toBe('Uptime Monitor Status'); + expect(alert.defaultActionGroupId).toBe('xpack.uptime.alerts.actionGroups.monitorStatus'); + expect(alert.actionGroups).toMatchInlineSnapshot(` + Array [ + Object { + "id": "xpack.uptime.alerts.actionGroups.monitorStatus", + "name": "Uptime Down Monitor", + }, + ] + `); + }); + }); + + describe('updateState', () => { + let spy: jest.SpyInstance; + beforeEach(() => { + spy = jest.spyOn(Date.prototype, 'toISOString'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sets initial state values', () => { + spy.mockImplementation(() => 'foo date string'); + const result = updateState({}, false); + expect(spy).toHaveBeenCalledTimes(1); + expect(result).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "foo date string", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "foo date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + }); + + it('updates the correct field in subsequent calls', () => { + spy + .mockImplementationOnce(() => 'first date string') + .mockImplementationOnce(() => 'second date string'); + const firstState = updateState({}, false); + const secondState = updateState(firstState, true); + expect(spy).toHaveBeenCalledTimes(2); + expect(firstState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "first date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + expect(secondState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "second date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": true, + "lastCheckedAt": "second date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "second date string", + } + `); + }); + + it('correctly marks resolution times', () => { + spy + .mockImplementationOnce(() => 'first date string') + .mockImplementationOnce(() => 'second date string') + .mockImplementationOnce(() => 'third date string'); + const firstState = updateState({}, true); + const secondState = updateState(firstState, true); + const thirdState = updateState(secondState, false); + expect(spy).toHaveBeenCalledTimes(3); + expect(firstState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "first date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "first date string", + "isTriggered": true, + "lastCheckedAt": "first date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "first date string", + } + `); + expect(secondState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "first date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "first date string", + "isTriggered": true, + "lastCheckedAt": "second date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "second date string", + } + `); + expect(thirdState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": "first date string", + "isTriggered": false, + "lastCheckedAt": "third date string", + "lastResolvedAt": "third date string", + "lastTriggeredAt": "second date string", + } + `); + }); + + it('correctly marks state fields across multiple triggers/resolutions', () => { + spy + .mockImplementationOnce(() => 'first date string') + .mockImplementationOnce(() => 'second date string') + .mockImplementationOnce(() => 'third date string') + .mockImplementationOnce(() => 'fourth date string') + .mockImplementationOnce(() => 'fifth date string'); + const firstState = updateState({}, false); + const secondState = updateState(firstState, true); + const thirdState = updateState(secondState, false); + const fourthState = updateState(thirdState, true); + const fifthState = updateState(fourthState, false); + expect(spy).toHaveBeenCalledTimes(5); + expect(firstState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "first date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + expect(secondState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "second date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": true, + "lastCheckedAt": "second date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "second date string", + } + `); + expect(thirdState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": false, + "lastCheckedAt": "third date string", + "lastResolvedAt": "third date string", + "lastTriggeredAt": "second date string", + } + `); + expect(fourthState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "fourth date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": true, + "lastCheckedAt": "fourth date string", + "lastResolvedAt": "third date string", + "lastTriggeredAt": "fourth date string", + } + `); + expect(fifthState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": false, + "lastCheckedAt": "fifth date string", + "lastResolvedAt": "fifth date string", + "lastTriggeredAt": "fourth date string", + } + `); + }); + }); + + describe('uniqueMonitorIds', () => { + let items: GetMonitorStatusResult[]; + beforeEach(() => { + items = [ + { + monitor_id: 'first', + location: 'harrisburg', + count: 234, + status: 'down', + }, + { + monitor_id: 'first', + location: 'fairbanks', + count: 312, + status: 'down', + }, + { + monitor_id: 'second', + location: 'harrisburg', + count: 325, + status: 'down', + }, + { + monitor_id: 'second', + location: 'fairbanks', + count: 331, + status: 'down', + }, + { + monitor_id: 'third', + location: 'harrisburg', + count: 331, + status: 'down', + }, + { + monitor_id: 'third', + location: 'fairbanks', + count: 342, + status: 'down', + }, + { + monitor_id: 'fourth', + location: 'harrisburg', + count: 355, + status: 'down', + }, + { + monitor_id: 'fourth', + location: 'fairbanks', + count: 342, + status: 'down', + }, + { + monitor_id: 'fifth', + location: 'harrisburg', + count: 342, + status: 'down', + }, + { + monitor_id: 'fifth', + location: 'fairbanks', + count: 342, + status: 'down', + }, + ]; + }); + + it('creates a set of unique IDs from a list of composite-unique objects', () => { + expect(uniqueMonitorIds(items)).toEqual( + new Set(['first', 'second', 'third', 'fourth', 'fifth']) + ); + }); + }); + + describe('contextMessage', () => { + let ids: string[]; + beforeEach(() => { + ids = ['first', 'second', 'third', 'fourth', 'fifth']; + }); + + it('creates a message with appropriate number of monitors', () => { + expect(contextMessage(ids, 3)).toMatchInlineSnapshot( + `"Down monitors: first, second, third... and 2 other monitors"` + ); + }); + + it('throws an error if `max` is less than 2', () => { + expect(() => contextMessage(ids, 1)).toThrowErrorMatchingInlineSnapshot( + '"Maximum value must be greater than 2, received 1."' + ); + }); + + it('returns only the ids if length < max', () => { + expect(contextMessage(ids.slice(0, 2), 3)).toMatchInlineSnapshot( + `"Down monitors: first, second"` + ); + }); + + it('returns a default message when no monitors are provided', () => { + expect(contextMessage([], 3)).toMatchInlineSnapshot(`"No down monitor IDs received"`); + }); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/index.ts b/x-pack/plugins/uptime/server/lib/alerts/index.ts new file mode 100644 index 00000000000000..0e61fd70e0024b --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/index.ts @@ -0,0 +1,10 @@ +/* + * 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 { UptimeAlertTypeFactory } from './types'; +import { statusCheckAlertFactory } from './status_check'; + +export const uptimeAlertTypeFactories: UptimeAlertTypeFactory[] = [statusCheckAlertFactory]; diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts new file mode 100644 index 00000000000000..3e90d2ce95a101 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts @@ -0,0 +1,234 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { isRight } from 'fp-ts/lib/Either'; +import { ThrowReporter } from 'io-ts/lib/ThrowReporter'; +import { i18n } from '@kbn/i18n'; +import { AlertExecutorOptions } from '../../../../alerting/server'; +import { ACTION_GROUP_DEFINITIONS } from '../../../../../legacy/plugins/uptime/common/constants'; +import { UptimeAlertTypeFactory } from './types'; +import { GetMonitorStatusResult } from '../requests'; +import { + StatusCheckExecutorParamsType, + StatusCheckAlertStateType, + StatusCheckAlertState, +} from '../../../../../legacy/plugins/uptime/common/runtime_types'; + +const { MONITOR_STATUS } = ACTION_GROUP_DEFINITIONS; + +/** + * Reduce a composite-key array of status results to a set of unique IDs. + * @param items to reduce + */ +export const uniqueMonitorIds = (items: GetMonitorStatusResult[]): Set => + items.reduce((acc, { monitor_id }) => { + acc.add(monitor_id); + return acc; + }, new Set()); + +/** + * Generates a message to include in contexts of alerts. + * @param monitors the list of monitors to include in the message + * @param max + */ +export const contextMessage = (monitorIds: string[], max: number): string => { + const MIN = 2; + if (max < MIN) throw new Error(`Maximum value must be greater than ${MIN}, received ${max}.`); + + // generate the message + let message; + if (monitorIds.length === 1) { + message = i18n.translate('xpack.uptime.alerts.message.singularTitle', { + defaultMessage: 'Down monitor: ', + }); + } else if (monitorIds.length) { + message = i18n.translate('xpack.uptime.alerts.message.multipleTitle', { + defaultMessage: 'Down monitors: ', + }); + } + // this shouldn't happen because the function should only be called + // when > 0 monitors are down + else { + message = i18n.translate('xpack.uptime.alerts.message.emptyTitle', { + defaultMessage: 'No down monitor IDs received', + }); + } + + for (let i = 0; i < monitorIds.length; i++) { + const id = monitorIds[i]; + if (i === max) { + return ( + message + + i18n.translate('xpack.uptime.alerts.message.overflowBody', { + defaultMessage: `... and {overflowCount} other monitors`, + values: { + overflowCount: monitorIds.length - i, + }, + }) + ); + } else if (i === 0) { + message = message + id; + } else { + message = message + `, ${id}`; + } + } + + return message; +}; + +/** + * Creates an exhaustive list of all the down monitors. + * @param list all the monitors that are down + * @param sizeLimit the max monitors, we shouldn't allow an arbitrarily long string + */ +export const fullListByIdAndLocation = ( + list: GetMonitorStatusResult[], + sizeLimit: number = 1000 +) => { + return ( + list + // sort by id, then location + .sort((a, b) => { + if (a.monitor_id > b.monitor_id) { + return 1; + } else if (a.monitor_id < b.monitor_id) { + return -1; + } else if (a.location > b.location) { + return 1; + } + return -1; + }) + .slice(0, sizeLimit) + .reduce((cur, { monitor_id: id, location }) => cur + `${id} from ${location}; `, '') + + (sizeLimit < list.length + ? i18n.translate('xpack.uptime.alerts.message.fullListOverflow', { + defaultMessage: '...and {overflowCount} other {pluralizedMonitor}', + values: { + pluralizedMonitor: + list.length - sizeLimit === 1 ? 'monitor/location' : 'monitors/locations', + overflowCount: list.length - sizeLimit, + }, + }) + : '') + ); +}; + +export const updateState = ( + state: Record, + isTriggeredNow: boolean +): StatusCheckAlertState => { + const now = new Date().toISOString(); + const decoded = StatusCheckAlertStateType.decode(state); + if (!isRight(decoded)) { + const triggerVal = isTriggeredNow ? now : undefined; + return { + currentTriggerStarted: triggerVal, + firstCheckedAt: now, + firstTriggeredAt: triggerVal, + isTriggered: isTriggeredNow, + lastTriggeredAt: triggerVal, + lastCheckedAt: now, + lastResolvedAt: undefined, + }; + } + const { + currentTriggerStarted, + firstCheckedAt, + firstTriggeredAt, + lastTriggeredAt, + // this is the stale trigger status, we're naming it `wasTriggered` + // to differentiate it from the `isTriggeredNow` param + isTriggered: wasTriggered, + lastResolvedAt, + } = decoded.right; + + let cts: string | undefined; + if (isTriggeredNow && !currentTriggerStarted) { + cts = now; + } else if (isTriggeredNow) { + cts = currentTriggerStarted; + } + + return { + currentTriggerStarted: cts, + firstCheckedAt: firstCheckedAt ?? now, + firstTriggeredAt: isTriggeredNow && !firstTriggeredAt ? now : firstTriggeredAt, + lastCheckedAt: now, + lastTriggeredAt: isTriggeredNow ? now : lastTriggeredAt, + lastResolvedAt: !isTriggeredNow && wasTriggered ? now : lastResolvedAt, + isTriggered: isTriggeredNow, + }; +}; + +// Right now the maximum number of monitors shown in the message is hardcoded here. +// we might want to make this a parameter in the future +const DEFAULT_MAX_MESSAGE_ROWS = 3; + +export const statusCheckAlertFactory: UptimeAlertTypeFactory = (server, libs) => ({ + id: 'xpack.uptime.alerts.monitorStatus', + name: i18n.translate('xpack.uptime.alerts.monitorStatus', { + defaultMessage: 'Uptime Monitor Status', + }), + validate: { + params: schema.object({ + filters: schema.maybe(schema.string()), + numTimes: schema.number(), + timerange: schema.object({ + from: schema.string(), + to: schema.string(), + }), + locations: schema.arrayOf(schema.string()), + }), + }, + defaultActionGroupId: MONITOR_STATUS.id, + actionGroups: [ + { + id: MONITOR_STATUS.id, + name: MONITOR_STATUS.name, + }, + ], + async executor(options: AlertExecutorOptions) { + const { params: rawParams } = options; + const decoded = StatusCheckExecutorParamsType.decode(rawParams); + if (!isRight(decoded)) { + ThrowReporter.report(decoded); + return { + error: 'Alert param types do not conform to required shape.', + }; + } + + const params = decoded.right; + + /* This is called `monitorsByLocation` but it's really + * monitors by location by status. The query we run to generate this + * filters on the status field, so effectively there should be one and only one + * status represented in the result set. */ + const monitorsByLocation = await libs.requests.getMonitorStatus({ + callES: options.services.callCluster, + ...params, + }); + + // if no monitors are down for our query, we don't need to trigger an alert + if (monitorsByLocation.length) { + const uniqueIds = uniqueMonitorIds(monitorsByLocation); + const alertInstance = options.services.alertInstanceFactory(MONITOR_STATUS.id); + alertInstance.replaceState({ + ...options.state, + monitors: monitorsByLocation, + }); + alertInstance.scheduleActions(MONITOR_STATUS.id, { + message: contextMessage(Array.from(uniqueIds.keys()), DEFAULT_MAX_MESSAGE_ROWS), + server, + completeIdList: fullListByIdAndLocation(monitorsByLocation), + }); + } + + // this stateful data is at the cluster level, not an alert instance level, + // so any alert of this type will flush/overwrite the state when they return + return updateState(options.state, monitorsByLocation.length > 0); + }, +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/types.ts b/x-pack/plugins/uptime/server/lib/alerts/types.ts new file mode 100644 index 00000000000000..bc1e82224f7b02 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/types.ts @@ -0,0 +1,11 @@ +/* + * 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 { AlertType } from '../../../../alerting/server'; +import { UptimeCoreSetup } from '../adapters'; +import { UMServerLibs } from '../lib'; + +export type UptimeAlertTypeFactory = (server: UptimeCoreSetup, libs: UMServerLibs) => AlertType; diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts new file mode 100644 index 00000000000000..74b8c352c8553d --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts @@ -0,0 +1,553 @@ +/* + * 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 { elasticsearchServiceMock } from '../../../../../../../src/core/server/mocks'; +import { getMonitorStatus } from '../get_monitor_status'; +import { ScopedClusterClient } from 'src/core/server/elasticsearch'; + +interface BucketItemCriteria { + monitor_id: string; + status: string; + location: string; + doc_count: number; +} + +interface BucketKey { + monitor_id: string; + status: string; + location: string; +} + +interface BucketItem { + key: BucketKey; + doc_count: number; +} + +interface MultiPageCriteria { + after_key?: BucketKey; + bucketCriteria: BucketItemCriteria[]; +} + +const genBucketItem = ({ + monitor_id, + status, + location, + doc_count, +}: BucketItemCriteria): BucketItem => ({ + key: { + monitor_id, + status, + location, + }, + doc_count, +}); + +type MockCallES = (method: any, params: any) => Promise; + +const setupMock = ( + criteria: MultiPageCriteria[] +): [MockCallES, jest.Mocked>] => { + const esMock = elasticsearchServiceMock.createScopedClusterClient(); + + criteria.forEach(({ after_key, bucketCriteria }) => { + const mockResponse = { + aggregations: { + monitors: { + after_key, + buckets: bucketCriteria.map(item => genBucketItem(item)), + }, + }, + }; + esMock.callAsCurrentUser.mockResolvedValueOnce(mockResponse); + }); + return [(method: any, params: any) => esMock.callAsCurrentUser(method, params), esMock]; +}; + +describe('getMonitorStatus', () => { + it('applies bool filters to params', async () => { + const [callES, esMock] = setupMock([]); + const exampleFilter = `{ + "bool": { + "should": [ + { + "bool": { + "should": [ + { + "match_phrase": { + "monitor.id": "apm-dev" + } + } + ], + "minimum_should_match": 1 + } + }, + { + "bool": { + "should": [ + { + "match_phrase": { + "monitor.id": "auto-http-0X8D6082B94BBE3B8A" + } + } + ], + "minimum_should_match": 1 + } + } + ], + "minimum_should_match": 1 + } + }`; + await getMonitorStatus({ + callES, + filters: exampleFilter, + locations: [], + numTimes: 5, + timerange: { + from: 'now-10m', + to: 'now-1m', + }, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitor_id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "status": Object { + "terms": Object { + "field": "monitor.status", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.status": "down", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-10m", + "lte": "now-1m", + }, + }, + }, + ], + "minimum_should_match": 1, + "should": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "monitor.id": "apm-dev", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "monitor.id": "auto-http-0X8D6082B94BBE3B8A", + }, + }, + ], + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + }); + + it('applies locations to params', async () => { + const [callES, esMock] = setupMock([]); + await getMonitorStatus({ + callES, + locations: ['fairbanks', 'harrisburg'], + numTimes: 1, + timerange: { + from: 'now-2m', + to: 'now', + }, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitor_id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "status": Object { + "terms": Object { + "field": "monitor.status", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.status": "down", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-2m", + "lte": "now", + }, + }, + }, + Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "observer.geo.name": "fairbanks", + }, + }, + Object { + "term": Object { + "observer.geo.name": "harrisburg", + }, + }, + ], + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + }); + + it('fetches single page of results', async () => { + const [callES, esMock] = setupMock([ + { + bucketCriteria: [ + { + monitor_id: 'foo', + status: 'down', + location: 'fairbanks', + doc_count: 43, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'harrisburg', + doc_count: 53, + }, + { + monitor_id: 'foo', + status: 'down', + location: 'harrisburg', + doc_count: 44, + }, + ], + }, + ]); + const clientParameters = { + filters: undefined, + locations: [], + numTimes: 5, + timerange: { + from: 'now-12m', + to: 'now-2m', + }, + }; + const result = await getMonitorStatus({ + callES, + ...clientParameters, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitor_id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "status": Object { + "terms": Object { + "field": "monitor.status", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.status": "down", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-12m", + "lte": "now-2m", + }, + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "count": 43, + "location": "fairbanks", + "monitor_id": "foo", + "status": "down", + }, + Object { + "count": 53, + "location": "harrisburg", + "monitor_id": "bar", + "status": "down", + }, + Object { + "count": 44, + "location": "harrisburg", + "monitor_id": "foo", + "status": "down", + }, + ] + `); + }); + + it('fetches multiple pages of results in the thing', async () => { + const criteria = [ + { + after_key: { + monitor_id: 'foo', + location: 'harrisburg', + status: 'down', + }, + bucketCriteria: [ + { + monitor_id: 'foo', + status: 'down', + location: 'fairbanks', + doc_count: 43, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'harrisburg', + doc_count: 53, + }, + { + monitor_id: 'foo', + status: 'down', + location: 'harrisburg', + doc_count: 44, + }, + ], + }, + { + after_key: { + monitor_id: 'bar', + status: 'down', + location: 'fairbanks', + }, + bucketCriteria: [ + { + monitor_id: 'sna', + status: 'down', + location: 'fairbanks', + doc_count: 21, + }, + { + monitor_id: 'fu', + status: 'down', + location: 'fairbanks', + doc_count: 21, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'fairbanks', + doc_count: 45, + }, + ], + }, + { + bucketCriteria: [ + { + monitor_id: 'sna', + status: 'down', + location: 'harrisburg', + doc_count: 21, + }, + { + monitor_id: 'fu', + status: 'down', + location: 'harrisburg', + doc_count: 21, + }, + ], + }, + ]; + const [callES] = setupMock(criteria); + const result = await getMonitorStatus({ + callES, + locations: [], + numTimes: 5, + timerange: { + from: 'now-10m', + to: 'now-1m', + }, + }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "count": 43, + "location": "fairbanks", + "monitor_id": "foo", + "status": "down", + }, + Object { + "count": 53, + "location": "harrisburg", + "monitor_id": "bar", + "status": "down", + }, + Object { + "count": 44, + "location": "harrisburg", + "monitor_id": "foo", + "status": "down", + }, + Object { + "count": 21, + "location": "fairbanks", + "monitor_id": "sna", + "status": "down", + }, + Object { + "count": 21, + "location": "fairbanks", + "monitor_id": "fu", + "status": "down", + }, + Object { + "count": 45, + "location": "fairbanks", + "monitor_id": "bar", + "status": "down", + }, + Object { + "count": 21, + "location": "harrisburg", + "monitor_id": "sna", + "status": "down", + }, + Object { + "count": 21, + "location": "harrisburg", + "monitor_id": "fu", + "status": "down", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts new file mode 100644 index 00000000000000..2cebd532fd29b0 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts @@ -0,0 +1,150 @@ +/* + * 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 { UMElasticsearchQueryFn } from '../adapters'; +import { INDEX_NAMES } from '../../../../../legacy/plugins/uptime/common/constants'; + +export interface GetMonitorStatusParams { + filters?: string; + locations: string[]; + numTimes: number; + timerange: { from: string; to: string }; +} + +export interface GetMonitorStatusResult { + monitor_id: string; + status: string; + location: string; + count: number; +} + +interface MonitorStatusKey { + monitor_id: string; + status: string; + location: string; +} + +const formatBuckets = async ( + buckets: any[], + numTimes: number +): Promise => { + return buckets + .filter((monitor: any) => monitor?.doc_count > numTimes) + .map(({ key, doc_count }: any) => ({ ...key, count: doc_count })); +}; + +const getLocationClause = (locations: string[]) => ({ + bool: { + should: [ + ...locations.map(location => ({ + term: { + 'observer.geo.name': location, + }, + })), + ], + }, +}); + +export const getMonitorStatus: UMElasticsearchQueryFn< + GetMonitorStatusParams, + GetMonitorStatusResult[] +> = async ({ callES, filters, locations, numTimes, timerange: { from, to } }) => { + const queryResults: Array> = []; + let afterKey: MonitorStatusKey | undefined; + + do { + // today this value is hardcoded. In the future we may support + // multiple status types for this alert, and this will become a parameter + const STATUS = 'down'; + const esParams: any = { + index: INDEX_NAMES.HEARTBEAT, + body: { + query: { + bool: { + filter: [ + { + term: { + 'monitor.status': STATUS, + }, + }, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ], + }, + }, + size: 0, + aggs: { + monitors: { + composite: { + size: 2000, + sources: [ + { + monitor_id: { + terms: { + field: 'monitor.id', + }, + }, + }, + { + status: { + terms: { + field: 'monitor.status', + }, + }, + }, + { + location: { + terms: { + field: 'observer.geo.name', + missing_bucket: true, + }, + }, + }, + ], + }, + }, + }, + }, + }; + + /** + * `filters` are an unparsed JSON string. We parse them and append the bool fields of the query + * to the bool of the parsed filters. + */ + if (filters) { + const parsedFilters = JSON.parse(filters); + esParams.body.query.bool = Object.assign({}, esParams.body.query.bool, parsedFilters.bool); + } + + /** + * Perform a logical `and` against the selected location filters. + */ + if (locations.length) { + esParams.body.query.bool.filter.push(getLocationClause(locations)); + } + + /** + * We "paginate" results by utilizing the `afterKey` field + * to tell Elasticsearch where it should start on subsequent queries. + */ + if (afterKey) { + esParams.body.aggs.monitors.composite.after = afterKey; + } + + const result = await callES('search', esParams); + afterKey = result?.aggregations?.monitors?.after_key; + + queryResults.push(formatBuckets(result?.aggregations?.monitors?.buckets || [], numTimes)); + } while (afterKey !== undefined); + + return (await Promise.all(queryResults)).reduce((acc, cur) => acc.concat(cur), []); +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/index.ts b/x-pack/plugins/uptime/server/lib/requests/index.ts index b1d7ff2c2ce02c..7225d329d3c7f8 100644 --- a/x-pack/plugins/uptime/server/lib/requests/index.ts +++ b/x-pack/plugins/uptime/server/lib/requests/index.ts @@ -12,6 +12,8 @@ export { getMonitorDurationChart, GetMonitorChartsParams } from './get_monitor_d export { getMonitorDetails, GetMonitorDetailsParams } from './get_monitor_details'; export { getMonitorLocations, GetMonitorLocationsParams } from './get_monitor_locations'; export { getMonitorStates, GetMonitorStatesParams } from './get_monitor_states'; +export { getMonitorStatus, GetMonitorStatusParams } from './get_monitor_status'; +export * from './get_monitor_status'; export { getPings, GetPingsParams } from './get_pings'; export { getPingHistogram, GetPingHistogramParams } from './get_ping_histogram'; export { UptimeRequests } from './uptime_requests'; diff --git a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts b/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts index 7f192994bd075a..ddf506786f1455 100644 --- a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts +++ b/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts @@ -16,6 +16,8 @@ import { GetMonitorStatesParams, GetPingsParams, GetPingHistogramParams, + GetMonitorStatusParams, + GetMonitorStatusResult, } from '.'; import { OverviewFilters, @@ -42,6 +44,7 @@ export interface UptimeRequests { getMonitorDetails: ESQ; getMonitorLocations: ESQ; getMonitorStates: ESQ; + getMonitorStatus: ESQ; getPings: ESQ; getPingHistogram: ESQ; getSnapshotCount: ESQ; diff --git a/x-pack/plugins/uptime/server/uptime_server.ts b/x-pack/plugins/uptime/server/uptime_server.ts index 4dfa1373db8d9a..d4b38b8ad27a0f 100644 --- a/x-pack/plugins/uptime/server/uptime_server.ts +++ b/x-pack/plugins/uptime/server/uptime_server.ts @@ -8,12 +8,22 @@ import { makeExecutableSchema } from 'graphql-tools'; import { DEFAULT_GRAPHQL_PATH, resolvers, typeDefs } from './graphql'; import { UMServerLibs } from './lib/lib'; import { createRouteWithAuth, restApiRoutes, uptimeRouteWrapper } from './rest_api'; +import { UptimeCoreSetup, UptimeCorePlugins } from './lib/adapters'; +import { uptimeAlertTypeFactories } from './lib/alerts'; -export const initUptimeServer = (libs: UMServerLibs) => { +export const initUptimeServer = ( + server: UptimeCoreSetup, + libs: UMServerLibs, + plugins: UptimeCorePlugins +) => { restApiRoutes.forEach(route => libs.framework.registerRoute(uptimeRouteWrapper(createRouteWithAuth(libs, route))) ); + uptimeAlertTypeFactories.forEach(alertTypeFactory => + plugins.alerting.registerType(alertTypeFactory(server, libs)) + ); + const graphQLSchema = makeExecutableSchema({ resolvers: resolvers.map(createResolversFn => createResolversFn(libs)), typeDefs, diff --git a/x-pack/test/functional/page_objects/uptime_page.ts b/x-pack/test/functional/page_objects/uptime_page.ts index f6e93cd14e4971..57842ffbb2c5d3 100644 --- a/x-pack/test/functional/page_objects/uptime_page.ts +++ b/x-pack/test/functional/page_objects/uptime_page.ts @@ -24,11 +24,13 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo public async goToUptimeOverviewAndLoadData( datePickerStartValue: string, datePickerEndValue: string, - monitorIdToCheck: string + monitorIdToCheck?: string ) { await pageObjects.common.navigateToApp('uptime'); await pageObjects.timePicker.setAbsoluteRange(datePickerStartValue, datePickerEndValue); - await uptimeService.monitorIdExists(monitorIdToCheck); + if (monitorIdToCheck) { + await uptimeService.monitorIdExists(monitorIdToCheck); + } } public async loadDataAndGoToMonitorPage( @@ -96,5 +98,39 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo public locationMissingIsDisplayed() { return uptimeService.locationMissingExists(); } + + public async openAlertFlyoutAndCreateMonitorStatusAlert({ + alertInterval, + alertName, + alertNumTimes, + alertTags, + alertThrottleInterval, + alertTimerangeSelection, + filters, + }: { + alertName: string; + alertTags: string[]; + alertInterval: string; + alertThrottleInterval: string; + alertNumTimes: string; + alertTimerangeSelection: string; + filters?: string; + }) { + const { alerts, setKueryBarText } = uptimeService; + await alerts.openFlyout(); + await alerts.openMonitorStatusAlertType(); + await alerts.setAlertName(alertName); + await alerts.setAlertTags(alertTags); + await alerts.setAlertInterval(alertInterval); + await alerts.setAlertThrottleInterval(alertThrottleInterval); + if (filters) { + await setKueryBarText('xpack.uptime.alerts.monitorStatus.filterBar', filters); + } + await alerts.setAlertStatusNumTimes(alertNumTimes); + await alerts.setAlertTimerangeSelection(alertTimerangeSelection); + await alerts.setMonitorStatusSelectableToHours(); + await alerts.setLocationsSelectable(); + await alerts.clickSaveAlertButtion(); + } })(); } diff --git a/x-pack/test/functional/services/uptime.ts b/x-pack/test/functional/services/uptime.ts index 938be2c71ae743..7994a7e9340333 100644 --- a/x-pack/test/functional/services/uptime.ts +++ b/x-pack/test/functional/services/uptime.ts @@ -12,6 +12,91 @@ export function UptimeProvider({ getService }: FtrProviderContext) { const retry = getService('retry'); return { + alerts: { + async openFlyout() { + await testSubjects.click('xpack.uptime.alertsPopover.toggleButton', 5000); + await testSubjects.click('xpack.uptime.toggleAlertFlyout', 5000); + }, + async openMonitorStatusAlertType() { + return testSubjects.click('xpack.uptime.alerts.monitorStatus-SelectOption', 5000); + }, + async setAlertTags(tags: string[]) { + for (let i = 0; i < tags.length; i += 1) { + await testSubjects.click('comboBoxSearchInput', 5000); + await testSubjects.setValue('comboBoxInput', tags[i]); + await browser.pressKeys(browser.keys.ENTER); + } + }, + async setAlertName(name: string) { + return testSubjects.setValue('alertNameInput', name); + }, + async setAlertInterval(value: string) { + return testSubjects.setValue('intervalInput', value); + }, + async setAlertThrottleInterval(value: string) { + return testSubjects.setValue('throttleInput', value); + }, + async setAlertExpressionValue( + expressionAttribute: string, + fieldAttribute: string, + value: string + ) { + await testSubjects.click(expressionAttribute); + await testSubjects.setValue(fieldAttribute, value); + return browser.pressKeys(browser.keys.ESCAPE); + }, + async setAlertStatusNumTimes(value: string) { + return this.setAlertExpressionValue( + 'xpack.uptime.alerts.monitorStatus.numTimesExpression', + 'xpack.uptime.alerts.monitorStatus.numTimesField', + value + ); + }, + async setAlertTimerangeSelection(value: string) { + return this.setAlertExpressionValue( + 'xpack.uptime.alerts.monitorStatus.timerangeValueExpression', + 'xpack.uptime.alerts.monitorStatus.timerangeValueField', + value + ); + }, + async setAlertExpressionSelectable( + expressionAttribute: string, + selectableAttribute: string, + optionAttributes: string[] + ) { + await testSubjects.click(expressionAttribute, 5000); + await testSubjects.click(selectableAttribute, 5000); + for (let i = 0; i < optionAttributes.length; i += 1) { + await testSubjects.click(optionAttributes[i], 5000); + } + return browser.pressKeys(browser.keys.ESCAPE); + }, + async setMonitorStatusSelectableToHours() { + return this.setAlertExpressionSelectable( + 'xpack.uptime.alerts.monitorStatus.timerangeUnitExpression', + 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable', + ['xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.hoursOption'] + ); + }, + async setLocationsSelectable() { + await testSubjects.click( + 'xpack.uptime.alerts.monitorStatus.locationsSelectionExpression', + 5000 + ); + await testSubjects.click( + 'xpack.uptime.alerts.monitorStatus.locationsSelectionSwitch', + 5000 + ); + await testSubjects.click( + 'xpack.uptime.alerts.monitorStatus.locationsSelectionSelectable', + 5000 + ); + return browser.pressKeys(browser.keys.ESCAPE); + }, + async clickSaveAlertButtion() { + return testSubjects.click('saveAlertButton'); + }, + }, async assertExists(key: string) { if (!(await testSubjects.exists(key))) { throw new Error(`Couldn't find expected element with key "${key}".`); @@ -35,11 +120,14 @@ export function UptimeProvider({ getService }: FtrProviderContext) { async getMonitorNameDisplayedOnPageTitle() { return await testSubjects.getVisibleText('monitor-page-title'); }, - async setFilterText(filterQuery: string) { - await testSubjects.click('xpack.uptime.filterBar'); - await testSubjects.setValue('xpack.uptime.filterBar', filterQuery); + async setKueryBarText(attribute: string, value: string) { + await testSubjects.click(attribute); + await testSubjects.setValue(attribute, value); await browser.pressKeys(browser.keys.ENTER); }, + async setFilterText(filterQuery: string) { + await this.setKueryBarText('xpack.uptime.filterBar', filterQuery); + }, async goToNextPage() { await testSubjects.click('xpack.uptime.monitorList.nextButton', 5000); }, diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts new file mode 100644 index 00000000000000..2a0358160da512 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + describe('overview page alert flyout controls', function() { + const DEFAULT_DATE_START = 'Sep 10, 2019 @ 12:40:08.078'; + const DEFAULT_DATE_END = 'Sep 11, 2019 @ 19:40:08.078'; + const pageObjects = getPageObjects(['common', 'uptime']); + const supertest = getService('supertest'); + const retry = getService('retry'); + + it('posts an alert, verfies its presence, and deletes the alert', async () => { + await pageObjects.uptime.goToUptimeOverviewAndLoadData(DEFAULT_DATE_START, DEFAULT_DATE_END); + + await pageObjects.uptime.openAlertFlyoutAndCreateMonitorStatusAlert({ + alertInterval: '11', + alertName: 'uptime-test', + alertNumTimes: '3', + alertTags: ['uptime', 'another'], + alertThrottleInterval: '30', + alertTimerangeSelection: '1', + filters: 'monitor.id: "0001-up"', + }); + + // The creation of the alert could take some time, so the first few times we query after + // the previous line resolves, the API may not be done creating the alert yet, so we + // put the fetch code in a retry block with a timeout. + let alert: any; + await retry.tryForTime(15000, async () => { + const apiResponse = await supertest.get('/api/alert/_find'); + const alertsFromThisTest = apiResponse.body.data.filter( + ({ name }: { name: string }) => name === 'uptime-test' + ); + expect(alertsFromThisTest).to.have.length(1); + alert = alertsFromThisTest[0]; + }); + + // Ensure the parameters and other stateful data + // on the alert match up with the values we provided + // for our test helper to input into the flyout. + const { + actions, + alertTypeId, + consumer, + id, + params: { numTimes, timerange, locations, filters }, + schedule: { interval }, + tags, + } = alert; + + // we're not testing the flyout's ability to associate alerts with action connectors + expect(actions).to.eql([]); + + expect(alertTypeId).to.eql('xpack.uptime.alerts.monitorStatus'); + expect(consumer).to.eql('uptime'); + expect(interval).to.eql('11m'); + expect(tags).to.eql(['uptime', 'another']); + expect(numTimes).to.be(3); + expect(timerange.from).to.be('now-1h'); + expect(timerange.to).to.be('now'); + expect(locations).to.eql(['mpls']); + expect(filters).to.eql( + '{"bool":{"should":[{"match_phrase":{"monitor.id":"0001-up"}}],"minimum_should_match":1}}' + ); + + await supertest + .delete(`/api/alert/${id}`) + .set('kbn-xsrf', 'true') + .expect(204); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts new file mode 100644 index 00000000000000..a433175acae012 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts @@ -0,0 +1,27 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +const ARCHIVE = 'uptime/full_heartbeat'; + +export default ({ getService, loadTestFile }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + + describe('Uptime app', function() { + this.tags('ciGroup6'); + + describe('with real-world data', () => { + before(async () => { + await esArchiver.load(ARCHIVE); + await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'UTC' }); + }); + after(async () => await esArchiver.unload(ARCHIVE)); + + loadTestFile(require.resolve('./alert_flyout')); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index b19ec95c68916d..538817bd9d14c9 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -28,7 +28,10 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { services, pageObjects, // list paths to the files that contain your plugins tests - testFiles: [resolve(__dirname, './apps/triggers_actions_ui')], + testFiles: [ + resolve(__dirname, './apps/triggers_actions_ui'), + resolve(__dirname, './apps/uptime'), + ], apps: { ...xpackFunctionalConfig.get('apps'), triggersActions: { From 3bd3364a5567969558245cdfa5a7381ee4ae7c83 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Thu, 19 Mar 2020 09:58:22 -0700 Subject: [PATCH 06/32] [Canvas] Add Lens embeddables (#57499) * Added lens embeddables to embed flyout Fixed import embedded panel styles (#58654) Merging to WIP draft branch * Added i18n strings for savedLens * Added tests for lens embeddables * Updated tests * Updated tests * Added style overrides for lens table * DDisables triggers on lens emebeddable * Updated test * Sets embeddable view mode according to app state * Fix embeddable component * Removed embeddable view mode logic * Removed unused import --- .../expression_types/embeddable_types.ts | 9 +- .../functions/common/index.ts | 2 + .../functions/common/saved_lens.test.ts | 43 ++++++++++ .../functions/common/saved_lens.ts | 83 +++++++++++++++++++ .../renderers/embeddable/embeddable.scss | 33 ++++++++ .../renderers/embeddable/embeddable.tsx | 7 +- .../embeddable_input_to_expression.test.ts | 48 ++++++++++- .../embeddable_input_to_expression.ts | 19 +++++ .../plugins/canvas/common/lib/constants.ts | 1 + .../canvas/i18n/functions/dict/saved_lens.ts | 27 ++++++ .../canvas/i18n/functions/function_help.ts | 2 + .../components/embeddable_flyout/index.tsx | 3 + .../workpad_interactive_page/index.js | 10 ++- .../plugins/canvas/public/style/index.scss | 1 + x-pack/plugins/lens/common/constants.ts | 1 + 15 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss create mode 100644 x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts index d9e841092be56b..538aa9f74e2a6c 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts @@ -7,9 +7,16 @@ // @ts-ignore import { MAP_SAVED_OBJECT_TYPE } from '../../../maps/common/constants'; import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/visualizations/public'; +import { LENS_EMBEDDABLE_TYPE } from '../../../../../plugins/lens/common/constants'; import { SEARCH_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/constants'; -export const EmbeddableTypes: { map: string; search: string; visualization: string } = { +export const EmbeddableTypes: { + lens: string; + map: string; + search: string; + visualization: string; +} = { + lens: LENS_EMBEDDABLE_TYPE, map: MAP_SAVED_OBJECT_TYPE, search: SEARCH_EMBEDDABLE_TYPE, visualization: VISUALIZE_EMBEDDABLE_TYPE, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 48b50930d563e0..36fa6497ab6f3b 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -48,6 +48,7 @@ import { rounddate } from './rounddate'; import { rowCount } from './rowCount'; import { repeatImage } from './repeatImage'; import { revealImage } from './revealImage'; +import { savedLens } from './saved_lens'; import { savedMap } from './saved_map'; import { savedSearch } from './saved_search'; import { savedVisualization } from './saved_visualization'; @@ -109,6 +110,7 @@ export const functions = [ revealImage, rounddate, rowCount, + savedLens, savedMap, savedSearch, savedVisualization, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts new file mode 100644 index 00000000000000..6b197148e63736 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts @@ -0,0 +1,43 @@ +/* + * 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. + */ +jest.mock('ui/new_platform'); +import { savedLens } from './saved_lens'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; + +const filterContext = { + and: [ + { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, + { + and: [], + column: 'time-column', + type: 'time', + from: '2019-06-04T04:00:00.000Z', + to: '2019-06-05T04:00:00.000Z', + }, + ], +}; + +describe('savedLens', () => { + const fn = savedLens().fn; + const args = { + id: 'some-id', + title: null, + timerange: null, + }; + + it('accepts null context', () => { + const expression = fn(null, args, {} as any); + + expect(expression.input.filters).toEqual([]); + }); + + it('accepts filter context', () => { + const expression = fn(filterContext, args, {} as any); + const embeddableFilters = getQueryFilters(filterContext.and); + + expect(expression.input.filters).toEqual(embeddableFilters); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts new file mode 100644 index 00000000000000..60026adc0998aa --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts @@ -0,0 +1,83 @@ +/* + * 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 { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { TimeRange } from 'src/plugins/data/public'; +import { EmbeddableInput } from 'src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { Filter, TimeRange as TimeRangeArg } from '../../../types'; +import { + EmbeddableTypes, + EmbeddableExpressionType, + EmbeddableExpression, +} from '../../expression_types'; +import { getFunctionHelp } from '../../../i18n'; +import { Filter as DataFilter } from '../../../../../../../src/plugins/data/public'; + +interface Arguments { + id: string; + title: string | null; + timerange: TimeRangeArg | null; +} + +export type SavedLensInput = EmbeddableInput & { + id: string; + timeRange?: TimeRange; + filters: DataFilter[]; +}; + +const defaultTimeRange = { + from: 'now-15m', + to: 'now', +}; + +type Return = EmbeddableExpression; + +export function savedLens(): ExpressionFunctionDefinition< + 'savedLens', + Filter | null, + Arguments, + Return +> { + const { help, args: argHelp } = getFunctionHelp().savedLens; + return { + name: 'savedLens', + help, + args: { + id: { + types: ['string'], + required: false, + help: argHelp.id, + }, + timerange: { + types: ['timerange'], + help: argHelp.timerange, + required: false, + }, + title: { + types: ['string'], + help: argHelp.title, + required: false, + }, + }, + type: EmbeddableExpressionType, + fn: (context, args) => { + const filters = context ? context.and : []; + + return { + type: EmbeddableExpressionType, + input: { + id: args.id, + filters: getQueryFilters(filters), + timeRange: args.timerange || defaultTimeRange, + title: args.title ? args.title : undefined, + disableTriggers: true, + }, + embeddableType: EmbeddableTypes.lens, + }; + }, + }; +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss new file mode 100644 index 00000000000000..04f2f393d1e80e --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss @@ -0,0 +1,33 @@ +.canvasEmbeddable { + .embPanel { + border: none; + background: none; + + .embPanel__title { + margin-bottom: $euiSizeXS; + } + + .embPanel__optionsMenuButton { + border-radius: $euiBorderRadius; + } + + .canvas-isFullscreen & { + .embPanel__optionsMenuButton { + opacity: 0; + } + + &:focus .embPanel__optionsMenuButton, + &:hover .embPanel__optionsMenuButton { + opacity: 1; + } + } + } + + .euiTable { + background: none; + } + + .lnsExpressionRenderer { + @include euiScrollBar; + } +} \ No newline at end of file diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index 549e69e57e9212..d91e70e43bfd54 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -18,11 +18,12 @@ import { start } from '../../../../../../../src/legacy/core_plugins/embeddable_a import { EmbeddableExpression } from '../../expression_types/embeddable'; import { RendererStrings } from '../../../i18n'; import { getSavedObjectFinder } from '../../../../../../../src/plugins/saved_objects/public'; - -const { embeddable: strings } = RendererStrings; import { embeddableInputToExpression } from './embeddable_input_to_expression'; import { EmbeddableInput } from '../../expression_types'; import { RendererHandlers } from '../../../types'; +import { CANVAS_EMBEDDABLE_CLASSNAME } from '../../../common/lib'; + +const { embeddable: strings } = RendererStrings; const embeddablesRegistry: { [key: string]: IEmbeddable; @@ -31,7 +32,7 @@ const embeddablesRegistry: { const renderEmbeddable = (embeddableObject: IEmbeddable, domNode: HTMLElement) => { return (
      diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts index 8694c0e2c7f9f3..4c622b0c247faf 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts @@ -7,12 +7,17 @@ jest.mock('ui/new_platform'); import { embeddableInputToExpression } from './embeddable_input_to_expression'; import { SavedMapInput } from '../../functions/common/saved_map'; +import { SavedLensInput } from '../../functions/common/saved_lens'; import { EmbeddableTypes } from '../../expression_types'; import { fromExpression, Ast } from '@kbn/interpreter/common'; -const baseSavedMapInput = { +const baseEmbeddableInput = { id: 'embeddableId', filters: [], +}; + +const baseSavedMapInput = { + ...baseEmbeddableInput, isLayerTOCOpen: false, refreshConfig: { isPaused: true, @@ -73,4 +78,45 @@ describe('input to expression', () => { expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); }); }); + + describe('Lens Embeddable', () => { + it('converts to a savedLens expression', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.lens); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedLens'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + + expect(ast.chain[0].arguments).not.toHaveProperty('title'); + expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); + }); + + it('includes optional input values', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + title: 'title', + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.map); + const ast = fromExpression(expression); + + expect(ast.chain[0].arguments).toHaveProperty('title', [input.title]); + expect(ast.chain[0].arguments).toHaveProperty('timerange'); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); + }); }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts index a3cb53acebed24..6428507b16a0cb 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts @@ -6,6 +6,7 @@ import { EmbeddableTypes, EmbeddableInput } from '../../expression_types'; import { SavedMapInput } from '../../functions/common/saved_map'; +import { SavedLensInput } from '../../functions/common/saved_lens'; /* Take the input from an embeddable and the type of embeddable and convert it into an expression @@ -46,5 +47,23 @@ export function embeddableInputToExpression( } } + if (embeddableType === EmbeddableTypes.lens) { + const lensInput = input as SavedLensInput; + + expressionParts.push('savedLens'); + + expressionParts.push(`id="${input.id}"`); + + if (input.title) { + expressionParts.push(`title="${input.title}"`); + } + + if (lensInput.timeRange) { + expressionParts.push( + `timerange={timerange from="${lensInput.timeRange.from}" to="${lensInput.timeRange.to}"}` + ); + } + } + return expressionParts.join(' '); } diff --git a/x-pack/legacy/plugins/canvas/common/lib/constants.ts b/x-pack/legacy/plugins/canvas/common/lib/constants.ts index 40e143b9ec589e..ac8e80b8d7b899 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/constants.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/constants.ts @@ -39,3 +39,4 @@ export const API_ROUTE_SHAREABLE_BASE = '/public/canvas'; export const API_ROUTE_SHAREABLE_ZIP = '/public/canvas/zip'; export const API_ROUTE_SHAREABLE_RUNTIME = '/public/canvas/runtime'; export const API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD = `/public/canvas/${SHAREABLE_RUNTIME_NAME}.js`; +export const CANVAS_EMBEDDABLE_CLASSNAME = `canvasEmbeddable`; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts new file mode 100644 index 00000000000000..1efcbc9d3a18eb --- /dev/null +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts @@ -0,0 +1,27 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { savedLens } from '../../../canvas_plugin_src/functions/common/saved_lens'; +import { FunctionHelp } from '../function_help'; +import { FunctionFactory } from '../../../types'; + +export const help: FunctionHelp> = { + help: i18n.translate('xpack.canvas.functions.savedLensHelpText', { + defaultMessage: `Returns an embeddable for a saved lens object`, + }), + args: { + id: i18n.translate('xpack.canvas.functions.savedLens.args.idHelpText', { + defaultMessage: `The ID of the Saved Lens Object`, + }), + timerange: i18n.translate('xpack.canvas.functions.savedLens.args.timerangeHelpText', { + defaultMessage: `The timerange of data that should be included`, + }), + title: i18n.translate('xpack.canvas.functions.savedLens.args.titleHelpText', { + defaultMessage: `The title for the lens emebeddable`, + }), + }, +}; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts index dbdadd09df67f6..e7d7b4ca4321b6 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts @@ -62,6 +62,7 @@ import { help as replace } from './dict/replace'; import { help as revealImage } from './dict/reveal_image'; import { help as rounddate } from './dict/rounddate'; import { help as rowCount } from './dict/row_count'; +import { help as savedLens } from './dict/saved_lens'; import { help as savedMap } from './dict/saved_map'; import { help as savedSearch } from './dict/saved_search'; import { help as savedVisualization } from './dict/saved_visualization'; @@ -216,6 +217,7 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ revealImage, rounddate, rowCount, + savedLens, savedMap, savedSearch, savedVisualization, diff --git a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx index 565ca5fa5bbd6e..353a59397d6b64 100644 --- a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx @@ -21,6 +21,9 @@ const allowedEmbeddables = { [EmbeddableTypes.map]: (id: string) => { return `savedMap id="${id}" | render`; }, + [EmbeddableTypes.lens]: (id: string) => { + return `savedLens id="${id}" | render`; + }, // FIX: Only currently allow Map embeddables /* [EmbeddableTypes.visualization]: (id: string) => { return `filters | savedVisualization id="${id}" | render`; diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js index b775524acf6393..2500a412c0fac4 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js @@ -19,6 +19,7 @@ import { } from '../../../state/actions/elements'; import { selectToplevelNodes } from '../../../state/actions/transient'; import { crawlTree, globalStateUpdater, shapesForNodes } from '../integration_utils'; +import { CANVAS_EMBEDDABLE_CLASSNAME } from '../../../../common/lib'; import { InteractiveWorkpadPage as InteractiveComponent } from './interactive_workpad_page'; import { eventHandlers } from './event_handlers'; @@ -79,9 +80,14 @@ const isEmbeddableBody = element => { const hasClosest = typeof element.closest === 'function'; if (hasClosest) { - return element.closest('.embeddable') && !element.closest('.embPanel__header'); + return ( + element.closest(`.${CANVAS_EMBEDDABLE_CLASSNAME}`) && !element.closest('.embPanel__header') + ); } else { - return closest.call(element, '.embeddable') && !closest.call(element, '.embPanel__header'); + return ( + closest.call(element, `.${CANVAS_EMBEDDABLE_CLASSNAME}`) && + !closest.call(element, '.embPanel__header') + ); } }; diff --git a/x-pack/legacy/plugins/canvas/public/style/index.scss b/x-pack/legacy/plugins/canvas/public/style/index.scss index 4b856208636920..39e5903ff1d966 100644 --- a/x-pack/legacy/plugins/canvas/public/style/index.scss +++ b/x-pack/legacy/plugins/canvas/public/style/index.scss @@ -61,6 +61,7 @@ @import '../../canvas_plugin_src/renderers/advanced_filter/component/advanced_filter.scss'; @import '../../canvas_plugin_src/renderers/dropdown_filter/component/dropdown_filter.scss'; +@import '../../canvas_plugin_src/renderers/embeddable/embeddable.scss'; @import '../../canvas_plugin_src/renderers/plot/plot.scss'; @import '../../canvas_plugin_src/renderers/reveal_image/reveal_image.scss'; @import '../../canvas_plugin_src/renderers/time_filter/components/datetime_calendar/datetime_calendar.scss'; diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index 57f2a633e45243..16ae1b8da752b2 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -5,6 +5,7 @@ */ export const PLUGIN_ID = 'lens'; +export const LENS_EMBEDDABLE_TYPE = 'lens'; export const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; export const BASE_APP_URL = '/app/kibana'; export const BASE_API_URL = '/api/lens'; From bafd45fff2eea61f16764753884b9d9c709cf683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Thu, 19 Mar 2020 13:20:48 -0400 Subject: [PATCH 07/32] Fix race condition in flaky alerting test (#60438) * Fix race condition in flaky test * Fix flakiness in test * Fix more flakiness --- .../common/lib/task_manager_utils.ts | 40 ++++++++++++++- .../tests/alerting/alerts.ts | 51 +++++++++++-------- 2 files changed, 69 insertions(+), 22 deletions(-) diff --git a/x-pack/test/alerting_api_integration/common/lib/task_manager_utils.ts b/x-pack/test/alerting_api_integration/common/lib/task_manager_utils.ts index 3a1d035a023c22..8eb0d11bbb5694 100644 --- a/x-pack/test/alerting_api_integration/common/lib/task_manager_utils.ts +++ b/x-pack/test/alerting_api_integration/common/lib/task_manager_utils.ts @@ -13,7 +13,7 @@ export class TaskManagerUtils { this.retry = retry; } - async waitForIdle(taskRunAtFilter: Date) { + async waitForEmpty(taskRunAtFilter: Date) { return await this.retry.try(async () => { const searchResult = await this.es.search({ index: '.kibana_task_manager', @@ -44,6 +44,44 @@ export class TaskManagerUtils { }); } + async waitForAllTasksIdle(taskRunAtFilter: Date) { + return await this.retry.try(async () => { + const searchResult = await this.es.search({ + index: '.kibana_task_manager', + body: { + query: { + bool: { + must: [ + { + terms: { + 'task.scope': ['actions', 'alerting'], + }, + }, + { + range: { + 'task.scheduledAt': { + gte: taskRunAtFilter, + }, + }, + }, + ], + must_not: [ + { + term: { + 'task.status': 'idle', + }, + }, + ], + }, + }, + }, + }); + if (searchResult.hits.total.value) { + throw new Error(`Expected 0 non-idle tasks but received ${searchResult.hits.total.value}`); + } + }); + } + async waitForActionTaskParamsToBeCleanedUp(createdAtFilter: Date): Promise { return await this.retry.try(async () => { const searchResult = await this.es.search({ diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index 6766705f688a68..6eed28cc381dd2 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -26,9 +26,7 @@ export default function alertTests({ getService }: FtrProviderContext) { const esTestIndexTool = new ESTestIndexTool(es, retry); const taskManagerUtils = new TaskManagerUtils(es, retry); - // FLAKY: https://github.com/elastic/kibana/issues/58643 - // FLAKY: https://github.com/elastic/kibana/issues/58991 - describe.skip('alerts', () => { + describe('alerts', () => { const authorizationIndex = '.kibana-test-authorization'; const objectRemover = new ObjectRemover(supertest); @@ -99,9 +97,11 @@ export default function alertTests({ getService }: FtrProviderContext) { // Wait for the action to index a document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.index-record', reference); + await taskManagerUtils.waitForAllTasksIdle(testStart); + const alertId = response.body.id; await alertUtils.disable(alertId); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 1 alert executed with proper params const alertSearchResult = await esTestIndexTool.search( @@ -166,17 +166,23 @@ instanceStateValue: true }); it('should pass updated alert params to executor', async () => { + const testStart = new Date(); // create an alert const reference = alertUtils.generateReference(); - const overwrites = { - throttle: '1s', - schedule: { interval: '1s' }, - }; - const response = await alertUtils.createAlwaysFiringAction({ reference, overwrites }); + const response = await alertUtils.createAlwaysFiringAction({ + reference, + overwrites: { throttle: null }, + }); // only need to test creation success paths if (response.statusCode !== 200) return; + // Wait for the action to index a document before disabling the alert and waiting for tasks to finish + await esTestIndexTool.waitForDocs('action:test.index-record', reference); + + // Avoid invalidating an API key while the alert is executing + await taskManagerUtils.waitForAllTasksIdle(testStart); + // update the alert with super user const alertId = response.body.id; const reference2 = alertUtils.generateReference(); @@ -188,8 +194,8 @@ instanceStateValue: true overwrites: { name: 'def', tags: ['fee', 'fi', 'fo'], - throttle: '1s', - schedule: { interval: '1s' }, + // This will cause the task to re-run on update + schedule: { interval: '59s' }, }, }); @@ -197,6 +203,9 @@ instanceStateValue: true // make sure alert info passed to executor is correct await esTestIndexTool.waitForDocs('alert:test.always-firing', reference2); + + await taskManagerUtils.waitForAllTasksIdle(testStart); + await alertUtils.disable(alertId); const alertSearchResult = await esTestIndexTool.search( 'alert:test.always-firing', @@ -359,7 +368,7 @@ instanceStateValue: true // Wait for test.authorization to index a document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('alert:test.authorization', reference); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 1 document exists with proper params searchResult = await esTestIndexTool.search('alert:test.authorization', reference); @@ -387,7 +396,7 @@ instanceStateValue: true // Wait for test.authorization to index a document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('alert:test.authorization', reference); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 1 document exists with proper params searchResult = await esTestIndexTool.search('alert:test.authorization', reference); @@ -467,7 +476,7 @@ instanceStateValue: true // Ensure test.authorization indexed 1 document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.authorization', reference); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 1 document with proper params exists searchResult = await esTestIndexTool.search('action:test.authorization', reference); @@ -495,7 +504,7 @@ instanceStateValue: true // Ensure test.authorization indexed 1 document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.authorization', reference); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 1 document with proper params exists searchResult = await esTestIndexTool.search('action:test.authorization', reference); @@ -544,7 +553,7 @@ instanceStateValue: true // Wait until alerts scheduled actions 3 times before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('alert:test.always-firing', reference, 3); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure actions only executed once const searchResult = await esTestIndexTool.search( @@ -610,7 +619,7 @@ instanceStateValue: true // Wait for actions to execute twice before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.index-record', reference, 2); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 2 actions with proper params exists const searchResult = await esTestIndexTool.search( @@ -660,7 +669,7 @@ instanceStateValue: true // Actions should execute twice before widning things down await esTestIndexTool.waitForDocs('action:test.index-record', reference, 2); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 2 actions are executed const searchResult = await esTestIndexTool.search( @@ -705,7 +714,7 @@ instanceStateValue: true // execution once before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('alert:test.always-firing', reference, 2); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Should not have executed any action const executedActionsResult = await esTestIndexTool.search( @@ -750,7 +759,7 @@ instanceStateValue: true // once before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('alert:test.always-firing', reference, 2); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Should not have executed any action const executedActionsResult = await esTestIndexTool.search( @@ -796,7 +805,7 @@ instanceStateValue: true // Ensure actions are executed once before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.index-record', reference, 1); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Should have one document indexed by the action const searchResult = await esTestIndexTool.search( From f5355a9ee86a0657c9d935ed84bf042b494ed299 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Thu, 19 Mar 2020 14:35:04 -0400 Subject: [PATCH 08/32] [ML] Data Visualizer: Replace KqlFilterBar with QueryStringInput (#60544) * data visualizer:replace kqlFilterBar * remove unused translation * show syntax error toast --- x-pack/plugins/ml/public/application/app.tsx | 5 + .../content_types/number_content.tsx | 2 +- .../components/search_panel/search_panel.tsx | 126 +++++++++++------- .../datavisualizer/index_based/page.tsx | 10 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 6 files changed, 91 insertions(+), 54 deletions(-) diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 2597715488399b..6269c11fca8961 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -9,6 +9,8 @@ import ReactDOM from 'react-dom'; import { AppMountParameters, CoreStart } from 'kibana/public'; +import { Storage } from '../../../../../src/plugins/kibana_utils/public'; + import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { setDependencyCache, clearCache } from './util/dependency_cache'; import { setLicenseCache } from './license'; @@ -24,6 +26,8 @@ interface AppProps { appMountParams: AppMountParameters; } +const localStorage = new Storage(window.localStorage); + const App: FC = ({ coreStart, deps, appMountParams }) => { setDependencyCache({ indexPatterns: deps.data.indexPatterns, @@ -62,6 +66,7 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { appName: 'ML', data: deps.data, security: deps.security, + storage: localStorage, ...coreStart, }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_card/content_types/number_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_card/content_types/number_content.tsx index 29be9d2e1e2a45..e2c156fc66ded1 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_card/content_types/number_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_card/content_types/number_content.tsx @@ -157,7 +157,7 @@ export const NumberContent: FC = ({ config }) => { buttonSize="compressed" /> - {detailsMode === DETAILS_MODE.DISTRIBUTION && ( + {distribution && detailsMode === DETAILS_MODE.DISTRIBUTION && ( diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx index 527cd31ed91d40..50c76725f52456 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx @@ -4,18 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC } from 'react'; +import React, { FC, useState } from 'react'; -import { - EuiFieldSearch, - EuiFlexItem, - EuiFlexGroup, - EuiForm, - EuiFormRow, - EuiIconTip, - EuiSuperSelect, - EuiText, -} from '@elastic/eui'; +import { EuiFlexItem, EuiFlexGroup, EuiIconTip, EuiSuperSelect, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -23,18 +14,24 @@ import { i18n } from '@kbn/i18n'; import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; import { SEARCH_QUERY_LANGUAGE } from '../../../../../../common/constants/search'; -import { SavedSearchQuery } from '../../../../contexts/ml'; -// @ts-ignore -import { KqlFilterBar } from '../../../../components/kql_filter_bar/index'; +import { + esKuery, + esQuery, + Query, + QueryStringInput, +} from '../../../../../../../../../src/plugins/data/public'; + +import { getToastNotifications } from '../../../../util/dependency_cache'; interface Props { indexPattern: IndexPattern; - searchString: string | SavedSearchQuery; - setSearchString(s: string): void; - searchQuery: string | SavedSearchQuery; - setSearchQuery(q: string | SavedSearchQuery): void; + searchString: Query['query']; + setSearchString(s: Query['query']): void; + searchQuery: Query['query']; + setSearchQuery(q: Query['query']): void; searchQueryLanguage: SEARCH_QUERY_LANGUAGE; + setSearchQueryLanguage(q: any): void; samplerShardSize: number; setSamplerShardSize(s: number): void; totalCount: number; @@ -59,6 +56,20 @@ const searchSizeOptions = [1000, 5000, 10000, 100000, -1].map(v => { }; }); +const kqlSyntaxErrorMessage = i18n.translate( + 'xpack.ml.datavisualizer.invalidKqlSyntaxErrorMessage', + { + defaultMessage: + 'Invalid syntax in search bar. The input must be valid Kibana Query Language (KQL)', + } +); +const luceneSyntaxErrorMessage = i18n.translate( + 'xpack.ml.datavisualizer.invalidLuceneSyntaxErrorMessage', + { + defaultMessage: 'Invalid syntax in search bar. The input must be valid Lucene', + } +); + export const SearchPanel: FC = ({ indexPattern, searchString, @@ -66,44 +77,65 @@ export const SearchPanel: FC = ({ searchQuery, setSearchQuery, searchQueryLanguage, + setSearchQueryLanguage, samplerShardSize, setSamplerShardSize, totalCount, }) => { - const searchHandler = (d: Record) => { - setSearchQuery(d.filterQuery); + // The internal state of the input query bar updated on every key stroke. + const [searchInput, setSearchInput] = useState({ + query: searchString || '', + language: searchQueryLanguage, + }); + + const searchHandler = (query: Query) => { + let filterQuery; + try { + if (query.language === SEARCH_QUERY_LANGUAGE.KUERY) { + filterQuery = esKuery.toElasticsearchQuery( + esKuery.fromKueryExpression(query.query), + indexPattern + ); + } else if (query.language === SEARCH_QUERY_LANGUAGE.LUCENE) { + filterQuery = esQuery.luceneStringToDsl(query.query); + } else { + filterQuery = {}; + } + + setSearchQuery(filterQuery); + setSearchString(query.query); + setSearchQueryLanguage(query.language); + } catch (e) { + console.log('Invalid syntax', e); // eslint-disable-line no-console + const toastNotifications = getToastNotifications(); + const notification = + query.language === SEARCH_QUERY_LANGUAGE.KUERY + ? kqlSyntaxErrorMessage + : luceneSyntaxErrorMessage; + toastNotifications.addDanger(notification); + } }; + const searchChangeHandler = (query: Query) => setSearchInput(query); return ( - {searchQueryLanguage === SEARCH_QUERY_LANGUAGE.KUERY ? ( - - ) : ( - - - - - - )} + diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx index b66d12b6c9ebee..3a37274edbc16d 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -24,6 +24,7 @@ import { import { IFieldType, KBN_FIELD_TYPES, + Query, esQuery, esKuery, } from '../../../../../../../src/plugins/data/public'; @@ -36,7 +37,7 @@ import { checkPermission } from '../../privilege/check_privilege'; import { mlNodesAvailable } from '../../ml_nodes_check/check_ml_nodes'; import { FullTimeRangeSelector } from '../../components/full_time_range_selector'; import { mlTimefilterRefresh$ } from '../../services/timefilter_refresh_service'; -import { useMlContext, SavedSearchQuery } from '../../contexts/ml'; +import { useMlContext } from '../../contexts/ml'; import { kbnTypeToMLJobType } from '../../util/field_types_utils'; import { useTimefilter } from '../../contexts/kibana'; import { timeBasedIndexCheck, getQueryFromSavedSearch } from '../../util/index_utils'; @@ -49,8 +50,8 @@ import { SearchPanel } from './components/search_panel'; import { DataLoader } from './data_loader'; interface DataVisualizerPageState { - searchQuery: string | SavedSearchQuery; - searchString: string | SavedSearchQuery; + searchQuery: Query['query']; + searchString: Query['query']; searchQueryLanguage: SEARCH_QUERY_LANGUAGE; samplerShardSize: number; overallStats: any; @@ -160,7 +161,7 @@ export const Page: FC = () => { const [searchString, setSearchString] = useState(initSearchString); const [searchQuery, setSearchQuery] = useState(initSearchQuery); - const [searchQueryLanguage] = useState(initQueryLanguage); + const [searchQueryLanguage, setSearchQueryLanguage] = useState(initQueryLanguage); const [samplerShardSize, setSamplerShardSize] = useState(defaults.samplerShardSize); // TODO - type overallStats and stats @@ -676,6 +677,7 @@ export const Page: FC = () => { searchQuery={searchQuery} setSearchQuery={setSearchQuery} searchQueryLanguage={searchQueryLanguage} + setSearchQueryLanguage={setSearchQueryLanguage} samplerShardSize={samplerShardSize} setSamplerShardSize={setSamplerShardSize} totalCount={overallStats.totalCount} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 03bfb089d8bd06..95fa13c028c30c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7677,7 +7677,6 @@ "xpack.ml.datavisualizer.page.fieldsPanelTitle": "フィールド", "xpack.ml.datavisualizer.page.metricsPanelTitle": "メトリック", "xpack.ml.datavisualizer.searchPanel.allOptionLabel": "すべて", - "xpack.ml.datavisualizer.searchPanel.kqlEditOnlyLabel": "現在 KQAL で保存された検索のみ編集できます。", "xpack.ml.datavisualizer.searchPanel.queryBarPlaceholder": "小さいサンプルサイズを選択することで、クエリの実行時間を短縮しクラスターへの負荷を軽減できます。", "xpack.ml.datavisualizer.searchPanel.queryBarPlaceholderText": "検索… (例: status:200 AND extension:\"PHP\")", "xpack.ml.datavisualizer.searchPanel.sampleSizeAriaLabel": "サンプリングするドキュメント数を選択してください", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 682ac4c0bba103..9f9b5cc442b9a3 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7677,7 +7677,6 @@ "xpack.ml.datavisualizer.page.fieldsPanelTitle": "字段", "xpack.ml.datavisualizer.page.metricsPanelTitle": "指标", "xpack.ml.datavisualizer.searchPanel.allOptionLabel": "全部", - "xpack.ml.datavisualizer.searchPanel.kqlEditOnlyLabel": "当前仅可以编辑 KQL 已保存搜索", "xpack.ml.datavisualizer.searchPanel.queryBarPlaceholder": "选择较小的样例大小将减少查询运行时间和集群上的负载。", "xpack.ml.datavisualizer.searchPanel.queryBarPlaceholderText": "搜索……(例如,status:200 AND extension:\"PHP\")", "xpack.ml.datavisualizer.searchPanel.sampleSizeAriaLabel": "选择要采样的文档数目", From 58b7e20795d7a734874db0367120807cf32a7d16 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Thu, 19 Mar 2020 14:38:12 -0400 Subject: [PATCH 09/32] Refactor to use new top-level `PackageIcon` component (#60628) - removes PackageIcon from EPM section - refactors code to use new top-level `PackageIcon` component --- .../components/package_icon.tsx | 2 +- .../components/layout.tsx | 9 +++++-- .../step_select_package.tsx | 6 ++--- .../sections/epm/components/index.ts | 2 -- .../sections/epm/components/package_card.tsx | 4 +-- .../sections/epm/components/package_icon.tsx | 26 ------------------- 6 files changed, 13 insertions(+), 36 deletions(-) delete mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_icon.tsx diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/package_icon.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/package_icon.tsx index 1ac222802e7d49..c5a0e600b7d50b 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/package_icon.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/package_icon.tsx @@ -25,7 +25,7 @@ export const PackageIcon: React.FunctionComponent<{ const usePackageIcon = (packageName: string, version?: string, icons?: Package['icons']) => { const { toImage } = useLinks(); - const [iconType, setIconType] = useState(''); + const [iconType, setIconType] = useState(''); // FIXME: use `empty` icon during initialization - see: https://github.com/elastic/kibana/issues/60622 const pkgKey = `${packageName}-${version ?? ''}`; // Generates an icon path or Eui Icon name based on an icon list from the package diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx index c063155c571d25..8bb7b2553c1b1f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx @@ -17,7 +17,7 @@ import { } from '@elastic/eui'; import { WithHeaderLayout } from '../../../../layouts'; import { AgentConfig, PackageInfo } from '../../../../types'; -import { PackageIcon } from '../../../epm/components'; +import { PackageIcon } from '../../../../components/package_icon'; import { CreateDatasourceFrom, CreateDatasourceStep } from '../types'; import { CreateDatasourceStepsNavigation } from './navigation'; @@ -94,7 +94,12 @@ export const CreateDatasourcePageLayout: React.FunctionComponent<{ - + {packageInfo?.title || packageInfo?.name || '-'} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx index f90e7f0ab0460f..0b48020c3cac1a 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx @@ -18,7 +18,7 @@ import { import { Error } from '../../../components'; import { AgentConfig, PackageInfo } from '../../../types'; import { useGetOneAgentConfig, useGetPackages, sendGetPackageInfoByKey } from '../../../hooks'; -import { PackageIcon } from '../../epm/components'; +import { PackageIcon } from '../../../components/package_icon'; export const StepSelectPackage: React.FunctionComponent<{ agentConfigId: string; @@ -125,12 +125,12 @@ export const StepSelectPackage: React.FunctionComponent<{ allowExclusions={false} singleSelection={true} isLoading={isPackagesLoading} - options={packages.map(({ title, name, version }) => { + options={packages.map(({ title, name, version, icons }) => { const pkgkey = `${name}-${version}`; return { label: title || name, key: pkgkey, - prepend: , + prepend: , checked: selectedPkgKey === pkgkey ? 'on' : undefined, }; })} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts index 2cb940e2ff40c5..41bc2aa2588073 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts @@ -3,5 +3,3 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -export { PackageIcon } from './package_icon'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx index d1d7cfc180cad9..8ad081cbbabe40 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx @@ -8,7 +8,7 @@ import styled from 'styled-components'; import { EuiCard } from '@elastic/eui'; import { PackageInfo, PackageListItem } from '../../../types'; import { useLinks } from '../hooks'; -import { PackageIcon } from './package_icon'; +import { PackageIcon } from '../../../components/package_icon'; export interface BadgeProps { showInstalledBadge?: boolean; @@ -40,7 +40,7 @@ export function PackageCard({ layout="horizontal" title={title || ''} description={description} - icon={} + icon={} href={url} /> ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_icon.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_icon.tsx deleted file mode 100644 index dd2f46adc31885..00000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_icon.tsx +++ /dev/null @@ -1,26 +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 from 'react'; -import { ICON_TYPES, EuiIcon, EuiIconProps } from '@elastic/eui'; -import { PackageInfo, PackageListItem } from '../../../types'; -import { useLinks } from '../hooks'; - -type Package = PackageInfo | PackageListItem; - -export const PackageIcon: React.FunctionComponent<{ - packageName: string; - icons?: Package['icons']; -} & Omit> = ({ packageName, icons, ...euiIconProps }) => { - const { toImage } = useLinks(); - // try to find a logo in EUI - const euiLogoIcon = ICON_TYPES.find(key => key.toLowerCase() === `logo${packageName}`); - const svgIcons = icons?.filter(icon => icon.type === 'image/svg+xml'); - const localIcon = svgIcons && Array.isArray(svgIcons) && svgIcons[0]; - const pathToLocal = localIcon && toImage(localIcon.src); - const euiIconType = pathToLocal || euiLogoIcon || 'package'; - - return ; -}; From 020e4d0f037be42dbd522a23e9f4609bdf7657ed Mon Sep 17 00:00:00 2001 From: Alex Holmansky Date: Thu, 19 Mar 2020 15:07:29 -0400 Subject: [PATCH 10/32] Switch back to a dedicated workflow token (#60673) --- .github/workflows/pr-project-assigner.yml | 2 +- .github/workflows/project-assigner.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-project-assigner.yml b/.github/workflows/pr-project-assigner.yml index d8b25b980a478e..0516f2cf956401 100644 --- a/.github/workflows/pr-project-assigner.yml +++ b/.github/workflows/pr-project-assigner.yml @@ -17,5 +17,5 @@ jobs: { "label": "Feature:Lens", "projectNumber": 32, "columnName": "In progress" }, { "label": "Team:Canvas", "projectNumber": 38, "columnName": "Review in progress" } ] - ghToken: ${{ secrets.GITHUB_TOKEN }} + ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} diff --git a/.github/workflows/project-assigner.yml b/.github/workflows/project-assigner.yml index 30032c9a7f998b..eb5827e121c74b 100644 --- a/.github/workflows/project-assigner.yml +++ b/.github/workflows/project-assigner.yml @@ -12,6 +12,6 @@ jobs: id: project_assigner with: issue-mappings: '[{"label": "Team:AppArch", "projectNumber": 37, "columnName": "To triage"}, {"label": "Feature:Lens", "projectNumber": 32, "columnName": "Long-term goals"}, {"label": "Team:Canvas", "projectNumber": 38, "columnName": "Inbox"}]' - ghToken: ${{ secrets.GITHUB_TOKEN }} + ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} From 431b06fee03e5b9376644f64c08bad96edf0b6c7 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Thu, 19 Mar 2020 14:12:01 -0500 Subject: [PATCH 11/32] [Metrics Alerts] Add functional and unit tests (#60442) * Add tests for metric threshold alerts * Fix count aggregator * Remove redundant typedefs Co-authored-by: Elastic Machine --- .../metric_threshold_executor.test.ts | 244 +++++++++++++++++ .../metric_threshold_executor.ts | 255 ++++++++++++++++++ .../register_metric_threshold_alert_type.ts | 239 +--------------- .../alerting/metric_threshold/test_mocks.ts | 110 ++++++++ .../lib/alerting/metric_threshold/types.ts | 1 - .../test/api_integration/apis/infra/index.js | 1 + .../apis/infra/metrics_alerting.ts | 98 +++++++ 7 files changed, 712 insertions(+), 236 deletions(-) create mode 100644 x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts create mode 100644 x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts create mode 100644 x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts create mode 100644 x-pack/test/api_integration/apis/infra/metrics_alerting.ts diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts new file mode 100644 index 00000000000000..a6b9b70feede21 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -0,0 +1,244 @@ +/* + * 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 { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; +import { Comparator, AlertStates } from './types'; +import * as mocks from './test_mocks'; +import { AlertExecutorOptions } from '../../../../../alerting/server'; + +const executor = createMetricThresholdExecutor('test') as (opts: { + params: AlertExecutorOptions['params']; + services: { callCluster: AlertExecutorOptions['params']['callCluster'] }; +}) => Promise; +const alertInstances = new Map(); + +const services = { + callCluster(_: string, { body }: any) { + const metric = body.query.bool.filter[1].exists.field; + if (body.aggs.groupings) { + if (body.aggs.groupings.composite.after) { + return mocks.compositeEndResponse; + } + if (metric === 'test.metric.2') { + return mocks.alternateCompositeResponse; + } + return mocks.basicCompositeResponse; + } + if (metric === 'test.metric.2') { + return mocks.alternateMetricResponse; + } + return mocks.basicMetricResponse; + }, + alertInstanceFactory(instanceID: string) { + let state: any; + const actionQueue: any[] = []; + const instance = { + actionQueue: [], + get state() { + return state; + }, + get mostRecentAction() { + return actionQueue.pop(); + }, + }; + alertInstances.set(instanceID, instance); + return { + instanceID, + scheduleActions(id: string, action: any) { + actionQueue.push({ id, action }); + }, + replaceState(newState: any) { + state = newState; + }, + }; + }, +}; + +const baseCriterion = { + aggType: 'avg', + metric: 'test.metric.1', + timeSize: 1, + timeUnit: 'm', + indexPattern: 'metricbeat-*', +}; +describe('The metric threshold alert type', () => { + describe('querying the entire infrastructure', () => { + const instanceID = 'test-*'; + const execute = (comparator: Comparator, threshold: number[]) => + executor({ + services, + params: { + criteria: [ + { + ...baseCriterion, + comparator, + threshold, + }, + ], + }, + }); + test('alerts as expected with the > comparator', async () => { + await execute(Comparator.GT, [0.75]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.GT, [1.5]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + test('alerts as expected with the < comparator', async () => { + await execute(Comparator.LT, [1.5]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.LT, [0.75]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + test('alerts as expected with the >= comparator', async () => { + await execute(Comparator.GT_OR_EQ, [0.75]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.GT_OR_EQ, [1.0]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.GT_OR_EQ, [1.5]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + test('alerts as expected with the <= comparator', async () => { + await execute(Comparator.LT_OR_EQ, [1.5]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.LT_OR_EQ, [1.0]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.LT_OR_EQ, [0.75]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + test('alerts as expected with the between comparator', async () => { + await execute(Comparator.BETWEEN, [0, 1.5]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.BETWEEN, [0, 0.75]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + }); + + describe('querying with a groupBy parameter', () => { + const execute = (comparator: Comparator, threshold: number[]) => + executor({ + services, + params: { + groupBy: 'something', + criteria: [ + { + ...baseCriterion, + comparator, + threshold, + }, + ], + }, + }); + const instanceIdA = 'test-a'; + const instanceIdB = 'test-b'; + test('sends an alert when all groups pass the threshold', async () => { + await execute(Comparator.GT, [0.75]); + expect(alertInstances.get(instanceIdA).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.ALERT); + expect(alertInstances.get(instanceIdB).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.ALERT); + }); + test('sends an alert when only some groups pass the threshold', async () => { + await execute(Comparator.LT, [1.5]); + expect(alertInstances.get(instanceIdA).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.ALERT); + expect(alertInstances.get(instanceIdB).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.OK); + }); + test('sends no alert when no groups pass the threshold', async () => { + await execute(Comparator.GT, [5]); + expect(alertInstances.get(instanceIdA).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.OK); + expect(alertInstances.get(instanceIdB).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.OK); + }); + }); + + describe('querying with multiple criteria', () => { + const execute = ( + comparator: Comparator, + thresholdA: number[], + thresholdB: number[], + groupBy: string = '' + ) => + executor({ + services, + params: { + groupBy, + criteria: [ + { + ...baseCriterion, + comparator, + threshold: thresholdA, + }, + { + ...baseCriterion, + comparator, + threshold: thresholdB, + metric: 'test.metric.2', + }, + ], + }, + }); + test('sends an alert when all criteria cross the threshold', async () => { + const instanceID = 'test-*'; + await execute(Comparator.GT_OR_EQ, [1.0], [3.0]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + }); + test('sends no alert when some, but not all, criteria cross the threshold', async () => { + const instanceID = 'test-*'; + await execute(Comparator.LT_OR_EQ, [1.0], [3.0]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + test('alerts only on groups that meet all criteria when querying with a groupBy parameter', async () => { + const instanceIdA = 'test-a'; + const instanceIdB = 'test-b'; + await execute(Comparator.GT_OR_EQ, [1.0], [3.0], 'something'); + expect(alertInstances.get(instanceIdA).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.ALERT); + expect(alertInstances.get(instanceIdB).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.OK); + }); + }); + describe('querying with the count aggregator', () => { + const instanceID = 'test-*'; + const execute = (comparator: Comparator, threshold: number[]) => + executor({ + services, + params: { + criteria: [ + { + ...baseCriterion, + comparator, + threshold, + aggType: 'count', + }, + ], + }, + }); + test('alerts based on the doc_count value instead of the aggregatedValue', async () => { + await execute(Comparator.GT, [2]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.LT, [1.5]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + }); +}); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts new file mode 100644 index 00000000000000..8c509c017cf20a --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -0,0 +1,255 @@ +/* + * 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 { mapValues } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { InfraDatabaseSearchResponse } from '../../adapters/framework/adapter_types'; +import { createAfterKeyHandler } from '../../../utils/create_afterkey_handler'; +import { getAllCompositeData } from '../../../utils/get_all_composite_data'; +import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; +import { MetricExpressionParams, Comparator, AlertStates } from './types'; +import { AlertServices, AlertExecutorOptions } from '../../../../../alerting/server'; + +interface Aggregation { + aggregatedIntervals: { + buckets: Array<{ aggregatedValue: { value: number }; doc_count: number }>; + }; +} + +interface CompositeAggregationsResponse { + groupings: { + buckets: Aggregation[]; + }; +} + +const getCurrentValueFromAggregations = ( + aggregations: Aggregation, + aggType: MetricExpressionParams['aggType'] +) => { + try { + const { buckets } = aggregations.aggregatedIntervals; + if (!buckets.length) return null; // No Data state + const mostRecentBucket = buckets[buckets.length - 1]; + if (aggType === 'count') { + return mostRecentBucket.doc_count; + } + const { value } = mostRecentBucket.aggregatedValue; + return value; + } catch (e) { + return undefined; // Error state + } +}; + +const getParsedFilterQuery: ( + filterQuery: string | undefined +) => Record = filterQuery => { + if (!filterQuery) return {}; + try { + return JSON.parse(filterQuery).bool; + } catch (e) { + return { + query_string: { + query: filterQuery, + analyze_wildcard: true, + }, + }; + } +}; + +export const getElasticsearchMetricQuery = ( + { metric, aggType, timeUnit, timeSize }: MetricExpressionParams, + groupBy?: string, + filterQuery?: string +) => { + const interval = `${timeSize}${timeUnit}`; + + const aggregations = + aggType === 'count' + ? {} + : aggType === 'rate' + ? networkTraffic('aggregatedValue', metric) + : { + aggregatedValue: { + [aggType]: { + field: metric, + }, + }, + }; + + const baseAggs = { + aggregatedIntervals: { + date_histogram: { + field: '@timestamp', + fixed_interval: interval, + }, + aggregations, + }, + }; + + const aggs = groupBy + ? { + groupings: { + composite: { + size: 10, + sources: [ + { + groupBy: { + terms: { + field: groupBy, + }, + }, + }, + ], + }, + aggs: baseAggs, + }, + } + : baseAggs; + + const parsedFilterQuery = getParsedFilterQuery(filterQuery); + + return { + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: `now-${interval}`, + }, + }, + }, + { + exists: { + field: metric, + }, + }, + ], + ...parsedFilterQuery, + }, + }, + size: 0, + aggs, + }; +}; + +const getMetric: ( + services: AlertServices, + params: MetricExpressionParams, + groupBy: string | undefined, + filterQuery: string | undefined +) => Promise> = async function( + { callCluster }, + params, + groupBy, + filterQuery +) { + const { indexPattern, aggType } = params; + const searchBody = getElasticsearchMetricQuery(params, groupBy, filterQuery); + + try { + if (groupBy) { + const bucketSelector = ( + response: InfraDatabaseSearchResponse<{}, CompositeAggregationsResponse> + ) => response.aggregations?.groupings?.buckets || []; + const afterKeyHandler = createAfterKeyHandler( + 'aggs.groupings.composite.after', + response => response.aggregations?.groupings?.after_key + ); + const compositeBuckets = (await getAllCompositeData( + body => callCluster('search', { body, index: indexPattern }), + searchBody, + bucketSelector, + afterKeyHandler + )) as Array; + return compositeBuckets.reduce( + (result, bucket) => ({ + ...result, + [bucket.key.groupBy]: getCurrentValueFromAggregations(bucket, aggType), + }), + {} + ); + } + const result = await callCluster('search', { + body: searchBody, + index: indexPattern, + }); + return { '*': getCurrentValueFromAggregations(result.aggregations, aggType) }; + } catch (e) { + return { '*': undefined }; // Trigger an Error state + } +}; + +const comparatorMap = { + [Comparator.BETWEEN]: (value: number, [a, b]: number[]) => + value >= Math.min(a, b) && value <= Math.max(a, b), + // `threshold` is always an array of numbers in case the BETWEEN comparator is + // used; all other compartors will just destructure the first value in the array + [Comparator.GT]: (a: number, [b]: number[]) => a > b, + [Comparator.LT]: (a: number, [b]: number[]) => a < b, + [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b, + [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b, +}; + +export const createMetricThresholdExecutor = (alertUUID: string) => + async function({ services, params }: AlertExecutorOptions) { + const { criteria, groupBy, filterQuery } = params as { + criteria: MetricExpressionParams[]; + groupBy: string | undefined; + filterQuery: string | undefined; + }; + + const alertResults = await Promise.all( + criteria.map(criterion => + (async () => { + const currentValues = await getMetric(services, criterion, groupBy, filterQuery); + const { threshold, comparator } = criterion; + const comparisonFunction = comparatorMap[comparator]; + return mapValues(currentValues, value => ({ + shouldFire: + value !== undefined && value !== null && comparisonFunction(value, threshold), + currentValue: value, + isNoData: value === null, + isError: value === undefined, + })); + })() + ) + ); + + const groups = Object.keys(alertResults[0]); + for (const group of groups) { + const alertInstance = services.alertInstanceFactory(`${alertUUID}-${group}`); + + // AND logic; all criteria must be across the threshold + const shouldAlertFire = alertResults.every(result => result[group].shouldFire); + // AND logic; because we need to evaluate all criteria, if one of them reports no data then the + // whole alert is in a No Data/Error state + const isNoData = alertResults.some(result => result[group].isNoData); + const isError = alertResults.some(result => result[group].isError); + if (shouldAlertFire) { + alertInstance.scheduleActions(FIRED_ACTIONS.id, { + group, + value: alertResults.map(result => result[group].currentValue), + }); + } + // Future use: ability to fetch display current alert state + alertInstance.replaceState({ + alertState: isError + ? AlertStates.ERROR + : isNoData + ? AlertStates.NO_DATA + : shouldAlertFire + ? AlertStates.ALERT + : AlertStates.OK, + }); + } + }; + +export const FIRED_ACTIONS = { + id: 'metrics.threshold.fired', + name: i18n.translate('xpack.infra.metrics.alerting.threshold.fired', { + defaultMessage: 'Fired', + }), +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index d318171f3bb48b..501d7549e17129 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -4,188 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ import uuid from 'uuid'; -import { mapValues } from 'lodash'; -import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; -import { InfraDatabaseSearchResponse } from '../../adapters/framework/adapter_types'; -import { createAfterKeyHandler } from '../../../utils/create_afterkey_handler'; -import { getAllCompositeData } from '../../../utils/get_all_composite_data'; -import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; -import { - MetricExpressionParams, - Comparator, - AlertStates, - METRIC_THRESHOLD_ALERT_TYPE_ID, -} from './types'; -import { AlertServices, PluginSetupContract } from '../../../../../alerting/server'; - -interface Aggregation { - aggregatedIntervals: { buckets: Array<{ aggregatedValue: { value: number } }> }; -} - -interface CompositeAggregationsResponse { - groupings: { - buckets: Aggregation[]; - }; -} - -const FIRED_ACTIONS = { - id: 'metrics.threshold.fired', - name: i18n.translate('xpack.infra.metrics.alerting.threshold.fired', { - defaultMessage: 'Fired', - }), -}; - -const getCurrentValueFromAggregations = (aggregations: Aggregation) => { - try { - const { buckets } = aggregations.aggregatedIntervals; - if (!buckets.length) return null; // No Data state - const { value } = buckets[buckets.length - 1].aggregatedValue; - return value; - } catch (e) { - return undefined; // Error state - } -}; - -const getParsedFilterQuery: ( - filterQuery: string | undefined -) => Record = filterQuery => { - if (!filterQuery) return {}; - try { - return JSON.parse(filterQuery).bool; - } catch (e) { - return { - query_string: { - query: filterQuery, - analyze_wildcard: true, - }, - }; - } -}; - -const getMetric: ( - services: AlertServices, - params: MetricExpressionParams, - groupBy: string | undefined, - filterQuery: string | undefined -) => Promise> = async function( - { callCluster }, - { metric, aggType, timeUnit, timeSize, indexPattern }, - groupBy, - filterQuery -) { - const interval = `${timeSize}${timeUnit}`; - - const aggregations = - aggType === 'rate' - ? networkTraffic('aggregatedValue', metric) - : { - aggregatedValue: { - [aggType]: { - field: metric, - }, - }, - }; - - const baseAggs = { - aggregatedIntervals: { - date_histogram: { - field: '@timestamp', - fixed_interval: interval, - }, - aggregations, - }, - }; - - const aggs = groupBy - ? { - groupings: { - composite: { - size: 10, - sources: [ - { - groupBy: { - terms: { - field: groupBy, - }, - }, - }, - ], - }, - aggs: baseAggs, - }, - } - : baseAggs; - - const parsedFilterQuery = getParsedFilterQuery(filterQuery); - - const searchBody = { - query: { - bool: { - filter: [ - { - range: { - '@timestamp': { - gte: `now-${interval}`, - }, - }, - }, - { - exists: { - field: metric, - }, - }, - ], - ...parsedFilterQuery, - }, - }, - size: 0, - aggs, - }; - - try { - if (groupBy) { - const bucketSelector = ( - response: InfraDatabaseSearchResponse<{}, CompositeAggregationsResponse> - ) => response.aggregations?.groupings?.buckets || []; - const afterKeyHandler = createAfterKeyHandler( - 'aggs.groupings.composite.after', - response => response.aggregations?.groupings?.after_key - ); - const compositeBuckets = (await getAllCompositeData( - body => callCluster('search', { body, index: indexPattern }), - searchBody, - bucketSelector, - afterKeyHandler - )) as Array; - return compositeBuckets.reduce( - (result, bucket) => ({ - ...result, - [bucket.key.groupBy]: getCurrentValueFromAggregations(bucket), - }), - {} - ); - } - const result = await callCluster('search', { - body: searchBody, - index: indexPattern, - }); - return { '*': getCurrentValueFromAggregations(result.aggregations) }; - } catch (e) { - return { '*': undefined }; // Trigger an Error state - } -}; - -const comparatorMap = { - [Comparator.BETWEEN]: (value: number, [a, b]: number[]) => - value >= Math.min(a, b) && value <= Math.max(a, b), - // `threshold` is always an array of numbers in case the BETWEEN comparator is - // used; all other compartors will just destructure the first value in the array - [Comparator.GT]: (a: number, [b]: number[]) => a > b, - [Comparator.LT]: (a: number, [b]: number[]) => a < b, - [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b, - [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b, -}; +import { PluginSetupContract } from '../../../../../alerting/server'; +import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; +import { METRIC_THRESHOLD_ALERT_TYPE_ID } from './types'; export async function registerMetricThresholdAlertType(alertingPlugin: PluginSetupContract) { if (!alertingPlugin) { @@ -217,59 +39,6 @@ export async function registerMetricThresholdAlertType(alertingPlugin: PluginSet }, defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], - async executor({ services, params }) { - const { criteria, groupBy, filterQuery } = params as { - criteria: MetricExpressionParams[]; - groupBy: string | undefined; - filterQuery: string | undefined; - }; - - const alertResults = await Promise.all( - criteria.map(criterion => - (async () => { - const currentValues = await getMetric(services, criterion, groupBy, filterQuery); - const { threshold, comparator } = criterion; - const comparisonFunction = comparatorMap[comparator]; - - return mapValues(currentValues, value => ({ - shouldFire: - value !== undefined && value !== null && comparisonFunction(value, threshold), - currentValue: value, - isNoData: value === null, - isError: value === undefined, - })); - })() - ) - ); - - const groups = Object.keys(alertResults[0]); - for (const group of groups) { - const alertInstance = services.alertInstanceFactory(`${alertUUID}-${group}`); - - // AND logic; all criteria must be across the threshold - const shouldAlertFire = alertResults.every(result => result[group].shouldFire); - // AND logic; because we need to evaluate all criteria, if one of them reports no data then the - // whole alert is in a No Data/Error state - const isNoData = alertResults.some(result => result[group].isNoData); - const isError = alertResults.some(result => result[group].isError); - if (shouldAlertFire) { - alertInstance.scheduleActions(FIRED_ACTIONS.id, { - group, - value: alertResults.map(result => result[group].currentValue), - }); - } - - // Future use: ability to fetch display current alert state - alertInstance.replaceState({ - alertState: isError - ? AlertStates.ERROR - : isNoData - ? AlertStates.NO_DATA - : shouldAlertFire - ? AlertStates.ALERT - : AlertStates.OK, - }); - } - }, + executor: createMetricThresholdExecutor(alertUUID), }); } diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts new file mode 100644 index 00000000000000..e87ffcfb2b912d --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const bucketsA = [ + { + doc_count: 2, + aggregatedValue: { value: 0.5 }, + }, + { + doc_count: 3, + aggregatedValue: { value: 1.0 }, + }, +]; + +const bucketsB = [ + { + doc_count: 4, + aggregatedValue: { value: 2.5 }, + }, + { + doc_count: 5, + aggregatedValue: { value: 3.5 }, + }, +]; + +export const basicMetricResponse = { + aggregations: { + aggregatedIntervals: { + buckets: bucketsA, + }, + }, +}; + +export const alternateMetricResponse = { + aggregations: { + aggregatedIntervals: { + buckets: bucketsB, + }, + }, +}; + +export const basicCompositeResponse = { + aggregations: { + groupings: { + after_key: 'foo', + buckets: [ + { + key: { + groupBy: 'a', + }, + aggregatedIntervals: { + buckets: bucketsA, + }, + }, + { + key: { + groupBy: 'b', + }, + aggregatedIntervals: { + buckets: bucketsB, + }, + }, + ], + }, + }, + hits: { + total: { + value: 2, + }, + }, +}; + +export const alternateCompositeResponse = { + aggregations: { + groupings: { + after_key: 'foo', + buckets: [ + { + key: { + groupBy: 'a', + }, + aggregatedIntervals: { + buckets: bucketsB, + }, + }, + { + key: { + groupBy: 'b', + }, + aggregatedIntervals: { + buckets: bucketsA, + }, + }, + ], + }, + }, + hits: { + total: { + value: 2, + }, + }, +}; + +export const compositeEndResponse = { + aggregations: {}, + hits: { total: { value: 0 } }, +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts index e247eb8a3f8891..07739c9d81bc41 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts @@ -33,5 +33,4 @@ export interface MetricExpressionParams { indexPattern: string; threshold: number[]; comparator: Comparator; - filterQuery: string; } diff --git a/x-pack/test/api_integration/apis/infra/index.js b/x-pack/test/api_integration/apis/infra/index.js index f5bdf280c46d21..8bb3475da6cc9d 100644 --- a/x-pack/test/api_integration/apis/infra/index.js +++ b/x-pack/test/api_integration/apis/infra/index.js @@ -16,6 +16,7 @@ export default function({ loadTestFile }) { loadTestFile(require.resolve('./sources')); loadTestFile(require.resolve('./waffle')); loadTestFile(require.resolve('./log_item')); + loadTestFile(require.resolve('./metrics_alerting')); loadTestFile(require.resolve('./metrics_explorer')); loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./ip_to_hostname')); diff --git a/x-pack/test/api_integration/apis/infra/metrics_alerting.ts b/x-pack/test/api_integration/apis/infra/metrics_alerting.ts new file mode 100644 index 00000000000000..09f5a498ddc000 --- /dev/null +++ b/x-pack/test/api_integration/apis/infra/metrics_alerting.ts @@ -0,0 +1,98 @@ +/* + * 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 expect from '@kbn/expect'; +import { getElasticsearchMetricQuery } from '../../../../plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor'; +import { MetricExpressionParams } from '../../../../plugins/infra/server/lib/alerting/metric_threshold/types'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const client = getService('legacyEs'); + const index = 'test-index'; + const baseParams = { + metric: 'test.metric', + timeUnit: 'm', + timeSize: 5, + }; + describe('Metrics Threshold Alerts', () => { + before(async () => { + await client.index({ + index, + body: {}, + }); + }); + const aggs = ['avg', 'min', 'max', 'rate', 'cardinality', 'count']; + + describe('querying the entire infrastructure', () => { + for (const aggType of aggs) { + it(`should work with the ${aggType} aggregator`, async () => { + const searchBody = getElasticsearchMetricQuery({ + ...baseParams, + aggType, + } as MetricExpressionParams); + const result = await client.search({ + index, + body: searchBody, + }); + expect(result.error).to.not.be.ok(); + expect(result.hits).to.be.ok(); + }); + } + it('should work with a filterQuery', async () => { + const searchBody = getElasticsearchMetricQuery( + { + ...baseParams, + aggType: 'avg', + } as MetricExpressionParams, + undefined, + '{"bool":{"should":[{"match_phrase":{"agent.hostname":"foo"}}],"minimum_should_match":1}}' + ); + const result = await client.search({ + index, + body: searchBody, + }); + expect(result.error).to.not.be.ok(); + expect(result.hits).to.be.ok(); + }); + }); + describe('querying with a groupBy parameter', () => { + for (const aggType of aggs) { + it(`should work with the ${aggType} aggregator`, async () => { + const searchBody = getElasticsearchMetricQuery( + { + ...baseParams, + aggType, + } as MetricExpressionParams, + 'agent.id' + ); + const result = await client.search({ + index, + body: searchBody, + }); + expect(result.error).to.not.be.ok(); + expect(result.hits).to.be.ok(); + }); + } + it('should work with a filterQuery', async () => { + const searchBody = getElasticsearchMetricQuery( + { + ...baseParams, + aggType: 'avg', + } as MetricExpressionParams, + 'agent.id', + '{"bool":{"should":[{"match_phrase":{"agent.hostname":"foo"}}],"minimum_should_match":1}}' + ); + const result = await client.search({ + index, + body: searchBody, + }); + expect(result.error).to.not.be.ok(); + expect(result.hits).to.be.ok(); + }); + }); + }); +} From 915b784cd6cd23b86b5384874496cff1bd3dd203 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Thu, 19 Mar 2020 20:29:13 +0100 Subject: [PATCH 12/32] =?UTF-8?q?Use=20static=20initializer=20in=20Validat?= =?UTF-8?q?edDualRange=20for=20storybook=20com=E2=80=A6=20(#60601)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #60356. --- .../public/validated_range/validated_dual_range.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/plugins/kibana_react/public/validated_range/validated_dual_range.tsx b/src/plugins/kibana_react/public/validated_range/validated_dual_range.tsx index e7392eeba3830f..ce583236e7c81b 100644 --- a/src/plugins/kibana_react/public/validated_range/validated_dual_range.tsx +++ b/src/plugins/kibana_react/public/validated_range/validated_dual_range.tsx @@ -47,7 +47,11 @@ interface State { } export class ValidatedDualRange extends Component { - static defaultProps: { fullWidth: boolean; allowEmptyRange: boolean; compressed: boolean }; + static defaultProps: { fullWidth: boolean; allowEmptyRange: boolean; compressed: boolean } = { + allowEmptyRange: true, + fullWidth: false, + compressed: false, + }; static getDerivedStateFromProps(nextProps: Props, prevState: State) { if (nextProps.value !== prevState.prevValue) { @@ -125,9 +129,3 @@ export class ValidatedDualRange extends Component { ); } } - -ValidatedDualRange.defaultProps = { - allowEmptyRange: true, - fullWidth: false, - compressed: false, -}; From ce2e3fd621028a05cf312bc19706b92c53f87878 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Thu, 19 Mar 2020 12:36:19 -0700 Subject: [PATCH 13/32] [Reporting] Allow reports to be deleted in Management > Kibana > Reporting (#60077) * [Reporting] Feature Delete Button in Job Listing * refactor listing buttons * multi-delete * confirm modal * remove unused * fix test * mock the id generator for snapshotting * simplify * add search bar above table * fix types errors --- .../reporting/server/lib/jobs_query.ts | 18 + .../plugins/reporting/server/routes/jobs.ts | 52 +- .../server/routes/lib/job_response_handler.ts | 34 +- .../routes/lib/route_config_factories.ts | 17 +- x-pack/legacy/plugins/reporting/types.d.ts | 1 + .../report_listing.test.tsx.snap | 728 +++++++++++++++++- .../report_info_button.test.tsx.snap | 0 .../public/components/buttons/index.tsx | 10 + .../buttons/report_delete_button.tsx | 99 +++ .../buttons/report_download_button.tsx | 72 ++ .../{ => buttons}/report_error_button.tsx | 20 +- .../{ => buttons}/report_info_button.test.tsx | 5 +- .../{ => buttons}/report_info_button.tsx | 4 +- ...oad_button.tsx => job_download_button.tsx} | 0 .../public/components/job_success.tsx | 2 +- .../components/job_warning_formulas.tsx | 2 +- .../components/job_warning_max_size.tsx | 2 +- .../public/components/report_listing.test.tsx | 7 +- .../public/components/report_listing.tsx | 339 ++++---- .../public/lib/reporting_api_client.ts | 6 + 20 files changed, 1225 insertions(+), 193 deletions(-) rename x-pack/plugins/reporting/public/components/{ => buttons}/__snapshots__/report_info_button.test.tsx.snap (100%) create mode 100644 x-pack/plugins/reporting/public/components/buttons/index.tsx create mode 100644 x-pack/plugins/reporting/public/components/buttons/report_delete_button.tsx create mode 100644 x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx rename x-pack/plugins/reporting/public/components/{ => buttons}/report_error_button.tsx (83%) rename x-pack/plugins/reporting/public/components/{ => buttons}/report_info_button.test.tsx (94%) rename x-pack/plugins/reporting/public/components/{ => buttons}/report_info_button.tsx (98%) rename x-pack/plugins/reporting/public/components/{download_button.tsx => job_download_button.tsx} (100%) diff --git a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts index 3562834230ea1d..c01e6377b039e5 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; +import Boom from 'boom'; import { errors as elasticsearchErrors } from 'elasticsearch'; import { ElasticsearchServiceSetup } from 'kibana/server'; import { get } from 'lodash'; @@ -152,5 +154,21 @@ export function jobsQueryFactory(server: ServerFacade, elasticsearch: Elasticsea return hits[0]; }); }, + + async delete(deleteIndex: string, id: string) { + try { + const query = { id, index: deleteIndex }; + return callAsInternalUser('delete', query); + } catch (error) { + const wrappedError = new Error( + i18n.translate('xpack.reporting.jobsQuery.deleteError', { + defaultMessage: 'Could not delete the report: {error}', + values: { error: error.message }, + }) + ); + + throw Boom.boomify(wrappedError, { statusCode: error.status }); + } + }, }; } diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts index 2de420e6577c3c..b9aa75e0ddd000 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts @@ -18,9 +18,13 @@ import { } from '../../types'; import { jobsQueryFactory } from '../lib/jobs_query'; import { ReportingSetupDeps, ReportingCore } from '../types'; -import { jobResponseHandlerFactory } from './lib/job_response_handler'; +import { + deleteJobResponseHandlerFactory, + downloadJobResponseHandlerFactory, +} from './lib/job_response_handler'; import { makeRequestFacade } from './lib/make_request_facade'; import { + getRouteConfigFactoryDeletePre, getRouteConfigFactoryDownloadPre, getRouteConfigFactoryManagementPre, } from './lib/route_config_factories'; @@ -40,7 +44,6 @@ export function registerJobInfoRoutes( const { elasticsearch } = plugins; const jobsQuery = jobsQueryFactory(server, elasticsearch); const getRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); - const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(server, plugins, logger); // list jobs in the queue, paginated server.route({ @@ -138,7 +141,8 @@ export function registerJobInfoRoutes( // trigger a download of the output from a job const exportTypesRegistry = reporting.getExportTypesRegistry(); - const jobResponseHandler = jobResponseHandlerFactory(server, elasticsearch, exportTypesRegistry); + const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(server, plugins, logger); + const downloadResponseHandler = downloadJobResponseHandlerFactory(server, elasticsearch, exportTypesRegistry); // prettier-ignore server.route({ path: `${MAIN_ENTRY}/download/{docId}`, method: 'GET', @@ -147,7 +151,47 @@ export function registerJobInfoRoutes( const request = makeRequestFacade(legacyRequest); const { docId } = request.params; - let response = await jobResponseHandler( + let response = await downloadResponseHandler( + request.pre.management.jobTypes, + request.pre.user, + h, + { docId } + ); + + if (isResponse(response)) { + const { statusCode } = response; + + if (statusCode !== 200) { + if (statusCode === 500) { + logger.error(`Report ${docId} has failed: ${JSON.stringify(response.source)}`); + } else { + logger.debug( + `Report ${docId} has non-OK status: [${statusCode}] Reason: [${JSON.stringify( + response.source + )}]` + ); + } + } + + response = response.header('accept-ranges', 'none'); + } + + return response; + }, + }); + + // allow a report to be deleted + const getRouteConfigDelete = getRouteConfigFactoryDeletePre(server, plugins, logger); + const deleteResponseHandler = deleteJobResponseHandlerFactory(server, elasticsearch); + server.route({ + path: `${MAIN_ENTRY}/delete/{docId}`, + method: 'DELETE', + options: getRouteConfigDelete(), + handler: async (legacyRequest: Legacy.Request, h: ReportingResponseToolkit) => { + const request = makeRequestFacade(legacyRequest); + const { docId } = request.params; + + let response = await deleteResponseHandler( request.pre.management.jobTypes, request.pre.user, h, diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts index 62f0d0a72b389a..30627d5b232301 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts @@ -20,7 +20,7 @@ interface JobResponseHandlerOpts { excludeContent?: boolean; } -export function jobResponseHandlerFactory( +export function downloadJobResponseHandlerFactory( server: ServerFacade, elasticsearch: ElasticsearchServiceSetup, exportTypesRegistry: ExportTypesRegistry @@ -36,6 +36,7 @@ export function jobResponseHandlerFactory( opts: JobResponseHandlerOpts = {} ) { const { docId } = params; + // TODO: async/await return jobsQuery.get(user, docId, { includeContent: !opts.excludeContent }).then(doc => { if (!doc) return Boom.notFound(); @@ -67,3 +68,34 @@ export function jobResponseHandlerFactory( }); }; } + +export function deleteJobResponseHandlerFactory( + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup +) { + const jobsQuery = jobsQueryFactory(server, elasticsearch); + + return async function deleteJobResponseHander( + validJobTypes: string[], + user: any, + h: ResponseToolkit, + params: JobResponseHandlerParams + ) { + const { docId } = params; + const doc = await jobsQuery.get(user, docId, { includeContent: false }); + if (!doc) return Boom.notFound(); + + const { jobtype: jobType } = doc._source; + if (!validJobTypes.includes(jobType)) { + return Boom.unauthorized(`Sorry, you are not authorized to delete ${jobType} reports`); + } + + try { + const docIndex = doc._index; + await jobsQuery.delete(docIndex, docId); + return h.response({ deleted: true }); + } catch (error) { + return Boom.boomify(error, { statusCode: error.statusCode }); + } + }; +} diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts index 82ba9ba22c7061..3d275d34e2f7d6 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts @@ -106,7 +106,22 @@ export function getRouteConfigFactoryDownloadPre( const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); return (): RouteConfigFactory => ({ ...getManagementRouteConfig(), - tags: [API_TAG], + tags: [API_TAG, 'download'], + response: { + ranges: false, + }, + }); +} + +export function getRouteConfigFactoryDeletePre( + server: ServerFacade, + plugins: ReportingSetupDeps, + logger: Logger +): GetRouteConfigFactoryFn { + const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); + return (): RouteConfigFactory => ({ + ...getManagementRouteConfig(), + tags: [API_TAG, 'delete'], response: { ranges: false, }, diff --git a/x-pack/legacy/plugins/reporting/types.d.ts b/x-pack/legacy/plugins/reporting/types.d.ts index 917e9d7daae407..238079ba92a291 100644 --- a/x-pack/legacy/plugins/reporting/types.d.ts +++ b/x-pack/legacy/plugins/reporting/types.d.ts @@ -197,6 +197,7 @@ export interface JobDocPayload { export interface JobSource { _id: string; + _index: string; _source: { jobtype: string; output: JobDocOutput; diff --git a/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap b/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap index b5304c6020c43e..1da95dd0ba1975 100644 --- a/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap +++ b/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap @@ -2,6 +2,526 @@ exports[`ReportListing Report job listing with some items 1`] = ` Array [ + +
      + + +
      + +
      + + + +
      +
      + + + + +
      + + +
      +
      + + + +
      + +
      + + + +
      + + +
      +
      + +
      + +
      + +
      + + +
      + +
      + +
      + + +
      + + +
      + +
      + +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      + +
      +
      + + +
      + +
      +
      + + +
      +
      +
      + + Report + +
      +
      +
      + + Created at + +
      +
      +
      + + Status + +
      +
      +
      + + Actions + +
      +
      +
      + + Loading reports + +
      +
      +
      +
      +
      + +
      + ,
      + > + + +
      + +
      + +
      + + +
      + + +
      + + +
      + +
      +
      + + +
      + +
      + > + + +
      + +
      + +
      + + +
      + + +
      + + +
      + +
      +
      + + +
      + + Promise; +type Props = { jobsToDelete: Job[]; performDelete: DeleteFn } & ListingProps; +interface State { + showConfirm: boolean; +} + +export class ReportDeleteButton extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { showConfirm: false }; + } + + private hideConfirm() { + this.setState({ showConfirm: false }); + } + + private showConfirm() { + this.setState({ showConfirm: true }); + } + + private renderConfirm() { + const { intl, jobsToDelete } = this.props; + + const title = + jobsToDelete.length > 1 + ? intl.formatMessage( + { + id: 'xpack.reporting.listing.table.deleteNumConfirmTitle', + defaultMessage: `Delete {num} reports?`, + }, + { num: jobsToDelete.length } + ) + : intl.formatMessage( + { + id: 'xpack.reporting.listing.table.deleteConfirmTitle', + defaultMessage: `Delete the "{name}" report?`, + }, + { name: jobsToDelete[0].object_title } + ); + const message = intl.formatMessage({ + id: 'xpack.reporting.listing.table.deleteConfirmMessage', + defaultMessage: `You can't recover deleted reports.`, + }); + const confirmButtonText = intl.formatMessage({ + id: 'xpack.reporting.listing.table.deleteConfirmButton', + defaultMessage: `Delete`, + }); + const cancelButtonText = intl.formatMessage({ + id: 'xpack.reporting.listing.table.deleteCancelButton', + defaultMessage: `Cancel`, + }); + + return ( + + this.hideConfirm()} + onConfirm={() => this.props.performDelete()} + confirmButtonText={confirmButtonText} + cancelButtonText={cancelButtonText} + defaultFocusedButton="confirm" + buttonColor="danger" + > + {message} + + + ); + } + + public render() { + const { jobsToDelete, intl } = this.props; + if (jobsToDelete.length === 0) return null; + + return ( + + this.showConfirm()} iconType="trash" color={'danger'}> + {intl.formatMessage( + { + id: 'xpack.reporting.listing.table.deleteReportButton', + defaultMessage: `Delete ({num})`, + }, + { num: jobsToDelete.length } + )} + + {this.state.showConfirm ? this.renderConfirm() : null} + + ); + } +} diff --git a/x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx b/x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx new file mode 100644 index 00000000000000..b0674c149609d1 --- /dev/null +++ b/x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx @@ -0,0 +1,72 @@ +/* + * 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 { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import React, { FunctionComponent } from 'react'; +import { JobStatuses } from '../../../constants'; +import { Job as ListingJob, Props as ListingProps } from '../report_listing'; + +type Props = { record: ListingJob } & ListingProps; + +export const ReportDownloadButton: FunctionComponent = (props: Props) => { + const { record, apiClient, intl } = props; + + if (record.status !== JobStatuses.COMPLETED) { + return null; + } + + const button = ( + apiClient.downloadReport(record.id)} + iconType="importAction" + aria-label={intl.formatMessage({ + id: 'xpack.reporting.listing.table.downloadReportAriaLabel', + defaultMessage: 'Download report', + })} + /> + ); + + if (record.csv_contains_formulas) { + return ( + + {button} + + ); + } + + if (record.max_size_reached) { + return ( + + {button} + + ); + } + + return ( + + {button} + + ); +}; diff --git a/x-pack/plugins/reporting/public/components/report_error_button.tsx b/x-pack/plugins/reporting/public/components/buttons/report_error_button.tsx similarity index 83% rename from x-pack/plugins/reporting/public/components/report_error_button.tsx rename to x-pack/plugins/reporting/public/components/buttons/report_error_button.tsx index 252dee9c619a98..1e33cc0188b8c2 100644 --- a/x-pack/plugins/reporting/public/components/report_error_button.tsx +++ b/x-pack/plugins/reporting/public/components/buttons/report_error_button.tsx @@ -7,12 +7,14 @@ import { EuiButtonIcon, EuiCallOut, EuiPopover } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import React, { Component } from 'react'; -import { JobContent, ReportingAPIClient } from '../lib/reporting_api_client'; +import { JobStatuses } from '../../../constants'; +import { JobContent, ReportingAPIClient } from '../../lib/reporting_api_client'; +import { Job as ListingJob } from '../report_listing'; interface Props { - jobId: string; intl: InjectedIntl; apiClient: ReportingAPIClient; + record: ListingJob; } interface State { @@ -39,12 +41,18 @@ class ReportErrorButtonUi extends Component { } public render() { + const { record, intl } = this.props; + + if (record.status !== JobStatuses.FAILED) { + return null; + } + const button = ( { }; private loadError = async () => { + const { record, apiClient, intl } = this.props; + this.setState({ isLoading: true }); try { - const reportContent: JobContent = await this.props.apiClient.getContent(this.props.jobId); + const reportContent: JobContent = await apiClient.getContent(record.id); if (this.mounted) { this.setState({ isLoading: false, error: reportContent.content }); } @@ -99,7 +109,7 @@ class ReportErrorButtonUi extends Component { if (this.mounted) { this.setState({ isLoading: false, - calloutTitle: this.props.intl.formatMessage({ + calloutTitle: intl.formatMessage({ id: 'xpack.reporting.errorButton.unableToFetchReportContentTitle', defaultMessage: 'Unable to fetch report content', }), diff --git a/x-pack/plugins/reporting/public/components/report_info_button.test.tsx b/x-pack/plugins/reporting/public/components/buttons/report_info_button.test.tsx similarity index 94% rename from x-pack/plugins/reporting/public/components/report_info_button.test.tsx rename to x-pack/plugins/reporting/public/components/buttons/report_info_button.test.tsx index 2edd59e6de7a38..028a8e960040a5 100644 --- a/x-pack/plugins/reporting/public/components/report_info_button.test.tsx +++ b/x-pack/plugins/reporting/public/components/buttons/report_info_button.test.tsx @@ -7,9 +7,10 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ReportInfoButton } from './report_info_button'; -import { ReportingAPIClient } from '../lib/reporting_api_client'; -jest.mock('../lib/reporting_api_client'); +jest.mock('../../lib/reporting_api_client'); + +import { ReportingAPIClient } from '../../lib/reporting_api_client'; const httpSetup = {} as any; const apiClient = new ReportingAPIClient(httpSetup); diff --git a/x-pack/plugins/reporting/public/components/report_info_button.tsx b/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx similarity index 98% rename from x-pack/plugins/reporting/public/components/report_info_button.tsx rename to x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx index 81a5af3b87957d..941baa5af67765 100644 --- a/x-pack/plugins/reporting/public/components/report_info_button.tsx +++ b/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx @@ -17,8 +17,8 @@ import { } from '@elastic/eui'; import React, { Component, Fragment } from 'react'; import { get } from 'lodash'; -import { USES_HEADLESS_JOB_TYPES } from '../../constants'; -import { JobInfo, ReportingAPIClient } from '../lib/reporting_api_client'; +import { USES_HEADLESS_JOB_TYPES } from '../../../constants'; +import { JobInfo, ReportingAPIClient } from '../../lib/reporting_api_client'; interface Props { jobId: string; diff --git a/x-pack/plugins/reporting/public/components/download_button.tsx b/x-pack/plugins/reporting/public/components/job_download_button.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/download_button.tsx rename to x-pack/plugins/reporting/public/components/job_download_button.tsx diff --git a/x-pack/plugins/reporting/public/components/job_success.tsx b/x-pack/plugins/reporting/public/components/job_success.tsx index c2feac382ca7ad..ad16a506aeb70f 100644 --- a/x-pack/plugins/reporting/public/components/job_success.tsx +++ b/x-pack/plugins/reporting/public/components/job_success.tsx @@ -9,8 +9,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../index.d'; +import { DownloadButton } from './job_download_button'; import { ReportLink } from './report_link'; -import { DownloadButton } from './download_button'; export const getSuccessToast = ( job: JobSummary, diff --git a/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx b/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx index 22f656dbe738cf..8717ae16d1ba10 100644 --- a/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx +++ b/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx @@ -9,8 +9,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../index.d'; +import { DownloadButton } from './job_download_button'; import { ReportLink } from './report_link'; -import { DownloadButton } from './download_button'; export const getWarningFormulasToast = ( job: JobSummary, diff --git a/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx b/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx index 1abba8888bb818..83fa129f0715ab 100644 --- a/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx +++ b/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx @@ -9,8 +9,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../index.d'; +import { DownloadButton } from './job_download_button'; import { ReportLink } from './report_link'; -import { DownloadButton } from './download_button'; export const getWarningMaxSizeToast = ( job: JobSummary, diff --git a/x-pack/plugins/reporting/public/components/report_listing.test.tsx b/x-pack/plugins/reporting/public/components/report_listing.test.tsx index 5cf894580eae03..9b541261a690ba 100644 --- a/x-pack/plugins/reporting/public/components/report_listing.test.tsx +++ b/x-pack/plugins/reporting/public/components/report_listing.test.tsx @@ -5,12 +5,15 @@ */ import React from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { ReportListing } from './report_listing'; import { Observable } from 'rxjs'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ILicense } from '../../../licensing/public'; import { ReportingAPIClient } from '../lib/reporting_api_client'; +jest.mock('@elastic/eui/lib/components/form/form_row/make_id', () => () => 'generated-id'); + +import { ReportListing } from './report_listing'; + const reportingAPIClient = { list: () => Promise.resolve([ diff --git a/x-pack/plugins/reporting/public/components/report_listing.tsx b/x-pack/plugins/reporting/public/components/report_listing.tsx index 13fca019f32840..af7ff5941304a2 100644 --- a/x-pack/plugins/reporting/public/components/report_listing.tsx +++ b/x-pack/plugins/reporting/public/components/report_listing.tsx @@ -4,34 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; -import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; -import { get } from 'lodash'; -import moment from 'moment'; -import React, { Component } from 'react'; -import { Subscription } from 'rxjs'; - import { - EuiBasicTable, - EuiButtonIcon, + EuiInMemoryTable, EuiPageContent, EuiSpacer, EuiText, EuiTextColor, EuiTitle, - EuiToolTip, } from '@elastic/eui'; - -import { ToastsSetup, ApplicationStart } from 'src/core/public'; -import { LicensingPluginSetup, ILicense } from '../../../licensing/public'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; +import { get } from 'lodash'; +import moment from 'moment'; +import { Component, default as React } from 'react'; +import { Subscription } from 'rxjs'; +import { ApplicationStart, ToastsSetup } from 'src/core/public'; +import { ILicense, LicensingPluginSetup } from '../../../licensing/public'; import { Poller } from '../../common/poller'; import { JobStatuses, JOB_COMPLETION_NOTIFICATIONS_POLLER_CONFIG } from '../../constants'; -import { ReportingAPIClient, JobQueueEntry } from '../lib/reporting_api_client'; import { checkLicense } from '../lib/license_check'; -import { ReportErrorButton } from './report_error_button'; -import { ReportInfoButton } from './report_info_button'; +import { JobQueueEntry, ReportingAPIClient } from '../lib/reporting_api_client'; +import { + ReportDeleteButton, + ReportDownloadButton, + ReportErrorButton, + ReportInfoButton, +} from './buttons'; -interface Job { +export interface Job { id: string; type: string; object_type: string; @@ -49,7 +49,7 @@ interface Job { warnings: string[]; } -interface Props { +export interface Props { intl: InjectedIntl; apiClient: ReportingAPIClient; license$: LicensingPluginSetup['license$']; @@ -61,6 +61,7 @@ interface State { page: number; total: number; jobs: Job[]; + selectedJobs: Job[]; isLoading: boolean; showLinks: boolean; enableLinks: boolean; @@ -113,6 +114,7 @@ class ReportListingUi extends Component { page: 0, total: 0, jobs: [], + selectedJobs: [], isLoading: false, showLinks: false, enableLinks: false, @@ -182,6 +184,140 @@ class ReportListingUi extends Component { }); }; + private onSelectionChange = (jobs: Job[]) => { + this.setState(current => ({ ...current, selectedJobs: jobs })); + }; + + private removeRecord = (record: Job) => { + const { jobs } = this.state; + const filtered = jobs.filter(j => j.id !== record.id); + this.setState(current => ({ ...current, jobs: filtered })); + }; + + private renderDeleteButton = () => { + const { selectedJobs } = this.state; + if (selectedJobs.length === 0) return null; + + const performDelete = async () => { + for (const record of selectedJobs) { + try { + await this.props.apiClient.deleteReport(record.id); + this.removeRecord(record); + this.props.toasts.addSuccess( + this.props.intl.formatMessage( + { + id: 'xpack.reporting.listing.table.deleteConfim', + defaultMessage: `The {reportTitle} report was deleted`, + }, + { reportTitle: record.object_title } + ) + ); + } catch (error) { + this.props.toasts.addDanger( + this.props.intl.formatMessage( + { + id: 'xpack.reporting.listing.table.deleteFailedErrorMessage', + defaultMessage: `The report was not deleted: {error}`, + }, + { error } + ) + ); + throw error; + } + } + }; + + return ( + + ); + }; + + private onTableChange = ({ page }: { page: { index: number } }) => { + const { index: pageIndex } = page; + this.setState(() => ({ page: pageIndex }), this.fetchJobs); + }; + + private fetchJobs = async () => { + // avoid page flicker when poller is updating table - only display loading screen on first load + if (this.isInitialJobsFetch) { + this.setState(() => ({ isLoading: true })); + } + + let jobs: JobQueueEntry[]; + let total: number; + try { + jobs = await this.props.apiClient.list(this.state.page); + total = await this.props.apiClient.total(); + this.isInitialJobsFetch = false; + } catch (fetchError) { + if (!this.licenseAllowsToShowThisPage()) { + this.props.toasts.addDanger(this.state.badLicenseMessage); + this.props.redirect('kibana#/management'); + return; + } + + if (fetchError.message === 'Failed to fetch') { + this.props.toasts.addDanger( + fetchError.message || + this.props.intl.formatMessage({ + id: 'xpack.reporting.listing.table.requestFailedErrorMessage', + defaultMessage: 'Request failed', + }) + ); + } + if (this.mounted) { + this.setState(() => ({ isLoading: false, jobs: [], total: 0 })); + } + return; + } + + if (this.mounted) { + this.setState(() => ({ + isLoading: false, + total, + jobs: jobs.map( + (job: JobQueueEntry): Job => { + const { _source: source } = job; + return { + id: job._id, + type: source.jobtype, + object_type: source.payload.objectType, + object_title: source.payload.title, + created_by: source.created_by, + created_at: source.created_at, + started_at: source.started_at, + completed_at: source.completed_at, + status: source.status, + statusLabel: jobStatusLabelsMap.get(source.status as JobStatuses) || source.status, + max_size_reached: source.output ? source.output.max_size_reached : false, + attempts: source.attempts, + max_attempts: source.max_attempts, + csv_contains_formulas: get(source, 'output.csv_contains_formulas'), + warnings: source.output ? source.output.warnings : undefined, + }; + } + ), + })); + } + }; + + private licenseAllowsToShowThisPage = () => { + return this.state.showLinks && this.state.enableLinks; + }; + + private formatDate(timestamp: string) { + try { + return moment(timestamp).format('YYYY-MM-DD @ hh:mm A'); + } catch (error) { + // ignore parse error and display unformatted value + return timestamp; + } + } + private renderTable() { const { intl } = this.props; @@ -317,9 +453,9 @@ class ReportListingUi extends Component { render: (record: Job) => { return (
      - {this.renderDownloadButton(record)} - {this.renderReportErrorButton(record)} - {this.renderInfoButton(record)} + + +
      ); }, @@ -335,13 +471,22 @@ class ReportListingUi extends Component { hidePerPageOptions: true, }; + const selection = { + itemId: 'id', + onSelectionChange: this.onSelectionChange, + }; + + const search = { + toolsRight: this.renderDeleteButton(), + }; + return ( - { }) } pagination={pagination} + selection={selection} + search={search} + isSelectable={true} onChange={this.onTableChange} data-test-subj="reportJobListing" /> ); } - - private renderDownloadButton = (record: Job) => { - if (record.status !== JobStatuses.COMPLETED) { - return; - } - - const { intl } = this.props; - const button = ( - this.props.apiClient.downloadReport(record.id)} - iconType="importAction" - aria-label={intl.formatMessage({ - id: 'xpack.reporting.listing.table.downloadReportAriaLabel', - defaultMessage: 'Download report', - })} - /> - ); - - if (record.csv_contains_formulas) { - return ( - - {button} - - ); - } - - if (record.max_size_reached) { - return ( - - {button} - - ); - } - - return button; - }; - - private renderReportErrorButton = (record: Job) => { - if (record.status !== JobStatuses.FAILED) { - return; - } - - return ; - }; - - private renderInfoButton = (record: Job) => { - return ; - }; - - private onTableChange = ({ page }: { page: { index: number } }) => { - const { index: pageIndex } = page; - this.setState(() => ({ page: pageIndex }), this.fetchJobs); - }; - - private fetchJobs = async () => { - // avoid page flicker when poller is updating table - only display loading screen on first load - if (this.isInitialJobsFetch) { - this.setState(() => ({ isLoading: true })); - } - - let jobs: JobQueueEntry[]; - let total: number; - try { - jobs = await this.props.apiClient.list(this.state.page); - total = await this.props.apiClient.total(); - this.isInitialJobsFetch = false; - } catch (fetchError) { - if (!this.licenseAllowsToShowThisPage()) { - this.props.toasts.addDanger(this.state.badLicenseMessage); - this.props.redirect('kibana#/management'); - return; - } - - if (fetchError.message === 'Failed to fetch') { - this.props.toasts.addDanger( - fetchError.message || - this.props.intl.formatMessage({ - id: 'xpack.reporting.listing.table.requestFailedErrorMessage', - defaultMessage: 'Request failed', - }) - ); - } - if (this.mounted) { - this.setState(() => ({ isLoading: false, jobs: [], total: 0 })); - } - return; - } - - if (this.mounted) { - this.setState(() => ({ - isLoading: false, - total, - jobs: jobs.map( - (job: JobQueueEntry): Job => { - const { _source: source } = job; - return { - id: job._id, - type: source.jobtype, - object_type: source.payload.objectType, - object_title: source.payload.title, - created_by: source.created_by, - created_at: source.created_at, - started_at: source.started_at, - completed_at: source.completed_at, - status: source.status, - statusLabel: jobStatusLabelsMap.get(source.status as JobStatuses) || source.status, - max_size_reached: source.output ? source.output.max_size_reached : false, - attempts: source.attempts, - max_attempts: source.max_attempts, - csv_contains_formulas: get(source, 'output.csv_contains_formulas'), - warnings: source.output ? source.output.warnings : undefined, - }; - } - ), - })); - } - }; - - private licenseAllowsToShowThisPage = () => { - return this.state.showLinks && this.state.enableLinks; - }; - - private formatDate(timestamp: string) { - try { - return moment(timestamp).format('YYYY-MM-DD @ hh:mm A'); - } catch (error) { - // ignore parse error and display unformatted value - return timestamp; - } - } } export const ReportListing = injectI18n(ReportListingUi); diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client.ts index ddfeb144d3cd74..cddfcd3ec855a6 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client.ts @@ -85,6 +85,12 @@ export class ReportingAPIClient { window.open(location); } + public async deleteReport(jobId: string) { + return await this.http.delete(`${API_LIST_URL}/delete/${jobId}`, { + asSystemRequest: true, + }); + } + public list = (page = 0, jobIds: string[] = []): Promise => { const query = { page } as any; if (jobIds.length > 0) { From f47022a41d09e241fa8c49bfd4d84d4fa6913deb Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Thu, 19 Mar 2020 13:05:01 -0700 Subject: [PATCH 14/32] Disables PR Project Assigner workflow Signed-off-by: Tyler Smalley --- .github/workflows/pr-project-assigner.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-project-assigner.yml b/.github/workflows/pr-project-assigner.yml index 0516f2cf956401..ca5d0b9864f993 100644 --- a/.github/workflows/pr-project-assigner.yml +++ b/.github/workflows/pr-project-assigner.yml @@ -13,9 +13,9 @@ jobs: with: issue-mappings: | [ - { "label": "Team:AppArch", "projectNumber": 37, "columnName": "Review in progress" }, - { "label": "Feature:Lens", "projectNumber": 32, "columnName": "In progress" }, - { "label": "Team:Canvas", "projectNumber": 38, "columnName": "Review in progress" } ] ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} +# { "label": "Team:AppArch", "projectNumber": 37, "columnName": "Review in progress" }, +# { "label": "Feature:Lens", "projectNumber": 32, "columnName": "In progress" }, +# { "label": "Team:Canvas", "projectNumber": 38, "columnName": "Review in progress" } \ No newline at end of file From cd2d54d59af929f750025b0859426735df99cfcf Mon Sep 17 00:00:00 2001 From: kqualters-elastic <56408403+kqualters-elastic@users.noreply.github.com> Date: Thu, 19 Mar 2020 16:14:45 -0400 Subject: [PATCH 15/32] Use common event model for determining if event is v0 or v1 (#60667) --- .../server/routes/resolver/utils/normalize.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts b/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts index 67a532d949e81d..6d5ac8efdc1da5 100644 --- a/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts +++ b/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts @@ -4,28 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ResolverEvent, LegacyEndpointEvent } from '../../../../common/types'; - -function isLegacyData(data: ResolverEvent): data is LegacyEndpointEvent { - return data.agent?.type === 'endgame'; -} +import { ResolverEvent } from '../../../../common/types'; +import { isLegacyEvent } from '../../../../common/models/event'; export function extractEventID(event: ResolverEvent) { - if (isLegacyData(event)) { + if (isLegacyEvent(event)) { return String(event.endgame.serial_event_id); } return event.event.id; } export function extractEntityID(event: ResolverEvent) { - if (isLegacyData(event)) { + if (isLegacyEvent(event)) { return String(event.endgame.unique_pid); } return event.process.entity_id; } export function extractParentEntityID(event: ResolverEvent) { - if (isLegacyData(event)) { + if (isLegacyEvent(event)) { const ppid = event.endgame.unique_ppid; return ppid && String(ppid); // if unique_ppid is undefined return undefined } From 404e941e636450d2ad0dcc68b914715bcc669a9f Mon Sep 17 00:00:00 2001 From: marshallmain <55718608+marshallmain@users.noreply.github.com> Date: Thu, 19 Mar 2020 17:01:39 -0400 Subject: [PATCH 16/32] [Endpoint] Log random seed for sample data CLI to console (#60646) * log random seed to console * fix off by 1 error with children --- x-pack/plugins/endpoint/common/generate_data.ts | 2 +- x-pack/plugins/endpoint/scripts/resolver_generator.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/endpoint/common/generate_data.ts b/x-pack/plugins/endpoint/common/generate_data.ts index f5ed6da197273e..75351bb3bf07d7 100644 --- a/x-pack/plugins/endpoint/common/generate_data.ts +++ b/x-pack/plugins/endpoint/common/generate_data.ts @@ -325,7 +325,7 @@ export class EndpointDocGenerator { for (let i = 0; i < generations; i++) { const newParents: EndpointEvent[] = []; parents.forEach(element => { - const numChildren = this.randomN(maxChildrenPerNode); + const numChildren = this.randomN(maxChildrenPerNode + 1); for (let j = 0; j < numChildren; j++) { timestamp = timestamp + 1000; const child = this.generateEvent({ diff --git a/x-pack/plugins/endpoint/scripts/resolver_generator.ts b/x-pack/plugins/endpoint/scripts/resolver_generator.ts index 503999daec5879..3d11ccaad005d4 100644 --- a/x-pack/plugins/endpoint/scripts/resolver_generator.ts +++ b/x-pack/plugins/endpoint/scripts/resolver_generator.ts @@ -131,8 +131,13 @@ async function main() { process.exit(1); } } - - const generator = new EndpointDocGenerator(argv.seed); + let seed = argv.seed; + if (!seed) { + seed = Math.random().toString(); + // eslint-disable-next-line no-console + console.log('No seed supplied, using random seed: ' + seed); + } + const generator = new EndpointDocGenerator(seed); for (let i = 0; i < argv.numHosts; i++) { await client.index({ index: argv.metadataIndex, From b2b5fcedcc2bb7cce0008074bc1fccc075c6d5bf Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Thu, 19 Mar 2020 22:02:16 +0100 Subject: [PATCH 17/32] [ML] Transforms: Fix pivot preview table mapping. (#60609) - Fixes regression caused by elastic/elasticsearch#53572. - Adjusts the TS mappings and code to reflect the newly returned API response. - Re-enables functional tests. --- .../transform/public/app/common/index.ts | 1 + .../public/app/common/pivot_preview.ts | 29 +++++++++++++++++++ .../pivot_preview/use_pivot_preview_data.ts | 24 ++++----------- .../transform/public/app/hooks/use_api.ts | 4 +-- .../test/functional/apps/transform/index.ts | 3 +- 5 files changed, 38 insertions(+), 23 deletions(-) create mode 100644 x-pack/plugins/transform/public/app/common/pivot_preview.ts diff --git a/x-pack/plugins/transform/public/app/common/index.ts b/x-pack/plugins/transform/public/app/common/index.ts index e81fadddbea69f..ee026e2e590a44 100644 --- a/x-pack/plugins/transform/public/app/common/index.ts +++ b/x-pack/plugins/transform/public/app/common/index.ts @@ -36,6 +36,7 @@ export { TRANSFORM_MODE, } from './transform_stats'; export { getDiscoverUrl } from './navigation'; +export { GetTransformsResponse, PreviewData, PreviewMappings } from './pivot_preview'; export { getEsAggFromAggConfig, isPivotAggsConfigWithUiSupport, diff --git a/x-pack/plugins/transform/public/app/common/pivot_preview.ts b/x-pack/plugins/transform/public/app/common/pivot_preview.ts new file mode 100644 index 00000000000000..14368a80b01318 --- /dev/null +++ b/x-pack/plugins/transform/public/app/common/pivot_preview.ts @@ -0,0 +1,29 @@ +/* + * 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 { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; + +import { Dictionary } from '../../../common/types/common'; + +interface EsMappingType { + type: ES_FIELD_TYPES; +} + +export type PreviewItem = Dictionary; +export type PreviewData = PreviewItem[]; +export interface PreviewMappings { + properties: Dictionary; +} + +export interface GetTransformsResponse { + preview: PreviewData; + generated_dest_index: { + mappings: PreviewMappings; + // Not in use yet + aliases: any; + settings: any; + }; +} diff --git a/x-pack/plugins/transform/public/app/components/pivot_preview/use_pivot_preview_data.ts b/x-pack/plugins/transform/public/app/components/pivot_preview/use_pivot_preview_data.ts index c3ccddbfc2906e..83fa7ba189ff0d 100644 --- a/x-pack/plugins/transform/public/app/components/pivot_preview/use_pivot_preview_data.ts +++ b/x-pack/plugins/transform/public/app/components/pivot_preview/use_pivot_preview_data.ts @@ -9,8 +9,7 @@ import { useEffect, useState } from 'react'; import { dictionaryToArray } from '../../../../common/types/common'; import { useApi } from '../../hooks/use_api'; -import { Dictionary } from '../../../../common/types/common'; -import { IndexPattern, ES_FIELD_TYPES } from '../../../../../../../src/plugins/data/public'; +import { IndexPattern } from '../../../../../../../src/plugins/data/public'; import { getPreviewRequestBody, @@ -18,6 +17,8 @@ import { PivotAggsConfigDict, PivotGroupByConfigDict, PivotQuery, + PreviewData, + PreviewMappings, } from '../../common'; export enum PIVOT_PREVIEW_STATUS { @@ -27,16 +28,6 @@ export enum PIVOT_PREVIEW_STATUS { ERROR, } -interface EsMappingType { - type: ES_FIELD_TYPES; -} - -export type PreviewItem = Dictionary; -type PreviewData = PreviewItem[]; -interface PreviewMappings { - properties: Dictionary; -} - export interface UsePivotPreviewDataReturnType { errorMessage: string; status: PIVOT_PREVIEW_STATUS; @@ -45,11 +36,6 @@ export interface UsePivotPreviewDataReturnType { previewRequest: PreviewRequestBody; } -export interface GetTransformsResponse { - preview: PreviewData; - mappings: PreviewMappings; -} - export const usePivotPreviewData = ( indexPatternTitle: IndexPattern['title'], query: PivotQuery, @@ -77,9 +63,9 @@ export const usePivotPreviewData = ( setStatus(PIVOT_PREVIEW_STATUS.LOADING); try { - const resp: GetTransformsResponse = await api.getTransformsPreview(previewRequest); + const resp = await api.getTransformsPreview(previewRequest); setPreviewData(resp.preview); - setPreviewMappings(resp.mappings); + setPreviewMappings(resp.generated_dest_index.mappings); setStatus(PIVOT_PREVIEW_STATUS.LOADED); } catch (e) { setErrorMessage(JSON.stringify(e, null, 2)); diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index c503051ed90afb..39341dd1add65b 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -8,7 +8,7 @@ import { TransformId, TransformEndpointRequest, TransformEndpointResult } from ' import { API_BASE_PATH } from '../../../common/constants'; import { useAppDependencies } from '../app_dependencies'; -import { PreviewRequestBody } from '../common'; +import { GetTransformsResponse, PreviewRequestBody } from '../common'; import { EsIndex } from './use_api_types'; @@ -37,7 +37,7 @@ export const useApi = () => { body: JSON.stringify(transformsInfo), }); }, - getTransformsPreview(obj: PreviewRequestBody): Promise { + getTransformsPreview(obj: PreviewRequestBody): Promise { return http.post(`${API_BASE_PATH}transforms/_preview`, { body: JSON.stringify(obj) }); }, startTransforms(transformsInfo: TransformEndpointRequest[]): Promise { diff --git a/x-pack/test/functional/apps/transform/index.ts b/x-pack/test/functional/apps/transform/index.ts index 5dcfd876f5b53d..60b72f122f1131 100644 --- a/x-pack/test/functional/apps/transform/index.ts +++ b/x-pack/test/functional/apps/transform/index.ts @@ -8,8 +8,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function({ getService, loadTestFile }: FtrProviderContext) { const transform = getService('transform'); - // prevent test failures with current ES snapshot, see https://github.com/elastic/kibana/issues/60516 - describe.skip('transform', function() { + describe('transform', function() { this.tags(['ciGroup9', 'transform']); before(async () => { From 347160b71aaef4790d6db2f5b14670b8b8bc07c3 Mon Sep 17 00:00:00 2001 From: Eric Davis Date: Thu, 19 Mar 2020 17:10:56 -0400 Subject: [PATCH 18/32] [Endpoint] TEST: GET alert details - boundary test for first alert retrieval (#60320) * boundary test for first alert retrieval * boundary test for first alert retrieval cleaned up * redo merge conflict resolving for api test * redo merge conflict resolving for api test try 2 * updating to current dataset expectations Co-authored-by: Elastic Machine --- .../test/api_integration/apis/endpoint/alerts.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/endpoint/alerts.ts b/x-pack/test/api_integration/apis/endpoint/alerts.ts index 140d8ca8136944..568c30aa5484fe 100644 --- a/x-pack/test/api_integration/apis/endpoint/alerts.ts +++ b/x-pack/test/api_integration/apis/endpoint/alerts.ts @@ -215,7 +215,7 @@ export default function({ getService }: FtrProviderContext) { expect(body.result_from_index).to.eql(0); }); - it('should return alert details by id', async () => { + it('should return alert details by id, getting last alert', async () => { const documentID = 'zbNm0HABdD75WLjLYgcB'; const prevDocumentID = '2rNm0HABdD75WLjLYgcU'; const { body } = await supertest @@ -227,6 +227,18 @@ export default function({ getService }: FtrProviderContext) { expect(body.next).to.eql(null); // last alert, no more beyond this }); + it('should return alert details by id, getting first alert', async () => { + const documentID = 'p7Nm0HABdD75WLjLYghv'; + const nextDocumentID = 'mbNm0HABdD75WLjLYgho'; + const { body } = await supertest + .get(`/api/endpoint/alerts/${documentID}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.id).to.eql(documentID); + expect(body.next).to.eql(`/api/endpoint/alerts/${nextDocumentID}`); + expect(body.prev).to.eql(null); // first alert, no more before this + }); + it('should return 404 when alert is not found', async () => { await supertest .get('/api/endpoint/alerts/does-not-exist') From 3acbbcd2b04b45c3aa1904a91abd33c91bb280d8 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 19 Mar 2020 23:23:37 +0200 Subject: [PATCH 19/32] Return incident's url (#60617) --- .../servicenow/action_handlers.test.ts | 25 +++++++++++++++++++ .../servicenow/action_handlers.ts | 15 +++++++---- .../servicenow/index.test.ts | 4 ++- .../servicenow/lib/constants.ts | 3 +++ .../servicenow/lib/index.test.ts | 2 ++ .../servicenow/lib/index.ts | 8 +++++- .../servicenow/lib/types.ts | 1 + .../builtin_action_types/servicenow/mock.ts | 2 ++ .../builtin_action_types/servicenow/types.ts | 7 ++---- .../builtin_action_types/servicenow.ts | 7 +++++- 10 files changed, 61 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts index be687e33e22015..2712b8f6ea9b52 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts @@ -78,11 +78,13 @@ beforeAll(() => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }), updateIncident: jest.fn().mockResolvedValue({ incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }), batchCreateComments: jest .fn() @@ -107,6 +109,7 @@ describe('handleIncident', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', comments: [ { commentId: '456', @@ -129,6 +132,7 @@ describe('handleIncident', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', comments: [ { commentId: '456', @@ -161,6 +165,7 @@ describe('handleCreateIncident', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); @@ -203,6 +208,7 @@ describe('handleCreateIncident', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', comments: [ { commentId: '456', @@ -236,6 +242,7 @@ describe('handleUpdateIncident', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); @@ -326,6 +333,7 @@ describe('handleUpdateIncident', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', comments: [ { commentId: '456', @@ -383,8 +391,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('nothing & append', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -426,8 +436,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('append & append', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -471,8 +483,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('nothing & nothing', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -511,8 +525,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('overwrite & nothing', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -553,8 +569,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('overwrite & overwrite', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -596,8 +614,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('nothing & overwrite', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -638,8 +658,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('append & overwrite', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -682,8 +704,10 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); + test('append & nothing', async () => { const { serviceNow } = new ServiceNowMock(); finalMapping.set('title', { @@ -725,6 +749,7 @@ describe('handleUpdateIncident: different action types', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts index 6439a68813fd5e..fb296089e9ec54 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts @@ -47,11 +47,11 @@ export const handleCreateIncident = async ({ fields, }); - const { incidentId, number, pushedDate } = await serviceNow.createIncident({ + const createdIncident = await serviceNow.createIncident({ ...incident, }); - const res: HandlerResponse = { incidentId, number, pushedDate }; + const res: HandlerResponse = { ...createdIncident }; if ( comments && @@ -61,7 +61,12 @@ export const handleCreateIncident = async ({ ) { comments = transformComments(comments, params, ['informationAdded']); res.comments = [ - ...(await createComments(serviceNow, incidentId, mapping.get('comments').target, comments)), + ...(await createComments( + serviceNow, + res.incidentId, + mapping.get('comments').target, + comments + )), ]; } @@ -88,11 +93,11 @@ export const handleUpdateIncident = async ({ currentIncident, }); - const { number, pushedDate } = await serviceNow.updateIncident(incidentId, { + const updatedIncident = await serviceNow.updateIncident(incidentId, { ...incident, }); - const res: HandlerResponse = { incidentId, number, pushedDate }; + const res: HandlerResponse = { ...updatedIncident }; if ( comments && diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts index 8ee81c5e764513..67d595cc3ec56c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts @@ -231,8 +231,10 @@ describe('execute()', () => { services, }; + handleIncidentMock.mockImplementation(() => incidentResponse); + const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toEqual({ actionId, status: 'ok' }); + expect(actionResponse).toEqual({ actionId, status: 'ok', data: incidentResponse }); }); test('should throw an error when failed to update an incident', async () => { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts index c84e1928e2e5a7..3f102ae19f437c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts @@ -8,3 +8,6 @@ export const API_VERSION = 'v2'; export const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`; export const USER_URL = `api/now/${API_VERSION}/table/sys_user?user_name=`; export const COMMENT_URL = `api/now/${API_VERSION}/table/incident`; + +// Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html +export const VIEW_INCIDENT_URL = `nav_to.do?uri=incident.do?sys_id=`; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts index 17c8bce6514030..40eeb0f920f82c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts @@ -92,6 +92,7 @@ describe('ServiceNow lib', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); @@ -116,6 +117,7 @@ describe('ServiceNow lib', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts index 2d1d8975c9efc7..1acb6c563801cf 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts @@ -6,7 +6,7 @@ import axios, { AxiosInstance, Method, AxiosResponse } from 'axios'; -import { INCIDENT_URL, USER_URL, COMMENT_URL } from './constants'; +import { INCIDENT_URL, USER_URL, COMMENT_URL, VIEW_INCIDENT_URL } from './constants'; import { Instance, Incident, IncidentResponse, UpdateIncident, CommentResponse } from './types'; import { Comment } from '../types'; @@ -72,6 +72,10 @@ class ServiceNow { return `[Action][ServiceNow]: ${msg}`; } + private _getIncidentViewURL(id: string) { + return `${this.instance.url}/${VIEW_INCIDENT_URL}${id}`; + } + async getUserID(): Promise { try { const res = await this._request({ url: `${this.userUrl}${this.instance.username}` }); @@ -109,6 +113,7 @@ class ServiceNow { number: res.data.result.number, incidentId: res.data.result.sys_id, pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(), + url: this._getIncidentViewURL(res.data.result.sys_id), }; } catch (error) { throw new Error(this._getErrorMessage(`Unable to create incident. Error: ${error.message}`)); @@ -126,6 +131,7 @@ class ServiceNow { number: res.data.result.number, incidentId: res.data.result.sys_id, pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), + url: this._getIncidentViewURL(res.data.result.sys_id), }; } catch (error) { throw new Error( diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts index 3c245bf3f688f4..a65e417dbc486e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts @@ -21,6 +21,7 @@ export interface IncidentResponse { number: string; incidentId: string; pushedDate: string; + url: string; } export interface CommentResponse { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts index b9608511159b61..06c006fb378254 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts @@ -69,6 +69,8 @@ const params: ExecutorParams = { const incidentResponse = { incidentId: 'c816f79cc0a8016401c5a33be04be441', number: 'INC0010001', + pushedDate: '2020-03-13T08:34:53.450Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }; const userId = '2e9a0a5e2f79001016ab51172799b670'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 418b78add2429e..71b05be8f3e4df 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -16,7 +16,7 @@ import { } from './schema'; import { ServiceNow } from './lib'; -import { Incident } from './lib/types'; +import { Incident, IncidentResponse } from './lib/types'; // config definition export type ConfigType = TypeOf; @@ -50,11 +50,8 @@ export type IncidentHandlerArguments = CreateHandlerArguments & { incidentId: string | null; }; -export interface HandlerResponse { - incidentId: string; - number: string; +export interface HandlerResponse extends IncidentResponse { comments?: SimpleComment[]; - pushedDate: string; } export interface SimpleComment { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index b735dae2ca5b19..48f348e1b834d8 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -294,7 +294,12 @@ export default function servicenowTest({ getService }: FtrProviderContext) { expect(result).to.eql({ status: 'ok', actionId: simulatedActionId, - data: { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z' }, + data: { + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${servicenowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, + }, }); }); From d1aaa4430af53087eab9e120de3ed2c35dbb9ae3 Mon Sep 17 00:00:00 2001 From: nnamdifrankie <56440728+nnamdifrankie@users.noreply.github.com> Date: Thu, 19 Mar 2020 18:15:56 -0400 Subject: [PATCH 20/32] [Ingest]EMT-248: add post action request handler and resources (#60581) [Ingest]EMT-248: add resource to allow to post new agent action. --- .../ingest_manager/common/constants/routes.ts | 1 + .../common/types/models/agent.ts | 9 +- .../common/types/rest_spec/agent.ts | 16 ++- .../routes/agent/actions_handlers.test.ts | 103 ++++++++++++++++++ .../server/routes/agent/actions_handlers.ts | 57 ++++++++++ .../server/routes/agent/index.ts | 15 +++ .../server/services/agents/actions.test.ts | 67 ++++++++++++ .../server/services/agents/actions.ts | 50 +++++++++ .../server/services/agents/index.ts | 1 + .../server/types/models/agent.ts | 11 ++ .../server/types/rest_spec/agent.ts | 11 +- .../apis/fleet/agents/actions.ts | 86 +++++++++++++++ .../test/api_integration/apis/fleet/index.js | 1 + 13 files changed, 423 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts create mode 100644 x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/agents/actions.ts create mode 100644 x-pack/test/api_integration/apis/fleet/agents/actions.ts diff --git a/x-pack/plugins/ingest_manager/common/constants/routes.ts b/x-pack/plugins/ingest_manager/common/constants/routes.ts index 1dc98f9bc89476..5bf7c910168c0a 100644 --- a/x-pack/plugins/ingest_manager/common/constants/routes.ts +++ b/x-pack/plugins/ingest_manager/common/constants/routes.ts @@ -50,6 +50,7 @@ export const AGENT_API_ROUTES = { EVENTS_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/events`, CHECKIN_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/checkin`, ACKS_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/acks`, + ACTIONS_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/actions`, ENROLL_PATTERN: `${FLEET_API_ROOT}/agents/enroll`, UNENROLL_PATTERN: `${FLEET_API_ROOT}/agents/unenroll`, STATUS_PATTERN: `${FLEET_API_ROOT}/agent-status`, diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent.ts b/x-pack/plugins/ingest_manager/common/types/models/agent.ts index 179cc3fc9eb553..aa5729a101e111 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts @@ -14,14 +14,17 @@ export type AgentType = export type AgentStatus = 'offline' | 'error' | 'online' | 'inactive' | 'warning'; -export interface AgentAction extends SavedObjectAttributes { +export interface NewAgentAction { type: 'CONFIG_CHANGE' | 'DATA_DUMP' | 'RESUME' | 'PAUSE'; - id: string; - created_at: string; data?: string; sent_at?: string; } +export type AgentAction = NewAgentAction & { + id: string; + created_at: string; +} & SavedObjectAttributes; + export interface AgentEvent { type: 'STATE' | 'ERROR' | 'ACTION_RESULT' | 'ACTION'; subtype: // State diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts index 7bbaf42422bb25..21ab41740ce3e2 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Agent, AgentAction, AgentEvent, AgentStatus, AgentType } from '../models'; +import { Agent, AgentAction, AgentEvent, AgentStatus, AgentType, NewAgentAction } from '../models'; export interface GetAgentsRequest { query: { @@ -81,6 +81,20 @@ export interface PostAgentAcksResponse { success: boolean; } +export interface PostNewAgentActionRequest { + body: { + action: NewAgentAction; + }; + params: { + agentId: string; + }; +} + +export interface PostNewAgentActionResponse { + success: boolean; + item: AgentAction; +} + export interface PostAgentUnenrollRequest { body: { kuery: string } | { ids: string[] }; } diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts new file mode 100644 index 00000000000000..a20ba4a880537d --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts @@ -0,0 +1,103 @@ +/* + * 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 { NewAgentActionSchema } from '../../types/models'; +import { + KibanaResponseFactory, + RequestHandlerContext, + SavedObjectsClientContract, +} from 'kibana/server'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/saved_objects/service/saved_objects_client.mock'; +import { httpServerMock } from '../../../../../../src/core/server/http/http_server.mocks'; +import { ActionsService } from '../../services/agents'; +import { AgentAction } from '../../../common/types/models'; +import { postNewAgentActionHandlerBuilder } from './actions_handlers'; +import { + PostNewAgentActionRequest, + PostNewAgentActionResponse, +} from '../../../common/types/rest_spec'; + +describe('test actions handlers schema', () => { + it('validate that new agent actions schema is valid', async () => { + expect( + NewAgentActionSchema.validate({ + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }) + ).toBeTruthy(); + }); + + it('validate that new agent actions schema is invalid when required properties are not provided', async () => { + expect(() => { + NewAgentActionSchema.validate({ + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }); + }).toThrowError(); + }); +}); + +describe('test actions handlers', () => { + let mockResponse: jest.Mocked; + let mockSavedObjectsClient: jest.Mocked; + + beforeEach(() => { + mockSavedObjectsClient = savedObjectsClientMock.create(); + mockResponse = httpServerMock.createResponseFactory(); + }); + + it('should succeed on valid new agent action', async () => { + const postNewAgentActionRequest: PostNewAgentActionRequest = { + body: { + action: { + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }, + }, + params: { + agentId: 'id', + }, + }; + + const mockRequest = httpServerMock.createKibanaRequest(postNewAgentActionRequest); + + const agentAction = ({ + type: 'CONFIG_CHANGE', + id: 'action1', + sent_at: '2020-03-14T19:45:02.620Z', + timestamp: '2019-01-04T14:32:03.36764-05:00', + created_at: '2020-03-14T19:45:02.620Z', + } as unknown) as AgentAction; + + const actionsService: ActionsService = { + getAgent: jest.fn().mockReturnValueOnce({ + id: 'agent', + }), + updateAgentActions: jest.fn().mockReturnValueOnce(agentAction), + } as jest.Mocked; + + const postNewAgentActionHandler = postNewAgentActionHandlerBuilder(actionsService); + await postNewAgentActionHandler( + ({ + core: { + savedObjects: { + client: mockSavedObjectsClient, + }, + }, + } as unknown) as RequestHandlerContext, + mockRequest, + mockResponse + ); + + const expectedAgentActionResponse = (mockResponse.ok.mock.calls[0][0] + ?.body as unknown) as PostNewAgentActionResponse; + + expect(expectedAgentActionResponse.item).toEqual(agentAction); + expect(expectedAgentActionResponse.success).toEqual(true); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts new file mode 100644 index 00000000000000..2b9c2308035932 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts @@ -0,0 +1,57 @@ +/* + * 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. + */ + +// handlers that handle agent actions request + +import { RequestHandler } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { PostNewAgentActionRequestSchema } from '../../types/rest_spec'; +import { ActionsService } from '../../services/agents'; +import { NewAgentAction } from '../../../common/types/models'; +import { PostNewAgentActionResponse } from '../../../common/types/rest_spec'; + +export const postNewAgentActionHandlerBuilder = function( + actionsService: ActionsService +): RequestHandler< + TypeOf, + undefined, + TypeOf +> { + return async (context, request, response) => { + try { + const soClient = context.core.savedObjects.client; + + const agent = await actionsService.getAgent(soClient, request.params.agentId); + + const newAgentAction = request.body.action as NewAgentAction; + + const savedAgentAction = await actionsService.updateAgentActions( + soClient, + agent, + newAgentAction + ); + + const body: PostNewAgentActionResponse = { + success: true, + item: savedAgentAction, + }; + + return response.ok({ body }); + } catch (e) { + if (e.isBoom) { + return response.customError({ + statusCode: e.output.statusCode, + body: { message: e.message }, + }); + } + + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } + }; +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts index 414d2d79e90671..d4610270178429 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts @@ -22,6 +22,7 @@ import { PostAgentAcksRequestSchema, PostAgentUnenrollRequestSchema, GetAgentStatusRequestSchema, + PostNewAgentActionRequestSchema, } from '../../types'; import { getAgentsHandler, @@ -37,6 +38,7 @@ import { } from './handlers'; import { postAgentAcksHandlerBuilder } from './acks_handlers'; import * as AgentService from '../../services/agents'; +import { postNewAgentActionHandlerBuilder } from './actions_handlers'; export const registerRoutes = (router: IRouter) => { // Get one @@ -111,6 +113,19 @@ export const registerRoutes = (router: IRouter) => { }) ); + // Agent actions + router.post( + { + path: AGENT_API_ROUTES.ACTIONS_PATTERN, + validate: PostNewAgentActionRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + postNewAgentActionHandlerBuilder({ + getAgent: AgentService.getAgent, + updateAgentActions: AgentService.updateAgentActions, + }) + ); + router.post( { path: AGENT_API_ROUTES.UNENROLL_PATTERN, diff --git a/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts b/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts new file mode 100644 index 00000000000000..b500aeb825fec7 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { createAgentAction, updateAgentActions } from './actions'; +import { Agent, AgentAction, NewAgentAction } from '../../../common/types/models'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/saved_objects/service/saved_objects_client.mock'; +import { AGENT_TYPE_PERMANENT } from '../../../common/constants'; + +interface UpdatedActions { + actions: AgentAction[]; +} + +describe('test agent actions services', () => { + it('should update agent current actions with new action', async () => { + const mockSavedObjectsClient = savedObjectsClientMock.create(); + + const newAgentAction: NewAgentAction = { + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }; + + await updateAgentActions( + mockSavedObjectsClient, + ({ + id: 'id', + type: AGENT_TYPE_PERMANENT, + actions: [ + { + type: 'CONFIG_CHANGE', + id: 'action1', + sent_at: '2020-03-14T19:45:02.620Z', + timestamp: '2019-01-04T14:32:03.36764-05:00', + created_at: '2020-03-14T19:45:02.620Z', + }, + ], + } as unknown) as Agent, + newAgentAction + ); + + const updatedAgentActions = (mockSavedObjectsClient.update.mock + .calls[0][2] as unknown) as UpdatedActions; + + expect(updatedAgentActions.actions.length).toEqual(2); + const actualAgentAction = updatedAgentActions.actions.find(action => action?.data === 'data'); + expect(actualAgentAction?.type).toEqual(newAgentAction.type); + expect(actualAgentAction?.data).toEqual(newAgentAction.data); + expect(actualAgentAction?.sent_at).toEqual(newAgentAction.sent_at); + }); + + it('should create agent action from new agent action model', async () => { + const newAgentAction: NewAgentAction = { + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }; + const now = new Date(); + const agentAction = createAgentAction(now, newAgentAction); + + expect(agentAction.type).toEqual(newAgentAction.type); + expect(agentAction.data).toEqual(newAgentAction.data); + expect(agentAction.sent_at).toEqual(newAgentAction.sent_at); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/agents/actions.ts b/x-pack/plugins/ingest_manager/server/services/agents/actions.ts new file mode 100644 index 00000000000000..2f8ed9f504453a --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/actions.ts @@ -0,0 +1,50 @@ +/* + * 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 { SavedObjectsClientContract } from 'kibana/server'; +import uuid from 'uuid'; +import { + Agent, + AgentAction, + AgentSOAttributes, + NewAgentAction, +} from '../../../common/types/models'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../../common/constants'; + +export async function updateAgentActions( + soClient: SavedObjectsClientContract, + agent: Agent, + newAgentAction: NewAgentAction +): Promise { + const agentAction = createAgentAction(new Date(), newAgentAction); + + agent.actions.push(agentAction); + + await soClient.update(AGENT_SAVED_OBJECT_TYPE, agent.id, { + actions: agent.actions, + }); + + return agentAction; +} + +export function createAgentAction(createdAt: Date, newAgentAction: NewAgentAction): AgentAction { + const agentAction = { + id: uuid.v4(), + created_at: createdAt.toISOString(), + }; + + return Object.assign(agentAction, newAgentAction); +} + +export interface ActionsService { + getAgent: (soClient: SavedObjectsClientContract, agentId: string) => Promise; + + updateAgentActions: ( + soClient: SavedObjectsClientContract, + agent: Agent, + newAgentAction: NewAgentAction + ) => Promise; +} diff --git a/x-pack/plugins/ingest_manager/server/services/agents/index.ts b/x-pack/plugins/ingest_manager/server/services/agents/index.ts index 477f081d1900b1..c95c9ecc2a1d88 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/index.ts @@ -12,3 +12,4 @@ export * from './unenroll'; export * from './status'; export * from './crud'; export * from './update'; +export * from './actions'; diff --git a/x-pack/plugins/ingest_manager/server/types/models/agent.ts b/x-pack/plugins/ingest_manager/server/types/models/agent.ts index e0d252faaaf87c..f70b3cf0ed092b 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/agent.ts @@ -52,3 +52,14 @@ export const AckEventSchema = schema.object({ export const AgentEventSchema = schema.object({ ...AgentEventBase, }); + +export const NewAgentActionSchema = schema.object({ + type: schema.oneOf([ + schema.literal('CONFIG_CHANGE'), + schema.literal('DATA_DUMP'), + schema.literal('RESUME'), + schema.literal('PAUSE'), + ]), + data: schema.maybe(schema.string()), + sent_at: schema.maybe(schema.string()), +}); diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts index 9fe84c12521add..f94c02ccee40bc 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts @@ -5,7 +5,7 @@ */ import { schema } from '@kbn/config-schema'; -import { AckEventSchema, AgentEventSchema, AgentTypeSchema } from '../models'; +import { AckEventSchema, AgentEventSchema, AgentTypeSchema, NewAgentActionSchema } from '../models'; export const GetAgentsRequestSchema = { query: schema.object({ @@ -52,6 +52,15 @@ export const PostAgentAcksRequestSchema = { }), }; +export const PostNewAgentActionRequestSchema = { + body: schema.object({ + action: NewAgentActionSchema, + }), + params: schema.object({ + agentId: schema.string(), + }), +}; + export const PostAgentUnenrollRequestSchema = { body: schema.oneOf([ schema.object({ diff --git a/x-pack/test/api_integration/apis/fleet/agents/actions.ts b/x-pack/test/api_integration/apis/fleet/agents/actions.ts new file mode 100644 index 00000000000000..f27b932cff5cba --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/agents/actions.ts @@ -0,0 +1,86 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function(providerContext: FtrProviderContext) { + const { getService } = providerContext; + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + + describe('fleet_agents_actions', () => { + before(async () => { + await esArchiver.loadIfNeeded('fleet/agents'); + }); + after(async () => { + await esArchiver.unload('fleet/agents'); + }); + + it('should return a 200 if this a valid actions request', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + type: 'CONFIG_CHANGE', + data: 'action_data', + sent_at: '2020-03-18T19:45:02.620Z', + }, + }) + .expect(200); + + expect(apiResponse.success).to.be(true); + expect(apiResponse.item.data).to.be('action_data'); + expect(apiResponse.item.sent_at).to.be('2020-03-18T19:45:02.620Z'); + + const { body: agentResponse } = await supertest + .get(`/api/ingest_manager/fleet/agents/agent1`) + .set('kbn-xsrf', 'xx') + .expect(200); + + const updatedAction = agentResponse.item.actions.find( + (itemAction: Record) => itemAction?.data === 'action_data' + ); + + expect(updatedAction.type).to.be('CONFIG_CHANGE'); + expect(updatedAction.data).to.be('action_data'); + expect(updatedAction.sent_at).to.be('2020-03-18T19:45:02.620Z'); + }); + + it('should return a 400 when request does not have type information', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + data: 'action_data', + sent_at: '2020-03-18T19:45:02.620Z', + }, + }) + .expect(400); + expect(apiResponse.message).to.eql( + '[request body.action.type]: expected at least one defined value but got [undefined]' + ); + }); + + it('should return a 404 when agent does not exist', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/agent100/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + type: 'CONFIG_CHANGE', + data: 'action_data', + sent_at: '2020-03-18T19:45:02.620Z', + }, + }) + .expect(404); + expect(apiResponse.message).to.eql('Saved object [agents/agent100] not found'); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/fleet/index.js b/x-pack/test/api_integration/apis/fleet/index.js index 69d30291f030bf..547bbb8c7c6ee9 100644 --- a/x-pack/test/api_integration/apis/fleet/index.js +++ b/x-pack/test/api_integration/apis/fleet/index.js @@ -15,5 +15,6 @@ export default function loadTests({ loadTestFile }) { loadTestFile(require.resolve('./agents/acks')); loadTestFile(require.resolve('./enrollment_api_keys/crud')); loadTestFile(require.resolve('./install')); + loadTestFile(require.resolve('./agents/actions')); }); } From d5989e8baa55350caff19e721cd5d15ff5621f93 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Thu, 19 Mar 2020 18:29:26 -0400 Subject: [PATCH 21/32] [Alerting] add functional tests for index threshold alertType (#60597) resolves https://github.com/elastic/kibana/issues/58902 --- .../alert_types/index_threshold/alert_type.ts | 2 + .../common/lib/es_test_index_tool.ts | 23 +- .../index_threshold/alert.ts | 398 ++++++++++++++++++ .../index_threshold/create_test_data.ts | 48 +-- .../index_threshold/index.ts | 1 + .../time_series_query_endpoint.ts | 24 +- 6 files changed, 447 insertions(+), 49 deletions(-) create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts index b79321a8803fad..6d27f8a99dd4b5 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts @@ -113,6 +113,7 @@ export function getAlertType(service: Service): AlertType { timeWindowUnit: params.timeWindowUnit, interval: undefined, }; + // console.log(`index_threshold: query: ${JSON.stringify(queryParams, null, 4)}`); const result = await service.indexThreshold.timeSeriesQuery({ logger, callCluster, @@ -121,6 +122,7 @@ export function getAlertType(service: Service): AlertType { logger.debug(`alert ${ID}:${alertId} "${name}" query result: ${JSON.stringify(result)}`); const groupResults = result.results || []; + // console.log(`index_threshold: response: ${JSON.stringify(groupResults, null, 4)}`); for (const groupResult of groupResults) { const instanceId = groupResult.group; const value = groupResult.metrics[0][1]; diff --git a/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts b/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts index ccd7748d9e899d..999a8686e0ee7a 100644 --- a/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts +++ b/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts @@ -4,20 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -export const ES_TEST_INDEX_NAME = '.kibaka-alerting-test-data'; +export const ES_TEST_INDEX_NAME = '.kibana-alerting-test-data'; export class ESTestIndexTool { - private readonly es: any; - private readonly retry: any; - - constructor(es: any, retry: any) { - this.es = es; - this.retry = retry; - } + constructor( + private readonly es: any, + private readonly retry: any, + private readonly index: string = ES_TEST_INDEX_NAME + ) {} async setup() { return await this.es.indices.create({ - index: ES_TEST_INDEX_NAME, + index: this.index, body: { mappings: { properties: { @@ -56,12 +54,13 @@ export class ESTestIndexTool { } async destroy() { - return await this.es.indices.delete({ index: ES_TEST_INDEX_NAME, ignore: [404] }); + return await this.es.indices.delete({ index: this.index, ignore: [404] }); } async search(source: string, reference: string) { return await this.es.search({ - index: ES_TEST_INDEX_NAME, + index: this.index, + size: 1000, body: { query: { bool: { @@ -86,7 +85,7 @@ export class ESTestIndexTool { async waitForDocs(source: string, reference: string, numDocs: number = 1) { return await this.retry.try(async () => { const searchResult = await this.search(source, reference); - if (searchResult.hits.total.value !== numDocs) { + if (searchResult.hits.total.value < numDocs) { throw new Error(`Expected ${numDocs} but received ${searchResult.hits.total.value}.`); } return searchResult.hits.hits; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts new file mode 100644 index 00000000000000..13f3a4971183c6 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts @@ -0,0 +1,398 @@ +/* + * 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 expect from '@kbn/expect'; + +import { Spaces } from '../../../../scenarios'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { + ESTestIndexTool, + ES_TEST_INDEX_NAME, + getUrlPrefix, + ObjectRemover, +} from '../../../../../common/lib'; +import { createEsDocuments } from './create_test_data'; + +const ALERT_TYPE_ID = '.index-threshold'; +const ACTION_TYPE_ID = '.index'; +const ES_TEST_INDEX_SOURCE = 'builtin-alert:index-threshold'; +const ES_TEST_INDEX_REFERENCE = '-na-'; +const ES_TEST_OUTPUT_INDEX_NAME = `${ES_TEST_INDEX_NAME}-output`; + +const ALERT_INTERVALS_TO_WRITE = 5; +const ALERT_INTERVAL_SECONDS = 3; +const ALERT_INTERVAL_MILLIS = ALERT_INTERVAL_SECONDS * 1000; + +// eslint-disable-next-line import/no-default-export +export default function alertTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + const es = getService('legacyEs'); + const esTestIndexTool = new ESTestIndexTool(es, retry); + const esTestIndexToolOutput = new ESTestIndexTool(es, retry, ES_TEST_OUTPUT_INDEX_NAME); + + describe('alert', async () => { + let endDate: string; + let actionId: string; + const objectRemover = new ObjectRemover(supertest); + + beforeEach(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + + await esTestIndexToolOutput.destroy(); + await esTestIndexToolOutput.setup(); + + actionId = await createAction(supertest, objectRemover); + + // write documents in the future, figure out the end date + const endDateMillis = Date.now() + (ALERT_INTERVALS_TO_WRITE - 1) * ALERT_INTERVAL_MILLIS; + endDate = new Date(endDateMillis).toISOString(); + + // write documents from now to the future end date in 3 groups + createEsDocumentsInGroups(3); + }); + + afterEach(async () => { + await objectRemover.removeAll(); + await esTestIndexTool.destroy(); + await esTestIndexToolOutput.destroy(); + }); + + // The tests below create two alerts, one that will fire, one that will + // never fire; the tests ensure the ones that should fire, do fire, and + // those that shouldn't fire, do not fire. + it('runs correctly: count all < >', async () => { + await createAlert({ + name: 'never fire', + aggType: 'count', + groupBy: 'all', + thresholdComparator: '<', + threshold: [0], + }); + + await createAlert({ + name: 'always fire', + aggType: 'count', + groupBy: 'all', + thresholdComparator: '>', + threshold: [-1], + }); + + const docs = await waitForDocs(2); + for (const doc of docs) { + const { group } = doc._source; + const { name, value, title, message } = doc._source.params; + + expect(name).to.be('always fire'); + expect(group).to.be('all documents'); + + // we'll check title and message in this test, but not subsequent ones + expect(title).to.be('alert always fire group all documents exceeded threshold'); + + const expectedPrefix = `alert always fire group all documents value ${value} exceeded threshold count > -1 over`; + const messagePrefix = message.substr(0, expectedPrefix.length); + expect(messagePrefix).to.be(expectedPrefix); + } + }); + + it('runs correctly: count grouped <= =>', async () => { + // create some more documents in the first group + createEsDocumentsInGroups(1); + + await createAlert({ + name: 'never fire', + aggType: 'count', + groupBy: 'top', + termField: 'group', + termSize: 2, + thresholdComparator: '<=', + threshold: [-1], + }); + + await createAlert({ + name: 'always fire', + aggType: 'count', + groupBy: 'top', + termField: 'group', + termSize: 2, // two actions will fire each interval + thresholdComparator: '>=', + threshold: [0], + }); + + const docs = await waitForDocs(4); + let inGroup0 = 0; + + for (const doc of docs) { + const { group } = doc._source; + const { name } = doc._source.params; + + expect(name).to.be('always fire'); + if (group === 'group-0') inGroup0++; + } + + // there should be 2 docs in group-0, rando split between others + expect(inGroup0).to.be(2); + }); + + it('runs correctly: sum all between', async () => { + // create some more documents in the first group + createEsDocumentsInGroups(1); + + await createAlert({ + name: 'never fire', + aggType: 'sum', + aggField: 'testedValue', + groupBy: 'all', + thresholdComparator: 'between', + threshold: [-2, -1], + }); + + await createAlert({ + name: 'always fire', + aggType: 'sum', + aggField: 'testedValue', + groupBy: 'all', + thresholdComparator: 'between', + threshold: [0, 1000000], + }); + + const docs = await waitForDocs(2); + for (const doc of docs) { + const { name } = doc._source.params; + + expect(name).to.be('always fire'); + } + }); + + it('runs correctly: avg all', async () => { + // create some more documents in the first group + createEsDocumentsInGroups(1); + + await createAlert({ + name: 'never fire', + aggType: 'avg', + aggField: 'testedValue', + groupBy: 'all', + thresholdComparator: '<', + threshold: [0], + }); + + await createAlert({ + name: 'always fire', + aggType: 'avg', + aggField: 'testedValue', + groupBy: 'all', + thresholdComparator: '>=', + threshold: [0], + }); + + const docs = await waitForDocs(4); + for (const doc of docs) { + const { name } = doc._source.params; + + expect(name).to.be('always fire'); + } + }); + + it('runs correctly: max grouped', async () => { + // create some more documents in the first group + createEsDocumentsInGroups(1); + + await createAlert({ + name: 'never fire', + aggType: 'max', + aggField: 'testedValue', + groupBy: 'top', + termField: 'group', + termSize: 2, + thresholdComparator: '<', + threshold: [0], + }); + + await createAlert({ + name: 'always fire', + aggType: 'max', + aggField: 'testedValue', + groupBy: 'top', + termField: 'group', + termSize: 2, // two actions will fire each interval + thresholdComparator: '>=', + threshold: [0], + }); + + const docs = await waitForDocs(4); + let inGroup2 = 0; + + for (const doc of docs) { + const { group } = doc._source; + const { name } = doc._source.params; + + expect(name).to.be('always fire'); + if (group === 'group-2') inGroup2++; + } + + // there should be 2 docs in group-2, rando split between others + expect(inGroup2).to.be(2); + }); + + it('runs correctly: min grouped', async () => { + // create some more documents in the first group + createEsDocumentsInGroups(1); + + await createAlert({ + name: 'never fire', + aggType: 'min', + aggField: 'testedValue', + groupBy: 'top', + termField: 'group', + termSize: 2, + thresholdComparator: '<', + threshold: [0], + }); + + await createAlert({ + name: 'always fire', + aggType: 'min', + aggField: 'testedValue', + groupBy: 'top', + termField: 'group', + termSize: 2, // two actions will fire each interval + thresholdComparator: '>=', + threshold: [0], + }); + + const docs = await waitForDocs(4); + let inGroup0 = 0; + + for (const doc of docs) { + const { group } = doc._source; + const { name } = doc._source.params; + + expect(name).to.be('always fire'); + if (group === 'group-0') inGroup0++; + } + + // there should be 2 docs in group-0, rando split between others + expect(inGroup0).to.be(2); + }); + + async function createEsDocumentsInGroups(groups: number) { + await createEsDocuments( + es, + esTestIndexTool, + endDate, + ALERT_INTERVALS_TO_WRITE, + ALERT_INTERVAL_MILLIS, + groups + ); + } + + async function waitForDocs(count: number): Promise { + return await esTestIndexToolOutput.waitForDocs( + ES_TEST_INDEX_SOURCE, + ES_TEST_INDEX_REFERENCE, + count + ); + } + + interface CreateAlertParams { + name: string; + aggType: string; + aggField?: string; + groupBy: 'all' | 'top'; + termField?: string; + termSize?: number; + thresholdComparator: string; + threshold: number[]; + } + + async function createAlert(params: CreateAlertParams): Promise { + const action = { + id: actionId, + group: 'threshold met', + params: { + documents: [ + { + source: ES_TEST_INDEX_SOURCE, + reference: ES_TEST_INDEX_REFERENCE, + params: { + name: '{{{alertName}}}', + value: '{{{context.value}}}', + title: '{{{context.title}}}', + message: '{{{context.message}}}', + }, + date: '{{{context.date}}}', + // TODO: I wanted to write the alert value here, but how? + // We only mustache interpolate string values ... + // testedValue: '{{{context.value}}}', + group: '{{{context.group}}}', + }, + ], + }, + }; + + const { statusCode, body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .send({ + name: params.name, + consumer: 'function test', + enabled: true, + alertTypeId: ALERT_TYPE_ID, + schedule: { interval: `${ALERT_INTERVAL_SECONDS}s` }, + actions: [action], + params: { + index: ES_TEST_INDEX_NAME, + timeField: 'date', + aggType: params.aggType, + aggField: params.aggField, + groupBy: params.groupBy, + termField: params.termField, + termSize: params.termSize, + timeWindowSize: ALERT_INTERVAL_SECONDS * 5, + timeWindowUnit: 's', + thresholdComparator: params.thresholdComparator, + threshold: params.threshold, + }, + }); + + // will print the error body, if an error occurred + // if (statusCode !== 200) console.log(createdAlert); + + expect(statusCode).to.be(200); + + const alertId = createdAlert.id; + objectRemover.add(Spaces.space1.id, alertId, 'alert'); + + return alertId; + } + }); +} + +async function createAction(supertest: any, objectRemover: ObjectRemover): Promise { + const { statusCode, body: createdAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'index action for index threshold FT', + actionTypeId: ACTION_TYPE_ID, + config: { + index: ES_TEST_OUTPUT_INDEX_NAME, + }, + secrets: {}, + }); + + // will print the error body, if an error occurred + // if (statusCode !== 200) console.log(createdAction); + + expect(statusCode).to.be(200); + + const actionId = createdAction.id; + objectRemover.add(Spaces.space1.id, actionId, 'action'); + + return actionId; +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts index 523c348e260497..21f73ac9b98330 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts @@ -8,53 +8,50 @@ import { times } from 'lodash'; import { v4 as uuid } from 'uuid'; import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '../../../../../common/lib'; -// date to start writing data -export const START_DATE = '2020-01-01T00:00:00Z'; +// default end date +export const END_DATE = '2020-01-01T00:00:00Z'; -const DOCUMENT_SOURCE = 'queryDataEndpointTests'; +export const DOCUMENT_SOURCE = 'queryDataEndpointTests'; +export const DOCUMENT_REFERENCE = '-na-'; // Create a set of es documents to run the queries against. -// Will create 2 documents for each interval. +// Will create `groups` documents for each interval. // The difference between the dates of the docs will be intervalMillis. // The date of the last documents will be startDate - intervalMillis / 2. -// So there will be 2 documents written in the middle of each interval range. -// The data value written to each doc is a power of 2, with 2^0 as the value -// of the last documents, the values increasing for older documents. The -// second document for each time value will be power of 2 + 1 +// So the documents will be written in the middle of each interval range. +// The data value written to each doc is a power of 2 + the group index, with +// 2^0 as the value of the last documents, the values increasing for older +// documents. export async function createEsDocuments( es: any, esTestIndexTool: ESTestIndexTool, - startDate: string = START_DATE, + endDate: string = END_DATE, intervals: number = 1, - intervalMillis: number = 1000 + intervalMillis: number = 1000, + groups: number = 2 ) { - const totalDocuments = intervals * 2; - const startDateMillis = Date.parse(startDate) - intervalMillis / 2; + const endDateMillis = Date.parse(endDate) - intervalMillis / 2; times(intervals, interval => { - const date = startDateMillis - interval * intervalMillis; + const date = endDateMillis - interval * intervalMillis; - // base value for each window is 2^window + // base value for each window is 2^interval const testedValue = 2 ** interval; // don't need await on these, wait at the end of the function - createEsDocument(es, '-na-', date, testedValue, 'groupA'); - createEsDocument(es, '-na-', date, testedValue + 1, 'groupB'); + times(groups, group => { + createEsDocument(es, date, testedValue + group, `group-${group}`); + }); }); - await esTestIndexTool.waitForDocs(DOCUMENT_SOURCE, '-na-', totalDocuments); + const totalDocuments = intervals * groups; + await esTestIndexTool.waitForDocs(DOCUMENT_SOURCE, DOCUMENT_REFERENCE, totalDocuments); } -async function createEsDocument( - es: any, - reference: string, - epochMillis: number, - testedValue: number, - group: string -) { +async function createEsDocument(es: any, epochMillis: number, testedValue: number, group: string) { const document = { source: DOCUMENT_SOURCE, - reference, + reference: DOCUMENT_REFERENCE, date: new Date(epochMillis).toISOString(), testedValue, group, @@ -65,6 +62,7 @@ async function createEsDocument( index: ES_TEST_INDEX_NAME, body: document, }); + // console.log(`writing document to ${ES_TEST_INDEX_NAME}:`, JSON.stringify(document, null, 4)); if (response.result !== 'created') { throw new Error(`document not created: ${JSON.stringify(response)}`); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts index 9158954f233643..507548f94aaf3e 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts @@ -12,5 +12,6 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./time_series_query_endpoint')); loadTestFile(require.resolve('./fields_endpoint')); loadTestFile(require.resolve('./indices_endpoint')); + loadTestFile(require.resolve('./alert')); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/time_series_query_endpoint.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/time_series_query_endpoint.ts index 1aa1d3d21f00d1..c9b488da5dec5d 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/time_series_query_endpoint.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/time_series_query_endpoint.ts @@ -39,12 +39,12 @@ const START_DATE_MINUS_2INTERVALS = getStartDate(-2 * INTERVAL_MILLIS); are offset from the top of the minute by 30 seconds, the queries always run from the top of the hour. - { "date":"2019-12-31T23:59:30.000Z", "testedValue":1, "group":"groupA" } - { "date":"2019-12-31T23:59:30.000Z", "testedValue":2, "group":"groupB" } - { "date":"2019-12-31T23:58:30.000Z", "testedValue":2, "group":"groupA" } - { "date":"2019-12-31T23:58:30.000Z", "testedValue":3, "group":"groupB" } - { "date":"2019-12-31T23:57:30.000Z", "testedValue":4, "group":"groupA" } - { "date":"2019-12-31T23:57:30.000Z", "testedValue":5, "group":"groupB" } + { "date":"2019-12-31T23:59:30.000Z", "testedValue":1, "group":"group-0" } + { "date":"2019-12-31T23:59:30.000Z", "testedValue":2, "group":"group-1" } + { "date":"2019-12-31T23:58:30.000Z", "testedValue":2, "group":"group-0" } + { "date":"2019-12-31T23:58:30.000Z", "testedValue":3, "group":"group-1" } + { "date":"2019-12-31T23:57:30.000Z", "testedValue":4, "group":"group-0" } + { "date":"2019-12-31T23:57:30.000Z", "testedValue":5, "group":"group-1" } */ // eslint-disable-next-line import/no-default-export @@ -162,7 +162,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider const expected = { results: [ { - group: 'groupA', + group: 'group-0', metrics: [ [START_DATE_MINUS_2INTERVALS, 1], [START_DATE_MINUS_1INTERVALS, 2], @@ -170,7 +170,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider ], }, { - group: 'groupB', + group: 'group-1', metrics: [ [START_DATE_MINUS_2INTERVALS, 1], [START_DATE_MINUS_1INTERVALS, 2], @@ -197,7 +197,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider const expected = { results: [ { - group: 'groupB', + group: 'group-1', metrics: [ [START_DATE_MINUS_2INTERVALS, 5 / 1], [START_DATE_MINUS_1INTERVALS, (5 + 3) / 2], @@ -205,7 +205,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider ], }, { - group: 'groupA', + group: 'group-0', metrics: [ [START_DATE_MINUS_2INTERVALS, 4 / 1], [START_DATE_MINUS_1INTERVALS, (4 + 2) / 2], @@ -230,7 +230,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider }); const result = await runQueryExpect(query, 200); expect(result.results.length).to.be(1); - expect(result.results[0].group).to.be('groupB'); + expect(result.results[0].group).to.be('group-1'); }); it('should return correct sorted group for min', async () => { @@ -245,7 +245,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider }); const result = await runQueryExpect(query, 200); expect(result.results.length).to.be(1); - expect(result.results[0].group).to.be('groupA'); + expect(result.results[0].group).to.be('group-0'); }); it('should return an error when passed invalid input', async () => { From 0163a71d24670eb4a813b27850582bec3aa9534a Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Thu, 19 Mar 2020 17:08:53 -0600 Subject: [PATCH 22/32] [SIEM] [Case] Bulk status update, add comment avatar, id => title in breadcrumbs (#60410) --- .../__snapshots__/index.test.tsx.snap | 82 +++---- .../siem/public/containers/case/api.ts | 24 +- .../siem/public/containers/case/types.ts | 6 + .../containers/case/use_bulk_update_case.tsx | 106 +++++++++ x-pack/legacy/plugins/siem/public/legacy.ts | 13 +- .../plugins/siem/public/lib/kibana/hooks.ts | 64 ++++++ .../components/all_cases/__mock__/index.tsx | 2 +- .../case/components/all_cases/index.test.tsx | 215 ++++++++++++++++-- .../pages/case/components/all_cases/index.tsx | 44 ++-- .../case/components/all_cases/translations.ts | 4 +- .../case/components/bulk_actions/index.tsx | 19 +- .../components/bulk_actions/translations.ts | 2 +- .../case/components/case_view/index.test.tsx | 39 +++- .../pages/case/components/case_view/index.tsx | 4 + .../components/user_action_tree/index.tsx | 9 +- .../user_action_tree/user_action_item.tsx | 14 +- .../plugins/siem/public/pages/case/utils.ts | 2 +- x-pack/legacy/plugins/siem/public/plugin.tsx | 12 +- .../siem/public/utils/route/spy_routes.tsx | 30 +-- 19 files changed, 565 insertions(+), 126 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap index c3ce9a97bbea13..e15ce0ae5f5437 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap @@ -38,18 +38,18 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] = data-test-subj="stat-item" >

      @@ -258,18 +258,18 @@ exports[`Stat Items Component disable charts it renders the default widget 2`] = data-test-subj="stat-item" >

      @@ -548,18 +548,18 @@ exports[`Stat Items Component rendering kpis with charts it renders the default data-test-subj="stat-item" >

      1,714 @@ -734,10 +734,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default key="stat-items-field-uniqueDestinationIps" >

      2,359 @@ -815,10 +815,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default >

      => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { + const response = await KibanaServices.get().http.fetch(CASES_URL, { method: 'POST', body: JSON.stringify(newCase), }); @@ -104,13 +112,21 @@ export const patchCase = async ( updatedCase: Partial, version: string ): Promise => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { + const response = await KibanaServices.get().http.fetch(CASES_URL, { method: 'PATCH', body: JSON.stringify({ cases: [{ ...updatedCase, id: caseId, version }] }), }); return convertToCamelCase(decodeCasesResponse(response)); }; +export const patchCasesStatus = async (cases: BulkUpdateStatus[]): Promise => { + const response = await KibanaServices.get().http.fetch(CASES_URL, { + method: 'PATCH', + body: JSON.stringify({ cases }), + }); + return convertToCamelCase(decodeCasesResponse(response)); +}; + export const postComment = async (newComment: CommentRequest, caseId: string): Promise => { const response = await KibanaServices.get().http.fetch( `${CASES_URL}/${caseId}/comments`, @@ -139,7 +155,7 @@ export const patchComment = async ( }; export const deleteCases = async (caseIds: string[]): Promise => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { + const response = await KibanaServices.get().http.fetch(CASES_URL, { method: 'DELETE', query: { ids: JSON.stringify(caseIds) }, }); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 5b6ff8438be8c9..44519031e91cb7 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -78,3 +78,9 @@ export interface FetchCasesProps { export interface ApiProps { signal: AbortSignal; } + +export interface BulkUpdateStatus { + status: string; + id: string; + version: string; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx new file mode 100644 index 00000000000000..77d779ab906cf7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx @@ -0,0 +1,106 @@ +/* + * 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 { useCallback, useReducer } from 'react'; +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import * as i18n from './translations'; +import { patchCasesStatus } from './api'; +import { BulkUpdateStatus, Case } from './types'; + +interface UpdateState { + isUpdated: boolean; + isLoading: boolean; + isError: boolean; +} +type Action = + | { type: 'FETCH_INIT' } + | { type: 'FETCH_SUCCESS'; payload: boolean } + | { type: 'FETCH_FAILURE' } + | { type: 'RESET_IS_UPDATED' }; + +const dataFetchReducer = (state: UpdateState, action: Action): UpdateState => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + isLoading: true, + isError: false, + }; + case 'FETCH_SUCCESS': + return { + ...state, + isLoading: false, + isError: false, + isUpdated: action.payload, + }; + case 'FETCH_FAILURE': + return { + ...state, + isLoading: false, + isError: true, + }; + case 'RESET_IS_UPDATED': + return { + ...state, + isUpdated: false, + }; + default: + return state; + } +}; +interface UseUpdateCase extends UpdateState { + updateBulkStatus: (cases: Case[], status: string) => void; + dispatchResetIsUpdated: () => void; +} + +export const useUpdateCases = (): UseUpdateCase => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + isUpdated: false, + }); + const [, dispatchToaster] = useStateToaster(); + + const dispatchUpdateCases = useCallback((cases: BulkUpdateStatus[]) => { + let cancel = false; + const patchData = async () => { + try { + dispatch({ type: 'FETCH_INIT' }); + await patchCasesStatus(cases); + if (!cancel) { + dispatch({ type: 'FETCH_SUCCESS', payload: true }); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + dispatch({ type: 'FETCH_FAILURE' }); + } + } + }; + patchData(); + return () => { + cancel = true; + }; + }, []); + + const dispatchResetIsUpdated = useCallback(() => { + dispatch({ type: 'RESET_IS_UPDATED' }); + }, []); + + const updateBulkStatus = useCallback((cases: Case[], status: string) => { + const updateCasesStatus: BulkUpdateStatus[] = cases.map(theCase => ({ + status, + id: theCase.id, + version: theCase.version, + })); + dispatchUpdateCases(updateCasesStatus); + }, []); + return { ...state, updateBulkStatus, dispatchResetIsUpdated }; +}; diff --git a/x-pack/legacy/plugins/siem/public/legacy.ts b/x-pack/legacy/plugins/siem/public/legacy.ts index 157ec54353a3e0..b3a06a170bb807 100644 --- a/x-pack/legacy/plugins/siem/public/legacy.ts +++ b/x-pack/legacy/plugins/siem/public/legacy.ts @@ -5,19 +5,12 @@ */ import { npSetup, npStart } from 'ui/new_platform'; -import { PluginsSetup, PluginsStart } from 'ui/new_platform/new_platform'; import { PluginInitializerContext } from '../../../../../src/core/public'; import { plugin } from './'; -import { - TriggersAndActionsUIPublicPluginSetup, - TriggersAndActionsUIPublicPluginStart, -} from '../../../../plugins/triggers_actions_ui/public'; +import { SetupPlugins, StartPlugins } from './plugin'; const pluginInstance = plugin({} as PluginInitializerContext); -type myPluginsSetup = PluginsSetup & { triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup }; -type myPluginsStart = PluginsStart & { triggers_actions_ui: TriggersAndActionsUIPublicPluginStart }; - -pluginInstance.setup(npSetup.core, npSetup.plugins as myPluginsSetup); -pluginInstance.start(npStart.core, npStart.plugins as myPluginsStart); +pluginInstance.setup(npSetup.core, (npSetup.plugins as unknown) as SetupPlugins); +pluginInstance.start(npStart.core, (npStart.plugins as unknown) as StartPlugins); diff --git a/x-pack/legacy/plugins/siem/public/lib/kibana/hooks.ts b/x-pack/legacy/plugins/siem/public/lib/kibana/hooks.ts index a4a70c77833c05..95ecee7b12bb11 100644 --- a/x-pack/legacy/plugins/siem/public/lib/kibana/hooks.ts +++ b/x-pack/legacy/plugins/siem/public/lib/kibana/hooks.ts @@ -6,8 +6,13 @@ import moment from 'moment-timezone'; +import { useCallback, useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../common/constants'; import { useUiSetting, useKibana } from './kibana_react'; +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import { AuthenticatedUser } from '../../../../../../plugins/security/common/model'; +import { convertToCamelCase } from '../../containers/case/utils'; export const useDateFormat = (): string => useUiSetting(DEFAULT_DATE_FORMAT); @@ -17,3 +22,62 @@ export const useTimeZone = (): string => { }; export const useBasePath = (): string => useKibana().services.http.basePath.get(); + +interface UserRealm { + name: string; + type: string; +} + +export interface AuthenticatedElasticUser { + username: string; + email: string; + fullName: string; + roles: string[]; + enabled: boolean; + metadata?: { + _reserved: boolean; + }; + authenticationRealm: UserRealm; + lookupRealm: UserRealm; + authenticationProvider: string; +} + +export const useCurrentUser = (): AuthenticatedElasticUser | null => { + const [user, setUser] = useState(null); + + const [, dispatchToaster] = useStateToaster(); + + const { security } = useKibana().services; + + const fetchUser = useCallback(() => { + let didCancel = false; + const fetchData = async () => { + try { + const response = await security.authc.getCurrentUser(); + if (!didCancel) { + setUser(convertToCamelCase(response)); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.translate('xpack.siem.getCurrentUser.Error', { + defaultMessage: 'Error getting user', + }), + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setUser(null); + } + } + }; + fetchData(); + return () => { + didCancel = true; + }; + }, [security]); + + useEffect(() => { + fetchUser(); + }, []); + return user; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 5d00b770b3ca9c..48fbb4e74c4072 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -10,7 +10,7 @@ import { UseGetCasesState } from '../../../../../containers/case/use_get_cases'; export const useGetCasesMockState: UseGetCasesState = { data: { countClosedCases: 0, - countOpenCases: 0, + countOpenCases: 5, cases: [ { closedAt: null, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx index 001acc1d4d36ed..13869c79c45fd8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx @@ -10,35 +10,86 @@ import moment from 'moment-timezone'; import { AllCases } from './'; import { TestProviders } from '../../../../mock'; import { useGetCasesMockState } from './__mock__'; -import * as apiHook from '../../../../containers/case/use_get_cases'; -import { act } from '@testing-library/react'; -import { wait } from '../../../../lib/helpers'; +import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; +import { useGetCases } from '../../../../containers/case/use_get_cases'; +import { useGetCasesStatus } from '../../../../containers/case/use_get_cases_status'; +import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; +jest.mock('../../../../containers/case/use_bulk_update_case'); +jest.mock('../../../../containers/case/use_delete_cases'); +jest.mock('../../../../containers/case/use_get_cases'); +jest.mock('../../../../containers/case/use_get_cases_status'); +const useDeleteCasesMock = useDeleteCases as jest.Mock; +const useGetCasesMock = useGetCases as jest.Mock; +const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; +const useUpdateCasesMock = useUpdateCases as jest.Mock; describe('AllCases', () => { + const dispatchResetIsDeleted = jest.fn(); + const dispatchResetIsUpdated = jest.fn(); const dispatchUpdateCaseProperty = jest.fn(); + const handleOnDeleteConfirm = jest.fn(); + const handleToggleModal = jest.fn(); const refetchCases = jest.fn(); const setFilters = jest.fn(); const setQueryParams = jest.fn(); const setSelectedCases = jest.fn(); + const updateBulkStatus = jest.fn(); + const fetchCasesStatus = jest.fn(); + + const defaultGetCases = { + ...useGetCasesMockState, + dispatchUpdateCaseProperty, + refetchCases, + setFilters, + setQueryParams, + setSelectedCases, + }; + const defaultDeleteCases = { + dispatchResetIsDeleted, + handleOnDeleteConfirm, + handleToggleModal, + isDeleted: false, + isDisplayConfirmDeleteModal: false, + isLoading: false, + }; + const defaultCasesStatus = { + countClosedCases: 0, + countOpenCases: 5, + fetchCasesStatus, + isError: false, + isLoading: true, + }; + const defaultUpdateCases = { + isUpdated: false, + isLoading: false, + isError: false, + dispatchResetIsUpdated, + updateBulkStatus, + }; + /* eslint-disable no-console */ + // Silence until enzyme fixed to use ReactTestUtils.act() + const originalError = console.error; + beforeAll(() => { + console.error = jest.fn(); + }); + afterAll(() => { + console.error = originalError; + }); + /* eslint-enable no-console */ beforeEach(() => { jest.resetAllMocks(); - jest.spyOn(apiHook, 'useGetCases').mockReturnValue({ - ...useGetCasesMockState, - dispatchUpdateCaseProperty, - refetchCases, - setFilters, - setQueryParams, - setSelectedCases, - }); + useUpdateCasesMock.mockImplementation(() => defaultUpdateCases); + useGetCasesMock.mockImplementation(() => defaultGetCases); + useDeleteCasesMock.mockImplementation(() => defaultDeleteCases); + useGetCasesStatusMock.mockImplementation(() => defaultCasesStatus); moment.tz.setDefault('UTC'); }); - it('should render AllCases', async () => { + it('should render AllCases', () => { const wrapper = mount( ); - await act(() => wait()); expect( wrapper .find(`a[data-test-subj="case-details-link"]`) @@ -76,13 +127,12 @@ describe('AllCases', () => { .text() ).toEqual('Showing 10 cases'); }); - it('should tableHeaderSortButton AllCases', async () => { + it('should tableHeaderSortButton AllCases', () => { const wrapper = mount( ); - await act(() => wait()); wrapper .find('[data-test-subj="tableHeaderSortButton"]') .first() @@ -94,4 +144,139 @@ describe('AllCases', () => { sortOrder: 'asc', }); }); + it('closes case when row action icon clicked', () => { + const wrapper = mount( + + + + ); + wrapper + .find('[data-test-subj="action-close"]') + .first() + .simulate('click'); + const firstCase = useGetCasesMockState.data.cases[0]; + expect(dispatchUpdateCaseProperty).toBeCalledWith({ + caseId: firstCase.id, + updateKey: 'status', + updateValue: 'closed', + refetchCasesStatus: fetchCasesStatus, + version: firstCase.version, + }); + }); + it('Bulk delete', () => { + useGetCasesMock.mockImplementation(() => ({ + ...defaultGetCases, + selectedCases: useGetCasesMockState.data.cases, + })); + useDeleteCasesMock + .mockReturnValueOnce({ + ...defaultDeleteCases, + isDisplayConfirmDeleteModal: false, + }) + .mockReturnValue({ + ...defaultDeleteCases, + isDisplayConfirmDeleteModal: true, + }); + + const wrapper = mount( + + + + ); + wrapper + .find('[data-test-subj="case-table-bulk-actions"] button') + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="cases-bulk-delete-button"]') + .first() + .simulate('click'); + expect(handleToggleModal).toBeCalled(); + + wrapper + .find( + '[data-test-subj="confirm-delete-case-modal"] [data-test-subj="confirmModalConfirmButton"]' + ) + .last() + .simulate('click'); + expect(handleOnDeleteConfirm.mock.calls[0][0]).toStrictEqual( + useGetCasesMockState.data.cases.map(theCase => theCase.id) + ); + }); + it('Bulk close status update', () => { + useGetCasesMock.mockImplementation(() => ({ + ...defaultGetCases, + selectedCases: useGetCasesMockState.data.cases, + })); + + const wrapper = mount( + + + + ); + wrapper + .find('[data-test-subj="case-table-bulk-actions"] button') + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="cases-bulk-close-button"]') + .first() + .simulate('click'); + expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'closed'); + }); + it('Bulk open status update', () => { + useGetCasesMock.mockImplementation(() => ({ + ...defaultGetCases, + selectedCases: useGetCasesMockState.data.cases, + filterOptions: { + ...defaultGetCases.filterOptions, + status: 'closed', + }, + })); + + const wrapper = mount( + + + + ); + wrapper + .find('[data-test-subj="case-table-bulk-actions"] button') + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="cases-bulk-open-button"]') + .first() + .simulate('click'); + expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'open'); + }); + it('isDeleted is true, refetch', () => { + useDeleteCasesMock.mockImplementation(() => ({ + ...defaultDeleteCases, + isDeleted: true, + })); + + mount( + + + + ); + expect(refetchCases).toBeCalled(); + expect(fetchCasesStatus).toBeCalled(); + expect(dispatchResetIsDeleted).toBeCalled(); + }); + it('isUpdated is true, refetch', () => { + useUpdateCasesMock.mockImplementation(() => ({ + ...defaultUpdateCases, + isUpdated: true, + })); + + mount( + + + + ); + expect(refetchCases).toBeCalled(); + expect(fetchCasesStatus).toBeCalled(); + expect(dispatchResetIsUpdated).toBeCalled(); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index 9a84dd07b0af44..e7e1e624ccba2d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -43,6 +43,7 @@ import { OpenClosedStats } from '../open_closed_stats'; import { getActions } from './actions'; import { CasesTableFilters } from './table_filters'; +import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; const CONFIGURE_CASES_URL = getConfigureCasesUrl(); const CREATE_CASE_URL = getCreateCaseUrl(); @@ -106,13 +107,20 @@ export const AllCases = React.memo(() => { isDisplayConfirmDeleteModal, } = useDeleteCases(); + const { dispatchResetIsUpdated, isUpdated, updateBulkStatus } = useUpdateCases(); + useEffect(() => { if (isDeleted) { refetchCases(filterOptions, queryParams); fetchCasesStatus(); dispatchResetIsDeleted(); } - }, [isDeleted, filterOptions, queryParams]); + if (isUpdated) { + refetchCases(filterOptions, queryParams); + fetchCasesStatus(); + dispatchResetIsUpdated(); + } + }, [isDeleted, isUpdated, filterOptions, queryParams]); const [deleteThisCase, setDeleteThisCase] = useState({ title: '', @@ -135,36 +143,38 @@ export const AllCases = React.memo(() => { [deleteBulk, deleteThisCase, isDisplayConfirmDeleteModal] ); - const toggleDeleteModal = useCallback( - (deleteCase: Case) => { - handleToggleModal(); - setDeleteThisCase(deleteCase); - }, - [isDisplayConfirmDeleteModal] - ); + const toggleDeleteModal = useCallback((deleteCase: Case) => { + handleToggleModal(); + setDeleteThisCase(deleteCase); + }, []); + + const toggleBulkDeleteModal = useCallback((deleteCases: string[]) => { + handleToggleModal(); + setDeleteBulk(deleteCases); + }, []); - const toggleBulkDeleteModal = useCallback( - (deleteCases: string[]) => { - handleToggleModal(); - setDeleteBulk(deleteCases); + const handleUpdateCaseStatus = useCallback( + (status: string) => { + updateBulkStatus(selectedCases, status); }, - [isDisplayConfirmDeleteModal] + [selectedCases] ); const selectedCaseIds = useMemo( - (): string[] => - selectedCases.reduce((arr: string[], caseObj: Case) => [...arr, caseObj.id], []), + (): string[] => selectedCases.map((caseObj: Case) => caseObj.id), [selectedCases] ); const getBulkItemsPopoverContent = useCallback( (closePopover: () => void) => ( ), @@ -322,7 +332,7 @@ export const AllCases = React.memo(() => { void; deleteCasesAction: (cases: string[]) => void; selectedCaseIds: string[]; - caseStatus: string; + updateCaseStatus: (status: string) => void; } export const getBulkItems = ({ - deleteCasesAction, - closePopover, caseStatus, + closePopover, + deleteCasesAction, selectedCaseIds, + updateCaseStatus, }: GetBulkItems) => { return [ caseStatus === 'open' ? ( { + onClick={() => { closePopover(); + updateCaseStatus('closed'); }} > {i18n.BULK_ACTION_CLOSE_SELECTED} ) : ( { closePopover(); + updateCaseStatus('open'); }} > {i18n.BULK_ACTION_OPEN_SELECTED} ), { + onClick={() => { closePopover(); deleteCasesAction(selectedCaseIds); }} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts index 0bf213868bd765..97045c8ebaf8b9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts @@ -16,7 +16,7 @@ export const BULK_ACTION_CLOSE_SELECTED = i18n.translate( export const BULK_ACTION_OPEN_SELECTED = i18n.translate( 'xpack.siem.case.caseTable.bulkActions.openSelectedTitle', { - defaultMessage: 'Open selected', + defaultMessage: 'Reopen selected', } ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index ec18bdb2bf9abe..41100ec6d50f14 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; +import { Router } from 'react-router-dom'; import { mount } from 'enzyme'; import { CaseComponent } from './'; import { caseProps, caseClosedProps, data, dataClosed } from './__mock__'; @@ -12,6 +13,27 @@ import { TestProviders } from '../../../../mock'; import { useUpdateCase } from '../../../../containers/case/use_update_case'; jest.mock('../../../../containers/case/use_update_case'); const useUpdateCaseMock = useUpdateCase as jest.Mock; +type Action = 'PUSH' | 'POP' | 'REPLACE'; +const pop: Action = 'POP'; +const location = { + pathname: '/network', + search: '', + state: '', + hash: '', +}; +const mockHistory = { + length: 2, + location, + action: pop, + push: jest.fn(), + replace: jest.fn(), + go: jest.fn(), + goBack: jest.fn(), + goForward: jest.fn(), + block: jest.fn(), + createHref: jest.fn(), + listen: jest.fn(), +}; describe('CaseView ', () => { const updateCaseProperty = jest.fn(); @@ -42,7 +64,9 @@ describe('CaseView ', () => { it('should render CaseComponent', () => { const wrapper = mount( - + + + ); expect( @@ -83,6 +107,7 @@ describe('CaseView ', () => { .prop('raw') ).toEqual(data.description); }); + it('should show closed indicators in header when case is closed', () => { useUpdateCaseMock.mockImplementation(() => ({ ...defaultUpdateCaseState, @@ -90,7 +115,9 @@ describe('CaseView ', () => { })); const wrapper = mount( - + + + ); expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); @@ -111,7 +138,9 @@ describe('CaseView ', () => { it('should dispatch update state when button is toggled', () => { const wrapper = mount( - + + + ); @@ -128,7 +157,9 @@ describe('CaseView ', () => { it('should render comments', () => { const wrapper = mount( - + + + ); expect( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index dce7bde2225c92..08af603cb0dbfc 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -23,6 +23,7 @@ import { getTypedPayload } from '../../../../containers/case/utils'; import { WhitePageWrapper } from '../wrappers'; import { useBasePath } from '../../../../lib/kibana'; import { CaseStatus } from '../case_status'; +import { SpyRoute } from '../../../../utils/route/spy_routes'; interface Props { caseId: string; @@ -93,6 +94,8 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]); + const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]); + const caseStatusData = useMemo( () => caseData.status === 'open' @@ -179,6 +182,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => + ); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index cebc66a0c83631..04697e63b74513 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -12,6 +12,7 @@ import { useUpdateComment } from '../../../../containers/case/use_update_comment import { UserActionItem } from './user_action_item'; import { UserActionMarkdown } from './user_action_markdown'; import { AddComment } from '../add_comment'; +import { useCurrentUser } from '../../../../lib/kibana'; export interface UserActionTreeProps { data: Case; @@ -20,14 +21,14 @@ export interface UserActionTreeProps { } const DescriptionId = 'description'; -const NewId = 'newComent'; +const NewId = 'newComment'; export const UserActionTree = React.memo( ({ data: caseData, onUpdateField, isLoadingDescription }: UserActionTreeProps) => { const { comments, isLoadingIds, updateComment, addPostedComment } = useUpdateComment( caseData.comments ); - + const currentUser = useCurrentUser(); const [manageMarkdownEditIds, setManangeMardownEditIds] = useState([]); const handleManageMarkdownEditId = useCallback( @@ -112,10 +113,10 @@ export const UserActionTree = React.memo( id={NewId} isEditable={true} isLoading={isLoadingIds.includes(NewId)} - fullName="to be determined" + fullName={currentUser != null ? currentUser.fullName : ''} markdown={MarkdownNewComment} onEdit={handleManageMarkdownEditId.bind(null, NewId)} - userName="to be determined" + userName={currentUser != null ? currentUser.username : ''} /> ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index 0a33301010535e..7b99f2ef76ab3b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; import React from 'react'; import styled, { css } from 'styled-components'; @@ -48,6 +48,12 @@ const UserActionItemContainer = styled(EuiFlexGroup)` margin-right: ${theme.eui.euiSize}; vertical-align: top; } + .userAction_loadingAvatar { + position: relative; + margin-right: ${theme.eui.euiSizeXL}; + top: ${theme.eui.euiSizeM}; + left: ${theme.eui.euiSizeS}; + } .userAction__title { padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; background: ${theme.eui.euiColorLightestShade}; @@ -74,7 +80,11 @@ export const UserActionItem = ({ }: UserActionItemProps) => ( - + {fullName.length > 0 || userName.length > 0 ? ( + + ) : ( + + )} {isEditable && markdown} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts index bd6cb5da5eb01a..ccb3b71a476ec4 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts @@ -28,7 +28,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { breadcrumb = [ ...breadcrumb, { - text: params.detailName, + text: params.state?.caseTitle ?? '', href: getCaseDetailsUrl(params.detailName), }, ]; diff --git a/x-pack/legacy/plugins/siem/public/plugin.tsx b/x-pack/legacy/plugins/siem/public/plugin.tsx index 71fa3a54df7689..da4aad97e5b485 100644 --- a/x-pack/legacy/plugins/siem/public/plugin.tsx +++ b/x-pack/legacy/plugins/siem/public/plugin.tsx @@ -27,21 +27,24 @@ import { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, } from '../../../../plugins/triggers_actions_ui/public'; +import { SecurityPluginSetup } from '../../../../plugins/security/public'; export { AppMountParameters, CoreSetup, CoreStart, PluginInitializerContext }; export interface SetupPlugins { home: HomePublicPluginSetup; - usageCollection: UsageCollectionSetup; + security: SecurityPluginSetup; triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; + usageCollection: UsageCollectionSetup; } export interface StartPlugins { data: DataPublicPluginStart; embeddable: EmbeddableStart; inspector: InspectorStart; newsfeed?: NewsfeedStart; - uiActions: UiActionsStart; + security: SecurityPluginSetup; triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; + uiActions: UiActionsStart; } export type StartServices = CoreStart & StartPlugins; @@ -61,6 +64,8 @@ export class Plugin implements IPlugin { public setup(core: CoreSetup, plugins: SetupPlugins) { initTelemetry(plugins.usageCollection, this.id); + const security = plugins.security; + core.application.register({ id: this.id, title: this.name, @@ -69,8 +74,7 @@ export class Plugin implements IPlugin { const { renderApp } = await import('./app'); plugins.triggers_actions_ui.actionTypeRegistry.register(serviceNowActionType()); - - return renderApp(coreStart, startPlugins as StartPlugins, params); + return renderApp(coreStart, { ...startPlugins, security } as StartPlugins, params); }, }); diff --git a/x-pack/legacy/plugins/siem/public/utils/route/spy_routes.tsx b/x-pack/legacy/plugins/siem/public/utils/route/spy_routes.tsx index ddee2359b28ba4..9030e2713548bd 100644 --- a/x-pack/legacy/plugins/siem/public/utils/route/spy_routes.tsx +++ b/x-pack/legacy/plugins/siem/public/utils/route/spy_routes.tsx @@ -39,12 +39,13 @@ export const SpyRouteComponent = memo( dispatch({ type: 'updateRouteWithOutSearch', route: { - pageName, detailName, - tabName, - pathName: pathname, - history, flowTarget, + history, + pageName, + pathName: pathname, + state, + tabName, }, }); setIsInitializing(false); @@ -52,13 +53,14 @@ export const SpyRouteComponent = memo( dispatch({ type: 'updateRoute', route: { - pageName, detailName, - tabName, - search, - pathName: pathname, - history, flowTarget, + history, + pageName, + pathName: pathname, + search, + state, + tabName, }, }); } @@ -67,14 +69,14 @@ export const SpyRouteComponent = memo( dispatch({ type: 'updateRoute', route: { - pageName, detailName, - tabName, - search, - pathName: pathname, - history, flowTarget, + history, + pageName, + pathName: pathname, + search, state, + tabName, }, }); } From 182acdb6666807094f5b92e2fda9211f353e518a Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Thu, 19 Mar 2020 19:33:36 -0500 Subject: [PATCH 23/32] [SIEM] Fixes Modification of ML Rules (#60662) * Fix updating of ML rules * Add a regression test for updating ML Rules * Allow ML Rules to be patched And adds a regression unit test. * Allow ML rule params to be imported when overwriting * Add a basic regression test for creating a rule with ML params * Prevent users from changing an existing Rule's type --- .../components/select_rule_type/index.tsx | 5 +- .../components/step_define_rule/index.tsx | 8 ++- .../routes/__mocks__/request_responses.ts | 18 ++++++ .../routes/rules/import_rules_route.ts | 2 + .../rules/create_rules.test.ts | 50 +++++++++++++++++ .../rules/patch_rules.test.ts | 51 +++++++++++++++++ .../lib/detection_engine/rules/patch_rules.ts | 6 ++ .../rules/update_rules.test.ts | 56 +++++++++++++++++++ .../detection_engine/rules/update_rules.ts | 6 ++ 9 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx index b3b35699914f6b..229ccde54ecab7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx @@ -14,9 +14,10 @@ import { isMlRule } from '../../helpers'; interface SelectRuleTypeProps { field: FieldHook; + isReadOnly: boolean; } -export const SelectRuleType: React.FC = ({ field }) => { +export const SelectRuleType: React.FC = ({ field, isReadOnly = false }) => { const ruleType = field.value as RuleType; const setType = useCallback( (type: RuleType) => { @@ -37,6 +38,7 @@ export const SelectRuleType: React.FC = ({ field }) => { description={i18n.QUERY_TYPE_DESCRIPTION} icon={} selectable={{ + isDisabled: isReadOnly, onClick: setQuery, isSelected: !isMlRule(ruleType), }} @@ -49,6 +51,7 @@ export const SelectRuleType: React.FC = ({ field }) => { isDisabled={!license} icon={} selectable={{ + isDisabled: isReadOnly, onClick: setMl, isSelected: isMlRule(ruleType), }} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index 6b1a9a828d9501..d3ef185f3786b3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -178,7 +178,13 @@ const StepDefineRuleComponent: FC = ({ <>
      - + <> ({ scheduledTaskId: '2dabe330-0702-11ea-8b50-773b89126888', }); +export const getMlResult = (): RuleAlertType => { + const result = getResult(); + + return { + ...result, + params: { + ...result.params, + query: undefined, + language: undefined, + filters: undefined, + index: undefined, + type: 'machine_learning', + anomalyThreshold: 44, + machineLearningJobId: 'some_job_id', + }, + }; +}; + export const updateActionResult = (): ActionResult => ({ id: 'result-1', actionTypeId: 'action-id-1', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index 920cf97d32a7a2..d95ef595e5c403 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -228,6 +228,8 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config references, note, version, + anomalyThreshold, + machineLearningJobId, }); resolve({ rule_id: ruleId, status_code: 200 }); } else if (rule != null) { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.test.ts new file mode 100644 index 00000000000000..4c8d0f51f251bd --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.test.ts @@ -0,0 +1,50 @@ +/* + * 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 { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; +import { getMlResult } from '../routes/__mocks__/request_responses'; +import { createRules } from './create_rules'; + +describe('createRules', () => { + let actionsClient: ReturnType; + let alertsClient: ReturnType; + + beforeEach(() => { + actionsClient = actionsClientMock.create(); + alertsClient = alertsClientMock.create(); + }); + + it('calls the alertsClient with ML params', async () => { + const params = { + ...getMlResult().params, + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }; + + await createRules({ + alertsClient, + actionsClient, + ...params, + ruleId: 'new-rule-id', + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + params: expect.objectContaining({ + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }), + }), + }) + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts new file mode 100644 index 00000000000000..b424d2912ebc80 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; +import { getMlResult } from '../routes/__mocks__/request_responses'; +import { patchRules } from './patch_rules'; + +describe('patchRules', () => { + let actionsClient: ReturnType; + let alertsClient: ReturnType; + let savedObjectsClient: ReturnType; + + beforeEach(() => { + actionsClient = actionsClientMock.create(); + alertsClient = alertsClientMock.create(); + savedObjectsClient = savedObjectsClientMock.create(); + }); + + it('calls the alertsClient with ML params', async () => { + alertsClient.get.mockResolvedValue(getMlResult()); + const params = { + ...getMlResult().params, + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }; + + await patchRules({ + alertsClient, + actionsClient, + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...params, + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + params: expect.objectContaining({ + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }), + }), + }) + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts index 4fb73235854c0c..a8da01f87a6fb3 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts @@ -46,6 +46,8 @@ export const patchRules = async ({ version, throttle, lists, + anomalyThreshold, + machineLearningJobId, }: PatchRuleParams): Promise => { const rule = await readRules({ alertsClient, ruleId, id }); if (rule == null) { @@ -79,6 +81,8 @@ export const patchRules = async ({ throttle, note, lists, + anomalyThreshold, + machineLearningJobId, }); const nextParams = defaults( @@ -109,6 +113,8 @@ export const patchRules = async ({ note, version: calculatedVersion, lists, + anomalyThreshold, + machineLearningJobId, } ); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts new file mode 100644 index 00000000000000..5ee740a8b88456 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts @@ -0,0 +1,56 @@ +/* + * 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 { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; +import { getMlResult } from '../routes/__mocks__/request_responses'; +import { updateRules } from './update_rules'; + +describe('updateRules', () => { + let actionsClient: ReturnType; + let alertsClient: ReturnType; + let savedObjectsClient: ReturnType; + + beforeEach(() => { + actionsClient = actionsClientMock.create(); + alertsClient = alertsClientMock.create(); + savedObjectsClient = savedObjectsClientMock.create(); + }); + + it('calls the alertsClient with ML params', async () => { + alertsClient.get.mockResolvedValue(getMlResult()); + + const params = { + ...getMlResult().params, + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }; + + await updateRules({ + alertsClient, + actionsClient, + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...params, + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + params: expect.objectContaining({ + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }), + }), + }) + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts index b2a1d2a6307d26..ae8ea9dd32cd24 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts @@ -46,6 +46,8 @@ export const updateRules = async ({ throttle, note, lists, + anomalyThreshold, + machineLearningJobId, }: UpdateRuleParams): Promise => { const rule = await readRules({ alertsClient, ruleId, id }); if (rule == null) { @@ -78,6 +80,8 @@ export const updateRules = async ({ version, throttle, note, + anomalyThreshold, + machineLearningJobId, }); // TODO: Remove this and use regular lists once the feature is stable for a release @@ -115,6 +119,8 @@ export const updateRules = async ({ references, note, version: calculatedVersion, + anomalyThreshold, + machineLearningJobId, ...listsParam, }, }, From c3957d855442c238b3403b76d6487fb7af9359ce Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 19 Mar 2020 17:41:28 -0700 Subject: [PATCH 24/32] [canvas/shareable_runtime] sync sass loaders with kbn/optimizer (#60653) * [canvas/shareable_runtime] sync sass loaders with kbn/optimizer * limit sass options to those relevant in this context Co-authored-by: spalger Co-authored-by: Elastic Machine --- .../shareable_runtime/webpack.config.js | 55 +++++++++++++++++-- x-pack/package.json | 1 + 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/x-pack/legacy/plugins/canvas/shareable_runtime/webpack.config.js b/x-pack/legacy/plugins/canvas/shareable_runtime/webpack.config.js index 0ce722eb90d434..66b0a7bc558cb6 100644 --- a/x-pack/legacy/plugins/canvas/shareable_runtime/webpack.config.js +++ b/x-pack/legacy/plugins/canvas/shareable_runtime/webpack.config.js @@ -6,6 +6,7 @@ const path = require('path'); const webpack = require('webpack'); +const { stringifyRequest } = require('loader-utils'); // eslint-disable-line const { KIBANA_ROOT, @@ -140,19 +141,63 @@ module.exports = { }, { test: /\.scss$/, - exclude: /\.module.(s(a|c)ss)$/, + exclude: [/node_modules/, /\.module\.s(a|c)ss$/], use: [ - { loader: 'style-loader' }, - { loader: 'css-loader', options: { importLoaders: 2 } }, + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + options: { + sourceMap: !isProd, + }, + }, { loader: 'postcss-loader', options: { + sourceMap: !isProd, config: { - path: require.resolve('./postcss.config.js'), + path: require.resolve('./postcss.config'), + }, + }, + }, + { + loader: 'resolve-url-loader', + options: { + // eslint-disable-next-line no-unused-vars + join: (_, __) => (uri, base) => { + if (!base) { + return null; + } + + // manually force ui/* urls in legacy styles to resolve to ui/legacy/public + if (uri.startsWith('ui/') && base.split(path.sep).includes('legacy')) { + return path.resolve(KIBANA_ROOT, 'src/legacy/ui/public', uri.replace('ui/', '')); + } + + return null; + }, + }, + }, + { + loader: 'sass-loader', + options: { + // must always be enabled as long as we're using the `resolve-url-loader` to + // rewrite `ui/*` urls. They're dropped by subsequent loaders though + sourceMap: true, + prependData(loaderContext) { + return `@import ${stringifyRequest( + loaderContext, + path.resolve(KIBANA_ROOT, 'src/legacy/ui/public/styles/_styling_constants.scss') + )};\n`; + }, + webpackImporter: false, + sassOptions: { + outputStyle: 'nested', + includePaths: [path.resolve(KIBANA_ROOT, 'node_modules')], }, }, }, - { loader: 'sass-loader' }, ], }, { diff --git a/x-pack/package.json b/x-pack/package.json index bc00dc21d99089..5d75e0c9edc4ca 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -142,6 +142,7 @@ "jest-cli": "^24.9.0", "jest-styled-components": "^7.0.0", "jsdom": "^15.2.1", + "loader-utils": "^1.2.3", "madge": "3.4.4", "marge": "^1.0.1", "mocha": "^6.2.2", From 19f719ccb5caee6c02160d08e7544154b6e682a6 Mon Sep 17 00:00:00 2001 From: MadameSheema Date: Fri, 20 Mar 2020 03:44:54 +0100 Subject: [PATCH 25/32] [SIEM] Cypress screenshots upload to google cloud (#60556) * testing screenshots upload to google cloud * testing another pattern * fixes artifact pattern * uploads only the .png files * only limit uploads from kibana-siem directory Co-authored-by: spalger --- vars/kibanaPipeline.groovy | 1 + 1 file changed, 1 insertion(+) diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index cb5508642711a0..6252a103d28813 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -98,6 +98,7 @@ def withGcsArtifactUpload(workerName, closure) { def uploadPrefix = "kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/${workerName}" def ARTIFACT_PATTERNS = [ 'target/kibana-*', + 'target/kibana-siem/**/*.png', 'target/junit/**/*', 'test/**/screenshots/**/*.png', 'test/functional/failure_debug/html/*.html', From c638cc2a112561cf70a5afdef2015e47eec4e833 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Fri, 20 Mar 2020 07:17:00 +0100 Subject: [PATCH 26/32] fix test description (#60638) --- .../apps/saved_objects_management/edit_saved_object.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/functional/apps/saved_objects_management/edit_saved_object.ts b/test/functional/apps/saved_objects_management/edit_saved_object.ts index c001613f09f499..6af91ac9c5c949 100644 --- a/test/functional/apps/saved_objects_management/edit_saved_object.ts +++ b/test/functional/apps/saved_objects_management/edit_saved_object.ts @@ -44,7 +44,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await button.click(); }; - describe('TOTO saved objects edition page', () => { + describe('saved objects edition page', () => { beforeEach(async () => { await esArchiver.load('saved_objects_management/edit_saved_object'); }); From ef0935ff458384200522dca46b0d71ddb55245a9 Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Fri, 20 Mar 2020 06:38:02 +0000 Subject: [PATCH 27/32] Add addInfo toast to core notifications service (#60574) * addInfo toast * md files * fis types * Added options to toast methods * Export ToastOptions * Export ToastOptions * added test Co-authored-by: Elastic Machine --- ...na-plugin-core-public.errortoastoptions.md | 4 +- .../kibana-plugin-core-public.itoasts.md | 2 +- .../core/public/kibana-plugin-core-public.md | 3 +- .../kibana-plugin-core-public.toastoptions.md | 20 +++++++++ ...ore-public.toastoptions.toastlifetimems.md | 13 ++++++ ...-plugin-core-public.toastsapi.adddanger.md | 3 +- ...na-plugin-core-public.toastsapi.addinfo.md | 27 +++++++++++ ...plugin-core-public.toastsapi.addsuccess.md | 3 +- ...plugin-core-public.toastsapi.addwarning.md | 3 +- .../kibana-plugin-core-public.toastsapi.md | 7 +-- src/core/public/index.ts | 1 + src/core/public/notifications/index.ts | 1 + src/core/public/notifications/toasts/index.ts | 1 + .../notifications/toasts/toasts_api.test.ts | 15 +++++++ .../notifications/toasts/toasts_api.tsx | 45 ++++++++++++++++--- .../toasts/toasts_service.mock.ts | 1 + src/core/public/public.api.md | 16 ++++--- .../index_management/__mocks__/ui/notify.js | 1 + 18 files changed, 145 insertions(+), 21 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.toastoptions.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.toastoptions.toastlifetimems.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.toastsapi.addinfo.md diff --git a/docs/development/core/public/kibana-plugin-core-public.errortoastoptions.md b/docs/development/core/public/kibana-plugin-core-public.errortoastoptions.md index cda64018c3f690..dc256e6f5bc067 100644 --- a/docs/development/core/public/kibana-plugin-core-public.errortoastoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.errortoastoptions.md @@ -4,12 +4,12 @@ ## ErrorToastOptions interface -Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. +Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) error APIs. Signature: ```typescript -export interface ErrorToastOptions +export interface ErrorToastOptions extends ToastOptions ``` ## Properties diff --git a/docs/development/core/public/kibana-plugin-core-public.itoasts.md b/docs/development/core/public/kibana-plugin-core-public.itoasts.md index 305ed82ea5693d..e009c77fe23bcd 100644 --- a/docs/development/core/public/kibana-plugin-core-public.itoasts.md +++ b/docs/development/core/public/kibana-plugin-core-public.itoasts.md @@ -9,5 +9,5 @@ Methods for adding and removing global toast messages. See [ToastsApi](./kibana- Signature: ```typescript -export declare type IToasts = Pick; +export declare type IToasts = Pick; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index a9fbaa25ea150a..b8aa56eb2941b5 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -57,7 +57,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [CoreStart](./kibana-plugin-core-public.corestart.md) | Core services exposed to the Plugin start lifecycle | | [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | | | [EnvironmentMode](./kibana-plugin-core-public.environmentmode.md) | | -| [ErrorToastOptions](./kibana-plugin-core-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. | +| [ErrorToastOptions](./kibana-plugin-core-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) error APIs. | | [FatalErrorInfo](./kibana-plugin-core-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | | [FatalErrorsSetup](./kibana-plugin-core-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | | [HttpFetchOptions](./kibana-plugin-core-public.httpfetchoptions.md) | All options that may be used with a [HttpHandler](./kibana-plugin-core-public.httphandler.md). | @@ -115,6 +115,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsUpdateOptions](./kibana-plugin-core-public.savedobjectsupdateoptions.md) | | | [StringValidationRegex](./kibana-plugin-core-public.stringvalidationregex.md) | StringValidation with regex object | | [StringValidationRegexString](./kibana-plugin-core-public.stringvalidationregexstring.md) | StringValidation as regex string | +| [ToastOptions](./kibana-plugin-core-public.toastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. | | [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) | UiSettings parameters defined by the plugins. | | [UiSettingsState](./kibana-plugin-core-public.uisettingsstate.md) | | | [UserProvidedValues](./kibana-plugin-core-public.userprovidedvalues.md) | Describes the values explicitly set by user. | diff --git a/docs/development/core/public/kibana-plugin-core-public.toastoptions.md b/docs/development/core/public/kibana-plugin-core-public.toastoptions.md new file mode 100644 index 00000000000000..0d85c482c2288c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.toastoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ToastOptions](./kibana-plugin-core-public.toastoptions.md) + +## ToastOptions interface + +Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. + +Signature: + +```typescript +export interface ToastOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [toastLifeTimeMs](./kibana-plugin-core-public.toastoptions.toastlifetimems.md) | number | How long should the toast remain on screen. | + diff --git a/docs/development/core/public/kibana-plugin-core-public.toastoptions.toastlifetimems.md b/docs/development/core/public/kibana-plugin-core-public.toastoptions.toastlifetimems.md new file mode 100644 index 00000000000000..bb0e2f9afc83b9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.toastoptions.toastlifetimems.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ToastOptions](./kibana-plugin-core-public.toastoptions.md) > [toastLifeTimeMs](./kibana-plugin-core-public.toastoptions.toastlifetimems.md) + +## ToastOptions.toastLifeTimeMs property + +How long should the toast remain on screen. + +Signature: + +```typescript +toastLifeTimeMs?: number; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.toastsapi.adddanger.md b/docs/development/core/public/kibana-plugin-core-public.toastsapi.adddanger.md index e8cc9ff74e0c47..420100a1209ab9 100644 --- a/docs/development/core/public/kibana-plugin-core-public.toastsapi.adddanger.md +++ b/docs/development/core/public/kibana-plugin-core-public.toastsapi.adddanger.md @@ -9,7 +9,7 @@ Adds a new toast pre-configured with the danger color and alert icon. Signature: ```typescript -addDanger(toastOrTitle: ToastInput): Toast; +addDanger(toastOrTitle: ToastInput, options?: ToastOptions): Toast; ``` ## Parameters @@ -17,6 +17,7 @@ addDanger(toastOrTitle: ToastInput): Toast; | Parameter | Type | Description | | --- | --- | --- | | toastOrTitle | ToastInput | a [ToastInput](./kibana-plugin-core-public.toastinput.md) | +| options | ToastOptions | a [ToastOptions](./kibana-plugin-core-public.toastoptions.md) | Returns: diff --git a/docs/development/core/public/kibana-plugin-core-public.toastsapi.addinfo.md b/docs/development/core/public/kibana-plugin-core-public.toastsapi.addinfo.md new file mode 100644 index 00000000000000..76508d26b4ae98 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.toastsapi.addinfo.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ToastsApi](./kibana-plugin-core-public.toastsapi.md) > [addInfo](./kibana-plugin-core-public.toastsapi.addinfo.md) + +## ToastsApi.addInfo() method + +Adds a new toast pre-configured with the info color and info icon. + +Signature: + +```typescript +addInfo(toastOrTitle: ToastInput, options?: ToastOptions): Toast; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| toastOrTitle | ToastInput | a [ToastInput](./kibana-plugin-core-public.toastinput.md) | +| options | ToastOptions | a [ToastOptions](./kibana-plugin-core-public.toastoptions.md) | + +Returns: + +`Toast` + +a [Toast](./kibana-plugin-core-public.toast.md) + diff --git a/docs/development/core/public/kibana-plugin-core-public.toastsapi.addsuccess.md b/docs/development/core/public/kibana-plugin-core-public.toastsapi.addsuccess.md index 160cbd4bf6b29d..c79f48042514ae 100644 --- a/docs/development/core/public/kibana-plugin-core-public.toastsapi.addsuccess.md +++ b/docs/development/core/public/kibana-plugin-core-public.toastsapi.addsuccess.md @@ -9,7 +9,7 @@ Adds a new toast pre-configured with the success color and check icon. Signature: ```typescript -addSuccess(toastOrTitle: ToastInput): Toast; +addSuccess(toastOrTitle: ToastInput, options?: ToastOptions): Toast; ``` ## Parameters @@ -17,6 +17,7 @@ addSuccess(toastOrTitle: ToastInput): Toast; | Parameter | Type | Description | | --- | --- | --- | | toastOrTitle | ToastInput | a [ToastInput](./kibana-plugin-core-public.toastinput.md) | +| options | ToastOptions | a [ToastOptions](./kibana-plugin-core-public.toastoptions.md) | Returns: diff --git a/docs/development/core/public/kibana-plugin-core-public.toastsapi.addwarning.md b/docs/development/core/public/kibana-plugin-core-public.toastsapi.addwarning.md index 17f94cc5b45537..6154af148332da 100644 --- a/docs/development/core/public/kibana-plugin-core-public.toastsapi.addwarning.md +++ b/docs/development/core/public/kibana-plugin-core-public.toastsapi.addwarning.md @@ -9,7 +9,7 @@ Adds a new toast pre-configured with the warning color and help icon. Signature: ```typescript -addWarning(toastOrTitle: ToastInput): Toast; +addWarning(toastOrTitle: ToastInput, options?: ToastOptions): Toast; ``` ## Parameters @@ -17,6 +17,7 @@ addWarning(toastOrTitle: ToastInput): Toast; | Parameter | Type | Description | | --- | --- | --- | | toastOrTitle | ToastInput | a [ToastInput](./kibana-plugin-core-public.toastinput.md) | +| options | ToastOptions | a [ToastOptions](./kibana-plugin-core-public.toastoptions.md) | Returns: diff --git a/docs/development/core/public/kibana-plugin-core-public.toastsapi.md b/docs/development/core/public/kibana-plugin-core-public.toastsapi.md index 4aa240fba0061c..ca4c08989128aa 100644 --- a/docs/development/core/public/kibana-plugin-core-public.toastsapi.md +++ b/docs/development/core/public/kibana-plugin-core-public.toastsapi.md @@ -23,10 +23,11 @@ export declare class ToastsApi implements IToasts | Method | Modifiers | Description | | --- | --- | --- | | [add(toastOrTitle)](./kibana-plugin-core-public.toastsapi.add.md) | | Adds a new toast to current array of toast. | -| [addDanger(toastOrTitle)](./kibana-plugin-core-public.toastsapi.adddanger.md) | | Adds a new toast pre-configured with the danger color and alert icon. | +| [addDanger(toastOrTitle, options)](./kibana-plugin-core-public.toastsapi.adddanger.md) | | Adds a new toast pre-configured with the danger color and alert icon. | | [addError(error, options)](./kibana-plugin-core-public.toastsapi.adderror.md) | | Adds a new toast that displays an exception message with a button to open the full stacktrace in a modal. | -| [addSuccess(toastOrTitle)](./kibana-plugin-core-public.toastsapi.addsuccess.md) | | Adds a new toast pre-configured with the success color and check icon. | -| [addWarning(toastOrTitle)](./kibana-plugin-core-public.toastsapi.addwarning.md) | | Adds a new toast pre-configured with the warning color and help icon. | +| [addInfo(toastOrTitle, options)](./kibana-plugin-core-public.toastsapi.addinfo.md) | | Adds a new toast pre-configured with the info color and info icon. | +| [addSuccess(toastOrTitle, options)](./kibana-plugin-core-public.toastsapi.addsuccess.md) | | Adds a new toast pre-configured with the success color and check icon. | +| [addWarning(toastOrTitle, options)](./kibana-plugin-core-public.toastsapi.addwarning.md) | | Adds a new toast pre-configured with the warning color and help icon. | | [get$()](./kibana-plugin-core-public.toastsapi.get_.md) | | Observable of the toast messages to show to the user. | | [remove(toastOrId)](./kibana-plugin-core-public.toastsapi.remove.md) | | Removes a toast from the current array of toasts if present. | diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 0ff044878afa9a..b91afa3ae7dc0c 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -168,6 +168,7 @@ export { ToastInputFields, ToastsSetup, ToastsStart, + ToastOptions, ErrorToastOptions, } from './notifications'; diff --git a/src/core/public/notifications/index.ts b/src/core/public/notifications/index.ts index 55b64ac375f087..1a5c2cee7ced60 100644 --- a/src/core/public/notifications/index.ts +++ b/src/core/public/notifications/index.ts @@ -19,6 +19,7 @@ export { ErrorToastOptions, + ToastOptions, Toast, ToastInput, IToasts, diff --git a/src/core/public/notifications/toasts/index.ts b/src/core/public/notifications/toasts/index.ts index 6e9de116833646..b259258b8a335b 100644 --- a/src/core/public/notifications/toasts/index.ts +++ b/src/core/public/notifications/toasts/index.ts @@ -20,6 +20,7 @@ export { ToastsService, ToastsSetup, ToastsStart } from './toasts_service'; export { ErrorToastOptions, + ToastOptions, ToastsApi, ToastInput, IToasts, diff --git a/src/core/public/notifications/toasts/toasts_api.test.ts b/src/core/public/notifications/toasts/toasts_api.test.ts index a0e419e9896578..7c0ef5576256a2 100644 --- a/src/core/public/notifications/toasts/toasts_api.test.ts +++ b/src/core/public/notifications/toasts/toasts_api.test.ts @@ -146,6 +146,21 @@ describe('#remove()', () => { }); }); +describe('#addInfo()', () => { + it('adds a info toast', async () => { + const toasts = new ToastsApi(toastDeps()); + expect(toasts.addInfo({})).toHaveProperty('color', 'primary'); + }); + + it('returns the created toast', async () => { + const toasts = new ToastsApi(toastDeps()); + const toast = toasts.addInfo({}, { toastLifeTimeMs: 1 }); + const currentToasts = await getCurrentToasts(toasts); + expect(currentToasts[0].toastLifeTimeMs).toBe(1); + expect(currentToasts[0]).toBe(toast); + }); +}); + describe('#addSuccess()', () => { it('adds a success toast', async () => { const toasts = new ToastsApi(toastDeps()); diff --git a/src/core/public/notifications/toasts/toasts_api.tsx b/src/core/public/notifications/toasts/toasts_api.tsx index 8b1850ff9508f9..53717b9c2e1748 100644 --- a/src/core/public/notifications/toasts/toasts_api.tsx +++ b/src/core/public/notifications/toasts/toasts_api.tsx @@ -55,7 +55,18 @@ export type ToastInput = string | ToastInputFields; * Options available for {@link IToasts} APIs. * @public */ -export interface ErrorToastOptions { +export interface ToastOptions { + /** + * How long should the toast remain on screen. + */ + toastLifeTimeMs?: number; +} + +/** + * Options available for {@link IToasts} error APIs. + * @public + */ +export interface ErrorToastOptions extends ToastOptions { /** * The title of the toast and the dialog when expanding the message. */ @@ -84,7 +95,7 @@ const normalizeToast = (toastOrTitle: ToastInput): ToastInputFields => { */ export type IToasts = Pick< ToastsApi, - 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' | 'addInfo' >; /** @@ -145,17 +156,35 @@ export class ToastsApi implements IToasts { } } + /** + * Adds a new toast pre-configured with the info color and info icon. + * + * @param toastOrTitle - a {@link ToastInput} + * @param options - a {@link ToastOptions} + * @returns a {@link Toast} + */ + public addInfo(toastOrTitle: ToastInput, options?: ToastOptions) { + return this.add({ + color: 'primary', + iconType: 'iInCircle', + ...normalizeToast(toastOrTitle), + ...options, + }); + } + /** * Adds a new toast pre-configured with the success color and check icon. * * @param toastOrTitle - a {@link ToastInput} + * @param options - a {@link ToastOptions} * @returns a {@link Toast} */ - public addSuccess(toastOrTitle: ToastInput) { + public addSuccess(toastOrTitle: ToastInput, options?: ToastOptions) { return this.add({ color: 'success', iconType: 'check', ...normalizeToast(toastOrTitle), + ...options, }); } @@ -163,14 +192,16 @@ export class ToastsApi implements IToasts { * Adds a new toast pre-configured with the warning color and help icon. * * @param toastOrTitle - a {@link ToastInput} + * @param options - a {@link ToastOptions} * @returns a {@link Toast} */ - public addWarning(toastOrTitle: ToastInput) { + public addWarning(toastOrTitle: ToastInput, options?: ToastOptions) { return this.add({ color: 'warning', iconType: 'help', toastLifeTimeMs: this.uiSettings.get('notifications:lifetime:warning'), ...normalizeToast(toastOrTitle), + ...options, }); } @@ -178,14 +209,16 @@ export class ToastsApi implements IToasts { * Adds a new toast pre-configured with the danger color and alert icon. * * @param toastOrTitle - a {@link ToastInput} + * @param options - a {@link ToastOptions} * @returns a {@link Toast} */ - public addDanger(toastOrTitle: ToastInput) { + public addDanger(toastOrTitle: ToastInput, options?: ToastOptions) { return this.add({ color: 'danger', iconType: 'alert', toastLifeTimeMs: this.uiSettings.get('notifications:lifetime:warning'), ...normalizeToast(toastOrTitle), + ...options, }); } @@ -201,7 +234,6 @@ export class ToastsApi implements IToasts { return this.add({ color: 'danger', iconType: 'alert', - title: options.title, toastLifeTimeMs: this.uiSettings.get('notifications:lifetime:error'), text: mountReactNode( this.i18n!.Context} /> ), + ...options, }); } diff --git a/src/core/public/notifications/toasts/toasts_service.mock.ts b/src/core/public/notifications/toasts/toasts_service.mock.ts index f44bd3253048db..2eb9cea7eb5c3d 100644 --- a/src/core/public/notifications/toasts/toasts_service.mock.ts +++ b/src/core/public/notifications/toasts/toasts_service.mock.ts @@ -25,6 +25,7 @@ const createToastsApiMock = () => { get$: jest.fn(() => new Observable()), add: jest.fn(), remove: jest.fn(), + addInfo: jest.fn(), addSuccess: jest.fn(), addWarning: jest.fn(), addDanger: jest.fn(), diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 7428280b2dccbb..37212a07ee6315 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -561,7 +561,7 @@ export interface EnvironmentMode { } // @public -export interface ErrorToastOptions { +export interface ErrorToastOptions extends ToastOptions { title: string; toastMessage?: string; } @@ -778,7 +778,7 @@ export interface ImageValidation { } // @public -export type IToasts = Pick; +export type IToasts = Pick; // @public export interface IUiSettingsClient { @@ -1270,16 +1270,22 @@ export type ToastInputFields = Pick; remove(toastOrId: Toast | string): void; // @internal (undocumented) diff --git a/x-pack/plugins/index_management/__mocks__/ui/notify.js b/x-pack/plugins/index_management/__mocks__/ui/notify.js index d508c3383d5f91..3d64a99232bc3f 100644 --- a/x-pack/plugins/index_management/__mocks__/ui/notify.js +++ b/x-pack/plugins/index_management/__mocks__/ui/notify.js @@ -5,6 +5,7 @@ */ export const toastNotifications = { + addInfo: () => {}, addSuccess: () => {}, addDanger: () => {}, addWarning: () => {}, From b8415269792c5c9cd86caf49ed767ab0e2530847 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Fri, 20 Mar 2020 10:14:44 +0100 Subject: [PATCH 28/32] Fix ace a11y listener (#60639) Also move the hook use_ui_ace_keyboard_mode.tsx into es_ui_shared This was defined (and used) in both Console and SearchProfiler. Co-authored-by: Elastic Machine --- .../editor/legacy/console_editor/editor.tsx | 2 +- src/plugins/es_ui_shared/public/index.ts | 2 + .../public}/use_ui_ace_keyboard_mode.tsx | 2 +- .../public/application/editor/editor.tsx | 2 +- .../editor/use_ui_ace_keyboard_mode.tsx | 110 ------------------ 5 files changed, 5 insertions(+), 113 deletions(-) rename src/plugins/{console/public/application/containers/editor/legacy => es_ui_shared/public}/use_ui_ace_keyboard_mode.tsx (99%) delete mode 100644 x-pack/plugins/searchprofiler/public/application/editor/use_ui_ace_keyboard_mode.tsx diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx index 170024c192e7f1..cf62de82bcf4b3 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx @@ -22,6 +22,7 @@ import { i18n } from '@kbn/i18n'; import { debounce } from 'lodash'; import { parse } from 'query-string'; import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'; +import { useUIAceKeyboardMode } from '../../../../../../../es_ui_shared/public'; // @ts-ignore import mappings from '../../../../../lib/mappings/mappings'; import { ConsoleMenu } from '../../../../components'; @@ -34,7 +35,6 @@ import { import * as senseEditor from '../../../../models/sense_editor'; import { autoIndent, getDocumentation } from '../console_menu_actions'; import { subscribeResizeChecker } from '../subscribe_console_resize_checker'; -import { useUIAceKeyboardMode } from '../use_ui_ace_keyboard_mode'; import { applyCurrentSettings } from './apply_editor_settings'; import { registerCommands } from './keyboard_shortcuts'; diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index 8ed01b9b61c7e2..5935c7cc38d035 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -29,3 +29,5 @@ export { } from './request/np_ready_request'; export { indices } from './indices'; + +export { useUIAceKeyboardMode } from './use_ui_ace_keyboard_mode'; diff --git a/src/plugins/console/public/application/containers/editor/legacy/use_ui_ace_keyboard_mode.tsx b/src/plugins/es_ui_shared/public/use_ui_ace_keyboard_mode.tsx similarity index 99% rename from src/plugins/console/public/application/containers/editor/legacy/use_ui_ace_keyboard_mode.tsx rename to src/plugins/es_ui_shared/public/use_ui_ace_keyboard_mode.tsx index ca74b19b76f161..a93906d50b64af 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/use_ui_ace_keyboard_mode.tsx +++ b/src/plugins/es_ui_shared/public/use_ui_ace_keyboard_mode.tsx @@ -96,7 +96,7 @@ export function useUIAceKeyboardMode(aceTextAreaElement: HTMLTextAreaElement | n } return () => { if (aceTextAreaElement) { - document.removeEventListener('keydown', documentKeyDownListener); + document.removeEventListener('keydown', documentKeyDownListener, { capture: true }); aceTextAreaElement.removeEventListener('keydown', aceKeydownListener); const textAreaContainer = aceTextAreaElement.parentElement; if (textAreaContainer && textAreaContainer.contains(overlayMountNode.current!)) { diff --git a/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx b/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx index 5f8ab776a7672c..ece22905c64d92 100644 --- a/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx +++ b/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx @@ -8,7 +8,7 @@ import React, { memo, useRef, useEffect, useState } from 'react'; import { Editor as AceEditor } from 'brace'; import { initializeEditor } from './init_editor'; -import { useUIAceKeyboardMode } from './use_ui_ace_keyboard_mode'; +import { useUIAceKeyboardMode } from '../../../../../../src/plugins/es_ui_shared/public'; type EditorShim = ReturnType; diff --git a/x-pack/plugins/searchprofiler/public/application/editor/use_ui_ace_keyboard_mode.tsx b/x-pack/plugins/searchprofiler/public/application/editor/use_ui_ace_keyboard_mode.tsx deleted file mode 100644 index edf31c2e7c07f8..00000000000000 --- a/x-pack/plugins/searchprofiler/public/application/editor/use_ui_ace_keyboard_mode.tsx +++ /dev/null @@ -1,110 +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. - */ - -/** - * Copied from Console plugin - */ - -import React, { useEffect, useRef } from 'react'; -import * as ReactDOM from 'react-dom'; -import { keyCodes, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -const OverlayText = () => ( - // The point of this element is for accessibility purposes, so ignore eslint error - // in this case - // - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions - <> - - {i18n.translate('xpack.searchProfiler.aceAccessibilityOverlayInstructionEnter', { - defaultMessage: 'Press Enter to start editing.', - })} - - - {i18n.translate('xpack.searchProfiler.aceAccessibilityOverlayInstructionExit', { - defaultMessage: `When you are done, press Escape to stop editing.`, - })} - - -); - -export function useUIAceKeyboardMode(aceTextAreaElement: HTMLTextAreaElement | null) { - const overlayMountNode = useRef(null); - const autoCompleteVisibleRef = useRef(false); - - useEffect(() => { - function onDismissOverlay(event: KeyboardEvent) { - if (event.keyCode === keyCodes.ENTER) { - event.preventDefault(); - aceTextAreaElement!.focus(); - } - } - - function enableOverlay() { - if (overlayMountNode.current) { - overlayMountNode.current.focus(); - } - } - - const isAutoCompleteVisible = () => { - const autoCompleter = document.querySelector('.ace_autocomplete'); - if (!autoCompleter) { - return false; - } - // The autoComplete is just hidden when it's closed, not removed from the DOM. - return autoCompleter.style.display !== 'none'; - }; - - const documentKeyDownListener = () => { - autoCompleteVisibleRef.current = isAutoCompleteVisible(); - }; - - const aceKeydownListener = (event: KeyboardEvent) => { - if (event.keyCode === keyCodes.ESCAPE && !autoCompleteVisibleRef.current) { - event.preventDefault(); - event.stopPropagation(); - enableOverlay(); - } - }; - - if (aceTextAreaElement) { - // We don't control HTML elements inside of ace so we imperatively create an element - // that acts as a container and insert it just before ace's textarea element - // so that the overlay lives at the correct spot in the DOM hierarchy. - overlayMountNode.current = document.createElement('div'); - overlayMountNode.current.className = 'kbnUiAceKeyboardHint'; - overlayMountNode.current.setAttribute('role', 'application'); - overlayMountNode.current.tabIndex = 0; - overlayMountNode.current.addEventListener('focus', enableOverlay); - overlayMountNode.current.addEventListener('keydown', onDismissOverlay); - - ReactDOM.render(, overlayMountNode.current); - - aceTextAreaElement.parentElement!.insertBefore(overlayMountNode.current, aceTextAreaElement); - aceTextAreaElement.setAttribute('tabindex', '-1'); - - // Order of events: - // 1. Document capture event fires first and we check whether an autocomplete menu is open on keydown - // (not ideal because this is scoped to the entire document). - // 2. Ace changes it's state (like hiding or showing autocomplete menu) - // 3. We check what button was pressed and whether autocomplete was visible then determine - // whether it should act like a dismiss or if we should display an overlay. - document.addEventListener('keydown', documentKeyDownListener, { capture: true }); - aceTextAreaElement.addEventListener('keydown', aceKeydownListener); - } - return () => { - if (aceTextAreaElement) { - document.removeEventListener('keydown', documentKeyDownListener); - aceTextAreaElement.removeEventListener('keydown', aceKeydownListener); - const textAreaContainer = aceTextAreaElement.parentElement; - if (textAreaContainer && textAreaContainer.contains(overlayMountNode.current!)) { - textAreaContainer.removeChild(overlayMountNode.current!); - } - } - }; - }, [aceTextAreaElement]); -} From 8f1e22f07852123efc9c8b2946578b2745ef5d47 Mon Sep 17 00:00:00 2001 From: patrykkopycinski Date: Fri, 20 Mar 2020 10:54:51 +0100 Subject: [PATCH 29/32] [SIEM] Add support for actions and throttle in Rules (#59641) --- .../routes/__mocks__/request_responses.ts | 1 + .../routes/__mocks__/utils.ts | 1 + .../routes/rules/create_rules_bulk_route.ts | 4 + .../routes/rules/create_rules_route.ts | 4 + .../routes/rules/import_rules_route.ts | 6 + .../routes/rules/patch_rules_bulk_route.ts | 4 + .../routes/rules/patch_rules_route.ts | 4 + .../routes/rules/update_rules_bulk_route.ts | 4 + .../routes/rules/update_rules_route.ts | 4 + .../detection_engine/routes/rules/utils.ts | 3 + .../routes/rules/validate.test.ts | 1 + .../add_prepackaged_rules_schema.test.ts | 197 +++++++++++++++++- .../schemas/add_prepackaged_rules_schema.ts | 4 + .../schemas/create_rules_bulk_schema.test.ts | 40 ++++ .../schemas/create_rules_schema.test.ts | 181 +++++++++++++++- .../routes/schemas/create_rules_schema.ts | 4 + .../schemas/import_rules_schema.test.ts | 181 +++++++++++++++- .../routes/schemas/import_rules_schema.ts | 4 + .../routes/schemas/patch_rules_schema.test.ts | 145 ++++++++++++- .../routes/schemas/patch_rules_schema.ts | 4 + .../routes/schemas/response/rules_schema.ts | 4 + .../routes/schemas/response/schemas.ts | 20 ++ .../routes/schemas/schemas.ts | 12 ++ .../schemas/update_rules_schema.test.ts | 181 +++++++++++++++- .../routes/schemas/update_rules_schema.ts | 4 + .../detection_engine/rules/create_rules.ts | 7 +- .../create_rules_stream_from_ndjson.test.ts | 20 ++ .../rules/get_export_all.test.ts | 1 + .../rules/get_export_by_object_ids.test.ts | 2 + .../rules/install_prepacked_rules.ts | 4 + .../lib/detection_engine/rules/patch_rules.ts | 11 +- .../rules/transform_actions.test.ts | 41 ++++ .../rules/transform_actions.ts | 32 +++ .../rules/update_prepacked_rules.ts | 6 +- .../detection_engine/rules/update_rules.ts | 11 +- .../signals/build_bulk_body.test.ts | 12 ++ .../signals/build_bulk_body.ts | 8 +- .../signals/build_rule.test.ts | 87 +++++++- .../detection_engine/signals/build_rule.ts | 8 +- .../signals/bulk_create_ml_signals.ts | 4 +- .../signals/search_after_bulk_create.test.ts | 16 ++ .../signals/search_after_bulk_create.ts | 10 +- .../signals/signal_rule_alert_type.ts | 6 + .../signals/single_bulk_create.test.ts | 10 + .../signals/single_bulk_create.ts | 8 +- .../lib/detection_engine/signals/types.ts | 4 +- .../siem/server/lib/detection_engine/types.ts | 13 +- .../security_and_spaces/tests/utils.ts | 3 + 48 files changed, 1314 insertions(+), 27 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index cf8e2b32869d86..0e0ab58a7a1999 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -50,6 +50,7 @@ export const mockPrepackagedRule = (): PrepackagedRules => ({ technique: [{ id: 'techniqueId', name: 'techniqueName', reference: 'techniqueRef' }], }, ], + throttle: null, enabled: true, filters: [], immutable: false, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts index aa9b05eb379a63..13d75cc44992c5 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts @@ -82,6 +82,7 @@ export const getOutputRuleAlertForRest = (): Omit< OutputRuleAlertRest, 'machine_learning_job_id' | 'anomaly_threshold' > => ({ + actions: [], created_by: 'elastic', created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index e8b1162b06182a..4ffa29c385f28d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -56,6 +56,7 @@ export const createRulesBulkRoute = (router: IRouter) => { .filter(rule => rule.rule_id == null || !dupes.includes(rule.rule_id)) .map(async payloadRule => { const { + actions, anomaly_threshold: anomalyThreshold, description, enabled, @@ -77,6 +78,7 @@ export const createRulesBulkRoute = (router: IRouter) => { severity, tags, threat, + throttle, to, type, references, @@ -110,6 +112,7 @@ export const createRulesBulkRoute = (router: IRouter) => { const createdRule = await createRules({ alertsClient, actionsClient, + actions, anomalyThreshold, description, enabled, @@ -133,6 +136,7 @@ export const createRulesBulkRoute = (router: IRouter) => { name, severity, tags, + throttle, to, type, threat, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts index 3a440178344da6..cee9054cf922e5 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -31,6 +31,7 @@ export const createRulesRoute = (router: IRouter): void => { }, async (context, request, response) => { const { + actions, anomaly_threshold: anomalyThreshold, description, enabled, @@ -54,6 +55,7 @@ export const createRulesRoute = (router: IRouter): void => { severity, tags, threat, + throttle, to, type, references, @@ -96,6 +98,7 @@ export const createRulesRoute = (router: IRouter): void => { const createdRule = await createRules({ alertsClient, actionsClient, + actions, anomalyThreshold, description, enabled, @@ -119,6 +122,7 @@ export const createRulesRoute = (router: IRouter): void => { name, severity, tags, + throttle, to, type, threat, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index d95ef595e5c403..72a6e70cbb14a4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -111,6 +111,7 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config return null; } const { + actions, anomaly_threshold: anomalyThreshold, description, enabled, @@ -133,6 +134,7 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config severity, tags, threat, + throttle, to, type, references, @@ -163,6 +165,7 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config await createRules({ alertsClient, actionsClient, + actions, anomalyThreshold, description, enabled, @@ -189,6 +192,7 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config to, type, threat, + throttle, references, note, version, @@ -199,6 +203,7 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config await patchRules({ alertsClient, actionsClient, + actions, savedObjectsClient, description, enabled, @@ -225,6 +230,7 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config to, type, threat, + throttle, references, note, version, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index e64bbe625f5f69..698f58438a5e6b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -46,6 +46,7 @@ export const patchRulesBulkRoute = (router: IRouter) => { const rules = await Promise.all( request.body.map(async payloadRule => { const { + actions, description, enabled, false_positives: falsePositives, @@ -70,6 +71,7 @@ export const patchRulesBulkRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, @@ -79,6 +81,7 @@ export const patchRulesBulkRoute = (router: IRouter) => { const rule = await patchRules({ alertsClient, actionsClient, + actions, description, enabled, falsePositives, @@ -104,6 +107,7 @@ export const patchRulesBulkRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts index 2d810d33c6e51f..4493bb380d03dd 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -30,6 +30,7 @@ export const patchRulesRoute = (router: IRouter) => { }, async (context, request, response) => { const { + actions, description, enabled, false_positives: falsePositives, @@ -54,6 +55,7 @@ export const patchRulesRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, @@ -76,6 +78,7 @@ export const patchRulesRoute = (router: IRouter) => { const rule = await patchRules({ actionsClient, alertsClient, + actions, description, enabled, falsePositives, @@ -101,6 +104,7 @@ export const patchRulesRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index deb319492258cd..6c3c8dffa3dfad 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -47,6 +47,7 @@ export const updateRulesBulkRoute = (router: IRouter) => { const rules = await Promise.all( request.body.map(async payloadRule => { const { + actions, anomaly_threshold: anomalyThreshold, description, enabled, @@ -73,6 +74,7 @@ export const updateRulesBulkRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, @@ -84,6 +86,7 @@ export const updateRulesBulkRoute = (router: IRouter) => { const rule = await updateRules({ alertsClient, actionsClient, + actions, anomalyThreshold, description, enabled, @@ -112,6 +115,7 @@ export const updateRulesBulkRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts index c47a412c2e9df1..7e56c32ade92a8 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -30,6 +30,7 @@ export const updateRulesRoute = (router: IRouter) => { }, async (context, request, response) => { const { + actions, anomaly_threshold: anomalyThreshold, description, enabled, @@ -56,6 +57,7 @@ export const updateRulesRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, @@ -80,6 +82,7 @@ export const updateRulesRoute = (router: IRouter) => { const rule = await updateRules({ alertsClient, actionsClient, + actions, anomalyThreshold, description, enabled, @@ -108,6 +111,7 @@ export const updateRulesRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index fe7618bca0c75b..3d831026256fce 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -29,6 +29,7 @@ import { OutputError, } from '../utils'; import { hasListsFeature } from '../../feature_flags'; +import { transformAlertToRuleAction } from '../../rules/transform_actions'; type PromiseFromStreams = ImportRuleAlertRest | Error; @@ -102,6 +103,7 @@ export const transformAlertToRule = ( ruleStatus?: SavedObject ): Partial => { return pickBy((value: unknown) => value != null, { + actions: alert.actions.map(transformAlertToRuleAction), created_at: alert.createdAt.toISOString(), updated_at: alert.updatedAt.toISOString(), created_by: alert.createdBy, @@ -134,6 +136,7 @@ export const transformAlertToRule = ( to: alert.params.to, type: alert.params.type, threat: alert.params.threat, + throttle: alert.throttle, note: alert.params.note, version: alert.params.version, status: ruleStatus?.attributes.status, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/validate.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/validate.test.ts index 1dce602f3fcac8..3727908ac62de7 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/validate.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/validate.test.ts @@ -19,6 +19,7 @@ import { BulkError } from '../utils'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; export const ruleOutput: RulesSchema = { + actions: [], created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', created_by: 'elastic', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts index 171a34f0d05922..2b18e1b9bf52c7 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ThreatParams, PrepackagedRules } from '../../types'; +import { AlertAction } from '../../../../../../../../plugins/alerting/common'; +import { ThreatParams, PrepackagedRules, RuleAlertAction } from '../../types'; import { addPrepackagedRulesSchema } from './add_prepackaged_rules_schema'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; @@ -1284,6 +1285,200 @@ describe('add prepackaged rules schema', () => { ); }); + test('The default for "actions" will be an empty array', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + index: ['auditbeat-*'], + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.actions + ).toEqual([]); + }); + + test('You cannot send in an array of actions that are missing "group"', () => { + expect( + addPrepackagedRulesSchema.validate< + Partial> & { + actions: Array>; + } + >({ + actions: [ + { + id: 'id', + action_type_id: 'actionTypeId', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "group" fails because ["group" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "id"', () => { + expect( + addPrepackagedRulesSchema.validate< + Partial> & { + actions: Array>; + } + >({ + actions: [ + { + group: 'group', + action_type_id: 'action_type_id', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "id" fails because ["id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "action_type_id"', () => { + expect( + addPrepackagedRulesSchema.validate< + Partial> & { + actions: Array>; + } + >({ + actions: [ + { + group: 'group', + id: 'id', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "params"', () => { + expect( + addPrepackagedRulesSchema.validate< + Partial> & { + actions: Array>; + } + >({ + actions: [ + { + group: 'group', + id: 'id', + action_type_id: 'action_type_id', + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "params" fails because ["params" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are including "actionTypeId', () => { + expect( + addPrepackagedRulesSchema.validate< + Partial> & { + actions: AlertAction[]; + } + >({ + actions: [ + { + group: 'group', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('The default for "throttle" will be null', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + index: ['auditbeat-*'], + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.throttle + ).toEqual(null); + }); + describe('note', () => { test('You can set note to any string you want', () => { expect( diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts index 4c60a66141250a..da9f9777a01a61 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts @@ -8,6 +8,7 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ import { + actions, enabled, description, false_positives, @@ -31,6 +32,7 @@ import { to, type, threat, + throttle, references, note, version, @@ -53,6 +55,7 @@ import { hasListsFeature } from '../../feature_flags'; * - index is a required field that must exist */ export const addPrepackagedRulesSchema = Joi.object({ + actions: actions.default([]), anomaly_threshold: anomaly_threshold.when('type', { is: 'machine_learning', then: Joi.required(), @@ -101,6 +104,7 @@ export const addPrepackagedRulesSchema = Joi.object({ to: to.default('now'), type: type.required(), threat: threat.default([]), + throttle: throttle.default(null), references: references.default([]), note: note.allow(''), version: version.required(), diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_bulk_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_bulk_schema.test.ts index fa007bba6551a1..0bf59759a6db6e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_bulk_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_bulk_schema.test.ts @@ -217,4 +217,44 @@ describe('create_rules_bulk_schema', () => { '"value" at position 0 fails because [child "note" fails because ["note" must be a string]]' ); }); + + test('The default for "actions" will be an empty array', () => { + expect( + createRulesBulkSchema.validate>([ + { + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }, + ]).value[0].actions + ).toEqual([]); + }); + + test('The default for "throttle" will be null', () => { + expect( + createRulesBulkSchema.validate>([ + { + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }, + ]).value[0].throttle + ).toEqual(null); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts index db5097a6f25dbe..d9c30555128159 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { createRulesSchema } from './create_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertParamsRest } from '../../types'; +import { ThreatParams, RuleAlertParamsRest, RuleAlertAction } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create rules schema', () => { @@ -1234,6 +1235,184 @@ describe('create rules schema', () => { ); }); + test('The default for "actions" will be an empty array', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.actions + ).toEqual([]); + }); + + test('You cannot send in an array of actions that are missing "group"', () => { + expect( + createRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ id: 'id', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "group" fails because ["group" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "id"', () => { + expect( + createRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "id" fails because ["id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "action_type_id"', () => { + expect( + createRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "params"', () => { + expect( + createRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', action_type_id: 'action_type_id' }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "params" fails because ["params" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are including "actionTypeId"', () => { + expect( + createRulesSchema.validate< + Partial< + Omit & { + actions: AlertAction[]; + } + > + >({ + actions: [ + { + group: 'group', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('The default for "throttle" will be null', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.throttle + ).toEqual(null); + }); + describe('note', () => { test('You can set note to a string', () => { expect( diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts index 0aa7317dd8cdc5..5213f3faaf4865 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts @@ -8,6 +8,7 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ import { + actions, anomaly_threshold, enabled, description, @@ -32,6 +33,7 @@ import { to, type, threat, + throttle, references, note, version, @@ -44,6 +46,7 @@ import { DEFAULT_MAX_SIGNALS } from '../../../../../common/constants'; import { hasListsFeature } from '../../feature_flags'; export const createRulesSchema = Joi.object({ + actions: actions.default([]), anomaly_threshold: anomaly_threshold.when('type', { is: 'machine_learning', then: Joi.required(), @@ -89,6 +92,7 @@ export const createRulesSchema = Joi.object({ to: to.default('now'), type: type.required(), threat: threat.default([]), + throttle: throttle.default(null), references: references.default([]), note: note.allow(''), version: version.default(1), diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts index bcb24268fc6c7a..ffb49896ef7c76 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { importRulesSchema, importRulesQuerySchema, importRulesPayloadSchema, } from './import_rules_schema'; -import { ThreatParams, ImportRuleAlertRest } from '../../types'; +import { ThreatParams, ImportRuleAlertRest, RuleAlertAction } from '../../types'; import { ImportRulesRequestParams } from '../../rules/types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; @@ -1433,6 +1434,184 @@ describe('import rules schema', () => { ); }); + test('The default for "actions" will be an empty array', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.actions + ).toEqual([]); + }); + + test('You cannot send in an array of actions that are missing "group"', () => { + expect( + importRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ id: 'id', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'junk', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "group" fails because ["group" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "id"', () => { + expect( + importRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "id" fails because ["id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "action_type_id"', () => { + expect( + importRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "params"', () => { + expect( + importRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', action_type_id: 'action_type_id' }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "params" fails because ["params" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are including "actionTypeId', () => { + expect( + importRulesSchema.validate< + Partial< + Omit & { + actions: AlertAction[]; + } + > + >({ + actions: [ + { + group: 'group', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('The default for "throttle" will be null', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.throttle + ).toEqual(null); + }); + describe('note', () => { test('You can set note to a string', () => { expect( diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.ts index 469b59a8e08ad2..56aa45659fda7e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.ts @@ -9,6 +9,7 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ import { id, + actions, created_at, updated_at, created_by, @@ -37,6 +38,7 @@ import { to, type, threat, + throttle, references, note, version, @@ -65,6 +67,7 @@ export const importRulesSchema = Joi.object({ otherwise: Joi.forbidden(), }), id, + actions: actions.default([]), description: description.required(), enabled: enabled.default(true), false_positives: false_positives.default([]), @@ -106,6 +109,7 @@ export const importRulesSchema = Joi.object({ to: to.default('now'), type: type.required(), threat: threat.default([]), + throttle: throttle.default(null), references: references.default([]), note: note.allow(''), version: version.default(1), diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts index 6fc1a0c3caa9c6..42945e0970cbab 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { patchRulesSchema } from './patch_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams } from '../../types'; +import { ThreatParams, RuleAlertAction } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('patch rules schema', () => { @@ -1063,6 +1064,148 @@ describe('patch rules schema', () => { }); }); + test('You cannot send in an array of actions that are missing "group"', () => { + expect( + patchRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ id: 'id', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "group" fails because ["group" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "id"', () => { + expect( + patchRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "id" fails because ["id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "action_type_id"', () => { + expect( + patchRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "params"', () => { + expect( + patchRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', action_type_id: 'action_type_id' }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "params" fails because ["params" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are including "actionTypeId', () => { + expect( + patchRulesSchema.validate< + Partial< + Omit & { + actions: AlertAction[]; + } + > + >({ + actions: [ + { + group: 'group', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + // TODO: (LIST-FEATURE) We can enable this once we change the schema's to not be global per module but rather functions that can create the schema // on demand. Since they are per module, we have a an issue where the ENV variables do not take effect. It is better we change all the // schema's to be function calls to avoid global side effects or just wait until the feature is available. If you want to test this early, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.ts index 8bb155d83cf44f..52aefa29884c3d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.ts @@ -8,6 +8,7 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ import { + actions, enabled, description, false_positives, @@ -31,6 +32,7 @@ import { to, type, threat, + throttle, references, note, id, @@ -43,6 +45,7 @@ import { hasListsFeature } from '../../feature_flags'; /* eslint-enable @typescript-eslint/camelcase */ export const patchRulesSchema = Joi.object({ + actions, anomaly_threshold, description, enabled, @@ -69,6 +72,7 @@ export const patchRulesSchema = Joi.object({ to, type, threat, + throttle, references, note: note.allow(''), version, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_schema.ts index 75de97a55534b4..1574e8f5aa6e1c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_schema.ts @@ -12,6 +12,7 @@ import { Either, fold, right, left } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/pipeable'; import { checkTypeDependents } from './check_type_dependents'; import { + actions, anomaly_threshold, description, enabled, @@ -42,6 +43,7 @@ import { timeline_title, type, threat, + throttle, job_status, status_date, last_success_at, @@ -117,6 +119,8 @@ export const dependentRulesSchema = t.partial({ * Instead use dependentRulesSchema and check_type_dependents for how to do those. */ export const partialRulesSchema = t.partial({ + actions, + throttle, status: job_status, status_date, last_success_at, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/schemas.ts index d90cb7b1f0829f..538c8f754fd6ea 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/schemas.ts @@ -25,6 +25,25 @@ export const file_name = t.string; */ export const filters = t.array(t.unknown); // Filters are not easily type-able yet +/** + * Params is an "object", since it is a type of AlertActionParams which is action templates. + * @see x-pack/plugins/alerting/common/alert.ts + */ +export const action_group = t.string; +export const action_id = t.string; +export const action_action_type_id = t.string; +export const action_params = t.object; +export const action = t.exact( + t.type({ + group: action_group, + id: action_id, + action_type_id: action_action_type_id, + params: action_params, + }) +); + +export const actions = t.array(action); + // TODO: Create a regular expression type or custom date math part type here export const from = t.string; @@ -45,6 +64,7 @@ export const output_index = t.string; export const saved_id = t.string; export const timeline_id = t.string; export const timeline_title = t.string; +export const throttle = t.string; export const anomaly_threshold = PositiveInteger; export const machine_learning_job_id = t.string; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts index 007294293f59bd..16e419f389f09e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts @@ -110,6 +110,18 @@ export const updated_by = Joi.string(); export const version = Joi.number() .integer() .min(1); +export const action_group = Joi.string(); +export const action_id = Joi.string(); +export const action_action_type_id = Joi.string(); +export const action_params = Joi.object(); +export const action = Joi.object({ + group: action_group.required(), + id: action_id.required(), + action_type_id: action_action_type_id.required(), + params: action_params.required(), +}); +export const actions = Joi.array().items(action); +export const throttle = Joi.string().allow(null); export const note = Joi.string(); // NOTE: Experimental list support not being shipped currently and behind a feature flag diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts index a0689966a86948..db3709cd6b1265 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { updateRulesSchema } from './update_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertParamsRest } from '../../types'; +import { ThreatParams, RuleAlertParamsRest, RuleAlertAction } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create rules schema', () => { @@ -1253,6 +1254,184 @@ describe('create rules schema', () => { ); }); + test('The default for "actions" will be an empty array', () => { + expect( + updateRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.actions + ).toEqual([]); + }); + + test('You cannot send in an array of actions that are missing "group"', () => { + expect( + updateRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ id: 'id', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "group" fails because ["group" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "id"', () => { + expect( + updateRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "id" fails because ["id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "action_type_id"', () => { + expect( + updateRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "params"', () => { + expect( + updateRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', action_type_id: 'action_type_id' }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "params" fails because ["params" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are including "actionTypeId"', () => { + expect( + updateRulesSchema.validate< + Partial< + Omit & { + actions: AlertAction[]; + } + > + >({ + actions: [ + { + group: 'group', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('The default for "throttle" will be null', () => { + expect( + updateRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.throttle + ).toEqual(null); + }); + describe('note', () => { test('You can set note to a string', () => { expect( diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts index 421172cf0b1a11..f842c14f41ae6b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts @@ -8,6 +8,7 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ import { + actions, enabled, description, false_positives, @@ -31,6 +32,7 @@ import { to, type, threat, + throttle, references, id, note, @@ -52,6 +54,7 @@ import { hasListsFeature } from '../../feature_flags'; * - id is on here because you can pass in an id to update using it instead of rule_id. */ export const updateRulesSchema = Joi.object({ + actions: actions.default([]), anomaly_threshold: anomaly_threshold.when('type', { is: 'machine_learning', then: Joi.required(), @@ -98,6 +101,7 @@ export const updateRulesSchema = Joi.object({ to: to.default('now'), type: type.required(), threat: threat.default([]), + throttle: throttle.default(null), references: references.default([]), note: note.allow(''), version, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts index 0bf9d17d70fdc0..db70b90d5a17c0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts @@ -9,10 +9,12 @@ import { APP_ID, SIGNALS_ID } from '../../../../common/constants'; import { CreateRuleParams } from './types'; import { addTags } from './add_tags'; import { hasListsFeature } from '../feature_flags'; +import { transformRuleToAlertAction } from './transform_actions'; export const createRules = ({ alertsClient, actionsClient, // TODO: Use this actionsClient once we have actions such as email, etc... + actions, anomalyThreshold, description, enabled, @@ -37,6 +39,7 @@ export const createRules = ({ severity, tags, threat, + throttle, to, type, references, @@ -82,8 +85,8 @@ export const createRules = ({ }, schedule: { interval }, enabled, - actions: [], // TODO: Create and add actions here once we have email, etc... - throttle: null, + actions: actions?.map(transformRuleToAlertAction), + throttle, }, }); }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts index 3ed44081388336..695057ccc2f70c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts @@ -49,6 +49,7 @@ describe('create_rules_stream_from_ndjson', () => { ]); expect(result).toEqual([ { + actions: [], rule_id: 'rule-1', output_index: '.siem-signals', risk_score: 50, @@ -69,10 +70,12 @@ describe('create_rules_stream_from_ndjson', () => { max_signals: 100, tags: [], threat: [], + throttle: null, references: [], version: 1, }, { + actions: [], rule_id: 'rule-2', output_index: '.siem-signals', risk_score: 50, @@ -93,6 +96,7 @@ describe('create_rules_stream_from_ndjson', () => { max_signals: 100, tags: [], threat: [], + throttle: null, references: [], version: 1, }, @@ -135,6 +139,7 @@ describe('create_rules_stream_from_ndjson', () => { ]); expect(result).toEqual([ { + actions: [], rule_id: 'rule-1', output_index: '.siem-signals', risk_score: 50, @@ -155,10 +160,12 @@ describe('create_rules_stream_from_ndjson', () => { tags: [], lists: [], threat: [], + throttle: null, references: [], version: 1, }, { + actions: [], rule_id: 'rule-2', output_index: '.siem-signals', risk_score: 50, @@ -179,6 +186,7 @@ describe('create_rules_stream_from_ndjson', () => { lists: [], tags: [], threat: [], + throttle: null, references: [], version: 1, }, @@ -204,6 +212,7 @@ describe('create_rules_stream_from_ndjson', () => { ]); expect(result).toEqual([ { + actions: [], rule_id: 'rule-1', output_index: '.siem-signals', risk_score: 50, @@ -224,10 +233,12 @@ describe('create_rules_stream_from_ndjson', () => { lists: [], tags: [], threat: [], + throttle: null, references: [], version: 1, }, { + actions: [], rule_id: 'rule-2', output_index: '.siem-signals', risk_score: 50, @@ -248,6 +259,7 @@ describe('create_rules_stream_from_ndjson', () => { lists: [], tags: [], threat: [], + throttle: null, references: [], version: 1, }, @@ -273,6 +285,7 @@ describe('create_rules_stream_from_ndjson', () => { ]); const resultOrError = result as Error[]; expect(resultOrError[0]).toEqual({ + actions: [], rule_id: 'rule-1', output_index: '.siem-signals', risk_score: 50, @@ -293,11 +306,13 @@ describe('create_rules_stream_from_ndjson', () => { lists: [], tags: [], threat: [], + throttle: null, references: [], version: 1, }); expect(resultOrError[1].message).toEqual('Unexpected token , in JSON at position 1'); expect(resultOrError[2]).toEqual({ + actions: [], rule_id: 'rule-2', output_index: '.siem-signals', risk_score: 50, @@ -318,6 +333,7 @@ describe('create_rules_stream_from_ndjson', () => { lists: [], tags: [], threat: [], + throttle: null, references: [], version: 1, }); @@ -342,6 +358,7 @@ describe('create_rules_stream_from_ndjson', () => { ]); const resultOrError = result as BadRequestError[]; expect(resultOrError[0]).toEqual({ + actions: [], rule_id: 'rule-1', output_index: '.siem-signals', risk_score: 50, @@ -362,6 +379,7 @@ describe('create_rules_stream_from_ndjson', () => { lists: [], tags: [], threat: [], + throttle: null, references: [], version: 1, }); @@ -369,6 +387,7 @@ describe('create_rules_stream_from_ndjson', () => { 'child "description" fails because ["description" is required]' ); expect(resultOrError[2]).toEqual({ + actions: [], rule_id: 'rule-2', output_index: '.siem-signals', risk_score: 50, @@ -389,6 +408,7 @@ describe('create_rules_stream_from_ndjson', () => { lists: [], tags: [], threat: [], + throttle: null, references: [], version: 1, }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.test.ts index 532bfbaf469ff8..20ddcdc3f5362d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.test.ts @@ -30,6 +30,7 @@ describe('getExportAll', () => { const exports = await getExportAll(alertsClient); expect(exports).toEqual({ rulesNdjson: `${JSON.stringify({ + actions: [], created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', created_by: 'elastic', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts index f27299436c702c..e6d4c68d7108d6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts @@ -38,6 +38,7 @@ describe('get_export_by_object_ids', () => { const exports = await getExportByObjectIds(alertsClient, objects); expect(exports).toEqual({ rulesNdjson: `${JSON.stringify({ + actions: [], created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', created_by: 'elastic', @@ -158,6 +159,7 @@ describe('get_export_by_object_ids', () => { missingRules: [], rules: [ { + actions: [], created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', created_by: 'elastic', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts index bcbe460fb6a66c..801f3d949ed78b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts @@ -18,6 +18,7 @@ export const installPrepackagedRules = ( ): Array> => rules.reduce>>((acc, rule) => { const { + actions, anomaly_threshold: anomalyThreshold, description, enabled, @@ -43,6 +44,7 @@ export const installPrepackagedRules = ( to, type, threat, + throttle, references, note, version, @@ -53,6 +55,7 @@ export const installPrepackagedRules = ( createRules({ alertsClient, actionsClient, + actions, anomalyThreshold, description, enabled, @@ -79,6 +82,7 @@ export const installPrepackagedRules = ( to, type, threat, + throttle, references, note, version, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts index a8da01f87a6fb3..5b6fd08a9ea897 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts @@ -11,10 +11,12 @@ import { PatchRuleParams, IRuleSavedAttributesSavedObjectAttributes } from './ty import { addTags } from './add_tags'; import { ruleStatusSavedObjectType } from './saved_object_mappings'; import { calculateVersion, calculateName, calculateInterval } from './utils'; +import { transformRuleToAlertAction } from './transform_actions'; export const patchRules = async ({ alertsClient, actionsClient, // TODO: Use this whenever we add feature support for different action types + actions, savedObjectsClient, description, falsePositives, @@ -39,12 +41,12 @@ export const patchRules = async ({ severity, tags, threat, + throttle, to, type, references, note, version, - throttle, lists, anomalyThreshold, machineLearningJobId, @@ -55,6 +57,7 @@ export const patchRules = async ({ } const calculatedVersion = calculateVersion(rule.params.immutable, rule.params.version, { + actions, description, falsePositives, query, @@ -74,11 +77,11 @@ export const patchRules = async ({ severity, tags, threat, + throttle, to, type, references, version, - throttle, note, lists, anomalyThreshold, @@ -122,12 +125,12 @@ export const patchRules = async ({ id: rule.id, data: { tags: addTags(tags ?? rule.tags, rule.params.ruleId, immutable ?? rule.params.immutable), - throttle: throttle ?? rule.throttle ?? null, + throttle: throttle !== undefined ? throttle : rule.throttle, name: calculateName({ updatedName: name, originalName: rule.name }), schedule: { interval: calculateInterval(interval, rule.schedule.interval), }, - actions: rule.actions, + actions: actions?.map(transformRuleToAlertAction) ?? rule.actions, params: nextParams, }, }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts new file mode 100644 index 00000000000000..93b5f238be9edb --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts @@ -0,0 +1,41 @@ +/* + * 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 { transformRuleToAlertAction, transformAlertToRuleAction } from './transform_actions'; + +describe('transform_actions', () => { + test('it should transform RuleAlertAction[] to AlertAction[]', () => { + const ruleAction = { + id: 'id', + group: 'group', + action_type_id: 'action_type_id', + params: {}, + }; + const alertAction = transformRuleToAlertAction(ruleAction); + expect(alertAction).toEqual({ + id: ruleAction.id, + group: ruleAction.group, + actionTypeId: ruleAction.action_type_id, + params: ruleAction.params, + }); + }); + + test('it should transform AlertAction[] to RuleAlertAction[]', () => { + const alertAction = { + id: 'id', + group: 'group', + actionTypeId: 'actionTypeId', + params: {}, + }; + const ruleAction = transformAlertToRuleAction(alertAction); + expect(ruleAction).toEqual({ + id: alertAction.id, + group: alertAction.group, + action_type_id: alertAction.actionTypeId, + params: alertAction.params, + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts new file mode 100644 index 00000000000000..c1c17d2c708360 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts @@ -0,0 +1,32 @@ +/* + * 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 { AlertAction } from '../../../../../../../plugins/alerting/common'; +import { RuleAlertAction } from '../types'; + +export const transformRuleToAlertAction = ({ + group, + id, + action_type_id, + params, +}: RuleAlertAction): AlertAction => ({ + group, + id, + params, + actionTypeId: action_type_id, +}); + +export const transformAlertToRuleAction = ({ + group, + id, + actionTypeId, + params, +}: AlertAction): RuleAlertAction => ({ + group, + id, + params, + action_type_id: actionTypeId, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts index 1051ac28885b81..cc67622176a044 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -19,6 +19,7 @@ export const updatePrepackagedRules = async ( ): Promise => { await rules.forEach(async rule => { const { + actions, description, false_positives: falsePositives, from, @@ -39,9 +40,9 @@ export const updatePrepackagedRules = async ( to, type, threat, + throttle, references, version, - throttle, note, } = rule; @@ -50,6 +51,7 @@ export const updatePrepackagedRules = async ( return patchRules({ alertsClient, actionsClient, + actions, description, falsePositives, from, @@ -73,9 +75,9 @@ export const updatePrepackagedRules = async ( to, type, threat, + throttle, references, version, - throttle, note, }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts index ae8ea9dd32cd24..a80f9864820106 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts @@ -11,10 +11,12 @@ import { addTags } from './add_tags'; import { ruleStatusSavedObjectType } from './saved_object_mappings'; import { calculateVersion } from './utils'; import { hasListsFeature } from '../feature_flags'; +import { transformRuleToAlertAction } from './transform_actions'; export const updateRules = async ({ alertsClient, actionsClient, // TODO: Use this whenever we add feature support for different action types + actions, savedObjectsClient, description, falsePositives, @@ -39,11 +41,11 @@ export const updateRules = async ({ severity, tags, threat, + throttle, to, type, references, version, - throttle, note, lists, anomalyThreshold, @@ -55,6 +57,7 @@ export const updateRules = async ({ } const calculatedVersion = calculateVersion(rule.params.immutable, rule.params.version, { + actions, description, falsePositives, query, @@ -74,11 +77,11 @@ export const updateRules = async ({ severity, tags, threat, + throttle, to, type, references, version, - throttle, note, anomalyThreshold, machineLearningJobId, @@ -93,8 +96,8 @@ export const updateRules = async ({ tags: addTags(tags, rule.params.ruleId, immutable), name, schedule: { interval }, - actions: rule.actions, - throttle: throttle ?? rule.throttle ?? null, + actions: actions?.map(transformRuleToAlertAction) ?? rule.actions, + throttle: throttle !== undefined ? throttle : rule.throttle, params: { description, ruleId: rule.params.ruleId, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.test.ts index c30635c9d14901..c86696d6ec5ebf 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.test.ts @@ -25,6 +25,7 @@ describe('buildBulkBody', () => { ruleParams: sampleParams, id: sampleRuleGuid, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -32,6 +33,7 @@ describe('buildBulkBody', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -60,6 +62,7 @@ describe('buildBulkBody', () => { original_time: 'someTimeStamp', status: 'open', rule: { + actions: [], id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', rule_id: 'rule-1', false_positives: [], @@ -132,6 +135,7 @@ describe('buildBulkBody', () => { ruleParams: sampleParams, id: sampleRuleGuid, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -139,6 +143,7 @@ describe('buildBulkBody', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -176,6 +181,7 @@ describe('buildBulkBody', () => { original_time: 'someTimeStamp', status: 'open', rule: { + actions: [], id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', rule_id: 'rule-1', false_positives: [], @@ -247,6 +253,7 @@ describe('buildBulkBody', () => { ruleParams: sampleParams, id: sampleRuleGuid, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -254,6 +261,7 @@ describe('buildBulkBody', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -290,6 +298,7 @@ describe('buildBulkBody', () => { original_time: 'someTimeStamp', status: 'open', rule: { + actions: [], id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', rule_id: 'rule-1', false_positives: [], @@ -359,6 +368,7 @@ describe('buildBulkBody', () => { ruleParams: sampleParams, id: sampleRuleGuid, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -366,6 +376,7 @@ describe('buildBulkBody', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -397,6 +408,7 @@ describe('buildBulkBody', () => { original_time: 'someTimeStamp', status: 'open', rule: { + actions: [], id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', rule_id: 'rule-1', false_positives: [], diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts index e77755073b374b..adbd5f81d372a9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts @@ -8,12 +8,13 @@ import { SignalSourceHit, SignalHit } from './types'; import { buildRule } from './build_rule'; import { buildSignal } from './build_signal'; import { buildEventTypeSignal } from './build_event_type_signal'; -import { RuleTypeParams } from '../types'; +import { RuleTypeParams, RuleAlertAction } from '../types'; interface BuildBulkBodyParams { doc: SignalSourceHit; ruleParams: RuleTypeParams; id: string; + actions: RuleAlertAction[]; name: string; createdAt: string; createdBy: string; @@ -22,6 +23,7 @@ interface BuildBulkBodyParams { interval: string; enabled: boolean; tags: string[]; + throttle: string | null; } // format search_after result for signals index. @@ -30,6 +32,7 @@ export const buildBulkBody = ({ ruleParams, id, name, + actions, createdAt, createdBy, updatedAt, @@ -37,8 +40,10 @@ export const buildBulkBody = ({ interval, enabled, tags, + throttle, }: BuildBulkBodyParams): SignalHit => { const rule = buildRule({ + actions, ruleParams, id, name, @@ -49,6 +54,7 @@ export const buildBulkBody = ({ updatedBy, interval, tags, + throttle, }); const signal = buildSignal(doc, rule); const event = buildEventTypeSignal(doc); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.test.ts index 499e3e9c88a851..37d7ed8a510822 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.test.ts @@ -27,6 +27,7 @@ describe('buildRule', () => { }, ]; const rule = buildRule({ + actions: [], ruleParams, name: 'some-name', id: sampleRuleGuid, @@ -37,8 +38,10 @@ describe('buildRule', () => { updatedBy: 'elastic', interval: 'some interval', tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); const expected: Partial = { + actions: [], created_by: 'elastic', description: 'Detecting root and admin users', enabled: false, @@ -106,10 +109,11 @@ describe('buildRule', () => { expect(rule).toEqual(expected); }); - test('it omits a null value such as if enabled is null if is present', () => { + test('it omits a null value such as if "enabled" is null if is present', () => { const ruleParams = sampleRuleAlertParams(); ruleParams.filters = undefined; const rule = buildRule({ + actions: [], ruleParams, name: 'some-name', id: sampleRuleGuid, @@ -120,8 +124,10 @@ describe('buildRule', () => { updatedBy: 'elastic', interval: 'some interval', tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); const expected: Partial = { + actions: [], created_by: 'elastic', description: 'Detecting root and admin users', enabled: true, @@ -178,10 +184,11 @@ describe('buildRule', () => { expect(rule).toEqual(expected); }); - test('it omits a null value such as if filters is undefined if is present', () => { + test('it omits a null value such as if "filters" is undefined if is present', () => { const ruleParams = sampleRuleAlertParams(); ruleParams.filters = undefined; const rule = buildRule({ + actions: [], ruleParams, name: 'some-name', id: sampleRuleGuid, @@ -192,8 +199,84 @@ describe('buildRule', () => { updatedBy: 'elastic', interval: 'some interval', tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); const expected: Partial = { + actions: [], + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: 'some interval', + language: 'kuery', + max_signals: 10000, + name: 'some-name', + note: '', + output_index: '.siem-signals', + query: 'user.name: root or user.name: admin', + references: ['http://google.com'], + risk_score: 50, + rule_id: 'rule-1', + severity: 'high', + tags: ['some fake tag 1', 'some fake tag 2'], + to: 'now', + type: 'query', + updated_by: 'elastic', + version: 1, + updated_at: rule.updated_at, + created_at: rule.created_at, + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], + }; + expect(rule).toEqual(expected); + }); + + test('it omits a null value such as if "throttle" is undefined if is present', () => { + const ruleParams = sampleRuleAlertParams(); + const rule = buildRule({ + actions: [], + ruleParams, + name: 'some-name', + id: sampleRuleGuid, + enabled: true, + createdAt: '2020-01-28T15:58:34.810Z', + updatedAt: '2020-01-28T15:59:14.004Z', + createdBy: 'elastic', + updatedBy: 'elastic', + interval: 'some interval', + tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, + }); + const expected: Partial = { + actions: [], created_by: 'elastic', description: 'Detecting root and admin users', enabled: true, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts index a1bee162c92805..e94ca18b186e46 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts @@ -5,12 +5,13 @@ */ import { pickBy } from 'lodash/fp'; -import { RuleTypeParams, OutputRuleAlertRest } from '../types'; +import { RuleTypeParams, OutputRuleAlertRest, RuleAlertAction } from '../types'; interface BuildRuleParams { ruleParams: RuleTypeParams; name: string; id: string; + actions: RuleAlertAction[]; enabled: boolean; createdAt: string; createdBy: string; @@ -18,12 +19,14 @@ interface BuildRuleParams { updatedBy: string; interval: string; tags: string[]; + throttle: string | null; } export const buildRule = ({ ruleParams, name, id, + actions, enabled, createdAt, createdBy, @@ -31,10 +34,12 @@ export const buildRule = ({ updatedBy, interval, tags, + throttle, }: BuildRuleParams): Partial => { return pickBy((value: unknown) => value != null, { id, rule_id: ruleParams.ruleId, + actions, false_positives: ruleParams.falsePositives, saved_id: ruleParams.savedId, timeline_id: ruleParams.timelineId, @@ -62,6 +67,7 @@ export const buildRule = ({ created_by: createdBy, updated_by: updatedBy, threat: ruleParams.threat, + throttle, version: ruleParams.version, created_at: createdAt, updated_at: updatedAt, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts index 1ab34f26d4b705..95adb901724042 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts @@ -9,11 +9,12 @@ import { SearchResponse } from 'elasticsearch'; import { Logger } from '../../../../../../../../src/core/server'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; -import { RuleTypeParams } from '../types'; +import { RuleTypeParams, RuleAlertAction } from '../types'; import { singleBulkCreate } from './single_bulk_create'; import { AnomalyResults, Anomaly } from '../../machine_learning'; interface BulkCreateMlSignalsParams { + actions: RuleAlertAction[]; someResult: AnomalyResults; ruleParams: RuleTypeParams; services: AlertServices; @@ -28,6 +29,7 @@ interface BulkCreateMlSignalsParams { interval: string; enabled: boolean; tags: string[]; + throttle: string | null; } interface EcsAnomaly extends Anomaly { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 09daae84853819..315a5dd88d94e9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -43,6 +43,7 @@ describe('searchAfterAndBulkCreate', () => { inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -52,6 +53,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(mockService.callCluster).toHaveBeenCalledTimes(0); expect(result).toEqual(true); @@ -99,6 +101,7 @@ describe('searchAfterAndBulkCreate', () => { inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -108,6 +111,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(mockService.callCluster).toHaveBeenCalledTimes(5); expect(result).toEqual(true); @@ -126,6 +130,7 @@ describe('searchAfterAndBulkCreate', () => { inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -135,6 +140,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(mockLogger.error).toHaveBeenCalled(); expect(result).toEqual(false); @@ -160,6 +166,7 @@ describe('searchAfterAndBulkCreate', () => { inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -169,6 +176,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(mockLogger.error).toHaveBeenCalled(); expect(result).toEqual(false); @@ -194,6 +202,7 @@ describe('searchAfterAndBulkCreate', () => { inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -203,6 +212,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(result).toEqual(true); }); @@ -230,6 +240,7 @@ describe('searchAfterAndBulkCreate', () => { inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -239,6 +250,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(result).toEqual(true); }); @@ -266,6 +278,7 @@ describe('searchAfterAndBulkCreate', () => { inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -275,6 +288,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(result).toEqual(true); }); @@ -304,6 +318,7 @@ describe('searchAfterAndBulkCreate', () => { inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -313,6 +328,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(result).toEqual(false); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts index f54ad67af4a48a..a12778d5b8f163 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -5,7 +5,7 @@ */ import { AlertServices } from '../../../../../../../plugins/alerting/server'; -import { RuleTypeParams } from '../types'; +import { RuleTypeParams, RuleAlertAction } from '../types'; import { Logger } from '../../../../../../../../src/core/server'; import { singleSearchAfter } from './single_search_after'; import { singleBulkCreate } from './single_bulk_create'; @@ -20,6 +20,7 @@ interface SearchAfterAndBulkCreateParams { inputIndexPattern: string[]; signalsIndex: string; name: string; + actions: RuleAlertAction[]; createdAt: string; createdBy: string; updatedBy: string; @@ -29,6 +30,7 @@ interface SearchAfterAndBulkCreateParams { pageSize: number; filter: unknown; tags: string[]; + throttle: string | null; } // search_after through documents and re-index using bulk endpoint. @@ -41,6 +43,7 @@ export const searchAfterAndBulkCreate = async ({ inputIndexPattern, signalsIndex, filter, + actions, name, createdAt, createdBy, @@ -50,6 +53,7 @@ export const searchAfterAndBulkCreate = async ({ enabled, pageSize, tags, + throttle, }: SearchAfterAndBulkCreateParams): Promise => { if (someResult.hits.hits.length === 0) { return true; @@ -63,6 +67,7 @@ export const searchAfterAndBulkCreate = async ({ logger, id, signalsIndex, + actions, name, createdAt, createdBy, @@ -71,6 +76,7 @@ export const searchAfterAndBulkCreate = async ({ interval, enabled, tags, + throttle, }); const totalHits = typeof someResult.hits.total === 'number' ? someResult.hits.total : someResult.hits.total.value; @@ -127,6 +133,7 @@ export const searchAfterAndBulkCreate = async ({ logger, id, signalsIndex, + actions, name, createdAt, createdBy, @@ -135,6 +142,7 @@ export const searchAfterAndBulkCreate = async ({ interval, enabled, tags, + throttle, }); logger.debug('finished next bulk index'); } catch (exc) { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 7a4dcf68e0ca94..89dcd3274ebed8 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -67,6 +67,7 @@ export const signalRulesAlertType = ({ }); const { + actions, name, tags, createdAt, @@ -74,6 +75,7 @@ export const signalRulesAlertType = ({ updatedBy, enabled, schedule: { interval }, + throttle, } = savedObject.attributes; const updatedAt = savedObject.updated_at ?? ''; @@ -118,6 +120,8 @@ export const signalRulesAlertType = ({ } creationSucceeded = await bulkCreateMlSignals({ + actions, + throttle, someResult: anomalyResults, ruleParams: params, services, @@ -180,6 +184,7 @@ export const signalRulesAlertType = ({ inputIndexPattern: inputIndex, signalsIndex: outputIndex, filter: esFilter, + actions, name, createdBy, createdAt, @@ -189,6 +194,7 @@ export const signalRulesAlertType = ({ enabled, pageSize: searchAfterSize, tags, + throttle, }); } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts index 09e2c6b4fd586a..afabd4c44de7de 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts @@ -151,6 +151,7 @@ describe('singleBulkCreate', () => { logger: mockLogger, id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, + actions: [], name: 'rule-name', createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', @@ -159,6 +160,7 @@ describe('singleBulkCreate', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(successfulsingleBulkCreate).toEqual(true); }); @@ -182,6 +184,7 @@ describe('singleBulkCreate', () => { id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -189,6 +192,7 @@ describe('singleBulkCreate', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(successfulsingleBulkCreate).toEqual(true); }); @@ -204,6 +208,7 @@ describe('singleBulkCreate', () => { id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -211,6 +216,7 @@ describe('singleBulkCreate', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(successfulsingleBulkCreate).toEqual(true); }); @@ -227,6 +233,7 @@ describe('singleBulkCreate', () => { id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -234,6 +241,7 @@ describe('singleBulkCreate', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(mockLogger.error).not.toHaveBeenCalled(); @@ -252,6 +260,7 @@ describe('singleBulkCreate', () => { id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -259,6 +268,7 @@ describe('singleBulkCreate', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(mockLogger.error).toHaveBeenCalled(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts index 7d6d6d99fa4229..333a938e09d456 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts @@ -8,7 +8,7 @@ import { countBy, isEmpty } from 'lodash'; import { performance } from 'perf_hooks'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; import { SignalSearchResponse, BulkResponse } from './types'; -import { RuleTypeParams } from '../types'; +import { RuleTypeParams, RuleAlertAction } from '../types'; import { generateId } from './utils'; import { buildBulkBody } from './build_bulk_body'; import { Logger } from '../../../../../../../../src/core/server'; @@ -20,6 +20,7 @@ interface SingleBulkCreateParams { logger: Logger; id: string; signalsIndex: string; + actions: RuleAlertAction[]; name: string; createdAt: string; createdBy: string; @@ -28,6 +29,7 @@ interface SingleBulkCreateParams { interval: string; enabled: boolean; tags: string[]; + throttle: string | null; } /** @@ -60,6 +62,7 @@ export const singleBulkCreate = async ({ logger, id, signalsIndex, + actions, name, createdAt, createdBy, @@ -68,6 +71,7 @@ export const singleBulkCreate = async ({ interval, enabled, tags, + throttle, }: SingleBulkCreateParams): Promise => { someResult.hits.hits = filterDuplicateRules(id, someResult); @@ -99,6 +103,7 @@ export const singleBulkCreate = async ({ doc, ruleParams, id, + actions, name, createdAt, createdBy, @@ -107,6 +112,7 @@ export const singleBulkCreate = async ({ interval, enabled, tags, + throttle, }), ]); const start = performance.now(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts index 1ee3d4f0eb8e4f..06acff825f68e2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RuleAlertParams, OutputRuleAlertRest } from '../types'; +import { RuleAlertParams, OutputRuleAlertRest, RuleAlertAction } from '../types'; import { SearchResponse } from '../../types'; import { AlertType, @@ -147,6 +147,7 @@ export interface SignalHit { } export interface AlertAttributes { + actions: RuleAlertAction[]; enabled: boolean; name: string; tags: string[]; @@ -156,4 +157,5 @@ export interface AlertAttributes { schedule: { interval: string; }; + throttle: string | null; } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts index 5973a1dbe5f18d..2cbdc7db3ba64f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AlertAction } from '../../../../../../plugins/alerting/common'; import { CallAPIOptions } from '../../../../../../../src/core/server'; import { Filter } from '../../../../../../../src/plugins/data/server'; import { IRuleStatusAttributes } from './rules/types'; @@ -23,6 +24,10 @@ export interface ThreatParams { technique: IMitreAttack[]; } +export type RuleAlertAction = Omit & { + action_type_id: string; +}; + // Notice below we are using lists: ListsDefaultArraySchema[]; which is coming directly from the response output section. // TODO: Eventually this whole RuleAlertParams will be replaced with io-ts. For now we can slowly strangle it out and reduce duplicate types // We don't have the input types defined through io-ts just yet but as we being introducing types from there we will more and more remove @@ -30,6 +35,7 @@ export interface ThreatParams { export type RuleType = 'query' | 'saved_query' | 'machine_learning'; export interface RuleAlertParams { + actions: RuleAlertAction[]; anomalyThreshold: number | undefined; description: string; note: string | undefined | null; @@ -59,11 +65,14 @@ export interface RuleAlertParams { threat: ThreatParams[] | undefined | null; type: RuleType; version: number; - throttle?: string; + throttle: string | null; lists: ListsDefaultArraySchema | null | undefined; } -export type RuleTypeParams = Omit; +export type RuleTypeParams = Omit< + RuleAlertParams, + 'name' | 'enabled' | 'interval' | 'tags' | 'actions' | 'throttle' +>; export type RuleAlertParamsRest = Omit< RuleAlertParams, diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts index 6e2a391ec14e1f..c92e351ed59180 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts @@ -128,6 +128,7 @@ export const binaryToString = (res: any, callback: any): void => { * This is the typical output of a simple rule that Kibana will output with all the defaults. */ export const getSimpleRuleOutput = (ruleId = 'rule-1'): Partial => ({ + actions: [], created_by: 'elastic', description: 'Simple Rule Query', enabled: true, @@ -244,6 +245,7 @@ export const ruleToNdjson = (rule: Partial): Buffer => { * @param ruleId The ruleId to set which is optional and defaults to rule-1 */ export const getComplexRule = (ruleId = 'rule-1'): Partial => ({ + actions: [], name: 'Complex Rule Query', description: 'Complex Rule Query', false_positives: [ @@ -327,6 +329,7 @@ export const getComplexRule = (ruleId = 'rule-1'): Partial * @param ruleId The ruleId to set which is optional and defaults to rule-1 */ export const getComplexRuleOutput = (ruleId = 'rule-1'): Partial => ({ + actions: [], created_by: 'elastic', name: 'Complex Rule Query', description: 'Complex Rule Query', From ab4409973955a175bdffc61d672f47880de57978 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Fri, 20 Mar 2020 10:09:12 +0000 Subject: [PATCH 30/32] [SIEM] Export timeline (#58368) * update layout * add utility bars * add icon * adding a route for exporting timeline * organizing data * fix types * fix incorrect props for timeline table * add export timeline to tables action * fix types * add client side unit test * add server-side unit test * fix title for delete timelines * fix unit tests * update snapshot * fix dependency * add table ref * remove custom link * remove custom links * Update x-pack/legacy/plugins/siem/common/constants.ts Co-Authored-By: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> * remove type ExportTimelineIds * reduce props * Get notes and pinned events by timeline id * combine notes and pinned events data * fix unit test * fix type error * fix type error * fix unit tests * fix for review * clean up generic downloader * review with angela * review utils * fix for code review * fix for review * fix tests * review * fix title of delete modal * remove an extra bracket Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Elastic Machine --- .../legacy/plugins/siem/common/constants.ts | 3 + .../__snapshots__/index.test.tsx.snap | 3 + .../generic_downloader}/index.test.tsx | 10 +- .../generic_downloader}/index.tsx | 56 +- .../generic_downloader}/translations.ts | 0 .../delete_timeline_modal.test.tsx | 8 +- .../delete_timeline_modal.tsx | 42 +- .../delete_timeline_modal/index.test.tsx | 112 +--- .../delete_timeline_modal/index.tsx | 67 +- .../open_timeline/edit_timeline_actions.tsx | 58 ++ .../edit_timeline_batch_actions.tsx | 116 ++++ .../export_timeline/export_timeline.test.tsx | 90 +++ .../export_timeline/export_timeline.tsx | 59 ++ .../export_timeline/index.test.tsx | 35 + .../open_timeline/export_timeline/index.tsx | 73 +++ .../open_timeline/export_timeline/mocks.ts | 99 +++ .../components/open_timeline/index.test.tsx | 5 - .../public/components/open_timeline/index.tsx | 3 +- .../open_timeline/open_timeline.test.tsx | 266 ++++++++ .../open_timeline/open_timeline.tsx | 193 ++++-- .../open_timeline_modal_body.tsx | 1 - .../open_timeline/search_row/index.test.tsx | 151 ----- .../open_timeline/search_row/index.tsx | 74 +-- .../timelines_table/actions_columns.test.tsx | 192 ++---- .../timelines_table/actions_columns.tsx | 98 +-- .../timelines_table/common_columns.test.tsx | 613 ++++-------------- .../timelines_table/extended_columns.test.tsx | 72 +- .../icon_header_columns.test.tsx | 240 ++----- .../timelines_table/index.test.tsx | 359 ++-------- .../open_timeline/timelines_table/index.tsx | 55 +- .../open_timeline/timelines_table/mocks.ts | 32 + .../open_timeline/title_row/index.test.tsx | 116 +--- .../open_timeline/title_row/index.tsx | 53 +- .../components/open_timeline/translations.ts | 40 +- .../public/components/open_timeline/types.ts | 22 +- .../utility_bar/utility_bar_text.tsx | 2 +- .../detection_engine/rules/api.test.ts | 10 +- .../containers/detection_engine/rules/api.ts | 10 +- .../detection_engine/rules/types.ts | 4 +- .../public/containers/timeline/all/api.ts | 30 + .../public/containers/timeline/all/index.tsx | 14 +- .../detection_engine/rules/all/index.tsx | 10 +- .../__snapshots__/index.test.tsx.snap | 7 +- .../rule_actions_overflow/index.tsx | 11 +- .../__snapshots__/index.test.tsx.snap | 3 - .../public/pages/timelines/timelines_page.tsx | 38 +- .../public/pages/timelines/translations.ts | 7 + .../server/graphql/timeline/schema.gql.ts | 2 +- .../routes/rules/utils.test.ts | 12 +- .../detection_engine/routes/rules/utils.ts | 8 +- .../detection_engine/rules/get_export_all.ts | 4 +- .../rules/get_export_by_object_ids.ts | 4 +- .../siem/server/lib/note/saved_object.ts | 2 +- .../server/lib/pinned_event/saved_object.ts | 2 +- .../routes/__mocks__/request_responses.ts | 250 +++++++ .../routes/export_timelines_route.test.ts | 97 +++ .../timeline/routes/export_timelines_route.ts | 75 +++ .../routes/schemas/export_timelines_schema.ts | 20 + .../lib/timeline/routes/schemas/schemas.ts | 13 + .../siem/server/lib/timeline/routes/utils.ts | 187 ++++++ .../siem/server/lib/timeline/saved_object.ts | 6 +- .../plugins/siem/server/lib/timeline/types.ts | 59 +- .../plugins/siem/server/routes/index.ts | 3 + 63 files changed, 2425 insertions(+), 1881 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/components/generic_downloader/__snapshots__/index.test.tsx.snap rename x-pack/legacy/plugins/siem/public/{pages/detection_engine/rules/components/rule_downloader => components/generic_downloader}/index.test.tsx (63%) rename x-pack/legacy/plugins/siem/public/{pages/detection_engine/rules/components/rule_downloader => components/generic_downloader}/index.tsx (62%) rename x-pack/legacy/plugins/siem/public/{pages/detection_engine/rules/components/rule_downloader => components/generic_downloader}/translations.ts (100%) create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/mocks.ts create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/mocks.ts create mode 100644 x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts delete mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/__snapshots__/index.test.tsx.snap create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index 2a30293c244afd..c3fc4aea77863f 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -72,6 +72,9 @@ export const DETECTION_ENGINE_TAGS_URL = `${DETECTION_ENGINE_URL}/tags`; export const DETECTION_ENGINE_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/_find_statuses`; export const DETECTION_ENGINE_PREPACKAGED_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/prepackaged/_status`; +export const TIMELINE_URL = '/api/timeline'; +export const TIMELINE_EXPORT_URL = `${TIMELINE_URL}/_export`; + /** * Default signals index key for kibana.dev.yml */ diff --git a/x-pack/legacy/plugins/siem/public/components/generic_downloader/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/generic_downloader/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000000..219be8cbda311f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/__snapshots__/index.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GenericDownloader renders correctly against snapshot 1`] = ``; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx similarity index 63% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.test.tsx rename to x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx index 6306260dfc872f..a70772911ba605 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx @@ -6,12 +6,16 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { RuleDownloaderComponent } from './index'; +import { GenericDownloaderComponent } from './index'; -describe('RuleDownloader', () => { +describe('GenericDownloader', () => { test('renders correctly against snapshot', () => { const wrapper = shallow( - + ); expect(wrapper).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx similarity index 62% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx rename to x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx index 959864d50747fa..6f08f5c8c381cd 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx @@ -7,18 +7,28 @@ import React, { useEffect, useRef } from 'react'; import styled from 'styled-components'; import { isFunction } from 'lodash/fp'; -import { exportRules } from '../../../../../containers/detection_engine/rules'; -import { useStateToaster, errorToToaster } from '../../../../../components/toasters'; import * as i18n from './translations'; +import { ExportDocumentsProps } from '../../containers/detection_engine/rules'; +import { useStateToaster, errorToToaster } from '../toasters'; + const InvisibleAnchor = styled.a` display: none; `; -export interface RuleDownloaderProps { +export type ExportSelectedData = ({ + excludeExportDetails, + filename, + ids, + signal, +}: ExportDocumentsProps) => Promise; + +export interface GenericDownloaderProps { filename: string; - ruleIds?: string[]; - onExportComplete: (exportCount: number) => void; + ids?: string[]; + exportSelectedData: ExportSelectedData; + onExportSuccess?: (exportCount: number) => void; + onExportFailure?: () => void; } /** @@ -28,11 +38,14 @@ export interface RuleDownloaderProps { * @param payload Rule[] * */ -export const RuleDownloaderComponent = ({ + +export const GenericDownloaderComponent = ({ + exportSelectedData, filename, - ruleIds, - onExportComplete, -}: RuleDownloaderProps) => { + ids, + onExportSuccess, + onExportFailure, +}: GenericDownloaderProps) => { const anchorRef = useRef(null); const [, dispatchToaster] = useStateToaster(); @@ -40,11 +53,11 @@ export const RuleDownloaderComponent = ({ let isSubscribed = true; const abortCtrl = new AbortController(); - async function exportData() { - if (anchorRef && anchorRef.current && ruleIds != null && ruleIds.length > 0) { + const exportData = async () => { + if (anchorRef && anchorRef.current && ids != null && ids.length > 0) { try { - const exportResponse = await exportRules({ - ruleIds, + const exportResponse = await exportSelectedData({ + ids, signal: abortCtrl.signal, }); @@ -61,15 +74,20 @@ export const RuleDownloaderComponent = ({ window.URL.revokeObjectURL(objectURL); } - onExportComplete(ruleIds.length); + if (onExportSuccess != null) { + onExportSuccess(ids.length); + } } } catch (error) { if (isSubscribed) { + if (onExportFailure != null) { + onExportFailure(); + } errorToToaster({ title: i18n.EXPORT_FAILURE, error, dispatchToaster }); } } } - } + }; exportData(); @@ -77,13 +95,13 @@ export const RuleDownloaderComponent = ({ isSubscribed = false; abortCtrl.abort(); }; - }, [ruleIds]); + }, [ids]); return ; }; -RuleDownloaderComponent.displayName = 'RuleDownloaderComponent'; +GenericDownloaderComponent.displayName = 'GenericDownloaderComponent'; -export const RuleDownloader = React.memo(RuleDownloaderComponent); +export const GenericDownloader = React.memo(GenericDownloaderComponent); -RuleDownloader.displayName = 'RuleDownloader'; +GenericDownloader.displayName = 'GenericDownloader'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/translations.ts b/x-pack/legacy/plugins/siem/public/components/generic_downloader/translations.ts similarity index 100% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/translations.ts rename to x-pack/legacy/plugins/siem/public/components/generic_downloader/translations.ts diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx index e061141bf43e78..bb8f9b807c0302 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx @@ -12,10 +12,10 @@ import { DeleteTimelineModal } from './delete_timeline_modal'; import * as i18n from '../translations'; describe('DeleteTimelineModal', () => { - test('it renders the expected title when a title is specified', () => { + test('it renders the expected title when a timeline is selected', () => { const wrapper = mountWithIntl( @@ -29,10 +29,10 @@ describe('DeleteTimelineModal', () => { ).toEqual('Delete "Privilege Escalation"?'); }); - test('it trims leading and trailing whitespace around the title', () => { + test('it trims leading whitespace around the title', () => { const wrapper = mountWithIntl( diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 82fe0d1d162a4b..026c43feeff9b4 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -6,7 +6,8 @@ import { EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; +import React, { useCallback } from 'react'; +import { isEmpty } from 'lodash/fp'; import * as i18n from '../translations'; @@ -21,27 +22,34 @@ export const DELETE_TIMELINE_MODAL_WIDTH = 600; // px /** * Renders a modal that confirms deletion of a timeline */ -export const DeleteTimelineModal = React.memo(({ title, closeModal, onDelete }) => ( - (({ title, closeModal, onDelete }) => { + const getTitle = useCallback(() => { + const trimmedTitle = title != null ? title.trim() : ''; + const titleResult = !isEmpty(trimmedTitle) ? trimmedTitle : i18n.UNTITLED_TIMELINE; + return ( 0 ? title.trim() : i18n.UNTITLED_TIMELINE, + title: titleResult, }} /> - } - onCancel={closeModal} - onConfirm={onDelete} - cancelButtonText={i18n.CANCEL} - confirmButtonText={i18n.DELETE} - buttonColor="danger" - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - > -
      {i18n.DELETE_WARNING}
      -
      -)); + ); + }, [title]); + return ( + +
      {i18n.DELETE_WARNING}
      +
      + ); +}); DeleteTimelineModal.displayName = 'DeleteTimelineModal'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx index a3c5371435e52c..6e0ba5ebe24256 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx @@ -4,114 +4,54 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonIconProps } from '@elastic/eui'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; -import { DeleteTimelineModalButton } from '.'; +import { DeleteTimelineModalOverlay } from '.'; describe('DeleteTimelineModal', () => { const savedObjectId = 'abcd'; + const defaultProps = { + closeModal: jest.fn(), + deleteTimelines: jest.fn(), + isModalOpen: true, + savedObjectIds: [savedObjectId], + title: 'Privilege Escalation', + }; describe('showModalState', () => { - test('it disables the delete icon if deleteTimelines is not provided', () => { - const wrapper = mountWithIntl( - - ); + test('it does NOT render the modal when isModalOpen is false', () => { + const testProps = { + ...defaultProps, + isModalOpen: false, + }; + const wrapper = mountWithIntl(); - const props = wrapper - .find('[data-test-subj="delete-timeline"]') - .first() - .props() as EuiButtonIconProps; - - expect(props.isDisabled).toBe(true); - }); - - test('it disables the delete icon if savedObjectId is null', () => { - const wrapper = mountWithIntl( - - ); - - const props = wrapper - .find('[data-test-subj="delete-timeline"]') - .first() - .props() as EuiButtonIconProps; - - expect(props.isDisabled).toBe(true); - }); - - test('it disables the delete icon if savedObjectId is an empty string', () => { - const wrapper = mountWithIntl( - - ); - - const props = wrapper - .find('[data-test-subj="delete-timeline"]') - .first() - .props() as EuiButtonIconProps; - - expect(props.isDisabled).toBe(true); - }); - - test('it enables the delete icon if savedObjectId is NOT an empty string', () => { - const wrapper = mountWithIntl( - - ); - - const props = wrapper - .find('[data-test-subj="delete-timeline"]') - .first() - .props() as EuiButtonIconProps; - - expect(props.isDisabled).toBe(false); + expect( + wrapper + .find('[data-test-subj="delete-timeline-modal"]') + .first() + .exists() + ).toBe(false); }); - test('it does NOT render the modal when showModal is false', () => { - const wrapper = mountWithIntl( - - ); + test('it renders the modal when isModalOpen is true', () => { + const wrapper = mountWithIntl(); expect( wrapper .find('[data-test-subj="delete-timeline-modal"]') .first() .exists() - ).toBe(false); + ).toBe(true); }); - test('it renders the modal when showModal is clicked', () => { - const wrapper = mountWithIntl( - - ); - - wrapper - .find('[data-test-subj="delete-timeline"]') - .first() - .simulate('click'); + test('it hides popover when isModalOpen is true', () => { + const wrapper = mountWithIntl(); expect( wrapper - .find('[data-test-subj="delete-timeline-modal"]') + .find('[data-test-subj="remove-popover"]') .first() .exists() ).toBe(true); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx index 982937659c0aaf..df01ebacb1f936 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx @@ -4,58 +4,54 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonIcon, EuiModal, EuiToolTip, EuiOverlayMask } from '@elastic/eui'; -import React, { useCallback, useState } from 'react'; +import { EuiModal, EuiOverlayMask } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { createGlobalStyle } from 'styled-components'; import { DeleteTimelineModal, DELETE_TIMELINE_MODAL_WIDTH } from './delete_timeline_modal'; -import * as i18n from '../translations'; import { DeleteTimelines } from '../types'; +const RemovePopover = createGlobalStyle` +div.euiPopover__panel-isOpen { + display: none; +} +`; interface Props { - deleteTimelines?: DeleteTimelines; - savedObjectId?: string | null; - title?: string | null; + deleteTimelines: DeleteTimelines; + onComplete?: () => void; + isModalOpen: boolean; + savedObjectIds: string[]; + title: string | null; } /** * Renders a button that when clicked, displays the `Delete Timeline` modal */ -export const DeleteTimelineModalButton = React.memo( - ({ deleteTimelines, savedObjectId, title }) => { - const [showModal, setShowModal] = useState(false); - - const openModal = useCallback(() => setShowModal(true), [setShowModal]); - const closeModal = useCallback(() => setShowModal(false), [setShowModal]); - +export const DeleteTimelineModalOverlay = React.memo( + ({ deleteTimelines, isModalOpen, savedObjectIds, title, onComplete }) => { + const internalCloseModal = useCallback(() => { + if (onComplete != null) { + onComplete(); + } + }, [onComplete]); const onDelete = useCallback(() => { - if (deleteTimelines != null && savedObjectId != null) { - deleteTimelines([savedObjectId]); + if (savedObjectIds != null) { + deleteTimelines(savedObjectIds); } - closeModal(); - }, [deleteTimelines, savedObjectId, closeModal]); - + if (onComplete != null) { + onComplete(); + } + }, [deleteTimelines, savedObjectIds, onComplete]); return ( <> - - - - - {showModal ? ( + {isModalOpen && } + {isModalOpen ? ( - + @@ -64,5 +60,4 @@ export const DeleteTimelineModalButton = React.memo( ); } ); - -DeleteTimelineModalButton.displayName = 'DeleteTimelineModalButton'; +DeleteTimelineModalOverlay.displayName = 'DeleteTimelineModalOverlay'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx new file mode 100644 index 00000000000000..112e73a47ce7df --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx @@ -0,0 +1,58 @@ +/* + * 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 { useState, useCallback } from 'react'; +import { OpenTimelineResult } from './types'; + +export const useEditTimelineActions = () => { + const [actionItem, setActionTimeline] = useState(null); + const [isDeleteTimelineModalOpen, setIsDeleteTimelineModalOpen] = useState(false); + const [isEnableDownloader, setIsEnableDownloader] = useState(false); + + // Handle Delete Modal + const onCloseDeleteTimelineModal = useCallback(() => { + setIsDeleteTimelineModalOpen(false); + setActionTimeline(null); + }, [actionItem]); + + const onOpenDeleteTimelineModal = useCallback((selectedActionItem?: OpenTimelineResult) => { + setIsDeleteTimelineModalOpen(true); + if (selectedActionItem != null) { + setActionTimeline(selectedActionItem); + } + }, []); + + // Handle Downloader Modal + const enableExportTimelineDownloader = useCallback((selectedActionItem?: OpenTimelineResult) => { + setIsEnableDownloader(true); + if (selectedActionItem != null) { + setActionTimeline(selectedActionItem); + } + }, []); + + const disableExportTimelineDownloader = useCallback(() => { + setIsEnableDownloader(false); + setActionTimeline(null); + }, []); + + // On Compete every tasks + const onCompleteEditTimelineAction = useCallback(() => { + setIsDeleteTimelineModalOpen(false); + setIsEnableDownloader(false); + setActionTimeline(null); + }, []); + + return { + actionItem, + onCompleteEditTimelineAction, + isDeleteTimelineModalOpen, + onCloseDeleteTimelineModal, + onOpenDeleteTimelineModal, + isEnableDownloader, + enableExportTimelineDownloader, + disableExportTimelineDownloader, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx new file mode 100644 index 00000000000000..74b9a8cad98dc9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx @@ -0,0 +1,116 @@ +/* + * 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 { EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { isEmpty } from 'lodash/fp'; +import * as i18n from './translations'; +import { DeleteTimelines, OpenTimelineResult } from './types'; +import { EditTimelineActions } from './export_timeline'; +import { useEditTimelineActions } from './edit_timeline_actions'; + +const getExportedIds = (selectedTimelines: OpenTimelineResult[]) => { + const array = Array.isArray(selectedTimelines) ? selectedTimelines : [selectedTimelines]; + return array.reduce( + (acc, item) => (item.savedObjectId != null ? [...acc, item.savedObjectId] : [...acc]), + [] as string[] + ); +}; + +export const useEditTimelinBatchActions = ({ + deleteTimelines, + selectedItems, + tableRef, +}: { + deleteTimelines?: DeleteTimelines; + selectedItems?: OpenTimelineResult[]; + tableRef: React.MutableRefObject | undefined>; +}) => { + const { + enableExportTimelineDownloader, + disableExportTimelineDownloader, + isEnableDownloader, + isDeleteTimelineModalOpen, + onOpenDeleteTimelineModal, + onCloseDeleteTimelineModal, + } = useEditTimelineActions(); + + const onCompleteBatchActions = useCallback( + (closePopover?: () => void) => { + if (closePopover != null) closePopover(); + if (tableRef != null && tableRef.current != null) { + tableRef.current.changeSelection([]); + } + disableExportTimelineDownloader(); + onCloseDeleteTimelineModal(); + }, + [disableExportTimelineDownloader, onCloseDeleteTimelineModal, tableRef.current] + ); + + const selectedIds = useMemo(() => getExportedIds(selectedItems ?? []), [selectedItems]); + + const handleEnableExportTimelineDownloader = useCallback(() => enableExportTimelineDownloader(), [ + enableExportTimelineDownloader, + ]); + + const handleOnOpenDeleteTimelineModal = useCallback(() => onOpenDeleteTimelineModal(), [ + onOpenDeleteTimelineModal, + ]); + + const getBatchItemsPopoverContent = useCallback( + (closePopover: () => void) => { + const isDisabled = isEmpty(selectedItems); + return ( + <> + + + + {i18n.EXPORT_SELECTED} + , + + {i18n.DELETE_SELECTED} + , + ]} + /> + + ); + }, + [ + deleteTimelines, + isEnableDownloader, + isDeleteTimelineModalOpen, + selectedIds, + selectedItems, + handleEnableExportTimelineDownloader, + handleOnOpenDeleteTimelineModal, + onCompleteBatchActions, + ] + ); + return { onCompleteBatchActions, getBatchItemsPopoverContent }; +}; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx new file mode 100644 index 00000000000000..d377b10a55c213 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 { TimelineDownloader } from './export_timeline'; +import { mockSelectedTimeline } from './mocks'; +import { ReactWrapper, mount } from 'enzyme'; +import { useExportTimeline } from '.'; + +jest.mock('../translations', () => { + return { + EXPORT_SELECTED: 'EXPORT_SELECTED', + EXPORT_FILENAME: 'TIMELINE', + }; +}); + +jest.mock('.', () => { + return { + useExportTimeline: jest.fn(), + }; +}); + +describe('TimelineDownloader', () => { + let wrapper: ReactWrapper; + const defaultTestProps = { + exportedIds: ['baa20980-6301-11ea-9223-95b6d4dd806c'], + getExportedData: jest.fn(), + isEnableDownloader: true, + onComplete: jest.fn(), + }; + describe('should not render a downloader', () => { + beforeAll(() => { + ((useExportTimeline as unknown) as jest.Mock).mockReturnValue({ + enableDownloader: false, + setEnableDownloader: jest.fn(), + exportedIds: {}, + getExportedData: jest.fn(), + }); + }); + + afterAll(() => { + ((useExportTimeline as unknown) as jest.Mock).mockReset(); + }); + + test('Without exportedIds', () => { + const testProps = { + ...defaultTestProps, + exportedIds: undefined, + }; + wrapper = mount(); + expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeFalsy(); + }); + + test('With isEnableDownloader is false', () => { + const testProps = { + ...defaultTestProps, + isEnableDownloader: false, + }; + wrapper = mount(); + expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeFalsy(); + }); + }); + + describe('should render a downloader', () => { + beforeAll(() => { + ((useExportTimeline as unknown) as jest.Mock).mockReturnValue({ + enableDownloader: false, + setEnableDownloader: jest.fn(), + exportedIds: {}, + getExportedData: jest.fn(), + }); + }); + + afterAll(() => { + ((useExportTimeline as unknown) as jest.Mock).mockReset(); + }); + + test('With selectedItems and exportedIds is given and isEnableDownloader is true', () => { + const testProps = { + ...defaultTestProps, + selectedItems: mockSelectedTimeline, + }; + wrapper = mount(); + expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx new file mode 100644 index 00000000000000..ebfd5c18bd5dc2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx @@ -0,0 +1,59 @@ +/* + * 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 uuid from 'uuid'; +import { GenericDownloader, ExportSelectedData } from '../../generic_downloader'; +import * as i18n from '../translations'; +import { useStateToaster } from '../../toasters'; + +const ExportTimeline: React.FC<{ + exportedIds: string[] | undefined; + getExportedData: ExportSelectedData; + isEnableDownloader: boolean; + onComplete?: () => void; +}> = ({ onComplete, isEnableDownloader, exportedIds, getExportedData }) => { + const [, dispatchToaster] = useStateToaster(); + const onExportSuccess = useCallback( + exportCount => { + if (onComplete != null) { + onComplete(); + } + dispatchToaster({ + type: 'addToaster', + toast: { + id: uuid.v4(), + title: i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), + color: 'success', + iconType: 'check', + }, + }); + }, + [dispatchToaster, onComplete] + ); + const onExportFailure = useCallback(() => { + if (onComplete != null) { + onComplete(); + } + }, [onComplete]); + + return ( + <> + {exportedIds != null && isEnableDownloader && ( + + )} + + ); +}; +ExportTimeline.displayName = 'ExportTimeline'; +export const TimelineDownloader = React.memo(ExportTimeline); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx new file mode 100644 index 00000000000000..674cd6dad5f76f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx @@ -0,0 +1,35 @@ +/* + * 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 { mount } from 'enzyme'; +import { useExportTimeline, ExportTimeline } from '.'; + +describe('useExportTimeline', () => { + describe('call with selected timelines', () => { + let exportTimelineRes: ExportTimeline; + const TestHook = () => { + exportTimelineRes = useExportTimeline(); + return
      ; + }; + + beforeAll(() => { + mount(); + }); + + test('Downloader should be disabled by default', () => { + expect(exportTimelineRes.isEnableDownloader).toBeFalsy(); + }); + + test('Should include disableExportTimelineDownloader in return value', () => { + expect(exportTimelineRes).toHaveProperty('disableExportTimelineDownloader'); + }); + + test('Should include enableExportTimelineDownloader in return value', () => { + expect(exportTimelineRes).toHaveProperty('enableExportTimelineDownloader'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx new file mode 100644 index 00000000000000..946c4b3a612dd1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.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, { useState, useCallback } from 'react'; +import { DeleteTimelines } from '../types'; + +import { TimelineDownloader } from './export_timeline'; +import { DeleteTimelineModalOverlay } from '../delete_timeline_modal'; +import { exportSelectedTimeline } from '../../../containers/timeline/all/api'; + +export interface ExportTimeline { + disableExportTimelineDownloader: () => void; + enableExportTimelineDownloader: () => void; + isEnableDownloader: boolean; +} + +export const useExportTimeline = (): ExportTimeline => { + const [isEnableDownloader, setIsEnableDownloader] = useState(false); + + const enableExportTimelineDownloader = useCallback(() => { + setIsEnableDownloader(true); + }, []); + + const disableExportTimelineDownloader = useCallback(() => { + setIsEnableDownloader(false); + }, []); + + return { + disableExportTimelineDownloader, + enableExportTimelineDownloader, + isEnableDownloader, + }; +}; + +const EditTimelineActionsComponent: React.FC<{ + deleteTimelines: DeleteTimelines | undefined; + ids: string[]; + isEnableDownloader: boolean; + isDeleteTimelineModalOpen: boolean; + onComplete: () => void; + title: string; +}> = ({ + deleteTimelines, + ids, + isEnableDownloader, + isDeleteTimelineModalOpen, + onComplete, + title, +}) => ( + <> + + {deleteTimelines != null && ( + + )} + +); + +export const EditTimelineActions = React.memo(EditTimelineActionsComponent); +export const EditOneTimelineAction = React.memo(EditTimelineActionsComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/mocks.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/mocks.ts new file mode 100644 index 00000000000000..34d763839003c6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/mocks.ts @@ -0,0 +1,99 @@ +/* + * 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 const mockSelectedTimeline = [ + { + savedObjectId: 'baa20980-6301-11ea-9223-95b6d4dd806c', + version: 'WzExNzAsMV0=', + columns: [ + { + columnHeaderType: 'not-filtered', + indexes: null, + id: '@timestamp', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'message', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'event.category', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'event.action', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'host.name', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'source.ip', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'destination.ip', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'user.name', + name: null, + searchable: null, + }, + ], + dataProviders: [], + description: 'with a global note', + eventType: 'raw', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { kind: 'kuery', expression: 'zeek.files.sha1 : * ' }, + serializedQuery: + '{"bool":{"should":[{"exists":{"field":"zeek.files.sha1"}}],"minimum_should_match":1}}', + }, + }, + title: 'duplicate timeline', + dateRange: { start: 1582538951145, end: 1582625351145 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1583866966262, + createdBy: 'elastic', + updated: 1583866966262, + updatedBy: 'elastic', + notes: [ + { + noteId: 'noteIdOne', + }, + { + noteId: 'noteIdTwo', + }, + ], + pinnedEventIds: { '23D_e3ABGy2SlgJPuyEh': true, eHD_e3ABGy2SlgJPsh4u: true }, + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx index 520e2094fb3364..04f0abe0d00d17 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx @@ -526,11 +526,6 @@ describe('StatefulOpenTimeline', () => { .first() .simulate('change', { target: { checked: true } }); expect(getSelectedItem().length).toEqual(13); - wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .simulate('click'); - expect(getSelectedItem().length).toEqual(0); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx index 26a7487fee52b5..6d00edf28a88f9 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx @@ -256,7 +256,7 @@ export const StatefulOpenTimelineComponent = React.memo( sort={{ sortField: sortField as SortFieldTimeline, sortOrder: sortDirection as Direction }} onlyUserFavorite={onlyFavorites} > - {({ timelines, loading, totalCount }) => { + {({ timelines, loading, totalCount, refetch }) => { return !isModal ? ( ( pageIndex={pageIndex} pageSize={pageSize} query={search} + refetch={refetch} searchResults={timelines} selectedItems={selectedItems} sortDirection={sortDirection} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx index a1ca7812bba340..e010d54d711c3e 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx @@ -290,4 +290,270 @@ describe('OpenTimeline', () => { expect(props.actionTimelineToShow).not.toContain('delete'); }); + + test('it renders an empty string when the query is an empty string', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="selectable-query-text"]') + .first() + .text() + ).toEqual(''); + }); + + test('it renders the expected message when the query just has spaces', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="selectable-query-text"]') + .first() + .text() + ).toEqual(''); + }); + + test('it echos the query when the query has non-whitespace characters', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="selectable-query-text"]') + .first() + .text() + ).toContain('Would you like to go to Denver?'); + }); + + test('trims whitespace from the ends of the query', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="selectable-query-text"]') + .first() + .text() + ).toContain('Is it starting to feel cramped in here?'); + }); + + test('it renders the expected message when the query is an empty string', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="query-message"]') + .first() + .text() + ).toContain(`Showing: ${mockResults.length} timelines `); + }); + + test('it renders the expected message when the query just has whitespace', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="query-message"]') + .first() + .text() + ).toContain(`Showing: ${mockResults.length} timelines `); + }); + + test('it includes the word "with" when the query has non-whitespace characters', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="query-message"]') + .first() + .text() + ).toContain(`Showing: ${mockResults.length} timelines with "How was your day?"`); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 8aab02b495392f..b1b100349eb86f 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -4,15 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiPanel } from '@elastic/eui'; -import React from 'react'; - +import { EuiPanel, EuiBasicTable } from '@elastic/eui'; +import React, { useCallback, useMemo, useRef } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; -import { OpenTimelineProps } from './types'; +import { OpenTimelineProps, OpenTimelineResult } from './types'; import { SearchRow } from './search_row'; import { TimelinesTable } from './timelines_table'; import { TitleRow } from './title_row'; +import * as i18n from './translations'; +import { + UtilityBarGroup, + UtilityBarText, + UtilityBar, + UtilityBarSection, + UtilityBarAction, +} from '../utility_bar'; +import { useEditTimelinBatchActions } from './edit_timeline_batch_actions'; +import { useEditTimelineActions } from './edit_timeline_actions'; +import { EditOneTimelineAction } from './export_timeline'; + export const OpenTimeline = React.memo( ({ deleteTimelines, @@ -31,56 +43,145 @@ export const OpenTimeline = React.memo( pageIndex, pageSize, query, + refetch, searchResults, selectedItems, sortDirection, sortField, title, totalSearchResultsCount, - }) => ( - - + }) => { + const tableRef = useRef>(); + + const { + actionItem, + enableExportTimelineDownloader, + isEnableDownloader, + isDeleteTimelineModalOpen, + onOpenDeleteTimelineModal, + onCompleteEditTimelineAction, + } = useEditTimelineActions(); + + const { getBatchItemsPopoverContent } = useEditTimelinBatchActions({ + deleteTimelines, + selectedItems, + tableRef, + }); + + const nTimelines = useMemo( + () => ( + + {query.trim().length ? `${i18n.WITH} "${query.trim()}"` : ''} + + ), + }} + /> + ), + [totalSearchResultsCount, query] + ); + + const actionItemId = useMemo( + () => + actionItem != null && actionItem.savedObjectId != null ? [actionItem.savedObjectId] : [], + [actionItem] + ); + + const onRefreshBtnClick = useCallback(() => { + if (typeof refetch === 'function') refetch(); + }, [refetch]); + + return ( + <> + + + + + + + + + + + + <> + {i18n.SHOWING} {nTimelines} + + + - + + {i18n.SELECTED_TIMELINES(selectedItems.length)} + + {i18n.BATCH_ACTIONS} + + + {i18n.REFRESH} + + + + - - - ) + + onDeleteSelected != null && deleteTimelines != null + ? ['delete', 'duplicate', 'export', 'selectable'] + : ['duplicate', 'export', 'selectable'], + [onDeleteSelected, deleteTimelines] + )} + data-test-subj="timelines-table" + deleteTimelines={deleteTimelines} + defaultPageSize={defaultPageSize} + loading={isLoading} + itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} + enableExportTimelineDownloader={enableExportTimelineDownloader} + onOpenDeleteTimelineModal={onOpenDeleteTimelineModal} + onOpenTimeline={onOpenTimeline} + onSelectionChange={onSelectionChange} + onTableChange={onTableChange} + onToggleShowNotes={onToggleShowNotes} + pageIndex={pageIndex} + pageSize={pageSize} + searchResults={searchResults} + showExtendedColumns={true} + sortDirection={sortDirection} + sortField={sortField} + tableRef={tableRef} + totalSearchResultsCount={totalSearchResultsCount} + /> + + + ); + } ); OpenTimeline.displayName = 'OpenTimeline'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx index dcd0b377705830..60ebf2118d556f 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx @@ -58,7 +58,6 @@ export const OpenTimelineModalBody = memo( { expect(onQueryChange).toHaveBeenCalled(); }); }); - - describe('Showing message', () => { - test('it renders the expected message when the query is an empty string', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="query-message"]') - .first() - .text() - ).toContain('Showing: 32 timelines '); - }); - - test('it renders the expected message when the query just has whitespace', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="query-message"]') - .first() - .text() - ).toContain('Showing: 32 timelines '); - }); - - test('it includes the word "with" when the query has non-whitespace characters', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="query-message"]') - .first() - .text() - ).toContain('Showing: 32 timelines with'); - }); - }); - - describe('selectable query text', () => { - test('it renders an empty string when the query is an empty string', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="selectable-query-text"]') - .first() - .text() - ).toEqual(''); - }); - - test('it renders the expected message when the query just has spaces', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="selectable-query-text"]') - .first() - .text() - ).toEqual(''); - }); - - test('it echos the query when the query has non-whitespace characters', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="selectable-query-text"]') - .first() - .text() - ).toContain('Would you like to go to Denver?'); - }); - - test('trims whitespace from the ends of the query', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="selectable-query-text"]') - .first() - .text() - ).toContain('Is it starting to feel cramped in here?'); - }); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx index 5765d31078bcf7..55fce1f1c1ed07 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx @@ -11,9 +11,7 @@ import { EuiFlexItem, // @ts-ignore EuiSearchBar, - EuiText, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import styled from 'styled-components'; @@ -39,56 +37,38 @@ type Props = Pick< 'onlyFavorites' | 'onQueryChange' | 'onToggleOnlyFavorites' | 'query' | 'totalSearchResultsCount' >; +const searchBox = { + placeholder: i18n.SEARCH_PLACEHOLDER, + incremental: false, +}; + /** * Renders the row containing the search input and Only Favorites filter */ export const SearchRow = React.memo( - ({ onlyFavorites, onQueryChange, onToggleOnlyFavorites, query, totalSearchResultsCount }) => ( - - - - - - - - - - {i18n.ONLY_FAVORITES} - - - - + ({ onlyFavorites, onQueryChange, onToggleOnlyFavorites, query, totalSearchResultsCount }) => { + return ( + + + + + - -

      - - {query.trim().length ? `${i18n.WITH} "${query.trim()}"` : ''} - - ), - }} - /> -

      -
      -
      - ) + + + + {i18n.ONLY_FAVORITES} + + + + +
      + ); + } ); SearchRow.displayName = 'SearchRow'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx index eec11f571328f5..ca82e30798d824 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx @@ -11,14 +11,15 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; -import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { OpenTimelineResult } from '../types'; -import { TimelinesTable } from '.'; -import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; +import { TimelinesTableProps } from '.'; +import { getMockTimelinesTableProps } from './mocks'; jest.mock('../../../lib/kibana'); +const { TimelinesTable } = jest.requireActual('.'); + describe('#getActionsColumns', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; @@ -28,26 +29,13 @@ describe('#getActionsColumns', () => { }); test('it renders the delete timeline (trash icon) when actionTimelineToShow is including the action delete', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: ['delete'], + }; const wrapper = mountWithIntl( - + ); @@ -55,26 +43,13 @@ describe('#getActionsColumns', () => { }); test('it does NOT render the delete timeline (trash icon) when actionTimelineToShow is NOT including the action delete', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: [], + }; const wrapper = mountWithIntl( - + ); @@ -82,26 +57,13 @@ describe('#getActionsColumns', () => { }); test('it renders the duplicate icon timeline when actionTimelineToShow is including the action duplicate', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: ['duplicate'], + }; const wrapper = mountWithIntl( - + ); @@ -109,26 +71,13 @@ describe('#getActionsColumns', () => { }); test('it does NOT render the duplicate timeline when actionTimelineToShow is NOT including the action duplicate)', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: [], + }; const wrapper = mountWithIntl( - + ); @@ -136,25 +85,13 @@ describe('#getActionsColumns', () => { }); test('it does NOT render the delete timeline (trash icon) when deleteTimelines is not provided', () => { + const testProps: TimelinesTableProps = { + ...omit('deleteTimelines', getMockTimelinesTableProps(mockResults)), + actionTimelineToShow: ['delete'], + }; const wrapper = mountWithIntl( - + ); @@ -166,56 +103,29 @@ describe('#getActionsColumns', () => { omit('savedObjectId', { ...mockResults[0] }), ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(missingSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); const props = wrapper .find('[data-test-subj="open-duplicate"]') .first() .props() as EuiButtonIconProps; - expect(props.isDisabled).toBe(true); }); test('it renders an enabled the open duplicate button if the timeline has have a saved object id', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + }; const wrapper = mountWithIntl( - + ); @@ -229,27 +139,13 @@ describe('#getActionsColumns', () => { test('it invokes onOpenTimeline with the expected params when the button is clicked', () => { const onOpenTimeline = jest.fn(); - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + onOpenTimeline, + }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx index 2b8bd3339cca24..4bbf98dafe38df 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx @@ -6,76 +6,78 @@ /* eslint-disable react/display-name */ -import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; -import React from 'react'; - -import { ACTION_COLUMN_WIDTH } from './common_styles'; -import { DeleteTimelineModalButton } from '../delete_timeline_modal'; -import * as i18n from '../translations'; import { ActionTimelineToShow, DeleteTimelines, + EnableExportTimelineDownloader, OnOpenTimeline, OpenTimelineResult, + OnOpenDeleteTimelineModal, + TimelineActionsOverflowColumns, } from '../types'; +import * as i18n from '../translations'; /** * Returns the action columns (e.g. delete, open duplicate timeline) */ export const getActionsColumns = ({ actionTimelineToShow, - onOpenTimeline, deleteTimelines, + enableExportTimelineDownloader, + onOpenDeleteTimelineModal, + onOpenTimeline, }: { actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; + enableExportTimelineDownloader?: EnableExportTimelineDownloader; + onOpenDeleteTimelineModal?: OnOpenDeleteTimelineModal; onOpenTimeline: OnOpenTimeline; -}) => { +}): [TimelineActionsOverflowColumns] => { const openAsDuplicateColumn = { - align: 'center', - field: 'savedObjectId', - name: '', - render: (savedObjectId: string, timelineResult: OpenTimelineResult) => ( - - - onOpenTimeline({ - duplicate: true, - timelineId: `${timelineResult.savedObjectId}`, - }) - } - size="s" - /> - - ), - sortable: false, - width: ACTION_COLUMN_WIDTH, + name: i18n.OPEN_AS_DUPLICATE, + icon: 'copy', + onClick: ({ savedObjectId }: OpenTimelineResult) => { + onOpenTimeline({ + duplicate: true, + timelineId: savedObjectId ?? '', + }); + }, + enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, + description: i18n.OPEN_AS_DUPLICATE, + 'data-test-subj': 'open-duplicate', + }; + + const exportTimelineAction = { + name: i18n.EXPORT_SELECTED, + icon: 'exportAction', + onClick: (selectedTimeline: OpenTimelineResult) => { + if (enableExportTimelineDownloader != null) enableExportTimelineDownloader(selectedTimeline); + }, + enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, + description: i18n.EXPORT_SELECTED, }; const deleteTimelineColumn = { - align: 'center', - field: 'savedObjectId', - name: '', - render: (savedObjectId: string, { title }: OpenTimelineResult) => ( - - ), - sortable: false, - width: ACTION_COLUMN_WIDTH, + name: i18n.DELETE_SELECTED, + icon: 'trash', + onClick: (selectedTimeline: OpenTimelineResult) => { + if (onOpenDeleteTimelineModal != null) onOpenDeleteTimelineModal(selectedTimeline); + }, + enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, + description: i18n.DELETE_SELECTED, + 'data-test-subj': 'delete-timeline', }; return [ - actionTimelineToShow.includes('duplicate') ? openAsDuplicateColumn : null, - actionTimelineToShow.includes('delete') && deleteTimelines != null - ? deleteTimelineColumn - : null, - ].filter(action => action != null); + { + width: '40px', + actions: [ + actionTimelineToShow.includes('duplicate') ? openAsDuplicateColumn : null, + actionTimelineToShow.includes('export') ? exportTimelineAction : null, + actionTimelineToShow.includes('delete') && deleteTimelines != null + ? deleteTimelineColumn + : null, + ].filter(action => action != null), + }, + ]; }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx index 0f2cda9d79f0b1..093e4a5bab1006 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx @@ -11,15 +11,14 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { getEmptyValue } from '../../empty_value'; import { OpenTimelineResult } from '../types'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { NotePreviews } from '../note_previews'; -import { TimelinesTable } from '.'; +import { TimelinesTable, TimelinesTableProps } from '.'; import * as i18n from '../translations'; -import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; +import { getMockTimelinesTableProps } from './mocks'; jest.mock('../../../lib/kibana'); @@ -35,25 +34,13 @@ describe('#getCommonColumns', () => { test('it renders the expand button when the timelineResult has notes', () => { const hasNotes: OpenTimelineResult[] = [{ ...mockResults[0] }]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(hasNotes), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(true); @@ -62,25 +49,13 @@ describe('#getCommonColumns', () => { test('it does NOT render the expand button when the timelineResult notes are undefined', () => { const missingNotes: OpenTimelineResult[] = [omit('notes', { ...mockResults[0] })]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(missingNotes), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); @@ -89,25 +64,13 @@ describe('#getCommonColumns', () => { test('it does NOT render the expand button when the timelineResult notes are null', () => { const nullNotes: OpenTimelineResult[] = [{ ...mockResults[0], notes: null }]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(nullNotes), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); @@ -116,25 +79,13 @@ describe('#getCommonColumns', () => { test('it does NOT render the expand button when the notes are empty', () => { const emptylNotes: OpenTimelineResult[] = [{ ...mockResults[0], notes: [] }]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(emptylNotes), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); @@ -144,26 +95,13 @@ describe('#getCommonColumns', () => { const missingSavedObjectId: OpenTimelineResult[] = [ omit('savedObjectId', { ...mockResults[0] }), ]; - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(missingSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); @@ -172,25 +110,13 @@ describe('#getCommonColumns', () => { test('it does NOT render the expand button when the timelineResult savedObjectId is null', () => { const nullSavedObjectId: OpenTimelineResult[] = [{ ...mockResults[0], savedObjectId: null }]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(nullSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); @@ -199,25 +125,13 @@ describe('#getCommonColumns', () => { test('it renders the right arrow expander when the row is not expanded', () => { const hasNotes: OpenTimelineResult[] = [{ ...mockResults[0] }]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(hasNotes), + }; const wrapper = mountWithIntl( - + + + ); const props = wrapper @@ -235,26 +149,13 @@ describe('#getCommonColumns', () => { [mockResults[0].savedObjectId!]: , }; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(hasNotes), + itemIdToExpandedNotesRowMap, + }; const wrapper = mountWithIntl( - + ); @@ -275,25 +176,15 @@ describe('#getCommonColumns', () => { abc:
      , }; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(hasNotes), + itemIdToExpandedNotesRowMap, + onToggleShowNotes, + }; const wrapper = mountWithIntl( - + + + ); wrapper @@ -317,26 +208,14 @@ describe('#getCommonColumns', () => { 'saved-timeline-11': , }; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(hasNotes), + itemIdToExpandedNotesRowMap, + onToggleShowNotes, + }; const wrapper = mountWithIntl( - + ); @@ -353,26 +232,12 @@ describe('#getCommonColumns', () => { describe('Timeline Name column', () => { test('it renders the expected column name', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + }; const wrapper = mountWithIntl( - + ); @@ -385,26 +250,12 @@ describe('#getCommonColumns', () => { }); test('it renders the title when the timeline has a title and a saved object id', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + }; const wrapper = mountWithIntl( - + ); @@ -421,25 +272,13 @@ describe('#getCommonColumns', () => { omit('savedObjectId', { ...mockResults[0] }), ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(missingSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -453,25 +292,13 @@ describe('#getCommonColumns', () => { test('it renders an Untitled Timeline title when the timeline has no title and a saved object id', () => { const missingTitle: OpenTimelineResult[] = [omit('title', { ...mockResults[0] })]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(missingTitle), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -487,25 +314,13 @@ describe('#getCommonColumns', () => { omit(['title', 'savedObjectId'], { ...mockResults[0] }), ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(withMissingSavedObjectIdAndTitle), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -521,25 +336,13 @@ describe('#getCommonColumns', () => { { ...mockResults[0], title: ' ' }, ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(withJustWhitespaceTitle), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -555,25 +358,13 @@ describe('#getCommonColumns', () => { omit('savedObjectId', { ...mockResults[0], title: ' ' }), ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(withMissingSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -587,24 +378,7 @@ describe('#getCommonColumns', () => { test('it renders a hyperlink when the timeline has a saved object id', () => { const wrapper = mountWithIntl( - + ); @@ -621,25 +395,13 @@ describe('#getCommonColumns', () => { omit('savedObjectId', { ...mockResults[0] }), ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(missingSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -653,26 +415,13 @@ describe('#getCommonColumns', () => { test('it invokes `onOpenTimeline` when the hyperlink is clicked', () => { const onOpenTimeline = jest.fn(); + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + onOpenTimeline, + }; const wrapper = mountWithIntl( - + ); @@ -692,24 +441,7 @@ describe('#getCommonColumns', () => { test('it renders the expected column name', () => { const wrapper = mountWithIntl( - + ); @@ -724,24 +456,7 @@ describe('#getCommonColumns', () => { test('it renders the description when the timeline has a description', () => { const wrapper = mountWithIntl( - + ); @@ -758,24 +473,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( - + ); expect( @@ -791,26 +489,12 @@ describe('#getCommonColumns', () => { { ...mockResults[0], description: ' ' }, ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(justWhitespaceDescription), + }; const wrapper = mountWithIntl( - + ); expect( @@ -826,24 +510,7 @@ describe('#getCommonColumns', () => { test('it renders the expected column name', () => { const wrapper = mountWithIntl( - + ); @@ -858,24 +525,7 @@ describe('#getCommonColumns', () => { test('it renders the last modified (updated) date when the timeline has an updated property', () => { const wrapper = mountWithIntl( - + ); @@ -893,24 +543,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( - + ); expect( diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx index 4cbe1e45c473b9..3960d087651265 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx @@ -10,15 +10,14 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; -import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { getEmptyValue } from '../../empty_value'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { OpenTimelineResult } from '../types'; -import { TimelinesTable } from '.'; +import { TimelinesTable, TimelinesTableProps } from '.'; import * as i18n from '../translations'; -import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; +import { getMockTimelinesTableProps } from './mocks'; jest.mock('../../../lib/kibana'); @@ -32,26 +31,12 @@ describe('#getExtendedColumns', () => { describe('Modified By column', () => { test('it renders the expected column name', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + }; const wrapper = mountWithIntl( - + ); @@ -64,26 +49,12 @@ describe('#getExtendedColumns', () => { }); test('it renders the username when the timeline has an updatedBy property', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + }; const wrapper = mountWithIntl( - + ); @@ -97,27 +68,12 @@ describe('#getExtendedColumns', () => { test('it renders a placeholder when the timeline is missing the updatedBy property', () => { const missingUpdatedBy: OpenTimelineResult[] = [omit('updatedBy', { ...mockResults[0] })]; - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(missingUpdatedBy), + }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx index 31377d176acac9..658dd96faa9864 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx @@ -10,12 +10,10 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; -import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { mockTimelineResults } from '../../../mock/timeline_results'; -import { TimelinesTable } from '.'; +import { TimelinesTable, TimelinesTableProps } from '.'; import { OpenTimelineResult } from '../types'; -import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; - +import { getMockTimelinesTableProps } from './mocks'; jest.mock('../../../lib/kibana'); describe('#getActionsColumns', () => { @@ -29,24 +27,7 @@ describe('#getActionsColumns', () => { test('it renders the pinned events header icon', () => { const wrapper = mountWithIntl( - + ); @@ -55,26 +36,13 @@ describe('#getActionsColumns', () => { test('it renders the expected pinned events count', () => { const with6Events = [mockResults[0]]; - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(with6Events), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="pinned-event-count"]').text()).toEqual('6'); @@ -83,24 +51,7 @@ describe('#getActionsColumns', () => { test('it renders the notes count header icon', () => { const wrapper = mountWithIntl( - + ); @@ -109,26 +60,13 @@ describe('#getActionsColumns', () => { test('it renders the expected notes count', () => { const with4Notes = [mockResults[0]]; - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(with4Notes), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="notes-count"]').text()).toEqual('4'); @@ -137,24 +75,7 @@ describe('#getActionsColumns', () => { test('it renders the favorites header icon', () => { const wrapper = mountWithIntl( - + ); @@ -163,26 +84,13 @@ describe('#getActionsColumns', () => { test('it renders an empty star when favorite is undefined', () => { const undefinedFavorite: OpenTimelineResult[] = [omit('favorite', { ...mockResults[0] })]; - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(undefinedFavorite), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="favorite-starEmpty-star"]').exists()).toBe(true); @@ -190,26 +98,13 @@ describe('#getActionsColumns', () => { test('it renders an empty star when favorite is null', () => { const nullFavorite: OpenTimelineResult[] = [{ ...mockResults[0], favorite: null }]; - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(nullFavorite), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="favorite-starEmpty-star"]').exists()).toBe(true); @@ -217,33 +112,20 @@ describe('#getActionsColumns', () => { test('it renders an empty star when favorite is empty', () => { const emptyFavorite: OpenTimelineResult[] = [{ ...mockResults[0], favorite: [] }]; - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(emptyFavorite), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="favorite-starEmpty-star"]').exists()).toBe(true); }); test('it renders an filled star when favorite has one entry', () => { - const emptyFavorite: OpenTimelineResult[] = [ + const favorite: OpenTimelineResult[] = [ { ...mockResults[0], favorite: [ @@ -255,32 +137,20 @@ describe('#getActionsColumns', () => { }, ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(favorite), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="favorite-starFilled-star"]').exists()).toBe(true); }); test('it renders an filled star when favorite has more than one entry', () => { - const emptyFavorite: OpenTimelineResult[] = [ + const favorite: OpenTimelineResult[] = [ { ...mockResults[0], favorite: [ @@ -296,25 +166,13 @@ describe('#getActionsColumns', () => { }, ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(favorite), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="favorite-starFilled-star"]').exists()).toBe(true); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx index 9463bf7de28c1d..e124f58a0c9890 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx @@ -10,13 +10,12 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; -import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { OpenTimelineResult } from '../types'; import { TimelinesTable, TimelinesTableProps } from '.'; +import { getMockTimelinesTableProps } from './mocks'; import * as i18n from '../translations'; -import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; jest.mock('../../../lib/kibana'); @@ -31,24 +30,7 @@ describe('TimelinesTable', () => { test('it renders the select all timelines header checkbox when actionTimelineToShow has the action selectable', () => { const wrapper = mountWithIntl( - + ); @@ -61,26 +43,13 @@ describe('TimelinesTable', () => { }); test('it does NOT render the select all timelines header checkbox when actionTimelineToShow has not the action selectable', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: ['delete', 'duplicate'], + }; const wrapper = mountWithIntl( - + ); @@ -93,26 +62,13 @@ describe('TimelinesTable', () => { }); test('it renders the Modified By column when showExtendedColumns is true ', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + showExtendedColumns: true, + }; const wrapper = mountWithIntl( - + ); @@ -125,33 +81,20 @@ describe('TimelinesTable', () => { }); test('it renders the notes column in the position of the Modified By column when showExtendedColumns is false', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + showExtendedColumns: false, + }; const wrapper = mountWithIntl( - + ); expect( wrapper .find('thead tr th') - .at(5) + .at(6) .find('[data-test-subj="notes-count-header-icon"]') .first() .exists() @@ -161,24 +104,7 @@ describe('TimelinesTable', () => { test('it renders the delete timeline (trash icon) when actionTimelineToShow has the delete action', () => { const wrapper = mountWithIntl( - + ); @@ -191,26 +117,13 @@ describe('TimelinesTable', () => { }); test('it does NOT render the delete timeline (trash icon) when actionTimelineToShow has NOT the delete action', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: ['duplicate', 'selectable'], + }; const wrapper = mountWithIntl( - + ); @@ -225,24 +138,7 @@ describe('TimelinesTable', () => { test('it renders the rows per page selector when showExtendedColumns is true', () => { const wrapper = mountWithIntl( - + ); @@ -255,26 +151,13 @@ describe('TimelinesTable', () => { }); test('it does NOT render the rows per page selector when showExtendedColumns is false', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + showExtendedColumns: false, + }; const wrapper = mountWithIntl( - + ); @@ -288,27 +171,14 @@ describe('TimelinesTable', () => { test('it renders the default page size specified by the defaultPageSize prop', () => { const defaultPageSize = 123; - + const testProps = { + ...getMockTimelinesTableProps(mockResults), + defaultPageSize, + pageSize: defaultPageSize, + }; const wrapper = mountWithIntl( - + ); @@ -323,24 +193,7 @@ describe('TimelinesTable', () => { test('it sorts the Last Modified column in descending order when showExtendedColumns is true ', () => { const wrapper = mountWithIntl( - + ); @@ -353,26 +206,13 @@ describe('TimelinesTable', () => { }); test('it sorts the Last Modified column in descending order when showExtendedColumns is false ', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + showExtendedColumns: false, + }; const wrapper = mountWithIntl( - + ); @@ -385,25 +225,14 @@ describe('TimelinesTable', () => { }); test('it displays the expected message when no search results are found', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + searchResults: [], + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -416,27 +245,13 @@ describe('TimelinesTable', () => { test('it invokes onTableChange with the expected parameters when a table header is clicked to sort it', () => { const onTableChange = jest.fn(); - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + onTableChange, + }; const wrapper = mountWithIntl( - + ); @@ -455,27 +270,13 @@ describe('TimelinesTable', () => { test('it invokes onSelectionChange when a row is selected', () => { const onSelectionChange = jest.fn(); - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + onSelectionChange, + }; const wrapper = mountWithIntl( - + ); @@ -490,26 +291,13 @@ describe('TimelinesTable', () => { }); test('it enables the table loading animation when isLoading is true', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + loading: true, + }; const wrapper = mountWithIntl( - + ); @@ -524,24 +312,7 @@ describe('TimelinesTable', () => { test('it disables the table loading animation when isLoading is false', () => { const wrapper = mountWithIntl( - + ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx index f09a9f6af048b5..7091ef1f0a1f9c 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx @@ -17,6 +17,8 @@ import { OnTableChange, OnToggleShowNotes, OpenTimelineResult, + EnableExportTimelineDownloader, + OnOpenDeleteTimelineModal, } from '../types'; import { getActionsColumns } from './actions_columns'; import { getCommonColumns } from './common_columns'; @@ -46,34 +48,44 @@ const getExtendedColumnsIfEnabled = (showExtendedColumns: boolean) => * view, and the full view shown in the `All Timelines` view of the * `Timelines` page */ -const getTimelinesTableColumns = ({ + +export const getTimelinesTableColumns = ({ actionTimelineToShow, deleteTimelines, + enableExportTimelineDownloader, itemIdToExpandedNotesRowMap, + onOpenDeleteTimelineModal, onOpenTimeline, onToggleShowNotes, showExtendedColumns, }: { actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; + enableExportTimelineDownloader?: EnableExportTimelineDownloader; itemIdToExpandedNotesRowMap: Record; + onOpenDeleteTimelineModal?: OnOpenDeleteTimelineModal; onOpenTimeline: OnOpenTimeline; + onSelectionChange: OnSelectionChange; onToggleShowNotes: OnToggleShowNotes; showExtendedColumns: boolean; -}) => [ - ...getCommonColumns({ - itemIdToExpandedNotesRowMap, - onOpenTimeline, - onToggleShowNotes, - }), - ...getExtendedColumnsIfEnabled(showExtendedColumns), - ...getIconHeaderColumns(), - ...getActionsColumns({ - deleteTimelines, - onOpenTimeline, - actionTimelineToShow, - }), -]; +}) => { + return [ + ...getCommonColumns({ + itemIdToExpandedNotesRowMap, + onOpenTimeline, + onToggleShowNotes, + }), + ...getExtendedColumnsIfEnabled(showExtendedColumns), + ...getIconHeaderColumns(), + ...getActionsColumns({ + actionTimelineToShow, + deleteTimelines, + enableExportTimelineDownloader, + onOpenDeleteTimelineModal, + onOpenTimeline, + }), + ]; +}; export interface TimelinesTableProps { actionTimelineToShow: ActionTimelineToShow[]; @@ -81,6 +93,8 @@ export interface TimelinesTableProps { defaultPageSize: number; loading: boolean; itemIdToExpandedNotesRowMap: Record; + enableExportTimelineDownloader?: EnableExportTimelineDownloader; + onOpenDeleteTimelineModal?: OnOpenDeleteTimelineModal; onOpenTimeline: OnOpenTimeline; onSelectionChange: OnSelectionChange; onTableChange: OnTableChange; @@ -91,6 +105,8 @@ export interface TimelinesTableProps { showExtendedColumns: boolean; sortDirection: 'asc' | 'desc'; sortField: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tableRef?: React.MutableRefObject<_EuiBasicTable | undefined>; totalSearchResultsCount: number; } @@ -105,6 +121,8 @@ export const TimelinesTable = React.memo( defaultPageSize, loading: isLoading, itemIdToExpandedNotesRowMap, + enableExportTimelineDownloader, + onOpenDeleteTimelineModal, onOpenTimeline, onSelectionChange, onTableChange, @@ -115,6 +133,7 @@ export const TimelinesTable = React.memo( showExtendedColumns, sortField, sortDirection, + tableRef, totalSearchResultsCount, }) => { const pagination = { @@ -142,14 +161,17 @@ export const TimelinesTable = React.memo( !selectable ? i18n.MISSING_SAVED_OBJECT_ID : undefined, onSelectionChange, }; - + const basicTableProps = tableRef != null ? { ref: tableRef } : {}; return ( ( pagination={pagination} selection={actionTimelineToShow.includes('selectable') ? selection : undefined} sorting={sorting} + {...basicTableProps} /> ); } diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/mocks.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/mocks.ts new file mode 100644 index 00000000000000..519dfc1b66efee --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/mocks.ts @@ -0,0 +1,32 @@ +/* + * 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 { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; +import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; +import { OpenTimelineResult } from '../types'; +import { TimelinesTableProps } from '.'; + +export const getMockTimelinesTableProps = ( + mockOpenTimelineResults: OpenTimelineResult[] +): TimelinesTableProps => ({ + actionTimelineToShow: ['delete', 'duplicate', 'selectable'], + deleteTimelines: jest.fn(), + defaultPageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + enableExportTimelineDownloader: jest.fn(), + itemIdToExpandedNotesRowMap: {}, + loading: false, + onOpenDeleteTimelineModal: jest.fn(), + onOpenTimeline: jest.fn(), + onSelectionChange: jest.fn(), + onTableChange: jest.fn(), + onToggleShowNotes: jest.fn(), + pageIndex: 0, + pageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + searchResults: mockOpenTimelineResults, + showExtendedColumns: true, + sortDirection: DEFAULT_SORT_DIRECTION, + sortField: DEFAULT_SORT_FIELD, + totalSearchResultsCount: mockOpenTimelineResults.length, +}); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.test.tsx index 88dfab470ac962..fe49b05ae6275a 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.test.tsx @@ -19,12 +19,7 @@ describe('TitleRow', () => { test('it renders the title', () => { const wrapper = mountWithIntl( - + ); @@ -42,7 +37,6 @@ describe('TitleRow', () => { @@ -60,7 +54,7 @@ describe('TitleRow', () => { test('it does NOT render the Favorite Selected button when onAddTimelinesToFavorites is NOT provided', () => { const wrapper = mountWithIntl( - + ); @@ -77,7 +71,6 @@ describe('TitleRow', () => { @@ -97,7 +90,6 @@ describe('TitleRow', () => { @@ -119,7 +111,6 @@ describe('TitleRow', () => { @@ -134,107 +125,4 @@ describe('TitleRow', () => { expect(onAddTimelinesToFavorites).toHaveBeenCalled(); }); }); - - describe('Delete Selected button', () => { - test('it renders the Delete Selected button when onDeleteSelected is provided', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .exists() - ).toBe(true); - }); - - test('it does NOT render the Delete Selected button when onDeleteSelected is NOT provided', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .exists() - ).toBe(false); - }); - - test('it disables the Delete Selected button when the selectedTimelinesCount is 0', () => { - const wrapper = mountWithIntl( - - - - ); - - const props = wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .props() as EuiButtonProps; - - expect(props.isDisabled).toBe(true); - }); - - test('it enables the Delete Selected button when the selectedTimelinesCount is greater than 0', () => { - const wrapper = mountWithIntl( - - - - ); - - const props = wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .props() as EuiButtonProps; - - expect(props.isDisabled).toBe(false); - }); - - test('it invokes onDeleteSelected when the Delete Selected button is clicked', () => { - const onDeleteSelected = jest.fn(); - - const wrapper = mountWithIntl( - - - - ); - - wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .simulate('click'); - - expect(onDeleteSelected).toHaveBeenCalled(); - }); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx index c7de367e043640..559bbc3eecb824 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx @@ -11,9 +11,10 @@ import * as i18n from '../translations'; import { OpenTimelineProps } from '../types'; import { HeaderSection } from '../../header_section'; -type Props = Pick & { +type Props = Pick & { /** The number of timelines currently selected */ selectedTimelinesCount: number; + children?: JSX.Element; }; /** @@ -21,39 +22,25 @@ type Props = Pick( - ({ onAddTimelinesToFavorites, onDeleteSelected, selectedTimelinesCount, title }) => ( - - {(onAddTimelinesToFavorites || onDeleteSelected) && ( - - {onAddTimelinesToFavorites && ( - - - {i18n.FAVORITE_SELECTED} - - - )} + ({ children, onAddTimelinesToFavorites, selectedTimelinesCount, title }) => ( + + + {onAddTimelinesToFavorites && ( + + + {i18n.FAVORITE_SELECTED} + + + )} - {onDeleteSelected && ( - - - {i18n.DELETE_SELECTED} - - - )} - - )} + {children && {children}} + ) ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts index b4e0d9967f2a96..4063b73d9499a3 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts @@ -6,6 +6,14 @@ import { i18n } from '@kbn/i18n'; +export const ALL_ACTIONS = i18n.translate('xpack.siem.open.timeline.allActionsTooltip', { + defaultMessage: 'All actions', +}); + +export const BATCH_ACTIONS = i18n.translate('xpack.siem.open.timeline.batchActionsTitle', { + defaultMessage: 'Bulk actions', +}); + export const CANCEL = i18n.translate('xpack.siem.open.timeline.cancelButton', { defaultMessage: 'Cancel', }); @@ -34,6 +42,14 @@ export const EXPAND = i18n.translate('xpack.siem.open.timeline.expandButton', { defaultMessage: 'Expand', }); +export const EXPORT_FILENAME = i18n.translate('xpack.siem.open.timeline.exportFileNameTitle', { + defaultMessage: 'timelines_export', +}); + +export const EXPORT_SELECTED = i18n.translate('xpack.siem.open.timeline.exportSelectedButton', { + defaultMessage: 'Export selected', +}); + export const FAVORITE_SELECTED = i18n.translate('xpack.siem.open.timeline.favoriteSelectedButton', { defaultMessage: 'Favorite selected', }); @@ -66,7 +82,7 @@ export const ONLY_FAVORITES = i18n.translate('xpack.siem.open.timeline.onlyFavor }); export const OPEN_AS_DUPLICATE = i18n.translate('xpack.siem.open.timeline.openAsDuplicateTooltip', { - defaultMessage: 'Open as a duplicate timeline', + defaultMessage: 'Duplicate timeline', }); export const OPEN_TIMELINE = i18n.translate('xpack.siem.open.timeline.openTimelineButton', { @@ -85,6 +101,10 @@ export const POSTED = i18n.translate('xpack.siem.open.timeline.postedLabel', { defaultMessage: 'Posted:', }); +export const REFRESH = i18n.translate('xpack.siem.open.timeline.refreshTitle', { + defaultMessage: 'Refresh', +}); + export const SEARCH_PLACEHOLDER = i18n.translate('xpack.siem.open.timeline.searchPlaceholder', { defaultMessage: 'e.g. timeline name, or description', }); @@ -107,3 +127,21 @@ export const ZERO_TIMELINES_MATCH = i18n.translate( defaultMessage: '0 timelines match the search criteria', } ); + +export const SELECTED_TIMELINES = (selectedTimelines: number) => + i18n.translate('xpack.siem.open.timeline.selectedTimelinesTitle', { + values: { selectedTimelines }, + defaultMessage: + 'Selected {selectedTimelines} {selectedTimelines, plural, =1 {timeline} other {timelines}}', + }); + +export const SHOWING = i18n.translate('xpack.siem.open.timeline.showingLabel', { + defaultMessage: 'Showing:', +}); + +export const SUCCESSFULLY_EXPORTED_TIMELINES = (totalTimelines: number) => + i18n.translate('xpack.siem.open.timeline.successfullyExportedTimelinesTitle', { + values: { totalTimelines }, + defaultMessage: + 'Successfully exported {totalTimelines, plural, =0 {all timelines} =1 {{totalTimelines} timeline} other {{totalTimelines} timelines}}', + }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts index b14bb1cf86d318..b466ea32799d9f 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { SetStateAction, Dispatch } from 'react'; import { AllTimelinesVariables } from '../../containers/timeline/all'; import { TimelineModel } from '../../store/timeline/model'; import { NoteResult } from '../../graphql/types'; +import { Refetch } from '../../store/inputs/model'; /** The users who added a timeline to favorites */ export interface FavoriteTimelineResult { @@ -18,10 +20,22 @@ export interface FavoriteTimelineResult { export interface TimelineResultNote { savedObjectId?: string | null; note?: string | null; + noteId?: string | null; updated?: number | null; updatedBy?: string | null; } +export interface TimelineActionsOverflowColumns { + width: string; + actions: Array<{ + name: string; + icon?: string; + onClick?: (timeline: OpenTimelineResult) => void; + description: string; + render?: (timeline: OpenTimelineResult) => JSX.Element; + } | null>; +} + /** The results of the query run by the OpenTimeline component */ export interface OpenTimelineResult { created?: number | null; @@ -65,6 +79,9 @@ export type OnOpenTimeline = ({ timelineId: string; }) => void; +export type OnOpenDeleteTimelineModal = (selectedItem: OpenTimelineResult) => void; +export type SetActionTimeline = Dispatch>; +export type EnableExportTimelineDownloader = (selectedItem: OpenTimelineResult) => void; /** Invoked when the user presses enters to submit the text in the search input */ export type OnQueryChange = (query: EuiSearchBarQuery) => void; @@ -92,7 +109,7 @@ export interface OnTableChangeParams { /** Invoked by the EUI table implementation when the user interacts with the table */ export type OnTableChange = (tableChange: OnTableChangeParams) => void; -export type ActionTimelineToShow = 'duplicate' | 'delete' | 'selectable'; +export type ActionTimelineToShow = 'duplicate' | 'delete' | 'export' | 'selectable'; export interface OpenTimelineProps { /** Invoked when the user clicks the delete (trash) icon on an individual timeline */ @@ -127,6 +144,9 @@ export interface OpenTimelineProps { pageSize: number; /** The currently applied search criteria */ query: string; + /** Refetch timelines data */ + refetch?: Refetch; + /** The results of executing a search */ searchResults: OpenTimelineResult[]; /** the currently-selected timelines in the table */ diff --git a/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_text.tsx b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_text.tsx index 95e12518155a85..b7815b59f03f58 100644 --- a/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_text.tsx +++ b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_text.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { BarText } from './styles'; export interface UtilityBarTextProps { - children: string; + children: string | JSX.Element; dataTestSubj?: string; } diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts index 3048fc3dc5a02b..8fdc6a67f7d712 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts @@ -402,7 +402,7 @@ describe('Detections Rules API', () => { test('check parameter url, body and query when exporting rules', async () => { await exportRules({ - ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + ids: ['mySuperRuleId', 'mySuperRuleId_II'], signal: abortCtrl.signal, }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_export', { @@ -419,7 +419,7 @@ describe('Detections Rules API', () => { test('check parameter url, body and query when exporting rules with excludeExportDetails', async () => { await exportRules({ excludeExportDetails: true, - ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + ids: ['mySuperRuleId', 'mySuperRuleId_II'], signal: abortCtrl.signal, }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_export', { @@ -436,7 +436,7 @@ describe('Detections Rules API', () => { test('check parameter url, body and query when exporting rules with fileName', async () => { await exportRules({ filename: 'myFileName.ndjson', - ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + ids: ['mySuperRuleId', 'mySuperRuleId_II'], signal: abortCtrl.signal, }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_export', { @@ -454,7 +454,7 @@ describe('Detections Rules API', () => { await exportRules({ excludeExportDetails: true, filename: 'myFileName.ndjson', - ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + ids: ['mySuperRuleId', 'mySuperRuleId_II'], signal: abortCtrl.signal, }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_export', { @@ -470,7 +470,7 @@ describe('Detections Rules API', () => { test('happy path', async () => { const resp = await exportRules({ - ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + ids: ['mySuperRuleId', 'mySuperRuleId_II'], signal: abortCtrl.signal, }); expect(resp).toEqual(blob); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index b52c4964c66956..126de9762a6963 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -16,7 +16,7 @@ import { FetchRuleProps, BasicFetchProps, ImportRulesProps, - ExportRulesProps, + ExportDocumentsProps, RuleStatusResponse, ImportRulesResponse, PrePackagedRulesStatusResponse, @@ -233,13 +233,11 @@ export const importRules = async ({ export const exportRules = async ({ excludeExportDetails = false, filename = `${i18n.EXPORT_FILENAME}.ndjson`, - ruleIds = [], + ids = [], signal, -}: ExportRulesProps): Promise => { +}: ExportDocumentsProps): Promise => { const body = - ruleIds.length > 0 - ? JSON.stringify({ objects: ruleIds.map(rule => ({ rule_id: rule })) }) - : undefined; + ids.length > 0 ? JSON.stringify({ objects: ids.map(rule => ({ rule_id: rule })) }) : undefined; return KibanaServices.get().http.fetch(`${DETECTION_ENGINE_RULES_URL}/_export`, { method: 'POST', diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index 5466ba2203714f..c75d7b78cf92f0 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -191,8 +191,8 @@ export interface ImportRulesResponse { errors: ImportRulesResponseError[]; } -export interface ExportRulesProps { - ruleIds?: string[]; +export interface ExportDocumentsProps { + ids: string[]; filename?: string; excludeExportDetails?: boolean; signal: AbortSignal; diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts b/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts new file mode 100644 index 00000000000000..edda2e30ea4000 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts @@ -0,0 +1,30 @@ +/* + * 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 { KibanaServices } from '../../../lib/kibana'; +import { ExportSelectedData } from '../../../components/generic_downloader'; +import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; + +export const exportSelectedTimeline: ExportSelectedData = async ({ + excludeExportDetails = false, + filename = `timelines_export.ndjson`, + ids = [], + signal, +}): Promise => { + const body = ids.length > 0 ? JSON.stringify({ ids }) : undefined; + const response = await KibanaServices.get().http.fetch(`${TIMELINE_EXPORT_URL}`, { + method: 'POST', + body, + query: { + exclude_export_details: excludeExportDetails, + file_name: filename, + }, + signal, + asResponse: true, + }); + + return response.body!; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx b/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx index 22c7b03f34dd58..b5c91ca287f0b8 100644 --- a/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx @@ -3,13 +3,13 @@ * 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 { getOr } from 'lodash/fp'; -import React from 'react'; import memoizeOne from 'memoize-one'; import { Query } from 'react-apollo'; +import { ApolloQueryResult } from 'apollo-client'; import { OpenTimelineResult } from '../../../components/open_timeline/types'; import { GetAllTimeline, @@ -23,6 +23,7 @@ export interface AllTimelinesArgs { timelines: OpenTimelineResult[]; loading: boolean; totalCount: number; + refetch: () => void; } export interface AllTimelinesVariables { @@ -36,6 +37,10 @@ interface OwnProps extends AllTimelinesVariables { children?: (args: AllTimelinesArgs) => React.ReactNode; } +type Refetch = ( + variables: GetAllTimeline.Variables | undefined +) => Promise>; + const getAllTimeline = memoizeOne( (variables: string, timelines: TimelineResult[]): OpenTimelineResult[] => timelines.map(timeline => ({ @@ -84,6 +89,8 @@ const AllTimelinesQueryComponent: React.FC = ({ search, sort, }; + const handleRefetch = useCallback((refetch: Refetch) => refetch(variables), [variables]); + return ( query={allTimelinesQuery} @@ -91,9 +98,10 @@ const AllTimelinesQueryComponent: React.FC = ({ notifyOnNetworkStatusChange variables={variables} > - {({ data, loading }) => + {({ data, loading, refetch }) => children!({ loading, + refetch: handleRefetch.bind(null, refetch), totalCount: getOr(0, 'getAllTimeline.totalCount', data), timelines: getAllTimeline( JSON.stringify(variables), diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index bb718d80298172..621c70e3913190 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -22,6 +22,7 @@ import { FilterOptions, Rule, PaginationOptions, + exportRules, } from '../../../../containers/detection_engine/rules'; import { HeaderSection } from '../../../../components/header_section'; import { @@ -35,7 +36,7 @@ import { useStateToaster } from '../../../../components/toasters'; import { Loader } from '../../../../components/loader'; import { Panel } from '../../../../components/panel'; import { PrePackagedRulesPrompt } from '../components/pre_packaged_rules/load_empty_prompt'; -import { RuleDownloader } from '../components/rule_downloader'; +import { GenericDownloader } from '../../../../components/generic_downloader'; import { getPrePackagedRuleStatus } from '../helpers'; import * as i18n from '../translations'; import { EuiBasicTableOnChange } from '../types'; @@ -244,10 +245,10 @@ export const AllRules = React.memo( return ( <> - { + ids={exportRuleIds} + onExportSuccess={exportCount => { dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); dispatchToaster({ type: 'addToaster', @@ -259,6 +260,7 @@ export const AllRules = React.memo( }, }); }} + exportSelectedData={exportRules} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap index 9355d0ae2cccbc..65a606604d4a7c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap @@ -55,10 +55,11 @@ exports[`RuleActionsOverflow renders correctly against snapshot 1`] = ` } /> - `; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx index 7c8926c2064c72..e1ca84ed8cc642 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx @@ -16,12 +16,12 @@ import styled from 'styled-components'; import { noop } from 'lodash/fp'; import { useHistory } from 'react-router-dom'; -import { Rule } from '../../../../../containers/detection_engine/rules'; +import { Rule, exportRules } from '../../../../../containers/detection_engine/rules'; import * as i18n from './translations'; import * as i18nActions from '../../../rules/translations'; import { displaySuccessToast, useStateToaster } from '../../../../../components/toasters'; import { deleteRulesAction, duplicateRulesAction } from '../../all/actions'; -import { RuleDownloader } from '../rule_downloader'; +import { GenericDownloader } from '../../../../../components/generic_downloader'; import { DETECTION_ENGINE_PAGE_NAME } from '../../../../../components/link_to/redirect_to_detection_engine'; const MyEuiButtonIcon = styled(EuiButtonIcon)` @@ -129,10 +129,11 @@ const RuleActionsOverflowComponent = ({ > - { + ids={rulesToExport} + exportSelectedData={exportRules} + onExportSuccess={exportCount => { displaySuccessToast( i18nActions.SUCCESSFULLY_EXPORTED_RULES(exportCount), dispatchToaster diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 4259b68bf14a21..00000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RuleDownloader renders correctly against snapshot 1`] = ``; diff --git a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx index 86f702a8ad8a46..6d30ea58089f08 100644 --- a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx @@ -26,23 +26,25 @@ type OwnProps = TimelinesProps; export const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; -const TimelinesPageComponent: React.FC = ({ apolloClient }) => ( - <> - - - - - - - - - - -); +const TimelinesPageComponent: React.FC = ({ apolloClient }) => { + return ( + <> + + + + + + + + + + + ); +}; export const TimelinesPage = React.memo(TimelinesPageComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/timelines/translations.ts b/x-pack/legacy/plugins/siem/public/pages/timelines/translations.ts index 5426ccbdb4f9ad..723d164ad2cdd5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/timelines/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/timelines/translations.ts @@ -16,3 +16,10 @@ export const ALL_TIMELINES_PANEL_TITLE = i18n.translate( defaultMessage: 'All timelines', } ); + +export const ALL_TIMELINES_IMPORT_TIMELINE_TITLE = i18n.translate( + 'xpack.siem.timelines.allTimelines.importTimelineTitle', + { + defaultMessage: 'Import Timeline', + } +); diff --git a/x-pack/legacy/plugins/siem/server/graphql/timeline/schema.gql.ts b/x-pack/legacy/plugins/siem/server/graphql/timeline/schema.gql.ts index 8b24cea0d6af92..9dd04247b7f47c 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/timeline/schema.gql.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/timeline/schema.gql.ts @@ -150,7 +150,7 @@ export const timelineSchema = gql` updated created } - + input SortTimeline { sortField: SortFieldTimeline! sortOrder: Direction! diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts index 3a8d068cad38df..3a047f91a0bcbe 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts @@ -12,7 +12,7 @@ import { transformTags, getIdBulkError, transformOrBulkError, - transformRulesToNdjson, + transformDataToNdjson, transformAlertsToRules, transformOrImportError, getDuplicates, @@ -380,15 +380,15 @@ describe('utils', () => { }); }); - describe('transformRulesToNdjson', () => { + describe('transformDataToNdjson', () => { test('if rules are empty it returns an empty string', () => { - const ruleNdjson = transformRulesToNdjson([]); + const ruleNdjson = transformDataToNdjson([]); expect(ruleNdjson).toEqual(''); }); test('single rule will transform with new line ending character for ndjson', () => { const rule = sampleRule(); - const ruleNdjson = transformRulesToNdjson([rule]); + const ruleNdjson = transformDataToNdjson([rule]); expect(ruleNdjson.endsWith('\n')).toBe(true); }); @@ -399,7 +399,7 @@ describe('utils', () => { result2.rule_id = 'some other id'; result2.name = 'Some other rule'; - const ruleNdjson = transformRulesToNdjson([result1, result2]); + const ruleNdjson = transformDataToNdjson([result1, result2]); // this is how we count characters in JavaScript :-) const count = ruleNdjson.split('\n').length - 1; expect(count).toBe(2); @@ -412,7 +412,7 @@ describe('utils', () => { result2.rule_id = 'some other id'; result2.name = 'Some other rule'; - const ruleNdjson = transformRulesToNdjson([result1, result2]); + const ruleNdjson = transformDataToNdjson([result1, result2]); const ruleStrings = ruleNdjson.split('\n'); const reParsed1 = JSON.parse(ruleStrings[0]); const reParsed2 = JSON.parse(ruleStrings[1]); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index 3d831026256fce..e0ecbdedaac7c9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -150,10 +150,10 @@ export const transformAlertToRule = ( }); }; -export const transformRulesToNdjson = (rules: Array>): string => { - if (rules.length !== 0) { - const rulesString = rules.map(rule => JSON.stringify(rule)).join('\n'); - return `${rulesString}\n`; +export const transformDataToNdjson = (data: unknown[]): string => { + if (data.length !== 0) { + const dataString = data.map(rule => JSON.stringify(rule)).join('\n'); + return `${dataString}\n`; } else { return ''; } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts index 434919f80e149b..6a27abb66ce853 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts @@ -7,7 +7,7 @@ import { AlertsClient } from '../../../../../../../plugins/alerting/server'; import { getNonPackagedRules } from './get_existing_prepackaged_rules'; import { getExportDetailsNdjson } from './get_export_details_ndjson'; -import { transformAlertsToRules, transformRulesToNdjson } from '../routes/rules/utils'; +import { transformAlertsToRules, transformDataToNdjson } from '../routes/rules/utils'; export const getExportAll = async ( alertsClient: AlertsClient @@ -17,7 +17,7 @@ export const getExportAll = async ( }> => { const ruleAlertTypes = await getNonPackagedRules({ alertsClient }); const rules = transformAlertsToRules(ruleAlertTypes); - const rulesNdjson = transformRulesToNdjson(rules); + const rulesNdjson = transformDataToNdjson(rules); const exportDetails = getExportDetailsNdjson(rules); return { rulesNdjson, exportDetails }; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts index e3b38a879fc3d6..6f642231ebbafb 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts @@ -8,7 +8,7 @@ import { AlertsClient } from '../../../../../../../plugins/alerting/server'; import { getExportDetailsNdjson } from './get_export_details_ndjson'; import { isAlertType } from '../rules/types'; import { readRules } from './read_rules'; -import { transformRulesToNdjson, transformAlertToRule } from '../routes/rules/utils'; +import { transformDataToNdjson, transformAlertToRule } from '../routes/rules/utils'; import { OutputRuleAlertRest } from '../types'; interface ExportSuccesRule { @@ -37,7 +37,7 @@ export const getExportByObjectIds = async ( exportDetails: string; }> => { const rulesAndErrors = await getRulesFromObjects(alertsClient, objects); - const rulesNdjson = transformRulesToNdjson(rulesAndErrors.rules); + const rulesNdjson = transformDataToNdjson(rulesAndErrors.rules); const exportDetails = getExportDetailsNdjson(rulesAndErrors.rules, rulesAndErrors.missingRules); return { rulesNdjson, exportDetails }; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts index d825aae1b480bd..b6a43fc523adb9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts @@ -194,7 +194,7 @@ export class Note { } } -const convertSavedObjectToSavedNote = ( +export const convertSavedObjectToSavedNote = ( savedObject: unknown, timelineVersion?: string | undefined | null ): NoteSavedObject => diff --git a/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts index afa3595a09e1c6..9ea950e8a443b4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts @@ -180,7 +180,7 @@ export class PinnedEvent { } } -const convertSavedObjectToSavedPinnedEvent = ( +export const convertSavedObjectToSavedPinnedEvent = ( savedObject: unknown, timelineVersion?: string | undefined | null ): PinnedEventSavedObject => diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts new file mode 100644 index 00000000000000..eae1ece7e789d9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts @@ -0,0 +1,250 @@ +/* + * 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 { TIMELINE_EXPORT_URL } from '../../../../../common/constants'; +import { requestMock } from '../../../detection_engine/routes/__mocks__'; + +export const getExportTimelinesRequest = () => + requestMock.create({ + method: 'get', + path: TIMELINE_EXPORT_URL, + body: { + ids: ['f0e58720-57b6-11ea-b88d-3f1a31716be8', '890b8ae0-57df-11ea-a7c9-3976b7f1cb37'], + }, + }); + +export const mockTimelinesSavedObjects = () => ({ + saved_objects: [ + { + id: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', + type: 'fakeType', + attributes: {}, + references: [], + }, + { + id: '890b8ae0-57df-11ea-a7c9-3976b7f1cb37', + type: 'fakeType', + attributes: {}, + references: [], + }, + ], +}); + +export const mockTimelines = () => ({ + saved_objects: [ + { + savedObjectId: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', + version: 'Wzk0OSwxXQ==', + columns: [ + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: '@timestamp', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'message', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'event.category', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'event.action', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'host.name', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'source.ip', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'destination.ip', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'user.name', + searchable: null, + }, + ], + dataProviders: [], + description: 'with a global note', + eventType: 'raw', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { kind: 'kuery', expression: 'zeek.files.sha1 : * ' }, + serializedQuery: + '{"bool":{"should":[{"exists":{"field":"zeek.files.sha1"}}],"minimum_should_match":1}}', + }, + }, + title: 'test no.2', + dateRange: { start: 1582538951145, end: 1582625351145 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1582625382448, + createdBy: 'elastic', + updated: 1583741197521, + updatedBy: 'elastic', + }, + { + savedObjectId: '890b8ae0-57df-11ea-a7c9-3976b7f1cb37', + version: 'Wzk0NywxXQ==', + columns: [ + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: '@timestamp', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'message', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'event.category', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'event.action', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'host.name', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'source.ip', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'destination.ip', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'user.name', + searchable: null, + }, + ], + dataProviders: [], + description: 'with an event note', + eventType: 'raw', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + serializedQuery: + '{"bool":{"should":[{"exists":{"field":"zeek.files.sha1"}}],"minimum_should_match":1}}', + kuery: { expression: 'zeek.files.sha1 : * ', kind: 'kuery' }, + }, + }, + title: 'test no.3', + dateRange: { start: 1582538951145, end: 1582625351145 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1582642817439, + createdBy: 'elastic', + updated: 1583741175216, + updatedBy: 'elastic', + }, + ], +}); + +export const mockNotesSavedObjects = () => ({ + saved_objects: [ + { + id: 'eb3f3930-61dc-11ea-8a49-e77254c5b742', + type: 'fakeType', + attributes: {}, + references: [], + }, + { + id: '706e7510-5d52-11ea-8f07-0392944939c1', + type: 'fakeType', + attributes: {}, + references: [], + }, + ], +}); + +export const mockNotes = () => ({ + saved_objects: [ + { + noteId: 'eb3f3930-61dc-11ea-8a49-e77254c5b742', + version: 'Wzk1MCwxXQ==', + note: 'Global note', + timelineId: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', + created: 1583741205473, + createdBy: 'elastic', + updated: 1583741205473, + updatedBy: 'elastic', + }, + { + noteId: '706e7510-5d52-11ea-8f07-0392944939c1', + version: 'WzEwMiwxXQ==', + eventId: '6HW_eHABMQha2n6bHvQ0', + note: 'this is a note!!', + timelineId: '890b8ae0-57df-11ea-a7c9-3976b7f1cb37', + created: 1583241924223, + createdBy: 'elastic', + updated: 1583241924223, + updatedBy: 'elastic', + }, + ], +}); + +export const mockPinnedEvents = () => ({ + saved_objects: [], +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts new file mode 100644 index 00000000000000..fe434b53992124 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { + mockTimelines, + mockNotes, + mockTimelinesSavedObjects, + mockPinnedEvents, + getExportTimelinesRequest, +} from './__mocks__/request_responses'; +import { exportTimelinesRoute } from './export_timelines_route'; +import { + serverMock, + requestContextMock, + requestMock, +} from '../../detection_engine/routes/__mocks__'; +import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; +import { convertSavedObjectToSavedNote } from '../../note/saved_object'; +import { convertSavedObjectToSavedPinnedEvent } from '../../pinned_event/saved_object'; +import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; +jest.mock('../convert_saved_object_to_savedtimeline', () => { + return { + convertSavedObjectToSavedTimeline: jest.fn(), + }; +}); + +jest.mock('../../note/saved_object', () => { + return { + convertSavedObjectToSavedNote: jest.fn(), + }; +}); + +jest.mock('../../pinned_event/saved_object', () => { + return { + convertSavedObjectToSavedPinnedEvent: jest.fn(), + }; +}); +describe('export timelines', () => { + let server: ReturnType; + let { clients, context } = requestContextMock.createTools(); + const config = jest.fn().mockImplementation(() => { + return { + get: () => { + return 100; + }, + has: jest.fn(), + }; + }); + + beforeEach(() => { + server = serverMock.create(); + ({ clients, context } = requestContextMock.createTools()); + + clients.savedObjectsClient.bulkGet.mockResolvedValue(mockTimelinesSavedObjects()); + + ((convertSavedObjectToSavedTimeline as unknown) as jest.Mock).mockReturnValue(mockTimelines()); + ((convertSavedObjectToSavedNote as unknown) as jest.Mock).mockReturnValue(mockNotes()); + ((convertSavedObjectToSavedPinnedEvent as unknown) as jest.Mock).mockReturnValue( + mockPinnedEvents() + ); + exportTimelinesRoute(server.router, config); + }); + + describe('status codes', () => { + test('returns 200 when finding selected timelines', async () => { + const response = await server.inject(getExportTimelinesRequest(), context); + expect(response.status).toEqual(200); + }); + + test('catch error when status search throws error', async () => { + clients.savedObjectsClient.bulkGet.mockReset(); + clients.savedObjectsClient.bulkGet.mockRejectedValue(new Error('Test error')); + const response = await server.inject(getExportTimelinesRequest(), context); + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: 'Test error', + status_code: 500, + }); + }); + }); + + describe('request validation', () => { + test('disallows singular id query param', async () => { + const request = requestMock.create({ + method: 'get', + path: TIMELINE_EXPORT_URL, + body: { id: 'someId' }, + }); + const result = server.validate(request); + + expect(result.badRequest).toHaveBeenCalledWith('"id" is not allowed'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts new file mode 100644 index 00000000000000..3ded959aced363 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts @@ -0,0 +1,75 @@ +/* + * 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 { set as _set } from 'lodash/fp'; +import { IRouter } from '../../../../../../../../src/core/server'; +import { LegacyServices } from '../../../types'; +import { ExportTimelineRequestParams } from '../types'; + +import { + transformError, + buildRouteValidation, + buildSiemResponse, +} from '../../detection_engine/routes/utils'; +import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; + +import { + exportTimelinesSchema, + exportTimelinesQuerySchema, +} from './schemas/export_timelines_schema'; + +import { getExportTimelineByObjectIds } from './utils'; + +export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['config']) => { + router.post( + { + path: TIMELINE_EXPORT_URL, + validate: { + query: buildRouteValidation( + exportTimelinesQuerySchema + ), + body: buildRouteValidation(exportTimelinesSchema), + }, + options: { + tags: ['access:siem'], + }, + }, + async (context, request, response) => { + try { + const siemResponse = buildSiemResponse(response); + const savedObjectsClient = context.core.savedObjects.client; + const exportSizeLimit = config().get('savedObjects.maxImportExportSize'); + if (request.body?.ids != null && request.body.ids.length > exportSizeLimit) { + return siemResponse.error({ + statusCode: 400, + body: `Can't export more than ${exportSizeLimit} timelines`, + }); + } + + const responseBody = await getExportTimelineByObjectIds({ + client: savedObjectsClient, + request, + }); + + return response.ok({ + headers: { + 'Content-Disposition': `attachment; filename="${request.query.file_name}"`, + 'Content-Type': 'application/ndjson', + }, + body: responseBody, + }); + } catch (err) { + const error = transformError(err); + const siemResponse = buildSiemResponse(response); + + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts new file mode 100644 index 00000000000000..04edbbd7046c93 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts @@ -0,0 +1,20 @@ +/* + * 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 Joi from 'joi'; + +/* eslint-disable @typescript-eslint/camelcase */ +import { ids, exclude_export_details, file_name } from './schemas'; +/* eslint-disable @typescript-eslint/camelcase */ + +export const exportTimelinesSchema = Joi.object({ + ids, +}).min(1); + +export const exportTimelinesQuerySchema = Joi.object({ + file_name: file_name.default('export.ndjson'), + exclude_export_details: exclude_export_details.default(false), +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts new file mode 100644 index 00000000000000..67697c347634e1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts @@ -0,0 +1,13 @@ +/* + * 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 Joi from 'joi'; + +/* eslint-disable @typescript-eslint/camelcase */ + +export const ids = Joi.array().items(Joi.string()); + +export const exclude_export_details = Joi.boolean(); +export const file_name = Joi.string(); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts new file mode 100644 index 00000000000000..066862e025833f --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts @@ -0,0 +1,187 @@ +/* + * 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 { set as _set } from 'lodash/fp'; +import { + SavedObjectsFindOptions, + SavedObjectsFindResponse, +} from '../../../../../../../../src/core/server'; + +import { + ExportTimelineSavedObjectsClient, + ExportTimelineRequest, + ExportedNotes, + TimelineSavedObject, + ExportedTimelines, +} from '../types'; +import { + timelineSavedObjectType, + noteSavedObjectType, + pinnedEventSavedObjectType, +} from '../../../saved_objects'; + +import { convertSavedObjectToSavedNote } from '../../note/saved_object'; +import { convertSavedObjectToSavedPinnedEvent } from '../../pinned_event/saved_object'; +import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; +import { transformDataToNdjson } from '../../detection_engine/routes/rules/utils'; +import { NoteSavedObject } from '../../note/types'; +import { PinnedEventSavedObject } from '../../pinned_event/types'; + +const getAllSavedPinnedEvents = ( + pinnedEventsSavedObjects: SavedObjectsFindResponse +): PinnedEventSavedObject[] => { + return pinnedEventsSavedObjects != null + ? pinnedEventsSavedObjects.saved_objects.map(savedObject => + convertSavedObjectToSavedPinnedEvent(savedObject) + ) + : []; +}; + +const getPinnedEventsByTimelineId = ( + savedObjectsClient: ExportTimelineSavedObjectsClient, + timelineId: string +): Promise> => { + const options: SavedObjectsFindOptions = { + type: pinnedEventSavedObjectType, + search: timelineId, + searchFields: ['timelineId'], + }; + return savedObjectsClient.find(options); +}; + +const getAllSavedNote = ( + noteSavedObjects: SavedObjectsFindResponse +): NoteSavedObject[] => { + return noteSavedObjects != null + ? noteSavedObjects.saved_objects.map(savedObject => convertSavedObjectToSavedNote(savedObject)) + : []; +}; + +const getNotesByTimelineId = ( + savedObjectsClient: ExportTimelineSavedObjectsClient, + timelineId: string +): Promise> => { + const options: SavedObjectsFindOptions = { + type: noteSavedObjectType, + search: timelineId, + searchFields: ['timelineId'], + }; + + return savedObjectsClient.find(options); +}; + +const getGlobalEventNotesByTimelineId = (currentNotes: NoteSavedObject[]): ExportedNotes => { + const initialNotes: ExportedNotes = { + eventNotes: [], + globalNotes: [], + }; + + return ( + currentNotes.reduce((acc, note) => { + if (note.eventId == null) { + return { + ...acc, + globalNotes: [...acc.globalNotes, note], + }; + } else { + return { + ...acc, + eventNotes: [...acc.eventNotes, note], + }; + } + }, initialNotes) ?? initialNotes + ); +}; + +const getPinnedEventsIdsByTimelineId = ( + currentPinnedEvents: PinnedEventSavedObject[] +): string[] => { + return currentPinnedEvents.map(event => event.eventId) ?? []; +}; + +const getTimelines = async ( + savedObjectsClient: ExportTimelineSavedObjectsClient, + timelineIds: string[] +) => { + const savedObjects = await Promise.resolve( + savedObjectsClient.bulkGet( + timelineIds.reduce( + (acc, timelineId) => [...acc, { id: timelineId, type: timelineSavedObjectType }], + [] as Array<{ id: string; type: string }> + ) + ) + ); + + const timelineObjects: TimelineSavedObject[] | undefined = + savedObjects != null + ? savedObjects.saved_objects.map((savedObject: unknown) => { + return convertSavedObjectToSavedTimeline(savedObject); + }) + : []; + + return timelineObjects; +}; + +const getTimelinesFromObjects = async ( + savedObjectsClient: ExportTimelineSavedObjectsClient, + request: ExportTimelineRequest +): Promise => { + const timelines: TimelineSavedObject[] = await getTimelines(savedObjectsClient, request.body.ids); + // To Do for feature freeze + // if (timelines.length !== request.body.ids.length) { + // //figure out which is missing to tell user + // } + + const [notes, pinnedEventIds] = await Promise.all([ + Promise.all( + request.body.ids.map(timelineId => getNotesByTimelineId(savedObjectsClient, timelineId)) + ), + Promise.all( + request.body.ids.map(timelineId => + getPinnedEventsByTimelineId(savedObjectsClient, timelineId) + ) + ), + ]); + + const myNotes = notes.reduce( + (acc, note) => [...acc, ...getAllSavedNote(note)], + [] + ); + + const myPinnedEventIds = pinnedEventIds.reduce( + (acc, pinnedEventId) => [...acc, ...getAllSavedPinnedEvents(pinnedEventId)], + [] + ); + + const myResponse = request.body.ids.reduce((acc, timelineId) => { + const myTimeline = timelines.find(t => t.savedObjectId === timelineId); + if (myTimeline != null) { + const timelineNotes = myNotes.filter(n => n.timelineId === timelineId); + const timelinePinnedEventIds = myPinnedEventIds.filter(p => p.timelineId === timelineId); + return [ + ...acc, + { + ...myTimeline, + ...getGlobalEventNotesByTimelineId(timelineNotes), + pinnedEventIds: getPinnedEventsIdsByTimelineId(timelinePinnedEventIds), + }, + ]; + } + return acc; + }, []); + + return myResponse ?? []; +}; + +export const getExportTimelineByObjectIds = async ({ + client, + request, +}: { + client: ExportTimelineSavedObjectsClient; + request: ExportTimelineRequest; +}) => { + const timeline = await getTimelinesFromObjects(client, request); + return transformDataToNdjson(timeline); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts index 4b78a7bd3d06d1..88d7fcdb681646 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts @@ -271,7 +271,7 @@ export const convertStringToBase64 = (text: string): string => Buffer.from(text) // then this interface does not allow types without index signature // this is limiting us with our type for now so the easy way was to use any -const timelineWithReduxProperties = ( +export const timelineWithReduxProperties = ( notes: NoteSavedObject[], pinnedEvents: PinnedEventSavedObject[], timeline: TimelineSavedObject, @@ -279,7 +279,9 @@ const timelineWithReduxProperties = ( ): TimelineSavedObject => ({ ...timeline, favorite: - timeline.favorite != null ? timeline.favorite.filter(fav => fav.userName === userName) : [], + timeline.favorite != null && userName != null + ? timeline.favorite.filter(fav => fav.userName === userName) + : [], eventIdToNoteIds: notes.filter(note => note.eventId != null), noteIds: notes .filter(note => note.eventId == null && note.noteId != null) diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts index d757ea8049bc18..35bf86c17db7ea 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts @@ -9,8 +9,12 @@ import * as runtimeTypes from 'io-ts'; import { unionWithNullType } from '../framework'; -import { NoteSavedObjectToReturnRuntimeType } from '../note/types'; -import { PinnedEventToReturnSavedObjectRuntimeType } from '../pinned_event/types'; +import { NoteSavedObjectToReturnRuntimeType, NoteSavedObject } from '../note/types'; +import { + PinnedEventToReturnSavedObjectRuntimeType, + PinnedEventSavedObject, +} from '../pinned_event/types'; +import { SavedObjectsClient, KibanaRequest } from '../../../../../../../src/core/server'; /* * ColumnHeader Types @@ -199,3 +203,54 @@ export const AllTimelineSavedObjectRuntimeType = runtimeTypes.type({ export interface AllTimelineSavedObject extends runtimeTypes.TypeOf {} + +export interface ExportTimelineRequestParams { + body: { ids: string[] }; + query: { + file_name: string; + exclude_export_details: boolean; + }; +} + +export type ExportTimelineRequest = KibanaRequest< + unknown, + ExportTimelineRequestParams['query'], + ExportTimelineRequestParams['body'], + 'post' +>; + +export type ExportTimelineSavedObjectsClient = Pick< + SavedObjectsClient, + | 'get' + | 'errors' + | 'create' + | 'bulkCreate' + | 'delete' + | 'find' + | 'bulkGet' + | 'update' + | 'bulkUpdate' +>; + +export type ExportedGlobalNotes = Array>; +export type ExportedEventNotes = NoteSavedObject[]; + +export interface ExportedNotes { + eventNotes: ExportedEventNotes; + globalNotes: ExportedGlobalNotes; +} + +export type ExportedTimelines = TimelineSavedObject & + ExportedNotes & { + pinnedEventIds: string[]; + }; + +export interface BulkGetInput { + type: string; + id: string; +} + +export type NotesAndPinnedEventsByTimelineId = Record< + string, + { notes: NoteSavedObject[]; pinnedEvents: PinnedEventSavedObject[] } +>; diff --git a/x-pack/legacy/plugins/siem/server/routes/index.ts b/x-pack/legacy/plugins/siem/server/routes/index.ts index 08bdfc3aa5d4f5..08ff9208ce20bd 100644 --- a/x-pack/legacy/plugins/siem/server/routes/index.ts +++ b/x-pack/legacy/plugins/siem/server/routes/index.ts @@ -29,6 +29,7 @@ import { importRulesRoute } from '../lib/detection_engine/routes/rules/import_ru import { exportRulesRoute } from '../lib/detection_engine/routes/rules/export_rules_route'; import { findRulesStatusesRoute } from '../lib/detection_engine/routes/rules/find_rules_status_route'; import { getPrepackagedRulesStatusRoute } from '../lib/detection_engine/routes/rules/get_prepackaged_rules_status_route'; +import { exportTimelinesRoute } from '../lib/timeline/routes/export_timelines_route'; export const initRoutes = ( router: IRouter, @@ -54,6 +55,8 @@ export const initRoutes = ( importRulesRoute(router, config); exportRulesRoute(router, config); + exportTimelinesRoute(router, config); + findRulesStatusesRoute(router); // Detection Engine Signals routes that have the REST endpoints of /api/detection_engine/signals From 2c255200510291987d7b734c158241ad5c61580b Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Fri, 20 Mar 2020 11:44:35 +0100 Subject: [PATCH 31/32] [Upgrade Assistant] First iteration of batch reindex docs (#59887) * First iteration of batch reindex docs Tested with docs generator repo * Add top level bullet points and remove cruft * Address PR feedback Also move the experimental marker to similar position (before description) on existing endpoint docs for UA. --- docs/api/upgrade-assistant.asciidoc | 6 ++ .../upgrade-assistant/batch_queue.asciidoc | 68 ++++++++++++++++ .../batch_reindexing.asciidoc | 81 +++++++++++++++++++ .../upgrade-assistant/cancel_reindex.asciidoc | 4 +- .../check_reindex_status.asciidoc | 4 +- .../api/upgrade-assistant/reindexing.asciidoc | 4 +- docs/api/upgrade-assistant/status.asciidoc | 4 +- 7 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 docs/api/upgrade-assistant/batch_queue.asciidoc create mode 100644 docs/api/upgrade-assistant/batch_reindexing.asciidoc diff --git a/docs/api/upgrade-assistant.asciidoc b/docs/api/upgrade-assistant.asciidoc index b524307c0f273e..3e9c416b292cf8 100644 --- a/docs/api/upgrade-assistant.asciidoc +++ b/docs/api/upgrade-assistant.asciidoc @@ -10,11 +10,17 @@ The following upgrade assistant APIs are available: * <> to start a new reindex or resume a paused reindex +* <> to start or resume multiple reindex tasks + +* <> to check the current reindex batch queue + * <> to check the status of the reindex operation * <> to cancel reindexes that are waiting for the Elasticsearch reindex task to complete include::upgrade-assistant/status.asciidoc[] include::upgrade-assistant/reindexing.asciidoc[] +include::upgrade-assistant/batch_reindexing.asciidoc[] +include::upgrade-assistant/batch_queue.asciidoc[] include::upgrade-assistant/check_reindex_status.asciidoc[] include::upgrade-assistant/cancel_reindex.asciidoc[] diff --git a/docs/api/upgrade-assistant/batch_queue.asciidoc b/docs/api/upgrade-assistant/batch_queue.asciidoc new file mode 100644 index 00000000000000..dcb9b465e4ddc5 --- /dev/null +++ b/docs/api/upgrade-assistant/batch_queue.asciidoc @@ -0,0 +1,68 @@ +[[batch-reindex-queue]] +=== Batch reindex queue API +++++ +Batch reindex queue +++++ + +experimental["The underlying Upgrade Assistant concepts are stable, but the APIs for managing Upgrade Assistant are experimental."] + +Check the current reindex batch queue. + +[[batch-reindex-queue-request]] +==== Request + +`GET /api/upgrade_assistant/reindex/batch/queue` + +[[batch-reindex-queue-request-codes]] +==== Response code + +`200`:: + Indicates a successful call. + +[[batch-reindex-queue-example]] +==== Example + +The API returns the following: + +[source,js] +-------------------------------------------------- +{ + "queue": [ <1> + { + "indexName": "index1", + "newIndexName": "reindexed-v8-index2", + "status": 3, + "lastCompletedStep": 0, + "locked": null, + "reindexTaskId": null, + "reindexTaskPercComplete": null, + "errorMessage": null, + "runningReindexCount": null, + "reindexOptions": { + "queueSettings": { + "queuedAt": 1583406985489 + } + } + }, + { + "indexName": "index2", + "newIndexName": "reindexed-v8-index2", + "status": 3, + "lastCompletedStep": 0, + "locked": null, + "reindexTaskId": null, + "reindexTaskPercComplete": null, + "errorMessage": null, + "runningReindexCount": null, + "reindexOptions": { + "queueSettings": { + "queuedAt": 1583406987334 + } + } + } + ] +} +-------------------------------------------------- + +<1> Items in this array indicate reindex tasks at a given point in time and the order in which they will be executed. + diff --git a/docs/api/upgrade-assistant/batch_reindexing.asciidoc b/docs/api/upgrade-assistant/batch_reindexing.asciidoc new file mode 100644 index 00000000000000..40b6d9c816d5c3 --- /dev/null +++ b/docs/api/upgrade-assistant/batch_reindexing.asciidoc @@ -0,0 +1,81 @@ +[[batch-start-resume-reindex]] +=== Batch start or resume reindex API +++++ +Batch start or resume reindex +++++ + +experimental["The underlying Upgrade Assistant concepts are stable, but the APIs for managing Upgrade Assistant are experimental."] + +Start or resume multiple reindexing tasks in one request. Additionally, reindexing tasks started or resumed +via the batch endpoint will be placed on a queue and executed one-by-one, which ensures that minimal cluster resources +are consumed over time. + +[[batch-start-resume-reindex-request]] +==== Request + +`POST /api/upgrade_assistant/reindex/batch` + +[[batch-start-resume-reindex-request-body]] +==== Request body + +`indexNames`:: + (Required, array) The list of index names to be reindexed. + +[[batch-start-resume-reindex-codes]] +==== Response code + +`200`:: + Indicates a successful call. + +[[batch-start-resume-example]] +==== Example + +[source,js] +-------------------------------------------------- +POST /api/upgrade_assistant/reindex/batch +{ + "indexNames": [ <1> + "index1", + "index2" + ] +} +-------------------------------------------------- +<1> The order in which the indices are provided here determines the order in which the reindex tasks will be executed. + +Similar to the <>, the API returns the following: + +[source,js] +-------------------------------------------------- +{ + "enqueued": [ <1> + { + "indexName": "index1", + "newIndexName": "reindexed-v8-index1", + "status": 3, + "lastCompletedStep": 0, + "locked": null, + "reindexTaskId": null, + "reindexTaskPercComplete": null, + "errorMessage": null, + "runningReindexCount": null, + "reindexOptions": { <2> + "queueSettings": { + "queuedAt": 1583406985489 <3> + } + } + } + ], + "errors": [ <4> + { + "indexName": "index2", + "message": "Something went wrong!" + } + ] +} +-------------------------------------------------- + +<1> A list of reindex operations created, the order in the array indicates the order in which tasks will be executed. +<2> Presence of this key indicates that the reindex job will occur in the batch. +<3> A Unix timestamp of when the reindex task was placed in the queue. +<4> A list of errors that may have occurred preventing the reindex task from being created. + diff --git a/docs/api/upgrade-assistant/cancel_reindex.asciidoc b/docs/api/upgrade-assistant/cancel_reindex.asciidoc index 8951f235c9265f..d31894cd06a05c 100644 --- a/docs/api/upgrade-assistant/cancel_reindex.asciidoc +++ b/docs/api/upgrade-assistant/cancel_reindex.asciidoc @@ -4,10 +4,10 @@ Cancel reindex ++++ -Cancel reindexes that are waiting for the Elasticsearch reindex task to complete. For example, `lastCompletedStep` set to `40`. - experimental["The underlying Upgrade Assistant concepts are stable, but the APIs for managing Upgrade Assistant are experimental."] +Cancel reindexes that are waiting for the Elasticsearch reindex task to complete. For example, `lastCompletedStep` set to `40`. + [[cancel-reindex-request]] ==== Request diff --git a/docs/api/upgrade-assistant/check_reindex_status.asciidoc b/docs/api/upgrade-assistant/check_reindex_status.asciidoc index cb4664baf96b29..c422e5764c69f7 100644 --- a/docs/api/upgrade-assistant/check_reindex_status.asciidoc +++ b/docs/api/upgrade-assistant/check_reindex_status.asciidoc @@ -4,10 +4,10 @@ Check reindex status ++++ -Check the status of the reindex operation. - experimental["The underlying Upgrade Assistant concepts are stable, but the APIs for managing Upgrade Assistant are experimental."] +Check the status of the reindex operation. + [[check-reindex-status-request]] ==== Request diff --git a/docs/api/upgrade-assistant/reindexing.asciidoc b/docs/api/upgrade-assistant/reindexing.asciidoc index a6d5d9d0c16acc..51e7b917b67ac8 100644 --- a/docs/api/upgrade-assistant/reindexing.asciidoc +++ b/docs/api/upgrade-assistant/reindexing.asciidoc @@ -4,10 +4,10 @@ Start or resume reindex ++++ -Start a new reindex or resume a paused reindex. - experimental["The underlying Upgrade Assistant concepts are stable, but the APIs for managing Upgrade Assistant are experimental."] +Start a new reindex or resume a paused reindex. + [[start-resume-reindex-request]] ==== Request diff --git a/docs/api/upgrade-assistant/status.asciidoc b/docs/api/upgrade-assistant/status.asciidoc index 9ad77bcabff735..b087a66fa3bcd5 100644 --- a/docs/api/upgrade-assistant/status.asciidoc +++ b/docs/api/upgrade-assistant/status.asciidoc @@ -4,10 +4,10 @@ Upgrade readiness status ++++ -Check the status of your cluster. - experimental["The underlying Upgrade Assistant concepts are stable, but the APIs for managing Upgrade Assistant are experimental."] +Check the status of your cluster. + [[upgrade-assistant-api-status-request]] ==== Request From f18c571ed64e0669bd7283e08273d4f95f0d47e7 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Fri, 20 Mar 2020 12:07:54 +0000 Subject: [PATCH 32/32] [ML] Listing all categorization wizard checks (#60502) * [ML] Listing all categorization wizard checks * fixing translation * changes based on review * moving check * adding real values to messages * reordering checks enum * fixing types * updating tests * updating id --- .../ml/common/constants/categorization_job.ts | 80 +++++++++++++++++++ x-pack/plugins/ml/common/constants/new_job.ts | 12 --- x-pack/plugins/ml/common/types/categories.ts | 8 +- .../job_creator/categorization_job_creator.ts | 2 +- .../common/job_validator/job_validator.ts | 2 +- .../categorization_examples_loader.ts | 2 +- .../additional_section/additional_section.tsx | 10 ++- .../advanced_section/advanced_section.tsx | 4 +- .../examples_valid_callout.tsx | 51 +++++++++++- .../categorization_view/metric_selection.tsx | 2 +- .../categorization_view/top_categories.tsx | 2 +- .../services/ml_api_service/jobs.ts | 2 +- .../new_job/categorization/examples.ts | 2 +- .../categorization/validation_results.ts | 10 +-- .../apis/ml/categorization_field_examples.ts | 22 ++--- .../anomaly_detection/categorization_job.ts | 2 +- .../job_wizard_categorization.ts | 2 +- 17 files changed, 167 insertions(+), 48 deletions(-) create mode 100644 x-pack/plugins/ml/common/constants/categorization_job.ts diff --git a/x-pack/plugins/ml/common/constants/categorization_job.ts b/x-pack/plugins/ml/common/constants/categorization_job.ts new file mode 100644 index 00000000000000..c1c65e4bf15b85 --- /dev/null +++ b/x-pack/plugins/ml/common/constants/categorization_job.ts @@ -0,0 +1,80 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { VALIDATION_RESULT } from '../types/categories'; + +export const NUMBER_OF_CATEGORY_EXAMPLES = 5; +export const CATEGORY_EXAMPLES_SAMPLE_SIZE = 1000; +export const CATEGORY_EXAMPLES_WARNING_LIMIT = 0.75; +export const CATEGORY_EXAMPLES_ERROR_LIMIT = 0.02; + +export const VALID_TOKEN_COUNT = 3; +export const MEDIAN_LINE_LENGTH_LIMIT = 400; +export const NULL_COUNT_PERCENT_LIMIT = 0.75; + +export enum CATEGORY_EXAMPLES_VALIDATION_STATUS { + VALID = 'valid', + PARTIALLY_VALID = 'partially_valid', + INVALID = 'invalid', +} + +export const VALIDATION_CHECK_DESCRIPTION = { + [VALIDATION_RESULT.NO_EXAMPLES]: i18n.translate( + 'xpack.ml.models.jobService.categorization.messages.validNoDataFound', + { + defaultMessage: 'Examples were successfully loaded.', + } + ), + [VALIDATION_RESULT.FAILED_TO_TOKENIZE]: i18n.translate( + 'xpack.ml.models.jobService.categorization.messages.validFailureToGetTokens', + { + defaultMessage: 'The examples loaded were tokenized successfully.', + } + ), + [VALIDATION_RESULT.TOKEN_COUNT]: i18n.translate( + 'xpack.ml.models.jobService.categorization.messages.validTokenLength', + { + defaultMessage: + 'More than {tokenCount} tokens per example were found in over {percentage}% of the examples loaded.', + values: { + percentage: Math.floor(CATEGORY_EXAMPLES_WARNING_LIMIT * 100), + tokenCount: VALID_TOKEN_COUNT, + }, + } + ), + [VALIDATION_RESULT.MEDIAN_LINE_LENGTH]: i18n.translate( + 'xpack.ml.models.jobService.categorization.messages.validMedianLineLength', + { + defaultMessage: + 'The median line length of the examples loaded was less than {medianCharCount} characters.', + values: { + medianCharCount: MEDIAN_LINE_LENGTH_LIMIT, + }, + } + ), + [VALIDATION_RESULT.NULL_VALUES]: i18n.translate( + 'xpack.ml.models.jobService.categorization.messages.validNullValues', + { + defaultMessage: 'Less than {percentage}% of the examples loaded were null.', + values: { + percentage: Math.floor(100 - NULL_COUNT_PERCENT_LIMIT * 100), + }, + } + ), + [VALIDATION_RESULT.TOO_MANY_TOKENS]: i18n.translate( + 'xpack.ml.models.jobService.categorization.messages.validTooManyTokens', + { + defaultMessage: 'Less than 10000 tokens were found in total in the examples loaded.', + } + ), + [VALIDATION_RESULT.INSUFFICIENT_PRIVILEGES]: i18n.translate( + 'xpack.ml.models.jobService.categorization.messages.validUserPrivileges', + { + defaultMessage: 'The user has sufficient privileges to perform the checks.', + } + ), +}; diff --git a/x-pack/plugins/ml/common/constants/new_job.ts b/x-pack/plugins/ml/common/constants/new_job.ts index 862fa72d11fdb7..751413bb6485a0 100644 --- a/x-pack/plugins/ml/common/constants/new_job.ts +++ b/x-pack/plugins/ml/common/constants/new_job.ts @@ -25,15 +25,3 @@ export const DEFAULT_RARE_BUCKET_SPAN = '1h'; export const DEFAULT_QUERY_DELAY = '60s'; export const SHARED_RESULTS_INDEX_NAME = 'shared'; - -// Categorization -export const NUMBER_OF_CATEGORY_EXAMPLES = 5; -export const CATEGORY_EXAMPLES_SAMPLE_SIZE = 1000; -export const CATEGORY_EXAMPLES_WARNING_LIMIT = 0.75; -export const CATEGORY_EXAMPLES_ERROR_LIMIT = 0.02; - -export enum CATEGORY_EXAMPLES_VALIDATION_STATUS { - VALID = 'valid', - PARTIALLY_VALID = 'partially_valid', - INVALID = 'invalid', -} diff --git a/x-pack/plugins/ml/common/types/categories.ts b/x-pack/plugins/ml/common/types/categories.ts index 862ad8e194a0b2..5d4c3eab53ee88 100644 --- a/x-pack/plugins/ml/common/types/categories.ts +++ b/x-pack/plugins/ml/common/types/categories.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../constants/new_job'; +import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../constants/categorization_job'; export type CategoryId = number; @@ -39,12 +39,12 @@ export interface CategoryFieldExample { } export enum VALIDATION_RESULT { + NO_EXAMPLES, + FAILED_TO_TOKENIZE, + TOO_MANY_TOKENS, TOKEN_COUNT, MEDIAN_LINE_LENGTH, NULL_VALUES, - NO_EXAMPLES, - TOO_MANY_TOKENS, - FAILED_TO_TOKENIZE, INSUFFICIENT_PRIVILEGES, } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/categorization_job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/categorization_job_creator.ts index 7407a43aa9d5e0..95fd9df892cab9 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/categorization_job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/categorization_job_creator.ts @@ -16,8 +16,8 @@ import { CREATED_BY_LABEL, DEFAULT_BUCKET_SPAN, DEFAULT_RARE_BUCKET_SPAN, - CATEGORY_EXAMPLES_VALIDATION_STATUS, } from '../../../../../../common/constants/new_job'; +import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../../../common/constants/categorization_job'; import { ML_JOB_AGGREGATION } from '../../../../../../common/constants/aggregation_types'; import { CategorizationAnalyzer, diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts index 8f6b16c407fb66..82e5e15a24d5c6 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts @@ -16,7 +16,7 @@ import { JobCreator, JobCreatorType, isCategorizationJobCreator } from '../job_c import { populateValidationMessages, checkForExistingJobAndGroupIds } from './util'; import { ExistingJobsAndGroups } from '../../../../services/job_service'; import { cardinalityValidator, CardinalityValidatorResult } from './validators'; -import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../../../common/constants/new_job'; +import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../../../common/constants/categorization_job'; // delay start of validation to allow the user to make changes // e.g. if they are typing in a new value, try not to validate diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts index 8f3a56b6b2b903..de550f61858e66 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts @@ -11,7 +11,7 @@ import { ml } from '../../../../services/ml_api_service'; import { NUMBER_OF_CATEGORY_EXAMPLES, CATEGORY_EXAMPLES_VALIDATION_STATUS, -} from '../../../../../../common/constants/new_job'; +} from '../../../../../../common/constants/categorization_job'; export class CategorizationExamplesLoader { private _jobCreator: CategorizationJobCreator; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/additional_section.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/additional_section.tsx index dd287d10ab2c85..75856d5276fdfe 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/additional_section.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/additional_section.tsx @@ -5,11 +5,17 @@ */ import React, { FC, Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; import { EuiAccordion, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { CalendarsSelection } from './components/calendars'; import { CustomUrlsSelection } from './components/custom_urls'; -const ButtonContent = Additional settings; +const buttonContent = i18n.translate( + 'xpack.ml.newJob.wizard.jobDetailsStep.additionalSectionButton', + { + defaultMessage: 'Additional settings', + } +); interface Props { additionalExpanded: boolean; @@ -22,7 +28,7 @@ export const AdditionalSection: FC = ({ additionalExpanded, setAdditional = ({ advancedExpanded, setAdvancedExpand = ({ overallValidStatus, validationChecks, @@ -66,6 +84,10 @@ export const ExamplesValidCallout: FC = ({ ))} {analyzerUsed} + + + + ); }; @@ -96,3 +118,28 @@ const AnalyzerUsed: FC<{ categorizationAnalyzer: CategorizationAnalyzer }> = ({ ); }; + +const AllValidationChecks: FC<{ validationChecks: FieldExampleCheck[] }> = ({ + validationChecks, +}) => { + const list: EuiListGroupItemProps[] = Object.keys(VALIDATION_CHECK_DESCRIPTION).map((k, i) => { + const failedCheck = validationChecks.find(vc => vc.id === i); + if ( + failedCheck !== undefined && + failedCheck?.valid !== CATEGORY_EXAMPLES_VALIDATION_STATUS.VALID + ) { + return { + iconType: 'cross', + label: failedCheck.message, + size: 's', + }; + } + return { + iconType: 'check', + label: VALIDATION_CHECK_DESCRIPTION[i as VALIDATION_RESULT], + size: 's', + }; + }); + + return ; +}; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection.tsx index 411f6e898bd486..f5c3e90d63418d 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection.tsx @@ -18,7 +18,7 @@ import { CategoryFieldExample, FieldExampleCheck, } from '../../../../../../../../../common/types/categories'; -import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../../../../../../common/constants/new_job'; +import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../../../../../../common/constants/categorization_job'; import { LoadingWrapper } from '../../../charts/loading_wrapper'; interface Props { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/top_categories.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/top_categories.tsx index 3bade07250b464..227c93dc2d86ba 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/top_categories.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/top_categories.tsx @@ -11,7 +11,7 @@ import { JobCreatorContext } from '../../../job_creator_context'; import { CategorizationJobCreator } from '../../../../../common/job_creator'; import { Results } from '../../../../../common/results_loader'; import { ml } from '../../../../../../../services/ml_api_service'; -import { NUMBER_OF_CATEGORY_EXAMPLES } from '../../../../../../../../../common/constants/new_job'; +import { NUMBER_OF_CATEGORY_EXAMPLES } from '../../../../../../../../../common/constants/categorization_job'; export const TopCategories: FC = () => { const { jobCreator: jc, resultsLoader } = useContext(JobCreatorContext); diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts index bcceffb14123eb..16e25067fd91e3 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts @@ -17,7 +17,7 @@ import { CategoryFieldExample, FieldExampleCheck, } from '../../../../common/types/categories'; -import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../common/constants/new_job'; +import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../common/constants/categorization_job'; import { Category } from '../../../../common/types/categories'; export const jobs = { diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts index ea2c71b04f56d2..b209dc56815637 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts @@ -6,7 +6,7 @@ import { chunk } from 'lodash'; import { SearchResponse } from 'elasticsearch'; -import { CATEGORY_EXAMPLES_SAMPLE_SIZE } from '../../../../../common/constants/new_job'; +import { CATEGORY_EXAMPLES_SAMPLE_SIZE } from '../../../../../common/constants/categorization_job'; import { Token, CategorizationAnalyzer, diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts index 34e63eabb405ef..e3b37fffa9c770 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts @@ -6,10 +6,13 @@ import { i18n } from '@kbn/i18n'; import { + VALID_TOKEN_COUNT, + MEDIAN_LINE_LENGTH_LIMIT, + NULL_COUNT_PERCENT_LIMIT, CATEGORY_EXAMPLES_VALIDATION_STATUS, CATEGORY_EXAMPLES_ERROR_LIMIT, CATEGORY_EXAMPLES_WARNING_LIMIT, -} from '../../../../../common/constants/new_job'; +} from '../../../../../common/constants/categorization_job'; import { FieldExampleCheck, CategoryFieldExample, @@ -17,10 +20,6 @@ import { } from '../../../../../common/types/categories'; import { getMedianStringLength } from '../../../../../common/util/string_utils'; -const VALID_TOKEN_COUNT = 3; -const MEDIAN_LINE_LENGTH_LIMIT = 400; -const NULL_COUNT_PERCENT_LIMIT = 0.75; - export class ValidationResults { private _results: FieldExampleCheck[] = []; @@ -187,7 +186,6 @@ export class ValidationResults { valid: CATEGORY_EXAMPLES_VALIDATION_STATUS.PARTIALLY_VALID, message, }); - return; } } diff --git a/x-pack/test/api_integration/apis/ml/categorization_field_examples.ts b/x-pack/test/api_integration/apis/ml/categorization_field_examples.ts index ba7b9c31ad64cc..aab7a65a7c1223 100644 --- a/x-pack/test/api_integration/apis/ml/categorization_field_examples.ts +++ b/x-pack/test/api_integration/apis/ml/categorization_field_examples.ts @@ -96,7 +96,7 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 5, validationChecks: [ { - id: 0, + id: 3, valid: 'valid', message: '1000 field values analyzed, 95% contain 3 or more tokens.', }, @@ -117,12 +117,12 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 5, validationChecks: [ { - id: 1, + id: 4, valid: 'partially_valid', message: 'The median length for the field values analyzed is over 400 characters.', }, { - id: 4, + id: 2, valid: 'invalid', message: 'Tokenization of field value examples has failed due to more than 10000 tokens being found in a sample of 50 values.', @@ -144,12 +144,12 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 5, validationChecks: [ { - id: 0, + id: 3, valid: 'valid', message: '250 field values analyzed, 95% contain 3 or more tokens.', }, { - id: 2, + id: 5, valid: 'partially_valid', message: 'More than 75% of field values are null.', }, @@ -170,12 +170,12 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 5, validationChecks: [ { - id: 0, + id: 3, valid: 'valid', message: '500 field values analyzed, 100% contain 3 or more tokens.', }, { - id: 1, + id: 4, valid: 'partially_valid', message: 'The median length for the field values analyzed is over 400 characters.', }, @@ -196,7 +196,7 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 0, validationChecks: [ { - id: 3, + id: 0, valid: 'invalid', message: 'No examples for this field could be found. Please ensure the selected date range contains data.', @@ -218,7 +218,7 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 5, validationChecks: [ { - id: 0, + id: 3, valid: 'invalid', message: '1000 field values analyzed, 0% contain 3 or more tokens.', }, @@ -242,7 +242,7 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 5, validationChecks: [ { - id: 0, + id: 3, valid: 'valid', message: '1000 field values analyzed, 100% contain 3 or more tokens.', }, @@ -263,7 +263,7 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 5, validationChecks: [ { - id: 0, + id: 3, valid: 'partially_valid', message: '1000 field values analyzed, 50% contain 3 or more tokens.', }, diff --git a/x-pack/test/functional/apps/machine_learning/anomaly_detection/categorization_job.ts b/x-pack/test/functional/apps/machine_learning/anomaly_detection/categorization_job.ts index 80f020f66c0ed4..9fa53d6e546ba6 100644 --- a/x-pack/test/functional/apps/machine_learning/anomaly_detection/categorization_job.ts +++ b/x-pack/test/functional/apps/machine_learning/anomaly_detection/categorization_job.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../../plugins/ml/common/constants/new_job'; +import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../../plugins/ml/common/constants/categorization_job'; // eslint-disable-next-line import/no-default-export export default function({ getService }: FtrProviderContext) { diff --git a/x-pack/test/functional/services/machine_learning/job_wizard_categorization.ts b/x-pack/test/functional/services/machine_learning/job_wizard_categorization.ts index 2f4162c0cb60a8..97d45701a2685d 100644 --- a/x-pack/test/functional/services/machine_learning/job_wizard_categorization.ts +++ b/x-pack/test/functional/services/machine_learning/job_wizard_categorization.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../plugins/ml/common/constants/new_job'; +import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../plugins/ml/common/constants/categorization_job'; export function MachineLearningJobWizardCategorizationProvider({ getService }: FtrProviderContext) { const comboBox = getService('comboBox');