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}} 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 * 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/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', + } + )} + + +
+
{ + 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); + } + }); + }); } ); } diff --git a/x-pack/test_serverless/functional/page_objects/svl_search_landing_page.ts b/x-pack/test_serverless/functional/page_objects/svl_search_landing_page.ts index a9de87ca60d71..a2b991d5b2e14 100644 --- a/x-pack/test_serverless/functional/page_objects/svl_search_landing_page.ts +++ b/x-pack/test_serverless/functional/page_objects/svl_search_landing_page.ts @@ -68,6 +68,8 @@ export function SvlSearchLandingPageProvider({ getService }: FtrProviderContext) }, async expectRoleDescriptorsEditorToExist() { await testSubjects.existOrFail('create-api-role-descriptors-code-editor-container'); + await testSubjects.existOrFail('serverlessSearchSecurityPrivilegesFormReadOnlyButton'); + await testSubjects.existOrFail('serverlessSearchSecurityPrivilegesFormWriteOnlyButton'); }, async setRoleDescriptorsValue(value: string) { await testSubjects.existOrFail('create-api-role-descriptors-code-editor-container');