From 63ac99b3aaf0b2e6ad51cdf642bfee13ae0b5d7c Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 8 Jan 2020 15:59:06 +0100 Subject: [PATCH 01/23] Re-enable OIDC API integration test. (#54111) --- .../test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts b/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts index 630ec2792b9bf..1f5a64835416a 100644 --- a/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts +++ b/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts @@ -100,7 +100,7 @@ export default function({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/43938 - it.skip('should succeed if both the OpenID Connect response and the cookie are provided', async () => { + it('should succeed if both the OpenID Connect response and the cookie are provided', async () => { const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce); const authenticationResponse = `https://kibana.com/api/security/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`; From 8e0e4948d562be5373e89c53861449517e4c2729 Mon Sep 17 00:00:00 2001 From: patrykkopycinski Date: Wed, 8 Jan 2020 16:33:51 +0100 Subject: [PATCH 02/23] [SIEM] Fix columns in timeline do not resize (#51816) --- x-pack/legacy/plugins/siem/package.json | 2 +- .../components/fields_browser/index.tsx | 4 +- .../components/flyout/pane/index.test.tsx | 2 +- .../public/components/flyout/pane/index.tsx | 160 ++++++++------- .../pane/timeline_resize_handle.tsx} | 7 +- .../__snapshots__/index.test.tsx.snap | 14 -- .../components/resize_handle/index.test.tsx | 184 ----------------- .../public/components/resize_handle/index.tsx | 145 ------------- .../components/resize_handle/is_resizing.tsx | 16 -- .../__snapshots__/index.test.tsx.snap | 22 +- .../body/column_headers/column_header.tsx | 99 +++++++++ .../common/dragging_container.tsx | 24 +++ .../header/__snapshots__/index.test.tsx.snap | 56 ++++- .../column_headers/header/header_content.tsx | 76 +++++++ .../body/column_headers/header/index.test.tsx | 43 ---- .../body/column_headers/header/index.tsx | 123 ++--------- .../body/column_headers/index.test.tsx | 40 ---- .../timeline/body/column_headers/index.tsx | 194 +++++++----------- .../__snapshots__/index.test.tsx.snap | 60 +++--- .../public/components/timeline/styles.tsx | 81 ++++---- x-pack/package.json | 1 + yarn.lock | 20 +- 22 files changed, 509 insertions(+), 864 deletions(-) rename x-pack/legacy/plugins/siem/public/components/{resize_handle/styled_handles.tsx => flyout/pane/timeline_resize_handle.tsx} (70%) delete mode 100644 x-pack/legacy/plugins/siem/public/components/resize_handle/__snapshots__/index.test.tsx.snap delete mode 100644 x-pack/legacy/plugins/siem/public/components/resize_handle/index.test.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/resize_handle/is_resizing.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/common/dragging_container.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/header_content.tsx diff --git a/x-pack/legacy/plugins/siem/package.json b/x-pack/legacy/plugins/siem/package.json index bf5d6d3a3089c..558ac013e5963 100644 --- a/x-pack/legacy/plugins/siem/package.json +++ b/x-pack/legacy/plugins/siem/package.json @@ -13,7 +13,7 @@ "devDependencies": { "@types/lodash": "^4.14.110", "@types/js-yaml": "^3.12.1", - "@types/react-beautiful-dnd": "^11.0.3" + "@types/react-beautiful-dnd": "^11.0.4" }, "dependencies": { "lodash": "^4.17.15", diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx index 3958cd463d56e..c8cde5fa02a51 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx @@ -212,9 +212,7 @@ export const StatefulFieldsBrowserComponent = React.memo { ); - expect(wrapper.find('[data-test-subj="eui-flyout"]').get(0).props.maxWidth).toEqual('95%'); + expect(wrapper.find('Resizable').get(0).props.maxWidth).toEqual('95vw'); }); test('it applies timeline styles to the EuiFlyout', () => { diff --git a/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx b/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx index f2f0cf4f980f3..00ac15092a6ec 100644 --- a/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx @@ -5,13 +5,14 @@ */ import { EuiButtonIcon, EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiToolTip } from '@elastic/eui'; -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { connect } from 'react-redux'; import styled from 'styled-components'; import { ActionCreator } from 'typescript-fsa'; +import { Resizable, ResizeCallback } from 're-resizable'; +import { throttle } from 'lodash/fp'; -import { OnResize, Resizeable } from '../../resize_handle'; -import { TimelineResizeHandle } from '../../resize_handle/styled_handles'; +import { TimelineResizeHandle } from './timeline_resize_handle'; import { FlyoutHeader } from '../header'; import * as i18n from './translations'; @@ -41,10 +42,10 @@ interface DispatchProps { type Props = OwnProps & DispatchProps; -const EuiFlyoutContainer = styled.div<{ headerHeight: number; width: number }>` +const EuiFlyoutContainer = styled.div<{ headerHeight: number }>` .timeline-flyout { min-width: 150px; - width: ${({ width }) => `${width}px`}; + width: auto; } .timeline-flyout-header { align-items: center; @@ -65,8 +66,6 @@ const EuiFlyoutContainer = styled.div<{ headerHeight: number; width: number }>` } `; -EuiFlyoutContainer.displayName = 'EuiFlyoutContainer'; - const FlyoutHeaderContainer = styled.div` align-items: center; display: flex; @@ -75,88 +74,95 @@ const FlyoutHeaderContainer = styled.div` width: 100%; `; -FlyoutHeaderContainer.displayName = 'FlyoutHeaderContainer'; - // manually wrap the close button because EuiButtonIcon can't be a wrapped `styled` const WrappedCloseButton = styled.div` margin-right: 5px; `; -WrappedCloseButton.displayName = 'WrappedCloseButton'; - -const FlyoutHeaderWithCloseButton = React.memo<{ +const FlyoutHeaderWithCloseButtonComponent: React.FC<{ onClose: () => void; timelineId: string; usersViewing: string[]; -}>( - ({ onClose, timelineId, usersViewing }) => ( - - - - - - - - - ), +}> = ({ onClose, timelineId, usersViewing }) => ( + + + + + + + + +); + +const FlyoutHeaderWithCloseButton = React.memo( + FlyoutHeaderWithCloseButtonComponent, (prevProps, nextProps) => prevProps.timelineId === nextProps.timelineId && prevProps.usersViewing === nextProps.usersViewing ); -FlyoutHeaderWithCloseButton.displayName = 'FlyoutHeaderWithCloseButton'; - -const FlyoutPaneComponent = React.memo( - ({ - applyDeltaToWidth, - children, - flyoutHeight, - headerHeight, - onClose, - timelineId, - usersViewing, - width, - }) => { - const renderFlyout = useCallback(() => <>, []); - - const onResize: OnResize = useCallback( - ({ delta, id }) => { - const bodyClientWidthPixels = document.body.clientWidth; - +const FlyoutPaneComponent: React.FC = ({ + applyDeltaToWidth, + children, + flyoutHeight, + headerHeight, + onClose, + timelineId, + usersViewing, + width, +}) => { + const [lastDelta, setLastDelta] = useState(0); + const onResizeStop: ResizeCallback = useCallback( + (e, direction, ref, delta) => { + const bodyClientWidthPixels = document.body.clientWidth; + + if (delta.width) { applyDeltaToWidth({ bodyClientWidthPixels, - delta, - id, + delta: -(delta.width - lastDelta), + id: timelineId, maxWidthPercent, minWidthPixels, }); - }, - [applyDeltaToWidth, maxWidthPercent, minWidthPixels] - ); - return ( - - - setLastDelta(0), [setLastDelta]); + const throttledResize = throttle(100, onResizeStop); + + return ( + + + - } - id={timelineId} - onResize={onResize} - render={renderFlyout} - /> + ), + }} + onResizeStart={resetLastDelta} + onResize={throttledResize} + > ( {children} - - - ); - } -); - -FlyoutPaneComponent.displayName = 'FlyoutPaneComponent'; + + + + ); +}; export const Pane = connect(null, { applyDeltaToWidth: timelineActions.applyDeltaToWidth, -})(FlyoutPaneComponent); +})(React.memo(FlyoutPaneComponent)); Pane.displayName = 'Pane'; diff --git a/x-pack/legacy/plugins/siem/public/components/resize_handle/styled_handles.tsx b/x-pack/legacy/plugins/siem/public/components/flyout/pane/timeline_resize_handle.tsx similarity index 70% rename from x-pack/legacy/plugins/siem/public/components/resize_handle/styled_handles.tsx rename to x-pack/legacy/plugins/siem/public/components/flyout/pane/timeline_resize_handle.tsx index 4f641c5d2042e..3ee29c2eaaa16 100644 --- a/x-pack/legacy/plugins/siem/public/components/resize_handle/styled_handles.tsx +++ b/x-pack/legacy/plugins/siem/public/components/flyout/pane/timeline_resize_handle.tsx @@ -8,18 +8,13 @@ import styled from 'styled-components'; export const TIMELINE_RESIZE_HANDLE_WIDTH = 2; // px -export const CommonResizeHandle = styled.div` +export const TimelineResizeHandle = styled.div<{ height: number }>` cursor: col-resize; height: 100%; min-height: 20px; width: 0; -`; -CommonResizeHandle.displayName = 'CommonResizeHandle'; - -export const TimelineResizeHandle = styled(CommonResizeHandle)<{ height: number }>` border: ${TIMELINE_RESIZE_HANDLE_WIDTH}px solid ${props => props.theme.eui.euiColorLightShade}; z-index: 2; height: ${({ height }) => `${height}px`}; position: absolute; `; -TimelineResizeHandle.displayName = 'TimelineResizeHandle'; diff --git a/x-pack/legacy/plugins/siem/public/components/resize_handle/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/resize_handle/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 38027f80e6684..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/resize_handle/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,14 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Resizeable it renders 1`] = ` - - - - - -`; diff --git a/x-pack/legacy/plugins/siem/public/components/resize_handle/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/resize_handle/index.test.tsx deleted file mode 100644 index 1237a6538a4c1..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/resize_handle/index.test.tsx +++ /dev/null @@ -1,184 +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 { mount, shallow } from 'enzyme'; -import React from 'react'; - -import { TestProviders } from '../../mock/test_providers'; - -import { - addGlobalResizeCursorStyleToBody, - globalResizeCursorClassName, - removeGlobalResizeCursorStyleFromBody, - Resizeable, - calculateDeltaX, -} from '.'; -import { CommonResizeHandle } from './styled_handles'; - -describe('Resizeable', () => { - afterEach(() => { - document.body.classList.remove(globalResizeCursorClassName); - }); - - test('it applies the provided height to the ResizeHandleContainer when a height is specified', () => { - const wrapper = mount( - - } - height="100%" - id="test" - onResize={jest.fn()} - render={() => <>} - /> - - ); - - expect(wrapper.find('[data-test-subj="resize-handle-container"]').first()).toHaveStyleRule( - 'height', - '100%' - ); - }); - - test('it applies positioning styles to the ResizeHandleContainer when positionAbsolute is true and bottom/left/right/top is specified', () => { - const wrapper = mount( - - } - id="test" - left={0} - onResize={jest.fn()} - positionAbsolute - render={() => <>} - right={0} - top={0} - /> - - ); - const resizeHandleContainer = wrapper - .find('[data-test-subj="resize-handle-container"]') - .first(); - - expect(resizeHandleContainer).toHaveStyleRule('bottom', '0'); - expect(resizeHandleContainer).toHaveStyleRule('left', '0'); - expect(resizeHandleContainer).toHaveStyleRule('position', 'absolute'); - expect(resizeHandleContainer).toHaveStyleRule('right', '0'); - expect(resizeHandleContainer).toHaveStyleRule('top', '0'); - }); - - test('it DOES NOT apply positioning styles to the ResizeHandleContainer when positionAbsolute is false, regardless if bottom/left/right/top is specified', () => { - const wrapper = mount( - - } - id="test" - left={0} - onResize={jest.fn()} - render={() => <>} - right={0} - top={0} - /> - - ); - const resizeHandleContainer = wrapper - .find('[data-test-subj="resize-handle-container"]') - .first(); - - expect(resizeHandleContainer).not.toHaveStyleRule('bottom', '0'); - expect(resizeHandleContainer).not.toHaveStyleRule('left', '0'); - expect(resizeHandleContainer).not.toHaveStyleRule('position', 'absolute'); - expect(resizeHandleContainer).not.toHaveStyleRule('right', '0'); - expect(resizeHandleContainer).not.toHaveStyleRule('top', '0'); - }); - - test('it renders', () => { - const wrapper = shallow( - } - height="100%" - id="test" - onResize={jest.fn()} - render={() => <>} - /> - ); - - expect(wrapper).toMatchSnapshot(); - }); - - describe('resize cursor styling', () => { - test('it does NOT apply the global-resize-cursor style to the body by default', () => { - mount( - - } - height="100%" - id="test" - onResize={jest.fn()} - render={() => <>} - /> - - ); - - expect(document.body.className).not.toContain(globalResizeCursorClassName); - }); - - describe('#addGlobalResizeCursorStyleToBody', () => { - test('it adds the global-resize-cursor style to the body', () => { - mount( - - } - height="100%" - id="test" - onResize={jest.fn()} - render={() => <>} - /> - - ); - - addGlobalResizeCursorStyleToBody(); - - expect(document.body.className).toContain(globalResizeCursorClassName); - }); - }); - - describe('#removeGlobalResizeCursorStyleFromBody', () => { - test('it removes the global-resize-cursor style from body', () => { - mount( - - } - height="100%" - id="test" - onResize={jest.fn()} - render={() => <>} - /> - - ); - - addGlobalResizeCursorStyleToBody(); - removeGlobalResizeCursorStyleFromBody(); - - expect(document.body.className).not.toContain(globalResizeCursorClassName); - }); - }); - - describe('#calculateDeltaX', () => { - test('it returns 0 when prevX isEqual 0', () => { - expect(calculateDeltaX({ prevX: 0, screenX: 189 })).toEqual(0); - }); - - test('it returns positive difference when screenX > prevX', () => { - expect(calculateDeltaX({ prevX: 10, screenX: 189 })).toEqual(179); - }); - - test('it returns negative difference when prevX > screenX ', () => { - expect(calculateDeltaX({ prevX: 199, screenX: 189 })).toEqual(-10); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx b/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx deleted file mode 100644 index eb3326c2f2cd0..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx +++ /dev/null @@ -1,145 +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, { useEffect, useRef } from 'react'; -import { fromEvent, Observable, Subscription } from 'rxjs'; -import { concatMap, takeUntil } from 'rxjs/operators'; -import styled from 'styled-components'; - -export type OnResize = ({ delta, id }: { delta: number; id: string }) => void; - -export const resizeCursorStyle = 'col-resize'; -export const globalResizeCursorClassName = 'global-resize-cursor'; - -/** This polyfill is for Safari and IE-11 only. `movementX` is more accurate and "feels" better, so only use this function on Safari and IE-11 */ -export const calculateDeltaX = ({ prevX, screenX }: { prevX: number; screenX: number }) => - prevX !== 0 ? screenX - prevX : 0; - -const isSafari = /^((?!chrome|android|crios|fxios|Firefox).)*safari/i.test(navigator.userAgent); - -interface ResizeHandleContainerProps { - bottom?: string | number; - /** optionally provide a height style ResizeHandleContainer */ - height?: string; - left?: string | number; - positionAbsolute?: boolean; - right?: string | number; - top?: string | number; -} - -interface Props extends ResizeHandleContainerProps { - /** a (styled) resize handle */ - handle: React.ReactNode; - /** the `onResize` callback will be invoked with this id */ - id: string; - /** invoked when the handle is resized */ - onResize: OnResize; - /** The resizeable content to render */ - render: (isResizing: boolean) => React.ReactNode; -} - -const ResizeHandleContainer = styled.div` - bottom: ${({ positionAbsolute, bottom }) => positionAbsolute && bottom}; - cursor: ${resizeCursorStyle}; - height: ${({ height }) => height}; - left: ${({ positionAbsolute, left }) => positionAbsolute && left}; - position: ${({ positionAbsolute }) => positionAbsolute && 'absolute'}; - right: ${({ positionAbsolute, right }) => positionAbsolute && right}; - top: ${({ positionAbsolute, top }) => positionAbsolute && top}; - z-index: ${({ positionAbsolute, theme }) => positionAbsolute && theme.eui.euiZLevel1}; -`; -ResizeHandleContainer.displayName = 'ResizeHandleContainer'; - -export const addGlobalResizeCursorStyleToBody = () => { - document.body.classList.add(globalResizeCursorClassName); -}; - -export const removeGlobalResizeCursorStyleFromBody = () => { - document.body.classList.remove(globalResizeCursorClassName); -}; - -export const Resizeable = React.memo( - ({ bottom, handle, height, id, left, onResize, positionAbsolute, render, right, top }) => { - const drag$ = useRef | null>(null); - const dragEventTargets = useRef>([]); - const dragSubscription = useRef(null); - const prevX = useRef(0); - const ref = useRef(null); - const upSubscription = useRef(null); - const isResizingRef = useRef(false); - - const calculateDelta = (e: MouseEvent) => { - const deltaX = calculateDeltaX({ prevX: prevX.current, screenX: e.screenX }); - prevX.current = e.screenX; - return deltaX; - }; - useEffect(() => { - const move$ = fromEvent(document, 'mousemove'); - const down$ = fromEvent(ref.current!, 'mousedown'); - const up$ = fromEvent(document, 'mouseup'); - - drag$.current = down$.pipe(concatMap(() => move$.pipe(takeUntil(up$)))); - dragSubscription.current = - drag$.current && - drag$.current.subscribe(event => { - // We do a feature detection of event.movementX here and if it is missing - // we calculate the delta manually. Browsers IE-11 and Safari will call calculateDelta - const delta = - event.movementX == null || isSafari ? calculateDelta(event) : event.movementX; - if (!isResizingRef.current) { - isResizingRef.current = true; - } - onResize({ id, delta }); - if (event.target != null && event.target instanceof HTMLElement) { - const htmlElement: HTMLElement = event.target; - dragEventTargets.current = [ - ...dragEventTargets.current, - { htmlElement, prevCursor: htmlElement.style.cursor }, - ]; - htmlElement.style.cursor = resizeCursorStyle; - } - }); - - upSubscription.current = up$.subscribe(() => { - if (isResizingRef.current) { - dragEventTargets.current.reverse().forEach(eventTarget => { - eventTarget.htmlElement.style.cursor = eventTarget.prevCursor; - }); - dragEventTargets.current = []; - isResizingRef.current = false; - } - }); - return () => { - if (dragSubscription.current != null) { - dragSubscription.current.unsubscribe(); - } - if (upSubscription.current != null) { - upSubscription.current.unsubscribe(); - } - }; - }, []); - - return ( - <> - {render(isResizingRef.current)} - - {handle} - - - ); - } -); - -Resizeable.displayName = 'Resizeable'; diff --git a/x-pack/legacy/plugins/siem/public/components/resize_handle/is_resizing.tsx b/x-pack/legacy/plugins/siem/public/components/resize_handle/is_resizing.tsx deleted file mode 100644 index 5eb2d397b4c98..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/resize_handle/is_resizing.tsx +++ /dev/null @@ -1,16 +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 { useState } from 'react'; - -export const useIsContainerResizing = () => { - const [isResizing, setIsResizing] = useState(false); - - return { - isResizing, - setIsResizing, - }; -}; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap index 1b66a130c3550..dfea99ffd7091 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap @@ -1,20 +1,20 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` - - - + - - + - - - - + + + - - + + `; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/column_header.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/column_header.tsx index 15911f522032a..ccaeeff972a81 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/column_header.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/column_header.tsx @@ -4,6 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; +import { Draggable } from 'react-beautiful-dnd'; +import { Resizable, ResizeCallback } from 're-resizable'; +import { DragEffects } from '../../../drag_and_drop/draggable_wrapper'; +import { getDraggableFieldId, DRAG_TYPE_FIELD } from '../../../drag_and_drop/helpers'; +import { DraggableFieldBadge } from '../../../draggables/field_badge'; +import { OnColumnRemoved, OnColumnSorted, OnFilterChange, OnColumnResized } from '../../events'; +import { EventsTh, EventsThContent, EventsHeadingHandle } from '../../styles'; +import { Sort } from '../sort'; +import { DraggingContainer } from './common/dragging_container'; + +import { Header } from './header'; import { ColumnId } from '../column_id'; export type ColumnHeaderType = 'not-filtered' | 'text-filter'; @@ -22,3 +34,90 @@ export interface ColumnHeader { type?: string; width: number; } + +interface ColumneHeaderProps { + draggableIndex: number; + header: ColumnHeader; + onColumnRemoved: OnColumnRemoved; + onColumnSorted: OnColumnSorted; + onColumnResized: OnColumnResized; + onFilterChange?: OnFilterChange; + sort: Sort; + timelineId: string; +} + +const ColumnHeaderComponent: React.FC = ({ + draggableIndex, + header, + timelineId, + onColumnRemoved, + onColumnResized, + onColumnSorted, + onFilterChange, + sort, +}) => { + const [isDragging, setIsDragging] = React.useState(false); + const handleResizeStop: ResizeCallback = (e, direction, ref, delta) => { + onColumnResized({ columnId: header.id, delta: delta.width }); + }; + + return ( + , + }} + onResizeStop={handleResizeStop} + > + + {(dragProvided, dragSnapshot) => ( + + {!dragSnapshot.isDragging ? ( + +
+ + ) : ( + + + + + + )} + + )} + + + ); +}; + +export const ColumnHeader = React.memo(ColumnHeaderComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/common/dragging_container.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/common/dragging_container.tsx new file mode 100644 index 0000000000000..21aa17aa1c52c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/common/dragging_container.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FC, memo, useEffect } from 'react'; + +interface DraggingContainerProps { + children: JSX.Element; + onDragging: Function; +} + +const DraggingContainerComponent: FC = ({ children, onDragging }) => { + useEffect(() => { + onDragging(true); + + return () => onDragging(false); + }); + + return children; +}; + +export const DraggingContainer = memo(DraggingContainerComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap index 64c2b6ed10692..d30054ae1a3fe 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap @@ -1,14 +1,50 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Header renders correctly against snapshot 1`] = ` -} - id="@timestamp" - onResize={[Function]} - positionAbsolute={true} - render={[Function]} - right="-1px" - top={0} -/> + + + + + + `; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/header_content.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/header_content.tsx new file mode 100644 index 0000000000000..c38ae26050c93 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/header_content.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiToolTip } from '@elastic/eui'; +import { noop } from 'lodash/fp'; +import React from 'react'; + +import { TruncatableText } from '../../../../truncatable_text'; +import { EventsHeading, EventsHeadingTitleButton, EventsHeadingTitleSpan } from '../../../styles'; +import { useTimelineContext } from '../../../timeline_context'; +import { Sort } from '../../sort'; +import { SortIndicator } from '../../sort/sort_indicator'; +import { ColumnHeader } from '../column_header'; +import { HeaderToolTipContent } from '../header_tooltip_content'; +import { getSortDirection } from './helpers'; + +interface HeaderContentProps { + children: React.ReactNode; + header: ColumnHeader; + isResizing: boolean; + onClick: () => void; + sort: Sort; +} + +const HeaderContentComponent: React.FC = ({ + children, + header, + isResizing, + onClick, + sort, +}) => { + const isLoading = useTimelineContext(); + + return ( + + {header.aggregatable ? ( + + + } + > + <>{header.label ?? header.id} + + + + + + ) : ( + + + } + > + <>{header.label ?? header.id} + + + + )} + + {children} + + ); +}; + +export const HeaderContent = React.memo(HeaderContentComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.test.tsx index 64f32674cd042..fab2e7ee872bf 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.test.tsx @@ -32,9 +32,7 @@ describe('Header', () => { @@ -49,9 +47,7 @@ describe('Header', () => { @@ -74,9 +70,7 @@ describe('Header', () => { @@ -98,9 +92,7 @@ describe('Header', () => { @@ -126,9 +118,7 @@ describe('Header', () => { @@ -153,9 +143,7 @@ describe('Header', () => { @@ -181,9 +169,7 @@ describe('Header', () => { @@ -201,9 +187,7 @@ describe('Header', () => { @@ -221,9 +205,7 @@ describe('Header', () => { @@ -334,9 +316,7 @@ describe('Header', () => { @@ -357,9 +337,7 @@ describe('Header', () => { @@ -369,25 +347,4 @@ describe('Header', () => { expect(wrapper.find('[data-test-subj="header-tooltip"]').exists()).toEqual(true); }); }); - - describe('setIsResizing', () => { - test('setIsResizing have been call when it renders actions', () => { - const mockSetIsResizing = jest.fn(); - mount( - - - - ); - - expect(mockSetIsResizing).toHaveBeenCalled(); - }); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx index 311b4bfda60fe..c45b9ce425deb 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx @@ -4,103 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiToolTip } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import React from 'react'; +import React, { useCallback } from 'react'; -import { OnResize, Resizeable } from '../../../../resize_handle'; -import { TruncatableText } from '../../../../truncatable_text'; -import { OnColumnRemoved, OnColumnResized, OnColumnSorted, OnFilterChange } from '../../../events'; -import { - EventsHeading, - EventsHeadingHandle, - EventsHeadingTitleButton, - EventsHeadingTitleSpan, -} from '../../../styles'; -import { useTimelineContext } from '../../../timeline_context'; +import { OnColumnRemoved, OnColumnSorted, OnFilterChange } from '../../../events'; import { Sort } from '../../sort'; -import { SortIndicator } from '../../sort/sort_indicator'; import { Actions } from '../actions'; import { ColumnHeader } from '../column_header'; import { Filter } from '../filter'; -import { HeaderToolTipContent } from '../header_tooltip_content'; -import { getNewSortDirectionOnClick, getSortDirection } from './helpers'; - -interface HeaderCompProps { - children: React.ReactNode; - header: ColumnHeader; - isResizing: boolean; - onClick: () => void; - sort: Sort; -} - -const HeaderComp = React.memo( - ({ children, header, isResizing, onClick, sort }) => { - const isLoading = useTimelineContext(); - - return ( - - {header.aggregatable ? ( - - - } - > - <>{header.label ?? header.id} - - - - - - ) : ( - - - } - > - <>{header.label ?? header.id} - - - - )} - - {children} - - ); - } -); -HeaderComp.displayName = 'HeaderComp'; +import { getNewSortDirectionOnClick } from './helpers'; +import { HeaderContent } from './header_content'; interface Props { header: ColumnHeader; onColumnRemoved: OnColumnRemoved; - onColumnResized: OnColumnResized; onColumnSorted: OnColumnSorted; onFilterChange?: OnFilterChange; - setIsResizing: (isResizing: boolean) => void; sort: Sort; timelineId: string; } -/** Renders a header */ -export const HeaderComponent = ({ +export const HeaderComponent: React.FC = ({ header, onColumnRemoved, - onColumnResized, onColumnSorted, onFilterChange = noop, - setIsResizing, sort, -}: Props) => { - const onClick = () => { +}) => { + const onClick = useCallback(() => { onColumnSorted!({ columnId: header.id, sortDirection: getNewSortDirectionOnClick({ @@ -108,41 +39,17 @@ export const HeaderComponent = ({ currentSort: sort, }), }); - }; - - const onResize: OnResize = ({ delta, id }) => { - onColumnResized({ columnId: id, delta }); - }; - - const renderActions = (isResizing: boolean) => { - setIsResizing(isResizing); - return ( - <> - - - - - - - ); - }; + }, [onColumnSorted, header, sort]); return ( - } - id={header.id} - onResize={onResize} - positionAbsolute - render={renderActions} - right="-1px" - top={0} - /> + <> + + + + + + ); }; -HeaderComponent.displayName = 'HeaderComponent'; - export const Header = React.memo(HeaderComponent); - -Header.displayName = 'Header'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.test.tsx index 0fdd7d78ae253..4b97dd7573a45 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.test.tsx @@ -17,14 +17,6 @@ import { useMountAppended } from '../../../../utils/use_mount_appended'; import { ColumnHeadersComponent } from '.'; -jest.mock('../../../resize_handle/is_resizing', () => ({ - ...jest.requireActual('../../../resize_handle/is_resizing'), - useIsContainerResizing: () => ({ - isResizing: true, - setIsResizing: jest.fn(), - }), -})); - describe('ColumnHeaders', () => { const mount = useMountAppended(); @@ -117,37 +109,5 @@ describe('ColumnHeaders', () => { ).toContain(h.id); }); }); - - test('it disables dragging during a column resize', () => { - const wrapper = mount( - - - - ); - - defaultHeaders.forEach(h => { - expect( - wrapper - .find('[data-test-subj="draggable"]') - .first() - .prop('isDragDisabled') - ).toBe(true); - }); - }); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx index 52495c2e3c816..953ffb4d4932b 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx @@ -7,19 +7,12 @@ import { EuiCheckbox } from '@elastic/eui'; import { noop } from 'lodash/fp'; import React from 'react'; -import { Draggable, Droppable } from 'react-beautiful-dnd'; +import { Droppable } from 'react-beautiful-dnd'; import { BrowserFields } from '../../../../containers/source'; -import { DragEffects } from '../../../drag_and_drop/draggable_wrapper'; -import { - DRAG_TYPE_FIELD, - droppableTimelineColumnsPrefix, - getDraggableFieldId, -} from '../../../drag_and_drop/helpers'; -import { DraggableFieldBadge } from '../../../draggables/field_badge'; +import { DRAG_TYPE_FIELD, droppableTimelineColumnsPrefix } from '../../../drag_and_drop/helpers'; import { StatefulFieldsBrowser } from '../../../fields_browser'; import { FIELD_BROWSER_HEIGHT, FIELD_BROWSER_WIDTH } from '../../../fields_browser/helpers'; -import { useIsContainerResizing } from '../../../resize_handle/is_resizing'; import { OnColumnRemoved, OnColumnResized, @@ -39,7 +32,6 @@ import { import { Sort } from '../sort'; import { ColumnHeader } from './column_header'; import { EventsSelect } from './events_select'; -import { Header } from './header'; interface Props { actionsColumnWidth: number; @@ -78,132 +70,86 @@ export const ColumnHeadersComponent = ({ sort, timelineId, toggleColumn, -}: Props) => { - const { isResizing, setIsResizing } = useIsContainerResizing(); - - return ( - - - - {showEventsSelect && ( - - - - - - )} - - {showSelectAllCheckbox && ( - - - ) => { - onSelectAll({ isSelected: event.currentTarget.checked }); - }} - /> - - - )} - +}: Props) => ( + + + + {showEventsSelect && ( - - + + + + )} + {showSelectAllCheckbox && ( + + + ) => { + onSelectAll({ isSelected: event.currentTarget.checked }); + }} /> - + )} + + + + + + - - {dropProvided => ( + + {(dropProvided, snapshot) => ( + <> - {columnHeaders.map((header, i) => ( - ( + - {(dragProvided, dragSnapshot) => ( - - {!dragSnapshot.isDragging ? ( - -
- - ) : ( - - - - )} - - )} - + draggableIndex={draggableIndex} + timelineId={timelineId} + header={header} + onColumnRemoved={onColumnRemoved} + onColumnSorted={onColumnSorted} + onFilterChange={onFilterChange} + onColumnResized={onColumnResized} + sort={sort} + /> ))} - )} - - - - ); -}; - -ColumnHeadersComponent.displayName = 'ColumnHeadersComponent'; + {dropProvided.placeholder} + + )} + + + +); export const ColumnHeaders = React.memo(ColumnHeadersComponent); - -ColumnHeaders.displayName = 'ColumnHeaders'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap index a168f8d48fa33..75c05dd1455af 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap @@ -1,10 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Columns it renders the expected columns 1`] = ` - - - - - - + + - - - - + + - - - - + + - - - - + + - - - - + + - - - - + + - - - - + + + `; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx index f0f41fc1f674f..b6fdc1b2973aa 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx @@ -27,7 +27,7 @@ export const TimelineBodyGlobalStyle = createGlobalStyle` } `; -export const TimelineBody = styled.div.attrs(({ className }) => ({ +export const TimelineBody = styled.div.attrs(({ className = '' }) => ({ className: `siemTimeline__body ${className}`, }))<{ bodyHeight: number }>` height: ${({ bodyHeight }) => `${bodyHeight}px`}; @@ -56,15 +56,14 @@ TimelineBody.displayName = 'TimelineBody'; * EVENTS TABLE */ -export const EventsTable = styled.div.attrs(({ className }) => ({ +export const EventsTable = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable ${className}`, role: 'table', }))``; -EventsTable.displayName = 'EventsTable'; /* EVENTS HEAD */ -export const EventsThead = styled.div.attrs(({ className }) => ({ +export const EventsThead = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__thead ${className}`, role: 'rowgroup', }))` @@ -75,7 +74,6 @@ export const EventsThead = styled.div.attrs(({ className }) => ({ top: 0; z-index: ${({ theme }) => theme.eui.euiZLevel1}; `; -EventsThead.displayName = 'EventsThead'; export const EventsTrHeader = styled.div.attrs(({ className }) => ({ className: `siemEventsTable__trHeader ${className}`, @@ -83,9 +81,8 @@ export const EventsTrHeader = styled.div.attrs(({ className }) => ({ }))` display: flex; `; -EventsTrHeader.displayName = 'EventsTrHeader'; -export const EventsThGroupActions = styled.div.attrs(({ className }) => ({ +export const EventsThGroupActions = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__thGroupActions ${className}`, }))<{ actionsColumnWidth: number; justifyContent: string }>` display: flex; @@ -93,24 +90,27 @@ export const EventsThGroupActions = styled.div.attrs(({ className }) => ({ justify-content: ${({ justifyContent }) => justifyContent}; min-width: 0; `; -EventsThGroupActions.displayName = 'EventsThGroupActions'; -export const EventsThGroupData = styled.div.attrs(({ className }) => ({ +export const EventsThGroupData = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__thGroupData ${className}`, -}))` +}))<{ isDragging?: boolean }>` display: flex; + + > div:hover .siemEventsHeading__handle { + display: ${({ isDragging }) => (isDragging ? 'none' : 'block')}; + opacity: 1; + visibility: visible; + } `; -EventsThGroupData.displayName = 'EventsThGroupData'; -export const EventsTh = styled.div.attrs(({ className }) => ({ +export const EventsTh = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__th ${className}`, role: 'columnheader', -}))<{ isDragging?: boolean; position?: string }>` +}))` align-items: center; display: flex; flex-shrink: 0; min-width: 0; - position: ${({ position }) => position}; .siemEventsTable__thGroupActions &:first-child:last-child { flex: 1; @@ -121,10 +121,18 @@ export const EventsTh = styled.div.attrs(({ className }) => ({ cursor: move; /* Fallback for IE11 */ cursor: grab; } + + > div:focus { + outline: 0; /* disable focus on Resizable element */ + } + + /* don't display Draggable placeholder */ + [data-rbd-placeholder-context-id] { + display: none !important; + } `; -EventsTh.displayName = 'EventsTh'; -export const EventsThContent = styled.div.attrs(({ className }) => ({ +export const EventsThContent = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__thContent ${className}`, }))<{ textAlign?: string }>` font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; @@ -135,19 +143,17 @@ export const EventsThContent = styled.div.attrs(({ className }) => ({ text-align: ${({ textAlign }) => textAlign}; width: 100%; /* Using width: 100% instead of flex: 1 and max-width: 100% for IE11 */ `; -EventsThContent.displayName = 'EventsThContent'; /* EVENTS BODY */ -export const EventsTbody = styled.div.attrs(({ className }) => ({ +export const EventsTbody = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__tbody ${className}`, role: 'rowgroup', }))` overflow-x: hidden; `; -EventsTbody.displayName = 'EventsTbody'; -export const EventsTrGroup = styled.div.attrs(({ className }) => ({ +export const EventsTrGroup = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__trGroup ${className}`, }))<{ className?: string }>` border-bottom: ${({ theme }) => theme.eui.euiBorderWidthThin} solid @@ -157,17 +163,15 @@ export const EventsTrGroup = styled.div.attrs(({ className }) => ({ background-color: ${({ theme }) => theme.eui.euiTableHoverColor}; } `; -EventsTrGroup.displayName = 'EventsTrGroup'; -export const EventsTrData = styled.div.attrs(({ className }) => ({ +export const EventsTrData = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__trData ${className}`, role: 'row', }))` display: flex; `; -EventsTrData.displayName = 'EventsTrData'; -export const EventsTrSupplement = styled.div.attrs(({ className }) => ({ +export const EventsTrSupplement = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__trSupplement ${className}`, }))<{ className: string }>` font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; @@ -175,9 +179,8 @@ export const EventsTrSupplement = styled.div.attrs(({ className }) => ({ padding: 0 ${({ theme }) => theme.eui.paddingSizes.xs} 0 ${({ theme }) => theme.eui.paddingSizes.xl}; `; -EventsTrSupplement.displayName = 'EventsTrSupplement'; -export const EventsTdGroupActions = styled.div.attrs(({ className }) => ({ +export const EventsTdGroupActions = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__tdGroupActions ${className}`, }))<{ actionsColumnWidth: number }>` display: flex; @@ -185,16 +188,14 @@ export const EventsTdGroupActions = styled.div.attrs(({ className }) => ({ flex: 0 0 ${({ actionsColumnWidth }) => `${actionsColumnWidth}px`}; min-width: 0; `; -EventsTdGroupActions.displayName = 'EventsTdGroupActions'; -export const EventsTdGroupData = styled.div.attrs(({ className }) => ({ +export const EventsTdGroupData = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__tdGroupData ${className}`, }))` display: flex; `; -EventsTdGroupData.displayName = 'EventsTdGroupData'; -export const EventsTd = styled.div.attrs(({ className }) => ({ +export const EventsTd = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__td ${className}`, role: 'cell', }))` @@ -207,7 +208,6 @@ export const EventsTd = styled.div.attrs(({ className }) => ({ flex: 1; } `; -EventsTd.displayName = 'EventsTd'; export const EventsTdContent = styled.div.attrs(({ className }) => ({ className: `siemEventsTable__tdContent ${className}`, @@ -219,13 +219,12 @@ export const EventsTdContent = styled.div.attrs(({ className }) => ({ text-align: ${({ textAlign }) => textAlign}; width: 100%; /* Using width: 100% instead of flex: 1 and max-width: 100% for IE11 */ `; -EventsTdContent.displayName = 'EventsTdContent'; /** * EVENTS HEADING */ -export const EventsHeading = styled.div.attrs(({ className }) => ({ +export const EventsHeading = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsHeading ${className}`, }))<{ isLoading: boolean }>` align-items: center; @@ -235,9 +234,8 @@ export const EventsHeading = styled.div.attrs(({ className }) => ({ cursor: ${({ isLoading }) => (isLoading ? 'wait' : 'grab')}; } `; -EventsHeading.displayName = 'EventsHeading'; -export const EventsHeadingTitleButton = styled.button.attrs(({ className }) => ({ +export const EventsHeadingTitleButton = styled.button.attrs(({ className = '' }) => ({ className: `siemEventsHeading__title siemEventsHeading__title--aggregatable ${className}`, type: 'button', }))` @@ -260,16 +258,14 @@ export const EventsHeadingTitleButton = styled.button.attrs(({ className }) => ( margin-left: ${({ theme }) => theme.eui.euiSizeXS}; } `; -EventsHeadingTitleButton.displayName = 'EventsHeadingTitleButton'; export const EventsHeadingTitleSpan = styled.span.attrs(({ className }) => ({ className: `siemEventsHeading__title siemEventsHeading__title--notAggregatable ${className}`, }))` min-width: 0; `; -EventsHeadingTitleSpan.displayName = 'EventsHeadingTitleSpan'; -export const EventsHeadingExtra = styled.div.attrs(({ className }) => ({ +export const EventsHeadingExtra = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsHeading__extra ${className}`, }))` margin-left: auto; @@ -285,9 +281,8 @@ export const EventsHeadingExtra = styled.div.attrs(({ className }) => ({ } } `; -EventsHeadingExtra.displayName = 'EventsHeadingExtra'; -export const EventsHeadingHandle = styled.div.attrs(({ className }) => ({ +export const EventsHeadingHandle = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsHeading__handle ${className}`, }))` background-color: ${({ theme }) => theme.eui.euiBorderColor}; @@ -297,17 +292,11 @@ export const EventsHeadingHandle = styled.div.attrs(({ className }) => ({ visibility: hidden; width: ${({ theme }) => theme.eui.euiBorderWidthThick}; - .siemEventsTable__thead:hover & { - opacity: 1; - visibility: visible; - } - &:hover { background-color: ${({ theme }) => theme.eui.euiColorPrimary}; cursor: col-resize; } `; -EventsHeadingHandle.displayName = 'EventsHeadingHandle'; /** * EVENTS LOADING diff --git a/x-pack/package.json b/x-pack/package.json index d513e4ed34965..ffa593f5728ee 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -321,6 +321,7 @@ "request": "^2.88.0", "reselect": "3.0.1", "resize-observer-polyfill": "^1.5.0", + "re-resizable": "^6.1.1", "rison-node": "0.3.1", "rxjs": "^6.5.3", "semver": "5.7.0", diff --git a/yarn.lock b/yarn.lock index 9631ca271295e..96ec5213badcb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4003,10 +4003,10 @@ dependencies: "@types/react" "*" -"@types/react-beautiful-dnd@^11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-11.0.3.tgz#51d9f37942dd18cc4aa10da98a5c883664e7ee46" - integrity sha512-7ZbT/7mNJu+uRrUGdTQ1hAINtqg909L4NHrXyspV42fvVgBgda6ysiBzoDUMENmQ/RlRJdpyrcp8Dtd/77bp9Q== +"@types/react-beautiful-dnd@^11.0.4": + version "11.0.4" + resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-11.0.4.tgz#25cdf16864df8fd1d82f9416c8c0fd957e793024" + integrity sha512-a1Nvt1AcSEA962OuXrk1gu5bJQhzu0B3qFNO999/0nmF+oAD7HIAY0DwraS3L3XM1cVuRO1+PtpTkD4CfRK2QA== dependencies: "@types/react" "*" @@ -12374,6 +12374,11 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.4, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +fast-memoize@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.1.tgz#c3519241e80552ce395e1a32dcdde8d1fd680f5d" + integrity sha512-xdmw296PCL01tMOXx9mdJSmWY29jQgxyuZdq0rEHMu+Tpe1eOEtCycoG6chzlcrWsNgpZP7oL8RiQr7+G6Bl6g== + fast-safe-stringify@^2.0.4: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.6.tgz#04b26106cc56681f51a044cfc0d76cf0008ac2c2" @@ -22957,6 +22962,13 @@ re-reselect@^3.4.0: resolved "https://registry.yarnpkg.com/re-reselect/-/re-reselect-3.4.0.tgz#0f2303f3c84394f57f0cd31fea08a1ca4840a7cd" integrity sha512-JsecfN+JlckncVXTWFWjn0Vk6uInl8GSf4eEd9tTk5qXHlgqkPdILpnYpgZcISXNYAzvfvsCZviaDk8AxyS5sg== +re-resizable@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.1.1.tgz#7ff7cfe92c0b9d8b0bceaa578aadaeeff8931eaf" + integrity sha512-ngzX5xbXi9LlIghJUYZaBDkJUIMLYqO3tQ2cJZoNprCRGhfHnbyufKm51MZRIOBlLigLzPPFKBxQE8ZLezKGfA== + dependencies: + fast-memoize "^2.5.1" + react-ace@^5.5.0: version "5.10.0" resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-5.10.0.tgz#e328b37ac52759f700be5afdb86ada2f5ec84c5e" From 1ffd30eb859a679fa15ade5cad07258ce1523bb3 Mon Sep 17 00:00:00 2001 From: gchaps <33642766+gchaps@users.noreply.github.com> Date: Wed, 8 Jan 2020 08:08:51 -0800 Subject: [PATCH 03/23] [DOCS][Spaces] Adds example of of using default route setting (#54201) * Added defaultRoute examples Added `Examples: /app/monitoring, /app/ml, /app/kibana#/dashboards` * [DOCS] Adds default route example to Spaces docs Co-authored-by: ErnestoBezanilla --- .../images/spaces-configure-landing-page.png | Bin 0 -> 78322 bytes docs/spaces/index.asciidoc | 65 ++++++++++-------- 2 files changed, 35 insertions(+), 30 deletions(-) create mode 100644 docs/spaces/images/spaces-configure-landing-page.png diff --git a/docs/spaces/images/spaces-configure-landing-page.png b/docs/spaces/images/spaces-configure-landing-page.png new file mode 100644 index 0000000000000000000000000000000000000000..15006594b6d7bcb6c217273c0be6fd7850019dc5 GIT binary patch literal 78322 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

&il!C#FP$BNF%1gJ7jbgQo)#rudtbpj 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 literal 0 HcmV?d00001 diff --git a/docs/spaces/index.asciidoc b/docs/spaces/index.asciidoc index 69655aac521e7..fb5ef670692dc 100644 --- a/docs/spaces/index.asciidoc +++ b/docs/spaces/index.asciidoc @@ -2,13 +2,13 @@ [[xpack-spaces]] == Spaces -Spaces enable you to organize your dashboards and other saved -objects into meaningful categories. Once inside a space, you see only -the dashboards and saved objects that belong to that space. +Spaces enable you to organize your dashboards and other saved +objects into meaningful categories. Once inside a space, you see only +the dashboards and saved objects that belong to that space. -{kib} creates a default space for you. -After you create your own -spaces, you're asked to choose a space when you log in to Kibana. You can change your +{kib} creates a default space for you. +After you create your own +spaces, you're asked to choose a space when you log in to Kibana. You can change your current space at any time by using the menu in the upper left. [role="screenshot"] @@ -29,24 +29,24 @@ Kibana supports spaces in several ways. You can: [[spaces-managing]] === View, create, and delete spaces -Go to **Management > Spaces** for an overview of your spaces. This view provides actions +Go to **Management > Spaces** for an overview of your spaces. This view provides actions for you to create, edit, and delete spaces. [role="screenshot"] image::spaces/images/space-management.png["Space management"] [float] -==== Create or edit a space +==== Create or edit a space -You can create as many spaces as you like. Click *Create a space* and provide a name, -URL identifier, optional description. +You can create as many spaces as you like. Click *Create a space* and provide a name, +URL identifier, optional description. -The URL identifier is a short text string that becomes part of the -{kib} URL when you are inside that space. {kib} suggests a URL identifier based +The URL identifier is a short text string that becomes part of the +{kib} URL when you are inside that space. {kib} suggests a URL identifier based on the name of your space, but you can customize the identifier to your liking. You cannot change the space identifier once you create the space. -{kib} also has an <> +{kib} also has an <> if you prefer to create spaces programatically. [role="screenshot"] @@ -55,7 +55,7 @@ image::spaces/images/edit-space.png["Space management"] [float] ==== Delete a space -Deleting a space permanently removes the space and all of its contents. +Deleting a space permanently removes the space and all of its contents. Find the space on the *Spaces* overview page and click the trash icon in the Actions column. You can't delete the default space, but you can customize it to your liking. @@ -63,14 +63,14 @@ You can't delete the default space, but you can customize it to your liking. [[spaces-control-feature-visibility]] === Control feature access based on user needs -You have control over which features are visible in each space. -For example, you might hide Dev Tools +You have control over which features are visible in each space. +For example, you might hide Dev Tools in your "Executive" space or show Stack Monitoring only in your "Admin" space. You can define which features to show or hide when you add or edit a space. -Controlling feature -visibility is not a security feature. To secure access -to specific features on a per-user basis, you must configure +Controlling feature +visibility is not a security feature. To secure access +to specific features on a per-user basis, you must configure <>. [role="screenshot"] @@ -80,10 +80,10 @@ image::spaces/images/edit-space-feature-visibility.png["Controlling features vis [[spaces-control-user-access]] === Control feature access based on user privileges -When using Kibana with security, you can configure applications and features -based on your users’ privileges. This means different roles can have access -to different features in the same space. -Power users might have privileges to create and edit visualizations and dashboards, +When using Kibana with security, you can configure applications and features +based on your users’ privileges. This means different roles can have access +to different features in the same space. +Power users might have privileges to create and edit visualizations and dashboards, while analysts or executives might have Dashboard and Canvas with read-only privileges. See <> for details. @@ -106,7 +106,7 @@ interface. . Import your saved objects. . (Optional) Delete objects in the export space that you no longer need. -{kib} also has beta <> and +{kib} also has beta <> and <> APIs if you want to automate this process. [float] @@ -115,17 +115,22 @@ interface. You can create a custom experience for users by configuring the {kib} landing page on a per-space basis. The landing page can route users to a specific dashboard, application, or saved object as they enter each space. -To configure the landing page, use the `defaultRoute` setting in < Advanced settings>>. + +To configure the landing page, use the default route setting in < Advanced settings>>. +For example, you might set the default route to `/app/kibana#/dashboards`. + +[role="screenshot"] +image::spaces/images/spaces-configure-landing-page.png["Configure space-level landing page"] + [float] [[spaces-delete-started]] === Disable and version updates -Spaces are automatically enabled in {kib}. If you don't want use this feature, +Spaces are automatically enabled in {kib}. If you don't want use this feature, you can disable it -by setting `xpack.spaces.enabled` to `false` in your +by setting `xpack.spaces.enabled` to `false` in your `kibana.yml` configuration file. -If you are upgrading your -version of {kib}, the default space will contain all of your existing saved objects. - +If you are upgrading your +version of {kib}, the default space will contain all of your existing saved objects. From 513428af449188dda301110a4b962967466e23aa Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 8 Jan 2020 19:20:07 +0300 Subject: [PATCH 04/23] add `examples/` to no-restricted-path config (#54252) --- .eslintrc.js | 5 +++++ examples/demo_search/server/plugin.ts | 2 +- src/plugins/data/server/index.ts | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 8a9d4da6178e9..1b2459f196efd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -247,6 +247,7 @@ module.exports = { '!x-pack/test/**/*', '(src|x-pack)/plugins/**/(public|server)/**/*', 'src/core/(public|server)/**/*', + 'examples/**/*', ], from: [ 'src/core/public/**/*', @@ -283,11 +284,15 @@ module.exports = { 'x-pack/legacy/plugins/**/*', '!x-pack/legacy/plugins/*/server/**/*', '!x-pack/legacy/plugins/*/index.{js,ts,tsx}', + + 'examples/**/*', + '!examples/**/server/**/*', ], from: [ 'src/core/server', 'src/core/server/**/*', '(src|x-pack)/plugins/*/server/**/*', + 'examples/**/server/**/*', ], errorMessage: 'Server modules cannot be imported into client modules or shared modules.', diff --git a/examples/demo_search/server/plugin.ts b/examples/demo_search/server/plugin.ts index 23c82225563c8..653aa217717fa 100644 --- a/examples/demo_search/server/plugin.ts +++ b/examples/demo_search/server/plugin.ts @@ -18,7 +18,7 @@ */ import { Plugin, CoreSetup, PluginInitializerContext } from 'kibana/server'; -import { DataPluginSetup } from 'src/plugins/data/server/plugin'; +import { PluginSetup as DataPluginSetup } from 'src/plugins/data/server'; import { demoSearchStrategyProvider } from './demo_search_strategy'; import { DEMO_SEARCH_STRATEGY, IDemoRequest, IDemoResponse } from '../common'; diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index 8194e9a6c847e..3cd088744a439 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -18,7 +18,7 @@ */ import { PluginInitializerContext } from '../../../core/server'; -import { DataServerPlugin } from './plugin'; +import { DataServerPlugin, DataPluginSetup } from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { return new DataServerPlugin(initializerContext); @@ -93,4 +93,4 @@ export { getKbnTypeNames, } from '../common'; -export { DataServerPlugin as Plugin }; +export { DataServerPlugin as Plugin, DataPluginSetup as PluginSetup }; From bc640bdcbaf1e1b5bb6a01d8495d545a1a67b4de Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Wed, 8 Jan 2020 16:26:56 +0000 Subject: [PATCH 05/23] [Dashboard] Removing 100% as dshDashboardViewport height (#54263) --- .../public/embeddable/viewport/_dashboard_viewport.scss | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/plugins/dashboard_embeddable_container/public/embeddable/viewport/_dashboard_viewport.scss b/src/plugins/dashboard_embeddable_container/public/embeddable/viewport/_dashboard_viewport.scss index b446f1e57a895..bb95840676969 100644 --- a/src/plugins/dashboard_embeddable_container/public/embeddable/viewport/_dashboard_viewport.scss +++ b/src/plugins/dashboard_embeddable_container/public/embeddable/viewport/_dashboard_viewport.scss @@ -1,9 +1,7 @@ .dshDashboardViewport { - height: 100%; width: 100%; } .dshDashboardViewport-withMargins { width: 100%; - height: 100%; } From 26a4ec4117426ea8dc54193c58cf70c707ab3e03 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Wed, 8 Jan 2020 19:46:10 +0300 Subject: [PATCH 06/23] use correct type (#54244) --- src/legacy/server/kbn_server.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/legacy/server/kbn_server.d.ts b/src/legacy/server/kbn_server.d.ts index 0af6dacee59c8..8da1b3b05fa76 100644 --- a/src/legacy/server/kbn_server.d.ts +++ b/src/legacy/server/kbn_server.d.ts @@ -129,7 +129,7 @@ export interface KibanaCore { plugins: PluginsSetup; }; startDeps: { - core: CoreSetup; + core: CoreStart; plugins: Record; }; logger: LoggerFactory; From 53d1c96a4a015c7a55f18bb32d50884f492d8ad3 Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Wed, 8 Jan 2020 18:10:26 +0100 Subject: [PATCH 07/23] Remove non existing codeowners (#54274) * Remove non existing codeowners * Add TSVB to Kibana App codeowners --- .github/CODEOWNERS | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 18d60bce4b95e..a0a22446ba31d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,6 +14,7 @@ /src/legacy/core_plugins/kibana/public/local_application_service/ @elastic/kibana-app /src/legacy/core_plugins/kibana/public/home/ @elastic/kibana-app /src/legacy/core_plugins/kibana/public/dev_tools/ @elastic/kibana-app +/src/legacy/core_plugins/metrics/ @elastic/kibana-app /src/plugins/home/ @elastic/kibana-app /src/plugins/kibana_legacy/ @elastic/kibana-app /src/plugins/timelion/ @elastic/kibana-app @@ -147,6 +148,3 @@ /x-pack/legacy/plugins/searchprofiler/ @elastic/es-ui /x-pack/legacy/plugins/snapshot_restore/ @elastic/es-ui /x-pack/legacy/plugins/watcher/ @elastic/es-ui - -# Kibana TSVB external contractors -/src/legacy/core_plugins/metrics/ @elastic/kibana-tsvb-external From 7ffe38569e48cbebfeb00bd084799d8aa48f38f2 Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Wed, 8 Jan 2020 18:19:20 +0100 Subject: [PATCH 08/23] Fix Vega react eslint errors (#54259) --- .eslintrc.js | 6 ------ .../vis_type_vega/public/components/vega_actions_menu.tsx | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 1b2459f196efd..c43366abf0c3d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -113,12 +113,6 @@ module.exports = { 'react-hooks/exhaustive-deps': 'off', }, }, - { - files: ['src/legacy/core_plugins/vis_type_vega/**/*.{js,ts,tsx}'], - rules: { - 'react-hooks/exhaustive-deps': 'off', - }, - }, { files: ['src/legacy/ui/public/vis/**/*.{js,ts,tsx}'], rules: { diff --git a/src/legacy/core_plugins/vis_type_vega/public/components/vega_actions_menu.tsx b/src/legacy/core_plugins/vis_type_vega/public/components/vega_actions_menu.tsx index 71a88b47a8be3..3d7fda990b2ae 100644 --- a/src/legacy/core_plugins/vis_type_vega/public/components/vega_actions_menu.tsx +++ b/src/legacy/core_plugins/vis_type_vega/public/components/vega_actions_menu.tsx @@ -34,12 +34,12 @@ function VegaActionsMenu({ formatHJson, formatJson }: VegaActionsMenuProps) { const onHJsonCLick = useCallback(() => { formatHJson(); setIsPopoverOpen(false); - }, [isPopoverOpen, formatHJson]); + }, [formatHJson]); const onJsonCLick = useCallback(() => { formatJson(); setIsPopoverOpen(false); - }, [isPopoverOpen, formatJson]); + }, [formatJson]); const closePopover = useCallback(() => setIsPopoverOpen(false), []); From fc948a0c8e13c3bbeaefe95b12cf233e3ed8ec31 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Wed, 8 Jan 2020 13:38:34 -0500 Subject: [PATCH 09/23] [ML] DF Analytics Classification: ensure confusion matrix can be fetched (#53629) * check depVar field type before adding keyword suffix for evaluate endpoint * update indexPattern type and use FIELD types * add keyword suffix if field type is keyword * keyword suffix added if depVar is of type keyword AND text --- .../data_frame_analytics/common/analytics.ts | 4 ++- .../evaluate_panel.tsx | 35 ++++++++++++++++++- .../services/new_job_capabilities_service.ts | 3 +- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 3dd98395ef701..ce832513c4adc 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -374,6 +374,7 @@ interface LoadEvalDataConfig { searchQuery?: ResultsSearchQuery; ignoreDefaultQuery?: boolean; jobType: ANALYSIS_CONFIG_TYPE; + requiresKeyword?: boolean; } export const loadEvalData = async ({ @@ -385,6 +386,7 @@ export const loadEvalData = async ({ searchQuery, ignoreDefaultQuery, jobType, + requiresKeyword, }: LoadEvalDataConfig) => { const results: LoadEvaluateResult = { success: false, eval: null, error: null }; const defaultPredictionField = `${dependentVariable}_prediction`; @@ -392,7 +394,7 @@ export const loadEvalData = async ({ predictionFieldName ? predictionFieldName : defaultPredictionField }`; - if (jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION) { + if (jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && requiresKeyword === true) { predictedField = `${predictedField}.keyword`; } diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index ddf52943c2feb..7bb6949db1a99 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -35,8 +35,13 @@ import { ResultsSearchQuery, ANALYSIS_CONFIG_TYPE, } from '../../../../common/analytics'; +import { IIndexPattern } from '../../../../../../../../../../../src/plugins/data/common/index_patterns'; +import { ES_FIELD_TYPES } from '../../../../../../../../../../../src/plugins/data/public'; import { LoadingPanel } from '../loading_panel'; import { getColumnData } from './column_data'; +import { useKibanaContext } from '../../../../../contexts/kibana'; +import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; +import { getIndexPatternIdFromName } from '../../../../../util/index_utils'; const defaultPanelWidth = 500; @@ -55,17 +60,19 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) const [docsCount, setDocsCount] = useState(null); const [error, setError] = useState(null); const [panelWidth, setPanelWidth] = useState(defaultPanelWidth); - // Column visibility const [visibleColumns, setVisibleColumns] = useState(() => columns.map(({ id }: { id: string }) => id) ); + const kibanaContext = useKibanaContext(); const index = jobConfig.dest.index; + const sourceIndex = jobConfig.source.index[0]; const dependentVariable = getDependentVar(jobConfig.analysis); const predictionFieldName = getPredictionFieldName(jobConfig.analysis); // default is 'ml' const resultsField = jobConfig.dest.results_field; + let requiresKeyword = false; const loadData = async ({ isTrainingClause, @@ -76,6 +83,31 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) }) => { setIsLoading(true); + try { + const indexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex; + const indexPattern: IIndexPattern = await kibanaContext.indexPatterns.get(indexPatternId); + + if (indexPattern !== undefined) { + await newJobCapsService.initializeFromIndexPattern(indexPattern, false, false); + // If dependent_variable is of type keyword and text .keyword suffix is required for evaluate endpoint + const { fields } = newJobCapsService; + const depVarFieldType = fields.find(field => field.name === dependentVariable)?.type; + + // If it's a keyword type - check if it has a corresponding text type + if (depVarFieldType !== undefined && depVarFieldType === ES_FIELD_TYPES.KEYWORD) { + const field = newJobCapsService.getFieldById(dependentVariable.replace(/\.keyword$/, '')); + requiresKeyword = field !== null && field.type === ES_FIELD_TYPES.TEXT; + } else if (depVarFieldType !== undefined && depVarFieldType === ES_FIELD_TYPES.TEXT) { + // If text, check if has corresponding keyword type + const field = newJobCapsService.getFieldById(`${dependentVariable}.keyword`); + requiresKeyword = field !== null && field.type === ES_FIELD_TYPES.KEYWORD; + } + } + } catch (e) { + // Additional error handling due to missing field type is handled by loadEvalData + console.error('Unable to load new field types', error); // eslint-disable-line no-console + } + const evalData = await loadEvalData({ isTraining: false, index, @@ -85,6 +117,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) searchQuery, ignoreDefaultQuery, jobType: ANALYSIS_CONFIG_TYPE.CLASSIFICATION, + requiresKeyword, }); const docsCountResp = await loadDocsCount({ diff --git a/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts b/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts index 9d5c33d6cfc5c..d78c9298c6073 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts +++ b/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts @@ -14,6 +14,7 @@ import { } from '../../../common/types/fields'; import { ES_FIELD_TYPES, + IIndexPattern, IndexPattern, IndexPatternsContract, } from '../../../../../../../src/plugins/data/public'; @@ -89,7 +90,7 @@ class NewJobCapsService { } public async initializeFromIndexPattern( - indexPattern: IndexPattern, + indexPattern: IIndexPattern, includeEventRateField = true, removeTextFields = true ) { From 89e4daf5bd0d81b26caf3c1a87edad013ada639e Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Wed, 8 Jan 2020 14:16:31 -0500 Subject: [PATCH 10/23] [Canvas] Fixes bugs with autoplay and refresh (#53149) * Fixes bugs with autoplay and refresh * Fix typecheck Co-authored-by: Elastic Machine --- .../plugins/canvas/public/lib/app_state.ts | 2 +- .../__tests__/workpad_autoplay.test.ts | 145 ++++++++++++++++ .../__tests__/workpad_refresh.test.ts | 161 ++++++++++++++++++ ...orkpad_autoplay.js => workpad_autoplay.ts} | 33 ++-- ...{workpad_refresh.js => workpad_refresh.ts} | 36 +++- 5 files changed, 351 insertions(+), 26 deletions(-) create mode 100644 x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_autoplay.test.ts create mode 100644 x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_refresh.test.ts rename x-pack/legacy/plugins/canvas/public/state/middleware/{workpad_autoplay.js => workpad_autoplay.ts} (77%) rename x-pack/legacy/plugins/canvas/public/state/middleware/{workpad_refresh.js => workpad_refresh.ts} (65%) diff --git a/x-pack/legacy/plugins/canvas/public/lib/app_state.ts b/x-pack/legacy/plugins/canvas/public/lib/app_state.ts index be0f76b170c70..955125b713140 100644 --- a/x-pack/legacy/plugins/canvas/public/lib/app_state.ts +++ b/x-pack/legacy/plugins/canvas/public/lib/app_state.ts @@ -92,7 +92,7 @@ export function setFullscreen(payload: boolean) { } } -export function setAutoplayInterval(payload: string) { +export function setAutoplayInterval(payload: string | null) { const appState = getAppState(); const appValue = appState[AppStateKeys.AUTOPLAY_INTERVAL]; diff --git a/x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_autoplay.test.ts b/x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_autoplay.test.ts new file mode 100644 index 0000000000000..11ebdcdc51d4d --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_autoplay.test.ts @@ -0,0 +1,145 @@ +/* + * 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('../../../lib/app_state'); +jest.mock('../../../lib/router_provider'); + +import { workpadAutoplay } from '../workpad_autoplay'; +import { setAutoplayInterval } from '../../../lib/app_state'; +import { createTimeInterval } from '../../../lib/time_interval'; +// @ts-ignore Untyped local +import { routerProvider } from '../../../lib/router_provider'; + +const next = jest.fn(); +const dispatch = jest.fn(); +const getState = jest.fn(); +const routerMock = { navigateTo: jest.fn() }; +routerProvider.mockReturnValue(routerMock); + +const middleware = workpadAutoplay({ dispatch, getState })(next); + +const workpadState = { + persistent: { + workpad: { + id: 'workpad-id', + pages: ['page1', 'page2', 'page3'], + page: 0, + }, + }, +}; + +const autoplayState = { + ...workpadState, + transient: { + autoplay: { + inFlight: false, + enabled: true, + interval: 5000, + }, + fullscreen: true, + }, +}; + +const autoplayDisabledState = { + ...workpadState, + transient: { + autoplay: { + inFlight: false, + enabled: false, + interval: 5000, + }, + }, +}; + +const action = {}; + +describe('workpad autoplay middleware', () => { + beforeEach(() => { + dispatch.mockClear(); + jest.resetAllMocks(); + }); + + describe('app state', () => { + it('sets the app state to the interval from state when enabled', () => { + getState.mockReturnValue(autoplayState); + middleware(action); + + expect(setAutoplayInterval).toBeCalledWith( + createTimeInterval(autoplayState.transient.autoplay.interval) + ); + }); + + it('sets the app state to null when not enabled', () => { + getState.mockReturnValue(autoplayDisabledState); + middleware(action); + + expect(setAutoplayInterval).toBeCalledWith(null); + }); + }); + + describe('autoplay navigation', () => { + it('navigates forward after interval', () => { + jest.useFakeTimers(); + getState.mockReturnValue(autoplayState); + middleware(action); + + jest.advanceTimersByTime(autoplayState.transient.autoplay.interval + 1); + + expect(routerMock.navigateTo).toBeCalledWith('loadWorkpad', { + id: workpadState.persistent.workpad.id, + page: workpadState.persistent.workpad.page + 2, // (index + 1) + 1 more for 1 indexed page number + }); + + jest.useRealTimers(); + }); + + it('navigates from last page back to front', () => { + jest.useFakeTimers(); + const onLastPageState = { ...autoplayState }; + onLastPageState.persistent.workpad.page = onLastPageState.persistent.workpad.pages.length - 1; + + getState.mockReturnValue(autoplayState); + middleware(action); + + jest.advanceTimersByTime(autoplayState.transient.autoplay.interval + 1); + + expect(routerMock.navigateTo).toBeCalledWith('loadWorkpad', { + id: workpadState.persistent.workpad.id, + page: 1, + }); + + jest.useRealTimers(); + }); + + it('continues autoplaying', () => { + jest.useFakeTimers(); + getState.mockReturnValue(autoplayState); + middleware(action); + + jest.advanceTimersByTime(autoplayState.transient.autoplay.interval * 2 + 1); + expect(routerMock.navigateTo).toBeCalledTimes(2); + jest.useRealTimers(); + }); + + it('does not reset timer between middleware calls', () => { + jest.useFakeTimers(); + + getState.mockReturnValue(autoplayState); + middleware(action); + + // Advance until right before timeout + jest.advanceTimersByTime(autoplayState.transient.autoplay.interval - 1); + + // Run middleware again + middleware(action); + + // Advance timer + jest.advanceTimersByTime(1); + + expect(routerMock.navigateTo).toBeCalled(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_refresh.test.ts b/x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_refresh.test.ts new file mode 100644 index 0000000000000..2123c9606f1f0 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_refresh.test.ts @@ -0,0 +1,161 @@ +/* + * 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('../../../legacy'); +jest.mock('ui/new_platform'); // actions/elements has some dependencies on ui/new_platform. +jest.mock('../../../lib/app_state'); + +import { workpadRefresh } from '../workpad_refresh'; +import { inFlightComplete } from '../../actions/resolved_args'; +// @ts-ignore untyped local +import { setRefreshInterval } from '../../actions/workpad'; +import { setRefreshInterval as setAppStateRefreshInterval } from '../../../lib/app_state'; + +import { createTimeInterval } from '../../../lib/time_interval'; + +const next = jest.fn(); +const dispatch = jest.fn(); +const getState = jest.fn(); + +const middleware = workpadRefresh({ dispatch, getState })(next); + +const refreshState = { + transient: { + refresh: { + interval: 5000, + }, + }, +}; + +const noRefreshState = { + transient: { + refresh: { + interval: 0, + }, + }, +}; + +const inFlightState = { + transient: { + refresh: { + interval: 5000, + }, + inFlight: true, + }, +}; + +describe('workpad refresh middleware', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('onInflightComplete', () => { + it('refreshes if interval gt 0', () => { + jest.useFakeTimers(); + getState.mockReturnValue(refreshState); + + middleware(inFlightComplete()); + + jest.runAllTimers(); + + expect(dispatch).toHaveBeenCalled(); + }); + + it('does not reset interval if another action occurs', () => { + jest.useFakeTimers(); + getState.mockReturnValue(refreshState); + + middleware(inFlightComplete()); + + jest.advanceTimersByTime(refreshState.transient.refresh.interval - 1); + + expect(dispatch).not.toHaveBeenCalled(); + middleware(inFlightComplete()); + + jest.advanceTimersByTime(1); + + expect(dispatch).toHaveBeenCalled(); + }); + + it('does not refresh if interval is 0', () => { + jest.useFakeTimers(); + getState.mockReturnValue(noRefreshState); + + middleware(inFlightComplete()); + + jest.runAllTimers(); + expect(dispatch).not.toHaveBeenCalled(); + }); + }); + + describe('setRefreshInterval', () => { + it('does nothing if refresh interval is unchanged', () => { + getState.mockReturnValue(refreshState); + + jest.useFakeTimers(); + const interval = 1; + middleware(setRefreshInterval(interval)); + jest.runAllTimers(); + + expect(setAppStateRefreshInterval).not.toBeCalled(); + }); + + it('sets the app refresh interval', () => { + getState.mockReturnValue(noRefreshState); + next.mockImplementation(() => { + getState.mockReturnValue(refreshState); + }); + + jest.useFakeTimers(); + const interval = 1; + middleware(setRefreshInterval(interval)); + + expect(setAppStateRefreshInterval).toBeCalledWith(createTimeInterval(interval)); + jest.runAllTimers(); + }); + + it('starts a refresh for the new interval', () => { + getState.mockReturnValue(refreshState); + jest.useFakeTimers(); + + const interval = 1000; + + middleware(inFlightComplete()); + + jest.runTimersToTime(refreshState.transient.refresh.interval - 1); + expect(dispatch).not.toBeCalled(); + + getState.mockReturnValue(noRefreshState); + next.mockImplementation(() => { + getState.mockReturnValue(refreshState); + }); + middleware(setRefreshInterval(interval)); + jest.runTimersToTime(1); + + expect(dispatch).not.toBeCalled(); + + jest.runTimersToTime(interval); + expect(dispatch).toBeCalled(); + }); + }); + + describe('inFlight in progress', () => { + it('requeues the refresh when inflight is active', () => { + jest.useFakeTimers(); + getState.mockReturnValue(inFlightState); + + middleware(inFlightComplete()); + jest.runTimersToTime(refreshState.transient.refresh.interval); + + expect(dispatch).not.toBeCalled(); + + getState.mockReturnValue(refreshState); + jest.runAllTimers(); + + expect(dispatch).toBeCalled(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_autoplay.js b/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_autoplay.ts similarity index 77% rename from x-pack/legacy/plugins/canvas/public/state/middleware/workpad_autoplay.js rename to x-pack/legacy/plugins/canvas/public/state/middleware/workpad_autoplay.ts index 886620c5404cd..700905213f54c 100644 --- a/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_autoplay.js +++ b/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_autoplay.ts @@ -4,16 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { inFlightComplete } from '../actions/resolved_args'; +import { Middleware } from 'redux'; +import { State } from '../../../types'; import { getFullscreen } from '../selectors/app'; import { getInFlight } from '../selectors/resolved_args'; import { getWorkpad, getPages, getSelectedPageIndex, getAutoplay } from '../selectors/workpad'; +// @ts-ignore untyped local import { routerProvider } from '../../lib/router_provider'; import { setAutoplayInterval } from '../../lib/app_state'; import { createTimeInterval } from '../../lib/time_interval'; -export const workpadAutoplay = ({ getState }) => next => { - let playTimeout; +export const workpadAutoplay: Middleware<{}, State> = ({ getState }) => next => { + let playTimeout: number | undefined; let displayInterval = 0; const router = routerProvider(); @@ -42,18 +44,22 @@ export const workpadAutoplay = ({ getState }) => next => { } } + stopAutoUpdate(); startDelayedUpdate(); } function stopAutoUpdate() { clearTimeout(playTimeout); // cancel any pending update requests + playTimeout = undefined; } function startDelayedUpdate() { - stopAutoUpdate(); - playTimeout = setTimeout(() => { - updateWorkpad(); - }, displayInterval); + if (!playTimeout) { + stopAutoUpdate(); + playTimeout = window.setTimeout(() => { + updateWorkpad(); + }, displayInterval); + } } return action => { @@ -68,21 +74,14 @@ export const workpadAutoplay = ({ getState }) => next => { if (autoplay.enabled) { setAutoplayInterval(createTimeInterval(autoplay.interval)); } else { - setAutoplayInterval(0); + setAutoplayInterval(null); } - // when in-flight requests are finished, update the workpad after a given delay - if (action.type === inFlightComplete.toString() && shouldPlay) { - startDelayedUpdate(); - } // create new update request - - // This middleware creates or destroys an interval that will cause workpad elements to update - // clear any pending timeout - stopAutoUpdate(); - // if interval is larger than 0, start the delayed update if (shouldPlay) { startDelayedUpdate(); + } else { + stopAutoUpdate(); } }; }; diff --git a/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_refresh.js b/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_refresh.ts similarity index 65% rename from x-pack/legacy/plugins/canvas/public/state/middleware/workpad_refresh.js rename to x-pack/legacy/plugins/canvas/public/state/middleware/workpad_refresh.ts index 32822529f320c..f638c42ec2de0 100644 --- a/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_refresh.js +++ b/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_refresh.ts @@ -4,18 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Middleware } from 'redux'; +import { State } from '../../../types'; +// @ts-ignore Untyped Local import { fetchAllRenderables } from '../actions/elements'; +// @ts-ignore Untyped Local import { setRefreshInterval } from '../actions/workpad'; import { inFlightComplete } from '../actions/resolved_args'; import { getInFlight } from '../selectors/resolved_args'; +import { getRefreshInterval } from '../selectors/workpad'; import { setRefreshInterval as setAppStateRefreshInterval } from '../../lib/app_state'; import { createTimeInterval } from '../../lib/time_interval'; -export const workpadRefresh = ({ dispatch, getState }) => next => { - let refreshTimeout; +export const workpadRefresh: Middleware<{}, State> = ({ dispatch, getState }) => next => { + let refreshTimeout: number | undefined; let refreshInterval = 0; function updateWorkpad() { + cancelDelayedUpdate(); + if (refreshInterval === 0) { return; } @@ -31,30 +38,43 @@ export const workpadRefresh = ({ dispatch, getState }) => next => { } } + function cancelDelayedUpdate() { + clearTimeout(refreshTimeout); + refreshTimeout = undefined; + } + function startDelayedUpdate() { - clearTimeout(refreshTimeout); // cancel any pending update requests - refreshTimeout = setTimeout(() => { - updateWorkpad(); - }, refreshInterval); + if (!refreshTimeout) { + clearTimeout(refreshTimeout); // cancel any pending update requests + refreshTimeout = window.setTimeout(() => { + updateWorkpad(); + }, refreshInterval); + } } return action => { + const previousRefreshInterval = getRefreshInterval(getState()); next(action); + refreshInterval = getRefreshInterval(getState()); + // when in-flight requests are finished, update the workpad after a given delay if (action.type === inFlightComplete.toString() && refreshInterval > 0) { startDelayedUpdate(); } // create new update request // This middleware creates or destroys an interval that will cause workpad elements to update - if (action.type === setRefreshInterval.toString()) { + if ( + action.type === setRefreshInterval.toString() && + previousRefreshInterval !== refreshInterval + ) { // update the refresh interval refreshInterval = action.payload; setAppStateRefreshInterval(createTimeInterval(refreshInterval)); // clear any pending timeout - clearTimeout(refreshTimeout); + cancelDelayedUpdate(); // if interval is larger than 0, start the delayed update if (refreshInterval > 0) { From 8edb53ddbc8676c456520100edff782cc260ef86 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Wed, 8 Jan 2020 13:46:01 -0600 Subject: [PATCH 11/23] [Metrics UI] Pass relevant shouldAllowEdit capabilities into SettingsPage (#49781) * [Metrics UI] Pass relevant shouldAllowEdit capabilities into SettingsPage * Split settings pages in two; add loading screen to settings page * Restore timestamp field to metrics screen Co-authored-by: Elastic Machine --- .../fields_configuration_panel.tsx | 386 +++++++++--------- .../indices_configuration_panel.tsx | 172 ++++---- .../source_configuration_settings.tsx | 30 +- .../public/pages/infrastructure/index.tsx | 4 +- .../public/pages/infrastructure/settings.tsx | 19 + .../plugins/infra/public/pages/logs/index.tsx | 4 +- .../settings/index.tsx => logs/settings.tsx} | 7 +- 7 files changed, 334 insertions(+), 288 deletions(-) create mode 100644 x-pack/legacy/plugins/infra/public/pages/infrastructure/settings.tsx rename x-pack/legacy/plugins/infra/public/pages/{shared/settings/index.tsx => logs/settings.tsx} (64%) diff --git a/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx b/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx index 5f3d1a63e72eb..e65753ef24e9e 100644 --- a/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx +++ b/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx @@ -26,6 +26,7 @@ interface FieldsConfigurationPanelProps { podFieldProps: InputFieldProps; tiebreakerFieldProps: InputFieldProps; timestampFieldProps: InputFieldProps; + displaySettings: 'metrics' | 'logs'; } export const FieldsConfigurationPanel = ({ @@ -36,6 +37,7 @@ export const FieldsConfigurationPanel = ({ podFieldProps, tiebreakerFieldProps, timestampFieldProps, + displaySettings, }: FieldsConfigurationPanelProps) => ( @@ -94,193 +96,201 @@ export const FieldsConfigurationPanel = ({ /> - - - - } - description={ - - } - > - _doc, - }} - /> - } - isInvalid={tiebreakerFieldProps.isInvalid} - label={ - - } - > - - - - - - - } - description={ - - } - > - container.id, - }} - /> - } - isInvalid={containerFieldProps.isInvalid} - label={ - - } - > - - - - - - - } - description={ - - } - > - host.name, - }} - /> - } - isInvalid={hostFieldProps.isInvalid} - label={ - - } - > - - - - - - - } - description={ - - } - > - kubernetes.pod.uid, - }} - /> - } - isInvalid={podFieldProps.isInvalid} - label={ - - } - > - - - + {displaySettings === 'logs' && ( + <> + + + + } + description={ + + } + > + _doc, + }} + /> + } + isInvalid={tiebreakerFieldProps.isInvalid} + label={ + + } + > + + + + + )} + {displaySettings === 'metrics' && ( + <> + + + + } + description={ + + } + > + container.id, + }} + /> + } + isInvalid={containerFieldProps.isInvalid} + label={ + + } + > + + + + + + + } + description={ + + } + > + host.name, + }} + /> + } + isInvalid={hostFieldProps.isInvalid} + label={ + + } + > + + + + + + + } + description={ + + } + > + kubernetes.pod.uid, + }} + /> + } + isInvalid={podFieldProps.isInvalid} + label={ + + } + > + + + + + )} ); diff --git a/x-pack/legacy/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx b/x-pack/legacy/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx index e779b35975ec3..eed6768c8846c 100644 --- a/x-pack/legacy/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx +++ b/x-pack/legacy/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx @@ -23,6 +23,7 @@ interface IndicesConfigurationPanelProps { readOnly: boolean; logAliasFieldProps: InputFieldProps; metricAliasFieldProps: InputFieldProps; + displaySettings: 'metrics' | 'logs'; } export const IndicesConfigurationPanel = ({ @@ -30,6 +31,7 @@ export const IndicesConfigurationPanel = ({ readOnly, logAliasFieldProps, metricAliasFieldProps, + displaySettings, }: IndicesConfigurationPanelProps) => ( @@ -41,101 +43,105 @@ export const IndicesConfigurationPanel = ({ - - - - } - description={ - - } - > - metricbeat-*, - }} - /> + {displaySettings === 'metrics' && ( + + + } - isInvalid={metricAliasFieldProps.isInvalid} - label={ + description={ } > - - - - - - - } - description={ - - } - > - filebeat-*, - }} + helpText={ + metricbeat-*, + }} + /> + } + isInvalid={metricAliasFieldProps.isInvalid} + label={ + + } + > + + + + )} + {displaySettings === 'logs' && ( + + + } - isInvalid={logAliasFieldProps.isInvalid} - label={ + description={ } > - - - + helpText={ + filebeat-*, + }} + /> + } + isInvalid={logAliasFieldProps.isInvalid} + label={ + + } + > + + + + )} ); diff --git a/x-pack/legacy/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx b/x-pack/legacy/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx index 31afedc8f31ee..68dbdf38e6af6 100644 --- a/x-pack/legacy/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx +++ b/x-pack/legacy/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx @@ -25,13 +25,16 @@ import { IndicesConfigurationPanel } from './indices_configuration_panel'; import { NameConfigurationPanel } from './name_configuration_panel'; import { LogColumnsConfigurationPanel } from './log_columns_configuration_panel'; import { useSourceConfigurationFormState } from './source_configuration_form_state'; +import { SourceLoadingPage } from '../source_loading_page'; interface SourceConfigurationSettingsProps { shouldAllowEdit: boolean; + displaySettings: 'metrics' | 'logs'; } export const SourceConfigurationSettings = ({ shouldAllowEdit, + displaySettings, }: SourceConfigurationSettingsProps) => { const { createSourceConfiguration, @@ -80,7 +83,10 @@ export const SourceConfigurationSettings = ({ source, ]); - if (!source || !source.configuration) { + if (!source) { + return ; + } + if (!source.configuration) { return null; } @@ -112,6 +118,7 @@ export const SourceConfigurationSettings = ({ logAliasFieldProps={indicesConfigurationProps.logAlias} metricAliasFieldProps={indicesConfigurationProps.metricAlias} readOnly={!isWriteable} + displaySettings={displaySettings} /> @@ -124,18 +131,21 @@ export const SourceConfigurationSettings = ({ readOnly={!isWriteable} tiebreakerFieldProps={indicesConfigurationProps.tiebreakerField} timestampFieldProps={indicesConfigurationProps.timestampField} + displaySettings={displaySettings} /> - - - + {displaySettings === 'logs' && ( + + + + )} {errors.length > 0 ? ( <> diff --git a/x-pack/legacy/plugins/infra/public/pages/infrastructure/index.tsx b/x-pack/legacy/plugins/infra/public/pages/infrastructure/index.tsx index dfe4fb05d669a..5eaa2850aebdb 100644 --- a/x-pack/legacy/plugins/infra/public/pages/infrastructure/index.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/infrastructure/index.tsx @@ -20,7 +20,7 @@ import { WithSource } from '../../containers/with_source'; import { Source } from '../../containers/source'; import { MetricsExplorerPage } from './metrics_explorer'; import { SnapshotPage } from './snapshot'; -import { SettingsPage } from '../shared/settings'; +import { MetricsSettingsPage } from './settings'; import { AppNavigation } from '../../components/navigation/app_navigation'; import { SourceLoadingPage } from '../../components/source_loading_page'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; @@ -106,7 +106,7 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { )} /> - + diff --git a/x-pack/legacy/plugins/infra/public/pages/infrastructure/settings.tsx b/x-pack/legacy/plugins/infra/public/pages/infrastructure/settings.tsx new file mode 100644 index 0000000000000..d75af7879d17a --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/infrastructure/settings.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 { SourceConfigurationSettings } from '../../components/source_configuration/source_configuration_settings'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; + +export const MetricsSettingsPage = () => { + const uiCapabilities = useKibana().services.application?.capabilities; + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/index.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/index.tsx index a8a75f99253c2..f38f066b5323f 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/index.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/index.tsx @@ -17,7 +17,7 @@ import { SourceLoadingPage } from '../../components/source_loading_page'; import { SourceErrorPage } from '../../components/source_error_page'; import { Source, useSource } from '../../containers/source'; import { StreamPage } from './stream'; -import { SettingsPage } from '../shared/settings'; +import { LogsSettingsPage } from './settings'; import { AppNavigation } from '../../components/navigation/app_navigation'; import { useLogAnalysisCapabilities, @@ -107,7 +107,7 @@ export const LogsPage = ({ match }: RouteComponentProps) => { - + { +export const LogsSettingsPage = () => { const uiCapabilities = useKibana().services.application?.capabilities; return ( ); }; From bbe700d797362c30af60f7269ab1cc81085a0a96 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 8 Jan 2020 14:48:00 -0500 Subject: [PATCH 12/23] Update schemas boolean, byteSize, and duration to coerce strings (#54177) * Update Duration to coerce number strings to numbers (in millis) * Coerce in a way that's consistent with kbn-config-schema * Update ByteSizeValue to coerce strings to numbers * Update Boolean to coerce strings to boolean values * Fix Jest test * Address PR review feedback * Whoops * Whoops 2 * Whoops 3 --- packages/kbn-config-schema/README.md | 7 +++-- .../__snapshots__/index.test.ts.snap | 10 ++++--- .../src/byte_size_value/index.test.ts | 10 +++---- .../src/byte_size_value/index.ts | 14 ++++++---- .../kbn-config-schema/src/duration/index.ts | 15 ++++++----- .../kbn-config-schema/src/internals/index.ts | 18 ++++++++++++- .../__snapshots__/boolean_type.test.ts.snap | 4 +++ .../__snapshots__/byte_size_type.test.ts.snap | 26 +++++++++++-------- .../__snapshots__/duration_type.test.ts.snap | 18 ++++++++----- .../src/types/boolean_type.test.ts | 15 +++++++++++ .../src/types/byte_size_type.test.ts | 24 +++++++++++++++-- .../src/types/duration_type.test.ts | 24 +++++++++++++++-- .../builtin_action_types/es_index.test.ts | 2 +- 13 files changed, 141 insertions(+), 46 deletions(-) diff --git a/packages/kbn-config-schema/README.md b/packages/kbn-config-schema/README.md index fd62f1b3c03b2..e6f3e60128983 100644 --- a/packages/kbn-config-schema/README.md +++ b/packages/kbn-config-schema/README.md @@ -156,6 +156,9 @@ __Usage:__ const valueSchema = schema.boolean({ defaultValue: false }); ``` +__Notes:__ +* The `schema.boolean()` also supports a string as input if it equals `'true'` or `'false'` (case-insensitive). + #### `schema.literal()` Validates input data as a [string](https://www.typescriptlang.org/docs/handbook/advanced-types.html#string-literal-types), [numeric](https://www.typescriptlang.org/docs/handbook/advanced-types.html#numeric-literal-types) or boolean literal. @@ -397,7 +400,7 @@ const valueSchema = schema.byteSize({ min: '3kb' }); ``` __Notes:__ -* The string value for `schema.byteSize()` and its options supports the following prefixes: `b`, `kb`, `mb`, `gb` and `tb`. +* The string value for `schema.byteSize()` and its options supports the following optional suffixes: `b`, `kb`, `mb`, `gb` and `tb`. The default suffix is `b`. * The number value is treated as a number of bytes and hence should be a positive integer, e.g. `100` is equal to `'100b'`. * Currently you cannot specify zero bytes with a string format and should use number `0` instead. @@ -417,7 +420,7 @@ const valueSchema = schema.duration({ defaultValue: '70ms' }); ``` __Notes:__ -* The string value for `schema.duration()` supports the following prefixes: `ms`, `s`, `m`, `h`, `d`, `w`, `M` and `Y`. +* The string value for `schema.duration()` supports the following optional suffixes: `ms`, `s`, `m`, `h`, `d`, `w`, `M` and `Y`. The default suffix is `ms`. * The number value is treated as a number of milliseconds and hence should be a positive integer, e.g. `100` is equal to `'100ms'`. #### `schema.conditional()` diff --git a/packages/kbn-config-schema/src/byte_size_value/__snapshots__/index.test.ts.snap b/packages/kbn-config-schema/src/byte_size_value/__snapshots__/index.test.ts.snap index 1db6930062a9a..97e9082401b3d 100644 --- a/packages/kbn-config-schema/src/byte_size_value/__snapshots__/index.test.ts.snap +++ b/packages/kbn-config-schema/src/byte_size_value/__snapshots__/index.test.ts.snap @@ -1,9 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`#constructor throws if number of bytes is negative 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [-1024]"`; +exports[`#constructor throws if number of bytes is negative 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [-1024]."`; -exports[`#constructor throws if number of bytes is not safe 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [NaN]"`; +exports[`#constructor throws if number of bytes is not safe 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [NaN]."`; -exports[`#constructor throws if number of bytes is not safe 2`] = `"Value in bytes is expected to be a safe positive integer, but provided [Infinity]"`; +exports[`#constructor throws if number of bytes is not safe 2`] = `"Value in bytes is expected to be a safe positive integer, but provided [Infinity]."`; -exports[`#constructor throws if number of bytes is not safe 3`] = `"Value in bytes is expected to be a safe positive integer, but provided [9007199254740992]"`; +exports[`#constructor throws if number of bytes is not safe 3`] = `"Value in bytes is expected to be a safe positive integer, but provided [9007199254740992]."`; + +exports[`parsing units throws an error when unsupported unit specified 1`] = `"Failed to parse [1tb] as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] (e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer."`; diff --git a/packages/kbn-config-schema/src/byte_size_value/index.test.ts b/packages/kbn-config-schema/src/byte_size_value/index.test.ts index 46ed96c83dd1f..198d95aa0ab4c 100644 --- a/packages/kbn-config-schema/src/byte_size_value/index.test.ts +++ b/packages/kbn-config-schema/src/byte_size_value/index.test.ts @@ -20,6 +20,10 @@ import { ByteSizeValue } from '.'; describe('parsing units', () => { + test('number string (bytes)', () => { + expect(ByteSizeValue.parse('123').getValueInBytes()).toBe(123); + }); + test('bytes', () => { expect(ByteSizeValue.parse('123b').getValueInBytes()).toBe(123); }); @@ -37,12 +41,8 @@ describe('parsing units', () => { expect(ByteSizeValue.parse('1gb').getValueInBytes()).toBe(1073741824); }); - test('throws an error when no unit specified', () => { - expect(() => ByteSizeValue.parse('123')).toThrowError('could not parse byte size value'); - }); - test('throws an error when unsupported unit specified', () => { - expect(() => ByteSizeValue.parse('1tb')).toThrowError('could not parse byte size value'); + expect(() => ByteSizeValue.parse('1tb')).toThrowErrorMatchingSnapshot(); }); }); diff --git a/packages/kbn-config-schema/src/byte_size_value/index.ts b/packages/kbn-config-schema/src/byte_size_value/index.ts index fb0105503a149..48862821bb78d 100644 --- a/packages/kbn-config-schema/src/byte_size_value/index.ts +++ b/packages/kbn-config-schema/src/byte_size_value/index.ts @@ -35,9 +35,14 @@ export class ByteSizeValue { public static parse(text: string): ByteSizeValue { const match = /([1-9][0-9]*)(b|kb|mb|gb)/.exec(text); if (!match) { - throw new Error( - `could not parse byte size value [${text}]. Value must be a safe positive integer.` - ); + const number = Number(text); + if (typeof number !== 'number' || isNaN(number)) { + throw new Error( + `Failed to parse [${text}] as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] ` + + `(e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer.` + ); + } + return new ByteSizeValue(number); } const value = parseInt(match[1], 0); @@ -49,8 +54,7 @@ export class ByteSizeValue { constructor(private readonly valueInBytes: number) { if (!Number.isSafeInteger(valueInBytes) || valueInBytes < 0) { throw new Error( - `Value in bytes is expected to be a safe positive integer, ` + - `but provided [${valueInBytes}]` + `Value in bytes is expected to be a safe positive integer, but provided [${valueInBytes}].` ); } } diff --git a/packages/kbn-config-schema/src/duration/index.ts b/packages/kbn-config-schema/src/duration/index.ts index ff8f96614a193..b96b5a3687bbb 100644 --- a/packages/kbn-config-schema/src/duration/index.ts +++ b/packages/kbn-config-schema/src/duration/index.ts @@ -25,10 +25,14 @@ const timeFormatRegex = /^(0|[1-9][0-9]*)(ms|s|m|h|d|w|M|Y)$/; function stringToDuration(text: string) { const result = timeFormatRegex.exec(text); if (!result) { - throw new Error( - `Failed to parse [${text}] as time value. ` + - `Format must be [ms|s|m|h|d|w|M|Y] (e.g. '70ms', '5s', '3d', '1Y')` - ); + const number = Number(text); + if (typeof number !== 'number' || isNaN(number)) { + throw new Error( + `Failed to parse [${text}] as time value. Value must be a duration in milliseconds, or follow the format ` + + `[ms|s|m|h|d|w|M|Y] (e.g. '70ms', '5s', '3d', '1Y'), where the duration is a safe positive integer.` + ); + } + return numberToDuration(number); } const count = parseInt(result[1], 0); @@ -40,8 +44,7 @@ function stringToDuration(text: string) { function numberToDuration(numberMs: number) { if (!Number.isSafeInteger(numberMs) || numberMs < 0) { throw new Error( - `Failed to parse [${numberMs}] as time value. ` + - `Value should be a safe positive integer number.` + `Value in milliseconds is expected to be a safe positive integer, but provided [${numberMs}].` ); } diff --git a/packages/kbn-config-schema/src/internals/index.ts b/packages/kbn-config-schema/src/internals/index.ts index 4d5091eaa09b1..044c3050f9fa8 100644 --- a/packages/kbn-config-schema/src/internals/index.ts +++ b/packages/kbn-config-schema/src/internals/index.ts @@ -82,7 +82,23 @@ export const internals = Joi.extend([ base: Joi.boolean(), coerce(value: any, state: State, options: ValidationOptions) { // If value isn't defined, let Joi handle default value if it's defined. - if (value !== undefined && typeof value !== 'boolean') { + if (value === undefined) { + return value; + } + + // Allow strings 'true' and 'false' to be coerced to booleans (case-insensitive). + + // From Joi docs on `Joi.boolean`: + // > Generates a schema object that matches a boolean data type. Can also + // > be called via bool(). If the validation convert option is on + // > (enabled by default), a string (either "true" or "false") will be + // converted to a boolean if specified. + if (typeof value === 'string') { + const normalized = value.toLowerCase(); + value = normalized === 'true' ? true : normalized === 'false' ? false : value; + } + + if (typeof value !== 'boolean') { return this.createError('boolean.base', { value }, state, options); } diff --git a/packages/kbn-config-schema/src/types/__snapshots__/boolean_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/boolean_type.test.ts.snap index c3f33dc29bf50..0e5f6de2deea8 100644 --- a/packages/kbn-config-schema/src/types/__snapshots__/boolean_type.test.ts.snap +++ b/packages/kbn-config-schema/src/types/__snapshots__/boolean_type.test.ts.snap @@ -9,3 +9,7 @@ exports[`returns error when not boolean 1`] = `"expected value of type [boolean] exports[`returns error when not boolean 2`] = `"expected value of type [boolean] but got [Array]"`; exports[`returns error when not boolean 3`] = `"expected value of type [boolean] but got [string]"`; + +exports[`returns error when not boolean 4`] = `"expected value of type [boolean] but got [number]"`; + +exports[`returns error when not boolean 5`] = `"expected value of type [boolean] but got [string]"`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/byte_size_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/byte_size_type.test.ts.snap index f6f45a96ca161..ea2102b1776fb 100644 --- a/packages/kbn-config-schema/src/types/__snapshots__/byte_size_type.test.ts.snap +++ b/packages/kbn-config-schema/src/types/__snapshots__/byte_size_type.test.ts.snap @@ -18,6 +18,12 @@ ByteSizeValue { } `; +exports[`#defaultValue can be a string-formatted number 1`] = ` +ByteSizeValue { + "valueInBytes": 1024, +} +`; + exports[`#max returns error when larger 1`] = `"Value is [1mb] ([1048576b]) but it must be equal to or less than [1kb]"`; exports[`#max returns value when smaller 1`] = ` @@ -38,20 +44,18 @@ exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value o exports[`is required by default 1`] = `"expected value of type [ByteSize] but got [undefined]"`; -exports[`returns error when not string or positive safe integer 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [-123]"`; +exports[`returns error when not valid string or positive safe integer 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [-123]."`; -exports[`returns error when not string or positive safe integer 2`] = `"Value in bytes is expected to be a safe positive integer, but provided [NaN]"`; +exports[`returns error when not valid string or positive safe integer 2`] = `"Value in bytes is expected to be a safe positive integer, but provided [NaN]."`; -exports[`returns error when not string or positive safe integer 3`] = `"Value in bytes is expected to be a safe positive integer, but provided [Infinity]"`; +exports[`returns error when not valid string or positive safe integer 3`] = `"Value in bytes is expected to be a safe positive integer, but provided [Infinity]."`; -exports[`returns error when not string or positive safe integer 4`] = `"Value in bytes is expected to be a safe positive integer, but provided [9007199254740992]"`; +exports[`returns error when not valid string or positive safe integer 4`] = `"Value in bytes is expected to be a safe positive integer, but provided [9007199254740992]."`; -exports[`returns error when not string or positive safe integer 5`] = `"expected value of type [ByteSize] but got [Array]"`; +exports[`returns error when not valid string or positive safe integer 5`] = `"expected value of type [ByteSize] but got [Array]"`; -exports[`returns error when not string or positive safe integer 6`] = `"expected value of type [ByteSize] but got [RegExp]"`; +exports[`returns error when not valid string or positive safe integer 6`] = `"expected value of type [ByteSize] but got [RegExp]"`; -exports[`returns value by default 1`] = ` -ByteSizeValue { - "valueInBytes": 123, -} -`; +exports[`returns error when not valid string or positive safe integer 7`] = `"Failed to parse [123foo] as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] (e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer."`; + +exports[`returns error when not valid string or positive safe integer 8`] = `"Failed to parse [123 456] as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] (e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer."`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/duration_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/duration_type.test.ts.snap index a21c28e7cc614..c4e4ff652a2d7 100644 --- a/packages/kbn-config-schema/src/types/__snapshots__/duration_type.test.ts.snap +++ b/packages/kbn-config-schema/src/types/__snapshots__/duration_type.test.ts.snap @@ -6,20 +6,24 @@ exports[`#defaultValue can be a number 1`] = `"PT0.6S"`; exports[`#defaultValue can be a string 1`] = `"PT1H"`; +exports[`#defaultValue can be a string-formatted number 1`] = `"PT0.6S"`; + exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [moment.Duration] but got [undefined]"`; exports[`is required by default 1`] = `"expected value of type [moment.Duration] but got [undefined]"`; -exports[`returns error when not string or non-safe positive integer 1`] = `"Failed to parse [-123] as time value. Value should be a safe positive integer number."`; +exports[`returns error when not valid string or non-safe positive integer 1`] = `"Value in milliseconds is expected to be a safe positive integer, but provided [-123]."`; + +exports[`returns error when not valid string or non-safe positive integer 2`] = `"Value in milliseconds is expected to be a safe positive integer, but provided [NaN]."`; -exports[`returns error when not string or non-safe positive integer 2`] = `"Failed to parse [NaN] as time value. Value should be a safe positive integer number."`; +exports[`returns error when not valid string or non-safe positive integer 3`] = `"Value in milliseconds is expected to be a safe positive integer, but provided [Infinity]."`; -exports[`returns error when not string or non-safe positive integer 3`] = `"Failed to parse [Infinity] as time value. Value should be a safe positive integer number."`; +exports[`returns error when not valid string or non-safe positive integer 4`] = `"Value in milliseconds is expected to be a safe positive integer, but provided [9007199254740992]."`; -exports[`returns error when not string or non-safe positive integer 4`] = `"Failed to parse [9007199254740992] as time value. Value should be a safe positive integer number."`; +exports[`returns error when not valid string or non-safe positive integer 5`] = `"expected value of type [moment.Duration] but got [Array]"`; -exports[`returns error when not string or non-safe positive integer 5`] = `"expected value of type [moment.Duration] but got [Array]"`; +exports[`returns error when not valid string or non-safe positive integer 6`] = `"expected value of type [moment.Duration] but got [RegExp]"`; -exports[`returns error when not string or non-safe positive integer 6`] = `"expected value of type [moment.Duration] but got [RegExp]"`; +exports[`returns error when not valid string or non-safe positive integer 7`] = `"Failed to parse [123foo] as time value. Value must be a duration in milliseconds, or follow the format [ms|s|m|h|d|w|M|Y] (e.g. '70ms', '5s', '3d', '1Y'), where the duration is a safe positive integer."`; -exports[`returns value by default 1`] = `"PT2M3S"`; +exports[`returns error when not valid string or non-safe positive integer 8`] = `"Failed to parse [123 456] as time value. Value must be a duration in milliseconds, or follow the format [ms|s|m|h|d|w|M|Y] (e.g. '70ms', '5s', '3d', '1Y'), where the duration is a safe positive integer."`; diff --git a/packages/kbn-config-schema/src/types/boolean_type.test.ts b/packages/kbn-config-schema/src/types/boolean_type.test.ts index d6e274f05e3ff..e94999b505437 100644 --- a/packages/kbn-config-schema/src/types/boolean_type.test.ts +++ b/packages/kbn-config-schema/src/types/boolean_type.test.ts @@ -23,6 +23,17 @@ test('returns value by default', () => { expect(schema.boolean().validate(true)).toBe(true); }); +test('handles boolean strings', () => { + expect(schema.boolean().validate('true')).toBe(true); + expect(schema.boolean().validate('TRUE')).toBe(true); + expect(schema.boolean().validate('True')).toBe(true); + expect(schema.boolean().validate('TrUe')).toBe(true); + expect(schema.boolean().validate('false')).toBe(false); + expect(schema.boolean().validate('FALSE')).toBe(false); + expect(schema.boolean().validate('False')).toBe(false); + expect(schema.boolean().validate('FaLse')).toBe(false); +}); + test('is required by default', () => { expect(() => schema.boolean().validate(undefined)).toThrowErrorMatchingSnapshot(); }); @@ -49,4 +60,8 @@ test('returns error when not boolean', () => { expect(() => schema.boolean().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); expect(() => schema.boolean().validate('abc')).toThrowErrorMatchingSnapshot(); + + expect(() => schema.boolean().validate(0)).toThrowErrorMatchingSnapshot(); + + expect(() => schema.boolean().validate('no')).toThrowErrorMatchingSnapshot(); }); diff --git a/packages/kbn-config-schema/src/types/byte_size_type.test.ts b/packages/kbn-config-schema/src/types/byte_size_type.test.ts index 67eae1e7c382a..7c65ec2945b49 100644 --- a/packages/kbn-config-schema/src/types/byte_size_type.test.ts +++ b/packages/kbn-config-schema/src/types/byte_size_type.test.ts @@ -23,7 +23,15 @@ import { ByteSizeValue } from '../byte_size_value'; const { byteSize } = schema; test('returns value by default', () => { - expect(byteSize().validate('123b')).toMatchSnapshot(); + expect(byteSize().validate('123b')).toEqual(new ByteSizeValue(123)); +}); + +test('handles numeric strings', () => { + expect(byteSize().validate('123')).toEqual(new ByteSizeValue(123)); +}); + +test('handles numbers', () => { + expect(byteSize().validate(123)).toEqual(new ByteSizeValue(123)); }); test('is required by default', () => { @@ -51,6 +59,14 @@ describe('#defaultValue', () => { ).toMatchSnapshot(); }); + test('can be a string-formatted number', () => { + expect( + byteSize({ + defaultValue: '1024', + }).validate(undefined) + ).toMatchSnapshot(); + }); + test('can be a number', () => { expect( byteSize({ @@ -88,7 +104,7 @@ describe('#max', () => { }); }); -test('returns error when not string or positive safe integer', () => { +test('returns error when not valid string or positive safe integer', () => { expect(() => byteSize().validate(-123)).toThrowErrorMatchingSnapshot(); expect(() => byteSize().validate(NaN)).toThrowErrorMatchingSnapshot(); @@ -100,4 +116,8 @@ test('returns error when not string or positive safe integer', () => { expect(() => byteSize().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); expect(() => byteSize().validate(/abc/)).toThrowErrorMatchingSnapshot(); + + expect(() => byteSize().validate('123foo')).toThrowErrorMatchingSnapshot(); + + expect(() => byteSize().validate('123 456')).toThrowErrorMatchingSnapshot(); }); diff --git a/packages/kbn-config-schema/src/types/duration_type.test.ts b/packages/kbn-config-schema/src/types/duration_type.test.ts index 39655d43d7b75..09e92ce727f2a 100644 --- a/packages/kbn-config-schema/src/types/duration_type.test.ts +++ b/packages/kbn-config-schema/src/types/duration_type.test.ts @@ -23,7 +23,15 @@ import { schema } from '..'; const { duration, object, contextRef, siblingRef } = schema; test('returns value by default', () => { - expect(duration().validate('123s')).toMatchSnapshot(); + expect(duration().validate('123s')).toEqual(momentDuration(123000)); +}); + +test('handles numeric string', () => { + expect(duration().validate('123000')).toEqual(momentDuration(123000)); +}); + +test('handles number', () => { + expect(duration().validate(123000)).toEqual(momentDuration(123000)); }); test('is required by default', () => { @@ -51,6 +59,14 @@ describe('#defaultValue', () => { ).toMatchSnapshot(); }); + test('can be a string-formatted number', () => { + expect( + duration({ + defaultValue: '600', + }).validate(undefined) + ).toMatchSnapshot(); + }); + test('can be a number', () => { expect( duration({ @@ -124,7 +140,7 @@ Object { }); }); -test('returns error when not string or non-safe positive integer', () => { +test('returns error when not valid string or non-safe positive integer', () => { expect(() => duration().validate(-123)).toThrowErrorMatchingSnapshot(); expect(() => duration().validate(NaN)).toThrowErrorMatchingSnapshot(); @@ -136,4 +152,8 @@ test('returns error when not string or non-safe positive integer', () => { expect(() => duration().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); expect(() => duration().validate(/abc/)).toThrowErrorMatchingSnapshot(); + + expect(() => duration().validate('123foo')).toThrowErrorMatchingSnapshot(); + + expect(() => duration().validate('123 456')).toThrowErrorMatchingSnapshot(); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts index 35d81ba74fa72..1da8b06e1587a 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts @@ -142,7 +142,7 @@ describe('params validation', () => { ); expect(() => { - validateParams(actionType, { refresh: 'true' }); + validateParams(actionType, { refresh: 'foo' }); }).toThrowErrorMatchingInlineSnapshot( `"error validating action params: [refresh]: expected value of type [boolean] but got [string]"` ); From e93c6b8d1a187c50867c60fc3a422ff868fbcc62 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Wed, 8 Jan 2020 15:07:14 -0500 Subject: [PATCH 13/23] [ML] DF Analytics Results: adds link to docs (#54189) * add doc links to evaluate panel for analytics jobs * fix confusion matrix dataGrid label * internationalize link text --- .../evaluate_panel.tsx | 55 +++++++++++-------- .../regression_exploration/evaluate_panel.tsx | 32 ++++++++++- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index 7bb6949db1a99..68ed2c08d0df1 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -8,6 +8,7 @@ import React, { FC, useState, useEffect, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { + EuiButtonEmpty, EuiDataGrid, EuiFlexGroup, EuiFlexItem, @@ -18,6 +19,7 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; +import { metadata } from 'ui/metadata'; import { ErrorCallout } from '../error_callout'; import { getDependentVar, @@ -243,7 +245,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) - + @@ -260,6 +262,25 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) {getTaskStateBadge(jobStatus)} + + + + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.classificationDocsLink', + { + defaultMessage: 'Classification evaluation docs ', + } + )} + + {error !== null && ( @@ -327,28 +348,18 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) - - - - - - - - - - - - - + + + - + = ({ jobConfig, jobStatus, searchQuery }) return ( - + @@ -238,6 +247,25 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) {getTaskStateBadge(jobStatus)} + + + + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.regressionDocsLink', + { + defaultMessage: 'Regression evaluation docs ', + } + )} + + From 26ce6104a9091574d4ff85325aa7e4b8565faf8c Mon Sep 17 00:00:00 2001 From: Dmitry Lemeshko Date: Wed, 8 Jan 2020 21:08:48 +0100 Subject: [PATCH 14/23] Code coverage setup on CI (#49003) * running code coverage in CI * apply review feedback * add custom function to upload merged coverage reports * fix artifacts upload without coverage * add file extension to fix validation * Check code_coverage is set * run oss tests via grunt task * review fixes Co-authored-by: Elastic Machine --- .ci/Jenkinsfile_coverage | 112 ++++++++++++++++++++ scripts/functional_tests.js | 19 +++- src/dev/jest/config.js | 2 +- src/legacy/server/config/schema.js | 2 +- tasks/config/run.js | 1 + tasks/function_test_groups.js | 29 ++--- test/functional/services/remote/remote.ts | 4 +- test/scripts/jenkins_build_kibana.sh | 7 +- test/scripts/jenkins_ci_group.sh | 38 ++++--- test/scripts/jenkins_unit.sh | 14 ++- test/scripts/jenkins_xpack.sh | 75 +++++++------ test/scripts/jenkins_xpack_build_kibana.sh | 17 +-- test/scripts/jenkins_xpack_ci_group.sh | 101 +++++++++--------- vars/kibanaPipeline.groovy | 17 +++ x-pack/dev-tools/jest/create_jest_config.js | 2 +- x-pack/scripts/functional_tests.js | 59 ++++++----- 16 files changed, 347 insertions(+), 152 deletions(-) create mode 100644 .ci/Jenkinsfile_coverage diff --git a/.ci/Jenkinsfile_coverage b/.ci/Jenkinsfile_coverage new file mode 100644 index 0000000000000..d9ec1861c9979 --- /dev/null +++ b/.ci/Jenkinsfile_coverage @@ -0,0 +1,112 @@ +#!/bin/groovy + +library 'kibana-pipeline-library' +kibanaLibrary.load() // load from the Jenkins instance + +stage("Kibana Pipeline") { // This stage is just here to help the BlueOcean UI a little bit + timeout(time: 180, unit: 'MINUTES') { + timestamps { + ansiColor('xterm') { + catchError { + withEnv([ + 'CODE_COVERAGE=1', // Needed for multiple ci scripts, such as remote.ts, test/scripts/*.sh, schema.js, etc. + ]) { + parallel([ + 'kibana-intake-agent': { + withEnv([ + 'NODE_ENV=test' // Needed for jest tests only + ]) { + kibanaPipeline.legacyJobRunner('kibana-intake')() + } + }, + 'x-pack-intake-agent': { + withEnv([ + 'NODE_ENV=test' // Needed for jest tests only + ]) { + kibanaPipeline.legacyJobRunner('x-pack-intake')() + } + }, + 'kibana-oss-agent': kibanaPipeline.withWorkers('kibana-oss-tests', { kibanaPipeline.buildOss() }, [ + 'oss-ciGroup1': kibanaPipeline.getOssCiGroupWorker(1), + 'oss-ciGroup2': kibanaPipeline.getOssCiGroupWorker(2), + 'oss-ciGroup3': kibanaPipeline.getOssCiGroupWorker(3), + 'oss-ciGroup4': kibanaPipeline.getOssCiGroupWorker(4), + 'oss-ciGroup5': kibanaPipeline.getOssCiGroupWorker(5), + 'oss-ciGroup6': kibanaPipeline.getOssCiGroupWorker(6), + 'oss-ciGroup7': kibanaPipeline.getOssCiGroupWorker(7), + 'oss-ciGroup8': kibanaPipeline.getOssCiGroupWorker(8), + 'oss-ciGroup9': kibanaPipeline.getOssCiGroupWorker(9), + 'oss-ciGroup10': kibanaPipeline.getOssCiGroupWorker(10), + 'oss-ciGroup11': kibanaPipeline.getOssCiGroupWorker(11), + 'oss-ciGroup12': kibanaPipeline.getOssCiGroupWorker(12), + ]), + 'kibana-xpack-agent-1': kibanaPipeline.withWorkers('kibana-xpack-tests-1', { kibanaPipeline.buildXpack() }, [ + 'xpack-ciGroup1': kibanaPipeline.getXpackCiGroupWorker(1), + 'xpack-ciGroup2': kibanaPipeline.getXpackCiGroupWorker(2), + ]), + 'kibana-xpack-agent-2': kibanaPipeline.withWorkers('kibana-xpack-tests-2', { kibanaPipeline.buildXpack() }, [ + 'xpack-ciGroup3': kibanaPipeline.getXpackCiGroupWorker(3), + 'xpack-ciGroup4': kibanaPipeline.getXpackCiGroupWorker(4), + ]), + + 'kibana-xpack-agent-3': kibanaPipeline.withWorkers('kibana-xpack-tests-3', { kibanaPipeline.buildXpack() }, [ + 'xpack-ciGroup5': kibanaPipeline.getXpackCiGroupWorker(5), + 'xpack-ciGroup6': kibanaPipeline.getXpackCiGroupWorker(6), + 'xpack-ciGroup7': kibanaPipeline.getXpackCiGroupWorker(7), + 'xpack-ciGroup8': kibanaPipeline.getXpackCiGroupWorker(8), + 'xpack-ciGroup9': kibanaPipeline.getXpackCiGroupWorker(9), + 'xpack-ciGroup10': kibanaPipeline.getXpackCiGroupWorker(10), + ]), + ]) + kibanaPipeline.jobRunner('tests-l', false) { + kibanaPipeline.downloadCoverageArtifacts() + kibanaPipeline.bash( + ''' + # bootstrap from x-pack folder + source src/dev/ci_setup/setup_env.sh + cd x-pack + yarn kbn bootstrap --prefer-offline + cd .. + # extract archives + mkdir -p /tmp/extracted_coverage + echo extracting intakes + tar -xzf /tmp/downloaded_coverage/coverage/kibana-intake/kibana-coverage.tar.gz -C /tmp/extracted_coverage + tar -xzf /tmp/downloaded_coverage/coverage/x-pack-intake/kibana-coverage.tar.gz -C /tmp/extracted_coverage + echo extracting kibana-oss-tests + tar -xzf /tmp/downloaded_coverage/coverage/kibana-oss-tests/kibana-coverage.tar.gz -C /tmp/extracted_coverage + echo extracting kibana-xpack-tests + for i in {1..3}; do + tar -xzf /tmp/downloaded_coverage/coverage/kibana-xpack-tests-${i}/kibana-coverage.tar.gz -C /tmp/extracted_coverage + done + # replace path in json files to have valid html report + pwd=$(pwd) + du -sh /tmp/extracted_coverage/target/kibana-coverage/ + echo replacing path in json files + for i in {1..9}; do + sed -i "s|/dev/shm/workspace/kibana|$pwd|g" /tmp/extracted_coverage/target/kibana-coverage/functional/${i}*.json & + done + wait + # merge oss & x-pack reports + echo merging coverage reports + yarn nyc report --temp-dir /tmp/extracted_coverage/target/kibana-coverage/jest --report-dir target/kibana-coverage/jest-combined --reporter=html --reporter=json-summary + yarn nyc report --temp-dir /tmp/extracted_coverage/target/kibana-coverage/functional --report-dir target/kibana-coverage/functional-combined --reporter=html --reporter=json-summary + echo copy mocha reports + mkdir -p target/kibana-coverage/mocha-combined + cp -r /tmp/extracted_coverage/target/kibana-coverage/mocha target/kibana-coverage/mocha-combined + ''', + "run `yarn kbn bootstrap && merge coverage`" + ) + sh 'tar -czf kibana-jest-coverage.tar.gz target/kibana-coverage/jest-combined/*' + kibanaPipeline.uploadCoverageArtifacts("coverage/jest-combined", 'kibana-jest-coverage.tar.gz') + sh 'tar -czf kibana-functional-coverage.tar.gz target/kibana-coverage/functional-combined/*' + kibanaPipeline.uploadCoverageArtifacts("coverage/functional-combined", 'kibana-functional-coverage.tar.gz') + sh 'tar -czf kibana-mocha-coverage.tar.gz target/kibana-coverage/mocha-combined/*' + kibanaPipeline.uploadCoverageArtifacts("coverage/mocha-combined", 'kibana-mocha-coverage.tar.gz') + } + } + } + kibanaPipeline.sendMail() + } + } + } +} diff --git a/scripts/functional_tests.js b/scripts/functional_tests.js index 4472891e580fb..fc88f2657018f 100644 --- a/scripts/functional_tests.js +++ b/scripts/functional_tests.js @@ -17,12 +17,23 @@ * under the License. */ -require('../src/setup_node_env'); -require('@kbn/test').runTestsCli([ +// eslint-disable-next-line no-restricted-syntax +const alwaysImportedTests = [ require.resolve('../test/functional/config.js'), - require.resolve('../test/api_integration/config.js'), require.resolve('../test/plugin_functional/config.js'), - require.resolve('../test/interpreter_functional/config.ts'), require.resolve('../test/ui_capabilities/newsfeed_err/config.ts'), +]; +// eslint-disable-next-line no-restricted-syntax +const onlyNotInCoverageTests = [ + require.resolve('../test/api_integration/config.js'), + require.resolve('../test/interpreter_functional/config.ts'), require.resolve('../test/examples/config.js'), +]; + +require('../src/setup_node_env'); +require('@kbn/test').runTestsCli([ + // eslint-disable-next-line no-restricted-syntax + ...alwaysImportedTests, + // eslint-disable-next-line no-restricted-syntax + ...(!!process.env.CODE_COVERAGE ? [] : onlyNotInCoverageTests), ]); diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index 9321336f0f55e..1c95e75396bcc 100644 --- a/src/dev/jest/config.js +++ b/src/dev/jest/config.js @@ -69,7 +69,7 @@ export default { ], setupFilesAfterEnv: ['/src/dev/jest/setup/mocks.js'], coverageDirectory: '/target/kibana-coverage/jest', - coverageReporters: ['html', 'text'], + coverageReporters: !!process.env.CODE_COVERAGE ? ['json'] : ['html', 'text'], moduleFileExtensions: ['js', 'json', 'ts', 'tsx'], modulePathIgnorePatterns: ['__fixtures__/', 'target/'], testMatch: ['**/*.test.{js,ts,tsx}'], diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index a18cb7de5a61b..a53e8e0498c42 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -181,7 +181,7 @@ export default () => .default('localhost'), watchPrebuild: Joi.boolean().default(false), watchProxyTimeout: Joi.number().default(10 * 60000), - useBundleCache: Joi.boolean().default(Joi.ref('$prod')), + useBundleCache: Joi.boolean().default(!!process.env.CODE_COVERAGE ? true : Joi.ref('$prod')), sourceMaps: Joi.when('$prod', { is: true, then: Joi.boolean().valid(false), diff --git a/tasks/config/run.js b/tasks/config/run.js index a29061c9a7240..857895d75595c 100644 --- a/tasks/config/run.js +++ b/tasks/config/run.js @@ -152,6 +152,7 @@ module.exports = function(grunt) { args: [ 'nyc', '--reporter=html', + '--reporter=json-summary', '--report-dir=./target/kibana-coverage/mocha', NODE, 'scripts/mocha', diff --git a/tasks/function_test_groups.js b/tasks/function_test_groups.js index 7854e2cd49837..7b7293dc9a037 100644 --- a/tasks/function_test_groups.js +++ b/tasks/function_test_groups.js @@ -29,6 +29,21 @@ const TEST_TAGS = safeLoad(JOBS_YAML) .JOB.filter(id => id.startsWith('kibana-ciGroup')) .map(id => id.replace(/^kibana-/, '')); +const getDefaultArgs = tag => { + return [ + 'scripts/functional_tests', + '--include-tag', + tag, + '--config', + 'test/functional/config.js', + '--config', + 'test/ui_capabilities/newsfeed_err/config.ts', + // '--config', 'test/functional/config.firefox.js', + '--bail', + '--debug', + ]; +}; + export function getFunctionalTestGroupRunConfigs({ kibanaInstallDir } = {}) { return { // include a run task for each test group @@ -38,18 +53,8 @@ export function getFunctionalTestGroupRunConfigs({ kibanaInstallDir } = {}) { [`functionalTests_${tag}`]: { cmd: process.execPath, args: [ - 'scripts/functional_tests', - '--include-tag', - tag, - '--config', - 'test/functional/config.js', - '--config', - 'test/ui_capabilities/newsfeed_err/config.ts', - // '--config', 'test/functional/config.firefox.js', - '--bail', - '--debug', - '--kibana-install-dir', - kibanaInstallDir, + ...getDefaultArgs(tag), + ...(!!process.env.CODE_COVERAGE ? [] : ['--kibana-install-dir', kibanaInstallDir]), ], }, }), diff --git a/test/functional/services/remote/remote.ts b/test/functional/services/remote/remote.ts index 69c2793621095..afe8499a1c2ea 100644 --- a/test/functional/services/remote/remote.ts +++ b/test/functional/services/remote/remote.ts @@ -100,7 +100,9 @@ export async function RemoteProvider({ getService }: FtrProviderContext) { .subscribe({ next({ message, level }) { const msg = message.replace(/\\n/g, '\n'); - log[level === 'SEVERE' ? 'error' : 'debug'](`browser[${level}] ${msg}`); + log[level === 'SEVERE' || level === 'error' ? 'error' : 'debug']( + `browser[${level}] ${msg}` + ); }, }); diff --git a/test/scripts/jenkins_build_kibana.sh b/test/scripts/jenkins_build_kibana.sh index f79fe98e07bef..2605655ed7e7a 100755 --- a/test/scripts/jenkins_build_kibana.sh +++ b/test/scripts/jenkins_build_kibana.sh @@ -8,5 +8,8 @@ node scripts/es snapshot --license=oss --download-only; echo " -> Ensuring all functional tests are in a ciGroup" yarn run grunt functionalTests:ensureAllTestsInCiGroup; -echo " -> building and extracting OSS Kibana distributable for use in functional tests" -node scripts/build --debug --oss +# Do not build kibana for code coverage run +if [[ -z "$CODE_COVERAGE" ]] ; then + echo " -> building and extracting OSS Kibana distributable for use in functional tests" + node scripts/build --debug --oss +fi diff --git a/test/scripts/jenkins_ci_group.sh b/test/scripts/jenkins_ci_group.sh index 1cb566c908dbf..fccdb29ff512b 100755 --- a/test/scripts/jenkins_ci_group.sh +++ b/test/scripts/jenkins_ci_group.sh @@ -2,22 +2,30 @@ source test/scripts/jenkins_test_setup.sh -if [[ -z "$IS_PIPELINE_JOB" ]] ; then - yarn run grunt functionalTests:ensureAllTestsInCiGroup; - node scripts/build --debug --oss; -else - installDir="$(realpath $PARENT_DIR/kibana/build/oss/kibana-*-SNAPSHOT-linux-x86_64)" - destDir=${installDir}-${CI_WORKER_NUMBER} - cp -R "$installDir" "$destDir" +if [[ -z "$CODE_COVERAGE" ]] ; then + if [[ -z "$IS_PIPELINE_JOB" ]] ; then + yarn run grunt functionalTests:ensureAllTestsInCiGroup; + node scripts/build --debug --oss; + else + installDir="$(realpath $PARENT_DIR/kibana/build/oss/kibana-*-SNAPSHOT-linux-x86_64)" + destDir=${installDir}-${CI_WORKER_NUMBER} + cp -R "$installDir" "$destDir" - export KIBANA_INSTALL_DIR="$destDir" -fi + export KIBANA_INSTALL_DIR="$destDir" + fi + + checks-reporter-with-killswitch "Functional tests / Group ${CI_GROUP}" yarn run grunt "run:functionalTests_ciGroup${CI_GROUP}"; + + if [ "$CI_GROUP" == "1" ]; then + source test/scripts/jenkins_build_kbn_tp_sample_panel_action.sh + yarn run grunt run:pluginFunctionalTestsRelease --from=source; + yarn run grunt run:exampleFunctionalTestsRelease --from=source; + yarn run grunt run:interpreterFunctionalTestsRelease; + fi +else + echo " -> Running Functional tests with code coverage" -checks-reporter-with-killswitch "Functional tests / Group ${CI_GROUP}" yarn run grunt "run:functionalTests_ciGroup${CI_GROUP}"; + export NODE_OPTIONS=--max_old_space_size=8192 -if [ "$CI_GROUP" == "1" ]; then - source test/scripts/jenkins_build_kbn_tp_sample_panel_action.sh - yarn run grunt run:pluginFunctionalTestsRelease --from=source; - yarn run grunt run:exampleFunctionalTestsRelease --from=source; - yarn run grunt run:interpreterFunctionalTestsRelease; + yarn run grunt "run:functionalTests_ciGroup${CI_GROUP}"; fi diff --git a/test/scripts/jenkins_unit.sh b/test/scripts/jenkins_unit.sh index 75610884b542f..a8b5e8e4fdf97 100755 --- a/test/scripts/jenkins_unit.sh +++ b/test/scripts/jenkins_unit.sh @@ -4,4 +4,16 @@ set -e export TEST_BROWSER_HEADLESS=1 -"$(FORCE_COLOR=0 yarn bin)/grunt" jenkins:unit --dev; +if [[ -z "$CODE_COVERAGE" ]] ; then + "$(FORCE_COLOR=0 yarn bin)/grunt" jenkins:unit --dev; +else + echo "NODE_ENV=$NODE_ENV" + echo " -> Running jest tests with coverage" + node scripts/jest --ci --verbose --coverage + echo "" + echo "" + echo " -> Running mocha tests with coverage" + yarn run grunt "test:mochaCoverage"; + echo "" + echo "" +fi diff --git a/test/scripts/jenkins_xpack.sh b/test/scripts/jenkins_xpack.sh index 27f73c0b6e20d..e0055085d9b37 100755 --- a/test/scripts/jenkins_xpack.sh +++ b/test/scripts/jenkins_xpack.sh @@ -4,33 +4,48 @@ set -e export TEST_BROWSER_HEADLESS=1 -echo " -> Running mocha tests" -cd "$XPACK_DIR" -checks-reporter-with-killswitch "X-Pack Karma Tests" yarn test:browser -echo "" -echo "" - -echo " -> Running jest tests" -cd "$XPACK_DIR" -checks-reporter-with-killswitch "X-Pack Jest" node scripts/jest --ci --verbose -echo "" -echo "" - -echo " -> Running SIEM cyclic dependency test" -cd "$XPACK_DIR" -checks-reporter-with-killswitch "X-Pack SIEM cyclic dependency test" node legacy/plugins/siem/scripts/check_circular_deps -echo "" -echo "" - -# FAILING: https://github.com/elastic/kibana/issues/44250 -# echo " -> Running jest contracts tests" -# cd "$XPACK_DIR" -# SLAPSHOT_ONLINE=true CONTRACT_ONLINE=true node scripts/jest_contract.js --ci --verbose -# echo "" -# echo "" - -# echo " -> Running jest integration tests" -# cd "$XPACK_DIR" -# node scripts/jest_integration --ci --verbose -# echo "" -# echo "" +if [[ -z "$CODE_COVERAGE" ]] ; then + echo " -> Running mocha tests" + cd "$XPACK_DIR" + checks-reporter-with-killswitch "X-Pack Karma Tests" yarn test:browser + echo "" + echo "" + + echo " -> Running jest tests" + cd "$XPACK_DIR" + checks-reporter-with-killswitch "X-Pack Jest" node scripts/jest --ci --verbose + echo "" + echo "" + + echo " -> Running SIEM cyclic dependency test" + cd "$XPACK_DIR" + checks-reporter-with-killswitch "X-Pack SIEM cyclic dependency test" node legacy/plugins/siem/scripts/check_circular_deps + echo "" + echo "" + + # FAILING: https://github.com/elastic/kibana/issues/44250 + # echo " -> Running jest contracts tests" + # cd "$XPACK_DIR" + # SLAPSHOT_ONLINE=true CONTRACT_ONLINE=true node scripts/jest_contract.js --ci --verbose + # echo "" + # echo "" + + # echo " -> Running jest integration tests" + # cd "$XPACK_DIR" + # node scripts/jest_integration --ci --verbose + # echo "" + # echo "" +else + echo " -> Running jest tests with coverage" + cd "$XPACK_DIR" + # build runtime for canvas + echo "NODE_ENV=$NODE_ENV" + node ./legacy/plugins/canvas/scripts/shareable_runtime + node scripts/jest --ci --verbose --coverage + # rename file in order to be unique one + test -f ../target/kibana-coverage/jest/coverage-final.json \ + && mv ../target/kibana-coverage/jest/coverage-final.json \ + ../target/kibana-coverage/jest/xpack-coverage-final.json + echo "" + echo "" +fi \ No newline at end of file diff --git a/test/scripts/jenkins_xpack_build_kibana.sh b/test/scripts/jenkins_xpack_build_kibana.sh index 9f2bafc863f41..20b12b302cb39 100755 --- a/test/scripts/jenkins_xpack_build_kibana.sh +++ b/test/scripts/jenkins_xpack_build_kibana.sh @@ -20,10 +20,13 @@ node scripts/functional_tests --assert-none-excluded \ --include-tag ciGroup9 \ --include-tag ciGroup10 -echo " -> building and extracting default Kibana distributable for use in functional tests" -cd "$KIBANA_DIR" -node scripts/build --debug --no-oss -linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" -installDir="$PARENT_DIR/install/kibana" -mkdir -p "$installDir" -tar -xzf "$linuxBuild" -C "$installDir" --strip=1 +# Do not build kibana for code coverage run +if [[ -z "$CODE_COVERAGE" ]] ; then + echo " -> building and extracting default Kibana distributable for use in functional tests" + cd "$KIBANA_DIR" + node scripts/build --debug --no-oss + linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" + installDir="$PARENT_DIR/install/kibana" + mkdir -p "$installDir" + tar -xzf "$linuxBuild" -C "$installDir" --strip=1 +fi diff --git a/test/scripts/jenkins_xpack_ci_group.sh b/test/scripts/jenkins_xpack_ci_group.sh index fba05f8f252d7..58c407a848ae3 100755 --- a/test/scripts/jenkins_xpack_ci_group.sh +++ b/test/scripts/jenkins_xpack_ci_group.sh @@ -2,59 +2,60 @@ source test/scripts/jenkins_test_setup.sh -if [[ -z "$IS_PIPELINE_JOB" ]] ; then - echo " -> Ensuring all functional tests are in a ciGroup" +if [[ -z "$CODE_COVERAGE" ]] ; then + if [[ -z "$IS_PIPELINE_JOB" ]] ; then + echo " -> Ensuring all functional tests are in a ciGroup" + cd "$XPACK_DIR" + node scripts/functional_tests --assert-none-excluded \ + --include-tag ciGroup1 \ + --include-tag ciGroup2 \ + --include-tag ciGroup3 \ + --include-tag ciGroup4 \ + --include-tag ciGroup5 \ + --include-tag ciGroup6 \ + --include-tag ciGroup7 \ + --include-tag ciGroup8 \ + --include-tag ciGroup9 \ + --include-tag ciGroup10 + fi + + cd "$KIBANA_DIR" + + if [[ -z "$IS_PIPELINE_JOB" ]] ; then + echo " -> building and extracting default Kibana distributable for use in functional tests" + node scripts/build --debug --no-oss + + linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" + installDir="$PARENT_DIR/install/kibana" + + mkdir -p "$installDir" + tar -xzf "$linuxBuild" -C "$installDir" --strip=1 + + export KIBANA_INSTALL_DIR="$installDir" + else + installDir="$PARENT_DIR/install/kibana" + destDir="${installDir}-${CI_WORKER_NUMBER}" + cp -R "$installDir" "$destDir" + + export KIBANA_INSTALL_DIR="$destDir" + fi + + echo " -> Running functional and api tests" cd "$XPACK_DIR" - node scripts/functional_tests --assert-none-excluded \ - --include-tag ciGroup1 \ - --include-tag ciGroup2 \ - --include-tag ciGroup3 \ - --include-tag ciGroup4 \ - --include-tag ciGroup5 \ - --include-tag ciGroup6 \ - --include-tag ciGroup7 \ - --include-tag ciGroup8 \ - --include-tag ciGroup9 \ - --include-tag ciGroup10 -fi - -cd "$KIBANA_DIR" - -if [[ -z "$IS_PIPELINE_JOB" ]] ; then - echo " -> building and extracting default Kibana distributable for use in functional tests" - node scripts/build --debug --no-oss - - linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" - installDir="$PARENT_DIR/install/kibana" - mkdir -p "$installDir" - tar -xzf "$linuxBuild" -C "$installDir" --strip=1 + checks-reporter-with-killswitch "X-Pack Chrome Functional tests / Group ${CI_GROUP}" \ + node scripts/functional_tests \ + --debug --bail \ + --kibana-install-dir "$KIBANA_INSTALL_DIR" \ + --include-tag "ciGroup$CI_GROUP" - export KIBANA_INSTALL_DIR="$installDir" + echo "" + echo "" else - installDir="$PARENT_DIR/install/kibana" - destDir="${installDir}-${CI_WORKER_NUMBER}" - cp -R "$installDir" "$destDir" + echo " -> Running X-Pack functional tests with code coverage" + cd "$XPACK_DIR" - export KIBANA_INSTALL_DIR="$destDir" -fi + export NODE_OPTIONS=--max_old_space_size=8192 -echo " -> Running functional and api tests" -cd "$XPACK_DIR" - -checks-reporter-with-killswitch "X-Pack Chrome Functional tests / Group ${CI_GROUP}" \ - node scripts/functional_tests \ - --debug --bail \ - --kibana-install-dir "$KIBANA_INSTALL_DIR" \ - --include-tag "ciGroup$CI_GROUP" - -echo "" -echo "" - -# checks-reporter-with-killswitch "X-Pack Firefox Functional tests / Group ${CI_GROUP}" \ -# node scripts/functional_tests --debug --bail \ -# --kibana-install-dir "$installDir" \ -# --include-tag "ciGroup$CI_GROUP" \ -# --config "test/functional/config.firefox.js" -# echo "" -# echo "" + node scripts/functional_tests --debug --include-tag "ciGroup$CI_GROUP" +fi diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index c778dd799f6e5..5c6be70514c61 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -176,6 +176,18 @@ def uploadGcsArtifact(uploadPrefix, pattern) { ) } +def downloadCoverageArtifacts() { + def storageLocation = "gs://kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/coverage/" + def targetLocation = "/tmp/downloaded_coverage" + + sh "mkdir -p '${targetLocation}' && gsutil -m cp -r '${storageLocation}' '${targetLocation}'" +} + +def uploadCoverageArtifacts(prefix, pattern) { + def uploadPrefix = "kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/coverage/${prefix}" + uploadGcsArtifact(uploadPrefix, pattern) +} + def withGcsArtifactUpload(workerName, closure) { def uploadPrefix = "kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/${workerName}" def ARTIFACT_PATTERNS = [ @@ -201,6 +213,11 @@ def withGcsArtifactUpload(workerName, closure) { } } }) + + if (env.CODE_COVERAGE) { + sh 'tar -czf kibana-coverage.tar.gz target/kibana-coverage/**/*' + uploadGcsArtifact("kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/coverage/${workerName}", 'kibana-coverage.tar.gz') + } } def publishJunit() { diff --git a/x-pack/dev-tools/jest/create_jest_config.js b/x-pack/dev-tools/jest/create_jest_config.js index cd4414b5fdebe..02904cc48e030 100644 --- a/x-pack/dev-tools/jest/create_jest_config.js +++ b/x-pack/dev-tools/jest/create_jest_config.js @@ -30,7 +30,7 @@ export function createJestConfig({ kibanaDirectory, xPackKibanaDirectory }) { '^test_utils/find_test_subject': `${xPackKibanaDirectory}/test_utils/find_test_subject.ts`, }, coverageDirectory: '/../target/kibana-coverage/jest', - coverageReporters: ['html'], + coverageReporters: !!process.env.CODE_COVERAGE ? ['json'] : ['html'], setupFiles: [ `${kibanaDirectory}/src/dev/jest/setup/babel_polyfill.js`, `/dev-tools/jest/setup/polyfills.js`, diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 2b92e70fb30af..86db39823ba91 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -4,38 +4,43 @@ * you may not use this file except in compliance with the Elastic License. */ -require('@kbn/plugin-helpers').babelRegister(); -require('@kbn/test').runTestsCli([ +const alwaysImportedTests = [require.resolve('../test/functional/config.js')]; +const onlyNotInCoverageTests = [ require.resolve('../test/reporting/configs/chromium_api.js'), require.resolve('../test/reporting/configs/chromium_functional.js'), - require.resolve('../test/reporting/configs/generate_api'), - require.resolve('../test/functional/config.js'), + require.resolve('../test/reporting/configs/generate_api.js'), require.resolve('../test/api_integration/config_security_basic.js'), require.resolve('../test/api_integration/config.js'), require.resolve('../test/alerting_api_integration/spaces_only/config.ts'), require.resolve('../test/alerting_api_integration/security_and_spaces/config.ts'), require.resolve('../test/plugin_api_integration/config.js'), - require.resolve('../test/plugin_functional/config'), - require.resolve('../test/kerberos_api_integration/config'), - require.resolve('../test/kerberos_api_integration/anonymous_access.config'), - require.resolve('../test/saml_api_integration/config'), - require.resolve('../test/token_api_integration/config'), - require.resolve('../test/oidc_api_integration/config'), - require.resolve('../test/oidc_api_integration/implicit_flow.config'), - require.resolve('../test/pki_api_integration/config'), - require.resolve('../test/spaces_api_integration/spaces_only/config'), - require.resolve('../test/spaces_api_integration/security_and_spaces/config_trial'), - require.resolve('../test/spaces_api_integration/security_and_spaces/config_basic'), - require.resolve('../test/saved_object_api_integration/security_and_spaces/config_trial'), - require.resolve('../test/saved_object_api_integration/security_and_spaces/config_basic'), - require.resolve('../test/saved_object_api_integration/security_only/config_trial'), - require.resolve('../test/saved_object_api_integration/security_only/config_basic'), - require.resolve('../test/saved_object_api_integration/spaces_only/config'), - require.resolve('../test/ui_capabilities/security_and_spaces/config'), - require.resolve('../test/ui_capabilities/security_only/config'), - require.resolve('../test/ui_capabilities/spaces_only/config'), - require.resolve('../test/upgrade_assistant_integration/config'), - require.resolve('../test/licensing_plugin/config'), - require.resolve('../test/licensing_plugin/config.public'), - require.resolve('../test/licensing_plugin/config.legacy'), + require.resolve('../test/plugin_functional/config.ts'), + require.resolve('../test/kerberos_api_integration/config.ts'), + require.resolve('../test/kerberos_api_integration/anonymous_access.config.ts'), + require.resolve('../test/saml_api_integration/config.ts'), + require.resolve('../test/token_api_integration/config.js'), + require.resolve('../test/oidc_api_integration/config.ts'), + require.resolve('../test/oidc_api_integration/implicit_flow.config.ts'), + require.resolve('../test/pki_api_integration/config.ts'), + require.resolve('../test/spaces_api_integration/spaces_only/config.ts'), + require.resolve('../test/spaces_api_integration/security_and_spaces/config_trial.ts'), + require.resolve('../test/spaces_api_integration/security_and_spaces/config_basic.ts'), + require.resolve('../test/saved_object_api_integration/security_and_spaces/config_trial.ts'), + require.resolve('../test/saved_object_api_integration/security_and_spaces/config_basic.ts'), + require.resolve('../test/saved_object_api_integration/security_only/config_trial.ts'), + require.resolve('../test/saved_object_api_integration/security_only/config_basic.ts'), + require.resolve('../test/saved_object_api_integration/spaces_only/config.ts'), + require.resolve('../test/ui_capabilities/security_and_spaces/config.ts'), + require.resolve('../test/ui_capabilities/security_only/config.ts'), + require.resolve('../test/ui_capabilities/spaces_only/config.ts'), + require.resolve('../test/upgrade_assistant_integration/config.js'), + require.resolve('../test/licensing_plugin/config.ts'), + require.resolve('../test/licensing_plugin/config.public.ts'), + require.resolve('../test/licensing_plugin/config.legacy.ts'), +]; + +require('@kbn/plugin-helpers').babelRegister(); +require('@kbn/test').runTestsCli([ + ...alwaysImportedTests, + ...(!!process.env.CODE_COVERAGE ? [] : onlyNotInCoverageTests), ]); From 303e4842ea14f9316bfa28d3029e2abcc92acb6d Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 8 Jan 2020 14:28:29 -0700 Subject: [PATCH 15/23] [SIEM] [Case] Case workflow api schema (#51535) --- .../lib/case/saved_object_mappings_temp.ts | 91 +++++++++ x-pack/plugins/case/README.md | 9 + x-pack/plugins/case/kibana.json | 9 + x-pack/plugins/case/server/config.ts | 15 ++ x-pack/plugins/case/server/constants.ts | 8 + x-pack/plugins/case/server/index.ts | 14 ++ x-pack/plugins/case/server/plugin.ts | 63 ++++++ .../routes/api/__fixtures__/authc_mock.ts | 35 ++++ .../__fixtures__/create_mock_so_repository.ts | 77 +++++++ .../server/routes/api/__fixtures__/index.ts | 11 + .../routes/api/__fixtures__/mock_router.ts | 34 ++++ .../api/__fixtures__/mock_saved_objects.ts | 143 +++++++++++++ .../routes/api/__fixtures__/route_contexts.ts | 17 ++ .../routes/api/__tests__/delete_case.test.ts | 83 ++++++++ .../api/__tests__/delete_comment.test.ts | 53 +++++ .../api/__tests__/get_all_cases.test.ts | 34 ++++ .../routes/api/__tests__/get_case.test.ts | 101 +++++++++ .../routes/api/__tests__/get_comment.test.ts | 51 +++++ .../routes/api/__tests__/post_case.test.ts | 82 ++++++++ .../routes/api/__tests__/post_comment.test.ts | 97 +++++++++ .../routes/api/__tests__/update_case.test.ts | 59 ++++++ .../api/__tests__/update_comment.test.ts | 59 ++++++ .../case/server/routes/api/delete_case.ts | 56 +++++ .../case/server/routes/api/delete_comment.ts | 34 ++++ .../routes/api/get_all_case_comments.ts | 33 +++ .../case/server/routes/api/get_all_cases.ts | 27 +++ .../case/server/routes/api/get_case.ts | 49 +++++ .../case/server/routes/api/get_comment.ts | 33 +++ .../plugins/case/server/routes/api/index.ts | 36 ++++ .../case/server/routes/api/post_case.ts | 40 ++++ .../case/server/routes/api/post_comment.ts | 62 ++++++ .../plugins/case/server/routes/api/schema.ts | 44 ++++ .../plugins/case/server/routes/api/types.ts | 36 ++++ .../case/server/routes/api/update_case.ts | 36 ++++ .../case/server/routes/api/update_comment.ts | 36 ++++ .../plugins/case/server/routes/api/utils.ts | 48 +++++ x-pack/plugins/case/server/services/index.ts | 192 ++++++++++++++++++ x-pack/plugins/security/server/index.ts | 2 + 38 files changed, 1909 insertions(+) create mode 100644 x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts create mode 100644 x-pack/plugins/case/README.md create mode 100644 x-pack/plugins/case/kibana.json create mode 100644 x-pack/plugins/case/server/config.ts create mode 100644 x-pack/plugins/case/server/constants.ts create mode 100644 x-pack/plugins/case/server/index.ts create mode 100644 x-pack/plugins/case/server/plugin.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/index.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/delete_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/delete_comment.ts create mode 100644 x-pack/plugins/case/server/routes/api/get_all_case_comments.ts create mode 100644 x-pack/plugins/case/server/routes/api/get_all_cases.ts create mode 100644 x-pack/plugins/case/server/routes/api/get_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/get_comment.ts create mode 100644 x-pack/plugins/case/server/routes/api/index.ts create mode 100644 x-pack/plugins/case/server/routes/api/post_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/post_comment.ts create mode 100644 x-pack/plugins/case/server/routes/api/schema.ts create mode 100644 x-pack/plugins/case/server/routes/api/types.ts create mode 100644 x-pack/plugins/case/server/routes/api/update_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/update_comment.ts create mode 100644 x-pack/plugins/case/server/routes/api/utils.ts create mode 100644 x-pack/plugins/case/server/services/index.ts diff --git a/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts b/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts new file mode 100644 index 0000000000000..bd73805600a33 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts @@ -0,0 +1,91 @@ +/* + * 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. + */ +/* eslint-disable @typescript-eslint/no-empty-interface */ +/* eslint-disable @typescript-eslint/camelcase */ +import { + NewCaseFormatted, + NewCommentFormatted, +} from '../../../../../../../x-pack/plugins/case/server'; +import { ElasticsearchMappingOf } from '../../utils/typed_elasticsearch_mappings'; + +// Temporary file to write mappings for case +// while Saved Object Mappings API is programmed for the NP +// See: https://github.com/elastic/kibana/issues/50309 + +export const caseSavedObjectType = 'case-workflow'; +export const caseCommentSavedObjectType = 'case-workflow-comment'; + +export const caseSavedObjectMappings: { + [caseSavedObjectType]: ElasticsearchMappingOf; +} = { + [caseSavedObjectType]: { + properties: { + assignees: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + created_at: { + type: 'date', + }, + description: { + type: 'text', + }, + title: { + type: 'keyword', + }, + created_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + state: { + type: 'keyword', + }, + tags: { + type: 'keyword', + }, + case_type: { + type: 'keyword', + }, + }, + }, +}; + +export const caseCommentSavedObjectMappings: { + [caseCommentSavedObjectType]: ElasticsearchMappingOf; +} = { + [caseCommentSavedObjectType]: { + properties: { + comment: { + type: 'text', + }, + created_at: { + type: 'date', + }, + created_by: { + properties: { + full_name: { + type: 'keyword', + }, + username: { + type: 'keyword', + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/case/README.md b/x-pack/plugins/case/README.md new file mode 100644 index 0000000000000..c0acb87835207 --- /dev/null +++ b/x-pack/plugins/case/README.md @@ -0,0 +1,9 @@ +# Case Workflow + +*Experimental Feature* + +Elastic is developing a Case Management Workflow. Follow our progress: + +- [Case API Documentation](https://documenter.getpostman.com/view/172706/SW7c2SuF?version=latest) +- [Github Meta](https://github.com/elastic/kibana/issues/50103) + diff --git a/x-pack/plugins/case/kibana.json b/x-pack/plugins/case/kibana.json new file mode 100644 index 0000000000000..23e3cc789ad3b --- /dev/null +++ b/x-pack/plugins/case/kibana.json @@ -0,0 +1,9 @@ +{ + "configPath": ["xpack", "case"], + "id": "case", + "kibanaVersion": "kibana", + "requiredPlugins": ["security"], + "server": true, + "ui": false, + "version": "8.0.0" +} diff --git a/x-pack/plugins/case/server/config.ts b/x-pack/plugins/case/server/config.ts new file mode 100644 index 0000000000000..a7cb117198f9b --- /dev/null +++ b/x-pack/plugins/case/server/config.ts @@ -0,0 +1,15 @@ +/* + * 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, TypeOf } from '@kbn/config-schema'; + +export const ConfigSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), + indexPattern: schema.string({ defaultValue: '.case-test-2' }), + secret: schema.string({ defaultValue: 'Cool secret huh?' }), +}); + +export type ConfigType = TypeOf; diff --git a/x-pack/plugins/case/server/constants.ts b/x-pack/plugins/case/server/constants.ts new file mode 100644 index 0000000000000..276dcd135254a --- /dev/null +++ b/x-pack/plugins/case/server/constants.ts @@ -0,0 +1,8 @@ +/* + * 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 CASE_SAVED_OBJECT = 'case-workflow'; +export const CASE_COMMENT_SAVED_OBJECT = 'case-workflow-comment'; diff --git a/x-pack/plugins/case/server/index.ts b/x-pack/plugins/case/server/index.ts new file mode 100644 index 0000000000000..3963debea9795 --- /dev/null +++ b/x-pack/plugins/case/server/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. + */ + +import { PluginInitializerContext } from '../../../../src/core/server'; +import { ConfigSchema } from './config'; +import { CasePlugin } from './plugin'; +export { NewCaseFormatted, NewCommentFormatted } from './routes/api/types'; + +export const config = { schema: ConfigSchema }; +export const plugin = (initializerContext: PluginInitializerContext) => + new CasePlugin(initializerContext); diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts new file mode 100644 index 0000000000000..c52461cade058 --- /dev/null +++ b/x-pack/plugins/case/server/plugin.ts @@ -0,0 +1,63 @@ +/* + * 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 { first, map } from 'rxjs/operators'; +import { CoreSetup, Logger, PluginInitializerContext } from 'kibana/server'; +import { ConfigType } from './config'; +import { initCaseApi } from './routes/api'; +import { CaseService } from './services'; +import { PluginSetupContract as SecurityPluginSetup } from '../../security/server'; + +function createConfig$(context: PluginInitializerContext) { + return context.config.create().pipe(map(config => config)); +} + +export interface PluginsSetup { + security: SecurityPluginSetup; +} + +export class CasePlugin { + private readonly log: Logger; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.log = this.initializerContext.logger.get(); + } + + public async setup(core: CoreSetup, plugins: PluginsSetup) { + const config = await createConfig$(this.initializerContext) + .pipe(first()) + .toPromise(); + + if (!config.enabled) { + return; + } + const service = new CaseService(this.log); + + this.log.debug( + `Setting up Case Workflow with core contract [${Object.keys( + core + )}] and plugins [${Object.keys(plugins)}]` + ); + + const caseService = await service.setup({ + authentication: plugins.security.authc, + }); + + const router = core.http.createRouter(); + initCaseApi({ + caseService, + router, + }); + } + + public start() { + this.log.debug(`Starting Case Workflow`); + } + + public stop() { + this.log.debug(`Stopping Case Workflow`); + } +} 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 new file mode 100644 index 0000000000000..94ce9627b9ac6 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts @@ -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 { Authentication } from '../../../../../security/server'; + +const getCurrentUser = jest.fn().mockReturnValue({ + username: 'awesome', + full_name: 'Awesome D00d', +}); +const getCurrentUserThrow = jest.fn().mockImplementation(() => { + throw new Error('Bad User - the user is not authenticated'); +}); + +export const authenticationMock = { + create: (): jest.Mocked => ({ + login: jest.fn(), + createAPIKey: jest.fn(), + getCurrentUser, + invalidateAPIKey: jest.fn(), + isAuthenticated: jest.fn(), + logout: jest.fn(), + getSessionInfo: jest.fn(), + }), + createInvalid: (): jest.Mocked => ({ + login: jest.fn(), + createAPIKey: jest.fn(), + getCurrentUser: getCurrentUserThrow, + invalidateAPIKey: jest.fn(), + isAuthenticated: jest.fn(), + logout: jest.fn(), + getSessionInfo: jest.fn(), + }), +}; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts new file mode 100644 index 0000000000000..360c6de67b2a8 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -0,0 +1,77 @@ +/* + * 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, SavedObjectsErrorHelpers } from 'src/core/server'; +import { CASE_COMMENT_SAVED_OBJECT } from '../../../constants'; + +export const createMockSavedObjectsRepository = (savedObject: any[] = []) => { + const mockSavedObjectsClientContract = ({ + get: jest.fn((type, id) => { + const result = savedObject.filter(s => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return result[0]; + }), + find: jest.fn(findArgs => { + if (findArgs.hasReference && findArgs.hasReference.id === 'bad-guy') { + throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); + } + return { + total: savedObject.length, + saved_objects: savedObject, + }; + }), + create: jest.fn((type, attributes, references) => { + if (attributes.description === 'Throw an error' || attributes.comment === 'Throw an error') { + throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); + } + if (type === CASE_COMMENT_SAVED_OBJECT) { + return { + type, + id: 'mock-comment', + attributes, + ...references, + updated_at: '2019-12-02T22:48:08.327Z', + version: 'WzksMV0=', + }; + } + return { + type, + id: 'mock-it', + attributes, + references: [], + updated_at: '2019-12-02T22:48:08.327Z', + version: 'WzksMV0=', + }; + }), + update: jest.fn((type, id, attributes) => { + if (!savedObject.find(s => s.id === id)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return { + id, + type, + updated_at: '2019-11-22T22:50:55.191Z', + version: 'WzE3LDFd', + attributes, + }; + }), + delete: jest.fn((type: string, id: string) => { + const result = savedObject.filter(s => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + if (type === 'case-workflow-comment' && id === 'bad-guy') { + throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); + } + return {}; + }), + deleteByNamespace: jest.fn(), + } as unknown) as jest.Mocked; + + return mockSavedObjectsClientContract; +}; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts new file mode 100644 index 0000000000000..e1fec2d6b229c --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/index.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. + */ + +export { mockCases, mockCasesErrorTriggerData, mockCaseComments } from './mock_saved_objects'; +export { createMockSavedObjectsRepository } from './create_mock_so_repository'; +export { createRouteContext } from './route_contexts'; +export { authenticationMock } from './authc_mock'; +export { createRoute } from './mock_router'; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts new file mode 100644 index 0000000000000..84889c3ac49be --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'kibana/server'; +import { loggingServiceMock, httpServiceMock } from '../../../../../../../src/core/server/mocks'; +import { CaseService } from '../../../services'; +import { authenticationMock } from '../__fixtures__'; +import { RouteDeps } from '../index'; + +export const createRoute = async ( + api: (deps: RouteDeps) => void, + method: 'get' | 'post' | 'delete', + badAuth = false +) => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + + const log = loggingServiceMock.create().get('case'); + + const service = new CaseService(log); + const caseService = await service.setup({ + authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), + }); + + api({ + router, + caseService, + }); + + return router[method].mock.calls[0][1]; +}; 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 new file mode 100644 index 0000000000000..d59f0977e6993 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -0,0 +1,143 @@ +/* + * 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 mockCases = [ + { + type: 'case-workflow', + id: 'mock-id-1', + attributes: { + created_at: 1574718888885, + created_by: { + full_name: null, + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['defacement'], + case_type: 'security', + assignees: [], + }, + references: [], + updated_at: '2019-11-25T21:54:48.952Z', + version: 'WzAsMV0=', + }, + { + type: 'case-workflow', + id: 'mock-id-2', + attributes: { + created_at: 1574721120834, + created_by: { + full_name: null, + username: 'elastic', + }, + description: 'Oh no, a bad meanie destroying data!', + title: 'Damaging Data Destruction Detected', + state: 'open', + tags: ['Data Destruction'], + case_type: 'security', + assignees: [], + }, + references: [], + updated_at: '2019-11-25T22:32:00.900Z', + version: 'WzQsMV0=', + }, + { + type: 'case-workflow', + id: 'mock-id-3', + attributes: { + created_at: 1574721137881, + created_by: { + full_name: null, + username: 'elastic', + }, + description: 'Oh no, a bad meanie going LOLBins all over the place!', + title: 'Another bad one', + state: 'open', + tags: ['LOLBins'], + case_type: 'security', + assignees: [], + }, + references: [], + updated_at: '2019-11-25T22:32:17.947Z', + version: 'WzUsMV0=', + }, +]; + +export const mockCasesErrorTriggerData = [ + { + id: 'valid-id', + }, + { + id: 'bad-guy', + }, +]; + +export const mockCaseComments = [ + { + type: 'case-workflow-comment', + id: 'mock-comment-1', + attributes: { + comment: 'Wow, good luck catching that bad meanie!', + created_at: 1574718900112, + created_by: { + full_name: null, + username: 'elastic', + }, + }, + references: [ + { + type: 'case-workflow', + name: 'associated-case-workflow', + id: 'mock-id-1', + }, + ], + updated_at: '2019-11-25T21:55:00.177Z', + version: 'WzEsMV0=', + }, + { + type: 'case-workflow-comment', + id: 'mock-comment-2', + attributes: { + comment: 'Well I decided to update my comment. So what? Deal with it.', + created_at: 1574718902724, + created_by: { + full_name: null, + username: 'elastic', + }, + }, + references: [ + { + type: 'case-workflow', + name: 'associated-case-workflow', + id: 'mock-id-1', + }, + ], + updated_at: '2019-11-25T21:55:14.633Z', + version: 'WzMsMV0=', + }, + { + type: 'case-workflow-comment', + id: 'mock-comment-3', + attributes: { + comment: 'Wow, good luck catching that bad meanie!', + created_at: 1574721150542, + created_by: { + full_name: null, + username: 'elastic', + }, + }, + references: [ + { + type: 'case-workflow', + name: 'associated-case-workflow', + id: 'mock-id-3', + }, + ], + updated_at: '2019-11-25T22:32:30.608Z', + version: 'WzYsMV0=', + }, +]; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts new file mode 100644 index 0000000000000..b1881e394e796 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -0,0 +1,17 @@ +/* + * 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 { RequestHandlerContext } from 'src/core/server'; + +export const createRouteContext = (client: any) => { + return ({ + core: { + savedObjects: { + client, + }, + }, + } as unknown) as RequestHandlerContext; +}; diff --git a/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts new file mode 100644 index 0000000000000..9ea42ba42406b --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.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 { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, + mockCasesErrorTriggerData, +} from '../__fixtures__'; +import { initDeleteCaseApi } from '../delete_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('DELETE case', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initDeleteCaseApi, 'delete'); + }); + it(`deletes the case. responds with 204`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'mock-id-1', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(204); + }); + it(`returns an error when thrown from deleteCase service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'not-real', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + }); + it(`returns an error when thrown from getAllCaseComments service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'bad-guy', + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + }); + it(`returns an error when thrown from deleteComment service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'valid-id', + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts new file mode 100644 index 0000000000000..e50b3cbaa9c9a --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts @@ -0,0 +1,53 @@ +/* + * 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 { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, + mockCasesErrorTriggerData, +} from '../__fixtures__'; +import { initDeleteCommentApi } from '../delete_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('DELETE comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initDeleteCommentApi, 'delete'); + }); + it(`deletes the comment. responds with 204`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{comment_id}', + method: 'delete', + params: { + comment_id: 'mock-id-1', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(204); + }); + it(`returns an error when thrown from deleteComment service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{comment_id}', + method: 'delete', + params: { + comment_id: 'bad-guy', + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts new file mode 100644 index 0000000000000..2f8a229c08f29 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initGetAllCasesApi } from '../get_all_cases'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('GET all cases', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initGetAllCasesApi, 'get'); + }); + it(`returns the case without case comments when includeComments is false`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'get', + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.saved_objects).toHaveLength(3); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts new file mode 100644 index 0000000000000..3c5f8e52d1946 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts @@ -0,0 +1,101 @@ +/* + * 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 { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, + mockCasesErrorTriggerData, +} from '../__fixtures__'; +import { initGetCaseApi } from '../get_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('GET case', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initGetCaseApi, 'get'); + }); + it(`returns the case without case comments when includeComments is false`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'mock-id-1', + }, + method: 'get', + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload).toEqual(mockCases.find(s => s.id === 'mock-id-1')); + expect(response.payload.comments).toBeUndefined(); + }); + it(`returns an error when thrown from getCase`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'abcdefg', + }, + method: 'get', + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); + it(`returns the case with case comments when includeComments is true`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'mock-id-1', + }, + method: 'get', + query: { + includeComments: true, + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload.comments.saved_objects).toHaveLength(3); + }); + it(`returns an error when thrown from getAllCaseComments`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'bad-guy', + }, + method: 'get', + query: { + includeComments: true, + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(400); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts new file mode 100644 index 0000000000000..9b6a1e435838b --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/get_comment.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 { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCaseComments, +} from '../__fixtures__'; +import { initGetCommentApi } from '../get_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('GET comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initGetCommentApi, 'get'); + }); + it(`returns the comment`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{id}', + method: 'get', + params: { + id: 'mock-comment-1', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload).toEqual(mockCaseComments.find(s => s.id === 'mock-comment-1')); + }); + it(`returns an error when getComment throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{id}', + method: 'get', + params: { + id: 'not-real', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts new file mode 100644 index 0000000000000..bb688dde4c58f --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initPostCaseApi } from '../post_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('POST cases', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initPostCaseApi, 'post'); + }); + it(`Posts a new case`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'post', + body: { + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['defacement'], + case_type: 'security', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-it'); + expect(response.payload.attributes.created_by.username).toEqual('awesome'); + }); + it(`Returns an error if postNewCase throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'post', + body: { + description: 'Throw an error', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['error'], + case_type: 'security', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + }); + it(`Returns an error if user authentication throws`, async () => { + routeHandler = await createRoute(initPostCaseApi, 'post', true); + + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'post', + body: { + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['defacement'], + case_type: 'security', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(500); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts new file mode 100644 index 0000000000000..0c059b7f15ea4 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/post_comment.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 { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initPostCommentApi } from '../post_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('POST comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initPostCommentApi, 'post'); + }); + it(`Posts a new comment`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + comment: 'Wow, good luck catching that bad meanie!', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-comment'); + expect(response.payload.references[0].id).toEqual('mock-id-1'); + }); + it(`Returns an error if the case does not exist`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'this-is-not-real', + }, + body: { + comment: 'Wow, good luck catching that bad meanie!', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); + it(`Returns an error if postNewCase throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + comment: 'Throw an error', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + }); + it(`Returns an error if user authentication throws`, async () => { + routeHandler = await createRoute(initPostCommentApi, 'post', true); + + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + comment: 'Wow, good luck catching that bad meanie!', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(500); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts new file mode 100644 index 0000000000000..7ed478d2e7c01 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts @@ -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 { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initUpdateCaseApi } from '../update_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('UPDATE case', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initUpdateCaseApi, 'post'); + }); + it(`Updates a case`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + state: 'closed', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-id-1'); + expect(response.payload.attributes.state).toEqual('closed'); + }); + it(`Returns an error if updateCase throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'post', + params: { + id: 'mock-id-does-not-exist', + }, + body: { + state: 'closed', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts new file mode 100644 index 0000000000000..8aa84b45b7dbb --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts @@ -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 { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCaseComments, +} from '../__fixtures__'; +import { initUpdateCommentApi } from '../update_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('UPDATE comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initUpdateCommentApi, 'post'); + }); + it(`Updates a comment`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comment/{id}', + method: 'post', + params: { + id: 'mock-comment-1', + }, + body: { + comment: 'Update my comment', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-comment-1'); + expect(response.payload.attributes.comment).toEqual('Update my comment'); + }); + it(`Returns an error if updateComment throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comment/{id}', + method: 'post', + params: { + id: 'mock-comment-does-not-exist', + }, + body: { + comment: 'Update my comment', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/delete_case.ts b/x-pack/plugins/case/server/routes/api/delete_case.ts new file mode 100644 index 0000000000000..a5ae72b8b46ff --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/delete_case.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 { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initDeleteCaseApi({ caseService, router }: RouteDeps) { + router.delete( + { + path: '/api/cases/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + let allCaseComments; + try { + await caseService.deleteCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + allCaseComments = await caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + if (allCaseComments.saved_objects.length > 0) { + await Promise.all( + allCaseComments.saved_objects.map(({ id }) => + caseService.deleteComment({ + client: context.core.savedObjects.client, + commentId: id, + }) + ) + ); + } + return response.noContent(); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/delete_comment.ts b/x-pack/plugins/case/server/routes/api/delete_comment.ts new file mode 100644 index 0000000000000..4a540dd9fd69f --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/delete_comment.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initDeleteCommentApi({ caseService, router }: RouteDeps) { + router.delete( + { + path: '/api/cases/comments/{comment_id}', + validate: { + params: schema.object({ + comment_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = context.core.savedObjects.client; + try { + await caseService.deleteComment({ + client, + commentId: request.params.comment_id, + }); + return response.noContent(); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts b/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts new file mode 100644 index 0000000000000..cc4956ead1bd7 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts @@ -0,0 +1,33 @@ +/* + * 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 { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetAllCaseCommentsApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{id}/comments', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const theComments = await caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + return response.ok({ body: theComments }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_all_cases.ts b/x-pack/plugins/case/server/routes/api/get_all_cases.ts new file mode 100644 index 0000000000000..749a183dfe980 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_all_cases.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 { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetAllCasesApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases', + validate: false, + }, + async (context, request, response) => { + try { + const cases = await caseService.getAllCases({ + client: context.core.savedObjects.client, + }); + return response.ok({ body: cases }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_case.ts b/x-pack/plugins/case/server/routes/api/get_case.ts new file mode 100644 index 0000000000000..6aad22a1ebf1b --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_case.ts @@ -0,0 +1,49 @@ +/* + * 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 { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetCaseApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + query: schema.object({ + includeComments: schema.string({ defaultValue: 'true' }), + }), + }, + }, + async (context, request, response) => { + let theCase; + const includeComments = JSON.parse(request.query.includeComments); + try { + theCase = await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + if (!includeComments) { + return response.ok({ body: theCase }); + } + try { + const theComments = await caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + return response.ok({ body: { ...theCase, comments: theComments } }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_comment.ts b/x-pack/plugins/case/server/routes/api/get_comment.ts new file mode 100644 index 0000000000000..6fd507d89738d --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_comment.ts @@ -0,0 +1,33 @@ +/* + * 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 { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetCommentApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/comments/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const theComment = await caseService.getComment({ + client: context.core.savedObjects.client, + commentId: request.params.id, + }); + return response.ok({ body: theComment }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts new file mode 100644 index 0000000000000..11ef91d539e87 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -0,0 +1,36 @@ +/* + * 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 { IRouter } from 'src/core/server'; +import { initDeleteCommentApi } from './delete_comment'; +import { initDeleteCaseApi } from './delete_case'; +import { initGetAllCaseCommentsApi } from './get_all_case_comments'; +import { initGetAllCasesApi } from './get_all_cases'; +import { initGetCaseApi } from './get_case'; +import { initGetCommentApi } from './get_comment'; +import { initPostCaseApi } from './post_case'; +import { initPostCommentApi } from './post_comment'; +import { initUpdateCaseApi } from './update_case'; +import { initUpdateCommentApi } from './update_comment'; +import { CaseServiceSetup } from '../../services'; + +export interface RouteDeps { + caseService: CaseServiceSetup; + router: IRouter; +} + +export function initCaseApi(deps: RouteDeps) { + initGetAllCaseCommentsApi(deps); + initGetAllCasesApi(deps); + initGetCaseApi(deps); + initGetCommentApi(deps); + initDeleteCaseApi(deps); + initDeleteCommentApi(deps); + initPostCaseApi(deps); + initPostCommentApi(deps); + initUpdateCaseApi(deps); + initUpdateCommentApi(deps); +} diff --git a/x-pack/plugins/case/server/routes/api/post_case.ts b/x-pack/plugins/case/server/routes/api/post_case.ts new file mode 100644 index 0000000000000..e5aa0a3548b48 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/post_case.ts @@ -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 { formatNewCase, wrapError } from './utils'; +import { NewCaseSchema } from './schema'; +import { RouteDeps } from '.'; + +export function initPostCaseApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases', + validate: { + body: NewCaseSchema, + }, + }, + async (context, request, response) => { + let createdBy; + try { + createdBy = await caseService.getUser({ request, response }); + } catch (error) { + return response.customError(wrapError(error)); + } + + try { + const newCase = await caseService.postNewCase({ + client: context.core.savedObjects.client, + attributes: formatNewCase(request.body, { + ...createdBy, + }), + }); + return response.ok({ body: newCase }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/post_comment.ts b/x-pack/plugins/case/server/routes/api/post_comment.ts new file mode 100644 index 0000000000000..3f4592f5bb11f --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/post_comment.ts @@ -0,0 +1,62 @@ +/* + * 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 { formatNewComment, wrapError } from './utils'; +import { NewCommentSchema } from './schema'; +import { RouteDeps } from '.'; +import { CASE_SAVED_OBJECT } from '../../constants'; + +export function initPostCommentApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases/{id}/comment', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: NewCommentSchema, + }, + }, + async (context, request, response) => { + let createdBy; + let newComment; + try { + await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + createdBy = await caseService.getUser({ request, response }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + newComment = await caseService.postNewComment({ + client: context.core.savedObjects.client, + attributes: formatNewComment({ + newComment: request.body, + ...createdBy, + }), + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: request.params.id, + }, + ], + }); + + return response.ok({ body: newComment }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/schema.ts b/x-pack/plugins/case/server/routes/api/schema.ts new file mode 100644 index 0000000000000..4a4a0c3a11e36 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/schema.ts @@ -0,0 +1,44 @@ +/* + * 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'; + +export const UserSchema = schema.object({ + username: schema.string(), + full_name: schema.maybe(schema.string()), +}); + +export const NewCommentSchema = schema.object({ + comment: schema.string(), +}); + +export const CommentSchema = schema.object({ + comment: schema.string(), + created_at: schema.number(), + created_by: UserSchema, +}); + +export const UpdatedCommentSchema = schema.object({ + comment: schema.string(), +}); + +export const NewCaseSchema = schema.object({ + assignees: schema.arrayOf(UserSchema, { defaultValue: [] }), + description: schema.string(), + title: schema.string(), + state: schema.oneOf([schema.literal('open'), schema.literal('closed')], { defaultValue: 'open' }), + tags: schema.arrayOf(schema.string(), { defaultValue: [] }), + case_type: schema.string(), +}); + +export const UpdatedCaseSchema = schema.object({ + assignees: schema.maybe(schema.arrayOf(UserSchema)), + description: schema.maybe(schema.string()), + title: schema.maybe(schema.string()), + state: schema.maybe(schema.oneOf([schema.literal('open'), schema.literal('closed')])), + tags: schema.maybe(schema.arrayOf(schema.string())), + case_type: schema.maybe(schema.string()), +}); diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts new file mode 100644 index 0000000000000..d943e4e5fd7dd --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -0,0 +1,36 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import { + CommentSchema, + NewCaseSchema, + NewCommentSchema, + UpdatedCaseSchema, + UpdatedCommentSchema, + UserSchema, +} from './schema'; + +export type NewCaseType = TypeOf; +export type NewCommentFormatted = TypeOf; +export type NewCommentType = TypeOf; +export type UpdatedCaseTyped = TypeOf; +export type UpdatedCommentType = TypeOf; +export type UserType = TypeOf; + +export interface NewCaseFormatted extends NewCaseType { + created_at: number; + created_by: UserType; +} + +export interface UpdatedCaseType { + assignees?: UpdatedCaseTyped['assignees']; + description?: UpdatedCaseTyped['description']; + title?: UpdatedCaseTyped['title']; + state?: UpdatedCaseTyped['state']; + tags?: UpdatedCaseTyped['tags']; + case_type?: UpdatedCaseTyped['case_type']; +} diff --git a/x-pack/plugins/case/server/routes/api/update_case.ts b/x-pack/plugins/case/server/routes/api/update_case.ts new file mode 100644 index 0000000000000..52c8cab0022dd --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/update_case.ts @@ -0,0 +1,36 @@ +/* + * 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 { wrapError } from './utils'; +import { RouteDeps } from '.'; +import { UpdatedCaseSchema } from './schema'; + +export function initUpdateCaseApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: UpdatedCaseSchema, + }, + }, + async (context, request, response) => { + try { + const updatedCase = await caseService.updateCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + updatedAttributes: request.body, + }); + return response.ok({ body: updatedCase }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/update_comment.ts b/x-pack/plugins/case/server/routes/api/update_comment.ts new file mode 100644 index 0000000000000..e1ee6029e8e4f --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/update_comment.ts @@ -0,0 +1,36 @@ +/* + * 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 { wrapError } from './utils'; +import { NewCommentSchema } from './schema'; +import { RouteDeps } from '.'; + +export function initUpdateCommentApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases/comment/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: NewCommentSchema, + }, + }, + async (context, request, response) => { + try { + const updatedComment = await caseService.updateComment({ + client: context.core.savedObjects.client, + commentId: request.params.id, + updatedAttributes: request.body, + }); + return response.ok({ body: updatedComment }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts new file mode 100644 index 0000000000000..c6e33dbb8433b --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -0,0 +1,48 @@ +/* + * 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 { boomify, isBoom } from 'boom'; +import { CustomHttpResponseOptions, ResponseError } from 'kibana/server'; +import { + NewCaseType, + NewCaseFormatted, + NewCommentType, + NewCommentFormatted, + UserType, +} from './types'; + +export const formatNewCase = ( + newCase: NewCaseType, + { full_name, username }: { full_name?: string; username: string } +): NewCaseFormatted => ({ + created_at: new Date().valueOf(), + created_by: { full_name, username }, + ...newCase, +}); + +interface NewCommentArgs { + newComment: NewCommentType; + full_name?: UserType['full_name']; + username: UserType['username']; +} +export const formatNewComment = ({ + newComment, + full_name, + username, +}: NewCommentArgs): NewCommentFormatted => ({ + ...newComment, + created_at: new Date().valueOf(), + created_by: { full_name, username }, +}); + +export function wrapError(error: any): CustomHttpResponseOptions { + const boom = isBoom(error) ? error : boomify(error); + return { + body: boom, + headers: boom.output.headers, + statusCode: boom.output.statusCode, + }; +} diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts new file mode 100644 index 0000000000000..684d905a5c71f --- /dev/null +++ b/x-pack/plugins/case/server/services/index.ts @@ -0,0 +1,192 @@ +/* + * 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 { + KibanaRequest, + KibanaResponseFactory, + Logger, + SavedObject, + SavedObjectsClientContract, + SavedObjectsFindResponse, + SavedObjectsUpdateResponse, + SavedObjectReference, +} from 'kibana/server'; +import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../constants'; +import { + NewCaseFormatted, + NewCommentFormatted, + UpdatedCaseType, + UpdatedCommentType, +} from '../routes/api/types'; +import { + AuthenticatedUser, + PluginSetupContract as SecurityPluginSetup, +} from '../../../security/server'; + +interface ClientArgs { + client: SavedObjectsClientContract; +} + +interface GetCaseArgs extends ClientArgs { + caseId: string; +} +interface GetCommentArgs extends ClientArgs { + commentId: string; +} +interface PostCaseArgs extends ClientArgs { + attributes: NewCaseFormatted; +} + +interface PostCommentArgs extends ClientArgs { + attributes: NewCommentFormatted; + references: SavedObjectReference[]; +} +interface UpdateCaseArgs extends ClientArgs { + caseId: string; + updatedAttributes: UpdatedCaseType; +} +interface UpdateCommentArgs extends ClientArgs { + commentId: string; + updatedAttributes: UpdatedCommentType; +} + +interface GetUserArgs { + request: KibanaRequest; + response: KibanaResponseFactory; +} + +interface CaseServiceDeps { + authentication: SecurityPluginSetup['authc']; +} +export interface CaseServiceSetup { + deleteCase(args: GetCaseArgs): Promise<{}>; + deleteComment(args: GetCommentArgs): Promise<{}>; + getAllCases(args: ClientArgs): Promise; + getAllCaseComments(args: GetCaseArgs): Promise; + getCase(args: GetCaseArgs): Promise; + getComment(args: GetCommentArgs): Promise; + getUser(args: GetUserArgs): Promise; + postNewCase(args: PostCaseArgs): Promise; + postNewComment(args: PostCommentArgs): Promise; + updateCase(args: UpdateCaseArgs): Promise; + updateComment(args: UpdateCommentArgs): Promise; +} + +export class CaseService { + constructor(private readonly log: Logger) {} + public setup = async ({ authentication }: CaseServiceDeps): Promise => ({ + deleteCase: async ({ client, caseId }: GetCaseArgs) => { + try { + this.log.debug(`Attempting to GET case ${caseId}`); + return await client.delete(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.debug(`Error on GET case ${caseId}: ${error}`); + throw error; + } + }, + deleteComment: async ({ client, commentId }: GetCommentArgs) => { + try { + this.log.debug(`Attempting to GET comment ${commentId}`); + return await client.delete(CASE_COMMENT_SAVED_OBJECT, commentId); + } catch (error) { + this.log.debug(`Error on GET comment ${commentId}: ${error}`); + throw error; + } + }, + getCase: async ({ client, caseId }: GetCaseArgs) => { + try { + this.log.debug(`Attempting to GET case ${caseId}`); + return await client.get(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.debug(`Error on GET case ${caseId}: ${error}`); + throw error; + } + }, + getComment: async ({ client, commentId }: GetCommentArgs) => { + try { + this.log.debug(`Attempting to GET comment ${commentId}`); + return await client.get(CASE_COMMENT_SAVED_OBJECT, commentId); + } catch (error) { + this.log.debug(`Error on GET comment ${commentId}: ${error}`); + throw error; + } + }, + getAllCases: async ({ client }: ClientArgs) => { + try { + this.log.debug(`Attempting to GET all cases`); + return await client.find({ type: CASE_SAVED_OBJECT }); + } catch (error) { + this.log.debug(`Error on GET cases: ${error}`); + throw error; + } + }, + getAllCaseComments: async ({ client, caseId }: GetCaseArgs) => { + try { + this.log.debug(`Attempting to GET all comments for case ${caseId}`); + return await client.find({ + type: CASE_COMMENT_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for case ${caseId}: ${error}`); + throw error; + } + }, + getUser: async ({ request, response }: GetUserArgs) => { + let user; + try { + this.log.debug(`Attempting to authenticate a user`); + user = await authentication!.getCurrentUser(request); + } catch (error) { + this.log.debug(`Error on GET user: ${error}`); + throw error; + } + if (!user) { + this.log.debug(`Error on GET user: Bad User`); + throw new Error('Bad User - the user is not authenticated'); + } + return user; + }, + postNewCase: async ({ client, attributes }: PostCaseArgs) => { + try { + this.log.debug(`Attempting to POST a new case`); + return await client.create(CASE_SAVED_OBJECT, { ...attributes }); + } catch (error) { + this.log.debug(`Error on POST a new case: ${error}`); + throw error; + } + }, + postNewComment: async ({ client, attributes, references }: PostCommentArgs) => { + try { + this.log.debug(`Attempting to POST a new comment`); + return await client.create(CASE_COMMENT_SAVED_OBJECT, attributes, { references }); + } catch (error) { + this.log.debug(`Error on POST a new comment: ${error}`); + throw error; + } + }, + updateCase: async ({ client, caseId, updatedAttributes }: UpdateCaseArgs) => { + try { + this.log.debug(`Attempting to UPDATE case ${caseId}`); + return await client.update(CASE_SAVED_OBJECT, caseId, { ...updatedAttributes }); + } catch (error) { + this.log.debug(`Error on UPDATE case ${caseId}: ${error}`); + throw error; + } + }, + updateComment: async ({ client, commentId, updatedAttributes }: UpdateCommentArgs) => { + try { + this.log.debug(`Attempting to UPDATE comment ${commentId}`); + return await client.update(CASE_COMMENT_SAVED_OBJECT, commentId, { + ...updatedAttributes, + }); + } catch (error) { + this.log.debug(`Error on UPDATE comment ${commentId}: ${error}`); + throw error; + } + }, + }); +} diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index 33f554be5caa3..17e49b8cf40d3 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -17,6 +17,7 @@ import { Plugin, PluginSetupContract, PluginSetupDependencies } from './plugin'; // These exports are part of public Security plugin contract, any change in signature of exported // functions or removal of exports should be considered as a breaking change. export { + Authentication, AuthenticationResult, DeauthenticationResult, CreateAPIKeyResult, @@ -24,6 +25,7 @@ export { InvalidateAPIKeyResult, } from './authentication'; export { PluginSetupContract }; +export { AuthenticatedUser } from '../common/model'; export const config: PluginConfigDescriptor> = { schema: ConfigSchema, From e1e1d964c67d77d20d0d309bf09121bb9fc8e5ac Mon Sep 17 00:00:00 2001 From: Phillip Burch Date: Wed, 8 Jan 2020 16:37:37 -0600 Subject: [PATCH 16/23] Reset region and Account when switching inventory (#54287) --- .../components/waffle/waffle_inventory_switcher.tsx | 8 +++++++- .../public/pages/infrastructure/snapshot/toolbar.tsx | 11 ++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx index bdd08ab6b366f..785531db2ff5e 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx @@ -28,6 +28,8 @@ interface WaffleInventorySwitcherProps { changeNodeType: (nodeType: InfraNodeType) => void; changeGroupBy: (groupBy: InfraSnapshotGroupbyInput[]) => void; changeMetric: (metric: InfraSnapshotMetricInput) => void; + changeAccount: (id: string) => void; + changeRegion: (name: string) => void; } const getDisplayNameForType = (type: InventoryItemType) => { @@ -39,6 +41,8 @@ export const WaffleInventorySwitcher: React.FC = ( changeNodeType, changeGroupBy, changeMetric, + changeAccount, + changeRegion, nodeType, }) => { const [isOpen, setIsOpen] = useState(false); @@ -49,12 +53,14 @@ export const WaffleInventorySwitcher: React.FC = ( closePopover(); changeNodeType(targetNodeType); changeGroupBy([]); + changeAccount(''); + changeRegion(''); const inventoryModel = findInventoryModel(targetNodeType); changeMetric({ type: inventoryModel.metrics.defaultSnapshot as InfraSnapshotMetricType, }); }, - [closePopover, changeNodeType, changeGroupBy, changeMetric] + [closePopover, changeNodeType, changeGroupBy, changeMetric, changeAccount, changeRegion] ); const goToHost = useCallback(() => goToNodeType('host' as InfraNodeType), [goToNodeType]); const goToK8 = useCallback(() => goToNodeType('pod' as InfraNodeType), [goToNodeType]); diff --git a/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx b/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx index a828cd207aa5b..a5780f44050e1 100644 --- a/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx @@ -23,12 +23,21 @@ export const SnapshotToolbar = () => ( - {({ changeMetric, changeNodeType, changeGroupBy, nodeType }) => ( + {({ + changeMetric, + changeNodeType, + changeGroupBy, + changeAccount, + changeRegion, + nodeType, + }) => ( )} From 9282f19bf54df2d2ccdfaaf526626e94f29cc45f Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Wed, 8 Jan 2020 17:43:10 -0600 Subject: [PATCH 17/23] Management - New platform api (#52579) * implement management new platform api --- .../kibana/public/management/index.js | 13 +- src/legacy/ui/public/_index.scss | 1 + src/legacy/ui/public/management/_index.scss | 1 - .../__snapshots__/sidebar_nav.test.ts.snap | 24 --- .../management/components/sidebar_nav.tsx | 107 ---------- src/legacy/ui/public/management/index.js | 2 - src/plugins/management/kibana.json | 2 +- .../management_app.test.tsx.snap | 11 + .../management/public/components/_index.scss | 1 + .../management/public/components/index.ts | 21 ++ .../components/management_chrome}/index.ts | 2 +- .../management_chrome/management_chrome.tsx | 57 +++++ .../management_sidebar_nav.test.ts.snap | 95 +++++++++ .../management_sidebar_nav}/_index.scss | 0 .../management_sidebar_nav}/_sidebar_nav.scss | 2 +- .../management_sidebar_nav/index.ts | 20 ++ .../management_sidebar_nav.test.ts} | 26 ++- .../management_sidebar_nav.tsx | 200 ++++++++++++++++++ src/plugins/management/public/index.ts | 5 +- src/plugins/management/public/legacy/index.js | 3 +- .../management/public/legacy/section.js | 8 +- .../management/public/legacy/section.test.js | 40 ++-- .../public/legacy/sections_register.js | 72 ++++--- .../management/public/management_app.test.tsx | 66 ++++++ .../management/public/management_app.tsx | 102 +++++++++ .../public/management_section.test.ts | 65 ++++++ .../management/public/management_section.ts | 78 +++++++ .../public/management_service.test.ts | 55 +++++ .../management/public/management_service.ts | 103 +++++++++ src/plugins/management/public/plugin.ts | 24 ++- src/plugins/management/public/types.ts | 76 +++++++ test/plugin_functional/config.js | 1 + .../management_test_plugin/kibana.json | 9 + .../management_test_plugin/package.json | 17 ++ .../management_test_plugin/public/index.ts | 28 +++ .../management_test_plugin/public/plugin.tsx | 73 +++++++ .../management_test_plugin/tsconfig.json | 14 ++ .../test_suites/management/index.js | 24 +++ .../management/management_plugin.js | 40 ++++ x-pack/legacy/plugins/infra/types/eui.d.ts | 2 +- .../management/management_service.test.ts | 10 + .../translations/translations/ja-JP.json | 4 +- .../translations/translations/zh-CN.json | 4 +- 43 files changed, 1296 insertions(+), 212 deletions(-) delete mode 100644 src/legacy/ui/public/management/_index.scss delete mode 100644 src/legacy/ui/public/management/components/__snapshots__/sidebar_nav.test.ts.snap delete mode 100644 src/legacy/ui/public/management/components/sidebar_nav.tsx create mode 100644 src/plugins/management/public/__snapshots__/management_app.test.tsx.snap create mode 100644 src/plugins/management/public/components/_index.scss create mode 100644 src/plugins/management/public/components/index.ts rename src/{legacy/ui/public/management/components => plugins/management/public/components/management_chrome}/index.ts (93%) create mode 100644 src/plugins/management/public/components/management_chrome/management_chrome.tsx create mode 100644 src/plugins/management/public/components/management_sidebar_nav/__snapshots__/management_sidebar_nav.test.ts.snap rename src/{legacy/ui/public/management/components => plugins/management/public/components/management_sidebar_nav}/_index.scss (100%) rename src/{legacy/ui/public/management/components => plugins/management/public/components/management_sidebar_nav}/_sidebar_nav.scss (88%) create mode 100644 src/plugins/management/public/components/management_sidebar_nav/index.ts rename src/{legacy/ui/public/management/components/sidebar_nav.test.ts => plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.test.ts} (75%) create mode 100644 src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx create mode 100644 src/plugins/management/public/management_app.test.tsx create mode 100644 src/plugins/management/public/management_app.tsx create mode 100644 src/plugins/management/public/management_section.test.ts create mode 100644 src/plugins/management/public/management_section.ts create mode 100644 src/plugins/management/public/management_service.test.ts create mode 100644 src/plugins/management/public/management_service.ts create mode 100644 test/plugin_functional/plugins/management_test_plugin/kibana.json create mode 100644 test/plugin_functional/plugins/management_test_plugin/package.json create mode 100644 test/plugin_functional/plugins/management_test_plugin/public/index.ts create mode 100644 test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx create mode 100644 test/plugin_functional/plugins/management_test_plugin/tsconfig.json create mode 100644 test/plugin_functional/test_suites/management/index.js create mode 100644 test/plugin_functional/test_suites/management/management_plugin.js diff --git a/src/legacy/core_plugins/kibana/public/management/index.js b/src/legacy/core_plugins/kibana/public/management/index.js index 5323fb2dac2d2..d62770956b88e 100644 --- a/src/legacy/core_plugins/kibana/public/management/index.js +++ b/src/legacy/core_plugins/kibana/public/management/index.js @@ -28,7 +28,8 @@ import { I18nContext } from 'ui/i18n'; import { uiModules } from 'ui/modules'; import appTemplate from './app.html'; import landingTemplate from './landing.html'; -import { management, SidebarNav, MANAGEMENT_BREADCRUMB } from 'ui/management'; +import { management, MANAGEMENT_BREADCRUMB } from 'ui/management'; +import { ManagementSidebarNav } from '../../../../../plugins/management/public'; import { FeatureCatalogueRegistryProvider, FeatureCatalogueCategory, @@ -42,6 +43,7 @@ import { EuiIcon, EuiHorizontalRule, } from '@elastic/eui'; +import { npStart } from 'ui/new_platform'; const SIDENAV_ID = 'management-sidenav'; const LANDING_ID = 'management-landing'; @@ -102,7 +104,7 @@ export function updateLandingPage(version) { ); } -export function updateSidebar(items, id) { +export function updateSidebar(legacySections, id) { const node = document.getElementById(SIDENAV_ID); if (!node) { return; @@ -110,7 +112,12 @@ export function updateSidebar(items, id) { render( - + , node ); diff --git a/src/legacy/ui/public/_index.scss b/src/legacy/ui/public/_index.scss index 98675402b43cc..747ad025ef691 100644 --- a/src/legacy/ui/public/_index.scss +++ b/src/legacy/ui/public/_index.scss @@ -20,6 +20,7 @@ @import './saved_objects/index'; @import './share/index'; @import './style_compile/index'; +@import '../../../plugins/management/public/components/index'; // The following are prefixed with "vis" diff --git a/src/legacy/ui/public/management/_index.scss b/src/legacy/ui/public/management/_index.scss deleted file mode 100644 index 30ac0c9fe9b27..0000000000000 --- a/src/legacy/ui/public/management/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './components/index'; \ No newline at end of file diff --git a/src/legacy/ui/public/management/components/__snapshots__/sidebar_nav.test.ts.snap b/src/legacy/ui/public/management/components/__snapshots__/sidebar_nav.test.ts.snap deleted file mode 100644 index 3364bee33a544..0000000000000 --- a/src/legacy/ui/public/management/components/__snapshots__/sidebar_nav.test.ts.snap +++ /dev/null @@ -1,24 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Management filters and filters and maps section objects into SidebarNav items 1`] = ` -Array [ - Object { - "data-test-subj": "activeSection", - "href": undefined, - "icon": null, - "id": "activeSection", - "isSelected": false, - "items": Array [ - Object { - "data-test-subj": "item", - "href": undefined, - "icon": null, - "id": "item", - "isSelected": false, - "name": "item", - }, - ], - "name": "activeSection", - }, -] -`; diff --git a/src/legacy/ui/public/management/components/sidebar_nav.tsx b/src/legacy/ui/public/management/components/sidebar_nav.tsx deleted file mode 100644 index cd3d85090dce0..0000000000000 --- a/src/legacy/ui/public/management/components/sidebar_nav.tsx +++ /dev/null @@ -1,107 +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 { EuiIcon, EuiSideNav, IconType, EuiScreenReaderOnly } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { IndexedArray } from 'ui/indexed_array'; - -interface Subsection { - disabled: boolean; - visible: boolean; - id: string; - display: string; - url?: string; - icon?: IconType; -} -interface Section extends Subsection { - visibleItems: IndexedArray; -} - -const sectionVisible = (section: Subsection) => !section.disabled && section.visible; -const sectionToNav = (selectedId: string) => ({ display, id, url, icon }: Subsection) => ({ - id, - name: display, - icon: icon ? : null, - isSelected: selectedId === id, - href: url, - 'data-test-subj': id, -}); - -export const sideNavItems = (sections: Section[], selectedId: string) => - sections - .filter(sectionVisible) - .filter(section => section.visibleItems.filter(sectionVisible).length) - .map(section => ({ - items: section.visibleItems.filter(sectionVisible).map(sectionToNav(selectedId)), - ...sectionToNav(selectedId)(section), - })); - -interface SidebarNavProps { - sections: Section[]; - selectedId: string; -} - -interface SidebarNavState { - isSideNavOpenOnMobile: boolean; -} - -export class SidebarNav extends React.Component { - constructor(props: SidebarNavProps) { - super(props); - this.state = { - isSideNavOpenOnMobile: false, - }; - } - - public render() { - const HEADER_ID = 'management-nav-header'; - - return ( - <> - -

- {i18n.translate('common.ui.management.nav.label', { - defaultMessage: 'Management', - })} -

- - - - ); - } - - private renderMobileTitle() { - return ; - } - - private toggleOpenOnMobile = () => { - this.setState({ - isSideNavOpenOnMobile: !this.state.isSideNavOpenOnMobile, - }); - }; -} diff --git a/src/legacy/ui/public/management/index.js b/src/legacy/ui/public/management/index.js index ed8ddb65315e2..b2f1946dbc59c 100644 --- a/src/legacy/ui/public/management/index.js +++ b/src/legacy/ui/public/management/index.js @@ -23,8 +23,6 @@ export { PAGE_FOOTER_COMPONENT, } from '../../../core_plugins/kibana/public/management/sections/settings/components/default_component_registry'; export { registerSettingsComponent } from '../../../core_plugins/kibana/public/management/sections/settings/components/component_registry'; -export { SidebarNav } from './components'; export { MANAGEMENT_BREADCRUMB } from './breadcrumbs'; - import { npStart } from 'ui/new_platform'; export const management = npStart.plugins.management.legacy; diff --git a/src/plugins/management/kibana.json b/src/plugins/management/kibana.json index 755a387afbd05..80135f1bfb6c8 100644 --- a/src/plugins/management/kibana.json +++ b/src/plugins/management/kibana.json @@ -3,5 +3,5 @@ "version": "kibana", "server": false, "ui": true, - "requiredPlugins": [] + "requiredPlugins": ["kibana_legacy"] } diff --git a/src/plugins/management/public/__snapshots__/management_app.test.tsx.snap b/src/plugins/management/public/__snapshots__/management_app.test.tsx.snap new file mode 100644 index 0000000000000..7f13472ee02ee --- /dev/null +++ b/src/plugins/management/public/__snapshots__/management_app.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Management app can mount and unmount 1`] = ` +
+
+ Test App - Hello world! +
+
+`; + +exports[`Management app can mount and unmount 2`] = `
`; diff --git a/src/plugins/management/public/components/_index.scss b/src/plugins/management/public/components/_index.scss new file mode 100644 index 0000000000000..df0ebb48803d9 --- /dev/null +++ b/src/plugins/management/public/components/_index.scss @@ -0,0 +1 @@ +@import './management_sidebar_nav/index'; diff --git a/src/plugins/management/public/components/index.ts b/src/plugins/management/public/components/index.ts new file mode 100644 index 0000000000000..2650d23d3c25c --- /dev/null +++ b/src/plugins/management/public/components/index.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +export { ManagementSidebarNav } from './management_sidebar_nav'; +export { ManagementChrome } from './management_chrome'; diff --git a/src/legacy/ui/public/management/components/index.ts b/src/plugins/management/public/components/management_chrome/index.ts similarity index 93% rename from src/legacy/ui/public/management/components/index.ts rename to src/plugins/management/public/components/management_chrome/index.ts index e3a18ec4e2698..b82c1af871be7 100644 --- a/src/legacy/ui/public/management/components/index.ts +++ b/src/plugins/management/public/components/management_chrome/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { SidebarNav } from './sidebar_nav'; +export { ManagementChrome } from './management_chrome'; diff --git a/src/plugins/management/public/components/management_chrome/management_chrome.tsx b/src/plugins/management/public/components/management_chrome/management_chrome.tsx new file mode 100644 index 0000000000000..7e5cabd32e48f --- /dev/null +++ b/src/plugins/management/public/components/management_chrome/management_chrome.tsx @@ -0,0 +1,57 @@ +/* + * 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 * as React from 'react'; +import { EuiPage, EuiPageBody } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n/react'; +import { ManagementSidebarNav } from '../management_sidebar_nav'; +import { LegacySection } from '../../types'; +import { ManagementSection } from '../../management_section'; + +interface Props { + getSections: () => ManagementSection[]; + legacySections: LegacySection[]; + selectedId: string; + onMounted: (element: HTMLDivElement) => void; +} + +export class ManagementChrome extends React.Component { + private container = React.createRef(); + componentDidMount() { + if (this.container.current) { + this.props.onMounted(this.container.current); + } + } + render() { + return ( + + + + +
+ + + + ); + } +} diff --git a/src/plugins/management/public/components/management_sidebar_nav/__snapshots__/management_sidebar_nav.test.ts.snap b/src/plugins/management/public/components/management_sidebar_nav/__snapshots__/management_sidebar_nav.test.ts.snap new file mode 100644 index 0000000000000..e7225b356ed68 --- /dev/null +++ b/src/plugins/management/public/components/management_sidebar_nav/__snapshots__/management_sidebar_nav.test.ts.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Management adds legacy apps to existing SidebarNav sections 1`] = ` +Array [ + Object { + "data-test-subj": "activeSection", + "icon": null, + "id": "activeSection", + "items": Array [ + Object { + "data-test-subj": "item", + "href": undefined, + "id": "item", + "isSelected": false, + "name": "item", + "order": undefined, + }, + ], + "name": "activeSection", + "order": 10, + }, + Object { + "data-test-subj": "no-active-items", + "icon": null, + "id": "no-active-items", + "items": Array [ + Object { + "data-test-subj": "disabled", + "href": undefined, + "id": "disabled", + "isSelected": false, + "name": "disabled", + "order": undefined, + }, + Object { + "data-test-subj": "notVisible", + "href": undefined, + "id": "notVisible", + "isSelected": false, + "name": "notVisible", + "order": undefined, + }, + ], + "name": "No active items", + "order": 10, + }, +] +`; + +exports[`Management maps legacy sections and apps into SidebarNav items 1`] = ` +Array [ + Object { + "data-test-subj": "no-active-items", + "icon": null, + "id": "no-active-items", + "items": Array [ + Object { + "data-test-subj": "disabled", + "href": undefined, + "id": "disabled", + "isSelected": false, + "name": "disabled", + "order": undefined, + }, + Object { + "data-test-subj": "notVisible", + "href": undefined, + "id": "notVisible", + "isSelected": false, + "name": "notVisible", + "order": undefined, + }, + ], + "name": "No active items", + "order": 10, + }, + Object { + "data-test-subj": "activeSection", + "icon": null, + "id": "activeSection", + "items": Array [ + Object { + "data-test-subj": "item", + "href": undefined, + "id": "item", + "isSelected": false, + "name": "item", + "order": undefined, + }, + ], + "name": "activeSection", + "order": 10, + }, +] +`; diff --git a/src/legacy/ui/public/management/components/_index.scss b/src/plugins/management/public/components/management_sidebar_nav/_index.scss similarity index 100% rename from src/legacy/ui/public/management/components/_index.scss rename to src/plugins/management/public/components/management_sidebar_nav/_index.scss diff --git a/src/legacy/ui/public/management/components/_sidebar_nav.scss b/src/plugins/management/public/components/management_sidebar_nav/_sidebar_nav.scss similarity index 88% rename from src/legacy/ui/public/management/components/_sidebar_nav.scss rename to src/plugins/management/public/components/management_sidebar_nav/_sidebar_nav.scss index 0c2b2bc228b2c..cf88ed9b0a88b 100644 --- a/src/legacy/ui/public/management/components/_sidebar_nav.scss +++ b/src/plugins/management/public/components/management_sidebar_nav/_sidebar_nav.scss @@ -1,4 +1,4 @@ -.mgtSidebarNav { +.mgtSideBarNav { width: 192px; } diff --git a/src/plugins/management/public/components/management_sidebar_nav/index.ts b/src/plugins/management/public/components/management_sidebar_nav/index.ts new file mode 100644 index 0000000000000..79142fdb69a74 --- /dev/null +++ b/src/plugins/management/public/components/management_sidebar_nav/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { ManagementSidebarNav } from './management_sidebar_nav'; diff --git a/src/legacy/ui/public/management/components/sidebar_nav.test.ts b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.test.ts similarity index 75% rename from src/legacy/ui/public/management/components/sidebar_nav.test.ts rename to src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.test.ts index e02cc7d2901b6..e04e0a7572612 100644 --- a/src/legacy/ui/public/management/components/sidebar_nav.test.ts +++ b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.test.ts @@ -17,8 +17,8 @@ * under the License. */ -import { IndexedArray } from '../../indexed_array'; -import { sideNavItems } from '../components/sidebar_nav'; +import { IndexedArray } from '../../../../../legacy/ui/public/indexed_array'; +import { mergeLegacyItems } from './management_sidebar_nav'; const toIndexedArray = (initialSet: any[]) => new IndexedArray({ @@ -30,30 +30,33 @@ const toIndexedArray = (initialSet: any[]) => const activeProps = { visible: true, disabled: false }; const disabledProps = { visible: true, disabled: true }; const notVisibleProps = { visible: false, disabled: false }; - const visibleItem = { display: 'item', id: 'item', ...activeProps }; const notVisibleSection = { display: 'Not visible', id: 'not-visible', + order: 10, visibleItems: toIndexedArray([visibleItem]), ...notVisibleProps, }; const disabledSection = { display: 'Disabled', id: 'disabled', + order: 10, visibleItems: toIndexedArray([visibleItem]), ...disabledProps, }; const noItemsSection = { display: 'No items', id: 'no-items', + order: 10, visibleItems: toIndexedArray([]), ...activeProps, }; const noActiveItemsSection = { display: 'No active items', id: 'no-active-items', + order: 10, visibleItems: toIndexedArray([ { display: 'disabled', id: 'disabled', ...disabledProps }, { display: 'notVisible', id: 'notVisible', ...notVisibleProps }, @@ -63,6 +66,7 @@ const noActiveItemsSection = { const activeSection = { display: 'activeSection', id: 'activeSection', + order: 10, visibleItems: toIndexedArray([visibleItem]), ...activeProps, }; @@ -76,7 +80,19 @@ const managementSections = [ ]; describe('Management', () => { - it('filters and filters and maps section objects into SidebarNav items', () => { - expect(sideNavItems(managementSections, 'active-item-id')).toMatchSnapshot(); + it('maps legacy sections and apps into SidebarNav items', () => { + expect(mergeLegacyItems([], managementSections, 'active-item-id')).toMatchSnapshot(); + }); + + it('adds legacy apps to existing SidebarNav sections', () => { + const navSection = { + 'data-test-subj': 'activeSection', + icon: null, + id: 'activeSection', + items: [], + name: 'activeSection', + order: 10, + }; + expect(mergeLegacyItems([navSection], managementSections, 'active-item-id')).toMatchSnapshot(); }); }); diff --git a/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx new file mode 100644 index 0000000000000..cb0b82d0f0bde --- /dev/null +++ b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx @@ -0,0 +1,200 @@ +/* + * 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 { + EuiIcon, + // @ts-ignore + EuiSideNav, + EuiScreenReaderOnly, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { LegacySection, LegacyApp } from '../../types'; +import { ManagementApp } from '../../management_app'; +import { ManagementSection } from '../../management_section'; + +interface NavApp { + id: string; + name: string; + [key: string]: unknown; + order: number; // only needed while merging platform and legacy +} + +interface NavSection extends NavApp { + items: NavApp[]; +} + +interface ManagementSidebarNavProps { + getSections: () => ManagementSection[]; + legacySections: LegacySection[]; + selectedId: string; +} + +interface ManagementSidebarNavState { + isSideNavOpenOnMobile: boolean; +} + +const managementSectionOrAppToNav = (appOrSection: ManagementApp | ManagementSection) => ({ + id: appOrSection.id, + name: appOrSection.title, + 'data-test-subj': appOrSection.id, + order: appOrSection.order, +}); + +const managementSectionToNavSection = (section: ManagementSection) => { + const iconType = section.euiIconType + ? section.euiIconType + : section.icon + ? section.icon + : 'empty'; + + return { + icon: , + ...managementSectionOrAppToNav(section), + }; +}; + +const managementAppToNavItem = (selectedId?: string, parentId?: string) => ( + app: ManagementApp +) => ({ + isSelected: selectedId === app.id, + href: `#/management/${parentId}/${app.id}`, + ...managementSectionOrAppToNav(app), +}); + +const legacySectionToNavSection = (section: LegacySection) => ({ + name: section.display, + id: section.id, + icon: section.icon ? : null, + items: [], + 'data-test-subj': section.id, + // @ts-ignore + order: section.order, +}); + +const legacyAppToNavItem = (app: LegacyApp, selectedId: string) => ({ + isSelected: selectedId === app.id, + name: app.display, + id: app.id, + href: app.url, + 'data-test-subj': app.id, + // @ts-ignore + order: app.order, +}); + +const sectionVisible = (section: LegacySection | LegacyApp) => !section.disabled && section.visible; + +const sideNavItems = (sections: ManagementSection[], selectedId: string) => + sections.map(section => ({ + items: section.getAppsEnabled().map(managementAppToNavItem(selectedId, section.id)), + ...managementSectionToNavSection(section), + })); + +const findOrAddSection = (navItems: NavSection[], legacySection: LegacySection): NavSection => { + const foundSection = navItems.find(sec => sec.id === legacySection.id); + + if (foundSection) { + return foundSection; + } else { + const newSection = legacySectionToNavSection(legacySection); + navItems.push(newSection); + navItems.sort((a: NavSection, b: NavSection) => a.order - b.order); // only needed while merging platform and legacy + return newSection; + } +}; + +export const mergeLegacyItems = ( + navItems: NavSection[], + legacySections: LegacySection[], + selectedId: string +) => { + const filteredLegacySections = legacySections + .filter(sectionVisible) + .filter(section => section.visibleItems.length); + + filteredLegacySections.forEach(legacySection => { + const section = findOrAddSection(navItems, legacySection); + legacySection.visibleItems.forEach(app => { + section.items.push(legacyAppToNavItem(app, selectedId)); + return section.items.sort((a, b) => a.order - b.order); + }); + }); + + return navItems; +}; + +const sectionsToItems = ( + sections: ManagementSection[], + legacySections: LegacySection[], + selectedId: string +) => { + const navItems = sideNavItems(sections, selectedId); + return mergeLegacyItems(navItems, legacySections, selectedId); +}; + +export class ManagementSidebarNav extends React.Component< + ManagementSidebarNavProps, + ManagementSidebarNavState +> { + constructor(props: ManagementSidebarNavProps) { + super(props); + this.state = { + isSideNavOpenOnMobile: false, + }; + } + + public render() { + const HEADER_ID = 'management-nav-header'; + + return ( + <> + +

+ {i18n.translate('management.nav.label', { + defaultMessage: 'Management', + })} +

+
+ + + ); + } + + private renderMobileTitle() { + return ; + } + + private toggleOpenOnMobile = () => { + this.setState({ + isSideNavOpenOnMobile: !this.state.isSideNavOpenOnMobile, + }); + }; +} diff --git a/src/plugins/management/public/index.ts b/src/plugins/management/public/index.ts index ee3866c734f19..faec466dbd671 100644 --- a/src/plugins/management/public/index.ts +++ b/src/plugins/management/public/index.ts @@ -24,4 +24,7 @@ export function plugin(initializerContext: PluginInitializerContext) { return new ManagementPlugin(); } -export { ManagementStart } from './types'; +export { ManagementSetup, ManagementStart, RegisterManagementApp } from './types'; +export { ManagementApp } from './management_app'; +export { ManagementSection } from './management_section'; +export { ManagementSidebarNav } from './components'; // for use in legacy management apps diff --git a/src/plugins/management/public/legacy/index.js b/src/plugins/management/public/legacy/index.js index 63b9d2c6b27d7..f2e0ba89b7b59 100644 --- a/src/plugins/management/public/legacy/index.js +++ b/src/plugins/management/public/legacy/index.js @@ -17,4 +17,5 @@ * under the License. */ -export { management } from './sections_register'; +export { LegacyManagementAdapter } from './sections_register'; +export { LegacyManagementSection } from './section'; diff --git a/src/plugins/management/public/legacy/section.js b/src/plugins/management/public/legacy/section.js index f269e3fe295b7..7d733b7b3173b 100644 --- a/src/plugins/management/public/legacy/section.js +++ b/src/plugins/management/public/legacy/section.js @@ -22,7 +22,7 @@ import { IndexedArray } from '../../../../legacy/ui/public/indexed_array'; const listeners = []; -export class ManagementSection { +export class LegacyManagementSection { /** * @param {string} id * @param {object} options @@ -83,7 +83,11 @@ export class ManagementSection { */ register(id, options = {}) { - const item = new ManagementSection(id, assign(options, { parent: this }), this.capabilities); + const item = new LegacyManagementSection( + id, + assign(options, { parent: this }), + this.capabilities + ); if (this.hasItem(id)) { throw new Error(`'${id}' is already registered`); diff --git a/src/plugins/management/public/legacy/section.test.js b/src/plugins/management/public/legacy/section.test.js index 61bafd298afb3..45cc80ef80edd 100644 --- a/src/plugins/management/public/legacy/section.test.js +++ b/src/plugins/management/public/legacy/section.test.js @@ -17,7 +17,7 @@ * under the License. */ -import { ManagementSection } from './section'; +import { LegacyManagementSection } from './section'; import { IndexedArray } from '../../../../legacy/ui/public/indexed_array'; const capabilitiesMock = { @@ -29,42 +29,42 @@ const capabilitiesMock = { describe('ManagementSection', () => { describe('constructor', () => { it('defaults display to id', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.display).toBe('kibana'); }); it('defaults visible to true', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.visible).toBe(true); }); it('defaults disabled to false', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.disabled).toBe(false); }); it('defaults tooltip to empty string', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.tooltip).toBe(''); }); it('defaults url to empty string', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.url).toBe(''); }); it('exposes items', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.items).toHaveLength(0); }); it('exposes visibleItems', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.visibleItems).toHaveLength(0); }); it('assigns all options', () => { - const section = new ManagementSection( + const section = new LegacyManagementSection( 'kibana', { description: 'test', url: 'foobar' }, capabilitiesMock @@ -78,11 +78,11 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); }); it('returns a ManagementSection', () => { - expect(section.register('about')).toBeInstanceOf(ManagementSection); + expect(section.register('about')).toBeInstanceOf(LegacyManagementSection); }); it('provides a reference to the parent', () => { @@ -93,7 +93,7 @@ describe('ManagementSection', () => { section.register('about', { description: 'test' }); expect(section.items).toHaveLength(1); - expect(section.items[0]).toBeInstanceOf(ManagementSection); + expect(section.items[0]).toBeInstanceOf(LegacyManagementSection); expect(section.items[0].id).toBe('about'); }); @@ -126,7 +126,7 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); section.register('about'); }); @@ -157,12 +157,12 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); section.register('about'); }); it('returns registered section', () => { - expect(section.getSection('about')).toBeInstanceOf(ManagementSection); + expect(section.getSection('about')).toBeInstanceOf(LegacyManagementSection); }); it('returns undefined if un-registered', () => { @@ -171,7 +171,7 @@ describe('ManagementSection', () => { it('returns sub-sections specified via a /-separated path', () => { section.getSection('about').register('time'); - expect(section.getSection('about/time')).toBeInstanceOf(ManagementSection); + expect(section.getSection('about/time')).toBeInstanceOf(LegacyManagementSection); expect(section.getSection('about/time')).toBe(section.getSection('about').getSection('time')); }); @@ -184,7 +184,7 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); section.register('three', { order: 3 }); section.register('one', { order: 1 }); @@ -214,7 +214,7 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); }); it('hide sets visible to false', () => { @@ -233,7 +233,7 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); }); it('disable sets disabled to true', () => { @@ -251,7 +251,7 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); section.register('three', { order: 3 }); section.register('one', { order: 1 }); diff --git a/src/plugins/management/public/legacy/sections_register.js b/src/plugins/management/public/legacy/sections_register.js index 888b2c5bc3aeb..63d919377f89e 100644 --- a/src/plugins/management/public/legacy/sections_register.js +++ b/src/plugins/management/public/legacy/sections_register.js @@ -17,44 +17,48 @@ * under the License. */ -import { ManagementSection } from './section'; +import { LegacyManagementSection } from './section'; import { i18n } from '@kbn/i18n'; -export const management = capabilities => { - const main = new ManagementSection( - 'management', - { - display: i18n.translate('management.displayName', { - defaultMessage: 'Management', - }), - }, - capabilities - ); +export class LegacyManagementAdapter { + main = undefined; + init = capabilities => { + this.main = new LegacyManagementSection( + 'management', + { + display: i18n.translate('management.displayName', { + defaultMessage: 'Management', + }), + }, + capabilities + ); - main.register('data', { - display: i18n.translate('management.connectDataDisplayName', { - defaultMessage: 'Connect Data', - }), - order: 0, - }); + this.main.register('data', { + display: i18n.translate('management.connectDataDisplayName', { + defaultMessage: 'Connect Data', + }), + order: 0, + }); - main.register('elasticsearch', { - display: 'Elasticsearch', - order: 20, - icon: 'logoElasticsearch', - }); + this.main.register('elasticsearch', { + display: 'Elasticsearch', + order: 20, + icon: 'logoElasticsearch', + }); - main.register('kibana', { - display: 'Kibana', - order: 30, - icon: 'logoKibana', - }); + this.main.register('kibana', { + display: 'Kibana', + order: 30, + icon: 'logoKibana', + }); - main.register('logstash', { - display: 'Logstash', - order: 30, - icon: 'logoLogstash', - }); + this.main.register('logstash', { + display: 'Logstash', + order: 30, + icon: 'logoLogstash', + }); - return main; -}; + return this.main; + }; + getManagement = () => this.main; +} diff --git a/src/plugins/management/public/management_app.test.tsx b/src/plugins/management/public/management_app.test.tsx new file mode 100644 index 0000000000000..a76b234d95ef5 --- /dev/null +++ b/src/plugins/management/public/management_app.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { coreMock } from '../../../core/public/mocks'; + +import { ManagementApp } from './management_app'; +// @ts-ignore +import { LegacyManagementSection } from './legacy'; + +function createTestApp() { + const legacySection = new LegacyManagementSection('legacy'); + return new ManagementApp( + { + id: 'test-app', + title: 'Test App', + basePath: '', + mount(params) { + params.setBreadcrumbs([{ text: 'Test App' }]); + ReactDOM.render(
Test App - Hello world!
, params.element); + + return () => { + ReactDOM.unmountComponentAtNode(params.element); + }; + }, + }, + () => [], + jest.fn(), + () => legacySection, + coreMock.createSetup().getStartServices + ); +} + +test('Management app can mount and unmount', async () => { + const testApp = createTestApp(); + const container = document.createElement('div'); + document.body.appendChild(container); + const unmount = testApp.mount({ element: container, basePath: '', setBreadcrumbs: jest.fn() }); + expect(container).toMatchSnapshot(); + (await unmount)(); + expect(container).toMatchSnapshot(); +}); + +test('Enabled by default, can disable', () => { + const testApp = createTestApp(); + expect(testApp.enabled).toBe(true); + testApp.disable(); + expect(testApp.enabled).toBe(false); +}); diff --git a/src/plugins/management/public/management_app.tsx b/src/plugins/management/public/management_app.tsx new file mode 100644 index 0000000000000..f7e8dba4f8210 --- /dev/null +++ b/src/plugins/management/public/management_app.tsx @@ -0,0 +1,102 @@ +/* + * 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 * as React from 'react'; +import ReactDOM from 'react-dom'; +import { i18n } from '@kbn/i18n'; +import { CreateManagementApp, ManagementSectionMount, Unmount } from './types'; +import { KibanaLegacySetup } from '../../kibana_legacy/public'; +// @ts-ignore +import { LegacyManagementSection } from './legacy'; +import { ManagementChrome } from './components'; +import { ManagementSection } from './management_section'; +import { ChromeBreadcrumb, CoreSetup } from '../../../core/public/'; + +export class ManagementApp { + readonly id: string; + readonly title: string; + readonly basePath: string; + readonly order: number; + readonly mount: ManagementSectionMount; + protected enabledStatus: boolean = true; + + constructor( + { id, title, basePath, order = 100, mount }: CreateManagementApp, + getSections: () => ManagementSection[], + registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], + getLegacyManagementSections: () => LegacyManagementSection, + getStartServices: CoreSetup['getStartServices'] + ) { + this.id = id; + this.title = title; + this.basePath = basePath; + this.order = order; + this.mount = mount; + + registerLegacyApp({ + id: basePath.substr(1), // get rid of initial slash + title, + mount: async ({}, params) => { + let appUnmount: Unmount; + async function setBreadcrumbs(crumbs: ChromeBreadcrumb[]) { + const [coreStart] = await getStartServices(); + coreStart.chrome.setBreadcrumbs([ + { + text: i18n.translate('management.breadcrumb', { + defaultMessage: 'Management', + }), + href: '#/management', + }, + ...crumbs, + ]); + } + + ReactDOM.render( + { + appUnmount = await mount({ + basePath, + element, + setBreadcrumbs, + }); + }} + />, + params.element + ); + + return async () => { + appUnmount(); + ReactDOM.unmountComponentAtNode(params.element); + }; + }, + }); + } + public enable() { + this.enabledStatus = true; + } + public disable() { + this.enabledStatus = false; + } + public get enabled() { + return this.enabledStatus; + } +} diff --git a/src/plugins/management/public/management_section.test.ts b/src/plugins/management/public/management_section.test.ts new file mode 100644 index 0000000000000..c68175ee0a678 --- /dev/null +++ b/src/plugins/management/public/management_section.test.ts @@ -0,0 +1,65 @@ +/* + * 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 { ManagementSection } from './management_section'; +// @ts-ignore +import { LegacyManagementSection } from './legacy'; +import { coreMock } from '../../../core/public/mocks'; + +function createSection(registerLegacyApp: () => void) { + const legacySection = new LegacyManagementSection('legacy'); + const getLegacySection = () => legacySection; + const getManagementSections: () => ManagementSection[] = () => []; + + const testSectionConfig = { id: 'test-section', title: 'Test Section' }; + return new ManagementSection( + testSectionConfig, + getManagementSections, + registerLegacyApp, + getLegacySection, + coreMock.createSetup().getStartServices + ); +} + +test('cannot register two apps with the same id', () => { + const registerLegacyApp = jest.fn(); + const section = createSection(registerLegacyApp); + + const testAppConfig = { id: 'test-app', title: 'Test App', mount: () => () => {} }; + + section.registerApp(testAppConfig); + expect(registerLegacyApp).toHaveBeenCalled(); + expect(section.apps.length).toEqual(1); + + expect(() => { + section.registerApp(testAppConfig); + }).toThrow(); +}); + +test('can enable and disable apps', () => { + const registerLegacyApp = jest.fn(); + const section = createSection(registerLegacyApp); + + const testAppConfig = { id: 'test-app', title: 'Test App', mount: () => () => {} }; + + const app = section.registerApp(testAppConfig); + expect(section.getAppsEnabled().length).toEqual(1); + app.disable(); + expect(section.getAppsEnabled().length).toEqual(0); +}); diff --git a/src/plugins/management/public/management_section.ts b/src/plugins/management/public/management_section.ts new file mode 100644 index 0000000000000..2f323c4b6a9cf --- /dev/null +++ b/src/plugins/management/public/management_section.ts @@ -0,0 +1,78 @@ +/* + * 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 { CreateSection, RegisterManagementAppArgs } from './types'; +import { KibanaLegacySetup } from '../../kibana_legacy/public'; +import { CoreSetup } from '../../../core/public'; +// @ts-ignore +import { LegacyManagementSection } from './legacy'; +import { ManagementApp } from './management_app'; + +export class ManagementSection { + public readonly id: string = ''; + public readonly title: string = ''; + public readonly apps: ManagementApp[] = []; + public readonly order: number; + public readonly euiIconType?: string; + public readonly icon?: string; + private readonly getSections: () => ManagementSection[]; + private readonly registerLegacyApp: KibanaLegacySetup['registerLegacyApp']; + private readonly getLegacyManagementSection: () => LegacyManagementSection; + private readonly getStartServices: CoreSetup['getStartServices']; + + constructor( + { id, title, order = 100, euiIconType, icon }: CreateSection, + getSections: () => ManagementSection[], + registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], + getLegacyManagementSection: () => ManagementSection, + getStartServices: CoreSetup['getStartServices'] + ) { + this.id = id; + this.title = title; + this.order = order; + this.euiIconType = euiIconType; + this.icon = icon; + this.getSections = getSections; + this.registerLegacyApp = registerLegacyApp; + this.getLegacyManagementSection = getLegacyManagementSection; + this.getStartServices = getStartServices; + } + + registerApp({ id, title, order, mount }: RegisterManagementAppArgs) { + if (this.getApp(id)) { + throw new Error(`Management app already registered - id: ${id}, title: ${title}`); + } + + const app = new ManagementApp( + { id, title, order, mount, basePath: `/management/${this.id}/${id}` }, + this.getSections, + this.registerLegacyApp, + this.getLegacyManagementSection, + this.getStartServices + ); + this.apps.push(app); + return app; + } + getApp(id: ManagementApp['id']) { + return this.apps.find(app => app.id === id); + } + getAppsEnabled() { + return this.apps.filter(app => app.enabled).sort((a, b) => a.order - b.order); + } +} diff --git a/src/plugins/management/public/management_service.test.ts b/src/plugins/management/public/management_service.test.ts new file mode 100644 index 0000000000000..854406a10335b --- /dev/null +++ b/src/plugins/management/public/management_service.test.ts @@ -0,0 +1,55 @@ +/* + * 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 { ManagementService } from './management_service'; +import { coreMock } from '../../../core/public/mocks'; + +const mockKibanaLegacy = { registerLegacyApp: () => {}, forwardApp: () => {} }; + +test('Provides default sections', () => { + const service = new ManagementService().setup( + mockKibanaLegacy, + () => {}, + coreMock.createSetup().getStartServices + ); + expect(service.getAllSections().length).toEqual(3); + expect(service.getSection('kibana')).not.toBeUndefined(); + expect(service.getSection('logstash')).not.toBeUndefined(); + expect(service.getSection('elasticsearch')).not.toBeUndefined(); +}); + +test('Register section, enable and disable', () => { + const service = new ManagementService().setup( + mockKibanaLegacy, + () => {}, + coreMock.createSetup().getStartServices + ); + const testSection = service.register({ id: 'test-section', title: 'Test Section' }); + expect(service.getSection('test-section')).not.toBeUndefined(); + + const testApp = testSection.registerApp({ + id: 'test-app', + title: 'Test App', + mount: () => () => {}, + }); + expect(testSection.getApp('test-app')).not.toBeUndefined(); + expect(service.getSectionsEnabled().length).toEqual(1); + testApp.disable(); + expect(service.getSectionsEnabled().length).toEqual(0); +}); diff --git a/src/plugins/management/public/management_service.ts b/src/plugins/management/public/management_service.ts new file mode 100644 index 0000000000000..4a900345b3843 --- /dev/null +++ b/src/plugins/management/public/management_service.ts @@ -0,0 +1,103 @@ +/* + * 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 { ManagementSection } from './management_section'; +import { KibanaLegacySetup } from '../../kibana_legacy/public'; +// @ts-ignore +import { LegacyManagementSection } from './legacy'; +import { CreateSection } from './types'; +import { CoreSetup, CoreStart } from '../../../core/public'; + +export class ManagementService { + private sections: ManagementSection[] = []; + + private register( + registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], + getLegacyManagement: () => LegacyManagementSection, + getStartServices: CoreSetup['getStartServices'] + ) { + return (section: CreateSection) => { + if (this.getSection(section.id)) { + throw Error(`ManagementSection '${section.id}' already registered`); + } + + const newSection = new ManagementSection( + section, + this.getSectionsEnabled.bind(this), + registerLegacyApp, + getLegacyManagement, + getStartServices + ); + this.sections.push(newSection); + return newSection; + }; + } + private getSection(sectionId: ManagementSection['id']) { + return this.sections.find(section => section.id === sectionId); + } + + private getAllSections() { + return this.sections; + } + + private getSectionsEnabled() { + return this.sections + .filter(section => section.getAppsEnabled().length > 0) + .sort((a, b) => a.order - b.order); + } + + private sharedInterface = { + getSection: this.getSection.bind(this), + getSectionsEnabled: this.getSectionsEnabled.bind(this), + getAllSections: this.getAllSections.bind(this), + }; + + public setup( + kibanaLegacy: KibanaLegacySetup, + getLegacyManagement: () => LegacyManagementSection, + getStartServices: CoreSetup['getStartServices'] + ) { + const register = this.register.bind(this)( + kibanaLegacy.registerLegacyApp, + getLegacyManagement, + getStartServices + ); + + register({ id: 'kibana', title: 'Kibana', order: 30, euiIconType: 'logoKibana' }); + register({ id: 'logstash', title: 'Logstash', order: 30, euiIconType: 'logoLogstash' }); + register({ + id: 'elasticsearch', + title: 'Elasticsearch', + order: 20, + euiIconType: 'logoElasticsearch', + }); + + return { + register, + ...this.sharedInterface, + }; + } + + public start(navigateToApp: CoreStart['application']['navigateToApp']) { + return { + navigateToApp, // apps are currently registered as top level apps but this may change in the future + ...this.sharedInterface, + }; + } +} diff --git a/src/plugins/management/public/plugin.ts b/src/plugins/management/public/plugin.ts index c65dfd1dc7bb4..195d96c11d8d9 100644 --- a/src/plugins/management/public/plugin.ts +++ b/src/plugins/management/public/plugin.ts @@ -18,18 +18,30 @@ */ import { CoreSetup, CoreStart, Plugin } from 'kibana/public'; -import { ManagementStart } from './types'; +import { ManagementSetup, ManagementStart } from './types'; +import { ManagementService } from './management_service'; +import { KibanaLegacySetup } from '../../kibana_legacy/public'; // @ts-ignore -import { management } from './legacy'; +import { LegacyManagementAdapter } from './legacy'; -export class ManagementPlugin implements Plugin<{}, ManagementStart> { - public setup(core: CoreSetup) { - return {}; +export class ManagementPlugin implements Plugin { + private managementSections = new ManagementService(); + private legacyManagement = new LegacyManagementAdapter(); + + public setup(core: CoreSetup, { kibana_legacy }: { kibana_legacy: KibanaLegacySetup }) { + return { + sections: this.managementSections.setup( + kibana_legacy, + this.legacyManagement.getManagement, + core.getStartServices + ), + }; } public start(core: CoreStart) { return { - legacy: management(core.application.capabilities), + sections: this.managementSections.start(core.application.navigateToApp), + legacy: this.legacyManagement.init(core.application.capabilities), }; } } diff --git a/src/plugins/management/public/types.ts b/src/plugins/management/public/types.ts index 6ca1faf338c39..4dbea30ff062d 100644 --- a/src/plugins/management/public/types.ts +++ b/src/plugins/management/public/types.ts @@ -17,6 +17,82 @@ * under the License. */ +import { IconType } from '@elastic/eui'; +import { ManagementApp } from './management_app'; +import { ManagementSection } from './management_section'; +import { ChromeBreadcrumb, ApplicationStart } from '../../../core/public/'; + +export interface ManagementSetup { + sections: SectionsServiceSetup; +} + export interface ManagementStart { + sections: SectionsServiceStart; legacy: any; } + +interface SectionsServiceSetup { + getSection: (sectionId: ManagementSection['id']) => ManagementSection | undefined; + getAllSections: () => ManagementSection[]; + register: RegisterSection; +} + +interface SectionsServiceStart { + getSection: (sectionId: ManagementSection['id']) => ManagementSection | undefined; + getAllSections: () => ManagementSection[]; + navigateToApp: ApplicationStart['navigateToApp']; +} + +export interface CreateSection { + id: string; + title: string; + order?: number; + euiIconType?: string; // takes precedence over `icon` property. + icon?: string; // URL to image file; fallback if no `euiIconType` +} + +export type RegisterSection = (section: CreateSection) => ManagementSection; + +export interface RegisterManagementAppArgs { + id: string; + title: string; + mount: ManagementSectionMount; + order?: number; +} + +export type RegisterManagementApp = (managementApp: RegisterManagementAppArgs) => ManagementApp; + +export type Unmount = () => Promise | void; + +interface ManagementAppMountParams { + basePath: string; // base path for setting up your router + element: HTMLElement; // element the section should render into + setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; +} + +export type ManagementSectionMount = ( + params: ManagementAppMountParams +) => Unmount | Promise; + +export interface CreateManagementApp { + id: string; + title: string; + basePath: string; + order?: number; + mount: ManagementSectionMount; +} + +export interface LegacySection extends LegacyApp { + visibleItems: LegacyApp[]; +} + +export interface LegacyApp { + disabled: boolean; + visible: boolean; + id: string; + display: string; + url?: string; + euiIconType?: IconType; + icon?: string; + order: number; +} diff --git a/test/plugin_functional/config.js b/test/plugin_functional/config.js index 87026ce25d9aa..e9a4f3bcc4b1a 100644 --- a/test/plugin_functional/config.js +++ b/test/plugin_functional/config.js @@ -37,6 +37,7 @@ export default async function({ readConfigFile }) { require.resolve('./test_suites/panel_actions'), require.resolve('./test_suites/embeddable_explorer'), require.resolve('./test_suites/core_plugins'), + require.resolve('./test_suites/management'), ], services: { ...functionalConfig.get('services'), diff --git a/test/plugin_functional/plugins/management_test_plugin/kibana.json b/test/plugin_functional/plugins/management_test_plugin/kibana.json new file mode 100644 index 0000000000000..e52b60b3a4e31 --- /dev/null +++ b/test/plugin_functional/plugins/management_test_plugin/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "management_test_plugin", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["management_test_plugin"], + "server": false, + "ui": true, + "requiredPlugins": ["management"] +} diff --git a/test/plugin_functional/plugins/management_test_plugin/package.json b/test/plugin_functional/plugins/management_test_plugin/package.json new file mode 100644 index 0000000000000..656d92e9eb1f7 --- /dev/null +++ b/test/plugin_functional/plugins/management_test_plugin/package.json @@ -0,0 +1,17 @@ +{ + "name": "management_test_plugin", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/management_test_plugin", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.7.2" + } +} diff --git a/test/plugin_functional/plugins/management_test_plugin/public/index.ts b/test/plugin_functional/plugins/management_test_plugin/public/index.ts new file mode 100644 index 0000000000000..1efcc6cd3bbd6 --- /dev/null +++ b/test/plugin_functional/plugins/management_test_plugin/public/index.ts @@ -0,0 +1,28 @@ +/* + * 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 { PluginInitializer } from 'kibana/public'; +import { + ManagementTestPlugin, + ManagementTestPluginSetup, + ManagementTestPluginStart, +} from './plugin'; + +export const plugin: PluginInitializer = () => + new ManagementTestPlugin(); diff --git a/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx b/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx new file mode 100644 index 0000000000000..8b7cdd653ed8c --- /dev/null +++ b/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx @@ -0,0 +1,73 @@ +/* + * 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 * as React from 'react'; +import ReactDOM from 'react-dom'; +import { HashRouter as Router, Switch, Route, Link } from 'react-router-dom'; +import { CoreSetup, Plugin } from 'kibana/public'; +import { ManagementSetup } from '../../../../../src/plugins/management/public'; + +export class ManagementTestPlugin + implements Plugin { + public setup(core: CoreSetup, { management }: { management: ManagementSetup }) { + const testSection = management.sections.register({ + id: 'test-section', + title: 'Test Section', + euiIconType: 'logoKibana', + order: 25, + }); + + testSection!.registerApp({ + id: 'test-management', + title: 'Management Test', + mount(params) { + params.setBreadcrumbs([{ text: 'Management Test' }]); + ReactDOM.render( + +

Hello from management test plugin

+ + + + Link to /one + + + + + Link to basePath + + + +
, + params.element + ); + + return () => { + ReactDOM.unmountComponentAtNode(params.element); + }; + }, + }); + return {}; + } + + public start() {} + public stop() {} +} + +export type ManagementTestPluginSetup = ReturnType; +export type ManagementTestPluginStart = ReturnType; diff --git a/test/plugin_functional/plugins/management_test_plugin/tsconfig.json b/test/plugin_functional/plugins/management_test_plugin/tsconfig.json new file mode 100644 index 0000000000000..5fcaeafbb0d85 --- /dev/null +++ b/test/plugin_functional/plugins/management_test_plugin/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*", + ], + "exclude": [] +} diff --git a/test/plugin_functional/test_suites/management/index.js b/test/plugin_functional/test_suites/management/index.js new file mode 100644 index 0000000000000..2bfc05547b292 --- /dev/null +++ b/test/plugin_functional/test_suites/management/index.js @@ -0,0 +1,24 @@ +/* + * 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. + */ + +export default function({ loadTestFile }) { + describe('management plugin', () => { + loadTestFile(require.resolve('./management_plugin')); + }); +} diff --git a/test/plugin_functional/test_suites/management/management_plugin.js b/test/plugin_functional/test_suites/management/management_plugin.js new file mode 100644 index 0000000000000..d65fb1dcd3a7e --- /dev/null +++ b/test/plugin_functional/test_suites/management/management_plugin.js @@ -0,0 +1,40 @@ +/* + * 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. + */ + +export default function({ getService, getPageObjects }) { + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common']); + + describe('management plugin', function describeIndexTests() { + before(async () => { + await PageObjects.common.navigateToActualUrl('kibana', 'management'); + }); + + it('should be able to navigate to management test app', async () => { + await testSubjects.click('test-management'); + await testSubjects.existOrFail('test-management-header'); + }); + + it('should be able to navigate within management test app', async () => { + await testSubjects.click('test-management-link-one'); + await testSubjects.click('test-management-link-basepath'); + await testSubjects.existOrFail('test-management-link-one'); + }); + }); +} diff --git a/x-pack/legacy/plugins/infra/types/eui.d.ts b/x-pack/legacy/plugins/infra/types/eui.d.ts index afcb445a66adb..e73a73076923d 100644 --- a/x-pack/legacy/plugins/infra/types/eui.d.ts +++ b/x-pack/legacy/plugins/infra/types/eui.d.ts @@ -34,7 +34,7 @@ declare module '@elastic/eui' { items: Array<{ id: string; name: string; - onClick: () => void; + onClick?: () => void; }>; }>; mobileTitle?: React.ReactNode; diff --git a/x-pack/legacy/plugins/spaces/public/management/management_service.test.ts b/x-pack/legacy/plugins/spaces/public/management/management_service.test.ts index fa8ae64168673..fbd39db6969bd 100644 --- a/x-pack/legacy/plugins/spaces/public/management/management_service.test.ts +++ b/x-pack/legacy/plugins/spaces/public/management/management_service.test.ts @@ -6,6 +6,12 @@ import { ManagementService } from '.'; +const mockSections = { + getSection: jest.fn(), + getAllSections: jest.fn(), + navigateToApp: jest.fn(), +}; + describe('ManagementService', () => { describe('#start', () => { it('registers the spaces management page under the kibana section', () => { @@ -18,6 +24,7 @@ describe('ManagementService', () => { legacy: { getSection: jest.fn().mockReturnValue(mockKibanaSection), }, + sections: mockSections, }; const deps = { @@ -49,6 +56,7 @@ describe('ManagementService', () => { legacy: { getSection: jest.fn().mockReturnValue(mockKibanaSection), }, + sections: mockSections, }; const deps = { @@ -66,6 +74,7 @@ describe('ManagementService', () => { legacy: { getSection: jest.fn().mockReturnValue(undefined), }, + sections: mockSections, }; const deps = { @@ -94,6 +103,7 @@ describe('ManagementService', () => { legacy: { getSection: jest.fn().mockReturnValue(mockKibanaSection), }, + sections: mockSections, }; const deps = { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 09d71814e5bf0..545a4fc7c8f93 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -441,7 +441,9 @@ "common.ui.flotCharts.tueLabel": "火", "common.ui.flotCharts.wedLabel": "水", "common.ui.management.breadcrumb": "管理", - "common.ui.management.nav.menu": "管理メニュー", + "management.connectDataDisplayName": "データに接続", + "management.displayName": "管理", + "management.nav.menu": "管理メニュー", "common.ui.modals.cancelButtonLabel": "キャンセル", "common.ui.notify.fatalError.errorStatusMessage": "エラー {errStatus} {errStatusText}: {errMessage}", "common.ui.notify.fatalError.unavailableServerErrorMessage": "HTTP リクエストが接続に失敗しました。Kibana サーバーが実行されていて、ご使用のブラウザの接続が正常に動作していることを確認するか、システム管理者にお問い合わせください。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e221cba874bcd..05251d20a66e4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -441,7 +441,9 @@ "common.ui.flotCharts.tueLabel": "周二", "common.ui.flotCharts.wedLabel": "周三", "common.ui.management.breadcrumb": "管理", - "common.ui.management.nav.menu": "管理菜单", + "management.connectDataDisplayName": "连接数据", + "management.displayName": "管理", + "management.nav.menu": "管理菜单", "common.ui.modals.cancelButtonLabel": "取消", "common.ui.notify.fatalError.errorStatusMessage": "错误 {errStatus} {errStatusText}:{errMessage}", "common.ui.notify.fatalError.unavailableServerErrorMessage": "HTTP 请求无法连接。请检查 Kibana 服务器是否正在运行以及您的浏览器是否具有有效的连接,或请联系您的系统管理员。", From 404c42f9557ab774441389db955614d96923c3e0 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 8 Jan 2020 18:49:17 -0500 Subject: [PATCH 18/23] Filter scripted fields preview field list to source fields (#53826) Co-authored-by: Elastic Machine --- .../components/scripting_help/test_script.js | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/src/legacy/ui/public/field_editor/components/scripting_help/test_script.js b/src/legacy/ui/public/field_editor/components/scripting_help/test_script.js index 942f39fc98dec..12bf5c1cce004 100644 --- a/src/legacy/ui/public/field_editor/components/scripting_help/test_script.js +++ b/src/legacy/ui/public/field_editor/components/scripting_help/test_script.js @@ -30,6 +30,8 @@ import { EuiTitle, EuiCallOut, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { npStart } from 'ui/new_platform'; const { SearchBar } = npStart.plugins.data.ui; @@ -116,7 +118,13 @@ export class TestScript extends Component { if (previewData.error) { return ( - + -

First 10 results

+

+ +

{ - return !field.name.startsWith('_'); + const isMultiField = field.subType && field.subType.multi; + return !field.name.startsWith('_') && !isMultiField && !field.scripted; }) .forEach(field => { if (fieldsByTypeMap.has(field.type)) { @@ -180,9 +194,16 @@ export class TestScript extends Component { return ( - + - Run script + } /> @@ -219,11 +243,19 @@ export class TestScript extends Component { -

Preview results

+

+ +

- Run your script to preview the first 10 results. You can also select some additional - fields to include in your results to gain more context or add a query to filter on - specific documents. +

From 1e2cbb3710c46eb4ba1bfd29565027c3dd2420da Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Wed, 8 Jan 2020 19:32:10 -0500 Subject: [PATCH 19/23] [SIEM] Detection engine timeline (#53783) * change create to only have only one form to be open at the same time * add tick to risk score * remove compressed * fix select in schedule * fix bug to not allow more than one step panel to be open at a time * Add a color/health indicator to severity selector * Move and reword tags placeholder to bottom helper text * fix ux on the index patterns field * Reorganize MITRE ATT&CK threat * add url validation + some cleaning to prerp work for UT * add feature to get back timeline + be able to disable action on timeline modal * Add option to import the query from a saved timeline. * wip * Add timeline template selector * fix few bugs from last commit * review I * fix unit test for timeline_title * ui review * fix truncation on timeline selectable --- .../static/forms/components/field.tsx | 2 + .../static/forms/components/fields/index.ts | 1 + .../components/fields/super_select_field.tsx | 58 ++++ .../static/forms/hook_form_lib/constants.ts | 1 + .../components/open_timeline/helpers.ts | 6 +- .../public/components/open_timeline/index.tsx | 20 +- .../open_timeline/open_timeline.test.tsx | 16 +- .../open_timeline/open_timeline.tsx | 7 +- .../open_timeline_modal/index.tsx | 56 ++-- .../open_timeline_modal_body.test.tsx | 16 +- .../open_timeline_modal_body.tsx | 101 ++++--- .../timelines_table/actions_columns.test.tsx | 76 ++++- .../timelines_table/actions_columns.tsx | 20 +- .../timelines_table/common_columns.test.tsx | 81 +++-- .../timelines_table/common_columns.tsx | 2 - .../timelines_table/extended_columns.test.tsx | 9 +- .../icon_header_columns.test.tsx | 30 +- .../timelines_table/index.test.tsx | 70 +++-- .../open_timeline/timelines_table/index.tsx | 31 +- .../public/components/open_timeline/types.ts | 4 + .../timeline/search_super_select/index.tsx | 276 ++++++++++++++++++ .../search_super_select/translations.ts | 25 ++ .../detection_engine/rules/types.ts | 4 +- .../public/pages/detection_engine/index.tsx | 6 +- .../detection_engine/rules/all/actions.tsx | 2 +- .../detection_engine/rules/all/helpers.ts | 2 +- .../rules/components/add_item_form/index.tsx | 53 +++- .../components/description_step/helpers.tsx | 222 ++++++++++++++ .../components/description_step/index.tsx | 192 +++--------- .../components/description_step/types.ts | 33 +++ .../rules/components/mitre/helpers.ts | 18 ++ .../rules/components/mitre/index.tsx | 199 ++++++++----- .../rules/components/mitre/translations.ts | 6 +- .../rules/components/pick_timeline/index.tsx | 74 +++++ .../rules/components/query_bar/index.tsx | 126 +++++--- .../components/query_bar/translations.tsx | 14 + .../components/schedule_item_form/index.tsx | 30 +- .../rules/components/status_icon/index.tsx | 2 +- .../rules/components/step_about_rule/data.ts | 28 -- .../rules/components/step_about_rule/data.tsx | 43 +++ .../step_about_rule/default_value.ts | 20 +- .../components/step_about_rule/helpers.ts | 16 + .../components/step_about_rule/index.tsx | 62 ++-- .../components/step_about_rule/schema.tsx | 44 ++- .../step_about_rule/translations.ts | 7 + .../components/step_define_rule/index.tsx | 123 ++++---- .../components/step_define_rule/schema.tsx | 21 +- .../step_define_rule/translations.tsx | 22 ++ .../components/step_schedule_rule/index.tsx | 2 - .../detection_engine/rules/create/helpers.ts | 18 +- .../detection_engine/rules/create/index.tsx | 106 ++++--- .../rules/create/translations.ts | 4 + .../detection_engine/rules/details/index.tsx | 7 +- .../detection_engine/rules/edit/index.tsx | 7 +- .../pages/detection_engine/rules/helpers.tsx | 5 +- .../pages/detection_engine/rules/index.tsx | 12 +- .../pages/detection_engine/rules/types.ts | 5 +- .../routes/__mocks__/request_responses.ts | 2 + .../routes/index/signals_mapping.json | 3 + .../routes/rules/create_rules_bulk_route.ts | 2 + .../routes/rules/create_rules_route.ts | 2 + .../routes/rules/update_rules_bulk_route.ts | 2 + .../routes/rules/update_rules_route.ts | 2 + .../routes/rules/utils.test.ts | 11 + .../detection_engine/routes/rules/utils.ts | 1 + .../add_prepackaged_rules_schema.test.ts | 130 ++++++++- .../schemas/add_prepackaged_rules_schema.ts | 2 + .../schemas/create_rules_schema.test.ts | 120 +++++++- .../routes/schemas/create_rules_schema.ts | 2 + .../routes/schemas/schemas.ts | 5 + .../schemas/update_rules_schema.test.ts | 96 +++++- .../routes/schemas/update_rules_schema.ts | 2 + .../detection_engine/rules/create_rules.ts | 2 + .../rules/install_prepacked_rules.ts | 2 + .../detection_engine/rules/update_rules.ts | 2 + .../rules/queries/query_timelineid.json | 3 +- .../rules/queries/query_with_everything.json | 1 + .../saved_query_with_everything.json | 3 +- .../updates/update_query_everything.json | 1 + .../rules/updates/update_timelineid.json | 3 +- .../signals/__mocks__/es_results.ts | 1 + .../detection_engine/signals/build_rule.ts | 1 + .../signals/signal_rule_alert_type.ts | 1 + .../siem/server/lib/detection_engine/types.ts | 3 + 84 files changed, 2169 insertions(+), 679 deletions(-) create mode 100644 src/plugins/es_ui_shared/static/forms/components/fields/super_select_field.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/types.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/helpers.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/translations.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/helpers.ts diff --git a/src/plugins/es_ui_shared/static/forms/components/field.tsx b/src/plugins/es_ui_shared/static/forms/components/field.tsx index 89dea53d75b38..5b9a6dc9de002 100644 --- a/src/plugins/es_ui_shared/static/forms/components/field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/field.tsx @@ -37,6 +37,7 @@ import { RadioGroupField, RangeField, SelectField, + SuperSelectField, ToggleField, } from './fields'; @@ -50,6 +51,7 @@ const mapTypeToFieldComponent = { [FIELD_TYPES.RADIO_GROUP]: RadioGroupField, [FIELD_TYPES.RANGE]: RangeField, [FIELD_TYPES.SELECT]: SelectField, + [FIELD_TYPES.SUPER_SELECT]: SuperSelectField, [FIELD_TYPES.TOGGLE]: ToggleField, }; diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/index.ts b/src/plugins/es_ui_shared/static/forms/components/fields/index.ts index f973bb7b04d34..35635d0e8530c 100644 --- a/src/plugins/es_ui_shared/static/forms/components/fields/index.ts +++ b/src/plugins/es_ui_shared/static/forms/components/fields/index.ts @@ -25,5 +25,6 @@ export * from './multi_select_field'; export * from './radio_group_field'; export * from './range_field'; export * from './select_field'; +export * from './super_select_field'; export * from './toggle_field'; export * from './text_area_field'; diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/super_select_field.tsx b/src/plugins/es_ui_shared/static/forms/components/fields/super_select_field.tsx new file mode 100644 index 0000000000000..9b29d75230d7a --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/components/fields/super_select_field.tsx @@ -0,0 +1,58 @@ +/* + * 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 React from 'react'; +import { EuiFormRow, EuiSuperSelect } from '@elastic/eui'; + +import { FieldHook, getFieldValidityAndErrorMessage } from '../../hook_form_lib'; + +interface Props { + field: FieldHook; + euiFieldProps?: Record; + idAria?: string; + [key: string]: any; +} + +export const SuperSelectField = ({ field, euiFieldProps = {}, ...rest }: Props) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + return ( + + { + field.setValue(value); + }} + options={[]} + isInvalid={isInvalid} + data-test-subj="select" + {...euiFieldProps} + /> + + ); +}; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts index df2807e59ab46..4056947483107 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts @@ -28,6 +28,7 @@ export const FIELD_TYPES = { RADIO_GROUP: 'radioGroup', RANGE: 'range', SELECT: 'select', + SUPER_SELECT: 'superSelect', MULTI_SELECT: 'multiSelect', }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts index 91480f20d8b00..41e13408c1e01 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts @@ -181,6 +181,7 @@ export interface QueryTimelineById { apolloClient: ApolloClient | ApolloClient<{}> | undefined; duplicate: boolean; timelineId: string; + onOpenTimeline?: (timeline: TimelineModel) => void; openTimeline?: boolean; updateIsLoading: ActionCreator<{ id: string; isLoading: boolean }>; updateTimeline: DispatchUpdateTimeline; @@ -190,6 +191,7 @@ export const queryTimelineById = ({ apolloClient, duplicate = false, timelineId, + onOpenTimeline, openTimeline = true, updateIsLoading, updateTimeline, @@ -209,7 +211,9 @@ export const queryTimelineById = ({ ); const { timeline, notes } = formatTimelineResultToModel(timelineToOpen, duplicate); - if (updateTimeline) { + if (onOpenTimeline != null) { + onOpenTimeline(timeline); + } else if (updateTimeline) { updateTimeline({ duplicate, from: getOr(getDefaultFromValue(), 'dateRange.start', timeline), 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 c22c5fdbcfbc5..a97cfefaf0393 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 @@ -12,18 +12,20 @@ import { Dispatch } from 'redux'; import { defaultHeaders } from '../../components/timeline/body/column_headers/default_headers'; import { deleteTimelineMutation } from '../../containers/timeline/delete/persist.gql_query'; import { AllTimelinesVariables, AllTimelinesQuery } from '../../containers/timeline/all'; - import { allTimelinesQuery } from '../../containers/timeline/all/index.gql_query'; import { DeleteTimelineMutation, SortFieldTimeline, Direction } from '../../graphql/types'; import { State, timelineSelectors } from '../../store'; +import { timelineDefaults, TimelineModel } from '../../store/timeline/model'; import { createTimeline as dispatchCreateNewTimeline, updateIsLoading as dispatchUpdateIsLoading, } from '../../store/timeline/actions'; +import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { OpenTimeline } from './open_timeline'; import { OPEN_TIMELINE_CLASS_NAME, queryTimelineById, dispatchUpdateTimeline } from './helpers'; import { OpenTimelineModalBody } from './open_timeline_modal/open_timeline_modal_body'; import { + ActionTimelineToShow, DeleteTimelines, EuiSearchBarQuery, OnDeleteSelected, @@ -41,14 +43,14 @@ import { OpenTimelineReduxProps, } from './types'; import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants'; -import { ColumnHeader } from '../timeline/body/column_headers/column_header'; -import { timelineDefaults } from '../../store/timeline/model'; interface OwnProps { apolloClient: ApolloClient; /** Displays open timeline in modal */ isModal: boolean; closeModalTimeline?: () => void; + hideActions?: ActionTimelineToShow[]; + onOpenTimeline?: (timeline: TimelineModel) => void; } export type OpenTimelineOwnProps = OwnProps & @@ -69,15 +71,17 @@ export const getSelectedTimelineIds = (selectedItems: OpenTimelineResult[]): str /** Manages the state (e.g table selection) of the (pure) `OpenTimeline` component */ export const StatefulOpenTimelineComponent = React.memo( ({ + apolloClient, + closeModalTimeline, + createNewTimeline, defaultPageSize, + hideActions = [], isModal = false, + onOpenTimeline, + timeline, title, - apolloClient, - closeModalTimeline, updateTimeline, updateIsLoading, - timeline, - createNewTimeline, }) => { /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ const [itemIdToExpandedNotesRowMap, setItemIdToExpandedNotesRowMap] = useState< @@ -212,6 +216,7 @@ export const StatefulOpenTimelineComponent = React.memo( queryTimelineById({ apolloClient, duplicate, + onOpenTimeline, timelineId, updateIsLoading, updateTimeline, @@ -286,6 +291,7 @@ export const StatefulOpenTimelineComponent = React.memo( data-test-subj={'open-timeline-modal'} deleteTimelines={onDeleteOneTimeline} defaultPageSize={defaultPageSize} + hideActions={hideActions} isLoading={loading} itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} onAddTimelinesToFavorites={undefined} 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 1ed08eee633ab..a1ca7812bba34 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 @@ -143,7 +143,7 @@ describe('OpenTimeline', () => { ).toBe(true); }); - test('it shows extended columns and actions when onDeleteSelected and deleteTimelines are specified', () => { + test('it shows the delete action columns when onDeleteSelected and deleteTimelines are specified', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(true); + expect(props.actionTimelineToShow).toContain('delete'); }); - test('it does NOT show extended columns and actions when is onDeleteSelected undefined and deleteTimelines is specified', () => { + test('it does NOT show the delete action columns when is onDeleteSelected undefined and deleteTimelines is specified', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); - test('it does NOT show extended columns and actions when is onDeleteSelected provided and deleteTimelines is undefined', () => { + test('it does NOT show the delete action columns when is onDeleteSelected provided and deleteTimelines is undefined', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); - test('it does NOT show extended columns and actions when both onDeleteSelected and deleteTimelines are undefined', () => { + test('it does NOT show the delete action when both onDeleteSelected and deleteTimelines are undefined', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); }); 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 59ccfc8b250aa..8aab02b495392 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 @@ -57,6 +57,11 @@ export const OpenTimeline = React.memo( /> ( pageIndex={pageIndex} pageSize={pageSize} searchResults={searchResults} - showExtendedColumnsAndActions={onDeleteSelected != null && deleteTimelines != null} + showExtendedColumns={true} sortDirection={sortDirection} sortField={sortField} totalSearchResultsCount={totalSearchResultsCount} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx index cd89eb8aad6f4..c530929a3c96e 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx @@ -7,39 +7,49 @@ import { EuiModal, EuiOverlayMask } from '@elastic/eui'; import React from 'react'; +import { TimelineModel } from '../../../store/timeline/model'; import { useApolloClient } from '../../../utils/apollo_context'; + import * as i18n from '../translations'; +import { ActionTimelineToShow } from '../types'; import { StatefulOpenTimeline } from '..'; export interface OpenTimelineModalProps { onClose: () => void; + hideActions?: ActionTimelineToShow[]; + modalTitle?: string; + onOpen?: (timeline: TimelineModel) => void; } const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; const OPEN_TIMELINE_MODAL_WIDTH = 1000; // px -export const OpenTimelineModal = React.memo(({ onClose }) => { - const apolloClient = useApolloClient(); - - if (!apolloClient) return null; - - return ( - - - - - - ); -}); +export const OpenTimelineModal = React.memo( + ({ hideActions = [], modalTitle, onClose, onOpen }) => { + const apolloClient = useApolloClient(); + + if (!apolloClient) return null; + + return ( + + + + + + ); + } +); OpenTimelineModal.displayName = 'OpenTimelineModal'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx index 1010504c0acac..2c3adb138b7ac 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx @@ -143,7 +143,7 @@ describe('OpenTimelineModal', () => { ).toBe(true); }); - test('it shows extended columns and actions when onDeleteSelected and deleteTimelines are specified', () => { + test('it shows the delete action when onDeleteSelected and deleteTimelines are specified', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(true); + expect(props.actionTimelineToShow).toContain('delete'); }); - test('it does NOT show extended columns and actions when is onDeleteSelected undefined and deleteTimelines is specified', () => { + test('it does NOT show the delete when is onDeleteSelected undefined and deleteTimelines is specified', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); - test('it does NOT show extended columns and actions when is onDeleteSelected provided and deleteTimelines is undefined', () => { + test('it does NOT show the delete action when is onDeleteSelected provided and deleteTimelines is undefined', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); - test('it does NOT show extended columns and actions when both onDeleteSelected and deleteTimelines are undefined', () => { + test('it does NOT show extended columns when both onDeleteSelected and deleteTimelines are undefined', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); }); 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 e28725973aff2..dcd0b37770583 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 @@ -5,10 +5,10 @@ */ import { EuiModalBody, EuiModalHeader } from '@elastic/eui'; -import React from 'react'; +import React, { memo, useMemo } from 'react'; import styled from 'styled-components'; -import { OpenTimelineProps } from '../types'; +import { OpenTimelineProps, ActionTimelineToShow } from '../types'; import { SearchRow } from '../search_row'; import { TimelinesTable } from '../timelines_table'; import { TitleRow } from '../title_row'; @@ -19,10 +19,11 @@ export const HeaderContainer = styled.div` HeaderContainer.displayName = 'HeaderContainer'; -export const OpenTimelineModalBody = React.memo( +export const OpenTimelineModalBody = memo( ({ deleteTimelines, defaultPageSize, + hideActions = [], isLoading, itemIdToExpandedNotesRowMap, onAddTimelinesToFavorites, @@ -43,51 +44,61 @@ export const OpenTimelineModalBody = React.memo( sortField, title, totalSearchResultsCount, - }) => ( - <> - - - + }) => { + const actionsToShow = useMemo(() => { + const actions: ActionTimelineToShow[] = + onDeleteSelected != null && deleteTimelines != null + ? ['delete', 'duplicate'] + : ['duplicate']; + return actions.filter(action => !hideActions.includes(action)); + }, [onDeleteSelected, deleteTimelines, hideActions]); + return ( + <> + + + + + + + - + - - - - - - - - ) + + + ); + } ); OpenTimelineModalBody.displayName = 'OpenTimelineModalBody'; 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 89d6b4befa787..eec11f571328f 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 @@ -27,10 +27,11 @@ describe('#getActionsColumns', () => { mockResults = cloneDeep(mockTimelineResults); }); - test('it renders the delete timeline (trash icon) when showDeleteAction is true (because showExtendedColumnsAndActions is true)', () => { + test('it renders the delete timeline (trash icon) when actionTimelineToShow is including the action delete', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -53,10 +54,11 @@ describe('#getActionsColumns', () => { expect(wrapper.find('[data-test-subj="delete-timeline"]').exists()).toBe(true); }); - test('it does NOT render the delete timeline (trash icon) when showDeleteAction is false (because showExtendedColumnsAndActions is false)', () => { + test('it does NOT render the delete timeline (trash icon) when actionTimelineToShow is NOT including the action delete', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -79,10 +81,65 @@ describe('#getActionsColumns', () => { expect(wrapper.find('[data-test-subj="delete-timeline"]').exists()).toBe(false); }); + test('it renders the duplicate icon timeline when actionTimelineToShow is including the action duplicate', () => { + const wrapper = mountWithIntl( + + + + ); + + expect(wrapper.find('[data-test-subj="open-duplicate"]').exists()).toBe(true); + }); + + test('it does NOT render the duplicate timeline when actionTimelineToShow is NOT including the action duplicate)', () => { + const wrapper = mountWithIntl( + + + + ); + + expect(wrapper.find('[data-test-subj="open-duplicate"]').exists()).toBe(false); + }); + test('it does NOT render the delete timeline (trash icon) when deleteTimelines is not provided', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -111,6 +168,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingSavedObjectId.length} @@ -141,6 +199,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -174,6 +233,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} 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 51dba21ac225c..2b8bd3339cca2 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 @@ -12,19 +12,24 @@ import React from 'react'; import { ACTION_COLUMN_WIDTH } from './common_styles'; import { DeleteTimelineModalButton } from '../delete_timeline_modal'; import * as i18n from '../translations'; -import { DeleteTimelines, OnOpenTimeline, OpenTimelineResult } from '../types'; +import { + ActionTimelineToShow, + DeleteTimelines, + OnOpenTimeline, + OpenTimelineResult, +} from '../types'; /** * Returns the action columns (e.g. delete, open duplicate timeline) */ export const getActionsColumns = ({ + actionTimelineToShow, onOpenTimeline, deleteTimelines, - showDeleteAction, }: { + actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; onOpenTimeline: OnOpenTimeline; - showDeleteAction: boolean; }) => { const openAsDuplicateColumn = { align: 'center', @@ -67,7 +72,10 @@ export const getActionsColumns = ({ width: ACTION_COLUMN_WIDTH, }; - return showDeleteAction && deleteTimelines != null - ? [openAsDuplicateColumn, deleteTimelineColumn] - : [openAsDuplicateColumn]; + return [ + actionTimelineToShow.includes('duplicate') ? openAsDuplicateColumn : 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 559ee4a7bb494..0f2cda9d79f0b 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 @@ -37,6 +37,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={hasNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={hasNotes.length} @@ -63,6 +64,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingNotes.length} @@ -89,6 +91,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={nullNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={nullNotes.length} @@ -115,6 +118,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={emptylNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={emptylNotes.length} @@ -143,6 +147,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingSavedObjectId.length} @@ -169,6 +174,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={nullSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={nullSavedObjectId.length} @@ -195,6 +201,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={hasNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={hasNotes.length} @@ -231,6 +238,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={hasNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={hasNotes.length} @@ -269,6 +277,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={hasNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={hasNotes.length} @@ -311,6 +320,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={hasNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={hasNotes.length} @@ -346,6 +356,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -377,6 +388,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -411,6 +423,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingSavedObjectId.length} @@ -442,6 +455,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingTitle} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingTitle.length} @@ -475,6 +489,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={withMissingSavedObjectIdAndTitle} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={withMissingSavedObjectIdAndTitle.length} @@ -508,6 +523,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={withJustWhitespaceTitle} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={withJustWhitespaceTitle.length} @@ -541,6 +557,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={withMissingSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={withMissingSavedObjectId.length} @@ -571,6 +588,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -605,6 +623,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingSavedObjectId.length} @@ -637,6 +656,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -673,6 +693,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -704,6 +725,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -737,6 +759,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingDescription} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingDescription.length} @@ -771,6 +794,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={justWhitespaceDescription} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={justWhitespaceDescription.length} @@ -803,6 +827,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -834,6 +859,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -868,6 +894,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingUpdated} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingUpdated.length} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx index 0b51bd78283c5..0d3a73a389050 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx @@ -27,11 +27,9 @@ export const getCommonColumns = ({ itemIdToExpandedNotesRowMap, onOpenTimeline, onToggleShowNotes, - showExtendedColumnsAndActions, }: { onOpenTimeline: OnOpenTimeline; onToggleShowNotes: OnToggleShowNotes; - showExtendedColumnsAndActions: boolean; itemIdToExpandedNotesRowMap: Record; }) => [ { 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 bc88603721e8a..4cbe1e45c473b 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 @@ -35,6 +35,7 @@ describe('#getExtendedColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -66,6 +67,7 @@ describe('#getExtendedColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -99,6 +101,7 @@ describe('#getExtendedColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingUpdatedBy} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingUpdatedBy.length} 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 26836787efab1..31377d176acac 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 @@ -30,6 +30,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -57,6 +58,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={with6Events} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={with6Events.length} @@ -82,6 +84,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -109,6 +112,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={with4Notes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={with4Notes.length} @@ -134,6 +138,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -161,6 +166,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={undefinedFavorite} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={undefinedFavorite.length} @@ -187,6 +193,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={nullFavorite} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={nullFavorite.length} @@ -213,6 +220,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={emptyFavorite} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={emptyFavorite.length} @@ -249,6 +257,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={emptyFavorite} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={emptyFavorite.length} @@ -289,6 +298,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={emptyFavorite} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={emptyFavorite.length} 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 7d947cb28e9ec..26d9607a91fcd 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 @@ -28,10 +28,11 @@ describe('TimelinesTable', () => { mockResults = cloneDeep(mockTimelineResults); }); - test('it renders the select all timelines header checkbox when showExtendedColumnsAndActions is true', () => { + test('it renders the select all timelines header checkbox when actionTimelineToShow has the action selectable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -59,10 +60,11 @@ describe('TimelinesTable', () => { ).toBe(true); }); - test('it does NOT render the select all timelines header checkbox when showExtendedColumnsAndActions is false', () => { + test('it does NOT render the select all timelines header checkbox when actionTimelineToShow has not the action selectable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -90,10 +92,11 @@ describe('TimelinesTable', () => { ).toBe(false); }); - test('it renders the Modified By column when showExtendedColumnsAndActions is true ', () => { + test('it renders the Modified By column when showExtendedColumns is true ', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -121,10 +124,11 @@ describe('TimelinesTable', () => { ).toContain(i18n.MODIFIED_BY); }); - test('it renders the notes column in the position of the Modified By column when showExtendedColumnsAndActions is false', () => { + test('it renders the notes column in the position of the Modified By column when showExtendedColumns is false', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -148,16 +152,17 @@ describe('TimelinesTable', () => { wrapper .find('thead tr th') .at(5) - .find('[data-test-subj="notes-count-header-icon"]') + .find('svg[data-test-subj="notes-count-header-icon"]') .first() .exists() ).toBe(true); }); - test('it renders the delete timeline (trash icon) when showExtendedColumnsAndActions is true', () => { + test('it renders the delete timeline (trash icon) when actionTimelineToShow has the delete action', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -185,10 +190,11 @@ describe('TimelinesTable', () => { ).toBe(true); }); - test('it does NOT render the delete timeline (trash icon) when showExtendedColumnsAndActions is false', () => { + test('it does NOT render the delete timeline (trash icon) when actionTimelineToShow has NOT the delete action', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -216,10 +222,11 @@ describe('TimelinesTable', () => { ).toBe(false); }); - test('it renders the rows per page selector when showExtendedColumnsAndActions is true', () => { + test('it renders the rows per page selector when showExtendedColumns is true', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -247,10 +254,11 @@ describe('TimelinesTable', () => { ).toBe(true); }); - test('it does NOT render the rows per page selector when showExtendedColumnsAndActions is false', () => { + test('it does NOT render the rows per page selector when showExtendedColumns is false', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -284,6 +292,7 @@ describe('TimelinesTable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={defaultPageSize} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -311,10 +320,11 @@ describe('TimelinesTable', () => { ).toEqual('Rows per page: 123'); }); - test('it sorts the Last Modified column in descending order when showExtendedColumnsAndActions is true ', () => { + test('it sorts the Last Modified column in descending order when showExtendedColumns is true ', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -342,10 +352,11 @@ describe('TimelinesTable', () => { ).toContain(i18n.LAST_MODIFIED); }); - test('it sorts the Last Modified column in descending order when showExtendedColumnsAndActions is false ', () => { + test('it sorts the Last Modified column in descending order when showExtendedColumns is false ', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -376,6 +387,7 @@ describe('TimelinesTable', () => { test('it displays the expected message when no search results are found', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={[]} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={0} @@ -408,6 +420,7 @@ describe('TimelinesTable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -446,6 +459,7 @@ describe('TimelinesTable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -479,6 +493,7 @@ describe('TimelinesTable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -510,6 +525,7 @@ describe('TimelinesTable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} 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 ce88ade01d2ef..f09a9f6af048b 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 @@ -10,6 +10,7 @@ import styled from 'styled-components'; import * as i18n from '../translations'; import { + ActionTimelineToShow, DeleteTimelines, OnOpenTimeline, OnSelectionChange, @@ -36,8 +37,8 @@ const BasicTable = styled(EuiBasicTable)` `; BasicTable.displayName = 'BasicTable'; -const getExtendedColumnsIfEnabled = (showExtendedColumnsAndActions: boolean) => - showExtendedColumnsAndActions ? [...getExtendedColumns()] : []; +const getExtendedColumnsIfEnabled = (showExtendedColumns: boolean) => + showExtendedColumns ? [...getExtendedColumns()] : []; /** * Returns the column definitions (passed as the `columns` prop to @@ -46,34 +47,36 @@ const getExtendedColumnsIfEnabled = (showExtendedColumnsAndActions: boolean) => * `Timelines` page */ const getTimelinesTableColumns = ({ + actionTimelineToShow, deleteTimelines, itemIdToExpandedNotesRowMap, onOpenTimeline, onToggleShowNotes, - showExtendedColumnsAndActions, + showExtendedColumns, }: { + actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; itemIdToExpandedNotesRowMap: Record; onOpenTimeline: OnOpenTimeline; onToggleShowNotes: OnToggleShowNotes; - showExtendedColumnsAndActions: boolean; + showExtendedColumns: boolean; }) => [ ...getCommonColumns({ itemIdToExpandedNotesRowMap, onOpenTimeline, onToggleShowNotes, - showExtendedColumnsAndActions, }), - ...getExtendedColumnsIfEnabled(showExtendedColumnsAndActions), + ...getExtendedColumnsIfEnabled(showExtendedColumns), ...getIconHeaderColumns(), ...getActionsColumns({ deleteTimelines, onOpenTimeline, - showDeleteAction: showExtendedColumnsAndActions, + actionTimelineToShow, }), ]; export interface TimelinesTableProps { + actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; defaultPageSize: number; loading: boolean; @@ -85,7 +88,7 @@ export interface TimelinesTableProps { pageIndex: number; pageSize: number; searchResults: OpenTimelineResult[]; - showExtendedColumnsAndActions: boolean; + showExtendedColumns: boolean; sortDirection: 'asc' | 'desc'; sortField: string; totalSearchResultsCount: number; @@ -97,6 +100,7 @@ export interface TimelinesTableProps { */ export const TimelinesTable = React.memo( ({ + actionTimelineToShow, deleteTimelines, defaultPageSize, loading: isLoading, @@ -108,13 +112,13 @@ export const TimelinesTable = React.memo( pageIndex, pageSize, searchResults, - showExtendedColumnsAndActions, + showExtendedColumns, sortField, sortDirection, totalSearchResultsCount, }) => { const pagination = { - hidePerPageOptions: !showExtendedColumnsAndActions, + hidePerPageOptions: !showExtendedColumns, pageIndex, pageSize, pageSizeOptions: [ @@ -142,16 +146,17 @@ export const TimelinesTable = React.memo( return ( ( noItemsMessage={i18n.ZERO_TIMELINES_MATCH} onChange={onTableChange} pagination={pagination} - selection={showExtendedColumnsAndActions ? selection : undefined} + selection={actionTimelineToShow.includes('selectable') ? selection : undefined} sorting={sorting} /> ); 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 7bbefb9efa99e..e5e85ccf0954a 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 @@ -95,6 +95,8 @@ 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 interface OpenTimelineProps { /** Invoked when the user clicks the delete (trash) icon on an individual timeline */ deleteTimelines?: DeleteTimelines; @@ -140,6 +142,8 @@ export interface OpenTimelineProps { title: string; /** The total (server-side) count of the search results */ totalSearchResultsCount: number; + /** Hide action on timeline if needed it */ + hideActions?: ActionTimelineToShow[]; } export interface UpdateTimeline { diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx new file mode 100644 index 0000000000000..ac47b352a6276 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx @@ -0,0 +1,276 @@ +/* + * 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 { + EuiHighlight, + EuiInputPopover, + EuiSuperSelect, + EuiSelectable, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiTextColor, + EuiFilterButton, + EuiFilterGroup, + EuiSpacer, +} from '@elastic/eui'; +import { Option } from '@elastic/eui/src/components/selectable/types'; +import { isEmpty } from 'lodash/fp'; +import React, { memo, useCallback, useMemo, useState } from 'react'; +import { ListProps } from 'react-virtualized'; +import styled, { createGlobalStyle } from 'styled-components'; + +import { AllTimelinesQuery } from '../../../containers/timeline/all'; +import { getEmptyTagValue } from '../../empty_value'; +import { isUntitled } from '../../../components/open_timeline/helpers'; +import * as i18nTimeline from '../../../components/open_timeline/translations'; +import { SortFieldTimeline, Direction } from '../../../graphql/types'; +import * as i18n from './translations'; + +const SearchTimelineSuperSelectGlobalStyle = createGlobalStyle` + .euiPopover__panel.euiPopover__panel-isOpen.timeline-search-super-select-popover__popoverPanel { + visibility: hidden; + z-index: 0; + } +`; + +const MyEuiHighlight = styled(EuiHighlight)<{ selected: boolean }>` + padding-left: ${({ selected }) => (selected ? '3px' : '0px')}; +`; + +const MyEuiTextColor = styled(EuiTextColor)<{ selected: boolean }>` + padding-left: ${({ selected }) => (selected ? '20px' : '0px')}; +`; + +interface SearchTimelineSuperSelectProps { + isDisabled: boolean; + timelineId: string | null; + timelineTitle: string | null; + onTimelineChange: (timelineTitle: string, timelineId: string | null) => void; +} + +const basicSuperSelectOptions = [ + { + value: '-1', + inputDisplay: i18n.DEFAULT_TIMELINE_TITLE, + }, +]; + +const getBasicSelectableOptions = (timelineId: string) => [ + { + description: i18n.DEFAULT_TIMELINE_DESCRIPTION, + label: i18n.DEFAULT_TIMELINE_TITLE, + id: null, + title: i18n.DEFAULT_TIMELINE_TITLE, + checked: timelineId === '-1' ? 'on' : undefined, + } as Option, +]; + +const ORIGINAL_PAGE_SIZE = 50; +const POPOVER_HEIGHT = 260; +const TIMELINE_ITEM_HEIGHT = 50; +const SearchTimelineSuperSelectComponent: React.FC = ({ + isDisabled, + timelineId, + timelineTitle, + onTimelineChange, +}) => { + const [pageSize, setPageSize] = useState(ORIGINAL_PAGE_SIZE); + const [heightTrigger, setHeightTrigger] = useState(0); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [searchTimelineValue, setSearchTimelineValue] = useState(''); + const [onlyFavorites, setOnlyFavorites] = useState(false); + + const onSearchTimeline = useCallback(val => { + setSearchTimelineValue(val); + }, []); + + const handleClosePopover = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const handleOpenPopover = useCallback(() => { + setIsPopoverOpen(true); + }, []); + + const handleOnToggleOnlyFavorites = useCallback(() => { + setOnlyFavorites(!onlyFavorites); + }, [onlyFavorites]); + + const renderTimelineOption = useCallback((option, searchValue) => { + return ( + <> + {option.checked === 'on' && } + + {isUntitled(option) ? i18nTimeline.UNTITLED_TIMELINE : option.title} + +
+ + + {option.description != null && option.description.trim().length > 0 + ? option.description + : getEmptyTagValue()} + + + + ); + }, []); + + const handleTimelineChange = useCallback(options => { + const selectedTimeline = options.filter( + (option: { checked: string }) => option.checked === 'on' + ); + if (selectedTimeline != null && selectedTimeline.length > 0 && onTimelineChange != null) { + onTimelineChange( + isEmpty(selectedTimeline[0].title) + ? i18nTimeline.UNTITLED_TIMELINE + : selectedTimeline[0].title, + selectedTimeline[0].id + ); + } + setIsPopoverOpen(false); + }, []); + + const handleOnScroll = useCallback( + ( + totalTimelines: number, + totalCount: number, + { + clientHeight, + scrollHeight, + scrollTop, + }: { + clientHeight: number; + scrollHeight: number; + scrollTop: number; + } + ) => { + if (totalTimelines < totalCount) { + const clientHeightTrigger = clientHeight * 1.2; + if ( + scrollTop > 10 && + scrollHeight - scrollTop < clientHeightTrigger && + scrollHeight > heightTrigger + ) { + setHeightTrigger(scrollHeight); + setPageSize(pageSize + ORIGINAL_PAGE_SIZE); + } + } + }, + [heightTrigger, pageSize] + ); + + const superSelect = useMemo( + () => ( + + ), + [handleOpenPopover, isDisabled, timelineId, timelineTitle] + ); + + return ( + + + {({ timelines, loading, totalCount }) => ( + <> + + + + + {i18nTimeline.ONLY_FAVORITES} + + + + + + + ({ + description: t.description, + label: t.title, + id: t.savedObjectId, + key: `${t.title}-${index}`, + title: t.title, + checked: t.savedObjectId === timelineId ? 'on' : undefined, + } as Option) + ), + ]} + > + {(list, search) => ( + <> + {search} + {list} + + )} + + + )} + + + + ); +}; + +export const SearchTimelineSuperSelect = memo(SearchTimelineSuperSelectComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/translations.ts b/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/translations.ts new file mode 100644 index 0000000000000..bffee407bc999 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/translations.ts @@ -0,0 +1,25 @@ +/* + * 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'; + +export const DEFAULT_TIMELINE_TITLE = i18n.translate('xpack.siem.timeline.defaultTimelineTitle', { + defaultMessage: 'Default blank timeline', +}); + +export const DEFAULT_TIMELINE_DESCRIPTION = i18n.translate( + 'xpack.siem.timeline.defaultTimelineDescription', + { + defaultMessage: 'Timeline offered by default when creating new timeline.', + } +); + +export const SEARCH_BOX_TIMELINE_PLACEHOLDER = i18n.translate( + 'xpack.siem.timeline.searchBoxPlaceholder', + { + defaultMessage: 'e.g. timeline name or description', + } +); 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 9f3cba7189fb1..655299c4a2a34 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 @@ -70,8 +70,8 @@ export const RuleSchema = t.intersection([ risk_score: t.number, rule_id: t.string, severity: t.string, - type: t.string, tags: t.array(t.string), + type: t.string, to: t.string, threats: t.array(t.unknown), updated_at: t.string, @@ -79,6 +79,8 @@ export const RuleSchema = t.intersection([ }), t.partial({ saved_id: t.string, + timeline_id: t.string, + timeline_title: t.string, }), ]); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx index e8a2c98a94a56..9c95c74cd62a5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx @@ -56,13 +56,13 @@ export const DetectionEngineContainer = React.memo(() => { - + - + - + diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx index f176109b1d7a5..469745262d944 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx @@ -17,7 +17,7 @@ import { import { Action } from './reducer'; export const editRuleAction = (rule: Rule, history: H.History) => { - history.push(`/${DETECTION_ENGINE_PAGE_NAME}/rules/${rule.id}/edit`); + history.push(`/${DETECTION_ENGINE_PAGE_NAME}/rules/id/${rule.id}/edit`); }; export const runRuleAction = () => {}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts index 1909b75a85835..f5d3955314242 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts @@ -13,7 +13,7 @@ export const formatRules = (rules: Rule[], selectedIds?: string[]): TableData[] id: rule.id, rule_id: rule.rule_id, rule: { - href: `#/detection-engine/rules/${encodeURIComponent(rule.id)}`, + href: `#/detection-engine/rules/id/${encodeURIComponent(rule.id)}`, name: rule.name, status: 'Status Placeholder', }, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx index f090f6d97eaf9..725c7eeeedcfe 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx @@ -4,9 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonEmpty, EuiButtonIcon, EuiFormRow, EuiFieldText, EuiSpacer } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldText, + EuiSpacer, +} from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React, { ChangeEvent, useCallback, useEffect, useState, useRef } from 'react'; +import styled from 'styled-components'; import * as RuleI18n from '../../translations'; import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; @@ -17,11 +26,26 @@ interface AddItemProps { dataTestSubj: string; idAria: string; isDisabled: boolean; + validate?: (args: unknown) => boolean; } -export const AddItem = ({ addText, dataTestSubj, field, idAria, isDisabled }: AddItemProps) => { +const MyEuiFormRow = styled(EuiFormRow)` + .euiFormRow__labelWrapper { + .euiText { + padding-right: 32px; + } + } +`; + +export const AddItem = ({ + addText, + dataTestSubj, + field, + idAria, + isDisabled, + validate, +}: AddItemProps) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - // const [items, setItems] = useState(['']); const [haveBeenKeyboardDeleted, setHaveBeenKeyboardDeleted] = useState(-1); const inputsRef = useRef([]); @@ -104,7 +128,7 @@ export const AddItem = ({ addText, dataTestSubj, field, idAria, isDisabled }: Ad const values = field.value as string[]; return ( - - + + updateItem(e, index)} fullWidth {...euiFieldProps} /> + + removeItem(index)} aria-label={RuleI18n.DELETE} /> - } - onChange={e => updateItem(e, index)} - compressed - fullWidth - {...euiFieldProps} - /> + + + {values.length - 1 !== index && }
); })} - + {addText} - + ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx new file mode 100644 index 0000000000000..09d0c1131ea10 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx @@ -0,0 +1,222 @@ +/* + * 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 { + EuiBadge, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiLink, + EuiText, + EuiListGroup, +} from '@elastic/eui'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { isEmpty } from 'lodash/fp'; +import React from 'react'; +import styled from 'styled-components'; + +import { esFilters } from '../../../../../../../../../../src/plugins/data/public'; + +import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques'; + +import { FilterLabel } from './filter_label'; +import * as i18n from './translations'; +import { BuildQueryBarDescription, BuildThreatsDescription, ListItems } from './types'; + +const EuiBadgeWrap = styled(EuiBadge)` + .euiBadge__text { + white-space: pre-wrap !important; + } +`; + +export const buildQueryBarDescription = ({ + field, + filters, + filterManager, + query, + savedId, + indexPatterns, +}: BuildQueryBarDescription): ListItems[] => { + let items: ListItems[] = []; + if (!isEmpty(filters)) { + filterManager.setFilters(filters); + items = [ + ...items, + { + title: <>{i18n.FILTERS_LABEL} , + description: ( + + {filterManager.getFilters().map((filter, index) => ( + + + {indexPatterns != null ? ( + + ) : ( + + )} + + + ))} + + ), + }, + ]; + } + if (!isEmpty(query.query)) { + items = [ + ...items, + { + title: <>{i18n.QUERY_LABEL} , + description: <>{query.query} , + }, + ]; + } + if (!isEmpty(savedId)) { + items = [ + ...items, + { + title: <>{i18n.SAVED_ID_LABEL} , + description: <>{savedId} , + }, + ]; + } + return items; +}; + +const ThreatsEuiFlexGroup = styled(EuiFlexGroup)` + .euiFlexItem { + margin-bottom: 0px; + } +`; + +const MyEuiListGroup = styled(EuiListGroup)` + padding: 0px; + .euiListGroupItem__button { + padding: 0px; + } +`; + +export const buildThreatsDescription = ({ + label, + threats, +}: BuildThreatsDescription): ListItems[] => { + if (threats.length > 0) { + return [ + { + title: label, + description: ( + + {threats.map((threat, index) => { + const tactic = tacticsOptions.find(t => t.name === threat.tactic.name); + return ( + + +
+ + {tactic != null ? tactic.text : ''} + +
+ { + const myTechnique = techniquesOptions.find(t => t.name === technique.name); + return { + label: myTechnique != null ? myTechnique.label : '', + href: technique.reference, + target: '_blank', + }; + })} + /> +
+
+ ); + })} +
+ ), + }, + ]; + } + return []; +}; + +export const buildStringArrayDescription = ( + label: string, + field: string, + values: string[] +): ListItems[] => { + if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) { + return [ + { + title: label, + description: ( + + {values.map((val: string) => + isEmpty(val) ? null : ( + + {val} + + ) + )} + + ), + }, + ]; + } + return []; +}; + +export const buildSeverityDescription = (label: string, value: string): ListItems[] => { + return [ + { + title: label, + description: ( + + {value} + + ), + }, + ]; +}; + +export const buildUrlsDescription = (label: string, values: string[]): ListItems[] => { + if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) { + return [ + { + title: label, + description: ( + ({ + label: val, + href: val, + iconType: 'link', + size: 'xs', + target: '_blank', + }))} + /> + ), + }, + ]; + } + return []; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx index a05f43579e669..198756fc2336b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx @@ -4,19 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiBadge, - EuiDescriptionList, - EuiLoadingSpinner, - EuiFlexGroup, - EuiFlexItem, - EuiTextArea, - EuiLink, - EuiText, - EuiListGroup, -} from '@elastic/eui'; +import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem, EuiTextArea } from '@elastic/eui'; import { isEmpty, chunk, get, pick } from 'lodash/fp'; -import React, { memo, ReactNode, useState } from 'react'; +import React, { memo, useState } from 'react'; import styled from 'styled-components'; import { @@ -25,13 +15,19 @@ import { FilterManager, Query, } from '../../../../../../../../../../src/plugins/data/public'; +import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/search_super_select/translations'; import { useKibana } from '../../../../../lib/kibana'; -import { FilterLabel } from './filter_label'; -import { FormSchema } from '../shared_imports'; -import * as I18n from './translations'; - import { IMitreEnterpriseAttack } from '../../types'; -import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques'; +import { FieldValueTimeline } from '../pick_timeline'; +import { FormSchema } from '../shared_imports'; +import { ListItems } from './types'; +import { + buildQueryBarDescription, + buildSeverityDescription, + buildStringArrayDescription, + buildThreatsDescription, + buildUrlsDescription, +} from './helpers'; interface StepRuleDescriptionProps { direction?: 'row' | 'column'; @@ -40,29 +36,10 @@ interface StepRuleDescriptionProps { schema: FormSchema; } -const EuiBadgeWrap = styled(EuiBadge)` - .euiBadge__text { - white-space: pre-wrap !important; - } -`; - const EuiFlexItemWidth = styled(EuiFlexItem)<{ direction: string }>` ${props => (props.direction === 'row' ? 'width : 50%;' : 'width: 100%;')}; `; -const MyEuiListGroup = styled(EuiListGroup)` - padding: 0px; - .euiListGroupItem__button { - padding: 0px; - } -`; - -const ThreatsEuiFlexGroup = styled(EuiFlexGroup)` - .euiFlexItem { - margin-bottom: 0px; - } -`; - const MyEuiTextArea = styled(EuiTextArea)` max-width: 100%; height: 80px; @@ -87,9 +64,9 @@ const StepRuleDescriptionComponent: React.FC = ({ ); return ( - {chunk(Math.ceil(listItems.length / 2), listItems).map((chunckListItems, index) => ( + {chunk(Math.ceil(listItems.length / 2), listItems).map((chunkListItems, index) => ( - + ))} @@ -98,11 +75,6 @@ const StepRuleDescriptionComponent: React.FC = ({ export const StepRuleDescription = memo(StepRuleDescriptionComponent); -interface ListItems { - title: NonNullable; - description: NonNullable; -} - const buildListItems = ( data: unknown, schema: FormSchema, @@ -130,103 +102,23 @@ const getDescriptionItem = ( filterManager: FilterManager, indexPatterns?: IIndexPattern ): ListItems[] => { - if (field === 'useIndicesConfig') { - return []; - } else if (field === 'queryBar') { + if (field === 'queryBar') { const filters = get('queryBar.filters', value) as esFilters.Filter[]; const query = get('queryBar.query', value) as Query; const savedId = get('queryBar.saved_id', value); - let items: ListItems[] = []; - if (!isEmpty(filters)) { - filterManager.setFilters(filters); - items = [ - ...items, - { - title: <>{I18n.FILTERS_LABEL}, - description: ( - - {filterManager.getFilters().map((filter, index) => ( - - - {indexPatterns != null ? ( - - ) : ( - - )} - - - ))} - - ), - }, - ]; - } - if (!isEmpty(query.query)) { - items = [ - ...items, - { - title: <>{I18n.QUERY_LABEL}, - description: <>{query.query}, - }, - ]; - } - if (!isEmpty(savedId)) { - items = [ - ...items, - { - title: <>{I18n.SAVED_ID_LABEL}, - description: <>{savedId}, - }, - ]; - } - return items; + return buildQueryBarDescription({ + field, + filters, + filterManager, + query, + savedId, + indexPatterns, + }); } else if (field === 'threats') { const threats: IMitreEnterpriseAttack[] = get(field, value).filter( (threat: IMitreEnterpriseAttack) => threat.tactic.name !== 'none' ); - if (threats.length > 0) { - return [ - { - title: label, - description: ( - - {threats.map((threat, index) => { - const tactic = tacticsOptions.find(t => t.name === threat.tactic.name); - return ( - - -
- - {tactic != null ? tactic.text : ''} - -
- { - const myTechnique = techniquesOptions.find( - t => t.name === technique.name - ); - return { - label: myTechnique != null ? myTechnique.label : '', - href: technique.reference, - target: '_blank', - }; - })} - /> -
-
- ); - })} -
- ), - }, - ]; - } - return []; + return buildThreatsDescription({ label, threats }); } else if (field === 'description') { return [ { @@ -234,27 +126,23 @@ const getDescriptionItem = ( description: , }, ]; + } else if (field === 'references') { + const urls: string[] = get(field, value); + return buildUrlsDescription(label, urls); } else if (Array.isArray(get(field, value))) { const values: string[] = get(field, value); - if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) { - return [ - { - title: label, - description: ( - - {values.map((val: string) => - isEmpty(val) ? null : ( - - {val} - - ) - )} - - ), - }, - ]; - } - return []; + return buildStringArrayDescription(label, field, values); + } else if (field === 'severity') { + const val: string = get(field, value); + return buildSeverityDescription(label, val); + } else if (field === 'timeline') { + const timeline = get(field, value) as FieldValueTimeline; + return [ + { + title: label, + description: timeline.title ?? DEFAULT_TIMELINE_TITLE, + }, + ]; } const description: string = get(field, value); if (!isEmpty(description)) { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/types.ts new file mode 100644 index 0000000000000..d32fbcd725d12 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/types.ts @@ -0,0 +1,33 @@ +/* + * 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 { ReactNode } from 'react'; + +import { + IIndexPattern, + esFilters, + FilterManager, + Query, +} from '../../../../../../../../../../src/plugins/data/public'; +import { IMitreEnterpriseAttack } from '../../types'; + +export interface ListItems { + title: NonNullable; + description: NonNullable; +} + +export interface BuildQueryBarDescription { + field: string; + filters: esFilters.Filter[]; + filterManager: FilterManager; + query: Query; + savedId: string; + indexPatterns?: IIndexPattern; +} + +export interface BuildThreatsDescription { + label: string; + threats: IMitreEnterpriseAttack[]; +} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/helpers.ts new file mode 100644 index 0000000000000..1202fe54ad194 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/helpers.ts @@ -0,0 +1,18 @@ +/* + * 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 { isEmpty } from 'lodash/fp'; + +import { IMitreAttack } from '../../types'; + +export const isMitreAttackInvalid = ( + tacticName: string | null | undefined, + techniques: IMitreAttack[] | null | undefined +) => { + if (isEmpty(tacticName) || (tacticName !== 'none' && isEmpty(techniques))) { + return true; + } + return false; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx index a777506ee12ae..97c4c2fdd050a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx @@ -8,27 +8,30 @@ import { EuiButtonEmpty, EuiButtonIcon, EuiFormRow, - EuiSelect, + EuiSuperSelect, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiComboBox, - EuiFormControlLayout, + EuiText, } from '@elastic/eui'; import { isEmpty, kebabCase, camelCase } from 'lodash/fp'; -import React, { ChangeEvent, useCallback } from 'react'; +import React, { useCallback } from 'react'; import styled from 'styled-components'; import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques'; -import * as RuleI18n from '../../translations'; +import * as Rulei18n from '../../translations'; import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; -import * as I18n from './translations'; +import { threatsDefault } from '../step_about_rule/default_value'; import { IMitreEnterpriseAttack } from '../../types'; +import { isMitreAttackInvalid } from './helpers'; +import * as i18n from './translations'; -const MyEuiFormControlLayout = styled(EuiFormControlLayout)` - &.euiFormControlLayout--compressed { - height: fit-content !important; - } +const MitreContainer = styled.div` + margin-top: 16px; +`; +const MyEuiSuperSelect = styled(EuiSuperSelect)` + width: 280px; `; interface AddItemProps { field: FieldHook; @@ -43,7 +46,12 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI const removeItem = useCallback( (index: number) => { const values = field.value as string[]; - field.setValue([...values.slice(0, index), ...values.slice(index + 1)]); + const newValues = [...values.slice(0, index), ...values.slice(index + 1)]; + if (isEmpty(newValues)) { + field.setValue(threatsDefault); + } else { + field.setValue(newValues); + } }, [field] ); @@ -61,9 +69,9 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI }, [field]); const updateTactic = useCallback( - (index: number, event: ChangeEvent) => { + (index: number, value: string) => { const values = field.value as IMitreEnterpriseAttack[]; - const { id, reference, name } = tacticsOptions.find(t => t.value === event.target.value) || { + const { id, reference, name } = tacticsOptions.find(t => t.value === value) || { id: '', name: '', reference: '', @@ -97,75 +105,104 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI const values = field.value as IMitreEnterpriseAttack[]; + const getSelectTactic = (tacticName: string, index: number, disabled: boolean) => ( + {i18n.TACTIC_PLACEHOLDER}, + value: 'none', + disabled, + }, + ] + : []), + ...tacticsOptions.map(t => ({ + inputDisplay: <>{t.text}, + value: t.value, + disabled, + })), + ]} + aria-label="" + onChange={updateTactic.bind(null, index)} + fullWidth={false} + valueOfSelected={camelCase(tacticName)} + /> + ); + + const getSelectTechniques = (item: IMitreEnterpriseAttack, index: number, disabled: boolean) => { + const invalid = isMitreAttackInvalid(item.tactic.name, item.techniques); + return ( + + + t.tactics.includes(kebabCase(item.tactic.name)))} + selectedOptions={item.techniques} + onChange={updateTechniques.bind(null, index)} + isDisabled={disabled} + fullWidth={true} + isInvalid={invalid} + /> + {invalid && ( + +

{errorMessage}

+
+ )} +
+ + removeItem(index)} + aria-label={Rulei18n.DELETE} + /> + +
+ ); + }; + return ( - - <> - {values.map((item, index) => { - const euiSelectFieldProps = { - disabled: isDisabled, - }; - return ( -
- - - ({ text: t.text, value: t.value })), - ]} - aria-label="" - onChange={updateTactic.bind(null, index)} - prepend={I18n.TACTIC} - compressed - fullWidth={false} - value={camelCase(item.tactic.name)} - {...euiSelectFieldProps} - /> - - - - - t.tactics.includes(kebabCase(item.tactic.name)) - )} - selectedOptions={item.techniques} - onChange={updateTechniques.bind(null, index)} - isDisabled={isDisabled} - fullWidth={true} - /> - - - - removeItem(index)} - aria-label={RuleI18n.DELETE} - /> - - - {values.length - 1 !== index && } -
- ); - })} - - {I18n.ADD_MITRE_ATTACK} - - -
+ + {values.map((item, index) => ( +
+ + + {index === 0 ? ( + + <>{getSelectTactic(item.tactic.name, index, isDisabled)} + + ) : ( + getSelectTactic(item.tactic.name, index, isDisabled) + )} + + + {index === 0 ? ( + + <>{getSelectTechniques(item, index, isDisabled)} + + ) : ( + getSelectTechniques(item, index, isDisabled) + )} + + + {values.length - 1 !== index && } +
+ ))} + + {i18n.ADD_MITRE_ATTACK} + +
); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts index 22ee6cc3ef911..dd4c55c1503ec 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts @@ -7,13 +7,13 @@ import { i18n } from '@kbn/i18n'; export const TACTIC = i18n.translate('xpack.siem.detectionEngine.mitreAttack.tacticsDescription', { - defaultMessage: 'Tactic', + defaultMessage: 'tactic', }); -export const TECHNIQUES = i18n.translate( +export const TECHNIQUE = i18n.translate( 'xpack.siem.detectionEngine.mitreAttack.techniquesDescription', { - defaultMessage: 'Techniques', + defaultMessage: 'technique', } ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx new file mode 100644 index 0000000000000..873e0c2184c61 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx @@ -0,0 +1,74 @@ +/* + * 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 } from '@elastic/eui'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { SearchTimelineSuperSelect } from '../../../../../components/timeline/search_super_select'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; + +export interface FieldValueTimeline { + id: string | null; + title: string | null; +} + +interface QueryBarDefineRuleProps { + dataTestSubj: string; + field: FieldHook; + idAria: string; + isDisabled: boolean; +} + +export const PickTimeline = ({ + dataTestSubj, + field, + idAria, + isDisabled = false, +}: QueryBarDefineRuleProps) => { + const [timelineId, setTimelineId] = useState(null); + const [timelineTitle, setTimelineTitle] = useState(null); + + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + useEffect(() => { + const { id, title } = field.value as FieldValueTimeline; + if (timelineTitle !== title && timelineId !== id) { + setTimelineId(id); + setTimelineTitle(title); + } + }, [field.value]); + + const handleOnTimelineChange = useCallback( + (title: string, id: string | null) => { + if (id === null) { + field.setValue({ id, title: null }); + } else if (timelineTitle !== title && timelineId !== id) { + field.setValue({ id, title }); + } + }, + [field] + ); + + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx index c294ec24c4cb7..3e39beb6e61b7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx @@ -6,7 +6,7 @@ import { EuiFormRow, EuiMutationObserver } from '@elastic/eui'; import { isEqual } from 'lodash/fp'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Subscription } from 'rxjs'; import styled from 'styled-components'; @@ -19,11 +19,18 @@ import { SavedQueryTimeFilter, } from '../../../../../../../../../../src/plugins/data/public'; +import { BrowserFields } from '../../../../../containers/source'; +import { OpenTimelineModal } from '../../../../../components/open_timeline/open_timeline_modal'; +import { ActionTimelineToShow } from '../../../../../components/open_timeline/types'; import { QueryBar } from '../../../../../components/query_bar'; +import { buildGlobalQuery } from '../../../../../components/timeline/helpers'; +import { getDataProviderFilter } from '../../../../../components/timeline/query_bar'; +import { convertKueryToElasticSearchQuery } from '../../../../../lib/keury'; import { useKibana } from '../../../../../lib/kibana'; +import { TimelineModel } from '../../../../../store/timeline/model'; import { useSavedQueryServices } from '../../../../../utils/saved_query_services'; - import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; +import * as i18n from './translations'; export interface FieldValueQueryBar { filters: esFilters.Filter[]; @@ -31,11 +38,14 @@ export interface FieldValueQueryBar { saved_id: string | null; } interface QueryBarDefineRuleProps { + browserFields: BrowserFields; dataTestSubj: string; field: FieldHook; idAria: string; isLoading: boolean; indexPattern: IIndexPattern; + onCloseTimelineSearch: () => void; + openTimelineSearch: boolean; resizeParentContainer?: (height: number) => void; } @@ -56,14 +66,18 @@ const StyledEuiFormRow = styled(EuiFormRow)` // TODO need to add disabled in the SearchBar export const QueryBarDefineRule = ({ + browserFields, dataTestSubj, field, idAria, indexPattern, isLoading = false, + onCloseTimelineSearch, + openTimelineSearch = false, resizeParentContainer, }: QueryBarDefineRuleProps) => { const [originalHeight, setOriginalHeight] = useState(-1); + const [loadingTimeline, setLoadingTimeline] = useState(false); const [savedQuery, setSavedQuery] = useState(null); const [queryDraft, setQueryDraft] = useState({ query: '', language: 'kuery' }); const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); @@ -168,6 +182,38 @@ export const QueryBarDefineRule = ({ [field.value] ); + const onCloseTimelineModal = useCallback(() => { + setLoadingTimeline(true); + onCloseTimelineSearch(); + }, [onCloseTimelineSearch]); + + const onOpenTimeline = useCallback( + (timeline: TimelineModel) => { + setLoadingTimeline(false); + const newQuery = { + query: timeline.kqlQuery.filterQuery?.kuery?.expression ?? '', + language: timeline.kqlQuery.filterQuery?.kuery?.kind ?? 'kuery', + }; + const dataProvidersDsl = + timeline.dataProviders != null && timeline.dataProviders.length > 0 + ? convertKueryToElasticSearchQuery( + buildGlobalQuery(timeline.dataProviders, browserFields), + indexPattern + ) + : ''; + const newFilters = timeline.filters ?? []; + field.setValue({ + filters: + dataProvidersDsl !== '' + ? [...newFilters, getDataProviderFilter(dataProvidersDsl)] + : newFilters, + query: newQuery, + saved_id: '', + }); + }, + [browserFields, field, indexPattern] + ); + const onMutation = (event: unknown, observer: unknown) => { if (resizeParentContainer != null) { const suggestionContainer = document.getElementById('kbnTypeahead__items'); @@ -189,39 +235,51 @@ export const QueryBarDefineRule = ({ } }; + const actionTimelineToHide = useMemo(() => ['duplicate'], []); + return ( - - + - {mutationRef => ( -
- -
- )} -
-
+ + {mutationRef => ( +
+ +
+ )} +
+ + {openTimelineSearch ? ( + + ) : null} + ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/translations.tsx new file mode 100644 index 0000000000000..9b14e4f8599da --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/translations.tsx @@ -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. + */ + +import { i18n } from '@kbn/i18n'; + +export const IMPORT_TIMELINE_MODAL = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.importTimelineModalTitle', + { + defaultMessage: 'Import query from saved timeline', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx index 2e57ff8ba2c4f..8097c27cddfe8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFieldNumber, EuiFormRow, EuiSelect } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiFieldNumber, EuiFormRow, EuiSelect } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; @@ -32,6 +32,10 @@ const StyledEuiFormRow = styled(EuiFormRow)` } `; +const MyEuiSelect = styled(EuiSelect)` + width: auto; +`; + export const ScheduleItem = ({ dataTestSubj, field, idAria, isDisabled }: ScheduleItemProps) => { const [timeType, setTimeType] = useState('s'); const [timeVal, setTimeVal] = useState(0); @@ -79,22 +83,33 @@ export const ScheduleItem = ({ dataTestSubj, field, idAria, isDisabled }: Schedu // EUI missing some props const rest = { disabled: isDisabled }; + const label = useMemo( + () => ( + + + {field.label} + + + {field.labelAppend} + + + ), + [field.label, field.labelAppend] + ); return ( } - compressed fullWidth min={0} onChange={onChangeTimeVal} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/status_icon/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/status_icon/index.tsx index 48ff0d80d0398..3ec5bf1a12eb0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/status_icon/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/status_icon/index.tsx @@ -27,7 +27,7 @@ const RuleStatusIconStyled = styled.div` const RuleStatusIconComponent: React.FC = ({ name, type }) => { const theme = useEuiTheme(); - const color = type === 'passive' ? theme.euiColorLightestShade : theme.euiColorDarkestShade; + const color = type === 'passive' ? theme.euiColorLightestShade : theme.euiColorPrimary; return ( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.ts deleted file mode 100644 index 7d6e434bcc8c6..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.ts +++ /dev/null @@ -1,28 +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 * as I18n from './translations'; - -export type SeverityValue = 'low' | 'medium' | 'high' | 'critical'; - -interface SeverityOptionItem { - value: SeverityValue; - text: string; -} - -export const severityOptions: SeverityOptionItem[] = [ - { value: 'low', text: I18n.LOW }, - { value: 'medium', text: I18n.MEDIUM }, - { value: 'high', text: I18n.HIGH }, - { value: 'critical', text: I18n.CRITICAL }, -]; - -export const defaultRiskScoreBySeverity: Record = { - low: 21, - medium: 47, - high: 73, - critical: 99, -}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx new file mode 100644 index 0000000000000..9fb64189ebd1a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.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 { EuiHealth } from '@elastic/eui'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import React from 'react'; +import * as I18n from './translations'; + +export type SeverityValue = 'low' | 'medium' | 'high' | 'critical'; + +interface SeverityOptionItem { + value: SeverityValue; + inputDisplay: React.ReactElement; +} + +export const severityOptions: SeverityOptionItem[] = [ + { + value: 'low', + inputDisplay: {I18n.LOW}, + }, + { + value: 'medium', + inputDisplay: {I18n.MEDIUM} , + }, + { + value: 'high', + inputDisplay: {I18n.HIGH} , + }, + { + value: 'critical', + inputDisplay: {I18n.CRITICAL} , + }, +]; + +export const defaultRiskScoreBySeverity: Record = { + low: 21, + medium: 47, + high: 73, + critical: 99, +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts index c0c5ae77a1960..328c4a0f96066 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts @@ -6,6 +6,14 @@ import { AboutStepRule } from '../../types'; +export const threatsDefault = [ + { + framework: 'MITRE ATT&CK', + tactic: { id: 'none', name: 'none', reference: 'none' }, + techniques: [], + }, +]; + export const stepAboutDefaultValue: AboutStepRule = { name: '', description: '', @@ -15,11 +23,9 @@ export const stepAboutDefaultValue: AboutStepRule = { references: [''], falsePositives: [''], tags: [], - threats: [ - { - framework: 'MITRE ATT&CK', - tactic: { id: 'none', name: 'none', reference: 'none' }, - techniques: [], - }, - ], + timeline: { + id: null, + title: null, + }, + threats: threatsDefault, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/helpers.ts new file mode 100644 index 0000000000000..99b01c8b22974 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/helpers.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty } from 'lodash/fp'; + +const urlExpression = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi; + +export const isUrlInvalid = (url: string | null | undefined) => { + if (!isEmpty(url) && url != null && url.match(urlExpression) == null) { + return true; + } + return false; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx index e266c0b9ab47d..8956776dcd3b2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx @@ -7,17 +7,21 @@ import { EuiButton, EuiHorizontalRule, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEqual, get } from 'lodash/fp'; import React, { memo, useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; import { RuleStepProps, RuleStep, AboutStepRule } from '../../types'; import * as RuleI18n from '../../translations'; -import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports'; import { AddItem } from '../add_item_form'; +import { StepRuleDescription } from '../description_step'; +import { AddMitreThreat } from '../mitre'; +import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports'; + import { defaultRiskScoreBySeverity, severityOptions, SeverityValue } from './data'; import { stepAboutDefaultValue } from './default_value'; +import { isUrlInvalid } from './helpers'; import { schema } from './schema'; import * as I18n from './translations'; -import { StepRuleDescription } from '../description_step'; -import { AddMitreThreat } from '../mitre'; +import { PickTimeline } from '../pick_timeline'; const CommonUseField = getUseField({ component: Field }); @@ -25,6 +29,10 @@ interface StepAboutRuleProps extends RuleStepProps { defaultValues?: AboutStepRule | null; } +const TagContainer = styled.div` + margin-top: 16px; +`; + export const StepAboutRule = memo( ({ defaultValues, @@ -90,7 +98,6 @@ export const StepAboutRule = memo( idAria: 'detectionEngineStepAboutRuleName', 'data-test-subj': 'detectionEngineStepAboutRuleName', euiFieldProps: { - compressed: true, fullWidth: false, disabled: isLoading, }, @@ -99,11 +106,9 @@ export const StepAboutRule = memo( ( idAria: 'detectionEngineStepAboutRuleSeverity', 'data-test-subj': 'detectionEngineStepAboutRuleSeverity', euiFieldProps: { - compressed: true, fullWidth: false, disabled: isLoading, options: severityOptions, @@ -129,29 +133,38 @@ export const StepAboutRule = memo( euiFieldProps: { max: 100, min: 0, - compressed: true, fullWidth: false, disabled: isLoading, options: severityOptions, + showTicks: true, + tickInterval: 25, }, }} /> + ( path="threats" component={AddMitreThreat} componentProps={{ - compressed: true, idAria: 'detectionEngineStepAboutRuleMitreThreats', isDisabled: isLoading, dataTestSubj: 'detectionEngineStepAboutRuleMitreThreats', }} /> - + + + {({ severity }) => { const newRiskScore = defaultRiskScoreBySeverity[severity as SeverityValue]; @@ -202,7 +216,7 @@ export const StepAboutRule = memo( > - {myStepData.isNew ? RuleI18n.CONTINUE : RuleI18n.UPDATE} + {RuleI18n.CONTINUE} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx index c72312bb90836..9355f1c8bfefa 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx @@ -6,7 +6,6 @@ import { EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { isEmpty } from 'lodash/fp'; import React from 'react'; import * as RuleI18n from '../../translations'; @@ -18,6 +17,8 @@ import { ValidationFunc, ERROR_CODE, } from '../shared_imports'; +import { isMitreAttackInvalid } from '../mitre/helpers'; +import { isUrlInvalid } from './helpers'; import * as I18n from './translations'; const { emptyField } = fieldValidators; @@ -63,7 +64,7 @@ export const schema: FormSchema = { ], }, severity: { - type: FIELD_TYPES.SELECT, + type: FIELD_TYPES.SUPER_SELECT, label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldSeverityLabel', { @@ -92,6 +93,14 @@ export const schema: FormSchema = { } ), }, + timeline: { + label: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTimelineTemplateLabel', + { + defaultMessage: 'Timeline template', + } + ), + }, references: { label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldReferenceUrlsLabel', @@ -100,6 +109,28 @@ export const schema: FormSchema = { } ), labelAppend: {RuleI18n.OPTIONAL_FIELD}, + validations: [ + { + validator: ( + ...args: Parameters + ): ReturnType> | undefined => { + const [{ value, path }] = args; + let hasError = false; + (value as string[]).forEach(url => { + if (isUrlInvalid(url)) { + hasError = true; + } + }); + return hasError + ? { + code: 'ERR_FIELD_FORMAT', + path, + message: I18n.URL_FORMAT_INVALID, + } + : undefined; + }, + }, + ], }, falsePositives: { label: i18n.translate( @@ -126,7 +157,7 @@ export const schema: FormSchema = { const [{ value, path }] = args; let hasError = false; (value as IMitreEnterpriseAttack[]).forEach(v => { - if (isEmpty(v.tactic.name) || (v.tactic.name !== 'none' && isEmpty(v.techniques))) { + if (isMitreAttackInvalid(v.tactic.name, v.techniques)) { hasError = true; } }); @@ -146,6 +177,13 @@ export const schema: FormSchema = { label: i18n.translate('xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTagsLabel', { defaultMessage: 'Tags', }), + helpText: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTagsHelpText', + { + defaultMessage: + 'Type one or more custom identifying tags for this rule. Press enter after each tag to begin a new one.', + } + ), labelAppend: {RuleI18n.OPTIONAL_FIELD}, }, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts index 017d4fe6fdf49..052986480e9ab 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts @@ -54,3 +54,10 @@ export const CUSTOM_MITRE_ATTACK_TECHNIQUES_REQUIRED = i18n.translate( defaultMessage: 'At least one Technique is required with a Tactic.', } ); + +export const URL_FORMAT_INVALID = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.referencesUrlInvalidError', + { + defaultMessage: 'Url is invalid format', + } +); 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 cc4e959cc9c78..ecd2ce442238f 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 @@ -4,8 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; -import { isEqual, get } from 'lodash/fp'; +import { + EuiButtonEmpty, + EuiHorizontalRule, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; +import { isEmpty, isEqual, get } from 'lodash/fp'; import React, { memo, useCallback, useState, useEffect } from 'react'; import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/public'; @@ -18,7 +24,7 @@ import { StepRuleDescription } from '../description_step'; import { QueryBarDefineRule } from '../query_bar'; import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports'; import { schema } from './schema'; -import * as I18n from './translations'; +import * as i18n from './translations'; const CommonUseField = getUseField({ component: Field }); @@ -34,7 +40,6 @@ const stepDefineDefaultValue = { filters: [], saved_id: null, }, - useIndicesConfig: 'true', }; const getStepDefaultValue = ( @@ -45,7 +50,6 @@ const getStepDefaultValue = ( return { ...defaultValues, isNew: false, - useIndicesConfig: `${isEqual(defaultValues.index, indicesConfig)}`, }; } else { return { @@ -66,13 +70,22 @@ export const StepDefineRule = memo( setForm, setStepData, }) => { - const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(''); + const [openTimelineSearch, setOpenTimelineSearch] = useState(false); + const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); + const [mylocalIndicesConfig, setMyLocalIndicesConfig] = useState( + defaultValues != null ? defaultValues.index : indicesConfig ?? [] + ); const [ - { indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, - setIndices, - ] = useFetchIndexPatterns(defaultValues != null ? defaultValues.index : indicesConfig ?? []); - const [myStepData, setMyStepData] = useState(stepDefineDefaultValue); + { + browserFields, + indexPatterns: indexPatternQueryBar, + isLoading: indexPatternLoadingQueryBar, + }, + ] = useFetchIndexPatterns(mylocalIndicesConfig); + const [myStepData, setMyStepData] = useState( + getStepDefaultValue(indicesConfig, null) + ); const { form } = useForm({ defaultValue: myStepData, @@ -96,7 +109,7 @@ export const StepDefineRule = memo( const myDefaultValues = getStepDefaultValue(indicesConfig, defaultValues); if (!isEqual(myDefaultValues, myStepData)) { setMyStepData(myDefaultValues); - setLocalUseIndicesConfig(myDefaultValues.useIndicesConfig); + setLocalUseIndicesConfig(isEqual(myDefaultValues.index, indicesConfig)); if (!isReadOnlyView) { Object.keys(schema).forEach(key => { const val = get(key, myDefaultValues); @@ -115,6 +128,19 @@ export const StepDefineRule = memo( } }, [form]); + const handleResetIndices = useCallback(() => { + const indexField = form.getFields().index; + indexField.setValue(indicesConfig); + }, [form, indicesConfig]); + + const handleOpenTimelineSearch = useCallback(() => { + setOpenTimelineSearch(true); + }, []); + + const handleCloseTimelineSearch = useCallback(() => { + setOpenTimelineSearch(false); + }, []); + return isReadOnlyView && myStepData != null ? ( ( ) : ( <>
- + {i18n.RESET_DEFAULT_INDEX} + + ) : null, + }} componentProps={{ idAria: 'detectionEngineStepDefineRuleIndices', 'data-test-subj': 'detectionEngineStepDefineRuleIndices', euiFieldProps: { - compressed: true, fullWidth: true, isDisabled: isLoading, + placeholder: '', }, }} /> + {i18n.IMPORT_TIMELINE_QUERY} + + ), + }} component={QueryBarDefineRule} componentProps={{ - compressed: true, + browserFields, loading: indexPatternLoadingQueryBar, idAria: 'detectionEngineStepDefineRuleQueryBar', indexPattern: indexPatternQueryBar, isDisabled: isLoading, isLoading: indexPatternLoadingQueryBar, dataTestSubj: 'detectionEngineStepDefineRuleQueryBar', + openTimelineSearch, + onCloseTimelineSearch: handleCloseTimelineSearch, resizeParentContainer, }} /> - - {({ useIndicesConfig }) => { - if (localUseIndicesConfig !== useIndicesConfig) { - const indexField = form.getFields().index; - if ( - indexField != null && - useIndicesConfig === 'true' && - !isEqual(indexField.value, indicesConfig) - ) { - indexField.setValue(indicesConfig); - setIndices(indicesConfig); - } else if ( - indexField != null && - useIndicesConfig === 'false' && - isEqual(indexField.value, indicesConfig) - ) { - indexField.setValue([]); - setIndices([]); + + {({ index }) => { + if (index != null) { + if (isEqual(index, indicesConfig) && !localUseIndicesConfig) { + setLocalUseIndicesConfig(true); + } + if (!isEqual(index, indicesConfig) && localUseIndicesConfig) { + setLocalUseIndicesConfig(false); + } + if (index != null && !isEmpty(index) && !isEqual(index, mylocalIndicesConfig)) { + setMyLocalIndicesConfig(index); } - setLocalUseIndicesConfig(useIndicesConfig); } - return null; }} @@ -208,7 +223,7 @@ export const StepDefineRule = memo( > - {myStepData.isNew ? RuleI18n.CONTINUE : RuleI18n.UPDATE} + {RuleI18n.CONTINUE} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index 9b54ada8227c6..dbd7e3b3f96aa 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx @@ -10,7 +10,6 @@ import { isEmpty } from 'lodash/fp'; import React from 'react'; import { esKuery } from '../../../../../../../../../../src/plugins/data/public'; -import * as RuleI18n from '../../translations'; import { FieldValueQueryBar } from '../query_bar'; import { ERROR_CODE, @@ -19,33 +18,27 @@ import { FormSchema, ValidationFunc, } from '../shared_imports'; -import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY } from './translations'; +import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY, INDEX_HELPER_TEXT } from './translations'; const { emptyField } = fieldValidators; export const schema: FormSchema = { - useIndicesConfig: { - type: FIELD_TYPES.RADIO_GROUP, + index: { + type: FIELD_TYPES.COMBO_BOX, label: i18n.translate( - 'xpack.siem.detectionEngine.createRule.stepDefineRule.fieldIndicesTypeLabel', + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fiedIndexPatternsLabel', { - defaultMessage: 'Indices type', + defaultMessage: 'Index patterns', } ), - }, - index: { - type: FIELD_TYPES.COMBO_BOX, - label: i18n.translate('xpack.siem.detectionEngine.createRule.stepAboutRule.fiedIndicesLabel', { - defaultMessage: 'Indices', - }), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + helpText: {INDEX_HELPER_TEXT}, validations: [ { validator: emptyField( i18n.translate( 'xpack.siem.detectionEngine.createRule.stepDefineRule.outputIndiceNameFieldRequiredError', { - defaultMessage: 'An output indice name for signals is required.', + defaultMessage: 'Index patterns for signals is required.', } ) ), diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx index 0050c59a4a2c8..8394f090e346c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx @@ -33,3 +33,25 @@ export const CUSTOM_INDICES = i18n.translate( defaultMessage: 'Provide custom list of indices', } ); + +export const INDEX_HELPER_TEXT = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.indicesHelperDescription', + { + defaultMessage: + 'Enter the pattern of Elasticsearch indices where you would like this rule to run. By default, these will include index patterns defined in SIEM advanced settings.', + } +); + +export const RESET_DEFAULT_INDEX = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.resetDefaultIndicesButton', + { + defaultMessage: 'Reset to default index patterns', + } +); + +export const IMPORT_TIMELINE_QUERY = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.importTimelineQueryButton', + { + defaultMessage: 'Import query from saved timeline', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx index 6f7e49bc8ab9a..35b8ca6650bf6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx @@ -92,7 +92,6 @@ export const StepScheduleRule = memo( path="interval" component={ScheduleItem} componentProps={{ - compressed: true, idAria: 'detectionEngineStepScheduleRuleInterval', isDisabled: isLoading, dataTestSubj: 'detectionEngineStepScheduleRuleInterval', @@ -102,7 +101,6 @@ export const StepScheduleRule = memo( path="from" component={ScheduleItem} componentProps={{ - compressed: true, idAria: 'detectionEngineStepScheduleRuleFrom', isDisabled: isLoading, dataTestSubj: 'detectionEngineStepScheduleRuleFrom', diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts index a25ccce569dd4..12bbdbdfff3e9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts @@ -40,14 +40,14 @@ const getTimeTypeValue = (time: string): { unit: string; value: number } => { }; const formatDefineStepData = (defineStepData: DefineStepRule): DefineStepRuleJson => { - const { queryBar, useIndicesConfig, isNew, ...rest } = defineStepData; + const { queryBar, isNew, ...rest } = defineStepData; const { filters, query, saved_id: savedId } = queryBar; return { ...rest, language: query.language, filters, query: query.query as string, - ...(savedId != null ? { saved_id: savedId } : {}), + ...(savedId != null && savedId !== '' ? { saved_id: savedId } : {}), }; }; @@ -72,11 +72,21 @@ const formatScheduleStepData = (scheduleData: ScheduleStepRule): ScheduleStepRul }; const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRuleJson => { - const { falsePositives, references, riskScore, threats, isNew, ...rest } = aboutStepData; + const { + falsePositives, + references, + riskScore, + threats, + timeline, + isNew, + ...rest + } = aboutStepData; return { false_positives: falsePositives.filter(item => !isEmpty(item)), references: references.filter(item => !isEmpty(item)), risk_score: riskScore, + timeline_id: timeline.id, + timeline_title: timeline.title, threats: threats .filter(threat => threat.tactic.name !== 'none') .map(threat => ({ @@ -97,7 +107,7 @@ export const formatRule = ( scheduleData: ScheduleStepRule, ruleId?: string ): NewRule => { - const type: FormatRuleType = defineStepData.queryBar.saved_id != null ? 'saved_query' : 'query'; + const type: FormatRuleType = !isEmpty(defineStepData.queryBar.saved_id) ? 'saved_query' : 'query'; const persistData = { type, ...formatDefineStepData(defineStepData), diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx index 3e8dbeba89546..848b17aadbff4 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx @@ -12,12 +12,13 @@ import styled from 'styled-components'; import { HeaderPage } from '../../../../components/header_page'; import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redirect_to_detection_engine'; import { WrapperPage } from '../../../../components/wrapper_page'; +import { usePersistRule } from '../../../../containers/detection_engine/rules'; +import { SpyRoute } from '../../../../utils/route/spy_routes'; import { AccordionTitle } from '../components/accordion_title'; +import { FormData, FormHook } from '../components/shared_imports'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; import { StepScheduleRule } from '../components/step_schedule_rule'; -import { usePersistRule } from '../../../../containers/detection_engine/rules'; -import { SpyRoute } from '../../../../utils/route/spy_routes'; import * as RuleI18n from '../translations'; import { AboutStepRule, DefineStepRule, RuleStep, RuleStepData, ScheduleStepRule } from '../types'; import { formatRule } from './helpers'; @@ -28,17 +29,43 @@ const stepsRuleOrder = [RuleStep.defineRule, RuleStep.aboutRule, RuleStep.schedu const ResizeEuiPanel = styled(EuiPanel)<{ height?: number; }>` + .euiAccordion__iconWrapper { + display: none; + } .euiAccordion__childWrapper { height: ${props => (props.height !== -1 ? `${props.height}px !important` : 'auto')}; } + .euiAccordion__button { + cursor: default !important; + &:hover { + text-decoration: none !important; + } + } +`; + +const MyEuiPanel = styled(EuiPanel)` + .euiAccordion__iconWrapper { + display: none; + } + .euiAccordion__button { + cursor: default !important; + &:hover { + text-decoration: none !important; + } + } `; export const CreateRuleComponent = React.memo(() => { const [heightAccordion, setHeightAccordion] = useState(-1); - const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); + const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); const defineRuleRef = useRef(null); const aboutRuleRef = useRef(null); const scheduleRuleRef = useRef(null); + const stepsForm = useRef | null>>({ + [RuleStep.defineRule]: null, + [RuleStep.aboutRule]: null, + [RuleStep.scheduleRule]: null, + }); const stepsData = useRef>({ [RuleStep.defineRule]: { isValid: false, data: {} }, [RuleStep.aboutRule]: { isValid: false, data: {} }, @@ -57,11 +84,17 @@ export const CreateRuleComponent = React.memo(() => { if (isValid) { const stepRuleIdx = stepsRuleOrder.findIndex(item => step === item); if ([0, 1].includes(stepRuleIdx)) { - setIsStepRuleInEditView({ - ...isStepRuleInReadOnlyView, - [step]: true, - }); - if (openAccordionId !== stepsRuleOrder[stepRuleIdx + 1]) { + if (isStepRuleInReadOnlyView[stepsRuleOrder[stepRuleIdx + 1]]) { + setIsStepRuleInEditView({ + ...isStepRuleInReadOnlyView, + [step]: true, + [stepsRuleOrder[stepRuleIdx + 1]]: false, + }); + } else if (openAccordionId !== stepsRuleOrder[stepRuleIdx + 1]) { + setIsStepRuleInEditView({ + ...isStepRuleInReadOnlyView, + [step]: true, + }); openCloseAccordion(stepsRuleOrder[stepRuleIdx + 1]); setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]); } @@ -80,9 +113,13 @@ export const CreateRuleComponent = React.memo(() => { } } }, - [openAccordionId, stepsData.current, setRule] + [isStepRuleInReadOnlyView, openAccordionId, stepsData.current, setRule] ); + const setStepsForm = useCallback((step: RuleStep, form: FormHook) => { + stepsForm.current[step] = form; + }, []); + const getAccordionType = useCallback( (accordionId: RuleStep) => { if (accordionId === openAccordionId) { @@ -135,42 +172,38 @@ export const CreateRuleComponent = React.memo(() => { (id: RuleStep, isOpen: boolean) => { const activeRuleIdx = stepsRuleOrder.findIndex(step => step === openAccordionId); const stepRuleIdx = stepsRuleOrder.findIndex(step => step === id); - const isLatestStepsRuleValid = - stepRuleIdx === 0 - ? true - : stepsRuleOrder - .filter((stepRule, index) => index < stepRuleIdx) - .every(stepRule => stepsData.current[stepRule].isValid); - if (stepRuleIdx < activeRuleIdx && !isOpen) { + if ((id === openAccordionId || stepRuleIdx < activeRuleIdx) && !isOpen) { openCloseAccordion(id); } else if (stepRuleIdx >= activeRuleIdx) { if ( - openAccordionId != null && openAccordionId !== id && !stepsData.current[openAccordionId].isValid && !isStepRuleInReadOnlyView[id] && isOpen ) { openCloseAccordion(id); - } else if (!isLatestStepsRuleValid && isOpen) { - openCloseAccordion(id); - } else if (id !== openAccordionId && isOpen) { - setOpenAccordionId(id); } } }, - [isStepRuleInReadOnlyView, openAccordionId] + [isStepRuleInReadOnlyView, openAccordionId, stepsData] ); const manageIsEditable = useCallback( - (id: RuleStep) => { - setIsStepRuleInEditView({ - ...isStepRuleInReadOnlyView, - [id]: false, - }); + async (id: RuleStep) => { + const activeForm = await stepsForm.current[openAccordionId]?.submit(); + if (activeForm != null && activeForm?.isValid) { + setOpenAccordionId(id); + openCloseAccordion(openAccordionId); + + setIsStepRuleInEditView({ + ...isStepRuleInReadOnlyView, + [openAccordionId]: openAccordionId === RuleStep.scheduleRule ? false : true, + [id]: false, + }); + } }, - [isStepRuleInReadOnlyView] + [isStepRuleInReadOnlyView, openAccordionId] ); if (isSaved) { @@ -201,7 +234,7 @@ export const CreateRuleComponent = React.memo(() => { size="xs" onClick={manageIsEditable.bind(null, RuleStep.defineRule)} > - {`Edit`} + {i18n.EDIT_RULE} ) } @@ -210,13 +243,14 @@ export const CreateRuleComponent = React.memo(() => { setHeightAccordion(height)} /> - + { size="xs" onClick={manageIsEditable.bind(null, RuleStep.aboutRule)} > - {`Edit`} + {i18n.EDIT_RULE} ) } @@ -240,12 +274,13 @@ export const CreateRuleComponent = React.memo(() => { - + - + { size="xs" onClick={manageIsEditable.bind(null, RuleStep.scheduleRule)} > - {`Edit`} + {i18n.EDIT_RULE} ) } @@ -269,10 +304,11 @@ export const CreateRuleComponent = React.memo(() => { - + diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/translations.ts index 884f3f3741228..329bcc286fb70 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/translations.ts @@ -9,3 +9,7 @@ import { i18n } from '@kbn/i18n'; export const PAGE_TITLE = i18n.translate('xpack.siem.detectionEngine.createRule.pageTitle', { defaultMessage: 'Create new rule', }); + +export const EDIT_RULE = i18n.translate('xpack.siem.detectionEngine.createRule.editRuleButton', { + defaultMessage: 'Edit', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx index 1bc2bc24517e3..4d887c7cb5b6e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx @@ -113,7 +113,10 @@ export const RuleDetailsComponent = memo(({ signalsIn (({ signalsIn diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx index 8e32f82dff0b1..10b7f0e832f19 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx @@ -49,6 +49,7 @@ interface ScheduleStepRuleForm extends StepRuleForm { export const EditRuleComponent = memo(() => { const { ruleId } = useParams(); const [loading, rule] = useRule(ruleId); + const [initForm, setInitForm] = useState(false); const [myAboutRuleForm, setMyAboutRuleForm] = useState({ data: null, @@ -249,7 +250,7 @@ export const EditRuleComponent = memo(() => { }, []); if (isSaved || (rule != null && rule.immutable)) { - return ; + return ; } return ( @@ -257,7 +258,7 @@ export const EditRuleComponent = memo(() => { { responsive={false} > - + {i18n.CANCEL} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index 46301ae808919..47b5c1051bcfc 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -33,7 +33,6 @@ export const getStepsData = ({ filters: rule.filters as esFilters.Filter[], saved_id: rule.saved_id ?? null, }, - useIndicesConfig: 'true', } : null; const aboutRuleData: AboutStepRule | null = @@ -45,6 +44,10 @@ export const getStepsData = ({ threats: rule.threats as IMitreEnterpriseAttack[], falsePositives: rule.false_positives, riskScore: rule.risk_score, + timeline: { + id: rule.timeline_id ?? null, + title: rule.timeline_title ?? null, + }, } : null; const scheduleRuleData: ScheduleStepRule | null = diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx index 8b4cc2a213589..ef67f0a7d22c6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx @@ -8,6 +8,7 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { DETECTION_ENGINE_PAGE_NAME } from '../../../components/link_to/redirect_to_detection_engine'; import { FormattedRelativePreferenceDate } from '../../../components/formatted_date'; import { getEmptyTagValue } from '../../../components/empty_value'; import { HeaderPage } from '../../../components/header_page'; @@ -32,7 +33,10 @@ export const RulesComponent = React.memo(() => { /> { - + {i18n.ADD_NEW_RULE} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index 9b535034810bd..ec4206623bad9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -8,6 +8,7 @@ import { esFilters } from '../../../../../../../../src/plugins/data/common'; import { Rule } from '../../../containers/detection_engine/rules'; import { FieldValueQueryBar } from './components/query_bar'; import { FormData, FormHook } from './components/shared_imports'; +import { FieldValueTimeline } from './components/pick_timeline'; export interface EuiBasicTableSortTypes { field: string; @@ -76,11 +77,11 @@ export interface AboutStepRule extends StepRuleData { references: string[]; falsePositives: string[]; tags: string[]; + timeline: FieldValueTimeline; threats: IMitreEnterpriseAttack[]; } export interface DefineStepRule extends StepRuleData { - useIndicesConfig: string; index: string[]; queryBar: FieldValueQueryBar; } @@ -108,6 +109,8 @@ export interface AboutStepRuleJson { references: string[]; false_positives: string[]; tags: string[]; + timeline_id: string | null; + timeline_title: string | null; threats: IMitreEnterpriseAttack[]; } 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 edf196b96f5d0..f6ac0435cd7c1 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 @@ -52,6 +52,7 @@ export const fullRuleAlertParamsRest = (): RuleAlertParamsRest => ({ created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', timeline_id: 'timeline-id', + timeline_title: 'timeline-title', }); export const typicalPayload = (): Partial => ({ @@ -271,6 +272,7 @@ export const getResult = (): RuleAlertType => ({ outputIndex: '.siem-signals', savedId: 'some-id', timelineId: 'some-timeline-id', + timelineTitle: 'some-timeline-title', meta: { someMeta: 'someField' }, filters: [ { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/signals_mapping.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/signals_mapping.json index afe9bac9d87fe..79fb136afd52a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/signals_mapping.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/signals_mapping.json @@ -36,6 +36,9 @@ "timeline_id": { "type": "keyword" }, + "timeline_title": { + "type": "keyword" + }, "max_signals": { "type": "keyword" }, 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 256b341fca656..3d9719a7b248b 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 @@ -74,6 +74,7 @@ export const createCreateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou updated_at: updatedAt, references, timeline_id: timelineId, + timeline_title: timelineTitle, version, } = payloadRule; const ruleIdOrUuid = ruleId ?? uuid.v4(); @@ -112,6 +113,7 @@ export const createCreateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou outputIndex: finalIndex, savedId, timelineId, + timelineTitle, meta, filters, ruleId: ruleIdOrUuid, 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 476d5b8a49ba2..cf8fb2a28288f 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 @@ -44,6 +44,7 @@ export const createCreateRulesRoute = (server: ServerFacade): Hapi.ServerRoute = output_index: outputIndex, saved_id: savedId, timeline_id: timelineId, + timeline_title: timelineTitle, meta, filters, rule_id: ruleId, @@ -101,6 +102,7 @@ export const createCreateRulesRoute = (server: ServerFacade): Hapi.ServerRoute = outputIndex: finalIndex, savedId, timelineId, + timelineTitle, meta, filters, ruleId: ruleId != null ? ruleId : uuid.v4(), 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 b30b6c791522b..180a75bdaaeea 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 @@ -50,6 +50,7 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou output_index: outputIndex, saved_id: savedId, timeline_id: timelineId, + timeline_title: timelineTitle, meta, filters, rule_id: ruleId, @@ -82,6 +83,7 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou outputIndex, savedId, timelineId, + timelineTitle, meta, filters, id, 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 ec3d9514fa5db..6db8a8902915a 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 @@ -38,6 +38,7 @@ export const createUpdateRulesRoute: Hapi.ServerRoute = { output_index: outputIndex, saved_id: savedId, timeline_id: timelineId, + timeline_title: timelineTitle, meta, filters, rule_id: ruleId, @@ -77,6 +78,7 @@ export const createUpdateRulesRoute: Hapi.ServerRoute = { outputIndex, savedId, timelineId, + timelineTitle, meta, filters, id, 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 b1f61d11458fe..44d47ad435682 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 @@ -79,6 +79,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -141,6 +142,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -205,6 +207,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -269,6 +272,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -331,6 +335,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -396,6 +401,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -461,6 +467,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -526,6 +533,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -642,6 +650,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', version: 1, }; expect(output).toEqual({ @@ -714,6 +723,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', version: 1, }; expect(output).toEqual(expected); @@ -875,6 +885,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', version: 1, }; expect(output).toEqual(expected); 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 b9bf3f8a942fc..714035a423413 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 @@ -85,6 +85,7 @@ export const transformAlertToRule = (alert: RuleAlertType): Partial { test('You can omit the query string when filters are present', () => { expect( - addPrepackagedRulesSchema.validate< - Partial & { meta: string }> - >({ + addPrepackagedRulesSchema.validate>({ rule_id: 'rule-1', risk_score: 50, description: 'some description', @@ -1099,7 +1097,7 @@ describe('add prepackaged rules schema', () => { ).toBeFalsy(); }); - test('validates with timeline_id', () => { + test('validates with timeline_id and timeline_title', () => { expect( addPrepackagedRulesSchema.validate>({ rule_id: 'rule-1', @@ -1117,7 +1115,131 @@ describe('add prepackaged rules schema', () => { language: 'kuery', version: 1, timeline_id: 'timeline-id', + timeline_title: 'timeline-title', }).error ).toBeFalsy(); }); + + test('You cannot omit timeline_title when timeline_id is present', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + version: 1, + timeline_id: 'timeline-id', + }).error + ).toBeTruthy(); + }); + + test('You cannot have a null value for timeline_title when timeline_id is present', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + version: 1, + timeline_id: 'timeline-id', + timeline_title: null, + }).error + ).toBeTruthy(); + }); + + test('You cannot have empty string for timeline_title when timeline_id is present', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + version: 1, + timeline_id: 'timeline-id', + timeline_title: '', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title with an empty timeline_id', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + version: 1, + timeline_id: '', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title without timeline_id', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + version: 1, + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); }); 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 c993b05cb5f29..49907b4a975e6 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 @@ -21,6 +21,7 @@ import { language, saved_id, timeline_id, + timeline_title, meta, risk_score, max_signals, @@ -63,6 +64,7 @@ export const addPrepackagedRulesSchema = Joi.object({ otherwise: Joi.forbidden(), }), timeline_id, + timeline_title, meta, risk_score: risk_score.required(), max_signals: max_signals.default(DEFAULT_MAX_SIGNALS), 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 8dc00b66e97a3..87916bea60649 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 @@ -1024,7 +1024,7 @@ describe('create rules schema', () => { test('You can omit the query string when filters are present', () => { expect( - createRulesSchema.validate & { meta: string }>>({ + createRulesSchema.validate>({ rule_id: 'rule-1', output_index: '.siem-signals', risk_score: 50, @@ -1045,7 +1045,7 @@ describe('create rules schema', () => { ).toBeFalsy(); }); - test('timeline_id validates', () => { + test('validates with timeline_id and timeline_title', () => { expect( createRulesSchema.validate>({ rule_id: 'rule-1', @@ -1062,8 +1062,122 @@ describe('create rules schema', () => { references: ['index-1'], query: 'some query', language: 'kuery', - timeline_id: 'some_id', + timeline_id: 'timeline-id', + timeline_title: 'timeline-title', }).error ).toBeFalsy(); }); + + test('You cannot omit timeline_title when timeline_id is present', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', + }).error + ).toBeTruthy(); + }); + + test('You cannot have a null value for timeline_title when timeline_id is present', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', + timeline_title: null, + }).error + ).toBeTruthy(); + }); + + test('You cannot have empty string for timeline_title when timeline_id is present', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', + timeline_title: '', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title with an empty timeline_id', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: '', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title without timeline_id', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); }); 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 614451312d04d..df5c1694d6c78 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 @@ -22,6 +22,7 @@ import { output_index, saved_id, timeline_id, + timeline_title, meta, risk_score, max_signals, @@ -57,6 +58,7 @@ export const createRulesSchema = Joi.object({ otherwise: Joi.forbidden(), }), timeline_id, + timeline_title, meta, risk_score: risk_score.required(), max_signals: max_signals.default(DEFAULT_MAX_SIGNALS), 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 68d3166c74d6d..c8331bb7820dd 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 @@ -24,6 +24,11 @@ export const language = Joi.string().valid('kuery', 'lucene'); export const output_index = Joi.string(); export const saved_id = Joi.string(); export const timeline_id = Joi.string(); +export const timeline_title = Joi.string().when('timeline_id', { + is: Joi.exist(), + then: Joi.required(), + otherwise: Joi.forbidden(), +}); export const meta = Joi.object(); export const max_signals = Joi.number().greater(0); export const name = Joi.string(); 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 1f00e0a13866a..f713840ab43f9 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 @@ -867,7 +867,7 @@ describe('update rules schema', () => { ).toBeTruthy(); }); - test('timeline_id validates', () => { + test('validates with timeline_id and timeline_title', () => { expect( updateRulesSchema.validate>({ id: 'rule-1', @@ -881,7 +881,101 @@ describe('update rules schema', () => { type: 'saved_query', saved_id: 'some id', timeline_id: 'some-id', + timeline_title: 'some-title', }).error ).toBeFalsy(); }); + + test('You cannot omit timeline_title when timeline_id is present', () => { + expect( + updateRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: 'some-id', + }).error + ).toBeTruthy(); + }); + + test('You cannot have a null value for timeline_title when timeline_id is present', () => { + expect( + updateRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: 'timeline-id', + timeline_title: null, + }).error + ).toBeTruthy(); + }); + + test('You cannot have empty string for timeline_title when timeline_id is present', () => { + expect( + updateRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: 'some-id', + timeline_title: '', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title with an empty timeline_id', () => { + expect( + updateRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: '', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title without timeline_id', () => { + expect( + updateRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); }); 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 afd8a5fce4833..9c3188738faea 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 @@ -22,6 +22,7 @@ import { output_index, saved_id, timeline_id, + timeline_title, meta, risk_score, max_signals, @@ -53,6 +54,7 @@ export const updateRulesSchema = Joi.object({ output_index, saved_id, timeline_id, + timeline_title, meta, risk_score, max_signals, 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 07cf0b0c716cc..d2f76907d7aa3 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 @@ -19,6 +19,7 @@ export const createRules = async ({ language, savedId, timelineId, + timelineTitle, meta, filters, ruleId, @@ -56,6 +57,7 @@ export const createRules = async ({ outputIndex, savedId, timelineId, + timelineTitle, meta, filters, maxSignals, 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 9acfbf8c43221..9c3be64f71a0d 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 @@ -26,6 +26,7 @@ export const installPrepackagedRules = async ( language, saved_id: savedId, timeline_id: timelineId, + timeline_title: timelineTitle, meta, filters, rule_id: ruleId, @@ -55,6 +56,7 @@ export const installPrepackagedRules = async ( outputIndex, savedId, timelineId, + timelineTitle, meta, filters, ruleId, 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 c9dac82b6eb8f..0fe4b15437af8 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 @@ -74,6 +74,7 @@ export const updateRules = async ({ outputIndex, savedId, timelineId, + timelineTitle, meta, filters, from, @@ -118,6 +119,7 @@ export const updateRules = async ({ outputIndex, savedId, timelineId, + timelineTitle, meta, filters, index, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_timelineid.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_timelineid.json index 2f995029447ff..eb87a14e0c688 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_timelineid.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_timelineid.json @@ -7,5 +7,6 @@ "from": "now-6m", "to": "now", "query": "user.name: root or user.name: admin", - "timeline_id": "timeline-id" + "timeline_id": "timeline-id", + "timeline_title": "timeline_title" } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json index 60095a0a6a833..46a2feeefd49e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json @@ -78,5 +78,6 @@ "Some plain text string here explaining why this is a valid thing to look out for" ], "timeline_id": "timeline_id", + "timeline_title": "timeline_title", "version": 1 } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/saved_queries/saved_query_with_everything.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/saved_queries/saved_query_with_everything.json index 2628b69eb064d..16d5d6cc2b36a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/saved_queries/saved_query_with_everything.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/saved_queries/saved_query_with_everything.json @@ -78,5 +78,6 @@ "Some plain text string here explaining why this is a valid thing to look out for" ], "saved_id": "test-saved-id", - "timeline_id": "test-timeline-id" + "timeline_id": "test-timeline-id", + "timeline_title": "test-timeline-title" } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_query_everything.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_query_everything.json index 4da285e5b09bf..7fc8de9fe8f9e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_query_everything.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_query_everything.json @@ -78,5 +78,6 @@ "Some plain text string here explaining why this is a valid thing to look out for" ], "timeline_id": "other-timeline-id", + "timeline_title": "other-timeline-title", "version": 42 } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json index 8cfa3303f54a6..27dee7dd81463 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json @@ -1,4 +1,5 @@ { "rule_id": "query-rule-id", - "timeline_id": "other-timeline-id" + "timeline_id": "other-timeline-id", + "timeline_title": "other-timeline-title" } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts index 5e50b65b51717..ede82a597b238 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -31,6 +31,7 @@ export const sampleRuleAlertParams = ( filters: undefined, savedId: undefined, timelineId: undefined, + timelineTitle: undefined, meta: undefined, threats: undefined, version: 1, 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 0a3526d32e511..1093ff3a8a462 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 @@ -34,6 +34,7 @@ export const buildRule = ({ false_positives: ruleParams.falsePositives, saved_id: ruleParams.savedId, timeline_id: ruleParams.timelineId, + timeline_title: ruleParams.timelineTitle, meta: ruleParams.meta, max_signals: ruleParams.maxSignals, risk_score: ruleParams.riskScore, 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 87d31abbc5371..ab2c1733b04ca 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 @@ -42,6 +42,7 @@ export const signalRulesAlertType = ({ outputIndex: schema.nullable(schema.string()), savedId: schema.nullable(schema.string()), timelineId: schema.nullable(schema.string()), + timelineTitle: schema.nullable(schema.string()), meta: schema.nullable(schema.object({}, { allowUnknowns: true })), query: schema.nullable(schema.string()), filters: schema.nullable(schema.arrayOf(schema.object({}, { allowUnknowns: true }))), 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 f4a8263da6ba4..ff0f2a8782cd2 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 @@ -44,6 +44,7 @@ export interface RuleAlertParams { tags: string[]; to: string; timelineId: string | undefined | null; + timelineTitle: string | undefined | null; threats: ThreatParams[] | undefined | null; type: 'query' | 'saved_query'; version: number; @@ -60,6 +61,7 @@ export type RuleAlertParamsRest = Omit< | 'savedId' | 'riskScore' | 'timelineId' + | 'timelineTitle' | 'outputIndex' | 'updatedAt' | 'createdAt' @@ -68,6 +70,7 @@ export type RuleAlertParamsRest = Omit< false_positives: RuleAlertParams['falsePositives']; saved_id: RuleAlertParams['savedId']; timeline_id: RuleAlertParams['timelineId']; + timeline_title: RuleAlertParams['timelineTitle']; max_signals: RuleAlertParams['maxSignals']; risk_score: RuleAlertParams['riskScore']; output_index: RuleAlertParams['outputIndex']; From 0e46b240bbc7daef9e8191e847adb924f7947bab Mon Sep 17 00:00:00 2001 From: Brandon Morelli Date: Wed, 8 Jan 2020 16:44:56 -0800 Subject: [PATCH 20/23] [docs][APM] Add runtime index config documentation (#53907) --- docs/apm/settings.asciidoc | 14 +++++++++++--- docs/apm/troubleshooting.asciidoc | 16 ++++++++++++---- docs/settings/apm-settings.asciidoc | 23 ++++++++++++++++++++--- docs/settings/images/apm-settings.png | Bin 0 -> 299491 bytes 4 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 docs/settings/images/apm-settings.png diff --git a/docs/apm/settings.asciidoc b/docs/apm/settings.asciidoc index 2fc8748f13b09..37122fc9c635d 100644 --- a/docs/apm/settings.asciidoc +++ b/docs/apm/settings.asciidoc @@ -3,8 +3,16 @@ [[apm-settings-in-kibana]] === APM settings in Kibana -You do not need to configure any settings to use APM. It is enabled by default. -If you'd like to change any of the default values, -copy and paste the relevant settings below into your `kibana.yml` configuration file. +You do not need to configure any settings to use the APM app. It is enabled by default. + +[float] +[[apm-indices-settings]] +==== APM Indices + +include::./../settings/apm-settings.asciidoc[tag=apm-indices-settings] + +[float] +[[general-apm-settings]] +==== General APM settings include::./../settings/apm-settings.asciidoc[tag=general-apm-settings] diff --git a/docs/apm/troubleshooting.asciidoc b/docs/apm/troubleshooting.asciidoc index ec0863b09d653..22279b69b70fe 100644 --- a/docs/apm/troubleshooting.asciidoc +++ b/docs/apm/troubleshooting.asciidoc @@ -17,6 +17,7 @@ This section can help with any of the following: There are a number of factors that could be at play here. One important thing to double-check first is your index template. +*Index template* An APM index template must exist for the APM app to work correctly. By default, this index template is created by APM Server on startup. However, this only happens if `setup.template.enabled` is `true` in `apm-server.yml`. @@ -34,14 +35,21 @@ GET /_template/apm-{version} -------------------------------------------------- // CONSOLE +*Using Logstash, Kafka, etc.* If you're not outputting data directly from APM Server to Elasticsearch (perhaps you're using Logstash or Kafka), then the index template will not be set up automatically. Instead, you'll need to -{apm-server-ref}/_manually_loading_template_configuration.html#load-template-manually-alternate[load the template manually]. +{apm-server-ref}/_manually_loading_template_configuration.html[load the template manually]. -Finally, this problem can also occur if you've changed the index name that you write APM data to. -The default index pattern can be found {apm-server-ref}/elasticsearch-output.html#index-option-es[here]. -If you change this setting, you must also configure the `setup.template.name` and `setup.template.pattern` options. +*Using a custom index names* +This problem can also occur if you've customized the index name that you write APM data to. +The default index name that APM writes events to can be found +{apm-server-ref}/elasticsearch-output.html#index-option-es[here]. +If you change the default, you must also configure the `setup.template.name` and `setup.template.pattern` options. See {apm-server-ref}/configuration-template.html[Load the Elasticsearch index template]. +If the Elasticsearch index template has already been successfully loaded to the index, +you can customize the indices that the APM app uses to display data. +Navigate to *APM* > *Settings* > *Indices*, and change all `apm_oss.*Pattern` values to +include the new index pattern. For example: `customIndexName-*`. ==== Unknown route diff --git a/docs/settings/apm-settings.asciidoc b/docs/settings/apm-settings.asciidoc index 8d28b55a6502f..a6eeffec51cb0 100644 --- a/docs/settings/apm-settings.asciidoc +++ b/docs/settings/apm-settings.asciidoc @@ -5,9 +5,23 @@ APM settings ++++ -You do not need to configure any settings to use APM. It is enabled by default. -If you'd like to change any of the default values, -copy and paste the relevant settings below into your `kibana.yml` configuration file. +You do not need to configure any settings to use the APM app. It is enabled by default. + +[float] +[[apm-indices-settings-kb]] +==== APM Indices + +// This content is reused in the APM app documentation. +// Any changes made in this file will be seen there as well. +// tag::apm-indices-settings[] + +Index defaults can be changed in Kibana. Navigate to *APM* > *Settings* > *Indices*. +Index settings in the APM app take precedence over those set in `kibana.yml`. + +[role="screenshot"] +image::settings/images/apm-settings.png[APM app settings in Kibana] + +// end::apm-indices-settings[] [float] [[general-apm-settings-kb]] @@ -17,6 +31,9 @@ copy and paste the relevant settings below into your `kibana.yml` configuration // Any changes made in this file will be seen there as well. // tag::general-apm-settings[] +If you'd like to change any of the default values, +copy and paste the relevant settings below into your `kibana.yml` configuration file. + xpack.apm.enabled:: Set to `false` to disabled the APM plugin {kib}. Defaults to `true`. diff --git a/docs/settings/images/apm-settings.png b/docs/settings/images/apm-settings.png new file mode 100644 index 0000000000000000000000000000000000000000..876f135da93564e81886547698db7103fc83f0f2 GIT binary patch literal 299491 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 z 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

BFWe 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 O@3e*WkLBj)ul^74l!HG2 literal 0 HcmV?d00001 From a27c4c4a4e67383e790b0fd4de187735bda69487 Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Thu, 9 Jan 2020 02:55:17 +0200 Subject: [PATCH 21/23] =?UTF-8?q?[Telemetry]=20[Monitoring]=20Only=20retry?= =?UTF-8?q?=20fetching=20usage=20once=20monito=E2=80=A6=20(#54309)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix interval and add tests * Update x-pack/legacy/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js Co-Authored-By: Christiane (Tina) Heiligers Co-authored-by: Christiane (Tina) Heiligers --- .../__tests__/bulk_uploader.js | 54 ++++++++++++++--- .../server/kibana_monitoring/bulk_uploader.js | 58 ++++++++++++------- 2 files changed, 85 insertions(+), 27 deletions(-) diff --git a/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/__tests__/bulk_uploader.js b/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/__tests__/bulk_uploader.js index fff0b742925c8..ef7d3f1224fab 100644 --- a/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/__tests__/bulk_uploader.js +++ b/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/__tests__/bulk_uploader.js @@ -209,7 +209,7 @@ describe('BulkUploader', () => { }, CHECK_DELAY); }); - it('refetches UsageCollectors if uploading to local cluster was not successful', done => { + it('stops refetching UsageCollectors if uploading to local cluster was not successful', async () => { const usageCollectorFetch = sinon .stub() .returns({ type: 'type_usage_collector_test', result: { testData: 12345 } }); @@ -227,12 +227,52 @@ describe('BulkUploader', () => { uploader._onPayload = async () => ({ took: 0, ignored: true, errors: false }); - uploader.start(collectors); - setTimeout(() => { - uploader.stop(); - expect(usageCollectorFetch.callCount).to.be.greaterThan(1); - done(); - }, CHECK_DELAY); + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + + expect(uploader._holdSendingUsage).to.eql(true); + expect(usageCollectorFetch.callCount).to.eql(1); + }); + + it('fetches UsageCollectors once uploading to local cluster is successful again', async () => { + const usageCollectorFetch = sinon + .stub() + .returns({ type: 'type_usage_collector_test', result: { usageData: 12345 } }); + + const statsCollectorFetch = sinon + .stub() + .returns({ type: 'type_stats_collector_test', result: { statsData: 12345 } }); + + const collectors = new MockCollectorSet(server, [ + { + fetch: statsCollectorFetch, + isReady: () => true, + formatForBulkUpload: result => result, + isUsageCollector: false, + }, + { + fetch: usageCollectorFetch, + isReady: () => true, + formatForBulkUpload: result => result, + isUsageCollector: true, + }, + ]); + + const uploader = new BulkUploader({ ...server, interval: FETCH_INTERVAL }); + let bulkIgnored = true; + uploader._onPayload = async () => ({ took: 0, ignored: bulkIgnored, errors: false }); + + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + expect(uploader._holdSendingUsage).to.eql(true); + + bulkIgnored = false; + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + + expect(uploader._holdSendingUsage).to.eql(false); + expect(usageCollectorFetch.callCount).to.eql(2); + expect(statsCollectorFetch.callCount).to.eql(3); }); it('calls UsageCollectors if last reported exceeds during a _usageInterval', done => { diff --git a/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js b/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js index 2d81cb23b6b3b..5e0d8aa4be1fd 100644 --- a/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js +++ b/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js @@ -40,8 +40,14 @@ export class BulkUploader { } this._timer = null; + // Hold sending and fetching usage until monitoring.bulk is successful. This means that we + // send usage data on the second tick. But would save a lot of bandwidth fetching usage on + // every tick when ES is failing or monitoring is disabled. + this._holdSendingUsage = false; this._interval = interval; this._lastFetchUsageTime = null; + // Limit sending and fetching usage to once per day once usage is successfully stored + // into the monitoring indices. this._usageInterval = TELEMETRY_COLLECTION_INTERVAL; this._log = { @@ -65,6 +71,29 @@ export class BulkUploader { }); } + filterCollectorSet(usageCollection) { + const successfulUploadInLastDay = + this._lastFetchUsageTime && this._lastFetchUsageTime + this._usageInterval > Date.now(); + + return usageCollection.getFilteredCollectorSet(c => { + // this is internal bulk upload, so filter out API-only collectors + if (c.ignoreForInternalUploader) { + return false; + } + // Only collect usage data at the same interval as telemetry would (default to once a day) + if (usageCollection.isUsageCollector(c)) { + if (this._holdSendingUsage) { + return false; + } + if (successfulUploadInLastDay) { + return false; + } + } + + return true; + }); + } + /* * Start the interval timer * @param {usageCollection} usageCollection object to use for initial the fetch/upload and fetch/uploading on interval @@ -72,31 +101,15 @@ export class BulkUploader { */ start(usageCollection) { this._log.info('Starting monitoring stats collection'); - const filterCollectorSet = _usageCollection => { - const successfulUploadInLastDay = - this._lastFetchUsageTime && this._lastFetchUsageTime + this._usageInterval > Date.now(); - - return _usageCollection.getFilteredCollectorSet(c => { - // this is internal bulk upload, so filter out API-only collectors - if (c.ignoreForInternalUploader) { - return false; - } - // Only collect usage data at the same interval as telemetry would (default to once a day) - if (successfulUploadInLastDay && _usageCollection.isUsageCollector(c)) { - return false; - } - return true; - }); - }; if (this._timer) { clearInterval(this._timer); } else { - this._fetchAndUpload(filterCollectorSet(usageCollection)); // initial fetch + this._fetchAndUpload(this.filterCollectorSet(usageCollection)); // initial fetch } this._timer = setInterval(() => { - this._fetchAndUpload(filterCollectorSet(usageCollection)); + this._fetchAndUpload(this.filterCollectorSet(usageCollection)); }, this._interval); } @@ -146,12 +159,17 @@ export class BulkUploader { const sendSuccessful = !result.ignored && !result.errors; if (!sendSuccessful && hasUsageCollectors) { this._lastFetchUsageTime = null; + this._holdSendingUsage = true; this._log.debug( 'Resetting lastFetchWithUsage because uploading to the cluster was not successful.' ); } - if (sendSuccessful && hasUsageCollectors) { - this._lastFetchUsageTime = Date.now(); + + if (sendSuccessful) { + this._holdSendingUsage = false; + if (hasUsageCollectors) { + this._lastFetchUsageTime = Date.now(); + } } this._log.debug(`Uploaded bulk stats payload to the local cluster`); } catch (err) { From d5939c4af8721d5696353e664848858d13d8916c Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Thu, 9 Jan 2020 03:41:24 +0100 Subject: [PATCH 22/23] [Discover] fix histogram min interval (#53979) - Fixes issues involving min intervals for leap years and DST --- .../np_ready/angular/directives/histogram.tsx | 57 +++++++++++- .../np_ready/public/legacy/build_pipeline.ts | 2 + .../point_series/__tests__/_init_x_axis.js | 4 + .../agg_response/point_series/_init_x_axis.js | 18 +++- .../apps/discover/_discover_histogram.js | 91 +++++++++++++++++++ test/functional/apps/discover/index.js | 1 + .../apps/management/_handle_alias.js | 2 +- test/functional/page_objects/discover_page.js | 15 ++- 8 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 test/functional/apps/discover/_discover_histogram.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx index c2f716ff6c45a..b83ac5b4a7795 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx @@ -19,6 +19,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer } from '@elastic/eui'; import moment from 'moment-timezone'; +import { unitOfTime } from 'moment'; import React, { Component } from 'react'; import PropTypes from 'prop-types'; import lightEuiTheme from '@elastic/eui/dist/eui_theme_light.json'; @@ -53,6 +54,58 @@ interface DiscoverHistogramState { chartsTheme: EuiChartThemeType['theme']; } +function findIntervalFromDuration( + dateValue: number, + esValue: number, + esUnit: unitOfTime.Base, + timeZone: string +) { + const date = moment.tz(dateValue, timeZone); + const startOfDate = moment.tz(date, timeZone).startOf(esUnit); + const endOfDate = moment + .tz(date, timeZone) + .startOf(esUnit) + .add(esValue, esUnit); + return endOfDate.valueOf() - startOfDate.valueOf(); +} + +function getIntervalInMs( + value: number, + esValue: number, + esUnit: unitOfTime.Base, + timeZone: string +): number { + switch (esUnit) { + case 's': + return 1000 * esValue; + case 'ms': + return 1 * esValue; + default: + return findIntervalFromDuration(value, esValue, esUnit, timeZone); + } +} + +export function findMinInterval( + xValues: number[], + esValue: number, + esUnit: string, + timeZone: string +): number { + return xValues.reduce((minInterval, currentXvalue, index) => { + let currentDiff = minInterval; + if (index > 0) { + currentDiff = Math.abs(xValues[index - 1] - currentXvalue); + } + const singleUnitInterval = getIntervalInMs( + currentXvalue, + esValue, + esUnit as unitOfTime.Base, + timeZone + ); + return Math.min(minInterval, singleUnitInterval, currentDiff); + }, Number.MAX_SAFE_INTEGER); +} + export class DiscoverHistogram extends Component { public static propTypes = { chartData: PropTypes.object, @@ -154,7 +207,7 @@ export class DiscoverHistogram extends Component { + await esArchiver.unload('long_window_logstash'); + await esArchiver.unload('visualize'); + await esArchiver.unload('discover'); + }); + + it('should visualize monthly data with different day intervals', async () => { + //Nov 1, 2017 @ 01:00:00.000 - Mar 21, 2018 @ 02:00:00.000 + const fromTime = '2017-11-01 00:00:00.000'; + const toTime = '2018-03-21 00:00:00.000'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.setChartInterval('Monthly'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const chartCanvasExist = await PageObjects.discover.chartCanvasExist(); + expect(chartCanvasExist).to.be(true); + }); + it('should visualize weekly data with within DST changes', async () => { + //Nov 1, 2017 @ 01:00:00.000 - Mar 21, 2018 @ 02:00:00.000 + const fromTime = '2018-03-01 00:00:00.000'; + const toTime = '2018-05-01 00:00:00.000'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.setChartInterval('Weekly'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const chartCanvasExist = await PageObjects.discover.chartCanvasExist(); + expect(chartCanvasExist).to.be(true); + }); + it('should visualize monthly data with different years Scaled to 30d', async () => { + //Nov 1, 2017 @ 01:00:00.000 - Mar 21, 2018 @ 02:00:00.000 + const fromTime = '2010-01-01 00:00:00.000'; + const toTime = '2018-03-21 00:00:00.000'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.setChartInterval('Daily'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const chartCanvasExist = await PageObjects.discover.chartCanvasExist(); + expect(chartCanvasExist).to.be(true); + }); + }); +} diff --git a/test/functional/apps/discover/index.js b/test/functional/apps/discover/index.js index e10e772e93ab1..64a5a61335365 100644 --- a/test/functional/apps/discover/index.js +++ b/test/functional/apps/discover/index.js @@ -34,6 +34,7 @@ export default function({ getService, loadTestFile }) { loadTestFile(require.resolve('./_saved_queries')); loadTestFile(require.resolve('./_discover')); + loadTestFile(require.resolve('./_discover_histogram')); loadTestFile(require.resolve('./_filter_editor')); loadTestFile(require.resolve('./_errors')); loadTestFile(require.resolve('./_field_data')); diff --git a/test/functional/apps/management/_handle_alias.js b/test/functional/apps/management/_handle_alias.js index 06406bddeb009..3d9368f8d4680 100644 --- a/test/functional/apps/management/_handle_alias.js +++ b/test/functional/apps/management/_handle_alias.js @@ -74,7 +74,7 @@ export default function({ getService, getPageObjects }) { const toTime = 'Nov 19, 2016 @ 05:00:00.000'; await PageObjects.common.navigateToApp('discover'); - await PageObjects.discover.selectIndexPattern('alias2'); + await PageObjects.discover.selectIndexPattern('alias2*'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); await retry.try(async function() { diff --git a/test/functional/page_objects/discover_page.js b/test/functional/page_objects/discover_page.js index 7029fbf9e1350..3ba0f217813f2 100644 --- a/test/functional/page_objects/discover_page.js +++ b/test/functional/page_objects/discover_page.js @@ -117,8 +117,16 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { await testSubjects.click('discoverOpenButton'); } + async getChartCanvas() { + return await find.byCssSelector('.echChart canvas:last-of-type'); + } + + async chartCanvasExist() { + return await find.existsByCssSelector('.echChart canvas:last-of-type'); + } + async clickHistogramBar() { - const el = await find.byCssSelector('.echChart canvas:last-of-type'); + const el = await this.getChartCanvas(); await browser .getActions() @@ -128,7 +136,8 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { } async brushHistogram() { - const el = await find.byCssSelector('.echChart canvas:last-of-type'); + const el = await this.getChartCanvas(); + await browser.dragAndDrop( { location: el, offset: { x: 200, y: 20 } }, { location: el, offset: { x: 400, y: 30 } } @@ -279,7 +288,7 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { async selectIndexPattern(indexPattern) { await testSubjects.click('indexPattern-switch-link'); await find.clickByCssSelector( - `[data-test-subj="indexPattern-switcher"] [title="${indexPattern}*"]` + `[data-test-subj="indexPattern-switcher"] [title="${indexPattern}"]` ); await PageObjects.header.waitUntilLoadingHasFinished(); } From ecddfd8842e48f9d559c1b4a7d5c4a42fa4259e4 Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Thu, 9 Jan 2020 11:00:30 +0300 Subject: [PATCH 23/23] [Vis: Default editor] Reactify the timelion editor (#52990) * Reactify timelion editor * Change translation ids * Add @types/pegjs into renovate.json5 * Add validation, add hover suggestions * Style fixes * Change plugin setup, use kibana context * Change plugin start * Mock services * Fix other comments * Build renovate config * Fix some classnames and SASS file structure Co-authored-by: Elastic Machine Co-authored-by: Caroline Horn <549577+cchaos@users.noreply.github.com> --- package.json | 1 + renovate.json5 | 8 + .../core_plugins/timelion/common/types.ts | 46 +++ .../timelion/public/components/_index.scss | 1 + .../_timelion_expression_input.scss | 18 ++ .../timelion/public/components/index.ts | 21 ++ .../components/timelion_expression_input.tsx | 146 +++++++++ .../timelion_expression_input_helpers.ts | 287 ++++++++++++++++++ .../public/components/timelion_interval.tsx | 144 +++++++++ .../timelion_expression_input_helpers.js | 13 +- .../directives/timelion_expression_input.js | 6 +- .../core_plugins/timelion/public/index.scss | 1 + .../core_plugins/timelion/public/legacy.ts | 2 +- .../public/panels/timechart/schema.ts | 1 + .../core_plugins/timelion/public/plugin.ts | 7 +- .../arg_value_suggestions.ts} | 81 +++-- .../public/services/plugin_services.ts | 30 ++ .../timelion/public/timelion_vis_fn.ts | 6 +- .../timelion/public/vis/_index.scss | 1 + .../timelion/public/vis/_timelion_editor.scss | 15 + .../public/vis/{index.ts => index.tsx} | 13 +- .../timelion/public/vis/timelion_options.tsx | 48 +++ .../public/vis/timelion_vis_params.html | 27 -- .../server/lib/classes/timelion_function.d.ts | 17 +- .../core_plugins/timelion/server/types.ts | 9 +- .../public/code_editor/code_editor.tsx | 14 + yarn.lock | 5 + 27 files changed, 875 insertions(+), 93 deletions(-) create mode 100644 src/legacy/core_plugins/timelion/common/types.ts create mode 100644 src/legacy/core_plugins/timelion/public/components/_index.scss create mode 100644 src/legacy/core_plugins/timelion/public/components/_timelion_expression_input.scss create mode 100644 src/legacy/core_plugins/timelion/public/components/index.ts create mode 100644 src/legacy/core_plugins/timelion/public/components/timelion_expression_input.tsx create mode 100644 src/legacy/core_plugins/timelion/public/components/timelion_expression_input_helpers.ts create mode 100644 src/legacy/core_plugins/timelion/public/components/timelion_interval.tsx rename src/legacy/core_plugins/timelion/public/{directives/timelion_expression_suggestions/arg_value_suggestions.js => services/arg_value_suggestions.ts} (72%) create mode 100644 src/legacy/core_plugins/timelion/public/services/plugin_services.ts create mode 100644 src/legacy/core_plugins/timelion/public/vis/_timelion_editor.scss rename src/legacy/core_plugins/timelion/public/vis/{index.ts => index.tsx} (80%) create mode 100644 src/legacy/core_plugins/timelion/public/vis/timelion_options.tsx delete mode 100644 src/legacy/core_plugins/timelion/public/vis/timelion_vis_params.html diff --git a/package.json b/package.json index db5764e6e91ba..0dbed9e432e99 100644 --- a/package.json +++ b/package.json @@ -341,6 +341,7 @@ "@types/mustache": "^0.8.31", "@types/node": "^10.12.27", "@types/opn": "^5.1.0", + "@types/pegjs": "^0.10.1", "@types/pngjs": "^3.3.2", "@types/podium": "^1.0.0", "@types/prop-types": "^15.5.3", diff --git a/renovate.json5 b/renovate.json5 index f069e961c0f2b..a5983283a9e85 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -673,6 +673,14 @@ '@types/parse-link-header', ], }, + { + groupSlug: 'pegjs', + groupName: 'pegjs related packages', + packageNames: [ + 'pegjs', + '@types/pegjs', + ], + }, { groupSlug: 'pngjs', groupName: 'pngjs related packages', diff --git a/src/legacy/core_plugins/timelion/common/types.ts b/src/legacy/core_plugins/timelion/common/types.ts new file mode 100644 index 0000000000000..f7084948a14f7 --- /dev/null +++ b/src/legacy/core_plugins/timelion/common/types.ts @@ -0,0 +1,46 @@ +/* + * 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. + */ + +type TimelionFunctionArgsTypes = 'seriesList' | 'number' | 'string' | 'boolean' | 'null'; + +interface TimelionFunctionArgsSuggestion { + name: string; + help: string; +} + +export interface TimelionFunctionArgs { + name: string; + help?: string; + multi?: boolean; + types: TimelionFunctionArgsTypes[]; + suggestions?: TimelionFunctionArgsSuggestion[]; +} + +export interface ITimelionFunction { + aliases: string[]; + args: TimelionFunctionArgs[]; + name: string; + help: string; + chainable: boolean; + extended: boolean; + isAlias: boolean; + argsByName: { + [key: string]: TimelionFunctionArgs[]; + }; +} diff --git a/src/legacy/core_plugins/timelion/public/components/_index.scss b/src/legacy/core_plugins/timelion/public/components/_index.scss new file mode 100644 index 0000000000000..f2458a367e176 --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/_index.scss @@ -0,0 +1 @@ +@import './timelion_expression_input'; diff --git a/src/legacy/core_plugins/timelion/public/components/_timelion_expression_input.scss b/src/legacy/core_plugins/timelion/public/components/_timelion_expression_input.scss new file mode 100644 index 0000000000000..b1c0b5514ff7a --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/_timelion_expression_input.scss @@ -0,0 +1,18 @@ +.timExpressionInput { + flex: 1 1 auto; + display: flex; + flex-direction: column; + margin-top: $euiSize; +} + +.timExpressionInput__editor { + height: 100%; + padding-top: $euiSizeS; +} + +@include euiBreakpoint('xs', 's', 'm') { + .timExpressionInput__editor { + height: $euiSize * 15; + max-height: $euiSize * 15; + } +} diff --git a/src/legacy/core_plugins/timelion/public/components/index.ts b/src/legacy/core_plugins/timelion/public/components/index.ts new file mode 100644 index 0000000000000..8d7d32a3ba262 --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/index.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +export * from './timelion_expression_input'; +export * from './timelion_interval'; diff --git a/src/legacy/core_plugins/timelion/public/components/timelion_expression_input.tsx b/src/legacy/core_plugins/timelion/public/components/timelion_expression_input.tsx new file mode 100644 index 0000000000000..c695d09ca822b --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/timelion_expression_input.tsx @@ -0,0 +1,146 @@ +/* + * 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 React, { useEffect, useCallback, useRef, useMemo } from 'react'; +import { EuiFormLabel } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; + +import { CodeEditor, useKibana } from '../../../../../plugins/kibana_react/public'; +import { suggest, getSuggestion } from './timelion_expression_input_helpers'; +import { ITimelionFunction, TimelionFunctionArgs } from '../../common/types'; +import { getArgValueSuggestions } from '../services/arg_value_suggestions'; + +const LANGUAGE_ID = 'timelion_expression'; +monacoEditor.languages.register({ id: LANGUAGE_ID }); + +interface TimelionExpressionInputProps { + value: string; + setValue(value: string): void; +} + +function TimelionExpressionInput({ value, setValue }: TimelionExpressionInputProps) { + const functionList = useRef([]); + const kibana = useKibana(); + const argValueSuggestions = useMemo(getArgValueSuggestions, []); + + const provideCompletionItems = useCallback( + async (model: monacoEditor.editor.ITextModel, position: monacoEditor.Position) => { + const text = model.getValue(); + const wordUntil = model.getWordUntilPosition(position); + const wordRange = new monacoEditor.Range( + position.lineNumber, + wordUntil.startColumn, + position.lineNumber, + wordUntil.endColumn + ); + + const suggestions = await suggest( + text, + functionList.current, + // it's important to offset the cursor position on 1 point left + // because of PEG parser starts the line with 0, but monaco with 1 + position.column - 1, + argValueSuggestions + ); + + return { + suggestions: suggestions + ? suggestions.list.map((s: ITimelionFunction | TimelionFunctionArgs) => + getSuggestion(s, suggestions.type, wordRange) + ) + : [], + }; + }, + [argValueSuggestions] + ); + + const provideHover = useCallback( + async (model: monacoEditor.editor.ITextModel, position: monacoEditor.Position) => { + const suggestions = await suggest( + model.getValue(), + functionList.current, + // it's important to offset the cursor position on 1 point left + // because of PEG parser starts the line with 0, but monaco with 1 + position.column - 1, + argValueSuggestions + ); + + return { + contents: suggestions + ? suggestions.list.map((s: ITimelionFunction | TimelionFunctionArgs) => ({ + value: s.help, + })) + : [], + }; + }, + [argValueSuggestions] + ); + + useEffect(() => { + if (kibana.services.http) { + kibana.services.http.get('../api/timelion/functions').then(data => { + functionList.current = data; + }); + } + }, [kibana.services.http]); + + return ( +

+ + + +
+ +
+
+ ); +} + +export { TimelionExpressionInput }; diff --git a/src/legacy/core_plugins/timelion/public/components/timelion_expression_input_helpers.ts b/src/legacy/core_plugins/timelion/public/components/timelion_expression_input_helpers.ts new file mode 100644 index 0000000000000..fc90c276eeca2 --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/timelion_expression_input_helpers.ts @@ -0,0 +1,287 @@ +/* + * 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 { get, startsWith } from 'lodash'; +import PEG from 'pegjs'; +import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; + +// @ts-ignore +import grammar from 'raw-loader!../chain.peg'; + +import { i18n } from '@kbn/i18n'; +import { ITimelionFunction, TimelionFunctionArgs } from '../../common/types'; +import { ArgValueSuggestions, FunctionArg, Location } from '../services/arg_value_suggestions'; + +const Parser = PEG.generate(grammar); + +export enum SUGGESTION_TYPE { + ARGUMENTS = 'arguments', + ARGUMENT_VALUE = 'argument_value', + FUNCTIONS = 'functions', +} + +function inLocation(cursorPosition: number, location: Location) { + return cursorPosition >= location.min && cursorPosition <= location.max; +} + +function getArgumentsHelp( + functionHelp: ITimelionFunction | undefined, + functionArgs: FunctionArg[] = [] +) { + if (!functionHelp) { + return []; + } + + // Do not provide 'inputSeries' as argument suggestion for chainable functions + const argsHelp = functionHelp.chainable ? functionHelp.args.slice(1) : functionHelp.args.slice(0); + + // ignore arguments that are already provided in function declaration + const functionArgNames = functionArgs.map(arg => arg.name); + return argsHelp.filter(arg => !functionArgNames.includes(arg.name)); +} + +async function extractSuggestionsFromParsedResult( + result: ReturnType, + cursorPosition: number, + functionList: ITimelionFunction[], + argValueSuggestions: ArgValueSuggestions +) { + const activeFunc = result.functions.find(({ location }: { location: Location }) => + inLocation(cursorPosition, location) + ); + + if (!activeFunc) { + return; + } + + const functionHelp = functionList.find(({ name }) => name === activeFunc.function); + + if (!functionHelp) { + return; + } + + // return function suggestion when cursor is outside of parentheses + // location range includes '.', function name, and '('. + const openParen = activeFunc.location.min + activeFunc.function.length + 2; + if (cursorPosition < openParen) { + return { list: [functionHelp], type: SUGGESTION_TYPE.FUNCTIONS }; + } + + // return argument value suggestions when cursor is inside argument value + const activeArg = activeFunc.arguments.find((argument: FunctionArg) => { + return inLocation(cursorPosition, argument.location); + }); + if ( + activeArg && + activeArg.type === 'namedArg' && + inLocation(cursorPosition, activeArg.value.location) + ) { + const { function: functionName, arguments: functionArgs } = activeFunc; + + const { + name: argName, + value: { text: partialInput }, + } = activeArg; + + let valueSuggestions; + if (argValueSuggestions.hasDynamicSuggestionsForArgument(functionName, argName)) { + valueSuggestions = await argValueSuggestions.getDynamicSuggestionsForArgument( + functionName, + argName, + functionArgs, + partialInput + ); + } else { + const { suggestions: staticSuggestions } = + functionHelp.args.find(arg => arg.name === activeArg.name) || {}; + valueSuggestions = argValueSuggestions.getStaticSuggestionsForInput( + partialInput, + staticSuggestions + ); + } + return { + list: valueSuggestions, + type: SUGGESTION_TYPE.ARGUMENT_VALUE, + }; + } + + // return argument suggestions + const argsHelp = getArgumentsHelp(functionHelp, activeFunc.arguments); + const argumentSuggestions = argsHelp.filter(arg => { + if (get(activeArg, 'type') === 'namedArg') { + return startsWith(arg.name, activeArg.name); + } else if (activeArg) { + return startsWith(arg.name, activeArg.text); + } + return true; + }); + return { list: argumentSuggestions, type: SUGGESTION_TYPE.ARGUMENTS }; +} + +export async function suggest( + expression: string, + functionList: ITimelionFunction[], + cursorPosition: number, + argValueSuggestions: ArgValueSuggestions +) { + try { + const result = await Parser.parse(expression); + + return await extractSuggestionsFromParsedResult( + result, + cursorPosition, + functionList, + argValueSuggestions + ); + } catch (err) { + let message: any; + try { + // The grammar will throw an error containing a message if the expression is formatted + // correctly and is prepared to accept suggestions. If the expression is not formatted + // correctly the grammar will just throw a regular PEG SyntaxError, and this JSON.parse + // attempt will throw an error. + message = JSON.parse(err.message); + } catch (e) { + // The expression isn't correctly formatted, so JSON.parse threw an error. + return; + } + + switch (message.type) { + case 'incompleteFunction': { + let list; + if (message.function) { + // The user has start typing a function name, so we'll filter the list down to only + // possible matches. + list = functionList.filter(func => startsWith(func.name, message.function)); + } else { + // The user hasn't typed anything yet, so we'll just return the entire list. + list = functionList; + } + return { list, type: SUGGESTION_TYPE.FUNCTIONS }; + } + case 'incompleteArgument': { + const { currentFunction: functionName, currentArgs: functionArgs } = message; + const functionHelp = functionList.find(func => func.name === functionName); + return { + list: getArgumentsHelp(functionHelp, functionArgs), + type: SUGGESTION_TYPE.ARGUMENTS, + }; + } + case 'incompleteArgumentValue': { + const { name: argName, currentFunction: functionName, currentArgs: functionArgs } = message; + let valueSuggestions = []; + if (argValueSuggestions.hasDynamicSuggestionsForArgument(functionName, argName)) { + valueSuggestions = await argValueSuggestions.getDynamicSuggestionsForArgument( + functionName, + argName, + functionArgs + ); + } else { + const functionHelp = functionList.find(func => func.name === functionName); + if (functionHelp) { + const argHelp = functionHelp.args.find(arg => arg.name === argName); + if (argHelp && argHelp.suggestions) { + valueSuggestions = argHelp.suggestions; + } + } + } + return { + list: valueSuggestions, + type: SUGGESTION_TYPE.ARGUMENT_VALUE, + }; + } + } + } +} + +export function getSuggestion( + suggestion: ITimelionFunction | TimelionFunctionArgs, + type: SUGGESTION_TYPE, + range: monacoEditor.Range +): monacoEditor.languages.CompletionItem { + let kind: monacoEditor.languages.CompletionItemKind = + monacoEditor.languages.CompletionItemKind.Method; + let insertText: string = suggestion.name; + let insertTextRules: monacoEditor.languages.CompletionItem['insertTextRules']; + let detail: string = ''; + let command: monacoEditor.languages.CompletionItem['command']; + + switch (type) { + case SUGGESTION_TYPE.ARGUMENTS: + command = { + title: 'Trigger Suggestion Dialog', + id: 'editor.action.triggerSuggest', + }; + kind = monacoEditor.languages.CompletionItemKind.Property; + insertText = `${insertText}=`; + detail = `${i18n.translate( + 'timelion.expressionSuggestions.argument.description.acceptsText', + { + defaultMessage: 'Accepts', + } + )}: ${(suggestion as TimelionFunctionArgs).types}`; + + break; + case SUGGESTION_TYPE.FUNCTIONS: + command = { + title: 'Trigger Suggestion Dialog', + id: 'editor.action.triggerSuggest', + }; + kind = monacoEditor.languages.CompletionItemKind.Function; + insertText = `${insertText}($0)`; + insertTextRules = monacoEditor.languages.CompletionItemInsertTextRule.InsertAsSnippet; + detail = `(${ + (suggestion as ITimelionFunction).chainable + ? i18n.translate('timelion.expressionSuggestions.func.description.chainableHelpText', { + defaultMessage: 'Chainable', + }) + : i18n.translate('timelion.expressionSuggestions.func.description.dataSourceHelpText', { + defaultMessage: 'Data source', + }) + })`; + + break; + case SUGGESTION_TYPE.ARGUMENT_VALUE: + const param = suggestion.name.split(':'); + + if (param.length === 1 || param[1]) { + insertText = `${param.length === 1 ? insertText : param[1]},`; + } + + command = { + title: 'Trigger Suggestion Dialog', + id: 'editor.action.triggerSuggest', + }; + kind = monacoEditor.languages.CompletionItemKind.Property; + detail = suggestion.help || ''; + + break; + } + + return { + detail, + insertText, + insertTextRules, + kind, + label: suggestion.name, + documentation: suggestion.help, + command, + range, + }; +} diff --git a/src/legacy/core_plugins/timelion/public/components/timelion_interval.tsx b/src/legacy/core_plugins/timelion/public/components/timelion_interval.tsx new file mode 100644 index 0000000000000..6294e51e54788 --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/timelion_interval.tsx @@ -0,0 +1,144 @@ +/* + * 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 React, { useMemo, useCallback } from 'react'; +import { EuiFormRow, EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { useValidation } from 'ui/vis/editors/default/controls/agg_utils'; +import { isValidEsInterval } from '../../../../core_plugins/data/common'; + +const intervalOptions = [ + { + label: i18n.translate('timelion.vis.interval.auto', { + defaultMessage: 'Auto', + }), + value: 'auto', + }, + { + label: i18n.translate('timelion.vis.interval.second', { + defaultMessage: '1 second', + }), + value: '1s', + }, + { + label: i18n.translate('timelion.vis.interval.minute', { + defaultMessage: '1 minute', + }), + value: '1m', + }, + { + label: i18n.translate('timelion.vis.interval.hour', { + defaultMessage: '1 hour', + }), + value: '1h', + }, + { + label: i18n.translate('timelion.vis.interval.day', { + defaultMessage: '1 day', + }), + value: '1d', + }, + { + label: i18n.translate('timelion.vis.interval.week', { + defaultMessage: '1 week', + }), + value: '1w', + }, + { + label: i18n.translate('timelion.vis.interval.month', { + defaultMessage: '1 month', + }), + value: '1M', + }, + { + label: i18n.translate('timelion.vis.interval.year', { + defaultMessage: '1 year', + }), + value: '1y', + }, +]; + +interface TimelionIntervalProps { + value: string; + setValue(value: string): void; + setValidity(valid: boolean): void; +} + +function TimelionInterval({ value, setValue, setValidity }: TimelionIntervalProps) { + const onCustomInterval = useCallback( + (customValue: string) => { + setValue(customValue.trim()); + }, + [setValue] + ); + + const onChange = useCallback( + (opts: Array>) => { + setValue((opts[0] && opts[0].value) || ''); + }, + [setValue] + ); + + const selectedOptions = useMemo( + () => [intervalOptions.find(op => op.value === value) || { label: value, value }], + [value] + ); + + const isValid = intervalOptions.some(int => int.value === value) || isValidEsInterval(value); + + useValidation(setValidity, isValid); + + return ( + + + + ); +} + +export { TimelionInterval }; diff --git a/src/legacy/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js b/src/legacy/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js index b90f5932b5b09..231330b898edb 100644 --- a/src/legacy/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js +++ b/src/legacy/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js @@ -21,9 +21,15 @@ import expect from '@kbn/expect'; import PEG from 'pegjs'; import grammar from 'raw-loader!../../chain.peg'; import { SUGGESTION_TYPE, suggest } from '../timelion_expression_input_helpers'; -import { ArgValueSuggestionsProvider } from '../timelion_expression_suggestions/arg_value_suggestions'; +import { getArgValueSuggestions } from '../../services/arg_value_suggestions'; +import { setIndexPatterns, setSavedObjectsClient } from '../../services/plugin_services'; describe('Timelion expression suggestions', () => { + setIndexPatterns({}); + setSavedObjectsClient({}); + + const argValueSuggestions = getArgValueSuggestions(); + describe('getSuggestions', () => { const func1 = { name: 'func1', @@ -44,11 +50,6 @@ describe('Timelion expression suggestions', () => { }; const functionList = [func1, myFunc2]; let Parser; - const privateStub = () => { - return {}; - }; - const indexPatternsStub = {}; - const argValueSuggestions = ArgValueSuggestionsProvider(privateStub, indexPatternsStub); // eslint-disable-line new-cap beforeEach(function() { Parser = PEG.generate(grammar); }); diff --git a/src/legacy/core_plugins/timelion/public/directives/timelion_expression_input.js b/src/legacy/core_plugins/timelion/public/directives/timelion_expression_input.js index 137dd6b82046d..449c0489fea25 100644 --- a/src/legacy/core_plugins/timelion/public/directives/timelion_expression_input.js +++ b/src/legacy/core_plugins/timelion/public/directives/timelion_expression_input.js @@ -52,11 +52,11 @@ import { insertAtLocation, } from './timelion_expression_input_helpers'; import { comboBoxKeyCodes } from '@elastic/eui'; -import { ArgValueSuggestionsProvider } from './timelion_expression_suggestions/arg_value_suggestions'; +import { getArgValueSuggestions } from '../services/arg_value_suggestions'; const Parser = PEG.generate(grammar); -export function TimelionExpInput($http, $timeout, Private) { +export function TimelionExpInput($http, $timeout) { return { restrict: 'E', scope: { @@ -68,7 +68,7 @@ export function TimelionExpInput($http, $timeout, Private) { replace: true, template: timelionExpressionInputTemplate, link: function(scope, elem) { - const argValueSuggestions = Private(ArgValueSuggestionsProvider); + const argValueSuggestions = getArgValueSuggestions(); const expressionInput = elem.find('[data-expression-input]'); const functionReference = {}; let suggestibleFunctionLocation = {}; diff --git a/src/legacy/core_plugins/timelion/public/index.scss b/src/legacy/core_plugins/timelion/public/index.scss index f6123f4052156..7ccc6c300bc40 100644 --- a/src/legacy/core_plugins/timelion/public/index.scss +++ b/src/legacy/core_plugins/timelion/public/index.scss @@ -11,5 +11,6 @@ // timChart__legend-isLoading @import './app'; +@import './components/index'; @import './directives/index'; @import './vis/index'; diff --git a/src/legacy/core_plugins/timelion/public/legacy.ts b/src/legacy/core_plugins/timelion/public/legacy.ts index d989a68d40eeb..1cf6bb65cdc02 100644 --- a/src/legacy/core_plugins/timelion/public/legacy.ts +++ b/src/legacy/core_plugins/timelion/public/legacy.ts @@ -37,4 +37,4 @@ const setupPlugins: Readonly = { const pluginInstance = plugin({} as PluginInitializerContext); export const setup = pluginInstance.setup(npSetup.core, setupPlugins); -export const start = pluginInstance.start(npStart.core); +export const start = pluginInstance.start(npStart.core, npStart.plugins); diff --git a/src/legacy/core_plugins/timelion/public/panels/timechart/schema.ts b/src/legacy/core_plugins/timelion/public/panels/timechart/schema.ts index 04b27c4020ce3..0bbda4bf3646f 100644 --- a/src/legacy/core_plugins/timelion/public/panels/timechart/schema.ts +++ b/src/legacy/core_plugins/timelion/public/panels/timechart/schema.ts @@ -35,6 +35,7 @@ const DEBOUNCE_DELAY = 50; export function timechartFn(dependencies: TimelionVisualizationDependencies) { const { $rootScope, $compile, uiSettings } = dependencies; + return function() { return { help: 'Draw a timeseries chart', diff --git a/src/legacy/core_plugins/timelion/public/plugin.ts b/src/legacy/core_plugins/timelion/public/plugin.ts index ba8c25c20abea..42f0ee3ad4725 100644 --- a/src/legacy/core_plugins/timelion/public/plugin.ts +++ b/src/legacy/core_plugins/timelion/public/plugin.ts @@ -26,12 +26,14 @@ import { } from 'kibana/public'; import { Plugin as ExpressionsPlugin } from 'src/plugins/expressions/public'; import { DataPublicPluginSetup, TimefilterContract } from 'src/plugins/data/public'; +import { PluginsStart } from 'ui/new_platform/new_platform'; import { VisualizationsSetup } from '../../visualizations/public/np_ready/public'; import { getTimelionVisualizationConfig } from './timelion_vis_fn'; import { getTimelionVisualization } from './vis'; import { getTimeChart } from './panels/timechart/timechart'; import { Panel } from './panels/panel'; import { LegacyDependenciesPlugin, LegacyDependenciesPluginSetup } from './shim'; +import { setIndexPatterns, setSavedObjectsClient } from './services/plugin_services'; /** @internal */ export interface TimelionVisualizationDependencies extends LegacyDependenciesPluginSetup { @@ -85,12 +87,15 @@ export class TimelionPlugin implements Plugin, void> { dependencies.timelionPanels.set(timeChartPanel.name, timeChartPanel); } - public start(core: CoreStart) { + public start(core: CoreStart, plugins: PluginsStart) { const timelionUiEnabled = core.injectedMetadata.getInjectedVar('timelionUiEnabled'); if (timelionUiEnabled === false) { core.chrome.navLinks.update('timelion', { hidden: true }); } + + setIndexPatterns(plugins.data.indexPatterns); + setSavedObjectsClient(core.savedObjects.client); } public stop(): void {} diff --git a/src/legacy/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js b/src/legacy/core_plugins/timelion/public/services/arg_value_suggestions.ts similarity index 72% rename from src/legacy/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js rename to src/legacy/core_plugins/timelion/public/services/arg_value_suggestions.ts index e698a69401a37..8d133de51f6d9 100644 --- a/src/legacy/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js +++ b/src/legacy/core_plugins/timelion/public/services/arg_value_suggestions.ts @@ -17,33 +17,51 @@ * under the License. */ -import _ from 'lodash'; -import { npStart } from 'ui/new_platform'; +import { get } from 'lodash'; +import { TimelionFunctionArgs } from '../../common/types'; +import { getIndexPatterns, getSavedObjectsClient } from './plugin_services'; -export function ArgValueSuggestionsProvider() { - const { indexPatterns } = npStart.plugins.data; - const { client: savedObjectsClient } = npStart.core.savedObjects; +export interface Location { + min: number; + max: number; +} - async function getIndexPattern(functionArgs) { - const indexPatternArg = functionArgs.find(argument => { - return argument.name === 'index'; - }); +export interface FunctionArg { + function: string; + location: Location; + name: string; + text: string; + type: string; + value: { + location: Location; + text: string; + type: string; + value: string; + }; +} + +export function getArgValueSuggestions() { + const indexPatterns = getIndexPatterns(); + const savedObjectsClient = getSavedObjectsClient(); + + async function getIndexPattern(functionArgs: FunctionArg[]) { + const indexPatternArg = functionArgs.find(({ name }) => name === 'index'); if (!indexPatternArg) { // index argument not provided return; } - const indexPatternTitle = _.get(indexPatternArg, 'value.text'); + const indexPatternTitle = get(indexPatternArg, 'value.text'); - const resp = await savedObjectsClient.find({ + const { savedObjects } = await savedObjectsClient.find({ type: 'index-pattern', fields: ['title'], search: `"${indexPatternTitle}"`, - search_fields: ['title'], + searchFields: ['title'], perPage: 10, }); - const indexPatternSavedObject = resp.savedObjects.find(savedObject => { - return savedObject.attributes.title === indexPatternTitle; - }); + const indexPatternSavedObject = savedObjects.find( + ({ attributes }) => attributes.title === indexPatternTitle + ); if (!indexPatternSavedObject) { // index argument does not match an index pattern return; @@ -52,7 +70,7 @@ export function ArgValueSuggestionsProvider() { return await indexPatterns.get(indexPatternSavedObject.id); } - function containsFieldName(partial, field) { + function containsFieldName(partial: string, field: { name: string }) { if (!partial) { return true; } @@ -63,13 +81,13 @@ export function ArgValueSuggestionsProvider() { // Could not put with function definition since functions are defined on server const customHandlers = { es: { - index: async function(partial) { + async index(partial: string) { const search = partial ? `${partial}*` : '*'; const resp = await savedObjectsClient.find({ type: 'index-pattern', fields: ['title', 'type'], search: `${search}`, - search_fields: ['title'], + searchFields: ['title'], perPage: 25, }); return resp.savedObjects @@ -78,7 +96,7 @@ export function ArgValueSuggestionsProvider() { return { name: savedObject.attributes.title }; }); }, - metric: async function(partial, functionArgs) { + async metric(partial: string, functionArgs: FunctionArg[]) { if (!partial || !partial.includes(':')) { return [ { name: 'avg:' }, @@ -109,7 +127,7 @@ export function ArgValueSuggestionsProvider() { return { name: `${valueSplit[0]}:${field.name}`, help: field.type }; }); }, - split: async function(partial, functionArgs) { + async split(partial: string, functionArgs: FunctionArg[]) { const indexPattern = await getIndexPattern(functionArgs); if (!indexPattern) { return []; @@ -127,7 +145,7 @@ export function ArgValueSuggestionsProvider() { return { name: field.name, help: field.type }; }); }, - timefield: async function(partial, functionArgs) { + async timefield(partial: string, functionArgs: FunctionArg[]) { const indexPattern = await getIndexPattern(functionArgs); if (!indexPattern) { return []; @@ -150,7 +168,10 @@ export function ArgValueSuggestionsProvider() { * @param {string} argName - user provided argument name * @return {boolean} true when dynamic suggestion handler provided for function argument */ - hasDynamicSuggestionsForArgument: (functionName, argName) => { + hasDynamicSuggestionsForArgument: ( + functionName: T, + argName: keyof typeof customHandlers[T] + ) => { return customHandlers[functionName] && customHandlers[functionName][argName]; }, @@ -161,12 +182,13 @@ export function ArgValueSuggestionsProvider() { * @param {string} partial - user provided argument value * @return {array} array of dynamic suggestions matching partial */ - getDynamicSuggestionsForArgument: async ( - functionName, - argName, - functionArgs, + getDynamicSuggestionsForArgument: async ( + functionName: T, + argName: keyof typeof customHandlers[T], + functionArgs: FunctionArg[], partialInput = '' ) => { + // @ts-ignore return await customHandlers[functionName][argName](partialInput, functionArgs); }, @@ -175,7 +197,10 @@ export function ArgValueSuggestionsProvider() { * @param {array} staticSuggestions - argument value suggestions * @return {array} array of static suggestions matching partial */ - getStaticSuggestionsForInput: (partialInput = '', staticSuggestions = []) => { + getStaticSuggestionsForInput: ( + partialInput = '', + staticSuggestions: TimelionFunctionArgs['suggestions'] = [] + ) => { if (partialInput) { return staticSuggestions.filter(suggestion => { return suggestion.name.includes(partialInput); @@ -186,3 +211,5 @@ export function ArgValueSuggestionsProvider() { }, }; } + +export type ArgValueSuggestions = ReturnType; diff --git a/src/legacy/core_plugins/timelion/public/services/plugin_services.ts b/src/legacy/core_plugins/timelion/public/services/plugin_services.ts new file mode 100644 index 0000000000000..5ba4ee5e47983 --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/services/plugin_services.ts @@ -0,0 +1,30 @@ +/* + * 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 { IndexPatternsContract } from 'src/plugins/data/public'; +import { SavedObjectsClientContract } from 'kibana/public'; +import { createGetterSetter } from '../../../../../plugins/kibana_utils/public'; + +export const [getIndexPatterns, setIndexPatterns] = createGetterSetter( + 'IndexPatterns' +); + +export const [getSavedObjectsClient, setSavedObjectsClient] = createGetterSetter< + SavedObjectsClientContract +>('SavedObjectsClient'); diff --git a/src/legacy/core_plugins/timelion/public/timelion_vis_fn.ts b/src/legacy/core_plugins/timelion/public/timelion_vis_fn.ts index 474f464a550cd..206f9f5d8368d 100644 --- a/src/legacy/core_plugins/timelion/public/timelion_vis_fn.ts +++ b/src/legacy/core_plugins/timelion/public/timelion_vis_fn.ts @@ -28,7 +28,7 @@ const name = 'timelion_vis'; interface Arguments { expression: string; - interval: any; + interval: string; } interface RenderValue { @@ -38,7 +38,7 @@ interface RenderValue { } type Context = KibanaContext | null; -type VisParams = Arguments; +export type VisParams = Arguments; type Return = Promise>; export const getTimelionVisualizationConfig = ( @@ -60,7 +60,7 @@ export const getTimelionVisualizationConfig = ( help: '', }, interval: { - types: ['string', 'null'], + types: ['string'], default: 'auto', help: '', }, diff --git a/src/legacy/core_plugins/timelion/public/vis/_index.scss b/src/legacy/core_plugins/timelion/public/vis/_index.scss index e44b6336d33c1..17a2018f7a56a 100644 --- a/src/legacy/core_plugins/timelion/public/vis/_index.scss +++ b/src/legacy/core_plugins/timelion/public/vis/_index.scss @@ -1 +1,2 @@ @import './timelion_vis'; +@import './timelion_editor'; diff --git a/src/legacy/core_plugins/timelion/public/vis/_timelion_editor.scss b/src/legacy/core_plugins/timelion/public/vis/_timelion_editor.scss new file mode 100644 index 0000000000000..a9331930a86ff --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/vis/_timelion_editor.scss @@ -0,0 +1,15 @@ +.visEditor--timelion { + vis-options-react-wrapper, + .visEditorSidebar__options, + .visEditorSidebar__timelionOptions { + flex: 1 1 auto; + display: flex; + flex-direction: column; + } + + .visEditor__sidebar { + @include euiBreakpoint('xs', 's', 'm') { + width: 100%; + } + } +} diff --git a/src/legacy/core_plugins/timelion/public/vis/index.ts b/src/legacy/core_plugins/timelion/public/vis/index.tsx similarity index 80% rename from src/legacy/core_plugins/timelion/public/vis/index.ts rename to src/legacy/core_plugins/timelion/public/vis/index.tsx index 7b82553a24e5b..1edcb0a5ce71c 100644 --- a/src/legacy/core_plugins/timelion/public/vis/index.ts +++ b/src/legacy/core_plugins/timelion/public/vis/index.tsx @@ -17,19 +17,24 @@ * under the License. */ +import React from 'react'; import { i18n } from '@kbn/i18n'; // @ts-ignore import { DefaultEditorSize } from 'ui/vis/editor_size'; +import { VisOptionsProps } from 'ui/vis/editors/default'; +import { KibanaContextProvider } from '../../../../../plugins/kibana_react/public'; import { getTimelionRequestHandler } from './timelion_request_handler'; import visConfigTemplate from './timelion_vis.html'; -import editorConfigTemplate from './timelion_vis_params.html'; import { TimelionVisualizationDependencies } from '../plugin'; // @ts-ignore import { AngularVisController } from '../../../../ui/public/vis/vis_types/angular_vis_type'; +import { TimelionOptions } from './timelion_options'; +import { VisParams } from '../timelion_vis_fn'; export const TIMELION_VIS_NAME = 'timelion'; export function getTimelionVisualization(dependencies: TimelionVisualizationDependencies) { + const { http, uiSettings } = dependencies; const timelionRequestHandler = getTimelionRequestHandler(dependencies); // return the visType object, which kibana will use to display and configure new @@ -50,7 +55,11 @@ export function getTimelionVisualization(dependencies: TimelionVisualizationDepe template: visConfigTemplate, }, editorConfig: { - optionsTemplate: editorConfigTemplate, + optionsTemplate: (props: VisOptionsProps) => ( + + + + ), defaultSize: DefaultEditorSize.MEDIUM, }, requestHandler: timelionRequestHandler, diff --git a/src/legacy/core_plugins/timelion/public/vis/timelion_options.tsx b/src/legacy/core_plugins/timelion/public/vis/timelion_options.tsx new file mode 100644 index 0000000000000..527fcc3bc6ce8 --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/vis/timelion_options.tsx @@ -0,0 +1,48 @@ +/* + * 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 React, { useCallback } from 'react'; +import { EuiPanel } from '@elastic/eui'; + +import { VisOptionsProps } from 'ui/vis/editors/default'; +import { VisParams } from '../timelion_vis_fn'; +import { TimelionInterval, TimelionExpressionInput } from '../components'; + +function TimelionOptions({ stateParams, setValue, setValidity }: VisOptionsProps) { + const setInterval = useCallback((value: VisParams['interval']) => setValue('interval', value), [ + setValue, + ]); + const setExpressionInput = useCallback( + (value: VisParams['expression']) => setValue('expression', value), + [setValue] + ); + + return ( + + + + + ); +} + +export { TimelionOptions }; diff --git a/src/legacy/core_plugins/timelion/public/vis/timelion_vis_params.html b/src/legacy/core_plugins/timelion/public/vis/timelion_vis_params.html deleted file mode 100644 index 9f2d2094fb1f7..0000000000000 --- a/src/legacy/core_plugins/timelion/public/vis/timelion_vis_params.html +++ /dev/null @@ -1,27 +0,0 @@ -
-
- -
- -
-
- -
-
- -
- - -
- -
diff --git a/src/legacy/core_plugins/timelion/server/lib/classes/timelion_function.d.ts b/src/legacy/core_plugins/timelion/server/lib/classes/timelion_function.d.ts index 6e32a4454e707..798902aa133de 100644 --- a/src/legacy/core_plugins/timelion/server/lib/classes/timelion_function.d.ts +++ b/src/legacy/core_plugins/timelion/server/lib/classes/timelion_function.d.ts @@ -17,6 +17,8 @@ * under the License. */ +import { TimelionFunctionArgs } from '../../../common/types'; + export interface TimelionFunctionInterface extends TimelionFunctionConfig { chainable: boolean; originalFn: Function; @@ -32,21 +34,6 @@ export interface TimelionFunctionConfig { args: TimelionFunctionArgs[]; } -export interface TimelionFunctionArgs { - name: string; - help?: string; - multi?: boolean; - types: TimelionFunctionArgsTypes[]; - suggestions?: TimelionFunctionArgsSuggestion[]; -} - -export type TimelionFunctionArgsTypes = 'seriesList' | 'number' | 'string' | 'boolean' | 'null'; - -export interface TimelionFunctionArgsSuggestion { - name: string; - help: string; -} - // eslint-disable-next-line import/no-default-export export default class TimelionFunction { constructor(name: string, config: TimelionFunctionConfig); diff --git a/src/legacy/core_plugins/timelion/server/types.ts b/src/legacy/core_plugins/timelion/server/types.ts index e612bc14a0daa..a035d64f764f1 100644 --- a/src/legacy/core_plugins/timelion/server/types.ts +++ b/src/legacy/core_plugins/timelion/server/types.ts @@ -17,12 +17,5 @@ * under the License. */ -export { - TimelionFunctionInterface, - TimelionFunctionConfig, - TimelionFunctionArgs, - TimelionFunctionArgsSuggestion, - TimelionFunctionArgsTypes, -} from './lib/classes/timelion_function'; - +export { TimelionFunctionInterface, TimelionFunctionConfig } from './lib/classes/timelion_function'; export { TimelionRequestQuery } from './routes/run'; diff --git a/src/plugins/kibana_react/public/code_editor/code_editor.tsx b/src/plugins/kibana_react/public/code_editor/code_editor.tsx index 0ae77995c0502..62440f12c6d84 100644 --- a/src/plugins/kibana_react/public/code_editor/code_editor.tsx +++ b/src/plugins/kibana_react/public/code_editor/code_editor.tsx @@ -78,6 +78,13 @@ export interface Props { */ hoverProvider?: monacoEditor.languages.HoverProvider; + /** + * Language config provider for bracket + * Documentation for the provider can be found here: + * https://microsoft.github.io/monaco-editor/api/interfaces/monaco.languages.languageconfiguration.html + */ + languageConfiguration?: monacoEditor.languages.LanguageConfiguration; + /** * Function called before the editor is mounted in the view */ @@ -130,6 +137,13 @@ export class CodeEditor extends React.Component { if (this.props.hoverProvider) { monaco.languages.registerHoverProvider(this.props.languageId, this.props.hoverProvider); } + + if (this.props.languageConfiguration) { + monaco.languages.setLanguageConfiguration( + this.props.languageId, + this.props.languageConfiguration + ); + } }); // Register the theme diff --git a/yarn.lock b/yarn.lock index 96ec5213badcb..983ad570e0f68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3944,6 +3944,11 @@ resolved "https://registry.yarnpkg.com/@types/parse-link-header/-/parse-link-header-1.0.0.tgz#69f059e40a0fa93dc2e095d4142395ae6adc5d7a" integrity sha512-fCA3btjE7QFeRLfcD0Sjg+6/CnmC66HpMBoRfRzd2raTaWMJV21CCZ0LO8MOqf8onl5n0EPfjq4zDhbyX8SVwA== +"@types/pegjs@^0.10.1": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@types/pegjs/-/pegjs-0.10.1.tgz#9a2f3961dc62430fdb21061eb0ddbd890f9e3b94" + integrity sha512-ra8IchO9odGQmYKbm+94K58UyKCEKdZh9y0vxhG4pIpOJOBlC1C+ZtBVr6jLs+/oJ4pl+1p/4t3JtBA8J10Vvw== + "@types/pngjs@^3.3.2": version "3.3.2" resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-3.3.2.tgz#8ed3bd655ab3a92ea32ada7a21f618e63b93b1d4"