From 6d06a565b79249f81f868a9d494fea5cf6903989 Mon Sep 17 00:00:00 2001 From: Rodney Norris Date: Wed, 24 Apr 2024 13:23:10 -0500 Subject: [PATCH 1/4] [Console] Allow persistent console to be resizable (#180985) ## Summary Updates the Persistent console to be resizable by the user using an `EuiResizableButton` at the top of the console flyout. - Persistent console now defaults to the maximum size for the window - Top of console can be dragged to resize - On resize console size is saved to local storage and used as default - Double-clicking resize border will maximize console, or set it to 50% height if currently at the max height. https://github.com/elastic/kibana/assets/1972968/46c8da24-56c8-4bda-82f9-f9498ec209a0 --- .../embeddable/_embeddable_console.scss | 47 +----- .../containers/embeddable/_variables.scss | 7 - .../embeddable/console_resize_button.tsx | 142 ++++++++++++++++++ .../containers/embeddable/console_wrapper.tsx | 14 +- .../embeddable/embeddable_console.tsx | 96 ++++++++---- .../containers/embeddable/index.tsx | 7 +- src/plugins/console/public/index.ts | 1 - src/plugins/console/public/plugin.ts | 27 +++- .../services/embeddable_console.test.ts | 31 +++- .../public/services/embeddable_console.ts | 20 +++ .../public/types/embeddable_console.ts | 12 +- .../public/types/plugin_dependencies.ts | 4 +- 12 files changed, 297 insertions(+), 111 deletions(-) create mode 100644 src/plugins/console/public/application/containers/embeddable/console_resize_button.tsx diff --git a/src/plugins/console/public/application/containers/embeddable/_embeddable_console.scss b/src/plugins/console/public/application/containers/embeddable/_embeddable_console.scss index d7fa11e89f72d..8c00712bdaadc 100644 --- a/src/plugins/console/public/application/containers/embeddable/_embeddable_console.scss +++ b/src/plugins/console/public/application/containers/embeddable/_embeddable_console.scss @@ -38,24 +38,9 @@ animation-duration: $euiAnimSpeedNormal; animation-timing-function: $euiAnimSlightResistance; animation-fill-mode: forwards; - } - - &-isOpen.embeddableConsole--large { - animation-name: embeddableConsoleOpenPanelLarge; - height: $embeddableConsoleMaxHeight; - bottom: map-get($embeddableConsoleHeights, 'l') * -1; - } - - &-isOpen.embeddableConsole--medium { - animation-name: embeddableConsoleOpenPanelMedium; - height: map-get($embeddableConsoleHeights, 'm'); - bottom: map-get($embeddableConsoleHeights, 'm') * -1; - } - - &-isOpen.embeddableConsole--small { - animation-name: embeddableConsoleOpenPanelSmall; - height: map-get($embeddableConsoleHeights, 's'); - bottom: map-get($embeddableConsoleHeights, 's') * -1; + animation-name: embeddableConsoleOpenPanel; + height: var(--embedded-console-height); + bottom: var(--embedded-console-bottom); } } @@ -80,7 +65,6 @@ &--altViewButton-container { margin-left: auto; - // padding: $euiSizeS; } } @@ -132,34 +116,13 @@ } } -@keyframes embeddableConsoleOpenPanelLarge { - 0% { - // Accounts for the initial height offset from the top - transform: translateY(calc((#{$embeddableConsoleInitialHeight} * 3) * -1)); - } - - 100% { - transform: translateY(map-get($embeddableConsoleHeights, 'l') * -1); - } -} - -@keyframes embeddableConsoleOpenPanelMedium { - 0% { - transform: translateY(-$embeddableConsoleInitialHeight); - } - - 100% { - transform: translateY(map-get($embeddableConsoleHeights, 'm') * -1); - } -} - -@keyframes embeddableConsoleOpenPanelSmall { +@keyframes embeddableConsoleOpenPanel { 0% { transform: translateY(-$embeddableConsoleInitialHeight); } 100% { - transform: translateY(map-get($embeddableConsoleHeights, 's') * -1); + transform: translateY(var(--embedded-console-bottom)); } } diff --git a/src/plugins/console/public/application/containers/embeddable/_variables.scss b/src/plugins/console/public/application/containers/embeddable/_variables.scss index 33ecd64b999c9..9623db93b4ea7 100644 --- a/src/plugins/console/public/application/containers/embeddable/_variables.scss +++ b/src/plugins/console/public/application/containers/embeddable/_variables.scss @@ -3,10 +3,3 @@ $embeddableConsoleText: lighten(makeHighContrastColor($euiColorLightestShade, $e $embeddableConsoleBorderColor: transparentize($euiColorGhost, .8); $embeddableConsoleInitialHeight: $euiSizeXXL; $embeddableConsoleMaxHeight: calc(100vh - var(--euiFixedHeadersOffset, 0)); - -// Pixel heights ensure no blurriness caused by half pixel offsets -$embeddableConsoleHeights: ( - s: $euiSize * 30, - m: $euiSize * 50, - l: 100vh, -); diff --git a/src/plugins/console/public/application/containers/embeddable/console_resize_button.tsx b/src/plugins/console/public/application/containers/embeddable/console_resize_button.tsx new file mode 100644 index 0000000000000..0b29214594440 --- /dev/null +++ b/src/plugins/console/public/application/containers/embeddable/console_resize_button.tsx @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback, useEffect, useState, useRef } from 'react'; +import { EuiResizableButton, useEuiTheme, keys, EuiThemeComputed } from '@elastic/eui'; + +const CONSOLE_MIN_HEIGHT = 200; + +const getMouseOrTouchY = ( + e: TouchEvent | MouseEvent | React.MouseEvent | React.TouchEvent +): number => { + // Some Typescript fooling is needed here + const y = (e as TouchEvent).targetTouches + ? (e as TouchEvent).targetTouches[0].pageY + : (e as MouseEvent).pageY; + return y; +}; + +export interface EmbeddedConsoleResizeButtonProps { + consoleHeight: number; + setConsoleHeight: React.Dispatch>; +} + +export function getCurrentConsoleMaxSize(euiTheme: EuiThemeComputed<{}>) { + const euiBaseSize = parseInt(euiTheme.size.base, 10); + const winHeight = window.innerHeight; + const bodyStyle = getComputedStyle(document.body); + const headerOffset = parseInt(bodyStyle.getPropertyValue('--euiFixedHeadersOffset') ?? '0px', 10); + + // We leave a buffer of baseSize to allow room for the user to hover on the top border for resizing + return Math.max(winHeight - headerOffset - euiBaseSize, CONSOLE_MIN_HEIGHT); +} + +export const EmbeddedConsoleResizeButton = ({ + consoleHeight, + setConsoleHeight, +}: EmbeddedConsoleResizeButtonProps) => { + const { euiTheme } = useEuiTheme(); + const [maxConsoleHeight, setMaxConsoleHeight] = useState(800); + const initialConsoleHeight = useRef(consoleHeight); + const initialMouseY = useRef(0); + + useEffect(() => { + function handleResize() { + const newMaxConsoleHeight = getCurrentConsoleMaxSize(euiTheme); + // Calculate and save the console max height. This is the window height minus the header + // offset minuse the base size to allow a small buffer for grabbing the resize button. + if (maxConsoleHeight !== newMaxConsoleHeight) { + setMaxConsoleHeight(newMaxConsoleHeight); + } + if (consoleHeight > newMaxConsoleHeight && newMaxConsoleHeight > CONSOLE_MIN_HEIGHT) { + // When the current console height is greater than the new max height, + // we resize the console to the max height. This will ensure there is not weird + // behavior with the drag resize. + setConsoleHeight(newMaxConsoleHeight); + } + } + + handleResize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [maxConsoleHeight, euiTheme, consoleHeight, setConsoleHeight]); + const onResizeMouseMove = useCallback( + (e: MouseEvent | TouchEvent) => { + const currentMouseY = getMouseOrTouchY(e); + const mouseOffset = (currentMouseY - initialMouseY.current) * -1; + const changedConsoleHeight = initialConsoleHeight.current + mouseOffset; + + const newConsoleHeight = Math.min( + Math.max(changedConsoleHeight, CONSOLE_MIN_HEIGHT), + maxConsoleHeight + ); + + setConsoleHeight(newConsoleHeight); + }, + [maxConsoleHeight, setConsoleHeight] + ); + const onResizeMouseUp = useCallback( + (e: MouseEvent | TouchEvent) => { + initialMouseY.current = 0; + + window.removeEventListener('mousemove', onResizeMouseMove); + window.removeEventListener('mouseup', onResizeMouseUp); + window.removeEventListener('touchmove', onResizeMouseMove); + window.removeEventListener('touchend', onResizeMouseUp); + }, + [onResizeMouseMove] + ); + const onResizeMouseDown = useCallback( + (e: React.MouseEvent | React.TouchEvent) => { + initialMouseY.current = getMouseOrTouchY(e); + initialConsoleHeight.current = consoleHeight; + + // Window event listeners instead of React events are used + // in case the user's mouse leaves the component + window.addEventListener('mousemove', onResizeMouseMove); + window.addEventListener('mouseup', onResizeMouseUp); + window.addEventListener('touchmove', onResizeMouseMove); + window.addEventListener('touchend', onResizeMouseUp); + }, + [consoleHeight, onResizeMouseUp, onResizeMouseMove] + ); + const onResizeKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const KEYBOARD_OFFSET = 10; + + switch (e.key) { + case keys.ARROW_UP: + e.preventDefault(); // Safari+VO will screen reader navigate off the button otherwise + setConsoleHeight((height) => Math.min(height + KEYBOARD_OFFSET, maxConsoleHeight)); + break; + case keys.ARROW_DOWN: + e.preventDefault(); // Safari+VO will screen reader navigate off the button otherwise + setConsoleHeight((height) => Math.max(height - KEYBOARD_OFFSET, CONSOLE_MIN_HEIGHT)); + } + }, + [maxConsoleHeight, setConsoleHeight] + ); + const onResizeDoubleClick = useCallback(() => { + if (consoleHeight < maxConsoleHeight) { + setConsoleHeight(maxConsoleHeight); + } else { + setConsoleHeight(maxConsoleHeight / 2); + } + }, [consoleHeight, maxConsoleHeight, setConsoleHeight]); + + return ( + + ); +}; diff --git a/src/plugins/console/public/application/containers/embeddable/console_wrapper.tsx b/src/plugins/console/public/application/containers/embeddable/console_wrapper.tsx index 6429d8894d33c..53c75706b9da0 100644 --- a/src/plugins/console/public/application/containers/embeddable/console_wrapper.tsx +++ b/src/plugins/console/public/application/containers/embeddable/console_wrapper.tsx @@ -29,10 +29,9 @@ import { History, Settings, Storage, - createStorage, createHistory, createSettings, - setStorage, + getStorage, } from '../../../services'; import { createUsageTracker } from '../../../services/tracker'; import { MetricsTracker, EmbeddableConsoleDependencies } from '../../../types'; @@ -78,11 +77,7 @@ const loadDependencies = async ( await loadActiveApi(core.http); const autocompleteInfo = getAutocompleteInfo(); - const storage = createStorage({ - engine: window.localStorage, - prefix: 'sense:', - }); - setStorage(storage); + const storage = getStorage(); const history = createHistory({ storage }); const settings = createSettings({ storage }); const objectStorageClient = localStorageObjectClient.create(storage); @@ -107,7 +102,10 @@ const loadDependencies = async ( }; interface ConsoleWrapperProps - extends Omit { + extends Omit< + EmbeddableConsoleDependencies, + 'setDispatch' | 'alternateView' | 'setConsoleHeight' | 'getConsoleHeight' + > { onKeyDown: (this: Window, ev: WindowEventMap['keydown']) => any; } diff --git a/src/plugins/console/public/application/containers/embeddable/embeddable_console.tsx b/src/plugins/console/public/application/containers/embeddable/embeddable_console.tsx index 218496b9d81ab..42a6c4b0efb92 100644 --- a/src/plugins/console/public/application/containers/embeddable/embeddable_console.tsx +++ b/src/plugins/console/public/application/containers/embeddable/embeddable_console.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useReducer, useEffect } from 'react'; +import React, { useReducer, useEffect, useState } from 'react'; import classNames from 'classnames'; import useObservable from 'react-use/lib/useObservable'; import { @@ -14,15 +14,17 @@ import { EuiFocusTrap, EuiPortal, EuiScreenReaderOnly, + EuiThemeComputed, EuiThemeProvider, EuiWindowEvent, keys, + useEuiTheme, + useEuiThemeCSSVariables, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { dynamic } from '@kbn/shared-ux-utility'; import { - EmbeddableConsoleProps, EmbeddableConsoleDependencies, EmbeddableConsoleView, } from '../../../types/embeddable_console'; @@ -31,6 +33,7 @@ import * as store from '../../stores/embeddable_console'; import { setLoadFromParameter, removeLoadFromParameter } from '../../lib/load_from'; import './_index.scss'; +import { EmbeddedConsoleResizeButton, getCurrentConsoleMaxSize } from './console_resize_button'; const KBN_BODY_CONSOLE_CLASS = 'kbnBody--hasEmbeddableConsole'; @@ -42,14 +45,39 @@ const ConsoleWrapper = dynamic(async () => ({ default: (await import('./console_wrapper')).ConsoleWrapper, })); +const getInitialConsoleHeight = ( + getConsoleHeight: EmbeddableConsoleDependencies['getConsoleHeight'], + euiTheme: EuiThemeComputed +) => { + const lastHeight = getConsoleHeight(); + if (lastHeight) { + try { + const value = parseInt(lastHeight, 10); + if (!isNaN(value) && value > 0) { + return value; + } + } catch { + // ignore bad local storage value + } + } + return getCurrentConsoleMaxSize(euiTheme); +}; + export const EmbeddableConsole = ({ - size = 'm', core, usageCollection, setDispatch, alternateView, isMonacoEnabled, -}: EmbeddableConsoleProps & EmbeddableConsoleDependencies) => { + getConsoleHeight, + setConsoleHeight, +}: EmbeddableConsoleDependencies) => { + const { euiTheme } = useEuiTheme(); + const { setGlobalCSSVariables } = useEuiThemeCSSVariables(); + const [consoleHeight, setConsoleHeightState] = useState( + getInitialConsoleHeight(getConsoleHeight, euiTheme) + ); + const [consoleState, consoleDispatch] = useReducer( store.reducer, store.initialValue, @@ -71,6 +99,13 @@ export const EmbeddableConsole = ({ document.body.classList.add(KBN_BODY_CONSOLE_CLASS); return () => document.body.classList.remove(KBN_BODY_CONSOLE_CLASS); }, []); + useEffect(() => { + setGlobalCSSVariables({ + '--embedded-console-height': `${consoleHeight}px`, + '--embedded-console-bottom': `-${consoleHeight}px`, + }); + setConsoleHeight(consoleHeight.toString()); + }, [consoleHeight, setGlobalCSSVariables, setConsoleHeight]); const isOpen = consoleState.view !== EmbeddableConsoleView.Closed; const showConsole = @@ -105,14 +140,10 @@ export const EmbeddableConsole = ({ const classes = classNames('embeddableConsole', { 'embeddableConsole-isOpen': isOpen, - 'embeddableConsole--large': size === 'l', - 'embeddableConsole--medium': size === 'm', - 'embeddableConsole--small': size === 's', 'embeddableConsole--classicChrome': chromeStyle === 'classic', 'embeddableConsole--projectChrome': chromeStyle === 'project', 'embeddableConsole--unknownChrome': chromeStyle === undefined, 'embeddableConsole--fixed': true, - 'embeddableConsole--showOnMobile': false, }); return ( @@ -127,27 +158,36 @@ export const EmbeddableConsole = ({

{landmarkHeading}

-
- - {i18n.translate('console.embeddableConsole.title', { - defaultMessage: 'Console', - })} - - {alternateView && ( -
- -
+
+ {isOpen && ( + )} + +
+ + {i18n.translate('console.embeddableConsole.title', { + defaultMessage: 'Console', + })} + + {alternateView && ( +
+ +
+ )} +
{showConsole ? ( diff --git a/src/plugins/console/public/application/containers/embeddable/index.tsx b/src/plugins/console/public/application/containers/embeddable/index.tsx index 0563a5f445da2..0ec32dbeaac91 100644 --- a/src/plugins/console/public/application/containers/embeddable/index.tsx +++ b/src/plugins/console/public/application/containers/embeddable/index.tsx @@ -8,12 +8,9 @@ import { dynamic } from '@kbn/shared-ux-utility'; import React from 'react'; -import { - EmbeddableConsoleProps, - EmbeddableConsoleDependencies, -} from '../../../types/embeddable_console'; +import { EmbeddableConsoleDependencies } from '../../../types/embeddable_console'; -type EmbeddableConsoleInternalProps = EmbeddableConsoleProps & EmbeddableConsoleDependencies; +type EmbeddableConsoleInternalProps = EmbeddableConsoleDependencies; const Console = dynamic(async () => ({ default: (await import('./embeddable_console')).EmbeddableConsole, })); diff --git a/src/plugins/console/public/index.ts b/src/plugins/console/public/index.ts index 4e907d4329d1e..277190a1a443c 100644 --- a/src/plugins/console/public/index.ts +++ b/src/plugins/console/public/index.ts @@ -17,7 +17,6 @@ export type { ConsoleUILocatorParams, ConsolePluginSetup, ConsolePluginStart, - EmbeddableConsoleProps, EmbeddedConsoleView, EmbeddedConsoleViewButtonProps, } from './types'; diff --git a/src/plugins/console/public/plugin.ts b/src/plugins/console/public/plugin.ts index 43cedf1fa4bb0..54d8d1db97bc7 100644 --- a/src/plugins/console/public/plugin.ts +++ b/src/plugins/console/public/plugin.ts @@ -18,18 +18,30 @@ import { ConsolePluginSetup, ConsolePluginStart, ConsoleUILocatorParams, - EmbeddableConsoleProps, EmbeddedConsoleView, } from './types'; -import { AutocompleteInfo, setAutocompleteInfo, EmbeddableConsoleInfo } from './services'; +import { + AutocompleteInfo, + setAutocompleteInfo, + EmbeddableConsoleInfo, + createStorage, + setStorage, +} from './services'; export class ConsoleUIPlugin implements Plugin { private readonly autocompleteInfo = new AutocompleteInfo(); - private _embeddableConsole: EmbeddableConsoleInfo = new EmbeddableConsoleInfo(); - - constructor(private ctx: PluginInitializerContext) {} + private _embeddableConsole: EmbeddableConsoleInfo; + + constructor(private ctx: PluginInitializerContext) { + const storage = createStorage({ + engine: window.localStorage, + prefix: 'sense:', + }); + setStorage(storage); + this._embeddableConsole = new EmbeddableConsoleInfo(storage); + } public setup( { notifications, getStartServices, http }: CoreSetup, @@ -126,9 +138,8 @@ export class ConsoleUIPlugin embeddedConsoleUiSetting; if (embeddedConsoleAvailable) { - consoleStart.EmbeddableConsole = (props: EmbeddableConsoleProps) => { + consoleStart.EmbeddableConsole = (_props: {}) => { return EmbeddableConsole({ - ...props, core, usageCollection: deps.usageCollection, setDispatch: (d) => { @@ -136,6 +147,8 @@ export class ConsoleUIPlugin }, alternateView: this._embeddableConsole.alternateView, isMonacoEnabled, + getConsoleHeight: this._embeddableConsole.getConsoleHeight.bind(this._embeddableConsole), + setConsoleHeight: this._embeddableConsole.setConsoleHeight.bind(this._embeddableConsole), }); }; consoleStart.isEmbeddedConsoleAvailable = () => diff --git a/src/plugins/console/public/services/embeddable_console.test.ts b/src/plugins/console/public/services/embeddable_console.test.ts index 7df8230b6dbdf..92cc4d8450906 100644 --- a/src/plugins/console/public/services/embeddable_console.test.ts +++ b/src/plugins/console/public/services/embeddable_console.test.ts @@ -6,12 +6,17 @@ * Side Public License, v 1. */ +import { StorageMock } from './storage.mock'; import { EmbeddableConsoleInfo } from './embeddable_console'; describe('EmbeddableConsoleInfo', () => { + jest.useFakeTimers(); + let eConsole: EmbeddableConsoleInfo; + let storage: StorageMock; beforeEach(() => { - eConsole = new EmbeddableConsoleInfo(); + storage = new StorageMock({} as unknown as Storage, 'test'); + eConsole = new EmbeddableConsoleInfo(storage); }); describe('isEmbeddedConsoleAvailable', () => { it('returns true if dispatch has been set', () => { @@ -50,4 +55,28 @@ describe('EmbeddableConsoleInfo', () => { }); }); }); + describe('getConsoleHeight', () => { + it('returns value in storage when found', () => { + storage.get.mockReturnValue('201'); + expect(eConsole.getConsoleHeight()).toEqual('201'); + expect(storage.get).toHaveBeenCalledWith('embeddedConsoleHeight', undefined); + }); + it('returns undefined when not found', () => { + storage.get.mockReturnValue(undefined); + expect(eConsole.getConsoleHeight()).toEqual(undefined); + }); + }); + describe('setConsoleHeight', () => { + it('stores value in storage', () => { + // setConsoleHeight calls are debounced + eConsole.setConsoleHeight('120'); + eConsole.setConsoleHeight('110'); + eConsole.setConsoleHeight('100'); + + jest.runAllTimers(); + + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenCalledWith('embeddedConsoleHeight', '100'); + }); + }); }); diff --git a/src/plugins/console/public/services/embeddable_console.ts b/src/plugins/console/public/services/embeddable_console.ts index 91bf086bc3e33..f5e0197ad833b 100644 --- a/src/plugins/console/public/services/embeddable_console.ts +++ b/src/plugins/console/public/services/embeddable_console.ts @@ -6,16 +6,28 @@ * Side Public License, v 1. */ import type { Dispatch } from 'react'; +import { debounce } from 'lodash'; import { EmbeddedConsoleAction as EmbeddableConsoleAction, EmbeddedConsoleView, } from '../types/embeddable_console'; +import { Storage } from '.'; + +const CONSOLE_HEIGHT_KEY = 'embeddedConsoleHeight'; +const CONSOLE_HEIGHT_LOCAL_STORAGE_DEBOUNCE_WAIT_TIME = 500; export class EmbeddableConsoleInfo { private _dispatch: Dispatch | null = null; private _alternateView: EmbeddedConsoleView | undefined; + constructor(private readonly storage: Storage) { + this.setConsoleHeight = debounce( + this.setConsoleHeight.bind(this), + CONSOLE_HEIGHT_LOCAL_STORAGE_DEBOUNCE_WAIT_TIME + ); + } + public get alternateView(): EmbeddedConsoleView | undefined { return this._alternateView; } @@ -38,4 +50,12 @@ export class EmbeddableConsoleInfo { public registerAlternateView(view: EmbeddedConsoleView | null) { this._alternateView = view ?? undefined; } + + public getConsoleHeight(): string | undefined { + return this.storage.get(CONSOLE_HEIGHT_KEY, undefined); + } + + public setConsoleHeight(value: string) { + this.storage.set(CONSOLE_HEIGHT_KEY, value); + } } diff --git a/src/plugins/console/public/types/embeddable_console.ts b/src/plugins/console/public/types/embeddable_console.ts index 07a801c40287b..9a31e0f1cf151 100644 --- a/src/plugins/console/public/types/embeddable_console.ts +++ b/src/plugins/console/public/types/embeddable_console.ts @@ -10,22 +10,14 @@ import type { CoreStart } from '@kbn/core/public'; import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; import type { Dispatch } from 'react'; -/** - * EmbeddableConsoleProps are optional props used when rendering the embeddable developer console. - */ -export interface EmbeddableConsoleProps { - /** - * The default height of the content area. - */ - size?: 's' | 'm' | 'l'; -} - export interface EmbeddableConsoleDependencies { core: CoreStart; usageCollection?: UsageCollectionStart; setDispatch: (dispatch: Dispatch | null) => void; alternateView?: EmbeddedConsoleView; isMonacoEnabled: boolean; + getConsoleHeight: () => string | undefined; + setConsoleHeight: (value: string) => void; } export type EmbeddedConsoleAction = diff --git a/src/plugins/console/public/types/plugin_dependencies.ts b/src/plugins/console/public/types/plugin_dependencies.ts index 03db14c181be8..63446135e7f3c 100644 --- a/src/plugins/console/public/types/plugin_dependencies.ts +++ b/src/plugins/console/public/types/plugin_dependencies.ts @@ -13,7 +13,7 @@ import { UsageCollectionSetup, UsageCollectionStart } from '@kbn/usage-collectio import { SharePluginSetup, SharePluginStart, LocatorPublic } from '@kbn/share-plugin/public'; import { ConsoleUILocatorParams } from './locator'; -import { EmbeddableConsoleProps, EmbeddedConsoleView } from './embeddable_console'; +import { EmbeddedConsoleView } from './embeddable_console'; export interface AppSetupUIPluginDependencies { home?: HomePublicPluginSetup; @@ -55,7 +55,7 @@ export interface ConsolePluginStart { /** * EmbeddableConsole is a functional component used to render a portable version of the dev tools console on any page in Kibana */ - EmbeddableConsole?: FC; + EmbeddableConsole?: FC<{}>; /** * Register an alternate view for the Embedded Console * From 7d13fbadea35072d07f6a4ca39b2460ee90d1a3a Mon Sep 17 00:00:00 2001 From: Saarika Bhasi <55930906+saarikabhasi@users.noreply.github.com> Date: Wed, 24 Apr 2024 14:26:42 -0400 Subject: [PATCH 2/4] [Serverless Search] add readOnly and writeOnly privileges button in create api key flyout (#181472) ## Summary Add ready only and write only button to show privileges in code editor in`serverless_search` plugin in API key section https://github.com/elastic/kibana/assets/55930906/1e831194-218b-471a-9fd7-7737755e2c85 ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../api_key/security_privileges_form.tsx | 77 ++++++++++++++++++- .../page_objects/svl_search_landing_page.ts | 2 + 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/serverless_search/public/application/components/api_key/security_privileges_form.tsx b/x-pack/plugins/serverless_search/public/application/components/api_key/security_privileges_form.tsx index dcc301469837a..c647471f90e71 100644 --- a/x-pack/plugins/serverless_search/public/application/components/api_key/security_privileges_form.tsx +++ b/x-pack/plugins/serverless_search/public/application/components/api_key/security_privileges_form.tsx @@ -5,22 +5,51 @@ * 2.0. */ -import { EuiText, EuiLink, EuiSpacer } from '@elastic/eui'; +import { + EuiText, + EuiLink, + EuiSpacer, + EuiPanel, + EuiFlexItem, + EuiFlexGroup, + EuiButtonEmpty, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { CodeEditorField } from '@kbn/code-editor'; import React from 'react'; import { docLinks } from '../../../../common/doc_links'; - +const READ_ONLY_BOILERPLATE = `{ + "read-only-role": { + "cluster": [], + "indices": [ + { + "names": ["*"], + "privileges": ["read"] + } + ] + } +}`; +const WRITE_ONLY_BOILERPLATE = `{ + "write-only-role": { + "cluster": [], + "indices": [ + { + "names": ["*"], + "privileges": ["write"] + } + ] + } +}`; interface SecurityPrivilegesFormProps { - roleDescriptors: string; onChangeRoleDescriptors: (roleDescriptors: string) => void; error?: React.ReactNode | React.ReactNode[]; + roleDescriptors: string; } export const SecurityPrivilegesForm: React.FC = ({ - roleDescriptors, onChangeRoleDescriptors, error, + roleDescriptors, }) => { return (
@@ -39,6 +68,46 @@ export const SecurityPrivilegesForm: React.FC = ({

{error}

)} + + + + +

+ {i18n.translate('xpack.serverlessSearch.apiKey.privileges.boilerplate.label', { + defaultMessage: 'Replace with boilerplate:', + })} +

+
+
+ + + onChangeRoleDescriptors(READ_ONLY_BOILERPLATE)} + > + {i18n.translate( + 'xpack.serverlessSearch.apiKey.privileges.boilerplate.readOnlyLabel', + { + defaultMessage: 'Read-only', + } + )} + + + + onChangeRoleDescriptors(WRITE_ONLY_BOILERPLATE)} + > + {i18n.translate( + 'xpack.serverlessSearch.apiKey.privileges.boilerplate.writeOnlyLabel', + { + defaultMessage: 'Write-only', + } + )} + + +
+
Date: Wed, 24 Apr 2024 15:33:29 -0400 Subject: [PATCH 3/4] [APM] add filters support to apm latency, throughput, and error rate chart apis (#181359) ## Summary Rational: We'd like to embed APM visualizations across the observability solution, particularly within the SLO alert details page at this time. SLO configuration supports unified search filters. In order to ensure that the data accurately reflects the SLO configuration, API dependencies for APM visualizations must support filters. This PR adds filters support to: 1. `GET /internal/apm/services/{serviceName}/transactions/charts/latency` 2. `GET /internal/apm/services/{serviceName}/throughput` 3. `GET /internal/apm/services/{serviceName}/transactions/charts/error_rate` It is expected that consumers of the filters param send a serialized object containing a `filter` or `must_not` clause to include on the respective ES queries. Internally, it is expected that these objects are created using the `buildQueryFromFilters` helper exposed by `kbn/es-query`, passing the `Filter` object from the unified search `SearchBar` as the the parameter. ### Testing This feature is not yet available in the UI To test, I've added api integration tests for each api, as well as jest tests for any helpers introduced. --- .../get_failed_transaction_rate.ts | 8 +- .../server/routes/default_api_types.test.ts | 42 +++ .../apm/server/routes/default_api_types.ts | 30 +++ .../server/routes/services/get_throughput.ts | 6 +- .../apm/server/routes/services/route.ts | 5 +- .../get_failed_transaction_rate_periods.ts | 4 + .../transactions/get_latency_charts/index.ts | 12 +- .../apm/server/routes/transactions/route.ts | 10 +- .../tests/services/throughput.spec.ts | 245 ++++++++++++++++++ .../tests/transactions/error_rate.spec.ts | 132 ++++++++++ .../tests/transactions/latency.spec.ts | 120 +++++++++ 11 files changed, 606 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/observability_solution/apm/server/routes/default_api_types.test.ts diff --git a/x-pack/plugins/observability_solution/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts b/x-pack/plugins/observability_solution/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts index 08448ef50dc4b..4b1ee98c48cd3 100644 --- a/x-pack/plugins/observability_solution/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts +++ b/x-pack/plugins/observability_solution/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { BoolQuery } from '@kbn/es-query'; import { kqlQuery, rangeQuery, termQuery } from '@kbn/observability-plugin/server'; import { ApmServiceTransactionDocumentType } from '../../../common/document_type'; import { SERVICE_NAME, TRANSACTION_NAME, TRANSACTION_TYPE } from '../../../common/es_fields/apm'; @@ -22,6 +22,7 @@ import { export async function getFailedTransactionRate({ environment, kuery, + filters, serviceName, transactionTypes, transactionName, @@ -35,6 +36,7 @@ export async function getFailedTransactionRate({ }: { environment: string; kuery: string; + filters?: BoolQuery; serviceName: string; transactionTypes: string[]; transactionName?: string; @@ -62,7 +64,9 @@ export async function getFailedTransactionRate({ ...rangeQuery(startWithOffset, endWithOffset), ...environmentQuery(environment), ...kqlQuery(kuery), + ...(filters?.filter || []), ]; + const mustNot = filters?.must_not || []; const outcomes = getOutcomeAggregation(documentType); @@ -73,7 +77,7 @@ export async function getFailedTransactionRate({ body: { track_total_hits: false, size: 0, - query: { bool: { filter } }, + query: { bool: { filter, must_not: mustNot } }, aggs: { ...outcomes, timeseries: { diff --git a/x-pack/plugins/observability_solution/apm/server/routes/default_api_types.test.ts b/x-pack/plugins/observability_solution/apm/server/routes/default_api_types.test.ts new file mode 100644 index 0000000000000..baeda52f7fc3c --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/server/routes/default_api_types.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isLeft } from 'fp-ts/lib/Either'; +import { filtersRt } from './default_api_types'; + +describe('filtersRt', () => { + it('should decode', () => { + const filters = + '{"must_not":[{"term":{"service.name":"myService"}}],"filter":[{"range":{"@timestamp":{"gte":1617273600000,"lte":1617277200000}}}]}'; + const result = filtersRt.decode(filters); + expect(result).toEqual({ + _tag: 'Right', + right: { + should: [], + must: [], + must_not: [{ term: { 'service.name': 'myService' } }], + filter: [{ range: { '@timestamp': { gte: 1617273600000, lte: 1617277200000 } } }], + }, + }); + }); + + it.each(['3', 'true', '{}'])('should not decode invalid filter JSON: %s', (invalidJson) => { + const filters = `{ "filter": ${invalidJson}}`; + const result = filtersRt.decode(filters); + // @ts-ignore-next-line + expect(result.left[0].message).toEqual('filters.filter is not iterable'); + expect(isLeft(result)).toEqual(true); + }); + + it.each(['3', 'true', '{}'])('should not decode invalid must_not JSON: %s', (invalidJson) => { + const filters = `{ "must_not": ${invalidJson}}`; + const result = filtersRt.decode(filters); + // @ts-ignore-next-line + expect(result.left[0].message).toEqual('filters.must_not is not iterable'); + expect(isLeft(result)).toEqual(true); + }); +}); diff --git a/x-pack/plugins/observability_solution/apm/server/routes/default_api_types.ts b/x-pack/plugins/observability_solution/apm/server/routes/default_api_types.ts index a58a5a24af7b5..42ab1b63d431e 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/default_api_types.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/default_api_types.ts @@ -7,6 +7,8 @@ import * as t from 'io-ts'; import { isoToEpochRt, toNumberRt } from '@kbn/io-ts-utils'; +import { either } from 'fp-ts/lib/Either'; +import { BoolQuery } from '@kbn/es-query'; import { ApmDocumentType } from '../../common/document_type'; import { RollupInterval } from '../../common/rollup'; @@ -48,3 +50,31 @@ export const transactionDataSourceRt = t.type({ t.literal(RollupInterval.None), ]), }); + +const BoolQueryRt = t.type({ + should: t.array(t.record(t.string, t.unknown)), + must: t.array(t.record(t.string, t.unknown)), + must_not: t.array(t.record(t.string, t.unknown)), + filter: t.array(t.record(t.string, t.unknown)), +}); + +export const filtersRt = new t.Type( + 'BoolQuery', + BoolQueryRt.is, + (input: unknown, context: t.Context) => + either.chain(t.string.validate(input, context), (value: string) => { + try { + const filters = JSON.parse(value); + const decoded = { + should: [], + must: [], + must_not: filters.must_not ? [...filters.must_not] : [], + filter: filters.filter ? [...filters.filter] : [], + }; + return t.success(decoded); + } catch (err) { + return t.failure(input, context, err.message); + } + }), + (filters: BoolQuery): string => JSON.stringify(filters) +); diff --git a/x-pack/plugins/observability_solution/apm/server/routes/services/get_throughput.ts b/x-pack/plugins/observability_solution/apm/server/routes/services/get_throughput.ts index 5d45ea28c95e5..b5c48484e1039 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/services/get_throughput.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/services/get_throughput.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { BoolQuery } from '@kbn/es-query'; import { kqlQuery, rangeQuery, termQuery } from '@kbn/observability-plugin/server'; import { ApmServiceTransactionDocumentType } from '../../../common/document_type'; import { SERVICE_NAME, TRANSACTION_NAME, TRANSACTION_TYPE } from '../../../common/es_fields/apm'; @@ -17,6 +17,7 @@ import { Maybe } from '../../../typings/common'; interface Options { environment: string; kuery: string; + filters?: BoolQuery; serviceName: string; apmEventClient: APMEventClient; transactionType: string; @@ -34,6 +35,7 @@ export type ServiceThroughputResponse = Array<{ x: number; y: Maybe }>; export async function getThroughput({ environment, kuery, + filters, serviceName, apmEventClient, transactionType, @@ -67,7 +69,9 @@ export async function getThroughput({ ...environmentQuery(environment), ...kqlQuery(kuery), ...termQuery(TRANSACTION_NAME, transactionName), + ...(filters?.filter ?? []), ], + must_not: [...(filters?.must_not ?? [])], }, }, aggs: { diff --git a/x-pack/plugins/observability_solution/apm/server/routes/services/route.ts b/x-pack/plugins/observability_solution/apm/server/routes/services/route.ts index 05fdeec8fdf5d..4b0ef92450f34 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/services/route.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/services/route.ts @@ -33,6 +33,7 @@ import { withApmSpan } from '../../utils/with_apm_span'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { environmentRt, + filtersRt, kueryRt, probabilityRt, rangeRt, @@ -495,7 +496,7 @@ const serviceThroughputRoute = createApmServerRoute({ }), query: t.intersection([ t.type({ transactionType: t.string, bucketSizeInSeconds: toNumberRt }), - t.partial({ transactionName: t.string }), + t.partial({ transactionName: t.string, filters: filtersRt }), t.intersection([environmentRt, kueryRt, rangeRt, offsetRt, serviceTransactionDataSourceRt]), ]), }), @@ -512,6 +513,7 @@ const serviceThroughputRoute = createApmServerRoute({ const { environment, kuery, + filters, transactionType, transactionName, offset, @@ -525,6 +527,7 @@ const serviceThroughputRoute = createApmServerRoute({ const commonProps = { environment, kuery, + filters, serviceName, apmEventClient, transactionType, diff --git a/x-pack/plugins/observability_solution/apm/server/routes/transactions/get_failed_transaction_rate_periods.ts b/x-pack/plugins/observability_solution/apm/server/routes/transactions/get_failed_transaction_rate_periods.ts index 5b77a780bce6a..c2ba9d1014a67 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/transactions/get_failed_transaction_rate_periods.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/transactions/get_failed_transaction_rate_periods.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { BoolQuery } from '@kbn/es-query'; import { getFailedTransactionRate } from '../../lib/transaction_groups/get_failed_transaction_rate'; import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate'; import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client'; @@ -25,6 +26,7 @@ export interface FailedTransactionRateResponse { export async function getFailedTransactionRatePeriods({ environment, kuery, + filters, serviceName, transactionType, transactionName, @@ -38,6 +40,7 @@ export async function getFailedTransactionRatePeriods({ }: { environment: string; kuery: string; + filters?: BoolQuery; serviceName: string; transactionType: string; transactionName?: string; @@ -52,6 +55,7 @@ export async function getFailedTransactionRatePeriods({ const commonProps = { environment, kuery, + filters, serviceName, transactionTypes: [transactionType], transactionName, diff --git a/x-pack/plugins/observability_solution/apm/server/routes/transactions/get_latency_charts/index.ts b/x-pack/plugins/observability_solution/apm/server/routes/transactions/get_latency_charts/index.ts index f05682ca047bb..70e9555af4849 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/transactions/get_latency_charts/index.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/transactions/get_latency_charts/index.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { BoolQuery } from '@kbn/es-query'; import { kqlQuery, rangeQuery, termQuery } from '@kbn/observability-plugin/server'; import { ApmServiceTransactionDocumentType } from '../../../../common/document_type'; import { @@ -29,6 +29,7 @@ import { getDurationFieldForTransactions } from '../../../lib/helpers/transactio function searchLatency({ environment, kuery, + filters, serviceName, transactionType, transactionName, @@ -45,6 +46,7 @@ function searchLatency({ }: { environment: string; kuery: string; + filters?: BoolQuery; serviceName: string; transactionType: string | undefined; transactionName: string | undefined; @@ -87,7 +89,9 @@ function searchLatency({ ...termQuery(TRANSACTION_NAME, transactionName), ...termQuery(TRANSACTION_TYPE, transactionType), ...termQuery(FAAS_ID, serverlessId), + ...(filters?.filter || []), ], + must_not: filters?.must_not || [], }, }, aggs: { @@ -111,6 +115,7 @@ function searchLatency({ export async function getLatencyTimeseries({ environment, kuery, + filters, serviceName, transactionType, transactionName, @@ -127,6 +132,7 @@ export async function getLatencyTimeseries({ }: { environment: string; kuery: string; + filters?: BoolQuery; serviceName: string; transactionType?: string; transactionName?: string; @@ -144,6 +150,7 @@ export async function getLatencyTimeseries({ const response = await searchLatency({ environment, kuery, + filters, serviceName, transactionType, transactionName, @@ -195,6 +202,7 @@ export async function getLatencyPeriods({ apmEventClient, latencyAggregationType, kuery, + filters, environment, start, end, @@ -210,6 +218,7 @@ export async function getLatencyPeriods({ apmEventClient: APMEventClient; latencyAggregationType: LatencyAggregationType; kuery: string; + filters?: BoolQuery; environment: string; start: number; end: number; @@ -225,6 +234,7 @@ export async function getLatencyPeriods({ transactionName, apmEventClient, kuery, + filters, environment, documentType, rollupInterval, diff --git a/x-pack/plugins/observability_solution/apm/server/routes/transactions/route.ts b/x-pack/plugins/observability_solution/apm/server/routes/transactions/route.ts index 8e6b8a654a030..816879d7cb40a 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/transactions/route.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/transactions/route.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { jsonRt, toBooleanRt, toNumberRt } from '@kbn/io-ts-utils'; import * as t from 'io-ts'; import { offsetRt } from '../../../common/comparison_rt'; @@ -23,6 +22,7 @@ import { import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { environmentRt, + filtersRt, kueryRt, rangeRt, serviceTransactionDataSourceRt, @@ -221,7 +221,7 @@ const transactionLatencyChartsRoute = createApmServerRoute({ bucketSizeInSeconds: toNumberRt, useDurationSummary: toBooleanRt, }), - t.partial({ transactionName: t.string }), + t.partial({ transactionName: t.string, filters: filtersRt }), t.intersection([environmentRt, kueryRt, rangeRt, offsetRt]), serviceTransactionDataSourceRt, ]), @@ -235,6 +235,7 @@ const transactionLatencyChartsRoute = createApmServerRoute({ const { environment, kuery, + filters, transactionType, transactionName, latencyAggregationType, @@ -250,6 +251,7 @@ const transactionLatencyChartsRoute = createApmServerRoute({ const options = { environment, kuery, + filters, serviceName, transactionType, transactionName, @@ -372,7 +374,7 @@ const transactionChartsErrorRateRoute = createApmServerRoute({ }), query: t.intersection([ t.type({ transactionType: t.string, bucketSizeInSeconds: toNumberRt }), - t.partial({ transactionName: t.string }), + t.partial({ transactionName: t.string, filters: filtersRt }), t.intersection([environmentRt, kueryRt, rangeRt, offsetRt, serviceTransactionDataSourceRt]), ]), }), @@ -385,6 +387,7 @@ const transactionChartsErrorRateRoute = createApmServerRoute({ const { environment, kuery, + filters, transactionType, transactionName, start, @@ -398,6 +401,7 @@ const transactionChartsErrorRateRoute = createApmServerRoute({ return getFailedTransactionRatePeriods({ environment, kuery, + filters, serviceName, transactionType, transactionName, diff --git a/x-pack/test/apm_api_integration/tests/services/throughput.spec.ts b/x-pack/test/apm_api_integration/tests/services/throughput.spec.ts index ef56e61bf4f82..624706d30115a 100644 --- a/x-pack/test/apm_api_integration/tests/services/throughput.spec.ts +++ b/x-pack/test/apm_api_integration/tests/services/throughput.spec.ts @@ -7,6 +7,7 @@ import { apm, timerange } from '@kbn/apm-synthtrace-client'; import expect from '@kbn/expect'; +import { buildQueryFromFilters } from '@kbn/es-query'; import { first, last, meanBy } from 'lodash'; import moment from 'moment'; import { isFiniteNumber } from '@kbn/apm-plugin/common/utils/is_finite_number'; @@ -285,6 +286,250 @@ export default function ApiTest({ getService }: FtrProviderContext) { ); }); }); + + describe('handles kuery', () => { + let throughputMetrics: ThroughputReturn; + let throughputTransactions: ThroughputReturn; + + before(async () => { + const [throughputMetricsResponse, throughputTransactionsResponse] = await Promise.all([ + callApi( + { + query: { + kuery: 'transaction.name : "GET /api/product/list"', + }, + }, + 'metric' + ), + callApi( + { + query: { + kuery: 'transaction.name : "GET /api/product/list"', + }, + }, + 'transaction' + ), + ]); + throughputMetrics = throughputMetricsResponse.body; + throughputTransactions = throughputTransactionsResponse.body; + }); + + it('returns some transactions data', () => { + expect(throughputTransactions.currentPeriod.length).to.be.greaterThan(0); + const hasData = throughputTransactions.currentPeriod.some(({ y }) => isFiniteNumber(y)); + expect(hasData).to.equal(true); + }); + + it('returns some metrics data', () => { + expect(throughputMetrics.currentPeriod.length).to.be.greaterThan(0); + const hasData = throughputMetrics.currentPeriod.some(({ y }) => isFiniteNumber(y)); + expect(hasData).to.equal(true); + }); + + it('has same mean value for metrics and transactions data', () => { + const transactionsMean = meanBy(throughputTransactions.currentPeriod, 'y'); + const metricsMean = meanBy(throughputMetrics.currentPeriod, 'y'); + [transactionsMean, metricsMean].forEach((value) => + expect(roundNumber(value)).to.be.equal(roundNumber(GO_PROD_RATE)) + ); + }); + + it('has a bucket size of 30 seconds for transactions data', () => { + const firstTimerange = throughputTransactions.currentPeriod[0].x; + const secondTimerange = throughputTransactions.currentPeriod[1].x; + const timeIntervalAsSeconds = (secondTimerange - firstTimerange) / 1000; + expect(timeIntervalAsSeconds).to.equal(30); + }); + + it('has a bucket size of 1 minute for metrics data', () => { + const firstTimerange = throughputMetrics.currentPeriod[0].x; + const secondTimerange = throughputMetrics.currentPeriod[1].x; + const timeIntervalAsMinutes = (secondTimerange - firstTimerange) / 1000 / 60; + expect(timeIntervalAsMinutes).to.equal(1); + }); + }); + + describe('handles filters', () => { + let throughputMetrics: ThroughputReturn; + let throughputTransactions: ThroughputReturn; + const filters = [ + { + meta: { + disabled: false, + negate: false, + alias: null, + key: 'transaction.name', + params: ['GET /api/product/list'], + type: 'phrases', + }, + query: { + bool: { + minimum_should_match: 1, + should: { + match_phrase: { + 'transaction.name': 'GET /api/product/list', + }, + }, + }, + }, + }, + ]; + const serializedFilters = JSON.stringify(buildQueryFromFilters(filters, undefined)); + + before(async () => { + const [throughputMetricsResponse, throughputTransactionsResponse] = await Promise.all([ + callApi( + { + query: { + filters: serializedFilters, + }, + }, + 'metric' + ), + callApi( + { + query: { + filters: serializedFilters, + }, + }, + 'transaction' + ), + ]); + throughputMetrics = throughputMetricsResponse.body; + throughputTransactions = throughputTransactionsResponse.body; + }); + + it('returns some transactions data', () => { + expect(throughputTransactions.currentPeriod.length).to.be.greaterThan(0); + const hasData = throughputTransactions.currentPeriod.some(({ y }) => isFiniteNumber(y)); + expect(hasData).to.equal(true); + }); + + it('returns some metrics data', () => { + expect(throughputMetrics.currentPeriod.length).to.be.greaterThan(0); + const hasData = throughputMetrics.currentPeriod.some(({ y }) => isFiniteNumber(y)); + expect(hasData).to.equal(true); + }); + + it('has same mean value for metrics and transactions data', () => { + const transactionsMean = meanBy(throughputTransactions.currentPeriod, 'y'); + const metricsMean = meanBy(throughputMetrics.currentPeriod, 'y'); + [transactionsMean, metricsMean].forEach((value) => + expect(roundNumber(value)).to.be.equal(roundNumber(GO_PROD_RATE)) + ); + }); + + it('has a bucket size of 30 seconds for transactions data', () => { + const firstTimerange = throughputTransactions.currentPeriod[0].x; + const secondTimerange = throughputTransactions.currentPeriod[1].x; + const timeIntervalAsSeconds = (secondTimerange - firstTimerange) / 1000; + expect(timeIntervalAsSeconds).to.equal(30); + }); + + it('has a bucket size of 1 minute for metrics data', () => { + const firstTimerange = throughputMetrics.currentPeriod[0].x; + const secondTimerange = throughputMetrics.currentPeriod[1].x; + const timeIntervalAsMinutes = (secondTimerange - firstTimerange) / 1000 / 60; + expect(timeIntervalAsMinutes).to.equal(1); + }); + }); + + describe('handles negate filters', () => { + let throughputMetrics: ThroughputReturn; + let throughputTransactions: ThroughputReturn; + const filters = [ + { + meta: { + disabled: false, + negate: true, + alias: null, + key: 'transaction.name', + params: ['GET /api/product/list'], + type: 'phrases', + }, + query: { + bool: { + minimum_should_match: 1, + should: { + match_phrase: { + 'transaction.name': 'GET /api/product/list', + }, + }, + }, + }, + }, + ]; + const serializedFilters = JSON.stringify(buildQueryFromFilters(filters, undefined)); + + before(async () => { + const [throughputMetricsResponse, throughputTransactionsResponse] = await Promise.all([ + callApi( + { + query: { + filters: serializedFilters, + }, + }, + 'metric' + ), + callApi( + { + query: { + filters: serializedFilters, + }, + }, + 'transaction' + ), + ]); + throughputMetrics = throughputMetricsResponse.body; + throughputTransactions = throughputTransactionsResponse.body; + }); + + it('returns some transactions data', () => { + expect(throughputTransactions.currentPeriod.length).to.be.greaterThan(0); + const hasData = throughputTransactions.currentPeriod.some(({ y }) => isFiniteNumber(y)); + expect(hasData).to.equal(true); + }); + + it('returns some metrics data', () => { + expect(throughputMetrics.currentPeriod.length).to.be.greaterThan(0); + const hasData = throughputMetrics.currentPeriod.some(({ y }) => isFiniteNumber(y)); + expect(hasData).to.equal(true); + }); + + it('has same mean value for metrics and transactions data', () => { + const transactionsMean = meanBy(throughputTransactions.currentPeriod, 'y'); + const metricsMean = meanBy(throughputMetrics.currentPeriod, 'y'); + [transactionsMean, metricsMean].forEach((value) => + expect(roundNumber(value)).to.be.equal(roundNumber(GO_DEV_RATE)) + ); + }); + + it('has a bucket size of 30 seconds for transactions data', () => { + const firstTimerange = throughputTransactions.currentPeriod[0].x; + const secondTimerange = throughputTransactions.currentPeriod[1].x; + const timeIntervalAsSeconds = (secondTimerange - firstTimerange) / 1000; + expect(timeIntervalAsSeconds).to.equal(30); + }); + + it('has a bucket size of 1 minute for metrics data', () => { + const firstTimerange = throughputMetrics.currentPeriod[0].x; + const secondTimerange = throughputMetrics.currentPeriod[1].x; + const timeIntervalAsMinutes = (secondTimerange - firstTimerange) / 1000 / 60; + expect(timeIntervalAsMinutes).to.equal(1); + }); + }); + + describe('handles bad filters request', () => { + it('throws bad request error', async () => { + try { + await callApi({ + query: { environment: 'production', filters: '{}}' }, + }); + } catch (error) { + expect(error.res.status).to.be(400); + } + }); + }); }); }); } diff --git a/x-pack/test/apm_api_integration/tests/transactions/error_rate.spec.ts b/x-pack/test/apm_api_integration/tests/transactions/error_rate.spec.ts index 996127103b090..123bb0d6d594d 100644 --- a/x-pack/test/apm_api_integration/tests/transactions/error_rate.spec.ts +++ b/x-pack/test/apm_api_integration/tests/transactions/error_rate.spec.ts @@ -6,6 +6,7 @@ */ import { apm, timerange } from '@kbn/apm-synthtrace-client'; import expect from '@kbn/expect'; +import { buildQueryFromFilters } from '@kbn/es-query'; import { first, last } from 'lodash'; import moment from 'moment'; import { @@ -297,5 +298,136 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); }); }); + + describe('handles kuery', () => { + let txMetricsErrorRateResponse: ErrorRate; + + before(async () => { + const txMetricsResponse = await fetchErrorCharts({ + query: { + kuery: 'transaction.name : "GET /pear 🍎 "', + }, + }); + txMetricsErrorRateResponse = txMetricsResponse.body; + }); + + describe('has the correct calculation for average with kuery', () => { + const expectedFailureRate = config.secondTransaction.failureRate / 100; + + it('for tx metrics', () => { + expect(txMetricsErrorRateResponse.currentPeriod.average).to.eql(expectedFailureRate); + }); + }); + }); + + describe('handles filters', () => { + const filters = [ + { + meta: { + disabled: false, + negate: false, + alias: null, + key: 'transaction.name', + params: ['GET /api/product/list'], + type: 'phrases', + }, + query: { + bool: { + minimum_should_match: 1, + should: { + match_phrase: { + 'transaction.name': 'GET /pear 🍎 ', + }, + }, + }, + }, + }, + ]; + const serializedFilters = JSON.stringify(buildQueryFromFilters(filters, undefined)); + let txMetricsErrorRateResponse: ErrorRate; + + before(async () => { + const txMetricsResponse = await fetchErrorCharts({ + query: { + filters: serializedFilters, + }, + }); + txMetricsErrorRateResponse = txMetricsResponse.body; + }); + + describe('has the correct calculation for average with filter', () => { + const expectedFailureRate = config.secondTransaction.failureRate / 100; + + it('for tx metrics', () => { + expect(txMetricsErrorRateResponse.currentPeriod.average).to.eql(expectedFailureRate); + }); + }); + + describe('has the correct calculation for average with negate filter', () => { + const expectedFailureRate = config.secondTransaction.failureRate / 100; + + it('for tx metrics', () => { + expect(txMetricsErrorRateResponse.currentPeriod.average).to.eql(expectedFailureRate); + }); + }); + }); + + describe('handles negate filters', () => { + const filters = [ + { + meta: { + disabled: false, + negate: true, + alias: null, + key: 'transaction.name', + params: ['GET /api/product/list'], + type: 'phrases', + }, + query: { + bool: { + minimum_should_match: 1, + should: { + match_phrase: { + 'transaction.name': 'GET /pear 🍎 ', + }, + }, + }, + }, + }, + ]; + const serializedFilters = JSON.stringify(buildQueryFromFilters(filters, undefined)); + let txMetricsErrorRateResponse: ErrorRate; + + before(async () => { + const txMetricsResponse = await fetchErrorCharts({ + query: { + filters: serializedFilters, + }, + }); + txMetricsErrorRateResponse = txMetricsResponse.body; + }); + + describe('has the correct calculation for average with filter', () => { + const expectedFailureRate = config.firstTransaction.failureRate / 100; + + it('for tx metrics', () => { + expect(txMetricsErrorRateResponse.currentPeriod.average).to.eql(expectedFailureRate); + }); + }); + }); + + describe('handles bad filters request', () => { + it('for tx metrics', async () => { + try { + await fetchErrorCharts({ + query: { + filters: '{}}}', + }, + }); + } catch (e) { + expect(e.res.status).to.eql(400); + } + }); + }); }); } diff --git a/x-pack/test/apm_api_integration/tests/transactions/latency.spec.ts b/x-pack/test/apm_api_integration/tests/transactions/latency.spec.ts index a1cea01f408ca..eb876e6e312b7 100644 --- a/x-pack/test/apm_api_integration/tests/transactions/latency.spec.ts +++ b/x-pack/test/apm_api_integration/tests/transactions/latency.spec.ts @@ -6,6 +6,7 @@ */ import { apm, timerange } from '@kbn/apm-synthtrace-client'; import expect from '@kbn/expect'; +import { buildQueryFromFilters } from '@kbn/es-query'; import moment from 'moment'; import { APIClientRequestParamsOf, @@ -115,6 +116,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { ((GO_PROD_RATE * GO_PROD_DURATION + GO_DEV_RATE * GO_DEV_DURATION) / (GO_PROD_RATE + GO_DEV_RATE)) * 1000; + const expectedLatencyAvgValueProdMs = + ((GO_PROD_RATE * GO_PROD_DURATION) / GO_PROD_RATE) * 1000; + const expectedLatencyAvgValueDevMs = ((GO_DEV_RATE * GO_DEV_DURATION) / GO_DEV_RATE) * 1000; describe('average latency type', () => { it('returns average duration and timeseries', async () => { @@ -319,6 +323,122 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); }); }); + + describe('handles kuery', () => { + it('should return the appropriate latency values when a kuery is applied', async () => { + const response = await fetchLatencyCharts({ + query: { + latencyAggregationType: LatencyAggregationType.p95, + useDurationSummary: false, + kuery: 'transaction.name : "GET /api/product/list"', + }, + }); + + expect(response.status).to.be(200); + const latencyChartReturn = response.body as LatencyChartReturnType; + + expect(latencyChartReturn.currentPeriod.overallAvgDuration).to.be( + expectedLatencyAvgValueProdMs + ); + expect(latencyChartReturn.currentPeriod.latencyTimeseries.length).to.be.eql(15); + }); + }); + + describe('handles filters', () => { + it('should return the appropriate latency values when filters are applied', async () => { + const filters = [ + { + meta: { + disabled: false, + negate: false, + alias: null, + key: 'transaction.name', + params: ['GET /api/product/list'], + type: 'phrases', + }, + query: { + bool: { + minimum_should_match: 1, + should: { + match_phrase: { + 'transaction.name': 'GET /api/product/list', + }, + }, + }, + }, + }, + ]; + const serializedFilters = JSON.stringify(buildQueryFromFilters(filters, undefined)); + const response = await fetchLatencyCharts({ + query: { + latencyAggregationType: LatencyAggregationType.p95, + useDurationSummary: false, + filters: serializedFilters, + }, + }); + + expect(response.status).to.be(200); + const latencyChartReturn = response.body as LatencyChartReturnType; + + expect(latencyChartReturn.currentPeriod.overallAvgDuration).to.be( + expectedLatencyAvgValueProdMs + ); + expect(latencyChartReturn.currentPeriod.latencyTimeseries.length).to.be.eql(15); + }); + + it('should return the appropriate latency values when negate filters are applied', async () => { + const filters = [ + { + meta: { + disabled: false, + negate: true, + alias: null, + key: 'transaction.name', + params: ['GET /api/product/list'], + type: 'phrases', + }, + query: { + bool: { + minimum_should_match: 1, + should: { + match_phrase: { + 'transaction.name': 'GET /api/product/list', + }, + }, + }, + }, + }, + ]; + const serializedFilters = JSON.stringify(buildQueryFromFilters(filters, undefined)); + const response = await fetchLatencyCharts({ + query: { + latencyAggregationType: LatencyAggregationType.p95, + useDurationSummary: false, + filters: serializedFilters, + }, + }); + + expect(response.status).to.be(200); + const latencyChartReturn = response.body as LatencyChartReturnType; + + expect(latencyChartReturn.currentPeriod.overallAvgDuration).to.be( + expectedLatencyAvgValueDevMs + ); + expect(latencyChartReturn.currentPeriod.latencyTimeseries.length).to.be.eql(15); + }); + }); + + describe('handles bad filters request', () => { + it('throws bad request error', async () => { + try { + await fetchLatencyCharts({ + query: { environment: 'production', filters: '{}}' }, + }); + } catch (error) { + expect(error.res.status).to.be(400); + } + }); + }); } ); } From f52db83d33735f2ce0463d510e75b81f05dab6f1 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Wed, 24 Apr 2024 14:40:47 -0500 Subject: [PATCH 4/4] Revert "Enable heap snapshots for all our distributables (#181363)" This reverts commit 26b8c71730de6686fe4fe5de5f60ab5577b79902. --- config/node.options | 4 ---- .../os_packages/docker_generator/templates/base/Dockerfile | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/config/node.options b/config/node.options index 2bc49f5db1f4a..abcb40a5c19d4 100644 --- a/config/node.options +++ b/config/node.options @@ -13,7 +13,3 @@ ## enable OpenSSL 3 legacy provider --openssl-legacy-provider - -# Enable capturing heap snapshots. See https://nodejs.org/api/cli.html#--heapsnapshot-signalsignal ---heapsnapshot-signal=SIGUSR2 ---diagnostic-dir=./data \ No newline at end of file diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile index 2284e504229a8..1869086b51ab7 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile @@ -157,6 +157,9 @@ COPY --chown=1000:0 config/serverless.yml /usr/share/kibana/config/serverless.ym COPY --chown=1000:0 config/serverless.es.yml /usr/share/kibana/config/serverless.es.yml COPY --chown=1000:0 config/serverless.oblt.yml /usr/share/kibana/config/serverless.oblt.yml COPY --chown=1000:0 config/serverless.security.yml /usr/share/kibana/config/serverless.security.yml +# Supportability enhancement: enable capturing heap snapshots. See https://nodejs.org/api/cli.html#--heapsnapshot-signalsignal +RUN /usr/bin/echo -e '\n--heapsnapshot-signal=SIGUSR2' >> config/node.options +RUN /usr/bin/echo '--diagnostic-dir=./data' >> config/node.options ENV PROFILER_SIGNAL=SIGUSR1 {{/serverless}} {{^opensslLegacyProvider}}