diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 794503656ba04..74ee3f7c46e1b 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -78,7 +78,7 @@ pageLoadAssetSize: tileMap: 65337 timelion: 29920 transform: 41007 - triggersActionsUi: 170001 + triggersActionsUi: 186732 uiActions: 97717 uiActionsEnhanced: 313011 upgradeAssistant: 81241 diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index df04965cd8c32..e8c6fa4d5a013 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -47961,11 +47961,13 @@ __webpack_require__.r(__webpack_exports__); "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "installBazelTools", function() { return installBazelTools; }); -/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(4); -/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var _child_process__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(319); -/* harmony import */ var _fs__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(131); -/* harmony import */ var _log__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(246); +/* harmony import */ var dedent__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2); +/* harmony import */ var dedent__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(dedent__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(4); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_1__); +/* harmony import */ var _child_process__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(319); +/* harmony import */ var _fs__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(131); +/* harmony import */ var _log__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(246); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -47978,8 +47980,9 @@ __webpack_require__.r(__webpack_exports__); + async function readBazelToolsVersionFile(repoRootPath, versionFilename) { - const version = (await Object(_fs__WEBPACK_IMPORTED_MODULE_2__["readFile"])(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(repoRootPath, versionFilename))).toString().split('\n')[0]; + const version = (await Object(_fs__WEBPACK_IMPORTED_MODULE_3__["readFile"])(Object(path__WEBPACK_IMPORTED_MODULE_1__["resolve"])(repoRootPath, versionFilename))).toString().split('\n')[0]; if (!version) { throw new Error(`[bazel_tools] Failed on reading bazel tools versions\n ${versionFilename} file do not contain any version set`); @@ -47988,30 +47991,49 @@ async function readBazelToolsVersionFile(repoRootPath, versionFilename) { return version; } +async function isBazelBinAvailable() { + try { + await Object(_child_process__WEBPACK_IMPORTED_MODULE_2__["spawn"])('bazel', ['--version'], { + stdio: 'pipe' + }); + return true; + } catch { + return false; + } +} + async function installBazelTools(repoRootPath) { - _log__WEBPACK_IMPORTED_MODULE_3__["log"].debug(`[bazel_tools] reading bazel tools versions from version files`); + _log__WEBPACK_IMPORTED_MODULE_4__["log"].debug(`[bazel_tools] reading bazel tools versions from version files`); const bazeliskVersion = await readBazelToolsVersionFile(repoRootPath, '.bazeliskversion'); const bazelVersion = await readBazelToolsVersionFile(repoRootPath, '.bazelversion'); // Check what globals are installed - _log__WEBPACK_IMPORTED_MODULE_3__["log"].debug(`[bazel_tools] verify if bazelisk is installed`); + _log__WEBPACK_IMPORTED_MODULE_4__["log"].debug(`[bazel_tools] verify if bazelisk is installed`); const { - stdout - } = await Object(_child_process__WEBPACK_IMPORTED_MODULE_1__["spawn"])('yarn', ['global', 'list'], { + stdout: bazeliskPkgInstallStdout + } = await Object(_child_process__WEBPACK_IMPORTED_MODULE_2__["spawn"])('yarn', ['global', 'list'], { stdio: 'pipe' - }); // Install bazelisk if not installed + }); + const isBazelBinAlreadyAvailable = await isBazelBinAvailable(); // Install bazelisk if not installed - if (!stdout.includes(`@bazel/bazelisk@${bazeliskVersion}`)) { - _log__WEBPACK_IMPORTED_MODULE_3__["log"].info(`[bazel_tools] installing Bazel tools`); - _log__WEBPACK_IMPORTED_MODULE_3__["log"].debug(`[bazel_tools] bazelisk is not installed. Installing @bazel/bazelisk@${bazeliskVersion} and bazel@${bazelVersion}`); - await Object(_child_process__WEBPACK_IMPORTED_MODULE_1__["spawn"])('yarn', ['global', 'add', `@bazel/bazelisk@${bazeliskVersion}`], { + if (!bazeliskPkgInstallStdout.includes(`@bazel/bazelisk@${bazeliskVersion}`) || !isBazelBinAlreadyAvailable) { + _log__WEBPACK_IMPORTED_MODULE_4__["log"].info(`[bazel_tools] installing Bazel tools`); + _log__WEBPACK_IMPORTED_MODULE_4__["log"].debug(`[bazel_tools] bazelisk is not installed. Installing @bazel/bazelisk@${bazeliskVersion} and bazel@${bazelVersion}`); + await Object(_child_process__WEBPACK_IMPORTED_MODULE_2__["spawn"])('yarn', ['global', 'add', `@bazel/bazelisk@${bazeliskVersion}`], { env: { USE_BAZEL_VERSION: bazelVersion }, stdio: 'pipe' }); + const isBazelBinAvailableAfterInstall = await isBazelBinAvailable(); + + if (!isBazelBinAvailableAfterInstall) { + throw new Error(dedent__WEBPACK_IMPORTED_MODULE_0___default.a` + [bazel_tools] an error occurred when installing the Bazel tools. Please make sure 'yarn global bin' is on your $PATH, otherwise just add it there + `); + } } - _log__WEBPACK_IMPORTED_MODULE_3__["log"].success(`[bazel_tools] all bazel tools are correctly installed`); + _log__WEBPACK_IMPORTED_MODULE_4__["log"].success(`[bazel_tools] all bazel tools are correctly installed`); } /***/ }), diff --git a/packages/kbn-pm/src/utils/bazel/install_tools.ts b/packages/kbn-pm/src/utils/bazel/install_tools.ts index 4e19974590e83..3440d32ee4b51 100644 --- a/packages/kbn-pm/src/utils/bazel/install_tools.ts +++ b/packages/kbn-pm/src/utils/bazel/install_tools.ts @@ -6,6 +6,7 @@ * Public License, v 1. */ +import dedent from 'dedent'; import { resolve } from 'path'; import { spawn } from '../child_process'; import { readFile } from '../fs'; @@ -25,6 +26,16 @@ async function readBazelToolsVersionFile(repoRootPath: string, versionFilename: return version; } +async function isBazelBinAvailable() { + try { + await spawn('bazel', ['--version'], { stdio: 'pipe' }); + + return true; + } catch { + return false; + } +} + export async function installBazelTools(repoRootPath: string) { log.debug(`[bazel_tools] reading bazel tools versions from version files`); const bazeliskVersion = await readBazelToolsVersionFile(repoRootPath, '.bazeliskversion'); @@ -32,10 +43,17 @@ export async function installBazelTools(repoRootPath: string) { // Check what globals are installed log.debug(`[bazel_tools] verify if bazelisk is installed`); - const { stdout } = await spawn('yarn', ['global', 'list'], { stdio: 'pipe' }); + const { stdout: bazeliskPkgInstallStdout } = await spawn('yarn', ['global', 'list'], { + stdio: 'pipe', + }); + + const isBazelBinAlreadyAvailable = await isBazelBinAvailable(); // Install bazelisk if not installed - if (!stdout.includes(`@bazel/bazelisk@${bazeliskVersion}`)) { + if ( + !bazeliskPkgInstallStdout.includes(`@bazel/bazelisk@${bazeliskVersion}`) || + !isBazelBinAlreadyAvailable + ) { log.info(`[bazel_tools] installing Bazel tools`); log.debug( @@ -47,6 +65,13 @@ export async function installBazelTools(repoRootPath: string) { }, stdio: 'pipe', }); + + const isBazelBinAvailableAfterInstall = await isBazelBinAvailable(); + if (!isBazelBinAvailableAfterInstall) { + throw new Error(dedent` + [bazel_tools] an error occurred when installing the Bazel tools. Please make sure 'yarn global bin' is on your $PATH, otherwise just add it there + `); + } } log.success(`[bazel_tools] all bazel tools are correctly installed`); diff --git a/preinstall_check.js b/preinstall_check.js index e761afa91022c..e508f9ecb10c6 100644 --- a/preinstall_check.js +++ b/preinstall_check.js @@ -6,34 +6,37 @@ * Public License, v 1. */ -const isUsingNpm = process.env.npm_config_git !== undefined; +(() => { + const isUsingNpm = process.env.npm_config_git !== undefined; -if (isUsingNpm) { - throw `Use Yarn instead of npm, see Kibana's contributing guidelines`; -} - -// The value of the `npm_config_argv` env for each command: -// -// - `npm install`: '{"remain":[],"cooked":["install"],"original":[]}' -// - `yarn`: '{"remain":[],"cooked":["install"],"original":[]}' -// - `yarn kbn bootstrap`: '{"remain":[],"cooked":["run","kbn"],"original":["kbn","bootstrap"]}' -const rawArgv = process.env.npm_config_argv; - -if (rawArgv === undefined) { - return; -} + if (isUsingNpm) { + throw `Use Yarn instead of npm, see Kibana's contributing guidelines`; + } -try { - const argv = JSON.parse(rawArgv); + // The value of the `npm_config_argv` env for each command: + // + // - `npm install`: '{"remain":[],"cooked":["install"],"original":[]}' + // - `yarn`: '{"remain":[],"cooked":["install"],"original":[]}' + // - `yarn kbn bootstrap`: '{"remain":[],"cooked":["run","kbn"],"original":["kbn","bootstrap"]}' + const rawArgv = process.env.npm_config_argv; - if (argv.cooked.includes('kbn')) { - // all good, trying to install deps using `kbn` + if (rawArgv === undefined) { return; } - if (argv.cooked.includes('install')) { - console.log('\nWARNING: When installing dependencies, prefer `yarn kbn bootstrap`\n'); + try { + const argv = JSON.parse(rawArgv); + + // allow dependencies to be installed with `yarn kbn bootstrap` or `bazel run @nodejs//:yarn` (called under the hood by bazel) + if (argv.cooked.includes('kbn') || !!process.env.BAZEL_YARN_INSTALL) { + // all good, trying to install deps using `kbn` or bazel directly + return; + } + + if (argv.cooked.includes('install')) { + console.log('\nWARNING: When installing dependencies, prefer `yarn kbn bootstrap`\n'); + } + } catch (e) { + // if it fails we do nothing, as this is just intended to be a helpful message } -} catch (e) { - // if it fails we do nothing, as this is just intended to be a helpful message -} +})(); diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts index dac93ff29b68f..e6fd5004dd7d5 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts @@ -14,6 +14,16 @@ import { loggingSystemMock } from '../../../logging/logging_system.mock'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectsType } from '../../types'; import { errors as esErrors } from '@elastic/elasticsearch'; +import { DocumentMigrator } from '../core/document_migrator'; +jest.mock('../core/document_migrator', () => { + return { + // Create a mock for spying on the constructor + DocumentMigrator: jest.fn().mockImplementation((...args) => { + const { DocumentMigrator: RealDocMigrator } = jest.requireActual('../core/document_migrator'); + return new RealDocMigrator(args[0]); + }), + }; +}); const createRegistry = (types: Array>) => { const registry = new SavedObjectTypeRegistry(); @@ -31,12 +41,16 @@ const createRegistry = (types: Array>) => { }; describe('KibanaMigrator', () => { + beforeEach(() => { + (DocumentMigrator as jest.Mock).mockClear(); + }); describe('constructor', () => { it('coerces the current Kibana version if it has a hyphen', () => { const options = mockOptions(); options.kibanaVersion = '3.2.1-SNAPSHOT'; const migrator = new KibanaMigrator(options); expect(migrator.kibanaVersion).toEqual('3.2.1'); + expect((DocumentMigrator as jest.Mock).mock.calls[0][0].kibanaVersion).toEqual('3.2.1'); }); }); describe('getActiveMappings', () => { @@ -105,8 +119,8 @@ describe('KibanaMigrator', () => { const migrator = new KibanaMigrator(options); - expect(() => migrator.runMigrations()).rejects.toThrow( - /Migrations are not ready. Make sure prepareMigrations is called first./i + await expect(() => migrator.runMigrations()).toThrowErrorMatchingInlineSnapshot( + `"Migrations are not ready. Make sure prepareMigrations is called first."` ); }); diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index ecef84a6e297c..1a4611b491419 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -12,6 +12,7 @@ */ import { BehaviorSubject } from 'rxjs'; +import Semver from 'semver'; import { KibanaConfigType } from '../../../kibana_config'; import { ElasticsearchClient } from '../../../elasticsearch'; import { Logger } from '../../../logging'; @@ -97,7 +98,7 @@ export class KibanaMigrator { this.log = logger; this.kibanaVersion = kibanaVersion.split('-')[0]; // coerce a semver-like string (x.y.z-SNAPSHOT) or prerelease version (x.y.z-alpha) to a regular semver (x.y.z); this.documentMigrator = new DocumentMigrator({ - kibanaVersion, + kibanaVersion: this.kibanaVersion, typeRegistry, log: this.log, }); @@ -163,6 +164,15 @@ export class KibanaMigrator { registry: this.typeRegistry, }); + this.log.debug('Applying registered migrations for the following saved object types:'); + Object.entries(this.documentMigrator.migrationVersion) + .sort(([t1, v1], [t2, v2]) => { + return Semver.compare(v1, v2); + }) + .forEach(([type, migrationVersion]) => { + this.log.debug(`migrationVersion: ${migrationVersion} saved object type: ${type}`); + }); + const migrators = Object.keys(indexMap).map((index) => { // TODO migrationsV2: remove old migrations algorithm if (this.savedObjectsConfig.enableV2) { diff --git a/src/dev/code_coverage/ingest_coverage/integration_tests/team_assignment.test.js b/src/dev/code_coverage/ingest_coverage/integration_tests/team_assignment.test.js index bf4c55871598e..13ef28def4080 100644 --- a/src/dev/code_coverage/ingest_coverage/integration_tests/team_assignment.test.js +++ b/src/dev/code_coverage/ingest_coverage/integration_tests/team_assignment.test.js @@ -8,7 +8,6 @@ import { resolve } from 'path'; import execa from 'execa'; -import expect from '@kbn/expect'; import shell from 'shelljs'; const ROOT_DIR = resolve(__dirname, '../../../../..'); @@ -38,11 +37,14 @@ describe('Team Assignment', () => { describe(`when the codeowners file contains #CC#`, () => { it(`should strip the prefix and still drill down through the fs`, async () => { const { stdout } = await execa('grep', ['tre', teamAssignmentsPath], { cwd: ROOT_DIR }); - expect(stdout).to.be(`x-pack/plugins/code/jest.config.js kibana-tre -x-pack/plugins/code/server/config.ts kibana-tre -x-pack/plugins/code/server/index.ts kibana-tre -x-pack/plugins/code/server/plugin.test.ts kibana-tre -x-pack/plugins/code/server/plugin.ts kibana-tre`); + const lines = stdout.split('\n').filter((line) => !line.includes('/target')); + expect(lines).toEqual([ + 'x-pack/plugins/code/jest.config.js kibana-tre', + 'x-pack/plugins/code/server/config.ts kibana-tre', + 'x-pack/plugins/code/server/index.ts kibana-tre', + 'x-pack/plugins/code/server/plugin.test.ts kibana-tre', + 'x-pack/plugins/code/server/plugin.ts kibana-tre', + ]); }); }); }); diff --git a/src/plugins/kibana_overview/tsconfig.json b/src/plugins/kibana_overview/tsconfig.json new file mode 100644 index 0000000000000..ac3ac109cb35f --- /dev/null +++ b/src/plugins/kibana_overview/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "public/**/*", + "common/**/*", + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../../plugins/navigation/tsconfig.json" }, + { "path": "../../plugins/data/tsconfig.json" }, + { "path": "../../plugins/home/tsconfig.json" }, + { "path": "../../plugins/newsfeed/tsconfig.json" }, + { "path": "../../plugins/usage_collection/tsconfig.json" }, + { "path": "../../plugins/kibana_react/tsconfig.json" }, + ] +} diff --git a/src/plugins/vis_type_table/public/components/table_vis_basic.tsx b/src/plugins/vis_type_table/public/components/table_vis_basic.tsx index 6cea4d09c4e7f..8bb5186159b7d 100644 --- a/src/plugins/vis_type_table/public/components/table_vis_basic.tsx +++ b/src/plugins/vis_type_table/public/components/table_vis_basic.tsx @@ -13,15 +13,14 @@ import { orderBy } from 'lodash'; import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; import { createTableVisCell } from './table_vis_cell'; -import { Table } from '../table_vis_response_handler'; -import { TableVisConfig, TableVisUseUiStateProps } from '../types'; -import { useFormattedColumnsAndRows, usePagination } from '../utils'; +import { TableContext, TableVisConfig, TableVisUseUiStateProps } from '../types'; +import { usePagination } from '../utils'; import { TableVisControls } from './table_vis_controls'; import { createGridColumns } from './table_vis_columns'; interface TableVisBasicProps { fireEvent: IInterpreterRenderHandlers['event']; - table: Table; + table: TableContext; visConfig: TableVisConfig; title?: string; uiStateProps: TableVisUseUiStateProps; @@ -35,7 +34,7 @@ export const TableVisBasic = memo( title, uiStateProps: { columnsWidth, sort, setColumnsWidth, setSort }, }: TableVisBasicProps) => { - const { columns, rows } = useFormattedColumnsAndRows(table, visConfig); + const { columns, rows, formattedColumns } = table; // custom sorting is in place until the EuiDataGrid sorting gets rid of flaws -> https://github.com/elastic/eui/issues/4108 const sortedRows = useMemo( @@ -47,13 +46,19 @@ export const TableVisBasic = memo( ); // renderCellValue is a component which renders a cell based on column and row indexes - const renderCellValue = useMemo(() => createTableVisCell(columns, sortedRows), [ - columns, + const renderCellValue = useMemo(() => createTableVisCell(sortedRows, formattedColumns), [ + formattedColumns, sortedRows, ]); // Columns config - const gridColumns = createGridColumns(table, columns, columnsWidth, sortedRows, fireEvent); + const gridColumns = createGridColumns( + columns, + sortedRows, + formattedColumns, + columnsWidth, + fireEvent + ); // Pagination config const pagination = usePagination(visConfig, rows.length); @@ -126,10 +131,9 @@ export const TableVisBasic = memo( additionalControls: ( ), @@ -138,8 +142,7 @@ export const TableVisBasic = memo( renderCellValue={renderCellValue} renderFooterCellValue={ visConfig.showTotal - ? // @ts-expect-error - ({ colIndex }) => columns[colIndex].formattedTotal || null + ? ({ columnId }) => formattedColumns[columnId].formattedTotal || null : undefined } pagination={pagination} diff --git a/src/plugins/vis_type_table/public/components/table_vis_cell.tsx b/src/plugins/vis_type_table/public/components/table_vis_cell.tsx index 0a6aafc84bf26..04df3907c8c9b 100644 --- a/src/plugins/vis_type_table/public/components/table_vis_cell.tsx +++ b/src/plugins/vis_type_table/public/components/table_vis_cell.tsx @@ -9,17 +9,15 @@ import React from 'react'; import { EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { Table } from '../table_vis_response_handler'; -import { FormattedColumn } from '../types'; +import { DatatableRow } from 'src/plugins/expressions'; +import { FormattedColumns } from '../types'; -export const createTableVisCell = (formattedColumns: FormattedColumn[], rows: Table['rows']) => ({ - // @ts-expect-error - colIndex, +export const createTableVisCell = (rows: DatatableRow[], formattedColumns: FormattedColumns) => ({ rowIndex, columnId, }: EuiDataGridCellValueElementProps) => { const rowValue = rows[rowIndex][columnId]; - const column = formattedColumns[colIndex]; + const column = formattedColumns[columnId]; const content = column.formatter.convert(rowValue, 'html'); const cellContent = ( diff --git a/src/plugins/vis_type_table/public/components/table_vis_columns.tsx b/src/plugins/vis_type_table/public/components/table_vis_columns.tsx index 2610677b2491c..6b44a2504ff89 100644 --- a/src/plugins/vis_type_table/public/components/table_vis_columns.tsx +++ b/src/plugins/vis_type_table/public/components/table_vis_columns.tsx @@ -10,9 +10,8 @@ import React from 'react'; import { EuiDataGridColumnCellActionProps, EuiDataGridColumn } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; -import { Table } from '../table_vis_response_handler'; -import { FormattedColumn, TableVisUiState } from '../types'; +import { DatatableColumn, DatatableRow, IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { FormattedColumns, TableVisUiState } from '../types'; interface FilterCellData { /** @@ -27,33 +26,24 @@ interface FilterCellData { } export const createGridColumns = ( - table: Table, - columns: FormattedColumn[], + columns: DatatableColumn[], + rows: DatatableRow[], + formattedColumns: FormattedColumns, columnsWidth: TableVisUiState['colWidth'], - rows: Table['rows'], fireEvent: IInterpreterRenderHandlers['event'] ) => { const onFilterClick = (data: FilterCellData, negate: boolean) => { - /** - * Visible column index and the actual one from the source table could be different. - * e.x. a column could be filtered out if it's not a dimension - - * see formattedColumns in use_formatted_columns.ts file, - * or an extra percantage column could be added, which doesn't exist in the raw table - */ - const rawTableActualColumnIndex = table.columns.findIndex( - (c) => c.id === columns[data.column].id - ); fireEvent({ name: 'filterBucket', data: { data: [ { table: { - ...table, + columns, rows, }, ...data, - column: rawTableActualColumnIndex, + column: data.column, }, ], negate, @@ -63,12 +53,13 @@ export const createGridColumns = ( return columns.map( (col, colIndex): EuiDataGridColumn => { - const cellActions = col.filterable + const formattedColumn = formattedColumns[col.id]; + const cellActions = formattedColumn.filterable ? [ ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { const rowValue = rows[rowIndex][columnId]; const contentsIsDefined = rowValue !== null && rowValue !== undefined; - const cellContent = col.formatter.convert(rowValue); + const cellContent = formattedColumn.formatter.convert(rowValue); const filterForText = i18n.translate( 'visTypeTable.tableCellFilter.filterForValueText', @@ -105,7 +96,7 @@ export const createGridColumns = ( ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { const rowValue = rows[rowIndex][columnId]; const contentsIsDefined = rowValue !== null && rowValue !== undefined; - const cellContent = col.formatter.convert(rowValue); + const cellContent = formattedColumn.formatter.convert(rowValue); const filterOutText = i18n.translate( 'visTypeTable.tableCellFilter.filterOutValueText', @@ -144,8 +135,8 @@ export const createGridColumns = ( const initialWidth = columnsWidth.find((c) => c.colIndex === colIndex); const column: EuiDataGridColumn = { id: col.id, - display: col.title, - displayAsText: col.title, + display: col.name, + displayAsText: col.name, actions: { showHide: false, showMoveLeft: false, diff --git a/src/plugins/vis_type_table/public/components/table_vis_controls.tsx b/src/plugins/vis_type_table/public/components/table_vis_controls.tsx index 1f4f49442957f..3eda73084e41d 100644 --- a/src/plugins/vis_type_table/public/components/table_vis_controls.tsx +++ b/src/plugins/vis_type_table/public/components/table_vis_controls.tsx @@ -11,81 +11,103 @@ import { EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } f import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { DatatableRow } from 'src/plugins/expressions'; +import { DatatableColumn, DatatableRow } from 'src/plugins/expressions'; import { CoreStart } from 'kibana/public'; import { useKibana } from '../../../kibana_react/public'; -import { FormattedColumn } from '../types'; -import { Table } from '../table_vis_response_handler'; -import { exportAsCsv } from '../utils'; +import { exporters } from '../../../data/public'; +import { + CSV_SEPARATOR_SETTING, + CSV_QUOTE_VALUES_SETTING, + downloadFileAs, +} from '../../../share/public'; +import { getFormatService } from '../services'; interface TableVisControlsProps { dataGridAriaLabel: string; filename?: string; - cols: FormattedColumn[]; + columns: DatatableColumn[]; rows: DatatableRow[]; - table: Table; } -export const TableVisControls = memo(({ dataGridAriaLabel, ...props }: TableVisControlsProps) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const togglePopover = useCallback(() => setIsPopoverOpen((state) => !state), []); - const closePopover = useCallback(() => setIsPopoverOpen(false), []); +export const TableVisControls = memo( + ({ dataGridAriaLabel, filename, columns, rows }: TableVisControlsProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const togglePopover = useCallback(() => setIsPopoverOpen((state) => !state), []); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); - const { - services: { uiSettings }, - } = useKibana(); + const { + services: { uiSettings }, + } = useKibana(); - const onClickExport = useCallback( - (formatted: boolean) => - exportAsCsv(formatted, { - ...props, - uiSettings, - }), - [props, uiSettings] - ); + const onClickExport = useCallback( + (formatted: boolean) => { + const csvSeparator = uiSettings.get(CSV_SEPARATOR_SETTING); + const quoteValues = uiSettings.get(CSV_QUOTE_VALUES_SETTING); - const exportBtnAriaLabel = i18n.translate('visTypeTable.vis.controls.exportButtonAriaLabel', { - defaultMessage: 'Export {dataGridAriaLabel} as CSV', - values: { - dataGridAriaLabel, - }, - }); + const content = exporters.datatableToCSV( + { + type: 'datatable', + columns, + rows, + }, + { + csvSeparator, + quoteValues, + formatFactory: getFormatService().deserialize, + raw: !formatted, + } + ); + downloadFileAs(`${filename || 'unsaved'}.csv`, { content, type: exporters.CSV_MIME_TYPE }); + }, + [columns, rows, filename, uiSettings] + ); - const button = ( - - - - ); + const exportBtnAriaLabel = i18n.translate('visTypeTable.vis.controls.exportButtonAriaLabel', { + defaultMessage: 'Export {dataGridAriaLabel} as CSV', + values: { + dataGridAriaLabel, + }, + }); - const items = [ - onClickExport(false)}> - - , - onClickExport(true)}> - - , - ]; + const button = ( + + + + ); - return ( - - - - ); -}); + const items = [ + onClickExport(false)}> + + , + onClickExport(true)}> + + , + ]; + + return ( + + + + ); + } +); diff --git a/src/plugins/vis_type_table/public/components/table_vis_split.tsx b/src/plugins/vis_type_table/public/components/table_vis_split.tsx index be1a918e22c4b..3d1cacd732fae 100644 --- a/src/plugins/vis_type_table/public/components/table_vis_split.tsx +++ b/src/plugins/vis_type_table/public/components/table_vis_split.tsx @@ -9,8 +9,7 @@ import React, { memo } from 'react'; import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; -import { TableGroup } from '../table_vis_response_handler'; -import { TableVisConfig, TableVisUseUiStateProps } from '../types'; +import { TableGroup, TableVisConfig, TableVisUseUiStateProps } from '../types'; import { TableVisBasic } from './table_vis_basic'; interface TableVisSplitProps { @@ -24,11 +23,11 @@ export const TableVisSplit = memo( ({ fireEvent, tables, visConfig, uiStateProps }: TableVisSplitProps) => { return ( <> - {tables.map(({ tables: dataTable, key, title }) => ( -
+ {tables.map(({ table, title }) => ( +
{ diff --git a/src/plugins/vis_type_table/public/table_vis_fn.test.ts b/src/plugins/vis_type_table/public/table_vis_fn.test.ts index ea8030688caed..31b440ffb642f 100644 --- a/src/plugins/vis_type_table/public/table_vis_fn.test.ts +++ b/src/plugins/vis_type_table/public/table_vis_fn.test.ts @@ -7,11 +7,11 @@ */ import { createTableVisFn } from './table_vis_fn'; -import { tableVisResponseHandler } from './table_vis_response_handler'; +import { tableVisResponseHandler } from './utils'; import { functionWrapper } from '../../expressions/common/expression_functions/specs/tests/utils'; -jest.mock('./table_vis_response_handler', () => ({ +jest.mock('./utils', () => ({ tableVisResponseHandler: jest.fn().mockReturnValue({ tables: [{ columns: [], rows: [] }], }), @@ -62,6 +62,6 @@ describe('interpreter/functions#table', () => { it('calls response handler with correct values', async () => { await fn(context, { visConfig: JSON.stringify(visConfig) }, undefined); expect(tableVisResponseHandler).toHaveBeenCalledTimes(1); - expect(tableVisResponseHandler).toHaveBeenCalledWith(context, visConfig.dimensions); + expect(tableVisResponseHandler).toHaveBeenCalledWith(context, visConfig); }); }); diff --git a/src/plugins/vis_type_table/public/table_vis_fn.ts b/src/plugins/vis_type_table/public/table_vis_fn.ts index 99fee424b8bea..3dd8e81fc2ab2 100644 --- a/src/plugins/vis_type_table/public/table_vis_fn.ts +++ b/src/plugins/vis_type_table/public/table_vis_fn.ts @@ -7,10 +7,10 @@ */ import { i18n } from '@kbn/i18n'; -import { tableVisResponseHandler, TableContext } from './table_vis_response_handler'; import { ExpressionFunctionDefinition, Datatable, Render } from '../../expressions/public'; -import { TableVisConfig } from './types'; +import { TableVisData, TableVisConfig } from './types'; import { VIS_TYPE_TABLE } from '../common'; +import { tableVisResponseHandler } from './utils'; export type Input = Datatable; @@ -19,7 +19,7 @@ interface Arguments { } export interface TableVisRenderValue { - visData: TableContext; + visData: TableVisData; visType: typeof VIS_TYPE_TABLE; visConfig: TableVisConfig; } @@ -47,7 +47,7 @@ export const createTableVisFn = (): TableExpressionFunctionDefinition => ({ }, fn(input, args, handlers) { const visConfig = args.visConfig && JSON.parse(args.visConfig); - const convertedData = tableVisResponseHandler(input, visConfig.dimensions); + const convertedData = tableVisResponseHandler(input, visConfig); if (handlers?.inspectorAdapters?.tables) { handlers.inspectorAdapters.tables.logDatatable('default', input); diff --git a/src/plugins/vis_type_table/public/table_vis_response_handler.ts b/src/plugins/vis_type_table/public/table_vis_response_handler.ts deleted file mode 100644 index dbd01f94bd3c5..0000000000000 --- a/src/plugins/vis_type_table/public/table_vis_response_handler.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -import { Required } from '@kbn/utility-types'; - -import { getFormatService } from './services'; -import { Input } from './table_vis_fn'; -import { Dimensions } from './types'; - -export interface TableContext { - table?: Table; - tables: TableGroup[]; - direction?: 'row' | 'column'; -} - -export interface TableGroup { - table: Input; - tables: Table[]; - title: string; - name: string; - key: string | number; - column: number; - row: number; -} - -export interface Table { - columns: Input['columns']; - rows: Input['rows']; -} - -export function tableVisResponseHandler(input: Input, dimensions: Dimensions): TableContext { - let table: Table | undefined; - let tables: TableGroup[] = []; - let direction: TableContext['direction']; - - const split = dimensions.splitColumn || dimensions.splitRow; - - if (split) { - tables = []; - direction = dimensions.splitRow ? 'row' : 'column'; - const splitColumnIndex = split[0].accessor; - const splitColumnFormatter = getFormatService().deserialize(split[0].format); - const splitColumn = input.columns[splitColumnIndex]; - const splitMap: { [key: string]: number } = {}; - let splitIndex = 0; - - input.rows.forEach((row, rowIndex) => { - const splitValue: string | number = row[splitColumn.id]; - - if (!splitMap.hasOwnProperty(splitValue)) { - splitMap[splitValue] = splitIndex++; - const tableGroup: Required = { - title: `${splitColumnFormatter.convert(splitValue)}: ${splitColumn.name}`, - name: splitColumn.name, - key: splitValue, - column: splitColumnIndex, - row: rowIndex, - table: input, - tables: [], - }; - - tableGroup.tables.push({ - columns: input.columns, - rows: [], - }); - - tables.push(tableGroup); - } - - const tableIndex = splitMap[splitValue]; - tables[tableIndex].tables[0].rows.push(row); - }); - } else { - table = { - columns: input.columns, - rows: input.rows, - }; - } - - return { - direction, - table, - tables, - }; -} diff --git a/src/plugins/vis_type_table/public/types.ts b/src/plugins/vis_type_table/public/types.ts index 03cf8bb3395d6..61ba7739b9cb1 100644 --- a/src/plugins/vis_type_table/public/types.ts +++ b/src/plugins/vis_type_table/public/types.ts @@ -7,6 +7,7 @@ */ import { IFieldFormat } from 'src/plugins/data/public'; +import { DatatableColumn, DatatableRow } from 'src/plugins/expressions'; import { SchemaConfig } from 'src/plugins/visualizations/public'; import { TableVisParams } from '../common'; @@ -43,7 +44,6 @@ export interface TableVisConfig extends TableVisParams { } export interface FormattedColumn { - id: string; title: string; formatter: IFieldFormat; formattedTotal?: string | number; @@ -51,3 +51,24 @@ export interface FormattedColumn { sumTotal?: number; total?: number; } + +export interface FormattedColumns { + [key: string]: FormattedColumn; +} + +export interface TableContext { + columns: DatatableColumn[]; + rows: DatatableRow[]; + formattedColumns: FormattedColumns; +} + +export interface TableGroup { + table: TableContext; + title: string; +} + +export interface TableVisData { + table?: TableContext; + tables: TableGroup[]; + direction?: 'row' | 'column'; +} diff --git a/src/plugins/vis_type_table/public/utils/add_percentage_column.ts b/src/plugins/vis_type_table/public/utils/add_percentage_column.ts index 0e3879255dd06..11528c76ee300 100644 --- a/src/plugins/vis_type_table/public/utils/add_percentage_column.ts +++ b/src/plugins/vis_type_table/public/utils/add_percentage_column.ts @@ -6,48 +6,59 @@ * Public License, v 1. */ +import { findIndex } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { DatatableRow } from 'src/plugins/expressions'; +import { DatatableColumn } from 'src/plugins/expressions'; import { getFormatService } from '../services'; -import { FormattedColumn } from '../types'; -import { Table } from '../table_vis_response_handler'; +import { FormattedColumns, TableContext } from '../types'; -function insertColumn(arr: FormattedColumn[], index: number, col: FormattedColumn) { +function insertColumn(arr: DatatableColumn[], index: number, col: DatatableColumn) { const newArray = [...arr]; newArray.splice(index + 1, 0, col); return newArray; } /** - * @param columns - the formatted columns that will be displayed - * @param title - the title of the column to add to - * @param rows - the row data for the columns - * @param insertAtIndex - the index to insert the percentage column at - * @returns cols and rows for the table to render now included percentage column(s) + * Adds a brand new column with percentages of selected column to existing data table */ -export function addPercentageColumn( - columns: FormattedColumn[], - title: string, - rows: Table['rows'], - insertAtIndex: number -) { - const { id, sumTotal } = columns[insertAtIndex]; - const newId = `${id}-percents`; +export function addPercentageColumn(table: TableContext, name: string) { + const { columns, rows, formattedColumns } = table; + const insertAtIndex = findIndex(columns, { name }); + // column to show percentage for was removed + if (insertAtIndex < 0) return table; + + const { id } = columns[insertAtIndex]; + const { sumTotal } = formattedColumns[id]; + const percentageColumnId = `${id}-percents`; const formatter = getFormatService().deserialize({ id: 'percent' }); - const i18nTitle = i18n.translate('visTypeTable.params.percentageTableColumnName', { + const percentageColumnName = i18n.translate('visTypeTable.params.percentageTableColumnName', { defaultMessage: '{title} percentages', - values: { title }, + values: { title: name }, }); const newCols = insertColumn(columns, insertAtIndex, { - title: i18nTitle, - id: newId, - formatter, - filterable: false, + name: percentageColumnName, + id: percentageColumnId, + meta: { + type: 'number', + params: { id: 'percent' }, + }, }); - const newRows = rows.map((row) => ({ - [newId]: (row[id] as number) / (sumTotal as number), + const newFormattedColumns: FormattedColumns = { + ...formattedColumns, + [percentageColumnId]: { + title: percentageColumnName, + formatter, + filterable: false, + }, + }; + const newRows = rows.map((row) => ({ + [percentageColumnId]: (row[id] as number) / (sumTotal as number), ...row, })); - return { cols: newCols, rows: newRows }; + return { + columns: newCols, + rows: newRows, + formattedColumns: newFormattedColumns, + }; } diff --git a/src/plugins/vis_type_table/public/utils/create_formatted_table.ts b/src/plugins/vis_type_table/public/utils/create_formatted_table.ts new file mode 100644 index 0000000000000..9dbb6c0c76e25 --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/create_formatted_table.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { chain } from 'lodash'; +import { Datatable } from 'src/plugins/expressions'; +import { getFormatService } from '../services'; +import { FormattedColumn, FormattedColumns, TableVisConfig, TableContext } from '../types'; +import { AggTypes } from '../../common'; + +export const createFormattedTable = ( + table: Datatable | TableContext, + visConfig: TableVisConfig +) => { + const { buckets, metrics } = visConfig.dimensions; + + const formattedColumns = table.columns.reduce((acc, col, i) => { + const isBucket = buckets.find(({ accessor }) => accessor === i); + const dimension = isBucket || metrics.find(({ accessor }) => accessor === i); + + if (!dimension) return acc; + + const formatter = getFormatService().deserialize(dimension.format); + const formattedColumn: FormattedColumn = { + title: col.name, + formatter, + filterable: !!isBucket, + }; + + const isDate = dimension.format.id === 'date' || dimension.format.params?.id === 'date'; + const allowsNumericalAggregations = formatter.allowsNumericalAggregations; + + if (allowsNumericalAggregations || isDate || visConfig.totalFunc === AggTypes.COUNT) { + const sumOfColumnValues = table.rows.reduce((prev, curr) => { + // some metrics return undefined for some of the values + // derivative is an example of this as it returns undefined in the first row + if (curr[col.id] === undefined) return prev; + return prev + (curr[col.id] as number); + }, 0); + + formattedColumn.sumTotal = sumOfColumnValues; + + switch (visConfig.totalFunc) { + case AggTypes.SUM: { + if (!isDate) { + formattedColumn.formattedTotal = formatter.convert(sumOfColumnValues); + formattedColumn.total = sumOfColumnValues; + } + break; + } + case AggTypes.AVG: { + if (!isDate) { + const total = sumOfColumnValues / table.rows.length; + formattedColumn.formattedTotal = formatter.convert(total); + formattedColumn.total = total; + } + break; + } + case AggTypes.MIN: { + const total = chain(table.rows).map(col.id).min().value() as number; + formattedColumn.formattedTotal = formatter.convert(total); + formattedColumn.total = total; + break; + } + case AggTypes.MAX: { + const total = chain(table.rows).map(col.id).max().value() as number; + formattedColumn.formattedTotal = formatter.convert(total); + formattedColumn.total = total; + break; + } + case AggTypes.COUNT: { + const total = table.rows.length; + formattedColumn.formattedTotal = total; + formattedColumn.total = total; + break; + } + default: + break; + } + } + + acc[col.id] = formattedColumn; + + return acc; + }, {}); + + return { + // filter out columns which are not dimensions + columns: table.columns.filter((col) => formattedColumns[col.id]), + rows: table.rows, + formattedColumns, + }; +}; diff --git a/src/plugins/vis_type_table/public/utils/export_as_csv.ts b/src/plugins/vis_type_table/public/utils/export_as_csv.ts deleted file mode 100644 index 4371a20cfa0da..0000000000000 --- a/src/plugins/vis_type_table/public/utils/export_as_csv.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -import { isObject } from 'lodash'; -// @ts-ignore -import { saveAs } from '@elastic/filesaver'; - -import { CoreStart } from 'kibana/public'; -import { DatatableRow } from 'src/plugins/expressions'; -import { CSV_SEPARATOR_SETTING, CSV_QUOTE_VALUES_SETTING } from '../../../share/public'; -import { FormattedColumn } from '../types'; -import { Table } from '../table_vis_response_handler'; - -const nonAlphaNumRE = /[^a-zA-Z0-9]/; -const allDoubleQuoteRE = /"/g; - -interface ToCsvData { - filename?: string; - cols: FormattedColumn[]; - rows: DatatableRow[]; - table: Table; - uiSettings: CoreStart['uiSettings']; -} - -const toCsv = (formatted: boolean, { cols, rows, table, uiSettings }: ToCsvData) => { - const separator = uiSettings.get(CSV_SEPARATOR_SETTING); - const quoteValues = uiSettings.get(CSV_QUOTE_VALUES_SETTING); - - function escape(val: unknown) { - if (!formatted && isObject(val)) val = val.valueOf(); - val = String(val); - if (quoteValues && nonAlphaNumRE.test(val as string)) { - val = '"' + (val as string).replace(allDoubleQuoteRE, '""') + '"'; - } - return val as string; - } - - const csvRows: string[][] = []; - - for (const row of rows) { - const rowArray: string[] = []; - for (const col of cols) { - const value = row[col.id]; - const formattedValue = formatted ? escape(col.formatter.convert(value)) : escape(value); - rowArray.push(formattedValue); - } - csvRows.push(rowArray); - } - - // add headers to the rows - csvRows.unshift(cols.map(({ title }) => escape(title))); - - return csvRows.map((row) => row.join(separator) + '\r\n').join(''); -}; - -export const exportAsCsv = (formatted: boolean, data: ToCsvData) => { - const csv = new Blob([toCsv(formatted, data)], { type: 'text/plain;charset=utf-8' }); - saveAs(csv, `${data.filename || 'unsaved'}.csv`); -}; diff --git a/src/plugins/vis_type_table/public/utils/index.ts b/src/plugins/vis_type_table/public/utils/index.ts index 6a6dda0d12fa3..8731b52a7ba30 100644 --- a/src/plugins/vis_type_table/public/utils/index.ts +++ b/src/plugins/vis_type_table/public/utils/index.ts @@ -7,4 +7,4 @@ */ export * from './use'; -export * from './export_as_csv'; +export * from './table_vis_response_handler'; diff --git a/src/plugins/vis_type_table/public/utils/table_vis_response_handler.ts b/src/plugins/vis_type_table/public/utils/table_vis_response_handler.ts new file mode 100644 index 0000000000000..0a2b8d8180854 --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/table_vis_response_handler.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { Datatable } from 'src/plugins/expressions'; +import { getFormatService } from '../services'; +import { TableVisData, TableGroup, TableVisConfig, TableContext } from '../types'; +import { addPercentageColumn } from './add_percentage_column'; +import { createFormattedTable } from './create_formatted_table'; + +/** + * Converts datatable input from response into appropriate format for consuming renderer + */ +export function tableVisResponseHandler(input: Datatable, visConfig: TableVisConfig): TableVisData { + const tables: TableGroup[] = []; + let table: TableContext | undefined; + let direction: TableVisData['direction']; + + const split = visConfig.dimensions.splitColumn || visConfig.dimensions.splitRow; + + if (split) { + direction = visConfig.dimensions.splitRow ? 'row' : 'column'; + const splitColumnIndex = split[0].accessor; + const splitColumnFormatter = getFormatService().deserialize(split[0].format); + const splitColumn = input.columns[splitColumnIndex]; + const columns = input.columns.filter((c, idx) => idx !== splitColumnIndex); + const splitMap: { [key: string]: number } = {}; + let splitIndex = 0; + + input.rows.forEach((row) => { + const splitValue: string | number = row[splitColumn.id]; + + if (!splitMap.hasOwnProperty(splitValue)) { + splitMap[splitValue] = splitIndex++; + const tableGroup: TableGroup = { + title: `${splitColumnFormatter.convert(splitValue)}: ${splitColumn.name}`, + table: { + columns, + rows: [], + formattedColumns: {}, + }, + }; + + tables.push(tableGroup); + } + + const tableIndex = splitMap[splitValue]; + tables[tableIndex].table.rows.push(row); + }); + + tables.forEach((tg) => { + tg.table = createFormattedTable({ ...tg.table, columns: input.columns }, visConfig); + + if (visConfig.percentageCol) { + tg.table = addPercentageColumn(tg.table, visConfig.percentageCol); + } + }); + } else { + table = createFormattedTable(input, visConfig); + + if (visConfig.percentageCol) { + table = addPercentageColumn(table, visConfig.percentageCol); + } + } + + return { + direction, + table, + tables, + }; +} diff --git a/src/plugins/vis_type_table/public/utils/use/index.ts b/src/plugins/vis_type_table/public/utils/use/index.ts index 08daf7f28c0e8..9fcc791561046 100644 --- a/src/plugins/vis_type_table/public/utils/use/index.ts +++ b/src/plugins/vis_type_table/public/utils/use/index.ts @@ -6,6 +6,5 @@ * Public License, v 1. */ -export * from './use_formatted_columns'; export * from './use_pagination'; export * from './use_ui_state'; diff --git a/src/plugins/vis_type_table/public/utils/use/use_formatted_columns.ts b/src/plugins/vis_type_table/public/utils/use/use_formatted_columns.ts deleted file mode 100644 index 3a733e7a9a4dc..0000000000000 --- a/src/plugins/vis_type_table/public/utils/use/use_formatted_columns.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -import { useMemo } from 'react'; -import { chain, findIndex } from 'lodash'; - -import { AggTypes } from '../../../common'; -import { Table } from '../../table_vis_response_handler'; -import { FormattedColumn, TableVisConfig } from '../../types'; -import { getFormatService } from '../../services'; -import { addPercentageColumn } from '../add_percentage_column'; - -export const useFormattedColumnsAndRows = (table: Table, visConfig: TableVisConfig) => { - const { formattedColumns: columns, formattedRows: rows } = useMemo(() => { - const { buckets, metrics } = visConfig.dimensions; - let formattedRows = table.rows; - - let formattedColumns = table.columns - .map((col, i) => { - const isBucket = buckets.find(({ accessor }) => accessor === i); - const dimension = isBucket || metrics.find(({ accessor }) => accessor === i); - - if (!dimension) return undefined; - - const formatter = getFormatService().deserialize(dimension.format); - const formattedColumn: FormattedColumn = { - id: col.id, - title: col.name, - formatter, - filterable: !!isBucket, - }; - - const isDate = dimension.format.id === 'date' || dimension.format.params?.id === 'date'; - const allowsNumericalAggregations = formatter.allowsNumericalAggregations; - - if (allowsNumericalAggregations || isDate || visConfig.totalFunc === AggTypes.COUNT) { - const sumOfColumnValues = table.rows.reduce((prev, curr) => { - // some metrics return undefined for some of the values - // derivative is an example of this as it returns undefined in the first row - if (curr[col.id] === undefined) return prev; - return prev + (curr[col.id] as number); - }, 0); - - formattedColumn.sumTotal = sumOfColumnValues; - - switch (visConfig.totalFunc) { - case AggTypes.SUM: { - if (!isDate) { - formattedColumn.formattedTotal = formatter.convert(sumOfColumnValues); - formattedColumn.total = sumOfColumnValues; - } - break; - } - case AggTypes.AVG: { - if (!isDate) { - const total = sumOfColumnValues / table.rows.length; - formattedColumn.formattedTotal = formatter.convert(total); - formattedColumn.total = total; - } - break; - } - case AggTypes.MIN: { - const total = chain(table.rows).map(col.id).min().value() as number; - formattedColumn.formattedTotal = formatter.convert(total); - formattedColumn.total = total; - break; - } - case AggTypes.MAX: { - const total = chain(table.rows).map(col.id).max().value() as number; - formattedColumn.formattedTotal = formatter.convert(total); - formattedColumn.total = total; - break; - } - case AggTypes.COUNT: { - const total = table.rows.length; - formattedColumn.formattedTotal = total; - formattedColumn.total = total; - break; - } - default: - break; - } - } - - return formattedColumn; - }) - .filter((column): column is FormattedColumn => !!column); - - if (visConfig.percentageCol) { - const insertAtIndex = findIndex(formattedColumns, { title: visConfig.percentageCol }); - - // column to show percentage for was removed - if (insertAtIndex < 0) return { formattedColumns, formattedRows }; - - const { cols, rows: rowsWithPercentage } = addPercentageColumn( - formattedColumns, - visConfig.percentageCol, - table.rows, - insertAtIndex - ); - - formattedRows = rowsWithPercentage; - formattedColumns = cols; - } - - return { formattedColumns, formattedRows }; - }, [table, visConfig.dimensions, visConfig.percentageCol, visConfig.totalFunc]); - - return { columns, rows }; -}; diff --git a/test/tsconfig.json b/test/tsconfig.json index 1dc58f7b25c24..c3acf94f8c267 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -26,6 +26,7 @@ { "path": "../src/plugins/expressions/tsconfig.json" }, { "path": "../src/plugins/home/tsconfig.json" }, { "path": "../src/plugins/inspector/tsconfig.json" }, + { "path": "../src/plugins/kibana_overview/tsconfig.json" }, { "path": "../src/plugins/kibana_react/tsconfig.json" }, { "path": "../src/plugins/kibana_usage_collection/tsconfig.json" }, { "path": "../src/plugins/kibana_utils/tsconfig.json" }, diff --git a/tsconfig.json b/tsconfig.json index 21760919c89e9..f6e0fbc8d9e97 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,7 @@ "src/plugins/input_control_vis/**/*", "src/plugins/inspector/**/*", "src/plugins/kibana_legacy/**/*", + "src/plugins/kibana_overview/**/*", "src/plugins/kibana_react/**/*", "src/plugins/kibana_usage_collection/**/*", "src/plugins/kibana_utils/**/*", @@ -84,6 +85,7 @@ { "path": "./src/plugins/home/tsconfig.json" }, { "path": "./src/plugins/inspector/tsconfig.json" }, { "path": "./src/plugins/kibana_legacy/tsconfig.json" }, + { "path": "./src/plugins/kibana_overview/tsconfig.json" }, { "path": "./src/plugins/kibana_react/tsconfig.json" }, { "path": "./src/plugins/kibana_usage_collection/tsconfig.json" }, { "path": "./src/plugins/kibana_utils/tsconfig.json" }, diff --git a/tsconfig.refs.json b/tsconfig.refs.json index 1d08e764709ca..17b1fc5dc1fe9 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -17,6 +17,7 @@ { "path": "./src/plugins/home/tsconfig.json" }, { "path": "./src/plugins/inspector/tsconfig.json" }, { "path": "./src/plugins/kibana_legacy/tsconfig.json" }, + { "path": "./src/plugins/kibana_overview/tsconfig.json" }, { "path": "./src/plugins/kibana_react/tsconfig.json" }, { "path": "./src/plugins/kibana_usage_collection/tsconfig.json" }, { "path": "./src/plugins/kibana_utils/tsconfig.json" }, diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 9472cbf400a6a..1eb94af4dddf8 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -70,12 +70,14 @@ Table of Contents - [`params`](#params-6) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice) - [`subActionParams (getFields)`](#subactionparams-getfields) + - [`subActionParams (getIncident)`](#subactionparams-getincident) + - [`subActionParams (getChoices)`](#subactionparams-getchoices) - [Jira](#jira) - [`config`](#config-7) - [`secrets`](#secrets-7) - [`params`](#params-7) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-1) - - [`subActionParams (getIncident)`](#subactionparams-getincident) + - [`subActionParams (getIncident)`](#subactionparams-getincident-1) - [`subActionParams (issueTypes)`](#subactionparams-issuetypes) - [`subActionParams (fieldsByIssueType)`](#subactionparams-fieldsbyissuetype) - [`subActionParams (issues)`](#subactionparams-issues) @@ -347,17 +349,18 @@ const result = await actionsClient.execute({ Kibana ships with a set of built-in action types: -| Type | Id | Description | -| ------------------------------- | ------------- | ------------------------------------------------------------------ | -| [Server log](#server-log) | `.server-log` | Logs messages to the Kibana log using Kibana's logger | -| [Email](#email) | `.email` | Sends an email using SMTP | -| [Slack](#slack) | `.slack` | Posts a message to a slack channel | -| [Index](#index) | `.index` | Indexes document(s) into Elasticsearch | -| [Webhook](#webhook) | `.webhook` | Send a payload to a web service using HTTP POST or PUT | -| [PagerDuty](#pagerduty) | `.pagerduty` | Trigger, resolve, or acknowlege an incident to a PagerDuty service | -| [ServiceNow](#servicenow) | `.servicenow` | Create or update an incident to a ServiceNow instance | -| [Jira](#jira) | `.jira` | Create or update an issue to a Jira instance | -| [IBM Resilient](#ibm-resilient) | `.resilient` | Create or update an incident to a IBM Resilient instance | +| Type | Id | Description | +| ------------------------------- | ----------------- | ------------------------------------------------------------------ | +| [Server log](#server-log) | `.server-log` | Logs messages to the Kibana log using Kibana's logger | +| [Email](#email) | `.email` | Sends an email using SMTP | +| [Slack](#slack) | `.slack` | Posts a message to a slack channel | +| [Index](#index) | `.index` | Indexes document(s) into Elasticsearch | +| [Webhook](#webhook) | `.webhook` | Send a payload to a web service using HTTP POST or PUT | +| [PagerDuty](#pagerduty) | `.pagerduty` | Trigger, resolve, or acknowlege an incident to a PagerDuty service | +| [ServiceNow ITSM](#servicenow) | `.servicenow` | Create or update an incident to a ServiceNow ITSM instance | +| [ServiceNow SIR](#servicenow) | `.servicenow-sir` | Create or update an incident to a ServiceNow SIR instance | +| [Jira](#jira) | `.jira` | Create or update an issue to a Jira instance | +| [IBM Resilient](#ibm-resilient) | `.resilient` | Create or update an incident to a IBM Resilient instance | --- @@ -549,9 +552,11 @@ For more details see [PagerDuty v2 event parameters](https://v2.developer.pagerd ## ServiceNow -ID: `.servicenow` +ServiceNow ITSM ID: `.servicenow` -The ServiceNow action uses the [V2 Table API](https://developer.servicenow.com/app.do#!/rest_api_doc?v=orlando&id=c_TableAPI) to create and update ServiceNow incidents. +ServiceNow SIR ID: `.servicenow-sir` + +The ServiceNow actions use the [V2 Table API](https://developer.servicenow.com/app.do#!/rest_api_doc?v=orlando&id=c_TableAPI) to create and update ServiceNow incidents. Both action types use the same `config`, `secrets`, and `params` schema. ### `config` @@ -568,10 +573,10 @@ The ServiceNow action uses the [V2 Table API](https://developer.servicenow.com/a ### `params` -| Property | Description | Type | -| --------------- | --------------------------------------------------------------------- | ------ | -| subAction | The sub action to perform. It can be `getFields`, and `pushToService` | string | -| subActionParams | The parameters of the sub action | object | +| Property | Description | Type | +| --------------- | -------------------------------------------------------------------------------------------------- | ------ | +| subAction | The sub action to perform. It can be `pushToService`, `getFields`, `getIncident`, and `getChoices` | string | +| subActionParams | The parameters of the sub action | object | #### `subActionParams (pushToService)` @@ -595,6 +600,19 @@ The following table describes the properties of the `incident` object. No parameters for `getFields` sub-action. Provide an empty object `{}`. +#### `subActionParams (getIncident)` + +| Property | Description | Type | +| ---------- | ------------------------------------- | ------ | +| externalId | The id of the incident in ServiceNow. | string | + + +#### `subActionParams (getChoices)` + +| Property | Description | Type | +| -------- | ------------------------------------------------------------ | -------- | +| fields | An array of fields. Example: `[priority, category, impact]`. | string[] | + --- ## Jira diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.ts b/x-pack/plugins/actions/server/builtin_action_types/index.ts index 3a01b875ec4a0..21161ff8ad0dd 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -14,7 +14,7 @@ import { getActionType as getPagerDutyActionType } from './pagerduty'; import { getActionType as getServerLogActionType } from './server_log'; import { getActionType as getSlackActionType } from './slack'; import { getActionType as getWebhookActionType } from './webhook'; -import { getActionType as getServiceNowActionType } from './servicenow'; +import { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow'; import { getActionType as getJiraActionType } from './jira'; import { getActionType as getResilientActionType } from './resilient'; import { getActionType as getTeamsActionType } from './teams'; @@ -38,7 +38,8 @@ export { } from './webhook'; export { ActionParamsType as ServiceNowActionParams, - ActionTypeId as ServiceNowActionTypeId, + ServiceNowITSMActionTypeId, + ServiceNowSIRActionTypeId, } from './servicenow'; export { ActionParamsType as JiraActionParams, ActionTypeId as JiraActionTypeId } from './jira'; export { @@ -66,7 +67,8 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getServerLogActionType({ logger })); actionTypeRegistry.register(getSlackActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities })); - actionTypeRegistry.register(getServiceNowActionType({ logger, configurationUtilities })); + actionTypeRegistry.register(getServiceNowITSMActionType({ logger, configurationUtilities })); + actionTypeRegistry.register(getServiceNowSIRActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getJiraActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getResilientActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getTeamsActionType({ logger, configurationUtilities })); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts index 772cd16cc4d51..ef5de9fc487bc 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -5,7 +5,7 @@ */ import { Logger } from '../../../../../../src/core/server'; -import { externalServiceMock, apiParams, serviceNowCommonFields } from './mocks'; +import { externalServiceMock, apiParams, serviceNowCommonFields, serviceNowChoices } from './mocks'; import { ExternalService } from './types'; import { api } from './api'; let mockedLogger: jest.Mocked; @@ -235,4 +235,14 @@ describe('api', () => { expect(res).toEqual(serviceNowCommonFields); }); }); + + describe('getChoices', () => { + test('it returns the fields correctly', async () => { + const res = await api.getChoices({ + externalService, + params: { fields: ['priority'] }, + }); + expect(res).toEqual(serviceNowChoices); + }); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts index 9981a8431a736..7f5747277a4e9 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts @@ -5,6 +5,8 @@ */ import { ExternalServiceApi, + GetChoicesHandlerArgs, + GetChoicesResponse, GetCommonFieldsHandlerArgs, GetCommonFieldsResponse, GetIncidentApiHandlerArgs, @@ -71,7 +73,16 @@ const getFieldsHandler = async ({ return res; }; +const getChoicesHandler = async ({ + externalService, + params, +}: GetChoicesHandlerArgs): Promise => { + const res = await externalService.getChoices(params.fields); + return res; +}; + export const api: ExternalServiceApi = { + getChoices: getChoicesHandler, getFields: getFieldsHandler, getIncident: getIncidentHandler, handshake: handshakeHandler, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index 107d86f111deb..fd4991e5f7e98 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -11,7 +11,8 @@ import { validate } from './validators'; import { ExternalIncidentServiceConfiguration, ExternalIncidentServiceSecretConfiguration, - ExecutorParamsSchema, + ExecutorParamsSchemaITSM, + ExecutorParamsSchemaSIR, } from './schema'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types'; @@ -27,18 +28,26 @@ import { PushToServiceResponse, ExecutorSubActionCommonFieldsParams, ServiceNowExecutorResultData, + ExecutorSubActionGetChoicesParams, } from './types'; -export type ActionParamsType = TypeOf; +export type ActionParamsType = + | TypeOf + | TypeOf; interface GetActionTypeParams { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; } -export const ActionTypeId = '.servicenow'; +const serviceNowITSMTable = 'incident'; +const serviceNowSIRTable = 'sn_si_incident'; + +export const ServiceNowITSMActionTypeId = '.servicenow'; +export const ServiceNowSIRActionTypeId = '.servicenow-sir'; + // action type definition -export function getActionType( +export function getServiceNowITSMActionType( params: GetActionTypeParams ): ActionType< ServiceNowPublicConfigurationType, @@ -48,9 +57,9 @@ export function getActionType( > { const { logger, configurationUtilities } = params; return { - id: ActionTypeId, + id: ServiceNowITSMActionTypeId, minimumLicenseRequired: 'platinum', - name: i18n.NAME, + name: i18n.SERVICENOW_ITSM, validate: { config: schema.object(ExternalIncidentServiceConfiguration, { validate: curry(validate.config)(configurationUtilities), @@ -58,19 +67,46 @@ export function getActionType( secrets: schema.object(ExternalIncidentServiceSecretConfiguration, { validate: curry(validate.secrets)(configurationUtilities), }), - params: ExecutorParamsSchema, + params: ExecutorParamsSchemaITSM, }, - executor: curry(executor)({ logger, configurationUtilities }), + executor: curry(executor)({ logger, configurationUtilities, table: serviceNowITSMTable }), + }; +} + +export function getServiceNowSIRActionType( + params: GetActionTypeParams +): ActionType< + ServiceNowPublicConfigurationType, + ServiceNowSecretConfigurationType, + ExecutorParams, + PushToServiceResponse | {} +> { + const { logger, configurationUtilities } = params; + return { + id: ServiceNowSIRActionTypeId, + minimumLicenseRequired: 'platinum', + name: i18n.SERVICENOW_SIR, + validate: { + config: schema.object(ExternalIncidentServiceConfiguration, { + validate: curry(validate.config)(configurationUtilities), + }), + secrets: schema.object(ExternalIncidentServiceSecretConfiguration, { + validate: curry(validate.secrets)(configurationUtilities), + }), + params: ExecutorParamsSchemaSIR, + }, + executor: curry(executor)({ logger, configurationUtilities, table: serviceNowSIRTable }), }; } // action executor -const supportedSubActions: string[] = ['getFields', 'pushToService']; +const supportedSubActions: string[] = ['getFields', 'pushToService', 'getChoices', 'getIncident']; async function executor( { logger, configurationUtilities, - }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, + table, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; table: string }, execOptions: ActionTypeExecutorOptions< ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType, @@ -82,6 +118,7 @@ async function executor( let data: ServiceNowExecutorResultData | null = null; const externalService = createExternalService( + table, { config, secrets, @@ -122,5 +159,13 @@ async function executor( }); } + if (subAction === 'getChoices') { + const getChoicesParams = subActionParams as ExecutorSubActionGetChoicesParams; + data = await api.getChoices({ + externalService, + params: getChoicesParams, + }); + } + return { status: 'ok', data: data ?? {}, actionId }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts index 9d9b1e164e7dd..f958cdb73ebfc 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types'; +import { ExternalService, ExecutorSubActionPushParams } from './types'; export const serviceNowCommonFields = [ { @@ -33,8 +33,43 @@ export const serviceNowCommonFields = [ element: 'sys_updated_by', }, ]; + +export const serviceNowChoices = [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + element: 'priority', + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + element: 'priority', + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + element: 'priority', + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + element: 'priority', + }, + { + dependent_value: '', + label: '5 - Planning', + value: '5', + element: 'priority', + }, +]; + const createMock = (): jest.Mocked => { const service = { + getChoices: jest.fn().mockImplementation(() => Promise.resolve(serviceNowChoices)), getFields: jest.fn().mockImplementation(() => Promise.resolve(serviceNowCommonFields)), getIncident: jest.fn().mockImplementation(() => Promise.resolve({ @@ -89,8 +124,6 @@ const executorParams: ExecutorSubActionPushParams = { ], }; -const apiParams: PushToServiceApiParams = { - ...executorParams, -}; +const apiParams = executorParams; export { externalServiceMock, executorParams, apiParams }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index 1c05fa93f2362..5c7de935223a8 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -28,25 +28,48 @@ export const ExecutorSubActionSchema = schema.oneOf([ schema.literal('getIncident'), schema.literal('pushToService'), schema.literal('handshake'), + schema.literal('getChoices'), ]); -export const ExecutorSubActionPushParamsSchema = schema.object({ +const CommentsSchema = schema.nullable( + schema.arrayOf( + schema.object({ + comment: schema.string(), + commentId: schema.string(), + }) + ) +); + +const CommonAttributes = { + short_description: schema.string(), + description: schema.nullable(schema.string()), + externalId: schema.nullable(schema.string()), +}; + +// Schema for ServiceNow Incident Management (ITSM) +export const ExecutorSubActionPushParamsSchemaITSM = schema.object({ incident: schema.object({ - short_description: schema.string(), - description: schema.nullable(schema.string()), - externalId: schema.nullable(schema.string()), + ...CommonAttributes, severity: schema.nullable(schema.string()), urgency: schema.nullable(schema.string()), impact: schema.nullable(schema.string()), }), - comments: schema.nullable( - schema.arrayOf( - schema.object({ - comment: schema.string(), - commentId: schema.string(), - }) - ) - ), + comments: CommentsSchema, +}); + +// Schema for ServiceNow Security Incident Response (SIR) +export const ExecutorSubActionPushParamsSchemaSIR = schema.object({ + incident: schema.object({ + ...CommonAttributes, + category: schema.nullable(schema.string()), + dest_ip: schema.nullable(schema.string()), + malware_hash: schema.nullable(schema.string()), + malware_url: schema.nullable(schema.string()), + priority: schema.nullable(schema.string()), + source_ip: schema.nullable(schema.string()), + subcategory: schema.nullable(schema.string()), + }), + comments: CommentsSchema, }); export const ExecutorSubActionGetIncidentParamsSchema = schema.object({ @@ -56,8 +79,36 @@ export const ExecutorSubActionGetIncidentParamsSchema = schema.object({ // Reserved for future implementation export const ExecutorSubActionHandshakeParamsSchema = schema.object({}); export const ExecutorSubActionCommonFieldsParamsSchema = schema.object({}); +export const ExecutorSubActionGetChoicesParamsSchema = schema.object({ + fields: schema.arrayOf(schema.string()), +}); + +// Executor parameters for ServiceNow Incident Management (ITSM) +export const ExecutorParamsSchemaITSM = schema.oneOf([ + schema.object({ + subAction: schema.literal('getFields'), + subActionParams: ExecutorSubActionCommonFieldsParamsSchema, + }), + schema.object({ + subAction: schema.literal('getIncident'), + subActionParams: ExecutorSubActionGetIncidentParamsSchema, + }), + schema.object({ + subAction: schema.literal('handshake'), + subActionParams: ExecutorSubActionHandshakeParamsSchema, + }), + schema.object({ + subAction: schema.literal('pushToService'), + subActionParams: ExecutorSubActionPushParamsSchemaITSM, + }), + schema.object({ + subAction: schema.literal('getChoices'), + subActionParams: ExecutorSubActionGetChoicesParamsSchema, + }), +]); -export const ExecutorParamsSchema = schema.oneOf([ +// Executor parameters for ServiceNow Security Incident Response (SIR) +export const ExecutorParamsSchemaSIR = schema.oneOf([ schema.object({ subAction: schema.literal('getFields'), subActionParams: ExecutorSubActionCommonFieldsParamsSchema, @@ -72,6 +123,10 @@ export const ExecutorParamsSchema = schema.oneOf([ }), schema.object({ subAction: schema.literal('pushToService'), - subActionParams: ExecutorSubActionPushParamsSchema, + subActionParams: ExecutorSubActionPushParamsSchemaSIR, + }), + schema.object({ + subAction: schema.literal('getChoices'), + subActionParams: ExecutorSubActionGetChoicesParamsSchema, }), ]); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts index 4ef0e7da166e6..18f3a2f3ff379 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -12,7 +12,7 @@ import { ExternalService } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsConfigMock } from '../../actions_config.mock'; -import { serviceNowCommonFields } from './mocks'; +import { serviceNowCommonFields, serviceNowChoices } from './mocks'; const logger = loggingSystemMock.create().get() as jest.Mocked; jest.mock('axios'); @@ -29,12 +29,14 @@ axios.create = jest.fn(() => axios); const requestMock = utils.request as jest.Mock; const patchMock = utils.patch as jest.Mock; const configurationUtilities = actionsConfigMock.create(); +const table = 'incident'; describe('ServiceNow service', () => { let service: ExternalService; - beforeAll(() => { + beforeEach(() => { service = createExternalService( + table, { // The trailing slash at the end of the url is intended. // All API calls need to have the trailing slash removed. @@ -54,6 +56,7 @@ describe('ServiceNow service', () => { test('throws without url', () => { expect(() => createExternalService( + table, { config: { apiUrl: null }, secrets: { username: 'admin', password: 'admin' }, @@ -67,6 +70,7 @@ describe('ServiceNow service', () => { test('throws without username', () => { expect(() => createExternalService( + table, { config: { apiUrl: 'test.com' }, secrets: { username: '', password: 'admin' }, @@ -80,6 +84,7 @@ describe('ServiceNow service', () => { test('throws without password', () => { expect(() => createExternalService( + table, { config: { apiUrl: 'test.com' }, secrets: { username: '', password: undefined }, @@ -114,6 +119,30 @@ describe('ServiceNow service', () => { }); }); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + 'sn_si_incident', + { + config: { apiUrl: 'https://dev102283.service-now.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities + ); + + requestMock.mockImplementation(() => ({ + data: { result: { sys_id: '1', number: 'INC01' } }, + })); + + await service.getIncident('1'); + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', + }); + }); + test('it should throw an error', async () => { requestMock.mockImplementation(() => { throw new Error('An error has occurred'); @@ -122,6 +151,17 @@ describe('ServiceNow service', () => { 'Unable to get incident with id 1. Error: An error has occurred' ); }); + + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect(service.getIncident('1')).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); + }); }); describe('createIncident', () => { @@ -161,6 +201,39 @@ describe('ServiceNow service', () => { }); }); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + 'sn_si_incident', + { + config: { apiUrl: 'https://dev102283.service-now.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities + ); + + requestMock.mockImplementation(() => ({ + data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, + })); + + const res = await service.createIncident({ + incident: { short_description: 'title', description: 'desc' }, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident', + method: 'post', + data: { short_description: 'title', description: 'desc' }, + }); + + expect(res.url).toEqual( + 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1' + ); + }); + test('it should throw an error', async () => { requestMock.mockImplementation(() => { throw new Error('An error has occurred'); @@ -174,6 +247,17 @@ describe('ServiceNow service', () => { '[Action][ServiceNow]: Unable to create incident. Error: An error has occurred' ); }); + + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect(service.getIncident('1')).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); + }); }); describe('updateIncident', () => { @@ -214,6 +298,39 @@ describe('ServiceNow service', () => { }); }); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + 'sn_si_incident', + { + config: { apiUrl: 'https://dev102283.service-now.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities + ); + + patchMock.mockImplementation(() => ({ + data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, + })); + + const res = await service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' }, + }); + + expect(patchMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', + data: { short_description: 'title', description: 'desc' }, + }); + + expect(res.url).toEqual( + 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1' + ); + }); + test('it should throw an error', async () => { patchMock.mockImplementation(() => { throw new Error('An error has occurred'); @@ -228,6 +345,7 @@ describe('ServiceNow service', () => { '[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred' ); }); + test('it creates the comment correctly', async () => { patchMock.mockImplementation(() => ({ data: { result: { sys_id: '11', number: 'INC011', sys_updated_on: '2020-03-10 12:24:20' } }, @@ -245,6 +363,17 @@ describe('ServiceNow service', () => { url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=11', }); }); + + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect(service.getIncident('1')).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); + }); }); describe('getFields', () => { @@ -259,9 +388,10 @@ describe('ServiceNow service', () => { logger, configurationUtilities, url: - 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', }); }); + test('it returns common fields correctly', async () => { requestMock.mockImplementation(() => ({ data: { result: serviceNowCommonFields }, @@ -270,6 +400,31 @@ describe('ServiceNow service', () => { expect(res).toEqual(serviceNowCommonFields); }); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + 'sn_si_incident', + { + config: { apiUrl: 'https://dev102283.service-now.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities + ); + + requestMock.mockImplementation(() => ({ + data: { result: serviceNowCommonFields }, + })); + await service.getFields(); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: + 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + }); + }); + test('it should throw an error', async () => { requestMock.mockImplementation(() => { throw new Error('An error has occurred'); @@ -278,5 +433,87 @@ describe('ServiceNow service', () => { '[Action][ServiceNow]: Unable to get fields. Error: An error has occurred' ); }); + + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect(service.getIncident('1')).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); + }); + }); + + describe('getChoices', () => { + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { result: serviceNowChoices }, + })); + await service.getChoices(['priority', 'category']); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: + 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', + }); + }); + + test('it returns common fields correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { result: serviceNowChoices }, + })); + const res = await service.getChoices(['priority']); + expect(res).toEqual(serviceNowChoices); + }); + + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + 'sn_si_incident', + { + config: { apiUrl: 'https://dev102283.service-now.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities + ); + + requestMock.mockImplementation(() => ({ + data: { result: serviceNowChoices }, + })); + + await service.getChoices(['priority', 'category']); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: + 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + await expect(service.getChoices(['priority'])).rejects.toThrow( + '[Action][ServiceNow]: Unable to get choices. Error: An error has occurred' + ); + }); + + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect(service.getIncident('1')).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); + }); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 108fe06bcbcaa..7c7723c98a070 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -15,13 +15,10 @@ import { request, getErrorMessage, addTimeZoneToDate, patch } from '../lib/axios import { ActionsConfigurationUtilities } from '../../actions_config'; const API_VERSION = 'v2'; -const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`; const SYS_DICTIONARY = `api/now/${API_VERSION}/table/sys_dictionary`; -// Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html -const VIEW_INCIDENT_URL = `nav_to.do?uri=incident.do?sys_id=`; - export const createExternalService = ( + table: string, { config, secrets }: ExternalServiceCredentials, logger: Logger, configurationUtilities: ActionsConfigurationUtilities @@ -30,24 +27,36 @@ export const createExternalService = ( const { username, password } = secrets as ServiceNowSecretConfigurationType; if (!url || !username || !password) { - throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); + throw Error(`[Action]${i18n.SERVICENOW}: Wrong configuration.`); } const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; - const incidentUrl = `${urlWithoutTrailingSlash}/${INCIDENT_URL}`; - const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`; + const incidentUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/${table}`; + const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^ORname=${table}^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`; + const choicesUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/sys_choice`; const axiosInstance = axios.create({ auth: { username, password }, }); const getIncidentViewURL = (id: string) => { - return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}${id}`; + // Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html + return `${urlWithoutTrailingSlash}/nav_to.do?uri=${table}.do?sys_id=${id}`; + }; + + const getChoicesURL = (fields: string[]) => { + const elements = fields + .slice(1) + .reduce((acc, field) => `${acc}^ORelement=${field}`, `element=${fields[0]}`); + + return `${choicesUrl}?sysparm_query=name=task^ORname=${table}^${elements}&sysparm_fields=label,value,dependent_value,element`; }; const checkInstance = (res: AxiosResponse) => { if (res.status === 200 && res.data.result == null) { throw new Error( - `There is an issue with your Service Now Instance. Please check ${res.request.connection.servername}` + `There is an issue with your Service Now Instance. Please check ${ + res.request?.connection?.servername ?? '' + }.` ); } }; @@ -64,7 +73,10 @@ export const createExternalService = ( return { ...res.data.result }; } catch (error) { throw new Error( - getErrorMessage(i18n.NAME, `Unable to get incident with id ${id}. Error: ${error.message}`) + getErrorMessage( + i18n.SERVICENOW, + `Unable to get incident with id ${id}. Error: ${error.message}` + ) ); } }; @@ -82,7 +94,10 @@ export const createExternalService = ( return res.data.result.length > 0 ? { ...res.data.result } : undefined; } catch (error) { throw new Error( - getErrorMessage(i18n.NAME, `Unable to find incidents by query. Error: ${error.message}`) + getErrorMessage( + i18n.SERVICENOW, + `Unable to find incidents by query. Error: ${error.message}` + ) ); } }; @@ -106,7 +121,7 @@ export const createExternalService = ( }; } catch (error) { throw new Error( - getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}`) + getErrorMessage(i18n.SERVICENOW, `Unable to create incident. Error: ${error.message}`) ); } }; @@ -130,7 +145,7 @@ export const createExternalService = ( } catch (error) { throw new Error( getErrorMessage( - i18n.NAME, + i18n.SERVICENOW, `Unable to update incident with id ${incidentId}. Error: ${error.message}` ) ); @@ -148,7 +163,26 @@ export const createExternalService = ( checkInstance(res); return res.data.result.length > 0 ? res.data.result : []; } catch (error) { - throw new Error(getErrorMessage(i18n.NAME, `Unable to get fields. Error: ${error.message}`)); + throw new Error( + getErrorMessage(i18n.SERVICENOW, `Unable to get fields. Error: ${error.message}`) + ); + } + }; + + const getChoices = async (fields: string[]) => { + try { + const res = await request({ + axios: axiosInstance, + url: getChoicesURL(fields), + logger, + configurationUtilities, + }); + checkInstance(res); + return res.data.result; + } catch (error) { + throw new Error( + getErrorMessage(i18n.SERVICENOW, `Unable to get choices. Error: ${error.message}`) + ); } }; @@ -158,5 +192,6 @@ export const createExternalService = ( getFields, getIncident, updateIncident, + getChoices, }; }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts index 287fe8cacda79..84fe538e0a63a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts @@ -6,10 +6,18 @@ import { i18n } from '@kbn/i18n'; -export const NAME = i18n.translate('xpack.actions.builtin.servicenowTitle', { +export const SERVICENOW = i18n.translate('xpack.actions.builtin.serviceNowTitle', { defaultMessage: 'ServiceNow', }); +export const SERVICENOW_ITSM = i18n.translate('xpack.actions.builtin.serviceNowITSMTitle', { + defaultMessage: 'ServiceNow ITSM', +}); + +export const SERVICENOW_SIR = i18n.translate('xpack.actions.builtin.serviceNowSIRTitle', { + defaultMessage: 'ServiceNow SIR', +}); + export const ALLOWED_HOSTS_ERROR = (message: string) => i18n.translate('xpack.actions.builtin.configuration.apiAllowedHostsError', { defaultMessage: 'error configuring connector action: {message}', diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 9868f5d1bea06..c74d1fbffd759 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -8,13 +8,16 @@ import { TypeOf } from '@kbn/config-schema'; import { - ExecutorParamsSchema, + ExecutorParamsSchemaITSM, ExecutorSubActionCommonFieldsParamsSchema, ExecutorSubActionGetIncidentParamsSchema, ExecutorSubActionHandshakeParamsSchema, - ExecutorSubActionPushParamsSchema, + ExecutorSubActionPushParamsSchemaITSM, ExternalIncidentServiceConfigurationSchema, ExternalIncidentServiceSecretConfigurationSchema, + ExecutorParamsSchemaSIR, + ExecutorSubActionPushParamsSchemaSIR, + ExecutorSubActionGetChoicesParamsSchema, } from './schema'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { Logger } from '../../../../../../src/core/server'; @@ -30,14 +33,29 @@ export type ExecutorSubActionCommonFieldsParams = TypeOf< typeof ExecutorSubActionCommonFieldsParamsSchema >; -export type ServiceNowExecutorResultData = PushToServiceResponse | GetCommonFieldsResponse; +export type ExecutorSubActionGetChoicesParams = TypeOf< + typeof ExecutorSubActionGetChoicesParamsSchema +>; + +export type ServiceNowExecutorResultData = + | PushToServiceResponse + | GetCommonFieldsResponse + | GetChoicesResponse; export interface CreateCommentRequest { [key: string]: string; } -export type ExecutorParams = TypeOf; -export type ExecutorSubActionPushParams = TypeOf; +export type ExecutorParams = + | TypeOf + | TypeOf; + +export type ExecutorSubActionPushParamsITSM = TypeOf; +export type ExecutorSubActionPushParamsSIR = TypeOf; + +export type ExecutorSubActionPushParams = + | ExecutorSubActionPushParamsITSM + | ExecutorSubActionPushParamsSIR; export interface ExternalServiceCredentials { config: Record; @@ -62,14 +80,17 @@ export interface PushToServiceResponse extends ExternalServiceIncidentResponse { export type ExternalServiceParams = Record; export interface ExternalService { - getFields: () => Promise; + getChoices: (fields: string[]) => Promise; getIncident: (id: string) => Promise; + getFields: () => Promise; createIncident: (params: ExternalServiceParams) => Promise; updateIncident: (params: ExternalServiceParams) => Promise; findIncidents: (params?: Record) => Promise; } export type PushToServiceApiParams = ExecutorSubActionPushParams; +export type PushToServiceApiParamsITSM = ExecutorSubActionPushParamsITSM; +export type PushToServiceApiParamsSIR = ExecutorSubActionPushParamsSIR; export interface ExternalServiceApiHandlerArgs { externalService: ExternalService; @@ -83,7 +104,17 @@ export type ExecutorSubActionHandshakeParams = TypeOf< typeof ExecutorSubActionHandshakeParamsSchema >; -export type Incident = Omit; +export type ServiceNowITSMIncident = Omit< + TypeOf['incident'], + 'externalId' +>; + +export type ServiceNowSIRIncident = Omit< + TypeOf['incident'], + 'externalId' +>; + +export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident; export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs { params: PushToServiceApiParams; @@ -104,13 +135,29 @@ export interface ExternalServiceFields { max_length: string; element: string; } + +export interface ExternalServiceChoices { + value: string; + label: string; + dependent_value: string; + element: string; +} + export type GetCommonFieldsResponse = ExternalServiceFields[]; +export type GetChoicesResponse = ExternalServiceChoices[]; + export interface GetCommonFieldsHandlerArgs { externalService: ExternalService; params: ExecutorSubActionCommonFieldsParams; } +export interface GetChoicesHandlerArgs { + externalService: ExternalService; + params: ExecutorSubActionGetChoicesParams; +} + export interface ExternalServiceApi { + getChoices: (args: GetChoicesHandlerArgs) => Promise; getFields: (args: GetCommonFieldsHandlerArgs) => Promise; handshake: (args: HandshakeApiHandlerArgs) => Promise; pushToService: (args: PushToServiceApiHandlerArgs) => Promise; diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index 4e59dfd099811..b573bcfc10914 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -35,7 +35,8 @@ export type { SlackActionParams, WebhookActionTypeId, WebhookActionParams, - ServiceNowActionTypeId, + ServiceNowITSMActionTypeId, + ServiceNowSIRActionTypeId, ServiceNowActionParams, JiraActionTypeId, JiraActionParams, diff --git a/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.tsx b/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.tsx index 8a1d73c818944..a221f4bfb05a9 100644 --- a/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.tsx +++ b/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.tsx @@ -57,7 +57,9 @@ export function AnomalyDetectionSetupLink() { export function MissingJobsAlert({ environment }: { environment?: string }) { const { data = DEFAULT_DATA, status } = useFetcher( (callApmApi) => - callApmApi({ endpoint: `GET /api/apm/settings/anomaly-detection/jobs` }), + callApmApi({ + endpoint: `GET /api/apm/settings/anomaly-detection/jobs`, + }), [], { preservePreviousData: false, showToastOnError: false } ); diff --git a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx index d7375d14e17cf..3b68eccbd9dc4 100644 --- a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx @@ -13,7 +13,6 @@ import { asInteger } from '../../../../common/utils/formatters'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; import { useFetcher } from '../../../hooks/use_fetcher'; -import { callApmApi } from '../../../services/rest/createCallApmApi'; import { ChartPreview } from '../chart_preview'; import { EnvironmentField, IsAboveField, ServiceField } from '../fields'; import { getAbsoluteTimeRange } from '../helper'; @@ -46,20 +45,23 @@ export function ErrorCountAlertTrigger(props: Props) { const { threshold, windowSize, windowUnit, environment } = alertParams; - const { data } = useFetcher(() => { - if (windowSize && windowUnit) { - return callApmApi({ - endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_count', - params: { - query: { - ...getAbsoluteTimeRange(windowSize, windowUnit), - environment, - serviceName, + const { data } = useFetcher( + (callApmApi) => { + if (windowSize && windowUnit) { + return callApmApi({ + endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_count', + params: { + query: { + ...getAbsoluteTimeRange(windowSize, windowUnit), + environment, + serviceName, + }, }, - }, - }); - } - }, [windowSize, windowUnit, environment, serviceName]); + }); + } + }, + [windowSize, windowUnit, environment, serviceName] + ); const defaults = { threshold: 25, diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx index 7c0a74f2e1b60..4d28cdaec3782 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx @@ -8,7 +8,6 @@ import { i18n } from '@kbn/i18n'; import { map } from 'lodash'; import React from 'react'; import { useParams } from 'react-router-dom'; -import { useFetcher } from '../../../../../observability/public'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { getDurationFormatter } from '../../../../common/utils/formatters'; @@ -16,7 +15,7 @@ import { TimeSeries } from '../../../../typings/timeseries'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; -import { callApmApi } from '../../../services/rest/createCallApmApi'; +import { useFetcher } from '../../../hooks/use_fetcher'; import { getMaxY, getResponseTimeTickFormatter, @@ -88,29 +87,32 @@ export function TransactionDurationAlertTrigger(props: Props) { windowUnit, } = alertParams; - const { data } = useFetcher(() => { - if (windowSize && windowUnit) { - return callApmApi({ - endpoint: 'GET /api/apm/alerts/chart_preview/transaction_duration', - params: { - query: { - ...getAbsoluteTimeRange(windowSize, windowUnit), - aggregationType, - environment, - serviceName, - transactionType: alertParams.transactionType, + const { data } = useFetcher( + (callApmApi) => { + if (windowSize && windowUnit) { + return callApmApi({ + endpoint: 'GET /api/apm/alerts/chart_preview/transaction_duration', + params: { + query: { + ...getAbsoluteTimeRange(windowSize, windowUnit), + aggregationType, + environment, + serviceName, + transactionType: alertParams.transactionType, + }, }, - }, - }); - } - }, [ - aggregationType, - environment, - serviceName, - alertParams.transactionType, - windowSize, - windowUnit, - ]); + }); + } + }, + [ + aggregationType, + environment, + serviceName, + alertParams.transactionType, + windowSize, + windowUnit, + ] + ); const maxY = getMaxY([ { data: data ?? [] } as TimeSeries<{ x: number; y: number | null }>, diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx index e06f39ec10220..58adbb7b172c8 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx @@ -12,7 +12,6 @@ import { useApmServiceContext } from '../../../context/apm_service/use_apm_servi import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; import { useFetcher } from '../../../hooks/use_fetcher'; -import { callApmApi } from '../../../services/rest/createCallApmApi'; import { ChartPreview } from '../chart_preview'; import { EnvironmentField, @@ -54,27 +53,30 @@ export function TransactionErrorRateAlertTrigger(props: Props) { const thresholdAsPercent = (threshold ?? 0) / 100; - const { data } = useFetcher(() => { - if (windowSize && windowUnit) { - return callApmApi({ - endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_rate', - params: { - query: { - ...getAbsoluteTimeRange(windowSize, windowUnit), - environment, - serviceName, - transactionType: alertParams.transactionType, + const { data } = useFetcher( + (callApmApi) => { + if (windowSize && windowUnit) { + return callApmApi({ + endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_rate', + params: { + query: { + ...getAbsoluteTimeRange(windowSize, windowUnit), + environment, + serviceName, + transactionType: alertParams.transactionType, + }, }, - }, - }); - } - }, [ - alertParams.transactionType, - environment, - serviceName, - windowSize, - windowUnit, - ]); + }); + } + }, + [ + alertParams.transactionType, + environment, + serviceName, + windowSize, + windowUnit, + ] + ); if (serviceName && !transactionTypes.length) { return null; diff --git a/x-pack/plugins/apm/public/components/app/Correlations/ErrorCorrelations.tsx b/x-pack/plugins/apm/public/components/app/Correlations/ErrorCorrelations.tsx index 25973b9bda388..891a2081804cc 100644 --- a/x-pack/plugins/apm/public/components/app/Correlations/ErrorCorrelations.tsx +++ b/x-pack/plugins/apm/public/components/app/Correlations/ErrorCorrelations.tsx @@ -25,10 +25,7 @@ import { } from '@elastic/eui'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; -import { - APIReturnType, - callApmApi, -} from '../../../services/rest/createCallApmApi'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; import { px } from '../../../style/variables'; import { SignificantTermsTable } from './SignificantTermsTable'; import { ChartContainer } from '../../shared/charts/chart_container'; @@ -65,32 +62,35 @@ export function ErrorCorrelations() { const { urlParams, uiFilters } = useUrlParams(); const { transactionName, transactionType, start, end } = urlParams; - const { data, status } = useFetcher(() => { - if (start && end) { - return callApmApi({ - endpoint: 'GET /api/apm/correlations/failed_transactions', - params: { - query: { - serviceName, - transactionName, - transactionType, - start, - end, - uiFilters: JSON.stringify(uiFilters), - fieldNames: fieldNames.map((field) => field.label).join(','), + const { data, status } = useFetcher( + (callApmApi) => { + if (start && end) { + return callApmApi({ + endpoint: 'GET /api/apm/correlations/failed_transactions', + params: { + query: { + serviceName, + transactionName, + transactionType, + start, + end, + uiFilters: JSON.stringify(uiFilters), + fieldNames: fieldNames.map((field) => field.label).join(','), + }, }, - }, - }); - } - }, [ - serviceName, - start, - end, - transactionName, - transactionType, - uiFilters, - fieldNames, - ]); + }); + } + }, + [ + serviceName, + start, + end, + transactionName, + transactionType, + uiFilters, + fieldNames, + ] + ); return ( <> diff --git a/x-pack/plugins/apm/public/components/app/Correlations/LatencyCorrelations.tsx b/x-pack/plugins/apm/public/components/app/Correlations/LatencyCorrelations.tsx index 438303110fbc4..493c6e04ffbb1 100644 --- a/x-pack/plugins/apm/public/components/app/Correlations/LatencyCorrelations.tsx +++ b/x-pack/plugins/apm/public/components/app/Correlations/LatencyCorrelations.tsx @@ -26,10 +26,7 @@ import { import { getDurationFormatter } from '../../../../common/utils/formatters'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; -import { - APIReturnType, - callApmApi, -} from '../../../services/rest/createCallApmApi'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; import { SignificantTermsTable } from './SignificantTermsTable'; import { ChartContainer } from '../../shared/charts/chart_container'; @@ -65,34 +62,37 @@ export function LatencyCorrelations() { const { urlParams, uiFilters } = useUrlParams(); const { transactionName, transactionType, start, end } = urlParams; - const { data, status } = useFetcher(() => { - if (start && end) { - return callApmApi({ - endpoint: 'GET /api/apm/correlations/slow_transactions', - params: { - query: { - serviceName, - transactionName, - transactionType, - start, - end, - uiFilters: JSON.stringify(uiFilters), - durationPercentile, - fieldNames: fieldNames.map((field) => field.label).join(','), + const { data, status } = useFetcher( + (callApmApi) => { + if (start && end) { + return callApmApi({ + endpoint: 'GET /api/apm/correlations/slow_transactions', + params: { + query: { + serviceName, + transactionName, + transactionType, + start, + end, + uiFilters: JSON.stringify(uiFilters), + durationPercentile, + fieldNames: fieldNames.map((field) => field.label).join(','), + }, }, - }, - }); - } - }, [ - serviceName, - start, - end, - transactionName, - transactionType, - uiFilters, - durationPercentile, - fieldNames, - ]); + }); + } + }, + [ + serviceName, + start, + end, + transactionName, + transactionType, + uiFilters, + durationPercentile, + fieldNames, + ] + ); return ( <> diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx index 95ebd5d4036de..9501474e3be65 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx @@ -23,7 +23,6 @@ import { useTrackPageview } from '../../../../../observability/public'; import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n'; import { useFetcher } from '../../../hooks/use_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { callApmApi } from '../../../services/rest/createCallApmApi'; import { fontFamilyCode, fontSizes, px, units } from '../../../style/variables'; import { ApmHeader } from '../../shared/ApmHeader'; import { SearchBar } from '../../shared/search_bar'; @@ -70,24 +69,27 @@ export function ErrorGroupDetails({ location, match }: ErrorGroupDetailsProps) { const { urlParams, uiFilters } = useUrlParams(); const { start, end } = urlParams; - const { data: errorGroupData } = useFetcher(() => { - if (start && end) { - return callApmApi({ - endpoint: 'GET /api/apm/services/{serviceName}/errors/{groupId}', - params: { - path: { - serviceName, - groupId, + const { data: errorGroupData } = useFetcher( + (callApmApi) => { + if (start && end) { + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/errors/{groupId}', + params: { + path: { + serviceName, + groupId, + }, + query: { + start, + end, + uiFilters: JSON.stringify(uiFilters), + }, }, - query: { - start, - end, - uiFilters: JSON.stringify(uiFilters), - }, - }, - }); - } - }, [serviceName, start, end, groupId, uiFilters]); + }); + } + }, + [serviceName, start, end, groupId, uiFilters] + ); const { errorDistributionData } = useErrorGroupDistributionFetcher({ serviceName, diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx index 71cb8e0e01602..af8c667df6ca2 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx @@ -18,7 +18,6 @@ import { useTrackPageview } from '../../../../../observability/public'; import { Projection } from '../../../../common/projections'; import { useFetcher } from '../../../hooks/use_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { callApmApi } from '../../../services/rest/createCallApmApi'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { SearchBar } from '../../shared/search_bar'; import { ErrorDistribution } from '../ErrorGroupDetails/Distribution'; @@ -37,27 +36,30 @@ function ErrorGroupOverview({ serviceName }: ErrorGroupOverviewProps) { groupId: undefined, }); - const { data: errorGroupListData } = useFetcher(() => { - const normalizedSortDirection = sortDirection === 'asc' ? 'asc' : 'desc'; + const { data: errorGroupListData } = useFetcher( + (callApmApi) => { + const normalizedSortDirection = sortDirection === 'asc' ? 'asc' : 'desc'; - if (start && end) { - return callApmApi({ - endpoint: 'GET /api/apm/services/{serviceName}/errors', - params: { - path: { - serviceName, + if (start && end) { + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/errors', + params: { + path: { + serviceName, + }, + query: { + start, + end, + sortField, + sortDirection: normalizedSortDirection, + uiFilters: JSON.stringify(uiFilters), + }, }, - query: { - start, - end, - sortField, - sortDirection: normalizedSortDirection, - uiFilters: JSON.stringify(uiFilters), - }, - }, - }); - } - }, [serviceName, start, end, sortField, sortDirection, uiFilters]); + }); + } + }, + [serviceName, start, end, sortField, sortDirection, uiFilters] + ); useTrackPageview({ app: 'apm', diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts index 7ce9d3f25354c..0468fbabcdb41 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts @@ -21,6 +21,7 @@ export const fetchUxOverviewDate = async ({ }: FetchDataParams): Promise => { const data = await callApmApi({ endpoint: 'GET /api/apm/rum-client/web-core-vitals', + signal: null, params: { query: { start: new Date(absoluteTime.start).toISOString(), @@ -41,6 +42,7 @@ export async function hasRumData({ }: HasDataParams): Promise { return await callApmApi({ endpoint: 'GET /api/apm/observability_overview/has_rum_data', + signal: null, params: { query: { start: new Date(absoluteTime.start).toISOString(), diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx index 6f8d058903183..463c9f36fb2fe 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -17,7 +17,6 @@ import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { useLicenseContext } from '../../../context/license/use_license_context'; import { useTheme } from '../../../hooks/use_theme'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { callApmApi } from '../../../services/rest/createCallApmApi'; import { DatePicker } from '../../shared/DatePicker'; import { LicensePrompt } from '../../shared/LicensePrompt'; import { Controls } from './Controls'; @@ -86,28 +85,31 @@ export function ServiceMap({ const license = useLicenseContext(); const { urlParams } = useUrlParams(); - const { data = { elements: [] }, status, error } = useFetcher(() => { - // When we don't have a license or a valid license, don't make the request. - if (!license || !isActivePlatinumLicense(license)) { - return; - } - - const { start, end, environment } = urlParams; - if (start && end) { - return callApmApi({ - isCachable: false, - endpoint: 'GET /api/apm/service-map', - params: { - query: { - start, - end, - environment, - serviceName, + const { data = { elements: [] }, status, error } = useFetcher( + (callApmApi) => { + // When we don't have a license or a valid license, don't make the request. + if (!license || !isActivePlatinumLicense(license)) { + return; + } + + const { start, end, environment } = urlParams; + if (start && end) { + return callApmApi({ + isCachable: false, + endpoint: 'GET /api/apm/service-map', + params: { + query: { + start, + end, + environment, + serviceName, + }, }, - }, - }); - } - }, [license, serviceName, urlParams]); + }); + } + }, + [license, serviceName, urlParams] + ); const { ref, height } = useRefDimensions(); diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts index e15a57ff7539e..a9c334f414db2 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts @@ -26,6 +26,7 @@ export async function saveConfig({ try { await callApmApi({ endpoint: 'PUT /api/apm/settings/agent-configuration', + signal: null, params: { query: { overwrite: isEditMode }, body: { diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx index 958aafa8159df..09251efe8b977 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx @@ -73,6 +73,7 @@ async function deleteConfig( try { await callApmApi({ endpoint: 'DELETE /api/apm/settings/agent-configuration', + signal: null, params: { body: { service: { diff --git a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx index 8c10b96c51ce2..29fca54547e7a 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx @@ -74,6 +74,7 @@ async function saveApmIndices({ }) { await callApmApi({ endpoint: 'POST /api/apm/settings/apm-indices/save', + signal: null, params: { body: apmIndices, }, diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/DeleteButton.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/DeleteButton.tsx index ffcb85384642a..9257d5d78b6b3 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/DeleteButton.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/DeleteButton.tsx @@ -48,6 +48,7 @@ async function deleteConfig( try { await callApmApi({ endpoint: 'DELETE /api/apm/settings/custom_links/{id}', + signal: null, params: { path: { id: customLinkId }, }, diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/LinkPreview.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/LinkPreview.tsx index 25fd8f7ad3caf..626aca6218ea3 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/LinkPreview.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/LinkPreview.tsx @@ -31,6 +31,7 @@ interface Props { const fetchTransaction = debounce( async (filters: Filter[], callback: (transaction: Transaction) => void) => { const transaction = await callApmApi({ + signal: null, endpoint: 'GET /api/apm/settings/custom_links/transaction', params: { query: convertFiltersToQuery(filters) }, }); diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/saveCustomLink.ts b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/saveCustomLink.ts index cb1eaf6bca3f0..2d172d652ad81 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/saveCustomLink.ts +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/saveCustomLink.ts @@ -35,6 +35,7 @@ export async function saveCustomLink({ if (id) { await callApmApi({ endpoint: 'PUT /api/apm/settings/custom_links/{id}', + signal: null, params: { path: { id }, body: customLink, @@ -43,6 +44,7 @@ export async function saveCustomLink({ } else { await callApmApi({ endpoint: 'POST /api/apm/settings/custom_links', + signal: null, params: { body: customLink, }, diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/create_jobs.ts b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/create_jobs.ts index 7106a4c48ef70..dc73bf12ff4b8 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/create_jobs.ts +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/create_jobs.ts @@ -28,6 +28,7 @@ export async function createJobs({ try { await callApmApi({ endpoint: 'POST /api/apm/settings/anomaly-detection/jobs', + signal: null, params: { body: { environments }, }, diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/use_anomaly_detection_jobs_fetcher.ts b/x-pack/plugins/apm/public/components/app/service_inventory/use_anomaly_detection_jobs_fetcher.ts index 901841ac4d593..eacb09bde70ac 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/use_anomaly_detection_jobs_fetcher.ts +++ b/x-pack/plugins/apm/public/components/app/service_inventory/use_anomaly_detection_jobs_fetcher.ts @@ -8,7 +8,9 @@ import { useFetcher } from '../../../hooks/use_fetcher'; export function useAnomalyDetectionJobsFetcher() { const { data, status } = useFetcher( (callApmApi) => - callApmApi({ endpoint: `GET /api/apm/settings/anomaly-detection/jobs` }), + callApmApi({ + endpoint: `GET /api/apm/settings/anomaly-detection/jobs`, + }), [], { showToastOnError: false } ); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx index 1bd7310e3251d..e93b29025426d 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -26,7 +26,6 @@ import { import { ServiceDependencyItem } from '../../../../../server/lib/services/get_service_dependencies'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; -import { callApmApi } from '../../../../services/rest/createCallApmApi'; import { px, unit } from '../../../../style/variables'; import { AgentIcon } from '../../../shared/AgentIcon'; import { SparkPlot } from '../../../shared/charts/spark_plot'; @@ -167,26 +166,29 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { }, ]; - const { data = [], status } = useFetcher(() => { - if (!start || !end) { - return; - } + const { data = [], status } = useFetcher( + (callApmApi) => { + if (!start || !end) { + return; + } - return callApmApi({ - endpoint: 'GET /api/apm/services/{serviceName}/dependencies', - params: { - path: { - serviceName, + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/dependencies', + params: { + path: { + serviceName, + }, + query: { + start, + end, + environment: environment || ENVIRONMENT_ALL.value, + numBuckets: 20, + }, }, - query: { - start, - end, - environment: environment || ENVIRONMENT_ALL.value, - numBuckets: 20, - }, - }, - }); - }, [start, end, serviceName, environment]); + }); + }, + [start, end, serviceName, environment] + ); // need top-level sortable fields for the managed table const items = data.map((item) => ({ diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx index d14ef648c22d3..c523728025674 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx @@ -16,7 +16,6 @@ import { asInteger } from '../../../../../common/utils/formatters'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; -import { callApmApi } from '../../../../services/rest/createCallApmApi'; import { px, unit } from '../../../../style/variables'; import { SparkPlot } from '../../../shared/charts/spark_plot'; import { ErrorDetailLink } from '../../../shared/Links/apm/ErrorDetailLink'; @@ -140,50 +139,53 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { }, }, status, - } = useFetcher(() => { - if (!start || !end || !transactionType) { - return; - } + } = useFetcher( + (callApmApi) => { + if (!start || !end || !transactionType) { + return; + } - return callApmApi({ - endpoint: 'GET /api/apm/services/{serviceName}/error_groups', - params: { - path: { serviceName }, - query: { - start, - end, - uiFilters: JSON.stringify(uiFilters), - size: PAGE_SIZE, - numBuckets: 20, - pageIndex: tableOptions.pageIndex, - sortField: tableOptions.sort.field, - sortDirection: tableOptions.sort.direction, - transactionType, - }, - }, - }).then((response) => { - return { - items: response.error_groups, - totalItemCount: response.total_error_groups, - tableOptions: { - pageIndex: tableOptions.pageIndex, - sort: { - field: tableOptions.sort.field, - direction: tableOptions.sort.direction, + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/error_groups', + params: { + path: { serviceName }, + query: { + start, + end, + uiFilters: JSON.stringify(uiFilters), + size: PAGE_SIZE, + numBuckets: 20, + pageIndex: tableOptions.pageIndex, + sortField: tableOptions.sort.field, + sortDirection: tableOptions.sort.direction, + transactionType, }, }, - }; - }); - }, [ - start, - end, - serviceName, - uiFilters, - tableOptions.pageIndex, - tableOptions.sort.field, - tableOptions.sort.direction, - transactionType, - ]); + }).then((response) => { + return { + items: response.error_groups, + totalItemCount: response.total_error_groups, + tableOptions: { + pageIndex: tableOptions.pageIndex, + sort: { + field: tableOptions.sort.field, + direction: tableOptions.sort.direction, + }, + }, + }; + }); + }, + [ + start, + end, + serviceName, + uiFilters, + tableOptions.pageIndex, + tableOptions.sort.field, + tableOptions.sort.direction, + transactionType, + ] + ); const { items, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx index f7c2891bb3e65..a0528b7220cc1 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useFetcher } from '../../../hooks/use_fetcher'; -import { callApmApi } from '../../../services/rest/createCallApmApi'; import { InstancesLatencyDistributionChart } from '../../shared/charts/instances_latency_distribution_chart'; import { ServiceOverviewInstancesTable } from './service_overview_instances_table'; @@ -29,28 +28,31 @@ export function ServiceOverviewInstancesChartAndTable({ uiFilters, } = useUrlParams(); - const { data = [], status } = useFetcher(() => { - if (!start || !end || !transactionType) { - return; - } + const { data = [], status } = useFetcher( + (callApmApi) => { + if (!start || !end || !transactionType) { + return; + } - return callApmApi({ - endpoint: - 'GET /api/apm/services/{serviceName}/service_overview_instances', - params: { - path: { - serviceName, + return callApmApi({ + endpoint: + 'GET /api/apm/services/{serviceName}/service_overview_instances', + params: { + path: { + serviceName, + }, + query: { + start, + end, + transactionType, + uiFilters: JSON.stringify(uiFilters), + numBuckets: 20, + }, }, - query: { - start, - end, - transactionType, - uiFilters: JSON.stringify(uiFilters), - numBuckets: 20, - }, - }, - }); - }, [start, end, serviceName, transactionType, uiFilters]); + }); + }, + [start, end, serviceName, transactionType, uiFilters] + ); return ( <> diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx index b79e011bde488..fae00822bb966 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx @@ -13,7 +13,6 @@ import { useFetcher } from '../../../hooks/use_fetcher'; import { useTheme } from '../../../hooks/use_theme'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import { callApmApi } from '../../../services/rest/createCallApmApi'; import { TimeseriesChart } from '../../shared/charts/timeseries_chart'; export function ServiceOverviewThroughputChart({ @@ -27,24 +26,27 @@ export function ServiceOverviewThroughputChart({ const { transactionType } = useApmServiceContext(); const { start, end } = urlParams; - const { data, status } = useFetcher(() => { - if (serviceName && transactionType && start && end) { - return callApmApi({ - endpoint: 'GET /api/apm/services/{serviceName}/throughput', - params: { - path: { - serviceName, + const { data, status } = useFetcher( + (callApmApi) => { + if (serviceName && transactionType && start && end) { + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/throughput', + params: { + path: { + serviceName, + }, + query: { + start, + end, + transactionType, + uiFilters: JSON.stringify(uiFilters), + }, }, - query: { - start, - end, - transactionType, - uiFilters: JSON.stringify(uiFilters), - }, - }, - }); - } - }, [serviceName, start, end, uiFilters, transactionType]); + }); + } + }, + [serviceName, start, end, uiFilters, transactionType] + ); return ( diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx index c77e80d0176de..069c4466d28a0 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx @@ -23,10 +23,7 @@ import { import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; -import { - APIReturnType, - callApmApi, -} from '../../../../services/rest/createCallApmApi'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { px, unit } from '../../../../style/variables'; import { SparkPlot } from '../../../shared/charts/spark_plot'; import { ImpactBar } from '../../../shared/ImpactBar'; @@ -110,53 +107,56 @@ export function ServiceOverviewTransactionsTable(props: Props) { }, }, status, - } = useFetcher(() => { - if (!start || !end || !latencyAggregationType || !transactionType) { - return; - } + } = useFetcher( + (callApmApi) => { + if (!start || !end || !latencyAggregationType || !transactionType) { + return; + } - return callApmApi({ - endpoint: - 'GET /api/apm/services/{serviceName}/transactions/groups/overview', - params: { - path: { serviceName }, - query: { - start, - end, - uiFilters: JSON.stringify(uiFilters), - size: PAGE_SIZE, - numBuckets: 20, - pageIndex: tableOptions.pageIndex, - sortField: tableOptions.sort.field, - sortDirection: tableOptions.sort.direction, - transactionType, - latencyAggregationType: latencyAggregationType as LatencyAggregationType, - }, - }, - }).then((response) => { - return { - items: response.transactionGroups, - totalItemCount: response.totalTransactionGroups, - tableOptions: { - pageIndex: tableOptions.pageIndex, - sort: { - field: tableOptions.sort.field, - direction: tableOptions.sort.direction, + return callApmApi({ + endpoint: + 'GET /api/apm/services/{serviceName}/transactions/groups/overview', + params: { + path: { serviceName }, + query: { + start, + end, + uiFilters: JSON.stringify(uiFilters), + size: PAGE_SIZE, + numBuckets: 20, + pageIndex: tableOptions.pageIndex, + sortField: tableOptions.sort.field, + sortDirection: tableOptions.sort.direction, + transactionType, + latencyAggregationType: latencyAggregationType as LatencyAggregationType, }, }, - }; - }); - }, [ - serviceName, - start, - end, - uiFilters, - tableOptions.pageIndex, - tableOptions.sort.field, - tableOptions.sort.direction, - transactionType, - latencyAggregationType, - ]); + }).then((response) => { + return { + items: response.transactionGroups, + totalItemCount: response.totalTransactionGroups, + tableOptions: { + pageIndex: tableOptions.pageIndex, + sort: { + field: tableOptions.sort.field, + direction: tableOptions.sort.direction, + }, + }, + }; + }); + }, + [ + serviceName, + start, + end, + uiFilters, + tableOptions.pageIndex, + tableOptions.sort.field, + tableOptions.sort.direction, + transactionType, + latencyAggregationType, + ] + ); const { items, diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx index d712fa27c75ac..a16edfee5fb3d 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx @@ -12,7 +12,6 @@ import { asPercent } from '../../../../../common/utils/formatters'; import { useFetcher } from '../../../../hooks/use_fetcher'; import { useTheme } from '../../../../hooks/use_theme'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; -import { callApmApi } from '../../../../services/rest/createCallApmApi'; import { TimeseriesChart } from '../timeseries_chart'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; @@ -35,26 +34,29 @@ export function TransactionErrorRateChart({ const { transactionType } = useApmServiceContext(); const { start, end, transactionName } = urlParams; - const { data, status } = useFetcher(() => { - if (transactionType && serviceName && start && end) { - return callApmApi({ - endpoint: - 'GET /api/apm/services/{serviceName}/transactions/charts/error_rate', - params: { - path: { - serviceName, + const { data, status } = useFetcher( + (callApmApi) => { + if (transactionType && serviceName && start && end) { + return callApmApi({ + endpoint: + 'GET /api/apm/services/{serviceName}/transactions/charts/error_rate', + params: { + path: { + serviceName, + }, + query: { + start, + end, + transactionType, + transactionName, + uiFilters: JSON.stringify(uiFilters), + }, }, - query: { - start, - end, - transactionType, - transactionName, - uiFilters: JSON.stringify(uiFilters), - }, - }, - }); - } - }, [serviceName, start, end, uiFilters, transactionType, transactionName]); + }); + } + }, + [serviceName, start, end, uiFilters, transactionType, transactionName] + ); const errorRates = data?.transactionErrorRate || []; diff --git a/x-pack/plugins/apm/public/context/annotations/annotations_context.tsx b/x-pack/plugins/apm/public/context/annotations/annotations_context.tsx index 77285f976d850..9d1b6d70f9738 100644 --- a/x-pack/plugins/apm/public/context/annotations/annotations_context.tsx +++ b/x-pack/plugins/apm/public/context/annotations/annotations_context.tsx @@ -9,7 +9,6 @@ import { useParams } from 'react-router-dom'; import { Annotation } from '../../../common/annotations'; import { useFetcher } from '../../hooks/use_fetcher'; import { useUrlParams } from '../url_params_context/use_url_params'; -import { callApmApi } from '../../services/rest/createCallApmApi'; export const AnnotationsContext = createContext({ annotations: [] } as { annotations: Annotation[]; @@ -27,23 +26,26 @@ export function AnnotationsContextProvider({ const { start, end } = urlParams; const { environment } = uiFilters; - const { data = INITIAL_STATE } = useFetcher(() => { - if (start && end && serviceName) { - return callApmApi({ - endpoint: 'GET /api/apm/services/{serviceName}/annotation/search', - params: { - path: { - serviceName, + const { data = INITIAL_STATE } = useFetcher( + (callApmApi) => { + if (start && end && serviceName) { + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/annotation/search', + params: { + path: { + serviceName, + }, + query: { + start, + end, + environment, + }, }, - query: { - start, - end, - environment, - }, - }, - }); - } - }, [start, end, environment, serviceName]); + }); + } + }, + [start, end, environment, serviceName] + ); return ; } diff --git a/x-pack/plugins/apm/public/hooks/useLocalUIFilters.ts b/x-pack/plugins/apm/public/hooks/useLocalUIFilters.ts index dabdf41c63f04..fbdee617864df 100644 --- a/x-pack/plugins/apm/public/hooks/useLocalUIFilters.ts +++ b/x-pack/plugins/apm/public/hooks/useLocalUIFilters.ts @@ -16,7 +16,6 @@ import { } from '../../server/lib/ui_filters/local_ui_filters/config'; import { fromQuery, toQuery } from '../components/shared/Links/url_helpers'; import { removeUndefinedProps } from '../context/url_params_context/helpers'; -import { useCallApi } from './useCallApi'; import { useFetcher } from './use_fetcher'; import { useUrlParams } from '../context/url_params_context/use_url_params'; import { LocalUIFilterName } from '../../common/ui_filter'; @@ -43,7 +42,6 @@ export function useLocalUIFilters({ }) { const history = useHistory(); const { uiFilters, urlParams } = useUrlParams(); - const callApi = useCallApi(); const values = pickKeys(uiFilters, ...filterNames); @@ -69,30 +67,34 @@ export function useLocalUIFilters({ }); }; - const { data = getInitialData(filterNames), status } = useFetcher(() => { - if (shouldFetch) { - return callApi({ - method: 'GET', - pathname: `/api/apm/ui_filters/local_filters/${projection}`, - query: { - uiFilters: JSON.stringify(uiFilters), - start: urlParams.start, - end: urlParams.end, - filterNames: JSON.stringify(filterNames), - ...params, - }, - }); - } - }, [ - callApi, - projection, - uiFilters, - urlParams.start, - urlParams.end, - filterNames, - params, - shouldFetch, - ]); + const { data = getInitialData(filterNames), status } = useFetcher( + (callApmApi) => { + if (shouldFetch && urlParams.start && urlParams.end) { + return callApmApi({ + endpoint: `GET /api/apm/ui_filters/local_filters/${projection}` as const, + params: { + query: { + uiFilters: JSON.stringify(uiFilters), + start: urlParams.start, + end: urlParams.end, + // type expects string constants, but we have to send it as json + filterNames: JSON.stringify(filterNames) as any, + ...params, + }, + }, + }); + } + }, + [ + projection, + uiFilters, + urlParams.start, + urlParams.end, + filterNames, + params, + shouldFetch, + ] + ); const filters = data.map((filter) => ({ ...filter, diff --git a/x-pack/plugins/apm/public/hooks/use_environments_fetcher.tsx b/x-pack/plugins/apm/public/hooks/use_environments_fetcher.tsx index 1ad151b8c7e90..38a8610b82acb 100644 --- a/x-pack/plugins/apm/public/hooks/use_environments_fetcher.tsx +++ b/x-pack/plugins/apm/public/hooks/use_environments_fetcher.tsx @@ -10,7 +10,6 @@ import { ENVIRONMENT_ALL, ENVIRONMENT_NOT_DEFINED, } from '../../common/environment_filter_values'; -import { callApmApi } from '../services/rest/createCallApmApi'; function getEnvironmentOptions(environments: string[]) { const environmentOptions = environments @@ -32,20 +31,23 @@ export function useEnvironmentsFetcher({ start?: string; end?: string; }) { - const { data: environments = [], status = 'loading' } = useFetcher(() => { - if (start && end) { - return callApmApi({ - endpoint: 'GET /api/apm/ui_filters/environments', - params: { - query: { - start, - end, - serviceName, + const { data: environments = [], status = 'loading' } = useFetcher( + (callApmApi) => { + if (start && end) { + return callApmApi({ + endpoint: 'GET /api/apm/ui_filters/environments', + params: { + query: { + start, + end, + serviceName, + }, }, - }, - }); - } - }, [start, end, serviceName]); + }); + } + }, + [start, end, serviceName] + ); const environmentOptions = useMemo( () => getEnvironmentOptions(environments), diff --git a/x-pack/plugins/apm/public/hooks/use_fetcher.tsx b/x-pack/plugins/apm/public/hooks/use_fetcher.tsx index 2b58f30a9ec64..27427d80adc96 100644 --- a/x-pack/plugins/apm/public/hooks/use_fetcher.tsx +++ b/x-pack/plugins/apm/public/hooks/use_fetcher.tsx @@ -8,7 +8,10 @@ import { i18n } from '@kbn/i18n'; import React, { useEffect, useMemo, useState } from 'react'; import { IHttpFetchError } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -import { APMClient, callApmApi } from '../services/rest/createCallApmApi'; +import { + callApmApi, + AutoAbortedAPMClient, +} from '../services/rest/createCallApmApi'; import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context'; export enum FETCH_STATUS { @@ -39,6 +42,14 @@ function getDetailsFromErrorResponse(error: IHttpFetchError) { ); } +const createAutoAbortedAPMClient = ( + signal: AbortSignal +): AutoAbortedAPMClient => { + return ((options: Parameters[0]) => { + return callApmApi({ ...options, signal }); + }) as AutoAbortedAPMClient; +}; + // fetcher functions can return undefined OR a promise. Previously we had a more simple type // but it led to issues when using object destructuring with default values type InferResponseType = Exclude extends Promise< @@ -48,7 +59,7 @@ type InferResponseType = Exclude extends Promise< : unknown; export function useFetcher( - fn: (callApmApi: APMClient) => TReturn, + fn: (callApmApi: AutoAbortedAPMClient) => TReturn, fnDeps: any[], options: { preservePreviousData?: boolean; @@ -66,10 +77,16 @@ export function useFetcher( const [counter, setCounter] = useState(0); useEffect(() => { - let didCancel = false; + let controller: AbortController = new AbortController(); async function doFetch() { - const promise = fn(callApmApi); + controller.abort(); + + controller = new AbortController(); + + const signal = controller.signal; + + const promise = fn(createAutoAbortedAPMClient(signal)); // if `fn` doesn't return a promise it is a signal that data fetching was not initiated. // This can happen if the data fetching is conditional (based on certain inputs). // In these cases it is not desirable to invoke the global loading spinner, or change the status to success @@ -85,7 +102,11 @@ export function useFetcher( try { const data = await promise; - if (!didCancel) { + // when http fetches are aborted, the promise will be rejected + // and this code is never reached. For async operations that are + // not cancellable, we need to check whether the signal was + // aborted before updating the result. + if (!signal.aborted) { setResult({ data, status: FETCH_STATUS.SUCCESS, @@ -95,7 +116,7 @@ export function useFetcher( } catch (e) { const err = e as Error | IHttpFetchError; - if (!didCancel) { + if (!signal.aborted) { const errorDetails = 'response' in err ? getDetailsFromErrorResponse(err) : err.message; @@ -130,7 +151,7 @@ export function useFetcher( doFetch(); return () => { - didCancel = true; + controller.abort(); }; /* eslint-disable react-hooks/exhaustive-deps */ }, [ diff --git a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.ts b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.ts index a0ed51be685c7..ac98bbab5775b 100644 --- a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.ts @@ -20,6 +20,7 @@ export const fetchObservabilityOverviewPageData = async ({ }: FetchDataParams): Promise => { const data = await callApmApi({ endpoint: 'GET /api/apm/observability_overview', + signal: null, params: { query: { start: new Date(absoluteTime.start).toISOString(), @@ -59,5 +60,6 @@ export const fetchObservabilityOverviewPageData = async ({ export async function hasData() { return await callApmApi({ endpoint: 'GET /api/apm/observability_overview/has_data', + signal: null, }); } diff --git a/x-pack/plugins/apm/public/services/rest/callApi.ts b/x-pack/plugins/apm/public/services/rest/callApi.ts index 4ee12908b7c79..d14cbc5f6d63e 100644 --- a/x-pack/plugins/apm/public/services/rest/callApi.ts +++ b/x-pack/plugins/apm/public/services/rest/callApi.ts @@ -87,5 +87,6 @@ function isCachable(fetchOptions: FetchOptions) { // order the options object to make sure that two objects with the same arguments, produce produce the // same cache key regardless of the order of properties function getCacheKey(options: FetchOptions) { - return hash(options); + const { pathname, method, body, query, headers } = options; + return hash({ pathname, method, body, query, headers }); } diff --git a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts index 2760ed558865a..b77233982ffc5 100644 --- a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts +++ b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts @@ -12,11 +12,14 @@ import { APMAPI } from '../../../server/routes/create_apm_api'; import { Client } from '../../../server/routes/typings'; export type APMClient = Client; +export type AutoAbortedAPMClient = Client; + export type APMClientOptions = Omit< FetchOptions, - 'query' | 'body' | 'pathname' + 'query' | 'body' | 'pathname' | 'signal' > & { endpoint: string; + signal: AbortSignal | null; params?: { body?: any; query?: any; diff --git a/x-pack/plugins/apm/public/services/rest/index_pattern.ts b/x-pack/plugins/apm/public/services/rest/index_pattern.ts index 6ec542ab6baf3..cea3bcc0b68cc 100644 --- a/x-pack/plugins/apm/public/services/rest/index_pattern.ts +++ b/x-pack/plugins/apm/public/services/rest/index_pattern.ts @@ -9,11 +9,13 @@ import { callApmApi } from './createCallApmApi'; export const createStaticIndexPattern = async () => { return await callApmApi({ endpoint: 'POST /api/apm/index_pattern/static', + signal: null, }); }; export const getApmIndexPatternTitle = async () => { return await callApmApi({ endpoint: 'GET /api/apm/index_pattern/title', + signal: null, }); }; diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts index f58e04061254d..87cb60a543193 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts @@ -65,10 +65,12 @@ describe('createApmEventClient', () => { await new Promise((resolve) => { setTimeout(() => { + incomingRequest.on('abort', () => { + setTimeout(() => { + resolve(undefined); + }, 0); + }); incomingRequest.abort(); - setTimeout(() => { - resolve(undefined); - }, 0); }, 50); }); diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index f00941d6e6800..47a13185ff90f 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -92,7 +92,7 @@ function getMockRequest() { url: '', events: { aborted$: { - subscribe: jest.fn(), + subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }), }, }, } as unknown) as KibanaRequest; diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts index 2b209f8f6a80a..8d6b9bfc1a4e6 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts @@ -196,19 +196,26 @@ export async function getServiceDependencies({ }); const latencySums = metricsByResolvedAddress - .map((metrics) => metrics.latency.value) + .map( + (metric) => (metric.latency.value ?? 0) * (metric.throughput.value ?? 0) + ) .filter(isFiniteNumber); const minLatencySum = Math.min(...latencySums); const maxLatencySum = Math.max(...latencySums); - return metricsByResolvedAddress.map((metric) => ({ - ...metric, - impact: - metric.latency.value === null - ? 0 - : ((metric.latency.value - minLatencySum) / + return metricsByResolvedAddress.map((metric) => { + const impact = + isFiniteNumber(metric.latency.value) && + isFiniteNumber(metric.throughput.value) + ? ((metric.latency.value * metric.throughput.value - minLatencySum) / (maxLatencySum - minLatencySum)) * - 100, - })); + 100 + : 0; + + return { + ...metric, + impact, + }; + }); } diff --git a/x-pack/plugins/apm/server/routes/create_api/index.ts b/x-pack/plugins/apm/server/routes/create_api/index.ts index 721badf7fc025..6f6ec4f06b6cb 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.ts @@ -10,6 +10,7 @@ import * as t from 'io-ts'; import { PathReporter } from 'io-ts/lib/PathReporter'; import { isLeft } from 'fp-ts/lib/Either'; import { KibanaResponseFactory, RouteRegistrar } from 'src/core/server'; +import { RequestAbortedError } from '@elastic/elasticsearch/lib/errors'; import { merge } from '../../../common/runtime_types/merge'; import { strictKeysRt } from '../../../common/runtime_types/strict_keys_rt'; import { APMConfig } from '../..'; @@ -132,6 +133,15 @@ export function createApi() { if (Boom.isBoom(error)) { return convertBoomToKibanaResponse(error, response); } + + if (error instanceof RequestAbortedError) { + return response.custom({ + statusCode: 499, + body: { + message: 'Client closed request', + }, + }); + } throw error; } } diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 7d7a5c3b0dab3..4cc3c747b201f 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -131,15 +131,20 @@ type MaybeOptional }> = RequiredKeys< ? { params?: T['params'] } : { params: T['params'] }; -export type Client = < - TEndpoint extends keyof TRouteState & string ->( - options: Omit & { +export type Client< + TRouteState, + TOptions extends { abortable: boolean } = { abortable: true } +> = ( + options: Omit< + FetchOptions, + 'query' | 'body' | 'pathname' | 'method' | 'signal' + > & { forceCache?: boolean; endpoint: TEndpoint; } & (TRouteState[TEndpoint] extends { params: t.Any } ? MaybeOptional<{ params: t.TypeOf }> - : {}) + : {}) & + (TOptions extends { abortable: true } ? { signal: AbortSignal | null } : {}) ) => Promise< TRouteState[TEndpoint] extends { ret: any } ? TRouteState[TEndpoint]['ret'] diff --git a/x-pack/plugins/case/common/api/connectors/mappings.ts b/x-pack/plugins/case/common/api/connectors/mappings.ts index f8e9830fed7c1..b9f84d406a184 100644 --- a/x-pack/plugins/case/common/api/connectors/mappings.ts +++ b/x-pack/plugins/case/common/api/connectors/mappings.ts @@ -16,8 +16,8 @@ import { Incident as ResilientIncident, } from '../../../../actions/server/builtin_action_types/resilient/types'; import { - PushToServiceApiParams as ServiceNowPushToServiceApiParams, - Incident as ServiceNowIncident, + PushToServiceApiParamsITSM as ServiceNowITSMPushToServiceApiParams, + ServiceNowITSMIncident, } from '../../../../actions/server/builtin_action_types/servicenow/types'; import { ResilientFieldsRT } from './resilient'; import { ServiceNowFieldsRT } from './servicenow'; @@ -33,13 +33,13 @@ export interface ElasticUser { export { JiraPushToServiceApiParams, ResilientPushToServiceApiParams, - ServiceNowPushToServiceApiParams, + ServiceNowITSMPushToServiceApiParams, }; -export type Incident = JiraIncident | ResilientIncident | ServiceNowIncident; +export type Incident = JiraIncident | ResilientIncident | ServiceNowITSMIncident; export type PushToServiceApiParams = | JiraPushToServiceApiParams | ResilientPushToServiceApiParams - | ServiceNowPushToServiceApiParams; + | ServiceNowITSMPushToServiceApiParams; const ActionTypeRT = rt.union([ rt.literal('append'), diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/utils.ts b/x-pack/plugins/case/server/routes/api/cases/configure/utils.ts index 89109af4cecb9..9e903b66459a9 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/utils.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/utils.ts @@ -19,7 +19,7 @@ import { PrepareFieldsForTransformArgs, PushToServiceApiParams, ResilientPushToServiceApiParams, - ServiceNowPushToServiceApiParams, + ServiceNowITSMPushToServiceApiParams, SimpleComment, Transformer, TransformerArgs, @@ -105,7 +105,11 @@ export const serviceFormatter = ( thirdPartyName: 'Resilient', }; case ConnectorTypes.servicenow: - const { severity, urgency, impact } = params as ServiceNowPushToServiceApiParams['incident']; + const { + severity, + urgency, + impact, + } = params as ServiceNowITSMPushToServiceApiParams['incident']; return { incident: { severity, urgency, impact }, thirdPartyName: 'ServiceNow', diff --git a/x-pack/plugins/code/tsconfig.json b/x-pack/plugins/code/tsconfig.json new file mode 100644 index 0000000000000..9c0b0ed21330f --- /dev/null +++ b/x-pack/plugins/code/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "server/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/infra/common/graphql/types.ts b/x-pack/plugins/infra/common/graphql/types.ts index 4a18c3d5ff334..ee536feb1ce65 100644 --- a/x-pack/plugins/infra/common/graphql/types.ts +++ b/x-pack/plugins/infra/common/graphql/types.ts @@ -28,12 +28,6 @@ export interface InfraSource { configuration: InfraSourceConfiguration; /** The status of the source */ status: InfraSourceStatus; - /** A consecutive span of log entries surrounding a point in time */ - logEntriesAround: InfraLogEntryInterval; - /** A consecutive span of log entries within an interval */ - logEntriesBetween: InfraLogEntryInterval; - /** Sequences of log entries matching sets of highlighting queries within an interval */ - logEntryHighlights: InfraLogEntryInterval[]; /** A snapshot of nodes */ snapshot?: InfraSnapshotResponse | null; @@ -129,80 +123,6 @@ export interface InfraIndexField { /** Whether the field should be displayed based on event.module and a ECS allowed list */ displayable: boolean; } -/** A consecutive sequence of log entries */ -export interface InfraLogEntryInterval { - /** The key corresponding to the start of the interval covered by the entries */ - start?: InfraTimeKey | null; - /** The key corresponding to the end of the interval covered by the entries */ - end?: InfraTimeKey | null; - /** Whether there are more log entries available before the start */ - hasMoreBefore: boolean; - /** Whether there are more log entries available after the end */ - hasMoreAfter: boolean; - /** The query the log entries were filtered by */ - filterQuery?: string | null; - /** The query the log entries were highlighted with */ - highlightQuery?: string | null; - /** A list of the log entries */ - entries: InfraLogEntry[]; -} -/** A representation of the log entry's position in the event stream */ -export interface InfraTimeKey { - /** The timestamp of the event that the log entry corresponds to */ - time: number; - /** The tiebreaker that disambiguates events with the same timestamp */ - tiebreaker: number; -} -/** A log entry */ -export interface InfraLogEntry { - /** A unique representation of the log entry's position in the event stream */ - key: InfraTimeKey; - /** The log entry's id */ - gid: string; - /** The source id */ - source: string; - /** The columns used for rendering the log entry */ - columns: InfraLogEntryColumn[]; -} -/** A special built-in column that contains the log entry's timestamp */ -export interface InfraLogEntryTimestampColumn { - /** The id of the corresponding column configuration */ - columnId: string; - /** The timestamp */ - timestamp: number; -} -/** A special built-in column that contains the log entry's constructed message */ -export interface InfraLogEntryMessageColumn { - /** The id of the corresponding column configuration */ - columnId: string; - /** A list of the formatted log entry segments */ - message: InfraLogMessageSegment[]; -} -/** A segment of the log entry message that was derived from a field */ -export interface InfraLogMessageFieldSegment { - /** The field the segment was derived from */ - field: string; - /** The segment's message */ - value: string; - /** A list of highlighted substrings of the value */ - highlights: string[]; -} -/** A segment of the log entry message that was derived from a string literal */ -export interface InfraLogMessageConstantSegment { - /** The segment's message */ - constant: string; -} -/** A column that contains the value of a field of the log entry */ -export interface InfraLogEntryFieldColumn { - /** The id of the corresponding column configuration */ - columnId: string; - /** The field name of the column */ - field: string; - /** The value of the field in the log entry */ - value: string; - /** A list of highlighted substrings of the value */ - highlights: string[]; -} export interface InfraSnapshotResponse { /** Nodes of type host, container or pod grouped by 0, 1 or 2 terms */ @@ -276,21 +196,6 @@ export interface DeleteSourceResult { // InputTypes // ==================================================== -export interface InfraTimeKeyInput { - time: number; - - tiebreaker: number; -} -/** A highlighting definition */ -export interface InfraLogEntryHighlightInput { - /** The query to highlight by */ - query: string; - /** The number of highlighted documents to include beyond the beginning of the interval */ - countBefore: number; - /** The number of highlighted documents to include beyond the end of the interval */ - countAfter: number; -} - export interface InfraTimerangeInput { /** The interval string to use for last bucket. The format is '{value}{unit}'. For example '5m' would return the metrics for the last 5 minutes of the timespan. */ interval: string; @@ -381,34 +286,6 @@ export interface SourceQueryArgs { /** The id of the source */ id: string; } -export interface LogEntriesAroundInfraSourceArgs { - /** The sort key that corresponds to the point in time */ - key: InfraTimeKeyInput; - /** The maximum number of preceding to return */ - countBefore?: number | null; - /** The maximum number of following to return */ - countAfter?: number | null; - /** The query to filter the log entries by */ - filterQuery?: string | null; -} -export interface LogEntriesBetweenInfraSourceArgs { - /** The sort key that corresponds to the start of the interval */ - startKey: InfraTimeKeyInput; - /** The sort key that corresponds to the end of the interval */ - endKey: InfraTimeKeyInput; - /** The query to filter the log entries by */ - filterQuery?: string | null; -} -export interface LogEntryHighlightsInfraSourceArgs { - /** The sort key that corresponds to the start of the interval */ - startKey: InfraTimeKeyInput; - /** The sort key that corresponds to the end of the interval */ - endKey: InfraTimeKeyInput; - /** The query to filter the log entries by */ - filterQuery?: string | null; - /** The highlighting to apply to the log entries */ - highlights: InfraLogEntryHighlightInput[]; -} export interface SnapshotInfraSourceArgs { timerange: InfraTimerangeInput; @@ -565,15 +442,6 @@ export type InfraSourceLogColumn = | InfraSourceMessageLogColumn | InfraSourceFieldLogColumn; -/** A column of a log entry */ -export type InfraLogEntryColumn = - | InfraLogEntryTimestampColumn - | InfraLogEntryMessageColumn - | InfraLogEntryFieldColumn; - -/** A segment of the log entry message */ -export type InfraLogMessageSegment = InfraLogMessageFieldSegment | InfraLogMessageConstantSegment; - // ==================================================== // END: Typescript template // ==================================================== @@ -582,46 +450,6 @@ export type InfraLogMessageSegment = InfraLogMessageFieldSegment | InfraLogMessa // Documents // ==================================================== -export namespace LogEntryHighlightsQuery { - export type Variables = { - sourceId?: string | null; - startKey: InfraTimeKeyInput; - endKey: InfraTimeKeyInput; - filterQuery?: string | null; - highlights: InfraLogEntryHighlightInput[]; - }; - - export type Query = { - __typename?: 'Query'; - - source: Source; - }; - - export type Source = { - __typename?: 'InfraSource'; - - id: string; - - logEntryHighlights: LogEntryHighlights[]; - }; - - export type LogEntryHighlights = { - __typename?: 'InfraLogEntryInterval'; - - start?: Start | null; - - end?: End | null; - - entries: Entries[]; - }; - - export type Start = InfraTimeKeyFields.Fragment; - - export type End = InfraTimeKeyFields.Fragment; - - export type Entries = InfraLogEntryHighlightFields.Fragment; -} - export namespace MetricsQuery { export type Variables = { sourceId: string; @@ -820,50 +648,6 @@ export namespace WaffleNodesQuery { }; } -export namespace LogEntries { - export type Variables = { - sourceId?: string | null; - timeKey: InfraTimeKeyInput; - countBefore?: number | null; - countAfter?: number | null; - filterQuery?: string | null; - }; - - export type Query = { - __typename?: 'Query'; - - source: Source; - }; - - export type Source = { - __typename?: 'InfraSource'; - - id: string; - - logEntriesAround: LogEntriesAround; - }; - - export type LogEntriesAround = { - __typename?: 'InfraLogEntryInterval'; - - start?: Start | null; - - end?: End | null; - - hasMoreBefore: boolean; - - hasMoreAfter: boolean; - - entries: Entries[]; - }; - - export type Start = InfraTimeKeyFields.Fragment; - - export type End = InfraTimeKeyFields.Fragment; - - export type Entries = InfraLogEntryFields.Fragment; -} - export namespace SourceConfigurationFields { export type Fragment = { __typename?: 'InfraSourceConfiguration'; @@ -994,124 +778,3 @@ export namespace InfraSourceFields { origin: string; }; } - -export namespace InfraLogEntryFields { - export type Fragment = { - __typename?: 'InfraLogEntry'; - - gid: string; - - key: Key; - - columns: Columns[]; - }; - - export type Key = { - __typename?: 'InfraTimeKey'; - - time: number; - - tiebreaker: number; - }; - - export type Columns = - | InfraLogEntryTimestampColumnInlineFragment - | InfraLogEntryMessageColumnInlineFragment - | InfraLogEntryFieldColumnInlineFragment; - - export type InfraLogEntryTimestampColumnInlineFragment = { - __typename?: 'InfraLogEntryTimestampColumn'; - - columnId: string; - - timestamp: number; - }; - - export type InfraLogEntryMessageColumnInlineFragment = { - __typename?: 'InfraLogEntryMessageColumn'; - - columnId: string; - - message: Message[]; - }; - - export type Message = - | InfraLogMessageFieldSegmentInlineFragment - | InfraLogMessageConstantSegmentInlineFragment; - - export type InfraLogMessageFieldSegmentInlineFragment = { - __typename?: 'InfraLogMessageFieldSegment'; - - field: string; - - value: string; - }; - - export type InfraLogMessageConstantSegmentInlineFragment = { - __typename?: 'InfraLogMessageConstantSegment'; - - constant: string; - }; - - export type InfraLogEntryFieldColumnInlineFragment = { - __typename?: 'InfraLogEntryFieldColumn'; - - columnId: string; - - field: string; - - value: string; - }; -} - -export namespace InfraLogEntryHighlightFields { - export type Fragment = { - __typename?: 'InfraLogEntry'; - - gid: string; - - key: Key; - - columns: Columns[]; - }; - - export type Key = { - __typename?: 'InfraTimeKey'; - - time: number; - - tiebreaker: number; - }; - - export type Columns = - | InfraLogEntryMessageColumnInlineFragment - | InfraLogEntryFieldColumnInlineFragment; - - export type InfraLogEntryMessageColumnInlineFragment = { - __typename?: 'InfraLogEntryMessageColumn'; - - columnId: string; - - message: Message[]; - }; - - export type Message = InfraLogMessageFieldSegmentInlineFragment; - - export type InfraLogMessageFieldSegmentInlineFragment = { - __typename?: 'InfraLogMessageFieldSegment'; - - field: string; - - highlights: string[]; - }; - - export type InfraLogEntryFieldColumnInlineFragment = { - __typename?: 'InfraLogEntryFieldColumn'; - - columnId: string; - - field: string; - - highlights: string[]; - }; -} diff --git a/x-pack/plugins/infra/common/http_api/log_sources/log_source_configuration.ts b/x-pack/plugins/infra/common/http_api/log_sources/log_source_configuration.ts index 7581e29692356..df7d80d33f1e6 100644 --- a/x-pack/plugins/infra/common/http_api/log_sources/log_source_configuration.ts +++ b/x-pack/plugins/infra/common/http_api/log_sources/log_source_configuration.ts @@ -53,6 +53,7 @@ export const logSourceColumnConfigurationRT = rt.union([ logSourceMessageColumnConfigurationRT, logSourceFieldColumnConfigurationRT, ]); +export type LogSourceColumnConfiguration = rt.TypeOf; export const logSourceConfigurationPropertiesRT = rt.strict({ name: rt.string, diff --git a/x-pack/plugins/infra/common/log_entry/log_entry.ts b/x-pack/plugins/infra/common/log_entry/log_entry.ts index eec1fb59f3091..bf3f9ceb0b088 100644 --- a/x-pack/plugins/infra/common/log_entry/log_entry.ts +++ b/x-pack/plugins/infra/common/log_entry/log_entry.ts @@ -6,87 +6,79 @@ import * as rt from 'io-ts'; import { TimeKey } from '../time'; -import { logEntryCursorRT } from './log_entry_cursor'; import { jsonArrayRT } from '../typed_json'; - -export interface LogEntryOrigin { - id: string; - index: string; - type: string; -} +import { logEntryCursorRT } from './log_entry_cursor'; export type LogEntryTime = TimeKey; -export interface LogEntryFieldsMapping { - message: string; - tiebreaker: string; - time: string; -} - -export function isEqual(time1: LogEntryTime, time2: LogEntryTime) { - return time1.time === time2.time && time1.tiebreaker === time2.tiebreaker; -} - -export function isLess(time1: LogEntryTime, time2: LogEntryTime) { - return ( - time1.time < time2.time || (time1.time === time2.time && time1.tiebreaker < time2.tiebreaker) - ); -} - -export function isLessOrEqual(time1: LogEntryTime, time2: LogEntryTime) { - return ( - time1.time < time2.time || (time1.time === time2.time && time1.tiebreaker <= time2.tiebreaker) - ); -} - -export function isBetween(min: LogEntryTime, max: LogEntryTime, operand: LogEntryTime) { - return isLessOrEqual(min, operand) && isLessOrEqual(operand, max); -} +/** + * message parts + */ export const logMessageConstantPartRT = rt.type({ constant: rt.string, }); +export type LogMessageConstantPart = rt.TypeOf; + export const logMessageFieldPartRT = rt.type({ field: rt.string, value: jsonArrayRT, highlights: rt.array(rt.string), }); +export type LogMessageFieldPart = rt.TypeOf; export const logMessagePartRT = rt.union([logMessageConstantPartRT, logMessageFieldPartRT]); +export type LogMessagePart = rt.TypeOf; + +/** + * columns + */ export const logTimestampColumnRT = rt.type({ columnId: rt.string, timestamp: rt.number }); +export type LogTimestampColumn = rt.TypeOf; + export const logFieldColumnRT = rt.type({ columnId: rt.string, field: rt.string, value: jsonArrayRT, highlights: rt.array(rt.string), }); +export type LogFieldColumn = rt.TypeOf; + export const logMessageColumnRT = rt.type({ columnId: rt.string, message: rt.array(logMessagePartRT), }); +export type LogMessageColumn = rt.TypeOf; export const logColumnRT = rt.union([logTimestampColumnRT, logFieldColumnRT, logMessageColumnRT]); +export type LogColumn = rt.TypeOf; +/** + * fields + */ export const logEntryContextRT = rt.union([ rt.type({}), rt.type({ 'container.id': rt.string }), rt.type({ 'host.name': rt.string, 'log.file.path': rt.string }), ]); +export type LogEntryContext = rt.TypeOf; + +export const logEntryFieldRT = rt.type({ + field: rt.string, + value: jsonArrayRT, +}); +export type LogEntryField = rt.TypeOf; + +/** + * entry + */ export const logEntryRT = rt.type({ id: rt.string, + index: rt.string, cursor: logEntryCursorRT, columns: rt.array(logColumnRT), context: logEntryContextRT, }); - -export type LogMessageConstantPart = rt.TypeOf; -export type LogMessageFieldPart = rt.TypeOf; -export type LogMessagePart = rt.TypeOf; -export type LogEntryContext = rt.TypeOf; export type LogEntry = rt.TypeOf; -export type LogTimestampColumn = rt.TypeOf; -export type LogFieldColumn = rt.TypeOf; -export type LogMessageColumn = rt.TypeOf; -export type LogColumn = rt.TypeOf; diff --git a/x-pack/plugins/infra/common/log_entry/log_entry_cursor.ts b/x-pack/plugins/infra/common/log_entry/log_entry_cursor.ts index 280403dd5438d..b11a48822e758 100644 --- a/x-pack/plugins/infra/common/log_entry/log_entry_cursor.ts +++ b/x-pack/plugins/infra/common/log_entry/log_entry_cursor.ts @@ -11,9 +11,23 @@ export const logEntryCursorRT = rt.type({ time: rt.number, tiebreaker: rt.number, }); - export type LogEntryCursor = rt.TypeOf; +export const logEntryBeforeCursorRT = rt.type({ + before: rt.union([logEntryCursorRT, rt.literal('last')]), +}); +export type LogEntryBeforeCursor = rt.TypeOf; + +export const logEntryAfterCursorRT = rt.type({ + after: rt.union([logEntryCursorRT, rt.literal('first')]), +}); +export type LogEntryAfterCursor = rt.TypeOf; + +export const logEntryAroundCursorRT = rt.type({ + center: logEntryCursorRT, +}); +export type LogEntryAroundCursor = rt.TypeOf; + export const getLogEntryCursorFromHit = (hit: { sort: [number, number] }) => decodeOrThrow(logEntryCursorRT)({ time: hit.sort[0], diff --git a/x-pack/plugins/infra/common/search_strategies/log_entries/log_entries.ts b/x-pack/plugins/infra/common/search_strategies/log_entries/log_entries.ts new file mode 100644 index 0000000000000..b2a879c3b72fd --- /dev/null +++ b/x-pack/plugins/infra/common/search_strategies/log_entries/log_entries.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; +import { DslQuery } from '../../../../../../src/plugins/data/common'; +import { logSourceColumnConfigurationRT } from '../../http_api/log_sources'; +import { + logEntryAfterCursorRT, + logEntryBeforeCursorRT, + logEntryCursorRT, + logEntryRT, +} from '../../log_entry'; +import { JsonObject, jsonObjectRT } from '../../typed_json'; +import { searchStrategyErrorRT } from '../common/errors'; + +export const LOG_ENTRIES_SEARCH_STRATEGY = 'infra-log-entries'; + +const logEntriesBaseSearchRequestParamsRT = rt.intersection([ + rt.type({ + sourceId: rt.string, + startTimestamp: rt.number, + endTimestamp: rt.number, + size: rt.number, + }), + rt.partial({ + query: jsonObjectRT, + columns: rt.array(logSourceColumnConfigurationRT), + highlightPhrase: rt.string, + }), +]); + +export const logEntriesBeforeSearchRequestParamsRT = rt.intersection([ + logEntriesBaseSearchRequestParamsRT, + logEntryBeforeCursorRT, +]); + +export const logEntriesAfterSearchRequestParamsRT = rt.intersection([ + logEntriesBaseSearchRequestParamsRT, + logEntryAfterCursorRT, +]); + +export const logEntriesSearchRequestParamsRT = rt.union([ + logEntriesBaseSearchRequestParamsRT, + logEntriesBeforeSearchRequestParamsRT, + logEntriesAfterSearchRequestParamsRT, +]); + +export type LogEntriesSearchRequestParams = rt.TypeOf; + +export type LogEntriesSearchRequestQuery = JsonObject | DslQuery; + +export const logEntriesSearchResponsePayloadRT = rt.intersection([ + rt.type({ + data: rt.intersection([ + rt.type({ + entries: rt.array(logEntryRT), + topCursor: rt.union([logEntryCursorRT, rt.null]), + bottomCursor: rt.union([logEntryCursorRT, rt.null]), + }), + rt.partial({ + hasMoreBefore: rt.boolean, + hasMoreAfter: rt.boolean, + }), + ]), + }), + rt.partial({ + errors: rt.array(searchStrategyErrorRT), + }), +]); + +export type LogEntriesSearchResponsePayload = rt.TypeOf; diff --git a/x-pack/plugins/infra/common/search_strategies/log_entries/log_entry.ts b/x-pack/plugins/infra/common/search_strategies/log_entries/log_entry.ts index af6bd203f980e..986f6baf04488 100644 --- a/x-pack/plugins/infra/common/search_strategies/log_entries/log_entry.ts +++ b/x-pack/plugins/infra/common/search_strategies/log_entries/log_entry.ts @@ -5,8 +5,7 @@ */ import * as rt from 'io-ts'; -import { logEntryCursorRT } from '../../log_entry'; -import { jsonArrayRT } from '../../typed_json'; +import { logEntryCursorRT, logEntryFieldRT } from '../../log_entry'; import { searchStrategyErrorRT } from '../common/errors'; export const LOG_ENTRY_SEARCH_STRATEGY = 'infra-log-entry'; @@ -18,18 +17,11 @@ export const logEntrySearchRequestParamsRT = rt.type({ export type LogEntrySearchRequestParams = rt.TypeOf; -const logEntryFieldRT = rt.type({ - field: rt.string, - value: jsonArrayRT, -}); - -export type LogEntryField = rt.TypeOf; - export const logEntryRT = rt.type({ id: rt.string, index: rt.string, fields: rt.array(logEntryFieldRT), - key: logEntryCursorRT, + cursor: logEntryCursorRT, }); export type LogEntry = rt.TypeOf; diff --git a/x-pack/plugins/infra/common/typed_json.ts b/x-pack/plugins/infra/common/typed_json.ts index f3e7608910e09..5aec8d3eaf2cc 100644 --- a/x-pack/plugins/infra/common/typed_json.ts +++ b/x-pack/plugins/infra/common/typed_json.ts @@ -7,6 +7,8 @@ import * as rt from 'io-ts'; import { JsonArray, JsonObject, JsonValue } from '../../../../src/plugins/kibana_utils/common'; +export { JsonArray, JsonObject, JsonValue }; + export const jsonScalarRT = rt.union([rt.null, rt.boolean, rt.number, rt.string]); export const jsonValueRT: rt.Type = rt.recursion('JsonValue', () => diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx b/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx index bda52d9323eb6..901a4b6a8383e 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx @@ -1,10 +1,12 @@ import { Meta, Story, Canvas, ArgsTable } from '@storybook/addon-docs/blocks'; -import { Subject } from 'rxjs'; +import { defer, of, Subject } from 'rxjs'; +import { delay } from 'rxjs/operators'; import { I18nProvider } from '@kbn/i18n/react'; import { EuiThemeProvider } from '../../../../../../src/plugins/kibana_react/common'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { LOG_ENTRIES_SEARCH_STRATEGY } from '../../../common/search_strategies/log_entries/log_entries'; import { DEFAULT_SOURCE_CONFIGURATION } from '../../test_utils/source_configuration'; import { generateFakeEntries, ENTRIES_EMPTY } from '../../test_utils/entries'; @@ -15,30 +17,61 @@ import { LogStream } from './'; export const startTimestamp = 1595145600000; export const endTimestamp = startTimestamp + 15 * 60 * 1000; +export const dataMock = { + search: { + search: ({ params }, options) => { + return defer(() => { + switch (options.strategy) { + case LOG_ENTRIES_SEARCH_STRATEGY: + if (params.after?.time === params.endTimestamp || params.before?.time === params.startTimestamp) { + return of({ + id: 'EMPTY_FAKE_RESPONSE', + total: 1, + loaded: 1, + isRunning: false, + isPartial: false, + rawResponse: ENTRIES_EMPTY, + }); + } else { + const entries = generateFakeEntries( + 200, + params.startTimestamp, + params.endTimestamp, + params.columns || DEFAULT_SOURCE_CONFIGURATION.data.configuration.logColumns + ); + return of({ + id: 'FAKE_RESPONSE', + total: 1, + loaded: 1, + isRunning: false, + isPartial: false, + rawResponse: { + data: { + entries, + topCursor: entries[0].cursor, + bottomCursor: entries[entries.length - 1].cursor, + hasMoreBefore: false, + }, + errors: [], + } + }); + } + default: + return of({ + id: 'FAKE_RESPONSE', + rawResponse: {}, + }); + } + }).pipe(delay(2000)); + }, + }, +}; + + export const fetch = function (url, params) { switch (url) { case '/api/infra/log_source_configurations/default': return DEFAULT_SOURCE_CONFIGURATION; - case '/api/log_entries/entries': - const body = JSON.parse(params.body); - if (body.after?.time === body.endTimestamp || body.before?.time === body.startTimestamp) { - return ENTRIES_EMPTY; - } else { - const entries = generateFakeEntries( - 200, - body.startTimestamp, - body.endTimestamp, - body.columns || DEFAULT_SOURCE_CONFIGURATION.data.configuration.logColumns - ); - return { - data: { - entries, - topCursor: entries[0].cursor, - bottomCursor: entries[entries.length - 1].cursor, - hasMoreBefore: false, - }, - }; - } default: return {}; } @@ -67,7 +100,7 @@ export const Template = (args) => ; (story) => ( - + {story()} diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx index b7410fda6f6fd..ab9bc0099f196 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx @@ -101,14 +101,14 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re // Internal state const { - loadingState, - pageLoadingState, entries, - hasMoreBefore, - hasMoreAfter, fetchEntries, - fetchPreviousEntries, fetchNextEntries, + fetchPreviousEntries, + hasMoreAfter, + hasMoreBefore, + isLoadingMore, + isReloading, } = useLogStream({ sourceId, startTimestamp, @@ -118,12 +118,6 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re columns: customColumns, }); - // Derived state - const isReloading = - isLoadingSourceConfiguration || loadingState === 'uninitialized' || loadingState === 'loading'; - - const isLoadingMore = pageLoadingState === 'loading'; - const columnConfigurations = useMemo(() => { return sourceConfiguration ? customColumns ?? sourceConfiguration.configuration.logColumns : []; }, [sourceConfiguration, customColumns]); @@ -177,7 +171,7 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re items={streamItems} scale="medium" wrap={true} - isReloading={isReloading} + isReloading={isLoadingSourceConfiguration || isReloading} isLoadingMore={isLoadingMore} hasMoreBeforeStart={hasMoreBefore} hasMoreAfterEnd={hasMoreAfter} diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.test.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.test.tsx index f578292d6d6fc..447e6afbbf1fd 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.test.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.test.tsx @@ -32,7 +32,7 @@ describe('LogEntryActionsMenu component', () => { fields: [{ field: 'host.ip', value: ['HOST_IP'] }], id: 'ITEM_ID', index: 'INDEX', - key: { + cursor: { time: 0, tiebreaker: 0, }, @@ -62,7 +62,7 @@ describe('LogEntryActionsMenu component', () => { fields: [{ field: 'container.id', value: ['CONTAINER_ID'] }], id: 'ITEM_ID', index: 'INDEX', - key: { + cursor: { time: 0, tiebreaker: 0, }, @@ -92,7 +92,7 @@ describe('LogEntryActionsMenu component', () => { fields: [{ field: 'kubernetes.pod.uid', value: ['POD_UID'] }], id: 'ITEM_ID', index: 'INDEX', - key: { + cursor: { time: 0, tiebreaker: 0, }, @@ -126,7 +126,7 @@ describe('LogEntryActionsMenu component', () => { ], id: 'ITEM_ID', index: 'INDEX', - key: { + cursor: { time: 0, tiebreaker: 0, }, @@ -158,7 +158,7 @@ describe('LogEntryActionsMenu component', () => { fields: [], id: 'ITEM_ID', index: 'INDEX', - key: { + cursor: { time: 0, tiebreaker: 0, }, @@ -192,7 +192,7 @@ describe('LogEntryActionsMenu component', () => { fields: [{ field: 'trace.id', value: ['1234567'] }], id: 'ITEM_ID', index: 'INDEX', - key: { + cursor: { time: 0, tiebreaker: 0, }, @@ -226,7 +226,7 @@ describe('LogEntryActionsMenu component', () => { ], id: 'ITEM_ID', index: 'INDEX', - key: { + cursor: { time: 0, tiebreaker: 0, }, @@ -256,7 +256,7 @@ describe('LogEntryActionsMenu component', () => { fields: [], id: 'ITEM_ID', index: 'INDEX', - key: { + cursor: { time: 0, tiebreaker: 0, }, diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx index 44e9902e0413f..b3c80a3a4924a 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx @@ -7,10 +7,8 @@ import { EuiBasicTableColumn, EuiInMemoryTable } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; -import { - LogEntry, - LogEntryField, -} from '../../../../common/search_strategies/log_entries/log_entry'; +import { LogEntryField } from '../../../../common/log_entry'; +import { LogEntry } from '../../../../common/search_strategies/log_entries/log_entry'; import { TimeKey } from '../../../../common/time'; import { FieldValue } from '../log_text_stream/field_value'; @@ -22,7 +20,7 @@ export const LogEntryFieldsTable: React.FC<{ () => onSetFieldFilter ? (field: LogEntryField) => () => { - onSetFieldFilter?.(`${field.field}:"${field.value}"`, logEntry.id, logEntry.key); + onSetFieldFilter?.(`${field.field}:"${field.value}"`, logEntry.id, logEntry.cursor); } : undefined, [logEntry, onSetFieldFilter] diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts index b0ff36574bede..13d5b7b889465 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts @@ -5,7 +5,6 @@ */ import { bisector } from 'd3-array'; - import { compareToTimeKey, TimeKey } from '../../../../common/time'; import { LogEntry } from '../../../../common/log_entry'; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx index 1a472df2b5c90..036818317011c 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx @@ -7,7 +7,6 @@ import React, { memo, useState, useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { isEmpty } from 'lodash'; - import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { useUiTracker } from '../../../../../observability/public'; import { isTimestampColumn } from '../../../utils/log_entry'; diff --git a/x-pack/plugins/infra/public/containers/logs/log_entry.ts b/x-pack/plugins/infra/public/containers/logs/log_entry.ts index af8618b8be565..60000e0b8baba 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_entry.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_entry.ts @@ -11,7 +11,11 @@ import { logEntrySearchResponsePayloadRT, LOG_ENTRY_SEARCH_STRATEGY, } from '../../../common/search_strategies/log_entries/log_entry'; -import { useDataSearch, useLatestPartialDataSearchResponse } from '../../utils/data_search'; +import { + normalizeDataSearchResponses, + useDataSearch, + useLatestPartialDataSearchResponse, +} from '../../utils/data_search'; export const useLogEntry = ({ sourceId, @@ -31,6 +35,7 @@ export const useLogEntry = ({ } : null; }, [sourceId, logEntryId]), + parseResponses: parseLogEntrySearchResponses, }); const { @@ -41,11 +46,7 @@ export const useLogEntry = ({ latestResponseErrors, loaded, total, - } = useLatestPartialDataSearchResponse( - logEntrySearchRequests$, - null, - decodeLogEntrySearchResponse - ); + } = useLatestPartialDataSearchResponse(logEntrySearchRequests$); return { cancelRequest, @@ -59,4 +60,7 @@ export const useLogEntry = ({ }; }; -const decodeLogEntrySearchResponse = decodeOrThrow(logEntrySearchResponsePayloadRT); +const parseLogEntrySearchResponses = normalizeDataSearchResponses( + null, + decodeOrThrow(logEntrySearchResponsePayloadRT) +); diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.gql_query.ts b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.gql_query.ts deleted file mode 100644 index 9d9fab5875427..0000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.gql_query.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import gql from 'graphql-tag'; - -import { sharedFragments } from '../../../../common/graphql/shared'; - -export const logEntryHighlightsQuery = gql` - query LogEntryHighlightsQuery( - $sourceId: ID = "default" - $startKey: InfraTimeKeyInput! - $endKey: InfraTimeKeyInput! - $filterQuery: String - $highlights: [InfraLogEntryHighlightInput!]! - ) { - source(id: $sourceId) { - id - logEntryHighlights( - startKey: $startKey - endKey: $endKey - filterQuery: $filterQuery - highlights: $highlights - ) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - entries { - ...InfraLogEntryHighlightFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryHighlightFields} -`; diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx index fb72874df5409..caac28a0756a1 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx @@ -5,13 +5,12 @@ */ import { useEffect, useMemo, useState } from 'react'; - -import { TimeKey } from '../../../../common/time'; -import { useTrackedPromise } from '../../../utils/use_tracked_promise'; -import { fetchLogEntriesHighlights } from './api/fetch_log_entries_highlights'; import { LogEntriesHighlightsResponse } from '../../../../common/http_api'; import { LogEntry } from '../../../../common/log_entry'; +import { TimeKey } from '../../../../common/time'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; +import { useTrackedPromise } from '../../../utils/use_tracked_promise'; +import { fetchLogEntriesHighlights } from './api/fetch_log_entries_highlights'; export const useLogEntryHighlights = ( sourceId: string, diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts index 1d9a7a1b1d777..8343525c82862 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts @@ -4,15 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useMemo, useEffect } from 'react'; -import useSetState from 'react-use/lib/useSetState'; +import { useCallback, useEffect, useMemo } from 'react'; import usePrevious from 'react-use/lib/usePrevious'; +import useSetState from 'react-use/lib/useSetState'; import { esKuery, esQuery, Query } from '../../../../../../../src/plugins/data/public'; -import { fetchLogEntries } from '../log_entries/api/fetch_log_entries'; -import { useTrackedPromise } from '../../../utils/use_tracked_promise'; -import { LogEntryCursor, LogEntry } from '../../../../common/log_entry'; -import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; +import { LogEntry, LogEntryCursor } from '../../../../common/log_entry'; +import { useSubscription } from '../../../utils/use_observable'; import { LogSourceConfigurationProperties } from '../log_source'; +import { useFetchLogEntriesAfter } from './use_fetch_log_entries_after'; +import { useFetchLogEntriesAround } from './use_fetch_log_entries_around'; +import { useFetchLogEntriesBefore } from './use_fetch_log_entries_before'; interface LogStreamProps { sourceId: string; @@ -31,16 +32,6 @@ interface LogStreamState { hasMoreAfter: boolean; } -type LoadingState = 'uninitialized' | 'loading' | 'success' | 'error'; - -interface LogStreamReturn extends LogStreamState { - fetchEntries: () => void; - fetchPreviousEntries: () => void; - fetchNextEntries: () => void; - loadingState: LoadingState; - pageLoadingState: LoadingState; -} - const INITIAL_STATE: LogStreamState = { entries: [], topCursor: null, @@ -50,11 +41,7 @@ const INITIAL_STATE: LogStreamState = { hasMoreAfter: true, }; -const EMPTY_DATA = { - entries: [], - topCursor: null, - bottomCursor: null, -}; +const LOG_ENTRIES_CHUNK_SIZE = 200; export function useLogStream({ sourceId, @@ -63,8 +50,7 @@ export function useLogStream({ query, center, columns, -}: LogStreamProps): LogStreamReturn { - const { services } = useKibanaContextForPlugin(); +}: LogStreamProps) { const [state, setState] = useSetState(INITIAL_STATE); // Ensure the pagination keeps working when the timerange gets extended @@ -85,175 +71,151 @@ export function useLogStream({ const parsedQuery = useMemo(() => { if (!query) { - return null; - } - - let q; - - if (typeof query === 'string') { - q = esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query)); + return undefined; + } else if (typeof query === 'string') { + return esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query)); } else if (query.language === 'kuery') { - q = esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query.query as string)); + return esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query.query as string)); } else if (query.language === 'lucene') { - q = esQuery.luceneStringToDsl(query.query as string); + return esQuery.luceneStringToDsl(query.query as string); + } else { + return undefined; } - - return JSON.stringify(q); }, [query]); - // Callbacks - const [entriesPromise, fetchEntries] = useTrackedPromise( - { - cancelPreviousOn: 'creation', - createPromise: () => { - setState(INITIAL_STATE); - const fetchPosition = center ? { center } : { before: 'last' }; + const commonFetchArguments = useMemo( + () => ({ + sourceId, + startTimestamp, + endTimestamp, + query: parsedQuery, + columnOverrides: columns, + }), + [columns, endTimestamp, parsedQuery, sourceId, startTimestamp] + ); - return fetchLogEntries( - { - sourceId, - startTimestamp, - endTimestamp, - query: parsedQuery, - columns, - ...fetchPosition, - }, - services.http.fetch - ); - }, - onResolve: ({ data }) => { + const { + fetchLogEntriesAround, + isRequestRunning: isLogEntriesAroundRequestRunning, + logEntriesAroundSearchResponses$, + } = useFetchLogEntriesAround(commonFetchArguments); + + useSubscription(logEntriesAroundSearchResponses$, { + next: ({ before, after, combined }) => { + if ((before.response.data != null || after?.response.data != null) && !combined.isPartial) { setState((prevState) => ({ - ...data, - hasMoreBefore: data.hasMoreBefore ?? prevState.hasMoreBefore, - hasMoreAfter: data.hasMoreAfter ?? prevState.hasMoreAfter, + ...prevState, + entries: combined.entries, + hasMoreAfter: combined.hasMoreAfter ?? prevState.hasMoreAfter, + hasMoreBefore: combined.hasMoreAfter ?? prevState.hasMoreAfter, + bottomCursor: combined.bottomCursor, + topCursor: combined.topCursor, })); - }, + } }, - [sourceId, startTimestamp, endTimestamp, query] - ); + }); - const [previousEntriesPromise, fetchPreviousEntries] = useTrackedPromise( - { - cancelPreviousOn: 'creation', - createPromise: () => { - if (state.topCursor === null) { - throw new Error( - 'useLogState: Cannot fetch previous entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.' - ); - } + const { + fetchLogEntriesBefore, + isRequestRunning: isLogEntriesBeforeRequestRunning, + logEntriesBeforeSearchResponse$, + } = useFetchLogEntriesBefore(commonFetchArguments); - if (!state.hasMoreBefore) { - return Promise.resolve({ data: EMPTY_DATA }); - } - - return fetchLogEntries( - { - sourceId, - startTimestamp, - endTimestamp, - query: parsedQuery, - before: state.topCursor, - }, - services.http.fetch - ); - }, - onResolve: ({ data }) => { - if (!data.entries.length) { - return; - } + useSubscription(logEntriesBeforeSearchResponse$, { + next: ({ response: { data, isPartial } }) => { + if (data != null && !isPartial) { setState((prevState) => ({ + ...prevState, entries: [...data.entries, ...prevState.entries], hasMoreBefore: data.hasMoreBefore ?? prevState.hasMoreBefore, topCursor: data.topCursor ?? prevState.topCursor, + bottomCursor: prevState.bottomCursor ?? data.bottomCursor, })); - }, + } }, - [sourceId, startTimestamp, endTimestamp, query, state.topCursor] - ); + }); - const [nextEntriesPromise, fetchNextEntries] = useTrackedPromise( - { - cancelPreviousOn: 'creation', - createPromise: () => { - if (state.bottomCursor === null) { - throw new Error( - 'useLogState: Cannot fetch next entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.' - ); - } + const fetchPreviousEntries = useCallback(() => { + if (state.topCursor === null) { + throw new Error( + 'useLogState: Cannot fetch previous entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.' + ); + } + + if (!state.hasMoreBefore) { + return; + } - if (!state.hasMoreAfter) { - return Promise.resolve({ data: EMPTY_DATA }); - } + fetchLogEntriesBefore(state.topCursor, LOG_ENTRIES_CHUNK_SIZE); + }, [fetchLogEntriesBefore, state.topCursor, state.hasMoreBefore]); - return fetchLogEntries( - { - sourceId, - startTimestamp, - endTimestamp, - query: parsedQuery, - after: state.bottomCursor, - }, - services.http.fetch - ); - }, - onResolve: ({ data }) => { - if (!data.entries.length) { - return; - } + const { + fetchLogEntriesAfter, + isRequestRunning: isLogEntriesAfterRequestRunning, + logEntriesAfterSearchResponse$, + } = useFetchLogEntriesAfter(commonFetchArguments); + + useSubscription(logEntriesAfterSearchResponse$, { + next: ({ response: { data, isPartial } }) => { + if (data != null && !isPartial) { setState((prevState) => ({ + ...prevState, entries: [...prevState.entries, ...data.entries], hasMoreAfter: data.hasMoreAfter ?? prevState.hasMoreAfter, + topCursor: prevState.topCursor ?? data.topCursor, bottomCursor: data.bottomCursor ?? prevState.bottomCursor, })); - }, + } }, - [sourceId, startTimestamp, endTimestamp, query, state.bottomCursor] - ); - - const loadingState = useMemo( - () => convertPromiseStateToLoadingState(entriesPromise.state), - [entriesPromise.state] - ); + }); - const pageLoadingState = useMemo(() => { - const states = [previousEntriesPromise.state, nextEntriesPromise.state]; - - if (states.includes('pending')) { - return 'loading'; + const fetchNextEntries = useCallback(() => { + if (state.bottomCursor === null) { + throw new Error( + 'useLogState: Cannot fetch next entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.' + ); } - if (states.includes('rejected')) { - return 'error'; + if (!state.hasMoreAfter) { + return; } - if (states.includes('resolved')) { - return 'success'; + fetchLogEntriesAfter(state.bottomCursor, LOG_ENTRIES_CHUNK_SIZE); + }, [fetchLogEntriesAfter, state.bottomCursor, state.hasMoreAfter]); + + const fetchEntries = useCallback(() => { + setState(INITIAL_STATE); + + if (center) { + fetchLogEntriesAround(center, LOG_ENTRIES_CHUNK_SIZE); + } else { + fetchLogEntriesBefore('last', LOG_ENTRIES_CHUNK_SIZE); } + }, [center, fetchLogEntriesAround, fetchLogEntriesBefore, setState]); + + const isReloading = useMemo( + () => + isLogEntriesAroundRequestRunning || + (state.bottomCursor == null && state.topCursor == null && isLogEntriesBeforeRequestRunning), + [ + isLogEntriesAroundRequestRunning, + isLogEntriesBeforeRequestRunning, + state.bottomCursor, + state.topCursor, + ] + ); - return 'uninitialized'; - }, [previousEntriesPromise.state, nextEntriesPromise.state]); + const isLoadingMore = useMemo( + () => isLogEntriesBeforeRequestRunning || isLogEntriesAfterRequestRunning, + [isLogEntriesAfterRequestRunning, isLogEntriesBeforeRequestRunning] + ); return { ...state, fetchEntries, - fetchPreviousEntries, fetchNextEntries, - loadingState, - pageLoadingState, + fetchPreviousEntries, + isLoadingMore, + isReloading, }; } - -function convertPromiseStateToLoadingState( - state: 'uninitialized' | 'pending' | 'resolved' | 'rejected' -): LoadingState { - switch (state) { - case 'uninitialized': - return 'uninitialized'; - case 'pending': - return 'loading'; - case 'resolved': - return 'success'; - case 'rejected': - return 'error'; - } -} diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_after.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_after.ts new file mode 100644 index 0000000000000..c7076ec51db6a --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_after.ts @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback } from 'react'; +import { Observable } from 'rxjs'; +import { exhaustMap } from 'rxjs/operators'; +import { IKibanaSearchRequest } from '../../../../../../../src/plugins/data/public'; +import { LogSourceColumnConfiguration } from '../../../../common/http_api/log_sources'; +import { LogEntryAfterCursor } from '../../../../common/log_entry'; +import { decodeOrThrow } from '../../../../common/runtime_types'; +import { + logEntriesSearchRequestParamsRT, + LogEntriesSearchRequestQuery, + LogEntriesSearchResponsePayload, + logEntriesSearchResponsePayloadRT, + LOG_ENTRIES_SEARCH_STRATEGY, +} from '../../../../common/search_strategies/log_entries/log_entries'; +import { + flattenDataSearchResponseDescriptor, + normalizeDataSearchResponses, + ParsedDataSearchRequestDescriptor, + useDataSearch, + useDataSearchResponseState, +} from '../../../utils/data_search'; +import { useOperator } from '../../../utils/use_observable'; + +export const useLogEntriesAfterRequest = ({ + columnOverrides, + endTimestamp, + highlightPhrase, + query, + sourceId, + startTimestamp, +}: { + columnOverrides?: LogSourceColumnConfiguration[]; + endTimestamp: number; + highlightPhrase?: string; + query?: LogEntriesSearchRequestQuery; + sourceId: string; + startTimestamp: number; +}) => { + const { search: fetchLogEntriesAfter, requests$: logEntriesAfterSearchRequests$ } = useDataSearch( + { + getRequest: useCallback( + (cursor: LogEntryAfterCursor['after'], size: number) => { + return !!sourceId + ? { + request: { + params: logEntriesSearchRequestParamsRT.encode({ + after: cursor, + columns: columnOverrides, + endTimestamp, + highlightPhrase, + query, + size, + sourceId, + startTimestamp, + }), + }, + options: { strategy: LOG_ENTRIES_SEARCH_STRATEGY }, + } + : null; + }, + [columnOverrides, endTimestamp, highlightPhrase, query, sourceId, startTimestamp] + ), + parseResponses: parseLogEntriesAfterSearchResponses, + } + ); + + return { + fetchLogEntriesAfter, + logEntriesAfterSearchRequests$, + }; +}; + +export const useLogEntriesAfterResponse = ( + logEntriesAfterSearchRequests$: Observable< + ParsedDataSearchRequestDescriptor + > +) => { + const logEntriesAfterSearchResponse$ = useOperator( + logEntriesAfterSearchRequests$, + flattenLogEntriesAfterSearchResponse + ); + + const { + cancelRequest, + isRequestRunning, + isResponsePartial, + loaded, + total, + } = useDataSearchResponseState(logEntriesAfterSearchResponse$); + + return { + cancelRequest, + isRequestRunning, + isResponsePartial, + loaded, + logEntriesAfterSearchRequests$, + logEntriesAfterSearchResponse$, + total, + }; +}; + +export const useFetchLogEntriesAfter = ({ + columnOverrides, + endTimestamp, + highlightPhrase, + query, + sourceId, + startTimestamp, +}: { + columnOverrides?: LogSourceColumnConfiguration[]; + endTimestamp: number; + highlightPhrase?: string; + query?: LogEntriesSearchRequestQuery; + sourceId: string; + startTimestamp: number; +}) => { + const { fetchLogEntriesAfter, logEntriesAfterSearchRequests$ } = useLogEntriesAfterRequest({ + columnOverrides, + endTimestamp, + highlightPhrase, + query, + sourceId, + startTimestamp, + }); + + const { + cancelRequest, + isRequestRunning, + isResponsePartial, + loaded, + logEntriesAfterSearchResponse$, + total, + } = useLogEntriesAfterResponse(logEntriesAfterSearchRequests$); + + return { + cancelRequest, + fetchLogEntriesAfter, + isRequestRunning, + isResponsePartial, + loaded, + logEntriesAfterSearchResponse$, + total, + }; +}; + +export const parseLogEntriesAfterSearchResponses = normalizeDataSearchResponses( + null, + decodeOrThrow(logEntriesSearchResponsePayloadRT) +); + +const flattenLogEntriesAfterSearchResponse = exhaustMap(flattenDataSearchResponseDescriptor); diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_around.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_around.ts new file mode 100644 index 0000000000000..01f6336e0d5c8 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_around.ts @@ -0,0 +1,184 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback } from 'react'; +import { combineLatest, Observable, Subject } from 'rxjs'; +import { last, map, startWith, switchMap } from 'rxjs/operators'; +import { LogSourceColumnConfiguration } from '../../../../common/http_api/log_sources'; +import { LogEntryCursor } from '../../../../common/log_entry'; +import { LogEntriesSearchRequestQuery } from '../../../../common/search_strategies/log_entries/log_entries'; +import { flattenDataSearchResponseDescriptor } from '../../../utils/data_search'; +import { useObservable, useObservableState } from '../../../utils/use_observable'; +import { useLogEntriesAfterRequest } from './use_fetch_log_entries_after'; +import { useLogEntriesBeforeRequest } from './use_fetch_log_entries_before'; + +export const useFetchLogEntriesAround = ({ + columnOverrides, + endTimestamp, + highlightPhrase, + query, + sourceId, + startTimestamp, +}: { + columnOverrides?: LogSourceColumnConfiguration[]; + endTimestamp: number; + highlightPhrase?: string; + query?: LogEntriesSearchRequestQuery; + sourceId: string; + startTimestamp: number; +}) => { + const { fetchLogEntriesBefore } = useLogEntriesBeforeRequest({ + columnOverrides, + endTimestamp, + highlightPhrase, + query, + sourceId, + startTimestamp, + }); + + const { fetchLogEntriesAfter } = useLogEntriesAfterRequest({ + columnOverrides, + endTimestamp, + highlightPhrase, + query, + sourceId, + startTimestamp, + }); + + type LogEntriesBeforeRequest = NonNullable>; + type LogEntriesAfterRequest = NonNullable>; + + const logEntriesAroundSearchRequests$ = useObservable( + () => new Subject<[LogEntriesBeforeRequest, Observable]>(), + [] + ); + + const fetchLogEntriesAround = useCallback( + (cursor: LogEntryCursor, size: number) => { + const logEntriesBeforeSearchRequest = fetchLogEntriesBefore(cursor, Math.floor(size / 2)); + + if (logEntriesBeforeSearchRequest == null) { + return; + } + + const logEntriesAfterSearchRequest$ = flattenDataSearchResponseDescriptor( + logEntriesBeforeSearchRequest + ).pipe( + last(), // in the future we could start earlier if we receive partial results already + map((lastBeforeSearchResponse) => { + const cursorAfter = lastBeforeSearchResponse.response.data?.bottomCursor ?? { + time: cursor.time - 1, + tiebreaker: 0, + }; + + const logEntriesAfterSearchRequest = fetchLogEntriesAfter( + cursorAfter, + Math.ceil(size / 2) + ); + + if (logEntriesAfterSearchRequest == null) { + throw new Error('Failed to create request: no request args given'); + } + + return logEntriesAfterSearchRequest; + }) + ); + + logEntriesAroundSearchRequests$.next([ + logEntriesBeforeSearchRequest, + logEntriesAfterSearchRequest$, + ]); + }, + [fetchLogEntriesAfter, fetchLogEntriesBefore, logEntriesAroundSearchRequests$] + ); + + const logEntriesAroundSearchResponses$ = useObservable( + (inputs$) => + inputs$.pipe( + switchMap(([currentSearchRequests$]) => + currentSearchRequests$.pipe( + switchMap(([beforeRequest, afterRequest$]) => { + const beforeResponse$ = flattenDataSearchResponseDescriptor(beforeRequest); + const afterResponse$ = afterRequest$.pipe( + switchMap(flattenDataSearchResponseDescriptor), + startWith(undefined) // emit "before" response even if "after" hasn't started yet + ); + return combineLatest([beforeResponse$, afterResponse$]); + }), + map(([beforeResponse, afterResponse]) => { + const loadedBefore = beforeResponse.response.loaded; + const loadedAfter = afterResponse?.response.loaded; + const totalBefore = beforeResponse.response.total; + const totalAfter = afterResponse?.response.total; + + return { + before: beforeResponse, + after: afterResponse, + combined: { + isRunning: + (beforeResponse.response.isRunning || afterResponse?.response.isRunning) ?? + false, + isPartial: + (beforeResponse.response.isPartial || afterResponse?.response.isPartial) ?? + false, + loaded: + loadedBefore != null || loadedAfter != null + ? (loadedBefore ?? 0) + (loadedAfter ?? 0) + : undefined, + total: + totalBefore != null || totalAfter != null + ? (totalBefore ?? 0) + (totalAfter ?? 0) + : undefined, + entries: [ + ...(beforeResponse.response.data?.entries ?? []), + ...(afterResponse?.response.data?.entries ?? []), + ], + errors: [ + ...(beforeResponse.response.errors ?? []), + ...(afterResponse?.response.errors ?? []), + ], + hasMoreBefore: beforeResponse.response.data?.hasMoreBefore, + hasMoreAfter: afterResponse?.response.data?.hasMoreAfter, + topCursor: beforeResponse.response.data?.topCursor, + bottomCursor: afterResponse?.response.data?.bottomCursor, + }, + }; + }) + ) + ) + ), + [logEntriesAroundSearchRequests$] + ); + + const { + latestValue: { + before: latestBeforeResponse, + after: latestAfterResponse, + combined: latestCombinedResponse, + }, + } = useObservableState(logEntriesAroundSearchResponses$, initialCombinedResponse); + + const cancelRequest = useCallback(() => { + latestBeforeResponse?.abortController.abort(); + latestAfterResponse?.abortController.abort(); + }, [latestBeforeResponse, latestAfterResponse]); + + return { + cancelRequest, + fetchLogEntriesAround, + isRequestRunning: latestCombinedResponse?.isRunning ?? false, + isResponsePartial: latestCombinedResponse?.isPartial ?? false, + loaded: latestCombinedResponse?.loaded, + logEntriesAroundSearchResponses$, + total: latestCombinedResponse?.total, + }; +}; + +const initialCombinedResponse = { + before: undefined, + after: undefined, + combined: undefined, +} as const; diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_before.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_before.ts new file mode 100644 index 0000000000000..5553be11b9fef --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_before.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback } from 'react'; +import { Observable } from 'rxjs'; +import { exhaustMap } from 'rxjs/operators'; +import { IKibanaSearchRequest } from '../../../../../../../src/plugins/data/public'; +import { LogSourceColumnConfiguration } from '../../../../common/http_api/log_sources'; +import { LogEntryBeforeCursor } from '../../../../common/log_entry'; +import { decodeOrThrow } from '../../../../common/runtime_types'; +import { + logEntriesSearchRequestParamsRT, + LogEntriesSearchRequestQuery, + LogEntriesSearchResponsePayload, + logEntriesSearchResponsePayloadRT, + LOG_ENTRIES_SEARCH_STRATEGY, +} from '../../../../common/search_strategies/log_entries/log_entries'; +import { + flattenDataSearchResponseDescriptor, + normalizeDataSearchResponses, + ParsedDataSearchRequestDescriptor, + useDataSearch, + useDataSearchResponseState, +} from '../../../utils/data_search'; +import { useOperator } from '../../../utils/use_observable'; + +export const useLogEntriesBeforeRequest = ({ + columnOverrides, + endTimestamp, + highlightPhrase, + query, + sourceId, + startTimestamp, +}: { + columnOverrides?: LogSourceColumnConfiguration[]; + endTimestamp: number; + highlightPhrase?: string; + query?: LogEntriesSearchRequestQuery; + sourceId: string; + startTimestamp: number; +}) => { + const { + search: fetchLogEntriesBefore, + requests$: logEntriesBeforeSearchRequests$, + } = useDataSearch({ + getRequest: useCallback( + (cursor: LogEntryBeforeCursor['before'], size: number) => { + return !!sourceId + ? { + request: { + params: logEntriesSearchRequestParamsRT.encode({ + before: cursor, + columns: columnOverrides, + endTimestamp, + highlightPhrase, + query, + size, + sourceId, + startTimestamp, + }), + }, + options: { strategy: LOG_ENTRIES_SEARCH_STRATEGY }, + } + : null; + }, + [columnOverrides, endTimestamp, highlightPhrase, query, sourceId, startTimestamp] + ), + parseResponses: parseLogEntriesBeforeSearchResponses, + }); + + return { + fetchLogEntriesBefore, + logEntriesBeforeSearchRequests$, + }; +}; + +export const useLogEntriesBeforeResponse = ( + logEntriesBeforeSearchRequests$: Observable< + ParsedDataSearchRequestDescriptor + > +) => { + const logEntriesBeforeSearchResponse$ = useOperator( + logEntriesBeforeSearchRequests$, + flattenLogEntriesBeforeSearchResponse + ); + + const { + cancelRequest, + isRequestRunning, + isResponsePartial, + loaded, + total, + } = useDataSearchResponseState(logEntriesBeforeSearchResponse$); + + return { + cancelRequest, + isRequestRunning, + isResponsePartial, + loaded, + logEntriesBeforeSearchRequests$, + logEntriesBeforeSearchResponse$, + total, + }; +}; + +export const useFetchLogEntriesBefore = ({ + columnOverrides, + endTimestamp, + highlightPhrase, + query, + sourceId, + startTimestamp, +}: { + columnOverrides?: LogSourceColumnConfiguration[]; + endTimestamp: number; + highlightPhrase?: string; + query?: LogEntriesSearchRequestQuery; + sourceId: string; + startTimestamp: number; +}) => { + const { fetchLogEntriesBefore, logEntriesBeforeSearchRequests$ } = useLogEntriesBeforeRequest({ + columnOverrides, + endTimestamp, + highlightPhrase, + query, + sourceId, + startTimestamp, + }); + + const { + cancelRequest, + isRequestRunning, + isResponsePartial, + loaded, + logEntriesBeforeSearchResponse$, + total, + } = useLogEntriesBeforeResponse(logEntriesBeforeSearchRequests$); + + return { + cancelRequest, + fetchLogEntriesBefore, + isRequestRunning, + isResponsePartial, + loaded, + logEntriesBeforeSearchResponse$, + total, + }; +}; + +export const parseLogEntriesBeforeSearchResponses = normalizeDataSearchResponses( + null, + decodeOrThrow(logEntriesSearchResponsePayloadRT) +); + +const flattenLogEntriesBeforeSearchResponse = exhaustMap(flattenDataSearchResponseDescriptor); diff --git a/x-pack/plugins/infra/public/graphql/introspection.json b/x-pack/plugins/infra/public/graphql/introspection.json index 5d351f3259ac5..efdca72c1383a 100644 --- a/x-pack/plugins/infra/public/graphql/introspection.json +++ b/x-pack/plugins/infra/public/graphql/introspection.json @@ -137,155 +137,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "logEntriesAround", - "description": "A consecutive span of log entries surrounding a point in time", - "args": [ - { - "name": "key", - "description": "The sort key that corresponds to the point in time", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "INPUT_OBJECT", "name": "InfraTimeKeyInput", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "countBefore", - "description": "The maximum number of preceding to return", - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "defaultValue": "0" - }, - { - "name": "countAfter", - "description": "The maximum number of following to return", - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "defaultValue": "0" - }, - { - "name": "filterQuery", - "description": "The query to filter the log entries by", - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "defaultValue": null - } - ], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "InfraLogEntryInterval", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "logEntriesBetween", - "description": "A consecutive span of log entries within an interval", - "args": [ - { - "name": "startKey", - "description": "The sort key that corresponds to the start of the interval", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "INPUT_OBJECT", "name": "InfraTimeKeyInput", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "endKey", - "description": "The sort key that corresponds to the end of the interval", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "INPUT_OBJECT", "name": "InfraTimeKeyInput", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "filterQuery", - "description": "The query to filter the log entries by", - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "defaultValue": null - } - ], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "InfraLogEntryInterval", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "logEntryHighlights", - "description": "Sequences of log entries matching sets of highlighting queries within an interval", - "args": [ - { - "name": "startKey", - "description": "The sort key that corresponds to the start of the interval", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "INPUT_OBJECT", "name": "InfraTimeKeyInput", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "endKey", - "description": "The sort key that corresponds to the end of the interval", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "INPUT_OBJECT", "name": "InfraTimeKeyInput", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "filterQuery", - "description": "The query to filter the log entries by", - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "defaultValue": null - }, - { - "name": "highlights", - "description": "The highlighting to apply to the log entries", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "InfraLogEntryHighlightInput", - "ofType": null - } - } - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "InfraLogEntryInterval", "ofType": null } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "snapshot", "description": "A snapshot of nodes", @@ -993,37 +844,6 @@ "enumValues": null, "possibleTypes": null }, - { - "kind": "INPUT_OBJECT", - "name": "InfraTimeKeyInput", - "description": "", - "fields": null, - "inputFields": [ - { - "name": "time", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "tiebreaker", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, { "kind": "SCALAR", "name": "Int", @@ -1034,486 +854,6 @@ "enumValues": null, "possibleTypes": null }, - { - "kind": "OBJECT", - "name": "InfraLogEntryInterval", - "description": "A consecutive sequence of log entries", - "fields": [ - { - "name": "start", - "description": "The key corresponding to the start of the interval covered by the entries", - "args": [], - "type": { "kind": "OBJECT", "name": "InfraTimeKey", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "end", - "description": "The key corresponding to the end of the interval covered by the entries", - "args": [], - "type": { "kind": "OBJECT", "name": "InfraTimeKey", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "hasMoreBefore", - "description": "Whether there are more log entries available before the start", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Boolean", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "hasMoreAfter", - "description": "Whether there are more log entries available after the end", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Boolean", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "filterQuery", - "description": "The query the log entries were filtered by", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "highlightQuery", - "description": "The query the log entries were highlighted with", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "entries", - "description": "A list of the log entries", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "InfraLogEntry", "ofType": null } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InfraTimeKey", - "description": "A representation of the log entry's position in the event stream", - "fields": [ - { - "name": "time", - "description": "The timestamp of the event that the log entry corresponds to", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tiebreaker", - "description": "The tiebreaker that disambiguates events with the same timestamp", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InfraLogEntry", - "description": "A log entry", - "fields": [ - { - "name": "key", - "description": "A unique representation of the log entry's position in the event stream", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "InfraTimeKey", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "gid", - "description": "The log entry's id", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "The source id", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "columns", - "description": "The columns used for rendering the log entry", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "UNION", "name": "InfraLogEntryColumn", "ofType": null } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "UNION", - "name": "InfraLogEntryColumn", - "description": "A column of a log entry", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": [ - { "kind": "OBJECT", "name": "InfraLogEntryTimestampColumn", "ofType": null }, - { "kind": "OBJECT", "name": "InfraLogEntryMessageColumn", "ofType": null }, - { "kind": "OBJECT", "name": "InfraLogEntryFieldColumn", "ofType": null } - ] - }, - { - "kind": "OBJECT", - "name": "InfraLogEntryTimestampColumn", - "description": "A special built-in column that contains the log entry's timestamp", - "fields": [ - { - "name": "columnId", - "description": "The id of the corresponding column configuration", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "ID", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "The timestamp", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InfraLogEntryMessageColumn", - "description": "A special built-in column that contains the log entry's constructed message", - "fields": [ - { - "name": "columnId", - "description": "The id of the corresponding column configuration", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "ID", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "message", - "description": "A list of the formatted log entry segments", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "UNION", "name": "InfraLogMessageSegment", "ofType": null } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "UNION", - "name": "InfraLogMessageSegment", - "description": "A segment of the log entry message", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": [ - { "kind": "OBJECT", "name": "InfraLogMessageFieldSegment", "ofType": null }, - { "kind": "OBJECT", "name": "InfraLogMessageConstantSegment", "ofType": null } - ] - }, - { - "kind": "OBJECT", - "name": "InfraLogMessageFieldSegment", - "description": "A segment of the log entry message that was derived from a field", - "fields": [ - { - "name": "field", - "description": "The field the segment was derived from", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "value", - "description": "The segment's message", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "highlights", - "description": "A list of highlighted substrings of the value", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InfraLogMessageConstantSegment", - "description": "A segment of the log entry message that was derived from a string literal", - "fields": [ - { - "name": "constant", - "description": "The segment's message", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InfraLogEntryFieldColumn", - "description": "A column that contains the value of a field of the log entry", - "fields": [ - { - "name": "columnId", - "description": "The id of the corresponding column configuration", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "ID", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "field", - "description": "The field name of the column", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "value", - "description": "The value of the field in the log entry", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "highlights", - "description": "A list of highlighted substrings of the value", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "InfraLogEntryHighlightInput", - "description": "A highlighting definition", - "fields": null, - "inputFields": [ - { - "name": "query", - "description": "The query to highlight by", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "countBefore", - "description": "The number of highlighted documents to include beyond the beginning of the interval", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Int", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "countAfter", - "description": "The number of highlighted documents to include beyond the end of the interval", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Int", "ofType": null } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, { "kind": "INPUT_OBJECT", "name": "InfraTimerangeInput", diff --git a/x-pack/plugins/infra/public/graphql/types.ts b/x-pack/plugins/infra/public/graphql/types.ts index f0f74c34a19e6..eb025ee4efd79 100644 --- a/x-pack/plugins/infra/public/graphql/types.ts +++ b/x-pack/plugins/infra/public/graphql/types.ts @@ -30,12 +30,6 @@ export interface InfraSource { configuration: InfraSourceConfiguration; /** The status of the source */ status: InfraSourceStatus; - /** A consecutive span of log entries surrounding a point in time */ - logEntriesAround: InfraLogEntryInterval; - /** A consecutive span of log entries within an interval */ - logEntriesBetween: InfraLogEntryInterval; - /** Sequences of log entries matching sets of highlighting queries within an interval */ - logEntryHighlights: InfraLogEntryInterval[]; /** A snapshot of nodes */ snapshot?: InfraSnapshotResponse | null; @@ -135,80 +129,6 @@ export interface InfraIndexField { /** Whether the field should be displayed based on event.module and a ECS allowed list */ displayable: boolean; } -/** A consecutive sequence of log entries */ -export interface InfraLogEntryInterval { - /** The key corresponding to the start of the interval covered by the entries */ - start?: InfraTimeKey | null; - /** The key corresponding to the end of the interval covered by the entries */ - end?: InfraTimeKey | null; - /** Whether there are more log entries available before the start */ - hasMoreBefore: boolean; - /** Whether there are more log entries available after the end */ - hasMoreAfter: boolean; - /** The query the log entries were filtered by */ - filterQuery?: string | null; - /** The query the log entries were highlighted with */ - highlightQuery?: string | null; - /** A list of the log entries */ - entries: InfraLogEntry[]; -} -/** A representation of the log entry's position in the event stream */ -export interface InfraTimeKey { - /** The timestamp of the event that the log entry corresponds to */ - time: number; - /** The tiebreaker that disambiguates events with the same timestamp */ - tiebreaker: number; -} -/** A log entry */ -export interface InfraLogEntry { - /** A unique representation of the log entry's position in the event stream */ - key: InfraTimeKey; - /** The log entry's id */ - gid: string; - /** The source id */ - source: string; - /** The columns used for rendering the log entry */ - columns: InfraLogEntryColumn[]; -} -/** A special built-in column that contains the log entry's timestamp */ -export interface InfraLogEntryTimestampColumn { - /** The id of the corresponding column configuration */ - columnId: string; - /** The timestamp */ - timestamp: number; -} -/** A special built-in column that contains the log entry's constructed message */ -export interface InfraLogEntryMessageColumn { - /** The id of the corresponding column configuration */ - columnId: string; - /** A list of the formatted log entry segments */ - message: InfraLogMessageSegment[]; -} -/** A segment of the log entry message that was derived from a field */ -export interface InfraLogMessageFieldSegment { - /** The field the segment was derived from */ - field: string; - /** The segment's message */ - value: string; - /** A list of highlighted substrings of the value */ - highlights: string[]; -} -/** A segment of the log entry message that was derived from a string literal */ -export interface InfraLogMessageConstantSegment { - /** The segment's message */ - constant: string; -} -/** A column that contains the value of a field of the log entry */ -export interface InfraLogEntryFieldColumn { - /** The id of the corresponding column configuration */ - columnId: string; - /** The field name of the column */ - field: string; - /** The value of the field in the log entry */ - value: string; - /** A list of highlighted substrings of the value */ - highlights: string[]; -} export interface InfraSnapshotResponse { /** Nodes of type host, container or pod grouped by 0, 1 or 2 terms */ @@ -282,21 +202,6 @@ export interface DeleteSourceResult { // InputTypes // ==================================================== -export interface InfraTimeKeyInput { - time: number; - - tiebreaker: number; -} -/** A highlighting definition */ -export interface InfraLogEntryHighlightInput { - /** The query to highlight by */ - query: string; - /** The number of highlighted documents to include beyond the beginning of the interval */ - countBefore: number; - /** The number of highlighted documents to include beyond the end of the interval */ - countAfter: number; -} - export interface InfraTimerangeInput { /** The interval string to use for last bucket. The format is '{value}{unit}'. For example '5m' would return the metrics for the last 5 minutes of the timespan. */ interval: string; @@ -387,34 +292,6 @@ export interface SourceQueryArgs { /** The id of the source */ id: string; } -export interface LogEntriesAroundInfraSourceArgs { - /** The sort key that corresponds to the point in time */ - key: InfraTimeKeyInput; - /** The maximum number of preceding to return */ - countBefore?: number | null; - /** The maximum number of following to return */ - countAfter?: number | null; - /** The query to filter the log entries by */ - filterQuery?: string | null; -} -export interface LogEntriesBetweenInfraSourceArgs { - /** The sort key that corresponds to the start of the interval */ - startKey: InfraTimeKeyInput; - /** The sort key that corresponds to the end of the interval */ - endKey: InfraTimeKeyInput; - /** The query to filter the log entries by */ - filterQuery?: string | null; -} -export interface LogEntryHighlightsInfraSourceArgs { - /** The sort key that corresponds to the start of the interval */ - startKey: InfraTimeKeyInput; - /** The sort key that corresponds to the end of the interval */ - endKey: InfraTimeKeyInput; - /** The query to filter the log entries by */ - filterQuery?: string | null; - /** The highlighting to apply to the log entries */ - highlights: InfraLogEntryHighlightInput[]; -} export interface SnapshotInfraSourceArgs { timerange: InfraTimerangeInput; @@ -571,15 +448,6 @@ export type InfraSourceLogColumn = | InfraSourceMessageLogColumn | InfraSourceFieldLogColumn; -/** A column of a log entry */ -export type InfraLogEntryColumn = - | InfraLogEntryTimestampColumn - | InfraLogEntryMessageColumn - | InfraLogEntryFieldColumn; - -/** A segment of the log entry message */ -export type InfraLogMessageSegment = InfraLogMessageFieldSegment | InfraLogMessageConstantSegment; - // ==================================================== // END: Typescript template // ==================================================== @@ -588,46 +456,6 @@ export type InfraLogMessageSegment = InfraLogMessageFieldSegment | InfraLogMessa // Documents // ==================================================== -export namespace LogEntryHighlightsQuery { - export type Variables = { - sourceId?: string | null; - startKey: InfraTimeKeyInput; - endKey: InfraTimeKeyInput; - filterQuery?: string | null; - highlights: InfraLogEntryHighlightInput[]; - }; - - export type Query = { - __typename?: 'Query'; - - source: Source; - }; - - export type Source = { - __typename?: 'InfraSource'; - - id: string; - - logEntryHighlights: LogEntryHighlights[]; - }; - - export type LogEntryHighlights = { - __typename?: 'InfraLogEntryInterval'; - - start?: Start | null; - - end?: End | null; - - entries: Entries[]; - }; - - export type Start = InfraTimeKeyFields.Fragment; - - export type End = InfraTimeKeyFields.Fragment; - - export type Entries = InfraLogEntryHighlightFields.Fragment; -} - export namespace MetricsQuery { export type Variables = { sourceId: string; @@ -826,50 +654,6 @@ export namespace WaffleNodesQuery { }; } -export namespace LogEntries { - export type Variables = { - sourceId?: string | null; - timeKey: InfraTimeKeyInput; - countBefore?: number | null; - countAfter?: number | null; - filterQuery?: string | null; - }; - - export type Query = { - __typename?: 'Query'; - - source: Source; - }; - - export type Source = { - __typename?: 'InfraSource'; - - id: string; - - logEntriesAround: LogEntriesAround; - }; - - export type LogEntriesAround = { - __typename?: 'InfraLogEntryInterval'; - - start?: Start | null; - - end?: End | null; - - hasMoreBefore: boolean; - - hasMoreAfter: boolean; - - entries: Entries[]; - }; - - export type Start = InfraTimeKeyFields.Fragment; - - export type End = InfraTimeKeyFields.Fragment; - - export type Entries = InfraLogEntryFields.Fragment; -} - export namespace SourceConfigurationFields { export type Fragment = { __typename?: 'InfraSourceConfiguration'; @@ -1000,124 +784,3 @@ export namespace InfraSourceFields { origin: string; }; } - -export namespace InfraLogEntryFields { - export type Fragment = { - __typename?: 'InfraLogEntry'; - - gid: string; - - key: Key; - - columns: Columns[]; - }; - - export type Key = { - __typename?: 'InfraTimeKey'; - - time: number; - - tiebreaker: number; - }; - - export type Columns = - | InfraLogEntryTimestampColumnInlineFragment - | InfraLogEntryMessageColumnInlineFragment - | InfraLogEntryFieldColumnInlineFragment; - - export type InfraLogEntryTimestampColumnInlineFragment = { - __typename?: 'InfraLogEntryTimestampColumn'; - - columnId: string; - - timestamp: number; - }; - - export type InfraLogEntryMessageColumnInlineFragment = { - __typename?: 'InfraLogEntryMessageColumn'; - - columnId: string; - - message: Message[]; - }; - - export type Message = - | InfraLogMessageFieldSegmentInlineFragment - | InfraLogMessageConstantSegmentInlineFragment; - - export type InfraLogMessageFieldSegmentInlineFragment = { - __typename?: 'InfraLogMessageFieldSegment'; - - field: string; - - value: string; - }; - - export type InfraLogMessageConstantSegmentInlineFragment = { - __typename?: 'InfraLogMessageConstantSegment'; - - constant: string; - }; - - export type InfraLogEntryFieldColumnInlineFragment = { - __typename?: 'InfraLogEntryFieldColumn'; - - columnId: string; - - field: string; - - value: string; - }; -} - -export namespace InfraLogEntryHighlightFields { - export type Fragment = { - __typename?: 'InfraLogEntry'; - - gid: string; - - key: Key; - - columns: Columns[]; - }; - - export type Key = { - __typename?: 'InfraTimeKey'; - - time: number; - - tiebreaker: number; - }; - - export type Columns = - | InfraLogEntryMessageColumnInlineFragment - | InfraLogEntryFieldColumnInlineFragment; - - export type InfraLogEntryMessageColumnInlineFragment = { - __typename?: 'InfraLogEntryMessageColumn'; - - columnId: string; - - message: Message[]; - }; - - export type Message = InfraLogMessageFieldSegmentInlineFragment; - - export type InfraLogMessageFieldSegmentInlineFragment = { - __typename?: 'InfraLogMessageFieldSegment'; - - field: string; - - highlights: string[]; - }; - - export type InfraLogEntryFieldColumnInlineFragment = { - __typename?: 'InfraLogEntryFieldColumn'; - - columnId: string; - - field: string; - - highlights: string[]; - }; -} diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx index e24fdd06bc6d9..83659ace3ce54 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx @@ -127,6 +127,7 @@ export const CategoryExampleMessage: React.FunctionComponent<{ onClick: () => { const logEntry: LogEntry = { id, + index: '', // TODO: use real index when loading via async search context, cursor: { time: timestamp, tiebreaker }, columns: [], diff --git a/x-pack/plugins/infra/public/test_utils/entries.ts b/x-pack/plugins/infra/public/test_utils/entries.ts index 96737fb175365..1633b9d8dc076 100644 --- a/x-pack/plugins/infra/public/test_utils/entries.ts +++ b/x-pack/plugins/infra/public/test_utils/entries.ts @@ -28,6 +28,7 @@ export function generateFakeEntries( const timestamp = i === count - 1 ? endTimestamp : startTimestamp + timestampStep * i; entries.push({ id: `entry-${i}`, + index: 'logs-fake', context: {}, cursor: { time: timestamp, tiebreaker: i }, columns: columns.map((column) => { diff --git a/x-pack/plugins/infra/public/utils/data_search/data_search.stories.mdx b/x-pack/plugins/infra/public/utils/data_search/data_search.stories.mdx index a698b806b4cd7..a8854692caa36 100644 --- a/x-pack/plugins/infra/public/utils/data_search/data_search.stories.mdx +++ b/x-pack/plugins/infra/public/utils/data_search/data_search.stories.mdx @@ -43,15 +43,35 @@ be issued by calling the returned `search()` function. For each new request the hook emits an object describing the request and its state in the `requests$` `Observable`. +Since the specific response shape depends on the data strategy used, the hook +takes a projection function, that is responsible for decoding the response in +an appropriate way. Because most response projections follow a similar pattern +there's a helper `normalizeDataSearchResponses(initialResponse, +parseRawResponse)`, which generates an RxJS operator, that... + +- emits an initial response containing the given `initialResponse` value +- applies `parseRawResponse` to the `rawResponse` property of each emitted response +- transforms transport layer errors as well as parsing errors into + `SearchStrategyError`s + ```typescript +const parseMyCustomSearchResponse = normalizeDataSearchResponses( + 'initial value', + decodeOrThrow(myCustomSearchResponsePayloadRT) +); + const { search, requests$ } = useDataSearch({ getRequest: useCallback((searchTerm: string) => ({ request: { params: { searchTerm - } - } - }), []); + }, + options: { + strategy: 'my-custom-search-strategy', + }, + }, + }), []), + parseResponses: parseMyCustomSearchResponse, }); ``` @@ -68,10 +88,6 @@ observables are unsubscribed from for proper cancellation if a new request has been created. This uses RxJS's `switchMap()` operator under the hood. The hook also makes sure that all observables are unsubscribed from on unmount. -Since the specific response shape depends on the data strategy used, the hook -takes a projection function, that is responsible for decoding the response in -an appropriate way. - A request can fail due to various reasons that include servers-side errors, Elasticsearch shard failures and network failures. The intention is to map all of them to a common `SearchStrategyError` interface. While the @@ -94,11 +110,7 @@ const { latestResponseErrors, loaded, total, -} = useLatestPartialDataSearchResponse( - requests$, - 'initialValue', - useMemo(() => decodeOrThrow(mySearchStrategyResponsePayloadRT), []), -); +} = useLatestPartialDataSearchResponse(requests$); ``` ## Representing the request state to the user diff --git a/x-pack/plugins/infra/public/utils/data_search/flatten_data_search_response.ts b/x-pack/plugins/infra/public/utils/data_search/flatten_data_search_response.ts new file mode 100644 index 0000000000000..98df6d441bd80 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/flatten_data_search_response.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { map } from 'rxjs/operators'; +import { IKibanaSearchRequest } from '../../../../../../src/plugins/data/public'; +import { ParsedDataSearchRequestDescriptor } from './types'; + +export const flattenDataSearchResponseDescriptor = < + Request extends IKibanaSearchRequest, + Response +>({ + abortController, + options, + request, + response$, +}: ParsedDataSearchRequestDescriptor) => + response$.pipe( + map((response) => { + return { + abortController, + options, + request, + response, + }; + }) + ); diff --git a/x-pack/plugins/infra/public/utils/data_search/index.ts b/x-pack/plugins/infra/public/utils/data_search/index.ts index c08ab0727fd90..10beba4aa4fdc 100644 --- a/x-pack/plugins/infra/public/utils/data_search/index.ts +++ b/x-pack/plugins/infra/public/utils/data_search/index.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './flatten_data_search_response'; +export * from './normalize_data_search_responses'; export * from './types'; export * from './use_data_search_request'; +export * from './use_data_search_response_state'; export * from './use_latest_partial_data_search_response'; diff --git a/x-pack/plugins/infra/public/utils/data_search/normalize_data_search_responses.ts b/x-pack/plugins/infra/public/utils/data_search/normalize_data_search_responses.ts new file mode 100644 index 0000000000000..5046cc128a835 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/normalize_data_search_responses.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable, of } from 'rxjs'; +import { catchError, map, startWith } from 'rxjs/operators'; +import { IKibanaSearchResponse } from '../../../../../../src/plugins/data/public'; +import { AbortError } from '../../../../../../src/plugins/kibana_utils/public'; +import { SearchStrategyError } from '../../../common/search_strategies/common/errors'; +import { ParsedKibanaSearchResponse } from './types'; + +export type RawResponseParser = ( + rawResponse: RawResponse +) => { data: Response; errors?: SearchStrategyError[] }; + +/** + * An operator factory that normalizes each {@link IKibanaSearchResponse} by + * parsing it into a {@link ParsedKibanaSearchResponse} and adding initial + * responses and error handling. + * + * @param initialResponse - The initial value to emit when a new request is + * handled. + * @param projectResponse - The projection function to apply to each response + * payload. It should validate that the response payload is of the type {@link + * RawResponse} and decode it to a {@link Response}. + * + * @return An operator that adds parsing and error handling transformations to + * each response payload using the arguments given above. + */ +export const normalizeDataSearchResponses = ( + initialResponse: InitialResponse, + parseRawResponse: RawResponseParser +) => ( + response$: Observable> +): Observable> => + response$.pipe( + map((response) => { + const { data, errors = [] } = parseRawResponse(response.rawResponse); + return { + data, + errors, + isPartial: response.isPartial ?? false, + isRunning: response.isRunning ?? false, + loaded: response.loaded, + total: response.total, + }; + }), + startWith({ + data: initialResponse, + errors: [], + isPartial: true, + isRunning: true, + loaded: 0, + total: undefined, + }), + catchError((error) => + of({ + data: initialResponse, + errors: [ + error instanceof AbortError + ? { + type: 'aborted' as const, + } + : { + type: 'generic' as const, + message: `${error.message ?? error}`, + }, + ], + isPartial: true, + isRunning: false, + loaded: 0, + total: undefined, + }) + ) + ); diff --git a/x-pack/plugins/infra/public/utils/data_search/types.ts b/x-pack/plugins/infra/public/utils/data_search/types.ts index ba0a4c639dae4..4fcb5898ea5bd 100644 --- a/x-pack/plugins/infra/public/utils/data_search/types.ts +++ b/x-pack/plugins/infra/public/utils/data_search/types.ts @@ -19,7 +19,17 @@ export interface DataSearchRequestDescriptor { +export interface ParsedDataSearchRequestDescriptor< + Request extends IKibanaSearchRequest, + ResponseData +> { + request: Request; + options: ISearchOptions; + response$: Observable>; + abortController: AbortController; +} + +export interface ParsedKibanaSearchResponse { total?: number; loaded?: number; isRunning: boolean; @@ -28,9 +38,12 @@ export interface NormalizedKibanaSearchResponse { errors: SearchStrategyError[]; } -export interface DataSearchResponseDescriptor { +export interface ParsedDataSearchResponseDescriptor< + Request extends IKibanaSearchRequest, + Response +> { request: Request; options: ISearchOptions; - response: NormalizedKibanaSearchResponse; + response: ParsedKibanaSearchResponse; abortController: AbortController; } diff --git a/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.test.tsx b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.test.tsx index 87c091f12ad90..780476abb7b1b 100644 --- a/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.test.tsx +++ b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.test.tsx @@ -17,6 +17,7 @@ import { import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; import { createKibanaReactContext } from '../../../../../../src/plugins/kibana_react/public'; import { PluginKibanaContextValue } from '../../hooks/use_kibana'; +import { normalizeDataSearchResponses } from './normalize_data_search_responses'; import { useDataSearch } from './use_data_search_request'; describe('useDataSearch hook', () => { @@ -34,6 +35,7 @@ describe('useDataSearch hook', () => { () => useDataSearch({ getRequest, + parseResponses: noopParseResponse, }), { wrapper: ({ children }) => {children}, @@ -48,7 +50,7 @@ describe('useDataSearch hook', () => { expect(dataMock.search.search).not.toHaveBeenCalled(); }); - it('creates search requests with the given params and options', async () => { + it('creates search requests with the given params and options and parses the responses', async () => { const dataMock = createDataPluginMock(); const searchResponseMock$ = of({ rawResponse: { @@ -78,6 +80,7 @@ describe('useDataSearch hook', () => { () => useDataSearch({ getRequest, + parseResponses: noopParseResponse, }), { wrapper: ({ children }) => {children}, @@ -112,10 +115,11 @@ describe('useDataSearch hook', () => { }); expect(firstRequest).toHaveProperty('options.strategy', 'test-search-strategy'); expect(firstRequest).toHaveProperty('response$', expect.any(Observable)); - await expect(firstRequest.response$.toPromise()).resolves.toEqual({ - rawResponse: { - firstKey: 'firstValue', + await expect(firstRequest.response$.toPromise()).resolves.toMatchObject({ + data: { + firstKey: 'firstValue', // because this specific response parser just copies the raw response }, + errors: [], }); }); @@ -145,6 +149,7 @@ describe('useDataSearch hook', () => { () => useDataSearch({ getRequest, + parseResponses: noopParseResponse, }), { wrapper: ({ children }) => {children}, @@ -186,3 +191,8 @@ const createDataPluginMock = () => { }; return dataMock; }; + +const noopParseResponse = normalizeDataSearchResponses( + null, + (response: Response) => ({ data: response }) +); diff --git a/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts index a23f06adc0353..0f1686a93be82 100644 --- a/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts +++ b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts @@ -5,8 +5,8 @@ */ import { useCallback } from 'react'; -import { Subject } from 'rxjs'; -import { map, share, switchMap, tap } from 'rxjs/operators'; +import { OperatorFunction, Subject } from 'rxjs'; +import { share, tap } from 'rxjs/operators'; import { IKibanaSearchRequest, IKibanaSearchResponse, @@ -14,6 +14,7 @@ import { } from '../../../../../../src/plugins/data/public'; import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; import { tapUnsubscribe, useObservable } from '../use_observable'; +import { ParsedDataSearchRequestDescriptor, ParsedKibanaSearchResponse } from './types'; export type DataSearchRequestFactory = ( ...args: Args @@ -25,69 +26,74 @@ export type DataSearchRequestFactory = OperatorFunction< + IKibanaSearchResponse, + ParsedKibanaSearchResponse +>; + export const useDataSearch = < RequestFactoryArgs extends any[], - Request extends IKibanaSearchRequest, - RawResponse + RequestParams, + Request extends IKibanaSearchRequest, + RawResponse, + Response >({ getRequest, + parseResponses, }: { getRequest: DataSearchRequestFactory; + parseResponses: ParseResponsesOperator; }) => { const { services } = useKibanaContextForPlugin(); - const request$ = useObservable( - () => new Subject<{ request: Request; options: ISearchOptions }>(), - [] - ); const requests$ = useObservable( - (inputs$) => - inputs$.pipe( - switchMap(([currentRequest$]) => currentRequest$), - map(({ request, options }) => { - const abortController = new AbortController(); - let isAbortable = true; - - return { - abortController, - request, - options, - response$: services.data.search - .search>(request, { - abortSignal: abortController.signal, - ...options, - }) - .pipe( - // avoid aborting failed or completed requests - tap({ - error: () => { - isAbortable = false; - }, - complete: () => { - isAbortable = false; - }, - }), - tapUnsubscribe(() => { - if (isAbortable) { - abortController.abort(); - } - }), - share() - ), - }; - }) - ), - [request$] + () => new Subject>(), + [] ); const search = useCallback( (...args: RequestFactoryArgs) => { - const request = getRequest(...args); + const requestArgs = getRequest(...args); - if (request) { - request$.next(request); + if (requestArgs == null) { + return; } + + const abortController = new AbortController(); + let isAbortable = true; + + const newRequestDescriptor = { + ...requestArgs, + abortController, + response$: services.data.search + .search>(requestArgs.request, { + abortSignal: abortController.signal, + ...requestArgs.options, + }) + .pipe( + // avoid aborting failed or completed requests + tap({ + error: () => { + isAbortable = false; + }, + complete: () => { + isAbortable = false; + }, + }), + tapUnsubscribe(() => { + if (isAbortable) { + abortController.abort(); + } + }), + parseResponses, + share() + ), + }; + + requests$.next(newRequestDescriptor); + + return newRequestDescriptor; }, - [getRequest, request$] + [getRequest, services.data.search, parseResponses, requests$] ); return { diff --git a/x-pack/plugins/infra/public/utils/data_search/use_data_search_response_state.ts b/x-pack/plugins/infra/public/utils/data_search/use_data_search_response_state.ts new file mode 100644 index 0000000000000..3b37b80f26cdc --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/use_data_search_response_state.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback } from 'react'; +import { Observable } from 'rxjs'; +import { IKibanaSearchRequest } from '../../../../../../src/plugins/data/public'; +import { useObservableState } from '../use_observable'; +import { ParsedDataSearchResponseDescriptor } from './types'; + +export const useDataSearchResponseState = < + Request extends IKibanaSearchRequest, + Response, + InitialResponse +>( + response$: Observable> +) => { + const { latestValue } = useObservableState(response$, undefined); + + const cancelRequest = useCallback(() => { + latestValue?.abortController.abort(); + }, [latestValue]); + + return { + cancelRequest, + isRequestRunning: latestValue?.response.isRunning ?? false, + isResponsePartial: latestValue?.response.isPartial ?? false, + latestResponseData: latestValue?.response.data, + latestResponseErrors: latestValue?.response.errors, + loaded: latestValue?.response.loaded, + total: latestValue?.response.total, + }; +}; diff --git a/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.test.tsx b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.test.tsx index 4c336aa1107a2..864d92f43bc17 100644 --- a/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.test.tsx +++ b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.test.tsx @@ -5,12 +5,9 @@ */ import { act, renderHook } from '@testing-library/react-hooks'; -import { Observable, of, Subject } from 'rxjs'; -import { - IKibanaSearchRequest, - IKibanaSearchResponse, -} from '../../../../../../src/plugins/data/public'; -import { DataSearchRequestDescriptor } from './types'; +import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; +import { IKibanaSearchRequest } from '../../../../../../src/plugins/data/public'; +import { ParsedDataSearchRequestDescriptor, ParsedKibanaSearchResponse } from './types'; import { useLatestPartialDataSearchResponse } from './use_latest_partial_data_search_response'; describe('useLatestPartialDataSearchResponse hook', () => { @@ -19,25 +16,31 @@ describe('useLatestPartialDataSearchResponse hook', () => { abortController: new AbortController(), options: {}, request: { params: 'firstRequestParam' }, - response$: new Subject>(), + response$: new BehaviorSubject>({ + data: 'initial', + isRunning: true, + isPartial: true, + errors: [], + }), }; const secondRequest = { abortController: new AbortController(), options: {}, request: { params: 'secondRequestParam' }, - response$: new Subject>(), + response$: new BehaviorSubject>({ + data: 'initial', + isRunning: true, + isPartial: true, + errors: [], + }), }; const requests$ = new Subject< - DataSearchRequestDescriptor, string> + ParsedDataSearchRequestDescriptor, string> >(); - const { result } = renderHook(() => - useLatestPartialDataSearchResponse(requests$, 'initial', (response) => ({ - data: `projection of ${response}`, - })) - ); + const { result } = renderHook(() => useLatestPartialDataSearchResponse(requests$)); expect(result).toHaveProperty('current.isRequestRunning', false); expect(result).toHaveProperty('current.latestResponseData', undefined); @@ -52,37 +55,43 @@ describe('useLatestPartialDataSearchResponse hook', () => { // first response of the first request arrives act(() => { - firstRequest.response$.next({ rawResponse: 'request-1-response-1', isRunning: true }); + firstRequest.response$.next({ + data: 'request-1-response-1', + isRunning: true, + isPartial: true, + errors: [], + }); }); expect(result).toHaveProperty('current.isRequestRunning', true); - expect(result).toHaveProperty( - 'current.latestResponseData', - 'projection of request-1-response-1' - ); + expect(result).toHaveProperty('current.latestResponseData', 'request-1-response-1'); // second request is started before the second response of the first request arrives act(() => { requests$.next(secondRequest); - secondRequest.response$.next({ rawResponse: 'request-2-response-1', isRunning: true }); + secondRequest.response$.next({ + data: 'request-2-response-1', + isRunning: true, + isPartial: true, + errors: [], + }); }); expect(result).toHaveProperty('current.isRequestRunning', true); - expect(result).toHaveProperty( - 'current.latestResponseData', - 'projection of request-2-response-1' - ); + expect(result).toHaveProperty('current.latestResponseData', 'request-2-response-1'); // second response of the second request arrives act(() => { - secondRequest.response$.next({ rawResponse: 'request-2-response-2', isRunning: false }); + secondRequest.response$.next({ + data: 'request-2-response-2', + isRunning: false, + isPartial: false, + errors: [], + }); }); expect(result).toHaveProperty('current.isRequestRunning', false); - expect(result).toHaveProperty( - 'current.latestResponseData', - 'projection of request-2-response-2' - ); + expect(result).toHaveProperty('current.latestResponseData', 'request-2-response-2'); }); it("unsubscribes from the latest request's response observable on unmount", () => { @@ -92,20 +101,16 @@ describe('useLatestPartialDataSearchResponse hook', () => { abortController: new AbortController(), options: {}, request: { params: 'firstRequestParam' }, - response$: new Observable>(() => { + response$: new Observable>(() => { return onUnsubscribe; }), }; - const requests$ = of, string>>( + const requests$ = of, string>>( firstRequest ); - const { unmount } = renderHook(() => - useLatestPartialDataSearchResponse(requests$, 'initial', (response) => ({ - data: `projection of ${response}`, - })) - ); + const { unmount } = renderHook(() => useLatestPartialDataSearchResponse(requests$)); expect(onUnsubscribe).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.ts b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.ts index 71fd96283d0ef..9366df8adbaf7 100644 --- a/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.ts +++ b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.ts @@ -4,111 +4,40 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useCallback } from 'react'; -import { Observable, of } from 'rxjs'; -import { catchError, map, startWith, switchMap } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; import { IKibanaSearchRequest } from '../../../../../../src/plugins/data/public'; -import { AbortError } from '../../../../../../src/plugins/kibana_utils/public'; -import { SearchStrategyError } from '../../../common/search_strategies/common/errors'; -import { useLatest, useObservable, useObservableState } from '../use_observable'; -import { DataSearchRequestDescriptor, DataSearchResponseDescriptor } from './types'; +import { useOperator } from '../use_observable'; +import { flattenDataSearchResponseDescriptor } from './flatten_data_search_response'; +import { ParsedDataSearchRequestDescriptor, ParsedDataSearchResponseDescriptor } from './types'; +import { useDataSearchResponseState } from './use_data_search_response_state'; -export const useLatestPartialDataSearchResponse = < - Request extends IKibanaSearchRequest, - RawResponse, - Response, - InitialResponse ->( - requests$: Observable>, - initialResponse: InitialResponse, - projectResponse: (rawResponse: RawResponse) => { data: Response; errors?: SearchStrategyError[] } +export const useLatestPartialDataSearchResponse = ( + requests$: Observable> ) => { - const latestInitialResponse = useLatest(initialResponse); - const latestProjectResponse = useLatest(projectResponse); - const latestResponse$: Observable< - DataSearchResponseDescriptor - > = useObservable( - (inputs$) => - inputs$.pipe( - switchMap(([currentRequests$]) => - currentRequests$.pipe( - switchMap(({ abortController, options, request, response$ }) => - response$.pipe( - map((response) => { - const { data, errors = [] } = latestProjectResponse.current(response.rawResponse); - return { - abortController, - options, - request, - response: { - data, - errors, - isPartial: response.isPartial ?? false, - isRunning: response.isRunning ?? false, - loaded: response.loaded, - total: response.total, - }, - }; - }), - startWith({ - abortController, - options, - request, - response: { - data: latestInitialResponse.current, - errors: [], - isPartial: true, - isRunning: true, - loaded: 0, - total: undefined, - }, - }), - catchError((error) => - of({ - abortController, - options, - request, - response: { - data: latestInitialResponse.current, - errors: [ - error instanceof AbortError - ? { - type: 'aborted' as const, - } - : { - type: 'generic' as const, - message: `${error.message ?? error}`, - }, - ], - isPartial: true, - isRunning: false, - loaded: 0, - total: undefined, - }, - }) - ) - ) - ) - ) - ) - ), - [requests$] as const - ); - - const { latestValue } = useObservableState(latestResponse$, undefined); + ParsedDataSearchResponseDescriptor + > = useOperator(requests$, flattenLatestDataSearchResponse); - const cancelRequest = useCallback(() => { - latestValue?.abortController.abort(); - }, [latestValue]); + const { + cancelRequest, + isRequestRunning, + isResponsePartial, + latestResponseData, + latestResponseErrors, + loaded, + total, + } = useDataSearchResponseState(latestResponse$); return { cancelRequest, - isRequestRunning: latestValue?.response.isRunning ?? false, - isResponsePartial: latestValue?.response.isPartial ?? false, - latestResponseData: latestValue?.response.data, - latestResponseErrors: latestValue?.response.errors, - loaded: latestValue?.response.loaded, - total: latestValue?.response.total, + isRequestRunning, + isResponsePartial, + latestResponseData, + latestResponseErrors, + loaded, + total, }; }; + +const flattenLatestDataSearchResponse = switchMap(flattenDataSearchResponseDescriptor); diff --git a/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts b/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts index c69104ad6177e..60034aea6be63 100644 --- a/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts +++ b/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts @@ -5,9 +5,7 @@ */ import { bisector } from 'd3-array'; - import { compareToTimeKey, getIndexAtTimeKey, TimeKey, UniqueTimeKey } from '../../../common/time'; -import { InfraLogEntryFields } from '../../graphql/types'; import { LogEntry, LogColumn, @@ -19,10 +17,6 @@ import { LogMessageConstantPart, } from '../../../common/log_entry'; -export type LogEntryMessageSegment = InfraLogEntryFields.Message; -export type LogEntryConstantMessageSegment = InfraLogEntryFields.InfraLogMessageConstantSegmentInlineFragment; -export type LogEntryFieldMessageSegment = InfraLogEntryFields.InfraLogMessageFieldSegmentInlineFragment; - export const getLogEntryKey = (entry: { cursor: TimeKey }) => entry.cursor; export const getUniqueLogEntryKey = (entry: { id: string; cursor: TimeKey }): UniqueTimeKey => ({ diff --git a/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts b/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts index 208316c693d4d..e14d938c426f9 100644 --- a/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts +++ b/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { InfraLogEntryHighlightFields } from '../../graphql/types'; import { LogEntry, LogColumn, @@ -14,13 +13,6 @@ import { LogMessageFieldPart, } from '../../../common/log_entry'; -export type LogEntryHighlightColumn = InfraLogEntryHighlightFields.Columns; -export type LogEntryHighlightMessageColumn = InfraLogEntryHighlightFields.InfraLogEntryMessageColumnInlineFragment; -export type LogEntryHighlightFieldColumn = InfraLogEntryHighlightFields.InfraLogEntryFieldColumnInlineFragment; - -export type LogEntryHighlightMessageSegment = InfraLogEntryHighlightFields.Message | {}; -export type LogEntryHighlightFieldMessageSegment = InfraLogEntryHighlightFields.InfraLogMessageFieldSegmentInlineFragment; - export interface LogEntryHighlightsMap { [entryId: string]: LogEntry[]; } diff --git a/x-pack/plugins/infra/public/utils/use_observable.ts b/x-pack/plugins/infra/public/utils/use_observable.ts index 342aa5aa797b1..508684f8d7268 100644 --- a/x-pack/plugins/infra/public/utils/use_observable.ts +++ b/x-pack/plugins/infra/public/utils/use_observable.ts @@ -5,7 +5,8 @@ */ import { useEffect, useRef, useState } from 'react'; -import { BehaviorSubject, Observable, PartialObserver, Subscription } from 'rxjs'; +import { BehaviorSubject, Observable, OperatorFunction, PartialObserver, Subscription } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; export const useLatest = (value: Value) => { const valueRef = useRef(value); @@ -62,7 +63,9 @@ export const useSubscription = ( const fixedUnsubscribe = latestUnsubscribe.current; const subscription = input$.subscribe({ - next: (value) => latestNext.current?.(value), + next: (value) => { + return latestNext.current?.(value); + }, error: (value) => latestError.current?.(value), complete: () => latestComplete.current?.(), }); @@ -78,6 +81,19 @@ export const useSubscription = ( return latestSubscription.current; }; +export const useOperator = ( + input$: Observable, + operator: OperatorFunction +) => { + const latestOperator = useLatest(operator); + + return useObservable( + (inputs$) => + inputs$.pipe(switchMap(([currentInput$]) => latestOperator.current(currentInput$))), + [input$] as const + ); +}; + export const tapUnsubscribe = (onUnsubscribe: () => void) => (source$: Observable) => { return new Observable((subscriber) => { const subscription = source$.subscribe({ diff --git a/x-pack/plugins/infra/server/graphql/types.ts b/x-pack/plugins/infra/server/graphql/types.ts index 02dcd76e8b34c..712438ce2bfe0 100644 --- a/x-pack/plugins/infra/server/graphql/types.ts +++ b/x-pack/plugins/infra/server/graphql/types.ts @@ -56,12 +56,6 @@ export interface InfraSource { configuration: InfraSourceConfiguration; /** The status of the source */ status: InfraSourceStatus; - /** A consecutive span of log entries surrounding a point in time */ - logEntriesAround: InfraLogEntryInterval; - /** A consecutive span of log entries within an interval */ - logEntriesBetween: InfraLogEntryInterval; - /** Sequences of log entries matching sets of highlighting queries within an interval */ - logEntryHighlights: InfraLogEntryInterval[]; /** A snapshot of nodes */ snapshot?: InfraSnapshotResponse | null; @@ -157,80 +151,6 @@ export interface InfraIndexField { /** Whether the field should be displayed based on event.module and a ECS allowed list */ displayable: boolean; } -/** A consecutive sequence of log entries */ -export interface InfraLogEntryInterval { - /** The key corresponding to the start of the interval covered by the entries */ - start?: InfraTimeKey | null; - /** The key corresponding to the end of the interval covered by the entries */ - end?: InfraTimeKey | null; - /** Whether there are more log entries available before the start */ - hasMoreBefore: boolean; - /** Whether there are more log entries available after the end */ - hasMoreAfter: boolean; - /** The query the log entries were filtered by */ - filterQuery?: string | null; - /** The query the log entries were highlighted with */ - highlightQuery?: string | null; - /** A list of the log entries */ - entries: InfraLogEntry[]; -} -/** A representation of the log entry's position in the event stream */ -export interface InfraTimeKey { - /** The timestamp of the event that the log entry corresponds to */ - time: number; - /** The tiebreaker that disambiguates events with the same timestamp */ - tiebreaker: number; -} -/** A log entry */ -export interface InfraLogEntry { - /** A unique representation of the log entry's position in the event stream */ - key: InfraTimeKey; - /** The log entry's id */ - gid: string; - /** The source id */ - source: string; - /** The columns used for rendering the log entry */ - columns: InfraLogEntryColumn[]; -} -/** A special built-in column that contains the log entry's timestamp */ -export interface InfraLogEntryTimestampColumn { - /** The id of the corresponding column configuration */ - columnId: string; - /** The timestamp */ - timestamp: number; -} -/** A special built-in column that contains the log entry's constructed message */ -export interface InfraLogEntryMessageColumn { - /** The id of the corresponding column configuration */ - columnId: string; - /** A list of the formatted log entry segments */ - message: InfraLogMessageSegment[]; -} -/** A segment of the log entry message that was derived from a field */ -export interface InfraLogMessageFieldSegment { - /** The field the segment was derived from */ - field: string; - /** The segment's message */ - value: string; - /** A list of highlighted substrings of the value */ - highlights: string[]; -} -/** A segment of the log entry message that was derived from a string literal */ -export interface InfraLogMessageConstantSegment { - /** The segment's message */ - constant: string; -} -/** A column that contains the value of a field of the log entry */ -export interface InfraLogEntryFieldColumn { - /** The id of the corresponding column configuration */ - columnId: string; - /** The field name of the column */ - field: string; - /** The value of the field in the log entry */ - value: string; - /** A list of highlighted substrings of the value */ - highlights: string[]; -} export interface InfraSnapshotResponse { /** Nodes of type host, container or pod grouped by 0, 1 or 2 terms */ @@ -304,21 +224,6 @@ export interface DeleteSourceResult { // InputTypes // ==================================================== -export interface InfraTimeKeyInput { - time: number; - - tiebreaker: number; -} -/** A highlighting definition */ -export interface InfraLogEntryHighlightInput { - /** The query to highlight by */ - query: string; - /** The number of highlighted documents to include beyond the beginning of the interval */ - countBefore: number; - /** The number of highlighted documents to include beyond the end of the interval */ - countAfter: number; -} - export interface InfraTimerangeInput { /** The interval string to use for last bucket. The format is '{value}{unit}'. For example '5m' would return the metrics for the last 5 minutes of the timespan. */ interval: string; @@ -409,34 +314,6 @@ export interface SourceQueryArgs { /** The id of the source */ id: string; } -export interface LogEntriesAroundInfraSourceArgs { - /** The sort key that corresponds to the point in time */ - key: InfraTimeKeyInput; - /** The maximum number of preceding to return */ - countBefore?: number | null; - /** The maximum number of following to return */ - countAfter?: number | null; - /** The query to filter the log entries by */ - filterQuery?: string | null; -} -export interface LogEntriesBetweenInfraSourceArgs { - /** The sort key that corresponds to the start of the interval */ - startKey: InfraTimeKeyInput; - /** The sort key that corresponds to the end of the interval */ - endKey: InfraTimeKeyInput; - /** The query to filter the log entries by */ - filterQuery?: string | null; -} -export interface LogEntryHighlightsInfraSourceArgs { - /** The sort key that corresponds to the start of the interval */ - startKey: InfraTimeKeyInput; - /** The sort key that corresponds to the end of the interval */ - endKey: InfraTimeKeyInput; - /** The query to filter the log entries by */ - filterQuery?: string | null; - /** The highlighting to apply to the log entries */ - highlights: InfraLogEntryHighlightInput[]; -} export interface SnapshotInfraSourceArgs { timerange: InfraTimerangeInput; @@ -593,15 +470,6 @@ export type InfraSourceLogColumn = | InfraSourceMessageLogColumn | InfraSourceFieldLogColumn; -/** A column of a log entry */ -export type InfraLogEntryColumn = - | InfraLogEntryTimestampColumn - | InfraLogEntryMessageColumn - | InfraLogEntryFieldColumn; - -/** A segment of the log entry message */ -export type InfraLogMessageSegment = InfraLogMessageFieldSegment | InfraLogMessageConstantSegment; - // ==================================================== // END: Typescript template // ==================================================== @@ -650,12 +518,6 @@ export namespace InfraSourceResolvers { configuration?: ConfigurationResolver; /** The status of the source */ status?: StatusResolver; - /** A consecutive span of log entries surrounding a point in time */ - logEntriesAround?: LogEntriesAroundResolver; - /** A consecutive span of log entries within an interval */ - logEntriesBetween?: LogEntriesBetweenResolver; - /** Sequences of log entries matching sets of highlighting queries within an interval */ - logEntryHighlights?: LogEntryHighlightsResolver; /** A snapshot of nodes */ snapshot?: SnapshotResolver; @@ -693,51 +555,6 @@ export namespace InfraSourceResolvers { Parent = InfraSource, Context = InfraContext > = Resolver; - export type LogEntriesAroundResolver< - R = InfraLogEntryInterval, - Parent = InfraSource, - Context = InfraContext - > = Resolver; - export interface LogEntriesAroundArgs { - /** The sort key that corresponds to the point in time */ - key: InfraTimeKeyInput; - /** The maximum number of preceding to return */ - countBefore?: number | null; - /** The maximum number of following to return */ - countAfter?: number | null; - /** The query to filter the log entries by */ - filterQuery?: string | null; - } - - export type LogEntriesBetweenResolver< - R = InfraLogEntryInterval, - Parent = InfraSource, - Context = InfraContext - > = Resolver; - export interface LogEntriesBetweenArgs { - /** The sort key that corresponds to the start of the interval */ - startKey: InfraTimeKeyInput; - /** The sort key that corresponds to the end of the interval */ - endKey: InfraTimeKeyInput; - /** The query to filter the log entries by */ - filterQuery?: string | null; - } - - export type LogEntryHighlightsResolver< - R = InfraLogEntryInterval[], - Parent = InfraSource, - Context = InfraContext - > = Resolver; - export interface LogEntryHighlightsArgs { - /** The sort key that corresponds to the start of the interval */ - startKey: InfraTimeKeyInput; - /** The sort key that corresponds to the end of the interval */ - endKey: InfraTimeKeyInput; - /** The query to filter the log entries by */ - filterQuery?: string | null; - /** The highlighting to apply to the log entries */ - highlights: InfraLogEntryHighlightInput[]; - } export type SnapshotResolver< R = InfraSnapshotResponse | null, @@ -1059,229 +876,6 @@ export namespace InfraIndexFieldResolvers { Context = InfraContext > = Resolver; } -/** A consecutive sequence of log entries */ -export namespace InfraLogEntryIntervalResolvers { - export interface Resolvers { - /** The key corresponding to the start of the interval covered by the entries */ - start?: StartResolver; - /** The key corresponding to the end of the interval covered by the entries */ - end?: EndResolver; - /** Whether there are more log entries available before the start */ - hasMoreBefore?: HasMoreBeforeResolver; - /** Whether there are more log entries available after the end */ - hasMoreAfter?: HasMoreAfterResolver; - /** The query the log entries were filtered by */ - filterQuery?: FilterQueryResolver; - /** The query the log entries were highlighted with */ - highlightQuery?: HighlightQueryResolver; - /** A list of the log entries */ - entries?: EntriesResolver; - } - - export type StartResolver< - R = InfraTimeKey | null, - Parent = InfraLogEntryInterval, - Context = InfraContext - > = Resolver; - export type EndResolver< - R = InfraTimeKey | null, - Parent = InfraLogEntryInterval, - Context = InfraContext - > = Resolver; - export type HasMoreBeforeResolver< - R = boolean, - Parent = InfraLogEntryInterval, - Context = InfraContext - > = Resolver; - export type HasMoreAfterResolver< - R = boolean, - Parent = InfraLogEntryInterval, - Context = InfraContext - > = Resolver; - export type FilterQueryResolver< - R = string | null, - Parent = InfraLogEntryInterval, - Context = InfraContext - > = Resolver; - export type HighlightQueryResolver< - R = string | null, - Parent = InfraLogEntryInterval, - Context = InfraContext - > = Resolver; - export type EntriesResolver< - R = InfraLogEntry[], - Parent = InfraLogEntryInterval, - Context = InfraContext - > = Resolver; -} -/** A representation of the log entry's position in the event stream */ -export namespace InfraTimeKeyResolvers { - export interface Resolvers { - /** The timestamp of the event that the log entry corresponds to */ - time?: TimeResolver; - /** The tiebreaker that disambiguates events with the same timestamp */ - tiebreaker?: TiebreakerResolver; - } - - export type TimeResolver = Resolver< - R, - Parent, - Context - >; - export type TiebreakerResolver< - R = number, - Parent = InfraTimeKey, - Context = InfraContext - > = Resolver; -} -/** A log entry */ -export namespace InfraLogEntryResolvers { - export interface Resolvers { - /** A unique representation of the log entry's position in the event stream */ - key?: KeyResolver; - /** The log entry's id */ - gid?: GidResolver; - /** The source id */ - source?: SourceResolver; - /** The columns used for rendering the log entry */ - columns?: ColumnsResolver; - } - - export type KeyResolver< - R = InfraTimeKey, - Parent = InfraLogEntry, - Context = InfraContext - > = Resolver; - export type GidResolver = Resolver< - R, - Parent, - Context - >; - export type SourceResolver = Resolver< - R, - Parent, - Context - >; - export type ColumnsResolver< - R = InfraLogEntryColumn[], - Parent = InfraLogEntry, - Context = InfraContext - > = Resolver; -} -/** A special built-in column that contains the log entry's timestamp */ -export namespace InfraLogEntryTimestampColumnResolvers { - export interface Resolvers { - /** The id of the corresponding column configuration */ - columnId?: ColumnIdResolver; - /** The timestamp */ - timestamp?: TimestampResolver; - } - - export type ColumnIdResolver< - R = string, - Parent = InfraLogEntryTimestampColumn, - Context = InfraContext - > = Resolver; - export type TimestampResolver< - R = number, - Parent = InfraLogEntryTimestampColumn, - Context = InfraContext - > = Resolver; -} -/** A special built-in column that contains the log entry's constructed message */ -export namespace InfraLogEntryMessageColumnResolvers { - export interface Resolvers { - /** The id of the corresponding column configuration */ - columnId?: ColumnIdResolver; - /** A list of the formatted log entry segments */ - message?: MessageResolver; - } - - export type ColumnIdResolver< - R = string, - Parent = InfraLogEntryMessageColumn, - Context = InfraContext - > = Resolver; - export type MessageResolver< - R = InfraLogMessageSegment[], - Parent = InfraLogEntryMessageColumn, - Context = InfraContext - > = Resolver; -} -/** A segment of the log entry message that was derived from a field */ -export namespace InfraLogMessageFieldSegmentResolvers { - export interface Resolvers { - /** The field the segment was derived from */ - field?: FieldResolver; - /** The segment's message */ - value?: ValueResolver; - /** A list of highlighted substrings of the value */ - highlights?: HighlightsResolver; - } - - export type FieldResolver< - R = string, - Parent = InfraLogMessageFieldSegment, - Context = InfraContext - > = Resolver; - export type ValueResolver< - R = string, - Parent = InfraLogMessageFieldSegment, - Context = InfraContext - > = Resolver; - export type HighlightsResolver< - R = string[], - Parent = InfraLogMessageFieldSegment, - Context = InfraContext - > = Resolver; -} -/** A segment of the log entry message that was derived from a string literal */ -export namespace InfraLogMessageConstantSegmentResolvers { - export interface Resolvers { - /** The segment's message */ - constant?: ConstantResolver; - } - - export type ConstantResolver< - R = string, - Parent = InfraLogMessageConstantSegment, - Context = InfraContext - > = Resolver; -} -/** A column that contains the value of a field of the log entry */ -export namespace InfraLogEntryFieldColumnResolvers { - export interface Resolvers { - /** The id of the corresponding column configuration */ - columnId?: ColumnIdResolver; - /** The field name of the column */ - field?: FieldResolver; - /** The value of the field in the log entry */ - value?: ValueResolver; - /** A list of highlighted substrings of the value */ - highlights?: HighlightsResolver; - } - - export type ColumnIdResolver< - R = string, - Parent = InfraLogEntryFieldColumn, - Context = InfraContext - > = Resolver; - export type FieldResolver< - R = string, - Parent = InfraLogEntryFieldColumn, - Context = InfraContext - > = Resolver; - export type ValueResolver< - R = string, - Parent = InfraLogEntryFieldColumn, - Context = InfraContext - > = Resolver; - export type HighlightsResolver< - R = string[], - Parent = InfraLogEntryFieldColumn, - Context = InfraContext - > = Resolver; -} export namespace InfraSnapshotResponseResolvers { export interface Resolvers { diff --git a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts index ffbc750af14f8..6702a43cb2316 100644 --- a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts @@ -215,6 +215,7 @@ function mapHitsToLogEntryDocuments(hits: SortedSearchHit[], fields: string[]): return { id: hit._id, + index: hit._index, cursor: { time: hit.sort[0], tiebreaker: hit.sort[1] }, fields: logFields, highlights: hit.highlight || {}, diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index 4c5debe58ed26..e318075045522 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -12,19 +12,19 @@ import { LogEntriesSummaryHighlightsBucket, LogEntriesRequest, } from '../../../../common/http_api'; -import { LogEntry, LogColumn } from '../../../../common/log_entry'; +import { LogColumn, LogEntryCursor, LogEntry } from '../../../../common/log_entry'; import { InfraSourceConfiguration, InfraSources, SavedSourceConfigurationFieldColumnRuntimeType, } from '../../sources'; -import { getBuiltinRules } from './builtin_rules'; +import { getBuiltinRules } from '../../../services/log_entries/message/builtin_rules'; import { CompiledLogMessageFormattingRule, Fields, Highlights, compileFormattingRules, -} from './message'; +} from '../../../services/log_entries/message/message'; import { KibanaFramework } from '../../adapters/framework/kibana_framework_adapter'; import { decodeOrThrow } from '../../../../common/runtime_types'; import { @@ -33,7 +33,6 @@ import { CompositeDatasetKey, createLogEntryDatasetsQuery, } from './queries/log_entry_datasets'; -import { LogEntryCursor } from '../../../../common/log_entry'; export interface LogEntriesParams { startTimestamp: number; @@ -156,6 +155,7 @@ export class InfraLogEntriesDomain { const entries = documents.map((doc) => { return { id: doc.id, + index: doc.index, cursor: doc.cursor, columns: columnDefinitions.map( (column): LogColumn => { @@ -317,6 +317,7 @@ export type LogEntryQuery = JsonObject; export interface LogEntryDocument { id: string; + index: string; fields: Fields; highlights: Highlights; cursor: LogEntryCursor; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts index 071a8a94e009b..5d4846598d204 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts @@ -5,7 +5,6 @@ */ import type { ILegacyScopedClusterClient } from 'src/core/server'; -import { LogEntryContext } from '../../../common/log_entry'; import { compareDatasetsByMaximumAnomalyScore, getJobId, @@ -13,6 +12,7 @@ import { logEntryCategoriesJobTypes, CategoriesSort, } from '../../../common/log_analysis'; +import { LogEntryContext } from '../../../common/log_entry'; import { startTracingSpan } from '../../../common/performance_tracing'; import { decodeOrThrow } from '../../../common/runtime_types'; import type { MlAnomalyDetectors, MlSystem } from '../../types'; diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.test.ts b/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.test.ts new file mode 100644 index 0000000000000..f07ee0508fa6c --- /dev/null +++ b/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.test.ts @@ -0,0 +1,318 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ResponseError } from '@elastic/elasticsearch/lib/errors'; +import { of, throwError } from 'rxjs'; +import { + elasticsearchServiceMock, + savedObjectsClientMock, + uiSettingsServiceMock, +} from 'src/core/server/mocks'; +import { + IEsSearchRequest, + IEsSearchResponse, + ISearchStrategy, + SearchStrategyDependencies, +} from 'src/plugins/data/server'; +import { InfraSource } from '../../lib/sources'; +import { createInfraSourcesMock } from '../../lib/sources/mocks'; +import { + logEntriesSearchRequestStateRT, + logEntriesSearchStrategyProvider, +} from './log_entries_search_strategy'; + +describe('LogEntries search strategy', () => { + it('handles initial search requests', async () => { + const esSearchStrategyMock = createEsSearchStrategyMock({ + id: 'ASYNC_REQUEST_ID', + isRunning: true, + rawResponse: { + took: 0, + _shards: { total: 1, failed: 0, skipped: 0, successful: 0 }, + timed_out: false, + hits: { total: 0, max_score: 0, hits: [] }, + }, + }); + + const dataMock = createDataPluginMock(esSearchStrategyMock); + const sourcesMock = createInfraSourcesMock(); + sourcesMock.getSourceConfiguration.mockResolvedValue(createSourceConfigurationMock()); + const mockDependencies = createSearchStrategyDependenciesMock(); + + const logEntriesSearchStrategy = logEntriesSearchStrategyProvider({ + data: dataMock, + sources: sourcesMock, + }); + + const response = await logEntriesSearchStrategy + .search( + { + params: { + sourceId: 'SOURCE_ID', + startTimestamp: 100, + endTimestamp: 200, + size: 3, + }, + }, + {}, + mockDependencies + ) + .toPromise(); + + expect(sourcesMock.getSourceConfiguration).toHaveBeenCalled(); + expect(esSearchStrategyMock.search).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + index: 'log-indices-*', + body: expect.objectContaining({ + fields: expect.arrayContaining(['event.dataset', 'message']), + }), + }), + }), + expect.anything(), + expect.anything() + ); + expect(response.id).toEqual(expect.any(String)); + expect(response.isRunning).toBe(true); + }); + + it('handles subsequent polling requests', async () => { + const esSearchStrategyMock = createEsSearchStrategyMock({ + id: 'ASYNC_REQUEST_ID', + isRunning: false, + rawResponse: { + took: 1, + _shards: { total: 1, failed: 0, skipped: 0, successful: 1 }, + timed_out: false, + hits: { + total: 0, + max_score: 0, + hits: [ + { + _id: 'HIT_ID', + _index: 'HIT_INDEX', + _type: '_doc', + _score: 0, + _source: null, + fields: { + '@timestamp': [1605116827143], + 'event.dataset': ['HIT_DATASET'], + MESSAGE_FIELD: ['HIT_MESSAGE'], + 'container.id': ['HIT_CONTAINER_ID'], + }, + sort: [1605116827143 as any, 1 as any], // incorrectly typed as string upstream + }, + ], + }, + }, + }); + const dataMock = createDataPluginMock(esSearchStrategyMock); + const sourcesMock = createInfraSourcesMock(); + sourcesMock.getSourceConfiguration.mockResolvedValue(createSourceConfigurationMock()); + const mockDependencies = createSearchStrategyDependenciesMock(); + + const logEntriesSearchStrategy = logEntriesSearchStrategyProvider({ + data: dataMock, + sources: sourcesMock, + }); + const requestId = logEntriesSearchRequestStateRT.encode({ + esRequestId: 'ASYNC_REQUEST_ID', + }); + + const response = await logEntriesSearchStrategy + .search( + { + id: requestId, + params: { + sourceId: 'SOURCE_ID', + startTimestamp: 100, + endTimestamp: 200, + size: 3, + }, + }, + {}, + mockDependencies + ) + .toPromise(); + + expect(sourcesMock.getSourceConfiguration).toHaveBeenCalled(); + expect(esSearchStrategyMock.search).toHaveBeenCalled(); + expect(response.id).toEqual(requestId); + expect(response.isRunning).toBe(false); + expect(response.rawResponse.data.entries).toEqual([ + { + id: 'HIT_ID', + index: 'HIT_INDEX', + cursor: { + time: 1605116827143, + tiebreaker: 1, + }, + columns: [ + { + columnId: 'TIMESTAMP_COLUMN_ID', + timestamp: 1605116827143, + }, + { + columnId: 'DATASET_COLUMN_ID', + field: 'event.dataset', + value: ['HIT_DATASET'], + highlights: [], + }, + { + columnId: 'MESSAGE_COLUMN_ID', + message: [ + { + field: 'MESSAGE_FIELD', + value: ['HIT_MESSAGE'], + highlights: [], + }, + ], + }, + ], + context: { + 'container.id': 'HIT_CONTAINER_ID', + }, + }, + ]); + }); + + it('forwards errors from the underlying search strategy', async () => { + const esSearchStrategyMock = createEsSearchStrategyMock({ + id: 'ASYNC_REQUEST_ID', + isRunning: false, + rawResponse: { + took: 1, + _shards: { total: 1, failed: 0, skipped: 0, successful: 1 }, + timed_out: false, + hits: { total: 0, max_score: 0, hits: [] }, + }, + }); + const dataMock = createDataPluginMock(esSearchStrategyMock); + const sourcesMock = createInfraSourcesMock(); + sourcesMock.getSourceConfiguration.mockResolvedValue(createSourceConfigurationMock()); + const mockDependencies = createSearchStrategyDependenciesMock(); + + const logEntriesSearchStrategy = logEntriesSearchStrategyProvider({ + data: dataMock, + sources: sourcesMock, + }); + + const response = logEntriesSearchStrategy.search( + { + id: logEntriesSearchRequestStateRT.encode({ esRequestId: 'UNKNOWN_ID' }), + params: { + sourceId: 'SOURCE_ID', + startTimestamp: 100, + endTimestamp: 200, + size: 3, + }, + }, + {}, + mockDependencies + ); + + await expect(response.toPromise()).rejects.toThrowError(ResponseError); + }); + + it('forwards cancellation to the underlying search strategy', async () => { + const esSearchStrategyMock = createEsSearchStrategyMock({ + id: 'ASYNC_REQUEST_ID', + isRunning: false, + rawResponse: { + took: 1, + _shards: { total: 1, failed: 0, skipped: 0, successful: 1 }, + timed_out: false, + hits: { total: 0, max_score: 0, hits: [] }, + }, + }); + const dataMock = createDataPluginMock(esSearchStrategyMock); + const sourcesMock = createInfraSourcesMock(); + sourcesMock.getSourceConfiguration.mockResolvedValue(createSourceConfigurationMock()); + const mockDependencies = createSearchStrategyDependenciesMock(); + + const logEntriesSearchStrategy = logEntriesSearchStrategyProvider({ + data: dataMock, + sources: sourcesMock, + }); + const requestId = logEntriesSearchRequestStateRT.encode({ + esRequestId: 'ASYNC_REQUEST_ID', + }); + + await logEntriesSearchStrategy.cancel?.(requestId, {}, mockDependencies); + + expect(esSearchStrategyMock.cancel).toHaveBeenCalled(); + }); +}); + +const createSourceConfigurationMock = (): InfraSource => ({ + id: 'SOURCE_ID', + origin: 'stored' as const, + configuration: { + name: 'SOURCE_NAME', + description: 'SOURCE_DESCRIPTION', + logAlias: 'log-indices-*', + metricAlias: 'metric-indices-*', + inventoryDefaultView: 'DEFAULT_VIEW', + metricsExplorerDefaultView: 'DEFAULT_VIEW', + logColumns: [ + { timestampColumn: { id: 'TIMESTAMP_COLUMN_ID' } }, + { + fieldColumn: { + id: 'DATASET_COLUMN_ID', + field: 'event.dataset', + }, + }, + { + messageColumn: { id: 'MESSAGE_COLUMN_ID' }, + }, + ], + fields: { + pod: 'POD_FIELD', + host: 'HOST_FIELD', + container: 'CONTAINER_FIELD', + message: ['MESSAGE_FIELD'], + timestamp: 'TIMESTAMP_FIELD', + tiebreaker: 'TIEBREAKER_FIELD', + }, + }, +}); + +const createEsSearchStrategyMock = (esSearchResponse: IEsSearchResponse) => ({ + search: jest.fn((esSearchRequest: IEsSearchRequest) => { + if (typeof esSearchRequest.id === 'string') { + if (esSearchRequest.id === esSearchResponse.id) { + return of(esSearchResponse); + } else { + return throwError( + new ResponseError({ + body: {}, + headers: {}, + meta: {} as any, + statusCode: 404, + warnings: [], + }) + ); + } + } else { + return of(esSearchResponse); + } + }), + cancel: jest.fn().mockResolvedValue(undefined), +}); + +const createSearchStrategyDependenciesMock = (): SearchStrategyDependencies => ({ + uiSettingsClient: uiSettingsServiceMock.createClient(), + esClient: elasticsearchServiceMock.createScopedClusterClient(), + savedObjectsClient: savedObjectsClientMock.create(), +}); + +// using the official data mock from within x-pack doesn't type-check successfully, +// because the `licensing` plugin modifies the `RequestHandlerContext` core type. +const createDataPluginMock = (esSearchStrategyMock: ISearchStrategy): any => ({ + search: { + getSearchStrategy: jest.fn().mockReturnValue(esSearchStrategyMock), + }, +}); diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.ts b/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.ts new file mode 100644 index 0000000000000..6ce3d4410a2dd --- /dev/null +++ b/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.ts @@ -0,0 +1,245 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pick } from '@kbn/std'; +import * as rt from 'io-ts'; +import { combineLatest, concat, defer, forkJoin, of } from 'rxjs'; +import { concatMap, filter, map, shareReplay, take } from 'rxjs/operators'; +import type { + IEsSearchRequest, + IKibanaSearchRequest, + IKibanaSearchResponse, +} from '../../../../../../src/plugins/data/common'; +import type { + ISearchStrategy, + PluginStart as DataPluginStart, +} from '../../../../../../src/plugins/data/server'; +import { LogSourceColumnConfiguration } from '../../../common/http_api/log_sources'; +import { + getLogEntryCursorFromHit, + LogColumn, + LogEntry, + LogEntryAfterCursor, + logEntryAfterCursorRT, + LogEntryBeforeCursor, + logEntryBeforeCursorRT, + LogEntryContext, +} from '../../../common/log_entry'; +import { decodeOrThrow } from '../../../common/runtime_types'; +import { + LogEntriesSearchRequestParams, + logEntriesSearchRequestParamsRT, + LogEntriesSearchResponsePayload, + logEntriesSearchResponsePayloadRT, +} from '../../../common/search_strategies/log_entries/log_entries'; +import type { IInfraSources } from '../../lib/sources'; +import { + createAsyncRequestRTs, + createErrorFromShardFailure, + jsonFromBase64StringRT, +} from '../../utils/typed_search_strategy'; +import { + CompiledLogMessageFormattingRule, + compileFormattingRules, + getBuiltinRules, +} from './message'; +import { + createGetLogEntriesQuery, + getLogEntriesResponseRT, + getSortDirection, + LogEntryHit, +} from './queries/log_entries'; + +type LogEntriesSearchRequest = IKibanaSearchRequest; +type LogEntriesSearchResponse = IKibanaSearchResponse; + +export const logEntriesSearchStrategyProvider = ({ + data, + sources, +}: { + data: DataPluginStart; + sources: IInfraSources; +}): ISearchStrategy => { + const esSearchStrategy = data.search.getSearchStrategy('ese'); + + return { + search: (rawRequest, options, dependencies) => + defer(() => { + const request = decodeOrThrow(asyncRequestRT)(rawRequest); + + const sourceConfiguration$ = defer(() => + sources.getSourceConfiguration(dependencies.savedObjectsClient, request.params.sourceId) + ).pipe(take(1), shareReplay(1)); + + const messageFormattingRules$ = defer(() => + sourceConfiguration$.pipe( + map(({ configuration }) => + compileFormattingRules(getBuiltinRules(configuration.fields.message)) + ) + ) + ).pipe(take(1), shareReplay(1)); + + const recoveredRequest$ = of(request).pipe( + filter(asyncRecoveredRequestRT.is), + map(({ id: { esRequestId } }) => ({ id: esRequestId })) + ); + + const initialRequest$ = of(request).pipe( + filter(asyncInitialRequestRT.is), + concatMap(({ params }) => + forkJoin([sourceConfiguration$, messageFormattingRules$]).pipe( + map( + ([{ configuration }, messageFormattingRules]): IEsSearchRequest => { + return { + params: createGetLogEntriesQuery( + configuration.logAlias, + params.startTimestamp, + params.endTimestamp, + pickRequestCursor(params), + params.size + 1, + configuration.fields.timestamp, + configuration.fields.tiebreaker, + messageFormattingRules.requiredFields, + params.query, + params.highlightPhrase + ), + }; + } + ) + ) + ) + ); + + const searchResponse$ = concat(recoveredRequest$, initialRequest$).pipe( + take(1), + concatMap((esRequest) => esSearchStrategy.search(esRequest, options, dependencies)) + ); + + return combineLatest([searchResponse$, sourceConfiguration$, messageFormattingRules$]).pipe( + map(([esResponse, { configuration }, messageFormattingRules]) => { + const rawResponse = decodeOrThrow(getLogEntriesResponseRT)(esResponse.rawResponse); + + const entries = rawResponse.hits.hits + .slice(0, request.params.size) + .map(getLogEntryFromHit(configuration.logColumns, messageFormattingRules)); + + const sortDirection = getSortDirection(pickRequestCursor(request.params)); + + if (sortDirection === 'desc') { + entries.reverse(); + } + + const hasMore = rawResponse.hits.hits.length > entries.length; + const hasMoreBefore = sortDirection === 'desc' ? hasMore : undefined; + const hasMoreAfter = sortDirection === 'asc' ? hasMore : undefined; + + const { topCursor, bottomCursor } = getResponseCursors(entries); + + const errors = (rawResponse._shards.failures ?? []).map(createErrorFromShardFailure); + + return { + ...esResponse, + ...(esResponse.id + ? { id: logEntriesSearchRequestStateRT.encode({ esRequestId: esResponse.id }) } + : {}), + rawResponse: logEntriesSearchResponsePayloadRT.encode({ + data: { entries, topCursor, bottomCursor, hasMoreBefore, hasMoreAfter }, + errors, + }), + }; + }) + ); + }), + cancel: async (id, options, dependencies) => { + const { esRequestId } = decodeOrThrow(logEntriesSearchRequestStateRT)(id); + return await esSearchStrategy.cancel?.(esRequestId, options, dependencies); + }, + }; +}; + +// exported for tests +export const logEntriesSearchRequestStateRT = rt.string.pipe(jsonFromBase64StringRT).pipe( + rt.type({ + esRequestId: rt.string, + }) +); + +const { asyncInitialRequestRT, asyncRecoveredRequestRT, asyncRequestRT } = createAsyncRequestRTs( + logEntriesSearchRequestStateRT, + logEntriesSearchRequestParamsRT +); + +const getLogEntryFromHit = ( + columnDefinitions: LogSourceColumnConfiguration[], + messageFormattingRules: CompiledLogMessageFormattingRule +) => (hit: LogEntryHit): LogEntry => { + const cursor = getLogEntryCursorFromHit(hit); + return { + id: hit._id, + index: hit._index, + cursor, + columns: columnDefinitions.map( + (column): LogColumn => { + if ('timestampColumn' in column) { + return { + columnId: column.timestampColumn.id, + timestamp: cursor.time, + }; + } else if ('messageColumn' in column) { + return { + columnId: column.messageColumn.id, + message: messageFormattingRules.format(hit.fields, hit.highlight || {}), + }; + } else { + return { + columnId: column.fieldColumn.id, + field: column.fieldColumn.field, + value: hit.fields[column.fieldColumn.field] ?? [], + highlights: hit.highlight?.[column.fieldColumn.field] ?? [], + }; + } + } + ), + context: getContextFromHit(hit), + }; +}; + +const pickRequestCursor = ( + params: LogEntriesSearchRequestParams +): LogEntryAfterCursor | LogEntryBeforeCursor | null => { + if (logEntryAfterCursorRT.is(params)) { + return pick(params, ['after']); + } else if (logEntryBeforeCursorRT.is(params)) { + return pick(params, ['before']); + } + + return null; +}; + +const getContextFromHit = (hit: LogEntryHit): LogEntryContext => { + // Get all context fields, then test for the presence and type of the ones that go together + const containerId = hit.fields['container.id']?.[0]; + const hostName = hit.fields['host.name']?.[0]; + const logFilePath = hit.fields['log.file.path']?.[0]; + + if (typeof containerId === 'string') { + return { 'container.id': containerId }; + } + + if (typeof hostName === 'string' && typeof logFilePath === 'string') { + return { 'host.name': hostName, 'log.file.path': logFilePath }; + } + + return {}; +}; + +function getResponseCursors(entries: LogEntry[]) { + const hasEntries = entries.length > 0; + const topCursor = hasEntries ? entries[0].cursor : null; + const bottomCursor = hasEntries ? entries[entries.length - 1].cursor : null; + + return { topCursor, bottomCursor }; +} diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entries_service.ts b/x-pack/plugins/infra/server/services/log_entries/log_entries_service.ts index edd53be9db841..9aba69428f257 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entries_service.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entries_service.ts @@ -6,12 +6,18 @@ import { CoreSetup } from 'src/core/server'; import { LOG_ENTRY_SEARCH_STRATEGY } from '../../../common/search_strategies/log_entries/log_entry'; +import { LOG_ENTRIES_SEARCH_STRATEGY } from '../../../common/search_strategies/log_entries/log_entries'; +import { logEntriesSearchStrategyProvider } from './log_entries_search_strategy'; import { logEntrySearchStrategyProvider } from './log_entry_search_strategy'; import { LogEntriesServiceSetupDeps, LogEntriesServiceStartDeps } from './types'; export class LogEntriesService { public setup(core: CoreSetup, setupDeps: LogEntriesServiceSetupDeps) { core.getStartServices().then(([, startDeps]) => { + setupDeps.data.search.registerSearchStrategy( + LOG_ENTRIES_SEARCH_STRATEGY, + logEntriesSearchStrategyProvider({ ...setupDeps, ...startDeps }) + ); setupDeps.data.search.registerSearchStrategy( LOG_ENTRY_SEARCH_STRATEGY, logEntrySearchStrategyProvider({ ...setupDeps, ...startDeps }) diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts index 38626675f5ae7..b3e1a31f73b7a 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts @@ -121,7 +121,7 @@ describe('LogEntry search strategy', () => { expect(response.rawResponse.data).toEqual({ id: 'HIT_ID', index: 'HIT_INDEX', - key: { + cursor: { time: 1605116827143, tiebreaker: 1, }, diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts index a0dfe3d7176fd..ab2b72055e4a4 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.ts @@ -119,6 +119,6 @@ const { asyncInitialRequestRT, asyncRecoveredRequestRT, asyncRequestRT } = creat const createLogEntryFromHit = (hit: LogEntryHit) => ({ id: hit._id, index: hit._index, - key: getLogEntryCursorFromHit(hit), + cursor: getLogEntryCursorFromHit(hit), fields: Object.entries(hit.fields).map(([field, value]) => ({ field, value })), }); diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_apache2.test.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_apache2.test.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_apache2.test.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_apache2.test.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_apache2.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_apache2.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_apache2.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_apache2.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_auditd.test.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_auditd.test.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_auditd.test.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_auditd.test.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_auditd.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_auditd.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_auditd.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_auditd.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_haproxy.test.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_haproxy.test.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_haproxy.test.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_haproxy.test.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_haproxy.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_haproxy.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_haproxy.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_haproxy.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_icinga.test.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_icinga.test.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_icinga.test.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_icinga.test.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_icinga.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_icinga.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_icinga.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_icinga.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_iis.test.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_iis.test.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_iis.test.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_iis.test.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_iis.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_iis.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_iis.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_iis.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_kafka.test.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_kafka.test.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_kafka.test.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_kafka.test.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_logstash.test.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_logstash.test.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_logstash.test.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_logstash.test.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_logstash.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_logstash.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_logstash.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_logstash.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_mongodb.test.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_mongodb.test.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_mongodb.test.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_mongodb.test.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_mongodb.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_mongodb.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_mongodb.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_mongodb.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_mysql.test.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_mysql.test.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_mysql.test.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_mysql.test.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_mysql.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_mysql.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_mysql.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_mysql.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_nginx.test.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_nginx.test.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_nginx.test.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_nginx.test.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_nginx.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_nginx.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_nginx.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_nginx.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_osquery.test.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_osquery.test.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_osquery.test.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_osquery.test.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_osquery.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_osquery.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_osquery.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_osquery.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_redis.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_redis.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_redis.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_redis.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_system.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_system.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_system.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_system.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_traefik.test.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_traefik.test.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_traefik.test.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_traefik.test.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_traefik.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_traefik.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/filebeat_traefik.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/filebeat_traefik.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/generic.test.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.test.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/generic.test.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.test.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/generic.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/generic.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/generic_webserver.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic_webserver.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/generic_webserver.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic_webserver.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/helpers.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/helpers.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/helpers.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/helpers.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/index.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/index.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/builtin_rules/index.ts rename to x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/index.ts diff --git a/x-pack/plugins/infra/server/services/log_entries/message/index.ts b/x-pack/plugins/infra/server/services/log_entries/message/index.ts new file mode 100644 index 0000000000000..05126eea075af --- /dev/null +++ b/x-pack/plugins/infra/server/services/log_entries/message/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './message'; +export { getBuiltinRules } from './builtin_rules'; diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/message.ts b/x-pack/plugins/infra/server/services/log_entries/message/message.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/message.ts rename to x-pack/plugins/infra/server/services/log_entries/message/message.ts diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/rule_types.ts b/x-pack/plugins/infra/server/services/log_entries/message/rule_types.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/domains/log_entries_domain/rule_types.ts rename to x-pack/plugins/infra/server/services/log_entries/message/rule_types.ts diff --git a/x-pack/plugins/infra/server/services/log_entries/queries/common.ts b/x-pack/plugins/infra/server/services/log_entries/queries/common.ts new file mode 100644 index 0000000000000..f170fa337a8b9 --- /dev/null +++ b/x-pack/plugins/infra/server/services/log_entries/queries/common.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const createSortClause = ( + sortDirection: 'asc' | 'desc', + timestampField: string, + tiebreakerField: string +) => ({ + sort: { + [timestampField]: sortDirection, + [tiebreakerField]: sortDirection, + }, +}); + +export const createTimeRangeFilterClauses = ( + startTimestamp: number, + endTimestamp: number, + timestampField: string +) => [ + { + range: { + [timestampField]: { + gte: startTimestamp, + lte: endTimestamp, + format: 'epoch_millis', + }, + }, + }, +]; diff --git a/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts b/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts new file mode 100644 index 0000000000000..81476fa2b286e --- /dev/null +++ b/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { RequestParams } from '@elastic/elasticsearch'; +import * as rt from 'io-ts'; +import { + LogEntryAfterCursor, + logEntryAfterCursorRT, + LogEntryBeforeCursor, + logEntryBeforeCursorRT, +} from '../../../../common/log_entry'; +import { jsonArrayRT, JsonObject } from '../../../../common/typed_json'; +import { + commonHitFieldsRT, + commonSearchSuccessResponseFieldsRT, +} from '../../../utils/elasticsearch_runtime_types'; +import { createSortClause, createTimeRangeFilterClauses } from './common'; + +export const createGetLogEntriesQuery = ( + logEntriesIndex: string, + startTimestamp: number, + endTimestamp: number, + cursor: LogEntryBeforeCursor | LogEntryAfterCursor | null | undefined, + size: number, + timestampField: string, + tiebreakerField: string, + fields: string[], + query?: JsonObject, + highlightTerm?: string +): RequestParams.AsyncSearchSubmit> => { + const sortDirection = getSortDirection(cursor); + const highlightQuery = createHighlightQuery(highlightTerm, fields); + + return { + index: logEntriesIndex, + allow_no_indices: true, + track_scores: false, + track_total_hits: false, + body: { + size, + query: { + bool: { + filter: [ + ...(query ? [query] : []), + ...(highlightQuery ? [highlightQuery] : []), + ...createTimeRangeFilterClauses(startTimestamp, endTimestamp, timestampField), + ], + }, + }, + fields, + _source: false, + ...createSortClause(sortDirection, timestampField, tiebreakerField), + ...createSearchAfterClause(cursor), + ...createHighlightClause(highlightQuery, fields), + }, + }; +}; + +export const getSortDirection = ( + cursor: LogEntryBeforeCursor | LogEntryAfterCursor | null | undefined +): 'asc' | 'desc' => (logEntryBeforeCursorRT.is(cursor) ? 'desc' : 'asc'); + +const createSearchAfterClause = ( + cursor: LogEntryBeforeCursor | LogEntryAfterCursor | null | undefined +): { search_after?: [number, number] } => { + if (logEntryBeforeCursorRT.is(cursor) && cursor.before !== 'last') { + return { + search_after: [cursor.before.time, cursor.before.tiebreaker], + }; + } else if (logEntryAfterCursorRT.is(cursor) && cursor.after !== 'first') { + return { + search_after: [cursor.after.time, cursor.after.tiebreaker], + }; + } + + return {}; +}; + +const createHighlightClause = (highlightQuery: JsonObject | undefined, fields: string[]) => + highlightQuery + ? { + highlight: { + boundary_scanner: 'word', + fields: fields.reduce( + (highlightFieldConfigs, fieldName) => ({ + ...highlightFieldConfigs, + [fieldName]: {}, + }), + {} + ), + fragment_size: 1, + number_of_fragments: 100, + post_tags: [''], + pre_tags: [''], + highlight_query: highlightQuery, + }, + } + : {}; + +const createHighlightQuery = ( + highlightTerm: string | undefined, + fields: string[] +): JsonObject | undefined => { + if (highlightTerm) { + return { + multi_match: { + fields, + lenient: true, + query: highlightTerm, + type: 'phrase', + }, + }; + } +}; + +export const logEntryHitRT = rt.intersection([ + commonHitFieldsRT, + rt.type({ + fields: rt.record(rt.string, jsonArrayRT), + sort: rt.tuple([rt.number, rt.number]), + }), + rt.partial({ + highlight: rt.record(rt.string, rt.array(rt.string)), + }), +]); + +export type LogEntryHit = rt.TypeOf; + +export const getLogEntriesResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + hits: rt.type({ + hits: rt.array(logEntryHitRT), + }), + }), +]); + +export type GetLogEntriesResponse = rt.TypeOf; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.test.tsx index c7336d998c452..bc96aa65b82a7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.test.tsx @@ -40,7 +40,7 @@ describe('Mapping', () => { wrappingComponent: TestProviders, }); expect(wrapper.find('[data-test-subj="field-mapping-desc"]').first().text()).toBe( - 'Field mappings require an established connection to ServiceNow. Please check your connection credentials.' + 'Field mappings require an established connection to ServiceNow ITSM. Please check your connection credentials.' ); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/config.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/config.ts index 3aca186378820..a29531d89b405 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/config.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/config.ts @@ -7,14 +7,14 @@ /* eslint-disable @kbn/eslint/no-restricted-paths */ import { - ServiceNowConnectorConfiguration, + ServiceNowITSMConnectorConfiguration, JiraConnectorConfiguration, ResilientConnectorConfiguration, } from '../../../../../triggers_actions_ui/public/common'; import { ConnectorConfiguration } from './types'; export const connectorsConfiguration: Record = { - '.servicenow': ServiceNowConnectorConfiguration as ConnectorConfiguration, + '.servicenow': ServiceNowITSMConnectorConfiguration as ConnectorConfiguration, '.jira': JiraConnectorConfiguration as ConnectorConfiguration, '.resilient': ResilientConnectorConfiguration as ConnectorConfiguration, }; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts index 5d83c226bfeca..00bc01b2ec0a7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts @@ -22,5 +22,4 @@ export interface ThirdPartyField { export interface ConnectorConfiguration extends ActionType { logo: string; - fields: Record; } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4369cbf35594d..fdf1c74f20512 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4784,7 +4784,6 @@ "xpack.actions.builtin.pagerdutyTitle": "PagerDuty", "xpack.actions.builtin.serverLog.errorLoggingErrorMessage": "メッセージのロギングエラー", "xpack.actions.builtin.serverLogTitle": "サーバーログ", - "xpack.actions.builtin.servicenowTitle": "ServiceNow", "xpack.actions.builtin.slack.errorPostingErrorMessage": "slack メッセージの投稿エラー", "xpack.actions.builtin.slack.errorPostingRetryDateErrorMessage": "slack メッセージの投稿エラー、 {retryString} で再試行", "xpack.actions.builtin.slack.errorPostingRetryLaterErrorMessage": "slack メッセージの投稿エラー、後ほど再試行", @@ -21161,7 +21160,6 @@ "xpack.triggersActionsUI.common.expressionItems.threshold.popoverTitle": "タイミング", "xpack.triggersActionsUI.components.addMessageVariables.addVariablePopoverButton": "変数を追加", "xpack.triggersActionsUI.components.addMessageVariables.addVariableTitle": "アラート変数を追加", - "xpack.triggersActionsUI.components.builtinActionTypes.common.requiredDescriptionTextField": "説明が必要です。", "xpack.triggersActionsUI.components.builtinActionTypes.common.requiredShortDescTextField": "短い説明が必要です。", "xpack.triggersActionsUI.components.builtinActionTypes.emailAction.actionTypeTitle": "メールに送信", "xpack.triggersActionsUI.components.builtinActionTypes.emailAction.configureAccountsHelpLabel": "電子メールアカウントの構成", @@ -21292,35 +21290,16 @@ "xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.logLevelFieldLabel": "レベル", "xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.logMessageFieldLabel": "メッセージ", "xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.selectMessageText": "Kibana ログにメッセージを追加します。", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.actionTypeTitle": "ServiceNow", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiTokenTextFieldLabel": "APIトークン", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlTextFieldLabel": "URL", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.authenticationLabel": "認証", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.commentsTextAreaFieldLabel": "追加のコメント", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.descriptionTextAreaFieldLabel": "説明", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.emailTextFieldLabel": "メール", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.impactSelectFieldLabel": "インパクト", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.invalidApiUrlTextField": "URL が無効です。", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldComments": "コメント", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldDescription": "説明", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldShortDescription": "短い説明", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.passwordTextFieldLabel": "パスワード", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.reenterValuesLabel": "ユーザー名とパスワードは暗号化されます。これらのフィールドの値を再入力してください。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.rememberValuesLabel": "これらの値を覚えておいてください。コネクターを編集するたびに再入力する必要があります。", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredApiTokenTextField": "API トークンが必要です。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredApiUrlTextField": "URL が必要です。", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredEmailTextField": "電子メールが必要です。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredPasswordTextField": "パスワードが必要です。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredUsernameTextField": "ユーザー名が必要です。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requireHttpsApiUrlTextField": "URL は https:// から始める必要があります。", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.selectMessageText": "ServiceNow でインシデントを作成します。", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.severitySelectFieldLabel": "深刻度", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectHighOptionLabel": "高", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectLawOptionLabel": "低", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectMediumOptionLabel": "中", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.title": "インシデント", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.titleFieldLabel": "短い説明(必須)", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.urgencySelectFieldLabel": "緊急", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.usernameTextFieldLabel": "ユーザー名", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel": "Personal Developer Instance の構成", "xpack.triggersActionsUI.components.builtinActionTypes.slackAction.actionTypeTitle": "Slack に送信", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d2504c8752c05..b3fefd55ce55a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4789,7 +4789,6 @@ "xpack.actions.builtin.pagerdutyTitle": "PagerDuty", "xpack.actions.builtin.serverLog.errorLoggingErrorMessage": "记录消息时出错", "xpack.actions.builtin.serverLogTitle": "服务器日志", - "xpack.actions.builtin.servicenowTitle": "ServiceNow", "xpack.actions.builtin.slack.errorPostingErrorMessage": "发布 slack 消息时出错", "xpack.actions.builtin.slack.errorPostingRetryDateErrorMessage": "发布 Slack 消息时出错,在 {retryString} 重试", "xpack.actions.builtin.slack.errorPostingRetryLaterErrorMessage": "发布 slack 消息时出错,稍后重试", @@ -21212,7 +21211,6 @@ "xpack.triggersActionsUI.common.expressionItems.threshold.popoverTitle": "当", "xpack.triggersActionsUI.components.addMessageVariables.addVariablePopoverButton": "添加变量", "xpack.triggersActionsUI.components.addMessageVariables.addVariableTitle": "添加告警变量", - "xpack.triggersActionsUI.components.builtinActionTypes.common.requiredDescriptionTextField": "“描述”必填。", "xpack.triggersActionsUI.components.builtinActionTypes.common.requiredShortDescTextField": "“简短描述”必填。", "xpack.triggersActionsUI.components.builtinActionTypes.emailAction.actionTypeTitle": "发送到电子邮件", "xpack.triggersActionsUI.components.builtinActionTypes.emailAction.configureAccountsHelpLabel": "配置电子邮件帐户", @@ -21343,35 +21341,16 @@ "xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.logLevelFieldLabel": "级别", "xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.logMessageFieldLabel": "消息", "xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.selectMessageText": "将消息添加到 Kibana 日志。", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.actionTypeTitle": "ServiceNow", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiTokenTextFieldLabel": "Api 令牌", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlTextFieldLabel": "URL", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.authenticationLabel": "身份验证", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.commentsTextAreaFieldLabel": "其他注释", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.descriptionTextAreaFieldLabel": "描述", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.emailTextFieldLabel": "电子邮件", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.impactSelectFieldLabel": "影响", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.invalidApiUrlTextField": "URL 无效。", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldComments": "注释", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldDescription": "描述", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldShortDescription": "简短描述", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.passwordTextFieldLabel": "密码", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.reenterValuesLabel": "用户名和密码已加密。请为这些字段重新输入值。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.rememberValuesLabel": "请记住这些值。每次编辑连接器时都必须重新输入。", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredApiTokenTextField": "“Api 令牌”必填。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredApiUrlTextField": "“URL”必填。", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredEmailTextField": "“电子邮件”必填。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredPasswordTextField": "“密码”必填。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredUsernameTextField": "“用户名”必填。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requireHttpsApiUrlTextField": "URL 必须以 https:// 开头。", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.selectMessageText": "在 ServiceNow 中创建事件。", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.severitySelectFieldLabel": "严重性", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectHighOptionLabel": "高", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectLawOptionLabel": "低", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectMediumOptionLabel": "中", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.title": "事件", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.titleFieldLabel": "简短描述(必填)", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.urgencySelectFieldLabel": "紧急性", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.usernameTextFieldLabel": "用户名", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel": "配置个人开发者实例", "xpack.triggersActionsUI.components.builtinActionTypes.slackAction.actionTypeTitle": "发送到 Slack", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts index b8514a06dc253..003b2c5eedb10 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts @@ -12,7 +12,7 @@ import { getPagerDutyActionType } from './pagerduty'; import { getWebhookActionType } from './webhook'; import { TypeRegistry } from '../../type_registry'; import { ActionTypeModel } from '../../../types'; -import { getServiceNowActionType } from './servicenow'; +import { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow'; import { getJiraActionType } from './jira'; import { getResilientActionType } from './resilient'; import { getTeamsActionType } from './teams'; @@ -28,7 +28,8 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getIndexActionType()); actionTypeRegistry.register(getPagerDutyActionType()); actionTypeRegistry.register(getWebhookActionType()); - actionTypeRegistry.register(getServiceNowActionType()); + actionTypeRegistry.register(getServiceNowITSMActionType()); + actionTypeRegistry.register(getServiceNowSIRActionType()); actionTypeRegistry.register(getJiraActionType()); actionTypeRegistry.register(getResilientActionType()); actionTypeRegistry.register(getTeamsActionType()); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.test.ts index d5474aaceaa48..4759eecf3ef0e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.test.ts @@ -8,6 +8,7 @@ import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; import { getIssueTypes, getFieldsByIssueType, getIssues, getIssue } from './api'; const issueTypesResponse = { + status: 'ok', data: { projects: [ { @@ -24,9 +25,11 @@ const issueTypesResponse = { }, ], }, + actionId: 'test', }; const fieldsResponse = { + status: 'ok', data: { projects: [ { @@ -70,13 +73,18 @@ const fieldsResponse = { ], }, ], + actionId: 'test', }, }; const issueResponse = { - id: '10267', - key: 'RJ-107', - fields: { summary: 'Test title' }, + status: 'ok', + data: { + id: '10267', + key: 'RJ-107', + fields: { summary: 'Test title' }, + }, + actionId: 'test', }; const issuesResponse = [issueResponse]; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/config.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/config.ts index 628600ee91c8e..d05bf78a5106e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/config.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/config.ts @@ -15,24 +15,4 @@ export const connectorConfiguration = { enabledInConfig: true, enabledInLicense: true, minimumLicenseRequired: 'gold', - fields: { - summary: { - label: i18n.MAPPING_FIELD_SUMMARY, - validSourceFields: ['title', 'description'], - defaultSourceField: 'title', - defaultActionType: 'overwrite', - }, - description: { - label: i18n.MAPPING_FIELD_DESC, - validSourceFields: ['title', 'description'], - defaultSourceField: 'description', - defaultActionType: 'overwrite', - }, - comments: { - label: i18n.MAPPING_FIELD_COMMENTS, - validSourceFields: ['comments'], - defaultSourceField: 'comments', - defaultActionType: 'append', - }, - }, }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.test.ts new file mode 100644 index 0000000000000..24c7f7687da69 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { getIncidentTypes, getSeverity } from './api'; + +const incidentTypesResponse = { + status: 'ok', + data: [ + { id: 17, name: 'Communication error (fax; email)' }, + { id: 1001, name: 'Custom type' }, + { id: 21, name: 'Denial of Service' }, + { id: 6, name: 'Improper disposal: digital asset(s)' }, + { id: 7, name: 'Improper disposal: documents / files' }, + { id: 4, name: 'Lost documents / files / records' }, + { id: 3, name: 'Lost PC / laptop / tablet' }, + { id: 1, name: 'Lost PDA / smartphone' }, + { id: 8, name: 'Lost storage device / media' }, + { id: 19, name: 'Malware' }, + { id: 23, name: 'Not an Issue' }, + { id: 18, name: 'Other' }, + { id: 22, name: 'Phishing' }, + { id: 11, name: 'Stolen documents / files / records' }, + { id: 12, name: 'Stolen PC / laptop / tablet' }, + { id: 13, name: 'Stolen PDA / smartphone' }, + { id: 14, name: 'Stolen storage device / media' }, + { id: 20, name: 'System Intrusion' }, + { id: 16, name: 'TBD / Unknown' }, + { id: 15, name: 'Vendor / 3rd party error' }, + ], + actionId: 'test', +}; + +const severityResponse = { + status: 'ok', + data: [ + { id: 4, name: 'Low' }, + { id: 5, name: 'Medium' }, + { id: 6, name: 'High' }, + ], + actionId: 'test', +}; + +describe('Resilient API', () => { + const http = httpServiceMock.createStartContract(); + + beforeEach(() => jest.resetAllMocks()); + + describe('getIncidentTypes', () => { + test('should call get choices API', async () => { + const abortCtrl = new AbortController(); + http.post.mockResolvedValueOnce(incidentTypesResponse); + const res = await getIncidentTypes({ + http, + signal: abortCtrl.signal, + connectorId: 'test', + }); + + expect(res).toEqual(incidentTypesResponse); + expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + body: '{"params":{"subAction":"incidentTypes","subActionParams":{}}}', + signal: abortCtrl.signal, + }); + }); + }); + + describe('getSeverity', () => { + test('should call get choices API', async () => { + const abortCtrl = new AbortController(); + http.post.mockResolvedValueOnce(severityResponse); + const res = await getSeverity({ + http, + signal: abortCtrl.signal, + connectorId: 'test', + }); + + expect(res).toEqual(severityResponse); + expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + body: '{"params":{"subAction":"severity","subActionParams":{}}}', + signal: abortCtrl.signal, + }); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/config.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/config.ts index a2054585c19b8..9717d594b20ec 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/config.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/config.ts @@ -15,24 +15,4 @@ export const connectorConfiguration = { enabledInConfig: true, enabledInLicense: true, minimumLicenseRequired: 'platinum', - fields: { - name: { - label: i18n.MAPPING_FIELD_NAME, - validSourceFields: ['title', 'description'], - defaultSourceField: 'title', - defaultActionType: 'overwrite', - }, - description: { - label: i18n.MAPPING_FIELD_DESC, - validSourceFields: ['title', 'description'], - defaultSourceField: 'description', - defaultActionType: 'overwrite', - }, - comments: { - label: i18n.MAPPING_FIELD_COMMENTS, - validSourceFields: ['comments'], - defaultSourceField: 'comments', - defaultActionType: 'append', - }, - }, }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts new file mode 100644 index 0000000000000..e87b84439f6f8 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { getChoices } from './api'; + +const choicesResponse = { + status: 'ok', + data: [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + element: 'priority', + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + element: 'priority', + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + element: 'priority', + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + element: 'priority', + }, + { + dependent_value: '', + label: '5 - Planning', + value: '5', + element: 'priority', + }, + ], +}; + +describe('ServiceNow API', () => { + const http = httpServiceMock.createStartContract(); + + beforeEach(() => jest.resetAllMocks()); + + describe('getChoices', () => { + test('should call get choices API', async () => { + const abortCtrl = new AbortController(); + http.post.mockResolvedValueOnce(choicesResponse); + const res = await getChoices({ + http, + signal: abortCtrl.signal, + connectorId: 'test', + fields: ['priority'], + }); + + expect(res).toEqual(choicesResponse); + expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + body: '{"params":{"subAction":"getChoices","subActionParams":{"fields":["priority"]}}}', + signal: abortCtrl.signal, + }); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts new file mode 100644 index 0000000000000..ecfc66f1b0391 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpSetup } from 'kibana/public'; +import { BASE_ACTION_API_PATH } from '../../../constants'; + +export async function getChoices({ + http, + signal, + connectorId, + fields, +}: { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; + fields: string[]; +}): Promise> { + return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + body: JSON.stringify({ + params: { subAction: 'getChoices', subActionParams: { fields } }, + }), + signal, + }); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts index 7f810cf5eb38f..6920ee71144a5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts @@ -7,32 +7,24 @@ import * as i18n from './translations'; import logo from './logo.svg'; -export const connectorConfiguration = { +export const serviceNowITSMConfiguration = { id: '.servicenow', - name: i18n.SERVICENOW_TITLE, + name: i18n.SERVICENOW_ITSM_TITLE, + desc: i18n.SERVICENOW_ITSM_DESC, + logo, + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'platinum', +}; + +export const serviceNowSIRConfiguration = { + id: '.servicenow-sir', + name: i18n.SERVICENOW_SIR_TITLE, + desc: i18n.SERVICENOW_SIR_DESC, logo, enabled: true, enabledInConfig: true, enabledInLicense: true, minimumLicenseRequired: 'platinum', - fields: { - short_description: { - label: i18n.MAPPING_FIELD_SHORT_DESC, - validSourceFields: ['title', 'description'], - defaultSourceField: 'title', - defaultActionType: 'overwrite', - }, - description: { - label: i18n.MAPPING_FIELD_DESC, - validSourceFields: ['title', 'description'], - defaultSourceField: 'description', - defaultActionType: 'overwrite', - }, - comments: { - label: i18n.MAPPING_FIELD_COMMENTS, - validSourceFields: ['comments'], - defaultSourceField: 'comments', - defaultActionType: 'append', - }, - }, }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/index.ts index 65bb3ae4f5a37..e1f66e506ed8e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { getActionType as getServiceNowActionType } from './servicenow'; +export { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx index dfa9bf56cc7a9..ce69f428e10a1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx @@ -8,102 +8,110 @@ import { registerBuiltInActionTypes } from '.././index'; import { ActionTypeModel } from '../../../../types'; import { ServiceNowActionConnector } from './types'; -const ACTION_TYPE_ID = '.servicenow'; -let actionTypeModel: ActionTypeModel; +const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow'; +const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir'; +let actionTypeRegistry: TypeRegistry; beforeAll(() => { - const actionTypeRegistry = new TypeRegistry(); + actionTypeRegistry = new TypeRegistry(); registerBuiltInActionTypes({ actionTypeRegistry }); - const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); - if (getResult !== null) { - actionTypeModel = getResult; - } }); describe('actionTypeRegistry.get() works', () => { - test('action type static data is as expected', () => { - expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + [SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => { + test(`${id}: action type static data is as expected`, () => { + const actionTypeModel = actionTypeRegistry.get(id); + expect(actionTypeModel.id).toEqual(id); + }); }); }); describe('servicenow connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { - const actionConnector = { - secrets: { - username: 'user', - password: 'pass', - }, - id: 'test', - actionTypeId: '.servicenow', - name: 'ServiceNow', - isPreconfigured: false, - config: { - apiUrl: 'https://dev94428.service-now.com/', - }, - } as ServiceNowActionConnector; + [SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => { + test(`${id}: connector validation succeeds when connector config is valid`, () => { + const actionTypeModel = actionTypeRegistry.get(id); + const actionConnector = { + secrets: { + username: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: id, + name: 'ServiceNow', + isPreconfigured: false, + config: { + apiUrl: 'https://dev94428.service-now.com/', + }, + } as ServiceNowActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - config: { - errors: { - apiUrl: [], + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { + errors: { + apiUrl: [], + }, }, - }, - secrets: { - errors: { - username: [], - password: [], + secrets: { + errors: { + username: [], + password: [], + }, }, - }, + }); }); - }); - test('connector validation fails when connector config is not valid', () => { - const actionConnector = ({ - secrets: { - username: 'user', - }, - id: '.servicenow', - actionTypeId: '.servicenow', - name: 'servicenow', - config: {}, - } as unknown) as ServiceNowActionConnector; + test(`${id}: connector validation fails when connector config is not valid`, () => { + const actionTypeModel = actionTypeRegistry.get(id); + const actionConnector = ({ + secrets: { + username: 'user', + }, + id, + actionTypeId: id, + name: 'servicenow', + config: {}, + } as unknown) as ServiceNowActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - config: { - errors: { - apiUrl: ['URL is required.'], + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { + errors: { + apiUrl: ['URL is required.'], + }, }, - }, - secrets: { - errors: { - username: [], - password: ['Password is required.'], + secrets: { + errors: { + username: [], + password: ['Password is required.'], + }, }, - }, + }); }); }); }); describe('servicenow action params validation', () => { - test('action params validation succeeds when action params is valid', () => { - const actionParams = { - subActionParams: { incident: { short_description: 'some title {{test}}' }, comments: [] }, - }; + [SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => { + test(`${id}: action params validation succeeds when action params is valid`, () => { + const actionTypeModel = actionTypeRegistry.get(id); + const actionParams = { + subActionParams: { incident: { short_description: 'some title {{test}}' }, comments: [] }, + }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { ['subActionParams.incident.short_description']: [] }, + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { ['subActionParams.incident.short_description']: [] }, + }); }); - }); - test('params validation fails when body is not valid', () => { - const actionParams = { - subActionParams: { incident: { short_description: '' }, comments: [] }, - }; + test(`${id}: params validation fails when body is not valid`, () => { + const actionTypeModel = actionTypeRegistry.get(id); + const actionParams = { + subActionParams: { incident: { short_description: '' }, comments: [] }, + }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { - ['subActionParams.incident.short_description']: ['Short description is required.'], - }, + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + ['subActionParams.incident.short_description']: ['Short description is required.'], + }, + }); }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx index 4389abff72fcd..1b968cfff5d01 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx @@ -10,13 +10,14 @@ import { ActionTypeModel, ConnectorValidationResult, } from '../../../../types'; -import { connectorConfiguration } from './config'; +import { serviceNowITSMConfiguration, serviceNowSIRConfiguration } from './config'; import logo from './logo.svg'; import { ServiceNowActionConnector, ServiceNowConfig, ServiceNowSecrets, - ServiceNowActionParams, + ServiceNowITSMActionParams, + ServiceNowSIRActionParams, } from './types'; import * as i18n from './translations'; import { isValidUrl } from '../../../lib/value_validators'; @@ -60,19 +61,21 @@ const validateConnector = ( return validationResult; }; -export function getActionType(): ActionTypeModel< +export function getServiceNowITSMActionType(): ActionTypeModel< ServiceNowConfig, ServiceNowSecrets, - ServiceNowActionParams + ServiceNowITSMActionParams > { return { - id: connectorConfiguration.id, + id: serviceNowITSMConfiguration.id, iconClass: logo, - selectMessage: i18n.SERVICENOW_DESC, - actionTypeTitle: connectorConfiguration.name, + selectMessage: serviceNowITSMConfiguration.desc, + actionTypeTitle: serviceNowITSMConfiguration.name, validateConnector, actionConnectorFields: lazy(() => import('./servicenow_connectors')), - validateParams: (actionParams: ServiceNowActionParams): GenericValidationResult => { + validateParams: ( + actionParams: ServiceNowITSMActionParams + ): GenericValidationResult => { const errors = { // eslint-disable-next-line @typescript-eslint/naming-convention 'subActionParams.incident.short_description': new Array(), @@ -89,6 +92,39 @@ export function getActionType(): ActionTypeModel< } return validationResult; }, - actionParamsFields: lazy(() => import('./servicenow_params')), + actionParamsFields: lazy(() => import('./servicenow_itsm_params')), + }; +} + +export function getServiceNowSIRActionType(): ActionTypeModel< + ServiceNowConfig, + ServiceNowSecrets, + ServiceNowSIRActionParams +> { + return { + id: serviceNowSIRConfiguration.id, + iconClass: logo, + selectMessage: serviceNowSIRConfiguration.desc, + actionTypeTitle: serviceNowSIRConfiguration.name, + validateConnector, + actionConnectorFields: lazy(() => import('./servicenow_connectors')), + validateParams: (actionParams: ServiceNowSIRActionParams): GenericValidationResult => { + const errors = { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'subActionParams.incident.short_description': new Array(), + }; + const validationResult = { + errors, + }; + if ( + actionParams.subActionParams && + actionParams.subActionParams.incident && + !actionParams.subActionParams.incident.short_description?.length + ) { + errors['subActionParams.incident.short_description'].push(i18n.TITLE_REQUIRED); + } + return validationResult; + }, + actionParamsFields: lazy(() => import('./servicenow_sir_params')), }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx similarity index 53% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx index 5519d7498a85e..51318e14a2cfd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx @@ -5,8 +5,18 @@ */ import React from 'react'; import { mount } from 'enzyme'; -import ServiceNowParamsFields from './servicenow_params'; +import { act } from '@testing-library/react'; + import { ActionConnector } from '../../../../types'; +import { useGetChoices } from './use_get_choices'; +import ServiceNowITSMParamsFields from './servicenow_itsm_params'; +import { Choice } from './types'; + +jest.mock('./use_get_choices'); +jest.mock('../../../../common/lib/kibana'); + +const useGetChoicesMock = useGetChoices as jest.Mock; + const actionParams = { subAction: 'pushToService', subActionParams: { @@ -16,7 +26,6 @@ const actionParams = { severity: '1', urgency: '2', impact: '3', - savedObjectId: '123', externalId: null, }, comments: [], @@ -31,6 +40,7 @@ const connector: ActionConnector = { name: 'Test', isPreconfigured: false, }; + const editAction = jest.fn(); const defaultProps = { actionConnector: connector, @@ -40,31 +50,71 @@ const defaultProps = { index: 0, messageVariables: [], }; -describe('ServiceNowParamsFields renders', () => { + +const useGetChoicesResponse = { + isLoading: false, + choices: ['severity', 'urgency', 'impact'] + .map((element) => [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + element, + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + element, + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + element, + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + element, + }, + ]) + .flat(), +}; + +describe('ServiceNowITSMParamsFields renders', () => { + let onChoices = (choices: Choice[]) => {}; + beforeEach(() => { jest.clearAllMocks(); + useGetChoicesMock.mockImplementation((args) => { + onChoices = args.onSuccess; + return useGetChoicesResponse; + }); }); + test('all params fields is rendered', () => { - const wrapper = mount(); - expect(wrapper.find('[data-test-subj="urgencySelect"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('value')).toStrictEqual( - '1' - ); - expect(wrapper.find('[data-test-subj="impactSelect"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="short_descriptionInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy(); + const wrapper = mount(); + expect(wrapper.find('[data-test-subj="urgencySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="impactSelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="short_descriptionInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="commentsTextArea"]').exists()).toBeTruthy(); }); + test('If short_description has errors, form row is invalid', () => { const newProps = { ...defaultProps, // eslint-disable-next-line @typescript-eslint/naming-convention errors: { 'subActionParams.incident.short_description': ['error'] }, }; - const wrapper = mount(); + const wrapper = mount(); const title = wrapper.find('[data-test-subj="short_descriptionInput"]').first(); expect(title.prop('isInvalid')).toBeTruthy(); }); + test('When subActionParams is undefined, set to default', () => { const { subActionParams, ...newParams } = actionParams; @@ -72,12 +122,13 @@ describe('ServiceNowParamsFields renders', () => { ...defaultProps, actionParams: newParams, }; - mount(); + mount(); expect(editAction.mock.calls[0][1]).toEqual({ incident: {}, comments: [], }); }); + test('When subAction is undefined, set to default', () => { const { subAction, ...newParams } = actionParams; @@ -85,11 +136,12 @@ describe('ServiceNowParamsFields renders', () => { ...defaultProps, actionParams: newParams, }; - mount(); + mount(); expect(editAction.mock.calls[0][1]).toEqual('pushToService'); }); + test('Resets fields when connector changes', () => { - const wrapper = mount(); + const wrapper = mount(); expect(editAction.mock.calls.length).toEqual(0); wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); expect(editAction.mock.calls.length).toEqual(1); @@ -98,6 +150,52 @@ describe('ServiceNowParamsFields renders', () => { comments: [], }); }); + + test('it transforms the urgencies to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoices(useGetChoicesResponse.choices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="urgencySelect"]').first().prop('options')).toEqual([ + { value: '1', text: '1 - Critical' }, + { value: '2', text: '2 - High' }, + { value: '3', text: '3 - Moderate' }, + { value: '4', text: '4 - Low' }, + ]); + }); + + test('it transforms the severities to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoices(useGetChoicesResponse.choices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('options')).toEqual([ + { value: '1', text: '1 - Critical' }, + { value: '2', text: '2 - High' }, + { value: '3', text: '3 - Moderate' }, + { value: '4', text: '4 - Low' }, + ]); + }); + + test('it transforms the impacts to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoices(useGetChoicesResponse.choices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="impactSelect"]').first().prop('options')).toEqual([ + { value: '1', text: '1 - Critical' }, + { value: '2', text: '2 - High' }, + { value: '3', text: '3 - Moderate' }, + { value: '4', text: '4 - Low' }, + ]); + }); + describe('UI updates', () => { const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent; const simpleFields = [ @@ -107,22 +205,25 @@ describe('ServiceNowParamsFields renders', () => { { dataTestSubj: '[data-test-subj="severitySelect"]', key: 'severity' }, { dataTestSubj: '[data-test-subj="impactSelect"]', key: 'impact' }, ]; + simpleFields.forEach((field) => test(`${field.key} update triggers editAction :D`, () => { - const wrapper = mount(); + const wrapper = mount(); const theField = wrapper.find(field.dataTestSubj).first(); theField.prop('onChange')!(changeEvent); expect(editAction.mock.calls[0][1].incident[field.key]).toEqual(changeEvent.target.value); }) ); + test('A comment triggers editAction', () => { - const wrapper = mount(); + const wrapper = mount(); const comments = wrapper.find('textarea[data-test-subj="commentsTextArea"]'); expect(comments.simulate('change', changeEvent)); expect(editAction.mock.calls[0][1].comments.length).toEqual(1); }); + test('An empty comment does not trigger editAction', () => { - const wrapper = mount(); + const wrapper = mount(); const emptyComment = { target: { value: '' } }; const comments = wrapper.find('[data-test-subj="commentsTextArea"] textarea'); expect(comments.simulate('change', emptyComment)); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx similarity index 64% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx index 3e6b443d790a9..658b964f8b91d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useCallback, useEffect, useMemo, useRef } from 'react'; -import { i18n } from '@kbn/i18n'; +import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EuiFormRow, EuiSelect, @@ -14,38 +13,29 @@ import { EuiSpacer, EuiTitle, } from '@elastic/eui'; +import { useKibana } from '../../../../common/lib/kibana'; import { ActionParamsProps } from '../../../../types'; -import { ServiceNowActionParams } from './types'; +import { ServiceNowITSMActionParams, Choice, Options } from './types'; import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; +import { useGetChoices } from './use_get_choices'; +import * as i18n from './translations'; -const selectOptions = [ - { - value: '1', - text: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectHighOptionLabel', - { defaultMessage: 'High' } - ), - }, - { - value: '2', - text: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectMediumOptionLabel', - { defaultMessage: 'Medium' } - ), - }, - { - value: '3', - text: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectLawOptionLabel', - { defaultMessage: 'Low' } - ), - }, -]; +const useGetChoicesFields = ['urgency', 'severity', 'impact']; +const defaultOptions: Options = { + urgency: [], + severity: [], + impact: [], +}; const ServiceNowParamsFields: React.FunctionComponent< - ActionParamsProps + ActionParamsProps > = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => { + const { + http, + notifications: { toasts }, + } = useKibana().services; + const actionConnectorRef = useRef(actionConnector?.id ?? ''); const { incident, comments } = useMemo( () => @@ -53,10 +43,12 @@ const ServiceNowParamsFields: React.FunctionComponent< (({ incident: {}, comments: [], - } as unknown) as ServiceNowActionParams['subActionParams']), + } as unknown) as ServiceNowITSMActionParams['subActionParams']), [actionParams.subActionParams] ); + const [options, setOptions] = useState(defaultOptions); + const editSubActionProperty = useCallback( (key: string, value: any) => { const newProps = @@ -80,6 +72,28 @@ const ServiceNowParamsFields: React.FunctionComponent< [editSubActionProperty] ); + const onChoicesSuccess = (choices: Choice[]) => + setOptions( + choices.reduce( + (acc, choice) => ({ + ...acc, + [choice.element]: [ + ...(acc[choice.element] != null ? acc[choice.element] : []), + { value: choice.value, text: choice.label }, + ], + }), + defaultOptions + ) + ); + + const { isLoading: isLoadingChoices } = useGetChoices({ + http, + toastNotifications: toasts, + actionConnector, + fields: useGetChoicesFields, + onSuccess: onChoicesSuccess, + }); + useEffect(() => { if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) { actionConnectorRef.current = actionConnector.id; @@ -94,6 +108,7 @@ const ServiceNowParamsFields: React.FunctionComponent< } // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionConnector]); + useEffect(() => { if (!actionParams.subAction) { editAction('subAction', 'pushToService', index); @@ -114,64 +129,47 @@ const ServiceNowParamsFields: React.FunctionComponent< return ( -

- {i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.title', - { defaultMessage: 'Incident' } - )} -

+

{i18n.INCIDENT}

- + editSubActionProperty('urgency', e.target.value)} /> - + editSubActionProperty('severity', e.target.value)} /> - + editSubActionProperty('impact', e.target.value)} /> @@ -185,10 +183,7 @@ const ServiceNowParamsFields: React.FunctionComponent< errors['subActionParams.incident.short_description'].length > 0 && incident.short_description !== undefined } - label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.titleFieldLabel', - { defaultMessage: 'Short description (required)' } - )} + label={i18n.SHORT_DESCRIPTION_LABEL} > 0 ? comments[0].comment : undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.commentsTextAreaFieldLabel', - { defaultMessage: 'Additional comments' } - )} + label={i18n.COMMENTS_LABEL} />
); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.test.tsx new file mode 100644 index 0000000000000..72dfd63da3d4e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.test.tsx @@ -0,0 +1,311 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mount } from 'enzyme'; +import { act } from '@testing-library/react'; + +import { ActionConnector } from '../../../../types'; +import { useGetChoices } from './use_get_choices'; +import ServiceNowSIRParamsFields from './servicenow_sir_params'; +import { Choice } from './types'; + +jest.mock('./use_get_choices'); +jest.mock('../../../../common/lib/kibana'); + +const useGetChoicesMock = useGetChoices as jest.Mock; + +const actionParams = { + subAction: 'pushToService', + subActionParams: { + incident: { + short_description: 'sn title', + description: 'some description', + category: 'Denial of Service', + dest_ip: '192.168.1.1', + source_ip: '192.168.1.2', + malware_hash: '098f6bcd4621d373cade4e832627b4f6', + malware_url: 'https://attack.com', + priority: '1', + subcategory: '20', + externalId: null, + }, + comments: [], + }, +}; + +const connector: ActionConnector = { + secrets: {}, + config: {}, + id: 'test', + actionTypeId: '.test', + name: 'Test', + isPreconfigured: false, +}; + +const editAction = jest.fn(); +const defaultProps = { + actionConnector: connector, + actionParams, + errors: { ['subActionParams.incident.short_description']: [] }, + editAction, + index: 0, + messageVariables: [], +}; + +const choicesResponse = { + isLoading: false, + choices: [ + { + dependent_value: '', + label: 'Priviledge Escalation', + value: 'Priviledge Escalation', + element: 'category', + }, + { + dependent_value: '', + label: 'Criminal activity/investigation', + value: 'Criminal activity/investigation', + element: 'category', + }, + { + dependent_value: '', + label: 'Denial of Service', + value: 'Denial of Service', + element: 'category', + }, + { + dependent_value: 'Denial of Service', + label: 'Inbound or outbound', + value: '12', + element: 'subcategory', + }, + { + dependent_value: 'Denial of Service', + label: 'Single or distributed (DoS or DDoS)', + value: '26', + element: 'subcategory', + }, + { + dependent_value: 'Denial of Service', + label: 'Inbound DDos', + value: 'inbound_ddos', + element: 'subcategory', + }, + { + dependent_value: '', + label: '1 - Critical', + value: '1', + element: 'priority', + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + element: 'priority', + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + element: 'priority', + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + element: 'priority', + }, + { + dependent_value: '', + label: '5 - Planning', + value: '5', + element: 'priority', + }, + ], +}; + +describe('ServiceNowSIRParamsFields renders', () => { + let onChoicesSuccess = (choices: Choice[]) => {}; + + beforeEach(() => { + jest.clearAllMocks(); + useGetChoicesMock.mockImplementation((args) => { + onChoicesSuccess = args.onSuccess; + return choicesResponse; + }); + }); + + test('all params fields is rendered', () => { + const wrapper = mount(); + expect(wrapper.find('[data-test-subj="short_descriptionInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="source_ipInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="dest_ipInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="malware_urlInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="malware_hashInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="prioritySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="commentsTextArea"]').exists()).toBeTruthy(); + }); + + test('If short_description has errors, form row is invalid', () => { + const newProps = { + ...defaultProps, + // eslint-disable-next-line @typescript-eslint/naming-convention + errors: { 'subActionParams.incident.short_description': ['error'] }, + }; + const wrapper = mount(); + const title = wrapper.find('[data-test-subj="short_descriptionInput"]').first(); + expect(title.prop('isInvalid')).toBeTruthy(); + }); + + test('When subActionParams is undefined, set to default', () => { + const { subActionParams, ...newParams } = actionParams; + + const newProps = { + ...defaultProps, + actionParams: newParams, + }; + mount(); + expect(editAction.mock.calls[0][1]).toEqual({ + incident: {}, + comments: [], + }); + }); + + test('When subAction is undefined, set to default', () => { + const { subAction, ...newParams } = actionParams; + + const newProps = { + ...defaultProps, + actionParams: newParams, + }; + mount(); + expect(editAction.mock.calls[0][1]).toEqual('pushToService'); + }); + + test('Resets fields when connector changes', () => { + const wrapper = mount(); + expect(editAction.mock.calls.length).toEqual(0); + wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); + expect(editAction.mock.calls.length).toEqual(1); + expect(editAction.mock.calls[0][1]).toEqual({ + incident: {}, + comments: [], + }); + }); + + test('it transforms the categories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(choicesResponse.choices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="categorySelect"]').first().prop('options')).toEqual([ + { value: 'Priviledge Escalation', text: 'Priviledge Escalation' }, + { + value: 'Criminal activity/investigation', + text: 'Criminal activity/investigation', + }, + { value: 'Denial of Service', text: 'Denial of Service' }, + ]); + }); + + test('it transforms the subcategories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(choicesResponse.choices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').first().prop('options')).toEqual([ + { + text: 'Inbound or outbound', + value: '12', + }, + { + text: 'Single or distributed (DoS or DDoS)', + value: '26', + }, + { + text: 'Inbound DDos', + value: 'inbound_ddos', + }, + ]); + }); + + test('it transforms the priorities to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(choicesResponse.choices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('options')).toEqual([ + { + text: '1 - Critical', + value: '1', + }, + { + text: '2 - High', + value: '2', + }, + { + text: '3 - Moderate', + value: '3', + }, + { + text: '4 - Low', + value: '4', + }, + { + text: '5 - Planning', + value: '5', + }, + ]); + }); + + describe('UI updates', () => { + const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent; + const simpleFields = [ + { dataTestSubj: 'input[data-test-subj="short_descriptionInput"]', key: 'short_description' }, + { dataTestSubj: 'textarea[data-test-subj="descriptionTextArea"]', key: 'description' }, + { dataTestSubj: '[data-test-subj="source_ipInput"]', key: 'source_ip' }, + { dataTestSubj: '[data-test-subj="dest_ipInput"]', key: 'dest_ip' }, + { dataTestSubj: '[data-test-subj="malware_urlInput"]', key: 'malware_url' }, + { dataTestSubj: '[data-test-subj="malware_hashInput"]', key: 'malware_hash' }, + { dataTestSubj: '[data-test-subj="prioritySelect"]', key: 'priority' }, + { dataTestSubj: '[data-test-subj="categorySelect"]', key: 'category' }, + { dataTestSubj: '[data-test-subj="subcategorySelect"]', key: 'subcategory' }, + ]; + + simpleFields.forEach((field) => + test(`${field.key} update triggers editAction :D`, () => { + const wrapper = mount(); + const theField = wrapper.find(field.dataTestSubj).first(); + theField.prop('onChange')!(changeEvent); + expect(editAction.mock.calls[0][1].incident[field.key]).toEqual(changeEvent.target.value); + }) + ); + + test('A comment triggers editAction', () => { + const wrapper = mount(); + const comments = wrapper.find('textarea[data-test-subj="commentsTextArea"]'); + expect(comments.simulate('change', changeEvent)); + expect(editAction.mock.calls[0][1].comments.length).toEqual(1); + }); + + test('An empty comment does not trigger editAction', () => { + const wrapper = mount(); + const emptyComment = { target: { value: '' } }; + const comments = wrapper.find('[data-test-subj="commentsTextArea"] textarea'); + expect(comments.simulate('change', emptyComment)); + expect(editAction.mock.calls.length).toEqual(0); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx new file mode 100644 index 0000000000000..26957d828f5e0 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx @@ -0,0 +1,295 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + EuiFormRow, + EuiSelect, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiSelectOption, +} from '@elastic/eui'; +import { useKibana } from '../../../../common/lib/kibana'; +import { ActionParamsProps } from '../../../../types'; +import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; +import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; + +import * as i18n from './translations'; +import { useGetChoices } from './use_get_choices'; +import { ServiceNowSIRActionParams, Fields, Choice } from './types'; + +const useGetChoicesFields = ['category', 'subcategory', 'priority']; +const defaultFields: Fields = { + category: [], + subcategory: [], + priority: [], +}; + +const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => + choices.map((choice) => ({ value: choice.value, text: choice.label })); + +const ServiceNowSIRParamsFields: React.FunctionComponent< + ActionParamsProps +> = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => { + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const actionConnectorRef = useRef(actionConnector?.id ?? ''); + const { incident, comments } = useMemo( + () => + actionParams.subActionParams ?? + (({ + incident: {}, + comments: [], + } as unknown) as ServiceNowSIRActionParams['subActionParams']), + [actionParams.subActionParams] + ); + + const [choices, setChoices] = useState(defaultFields); + + const editSubActionProperty = useCallback( + (key: string, value: any) => { + const newProps = + key !== 'comments' + ? { + incident: { ...incident, [key]: value }, + comments, + } + : { incident, [key]: value }; + editAction('subActionParams', newProps, index); + }, + [comments, editAction, incident, index] + ); + + const editComment = useCallback( + (key, value) => { + if (value.length > 0) { + editSubActionProperty(key, [{ commentId: '1', comment: value }]); + } + }, + [editSubActionProperty] + ); + + const onChoicesSuccess = useCallback((values: Choice[]) => { + setChoices( + values.reduce( + (acc, value) => ({ + ...acc, + [value.element]: [...(acc[value.element] != null ? acc[value.element] : []), value], + }), + defaultFields + ) + ); + }, []); + + const { isLoading: isLoadingChoices } = useGetChoices({ + http, + toastNotifications: toasts, + actionConnector, + // Not having a memoized fields variable will cause infinitive API calls. + fields: useGetChoicesFields, + onSuccess: onChoicesSuccess, + }); + + const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); + const priorityOptions = useMemo(() => choicesToEuiOptions(choices.priority), [choices.priority]); + + const subcategoryOptions = useMemo( + () => + choicesToEuiOptions( + choices.subcategory.filter( + (subcategory) => subcategory.dependent_value === incident.category + ) + ), + [choices.subcategory, incident.category] + ); + + useEffect(() => { + if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) { + actionConnectorRef.current = actionConnector.id; + editAction( + 'subActionParams', + { + incident: {}, + comments: [], + }, + index + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionConnector]); + + useEffect(() => { + if (!actionParams.subAction) { + editAction('subAction', 'pushToService', index); + } + if (!actionParams.subActionParams) { + editAction( + 'subActionParams', + { + incident: {}, + comments: [], + }, + index + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionParams]); + + return ( + + +

{i18n.INCIDENT}

+
+ + 0 && + incident.short_description !== undefined + } + label={i18n.SHORT_DESCRIPTION_LABEL} + > + + + + + + + + + + + + + + + + + + + + + { + editAction( + 'subActionParams', + { + incident: { ...incident, priority: e.target.value }, + comments, + }, + index + ); + }} + /> + + + + + + { + editAction( + 'subActionParams', + { + incident: { ...incident, category: e.target.value, subcategory: null }, + comments, + }, + index + ); + }} + /> + + + + + editSubActionProperty('subcategory', e.target.value)} + /> + + + + + + 0 ? comments[0].comment : undefined} + label={i18n.COMMENTS_LABEL} + /> +
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export { ServiceNowSIRParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index c84a916c0fef4..c8bc2f427bde2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -6,17 +6,31 @@ import { i18n } from '@kbn/i18n'; -export const SERVICENOW_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.selectMessageText', +export const SERVICENOW_ITSM_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText', { - defaultMessage: 'Create an incident in ServiceNow.', + defaultMessage: 'Create an incident in ServiceNow ITSM.', } ); -export const SERVICENOW_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.actionTypeTitle', +export const SERVICENOW_SIR_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.selectMessageText', { - defaultMessage: 'ServiceNow', + defaultMessage: 'Create an incident in ServiceNow SIR.', + } +); + +export const SERVICENOW_ITSM_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle', + { + defaultMessage: 'ServiceNow ITSM', + } +); + +export const SERVICENOW_SIR_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle', + { + defaultMessage: 'ServiceNow SIR', } ); @@ -98,65 +112,114 @@ export const PASSWORD_REQUIRED = i18n.translate( } ); -export const API_TOKEN_LABEL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiTokenTextFieldLabel', +export const TITLE_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.common.requiredShortDescTextField', { - defaultMessage: 'Api token', + defaultMessage: 'Short description is required.', } ); -export const API_TOKEN_REQUIRED = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredApiTokenTextField', +export const SOURCE_IP_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPTitle', { - defaultMessage: 'Api token is required.', + defaultMessage: 'Source IP', } ); -export const EMAIL_LABEL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.emailTextFieldLabel', +export const DEST_IP_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destinationIPTitle', { - defaultMessage: 'Email', + defaultMessage: 'Destination IP', } ); -export const EMAIL_REQUIRED = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredEmailTextField', +export const INCIDENT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.title', { - defaultMessage: 'Email is required.', + defaultMessage: 'Incident', } ); -export const MAPPING_FIELD_SHORT_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldShortDescription', +export const SHORT_DESCRIPTION_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.titleFieldLabel', { - defaultMessage: 'Short Description', + defaultMessage: 'Short description (required)', } ); -export const MAPPING_FIELD_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldDescription', +export const DESCRIPTION_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.descriptionTextAreaFieldLabel', { defaultMessage: 'Description', } ); -export const MAPPING_FIELD_COMMENTS = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldComments', +export const COMMENTS_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.commentsTextAreaFieldLabel', { - defaultMessage: 'Comments', + defaultMessage: 'Additional comments', } ); -export const DESCRIPTION_REQUIRED = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.common.requiredDescriptionTextField', +export const MALWARE_URL_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLTitle', { - defaultMessage: 'Description is required.', + defaultMessage: 'Malware URL', } ); -export const TITLE_REQUIRED = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.common.requiredShortDescTextField', +export const MALWARE_HASH_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashTitle', { - defaultMessage: 'Short description is required.', + defaultMessage: 'Malware hash', + } +); + +export const CHOICES_API_ERROR = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetChoicesMessage', + { + defaultMessage: 'Unable to get choices', + } +); + +export const CATEGORY_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.categoryTitle', + { + defaultMessage: 'Category', + } +); + +export const SUBCATEGORY_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.subcategoryTitle', + { + defaultMessage: 'Subcategory', + } +); + +export const URGENCY_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.urgencySelectFieldLabel', + { + defaultMessage: 'Urgency', + } +); + +export const SEVERITY_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectFieldLabel', + { + defaultMessage: 'Severity', + } +); + +export const IMPACT_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.impactSelectFieldLabel', + { + defaultMessage: 'Impact', + } +); + +export const PRIORITY_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.prioritySelectFieldLabel', + { + defaultMessage: 'Priority', } ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts index ae03680a80534..be9a7c634af8a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts @@ -4,18 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiSelectOption } from '@elastic/eui'; import { UserConfiguredActionConnector } from '../../../../types'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ExecutorSubActionPushParams } from '../../../../../../actions/server/builtin_action_types/servicenow/types'; +import { + ExecutorSubActionPushParamsITSM, + ExecutorSubActionPushParamsSIR, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../actions/server/builtin_action_types/servicenow/types'; export type ServiceNowActionConnector = UserConfiguredActionConnector< ServiceNowConfig, ServiceNowSecrets >; -export interface ServiceNowActionParams { +export interface ServiceNowITSMActionParams { subAction: string; - subActionParams: ExecutorSubActionPushParams; + subActionParams: ExecutorSubActionPushParamsITSM; +} + +export interface ServiceNowSIRActionParams { + subAction: string; + subActionParams: ExecutorSubActionPushParamsSIR; } export interface ServiceNowConfig { @@ -26,3 +35,13 @@ export interface ServiceNowSecrets { username: string; password: string; } + +export interface Choice { + value: string; + label: string; + element: string; + dependent_value: string; +} + +export type Fields = Record; +export type Options = Record; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.test.tsx new file mode 100644 index 0000000000000..4e8061ebaa6e9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.test.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { ActionConnector } from '../../../../types'; +import { useGetChoices, UseGetChoices, UseGetChoicesProps } from './use_get_choices'; +import { getChoices } from './api'; + +jest.mock('./api'); +jest.mock('../../../../common/lib/kibana'); + +const useKibanaMock = useKibana as jest.Mocked; +const getChoicesMock = getChoices as jest.Mock; +const onSuccess = jest.fn(); + +const actionConnector = { + secrets: { + username: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.servicenow', + name: 'ServiceNow ITSM', + isPreconfigured: false, + config: { + apiUrl: 'https://dev94428.service-now.com/', + }, +} as ActionConnector; + +const getChoicesResponse = [ + { + dependent_value: '', + label: 'Priviledge Escalation', + value: 'Priviledge Escalation', + element: 'category', + }, + { + dependent_value: '', + label: 'Criminal activity/investigation', + value: 'Criminal activity/investigation', + element: 'category', + }, + { + dependent_value: '', + label: 'Denial of Service', + value: 'Denial of Service', + element: 'category', + }, +]; + +describe('useGetChoices', () => { + const { services } = useKibanaMock(); + getChoicesMock.mockResolvedValue({ + data: getChoicesResponse, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const fields = ['priority']; + + it('init', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetChoices({ + http: services.http, + actionConnector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + isLoading: false, + choices: getChoicesResponse, + }); + }); + + it('returns an empty array when connector is not presented', async () => { + const { result } = renderHook(() => + useGetChoices({ + http: services.http, + actionConnector: undefined, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + expect(result.current).toEqual({ + isLoading: false, + choices: [], + }); + }); + + it('it calls onSuccess', async () => { + const { waitForNextUpdate } = renderHook(() => + useGetChoices({ + http: services.http, + actionConnector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + await waitForNextUpdate(); + + expect(onSuccess).toHaveBeenCalledWith(getChoicesResponse); + }); + + it('it displays an error when service fails', async () => { + getChoicesMock.mockResolvedValue({ + status: 'error', + serviceMessage: 'An error occurred', + }); + + const { waitForNextUpdate } = renderHook(() => + useGetChoices({ + http: services.http, + actionConnector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + await waitForNextUpdate(); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + text: 'An error occurred', + title: 'Unable to get choices', + }); + }); + + it('it displays an error when http throws an error', async () => { + getChoicesMock.mockImplementation(() => { + throw new Error('An error occurred'); + }); + + renderHook(() => + useGetChoices({ + http: services.http, + actionConnector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + text: 'An error occurred', + title: 'Unable to get choices', + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.tsx new file mode 100644 index 0000000000000..0e4338cec0e18 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../../types'; +import { getChoices } from './api'; +import { Choice } from './types'; +import * as i18n from './translations'; + +export interface UseGetChoicesProps { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + actionConnector?: ActionConnector; + fields: string[]; + onSuccess?: (choices: Choice[]) => void; +} + +export interface UseGetChoices { + choices: Choice[]; + isLoading: boolean; +} + +export const useGetChoices = ({ + http, + actionConnector, + toastNotifications, + fields, + onSuccess, +}: UseGetChoicesProps): UseGetChoices => { + const [isLoading, setIsLoading] = useState(false); + const [choices, setChoices] = useState([]); + const didCancel = useRef(false); + const abortCtrl = useRef(new AbortController()); + + const fetchData = useCallback(async () => { + if (!actionConnector) { + setIsLoading(false); + return; + } + + try { + didCancel.current = false; + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); + setIsLoading(true); + + const res = await getChoices({ + http, + signal: abortCtrl.current.signal, + connectorId: actionConnector.id, + fields, + }); + + if (!didCancel.current) { + setIsLoading(false); + setChoices(res.data ?? []); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.CHOICES_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } else if (onSuccess) { + onSuccess(res.data ?? []); + } + } + } catch (error) { + if (!didCancel.current) { + setIsLoading(false); + toastNotifications.addDanger({ + title: i18n.CHOICES_API_ERROR, + text: error.message, + }); + } + } + }, [actionConnector, http, fields, onSuccess, toastNotifications]); + + useEffect(() => { + fetchData(); + + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + setIsLoading(false); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + choices, + isLoading, + }; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index 13443e0b68245..9cde2802e0c40 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -550,7 +550,9 @@ describe('action_form', () => { ]); expect(setHasActionsWithBrokenConnector).toHaveBeenLastCalledWith(true); expect(wrapper.find(EuiAccordion)).toHaveLength(3); - expect(wrapper.find(`div[data-test-subj="alertActionAccordionCallout"]`)).toHaveLength(2); + expect( + wrapper.find(`EuiIconTip[data-test-subj="alertActionAccordionErrorTooltip"]`) + ).toHaveLength(2); }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 2145443ba044c..ed6ea6a73f242 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -34,7 +34,11 @@ import { ActionTypeForm, ActionTypeFormProps } from './action_type_form'; import { AddConnectorInline } from './connector_add_inline'; import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; -import { VIEW_LICENSE_OPTIONS_LINK, DEFAULT_HIDDEN_ACTION_TYPES } from '../../../common/constants'; +import { + VIEW_LICENSE_OPTIONS_LINK, + DEFAULT_HIDDEN_ACTION_TYPES, + DEFAULT_HIDDEN_ONLY_ON_ALERTS_ACTION_TYPES, +} from '../../../common/constants'; import { ActionGroup, AlertActionParam } from '../../../../../alerts/common'; import { useKibana } from '../../../common/lib/kibana'; import { DefaultActionParamsGetter } from '../../lib/get_defaults_for_action_params'; @@ -230,9 +234,15 @@ export const ActionForm = ({ .list() /** * TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502. + * TODO: Need to decide about ServiceNow SIR connector. * If actionTypes are set, hidden connectors are filtered out. Otherwise, they are not. */ - .filter(({ id }) => actionTypes ?? !DEFAULT_HIDDEN_ACTION_TYPES.includes(id)) + .filter( + ({ id }) => + actionTypes ?? + (!DEFAULT_HIDDEN_ACTION_TYPES.includes(id) && + !DEFAULT_HIDDEN_ONLY_ON_ALERTS_ACTION_TYPES.includes(id)) + ) .filter((item) => actionTypesIndex[item.id]) .filter((item) => !!item.actionParamsFields) .sort((a, b) => @@ -308,6 +318,7 @@ export const ActionForm = ({ key={`action-form-action-at-${index}`} actionTypeRegistry={actionTypeRegistry} emptyActionsIds={emptyActionsIds} + connectors={connectors} onDeleteConnector={() => { const updatedActions = actions.filter( (_item: AlertAction, i: number) => i !== index @@ -330,6 +341,9 @@ export const ActionForm = ({ }); setAddModalVisibility(true); }} + onSelectConnector={(connectorId: string) => { + setActionIdByIndex(connectorId, index); + }} /> ); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx index 6ffe730658d3d..c2e96e6f3c0ef 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; +import React, { Fragment, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -18,8 +18,13 @@ import { EuiEmptyPrompt, EuiCallOut, EuiText, + EuiFormRow, + EuiButtonEmpty, + EuiComboBox, + EuiComboBoxOptionOption, + EuiIconTip, } from '@elastic/eui'; -import { AlertAction, ActionTypeIndex } from '../../../types'; +import { AlertAction, ActionTypeIndex, ActionConnector } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { ActionAccordionFormProps } from './action_form'; import { useKibana } from '../../../common/lib/kibana'; @@ -27,9 +32,11 @@ import { useKibana } from '../../../common/lib/kibana'; type AddConnectorInFormProps = { actionTypesIndex: ActionTypeIndex; actionItem: AlertAction; + connectors: ActionConnector[]; index: number; onAddConnector: () => void; onDeleteConnector: () => void; + onSelectConnector: (connectorId: string) => void; emptyActionsIds: string[]; } & Pick; @@ -37,8 +44,10 @@ export const AddConnectorInline = ({ actionTypesIndex, actionItem, index, + connectors, onAddConnector, onDeleteConnector, + onSelectConnector, actionTypeRegistry, emptyActionsIds, }: AddConnectorInFormProps) => { @@ -46,10 +55,14 @@ export const AddConnectorInline = ({ application: { capabilities }, } = useKibana().services; const canSave = hasSaveActionsCapability(capabilities); + const [connectorOptionsList, setConnectorOptionsList] = useState([]); + const [isEmptyActionId, setIsEmptyActionId] = useState(false); + const [errors, setErrors] = useState([]); const actionTypeName = actionTypesIndex ? actionTypesIndex[actionItem.actionTypeId].name : actionItem.actionTypeId; + const actionType = actionTypesIndex[actionItem.actionTypeId]; const actionTypeRegistered = actionTypeRegistry.get(actionItem.actionTypeId); const noConnectorsLabel = ( @@ -61,6 +74,92 @@ export const AddConnectorInline = ({ }} /> ); + + const unableToLoadConnectorLabel = ( + + + + ); + + useEffect(() => { + if (connectors) { + const altConnectorOptions = connectors + .filter( + (connector) => + connector.actionTypeId === actionItem.actionTypeId && + // include only enabled by config connectors or preconfigured + (actionType?.enabledInConfig || connector.isPreconfigured) + ) + .map(({ name, id, isPreconfigured }) => ({ + label: `${name} ${isPreconfigured ? '(preconfigured)' : ''}`, + key: id, + id, + })); + setConnectorOptionsList(altConnectorOptions); + + if (altConnectorOptions.length > 0) { + setErrors([`Unable to load ${actionTypeRegistered.actionTypeTitle} connector`]); + } + } + + setIsEmptyActionId(!!emptyActionsIds.find((emptyId: string) => actionItem.id === emptyId)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const connectorsDropdown = ( + + + + } + labelAppend={ + + + + } + error={errors} + isInvalid={errors.length > 0} + > + { + // On selecting a option from this combo box, this component will + // be removed but the EuiComboBox performs some additional updates on + // closing the dropdown. Wrapping in a `setTimeout` to avoid `React state + // update on an unmounted component` warnings. + setTimeout(() => { + onSelectConnector(selectedOptions[0].id ?? ''); + }); + }} + isClearable={false} + /> + + + + ); + return ( + {!isEmptyActionId && ( + + + } + /> + + )} } extraAction={ @@ -106,38 +221,27 @@ export const AddConnectorInline = ({ paddingSize="l" > {canSave ? ( - actionItem.id === emptyId) ? ( - noConnectorsLabel - ) : ( - - ) - } - actions={[ - - - , - ]} - /> + connectorOptionsList.length > 0 ? ( + connectorsDropdown + ) : ( + + + + } + /> + ) ) : (

diff --git a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts index 833ed915fad59..8832f8b826eab 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts @@ -11,3 +11,5 @@ export { builtInGroupByTypes } from './group_by_types'; export const VIEW_LICENSE_OPTIONS_LINK = 'https://www.elastic.co/subscriptions'; // TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502. export const DEFAULT_HIDDEN_ACTION_TYPES = ['.case']; +// Action types included in this array will be hidden only from the alert's action type node list +export const DEFAULT_HIDDEN_ONLY_ON_ALERTS_ACTION_TYPES = ['.servicenow-sir']; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/index.ts index 86c33a373753f..c7f43fedb6f03 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/index.ts @@ -10,6 +10,6 @@ export * from './index_controls'; export * from './lib'; export * from './types'; -export { connectorConfiguration as ServiceNowConnectorConfiguration } from '../application/components/builtin_action_types/servicenow/config'; +export { serviceNowITSMConfiguration as ServiceNowITSMConnectorConfiguration } from '../application/components/builtin_action_types/servicenow/config'; export { connectorConfiguration as JiraConnectorConfiguration } from '../application/components/builtin_action_types/jira/config'; export { connectorConfiguration as ResilientConnectorConfiguration } from '../application/components/builtin_action_types/resilient/config'; diff --git a/x-pack/plugins/uptime/public/components/settings/types.ts b/x-pack/plugins/uptime/public/components/settings/types.ts index faa1c7e72e47b..7a3af47524b20 100644 --- a/x-pack/plugins/uptime/public/components/settings/types.ts +++ b/x-pack/plugins/uptime/public/components/settings/types.ts @@ -9,7 +9,7 @@ import { JiraActionTypeId, PagerDutyActionTypeId, ServerLogActionTypeId, - ServiceNowActionTypeId, + ServiceNowITSMActionTypeId as ServiceNowActionTypeId, SlackActionTypeId, TeamsActionTypeId, WebhookActionTypeId, diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index 18f3c83b00141..dfdacb230763f 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -38,6 +38,7 @@ export function getAllExternalServiceSimulatorPaths(): string[] { getExternalServiceSimulatorPath(service) ); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident`); + allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/sys_choice`); allPaths.push( `/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/sys_dictionary` ); diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts index 2c3138a36f071..b94bb89fc6f4a 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts @@ -127,6 +127,51 @@ export function initPlugin(router: IRouter, path: string) { }); } ); + + router.get( + { + path: `${path}/api/now/v2/table/sys_choice`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + result: [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + }, + { + dependent_value: '', + label: '5 - Planning', + value: '5', + }, + ], + }); + } + ); } function jsonResponse(res: KibanaResponseFactory, code: number, object?: Record) { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index e448ad1f9c2ad..5f7146b43bfdb 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -216,7 +216,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { // Cannot destructure property 'value' of 'undefined' as it is undefined. // // The error seems to come from the exact same place in the code based on the - // exact same circomstances: + // exact same circumstances: // // https://github.com/elastic/kibana/blob/b0a223ebcbac7e404e8ae6da23b2cc6a4b509ff1/packages/kbn-config-schema/src/types/literal_type.ts#L28 // @@ -247,7 +247,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subAction]: expected value to equal [getChoices]', }); }); }); @@ -265,7 +265,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', }); }); }); @@ -288,7 +288,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', }); }); }); @@ -315,7 +315,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', }); }); }); @@ -342,10 +342,33 @@ export default function servicenowTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', }); }); }); + + describe('getChoices', () => { + it('should fail when field is not provided', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getChoices', + subActionParams: {}, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subActionParams.fields]: expected value of type [array] but got [undefined]', + }); + }); + }); + }); }); describe('Execution', () => { @@ -376,6 +399,54 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }, }); }); + + describe('getChoices', () => { + it('should get choices', async () => { + const { body: result } = await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getChoices', + subActionParams: { fields: ['priority'] }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + actionId: simulatedActionId, + data: [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + }, + { + dependent_value: '', + label: '5 - Planning', + value: '5', + }, + ], + }); + }); + }); }); after(() => { diff --git a/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts b/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts index 79d5e68344432..0e61e3aaa0754 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts @@ -6,21 +6,17 @@ import expect from '@kbn/expect'; import { v4 as uuidv4 } from 'uuid'; - -import { decodeOrThrow } from '../../../../plugins/infra/common/runtime_types'; - import { LOG_ENTRIES_PATH, logEntriesRequestRT, logEntriesResponseRT, } from '../../../../plugins/infra/common/http_api'; - import { - LogTimestampColumn, LogFieldColumn, LogMessageColumn, + LogTimestampColumn, } from '../../../../plugins/infra/common/log_entry'; - +import { decodeOrThrow } from '../../../../plugins/infra/common/runtime_types'; import { FtrProviderContext } from '../../ftr_provider_context'; const KEY_WITHIN_DATA_RANGE = { diff --git a/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts b/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts index b3e7e0672fc7f..fe32e4493b6e4 100644 --- a/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts +++ b/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import url from 'url'; -import { sortBy, pick, last } from 'lodash'; +import { sortBy, pick, last, omit } from 'lodash'; import { ValuesType } from 'utility-types'; import { registry } from '../../../common/registry'; import { Maybe } from '../../../../../plugins/apm/typings/common'; @@ -306,7 +306,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { before(async () => { response = await supertest.get( url.format({ - pathname: `/api/apm/services/opbeans-java/dependencies`, + pathname: `/api/apm/services/opbeans-python/dependencies`, query: { start, end, @@ -323,14 +323,41 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns at least one item', () => { expect(response.body.length).to.be.greaterThan(0); + + expectSnapshot( + omit(response.body[0], [ + 'errorRate.timeseries', + 'throughput.timeseries', + 'latency.timeseries', + ]) + ).toMatchInline(` + Object { + "errorRate": Object { + "value": 0, + }, + "impact": 1.97910470896139, + "latency": Object { + "value": 1043.99015586546, + }, + "name": "redis", + "spanSubtype": "redis", + "spanType": "db", + "throughput": Object { + "value": 40.6333333333333, + }, + "type": "external", + } + `); }); it('returns the right names', () => { const names = response.body.map((item) => item.name); expectSnapshot(names.sort()).toMatchInline(` Array [ - "opbeans-go", + "elasticsearch", + "opbeans-java", "postgresql", + "redis", ] `); }); @@ -342,7 +369,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { expectSnapshot(serviceNames.sort()).toMatchInline(` Array [ - "opbeans-go", + "opbeans-java", ] `); }); @@ -356,32 +383,89 @@ export default function ApiTest({ getService }: FtrProviderContext) { expectSnapshot(latencyValues).toMatchInline(` Array [ Object { - "latency": 38506.4285714286, - "name": "opbeans-go", + "latency": 2568.40816326531, + "name": "elasticsearch", + }, + Object { + "latency": 25593.875, + "name": "opbeans-java", }, Object { - "latency": 5908.77272727273, + "latency": 28885.3293963255, "name": "postgresql", }, + Object { + "latency": 1043.99015586546, + "name": "redis", + }, ] `); }); it('returns the right throughput values', () => { const throughputValues = sortBy( - response.body.map((item) => ({ name: item.name, latency: item.throughput.value })), + response.body.map((item) => ({ name: item.name, throughput: item.throughput.value })), 'name' ); expectSnapshot(throughputValues).toMatchInline(` Array [ Object { - "latency": 0.466666666666667, - "name": "opbeans-go", + "name": "elasticsearch", + "throughput": 13.0666666666667, + }, + Object { + "name": "opbeans-java", + "throughput": 0.533333333333333, }, Object { - "latency": 3.66666666666667, "name": "postgresql", + "throughput": 50.8, + }, + Object { + "name": "redis", + "throughput": 40.6333333333333, + }, + ] + `); + }); + + it('returns the right impact values', () => { + const impactValues = sortBy( + response.body.map((item) => ({ + name: item.name, + impact: item.impact, + latency: item.latency.value, + throughput: item.throughput.value, + })), + 'name' + ); + + expectSnapshot(impactValues).toMatchInline(` + Array [ + Object { + "impact": 1.36961744704522, + "latency": 2568.40816326531, + "name": "elasticsearch", + "throughput": 13.0666666666667, + }, + Object { + "impact": 0, + "latency": 25593.875, + "name": "opbeans-java", + "throughput": 0.533333333333333, + }, + Object { + "impact": 100, + "latency": 28885.3293963255, + "name": "postgresql", + "throughput": 50.8, + }, + Object { + "impact": 1.97910470896139, + "latency": 1043.99015586546, + "name": "redis", + "throughput": 40.6333333333333, }, ] `); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/cloning.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/cloning.ts index 532de930bc1a1..91ca0e6f32fd8 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/cloning.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/cloning.ts @@ -13,7 +13,8 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - describe('jobs cloning supported by UI form', function () { + // Failing ES promotion, see https://github.com/elastic/kibana/issues/89980 + describe.skip('jobs cloning supported by UI form', function () { const testDataList: Array<{ suiteTitle: string; archive: string; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index 35e2e03969023..07e0ef62ea4db 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -21,6 +21,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const retry = getService('retry'); const find = getService('find'); const supertest = getService('supertest'); + const comboBox = getService('comboBox'); const objectRemover = new ObjectRemover(supertest); async function createActionManualCleanup(overwrites: Record = {}) { @@ -313,15 +314,70 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { describe('Edit alert with deleted connector', function () { const testRunUuid = uuid.v4(); - after(async () => { + afterEach(async () => { await objectRemover.removeAll(); }); - it('should show and update deleted connectors', async () => { + it('should show and update deleted connectors when there are existing connectors of the same type', async () => { const action = await createActionManualCleanup({ name: `slack-${testRunUuid}-${0}`, }); + await pageObjects.common.navigateToApp('triggersActions'); + const alert = await createAlwaysFiringAlert({ + name: testRunUuid, + actions: [ + { + group: 'default', + id: action.id, + params: { level: 'info', message: ' {{context.message}}' }, + }, + ], + }); + + // refresh to see alert + await browser.refresh(); + await pageObjects.header.waitUntilLoadingHasFinished(); + + // verify content + await testSubjects.existOrFail('alertsList'); + + // delete connector + await pageObjects.triggersActionsUI.changeTabs('connectorsTab'); + await pageObjects.triggersActionsUI.searchConnectors(action.name); + await testSubjects.click('deleteConnector'); + await testSubjects.existOrFail('deleteIdsConfirmation'); + await testSubjects.click('deleteIdsConfirmation > confirmModalConfirmButton'); + await testSubjects.missingOrFail('deleteIdsConfirmation'); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql('Deleted 1 connector'); + + // click on first alert + await pageObjects.triggersActionsUI.changeTabs('alertsTab'); + await pageObjects.triggersActionsUI.clickOnAlertInAlertsList(alert.name); + + const editButton = await testSubjects.find('openEditAlertFlyoutButton'); + await editButton.click(); + expect(await testSubjects.exists('hasActionsDisabled')).to.eql(false); + + expect(await testSubjects.exists('addNewActionConnectorActionGroup-0')).to.eql(false); + expect(await testSubjects.exists('alertActionAccordion-0')).to.eql(true); + + await comboBox.set('selectActionConnector-.slack-0', 'Slack#xyztest (preconfigured)'); + expect(await testSubjects.exists('addNewActionConnectorActionGroup-0')).to.eql(true); + }); + + it('should show and update deleted connectors when there are no existing connectors of the same type', async () => { + const action = await createActionManualCleanup({ + name: `index-${testRunUuid}-${0}`, + actionTypeId: '.index', + config: { + index: `index-${testRunUuid}-${0}`, + }, + secrets: {}, + }); + await pageObjects.common.navigateToApp('triggersActions'); const alert = await createAlwaysFiringAlert({ name: testRunUuid, @@ -373,7 +429,17 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('createActionConnectorButton-0'); await testSubjects.existOrFail('connectorAddModal'); await testSubjects.setValue('nameInput', 'new connector'); - await testSubjects.setValue('slackWebhookUrlInput', 'https://test'); + await retry.try(async () => { + // At times we find the driver controlling the ComboBox in tests + // can select the wrong item, this ensures we always select the correct index + await comboBox.set('connectorIndexesComboBox', 'test-index'); + expect( + await comboBox.isOptionSelected( + await testSubjects.find('connectorIndexesComboBox'), + 'test-index' + ) + ).to.be(true); + }); await testSubjects.click('connectorAddModal > saveActionButtonModal'); await testSubjects.missingOrFail('deleteIdsConfirmation'); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts index 8600cb6c852f5..54ce70911903b 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts @@ -10,9 +10,9 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { describe('Actions and Triggers app', function () { this.tags('ciGroup10'); loadTestFile(require.resolve('./home_page')); - loadTestFile(require.resolve('./connectors')); loadTestFile(require.resolve('./alerts_list')); loadTestFile(require.resolve('./alert_create_flyout')); loadTestFile(require.resolve('./details')); + loadTestFile(require.resolve('./connectors')); }); }; diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index a9d76eea80d8f..a1c0c272deb04 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -21,6 +21,7 @@ { "path": "../../src/plugins/es_ui_shared/tsconfig.json" }, { "path": "../../src/plugins/expressions/tsconfig.json" }, { "path": "../../src/plugins/home/tsconfig.json" }, + { "path": "../../src/plugins/kibana_overview/tsconfig.json" }, { "path": "../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../src/plugins/kibana_usage_collection/tsconfig.json" }, { "path": "../../src/plugins/kibana_utils/tsconfig.json" }, @@ -40,6 +41,7 @@ { "path": "../plugins/actions/tsconfig.json" }, { "path": "../plugins/alerts/tsconfig.json" }, + { "path": "../plugins/code/tsconfig.json" }, { "path": "../plugins/console_extensions/tsconfig.json" }, { "path": "../plugins/data_enhanced/tsconfig.json" }, { "path": "../plugins/enterprise_search/tsconfig.json" }, diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 7f64a552a5169..48283b3ac2747 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -9,6 +9,7 @@ "plugins/apm/scripts/**/*", "plugins/canvas/**/*", "plugins/console_extensions/**/*", + "plugins/code/**/*", "plugins/data_enhanced/**/*", "plugins/discover_enhanced/**/*", "plugins/dashboard_enhanced/**/*", @@ -67,6 +68,7 @@ { "path": "../src/plugins/home/tsconfig.json" }, { "path": "../src/plugins/inspector/tsconfig.json" }, { "path": "../src/plugins/kibana_legacy/tsconfig.json" }, + { "path": "../src/plugins/kibana_overview/tsconfig.json" }, { "path": "../src/plugins/kibana_react/tsconfig.json" }, { "path": "../src/plugins/kibana_usage_collection/tsconfig.json" }, { "path": "../src/plugins/kibana_utils/tsconfig.json" }, @@ -92,6 +94,7 @@ { "path": "./plugins/beats_management/tsconfig.json" }, { "path": "./plugins/canvas/tsconfig.json" }, { "path": "./plugins/cloud/tsconfig.json" }, + { "path": "./plugins/code/tsconfig.json" }, { "path": "./plugins/console_extensions/tsconfig.json" }, { "path": "./plugins/data_enhanced/tsconfig.json" }, { "path": "./plugins/discover_enhanced/tsconfig.json" }, diff --git a/x-pack/tsconfig.refs.json b/x-pack/tsconfig.refs.json index 43a488e8727cc..23b06040f3ec3 100644 --- a/x-pack/tsconfig.refs.json +++ b/x-pack/tsconfig.refs.json @@ -1,11 +1,12 @@ { "include": [], "references": [ - { "path": "./plugins/actions/tsconfig.json"}, - { "path": "./plugins/alerts/tsconfig.json"}, + { "path": "./plugins/actions/tsconfig.json" }, + { "path": "./plugins/alerts/tsconfig.json" }, { "path": "./plugins/beats_management/tsconfig.json" }, { "path": "./plugins/canvas/tsconfig.json" }, { "path": "./plugins/cloud/tsconfig.json" }, + { "path": "./plugins/code/tsconfig.json" }, { "path": "./plugins/console_extensions/tsconfig.json" }, { "path": "./plugins/dashboard_enhanced/tsconfig.json" }, { "path": "./plugins/data_enhanced/tsconfig.json" }, @@ -13,7 +14,7 @@ { "path": "./plugins/embeddable_enhanced/tsconfig.json" }, { "path": "./plugins/encrypted_saved_objects/tsconfig.json" }, { "path": "./plugins/enterprise_search/tsconfig.json" }, - { "path": "./plugins/event_log/tsconfig.json"}, + { "path": "./plugins/event_log/tsconfig.json" }, { "path": "./plugins/features/tsconfig.json" }, { "path": "./plugins/global_search_bar/tsconfig.json" }, { "path": "./plugins/global_search_providers/tsconfig.json" }, @@ -31,14 +32,14 @@ { "path": "./plugins/searchprofiler/tsconfig.json" }, { "path": "./plugins/security/tsconfig.json" }, { "path": "./plugins/spaces/tsconfig.json" }, - { "path": "./plugins/stack_alerts/tsconfig.json"}, + { "path": "./plugins/stack_alerts/tsconfig.json" }, { "path": "./plugins/task_manager/tsconfig.json" }, { "path": "./plugins/telemetry_collection_xpack/tsconfig.json" }, { "path": "./plugins/translations/tsconfig.json" }, - { "path": "./plugins/triggers_actions_ui/tsconfig.json"}, + { "path": "./plugins/triggers_actions_ui/tsconfig.json" }, { "path": "./plugins/spaces/tsconfig.json" }, { "path": "./plugins/security/tsconfig.json" }, - { "path": "./plugins/stack_alerts/tsconfig.json"}, + { "path": "./plugins/stack_alerts/tsconfig.json" }, { "path": "./plugins/encrypted_saved_objects/tsconfig.json" }, { "path": "./plugins/beats_management/tsconfig.json" }, { "path": "./plugins/cloud/tsconfig.json" },