diff --git a/.github/workflows/pr-project-assigner.yml b/.github/workflows/pr-project-assigner.yml index 3e9c86d0ee891..d8b25b980a478 100644 --- a/.github/workflows/pr-project-assigner.yml +++ b/.github/workflows/pr-project-assigner.yml @@ -13,9 +13,9 @@ jobs: with: issue-mappings: | [ - { "label": "Team:AppArch", "projectName": "kibana-app-arch", "columnId": 6173897 }, - { "label": "Feature:Lens", "projectName": "Lens", "columnId": 6219362 }, - { "label": "Team:Canvas", "projectName": "canvas", "columnId": 6187580 } + { "label": "Team:AppArch", "projectNumber": 37, "columnName": "Review in progress" }, + { "label": "Feature:Lens", "projectNumber": 32, "columnName": "In progress" }, + { "label": "Team:Canvas", "projectNumber": 38, "columnName": "Review in progress" } ] ghToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/project-assigner.yml b/.github/workflows/project-assigner.yml index 2bc4c506fea33..30032c9a7f998 100644 --- a/.github/workflows/project-assigner.yml +++ b/.github/workflows/project-assigner.yml @@ -11,7 +11,7 @@ jobs: uses: elastic/github-actions/project-assigner@v2.0.0 id: project_assigner with: - issue-mappings: '[{"label": "Team:AppArch", "projectName": "kibana-app-arch", "columnId": 6173895}, {"label": "Feature:Lens", "projectName": "Lens", "columnId": 6219363}, {"label": "Team:Canvas", "projectName": "canvas", "columnId": 6187593}]' + issue-mappings: '[{"label": "Team:AppArch", "projectNumber": 37, "columnName": "To triage"}, {"label": "Feature:Lens", "projectNumber": 32, "columnName": "Long-term goals"}, {"label": "Team:Canvas", "projectNumber": 38, "columnName": "Inbox"}]' ghToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts index 47947e985092b..fc419b0f10dca 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts +++ b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts @@ -18,9 +18,17 @@ */ import ace from 'brace'; -import { Editor as IAceEditor } from 'brace'; +import { Editor as IAceEditor, IEditSession as IAceEditSession } from 'brace'; import $ from 'jquery'; -import { CoreEditor, Position, Range, Token, TokensProvider, EditorEvent } from '../../../types'; +import { + CoreEditor, + Position, + Range, + Token, + TokensProvider, + EditorEvent, + AutoCompleterFunction, +} from '../../../types'; import { AceTokensProvider } from '../../../lib/ace_token_provider'; import * as curl from '../sense_editor/curl'; import smartResize from './smart_resize'; @@ -354,4 +362,48 @@ export class LegacyCoreEditor implements CoreEditor { } } } + + registerAutocompleter(autocompleter: AutoCompleterFunction): void { + // Hook into Ace + + // disable standard context based autocompletion. + // @ts-ignore + ace.define('ace/autocomplete/text_completer', ['require', 'exports', 'module'], function( + require: any, + exports: any + ) { + exports.getCompletions = function( + innerEditor: any, + session: any, + pos: any, + prefix: any, + callback: any + ) { + callback(null, []); + }; + }); + + const langTools = ace.acequire('ace/ext/language_tools'); + + langTools.setCompleters([ + { + identifierRegexps: [ + /[a-zA-Z_0-9\.\$\-\u00A2-\uFFFF]/, // adds support for dot character + ], + getCompletions: ( + DO_NOT_USE_1: IAceEditor, + DO_NOT_USE_2: IAceEditSession, + pos: { row: number; column: number }, + prefix: string, + callback: (...args: any[]) => void + ) => { + const position: Position = { + lineNumber: pos.row + 1, + column: pos.column + 1, + }; + autocompleter(position, prefix, callback); + }, + }, + ]); + } } diff --git a/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js b/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js index 1a09b6b00da9c..c5a0c2ebddf71 100644 --- a/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js +++ b/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js @@ -84,93 +84,90 @@ describe('Integration', () => { changeListener: function() {}, }; // mimic auto complete - senseEditor.autocomplete._test.getCompletions( - senseEditor, - null, - { row: cursor.lineNumber - 1, column: cursor.column - 1 }, - '', - function(err, terms) { - if (testToRun.assertThrows) { - done(); - return; - } + senseEditor.autocomplete._test.getCompletions(senseEditor, null, cursor, '', function( + err, + terms + ) { + if (testToRun.assertThrows) { + done(); + return; + } - if (err) { - throw err; - } + if (err) { + throw err; + } - if (testToRun.no_context) { - expect(!terms || terms.length === 0).toBeTruthy(); - } else { - expect(terms).not.toBeNull(); - expect(terms.length).toBeGreaterThan(0); - } + if (testToRun.no_context) { + expect(!terms || terms.length === 0).toBeTruthy(); + } else { + expect(terms).not.toBeNull(); + expect(terms.length).toBeGreaterThan(0); + } - if (!terms || terms.length === 0) { - done(); - return; - } + if (!terms || terms.length === 0) { + done(); + return; + } - if (testToRun.autoCompleteSet) { - const expectedTerms = _.map(testToRun.autoCompleteSet, function(t) { - if (typeof t !== 'object') { - t = { name: t }; - } - return t; - }); - if (terms.length !== expectedTerms.length) { - expect(_.pluck(terms, 'name')).toEqual(_.pluck(expectedTerms, 'name')); - } else { - const filteredActualTerms = _.map(terms, function(actualTerm, i) { - const expectedTerm = expectedTerms[i]; - const filteredTerm = {}; - _.each(expectedTerm, function(v, p) { - filteredTerm[p] = actualTerm[p]; - }); - return filteredTerm; - }); - expect(filteredActualTerms).toEqual(expectedTerms); + if (testToRun.autoCompleteSet) { + const expectedTerms = _.map(testToRun.autoCompleteSet, function(t) { + if (typeof t !== 'object') { + t = { name: t }; } + return t; + }); + if (terms.length !== expectedTerms.length) { + expect(_.pluck(terms, 'name')).toEqual(_.pluck(expectedTerms, 'name')); + } else { + const filteredActualTerms = _.map(terms, function(actualTerm, i) { + const expectedTerm = expectedTerms[i]; + const filteredTerm = {}; + _.each(expectedTerm, function(v, p) { + filteredTerm[p] = actualTerm[p]; + }); + return filteredTerm; + }); + expect(filteredActualTerms).toEqual(expectedTerms); } + } - const context = terms[0].context; - const { - cursor: { lineNumber, column }, - } = testToRun; - senseEditor.autocomplete._test.addReplacementInfoToContext( - context, - { lineNumber, column }, - terms[0].value - ); + const context = terms[0].context; + const { + cursor: { lineNumber, column }, + } = testToRun; + senseEditor.autocomplete._test.addReplacementInfoToContext( + context, + { lineNumber, column }, + terms[0].value + ); - function ac(prop, propTest) { - if (typeof testToRun[prop] !== 'undefined') { - if (propTest) { - propTest(context[prop], testToRun[prop], prop); - } else { - expect(context[prop]).toEqual(testToRun[prop]); - } + function ac(prop, propTest) { + if (typeof testToRun[prop] !== 'undefined') { + if (propTest) { + propTest(context[prop], testToRun[prop], prop); + } else { + expect(context[prop]).toEqual(testToRun[prop]); } } + } - function posCompare(actual, expected) { - expect(actual.lineNumber).toEqual(expected.lineNumber + lineOffset); - expect(actual.column).toEqual(expected.column); - } - - function rangeCompare(actual, expected, name) { - posCompare(actual.start, expected.start, name + '.start'); - posCompare(actual.end, expected.end, name + '.end'); - } + function posCompare(actual, expected) { + expect(actual.lineNumber).toEqual(expected.lineNumber + lineOffset); + expect(actual.column).toEqual(expected.column); + } - ac('prefixToAdd'); - ac('suffixToAdd'); - ac('addTemplate'); - ac('textBoxPosition', posCompare); - ac('rangeToReplace', rangeCompare); - done(); + function rangeCompare(actual, expected, name) { + posCompare(actual.start, expected.start, name + '.start'); + posCompare(actual.end, expected.end, name + '.end'); } - ); + + ac('prefixToAdd'); + ac('suffixToAdd'); + ac('addTemplate'); + ac('textBoxPosition', posCompare); + ac('rangeToReplace', rangeCompare); + done(); + }); }); } diff --git a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts b/src/plugins/console/public/application/models/sense_editor/sense_editor.ts index f559f5dfcd707..b1444bdf2bbab 100644 --- a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts +++ b/src/plugins/console/public/application/models/sense_editor/sense_editor.ts @@ -44,6 +44,7 @@ export class SenseEditor { coreEditor, parser: this.parser, }); + this.coreEditor.registerAutocompleter(this.autocomplete.getCompletions); this.coreEditor.on( 'tokenizerUpdate', this.highlightCurrentRequestsAndUpdateActionBar.bind(this) diff --git a/src/plugins/console/public/lib/autocomplete/__tests__/url_autocomplete.test.js b/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js similarity index 99% rename from src/plugins/console/public/lib/autocomplete/__tests__/url_autocomplete.test.js rename to src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js index 40fcd551fb6f7..0758a75695566 100644 --- a/src/plugins/console/public/lib/autocomplete/__tests__/url_autocomplete.test.js +++ b/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import '../../../application/models/sense_editor/sense_editor.test.mocks'; const _ = require('lodash'); import { diff --git a/src/plugins/console/public/lib/autocomplete/__tests__/url_params.test.js b/src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js similarity index 95% rename from src/plugins/console/public/lib/autocomplete/__tests__/url_params.test.js rename to src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js index ce2a2553b19ee..72fce53c4f1fe 100644 --- a/src/plugins/console/public/lib/autocomplete/__tests__/url_params.test.js +++ b/src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js @@ -16,10 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import '../../../application/models/sense_editor/sense_editor.test.mocks'; -import 'brace'; -import 'brace/mode/javascript'; -import 'brace/mode/json'; const _ = require('lodash'); import { UrlParams } from '../../autocomplete/url_params'; import { populateContext } from '../../autocomplete/engine'; diff --git a/src/plugins/console/public/lib/autocomplete/autocomplete.ts b/src/plugins/console/public/lib/autocomplete/autocomplete.ts index e09024ccfc859..d4f10ff4e4277 100644 --- a/src/plugins/console/public/lib/autocomplete/autocomplete.ts +++ b/src/plugins/console/public/lib/autocomplete/autocomplete.ts @@ -18,9 +18,9 @@ */ import _ from 'lodash'; -import ace, { Editor as AceEditor, IEditSession } from 'brace'; import { i18n } from '@kbn/i18n'; +// TODO: All of these imports need to be moved to the core editor so that it can inject components from there. import { getTopLevelUrlCompleteComponents, getEndpointBodyCompleteComponents, @@ -39,7 +39,7 @@ import { createTokenIterator } from '../../application/factories'; import { Position, Token, Range, CoreEditor } from '../../types'; -let LAST_EVALUATED_TOKEN: any = null; +let lastEvaluatedToken: any = null; function isUrlParamsToken(token: any) { switch ((token || {}).type) { @@ -889,7 +889,7 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor if (!currentToken) { if (pos.lineNumber === 1) { - LAST_EVALUATED_TOKEN = null; + lastEvaluatedToken = null; return; } currentToken = { position: { column: 0, lineNumber: 0 }, value: '', type: '' }; // empty row @@ -902,26 +902,26 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor if (parser.isEmptyToken(nextToken)) { // Empty line, or we're not on the edge of current token. Save the current position as base currentToken.position.column = pos.column; - LAST_EVALUATED_TOKEN = currentToken; + lastEvaluatedToken = currentToken; } else { nextToken.position.lineNumber = pos.lineNumber; - LAST_EVALUATED_TOKEN = nextToken; + lastEvaluatedToken = nextToken; } return; } - if (!LAST_EVALUATED_TOKEN) { - LAST_EVALUATED_TOKEN = currentToken; + if (!lastEvaluatedToken) { + lastEvaluatedToken = currentToken; return; // wait for the next typing. } if ( - LAST_EVALUATED_TOKEN.position.column !== currentToken.position.column || - LAST_EVALUATED_TOKEN.position.lineNumber !== currentToken.position.lineNumber || - LAST_EVALUATED_TOKEN.value === currentToken.value + lastEvaluatedToken.position.column !== currentToken.position.column || + lastEvaluatedToken.position.lineNumber !== currentToken.position.lineNumber || + lastEvaluatedToken.value === currentToken.value ) { // not on the same place or nothing changed, cache and wait for the next time - LAST_EVALUATED_TOKEN = currentToken; + lastEvaluatedToken = currentToken; return; } @@ -935,7 +935,7 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor return; } - LAST_EVALUATED_TOKEN = currentToken; + lastEvaluatedToken = currentToken; editor.execCommand('startAutocomplete'); }, 100); @@ -947,17 +947,7 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor } } - function getCompletions( - DO_NOT_USE: AceEditor, - DO_NOT_USE_SESSION: IEditSession, - pos: { row: number; column: number }, - prefix: string, - callback: (...args: any[]) => void - ) { - const position: Position = { - lineNumber: pos.row + 1, - column: pos.column + 1, - }; + function getCompletions(position: Position, prefix: string, callback: (...args: any[]) => void) { try { const context = getAutoCompleteContext(editor, position); if (!context) { @@ -1028,39 +1018,12 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor editor.on('changeSelection', editorChangeListener); - // Hook into Ace - - // disable standard context based autocompletion. - // @ts-ignore - ace.define('ace/autocomplete/text_completer', ['require', 'exports', 'module'], function( - require: any, - exports: any - ) { - exports.getCompletions = function( - innerEditor: any, - session: any, - pos: any, - prefix: any, - callback: any - ) { - callback(null, []); - }; - }); - - const langTools = ace.acequire('ace/ext/language_tools'); - - langTools.setCompleters([ - { - identifierRegexps: [ - /[a-zA-Z_0-9\.\$\-\u00A2-\uFFFF]/, // adds support for dot character - ], - getCompletions, - }, - ]); - return { + getCompletions, + // TODO: This needs to be cleaned up _test: { - getCompletions, + getCompletions: (_editor: any, _editSession: any, pos: any, prefix: any, callback: any) => + getCompletions(pos, prefix, callback), addReplacementInfoToContext, addChangeListener: () => editor.on('changeSelection', editorChangeListener), removeChangeListener: () => editor.off('changeSelection', editorChangeListener), diff --git a/src/plugins/console/public/lib/autocomplete/body_completer.js b/src/plugins/console/public/lib/autocomplete/body_completer.js index e23a58780a362..1aa315c50b9bf 100644 --- a/src/plugins/console/public/lib/autocomplete/body_completer.js +++ b/src/plugins/console/public/lib/autocomplete/body_completer.js @@ -115,7 +115,6 @@ class ScopeResolver extends SharedComponent { next: [], }; const components = this.resolveLinkToComponents(context, editor); - _.each(components, function(component) { const componentResult = component.match(token, context, editor); if (componentResult && componentResult.next) { diff --git a/src/plugins/console/public/lib/autocomplete/engine.js b/src/plugins/console/public/lib/autocomplete/engine.js index f4df8af871eba..7b64d91c95374 100644 --- a/src/plugins/console/public/lib/autocomplete/engine.js +++ b/src/plugins/console/public/lib/autocomplete/engine.js @@ -43,7 +43,7 @@ export function wrapComponentWithDefaults(component, defaults) { const tracer = function() { if (window.engine_trace) { - console.log.call(console, arguments); + console.log.call(console, ...arguments); } }; diff --git a/src/plugins/console/public/lib/kb/kb.js b/src/plugins/console/public/lib/kb/kb.js index 053b82bd81d0a..ef921fa7f476e 100644 --- a/src/plugins/console/public/lib/kb/kb.js +++ b/src/plugins/console/public/lib/kb/kb.js @@ -146,6 +146,10 @@ function loadApisFromJson( return api; } +// TODO: clean up setting up of active API and use of jQuery. +// This function should be attached to a class that holds the current state, not setup +// when the file is required. Also, jQuery should not be used to make network requests +// like this, it looks like a minor security issue. export function setActiveApi(api) { if (!api) { $.ajax({ diff --git a/src/plugins/console/public/types/core_editor.ts b/src/plugins/console/public/types/core_editor.ts index 79dc3ca74200b..b71f4fff44ca5 100644 --- a/src/plugins/console/public/types/core_editor.ts +++ b/src/plugins/console/public/types/core_editor.ts @@ -29,6 +29,12 @@ export type EditorEvent = | 'change' | 'changeSelection'; +export type AutoCompleterFunction = ( + pos: Position, + prefix: string, + callback: (...args: any[]) => void +) => void; + export interface Position { /** * The line number, not zero-indexed. @@ -256,4 +262,10 @@ export interface CoreEditor { * Register a keyboard shortcut and provide a function to be called. */ registerKeyboardShortcut(opts: { keys: any; fn: () => void; name: string }): void; + + /** + * Register a completions function that will be called when the editor + * detects a change + */ + registerAutocompleter(autocompleter: AutoCompleterFunction): void; } diff --git a/src/plugins/console/server/index.ts b/src/plugins/console/server/index.ts index b603deee12e23..62e5bd6bf8d95 100644 --- a/src/plugins/console/server/index.ts +++ b/src/plugins/console/server/index.ts @@ -21,7 +21,7 @@ import { PluginConfigDescriptor, PluginInitializerContext } from 'kibana/server' import { ConfigType, config as configSchema } from './config'; import { ConsoleServerPlugin } from './plugin'; -export { ConsoleSetup } from './types'; +export { ConsoleSetup, ConsoleStart } from './types'; export const plugin = (ctx: PluginInitializerContext) => new ConsoleServerPlugin(ctx); diff --git a/src/plugins/console/server/lib/index.ts b/src/plugins/console/server/lib/index.ts index 2347084b73a66..0c8fc125874cf 100644 --- a/src/plugins/console/server/lib/index.ts +++ b/src/plugins/console/server/lib/index.ts @@ -22,4 +22,4 @@ export { ProxyConfigCollection } from './proxy_config_collection'; export { proxyRequest } from './proxy_request'; export { getElasticsearchProxyConfig } from './elasticsearch_proxy_config'; export { setHeaders } from './set_headers'; -export { addProcessorDefinition, addExtensionSpecFilePath, loadSpec } from './spec_definitions'; +export { jsSpecLoaders } from './spec_definitions'; diff --git a/src/plugins/console/server/lib/spec_definitions/api.js b/src/plugins/console/server/lib/spec_definitions/api.js deleted file mode 100644 index 9c3835013bce9..0000000000000 --- a/src/plugins/console/server/lib/spec_definitions/api.js +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; - -class Api { - constructor(name) { - this.globalRules = {}; - this.endpoints = {}; - this.name = name; - } - - addGlobalAutocompleteRules = (parentNode, rules) => { - this.globalRules[parentNode] = rules; - }; - - addEndpointDescription = (endpoint, description = {}) => { - let copiedDescription = {}; - if (this.endpoints[endpoint]) { - copiedDescription = { ...this.endpoints[endpoint] }; - } - let urlParamsDef; - _.each(description.patterns || [], function(p) { - if (p.indexOf('{indices}') >= 0) { - urlParamsDef = urlParamsDef || {}; - urlParamsDef.ignore_unavailable = '__flag__'; - urlParamsDef.allow_no_indices = '__flag__'; - urlParamsDef.expand_wildcards = ['open', 'closed']; - } - }); - - if (urlParamsDef) { - description.url_params = _.extend(description.url_params || {}, copiedDescription.url_params); - _.defaults(description.url_params, urlParamsDef); - } - - _.extend(copiedDescription, description); - _.defaults(copiedDescription, { - id: endpoint, - patterns: [endpoint], - methods: ['GET'], - }); - - this.endpoints[endpoint] = copiedDescription; - }; - - asJson() { - return { - name: this.name, - globals: this.globalRules, - endpoints: this.endpoints, - }; - } -} - -export default Api; diff --git a/src/plugins/console/server/lib/spec_definitions/es.js b/src/plugins/console/server/lib/spec_definitions/es.js deleted file mode 100644 index fc24a64f8a6f4..0000000000000 --- a/src/plugins/console/server/lib/spec_definitions/es.js +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Api from './api'; -import { getSpec } from './json'; -import { register } from './js/ingest'; -const ES = new Api('es'); - -export const loadSpec = () => { - const spec = getSpec(); - - // adding generated specs - Object.keys(spec).forEach(endpoint => { - ES.addEndpointDescription(endpoint, spec[endpoint]); - }); - - // adding globals and custom API definitions - require('./js/aliases')(ES); - require('./js/aggregations')(ES); - require('./js/document')(ES); - require('./js/filter')(ES); - require('./js/globals')(ES); - register(ES); - require('./js/mappings')(ES); - require('./js/settings')(ES); - require('./js/query')(ES); - require('./js/reindex')(ES); - require('./js/search')(ES); -}; - -export default ES; diff --git a/src/plugins/console/server/lib/spec_definitions/js/query/index.js b/src/plugins/console/server/lib/spec_definitions/index.ts similarity index 94% rename from src/plugins/console/server/lib/spec_definitions/js/query/index.js rename to src/plugins/console/server/lib/spec_definitions/index.ts index cbe4e7ed2dd5f..7c70c406d8c22 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/query/index.js +++ b/src/plugins/console/server/lib/spec_definitions/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { queryDsl as default } from './dsl'; +export { jsSpecLoaders } from './js'; diff --git a/src/plugins/console/server/lib/spec_definitions/js/aggregations.js b/src/plugins/console/server/lib/spec_definitions/js/aggregations.ts similarity index 95% rename from src/plugins/console/server/lib/spec_definitions/js/aggregations.js rename to src/plugins/console/server/lib/spec_definitions/js/aggregations.ts index 629e143aa2b43..1170c9edd2366 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/aggregations.js +++ b/src/plugins/console/server/lib/spec_definitions/js/aggregations.ts @@ -16,8 +16,9 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -/*eslint camelcase: 0*/ +/* eslint-disable @typescript-eslint/camelcase */ const significantTermsArgs = { __template: { field: '', @@ -77,7 +78,7 @@ const simple_pipeline = { }, buckets_path: '', format: '', - gap_policy: gap_policy, + gap_policy, }; const rules = { '*': { @@ -461,7 +462,7 @@ const rules = { }, buckets_path: '', format: '', - gap_policy: gap_policy, + gap_policy, window: 5, model: { __one_of: ['simple', 'linear', 'ewma', 'holt', 'holt_winters'] }, settings: { @@ -485,7 +486,7 @@ const rules = { lag: 7, }, lag: 7, - gap_policy: gap_policy, + gap_policy, buckets_path: '', format: '', }, @@ -496,7 +497,7 @@ const rules = { }, buckets_path: {}, format: '', - gap_policy: gap_policy, + gap_policy, script: '', }, bucket_selector: { @@ -505,7 +506,7 @@ const rules = { script: '', }, buckets_path: {}, - gap_policy: gap_policy, + gap_policy, script: '', }, bucket_sort: { @@ -515,7 +516,7 @@ const rules = { sort: ['{field}'], from: 0, size: 0, - gap_policy: gap_policy, + gap_policy, }, matrix_stats: { __template: { @@ -526,8 +527,11 @@ const rules = { }, }; const { terms, histogram, date_histogram } = rules['*']; -export default function(api) { - api.addGlobalAutocompleteRules('aggregations', rules); - api.addGlobalAutocompleteRules('aggs', rules); - api.addGlobalAutocompleteRules('groupByAggs', { '*': { terms, histogram, date_histogram } }); -} + +export const aggs = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('aggregations', rules); + specService.addGlobalAutocompleteRules('aggs', rules); + specService.addGlobalAutocompleteRules('groupByAggs', { + '*': { terms, histogram, date_histogram }, + }); +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/aliases.js b/src/plugins/console/server/lib/spec_definitions/js/aliases.ts similarity index 80% rename from src/plugins/console/server/lib/spec_definitions/js/aliases.js rename to src/plugins/console/server/lib/spec_definitions/js/aliases.ts index f46713fb8dd3f..c7d51b70ab3e3 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/aliases.js +++ b/src/plugins/console/server/lib/spec_definitions/js/aliases.ts @@ -16,15 +16,17 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -export default function(api) { +/* eslint-disable @typescript-eslint/camelcase */ +export const aliases = (specService: SpecDefinitionsService) => { const aliasRules = { filter: {}, routing: '1', search_routing: '1,2', index_routing: '1', }; - api.addGlobalAutocompleteRules('aliases', { + specService.addGlobalAutocompleteRules('aliases', { '*': aliasRules, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/document.js b/src/plugins/console/server/lib/spec_definitions/js/document.ts similarity index 85% rename from src/plugins/console/server/lib/spec_definitions/js/document.js rename to src/plugins/console/server/lib/spec_definitions/js/document.ts index 2bdaa2ec2af9b..f8214faab2681 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/document.js +++ b/src/plugins/console/server/lib/spec_definitions/js/document.ts @@ -16,9 +16,11 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -export default function(api) { - api.addEndpointDescription('update', { +/* eslint-disable @typescript-eslint/camelcase */ +export const document = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('update', { data_autocomplete_rules: { script: { // populated by a global rule @@ -29,7 +31,7 @@ export default function(api) { }, }); - api.addEndpointDescription('put_script', { + specService.addEndpointDescription('put_script', { methods: ['POST', 'PUT'], patterns: ['_scripts/{lang}/{id}', '_scripts/{lang}/{id}/_create'], url_components: { @@ -40,7 +42,7 @@ export default function(api) { }, }); - api.addEndpointDescription('termvectors', { + specService.addEndpointDescription('termvectors', { data_autocomplete_rules: { fields: ['{field}'], offsets: { __one_of: [false, true] }, @@ -68,4 +70,4 @@ export default function(api) { }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/filter.js b/src/plugins/console/server/lib/spec_definitions/js/filter.ts similarity index 94% rename from src/plugins/console/server/lib/spec_definitions/js/filter.js rename to src/plugins/console/server/lib/spec_definitions/js/filter.ts index bf669cff788e8..27e02f7cf1837 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/filter.js +++ b/src/plugins/console/server/lib/spec_definitions/js/filter.ts @@ -16,8 +16,10 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -const filters = {}; +/* eslint-disable @typescript-eslint/camelcase */ +const filters: Record = {}; filters.and = { __template: { @@ -324,6 +326,6 @@ filters.nested = { _name: '', }; -export default function(api) { - api.addGlobalAutocompleteRules('filter', filters); -} +export const filter = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('filter', filters); +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/globals.js b/src/plugins/console/server/lib/spec_definitions/js/globals.ts similarity index 85% rename from src/plugins/console/server/lib/spec_definitions/js/globals.js rename to src/plugins/console/server/lib/spec_definitions/js/globals.ts index 316a76c8c9434..32e1957f74d0b 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/globals.js +++ b/src/plugins/console/server/lib/spec_definitions/js/globals.ts @@ -16,7 +16,9 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; +/* eslint-disable @typescript-eslint/camelcase */ const highlightOptions = { boundary_chars: {}, boundary_max_scan: 20, @@ -48,8 +50,9 @@ const highlightOptions = { }, tags_schema: {}, }; -export default function(api) { - api.addGlobalAutocompleteRules('highlight', { + +export const globals = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('highlight', { ...highlightOptions, fields: { '{field}': { @@ -60,7 +63,7 @@ export default function(api) { }, }); - api.addGlobalAutocompleteRules('script', { + specService.addGlobalAutocompleteRules('script', { __template: { source: 'SCRIPT', }, @@ -70,4 +73,4 @@ export default function(api) { lang: '', params: {}, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/index.d.ts b/src/plugins/console/server/lib/spec_definitions/js/index.ts similarity index 54% rename from src/plugins/console/server/lib/spec_definitions/index.d.ts rename to src/plugins/console/server/lib/spec_definitions/js/index.ts index da0125a186c15..234ccd22aaa8b 100644 --- a/src/plugins/console/server/lib/spec_definitions/index.d.ts +++ b/src/plugins/console/server/lib/spec_definitions/js/index.ts @@ -17,15 +17,30 @@ * under the License. */ -export declare function addProcessorDefinition(...args: any[]): any; +import { SpecDefinitionsService } from '../../../services'; -export declare function resolveApi(): object; +import { aggs } from './aggregations'; +import { aliases } from './aliases'; +import { document } from './document'; +import { filter } from './filter'; +import { globals } from './globals'; +import { ingest } from './ingest'; +import { mappings } from './mappings'; +import { settings } from './settings'; +import { query } from './query'; +import { reindex } from './reindex'; +import { search } from './search'; -export declare function addExtensionSpecFilePath(...args: any[]): any; - -/** - * A function that synchronously reads files JSON from disk and builds - * the autocomplete structures served to the client. This must be called - * after any extensions have been loaded. - */ -export declare function loadSpec(): any; +export const jsSpecLoaders: Array<(registry: SpecDefinitionsService) => void> = [ + aggs, + aliases, + document, + filter, + globals, + ingest, + mappings, + settings, + query, + reindex, + search, +]; diff --git a/src/plugins/console/server/lib/spec_definitions/js/ingest.js b/src/plugins/console/server/lib/spec_definitions/js/ingest.ts similarity index 96% rename from src/plugins/console/server/lib/spec_definitions/js/ingest.js rename to src/plugins/console/server/lib/spec_definitions/js/ingest.ts index edc9cc7b3e45c..1182dc075f42f 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/ingest.js +++ b/src/plugins/console/server/lib/spec_definitions/js/ingest.ts @@ -17,6 +17,9 @@ * under the License. */ +import { SpecDefinitionsService } from '../../../services'; + +/* eslint-disable @typescript-eslint/camelcase */ const commonPipelineParams = { on_failure: [], ignore_failure: { @@ -427,27 +430,23 @@ const pipelineDefinition = { version: 123, }; -export const register = api => { +export const ingest = (specService: SpecDefinitionsService) => { // Note: this isn't an actual API endpoint. It exists so the forEach processor's "processor" field // may recursively use the autocomplete rules for any processor. - api.addEndpointDescription('_processor', { + specService.addEndpointDescription('_processor', { data_autocomplete_rules: processorDefinition, }); - api.addEndpointDescription('ingest.put_pipeline', { + specService.addEndpointDescription('ingest.put_pipeline', { methods: ['PUT'], patterns: ['_ingest/pipeline/{id}'], data_autocomplete_rules: pipelineDefinition, }); - api.addEndpointDescription('ingest.simulate', { + specService.addEndpointDescription('ingest.simulate', { data_autocomplete_rules: { pipeline: pipelineDefinition, docs: [], }, }); }; - -export const addProcessorDefinition = processor => { - processorDefinition.__one_of.push(processor); -}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/mappings.js b/src/plugins/console/server/lib/spec_definitions/js/mappings.ts similarity index 96% rename from src/plugins/console/server/lib/spec_definitions/js/mappings.js rename to src/plugins/console/server/lib/spec_definitions/js/mappings.ts index 5884d14d4dc8b..8491bc17a2ff6 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/mappings.js +++ b/src/plugins/console/server/lib/spec_definitions/js/mappings.ts @@ -17,12 +17,15 @@ * under the License. */ -const _ = require('lodash'); +import _ from 'lodash'; + +import { SpecDefinitionsService } from '../../../services'; import { BOOLEAN } from './shared'; -export default function(api) { - api.addEndpointDescription('put_mapping', { +/* eslint-disable @typescript-eslint/camelcase */ +export const mappings = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('put_mapping', { priority: 10, // collides with put doc by id data_autocomplete_rules: { __template: { @@ -249,4 +252,4 @@ export default function(api) { }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/query/dsl.js b/src/plugins/console/server/lib/spec_definitions/js/query/dsl.ts similarity index 97% rename from src/plugins/console/server/lib/spec_definitions/js/query/dsl.js rename to src/plugins/console/server/lib/spec_definitions/js/query/dsl.ts index 16b952fe0fe4f..d6e5030fb6928 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/query/dsl.js +++ b/src/plugins/console/server/lib/spec_definitions/js/query/dsl.ts @@ -18,6 +18,9 @@ */ import _ from 'lodash'; + +import { SpecDefinitionsService } from '../../../../services'; + import { spanFirstTemplate, spanNearTemplate, @@ -32,6 +35,8 @@ import { rangeTemplate, regexpTemplate, } from './templates'; + +/* eslint-disable @typescript-eslint/camelcase */ const matchOptions = { cutoff_frequency: 0.001, query: '', @@ -57,6 +62,7 @@ const matchOptions = { prefix_length: 1, minimum_should_match: 1, }; + const innerHits = { docvalue_fields: ['FIELD'], from: {}, @@ -84,6 +90,7 @@ const innerHits = { __one_of: ['true', 'false'], }, }; + const SPAN_QUERIES_NO_FIELD_MASK = { // TODO add one_of for objects span_first: { @@ -115,6 +122,7 @@ const SPAN_QUERIES_NO_FIELD_MASK = { __scope_link: '.span_within', }, }; + const SPAN_QUERIES = { ...SPAN_QUERIES_NO_FIELD_MASK, field_masking_span: { @@ -165,13 +173,14 @@ const DECAY_FUNC_DESC = { decay: 0.5, }, }; + const SCORING_FUNCS = { script_score: { __template: { script: "_score * doc['f'].value", }, script: { - //populated by a global rule + // populated by a global rule }, }, boost_factor: 2.0, @@ -204,8 +213,8 @@ const SCORING_FUNCS = { }, }; -export function queryDsl(api) { - api.addGlobalAutocompleteRules('query', { +export const query = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('query', { match: { __template: { FIELD: 'TEXT', @@ -631,7 +640,7 @@ export function queryDsl(api) { filter: {}, boost: 2.0, script: { - //populated by a global rule + // populated by a global rule }, }, ], @@ -695,7 +704,7 @@ export function queryDsl(api) { script: "_score * doc['f'].value", }, script: { - //populated by a global rule + // populated by a global rule }, }, wrapper: { @@ -705,4 +714,4 @@ export function queryDsl(api) { query: '', }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/server.js b/src/plugins/console/server/lib/spec_definitions/js/query/index.ts similarity index 89% rename from src/plugins/console/server/lib/spec_definitions/server.js rename to src/plugins/console/server/lib/spec_definitions/js/query/index.ts index cb855958d403a..f4f896fd7814c 100644 --- a/src/plugins/console/server/lib/spec_definitions/server.js +++ b/src/plugins/console/server/lib/spec_definitions/js/query/index.ts @@ -17,10 +17,4 @@ * under the License. */ -import es from './es'; - -export function resolveApi() { - return { - es: es.asJson(), - }; -} +export { query } from './dsl'; diff --git a/src/plugins/console/server/lib/spec_definitions/js/query/templates.js b/src/plugins/console/server/lib/spec_definitions/js/query/templates.ts similarity index 97% rename from src/plugins/console/server/lib/spec_definitions/js/query/templates.js rename to src/plugins/console/server/lib/spec_definitions/js/query/templates.ts index 9b6311bf5712e..60192f81fec80 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/query/templates.js +++ b/src/plugins/console/server/lib/spec_definitions/js/query/templates.ts @@ -17,23 +17,28 @@ * under the License. */ +/* eslint-disable @typescript-eslint/camelcase */ export const regexpTemplate = { FIELD: 'REGEXP', }; + export const fuzzyTemplate = { FIELD: {}, }; + export const prefixTemplate = { FIELD: { value: '', }, }; + export const rangeTemplate = { FIELD: { gte: 10, lte: 20, }, }; + export const spanFirstTemplate = { match: { span_term: { @@ -42,6 +47,7 @@ export const spanFirstTemplate = { }, end: 3, }; + export const spanNearTemplate = { clauses: [ { @@ -55,11 +61,13 @@ export const spanNearTemplate = { slop: 12, in_order: false, }; + export const spanTermTemplate = { FIELD: { value: 'VALUE', }, }; + export const spanNotTemplate = { include: { span_term: { @@ -76,6 +84,7 @@ export const spanNotTemplate = { }, }, }; + export const spanOrTemplate = { clauses: [ { @@ -87,6 +96,7 @@ export const spanOrTemplate = { }, ], }; + export const spanContainingTemplate = { little: { span_term: { @@ -118,6 +128,7 @@ export const spanContainingTemplate = { }, }, }; + export const spanWithinTemplate = { little: { span_term: { @@ -149,6 +160,7 @@ export const spanWithinTemplate = { }, }, }; + export const wildcardTemplate = { FIELD: { value: 'VALUE', diff --git a/src/plugins/console/server/lib/spec_definitions/js/reindex.js b/src/plugins/console/server/lib/spec_definitions/js/reindex.ts similarity index 88% rename from src/plugins/console/server/lib/spec_definitions/js/reindex.js rename to src/plugins/console/server/lib/spec_definitions/js/reindex.ts index 45163d2b3c4c3..862a4323f7bf3 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/reindex.js +++ b/src/plugins/console/server/lib/spec_definitions/js/reindex.ts @@ -17,8 +17,11 @@ * under the License. */ -export default function(api) { - api.addEndpointDescription('reindex', { +import { SpecDefinitionsService } from '../../../services'; + +/* eslint-disable @typescript-eslint/camelcase */ +export const reindex = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('reindex', { methods: ['POST'], patterns: ['_reindex'], data_autocomplete_rules: { @@ -62,4 +65,4 @@ export default function(api) { script: { __scope_link: 'GLOBAL.script' }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/search.js b/src/plugins/console/server/lib/spec_definitions/js/search.ts similarity index 92% rename from src/plugins/console/server/lib/spec_definitions/js/search.js rename to src/plugins/console/server/lib/spec_definitions/js/search.ts index 19ce30d9929a5..e319870d7be5c 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/search.js +++ b/src/plugins/console/server/lib/spec_definitions/js/search.ts @@ -16,9 +16,11 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -export default function(api) { - api.addEndpointDescription('search', { +/* eslint-disable @typescript-eslint/camelcase */ +export const search = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('search', { priority: 10, // collides with get doc by id data_autocomplete_rules: { query: { @@ -191,7 +193,7 @@ export default function(api) { }, }); - api.addEndpointDescription('search_template', { + specService.addEndpointDescription('search_template', { data_autocomplete_rules: { template: { __one_of: [{ __scope_link: 'search' }, { __scope_link: 'GLOBAL.script' }], @@ -200,18 +202,18 @@ export default function(api) { }, }); - api.addEndpointDescription('render_search_template', { + specService.addEndpointDescription('render_search_template', { data_autocomplete_rules: { __one_of: [{ source: { __scope_link: 'search' } }, { __scope_link: 'GLOBAL.script' }], params: {}, }, }); - api.addEndpointDescription('_search/template/{id}', { + specService.addEndpointDescription('_search/template/{id}', { data_autocomplete_rules: { template: { __scope_link: 'search', }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/settings.js b/src/plugins/console/server/lib/spec_definitions/js/settings.ts similarity index 90% rename from src/plugins/console/server/lib/spec_definitions/js/settings.js rename to src/plugins/console/server/lib/spec_definitions/js/settings.ts index 26cd0987c34a5..88c58e618533b 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/settings.js +++ b/src/plugins/console/server/lib/spec_definitions/js/settings.ts @@ -16,11 +16,12 @@ * specific language governing permissions and limitations * under the License. */ - +import { SpecDefinitionsService } from '../../../services'; import { BOOLEAN } from './shared'; -export default function(api) { - api.addEndpointDescription('put_settings', { +/* eslint-disable @typescript-eslint/camelcase */ +export const settings = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('put_settings', { data_autocomplete_rules: { refresh_interval: '1s', number_of_shards: 1, @@ -71,4 +72,4 @@ export default function(api) { }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/shared.js b/src/plugins/console/server/lib/spec_definitions/js/shared.ts similarity index 94% rename from src/plugins/console/server/lib/spec_definitions/js/shared.js rename to src/plugins/console/server/lib/spec_definitions/js/shared.ts index ace189e2d0913..a884e1aebe2e7 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/shared.js +++ b/src/plugins/console/server/lib/spec_definitions/js/shared.ts @@ -17,6 +17,7 @@ * under the License. */ +/* eslint-disable @typescript-eslint/camelcase */ export const BOOLEAN = Object.freeze({ __one_of: [true, false], }); diff --git a/src/plugins/console/server/lib/spec_definitions/json/index.js b/src/plugins/console/server/lib/spec_definitions/json/index.js deleted file mode 100644 index 19f075e897dbb..0000000000000 --- a/src/plugins/console/server/lib/spec_definitions/json/index.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import glob from 'glob'; -import { join, basename } from 'path'; -import { readFileSync } from 'fs'; -import { merge } from 'lodash'; - -const extensionSpecFilePaths = []; -function _getSpec(dirname = __dirname) { - const generatedFiles = glob.sync(join(dirname, 'generated', '*.json')); - const overrideFiles = glob.sync(join(dirname, 'overrides', '*.json')); - - return generatedFiles.reduce((acc, file) => { - const overrideFile = overrideFiles.find(f => basename(f) === basename(file)); - const loadedSpec = JSON.parse(readFileSync(file, 'utf8')); - if (overrideFile) { - merge(loadedSpec, JSON.parse(readFileSync(overrideFile, 'utf8'))); - } - const spec = {}; - Object.entries(loadedSpec).forEach(([key, value]) => { - if (acc[key]) { - // add time to remove key collision - spec[`${key}${Date.now()}`] = value; - } else { - spec[key] = value; - } - }); - - return { ...acc, ...spec }; - }, {}); -} -export function getSpec() { - const result = _getSpec(); - extensionSpecFilePaths.forEach(extensionSpecFilePath => { - merge(result, _getSpec(extensionSpecFilePath)); - }); - return result; -} - -export function addExtensionSpecFilePath(extensionSpecFilePath) { - extensionSpecFilePaths.push(extensionSpecFilePath); -} diff --git a/src/plugins/console/server/plugin.ts b/src/plugins/console/server/plugin.ts index 1954918f4d74f..85b728ea83891 100644 --- a/src/plugins/console/server/plugin.ts +++ b/src/plugins/console/server/plugin.ts @@ -21,20 +21,18 @@ import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/serv import { readLegacyEsConfig } from '../../../legacy/core_plugins/console_legacy'; -import { - ProxyConfigCollection, - addExtensionSpecFilePath, - addProcessorDefinition, - loadSpec, -} from './lib'; +import { ProxyConfigCollection } from './lib'; +import { SpecDefinitionsService } from './services'; import { ConfigType } from './config'; import { registerProxyRoute } from './routes/api/console/proxy'; import { registerSpecDefinitionsRoute } from './routes/api/console/spec_definitions'; -import { ESConfigForProxy, ConsoleSetup } from './types'; +import { ESConfigForProxy, ConsoleSetup, ConsoleStart } from './types'; -export class ConsoleServerPlugin implements Plugin { +export class ConsoleServerPlugin implements Plugin { log: Logger; + specDefinitionsService = new SpecDefinitionsService(); + constructor(private readonly ctx: PluginInitializerContext) { this.log = this.ctx.logger.get(); } @@ -72,15 +70,19 @@ export class ConsoleServerPlugin implements Plugin { router, }); - registerSpecDefinitionsRoute({ router }); + registerSpecDefinitionsRoute({ + router, + services: { specDefinitions: this.specDefinitionsService }, + }); return { - addExtensionSpecFilePath, - addProcessorDefinition, + ...this.specDefinitionsService.setup(), }; } start() { - loadSpec(); + return { + ...this.specDefinitionsService.start(), + }; } } diff --git a/src/plugins/console/server/routes/api/console/spec_definitions/index.ts b/src/plugins/console/server/routes/api/console/spec_definitions/index.ts index 88bc250bbfce6..5c7e679cd0d35 100644 --- a/src/plugins/console/server/routes/api/console/spec_definitions/index.ts +++ b/src/plugins/console/server/routes/api/console/spec_definitions/index.ts @@ -17,12 +17,30 @@ * under the License. */ import { IRouter, RequestHandler } from 'kibana/server'; -import { resolveApi } from '../../../../lib/spec_definitions'; +import { SpecDefinitionsService } from '../../../../services'; -export const registerSpecDefinitionsRoute = ({ router }: { router: IRouter }) => { +interface SpecDefinitionsRouteResponse { + es: { + name: string; + globals: Record; + endpoints: Record; + }; +} + +export const registerSpecDefinitionsRoute = ({ + router, + services, +}: { + router: IRouter; + services: { specDefinitions: SpecDefinitionsService }; +}) => { const handler: RequestHandler = async (ctx, request, response) => { + const specResponse: SpecDefinitionsRouteResponse = { + es: services.specDefinitions.asJson(), + }; + return response.ok({ - body: resolveApi(), + body: specResponse, headers: { 'Content-Type': 'application/json', }, diff --git a/src/plugins/console/server/lib/spec_definitions/index.js b/src/plugins/console/server/services/index.ts similarity index 81% rename from src/plugins/console/server/lib/spec_definitions/index.js rename to src/plugins/console/server/services/index.ts index abf55639fbee8..c8dfeccd23070 100644 --- a/src/plugins/console/server/lib/spec_definitions/index.js +++ b/src/plugins/console/server/services/index.ts @@ -17,10 +17,4 @@ * under the License. */ -export { addProcessorDefinition } from './js/ingest'; - -export { addExtensionSpecFilePath } from './json'; - -export { loadSpec } from './es'; - -export { resolveApi } from './server'; +export { SpecDefinitionsService } from './spec_definitions_service'; diff --git a/src/plugins/console/server/services/spec_definitions_service.ts b/src/plugins/console/server/services/spec_definitions_service.ts new file mode 100644 index 0000000000000..39a8d5094bd5c --- /dev/null +++ b/src/plugins/console/server/services/spec_definitions_service.ts @@ -0,0 +1,150 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _, { merge } from 'lodash'; +import glob from 'glob'; +import { basename, join, resolve } from 'path'; +import { readFileSync } from 'fs'; + +import { jsSpecLoaders } from '../lib'; + +const PATH_TO_OSS_JSON_SPEC = resolve(__dirname, '../lib/spec_definitions/json'); + +export class SpecDefinitionsService { + private readonly name = 'es'; + + private readonly globalRules: Record = {}; + private readonly endpoints: Record = {}; + private readonly extensionSpecFilePaths: string[] = []; + + private hasLoadedSpec = false; + + public addGlobalAutocompleteRules(parentNode: string, rules: any) { + this.globalRules[parentNode] = rules; + } + + public addEndpointDescription(endpoint: string, description: any = {}) { + let copiedDescription: any = {}; + if (this.endpoints[endpoint]) { + copiedDescription = { ...this.endpoints[endpoint] }; + } + let urlParamsDef: any; + _.each(description.patterns || [], function(p) { + if (p.indexOf('{indices}') >= 0) { + urlParamsDef = urlParamsDef || {}; + urlParamsDef.ignore_unavailable = '__flag__'; + urlParamsDef.allow_no_indices = '__flag__'; + urlParamsDef.expand_wildcards = ['open', 'closed']; + } + }); + + if (urlParamsDef) { + description.url_params = _.extend(description.url_params || {}, copiedDescription.url_params); + _.defaults(description.url_params, urlParamsDef); + } + + _.extend(copiedDescription, description); + _.defaults(copiedDescription, { + id: endpoint, + patterns: [endpoint], + methods: ['GET'], + }); + + this.endpoints[endpoint] = copiedDescription; + } + + public asJson() { + return { + name: this.name, + globals: this.globalRules, + endpoints: this.endpoints, + }; + } + + public addExtensionSpecFilePath(path: string) { + this.extensionSpecFilePaths.push(path); + } + + public addProcessorDefinition(processor: any) { + if (!this.hasLoadedSpec) { + throw new Error( + 'Cannot add a processor definition because spec definitions have not loaded!' + ); + } + this.endpoints._processor!.data_autocomplete_rules.__one_of.push(processor); + } + + public setup() { + return { + addExtensionSpecFilePath: this.addExtensionSpecFilePath.bind(this), + }; + } + + public start() { + if (!this.hasLoadedSpec) { + this.loadJsonSpec(); + this.loadJSSpec(); + this.hasLoadedSpec = true; + return { + addProcessorDefinition: this.addProcessorDefinition.bind(this), + }; + } else { + throw new Error('Service has already started!'); + } + } + + private loadJSONSpecInDir(dirname: string) { + const generatedFiles = glob.sync(join(dirname, 'generated', '*.json')); + const overrideFiles = glob.sync(join(dirname, 'overrides', '*.json')); + + return generatedFiles.reduce((acc, file) => { + const overrideFile = overrideFiles.find(f => basename(f) === basename(file)); + const loadedSpec = JSON.parse(readFileSync(file, 'utf8')); + if (overrideFile) { + merge(loadedSpec, JSON.parse(readFileSync(overrideFile, 'utf8'))); + } + const spec: any = {}; + Object.entries(loadedSpec).forEach(([key, value]) => { + if (acc[key]) { + // add time to remove key collision + spec[`${key}${Date.now()}`] = value; + } else { + spec[key] = value; + } + }); + + return { ...acc, ...spec }; + }, {} as any); + } + + private loadJsonSpec() { + const result = this.loadJSONSpecInDir(PATH_TO_OSS_JSON_SPEC); + this.extensionSpecFilePaths.forEach(extensionSpecFilePath => { + merge(result, this.loadJSONSpecInDir(extensionSpecFilePath)); + }); + + Object.keys(result).forEach(endpoint => { + this.addEndpointDescription(endpoint, result[endpoint]); + }); + } + + private loadJSSpec() { + jsSpecLoaders.forEach(addJsSpec => addJsSpec(this)); + } +} diff --git a/src/plugins/console/server/types.ts b/src/plugins/console/server/types.ts index adafcd4d30526..4f026555ada7b 100644 --- a/src/plugins/console/server/types.ts +++ b/src/plugins/console/server/types.ts @@ -25,6 +25,11 @@ export type ConsoleSetup = ReturnType extends Prom ? U : ReturnType; +/** @public */ +export type ConsoleStart = ReturnType extends Promise + ? U + : ReturnType; + /** @internal */ export interface ESConfigForProxy { hosts: string[]; diff --git a/src/plugins/dashboard/public/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/embeddable/dashboard_container.tsx index 86a6e374d3e25..d29ce2e4f38f5 100644 --- a/src/plugins/dashboard/public/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/embeddable/dashboard_container.tsx @@ -57,6 +57,7 @@ export interface DashboardContainerInput extends ContainerInput { panels: { [panelId: string]: DashboardPanelState; }; + isEmptyState?: boolean; } interface IndexSignature { diff --git a/src/plugins/data/README.md b/src/plugins/data/README.md index 0fa304c988935..da0b71122fd9e 100644 --- a/src/plugins/data/README.md +++ b/src/plugins/data/README.md @@ -6,4 +6,4 @@ - `filter` - `index_patterns` - `query` -- `search` \ No newline at end of file +- `search`: Elasticsearch API service and strategies \ No newline at end of file diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 5ce79537ccaf3..4ab74e1883917 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -240,7 +240,6 @@ export abstract class Container< ...this.input.panels, [panelState.explicitInput.id]: panelState, }, - isEmptyState: false, } as Partial); return await this.untilEmbeddableLoaded(panelState.explicitInput.id); diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 7fef80edde85f..6345c34b0dda2 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -29,7 +29,6 @@ export interface EmbeddableInput { id: string; lastReloadRequestTime?: number; hidePanelTitles?: boolean; - isEmptyState?: boolean; /** * Reserved key for `ui_actions` events. diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 1564eb94a6903..d568e9b951d28 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -42,7 +42,7 @@ "xpack.transform": "plugins/transform", "xpack.triggersActionsUI": "plugins/triggers_actions_ui", "xpack.upgradeAssistant": "plugins/upgrade_assistant", - "xpack.uptime": "legacy/plugins/uptime", + "xpack.uptime": ["plugins/uptime", "legacy/plugins/uptime"], "xpack.watcher": "plugins/watcher" }, "translations": [ diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts index d9e841092be56..538aa9f74e2a6 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts @@ -7,9 +7,16 @@ // @ts-ignore import { MAP_SAVED_OBJECT_TYPE } from '../../../maps/common/constants'; import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/visualizations/public'; +import { LENS_EMBEDDABLE_TYPE } from '../../../../../plugins/lens/common/constants'; import { SEARCH_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/constants'; -export const EmbeddableTypes: { map: string; search: string; visualization: string } = { +export const EmbeddableTypes: { + lens: string; + map: string; + search: string; + visualization: string; +} = { + lens: LENS_EMBEDDABLE_TYPE, map: MAP_SAVED_OBJECT_TYPE, search: SEARCH_EMBEDDABLE_TYPE, visualization: VISUALIZE_EMBEDDABLE_TYPE, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 48b50930d563e..36fa6497ab6f3 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -48,6 +48,7 @@ import { rounddate } from './rounddate'; import { rowCount } from './rowCount'; import { repeatImage } from './repeatImage'; import { revealImage } from './revealImage'; +import { savedLens } from './saved_lens'; import { savedMap } from './saved_map'; import { savedSearch } from './saved_search'; import { savedVisualization } from './saved_visualization'; @@ -109,6 +110,7 @@ export const functions = [ revealImage, rounddate, rowCount, + savedLens, savedMap, savedSearch, savedVisualization, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts new file mode 100644 index 0000000000000..6b197148e6373 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +jest.mock('ui/new_platform'); +import { savedLens } from './saved_lens'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; + +const filterContext = { + and: [ + { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, + { + and: [], + column: 'time-column', + type: 'time', + from: '2019-06-04T04:00:00.000Z', + to: '2019-06-05T04:00:00.000Z', + }, + ], +}; + +describe('savedLens', () => { + const fn = savedLens().fn; + const args = { + id: 'some-id', + title: null, + timerange: null, + }; + + it('accepts null context', () => { + const expression = fn(null, args, {} as any); + + expect(expression.input.filters).toEqual([]); + }); + + it('accepts filter context', () => { + const expression = fn(filterContext, args, {} as any); + const embeddableFilters = getQueryFilters(filterContext.and); + + expect(expression.input.filters).toEqual(embeddableFilters); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts new file mode 100644 index 0000000000000..60026adc0998a --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { TimeRange } from 'src/plugins/data/public'; +import { EmbeddableInput } from 'src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { Filter, TimeRange as TimeRangeArg } from '../../../types'; +import { + EmbeddableTypes, + EmbeddableExpressionType, + EmbeddableExpression, +} from '../../expression_types'; +import { getFunctionHelp } from '../../../i18n'; +import { Filter as DataFilter } from '../../../../../../../src/plugins/data/public'; + +interface Arguments { + id: string; + title: string | null; + timerange: TimeRangeArg | null; +} + +export type SavedLensInput = EmbeddableInput & { + id: string; + timeRange?: TimeRange; + filters: DataFilter[]; +}; + +const defaultTimeRange = { + from: 'now-15m', + to: 'now', +}; + +type Return = EmbeddableExpression; + +export function savedLens(): ExpressionFunctionDefinition< + 'savedLens', + Filter | null, + Arguments, + Return +> { + const { help, args: argHelp } = getFunctionHelp().savedLens; + return { + name: 'savedLens', + help, + args: { + id: { + types: ['string'], + required: false, + help: argHelp.id, + }, + timerange: { + types: ['timerange'], + help: argHelp.timerange, + required: false, + }, + title: { + types: ['string'], + help: argHelp.title, + required: false, + }, + }, + type: EmbeddableExpressionType, + fn: (context, args) => { + const filters = context ? context.and : []; + + return { + type: EmbeddableExpressionType, + input: { + id: args.id, + filters: getQueryFilters(filters), + timeRange: args.timerange || defaultTimeRange, + title: args.title ? args.title : undefined, + disableTriggers: true, + }, + embeddableType: EmbeddableTypes.lens, + }; + }, + }; +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss new file mode 100644 index 0000000000000..04f2f393d1e80 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss @@ -0,0 +1,33 @@ +.canvasEmbeddable { + .embPanel { + border: none; + background: none; + + .embPanel__title { + margin-bottom: $euiSizeXS; + } + + .embPanel__optionsMenuButton { + border-radius: $euiBorderRadius; + } + + .canvas-isFullscreen & { + .embPanel__optionsMenuButton { + opacity: 0; + } + + &:focus .embPanel__optionsMenuButton, + &:hover .embPanel__optionsMenuButton { + opacity: 1; + } + } + } + + .euiTable { + background: none; + } + + .lnsExpressionRenderer { + @include euiScrollBar; + } +} \ No newline at end of file diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index 549e69e57e921..d91e70e43bfd5 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -18,11 +18,12 @@ import { start } from '../../../../../../../src/legacy/core_plugins/embeddable_a import { EmbeddableExpression } from '../../expression_types/embeddable'; import { RendererStrings } from '../../../i18n'; import { getSavedObjectFinder } from '../../../../../../../src/plugins/saved_objects/public'; - -const { embeddable: strings } = RendererStrings; import { embeddableInputToExpression } from './embeddable_input_to_expression'; import { EmbeddableInput } from '../../expression_types'; import { RendererHandlers } from '../../../types'; +import { CANVAS_EMBEDDABLE_CLASSNAME } from '../../../common/lib'; + +const { embeddable: strings } = RendererStrings; const embeddablesRegistry: { [key: string]: IEmbeddable; @@ -31,7 +32,7 @@ const embeddablesRegistry: { const renderEmbeddable = (embeddableObject: IEmbeddable, domNode: HTMLElement) => { return (
diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts index 8694c0e2c7f9f..4c622b0c247fa 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts @@ -7,12 +7,17 @@ jest.mock('ui/new_platform'); import { embeddableInputToExpression } from './embeddable_input_to_expression'; import { SavedMapInput } from '../../functions/common/saved_map'; +import { SavedLensInput } from '../../functions/common/saved_lens'; import { EmbeddableTypes } from '../../expression_types'; import { fromExpression, Ast } from '@kbn/interpreter/common'; -const baseSavedMapInput = { +const baseEmbeddableInput = { id: 'embeddableId', filters: [], +}; + +const baseSavedMapInput = { + ...baseEmbeddableInput, isLayerTOCOpen: false, refreshConfig: { isPaused: true, @@ -73,4 +78,45 @@ describe('input to expression', () => { expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); }); }); + + describe('Lens Embeddable', () => { + it('converts to a savedLens expression', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.lens); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedLens'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + + expect(ast.chain[0].arguments).not.toHaveProperty('title'); + expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); + }); + + it('includes optional input values', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + title: 'title', + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.map); + const ast = fromExpression(expression); + + expect(ast.chain[0].arguments).toHaveProperty('title', [input.title]); + expect(ast.chain[0].arguments).toHaveProperty('timerange'); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); + }); }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts index a3cb53acebed2..6428507b16a0c 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts @@ -6,6 +6,7 @@ import { EmbeddableTypes, EmbeddableInput } from '../../expression_types'; import { SavedMapInput } from '../../functions/common/saved_map'; +import { SavedLensInput } from '../../functions/common/saved_lens'; /* Take the input from an embeddable and the type of embeddable and convert it into an expression @@ -46,5 +47,23 @@ export function embeddableInputToExpression( } } + if (embeddableType === EmbeddableTypes.lens) { + const lensInput = input as SavedLensInput; + + expressionParts.push('savedLens'); + + expressionParts.push(`id="${input.id}"`); + + if (input.title) { + expressionParts.push(`title="${input.title}"`); + } + + if (lensInput.timeRange) { + expressionParts.push( + `timerange={timerange from="${lensInput.timeRange.from}" to="${lensInput.timeRange.to}"}` + ); + } + } + return expressionParts.join(' '); } diff --git a/x-pack/legacy/plugins/canvas/common/lib/constants.ts b/x-pack/legacy/plugins/canvas/common/lib/constants.ts index 40e143b9ec589..ac8e80b8d7b89 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/constants.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/constants.ts @@ -39,3 +39,4 @@ export const API_ROUTE_SHAREABLE_BASE = '/public/canvas'; export const API_ROUTE_SHAREABLE_ZIP = '/public/canvas/zip'; export const API_ROUTE_SHAREABLE_RUNTIME = '/public/canvas/runtime'; export const API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD = `/public/canvas/${SHAREABLE_RUNTIME_NAME}.js`; +export const CANVAS_EMBEDDABLE_CLASSNAME = `canvasEmbeddable`; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts new file mode 100644 index 0000000000000..1efcbc9d3a18e --- /dev/null +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { savedLens } from '../../../canvas_plugin_src/functions/common/saved_lens'; +import { FunctionHelp } from '../function_help'; +import { FunctionFactory } from '../../../types'; + +export const help: FunctionHelp> = { + help: i18n.translate('xpack.canvas.functions.savedLensHelpText', { + defaultMessage: `Returns an embeddable for a saved lens object`, + }), + args: { + id: i18n.translate('xpack.canvas.functions.savedLens.args.idHelpText', { + defaultMessage: `The ID of the Saved Lens Object`, + }), + timerange: i18n.translate('xpack.canvas.functions.savedLens.args.timerangeHelpText', { + defaultMessage: `The timerange of data that should be included`, + }), + title: i18n.translate('xpack.canvas.functions.savedLens.args.titleHelpText', { + defaultMessage: `The title for the lens emebeddable`, + }), + }, +}; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts index dbdadd09df67f..e7d7b4ca4321b 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts @@ -62,6 +62,7 @@ import { help as replace } from './dict/replace'; import { help as revealImage } from './dict/reveal_image'; import { help as rounddate } from './dict/rounddate'; import { help as rowCount } from './dict/row_count'; +import { help as savedLens } from './dict/saved_lens'; import { help as savedMap } from './dict/saved_map'; import { help as savedSearch } from './dict/saved_search'; import { help as savedVisualization } from './dict/saved_visualization'; @@ -216,6 +217,7 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ revealImage, rounddate, rowCount, + savedLens, savedMap, savedSearch, savedVisualization, diff --git a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx index 565ca5fa5bbd6..353a59397d6b6 100644 --- a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx @@ -21,6 +21,9 @@ const allowedEmbeddables = { [EmbeddableTypes.map]: (id: string) => { return `savedMap id="${id}" | render`; }, + [EmbeddableTypes.lens]: (id: string) => { + return `savedLens id="${id}" | render`; + }, // FIX: Only currently allow Map embeddables /* [EmbeddableTypes.visualization]: (id: string) => { return `filters | savedVisualization id="${id}" | render`; diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js index b775524acf639..2500a412c0fac 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js @@ -19,6 +19,7 @@ import { } from '../../../state/actions/elements'; import { selectToplevelNodes } from '../../../state/actions/transient'; import { crawlTree, globalStateUpdater, shapesForNodes } from '../integration_utils'; +import { CANVAS_EMBEDDABLE_CLASSNAME } from '../../../../common/lib'; import { InteractiveWorkpadPage as InteractiveComponent } from './interactive_workpad_page'; import { eventHandlers } from './event_handlers'; @@ -79,9 +80,14 @@ const isEmbeddableBody = element => { const hasClosest = typeof element.closest === 'function'; if (hasClosest) { - return element.closest('.embeddable') && !element.closest('.embPanel__header'); + return ( + element.closest(`.${CANVAS_EMBEDDABLE_CLASSNAME}`) && !element.closest('.embPanel__header') + ); } else { - return closest.call(element, '.embeddable') && !closest.call(element, '.embPanel__header'); + return ( + closest.call(element, `.${CANVAS_EMBEDDABLE_CLASSNAME}`) && + !closest.call(element, '.embPanel__header') + ); } }; diff --git a/x-pack/legacy/plugins/canvas/public/style/index.scss b/x-pack/legacy/plugins/canvas/public/style/index.scss index 4b85620863692..39e5903ff1d96 100644 --- a/x-pack/legacy/plugins/canvas/public/style/index.scss +++ b/x-pack/legacy/plugins/canvas/public/style/index.scss @@ -61,6 +61,7 @@ @import '../../canvas_plugin_src/renderers/advanced_filter/component/advanced_filter.scss'; @import '../../canvas_plugin_src/renderers/dropdown_filter/component/dropdown_filter.scss'; +@import '../../canvas_plugin_src/renderers/embeddable/embeddable.scss'; @import '../../canvas_plugin_src/renderers/plot/plot.scss'; @import '../../canvas_plugin_src/renderers/reveal_image/reveal_image.scss'; @import '../../canvas_plugin_src/renderers/time_filter/components/datetime_calendar/datetime_calendar.scss'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_add.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_add.test.js index 06a7c2f1ec45e..2be00e70f6f84 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_add.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_add.test.js @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import '../../public/np_ready/app/services/breadcrumbs.mock'; import { setupEnvironment, pageHelpers, nextTick, getRandomString } from './helpers'; import { indexPatterns } from '../../../../../../src/plugins/data/public'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_edit.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_edit.test.js index 04e80deaf8276..abc3e5dc9def2 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_edit.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_edit.test.js @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AutoFollowPatternForm } from '../../public/app/components/auto_follow_pattern_form'; +import '../../public/np_ready/app/services/breadcrumbs.mock'; +import { AutoFollowPatternForm } from '../../public/np_ready/app/components/auto_follow_pattern_form'; import { setupEnvironment, pageHelpers, nextTick } from './helpers'; import { AUTO_FOLLOW_PATTERN_EDIT } from './helpers/constants'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js index 88d8f98b973bd..20e982856dc19 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js @@ -4,21 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../public/np_ready/app/services/breadcrumbs.mock'; import { setupEnvironment, pageHelpers, nextTick, getRandomString } from './helpers'; import { getAutoFollowPatternClientMock } from '../../fixtures/auto_follow_pattern'; jest.mock('ui/new_platform'); -jest.mock('ui/chrome', () => ({ - addBasePath: () => 'api/cross_cluster_replication', - breadcrumbs: { set: () => {} }, - getUiSettingsClient: () => ({ - get: x => x, - getUpdate$: () => ({ subscribe: jest.fn() }), - }), -})); - const { setup } = pageHelpers.autoFollowPatternList; describe('', () => { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_add.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_add.test.js index 8d4523ca26de2..7680be9d858a4 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_add.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_add.test.js @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../public/np_ready/app/services/breadcrumbs.mock'; import { setupEnvironment, pageHelpers, nextTick } from './helpers'; -import { RemoteClustersFormField } from '../../public/app/components'; +import { RemoteClustersFormField } from '../../public/np_ready/app/components'; import { indexPatterns } from '../../../../../../src/plugins/data/public'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js index 5e2810ae882fb..cfa37ff2e0358 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../public/np_ready/app/services/breadcrumbs.mock'; import { setupEnvironment, pageHelpers, nextTick } from './helpers'; -import { FollowerIndexForm } from '../../public/app/components/follower_index_form/follower_index_form'; +import { FollowerIndexForm } from '../../public/np_ready/app/components/follower_index_form/follower_index_form'; import { FOLLOWER_INDEX_EDIT } from './helpers/constants'; jest.mock('ui/new_platform'); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js index 9fd5756a7febf..dde31d1d166f9 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js @@ -10,15 +10,6 @@ import { getFollowerIndexMock } from '../../fixtures/follower_index'; jest.mock('ui/new_platform'); -jest.mock('ui/chrome', () => ({ - addBasePath: () => 'api/cross_cluster_replication', - breadcrumbs: { set: () => {} }, - getUiSettingsClient: () => ({ - get: x => x, - getUpdate$: () => ({ subscribe: jest.fn() }), - }), -})); - const { setup } = pageHelpers.followerIndexList; describe('', () => { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js index 3eb195bac7ed1..1f64e589bc4c1 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed } from '../../../../../../test_utils'; -import { AutoFollowPatternAdd } from '../../../public/app/sections/auto_follow_pattern_add'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { AutoFollowPatternAdd } from '../../../public/np_ready/app/sections/auto_follow_pattern_add'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; const testBedConfig = { store: ccrStore, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js index 94a94554b9105..2b110c6552072 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed } from '../../../../../../test_utils'; -import { AutoFollowPatternEdit } from '../../../public/app/sections/auto_follow_pattern_edit'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { AutoFollowPatternEdit } from '../../../public/np_ready/app/sections/auto_follow_pattern_edit'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; import { AUTO_FOLLOW_PATTERN_EDIT_NAME } from './constants'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js index c0d29e8af2549..1d3e8ad6dff83 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed, findTestSubject } from '../../../../../../test_utils'; -import { AutoFollowPatternList } from '../../../public/app/sections/home/auto_follow_pattern_list'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { AutoFollowPatternList } from '../../../public/np_ready/app/sections/home/auto_follow_pattern_list'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; const testBedConfig = { store: ccrStore, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js index 785330049cb0c..f74baa1b2ad0a 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed } from '../../../../../../test_utils'; -import { FollowerIndexAdd } from '../../../public/app/sections/follower_index_add'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { FollowerIndexAdd } from '../../../public/np_ready/app/sections/follower_index_add'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; const testBedConfig = { store: ccrStore, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js index 56cbe5b47229c..47f8539bb593b 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed } from '../../../../../../test_utils'; -import { FollowerIndexEdit } from '../../../public/app/sections/follower_index_edit'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { FollowerIndexEdit } from '../../../public/np_ready/app/sections/follower_index_edit'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; import { FOLLOWER_INDEX_EDIT_NAME } from './constants'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js index 02b64cd7f306c..2154e11e17b1f 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed, findTestSubject } from '../../../../../../test_utils'; -import { FollowerIndicesList } from '../../../public/app/sections/home/follower_indices_list'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { FollowerIndicesList } from '../../../public/np_ready/app/sections/home/follower_indices_list'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; const testBedConfig = { store: ccrStore, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js index db30e4fe1dbe7..664ad909ba8e7 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed } from '../../../../../../test_utils'; -import { CrossClusterReplicationHome } from '../../../public/app/sections/home/home'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { CrossClusterReplicationHome } from '../../../public/np_ready/app/sections/home/home'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; import { BASE_PATH } from '../../../common/constants'; const testBedConfig = { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/http_requests.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/http_requests.js index 9bd88a08a5a61..e2bd54a92a1f1 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/http_requests.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/http_requests.js @@ -19,7 +19,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'GET', - 'api/cross_cluster_replication/follower_indices', + '/api/cross_cluster_replication/follower_indices', mockResponse(defaultResponse, response) ); }; @@ -29,7 +29,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'GET', - 'api/cross_cluster_replication/auto_follow_patterns', + '/api/cross_cluster_replication/auto_follow_patterns', mockResponse(defaultResponse, response) ); }; @@ -39,7 +39,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'DELETE', - /api\/cross_cluster_replication\/auto_follow_patterns/, + /\/api\/cross_cluster_replication\/auto_follow_patterns/, mockResponse(defaultResponse, response) ); }; @@ -61,7 +61,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'GET', - 'api/cross_cluster_replication/stats/auto_follow', + '/api/cross_cluster_replication/stats/auto_follow', mockResponse(defaultResponse, response) ); }; @@ -87,7 +87,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'GET', - /api\/cross_cluster_replication\/auto_follow_patterns\/.+/, + /\/api\/cross_cluster_replication\/auto_follow_patterns\/.+/, mockResponse(defaultResponse, response) ); }; @@ -105,7 +105,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'GET', - /api\/cross_cluster_replication\/follower_indices\/.+/, + /\/api\/cross_cluster_replication\/follower_indices\/.+/, mockResponse(defaultResponse, response) ); }; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/setup_environment.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/setup_environment.js index 8bd86067d8513..3562ad0df5b51 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/setup_environment.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/setup_environment.js @@ -7,14 +7,15 @@ import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; -import { setHttpClient } from '../../../public/app/services/api'; +import { setHttpClient } from '../../../public/np_ready/app/services/api'; import { init as initHttpRequests } from './http_requests'; export const setupEnvironment = () => { - // Mock Angular $q - const $q = { defer: () => ({ resolve() {} }) }; - // axios has a $http like interface so using it to simulate $http - setHttpClient(axios.create({ adapter: axiosXhrAdapter }), $q); + // axios has a similar interface to HttpSetup, but we + // flatten out the response. + const client = axios.create({ adapter: axiosXhrAdapter }); + client.interceptors.response.use(({ data }) => data); + setHttpClient(client); const { server, httpRequestsMockHelpers } = initHttpRequests(); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js index 2afa9c44a7b1c..2c536d069ef53 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../public/np_ready/app/services/breadcrumbs.mock'; import { setupEnvironment, pageHelpers, nextTick } from './helpers'; jest.mock('ui/new_platform'); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/constants/app.js b/x-pack/legacy/plugins/cross_cluster_replication/common/constants/app.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/common/constants/app.js rename to x-pack/legacy/plugins/cross_cluster_replication/common/constants/app.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/constants/base_path.js b/x-pack/legacy/plugins/cross_cluster_replication/common/constants/base_path.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/common/constants/base_path.js rename to x-pack/legacy/plugins/cross_cluster_replication/common/constants/base_path.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/constants/index.js b/x-pack/legacy/plugins/cross_cluster_replication/common/constants/index.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/common/constants/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/common/constants/index.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/constants/plugin.js b/x-pack/legacy/plugins/cross_cluster_replication/common/constants/plugin.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/common/constants/plugin.js rename to x-pack/legacy/plugins/cross_cluster_replication/common/constants/plugin.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/constants/settings.js b/x-pack/legacy/plugins/cross_cluster_replication/common/constants/settings.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/common/constants/settings.js rename to x-pack/legacy/plugins/cross_cluster_replication/common/constants/settings.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/index.js b/x-pack/legacy/plugins/cross_cluster_replication/index.js index cdb867972fcf5..aff4cc5b56738 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/index.js @@ -6,9 +6,7 @@ import { resolve } from 'path'; import { PLUGIN } from './common/constants'; -import { registerLicenseChecker } from './server/lib/register_license_checker'; -import { registerRoutes } from './server/routes/register_routes'; -import { ccrDataEnricher } from './cross_cluster_replication_data'; +import { plugin } from './server/np_ready'; export function crossClusterReplication(kibana) { return new kibana.Plugin({ @@ -47,15 +45,13 @@ export function crossClusterReplication(kibana) { ); }, init: function initCcrPlugin(server) { - registerLicenseChecker(server); - registerRoutes(server); - if ( - server.config().get('xpack.ccr.ui.enabled') && - server.newPlatform.setup.plugins.indexManagement && - server.newPlatform.setup.plugins.indexManagement.indexDataEnricher - ) { - server.newPlatform.setup.plugins.indexManagement.indexDataEnricher.add(ccrDataEnricher); - } + plugin({}).setup(server.newPlatform.setup.core, { + indexManagement: server.newPlatform.setup.plugins.indexManagement, + __LEGACY: { + server, + ccrUIEnabled: server.config().get('xpack.ccr.ui.enabled'), + }, + }); }, }); } diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/documentation_links.js b/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/documentation_links.js deleted file mode 100644 index 585ca7e0f5cf1..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/documentation_links.js +++ /dev/null @@ -1,14 +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 { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; - -const esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; - -export const autoFollowPatternUrl = `${esBase}/ccr-put-auto-follow-pattern.html`; -export const followerIndexUrl = `${esBase}/ccr-put-follow.html`; -export const byteUnitsUrl = `${esBase}/common-options.html#byte-units`; -export const timeUnitsUrl = `${esBase}/common-options.html#time-units`; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/index.js index 4ec268f0de7f2..e92c44da34474 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/index.js @@ -5,4 +5,3 @@ */ import './register_routes'; -import './extend_index_management'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/index.scss b/x-pack/legacy/plugins/cross_cluster_replication/public/index.scss index 6f65dc04d4427..31317e16e3e9f 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/index.scss +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/index.scss @@ -10,4 +10,4 @@ // ccrChart__legend--small // ccrChart__legend-isLoading -@import 'app/app'; +@import 'np_ready/app/app'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/_app.scss b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/_app.scss similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/_app.scss rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/_app.scss diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/app.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/app.js similarity index 97% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/app.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/app.js index 31626750a7f37..968646a4bd1b0 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/app.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/app.js @@ -7,7 +7,6 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import { Route, Switch, Redirect, withRouter } from 'react-router-dom'; -import { fatalError } from 'ui/notify'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -21,7 +20,8 @@ import { EuiTitle, } from '@elastic/eui'; -import { BASE_PATH } from '../../common/constants'; +import { BASE_PATH } from '../../../common/constants'; +import { getFatalErrors } from './services/notifications'; import { SectionError } from './components'; import routing from './services/routing'; import { loadPermissions } from './services/api'; @@ -81,7 +81,7 @@ class AppComponent extends Component { }); } catch (error) { // Expect an error in the shape provided by Angular's $http service. - if (error && error.data) { + if (error && error.body) { return this.setState({ isFetchingPermissions: false, fetchPermissionError: error, @@ -90,7 +90,7 @@ class AppComponent extends Component { // This error isn't an HTTP error, so let the fatal error screen tell the user something // unexpected happened. - fatalError( + getFatalErrors().add( error, i18n.translate('xpack.crossClusterReplication.app.checkPermissionsFatalErrorTitle', { defaultMessage: 'Cross-Cluster Replication app', diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/__snapshots__/auto_follow_pattern_form.test.js.snap b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/__snapshots__/auto_follow_pattern_form.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/__snapshots__/auto_follow_pattern_form.test.js.snap rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/__snapshots__/auto_follow_pattern_form.test.js.snap diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/index.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/index.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/index.ts rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/index.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.d.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_delete_provider.d.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.d.ts rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_delete_provider.d.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_delete_provider.js similarity index 98% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_delete_provider.js index f9c03165dcf97..7803b329e6258 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_delete_provider.js @@ -11,7 +11,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; import { deleteAutoFollowPattern } from '../store/actions'; -import { arrify } from '../../../common/services/utils'; +import { arrify } from '../../../../common/services/utils'; class AutoFollowPatternDeleteProviderUi extends PureComponent { state = { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_form.js similarity index 99% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_form.js index d4e418a964c8f..5bc5d8ba6e402 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_form.js @@ -29,8 +29,8 @@ import { EuiTitle, } from '@elastic/eui'; -import { indices } from '../../../../../../../src/plugins/es_ui_shared/public'; -import { indexPatterns } from '../../../../../../../src/plugins/data/public'; +import { indices } from '../../../../../../../../src/plugins/es_ui_shared/public'; +import { indexPatterns } from '../../../../../../../../src/plugins/data/public'; import routing from '../services/routing'; import { extractQueryParams } from '../services/query_params'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_form.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_form.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_indices_preview.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_indices_preview.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_page_title.js similarity index 92% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_page_title.js index 43cc0a39e6e57..9880e8c983a8e 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_page_title.js @@ -17,7 +17,7 @@ import { EuiTitle, } from '@elastic/eui'; -import { autoFollowPatternUrl } from '../services/documentation_links'; +import { getAutoFollowPatternUrl } from '../services/documentation_links'; export const AutoFollowPatternPageTitle = ({ title }) => ( @@ -35,7 +35,7 @@ export const AutoFollowPatternPageTitle = ({ title }) => ( + + { @@ -223,18 +223,24 @@ export class FollowerIndexForm extends PureComponent { isValidatingIndexName: false, }); } catch (error) { - // Expect an error in the shape provided by Angular's $http service. - if (error && error.data) { - // All validation does is check for a name collision, so we can just let the user attempt - // to save the follower index and get an error back from the API. - return this.setState({ - isValidatingIndexName: false, - }); + if (error) { + if (error.name === 'AbortError') { + // Ignore aborted requests + return; + } + // This could be an HTTP error + if (error.body) { + // All validation does is check for a name collision, so we can just let the user attempt + // to save the follower index and get an error back from the API. + return this.setState({ + isValidatingIndexName: false, + }); + } } // This error isn't an HTTP error, so let the fatal error screen tell the user something // unexpected happened. - fatalError( + getFatalErrors().add( error, i18n.translate( 'xpack.crossClusterReplication.followerIndexForm.indexNameValidationFatalErrorTitle', diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_form.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_form.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_request_flyout.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_request_flyout.js similarity index 96% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_request_flyout.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_request_flyout.js index cba1c104e45d9..cb02a929b16f8 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_request_flyout.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_request_flyout.js @@ -26,7 +26,7 @@ import { EuiTitle, } from '@elastic/eui'; -import { serializeFollowerIndex } from '../../../../common/services/follower_index_serialization'; +import { serializeFollowerIndex } from '../../../../../common/services/follower_index_serialization'; export class FollowerIndexRequestFlyout extends PureComponent { static propTypes = { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_page_title.js similarity index 92% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_page_title.js index a77059b5fe084..d72038096b72a 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_page_title.js @@ -17,7 +17,7 @@ import { EuiTitle, } from '@elastic/eui'; -import { followerIndexUrl } from '../services/documentation_links'; +import { getFollowerIndexUrl } from '../services/documentation_links'; export const FollowerIndexPageTitle = ({ title }) => ( @@ -35,7 +35,7 @@ export const FollowerIndexPageTitle = ({ title }) => ( ( diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/remote_clusters_provider.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/remote_clusters_provider.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/remote_clusters_provider.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/remote_clusters_provider.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_error.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_error.js similarity index 79% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_error.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_error.js index 8aaf89b30f0e7..a2c782a0e8e58 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_error.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_error.js @@ -9,21 +9,21 @@ import { EuiCallOut, EuiSpacer } from '@elastic/eui'; export function SectionError(props) { const { title, error, ...rest } = props; - const data = error.data ? error.data : error; + const data = error.body ? error.body : error; const { error: errorString, - cause, // wrapEsError() on the server add a "cause" array + attributes, // wrapEsError() on the server add a "cause" array message, } = data; return (
{message || errorString}
- {cause && ( + {attributes && attributes.cause && (
    - {cause.map((message, i) => ( + {attributes.cause.map((message, i) => (
  • {message}
  • ))}
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_loading.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_loading.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_loading.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_loading.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_unauthorized.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_unauthorized.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_unauthorized.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_unauthorized.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/api.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/api.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/api.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/api.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/sections.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/sections.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/sections.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/sections.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/ui_metric.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/ui_metric.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/ui_metric.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/ui_metric.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/index.js similarity index 88% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/index.js index 928d37558adb7..cc81fce4eebe7 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/index.js @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { I18nContext } from 'ui/i18n'; import React from 'react'; import { render } from 'react-dom'; import { Provider } from 'react-redux'; @@ -12,7 +11,7 @@ import { HashRouter } from 'react-router-dom'; import { App } from './app'; import { ccrStore } from './store'; -export const renderReact = async elem => { +export const renderReact = async (elem, I18nContext) => { render( diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js similarity index 91% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js index f55b9e4bceb0b..60a6cc79376e5 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js @@ -7,12 +7,10 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiPageContent } from '@elastic/eui'; -import { listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; +import { listBreadcrumb, addBreadcrumb, setBreadcrumbs } from '../../services/breadcrumbs'; import { AutoFollowPatternForm, AutoFollowPatternPageTitle, @@ -29,7 +27,7 @@ export class AutoFollowPatternAdd extends PureComponent { }; componentDidMount() { - chrome.breadcrumbs.set([MANAGEMENT_BREADCRUMB, listBreadcrumb, addBreadcrumb]); + setBreadcrumbs([listBreadcrumb, addBreadcrumb]); } componentWillUnmount() { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js similarity index 96% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js index a64c9566502f1..4cd3617abd989 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js @@ -8,12 +8,10 @@ import React, { PureComponent, Fragment } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPageContent, EuiSpacer } from '@elastic/eui'; -import { listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; +import { listBreadcrumb, editBreadcrumb, setBreadcrumbs } from '../../services/breadcrumbs'; import routing from '../../services/routing'; import { AutoFollowPatternForm, @@ -56,7 +54,7 @@ export class AutoFollowPatternEdit extends PureComponent { selectAutoFollowPattern(decodedId); - chrome.breadcrumbs.set([MANAGEMENT_BREADCRUMB, listBreadcrumb, editBreadcrumb]); + setBreadcrumbs([listBreadcrumb, editBreadcrumb]); } componentDidUpdate(prevProps, prevState) { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/follower_index_add.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/follower_index_add.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/follower_index_add.js similarity index 91% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/follower_index_add.js index 26b5d8d6bb880..003e27777652b 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/follower_index_add.js @@ -7,12 +7,10 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiPageContent } from '@elastic/eui'; -import { listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; +import { setBreadcrumbs, listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; import { FollowerIndexForm, FollowerIndexPageTitle, @@ -29,7 +27,7 @@ export class FollowerIndexAdd extends PureComponent { }; componentDidMount() { - chrome.breadcrumbs.set([MANAGEMENT_BREADCRUMB, listBreadcrumb, addBreadcrumb]); + setBreadcrumbs([listBreadcrumb, addBreadcrumb]); } componentWillUnmount() { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/follower_index_edit.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/follower_index_edit.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/follower_index_edit.js similarity index 97% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/follower_index_edit.js index 7dc45e88f4106..21493602c12a7 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/follower_index_edit.js @@ -8,8 +8,6 @@ import React, { PureComponent, Fragment } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiButtonEmpty, @@ -21,7 +19,7 @@ import { EuiSpacer, } from '@elastic/eui'; -import { listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; +import { setBreadcrumbs, listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; import routing from '../../services/routing'; import { FollowerIndexForm, @@ -76,7 +74,7 @@ export class FollowerIndexEdit extends PureComponent { selectFollowerIndex(decodedId); - chrome.breadcrumbs.set([MANAGEMENT_BREADCRUMB, listBreadcrumb, editBreadcrumb]); + setBreadcrumbs([listBreadcrumb, editBreadcrumb]); } componentDidUpdate(prevProps, prevState) { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js similarity index 99% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js index 7b31ffa5024b7..1a6d5e6efe35a 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js @@ -7,7 +7,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import { getIndexListUri } from '../../../../../../../../../../plugins/index_management/public'; +import { getIndexListUri } from '../../../../../../../../../../../plugins/index_management/public'; import { EuiButtonEmpty, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/context_menu/context_menu.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/context_menu/context_menu.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/context_menu/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/context_menu/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js similarity index 99% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js index 2ad118d28f38d..3e8cf6d3e2f78 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js @@ -31,7 +31,7 @@ import { } from '@elastic/eui'; import 'brace/theme/textmate'; -import { getIndexListUri } from '../../../../../../../../../../plugins/index_management/public'; +import { getIndexListUri } from '../../../../../../../../../../../plugins/index_management/public'; import { API_STATUS } from '../../../../../constants'; import { ContextMenu } from '../context_menu'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/follower_indices_list.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/follower_indices_list.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/follower_indices_list.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/follower_indices_list.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/home.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/home.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/home.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/home.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/home.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/home.js similarity index 91% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/home.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/home.js index f89d287540ebd..88db909612245 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/home.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/home.js @@ -7,13 +7,11 @@ import React, { PureComponent } from 'react'; import { Route, Switch } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTab, EuiTabs, EuiTitle } from '@elastic/eui'; -import { BASE_PATH } from '../../../../common/constants'; -import { listBreadcrumb } from '../../services/breadcrumbs'; +import { BASE_PATH } from '../../../../../common/constants'; +import { setBreadcrumbs, listBreadcrumb } from '../../services/breadcrumbs'; import routing from '../../services/routing'; import { AutoFollowPatternList } from './auto_follow_pattern_list'; import { FollowerIndicesList } from './follower_indices_list'; @@ -47,7 +45,7 @@ export class CrossClusterReplicationHome extends PureComponent { ]; componentDidMount() { - chrome.breadcrumbs.set([MANAGEMENT_BREADCRUMB, listBreadcrumb]); + setBreadcrumbs([listBreadcrumb]); } static getDerivedStateFromProps(props) { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/api.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/api.js similarity index 52% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/api.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/api.js index 52576387444fd..b50c36aa8df9f 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/api.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/api.js @@ -3,14 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import chrome from 'ui/chrome'; import { API_BASE_PATH, API_REMOTE_CLUSTERS_BASE_PATH, API_INDEX_MANAGEMENT_BASE_PATH, -} from '../../../common/constants'; -import { arrify } from '../../../common/services/utils'; +} from '../../../../common/constants'; +import { arrify } from '../../../../common/services/utils'; import { UIM_FOLLOWER_INDEX_CREATE, UIM_FOLLOWER_INDEX_UPDATE, @@ -33,22 +31,10 @@ import { import { trackUserRequest } from './track_ui_metric'; import { areAllSettingsDefault } from './follower_index_default_settings'; -const apiPrefix = chrome.addBasePath(API_BASE_PATH); -const apiPrefixRemoteClusters = chrome.addBasePath(API_REMOTE_CLUSTERS_BASE_PATH); -const apiPrefixIndexManagement = chrome.addBasePath(API_INDEX_MANAGEMENT_BASE_PATH); - -// This is an Angular service, which is why we use this provider pattern -// to access it within our React app. let httpClient; -// The deferred AngularJS api allows us to create a deferred promise -// to be resolved later. This allows us to cancel in-flight http Requests. -// https://docs.angularjs.org/api/ng/service/$q#the-deferred-api -let $q; - -export function setHttpClient(client, $deffered) { +export function setHttpClient(client) { httpClient = client; - $q = $deffered; } export const getHttpClient = () => { @@ -57,67 +43,65 @@ export const getHttpClient = () => { // --- -const extractData = response => response.data; - const createIdString = ids => ids.map(id => encodeURIComponent(id)).join(','); /* Auto Follow Pattern */ -export const loadAutoFollowPatterns = () => - httpClient.get(`${apiPrefix}/auto_follow_patterns`).then(extractData); +export const loadAutoFollowPatterns = () => httpClient.get(`${API_BASE_PATH}/auto_follow_patterns`); export const getAutoFollowPattern = id => - httpClient.get(`${apiPrefix}/auto_follow_patterns/${encodeURIComponent(id)}`).then(extractData); + httpClient.get(`${API_BASE_PATH}/auto_follow_patterns/${encodeURIComponent(id)}`); -export const loadRemoteClusters = () => httpClient.get(apiPrefixRemoteClusters).then(extractData); +export const loadRemoteClusters = () => httpClient.get(API_REMOTE_CLUSTERS_BASE_PATH); export const createAutoFollowPattern = autoFollowPattern => { - const request = httpClient.post(`${apiPrefix}/auto_follow_patterns`, autoFollowPattern); - return trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_CREATE).then(extractData); + const request = httpClient.post(`${API_BASE_PATH}/auto_follow_patterns`, { + body: JSON.stringify(autoFollowPattern), + }); + return trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_CREATE); }; export const updateAutoFollowPattern = (id, autoFollowPattern) => { const request = httpClient.put( - `${apiPrefix}/auto_follow_patterns/${encodeURIComponent(id)}`, - autoFollowPattern + `${API_BASE_PATH}/auto_follow_patterns/${encodeURIComponent(id)}`, + { body: JSON.stringify(autoFollowPattern) } ); - return trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_UPDATE).then(extractData); + return trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_UPDATE); }; export const deleteAutoFollowPattern = id => { const ids = arrify(id); const idString = ids.map(_id => encodeURIComponent(_id)).join(','); - const request = httpClient.delete(`${apiPrefix}/auto_follow_patterns/${idString}`); + const request = httpClient.delete(`${API_BASE_PATH}/auto_follow_patterns/${idString}`); const uiMetric = ids.length > 1 ? UIM_AUTO_FOLLOW_PATTERN_DELETE_MANY : UIM_AUTO_FOLLOW_PATTERN_DELETE; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; export const pauseAutoFollowPattern = id => { const ids = arrify(id); const idString = ids.map(encodeURIComponent).join(','); - const request = httpClient.post(`${apiPrefix}/auto_follow_patterns/${idString}/pause`); + const request = httpClient.post(`${API_BASE_PATH}/auto_follow_patterns/${idString}/pause`); const uiMetric = ids.length > 1 ? UIM_AUTO_FOLLOW_PATTERN_PAUSE_MANY : UIM_AUTO_FOLLOW_PATTERN_PAUSE; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; export const resumeAutoFollowPattern = id => { const ids = arrify(id); const idString = ids.map(encodeURIComponent).join(','); - const request = httpClient.post(`${apiPrefix}/auto_follow_patterns/${idString}/resume`); + const request = httpClient.post(`${API_BASE_PATH}/auto_follow_patterns/${idString}/resume`); const uiMetric = ids.length > 1 ? UIM_AUTO_FOLLOW_PATTERN_RESUME_MANY : UIM_AUTO_FOLLOW_PATTERN_RESUME; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; /* Follower Index */ -export const loadFollowerIndices = () => - httpClient.get(`${apiPrefix}/follower_indices`).then(extractData); +export const loadFollowerIndices = () => httpClient.get(`${API_BASE_PATH}/follower_indices`); export const getFollowerIndex = id => - httpClient.get(`${apiPrefix}/follower_indices/${encodeURIComponent(id)}`).then(extractData); + httpClient.get(`${API_BASE_PATH}/follower_indices/${encodeURIComponent(id)}`); export const createFollowerIndex = followerIndex => { const uiMetrics = [UIM_FOLLOWER_INDEX_CREATE]; @@ -125,32 +109,34 @@ export const createFollowerIndex = followerIndex => { if (isUsingAdvancedSettings) { uiMetrics.push(UIM_FOLLOWER_INDEX_USE_ADVANCED_OPTIONS); } - const request = httpClient.post(`${apiPrefix}/follower_indices`, followerIndex); - return trackUserRequest(request, uiMetrics).then(extractData); + const request = httpClient.post(`${API_BASE_PATH}/follower_indices`, { + body: JSON.stringify(followerIndex), + }); + return trackUserRequest(request, uiMetrics); }; export const pauseFollowerIndex = id => { const ids = arrify(id); const idString = createIdString(ids); - const request = httpClient.put(`${apiPrefix}/follower_indices/${idString}/pause`); + const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${idString}/pause`); const uiMetric = ids.length > 1 ? UIM_FOLLOWER_INDEX_PAUSE_MANY : UIM_FOLLOWER_INDEX_PAUSE; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; export const resumeFollowerIndex = id => { const ids = arrify(id); const idString = createIdString(ids); - const request = httpClient.put(`${apiPrefix}/follower_indices/${idString}/resume`); + const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${idString}/resume`); const uiMetric = ids.length > 1 ? UIM_FOLLOWER_INDEX_RESUME_MANY : UIM_FOLLOWER_INDEX_RESUME; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; export const unfollowLeaderIndex = id => { const ids = arrify(id); const idString = createIdString(ids); - const request = httpClient.put(`${apiPrefix}/follower_indices/${idString}/unfollow`); + const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${idString}/unfollow`); const uiMetric = ids.length > 1 ? UIM_FOLLOWER_INDEX_UNFOLLOW_MANY : UIM_FOLLOWER_INDEX_UNFOLLOW; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; export const updateFollowerIndex = (id, followerIndex) => { @@ -159,31 +145,28 @@ export const updateFollowerIndex = (id, followerIndex) => { if (isUsingAdvancedSettings) { uiMetrics.push(UIM_FOLLOWER_INDEX_USE_ADVANCED_OPTIONS); } - const request = httpClient.put( - `${apiPrefix}/follower_indices/${encodeURIComponent(id)}`, - followerIndex - ); - return trackUserRequest(request, uiMetrics).then(extractData); + const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${encodeURIComponent(id)}`, { + body: JSON.stringify(followerIndex), + }); + return trackUserRequest(request, uiMetrics); }; /* Stats */ -export const loadAutoFollowStats = () => - httpClient.get(`${apiPrefix}/stats/auto_follow`).then(extractData); +export const loadAutoFollowStats = () => httpClient.get(`${API_BASE_PATH}/stats/auto_follow`); /* Indices */ -let canceler = null; +let abortController = null; export const loadIndices = () => { - if (canceler) { - // If there is a previous request in flight we cancel it by resolving the canceler - canceler.resolve(); + if (abortController) { + abortController.abort(); + abortController = null; } - canceler = $q.defer(); - return httpClient - .get(`${apiPrefixIndexManagement}/indices`, { timeout: canceler.promise }) - .then(response => { - canceler = null; - return extractData(response); - }); + abortController = new AbortController(); + const { signal } = abortController; + return httpClient.get(`${API_INDEX_MANAGEMENT_BASE_PATH}/indices`, { signal }).then(response => { + abortController = null; + return response; + }); }; -export const loadPermissions = () => httpClient.get(`${apiPrefix}/permissions`).then(extractData); +export const loadPermissions = () => httpClient.get(`${API_BASE_PATH}/permissions`); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_errors.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_errors.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_errors.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_errors.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_errors.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_errors.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_errors.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_errors.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern_validators.js similarity index 97% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern_validators.js index 5186a02383d33..1b5a39658ee46 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern_validators.js @@ -8,8 +8,8 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { indices } from '../../../../../../../src/plugins/es_ui_shared/public'; -import { indexPatterns } from '../../../../../../../src/plugins/data/public'; +import { indices } from '../../../../../../../../src/plugins/es_ui_shared/public'; +import { indexPatterns } from '../../../../../../../../src/plugins/data/public'; const { indexNameBeginsWithPeriod, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern_validators.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern_validators.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.mock.ts similarity index 60% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.mock.ts index f275f15637091..b7c75108d4ef0 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.mock.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export { wrapCustomError } from './wrap_custom_error'; -export { wrapEsError } from './wrap_es_error'; -export { wrapUnknownError } from './wrap_unknown_error'; +jest.mock('./breadcrumbs', () => ({ + ...jest.requireActual('./breadcrumbs'), + setBreadcrumbs: jest.fn(), +})); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/breadcrumbs.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.ts similarity index 56% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/breadcrumbs.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.ts index f8c8cc710964a..dc64cdee07f7d 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/breadcrumbs.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.ts @@ -3,9 +3,27 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { i18n } from '@kbn/i18n'; -import { BASE_PATH } from '../../../common/constants'; +import { ChromeBreadcrumb } from 'src/core/public'; + +import { ManagementAppMountParams } from '../../../../../../../../src/plugins/management/public'; + +import { BASE_PATH } from '../../../../common/constants'; + +let setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs']; + +export const setBreadcrumbSetter = ({ + __LEGACY, +}: { + __LEGACY: { + chrome: any; + MANAGEMENT_BREADCRUMB: ChromeBreadcrumb; + }; +}): void => { + setBreadcrumbs = (crumbs: ChromeBreadcrumb[]) => { + __LEGACY.chrome.breadcrumbs.set([__LEGACY.MANAGEMENT_BREADCRUMB, ...crumbs]); + }; +}; export const listBreadcrumb = { text: i18n.translate('xpack.crossClusterReplication.homeBreadcrumbTitle', { @@ -25,3 +43,5 @@ export const editBreadcrumb = { defaultMessage: 'Edit', }), }; + +export { setBreadcrumbs }; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/documentation_links.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/documentation_links.ts new file mode 100644 index 0000000000000..f17926d2bee10 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/documentation_links.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +let esBase: string; + +export const setDocLinks = ({ + DOC_LINK_VERSION, + ELASTIC_WEBSITE_URL, +}: { + ELASTIC_WEBSITE_URL: string; + DOC_LINK_VERSION: string; +}) => { + esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; +}; + +export const getAutoFollowPatternUrl = () => `${esBase}/ccr-put-auto-follow-pattern.html`; +export const getFollowerIndexUrl = () => `${esBase}/ccr-put-follow.html`; +export const getByteUnitsUrl = () => `${esBase}/common-options.html#byte-units`; +export const getTimeUnitsUrl = () => `${esBase}/common-options.html#time-units`; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/follower_index_default_settings.js similarity index 89% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/follower_index_default_settings.js index 118a54887d404..d20fa76ef5451 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/follower_index_default_settings.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FOLLOWER_INDEX_ADVANCED_SETTINGS } from '../../../common/constants'; +import { FOLLOWER_INDEX_ADVANCED_SETTINGS } from '../../../../common/constants'; export const getSettingDefault = name => { if (!FOLLOWER_INDEX_ADVANCED_SETTINGS[name]) { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/get_remote_cluster_name.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/get_remote_cluster_name.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/input_validation.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/input_validation.js similarity index 97% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/input_validation.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/input_validation.js index 981b3f5929751..64c3e8412437e 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/input_validation.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/input_validation.js @@ -6,7 +6,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { indices } from '../../../../../../../src/plugins/es_ui_shared/public'; +import { indices } from '../../../../../../../../src/plugins/es_ui_shared/public'; const isEmpty = value => { return !value || !value.trim().length; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/notifications.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/notifications.ts new file mode 100644 index 0000000000000..5e1c3e9e99437 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/notifications.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { NotificationsSetup, IToasts, FatalErrorsSetup } from 'src/core/public'; + +let _notifications: IToasts; +let _fatalErrors: FatalErrorsSetup; + +export const setNotifications = ( + notifications: NotificationsSetup, + fatalErrorsSetup: FatalErrorsSetup +) => { + _notifications = notifications.toasts; + _fatalErrors = fatalErrorsSetup; +}; + +export const getNotifications = () => _notifications; +export const getFatalErrors = () => _fatalErrors; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/query_params.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/query_params.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/query_params.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/query_params.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/routing.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/routing.js similarity index 99% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/routing.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/routing.js index 487b1068794f9..965aeaaad22ad 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/routing.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/routing.js @@ -10,7 +10,7 @@ import { createLocation } from 'history'; import { stringify } from 'query-string'; -import { APPS, BASE_PATH, BASE_PATH_REMOTE_CLUSTERS } from '../../../common/constants'; +import { APPS, BASE_PATH, BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants'; const isModifiedEvent = event => !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/track_ui_metric.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/track_ui_metric.js similarity index 92% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/track_ui_metric.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/track_ui_metric.js index bd618f6a59e5c..36b9c185b487d 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/track_ui_metric.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/track_ui_metric.js @@ -7,7 +7,7 @@ import { createUiStatsReporter, METRIC_TYPE, -} from '../../../../../../../src/legacy/core_plugins/ui_metric/public'; +} from '../../../../../../../../src/legacy/core_plugins/ui_metric/public'; import { UIM_APP_NAME } from '../constants'; export const trackUiMetric = createUiStatsReporter(UIM_APP_NAME); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/utils.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/utils.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/utils.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/utils.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/utils.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/utils.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/utils.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/utils.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/action_types.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/action_types.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/action_types.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/action_types.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/api.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/api.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/api.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/api.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/auto_follow_pattern.js similarity index 95% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/auto_follow_pattern.js index 439858ad98ba3..b81cd30f3977a 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/auto_follow_pattern.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import { toastNotifications } from 'ui/notify'; +import { getNotifications } from '../../services/notifications'; import { SECTIONS, API_STATUS } from '../../constants'; import { loadAutoFollowPatterns as loadAutoFollowPatternsRequest, @@ -75,7 +75,7 @@ export const saveAutoFollowPattern = (id, autoFollowPattern, isUpdating = false) } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); routing.navigate(`/auto_follow_patterns`, undefined, { pattern: encodeURIComponent(id), }); @@ -111,7 +111,7 @@ export const deleteAutoFollowPattern = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsDeleted.length) { @@ -133,7 +133,7 @@ export const deleteAutoFollowPattern = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); // If we've just deleted a pattern we were looking at, we need to close the panel. const autoFollowPatternId = getSelectedAutoFollowPatternId('detail')(getState()); @@ -173,7 +173,7 @@ export const pauseAutoFollowPattern = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsPaused.length) { @@ -195,7 +195,7 @@ export const pauseAutoFollowPattern = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); } }, }); @@ -229,7 +229,7 @@ export const resumeAutoFollowPattern = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsResumed.length) { @@ -251,7 +251,7 @@ export const resumeAutoFollowPattern = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); } }, }); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/ccr.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/ccr.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/ccr.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/ccr.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/follower_index.js similarity index 95% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/follower_index.js index da1c259974498..ebdee067ced75 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/follower_index.js @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import { toastNotifications } from 'ui/notify'; + import routing from '../../services/routing'; +import { getNotifications } from '../../services/notifications'; import { SECTIONS, API_STATUS } from '../../constants'; import { loadFollowerIndices as loadFollowerIndicesRequest, @@ -75,7 +76,7 @@ export const saveFollowerIndex = (name, followerIndex, isUpdating = false) => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); routing.navigate(`/follower_indices`, undefined, { name: encodeURIComponent(name), }); @@ -111,7 +112,7 @@ export const pauseFollowerIndex = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsPaused.length) { @@ -133,7 +134,7 @@ export const pauseFollowerIndex = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); // Refresh list dispatch(loadFollowerIndices(true)); @@ -170,7 +171,7 @@ export const resumeFollowerIndex = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsResumed.length) { @@ -192,7 +193,7 @@ export const resumeFollowerIndex = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); } // Refresh list @@ -229,7 +230,7 @@ export const unfollowLeaderIndex = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsUnfollowed.length) { @@ -251,7 +252,7 @@ export const unfollowLeaderIndex = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); } if (response.itemsNotOpen.length) { @@ -273,7 +274,7 @@ export const unfollowLeaderIndex = id => } ); - toastNotifications.addWarning(warningMessage); + getNotifications().addWarning(warningMessage); } // If we've just unfollowed a follower index we were looking at, we need to close the panel. diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/api.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/api.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/api.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/api.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/api.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/api.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/auto_follow_pattern.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/auto_follow_pattern.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/follower_index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/follower_index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/stats.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/stats.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/stats.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/stats.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/selectors/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/selectors/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/selectors/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/selectors/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/store.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/store.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/store.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/store.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/extend_index_management/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/extend_index_management.ts similarity index 67% rename from x-pack/legacy/plugins/cross_cluster_replication/public/extend_index_management/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/extend_index_management.ts index c44918c500849..01c6250383fb8 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/extend_index_management/index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/extend_index_management.ts @@ -3,14 +3,15 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { i18n } from '@kbn/i18n'; -import { npSetup } from 'ui/new_platform'; import { get } from 'lodash'; +import { IndexMgmtSetup } from '../../../../../plugins/index_management/public'; const propertyPath = 'isFollowerIndex'; const followerBadgeExtension = { - matchIndex: index => { + matchIndex: (index: any) => { return get(index, propertyPath); }, label: i18n.translate('xpack.crossClusterReplication.indexMgmtBadge.followerLabel', { @@ -20,6 +21,8 @@ const followerBadgeExtension = { filterExpression: 'isFollowerIndex:true', }; -if (npSetup.plugins.indexManagement) { - npSetup.plugins.indexManagement.extensionsService.addBadge(followerBadgeExtension); -} +export const extendIndexManagement = (indexManagement?: IndexMgmtSetup) => { + if (indexManagement) { + indexManagement.extensionsService.addBadge(followerBadgeExtension); + } +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/index.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/index.ts new file mode 100644 index 0000000000000..11aea6b7b5de4 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; + +import { CrossClusterReplicationUIPlugin } from './plugin'; + +export const plugin = (ctx: PluginInitializerContext) => new CrossClusterReplicationUIPlugin(ctx); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/plugin.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/plugin.ts new file mode 100644 index 0000000000000..f7651cbb210a7 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/plugin.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + ChromeBreadcrumb, + CoreSetup, + Plugin, + PluginInitializerContext, + DocLinksStart, +} from 'src/core/public'; + +import { IndexMgmtSetup } from '../../../../../plugins/index_management/public'; + +// @ts-ignore; +import { setHttpClient } from './app/services/api'; +import { setBreadcrumbSetter } from './app/services/breadcrumbs'; +import { setDocLinks } from './app/services/documentation_links'; +import { setNotifications } from './app/services/notifications'; +import { extendIndexManagement } from './extend_index_management'; + +interface PluginDependencies { + indexManagement: IndexMgmtSetup; + __LEGACY: { + chrome: any; + MANAGEMENT_BREADCRUMB: ChromeBreadcrumb; + docLinks: DocLinksStart; + }; +} + +export class CrossClusterReplicationUIPlugin implements Plugin { + // @ts-ignore + constructor(private readonly ctx: PluginInitializerContext) {} + setup({ http, notifications, fatalErrors }: CoreSetup, deps: PluginDependencies) { + setHttpClient(http); + setBreadcrumbSetter(deps); + setDocLinks(deps.__LEGACY.docLinks); + setNotifications(notifications, fatalErrors); + extendIndexManagement(deps.indexManagement); + } + + start() {} +} diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/register_routes.js b/x-pack/legacy/plugins/cross_cluster_replication/public/register_routes.js index 7b9ba07f46c18..838939f46e523 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/register_routes.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/register_routes.js @@ -6,15 +6,21 @@ import { unmountComponentAtNode } from 'react-dom'; import chrome from 'ui/chrome'; -import { management } from 'ui/management'; +import { management, MANAGEMENT_BREADCRUMB } from 'ui/management'; +import { npSetup, npStart } from 'ui/new_platform'; import routes from 'ui/routes'; import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; import { i18n } from '@kbn/i18n'; import template from './main.html'; import { BASE_PATH } from '../common/constants'; -import { renderReact } from './app'; -import { setHttpClient } from './app/services/api'; + +import { plugin } from './np_ready'; + +/** + * TODO: When this file is deleted, use the management section for rendering + */ +import { renderReact } from './np_ready/app'; const isAvailable = xpackInfo.get('features.crossClusterReplication.isAvailable'); const isActive = xpackInfo.get('features.crossClusterReplication.isActive'); @@ -37,26 +43,31 @@ if (isLicenseOK && isCcrUiEnabled) { const CCR_REACT_ROOT = 'ccrReactRoot'; + plugin({}).setup(npSetup.core, { + ...npSetup.plugins, + __LEGACY: { + chrome, + docLinks: npStart.core.docLinks, + MANAGEMENT_BREADCRUMB, + }, + }); + const unmountReactApp = () => elem && unmountComponentAtNode(elem); routes.when(`${BASE_PATH}/:section?/:subsection?/:view?/:id?`, { template, controllerAs: 'ccr', controller: class CrossClusterReplicationController { - constructor($scope, $route, $http, $q) { + constructor($scope, $route) { // React-router's does not play well with the angular router. It will cause this controller // to re-execute without the $destroy handler being called. This means that the app will be mounted twice // creating a memory leak when leaving (only 1 app will be unmounted). // To avoid this, we unmount the React app each time we enter the controller. unmountReactApp(); - // NOTE: We depend upon Angular's $http service because it's decorated with interceptors, - // e.g. to check license status per request. - setHttpClient($http, $q); - $scope.$$postDigest(() => { elem = document.getElementById(CCR_REACT_ROOT); - renderReact(elem); + renderReact(elem, npStart.core.i18n.Context); // Angular Lifecycle const appRoute = $route.current; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_custom_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_custom_error.js deleted file mode 100644 index f9c102be7a1ff..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_custom_error.js +++ /dev/null @@ -1,21 +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 expect from '@kbn/expect'; -import { wrapCustomError } from '../wrap_custom_error'; - -describe('wrap_custom_error', () => { - describe('#wrapCustomError', () => { - it('should return a Boom object', () => { - const originalError = new Error('I am an error'); - const statusCode = 404; - const wrappedError = wrapCustomError(originalError, statusCode); - - expect(wrappedError.isBoom).to.be(true); - expect(wrappedError.output.statusCode).to.equal(statusCode); - }); - }); -}); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_unknown_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_unknown_error.js deleted file mode 100644 index 85e0b2b3033ad..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_unknown_error.js +++ /dev/null @@ -1,19 +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 expect from '@kbn/expect'; -import { wrapUnknownError } from '../wrap_unknown_error'; - -describe('wrap_unknown_error', () => { - describe('#wrapUnknownError', () => { - it('should return a Boom object', () => { - const originalError = new Error('I am an error'); - const wrappedError = wrapUnknownError(originalError); - - expect(wrappedError.isBoom).to.be(true); - }); - }); -}); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_custom_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_custom_error.js deleted file mode 100644 index 3295113d38ee5..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_custom_error.js +++ /dev/null @@ -1,18 +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 Boom from 'boom'; - -/** - * Wraps a custom error into a Boom error response and returns it - * - * @param err Object error - * @param statusCode Error status code - * @return Object Boom error response - */ -export function wrapCustomError(err, statusCode) { - return Boom.boomify(err, { statusCode }); -} diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_unknown_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_unknown_error.js deleted file mode 100644 index ffd915c513362..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_unknown_error.js +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; - -/** - * Wraps an unknown error into a Boom error response and returns it - * - * @param err Object Unknown error - * @return Object Boom error response - */ -export function wrapUnknownError(err) { - return Boom.boomify(err); -} diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js deleted file mode 100644 index a73aa96209c26..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js +++ /dev/null @@ -1,66 +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 expect from '@kbn/expect'; -import { licensePreRoutingFactory } from '../license_pre_routing_factory'; - -describe('license_pre_routing_factory', () => { - describe('#reportingFeaturePreRoutingFactory', () => { - let mockServer; - let mockLicenseCheckResults; - - beforeEach(() => { - mockServer = { - plugins: { - xpack_main: { - info: { - feature: () => ({ - getLicenseCheckResults: () => mockLicenseCheckResults, - }), - }, - }, - }, - }; - }); - - it('only instantiates one instance per server', () => { - const firstInstance = licensePreRoutingFactory(mockServer); - const secondInstance = licensePreRoutingFactory(mockServer); - - expect(firstInstance).to.be(secondInstance); - }); - - describe('isAvailable is false', () => { - beforeEach(() => { - mockLicenseCheckResults = { - isAvailable: false, - }; - }); - - it('replies with 403', () => { - const licensePreRouting = licensePreRoutingFactory(mockServer); - const response = licensePreRouting(); - expect(response).to.be.an(Error); - expect(response.isBoom).to.be(true); - expect(response.output.statusCode).to.be(403); - }); - }); - - describe('isAvailable is true', () => { - beforeEach(() => { - mockLicenseCheckResults = { - isAvailable: true, - }; - }); - - it('replies with nothing', () => { - const licensePreRouting = licensePreRoutingFactory(mockServer); - const response = licensePreRouting(); - expect(response).to.be(null); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/license_pre_routing_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/license_pre_routing_factory.js deleted file mode 100644 index 548ad7ca02104..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/license_pre_routing_factory.js +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { once } from 'lodash'; -import { wrapCustomError } from '../error_wrappers'; -import { PLUGIN } from '../../../common/constants'; - -export const licensePreRoutingFactory = once(server => { - const xpackMainPlugin = server.plugins.xpack_main; - - // License checking and enable/disable logic - function licensePreRouting() { - const licenseCheckResults = xpackMainPlugin.info.feature(PLUGIN.ID).getLicenseCheckResults(); - if (!licenseCheckResults.isAvailable) { - const error = new Error(licenseCheckResults.message); - const statusCode = 403; - const wrappedError = wrapCustomError(error, statusCode); - return wrappedError; - } else { - return null; - } - } - - return licensePreRouting; -}); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/client/elasticsearch_ccr.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/client/elasticsearch_ccr.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/cross_cluster_replication_data.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/cross_cluster_replication_data.ts similarity index 59% rename from x-pack/legacy/plugins/cross_cluster_replication/cross_cluster_replication_data.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/cross_cluster_replication_data.ts index 2944c3e6bc2ec..ae15073b979e1 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/cross_cluster_replication_data.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/cross_cluster_replication_data.ts @@ -3,9 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { APICaller } from 'src/core/server'; +import { Index } from '../../../../../plugins/index_management/server'; -export const ccrDataEnricher = async (indicesList, callWithRequest) => { - if (!indicesList || !indicesList.length) { +export const ccrDataEnricher = async (indicesList: Index[], callWithRequest: APICaller) => { + if (!indicesList?.length) { return indicesList; } const params = { @@ -18,9 +20,11 @@ export const ccrDataEnricher = async (indicesList, callWithRequest) => { params ); return indicesList.map(index => { - const isFollowerIndex = !!followerIndices.find(followerIndex => { - return followerIndex.follower_index === index.name; - }); + const isFollowerIndex = !!followerIndices.find( + (followerIndex: { follower_index: string }) => { + return followerIndex.follower_index === index.name; + } + ); return { ...index, isFollowerIndex, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/index.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/index.ts new file mode 100644 index 0000000000000..7a38d024d99a2 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/server'; +import { CrossClusterReplicationServerPlugin } from './plugin'; + +export const plugin = (ctx: PluginInitializerContext) => + new CrossClusterReplicationServerPlugin(ctx); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/__snapshots__/ccr_stats_serialization.test.js.snap b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/__snapshots__/ccr_stats_serialization.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/__snapshots__/ccr_stats_serialization.test.js.snap rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/__snapshots__/ccr_stats_serialization.test.js.snap diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/call_with_request_factory/call_with_request_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/call_with_request_factory/call_with_request_factory.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/call_with_request_factory/call_with_request_factory.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/call_with_request_factory/call_with_request_factory.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/call_with_request_factory/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/call_with_request_factory/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/call_with_request_factory/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/call_with_request_factory/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/ccr_stats_serialization.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/ccr_stats_serialization.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/ccr_stats_serialization.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/ccr_stats_serialization.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/ccr_stats_serialization.test.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/ccr_stats_serialization.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/ccr_stats_serialization.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/ccr_stats_serialization.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/check_license/check_license.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/check_license/check_license.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/check_license/check_license.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/check_license/check_license.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/check_license/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/check_license/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/check_license/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/check_license/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_es_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/__tests__/wrap_es_error.test.js similarity index 55% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_es_error.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/__tests__/wrap_es_error.test.js index 8241dc4329137..11a6fd4e1d816 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_es_error.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/__tests__/wrap_es_error.test.js @@ -16,24 +16,18 @@ describe('wrap_es_error', () => { originalError.response = '{}'; }); - it('should return a Boom object', () => { + it('should return the correct object', () => { const wrappedError = wrapEsError(originalError); - expect(wrappedError.isBoom).to.be(true); + expect(wrappedError.statusCode).to.be(originalError.statusCode); + expect(wrappedError.message).to.be(originalError.message); }); - it('should return the correct Boom object', () => { - const wrappedError = wrapEsError(originalError); - - expect(wrappedError.output.statusCode).to.be(originalError.statusCode); - expect(wrappedError.output.payload.message).to.be(originalError.message); - }); - - it('should return the correct Boom object with custom message', () => { + it('should return the correct object with custom message', () => { const wrappedError = wrapEsError(originalError, { 404: 'No encontrado!' }); - expect(wrappedError.output.statusCode).to.be(originalError.statusCode); - expect(wrappedError.output.payload.message).to.be('No encontrado!'); + expect(wrappedError.statusCode).to.be(originalError.statusCode); + expect(wrappedError.message).to.be('No encontrado!'); }); }); }); diff --git a/x-pack/plugins/infra/server/graphql/log_entries/index.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/index.ts similarity index 81% rename from x-pack/plugins/infra/server/graphql/log_entries/index.ts rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/index.ts index 21134862663ec..3756b0c74fb10 100644 --- a/x-pack/plugins/infra/server/graphql/log_entries/index.ts +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { createLogEntriesResolvers } from './resolvers'; +export { wrapEsError } from './wrap_es_error'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_es_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/wrap_es_error.ts similarity index 66% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_es_error.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/wrap_es_error.ts index 5f4884a3f2d26..8afd5f1a018eb 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_es_error.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/wrap_es_error.ts @@ -4,16 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; - -function extractCausedByChain(causedBy = {}, accumulator = []) { - const { reason, caused_by } = causedBy; // eslint-disable-line camelcase +function extractCausedByChain( + causedBy: Record = {}, + accumulator: string[] = [] +): string[] { + const { reason, caused_by } = causedBy; // eslint-disable-line @typescript-eslint/camelcase if (reason) { accumulator.push(reason); } - // eslint-disable-next-line camelcase + // eslint-disable-next-line @typescript-eslint/camelcase if (caused_by) { return extractCausedByChain(caused_by, accumulator); } @@ -26,34 +27,39 @@ function extractCausedByChain(causedBy = {}, accumulator = []) { * * @param err Object Error thrown by ES JS client * @param statusCodeToMessageMap Object Optional map of HTTP status codes => error messages - * @return Object Boom error response */ -export function wrapEsError(err, statusCodeToMessageMap = {}) { +export function wrapEsError( + err: any, + statusCodeToMessageMap: Record = {} +): { message: string; body?: { cause?: string[] }; statusCode: number } { const { statusCode, response } = err; const { error: { - root_cause = [], // eslint-disable-line camelcase - caused_by, // eslint-disable-line camelcase + root_cause = [], // eslint-disable-line @typescript-eslint/camelcase + caused_by = undefined, // eslint-disable-line @typescript-eslint/camelcase } = {}, } = JSON.parse(response); // If no custom message if specified for the error's status code, just // wrap the error as a Boom error response and return it if (!statusCodeToMessageMap[statusCode]) { - const boomError = Boom.boomify(err, { statusCode }); - // The caused_by chain has the most information so use that if it's available. If not then // settle for the root_cause. const causedByChain = extractCausedByChain(caused_by); const defaultCause = root_cause.length ? extractCausedByChain(root_cause[0]) : undefined; - boomError.output.payload.cause = causedByChain.length ? causedByChain : defaultCause; - return boomError; + return { + message: err.message, + statusCode, + body: { + cause: causedByChain.length ? causedByChain : defaultCause, + }, + }; } // Otherwise, use the custom message to create a Boom error response and // return it const message = statusCodeToMessageMap[statusCode]; - return new Boom(message, { statusCode }); + return { message, statusCode }; } diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/types.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error.ts similarity index 51% rename from x-pack/plugins/infra/public/components/logging/log_minimap/types.ts rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error.ts index d8197935dafa7..4137293cf39c0 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/types.ts +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error.ts @@ -4,14 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TimeKey } from '../../../../common/time'; +import * as legacyElasticsearch from 'elasticsearch'; -export interface SummaryBucket { - start: number; - end: number; - entriesCount: number; -} +const esErrorsParent = legacyElasticsearch.errors._Abstract; -export interface SummaryHighlightBucket extends SummaryBucket { - representativeKey: TimeKey; +export function isEsError(err: Error) { + return err instanceof esErrorsParent; } diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/__tests__/is_es_error_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/__tests__/is_es_error_factory.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/__tests__/is_es_error_factory.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/__tests__/is_es_error_factory.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/index.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/index.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/is_es_error_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/is_es_error_factory.ts similarity index 76% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/is_es_error_factory.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/is_es_error_factory.ts index 6c17554385ef8..fc6405b8e7513 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/is_es_error_factory.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/is_es_error_factory.ts @@ -6,13 +6,13 @@ import { memoize } from 'lodash'; -const esErrorsFactory = memoize(server => { +const esErrorsFactory = memoize((server: any) => { return server.plugins.elasticsearch.getCluster('admin').errors; }); -export function isEsErrorFactory(server) { +export function isEsErrorFactory(server: any) { const esErrors = esErrorsFactory(server); - return function isEsError(err) { + return function isEsError(err: any) { return err instanceof esErrors._Abstract; }; } diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/__jest__/license_pre_routing_factory.test.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/__jest__/license_pre_routing_factory.test.ts new file mode 100644 index 0000000000000..d22505f0e315a --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/__jest__/license_pre_routing_factory.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { kibanaResponseFactory } from '../../../../../../../../../src/core/server'; +import { licensePreRoutingFactory } from '../license_pre_routing_factory'; + +describe('license_pre_routing_factory', () => { + describe('#reportingFeaturePreRoutingFactory', () => { + let mockDeps: any; + let mockLicenseCheckResults: any; + + const anyContext: any = {}; + const anyRequest: any = {}; + + beforeEach(() => { + mockDeps = { + __LEGACY: { + server: { + plugins: { + xpack_main: { + info: { + feature: () => ({ + getLicenseCheckResults: () => mockLicenseCheckResults, + }), + }, + }, + }, + }, + }, + requestHandler: jest.fn(), + }; + }); + + describe('isAvailable is false', () => { + beforeEach(() => { + mockLicenseCheckResults = { + isAvailable: false, + }; + }); + + it('replies with 403', async () => { + const licensePreRouting = licensePreRoutingFactory(mockDeps); + const response = await licensePreRouting(anyContext, anyRequest, kibanaResponseFactory); + expect(response.status).toBe(403); + }); + }); + + describe('isAvailable is true', () => { + beforeEach(() => { + mockLicenseCheckResults = { + isAvailable: true, + }; + }); + + it('it calls the wrapped handler', async () => { + const licensePreRouting = licensePreRoutingFactory(mockDeps); + await licensePreRouting(anyContext, anyRequest, kibanaResponseFactory); + expect(mockDeps.requestHandler).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/index.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/index.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/license_pre_routing_factory.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/license_pre_routing_factory.ts new file mode 100644 index 0000000000000..c47faa940a650 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/license_pre_routing_factory.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandler } from 'src/core/server'; +import { PLUGIN } from '../../../../common/constants'; + +export const licensePreRoutingFactory = ({ + __LEGACY, + requestHandler, +}: { + __LEGACY: { server: any }; + requestHandler: RequestHandler; +}) => { + const xpackMainPlugin = __LEGACY.server.plugins.xpack_main; + + // License checking and enable/disable logic + const licensePreRouting: RequestHandler = (ctx, request, response) => { + const licenseCheckResults = xpackMainPlugin.info.feature(PLUGIN.ID).getLicenseCheckResults(); + if (!licenseCheckResults.isAvailable) { + return response.forbidden({ + body: licenseCheckResults.message, + }); + } else { + return requestHandler(ctx, request, response); + } + }; + + return licensePreRouting; +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/register_license_checker.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/register_license_checker.js similarity index 66% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/register_license_checker.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/register_license_checker.js index dbd99efd95573..b9bb34a80ce79 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/register_license_checker.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/register_license_checker.js @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mirrorPluginStatus } from '../../../../../server/lib/mirror_plugin_status'; -import { PLUGIN } from '../../../common/constants'; +import { mirrorPluginStatus } from '../../../../../../server/lib/mirror_plugin_status'; +import { PLUGIN } from '../../../../common/constants'; import { checkLicense } from '../check_license'; -export function registerLicenseChecker(server) { - const xpackMainPlugin = server.plugins.xpack_main; - const ccrPluggin = server.plugins[PLUGIN.ID]; +export function registerLicenseChecker(__LEGACY) { + const xpackMainPlugin = __LEGACY.server.plugins.xpack_main; + const ccrPluggin = __LEGACY.server.plugins[PLUGIN.ID]; mirrorPluginStatus(xpackMainPlugin, ccrPluggin); xpackMainPlugin.status.once('green', () => { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.ts new file mode 100644 index 0000000000000..1012c07af3d2a --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Plugin, PluginInitializerContext, CoreSetup } from 'src/core/server'; + +import { IndexMgmtSetup } from '../../../../../plugins/index_management/server'; + +// @ts-ignore +import { registerLicenseChecker } from './lib/register_license_checker'; +// @ts-ignore +import { registerRoutes } from './routes/register_routes'; +import { ccrDataEnricher } from './cross_cluster_replication_data'; + +interface PluginDependencies { + indexManagement: IndexMgmtSetup; + __LEGACY: { + server: any; + ccrUIEnabled: boolean; + }; +} + +export class CrossClusterReplicationServerPlugin implements Plugin { + // @ts-ignore + constructor(private readonly ctx: PluginInitializerContext) {} + setup({ http }: CoreSetup, { indexManagement, __LEGACY }: PluginDependencies) { + registerLicenseChecker(__LEGACY); + + const router = http.createRouter(); + registerRoutes({ router, __LEGACY }); + if (__LEGACY.ccrUIEnabled && indexManagement && indexManagement.indexDataEnricher) { + indexManagement.indexDataEnricher.add(ccrDataEnricher); + } + } + start() {} +} diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.test.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/auto_follow_pattern.test.js similarity index 68% rename from x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/auto_follow_pattern.test.js index c610039cfd2ac..f3024515c7213 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/auto_follow_pattern.test.js @@ -3,23 +3,23 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { deserializeAutoFollowPattern } from '../../../../../common/services/auto_follow_pattern_serialization'; +import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { getAutoFollowPatternMock, getAutoFollowPatternListMock } from '../../../../../fixtures'; +import { registerAutoFollowPatternRoutes } from '../auto_follow_pattern'; -import { deserializeAutoFollowPattern } from '../../../common/services/auto_follow_pattern_serialization'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; -import { isEsErrorFactory } from '../../lib/is_es_error_factory'; -import { getAutoFollowPatternMock, getAutoFollowPatternListMock } from '../../../fixtures'; -import { registerAutoFollowPatternRoutes } from './auto_follow_pattern'; +import { createRouter, callRoute } from './helpers'; -jest.mock('../../lib/call_with_request_factory'); -jest.mock('../../lib/is_es_error_factory'); -jest.mock('../../lib/license_pre_routing_factory'); +jest.mock('../../../lib/call_with_request_factory'); +jest.mock('../../../lib/is_es_error_factory'); +jest.mock('../../../lib/license_pre_routing_factory', () => ({ + licensePreRoutingFactory: ({ requestHandler }) => requestHandler, +})); const DESERIALIZED_KEYS = Object.keys(deserializeAutoFollowPattern(getAutoFollowPatternMock())); -/** - * Hashtable to save the route handlers - */ -const routeHandlers = {}; +let routeRegistry; /** * Helper to extract all the different server route handler so we can easily call them in our tests. @@ -28,8 +28,6 @@ const routeHandlers = {}; * if a "server.route()" call is moved or deleted, then the HANDLER_INDEX_TO_ACTION must be updated here. */ const registerHandlers = () => { - let index = 0; - const HANDLER_INDEX_TO_ACTION = { 0: 'list', 1: 'create', @@ -40,15 +38,12 @@ const registerHandlers = () => { 6: 'resume', }; - const server = { - route({ handler }) { - // Save handler and increment index - routeHandlers[HANDLER_INDEX_TO_ACTION[index]] = handler; - index++; - }, - }; + routeRegistry = createRouter(HANDLER_INDEX_TO_ACTION); - registerAutoFollowPatternRoutes(server); + registerAutoFollowPatternRoutes({ + __LEGACY: {}, + router: routeRegistry.router, + }); }; /** @@ -94,14 +89,16 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('list()', () => { beforeEach(() => { - routeHandler = routeHandlers.list; + routeHandler = routeRegistry.getRoutes().list; }); it('should deserialize the response from Elasticsearch', async () => { const totalResult = 2; setHttpRequestResponse(null, getAutoFollowPatternListMock(totalResult)); - const response = await routeHandler(); + const { + options: { body: response }, + } = await callRoute(routeHandler); const autoFollowPattern = response.patterns[0]; expect(response.patterns.length).toEqual(totalResult); @@ -112,21 +109,25 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('create()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.create; + routeHandler = routeRegistry.getRoutes().create; }); it('should throw a 409 conflict error if id already exists', async () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ - payload: { - id: 'some-id', - foo: 'bar', - }, - }).catch(err => err); // return the error - - expect(response.output.statusCode).toEqual(409); + const response = await callRoute( + routeHandler, + {}, + { + body: { + id: 'some-id', + foo: 'bar', + }, + } + ); + + expect(response.status).toEqual(409); }); it('should return 200 status when the id does not exist', async () => { @@ -135,12 +136,18 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(error); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ - payload: { - id: 'some-id', - foo: 'bar', - }, - }); + const { + options: { body: response }, + } = await callRoute( + routeHandler, + {}, + { + body: { + id: 'some-id', + foo: 'bar', + }, + } + ); expect(response).toEqual({ acknowledge: true }); }); @@ -148,7 +155,7 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('update()', () => { beforeEach(() => { - routeHandler = routeHandlers.update; + routeHandler = routeRegistry.getRoutes().update; }); it('should serialize the payload before sending it to Elasticsearch', async () => { @@ -156,16 +163,16 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { const request = { params: { id: 'foo' }, - payload: { + body: { remoteCluster: 'bar1', leaderIndexPatterns: ['bar2'], followIndexPattern: 'bar3', }, }; - const response = await routeHandler(request); + const response = await callRoute(routeHandler, {}, request); - expect(response).toEqual({ + expect(response.options.body).toEqual({ id: 'foo', body: { remote_cluster: 'bar1', @@ -178,7 +185,7 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('get()', () => { beforeEach(() => { - routeHandler = routeHandlers.get; + routeHandler = routeRegistry.getRoutes().get; }); it('should return a single resource even though ES return an array with 1 item', async () => { @@ -187,21 +194,23 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, esResponse); - const response = await routeHandler({ params: { id: 1 } }); - expect(Object.keys(response)).toEqual(DESERIALIZED_KEYS); + const response = await callRoute(routeHandler, {}, { params: { id: 1 } }); + expect(Object.keys(response.options.body)).toEqual(DESERIALIZED_KEYS); }); }); describe('delete()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.delete; + routeHandler = routeRegistry.getRoutes().delete; }); it('should delete a single item', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a' } }); expect(response.itemsDeleted).toEqual(['a']); expect(response.errors).toEqual([]); @@ -212,9 +221,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a,b,c' } }); + const response = await callRoute(routeHandler, {}, { params: { id: 'a,b,c' } }); - expect(response.itemsDeleted).toEqual(['a', 'b', 'c']); + expect(response.options.body.itemsDeleted).toEqual(['a', 'b', 'c']); }); it('should catch error and return them in array', async () => { @@ -224,7 +233,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: 'a,b' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a,b' } }); expect(response.itemsDeleted).toEqual(['a']); expect(response.errors[0].id).toEqual('b'); @@ -234,13 +245,15 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('pause()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.pause; + routeHandler = routeRegistry.getRoutes().pause; }); it('accept a single item', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a' } }); expect(response.itemsPaused).toEqual(['a']); expect(response.errors).toEqual([]); @@ -251,9 +264,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a,b,c' } }); + const response = await callRoute(routeHandler, {}, { params: { id: 'a,b,c' } }); - expect(response.itemsPaused).toEqual(['a', 'b', 'c']); + expect(response.options.body.itemsPaused).toEqual(['a', 'b', 'c']); }); it('should catch error and return them in array', async () => { @@ -263,7 +276,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: 'a,b' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a,b' } }); expect(response.itemsPaused).toEqual(['a']); expect(response.errors[0].id).toEqual('b'); @@ -273,13 +288,15 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('resume()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.resume; + routeHandler = routeRegistry.getRoutes().resume; }); it('accept a single item', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a' } }); expect(response.itemsResumed).toEqual(['a']); expect(response.errors).toEqual([]); @@ -290,9 +307,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a,b,c' } }); + const response = await callRoute(routeHandler, {}, { params: { id: 'a,b,c' } }); - expect(response.itemsResumed).toEqual(['a', 'b', 'c']); + expect(response.options.body.itemsResumed).toEqual(['a', 'b', 'c']); }); it('should catch error and return them in array', async () => { @@ -302,7 +319,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: 'a,b' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a,b' } }); expect(response.itemsResumed).toEqual(['a']); expect(response.errors[0].id).toEqual('b'); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/follower_index.test.js similarity index 72% rename from x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/follower_index.test.js index 7e363c2758a4c..f0139e5bd7011 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/follower_index.test.js @@ -3,21 +3,23 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { deserializeFollowerIndex } from '../../../common/services/follower_index_serialization'; +import { deserializeFollowerIndex } from '../../../../../common/services/follower_index_serialization'; import { getFollowerIndexStatsMock, getFollowerIndexListStatsMock, getFollowerIndexInfoMock, getFollowerIndexListInfoMock, -} from '../../../fixtures'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; -import { isEsErrorFactory } from '../../lib/is_es_error_factory'; -import { registerFollowerIndexRoutes } from './follower_index'; - -jest.mock('../../lib/call_with_request_factory'); -jest.mock('../../lib/is_es_error_factory'); -jest.mock('../../lib/license_pre_routing_factory'); +} from '../../../../../fixtures'; +import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { registerFollowerIndexRoutes } from '../follower_index'; +import { createRouter, callRoute } from './helpers'; + +jest.mock('../../../lib/call_with_request_factory'); +jest.mock('../../../lib/is_es_error_factory'); +jest.mock('../../../lib/license_pre_routing_factory', () => ({ + licensePreRoutingFactory: ({ requestHandler }) => requestHandler, +})); const DESERIALIZED_KEYS = Object.keys( deserializeFollowerIndex({ @@ -26,10 +28,7 @@ const DESERIALIZED_KEYS = Object.keys( }) ); -/** - * Hashtable to save the route handlers - */ -const routeHandlers = {}; +let routeRegistry; /** * Helper to extract all the different server route handler so we can easily call them in our tests. @@ -38,8 +37,6 @@ const routeHandlers = {}; * if a 'server.route()' call is moved or deleted, then the HANDLER_INDEX_TO_ACTION must be updated here. */ const registerHandlers = () => { - let index = 0; - const HANDLER_INDEX_TO_ACTION = { 0: 'list', 1: 'get', @@ -50,15 +47,11 @@ const registerHandlers = () => { 6: 'unfollow', }; - const server = { - route({ handler }) { - // Save handler and increment index - routeHandlers[HANDLER_INDEX_TO_ACTION[index]] = handler; - index++; - }, - }; - - registerFollowerIndexRoutes(server); + routeRegistry = createRouter(HANDLER_INDEX_TO_ACTION); + registerFollowerIndexRoutes({ + __LEGACY: {}, + router: routeRegistry.router, + }); }; /** @@ -104,7 +97,7 @@ describe('[CCR API Routes] Follower Index', () => { describe('list()', () => { beforeEach(() => { - routeHandler = routeHandlers.list; + routeHandler = routeRegistry.getRoutes().list; }); it('deserializes the response from Elasticsearch', async () => { @@ -117,7 +110,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, infoResult); setHttpRequestResponse(null, statsResult); - const response = await routeHandler(); + const { + options: { body: response }, + } = await callRoute(routeHandler); const followerIndex = response.indices[0]; expect(response.indices.length).toEqual(totalResult); @@ -127,7 +122,7 @@ describe('[CCR API Routes] Follower Index', () => { describe('get()', () => { beforeEach(() => { - routeHandler = routeHandlers.get; + routeHandler = routeRegistry.getRoutes().get; }); it('should return a single resource even though ES return an array with 1 item', async () => { @@ -138,7 +133,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { follower_indices: [followerIndexInfo] }); setHttpRequestResponse(null, { indices: [followerIndexStats] }); - const response = await routeHandler({ params: { id: mockId } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: mockId } }); expect(Object.keys(response)).toEqual(DESERIALIZED_KEYS); }); }); @@ -146,34 +143,40 @@ describe('[CCR API Routes] Follower Index', () => { describe('create()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.create; + routeHandler = routeRegistry.getRoutes().create; }); it('should return 200 status when follower index is created', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ - payload: { - name: 'follower_index', - remoteCluster: 'remote_cluster', - leaderIndex: 'leader_index', - }, - }); + const response = await callRoute( + routeHandler, + {}, + { + body: { + name: 'follower_index', + remoteCluster: 'remote_cluster', + leaderIndex: 'leader_index', + }, + } + ); - expect(response).toEqual({ acknowledge: true }); + expect(response.options.body).toEqual({ acknowledge: true }); }); }); describe('pause()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.pause; + routeHandler = routeRegistry.getRoutes().pause; }); it('should pause a single item', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1' } }); expect(response.itemsPaused).toEqual(['1']); expect(response.errors).toEqual([]); @@ -184,9 +187,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1,2,3' } }); + const response = await callRoute(routeHandler, {}, { params: { id: '1,2,3' } }); - expect(response.itemsPaused).toEqual(['1', '2', '3']); + expect(response.options.body.itemsPaused).toEqual(['1', '2', '3']); }); it('should catch error and return them in array', async () => { @@ -196,7 +199,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: '1,2' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1,2' } }); expect(response.itemsPaused).toEqual(['1']); expect(response.errors[0].id).toEqual('2'); @@ -206,13 +211,15 @@ describe('[CCR API Routes] Follower Index', () => { describe('resume()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.resume; + routeHandler = routeRegistry.getRoutes().resume; }); it('should resume a single item', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1' } }); expect(response.itemsResumed).toEqual(['1']); expect(response.errors).toEqual([]); @@ -223,9 +230,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1,2,3' } }); + const response = await callRoute(routeHandler, {}, { params: { id: '1,2,3' } }); - expect(response.itemsResumed).toEqual(['1', '2', '3']); + expect(response.options.body.itemsResumed).toEqual(['1', '2', '3']); }); it('should catch error and return them in array', async () => { @@ -235,7 +242,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: '1,2' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1,2' } }); expect(response.itemsResumed).toEqual(['1']); expect(response.errors[0].id).toEqual('2'); @@ -245,7 +254,7 @@ describe('[CCR API Routes] Follower Index', () => { describe('unfollow()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.unfollow; + routeHandler = routeRegistry.getRoutes().unfollow; }); it('should unfollow await single item', async () => { @@ -254,7 +263,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1' } }); expect(response.itemsUnfollowed).toEqual(['1']); expect(response.errors).toEqual([]); @@ -274,9 +285,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1,2,3' } }); + const response = await callRoute(routeHandler, {}, { params: { id: '1,2,3' } }); - expect(response.itemsUnfollowed).toEqual(['1', '2', '3']); + expect(response.options.body.itemsUnfollowed).toEqual(['1', '2', '3']); }); it('should catch error and return them in array', async () => { @@ -290,7 +301,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: '1,2' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1,2' } }); expect(response.itemsUnfollowed).toEqual(['1']); expect(response.errors[0].id).toEqual('2'); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/helpers.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/helpers.ts new file mode 100644 index 0000000000000..555fc0937c0ad --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/helpers.ts @@ -0,0 +1,37 @@ +/* + * 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 { RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory } from '../../../../../../../../../src/core/server'; + +export const callRoute = ( + route: RequestHandler, + ctx = {}, + request = {}, + response = kibanaResponseFactory +) => { + return route(ctx as any, request as any, response); +}; + +export const createRouter = (indexToActionMap: Record) => { + let index = 0; + const routeHandlers: Record> = {}; + const addHandler = (ignoreCtxForNow: any, handler: RequestHandler) => { + // Save handler and increment index + routeHandlers[indexToActionMap[index]] = handler; + index++; + }; + + return { + getRoutes: () => routeHandlers, + router: { + get: addHandler, + post: addHandler, + put: addHandler, + delete: addHandler, + }, + }; +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/auto_follow_pattern.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/auto_follow_pattern.ts new file mode 100644 index 0000000000000..d458f1ccb354b --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/auto_follow_pattern.ts @@ -0,0 +1,301 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +// @ts-ignore +import { callWithRequestFactory } from '../../lib/call_with_request_factory'; +import { isEsError } from '../../lib/is_es_error'; +// @ts-ignore +import { + deserializeAutoFollowPattern, + deserializeListAutoFollowPatterns, + serializeAutoFollowPattern, + // @ts-ignore +} from '../../../../common/services/auto_follow_pattern_serialization'; + +import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; +import { API_BASE_PATH } from '../../../../common/constants'; + +import { RouteDependencies } from '../types'; +import { mapErrorToKibanaHttpResponse } from '../map_to_kibana_http_error'; + +export const registerAutoFollowPatternRoutes = ({ router, __LEGACY }: RouteDependencies) => { + /** + * Returns a list of all auto-follow patterns + */ + router.get( + { + path: `${API_BASE_PATH}/auto_follow_patterns`, + validate: false, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + + try { + const result = await callWithRequest('ccr.autoFollowPatterns'); + return response.ok({ + body: { + patterns: deserializeListAutoFollowPatterns(result.patterns), + }, + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Create an auto-follow pattern + */ + router.post( + { + path: `${API_BASE_PATH}/auto_follow_patterns`, + validate: { + body: schema.object( + { + id: schema.string(), + }, + { unknowns: 'allow' } + ), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id, ...rest } = request.body; + const body = serializeAutoFollowPattern(rest); + + /** + * First let's make sur that an auto-follow pattern with + * the same id does not exist. + */ + try { + await callWithRequest('ccr.autoFollowPattern', { id }); + // If we get here it means that an auto-follow pattern with the same id exists + return response.conflict({ + body: `An auto-follow pattern with the name "${id}" already exists.`, + }); + } catch (err) { + if (err.statusCode !== 404) { + return mapErrorToKibanaHttpResponse(err); + } + } + + try { + return response.ok({ + body: await callWithRequest('ccr.saveAutoFollowPattern', { id, body }), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Update an auto-follow pattern + */ + router.put( + { + path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const body = serializeAutoFollowPattern(request.body); + + try { + return response.ok({ + body: await callWithRequest('ccr.saveAutoFollowPattern', { id, body }), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Returns a single auto-follow pattern + */ + router.get( + { + path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + + try { + const result = await callWithRequest('ccr.autoFollowPattern', { id }); + const autoFollowPattern = result.patterns[0]; + + return response.ok({ + body: deserializeAutoFollowPattern(autoFollowPattern), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Delete an auto-follow pattern + */ + router.delete( + { + path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsDeleted: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(_id => + callWithRequest('ccr.deleteAutoFollowPattern', { id: _id }) + .then(() => itemsDeleted.push(_id)) + .catch((err: Error) => { + if (isEsError(err)) { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } else { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } + }) + ) + ); + + return response.ok({ + body: { + itemsDeleted, + errors, + }, + }); + }, + }) + ); + + /** + * Pause auto-follow pattern(s) + */ + router.post( + { + path: `${API_BASE_PATH}/auto_follow_patterns/{id}/pause`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsPaused: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(_id => + callWithRequest('ccr.pauseAutoFollowPattern', { id: _id }) + .then(() => itemsPaused.push(_id)) + .catch((err: Error) => { + if (isEsError(err)) { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } else { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } + }) + ) + ); + + return response.ok({ + body: { + itemsPaused, + errors, + }, + }); + }, + }) + ); + + /** + * Resume auto-follow pattern(s) + */ + router.post( + { + path: `${API_BASE_PATH}/auto_follow_patterns/{id}/resume`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsResumed: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(_id => + callWithRequest('ccr.resumeAutoFollowPattern', { id: _id }) + .then(() => itemsResumed.push(_id)) + .catch((err: Error) => { + if (isEsError(err)) { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } else { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } + }) + ) + ); + + return response.ok({ + body: { + itemsResumed, + errors, + }, + }); + }, + }) + ); +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/ccr.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/ccr.ts new file mode 100644 index 0000000000000..b08b056ad2c8a --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/ccr.ts @@ -0,0 +1,112 @@ +/* + * 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 { API_BASE_PATH } from '../../../../common/constants'; +// @ts-ignore +import { callWithRequestFactory } from '../../lib/call_with_request_factory'; +// @ts-ignore +import { deserializeAutoFollowStats } from '../../lib/ccr_stats_serialization'; +import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; + +import { mapErrorToKibanaHttpResponse } from '../map_to_kibana_http_error'; +import { RouteDependencies } from '../types'; + +export const registerCcrRoutes = ({ router, __LEGACY }: RouteDependencies) => { + /** + * Returns Auto-follow stats + */ + router.get( + { + path: `${API_BASE_PATH}/stats/auto_follow`, + validate: false, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + + try { + const { auto_follow_stats: autoFollowStats } = await callWithRequest('ccr.stats'); + + return response.ok({ + body: deserializeAutoFollowStats(autoFollowStats), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Returns whether the user has CCR permissions + */ + router.get( + { + path: `${API_BASE_PATH}/permissions`, + validate: false, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const xpackMainPlugin = __LEGACY.server.plugins.xpack_main; + const xpackInfo = xpackMainPlugin && xpackMainPlugin.info; + + if (!xpackInfo) { + // xpackInfo is updated via poll, so it may not be available until polling has begun. + // In this rare situation, tell the client the service is temporarily unavailable. + return response.customError({ + statusCode: 503, + body: 'Security info unavailable', + }); + } + + const securityInfo = xpackInfo && xpackInfo.isAvailable() && xpackInfo.feature('security'); + if (!securityInfo || !securityInfo.isAvailable() || !securityInfo.isEnabled()) { + // If security isn't enabled or available (in the case where security is enabled but license reverted to Basic) let the user use CCR. + return response.ok({ + body: { + hasPermission: true, + missingClusterPrivileges: [], + }, + }); + } + + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + + try { + const { has_all_requested: hasPermission, cluster } = await callWithRequest( + 'ccr.permissions', + { + body: { + cluster: ['manage', 'manage_ccr'], + }, + } + ); + + const missingClusterPrivileges = Object.keys(cluster).reduce( + (permissions: any, permissionName: any) => { + if (!cluster[permissionName]) { + permissions.push(permissionName); + return permissions; + } + }, + [] as any[] + ); + + return response.ok({ + body: { + hasPermission, + missingClusterPrivileges, + }, + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts new file mode 100644 index 0000000000000..3896e1c02c915 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts @@ -0,0 +1,345 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +import { + deserializeFollowerIndex, + deserializeListFollowerIndices, + serializeFollowerIndex, + serializeAdvancedSettings, + // @ts-ignore +} from '../../../../common/services/follower_index_serialization'; +import { API_BASE_PATH } from '../../../../common/constants'; +// @ts-ignore +import { removeEmptyFields } from '../../../../common/services/utils'; +// @ts-ignore +import { callWithRequestFactory } from '../../lib/call_with_request_factory'; +import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; + +import { RouteDependencies } from '../types'; +import { mapErrorToKibanaHttpResponse } from '../map_to_kibana_http_error'; + +export const registerFollowerIndexRoutes = ({ router, __LEGACY }: RouteDependencies) => { + /** + * Returns a list of all follower indices + */ + router.get( + { + path: `${API_BASE_PATH}/follower_indices`, + validate: false, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + + try { + const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { + id: '_all', + }); + + const { + follow_stats: { indices: followerIndicesStats }, + } = await callWithRequest('ccr.stats'); + + const followerIndicesStatsMap = followerIndicesStats.reduce((map: any, stats: any) => { + map[stats.index] = stats; + return map; + }, {}); + + const collatedFollowerIndices = followerIndices.map((followerIndex: any) => { + return { + ...followerIndex, + ...followerIndicesStatsMap[followerIndex.follower_index], + }; + }); + + return response.ok({ + body: { + indices: deserializeListFollowerIndices(collatedFollowerIndices), + }, + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Returns a single follower index pattern + */ + router.get( + { + path: `${API_BASE_PATH}/follower_indices/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + + try { + const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id }); + + const followerIndexInfo = followerIndices && followerIndices[0]; + + if (!followerIndexInfo) { + return response.notFound({ + body: `The follower index "${id}" does not exist.`, + }); + } + + // If this follower is paused, skip call to ES stats api since it will return 404 + if (followerIndexInfo.status === 'paused') { + return response.ok({ + body: deserializeFollowerIndex({ + ...followerIndexInfo, + }), + }); + } else { + const { + indices: followerIndicesStats, + } = await callWithRequest('ccr.followerIndexStats', { id }); + + return response.ok({ + body: deserializeFollowerIndex({ + ...followerIndexInfo, + ...(followerIndicesStats ? followerIndicesStats[0] : {}), + }), + }); + } + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Create a follower index + */ + router.post( + { + path: `${API_BASE_PATH}/follower_indices`, + validate: { + body: schema.object( + { + name: schema.string(), + }, + { unknowns: 'allow' } + ), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { name, ...rest } = request.body; + const body = removeEmptyFields(serializeFollowerIndex(rest)); + + try { + return response.ok({ + body: await callWithRequest('ccr.saveFollowerIndex', { name, body }), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Edit a follower index + */ + router.put( + { + path: `${API_BASE_PATH}/follower_indices/{id}`, + validate: { + params: schema.object({ id: schema.string() }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + + // We need to first pause the follower and then resume it passing the advanced settings + try { + const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id }); + const followerIndexInfo = followerIndices && followerIndices[0]; + if (!followerIndexInfo) { + return response.notFound({ body: `The follower index "${id}" does not exist.` }); + } + + // Retrieve paused state instead of pulling it from the payload to ensure it's not stale. + const isPaused = followerIndexInfo.status === 'paused'; + // Pause follower if not already paused + if (!isPaused) { + await callWithRequest('ccr.pauseFollowerIndex', { id }); + } + + // Resume follower + const body = removeEmptyFields(serializeAdvancedSettings(request.body)); + return response.ok({ + body: await callWithRequest('ccr.resumeFollowerIndex', { id, body }), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Pauses a follower index + */ + router.put( + { + path: `${API_BASE_PATH}/follower_indices/{id}/pause`, + validate: { + params: schema.object({ id: schema.string() }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsPaused: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(_id => + callWithRequest('ccr.pauseFollowerIndex', { id: _id }) + .then(() => itemsPaused.push(_id)) + .catch((err: Error) => { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + }) + ) + ); + + return response.ok({ + body: { + itemsPaused, + errors, + }, + }); + }, + }) + ); + + /** + * Resumes a follower index + */ + router.put( + { + path: `${API_BASE_PATH}/follower_indices/{id}/resume`, + validate: { + params: schema.object({ id: schema.string() }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsResumed: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(_id => + callWithRequest('ccr.resumeFollowerIndex', { id: _id }) + .then(() => itemsResumed.push(_id)) + .catch((err: Error) => { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + }) + ) + ); + + return response.ok({ + body: { + itemsResumed, + errors, + }, + }); + }, + }) + ); + + /** + * Unfollow follower index's leader index + */ + router.put( + { + path: `${API_BASE_PATH}/follower_indices/{id}/unfollow`, + validate: { + params: schema.object({ id: schema.string() }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsUnfollowed: string[] = []; + const itemsNotOpen: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(async _id => { + try { + // Try to pause follower, let it fail silently since it may already be paused + try { + await callWithRequest('ccr.pauseFollowerIndex', { id: _id }); + } catch (e) { + // Swallow errors + } + + // Close index + await callWithRequest('indices.close', { index: _id }); + + // Unfollow leader + await callWithRequest('ccr.unfollowLeaderIndex', { id: _id }); + + // Try to re-open the index, store failures in a separate array to surface warnings in the UI + // This will allow users to query their index normally after unfollowing + try { + await callWithRequest('indices.open', { index: _id }); + } catch (e) { + itemsNotOpen.push(_id); + } + + // Push success + itemsUnfollowed.push(_id); + } catch (err) { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } + }) + ); + + return response.ok({ + body: { + itemsUnfollowed, + itemsNotOpen, + errors, + }, + }); + }, + }) + ); +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/map_to_kibana_http_error.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/map_to_kibana_http_error.ts new file mode 100644 index 0000000000000..6a81bd26dc47d --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/map_to_kibana_http_error.ts @@ -0,0 +1,26 @@ +/* + * 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 { kibanaResponseFactory } from '../../../../../../../src/core/server'; +// @ts-ignore +import { wrapEsError } from '../lib/error_wrappers'; +import { isEsError } from '../lib/is_es_error'; + +export const mapErrorToKibanaHttpResponse = (err: any) => { + if (isEsError(err)) { + const { statusCode, message, body } = wrapEsError(err); + return kibanaResponseFactory.customError({ + statusCode, + body: { + message, + attributes: { + cause: body?.cause, + }, + }, + }); + } + return kibanaResponseFactory.internalError(err); +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/register_routes.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/register_routes.ts similarity index 67% rename from x-pack/legacy/plugins/cross_cluster_replication/server/routes/register_routes.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/register_routes.ts index 6e4088ec8600f..7e59417550691 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/register_routes.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/register_routes.ts @@ -7,9 +7,10 @@ import { registerAutoFollowPatternRoutes } from './api/auto_follow_pattern'; import { registerFollowerIndexRoutes } from './api/follower_index'; import { registerCcrRoutes } from './api/ccr'; +import { RouteDependencies } from './types'; -export function registerRoutes(server) { - registerAutoFollowPatternRoutes(server); - registerFollowerIndexRoutes(server); - registerCcrRoutes(server); +export function registerRoutes(deps: RouteDependencies) { + registerAutoFollowPatternRoutes(deps); + registerFollowerIndexRoutes(deps); + registerCcrRoutes(deps); } diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/types.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/types.ts new file mode 100644 index 0000000000000..7f57c20c536e0 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/types.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IRouter } from 'src/core/server'; + +export interface RouteDependencies { + router: IRouter; + __LEGACY: { + server: any; + }; +} diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js b/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js deleted file mode 100644 index 4667f0a110c1f..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js +++ /dev/null @@ -1,256 +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 Boom from 'boom'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; -import { isEsErrorFactory } from '../../lib/is_es_error_factory'; -import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers'; -import { - deserializeAutoFollowPattern, - deserializeListAutoFollowPatterns, - serializeAutoFollowPattern, -} from '../../../common/services/auto_follow_pattern_serialization'; -import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; -import { API_BASE_PATH } from '../../../common/constants'; - -export const registerAutoFollowPatternRoutes = server => { - const isEsError = isEsErrorFactory(server); - const licensePreRouting = licensePreRoutingFactory(server); - - /** - * Returns a list of all auto-follow patterns - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const response = await callWithRequest('ccr.autoFollowPatterns'); - return { - patterns: deserializeListAutoFollowPatterns(response.patterns), - }; - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Create an auto-follow pattern - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns`, - method: 'POST', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id, ...rest } = request.payload; - const body = serializeAutoFollowPattern(rest); - - /** - * First let's make sur that an auto-follow pattern with - * the same id does not exist. - */ - try { - await callWithRequest('ccr.autoFollowPattern', { id }); - // If we get here it means that an auto-follow pattern with the same id exists - const error = Boom.conflict(`An auto-follow pattern with the name "${id}" already exists.`); - throw error; - } catch (err) { - if (err.statusCode !== 404) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - } - - try { - return await callWithRequest('ccr.saveAutoFollowPattern', { id, body }); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Update an auto-follow pattern - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, - method: 'PUT', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const body = serializeAutoFollowPattern(request.payload); - - try { - return await callWithRequest('ccr.saveAutoFollowPattern', { id, body }); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Returns a single auto-follow pattern - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - - try { - const response = await callWithRequest('ccr.autoFollowPattern', { id }); - const autoFollowPattern = response.patterns[0]; - - return deserializeAutoFollowPattern(autoFollowPattern); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Delete an auto-follow pattern - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, - method: 'DELETE', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsDeleted = []; - const errors = []; - - await Promise.all( - ids.map(_id => - callWithRequest('ccr.deleteAutoFollowPattern', { id: _id }) - .then(() => itemsDeleted.push(_id)) - .catch(err => { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - }) - ) - ); - - return { - itemsDeleted, - errors, - }; - }, - }); - - /** - * Pause auto-follow pattern(s) - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns/{id}/pause`, - method: 'POST', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsPaused = []; - const errors = []; - - await Promise.all( - ids.map(_id => - callWithRequest('ccr.pauseAutoFollowPattern', { id: _id }) - .then(() => itemsPaused.push(_id)) - .catch(err => { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - }) - ) - ); - - return { - itemsPaused, - errors, - }; - }, - }); - - /** - * Resume auto-follow pattern(s) - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns/{id}/resume`, - method: 'POST', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsResumed = []; - const errors = []; - - await Promise.all( - ids.map(_id => - callWithRequest('ccr.resumeAutoFollowPattern', { id: _id }) - .then(() => itemsResumed.push(_id)) - .catch(err => { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - }) - ) - ); - - return { - itemsResumed, - errors, - }; - }, - }); -}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/ccr.js b/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/ccr.js deleted file mode 100644 index 8255eb6e86b07..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/ccr.js +++ /dev/null @@ -1,107 +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 Boom from 'boom'; - -import { API_BASE_PATH } from '../../../common/constants'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; -import { isEsErrorFactory } from '../../lib/is_es_error_factory'; -import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers'; -import { deserializeAutoFollowStats } from '../../lib/ccr_stats_serialization'; -import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; - -export const registerCcrRoutes = server => { - const isEsError = isEsErrorFactory(server); - const licensePreRouting = licensePreRoutingFactory(server); - - /** - * Returns Auto-follow stats - */ - server.route({ - path: `${API_BASE_PATH}/stats/auto_follow`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const { auto_follow_stats: autoFollowStats } = await callWithRequest('ccr.stats'); - - return deserializeAutoFollowStats(autoFollowStats); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Returns whether the user has CCR permissions - */ - server.route({ - path: `${API_BASE_PATH}/permissions`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const xpackMainPlugin = server.plugins.xpack_main; - const xpackInfo = xpackMainPlugin && xpackMainPlugin.info; - - if (!xpackInfo) { - // xpackInfo is updated via poll, so it may not be available until polling has begun. - // In this rare situation, tell the client the service is temporarily unavailable. - throw new Boom('Security info unavailable', { statusCode: 503 }); - } - - const securityInfo = xpackInfo && xpackInfo.isAvailable() && xpackInfo.feature('security'); - if (!securityInfo || !securityInfo.isAvailable() || !securityInfo.isEnabled()) { - // If security isn't enabled or available (in the case where security is enabled but license reverted to Basic) let the user use CCR. - return { - hasPermission: true, - missingClusterPrivileges: [], - }; - } - - const callWithRequest = callWithRequestFactory(server, request); - - try { - const { has_all_requested: hasPermission, cluster } = await callWithRequest( - 'ccr.permissions', - { - body: { - cluster: ['manage', 'manage_ccr'], - }, - } - ); - - const missingClusterPrivileges = Object.keys(cluster).reduce( - (permissions, permissionName) => { - if (!cluster[permissionName]) { - permissions.push(permissionName); - return permissions; - } - }, - [] - ); - - return { - hasPermission, - missingClusterPrivileges, - }; - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); -}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.js deleted file mode 100644 index e532edaa39636..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.js +++ /dev/null @@ -1,328 +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 Boom from 'boom'; - -import { - deserializeFollowerIndex, - deserializeListFollowerIndices, - serializeFollowerIndex, - serializeAdvancedSettings, -} from '../../../common/services/follower_index_serialization'; -import { API_BASE_PATH } from '../../../common/constants'; -import { removeEmptyFields } from '../../../common/services/utils'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; -import { isEsErrorFactory } from '../../lib/is_es_error_factory'; -import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers'; -import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; - -export const registerFollowerIndexRoutes = server => { - const isEsError = isEsErrorFactory(server); - const licensePreRouting = licensePreRoutingFactory(server); - - /** - * Returns a list of all follower indices - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { - id: '_all', - }); - - const { - follow_stats: { indices: followerIndicesStats }, - } = await callWithRequest('ccr.stats'); - - const followerIndicesStatsMap = followerIndicesStats.reduce((map, stats) => { - map[stats.index] = stats; - return map; - }, {}); - - const collatedFollowerIndices = followerIndices.map(followerIndex => { - return { - ...followerIndex, - ...followerIndicesStatsMap[followerIndex.follower_index], - }; - }); - - return { - indices: deserializeListFollowerIndices(collatedFollowerIndices), - }; - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Returns a single follower index pattern - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices/{id}`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - - try { - const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id }); - - const followerIndexInfo = followerIndices && followerIndices[0]; - - if (!followerIndexInfo) { - const error = Boom.notFound(`The follower index "${id}" does not exist.`); - throw error; - } - - // If this follower is paused, skip call to ES stats api since it will return 404 - if (followerIndexInfo.status === 'paused') { - return deserializeFollowerIndex({ - ...followerIndexInfo, - }); - } else { - const { indices: followerIndicesStats } = await callWithRequest( - 'ccr.followerIndexStats', - { id } - ); - - return deserializeFollowerIndex({ - ...followerIndexInfo, - ...(followerIndicesStats ? followerIndicesStats[0] : {}), - }); - } - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Create a follower index - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices`, - method: 'POST', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { name, ...rest } = request.payload; - const body = removeEmptyFields(serializeFollowerIndex(rest)); - - try { - return await callWithRequest('ccr.saveFollowerIndex', { name, body }); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Edit a follower index - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices/{id}`, - method: 'PUT', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - - async function isFollowerIndexPaused() { - const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id }); - - const followerIndexInfo = followerIndices && followerIndices[0]; - - if (!followerIndexInfo) { - const error = Boom.notFound(`The follower index "${id}" does not exist.`); - throw error; - } - - return followerIndexInfo.status === 'paused'; - } - - // We need to first pause the follower and then resume it passing the advanced settings - try { - // Retrieve paused state instead of pulling it from the payload to ensure it's not stale. - const isPaused = await isFollowerIndexPaused(); - // Pause follower if not already paused - if (!isPaused) { - await callWithRequest('ccr.pauseFollowerIndex', { id }); - } - - // Resume follower - const body = removeEmptyFields(serializeAdvancedSettings(request.payload)); - return await callWithRequest('ccr.resumeFollowerIndex', { id, body }); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Pauses a follower index - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices/{id}/pause`, - method: 'PUT', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsPaused = []; - const errors = []; - - await Promise.all( - ids.map(_id => - callWithRequest('ccr.pauseFollowerIndex', { id: _id }) - .then(() => itemsPaused.push(_id)) - .catch(err => { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - }) - ) - ); - - return { - itemsPaused, - errors, - }; - }, - }); - - /** - * Resumes a follower index - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices/{id}/resume`, - method: 'PUT', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsResumed = []; - const errors = []; - - await Promise.all( - ids.map(_id => - callWithRequest('ccr.resumeFollowerIndex', { id: _id }) - .then(() => itemsResumed.push(_id)) - .catch(err => { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - }) - ) - ); - - return { - itemsResumed, - errors, - }; - }, - }); - - /** - * Unfollow follower index's leader index - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices/{id}/unfollow`, - method: 'PUT', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsUnfollowed = []; - const itemsNotOpen = []; - const errors = []; - - await Promise.all( - ids.map(async _id => { - try { - // Try to pause follower, let it fail silently since it may already be paused - try { - await callWithRequest('ccr.pauseFollowerIndex', { id: _id }); - } catch (e) { - // Swallow errors - } - - // Close index - await callWithRequest('indices.close', { index: _id }); - - // Unfollow leader - await callWithRequest('ccr.unfollowLeaderIndex', { id: _id }); - - // Try to re-open the index, store failures in a separate array to surface warnings in the UI - // This will allow users to query their index normally after unfollowing - try { - await callWithRequest('indices.open', { index: _id }); - } catch (e) { - itemsNotOpen.push(_id); - } - - // Push success - itemsUnfollowed.push(_id); - } catch (err) { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - } - }) - ); - - return { - itemsUnfollowed, - itemsNotOpen, - errors, - }; - }, - }); -}; diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/common.ts b/x-pack/legacy/plugins/siem/cypress/tasks/common.ts index a99471d92828e..03a1fe4496030 100644 --- a/x-pack/legacy/plugins/siem/cypress/tasks/common.ts +++ b/x-pack/legacy/plugins/siem/cypress/tasks/common.ts @@ -23,14 +23,14 @@ export const drag = (subject: JQuery) => { clientY: subjectLocation.top, force: true, }) - .wait(5) + .wait(100) .trigger('mousemove', { button: primaryButton, clientX: subjectLocation.left + dndSloppyClickDetectionThreshold, clientY: subjectLocation.top, force: true, }) - .wait(5); + .wait(100); }; /** Drags the subject being dragged on the specified drop target, but does not drop it */ @@ -44,7 +44,7 @@ export const dragWithoutDrop = (dropTarget: JQuery) => { export const drop = (dropTarget: JQuery) => { cy.wrap(dropTarget) .trigger('mousemove', { button: primaryButton, force: true }) - .wait(5) + .wait(100) .trigger('mouseup', { force: true }) - .wait(5); + .wait(100); }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 934d8ecd4bb79..44519031e91cb 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -18,6 +18,8 @@ export interface Comment { export interface Case { id: string; + closedAt: string | null; + closedBy: ElasticUser | null; comments: Comment[]; commentIds: string[]; createdAt: string; @@ -59,12 +61,13 @@ export interface AllCases extends CasesStatus { export enum SortFieldCase { createdAt = 'createdAt', - updatedAt = 'updatedAt', + closedAt = 'closedAt', } export interface ElasticUser { - readonly username: string; + readonly email?: string | null; readonly fullName?: string | null; + readonly username: string; } export interface FetchCasesProps { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index a179b6f546b9b..b70195e2c126f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -49,6 +49,8 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { }; const initialData: Case = { id: '', + closedAt: null, + closedBy: null, createdAt: '', comments: [], commentIds: [], diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index afcbe20fa791a..987620469901b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -5,7 +5,7 @@ */ import { useReducer, useCallback } from 'react'; - +import { cloneDeep } from 'lodash/fp'; import { CaseRequest } from '../../../../../../plugins/case/common/api'; import { errorToToaster, useStateToaster } from '../../components/toasters'; @@ -47,7 +47,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => ...state, isLoading: false, isError: false, - caseData: action.payload, + caseData: cloneDeep(action.payload), updateKey: null, }; case 'FETCH_FAILURE': diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 80655afb93cfd..48fbb4e74c407 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -13,6 +13,8 @@ export const useGetCasesMockState: UseGetCasesState = { countOpenCases: 5, cases: [ { + closedAt: null, + closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic' }, @@ -27,6 +29,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '362a5c10-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:13.328Z', createdBy: { username: 'elastic' }, @@ -41,6 +45,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:11.328Z', createdBy: { username: 'elastic' }, @@ -55,6 +61,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: '2020-02-13T19:44:13.328Z', + closedBy: { username: 'elastic' }, id: '31890e90-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:05.563Z', createdBy: { username: 'elastic' }, @@ -64,11 +72,13 @@ export const useGetCasesMockState: UseGetCasesState = { status: 'closed', tags: ['phishing'], title: 'Uh oh', - updatedAt: null, - updatedBy: null, + updatedAt: '2020-02-13T19:44:13.328Z', + updatedBy: { username: 'elastic' }, version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '2f5b3210-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:01.901Z', createdBy: { username: 'elastic' }, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 5859e6bbce263..b9e1113c486ad 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -36,7 +36,8 @@ const Spacer = styled.span` const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); export const getCasesColumns = ( - actions: Array> + actions: Array>, + filterStatus: string ): CasesColumns[] => [ { name: i18n.NAME, @@ -113,22 +114,39 @@ export const getCasesColumns = ( render: (comments: Case['commentIds']) => renderStringField(`${comments.length}`, `case-table-column-commentCount`), }, - { - field: 'createdAt', - name: i18n.OPENED_ON, - sortable: true, - render: (createdAt: Case['createdAt']) => { - if (createdAt != null) { - return ( - - ); + filterStatus === 'open' + ? { + field: 'createdAt', + name: i18n.OPENED_ON, + sortable: true, + render: (createdAt: Case['createdAt']) => { + if (createdAt != null) { + return ( + + ); + } + return getEmptyTagValue(); + }, } - return getEmptyTagValue(); - }, - }, + : { + field: 'closedAt', + name: i18n.CLOSED_ON, + sortable: true, + render: (closedAt: Case['closedAt']) => { + if (closedAt != null) { + return ( + + ); + } + return getEmptyTagValue(); + }, + }, { name: 'Actions', actions, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index 8f3a47a2a0d03..e7e1e624ccba2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -72,8 +72,8 @@ const ProgressLoader = styled(EuiProgress)` const getSortField = (field: string): SortFieldCase => { if (field === SortFieldCase.createdAt) { return SortFieldCase.createdAt; - } else if (field === SortFieldCase.updatedAt) { - return SortFieldCase.updatedAt; + } else if (field === SortFieldCase.closedAt) { + return SortFieldCase.closedAt; } return SortFieldCase.createdAt; }; @@ -216,17 +216,25 @@ export const AllCases = React.memo(() => { } setQueryParams(newQueryParams); }, - [setQueryParams, queryParams] + [queryParams] ); const onFilterChangedCallback = useCallback( (newFilterOptions: Partial) => { + if (newFilterOptions.status && newFilterOptions.status === 'closed') { + setQueryParams({ ...queryParams, sortField: SortFieldCase.closedAt }); + } else if (newFilterOptions.status && newFilterOptions.status === 'open') { + setQueryParams({ ...queryParams, sortField: SortFieldCase.createdAt }); + } setFilters({ ...filterOptions, ...newFilterOptions }); }, - [filterOptions, setFilters] + [filterOptions, queryParams] ); - const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions), [actions]); + const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions, filterOptions.status), [ + actions, + filterOptions.status, + ]); const memoizedPagination = useMemo( () => ({ pageIndex: queryParams.page - 1, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts index 7d7aba21e5f68..b18134f6d093e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts @@ -62,9 +62,3 @@ export const CLOSED = i18n.translate('xpack.siem.case.caseTable.closed', { export const DELETE = i18n.translate('xpack.siem.case.caseTable.delete', { defaultMessage: 'Delete', }); -export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', { - defaultMessage: 'Reopen case', -}); -export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', { - defaultMessage: 'Close case', -}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx new file mode 100644 index 0000000000000..9dbd71ea3e34c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import styled, { css } from 'styled-components'; +import { + EuiBadge, + EuiButtonToggle, + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import * as i18n from '../case_view/translations'; +import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; +import { CaseViewActions } from '../case_view/actions'; + +const MyDescriptionList = styled(EuiDescriptionList)` + ${({ theme }) => css` + & { + padding-right: ${theme.eui.euiSizeL}; + border-right: ${theme.eui.euiBorderThin}; + } + `} +`; + +interface CaseStatusProps { + 'data-test-subj': string; + badgeColor: string; + buttonLabel: string; + caseId: string; + caseTitle: string; + icon: string; + isLoading: boolean; + isSelected: boolean; + status: string; + title: string; + toggleStatusCase: (status: string) => void; + value: string | null; +} +const CaseStatusComp: React.FC = ({ + 'data-test-subj': dataTestSubj, + badgeColor, + buttonLabel, + caseId, + caseTitle, + icon, + isLoading, + isSelected, + status, + title, + toggleStatusCase, + value, +}) => { + const onChange = useCallback(e => toggleStatusCase(e.target.checked ? 'closed' : 'open'), [ + toggleStatusCase, + ]); + return ( + + + + + + {i18n.STATUS} + + + {status} + + + + + {title} + + + + + + + + + + + + + + + + + + + ); +}; + +export const CaseStatus = React.memo(CaseStatusComp); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index 53cc1f80b5c10..e11441eac3a9d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -10,6 +10,8 @@ import { Case } from '../../../../../containers/case/types'; export const caseProps: CaseProps = { caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', initialData: { + closedAt: null, + closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], comments: [ @@ -20,6 +22,7 @@ export const caseProps: CaseProps = { createdBy: { fullName: 'Steph Milovic', username: 'smilovic', + email: 'notmyrealemailfool@elastic.co', }, updatedAt: '2020-02-20T23:06:33.798Z', updatedBy: { @@ -29,7 +32,7 @@ export const caseProps: CaseProps = { }, ], createdAt: '2020-02-13T19:44:23.627Z', - createdBy: { fullName: null, username: 'elastic' }, + createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' }, description: 'Security banana Issue', status: 'open', tags: ['defacement'], @@ -41,35 +44,22 @@ export const caseProps: CaseProps = { version: 'WzQ3LDFd', }, }; - -export const data: Case = { - id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', - commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], - comments: [ - { - comment: 'Solve this fast!', - id: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', - createdAt: '2020-02-20T23:06:33.798Z', - createdBy: { - fullName: 'Steph Milovic', - username: 'smilovic', - }, - updatedAt: '2020-02-20T23:06:33.798Z', - updatedBy: { - username: 'elastic', - }, - version: 'WzQ3LDFd', +export const caseClosedProps: CaseProps = { + ...caseProps, + initialData: { + ...caseProps.initialData, + closedAt: '2020-02-20T23:06:33.798Z', + closedBy: { + username: 'elastic', }, - ], - createdAt: '2020-02-13T19:44:23.627Z', - createdBy: { username: 'elastic', fullName: null }, - description: 'Security banana Issue', - status: 'open', - tags: ['defacement'], - title: 'Another horrible breach!!', - updatedAt: '2020-02-19T15:02:57.995Z', - updatedBy: { - username: 'elastic', + status: 'closed', }, - version: 'WzQ3LDFd', +}; + +export const data: Case = { + ...caseProps.initialData, +}; + +export const dataClosed: Case = { + ...caseClosedProps.initialData, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx new file mode 100644 index 0000000000000..4e1e5ba753c36 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { CaseViewActions } from './actions'; +import { TestProviders } from '../../../../mock'; +import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; +jest.mock('../../../../containers/case/use_delete_cases'); +const useDeleteCasesMock = useDeleteCases as jest.Mock; + +describe('CaseView actions', () => { + const caseTitle = 'Cool title'; + const caseId = 'cool-id'; + const handleOnDeleteConfirm = jest.fn(); + const handleToggleModal = jest.fn(); + const dispatchResetIsDeleted = jest.fn(); + const defaultDeleteState = { + dispatchResetIsDeleted, + handleToggleModal, + handleOnDeleteConfirm, + isLoading: false, + isError: false, + isDeleted: false, + isDisplayConfirmDeleteModal: false, + }; + beforeEach(() => { + jest.resetAllMocks(); + useDeleteCasesMock.mockImplementation(() => defaultDeleteState); + }); + it('clicking trash toggles modal', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy(); + + wrapper + .find('button[data-test-subj="property-actions-ellipses"]') + .first() + .simulate('click'); + wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click'); + expect(handleToggleModal).toHaveBeenCalled(); + }); + it('toggle delete modal and confirm', () => { + useDeleteCasesMock.mockImplementation(() => ({ + ...defaultDeleteState, + isDisplayConfirmDeleteModal: true, + })); + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy(); + wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click'); + expect(handleOnDeleteConfirm.mock.calls[0][0]).toEqual([caseId]); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx new file mode 100644 index 0000000000000..88a717ac5fa6a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; + +import { Redirect } from 'react-router-dom'; +import * as i18n from './translations'; +import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; +import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import { SiemPageName } from '../../../home/types'; +import { PropertyActions } from '../property_actions'; + +interface CaseViewActions { + caseId: string; + caseTitle: string; +} + +const CaseViewActionsComponent: React.FC = ({ caseId, caseTitle }) => { + // Delete case + const { + handleToggleModal, + handleOnDeleteConfirm, + isDeleted, + isDisplayConfirmDeleteModal, + } = useDeleteCases(); + + const confirmDeleteModal = useMemo( + () => ( + + ), + [isDisplayConfirmDeleteModal] + ); + // TO DO refactor each of these const's into their own components + const propertyActions = useMemo( + () => [ + { + iconType: 'trash', + label: i18n.DELETE_CASE, + onClick: handleToggleModal, + }, + { + iconType: 'popout', + label: 'View ServiceNow incident', + onClick: () => null, + }, + { + iconType: 'importAction', + label: 'Update ServiceNow incident', + onClick: () => null, + }, + ], + [handleToggleModal] + ); + + if (isDeleted) { + return ; + } + return ( + <> + + {confirmDeleteModal} + + ); +}; + +export const CaseViewActions = React.memo(CaseViewActionsComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 91ca9182bd0ff..41100ec6d50f1 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -8,10 +8,11 @@ import React from 'react'; import { Router } from 'react-router-dom'; import { mount } from 'enzyme'; import { CaseComponent } from './'; -import * as updateHook from '../../../../containers/case/use_update_case'; -import * as deleteHook from '../../../../containers/case/use_delete_cases'; -import { caseProps, data } from './__mock__'; +import { caseProps, caseClosedProps, data, dataClosed } from './__mock__'; import { TestProviders } from '../../../../mock'; +import { useUpdateCase } from '../../../../containers/case/use_update_case'; +jest.mock('../../../../containers/case/use_update_case'); +const useUpdateCaseMock = useUpdateCase as jest.Mock; type Action = 'PUSH' | 'POP' | 'REPLACE'; const pop: Action = 'POP'; const location = { @@ -33,10 +34,8 @@ const mockHistory = { createHref: jest.fn(), listen: jest.fn(), }; + describe('CaseView ', () => { - const handleOnDeleteConfirm = jest.fn(); - const handleToggleModal = jest.fn(); - const dispatchResetIsDeleted = jest.fn(); const updateCaseProperty = jest.fn(); /* eslint-disable no-console */ // Silence until enzyme fixed to use ReactTestUtils.act() @@ -49,15 +48,17 @@ describe('CaseView ', () => { }); /* eslint-enable no-console */ + const defaultUpdateCaseState = { + caseData: data, + isLoading: false, + isError: false, + updateKey: null, + updateCaseProperty, + }; + beforeEach(() => { jest.resetAllMocks(); - jest.spyOn(updateHook, 'useUpdateCase').mockReturnValue({ - caseData: data, - isLoading: false, - isError: false, - updateKey: null, - updateCaseProperty, - }); + useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); }); it('should render CaseComponent', () => { @@ -92,6 +93,7 @@ describe('CaseView ', () => { .first() .text() ).toEqual(data.createdBy.username); + expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false); expect( wrapper .find(`[data-test-subj="case-view-createdAt"]`) @@ -106,6 +108,33 @@ describe('CaseView ', () => { ).toEqual(data.description); }); + it('should show closed indicators in header when case is closed', () => { + useUpdateCaseMock.mockImplementation(() => ({ + ...defaultUpdateCaseState, + caseData: dataClosed, + })); + const wrapper = mount( + + + + + + ); + expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); + expect( + wrapper + .find(`[data-test-subj="case-view-closedAt"]`) + .first() + .prop('value') + ).toEqual(dataClosed.closedAt); + expect( + wrapper + .find(`[data-test-subj="case-view-status"]`) + .first() + .text() + ).toEqual(dataClosed.status); + }); + it('should dispatch update state when button is toggled', () => { const wrapper = mount( @@ -117,7 +146,7 @@ describe('CaseView ', () => { wrapper .find('input[data-test-subj="toggle-case-status"]') - .simulate('change', { target: { value: false } }); + .simulate('change', { target: { checked: true } }); expect(updateCaseProperty).toBeCalledWith({ updateKey: 'status', @@ -160,50 +189,4 @@ describe('CaseView ', () => { .prop('source') ).toEqual(data.comments[0].comment); }); - - it('toggle delete modal and cancel', () => { - const wrapper = mount( - - - - - - ); - - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy(); - - wrapper - .find( - '[data-test-subj="case-view-actions"] button[data-test-subj="property-actions-ellipses"]' - ) - .first() - .simulate('click'); - wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click'); - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy(); - wrapper.find('button[data-test-subj="confirmModalCancelButton"]').simulate('click'); - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy(); - }); - - it('toggle delete modal and confirm', () => { - jest.spyOn(deleteHook, 'useDeleteCases').mockReturnValue({ - dispatchResetIsDeleted, - handleToggleModal, - handleOnDeleteConfirm, - isLoading: false, - isError: false, - isDeleted: false, - isDisplayConfirmDeleteModal: true, - }); - const wrapper = mount( - - - - - - ); - - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy(); - wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click'); - expect(handleOnDeleteConfirm.mock.calls[0][0]).toEqual([caseProps.caseId]); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index ba67087938216..08af603cb0dbf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -5,26 +5,14 @@ */ import React, { useCallback, useMemo } from 'react'; -import { - EuiBadge, - EuiButtonToggle, - EuiDescriptionList, - EuiDescriptionListDescription, - EuiDescriptionListTitle, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; -import styled, { css } from 'styled-components'; -import { Redirect } from 'react-router-dom'; +import styled from 'styled-components'; import * as i18n from './translations'; import { Case } from '../../../../containers/case/types'; -import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; import { getCaseUrl } from '../../../../components/link_to'; import { HeaderPage } from '../../../../components/header_page'; import { EditableTitle } from '../../../../components/header_page/editable_title'; -import { PropertyActions } from '../property_actions'; import { TagList } from '../tag_list'; import { useGetCase } from '../../../../containers/case/use_get_case'; import { UserActionTree } from '../user_action_tree'; @@ -33,24 +21,14 @@ import { useUpdateCase } from '../../../../containers/case/use_update_case'; import { WrapperPage } from '../../../../components/wrapper_page'; import { getTypedPayload } from '../../../../containers/case/utils'; import { WhitePageWrapper } from '../wrappers'; -import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; -import { SiemPageName } from '../../../home/types'; -import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import { useBasePath } from '../../../../lib/kibana'; +import { CaseStatus } from '../case_status'; import { SpyRoute } from '../../../../utils/route/spy_routes'; interface Props { caseId: string; } -const MyDescriptionList = styled(EuiDescriptionList)` - ${({ theme }) => css` - & { - padding-right: ${theme.eui.euiSizeL}; - border-right: ${theme.eui.euiBorderThin}; - } - `} -`; - const MyWrapper = styled(WrapperPage)` padding-bottom: 0; `; @@ -65,6 +43,8 @@ export interface CaseProps { } export const CaseComponent = React.memo(({ caseId, initialData }) => { + const basePath = window.location.origin + useBasePath(); + const caseLink = `${basePath}/app/siem#/case/${caseId}`; const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData); // Update Fields @@ -108,59 +88,46 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => return null; } }, - [updateCaseProperty, caseData.status] - ); - const toggleStatusCase = useCallback( - e => onUpdateField('status', e.target.checked ? 'open' : 'closed'), - [onUpdateField] + [caseData.status] ); - const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); + const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); + const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]); - // Delete case - const { - handleToggleModal, - handleOnDeleteConfirm, - isDeleted, - isDisplayConfirmDeleteModal, - } = useDeleteCases(); - - const confirmDeleteModal = useMemo( - () => ( - - ), - [isDisplayConfirmDeleteModal] - ); - // TO DO refactor each of these const's into their own components - const propertyActions = [ - { - iconType: 'trash', - label: 'Delete case', - onClick: handleToggleModal, - }, - { - iconType: 'popout', - label: 'View ServiceNow incident', - onClick: () => null, - }, - { - iconType: 'importAction', - label: 'Update ServiceNow incident', - onClick: () => null, - }, - ]; - - if (isDeleted) { - return ; - } const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]); + const caseStatusData = useMemo( + () => + caseData.status === 'open' + ? { + 'data-test-subj': 'case-view-createdAt', + value: caseData.createdAt, + title: i18n.CASE_OPENED, + buttonLabel: i18n.CLOSE_CASE, + status: caseData.status, + icon: 'checkInCircleFilled', + badgeColor: 'secondary', + isSelected: false, + } + : { + 'data-test-subj': 'case-view-closedAt', + value: caseData.closedAt, + title: i18n.CASE_CLOSED, + buttonLabel: i18n.REOPEN_CASE, + status: caseData.status, + icon: 'magnet', + badgeColor: 'danger', + isSelected: true, + }, + [caseData.closedAt, caseData.createdAt, caseData.status] + ); + const emailContent = useMemo( + () => ({ + subject: i18n.EMAIL_SUBJECT(caseData.title), + body: i18n.EMAIL_BODY(caseLink), + }), + [caseData.title] + ); return ( <> @@ -179,51 +146,13 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => } title={caseData.title} > - - - - - - {i18n.STATUS} - - - {caseData.status} - - - - - {i18n.CASE_OPENED} - - - - - - - - - - - - - - - - - - + @@ -239,6 +168,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => @@ -252,7 +182,6 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => - {confirmDeleteModal} ); @@ -276,4 +205,5 @@ export const CaseView = React.memo(({ caseId }: Props) => { return ; }); +CaseComponent.displayName = 'CaseComponent'; CaseView.displayName = 'CaseView'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index 82b5e771e2151..e5fa3bff51f85 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -55,3 +55,19 @@ export const STATUS = i18n.translate('xpack.siem.case.caseView.statusLabel', { export const CASE_OPENED = i18n.translate('xpack.siem.case.caseView.caseOpened', { defaultMessage: 'Case opened', }); + +export const CASE_CLOSED = i18n.translate('xpack.siem.case.caseView.caseClosed', { + defaultMessage: 'Case closed', +}); + +export const EMAIL_SUBJECT = (caseTitle: string) => + i18n.translate('xpack.siem.case.caseView.emailSubject', { + values: { caseTitle }, + defaultMessage: 'SIEM Case - {caseTitle}', + }); + +export const EMAIL_BODY = (caseUrl: string) => + i18n.translate('xpack.siem.case.caseView.emailBody', { + values: { caseUrl }, + defaultMessage: 'Case reference: {caseUrl}', + }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx new file mode 100644 index 0000000000000..51acb3b810d92 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { UserList } from './'; +import * as i18n from '../case_view/translations'; + +describe('UserList ', () => { + const title = 'Case Title'; + const caseLink = 'http://reddit.com'; + const user = { username: 'username', fullName: 'Full Name', email: 'testemail@elastic.co' }; + const open = jest.fn(); + beforeAll(() => { + window.open = open; + }); + beforeEach(() => { + jest.resetAllMocks(); + }); + it('triggers mailto when email icon clicked', () => { + const wrapper = shallow( + + ); + wrapper.find('[data-test-subj="user-list-email-button"]').simulate('click'); + expect(open).toBeCalledWith( + `mailto:${user.email}?subject=${i18n.EMAIL_SUBJECT(title)}&body=${i18n.EMAIL_BODY(caseLink)}`, + '_blank' + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx index abb49122dc142..74a1b98c29eef 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { EuiButtonIcon, EuiText, @@ -17,6 +17,10 @@ import styled, { css } from 'styled-components'; import { ElasticUser } from '../../../../containers/case/types'; interface UserListProps { + email: { + subject: string; + body: string; + }; headline: string; users: ElasticUser[]; } @@ -31,8 +35,11 @@ const MyFlexGroup = styled(EuiFlexGroup)` `} `; -const renderUsers = (users: ElasticUser[]) => { - return users.map(({ fullName, username }, key) => ( +const renderUsers = ( + users: ElasticUser[], + handleSendEmail: (emailAddress: string | undefined | null) => void +) => { + return users.map(({ fullName, username, email }, key) => ( @@ -50,7 +57,8 @@ const renderUsers = (users: ElasticUser[]) => { {}} // TO DO + data-test-subj="user-list-email-button" + onClick={handleSendEmail.bind(null, email)} // TO DO iconType="email" aria-label="email" /> @@ -59,12 +67,20 @@ const renderUsers = (users: ElasticUser[]) => { )); }; -export const UserList = React.memo(({ headline, users }: UserListProps) => { +export const UserList = React.memo(({ email, headline, users }: UserListProps) => { + const handleSendEmail = useCallback( + (emailAddress: string | undefined | null) => { + if (emailAddress && emailAddress != null) { + window.open(`mailto:${emailAddress}?subject=${email.subject}&body=${email.body}`, '_blank'); + } + }, + [email.subject] + ); return (

{headline}

- {renderUsers(users)} + {renderUsers(users, handleSendEmail)}
); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 6ef412d408ae5..341a34240fe49 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -30,6 +30,16 @@ export const OPENED_ON = i18n.translate('xpack.siem.case.caseView.openedOn', { defaultMessage: 'Opened on', }); +export const CLOSED_ON = i18n.translate('xpack.siem.case.caseView.closedOn', { + defaultMessage: 'Closed on', +}); +export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', { + defaultMessage: 'Reopen case', +}); +export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', { + defaultMessage: 'Close case', +}); + export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', { defaultMessage: 'Reporter', }); diff --git a/x-pack/legacy/plugins/uptime/README.md b/x-pack/legacy/plugins/uptime/README.md index 308f78ecdc368..2ed0e2fc77cbc 100644 --- a/x-pack/legacy/plugins/uptime/README.md +++ b/x-pack/legacy/plugins/uptime/README.md @@ -62,3 +62,13 @@ You can login with username `elastic` and password `changeme` by default. If you want to freeze a UI or API test you can include an async call like `await new Promise(r => setTimeout(r, 1000 * 60))` to freeze the execution for 60 seconds if you need to click around or check things in the state that is loaded. + +#### Running --ssl tests + +Some of our tests require there to be an SSL connection between Kibana and Elasticsearch. + +We can run these tests like described above, but with some special config. + +`node scripts/functional_tests_server.js --config=test/functional_with_es_ssl/config.ts` + +`node scripts/functional_test_runner.js --config=test/functional_with_es_ssl/config.ts` diff --git a/x-pack/legacy/plugins/uptime/common/constants/alerts.ts b/x-pack/legacy/plugins/uptime/common/constants/alerts.ts new file mode 100644 index 0000000000000..c0db9ae309843 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/constants/alerts.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface ActionGroupDefinition { + id: string; + name: string; +} + +type ActionGroupDefinitions = Record; + +export const ACTION_GROUP_DEFINITIONS: ActionGroupDefinitions = { + MONITOR_STATUS: { + id: 'xpack.uptime.alerts.actionGroups.monitorStatus', + name: 'Uptime Down Monitor', + }, +}; diff --git a/x-pack/legacy/plugins/uptime/common/constants/index.ts b/x-pack/legacy/plugins/uptime/common/constants/index.ts index 0425fc19a7b45..19f2de3c6f0f4 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/index.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { ACTION_GROUP_DEFINITIONS } from './alerts'; export { CHART_FORMAT_LIMITS } from './chart_format_limits'; export { CLIENT_DEFAULTS } from './client_defaults'; export { CONTEXT_DEFAULTS } from './context_defaults'; diff --git a/x-pack/legacy/plugins/uptime/common/constants/index_names.ts b/x-pack/legacy/plugins/uptime/common/constants/index_names.ts index e9c6b1e1106ab..9f33d280a1268 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/index_names.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/index_names.ts @@ -6,5 +6,4 @@ export const INDEX_NAMES = { HEARTBEAT: 'heartbeat-8*', - HEARTBEAT_STATES: 'heartbeat-states-8*', }; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts new file mode 100644 index 0000000000000..ee284249c38c0 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + StatusCheckAlertStateType, + StatusCheckAlertState, + StatusCheckExecutorParamsType, + StatusCheckExecutorParams, +} from './status_check'; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts new file mode 100644 index 0000000000000..bc234b268df27 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +export const StatusCheckAlertStateType = t.intersection([ + t.partial({ + currentTriggerStarted: t.string, + firstTriggeredAt: t.string, + lastTriggeredAt: t.string, + lastResolvedAt: t.string, + }), + t.type({ + firstCheckedAt: t.string, + lastCheckedAt: t.string, + isTriggered: t.boolean, + }), +]); + +export type StatusCheckAlertState = t.TypeOf; + +export const StatusCheckExecutorParamsType = t.intersection([ + t.partial({ + filters: t.string, + }), + t.type({ + locations: t.array(t.string), + numTimes: t.number, + timerange: t.type({ + from: t.string, + to: t.string, + }), + }), +]); + +export type StatusCheckExecutorParams = t.TypeOf; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts index 58f79abcf91ec..82fc9807300ed 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './alerts'; export * from './common'; export * from './monitor'; export * from './overview_filters'; diff --git a/x-pack/legacy/plugins/uptime/index.ts b/x-pack/legacy/plugins/uptime/index.ts index feecef5857895..f52ad8ce867b6 100644 --- a/x-pack/legacy/plugins/uptime/index.ts +++ b/x-pack/legacy/plugins/uptime/index.ts @@ -14,7 +14,7 @@ export const uptime = (kibana: any) => configPrefix: 'xpack.uptime', id: PLUGIN.ID, publicDir: resolve(__dirname, 'public'), - require: ['kibana', 'elasticsearch', 'xpack_main'], + require: ['alerting', 'kibana', 'elasticsearch', 'xpack_main'], uiExports: { app: { description: i18n.translate('xpack.uptime.pluginDescription', { diff --git a/x-pack/legacy/plugins/uptime/public/apps/index.ts b/x-pack/legacy/plugins/uptime/public/apps/index.ts index d322c35364d1a..d58bf8398fcde 100644 --- a/x-pack/legacy/plugins/uptime/public/apps/index.ts +++ b/x-pack/legacy/plugins/uptime/public/apps/index.ts @@ -8,8 +8,9 @@ import { npSetup } from 'ui/new_platform'; import { Plugin } from './plugin'; import 'uiExports/embeddableFactories'; -new Plugin({ +const plugin = new Plugin({ opaqueId: Symbol('uptime'), env: {} as any, config: { get: () => ({} as any) }, -}).setup(npSetup); +}); +plugin.setup(npSetup); diff --git a/x-pack/legacy/plugins/uptime/public/apps/plugin.ts b/x-pack/legacy/plugins/uptime/public/apps/plugin.ts index 2204d7e4097dd..eec49418910f8 100644 --- a/x-pack/legacy/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/legacy/plugins/uptime/public/apps/plugin.ts @@ -36,6 +36,7 @@ export class Plugin { public setup(setup: SetupObject) { const { core, plugins } = setup; const { home } = plugins; + home.featureCatalogue.register({ category: FeatureCatalogueCategory.DATA, description: PLUGIN.DESCRIPTION, @@ -45,6 +46,7 @@ export class Plugin { showOnHomePage: true, title: PLUGIN.TITLE, }); + core.application.register({ id: PLUGIN.ID, euiIconType: 'uptimeApp', diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx new file mode 100644 index 0000000000000..1529ab6db8875 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useSelector } from 'react-redux'; +import { DataPublicPluginSetup } from 'src/plugins/data/public'; +import { selectMonitorStatusAlert } from '../../../state/selectors'; +import { AlertMonitorStatusComponent } from '../../functional/alerts/alert_monitor_status'; + +interface Props { + autocomplete: DataPublicPluginSetup['autocomplete']; + enabled: boolean; + numTimes: number; + setAlertParams: (key: string, value: any) => void; + timerange: { + from: string; + to: string; + }; +} + +export const AlertMonitorStatus = ({ + autocomplete, + enabled, + numTimes, + setAlertParams, + timerange, +}: Props) => { + const { filters, locations } = useSelector(selectMonitorStatusAlert); + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts new file mode 100644 index 0000000000000..87179a96fc0b2 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AlertMonitorStatus } from './alert_monitor_status'; +export { ToggleAlertFlyoutButton } from './toggle_alert_flyout_button'; +export { UptimeAlertsFlyoutWrapper } from './uptime_alerts_flyout_wrapper'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx new file mode 100644 index 0000000000000..43b0be45365a1 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { ToggleAlertFlyoutButtonComponent } from '../../functional'; +import { setAlertFlyoutVisible } from '../../../state/actions'; + +export const ToggleAlertFlyoutButton = () => { + const dispatch = useDispatch(); + return ( + dispatch(setAlertFlyoutVisible(value))} + /> + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx new file mode 100644 index 0000000000000..b547f8b076f93 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { UptimeAlertsFlyoutWrapperComponent } from '../../functional'; +import { setAlertFlyoutVisible } from '../../../state/actions'; +import { selectAlertFlyoutVisibility } from '../../../state/selectors'; + +interface Props { + alertTypeId?: string; + canChangeTrigger?: boolean; +} + +export const UptimeAlertsFlyoutWrapper = ({ alertTypeId, canChangeTrigger }: Props) => { + const dispatch = useDispatch(); + const setAddFlyoutVisiblity = (value: React.SetStateAction) => + // @ts-ignore the value here is a boolean, and it works with the action creator function + dispatch(setAlertFlyoutVisible(value)); + + const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility); + + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/index.ts b/x-pack/legacy/plugins/uptime/public/components/connected/index.ts index baa961ddc87d2..7e442cbe850ba 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/connected/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { AlertMonitorStatus, ToggleAlertFlyoutButton, UptimeAlertsFlyoutWrapper } from './alerts'; export { PingHistogram } from './charts/ping_histogram'; export { Snapshot } from './charts/snapshot_container'; export { KueryBar } from './kuerybar/kuery_bar_container'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx index a42f96962b95e..132ae57b5154f 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx @@ -8,7 +8,7 @@ import { connect } from 'react-redux'; import { AppState } from '../../../state'; import { selectIndexPattern } from '../../../state/selectors'; import { getIndexPattern } from '../../../state/actions'; -import { KueryBarComponent } from '../../functional'; +import { KueryBarComponent } from '../../functional/kuery_bar/kuery_bar'; const mapStateToProps = (state: AppState) => ({ ...selectIndexPattern(state) }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx new file mode 100644 index 0000000000000..af8d17d1fc242 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + selectedLocationsToString, + AlertFieldNumber, + handleAlertFieldNumberChange, +} from '../alert_monitor_status'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; + +describe('alert monitor status component', () => { + describe('handleAlertFieldNumberChange', () => { + let mockSetIsInvalid: jest.Mock; + let mockSetFieldValue: jest.Mock; + + beforeEach(() => { + mockSetIsInvalid = jest.fn(); + mockSetFieldValue = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sets a valid number', () => { + handleAlertFieldNumberChange( + // @ts-ignore no need to implement this entire type here + { target: { value: '23' } }, + false, + mockSetIsInvalid, + mockSetFieldValue + ); + expect(mockSetIsInvalid).not.toHaveBeenCalled(); + expect(mockSetFieldValue).toHaveBeenCalledTimes(1); + expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 23, + ], + ] + `); + }); + + it('sets invalid for NaN value', () => { + handleAlertFieldNumberChange( + // @ts-ignore no need to implement this entire type here + { target: { value: 'foo' } }, + false, + mockSetIsInvalid, + mockSetFieldValue + ); + expect(mockSetIsInvalid).toHaveBeenCalledTimes(1); + expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + true, + ], + ] + `); + expect(mockSetFieldValue).not.toHaveBeenCalled(); + }); + + it('sets invalid to false when a valid value is received and invalid is true', () => { + handleAlertFieldNumberChange( + // @ts-ignore no need to implement this entire type here + { target: { value: '23' } }, + true, + mockSetIsInvalid, + mockSetFieldValue + ); + expect(mockSetIsInvalid).toHaveBeenCalledTimes(1); + expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + false, + ], + ] + `); + expect(mockSetFieldValue).toHaveBeenCalledTimes(1); + expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 23, + ], + ] + `); + }); + }); + + describe('AlertFieldNumber', () => { + it('responds with correct number value when a valid number is specified', () => { + const mockValueHandler = jest.fn(); + const component = mountWithIntl( + + ); + component.find('input').simulate('change', { target: { value: '45' } }); + expect(mockValueHandler).toHaveBeenCalled(); + expect(mockValueHandler.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 45, + ], + ] + `); + }); + + it('does not set an invalid number value', () => { + const mockValueHandler = jest.fn(); + const component = mountWithIntl( + + ); + component.find('input').simulate('change', { target: { value: 'not a number' } }); + expect(mockValueHandler).not.toHaveBeenCalled(); + expect(mockValueHandler.mock.calls).toEqual([]); + }); + + it('does not set a number value less than 1', () => { + const mockValueHandler = jest.fn(); + const component = mountWithIntl( + + ); + component.find('input').simulate('change', { target: { value: '0' } }); + expect(mockValueHandler).not.toHaveBeenCalled(); + expect(mockValueHandler.mock.calls).toEqual([]); + }); + }); + + describe('selectedLocationsToString', () => { + it('generates a formatted string for a valid list of options', () => { + const locations = [ + { + checked: 'on', + label: 'fairbanks', + }, + { + checked: 'on', + label: 'harrisburg', + }, + { + checked: undefined, + label: 'orlando', + }, + ]; + expect(selectedLocationsToString(locations)).toEqual('fairbanks, harrisburg'); + }); + + it('generates a formatted string for a single item', () => { + expect(selectedLocationsToString([{ checked: 'on', label: 'fairbanks' }])).toEqual( + 'fairbanks' + ); + }); + + it('returns an empty string when no valid options are available', () => { + expect(selectedLocationsToString([{ checked: 'off', label: 'harrisburg' }])).toEqual(''); + }); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx new file mode 100644 index 0000000000000..5143e1c963904 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx @@ -0,0 +1,431 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { + EuiExpression, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiSelectable, + EuiSpacer, + EuiSwitch, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { DataPublicPluginSetup } from 'src/plugins/data/public'; +import { KueryBar } from '../../connected/kuerybar/kuery_bar_container'; + +interface AlertFieldNumberProps { + 'aria-label': string; + 'data-test-subj': string; + disabled: boolean; + fieldValue: number; + setFieldValue: React.Dispatch>; +} + +export const handleAlertFieldNumberChange = ( + e: React.ChangeEvent, + isInvalid: boolean, + setIsInvalid: React.Dispatch>, + setFieldValue: React.Dispatch> +) => { + const num = parseInt(e.target.value, 10); + if (isNaN(num) || num < 1) { + setIsInvalid(true); + } else { + if (isInvalid) setIsInvalid(false); + setFieldValue(num); + } +}; + +export const AlertFieldNumber = ({ + 'aria-label': ariaLabel, + 'data-test-subj': dataTestSubj, + disabled, + fieldValue, + setFieldValue, +}: AlertFieldNumberProps) => { + const [isInvalid, setIsInvalid] = useState(false); + + return ( + handleAlertFieldNumberChange(e, isInvalid, setIsInvalid, setFieldValue)} + disabled={disabled} + value={fieldValue} + isInvalid={isInvalid} + /> + ); +}; + +interface AlertExpressionPopoverProps { + 'aria-label': string; + content: React.ReactElement; + description: string; + 'data-test-subj': string; + id: string; + value: string; +} + +const AlertExpressionPopover: React.FC = ({ + 'aria-label': ariaLabel, + content, + 'data-test-subj': dataTestSubj, + description, + id, + value, +}) => { + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen(!isOpen)} + value={value} + /> + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + > + {content} + + ); +}; + +export const selectedLocationsToString = (selectedLocations: any[]) => + // create a nicely-formatted description string for all `on` locations + selectedLocations + .filter(({ checked }) => checked === 'on') + .map(({ label }) => label) + .sort() + .reduce((acc, cur) => { + if (acc === '') { + return cur; + } + return acc + `, ${cur}`; + }, ''); + +interface AlertMonitorStatusProps { + autocomplete: DataPublicPluginSetup['autocomplete']; + enabled: boolean; + filters: string; + locations: string[]; + numTimes: number; + setAlertParams: (key: string, value: any) => void; + timerange: { + from: string; + to: string; + }; +} + +export const AlertMonitorStatusComponent: React.FC = props => { + const { filters, locations } = props; + const [numTimes, setNumTimes] = useState(5); + const [numMins, setNumMins] = useState(15); + const [allLabels, setAllLabels] = useState(true); + + // locations is an array of `Option[]`, but that type doesn't seem to be exported by EUI + const [selectedLocations, setSelectedLocations] = useState( + locations.map(location => ({ + 'aria-label': i18n.translate('xpack.uptime.alerts.locationSelectionItem.ariaLabel', { + defaultMessage: 'Location selection item for "{location}"', + values: { + location, + }, + }), + disabled: allLabels, + label: location, + })) + ); + const [timerangeUnitOptions, setTimerangeUnitOptions] = useState([ + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.secondsOption.ariaLabel', + { + defaultMessage: '"Seconds" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.secondsOption', + key: 's', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.seconds', { + defaultMessage: 'seconds', + }), + }, + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.minutesOption.ariaLabel', + { + defaultMessage: '"Minutes" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.minutesOption', + checked: 'on', + key: 'm', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.minutes', { + defaultMessage: 'minutes', + }), + }, + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.hoursOption.ariaLabel', + { + defaultMessage: '"Hours" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.hoursOption', + key: 'h', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.hours', { + defaultMessage: 'hours', + }), + }, + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.daysOption.ariaLabel', + { + defaultMessage: '"Days" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.daysOption', + key: 'd', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.days', { + defaultMessage: 'days', + }), + }, + ]); + + const { setAlertParams } = props; + + useEffect(() => { + setAlertParams('numTimes', numTimes); + }, [numTimes, setAlertParams]); + + useEffect(() => { + const timerangeUnit = timerangeUnitOptions.find(({ checked }) => checked === 'on')?.key ?? 'm'; + setAlertParams('timerange', { from: `now-${numMins}${timerangeUnit}`, to: 'now' }); + }, [numMins, timerangeUnitOptions, setAlertParams]); + + useEffect(() => { + if (allLabels) { + setAlertParams('locations', []); + } else { + setAlertParams( + 'locations', + selectedLocations.filter(l => l.checked === 'on').map(l => l.label) + ); + } + }, [selectedLocations, setAlertParams, allLabels]); + + useEffect(() => { + setAlertParams('filters', filters); + }, [filters, setAlertParams]); + + return ( + <> + + + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.numTimesExpression" + description="any monitor is down >" + id="ping-count" + value={`${numTimes} times`} + /> + + + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeValueExpression" + description="within" + id="timerange" + value={`last ${numMins}`} + /> + + + + +
+ +
+
+ { + if (newOptions.reduce((acc, { checked }) => acc || checked === 'on', false)) { + setTimerangeUnitOptions(newOptions); + } + }} + singleSelection={true} + listProps={{ + showIcons: true, + }} + > + {list => list} + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeUnitExpression" + description="" + id="timerange-unit" + value={ + timerangeUnitOptions.find(({ checked }) => checked === 'on')?.label.toLowerCase() ?? + '' + } + /> +
+
+ + {selectedLocations.length === 0 && ( + + )} + {selectedLocations.length > 0 && ( + + + { + setAllLabels(!allLabels); + setSelectedLocations( + selectedLocations.map((l: any) => ({ + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.monitorStatus.locationSelection', + { + defaultMessage: 'Select the location {location}', + values: { + location: l, + }, + } + ), + ...l, + 'data-test-subj': `xpack.uptime.alerts.monitorStatus.locationSelection.${l.label}LocationOption`, + disabled: !allLabels, + })) + ); + }} + /> + + + setSelectedLocations(e)} + > + {location => location} + + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.locationsSelectionExpression" + description="from" + id="locations" + value={ + selectedLocations.length === 0 || allLabels + ? 'any location' + : selectedLocationsToString(selectedLocations) + } + /> + )} + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts new file mode 100644 index 0000000000000..275333b60c5ee --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AlertMonitorStatusComponent } from './alert_monitor_status'; +export { ToggleAlertFlyoutButtonComponent } from './toggle_alert_flyout_button'; +export { UptimeAlertsContextProvider } from './uptime_alerts_context_provider'; +export { UptimeAlertsFlyoutWrapperComponent } from './uptime_alerts_flyout_wrapper'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx new file mode 100644 index 0000000000000..99853a9f775ec --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; + +interface Props { + setAlertFlyoutVisible: (value: boolean) => void; +} + +export const ToggleAlertFlyoutButtonComponent = ({ setAlertFlyoutVisible }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const kibana = useKibana(); + + return ( + setIsOpen(!isOpen)} + > + +
+ } + closePopover={() => setIsOpen(false)} + isOpen={isOpen} + ownFocus + > + setAlertFlyoutVisible(true)} + > + + , + + + , + ]} + /> + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx new file mode 100644 index 0000000000000..a174a7d9c0ea4 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { AlertsContextProvider } from '../../../../../../../plugins/triggers_actions_ui/public'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; + +export const UptimeAlertsContextProvider: React.FC = ({ children }) => { + const { + services: { + data: { fieldFormats }, + http, + charts, + notifications, + triggers_actions_ui: { actionTypeRegistry, alertTypeRegistry }, + uiSettings, + }, + } = useKibana(); + + return ( + + {children} + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx new file mode 100644 index 0000000000000..13705e7d19293 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { AlertAdd } from '../../../../../../../plugins/triggers_actions_ui/public'; + +interface Props { + alertFlyoutVisible: boolean; + alertTypeId?: string; + canChangeTrigger?: boolean; + setAlertFlyoutVisibility: React.Dispatch>; +} + +export const UptimeAlertsFlyoutWrapperComponent = ({ + alertFlyoutVisible, + alertTypeId, + canChangeTrigger, + setAlertFlyoutVisibility, +}: Props) => ( + +); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts index daba13d8df641..8d0352e01d40e 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts @@ -4,6 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +export { + ToggleAlertFlyoutButtonComponent, + UptimeAlertsContextProvider, + UptimeAlertsFlyoutWrapperComponent, +} from './alerts'; +export * from './alerts'; export { DonutChart } from './charts/donut_chart'; export { KueryBarComponent } from './kuery_bar/kuery_bar'; export { MonitorCharts } from './monitor_charts'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx index 2f5ccc2adf313..63aceed2be636 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx @@ -33,14 +33,18 @@ function convertKueryToEsQuery(kuery: string, indexPattern: IIndexPattern) { } interface Props { + 'aria-label': string; autocomplete: DataPublicPluginSetup['autocomplete']; + 'data-test-subj': string; loadIndexPattern: () => void; indexPattern: IIndexPattern | null; loading: boolean; } export function KueryBarComponent({ + 'aria-label': ariaLabel, autocomplete: autocompleteService, + 'data-test-subj': dataTestSubj, loadIndexPattern, indexPattern, loading, @@ -119,6 +123,8 @@ export function KueryBarComponent({ return ( -
+
{ @@ -205,7 +204,7 @@ describe('PingList component', () => { loading={false} data={{ allPings }} onPageCountChange={jest.fn()} - onSelectedLocationChange={(loc: EuiComboBoxOptionOption[]) => {}} + onSelectedLocationChange={(_loc: any[]) => {}} onSelectedStatusChange={jest.fn()} pageSize={30} selectedOption="down" diff --git a/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx b/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx index a377b9ed1507b..a2f3328b98612 100644 --- a/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx +++ b/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx @@ -10,6 +10,7 @@ import ReactDOM from 'react-dom'; import { get } from 'lodash'; import { i18n as i18nFormatter } from '@kbn/i18n'; import { PluginsSetup } from 'ui/new_platform/new_platform'; +import { alertTypeInitializers } from '../../alert_types'; import { UptimeApp, UptimeAppProps } from '../../../uptime_app'; import { getIntegratedAppAvailability } from './capabilities_adapter'; import { @@ -32,15 +33,30 @@ export const getKibanaFrameworkAdapter = ( http: { basePath }, i18n, } = core; + + const { + data: { autocomplete }, + // TODO: after NP migration we can likely fix this typing problem + // @ts-ignore we don't control this type + triggers_actions_ui, + } = plugins; + + alertTypeInitializers.forEach(init => + triggers_actions_ui.alertTypeRegistry.register(init({ autocomplete })) + ); + let breadcrumbs: ChromeBreadcrumb[] = []; core.chrome.getBreadcrumbs$().subscribe((nextBreadcrumbs?: ChromeBreadcrumb[]) => { breadcrumbs = nextBreadcrumbs || []; }); + const { apm, infrastructure, logs } = getIntegratedAppAvailability( capabilities, INTEGRATED_SOLUTIONS ); + const canSave = get(capabilities, 'uptime.save', false); + const props: UptimeAppProps = { basePath: basePath.get(), canSave, diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts b/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts new file mode 100644 index 0000000000000..6323ee3951e21 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { validate, initMonitorStatusAlertType } from '../monitor_status'; + +describe('monitor status alert type', () => { + describe('validate', () => { + let params: any; + + beforeEach(() => { + params = { + locations: [], + numTimes: 5, + timerange: { + from: 'now-15m', + to: 'now', + }, + }; + }); + + it(`doesn't throw on empty set`, () => { + expect(validate({})).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/locations: Array", + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number", + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }", + ], + }, + } + `); + }); + + describe('timerange', () => { + it('is undefined', () => { + delete params.timerange; + expect(validate(params)).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }", + ], + }, + } + `); + }); + + it('is missing `from` or `to` value', () => { + expect( + validate({ + ...params, + timerange: {}, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }/from: string", + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }/to: string", + ], + }, + } + `); + }); + + it('is invalid timespan', () => { + expect( + validate({ + ...params, + timerange: { + from: 'now', + to: 'now-15m', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "invalidTimeRange": "Time range start cannot exceed time range end", + }, + } + `); + }); + + it('has unparse-able `from` value', () => { + expect( + validate({ + ...params, + timerange: { + from: 'cannot parse this to a date', + to: 'now', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "timeRangeStartValueNaN": "Specified time range \`from\` is an invalid value", + }, + } + `); + }); + + it('has unparse-able `to` value', () => { + expect( + validate({ + ...params, + timerange: { + from: 'now-15m', + to: 'cannot parse this to a date', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "timeRangeEndValueNaN": "Specified time range \`to\` is an invalid value", + }, + } + `); + }); + }); + + describe('numTimes', () => { + it('is missing', () => { + delete params.numTimes; + expect(validate(params)).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number", + ], + }, + } + `); + }); + + it('is NaN', () => { + expect(validate({ ...params, numTimes: `this isn't a number` })).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value \\"this isn't a number\\" supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number", + ], + }, + } + `); + }); + + it('is < 1', () => { + expect(validate({ ...params, numTimes: 0 })).toMatchInlineSnapshot(` + Object { + "errors": Object { + "invalidNumTimes": "Number of alert check down times must be an integer greater than 0", + }, + } + `); + }); + }); + }); + + describe('initMonitorStatusAlertType', () => { + expect(initMonitorStatusAlertType({ autocomplete: {} })).toMatchInlineSnapshot(` + Object { + "alertParamsExpression": [Function], + "defaultActionMessage": "{{context.message}} + {{context.completeIdList}}", + "iconClass": "uptimeApp", + "id": "xpack.uptime.alerts.monitorStatus", + "name": "Uptime Monitor Status", + "validate": [Function], + } + `); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts b/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts new file mode 100644 index 0000000000000..f764505a6d683 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// TODO: after NP migration is complete we should be able to remove this lint ignore comment +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertTypeModel } from '../../../../../../plugins/triggers_actions_ui/public/types'; +import { initMonitorStatusAlertType } from './monitor_status'; + +export type AlertTypeInitializer = (dependenies: { autocomplete: any }) => AlertTypeModel; + +export const alertTypeInitializers: AlertTypeInitializer[] = [initMonitorStatusAlertType]; diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx new file mode 100644 index 0000000000000..effbb59539d16 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PathReporter } from 'io-ts/lib/PathReporter'; +import React from 'react'; +import DateMath from '@elastic/datemath'; +import { isRight } from 'fp-ts/lib/Either'; +import { + AlertTypeModel, + ValidationResult, + // TODO: this typing issue should be resolved after NP migration + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../plugins/triggers_actions_ui/public/types'; +import { AlertTypeInitializer } from '.'; +import { StatusCheckExecutorParamsType } from '../../../common/runtime_types'; +import { AlertMonitorStatus } from '../../components/connected/alerts'; + +export const validate = (alertParams: any): ValidationResult => { + const errors: Record = {}; + const decoded = StatusCheckExecutorParamsType.decode(alertParams); + + /* + * When the UI initially loads, this validate function is called with an + * empty set of params, we don't want to type check against that. + */ + if (!isRight(decoded)) { + errors.typeCheckFailure = 'Provided parameters do not conform to the expected type.'; + errors.typeCheckParsingMessage = PathReporter.report(decoded); + } + + if (isRight(decoded)) { + const { numTimes, timerange } = decoded.right; + const { from, to } = timerange; + const fromAbs = DateMath.parse(from)?.valueOf(); + const toAbs = DateMath.parse(to)?.valueOf(); + if (!fromAbs || isNaN(fromAbs)) { + errors.timeRangeStartValueNaN = 'Specified time range `from` is an invalid value'; + } + if (!toAbs || isNaN(toAbs)) { + errors.timeRangeEndValueNaN = 'Specified time range `to` is an invalid value'; + } + + // the default values for this test will pass, we only want to specify an error + // in the case that `from` is more recent than `to` + if ((fromAbs ?? 0) > (toAbs ?? 1)) { + errors.invalidTimeRange = 'Time range start cannot exceed time range end'; + } + + if (numTimes < 1) { + errors.invalidNumTimes = 'Number of alert check down times must be an integer greater than 0'; + } + } + + return { errors }; +}; + +export const initMonitorStatusAlertType: AlertTypeInitializer = ({ + autocomplete, +}): AlertTypeModel => ({ + id: 'xpack.uptime.alerts.monitorStatus', + name: 'Uptime Monitor Status', + iconClass: 'uptimeApp', + alertParamsExpression: params => { + return ; + }, + validate, + defaultActionMessage: '{{context.message}}\n{{context.completeIdList}}', +}); diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap index 5906a77f55441..30e15ba132996 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap @@ -14,6 +14,39 @@ Array [ TestingHeading
+
+
+
+ +
+
+
@@ -130,6 +163,39 @@ Array [ TestingHeading
+
+
+
+ +
+
+
,
{ const simpleBreadcrumbs: ChromeBreadcrumb[] = [ @@ -21,22 +22,26 @@ describe('PageHeader', () => { it('shallow renders with breadcrumbs and the date picker', () => { const component = renderWithRouter( - + + + ); expect(component).toMatchSnapshot('page_header_with_date_picker'); }); it('shallow renders with breadcrumbs without the date picker', () => { const component = renderWithRouter( - + + + ); expect(component).toMatchSnapshot('page_header_no_date_picker'); }); @@ -45,13 +50,15 @@ describe('PageHeader', () => { const [getBreadcrumbs, core] = mockCore(); mountWithRouter( - - - + + + + + ); @@ -62,6 +69,19 @@ describe('PageHeader', () => { }); }); +const MockReduxProvider = ({ children }: { children: React.ReactElement }) => ( + + {children} + +); + const mockCore: () => [() => ChromeBreadcrumb[], any] = () => { let breadcrumbObj: ChromeBreadcrumb[] = []; const get = () => { diff --git a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx index af9b8bf046416..f9184e2a0587f 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx @@ -83,7 +83,13 @@ export const OverviewPageComponent = ({ autocomplete, indexPattern, setEsKueryFi - + diff --git a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx index b0fb2d0ed7869..56d9ae2d5caa6 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx @@ -13,6 +13,7 @@ import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; import { useUrlParams } from '../hooks'; import { UptimeUrlParams } from '../lib/helper'; +import { ToggleAlertFlyoutButton } from '../components/connected'; interface PageHeaderProps { headingText: string; @@ -60,6 +61,9 @@ export const PageHeader = ({ headingText, breadcrumbs, datePicker = true }: Page

{headingText}

+ + + {datePickerComponent}
diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts index d15d601737b2d..4885f974dbbd4 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts @@ -12,6 +12,8 @@ export interface PopoverState { export type UiPayload = PopoverState & string & number & Map; +export const setAlertFlyoutVisible = createAction('TOGGLE ALERT FLYOUT'); + export const setBasePath = createAction('SET BASE PATH'); export const triggerAppRefresh = createAction('REFRESH APP'); diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap index 5d03c0058c3c1..1dc4e45606c60 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap @@ -2,6 +2,7 @@ exports[`ui reducer adds integration popover status to state 1`] = ` Object { + "alertFlyoutVisible": false, "basePath": "", "esKuery": "", "integrationsPopoverOpen": Object { @@ -14,6 +15,7 @@ Object { exports[`ui reducer sets the application's base path 1`] = ` Object { + "alertFlyoutVisible": false, "basePath": "yyz", "esKuery": "", "integrationsPopoverOpen": null, @@ -23,6 +25,7 @@ Object { exports[`ui reducer updates the refresh value 1`] = ` Object { + "alertFlyoutVisible": false, "basePath": "abc", "esKuery": "", "integrationsPopoverOpen": null, diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts index 417095b64ba2d..3c134366347aa 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { setBasePath, toggleIntegrationsPopover, triggerAppRefresh } from '../../actions'; +import { + setBasePath, + toggleIntegrationsPopover, + triggerAppRefresh, + setAlertFlyoutVisible, +} from '../../actions'; import { uiReducer } from '../ui'; import { Action } from 'redux-actions'; @@ -14,6 +19,7 @@ describe('ui reducer', () => { expect( uiReducer( { + alertFlyoutVisible: false, basePath: 'abc', esKuery: '', integrationsPopoverOpen: null, @@ -32,6 +38,7 @@ describe('ui reducer', () => { expect( uiReducer( { + alertFlyoutVisible: false, basePath: '', esKuery: '', integrationsPopoverOpen: null, @@ -47,6 +54,7 @@ describe('ui reducer', () => { expect( uiReducer( { + alertFlyoutVisible: false, basePath: 'abc', esKuery: '', integrationsPopoverOpen: null, @@ -56,4 +64,28 @@ describe('ui reducer', () => { ) ).toMatchSnapshot(); }); + + it('updates the alert flyout value', () => { + const action = setAlertFlyoutVisible(true) as Action; + expect( + uiReducer( + { + alertFlyoutVisible: false, + basePath: '', + esKuery: '', + integrationsPopoverOpen: null, + lastRefresh: 125, + }, + action + ) + ).toMatchInlineSnapshot(` + Object { + "alertFlyoutVisible": true, + "basePath": "", + "esKuery": "", + "integrationsPopoverOpen": null, + "lastRefresh": 125, + } + `); + }); }); diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts index bb5bd22085ac6..702d314250521 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts @@ -12,19 +12,22 @@ import { setEsKueryString, triggerAppRefresh, UiPayload, + setAlertFlyoutVisible, } from '../actions/ui'; export interface UiState { - integrationsPopoverOpen: PopoverState | null; + alertFlyoutVisible: boolean; basePath: string; esKuery: string; + integrationsPopoverOpen: PopoverState | null; lastRefresh: number; } const initialState: UiState = { - integrationsPopoverOpen: null, + alertFlyoutVisible: false, basePath: '', esKuery: '', + integrationsPopoverOpen: null, lastRefresh: Date.now(), }; @@ -35,6 +38,11 @@ export const uiReducer = handleActions( integrationsPopoverOpen: action.payload as PopoverState, }), + [String(setAlertFlyoutVisible)]: (state, action: Action) => ({ + ...state, + alertFlyoutVisible: action.payload ?? !state.alertFlyoutVisible, + }), + [String(setBasePath)]: (state, action: Action) => ({ ...state, basePath: action.payload as string, diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts index de446418632b8..b1da995709f93 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -35,6 +35,7 @@ describe('state selectors', () => { loading: false, }, ui: { + alertFlyoutVisible: false, basePath: 'yyz', esKuery: '', integrationsPopoverOpen: null, diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts index 4767c25e8f52f..7b5a5ddf8d3ca 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts @@ -46,6 +46,15 @@ export const selectDurationLines = ({ monitorDuration }: AppState) => { return monitorDuration; }; +export const selectAlertFlyoutVisibility = ({ ui: { alertFlyoutVisible } }: AppState) => + alertFlyoutVisible; + +export const selectMonitorStatusAlert = ({ indexPattern, overviewFilters, ui }: AppState) => ({ + filters: ui.esKuery, + indexPattern: indexPattern.index_pattern, + locations: overviewFilters.filters.locations, +}); + export const indexStatusSelector = ({ indexStatus }: AppState) => { return indexStatus; }; diff --git a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx index 09156db9ca7d2..fa2998532d145 100644 --- a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx +++ b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx @@ -23,6 +23,8 @@ import { CommonlyUsedRange } from './components/functional/uptime_date_picker'; import { store } from './state'; import { setBasePath } from './state/actions'; import { PageRouter } from './routes'; +import { UptimeAlertsFlyoutWrapper } from './components/connected'; +import { UptimeAlertsContextProvider } from './components/functional/alerts'; import { kibanaService } from './state/kibana_service'; export interface UptimeAppColors { @@ -99,11 +101,14 @@ const Application = (props: UptimeAppProps) => { - -
- -
-
+ + +
+ + +
+
+
diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 68a222cb656ed..6f58e2702ec5b 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -24,6 +24,8 @@ export const CaseAttributesRt = rt.intersection([ CaseBasicRt, rt.type({ comment_ids: rt.array(rt.string), + closed_at: rt.union([rt.string, rt.null]), + closed_by: rt.union([UserRT, rt.null]), created_at: rt.string, created_by: UserRT, updated_at: rt.union([rt.string, rt.null]), diff --git a/x-pack/plugins/case/common/api/user.ts b/x-pack/plugins/case/common/api/user.ts index ed44791c4e04d..651cd08f08a02 100644 --- a/x-pack/plugins/case/common/api/user.ts +++ b/x-pack/plugins/case/common/api/user.ts @@ -7,6 +7,7 @@ import * as rt from 'io-ts'; export const UserRT = rt.type({ + email: rt.union([rt.undefined, rt.string]), full_name: rt.union([rt.undefined, rt.string]), username: rt.string, }); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts index 17a2518482637..c08dae1dc18b4 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts @@ -13,7 +13,11 @@ function createAuthenticationMock({ authc.getCurrentUser.mockReturnValue( currentUser !== undefined ? currentUser - : ({ username: 'awesome', full_name: 'Awesome D00d' } as AuthenticatedUser) + : ({ + email: 'd00d@awesome.com', + username: 'awesome', + full_name: 'Awesome D00d', + } as AuthenticatedUser) ); return authc; } diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 1e1965f83ff68..5aa8b93f17b08 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -12,10 +12,13 @@ export const mockCases: Array> = [ type: 'cases', id: 'mock-id-1', attributes: { + closed_at: null, + closed_by: null, comment_ids: ['mock-comment-1'], created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, description: 'This is a brand new case of a bad meanie defacing data', @@ -25,6 +28,7 @@ export const mockCases: Array> = [ updated_at: '2019-11-25T21:54:48.952Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -36,10 +40,13 @@ export const mockCases: Array> = [ type: 'cases', id: 'mock-id-2', attributes: { + closed_at: null, + closed_by: null, comment_ids: [], created_at: '2019-11-25T22:32:00.900Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, description: 'Oh no, a bad meanie destroying data!', @@ -49,6 +56,7 @@ export const mockCases: Array> = [ updated_at: '2019-11-25T22:32:00.900Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -60,10 +68,13 @@ export const mockCases: Array> = [ type: 'cases', id: 'mock-id-3', attributes: { + closed_at: null, + closed_by: null, comment_ids: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', @@ -73,6 +84,39 @@ export const mockCases: Array> = [ updated_at: '2019-11-25T22:32:17.947Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + references: [], + updated_at: '2019-11-25T22:32:17.947Z', + version: 'WzUsMV0=', + }, + { + type: 'cases', + id: 'mock-id-4', + attributes: { + closed_at: '2019-11-25T22:32:17.947Z', + closed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comment_ids: [], + created_at: '2019-11-25T22:32:17.947Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'Oh no, a bad meanie going LOLBins all over the place!', + title: 'Another bad one', + status: 'closed', + tags: ['LOLBins'], + updated_at: '2019-11-25T22:32:17.947Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -100,11 +144,13 @@ export const mockCaseComments: Array> = [ created_at: '2019-11-25T21:55:00.177Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, updated_at: '2019-11-25T21:55:00.177Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -126,11 +172,13 @@ export const mockCaseComments: Array> = [ created_at: '2019-11-25T21:55:14.633Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, updated_at: '2019-11-25T21:55:14.633Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -153,11 +201,13 @@ export const mockCaseComments: Array> = [ created_at: '2019-11-25T22:32:30.608Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, updated_at: '2019-11-25T22:32:30.608Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index 0166ba89eb76c..c14a94e84e51c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -56,14 +56,14 @@ export function initPatchCommentApi({ caseService, router }: RouteDeps) { } const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const updatedComment = await caseService.patchComment({ client: context.core.savedObjects.client, commentId: query.id, updatedAttributes: { comment: query.comment, updated_at: new Date().toISOString(), - updated_by: { full_name, username }, + updated_by: { email, full_name, username }, }, version: query.version, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index 1da1161ab01d1..1542394fc438d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -49,7 +49,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout } const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const updateDate = new Date().toISOString(); const patch = await caseConfigureService.patch({ @@ -58,7 +58,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout updatedAttributes: { ...queryWithoutVersion, updated_at: updateDate, - updated_by: { full_name, username }, + updated_by: { email, full_name, username }, }, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index a22dd8437e508..c839d36dcf4df 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -43,7 +43,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route ); } const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const creationDate = new Date().toISOString(); const post = await caseConfigureService.post({ @@ -51,7 +51,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route attributes: { ...query, created_at: creationDate, - created_by: { full_name, username }, + created_by: { email, full_name, username }, updated_at: null, updated_by: null, }, diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts index 7ce37d2569e57..8fafb1af0eb82 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts @@ -34,6 +34,6 @@ describe('GET all cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.cases).toHaveLength(3); + expect(response.payload.cases).toHaveLength(4); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 7ab7212d2f436..19ff7f0734a77 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -25,7 +25,7 @@ describe('PATCH cases', () => { toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), })); }); - it(`Patch a case`, async () => { + it(`Close a case`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', method: 'patch', @@ -50,17 +50,61 @@ describe('PATCH cases', () => { expect(response.status).toEqual(200); expect(response.payload).toEqual([ { + closed_at: '2019-11-25T21:54:48.952Z', + closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, comment_ids: ['mock-comment-1'], comments: [], created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'elastic', username: 'elastic' }, + created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', status: 'closed', tags: ['defacement'], title: 'Super Bad Security Issue', updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { full_name: 'Awesome D00d', username: 'awesome' }, + updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, + version: 'WzE3LDFd', + }, + ]); + }); + it(`Open a case`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'patch', + body: { + cases: [ + { + id: 'mock-id-4', + status: 'open', + version: 'WzUsMV0=', + }, + ], + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload).toEqual([ + { + closed_at: null, + closed_by: null, + comment_ids: [], + comments: [], + created_at: '2019-11-25T22:32:17.947Z', + created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, + description: 'Oh no, a bad meanie going LOLBins all over the place!', + id: 'mock-id-4', + status: 'open', + tags: ['LOLBins'], + title: 'Another bad one', + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', }, ]); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 3fd8c2a1627ab..4aa0d8daf5b34 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -37,10 +37,23 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { client: context.core.savedObjects.client, caseIds: query.cases.map(q => q.id), }); + let nonExistingCases: CasePatchRequest[] = []; const conflictedCases = query.cases.filter(q => { const myCase = myCases.saved_objects.find(c => c.id === q.id); + + if (myCase && myCase.error) { + nonExistingCases = [...nonExistingCases, q]; + return false; + } return myCase == null || myCase?.version !== q.version; }); + if (nonExistingCases.length > 0) { + throw Boom.notFound( + `These cases ${nonExistingCases + .map(c => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } if (conflictedCases.length > 0) { throw Boom.conflict( `These cases ${conflictedCases @@ -60,18 +73,31 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { }); if (updateFilterCases.length > 0) { const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ client: context.core.savedObjects.client, cases: updateFilterCases.map(thisCase => { const { id: caseId, version, ...updateCaseAttributes } = thisCase; + let closedInfo = {}; + if (updateCaseAttributes.status && updateCaseAttributes.status === 'closed') { + closedInfo = { + closed_at: updatedDt, + closed_by: { email, full_name, username }, + }; + } else if (updateCaseAttributes.status && updateCaseAttributes.status === 'open') { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } return { caseId, updatedAttributes: { ...updateCaseAttributes, + ...closedInfo, updated_at: updatedDt, - updated_by: { full_name, username }, + updated_by: { email, full_name, username }, }, version, }; diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index eac259cc69c5a..7af3e7b70d96f 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -14,7 +14,7 @@ export interface RouteDeps { } export enum SortFieldCase { + closedAt = 'closed_at', createdAt = 'created_at', status = 'status', - updatedAt = 'updated_at', } diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 27ee6fc58e20a..19dbb024d1e0b 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -26,18 +26,22 @@ import { SortFieldCase } from './types'; export const transformNewCase = ({ createdDate, - newCase, + email, full_name, + newCase, username, }: { createdDate: string; - newCase: CaseRequest; + email?: string; full_name?: string; + newCase: CaseRequest; username: string; }): CaseAttributes => ({ + closed_at: newCase.status === 'closed' ? createdDate : null, + closed_by: newCase.status === 'closed' ? { email, full_name, username } : null, comment_ids: [], created_at: createdDate, - created_by: { full_name, username }, + created_by: { email, full_name, username }, updated_at: null, updated_by: null, ...newCase, @@ -46,18 +50,20 @@ export const transformNewCase = ({ interface NewCommentArgs { comment: string; createdDate: string; + email?: string; full_name?: string; username: string; } export const transformNewComment = ({ comment, createdDate, + email, full_name, username, }: NewCommentArgs): CommentAttributes => ({ comment, created_at: createdDate, - created_by: { full_name, username }, + created_by: { email, full_name, username }, updated_at: null, updated_by: null, }); @@ -133,9 +139,9 @@ export const sortToSnake = (sortField: string): SortFieldCase => { case 'createdAt': case 'created_at': return SortFieldCase.createdAt; - case 'updatedAt': - case 'updated_at': - return SortFieldCase.updatedAt; + case 'closedAt': + case 'closed_at': + return SortFieldCase.closedAt; default: return SortFieldCase.createdAt; } diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 2aa64528739b1..8eab040b9ca9c 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -14,6 +14,22 @@ export const caseSavedObjectType: SavedObjectsType = { namespaceAgnostic: false, mappings: { properties: { + closed_at: { + type: 'date', + }, + closed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, comment_ids: { type: 'keyword', }, @@ -28,6 +44,9 @@ export const caseSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, description: { @@ -53,6 +72,9 @@ export const caseSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, }, diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index 51c31421fec2f..f52da886e7611 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -28,6 +28,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { username: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, updated_at: { @@ -41,6 +44,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, }, diff --git a/x-pack/plugins/console_extensions/server/plugin.ts b/x-pack/plugins/console_extensions/server/plugin.ts index f4c41aa0a0ad5..8c2cb4d0db42b 100644 --- a/x-pack/plugins/console_extensions/server/plugin.ts +++ b/x-pack/plugins/console_extensions/server/plugin.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { join } from 'path'; -import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; +import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; -import { ConsoleSetup } from '../../../../src/plugins/console/server'; +import { ConsoleSetup, ConsoleStart } from '../../../../src/plugins/console/server'; import { processors } from './spec/ingest/index'; @@ -14,19 +14,25 @@ interface SetupDependencies { console: ConsoleSetup; } +interface StartDependencies { + console: ConsoleStart; +} + +const CONSOLE_XPACK_JSON_SPEC_PATH = join(__dirname, 'spec/'); + export class ConsoleExtensionsServerPlugin implements Plugin { log: Logger; constructor(private readonly ctx: PluginInitializerContext) { this.log = this.ctx.logger.get(); } - setup( - core: CoreSetup, - { console: { addProcessorDefinition, addExtensionSpecFilePath } }: SetupDependencies - ) { - addExtensionSpecFilePath(join(__dirname, 'spec/')); + setup(core: CoreSetup, { console: { addExtensionSpecFilePath } }: SetupDependencies) { + addExtensionSpecFilePath(CONSOLE_XPACK_JSON_SPEC_PATH); + this.log.debug(`Added extension path to ${CONSOLE_XPACK_JSON_SPEC_PATH}...`); + } + + start(core: CoreStart, { console: { addProcessorDefinition } }: StartDependencies) { processors.forEach(processor => addProcessorDefinition(processor)); - this.log.debug('Installed console autocomplete extensions.'); + this.log.debug('Added processor definition extensions.'); } - start() {} } diff --git a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts index 97bdad23beb24..419ee021a9189 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts @@ -12,11 +12,11 @@ export const LOG_ENTRIES_PATH = '/api/log_entries/entries'; export const logEntriesBaseRequestRT = rt.intersection([ rt.type({ sourceId: rt.string, - startDate: rt.number, - endDate: rt.number, + startTimestamp: rt.number, + endTimestamp: rt.number, }), rt.partial({ - query: rt.string, + query: rt.union([rt.string, rt.null]), size: rt.number, }), ]); @@ -31,7 +31,7 @@ export const logEntriesAfterRequestRT = rt.intersection([ rt.type({ after: rt.union([logEntriesCursorRT, rt.literal('first')]) }), ]); -export const logEntriesCenteredRT = rt.intersection([ +export const logEntriesCenteredRequestRT = rt.intersection([ logEntriesBaseRequestRT, rt.type({ center: logEntriesCursorRT }), ]); @@ -40,38 +40,39 @@ export const logEntriesRequestRT = rt.union([ logEntriesBaseRequestRT, logEntriesBeforeRequestRT, logEntriesAfterRequestRT, - logEntriesCenteredRT, + logEntriesCenteredRequestRT, ]); +export type LogEntriesBaseRequest = rt.TypeOf; +export type LogEntriesBeforeRequest = rt.TypeOf; +export type LogEntriesAfterRequest = rt.TypeOf; +export type LogEntriesCenteredRequest = rt.TypeOf; export type LogEntriesRequest = rt.TypeOf; -// JSON value -const valueRT = rt.union([rt.string, rt.number, rt.boolean, rt.object, rt.null, rt.undefined]); +export const logMessageConstantPartRT = rt.type({ + constant: rt.string, +}); +export const logMessageFieldPartRT = rt.type({ + field: rt.string, + value: rt.unknown, + highlights: rt.array(rt.string), +}); -export const logMessagePartRT = rt.union([ - rt.type({ - constant: rt.string, - }), - rt.type({ - field: rt.string, - value: valueRT, - highlights: rt.array(rt.string), - }), -]); +export const logMessagePartRT = rt.union([logMessageConstantPartRT, logMessageFieldPartRT]); -export const logColumnRT = rt.union([ - rt.type({ columnId: rt.string, timestamp: rt.number }), - rt.type({ - columnId: rt.string, - field: rt.string, - value: rt.union([rt.string, rt.undefined]), - highlights: rt.array(rt.string), - }), - rt.type({ - columnId: rt.string, - message: rt.array(logMessagePartRT), - }), -]); +export const logTimestampColumnRT = rt.type({ columnId: rt.string, timestamp: rt.number }); +export const logFieldColumnRT = rt.type({ + columnId: rt.string, + field: rt.string, + value: rt.unknown, + highlights: rt.array(rt.string), +}); +export const logMessageColumnRT = rt.type({ + columnId: rt.string, + message: rt.array(logMessagePartRT), +}); + +export const logColumnRT = rt.union([logTimestampColumnRT, logFieldColumnRT, logMessageColumnRT]); export const logEntryRT = rt.type({ id: rt.string, @@ -79,15 +80,20 @@ export const logEntryRT = rt.type({ columns: rt.array(logColumnRT), }); -export type LogMessagepart = rt.TypeOf; +export type LogMessageConstantPart = rt.TypeOf; +export type LogMessageFieldPart = rt.TypeOf; +export type LogMessagePart = rt.TypeOf; +export type LogTimestampColumn = rt.TypeOf; +export type LogFieldColumn = rt.TypeOf; +export type LogMessageColumn = rt.TypeOf; export type LogColumn = rt.TypeOf; export type LogEntry = rt.TypeOf; export const logEntriesResponseRT = rt.type({ data: rt.type({ entries: rt.array(logEntryRT), - topCursor: logEntriesCursorRT, - bottomCursor: logEntriesCursorRT, + topCursor: rt.union([logEntriesCursorRT, rt.null]), + bottomCursor: rt.union([logEntriesCursorRT, rt.null]), }), }); diff --git a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts index 516cd67f2764d..f6d61a7177b49 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts @@ -9,7 +9,7 @@ import { logEntriesBaseRequestRT, logEntriesBeforeRequestRT, logEntriesAfterRequestRT, - logEntriesCenteredRT, + logEntriesCenteredRequestRT, logEntryRT, } from './entries'; import { logEntriesCursorRT } from './common'; @@ -36,7 +36,7 @@ export const logEntriesHighlightsAfterRequestRT = rt.intersection([ ]); export const logEntriesHighlightsCenteredRequestRT = rt.intersection([ - logEntriesCenteredRT, + logEntriesCenteredRequestRT, highlightsRT, ]); diff --git a/x-pack/plugins/infra/common/http_api/log_entries/summary.ts b/x-pack/plugins/infra/common/http_api/log_entries/summary.ts index 4a2c0db0e995e..6af4b7c592ab6 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/summary.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/summary.ts @@ -10,8 +10,8 @@ export const LOG_ENTRIES_SUMMARY_PATH = '/api/log_entries/summary'; export const logEntriesSummaryRequestRT = rt.type({ sourceId: rt.string, - startDate: rt.number, - endDate: rt.number, + startTimestamp: rt.number, + endTimestamp: rt.number, bucketSize: rt.number, query: rt.union([rt.string, rt.undefined, rt.null]), }); diff --git a/x-pack/plugins/infra/public/components/logging/log_datepicker.tsx b/x-pack/plugins/infra/public/components/logging/log_datepicker.tsx new file mode 100644 index 0000000000000..e80f738eac6ba --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_datepicker.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSuperDatePicker, EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface LogDatepickerProps { + startDateExpression: string; + endDateExpression: string; + isStreaming: boolean; + onUpdateDateRange?: (range: { startDateExpression: string; endDateExpression: string }) => void; + onStartStreaming?: () => void; + onStopStreaming?: () => void; +} + +export const LogDatepicker: React.FC = ({ + startDateExpression, + endDateExpression, + isStreaming, + onUpdateDateRange, + onStartStreaming, + onStopStreaming, +}) => { + const handleTimeChange = useCallback( + ({ start, end, isInvalid }) => { + if (onUpdateDateRange && !isInvalid) { + onUpdateDateRange({ startDateExpression: start, endDateExpression: end }); + } + }, + [onUpdateDateRange] + ); + + return ( + + + + + + {isStreaming ? ( + + + + ) : ( + + + + )} + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx index 729689e65739e..2bdb1f91a6dde 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx @@ -10,10 +10,10 @@ import { max } from 'lodash'; import * as React from 'react'; import { euiStyled } from '../../../../../observability/public'; -import { SummaryBucket } from './types'; +import { LogEntriesSummaryBucket } from '../../../../common/http_api'; interface DensityChartProps { - buckets: SummaryBucket[]; + buckets: LogEntriesSummaryBucket[]; end: number; start: number; width: number; @@ -38,36 +38,36 @@ export const DensityChart: React.FC = ({ const xMax = max(buckets.map(bucket => bucket.entriesCount)) || 0; const xScale = scaleLinear() .domain([0, xMax]) - .range([0, width * (2 / 3)]); + .range([0, width]); - const path = area() + const path = area() .x0(xScale(0)) .x1(bucket => xScale(bucket.entriesCount)) - .y(bucket => yScale((bucket.start + bucket.end) / 2)) + .y0(bucket => yScale(bucket.start)) + .y1(bucket => yScale(bucket.end)) .curve(curveMonotoneY); - const pathData = path(buckets); - const highestPathCoord = String(pathData) - .replace(/[^.0-9,]/g, ' ') - .split(/[ ,]/) - .reduce((result, num) => (Number(num) > result ? Number(num) : result), 0); + const firstBucket = buckets[0]; + const lastBucket = buckets[buckets.length - 1]; + const pathBuckets = [ + // Make sure the graph starts at the count of the first point + { start, end: start, entriesCount: firstBucket.entriesCount }, + ...buckets, + // Make sure the line ends at the height of the last point + { start: lastBucket.end, end: lastBucket.end, entriesCount: lastBucket.entriesCount }, + // If the last point is not at the end of the minimap, make sure it doesn't extend indefinitely and goes to 0 + { start: end, end, entriesCount: 0 }, + ]; + const pathData = path(pathBuckets); + return ( - - - + + ); }; -const DensityChartNegativeBackground = euiStyled.rect` - fill: ${props => props.theme.eui.euiColorEmptyShade}; -`; - const DensityChartPositiveBackground = euiStyled.rect` fill: ${props => props.theme.darkMode diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx index 2e45bcea42109..975e83e0075ff 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx @@ -13,6 +13,7 @@ interface HighlightedIntervalProps { getPositionOfTime: (time: number) => number; start: number; end: number; + targetWidth: number; width: number; target: number | null; } @@ -22,6 +23,7 @@ export const HighlightedInterval: React.FC = ({ end, getPositionOfTime, start, + targetWidth, width, target, }) => { @@ -35,14 +37,14 @@ export const HighlightedInterval: React.FC = ({ )} ); diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx index e3a7e5aa30633..c67674d198a3f 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx @@ -13,42 +13,40 @@ import { DensityChart } from './density_chart'; import { HighlightedInterval } from './highlighted_interval'; import { SearchMarkers } from './search_markers'; import { TimeRuler } from './time_ruler'; -import { SummaryBucket, SummaryHighlightBucket } from './types'; +import { + LogEntriesSummaryBucket, + LogEntriesSummaryHighlightsBucket, +} from '../../../../common/http_api'; interface Interval { end: number; start: number; } -interface DragRecord { - startY: number; - currentY: number | null; -} - interface LogMinimapProps { className?: string; height: number; highlightedInterval: Interval | null; jumpToTarget: (params: LogEntryTime) => any; - intervalSize: number; - summaryBuckets: SummaryBucket[]; - summaryHighlightBuckets?: SummaryHighlightBucket[]; + summaryBuckets: LogEntriesSummaryBucket[]; + summaryHighlightBuckets?: LogEntriesSummaryHighlightsBucket[]; target: number | null; + start: number | null; + end: number | null; width: number; } interface LogMinimapState { target: number | null; - drag: DragRecord | null; - svgPosition: ClientRect; timeCursorY: number; } -function calculateYScale(target: number | null, height: number, intervalSize: number) { - const domainStart = target ? target - intervalSize / 2 : 0; - const domainEnd = target ? target + intervalSize / 2 : 0; +// Wide enough to fit "September" +const TIMERULER_WIDTH = 50; + +function calculateYScale(start: number | null, end: number | null, height: number) { return scaleLinear() - .domain([domainStart, domainEnd]) + .domain([start || 0, end || 0]) .range([0, height]); } @@ -58,103 +56,28 @@ export class LogMinimap extends React.Component = event => { + const minimapTop = event.currentTarget.getBoundingClientRect().top; + const clickedYPosition = event.clientY - minimapTop; - public handleClick = (event: MouseEvent) => { - if (!this.dragTargetArea) return; - const svgPosition = this.dragTargetArea.getBoundingClientRect(); - const clickedYPosition = event.clientY - svgPosition.top; const clickedTime = Math.floor(this.getYScale().invert(clickedYPosition)); - this.setState({ - drag: null, - }); - this.props.jumpToTarget({ - tiebreaker: 0, - time: clickedTime, - }); - }; - - private handleMouseDown: React.MouseEventHandler = event => { - const { clientY, target } = event; - if (target === this.dragTargetArea) { - const svgPosition = event.currentTarget.getBoundingClientRect(); - this.setState({ - drag: { - startY: clientY, - currentY: null, - }, - svgPosition, - }); - window.addEventListener('mousemove', this.handleDragMove); - } - window.addEventListener('mouseup', this.handleMouseUp); - }; - - private handleMouseUp = (event: MouseEvent) => { - window.removeEventListener('mousemove', this.handleDragMove); - window.removeEventListener('mouseup', this.handleMouseUp); - const { drag, svgPosition } = this.state; - if (!drag || !drag.currentY) { - this.handleClick(event); - return; - } - const getTime = (pos: number) => Math.floor(this.getYScale().invert(pos)); - const startYPosition = drag.startY - svgPosition.top; - const endYPosition = event.clientY - svgPosition.top; - const startTime = getTime(startYPosition); - const endTime = getTime(endYPosition); - const timeDifference = endTime - startTime; - const newTime = (this.props.target || 0) - timeDifference; - this.setState({ drag: null, target: newTime }); this.props.jumpToTarget({ tiebreaker: 0, - time: newTime, - }); - }; - - private handleDragMove = (event: MouseEvent) => { - const { drag } = this.state; - if (!drag) return; - this.setState({ - drag: { - ...drag, - currentY: event.clientY, - }, + time: clickedTime, }); }; public getYScale = () => { - const { target } = this.state; - const { height, intervalSize } = this.props; - return calculateYScale(target, height, intervalSize); + const { start, end, height } = this.props; + return calculateYScale(start, end, height); }; public getPositionOfTime = (time: number) => { - const { height, intervalSize } = this.props; - - const [minTime] = this.getYScale().domain(); - - return ((time - minTime) * height) / intervalSize; // + return this.getYScale()(time); }; private updateTimeCursor: React.MouseEventHandler = event => { @@ -166,6 +89,8 @@ export class LogMinimap extends React.Component - + + + - - - + {highlightedInterval ? ( ) : null} - - { - this.dragTargetArea = node; - }} - x={0} - y={0} - width={width / 3} - height={height} - /> + ); } } -const DragTargetArea = euiStyled.rect<{ isGrabbing: boolean }>` - fill: transparent; - cursor: ${({ isGrabbing }) => (isGrabbing ? 'grabbing' : 'grab')}; -`; - const MinimapBorder = euiStyled.line` stroke: ${props => props.theme.eui.euiColorMediumShade}; stroke-width: 1px; @@ -269,9 +170,9 @@ const TimeCursor = euiStyled.line` : props.theme.eui.euiColorDarkShade}; `; -const MinimapWrapper = euiStyled.svg<{ showOverscanBoundaries: boolean }>` - background: ${props => - props.showOverscanBoundaries ? props.theme.eui.euiColorMediumShade : 'transparent'}; +const MinimapWrapper = euiStyled.svg` + cursor: pointer; + fill: ${props => props.theme.eui.euiColorEmptyShade}; & ${TimeCursor} { visibility: hidden; } diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx index 8b87aa15f16f0..18d4a3bbfc8b3 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx @@ -10,10 +10,9 @@ import * as React from 'react'; import { euiStyled, keyframes } from '../../../../../observability/public'; import { LogEntryTime } from '../../../../common/log_entry'; import { SearchMarkerTooltip } from './search_marker_tooltip'; -import { SummaryHighlightBucket } from './types'; - +import { LogEntriesSummaryHighlightsBucket } from '../../../../common/http_api'; interface SearchMarkerProps { - bucket: SummaryHighlightBucket; + bucket: LogEntriesSummaryHighlightsBucket; height: number; width: number; jumpToTarget: (target: LogEntryTime) => void; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx index ebdc390aef11b..1e254d999036e 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx @@ -10,10 +10,10 @@ import * as React from 'react'; import { LogEntryTime } from '../../../../common/log_entry'; import { SearchMarker } from './search_marker'; -import { SummaryHighlightBucket } from './types'; +import { LogEntriesSummaryHighlightsBucket } from '../../../../common/http_api'; interface SearchMarkersProps { - buckets: SummaryHighlightBucket[]; + buckets: LogEntriesSummaryHighlightsBucket[]; className?: string; end: number; start: number; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx new file mode 100644 index 0000000000000..af981105d1718 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// The default d3-time-format is a bit strange for small ranges, so we will specify our own +export function getTimeLabelFormat(start: number, end: number): string | undefined { + const diff = Math.abs(end - start); + + // 15 seconds + if (diff < 15 * 1000) { + return ':%S.%L'; + } + + // 16 minutes + if (diff < 16 * 60 * 1000) { + return '%I:%M:%S'; + } + + // Use D3's default + return; +} diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx index b610737663e8d..454935c32fe1e 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx @@ -8,6 +8,7 @@ import { scaleTime } from 'd3-scale'; import * as React from 'react'; import { euiStyled } from '../../../../../observability/public'; +import { getTimeLabelFormat } from './time_label_formatter'; interface TimeRulerProps { end: number; @@ -23,37 +24,19 @@ export const TimeRuler: React.FC = ({ end, height, start, tickCo .range([0, height]); const ticks = yScale.ticks(tickCount); - const formatTick = yScale.tickFormat(); - - const dateModLabel = (() => { - for (let i = 0; i < ticks.length; i++) { - const tickLabel = formatTick(ticks[i]); - if (!tickLabel[0].match(/[0-9]/)) { - return i % 12; - } - } - })(); + const formatTick = yScale.tickFormat(tickCount, getTimeLabelFormat(start, end)); return ( {ticks.map((tick, tickIndex) => { const y = yScale(tick); - const isLabeledTick = tickIndex % 12 === dateModLabel; - const tickStartX = isLabeledTick ? 0 : width / 3 - 4; + return ( - {isLabeledTick && ( - - {formatTick(tick)} - - )} - + + {formatTick(tick)} + + ); })} @@ -71,15 +54,11 @@ const TimeRulerTickLabel = euiStyled.text` pointer-events: none; `; -const TimeRulerGridLine = euiStyled.line<{ isDark: boolean }>` +const TimeRulerGridLine = euiStyled.line` stroke: ${props => - props.isDark - ? props.theme.darkMode - ? props.theme.eui.euiColorDarkestShade - : props.theme.eui.euiColorDarkShade - : props.theme.darkMode - ? props.theme.eui.euiColorDarkShade - : props.theme.eui.euiColorMediumShade}; + props.theme.darkMode + ? props.theme.eui.euiColorDarkestShade + : props.theme.eui.euiColorDarkShade}; stroke-opacity: 0.5; stroke-width: 1px; `; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx deleted file mode 100644 index 41c6e554e603a..0000000000000 --- a/x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiFormRow, EuiRadioGroup } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import * as React from 'react'; - -interface IntervalSizeDescriptor { - label: string; - intervalSize: number; -} - -interface LogMinimapScaleControlsProps { - availableIntervalSizes: IntervalSizeDescriptor[]; - intervalSize: number; - setIntervalSize: (intervalSize: number) => any; -} - -export class LogMinimapScaleControls extends React.PureComponent { - public handleScaleChange = (intervalSizeDescriptorKey: string) => { - const { availableIntervalSizes, setIntervalSize } = this.props; - const [sizeDescriptor] = availableIntervalSizes.filter( - intervalKeyEquals(intervalSizeDescriptorKey) - ); - - if (sizeDescriptor) { - setIntervalSize(sizeDescriptor.intervalSize); - } - }; - - public render() { - const { availableIntervalSizes, intervalSize } = this.props; - const [currentSizeDescriptor] = availableIntervalSizes.filter(intervalSizeEquals(intervalSize)); - - return ( - - } - > - ({ - id: getIntervalSizeDescriptorKey(sizeDescriptor), - label: sizeDescriptor.label, - }))} - onChange={this.handleScaleChange} - idSelected={getIntervalSizeDescriptorKey(currentSizeDescriptor)} - /> - - ); - } -} - -const getIntervalSizeDescriptorKey = (sizeDescriptor: IntervalSizeDescriptor) => - `${sizeDescriptor.intervalSize}`; - -const intervalKeyEquals = (key: string) => (sizeDescriptor: IntervalSizeDescriptor) => - getIntervalSizeDescriptorKey(sizeDescriptor) === key; - -const intervalSizeEquals = (size: number) => (sizeDescriptor: IntervalSizeDescriptor) => - sizeDescriptor.intervalSize === size; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts index ca5ca9736b7b3..19e8108ee50e8 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts @@ -7,27 +7,27 @@ import { bisector } from 'd3-array'; import { compareToTimeKey, TimeKey } from '../../../../common/time'; -import { LogEntry, LogEntryHighlight } from '../../../utils/log_entry'; +import { LogEntry } from '../../../../common/http_api'; export type StreamItem = LogEntryStreamItem; export interface LogEntryStreamItem { kind: 'logEntry'; logEntry: LogEntry; - highlights: LogEntryHighlight[]; + highlights: LogEntry[]; } export function getStreamItemTimeKey(item: StreamItem) { switch (item.kind) { case 'logEntry': - return item.logEntry.key; + return item.logEntry.cursor; } } export function getStreamItemId(item: StreamItem) { switch (item.kind) { case 'logEntry': - return `${item.logEntry.key.time}:${item.logEntry.key.tiebreaker}:${item.logEntry.gid}`; + return `${item.logEntry.cursor.time}:${item.logEntry.cursor.tiebreaker}:${item.logEntry.id}`; } } diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx index 8c48d9e176d3b..5598528c0e0f5 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx @@ -6,144 +6,279 @@ /* eslint-disable max-classes-per-file */ -import { EuiButtonEmpty, EuiIcon, EuiProgress, EuiText } from '@elastic/eui'; -import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react'; +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiLoadingSpinner, + EuiButton, +} from '@elastic/eui'; +import { FormattedMessage, FormattedTime, FormattedRelative } from '@kbn/i18n/react'; import * as React from 'react'; +import { Unit } from '@elastic/datemath'; import { euiStyled } from '../../../../../observability/public'; +import { LogTextSeparator } from './log_text_separator'; +import { extendDatemath } from '../../../utils/datemath'; + +type Position = 'start' | 'end'; interface LogTextStreamLoadingItemViewProps { - alignment: 'top' | 'bottom'; + position: Position; + timestamp: number; // Either the top of the bottom's cursor timestamp + startDateExpression: string; + endDateExpression: string; className?: string; hasMore: boolean; isLoading: boolean; isStreaming: boolean; - lastStreamingUpdate: Date | null; - onLoadMore?: () => void; + onExtendRange?: (newDate: string) => void; + onStreamStart?: () => void; } +const TIMESTAMP_FORMAT = { + hour12: false, + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', +}; + export class LogTextStreamLoadingItemView extends React.PureComponent< LogTextStreamLoadingItemViewProps, {} > { public render() { const { - alignment, + position, + timestamp, + startDateExpression, + endDateExpression, className, hasMore, isLoading, isStreaming, - lastStreamingUpdate, - onLoadMore, + onExtendRange, + onStreamStart, } = this.props; - if (isStreaming) { - return ( - - - - - - - {lastStreamingUpdate ? ( - - - - - ), - }} - /> - - - ) : null} - - ); - } else if (isLoading) { - return ( - - - - - - ); - } else if (!hasMore) { - return ( - - - - - {onLoadMore ? ( - - - - ) : null} - - ); - } else { - return null; - } + const shouldShowCta = !hasMore && !isStreaming; + + const extra = ( + + {isLoading || isStreaming ? ( + + ) : shouldShowCta ? ( + + ) : null} + + ); + + return ( + + {position === 'start' ? extra : null} + + {position === 'end' ? extra : null} + + ); } } -interface ProgressEntryProps { - alignment: 'top' | 'bottom'; - className?: string; - color: 'subdued' | 'primary'; - isLoading: boolean; -} +const LoadingItemViewExtra = euiStyled(EuiFlexGroup)` + height: 40px; +`; -const ProgressEntry: React.FC = props => { - const { alignment, children, className, color, isLoading } = props; +const ProgressEntryWrapper = euiStyled.div<{ position: Position }>` + padding-left: ${props => props.theme.eui.euiSizeS}; + padding-top: ${props => + props.position === 'start' ? props.theme.eui.euiSizeL : props.theme.eui.euiSizeM}; + padding-bottom: ${props => + props.position === 'end' ? props.theme.eui.euiSizeL : props.theme.eui.euiSizeM}; +`; - // NOTE: styled-components seems to make all props in EuiProgress required, so this - // style attribute hacking replaces styled-components here for now until that can be fixed - // see: https://github.com/elastic/eui/issues/1655 - const alignmentStyle = - alignment === 'top' ? { top: 0, bottom: 'initial' } : { top: 'initial', bottom: 0 }; +type ProgressMessageProps = Pick< + LogTextStreamLoadingItemViewProps, + 'timestamp' | 'position' | 'isStreaming' +>; +const ProgressMessage: React.FC = ({ timestamp, position, isStreaming }) => { + const formattedTimestamp = + isStreaming && position === 'end' ? ( + + ) : ( + + ); - return ( - - + ) : isStreaming ? ( + + ) : ( + - {children} - + ); + + return ( + + {message} + ); }; -const ProgressEntryWrapper = euiStyled.div` - align-items: center; - display: flex; - min-height: ${props => props.theme.eui.euiSizeXXL}; - position: relative; -`; +const ProgressSpinner: React.FC<{ kind: 'streaming' | 'loading' }> = ({ kind }) => ( + <> + + + + + + {kind === 'streaming' ? ( + + ) : ( + + )} + + + +); -const ProgressMessage = euiStyled.div` - padding: 8px 16px; -`; +type ProgressCtaProps = Pick< + LogTextStreamLoadingItemViewProps, + 'position' | 'startDateExpression' | 'endDateExpression' | 'onExtendRange' | 'onStreamStart' +>; +const ProgressCta: React.FC = ({ + position, + startDateExpression, + endDateExpression, + onExtendRange, + onStreamStart, +}) => { + const rangeEdge = position === 'start' ? startDateExpression : endDateExpression; + + if (rangeEdge === 'now' && position === 'end') { + return ( + + + + ); + } + + const iconType = position === 'start' ? 'arrowUp' : 'arrowDown'; + const extendedRange = + position === 'start' + ? extendDatemath(startDateExpression, 'before', endDateExpression) + : extendDatemath(endDateExpression, 'after', startDateExpression); + if (!extendedRange || !('diffUnit' in extendedRange)) { + return null; + } + + return ( + { + if (typeof onExtendRange === 'function') { + onExtendRange(extendedRange.value); + } + }} + iconType={iconType} + size="s" + > + + + ); +}; + +const ProgressExtendMessage: React.FC<{ amount: number; unit: Unit }> = ({ amount, unit }) => { + switch (unit) { + case 'ms': + return ( + + ); + case 's': + return ( + + ); + case 'm': + return ( + + ); + case 'h': + return ( + + ); + case 'd': + return ( + + ); + case 'w': + return ( + + ); + case 'M': + return ( + + ); + case 'y': + return ( + + ); + default: + throw new TypeError('Unhandled unit: ' + unit); + } +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx index 5d295ca7e4817..5fc4606a774d5 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx @@ -8,15 +8,16 @@ import { mount } from 'enzyme'; import React from 'react'; import { EuiThemeProvider } from '../../../../../observability/public'; -import { LogEntryColumn } from '../../../utils/log_entry'; import { LogEntryFieldColumn } from './log_entry_field_column'; +import { LogColumn } from '../../../../common/http_api'; describe('LogEntryFieldColumn', () => { it('should output a
    when displaying an Array of values', () => { - const column: LogEntryColumn = { + const column: LogColumn = { columnId: 'TEST_COLUMN', field: 'TEST_FIELD', - value: JSON.stringify(['a', 'b', 'c']), + value: ['a', 'b', 'c'], + highlights: [], }; const component = mount( @@ -42,13 +43,14 @@ describe('LogEntryFieldColumn', () => { }); it('should output a text representation of a passed complex value', () => { - const column: LogEntryColumn = { + const column: LogColumn = { columnId: 'TEST_COLUMN', field: 'TEST_FIELD', - value: JSON.stringify({ + value: { lat: 1, lon: 2, - }), + }, + highlights: [], }; const component = mount( @@ -67,10 +69,11 @@ describe('LogEntryFieldColumn', () => { }); it('should output just text when passed a non-Array', () => { - const column: LogEntryColumn = { + const column: LogColumn = { columnId: 'TEST_COLUMN', field: 'TEST_FIELD', - value: JSON.stringify('foo'), + value: 'foo', + highlights: [], }; const component = mount( diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx index c6584f2fdbb6d..202108cda5ac0 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx @@ -8,14 +8,10 @@ import stringify from 'json-stable-stringify'; import React, { useMemo } from 'react'; import { euiStyled } from '../../../../../observability/public'; -import { - isFieldColumn, - isHighlightFieldColumn, - LogEntryColumn, - LogEntryHighlightColumn, -} from '../../../utils/log_entry'; +import { isFieldColumn, isHighlightFieldColumn } from '../../../utils/log_entry'; import { ActiveHighlightMarker, highlightFieldValue, HighlightMarker } from './highlighting'; import { LogEntryColumnContent } from './log_entry_column'; +import { LogColumn } from '../../../../common/http_api'; import { hoveredContentStyle, longWrappedContentStyle, @@ -25,8 +21,8 @@ import { } from './text_styles'; interface LogEntryFieldColumnProps { - columnValue: LogEntryColumn; - highlights: LogEntryHighlightColumn[]; + columnValue: LogColumn; + highlights: LogColumn[]; isActiveHighlight: boolean; isHighlighted: boolean; isHovered: boolean; @@ -41,9 +37,12 @@ export const LogEntryFieldColumn: React.FunctionComponent { - const value = useMemo(() => (isFieldColumn(columnValue) ? JSON.parse(columnValue.value) : null), [ - columnValue, - ]); + const value = useMemo(() => { + if (isFieldColumn(columnValue)) { + return columnValue.value; + } + return null; + }, [columnValue]); const formattedValue = Array.isArray(value) ? (
      {value.map((entry, i) => ( @@ -58,7 +57,7 @@ export const LogEntryFieldColumn: React.FunctionComponent ) : ( highlightFieldValue( - typeof value === 'object' && value != null ? stringify(value) : value, + typeof value === 'string' ? value : stringify(value), isHighlightFieldColumn(firstHighlight) ? firstHighlight.highlights : [], isActiveHighlight ? ActiveHighlightMarker : HighlightMarker ) diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx index 122f0fe472c6e..5ad7cba6427d1 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx @@ -5,6 +5,7 @@ */ import React, { memo, useMemo } from 'react'; +import stringify from 'json-stable-stringify'; import { euiStyled } from '../../../../../observability/public'; import { @@ -12,9 +13,7 @@ import { isFieldSegment, isHighlightMessageColumn, isMessageColumn, - LogEntryColumn, - LogEntryHighlightColumn, - LogEntryMessageSegment, + isHighlightFieldSegment, } from '../../../utils/log_entry'; import { ActiveHighlightMarker, highlightFieldValue, HighlightMarker } from './highlighting'; import { LogEntryColumnContent } from './log_entry_column'; @@ -25,10 +24,11 @@ import { unwrappedContentStyle, WrapMode, } from './text_styles'; +import { LogColumn, LogMessagePart } from '../../../../common/http_api'; interface LogEntryMessageColumnProps { - columnValue: LogEntryColumn; - highlights: LogEntryHighlightColumn[]; + columnValue: LogColumn; + highlights: LogColumn[]; isActiveHighlight: boolean; isHighlighted: boolean; isHovered: boolean; @@ -72,28 +72,39 @@ const MessageColumnContent = euiStyled(LogEntryColumnContent) messageSegments.map((messageSegment, index) => formatMessageSegment( messageSegment, - highlights.map(highlight => - isHighlightMessageColumn(highlight) ? highlight.message[index].highlights : [] - ), + highlights.map(highlight => { + if (isHighlightMessageColumn(highlight)) { + const segment = highlight.message[index]; + if (isHighlightFieldSegment(segment)) { + return segment.highlights; + } + } + return []; + }), isActiveHighlight ) ); const formatMessageSegment = ( - messageSegment: LogEntryMessageSegment, + messageSegment: LogMessagePart, [firstHighlight = []]: string[][], // we only support one highlight for now isActiveHighlight: boolean ): React.ReactNode => { if (isFieldSegment(messageSegment)) { + const value = + typeof messageSegment.value === 'string' + ? messageSegment.value + : stringify(messageSegment.value); + return highlightFieldValue( - messageSegment.value, + value, firstHighlight, isActiveHighlight ? ActiveHighlightMarker : HighlightMarker ); diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx index e5e3740f420e8..ce264245d385b 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx @@ -7,12 +7,7 @@ import React, { memo, useState, useCallback, useMemo } from 'react'; import { euiStyled } from '../../../../../observability/public'; -import { - LogEntry, - LogEntryHighlight, - LogEntryHighlightColumn, - isTimestampColumn, -} from '../../../utils/log_entry'; +import { isTimestampColumn } from '../../../utils/log_entry'; import { LogColumnConfiguration, isTimestampLogColumnConfiguration, @@ -26,12 +21,13 @@ import { LogEntryDetailsIconColumn } from './log_entry_icon_column'; import { LogEntryMessageColumn } from './log_entry_message_column'; import { LogEntryTimestampColumn } from './log_entry_timestamp_column'; import { monospaceTextStyle } from './text_styles'; +import { LogEntry, LogColumn } from '../../../../common/http_api'; interface LogEntryRowProps { boundingBoxRef?: React.Ref; columnConfigurations: LogColumnConfiguration[]; columnWidths: LogEntryColumnWidths; - highlights: LogEntryHighlight[]; + highlights: LogEntry[]; isActiveHighlight: boolean; isHighlighted: boolean; logEntry: LogEntry; @@ -63,9 +59,9 @@ export const LogEntryRow = memo( setIsHovered(false); }, []); - const openFlyout = useCallback(() => openFlyoutWithItem?.(logEntry.gid), [ + const openFlyout = useCallback(() => openFlyoutWithItem?.(logEntry.id), [ openFlyoutWithItem, - logEntry.gid, + logEntry.id, ]); const logEntryColumnsById = useMemo( @@ -85,7 +81,7 @@ export const LogEntryRow = memo( const highlightsByColumnId = useMemo( () => highlights.reduce<{ - [columnId: string]: LogEntryHighlightColumn[]; + [columnId: string]: LogColumn[]; }>( (columnsById, highlight) => highlight.columns.reduce( diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx new file mode 100644 index 0000000000000..9cc91fa11e4ed --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; + +/** + * Create a separator with a text on the right side + */ +export const LogTextSeparator: React.FC = ({ children }) => { + return ( + + {children} + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx index 6544a32ba414c..2c389b47fa6cf 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx @@ -54,6 +54,10 @@ interface ScrollableLogTextStreamViewProps { setFlyoutVisibility: (visible: boolean) => void; highlightedItem: string | null; currentHighlightKey: UniqueTimeKey | null; + startDateExpression: string; + endDateExpression: string; + updateDateRange: (range: { startDateExpression?: string; endDateExpression?: string }) => void; + startLiveStreaming: () => void; } interface ScrollableLogTextStreamViewState { @@ -90,7 +94,7 @@ export class ScrollableLogTextStreamView extends React.PureComponent< targetId: getStreamItemId(getStreamItemBeforeTimeKey(nextProps.items, nextProps.target!)), items: nextItems, }; - } else if (!nextProps.target || !hasItems) { + } else if (!hasItems) { return { target: null, targetId: null, @@ -129,9 +133,13 @@ export class ScrollableLogTextStreamView extends React.PureComponent< isLoadingMore, isReloading, isStreaming, - lastLoadedTime, scale, wrap, + startDateExpression, + endDateExpression, + lastLoadedTime, + updateDateRange, + startLiveStreaming, } = this.props; const { targetId, items, isScrollLocked } = this.state; const hasItems = items.length > 0; @@ -184,72 +192,88 @@ export class ScrollableLogTextStreamView extends React.PureComponent< isLocked={isScrollLocked} entriesCount={items.length} > - {registerChild => ( - <> - - {items.map((item, idx) => { - const currentTimestamp = item.logEntry.key.time; - let showDate = false; + {registerChild => + items.length > 0 ? ( + <> + + updateDateRange({ startDateExpression: newDateExpression }) + } + /> + {items.map((item, idx) => { + const currentTimestamp = item.logEntry.cursor.time; + let showDate = false; - if (idx > 0) { - const prevTimestamp = items[idx - 1].logEntry.key.time; - showDate = !moment(currentTimestamp).isSame(prevTimestamp, 'day'); - } + if (idx > 0) { + const prevTimestamp = items[idx - 1].logEntry.cursor.time; + showDate = !moment(currentTimestamp).isSame(prevTimestamp, 'day'); + } - return ( - - {showDate && } - - {itemMeasureRef => ( - - )} - - - ); - })} - - {isScrollLocked && ( - + {showDate && } + + {itemMeasureRef => ( + + )} + + + ); + })} + + updateDateRange({ endDateExpression: newDateExpression }) + } + onStreamStart={() => startLiveStreaming()} /> - )} - - )} + {isScrollLocked && ( + + )} + + ) : null + } )} @@ -275,14 +299,6 @@ export class ScrollableLogTextStreamView extends React.PureComponent< } }; - private handleLoadNewerItems = () => { - const { loadNewerItems } = this.props; - - if (loadNewerItems) { - loadNewerItems(); - } - }; - // this is actually a method but not recognized as such // eslint-disable-next-line @typescript-eslint/member-ordering private handleVisibleChildrenChange = callWithoutRepeats( diff --git a/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx b/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx deleted file mode 100644 index 3653a6d6bbeae..0000000000000 --- a/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiDatePicker, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import moment, { Moment } from 'moment'; -import React from 'react'; -import { FixedDatePicker } from '../fixed_datepicker'; - -const noop = () => undefined; - -interface LogTimeControlsProps { - currentTime: number | null; - startLiveStreaming: () => any; - stopLiveStreaming: () => void; - isLiveStreaming: boolean; - jumpToTime: (time: number) => any; -} - -export class LogTimeControls extends React.PureComponent { - public render() { - const { currentTime, isLiveStreaming } = this.props; - - const currentMoment = currentTime ? moment(currentTime) : null; - if (isLiveStreaming) { - return ( - - - - - - - - - - - ); - } else { - return ( - - - - - - - - - - - ); - } - } - - private handleChangeDate = (date: Moment | null) => { - if (date !== null) { - this.props.jumpToTime(date.valueOf()); - } - }; - - private startLiveStreaming = () => { - this.props.startLiveStreaming(); - }; - - private stopLiveStreaming = () => { - this.props.stopLiveStreaming(); - }; -} diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts new file mode 100644 index 0000000000000..2a19a82892427 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from '../../../../legacy_singletons'; + +import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; + +import { + LOG_ENTRIES_PATH, + LogEntriesRequest, + logEntriesRequestRT, + logEntriesResponseRT, +} from '../../../../../common/http_api'; + +export const fetchLogEntries = async (requestArgs: LogEntriesRequest) => { + const response = await npStart.http.fetch(LOG_ENTRIES_PATH, { + method: 'POST', + body: JSON.stringify(logEntriesRequestRT.encode(requestArgs)), + }); + + return pipe(logEntriesResponseRT.decode(response), fold(throwErrors(createPlainError), identity)); +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts deleted file mode 100644 index 83bae37c348d4..0000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { ApolloClient } from 'apollo-client'; -import { TimeKey } from '../../../../common/time'; -import { logEntriesQuery } from '../../../graphql/log_entries.gql_query'; -import { useApolloClient } from '../../../utils/apollo_context'; -import { LogEntriesResponse } from '.'; - -const LOAD_CHUNK_SIZE = 200; - -type LogEntriesGetter = ( - client: ApolloClient<{}>, - countBefore: number, - countAfter: number -) => (params: { - sourceId: string; - timeKey: TimeKey | null; - filterQuery: string | null; -}) => Promise; - -const getLogEntries: LogEntriesGetter = (client, countBefore, countAfter) => async ({ - sourceId, - timeKey, - filterQuery, -}) => { - if (!timeKey) throw new Error('TimeKey is null'); - const result = await client.query({ - query: logEntriesQuery, - variables: { - sourceId, - timeKey: { time: timeKey.time, tiebreaker: timeKey.tiebreaker }, - countBefore, - countAfter, - filterQuery, - }, - fetchPolicy: 'no-cache', - }); - // Workaround for Typescript. Since we're removing the GraphQL API in another PR or two - // 7.6 goes out I don't think it's worth the effort to actually make this - // typecheck pass - const { source } = result.data as any; - const { logEntriesAround } = source; - return { - entries: logEntriesAround.entries, - entriesStart: logEntriesAround.start, - entriesEnd: logEntriesAround.end, - hasMoreAfterEnd: logEntriesAround.hasMoreAfter, - hasMoreBeforeStart: logEntriesAround.hasMoreBefore, - lastLoadedTime: new Date(), - }; -}; - -export const useGraphQLQueries = () => { - const client = useApolloClient(); - if (!client) throw new Error('Unable to get Apollo Client from context'); - return { - getLogEntriesAround: getLogEntries(client, LOAD_CHUNK_SIZE, LOAD_CHUNK_SIZE), - getLogEntriesBefore: getLogEntries(client, LOAD_CHUNK_SIZE, 0), - getLogEntriesAfter: getLogEntries(client, 0, LOAD_CHUNK_SIZE), - }; -}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts index 04412f5fdd871..b9a5c4068e166 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts @@ -5,12 +5,18 @@ */ import { useEffect, useState, useReducer, useCallback } from 'react'; import createContainer from 'constate'; -import { pick, throttle, omit } from 'lodash'; -import { useGraphQLQueries } from './gql_queries'; +import { pick, throttle } from 'lodash'; import { TimeKey, timeKeyIsBetween } from '../../../../common/time'; -import { InfraLogEntry } from './types'; +import { + LogEntriesResponse, + LogEntry, + LogEntriesRequest, + LogEntriesBaseRequest, +} from '../../../../common/http_api'; +import { fetchLogEntries } from './api/fetch_log_entries'; const DESIRED_BUFFER_PAGES = 2; +const LIVE_STREAM_INTERVAL = 5000; enum Action { FetchingNewEntries, @@ -20,6 +26,7 @@ enum Action { ReceiveEntriesAfter, ErrorOnNewEntries, ErrorOnMoreEntries, + ExpandRange, } type ReceiveActions = @@ -29,41 +36,46 @@ type ReceiveActions = interface ReceiveEntriesAction { type: ReceiveActions; - payload: LogEntriesResponse; + payload: LogEntriesResponse['data']; +} +interface ExpandRangeAction { + type: Action.ExpandRange; + payload: { before: boolean; after: boolean }; } interface FetchOrErrorAction { - type: Exclude; + type: Exclude; } -type ActionObj = ReceiveEntriesAction | FetchOrErrorAction; +type ActionObj = ReceiveEntriesAction | FetchOrErrorAction | ExpandRangeAction; type Dispatch = (action: ActionObj) => void; interface LogEntriesProps { + startTimestamp: number; + endTimestamp: number; + timestampsLastUpdate: number; filterQuery: string | null; timeKey: TimeKey | null; pagesBeforeStart: number | null; pagesAfterEnd: number | null; sourceId: string; - isAutoReloading: boolean; + isStreaming: boolean; jumpToTargetPosition: (position: TimeKey) => void; } -type FetchEntriesParams = Omit; +type FetchEntriesParams = Omit; type FetchMoreEntriesParams = Pick; -export interface LogEntriesResponse { - entries: InfraLogEntry[]; - entriesStart: TimeKey | null; - entriesEnd: TimeKey | null; - hasMoreAfterEnd: boolean; - hasMoreBeforeStart: boolean; - lastLoadedTime: Date | null; -} - -export type LogEntriesStateParams = { +export interface LogEntriesStateParams { + entries: LogEntriesResponse['data']['entries']; + topCursor: LogEntriesResponse['data']['topCursor'] | null; + bottomCursor: LogEntriesResponse['data']['bottomCursor'] | null; + centerCursor: TimeKey | null; isReloading: boolean; isLoadingMore: boolean; -} & LogEntriesResponse; + lastLoadedTime: Date | null; + hasMoreBeforeStart: boolean; + hasMoreAfterEnd: boolean; +} export interface LogEntriesCallbacks { fetchNewerEntries: () => Promise; @@ -75,32 +87,40 @@ export const logEntriesInitialCallbacks = { export const logEntriesInitialState: LogEntriesStateParams = { entries: [], - entriesStart: null, - entriesEnd: null, - hasMoreAfterEnd: false, - hasMoreBeforeStart: false, + topCursor: null, + bottomCursor: null, + centerCursor: null, isReloading: true, isLoadingMore: false, lastLoadedTime: null, + hasMoreBeforeStart: false, + hasMoreAfterEnd: false, }; -const cleanDuplicateItems = (entriesA: InfraLogEntry[], entriesB: InfraLogEntry[]) => { - const gids = new Set(entriesB.map(item => item.gid)); - return entriesA.filter(item => !gids.has(item.gid)); +const cleanDuplicateItems = (entriesA: LogEntry[], entriesB: LogEntry[]) => { + const ids = new Set(entriesB.map(item => item.id)); + return entriesA.filter(item => !ids.has(item.id)); }; const shouldFetchNewEntries = ({ prevParams, timeKey, filterQuery, - entriesStart, - entriesEnd, -}: FetchEntriesParams & LogEntriesStateParams & { prevParams: FetchEntriesParams }) => { - if (!timeKey) return false; - const shouldLoadWithNewFilter = filterQuery !== prevParams.filterQuery; + topCursor, + bottomCursor, + startTimestamp, + endTimestamp, +}: FetchEntriesParams & LogEntriesStateParams & { prevParams: FetchEntriesParams | undefined }) => { + const shouldLoadWithNewDates = prevParams + ? (startTimestamp !== prevParams.startTimestamp && + startTimestamp > prevParams.startTimestamp) || + (endTimestamp !== prevParams.endTimestamp && endTimestamp < prevParams.endTimestamp) + : true; + const shouldLoadWithNewFilter = prevParams ? filterQuery !== prevParams.filterQuery : true; const shouldLoadAroundNewPosition = - !entriesStart || !entriesEnd || !timeKeyIsBetween(entriesStart, entriesEnd, timeKey); - return shouldLoadWithNewFilter || shouldLoadAroundNewPosition; + timeKey && (!topCursor || !bottomCursor || !timeKeyIsBetween(topCursor, bottomCursor, timeKey)); + + return shouldLoadWithNewDates || shouldLoadWithNewFilter || shouldLoadAroundNewPosition; }; enum ShouldFetchMoreEntries { @@ -124,48 +144,105 @@ const useFetchEntriesEffect = ( dispatch: Dispatch, props: LogEntriesProps ) => { - const { getLogEntriesAround, getLogEntriesBefore, getLogEntriesAfter } = useGraphQLQueries(); - - const [prevParams, cachePrevParams] = useState(props); + const [prevParams, cachePrevParams] = useState(); const [startedStreaming, setStartedStreaming] = useState(false); - const runFetchNewEntriesRequest = async (override = {}) => { + const runFetchNewEntriesRequest = async (overrides: Partial = {}) => { + if (!props.startTimestamp || !props.endTimestamp) { + return; + } + dispatch({ type: Action.FetchingNewEntries }); + try { - const payload = await getLogEntriesAround({ - ...omit(props, 'jumpToTargetPosition'), - ...override, - }); + const commonFetchArgs: LogEntriesBaseRequest = { + sourceId: overrides.sourceId || props.sourceId, + startTimestamp: overrides.startTimestamp || props.startTimestamp, + endTimestamp: overrides.endTimestamp || props.endTimestamp, + query: overrides.filterQuery || props.filterQuery, + }; + + const fetchArgs: LogEntriesRequest = props.timeKey + ? { + ...commonFetchArgs, + center: props.timeKey, + } + : { + ...commonFetchArgs, + before: 'last', + }; + + const { data: payload } = await fetchLogEntries(fetchArgs); dispatch({ type: Action.ReceiveNewEntries, payload }); + + // Move position to the bottom if it's the first load. + // Do it in the next tick to allow the `dispatch` to fire + if (!props.timeKey && payload.bottomCursor) { + setTimeout(() => { + props.jumpToTargetPosition(payload.bottomCursor!); + }); + } else if ( + props.timeKey && + payload.topCursor && + payload.bottomCursor && + !timeKeyIsBetween(payload.topCursor, payload.bottomCursor, props.timeKey) + ) { + props.jumpToTargetPosition(payload.topCursor); + } } catch (e) { dispatch({ type: Action.ErrorOnNewEntries }); } }; const runFetchMoreEntriesRequest = async (direction: ShouldFetchMoreEntries) => { - dispatch({ type: Action.FetchingMoreEntries }); + if (!props.startTimestamp || !props.endTimestamp) { + return; + } const getEntriesBefore = direction === ShouldFetchMoreEntries.Before; - const timeKey = getEntriesBefore - ? state.entries[0].key - : state.entries[state.entries.length - 1].key; - const getMoreLogEntries = getEntriesBefore ? getLogEntriesBefore : getLogEntriesAfter; + + // Control that cursors are correct + if ((getEntriesBefore && !state.topCursor) || !state.bottomCursor) { + return; + } + + dispatch({ type: Action.FetchingMoreEntries }); + try { - const payload = await getMoreLogEntries({ ...props, timeKey }); + const commonFetchArgs: LogEntriesBaseRequest = { + sourceId: props.sourceId, + startTimestamp: props.startTimestamp, + endTimestamp: props.endTimestamp, + query: props.filterQuery, + }; + + const fetchArgs: LogEntriesRequest = getEntriesBefore + ? { + ...commonFetchArgs, + before: state.topCursor!, // We already check for nullity above + } + : { + ...commonFetchArgs, + after: state.bottomCursor, + }; + + const { data: payload } = await fetchLogEntries(fetchArgs); + dispatch({ type: getEntriesBefore ? Action.ReceiveEntriesBefore : Action.ReceiveEntriesAfter, payload, }); - return payload.entriesEnd; + + return payload.bottomCursor; } catch (e) { dispatch({ type: Action.ErrorOnMoreEntries }); } }; const fetchNewEntriesEffectDependencies = Object.values( - pick(props, ['sourceId', 'filterQuery', 'timeKey']) + pick(props, ['sourceId', 'filterQuery', 'timeKey', 'startTimestamp', 'endTimestamp']) ); const fetchNewEntriesEffect = () => { - if (props.isAutoReloading) return; + if (props.isStreaming && prevParams) return; if (shouldFetchNewEntries({ ...props, ...state, prevParams })) { runFetchNewEntriesRequest(); } @@ -177,7 +254,7 @@ const useFetchEntriesEffect = ( Object.values(pick(state, ['hasMoreBeforeStart', 'hasMoreAfterEnd'])), ]; const fetchMoreEntriesEffect = () => { - if (state.isLoadingMore || props.isAutoReloading) return; + if (state.isLoadingMore || props.isStreaming) return; const direction = shouldFetchMoreEntries(props, state); switch (direction) { case ShouldFetchMoreEntries.Before: @@ -191,30 +268,25 @@ const useFetchEntriesEffect = ( const fetchNewerEntries = useCallback( throttle(() => runFetchMoreEntriesRequest(ShouldFetchMoreEntries.After), 500), - [props, state.entriesEnd] + [props, state.bottomCursor] ); const streamEntriesEffectDependencies = [ - props.isAutoReloading, + props.isStreaming, state.isLoadingMore, state.isReloading, ]; const streamEntriesEffect = () => { (async () => { - if (props.isAutoReloading && !state.isLoadingMore && !state.isReloading) { + if (props.isStreaming && !state.isLoadingMore && !state.isReloading) { if (startedStreaming) { - await new Promise(res => setTimeout(res, 5000)); + await new Promise(res => setTimeout(res, LIVE_STREAM_INTERVAL)); } else { - const nowKey = { - tiebreaker: 0, - time: Date.now(), - }; - props.jumpToTargetPosition(nowKey); + const endTimestamp = Date.now(); + props.jumpToTargetPosition({ tiebreaker: 0, time: endTimestamp }); setStartedStreaming(true); if (state.hasMoreAfterEnd) { - runFetchNewEntriesRequest({ - timeKey: nowKey, - }); + runFetchNewEntriesRequest({ endTimestamp }); return; } } @@ -222,15 +294,41 @@ const useFetchEntriesEffect = ( if (newEntriesEnd) { props.jumpToTargetPosition(newEntriesEnd); } - } else if (!props.isAutoReloading) { + } else if (!props.isStreaming) { setStartedStreaming(false); } })(); }; + const expandRangeEffect = () => { + if (!prevParams || !prevParams.startTimestamp || !prevParams.endTimestamp) { + return; + } + + if (props.timestampsLastUpdate === prevParams.timestampsLastUpdate) { + return; + } + + const shouldExpand = { + before: props.startTimestamp < prevParams.startTimestamp, + after: props.endTimestamp > prevParams.endTimestamp, + }; + + dispatch({ type: Action.ExpandRange, payload: shouldExpand }); + }; + + const expandRangeEffectDependencies = [ + prevParams?.startTimestamp, + prevParams?.endTimestamp, + props.startTimestamp, + props.endTimestamp, + props.timestampsLastUpdate, + ]; + useEffect(fetchNewEntriesEffect, fetchNewEntriesEffectDependencies); useEffect(fetchMoreEntriesEffect, fetchMoreEntriesEffectDependencies); useEffect(streamEntriesEffect, streamEntriesEffectDependencies); + useEffect(expandRangeEffect, expandRangeEffectDependencies); return { fetchNewerEntries, checkForNewEntries: runFetchNewEntriesRequest }; }; @@ -249,44 +347,87 @@ export const useLogEntriesState: ( const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: ActionObj) => { switch (action.type) { case Action.ReceiveNewEntries: - return { ...prevState, ...action.payload, isReloading: false }; + return { + ...prevState, + ...action.payload, + centerCursor: getCenterCursor(action.payload.entries), + lastLoadedTime: new Date(), + isReloading: false, + + // Be optimistic. If any of the before/after requests comes empty, set + // the corresponding flag to `false` + hasMoreBeforeStart: true, + hasMoreAfterEnd: true, + }; case Action.ReceiveEntriesBefore: { - const prevEntries = cleanDuplicateItems(prevState.entries, action.payload.entries); - const newEntries = [...action.payload.entries, ...prevEntries]; - const { hasMoreBeforeStart, entriesStart, lastLoadedTime } = action.payload; + const newEntries = action.payload.entries; + const prevEntries = cleanDuplicateItems(prevState.entries, newEntries); + const entries = [...newEntries, ...prevEntries]; + const update = { - entries: newEntries, + entries, isLoadingMore: false, - hasMoreBeforeStart, - entriesStart, - lastLoadedTime, + hasMoreBeforeStart: newEntries.length > 0, + // Keep the previous cursor if request comes empty, to easily extend the range. + topCursor: newEntries.length > 0 ? action.payload.topCursor : prevState.topCursor, + centerCursor: getCenterCursor(entries), + lastLoadedTime: new Date(), }; + return { ...prevState, ...update }; } case Action.ReceiveEntriesAfter: { - const prevEntries = cleanDuplicateItems(prevState.entries, action.payload.entries); - const newEntries = [...prevEntries, ...action.payload.entries]; - const { hasMoreAfterEnd, entriesEnd, lastLoadedTime } = action.payload; + const newEntries = action.payload.entries; + const prevEntries = cleanDuplicateItems(prevState.entries, newEntries); + const entries = [...prevEntries, ...newEntries]; + const update = { - entries: newEntries, + entries, isLoadingMore: false, - hasMoreAfterEnd, - entriesEnd, - lastLoadedTime, + hasMoreAfterEnd: newEntries.length > 0, + // Keep the previous cursor if request comes empty, to easily extend the range. + bottomCursor: newEntries.length > 0 ? action.payload.bottomCursor : prevState.bottomCursor, + centerCursor: getCenterCursor(entries), + lastLoadedTime: new Date(), }; + return { ...prevState, ...update }; } case Action.FetchingNewEntries: - return { ...prevState, isReloading: true }; + return { + ...prevState, + isReloading: true, + entries: [], + topCursor: null, + bottomCursor: null, + centerCursor: null, + hasMoreBeforeStart: true, + hasMoreAfterEnd: true, + }; case Action.FetchingMoreEntries: return { ...prevState, isLoadingMore: true }; case Action.ErrorOnNewEntries: return { ...prevState, isReloading: false }; case Action.ErrorOnMoreEntries: return { ...prevState, isLoadingMore: false }; + + case Action.ExpandRange: { + const hasMoreBeforeStart = action.payload.before ? true : prevState.hasMoreBeforeStart; + const hasMoreAfterEnd = action.payload.after ? true : prevState.hasMoreAfterEnd; + + return { + ...prevState, + hasMoreBeforeStart, + hasMoreAfterEnd, + }; + } default: throw new Error(); } }; +function getCenterCursor(entries: LogEntry[]): TimeKey | null { + return entries.length > 0 ? entries[Math.floor(entries.length / 2)].cursor : null; +} + export const LogEntriesState = createContainer(useLogEntriesState); diff --git a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx index 5c1667a4b7680..267abe631c142 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx @@ -19,7 +19,7 @@ export enum FlyoutVisibility { visible = 'visible', } -interface FlyoutOptionsUrlState { +export interface FlyoutOptionsUrlState { flyoutId?: string | null; flyoutVisibility?: string | null; surroundingLogsId?: string | null; diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts b/x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts new file mode 100644 index 0000000000000..030a9d180c7b5 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from '../../../../legacy_singletons'; + +import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; + +import { + LOG_ENTRIES_HIGHLIGHTS_PATH, + LogEntriesHighlightsRequest, + logEntriesHighlightsRequestRT, + logEntriesHighlightsResponseRT, +} from '../../../../../common/http_api'; + +export const fetchLogEntriesHighlights = async (requestArgs: LogEntriesHighlightsRequest) => { + const response = await npStart.http.fetch(LOG_ENTRIES_HIGHLIGHTS_PATH, { + method: 'POST', + body: JSON.stringify(logEntriesHighlightsRequestRT.encode(requestArgs)), + }); + + return pipe( + logEntriesHighlightsResponseRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx index 2b19958a9b1a1..7701850443768 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx @@ -6,62 +6,47 @@ import { useEffect, useMemo, useState } from 'react'; -import { getNextTimeKey, getPreviousTimeKey, TimeKey } from '../../../../common/time'; -import { LogEntryHighlightsQuery } from '../../../graphql/types'; -import { DependencyError, useApolloClient } from '../../../utils/apollo_context'; -import { LogEntryHighlightsMap } from '../../../utils/log_entry'; +import { TimeKey } from '../../../../common/time'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; -import { logEntryHighlightsQuery } from './log_entry_highlights.gql_query'; - -export type LogEntryHighlights = LogEntryHighlightsQuery.Query['source']['logEntryHighlights']; +import { fetchLogEntriesHighlights } from './api/fetch_log_entries_highlights'; +import { LogEntry, LogEntriesHighlightsResponse } from '../../../../common/http_api'; export const useLogEntryHighlights = ( sourceId: string, sourceVersion: string | undefined, - startKey: TimeKey | null, - endKey: TimeKey | null, + startTimestamp: number | null, + endTimestamp: number | null, + centerPoint: TimeKey | null, + size: number, filterQuery: string | null, highlightTerms: string[] ) => { - const apolloClient = useApolloClient(); - const [logEntryHighlights, setLogEntryHighlights] = useState([]); + const [logEntryHighlights, setLogEntryHighlights] = useState< + LogEntriesHighlightsResponse['data'] + >([]); const [loadLogEntryHighlightsRequest, loadLogEntryHighlights] = useTrackedPromise( { cancelPreviousOn: 'resolution', createPromise: async () => { - if (!apolloClient) { - throw new DependencyError('Failed to load source: No apollo client available.'); - } - if (!startKey || !endKey || !highlightTerms.length) { + if (!startTimestamp || !endTimestamp || !centerPoint || !highlightTerms.length) { throw new Error('Skipping request: Insufficient parameters'); } - return await apolloClient.query< - LogEntryHighlightsQuery.Query, - LogEntryHighlightsQuery.Variables - >({ - fetchPolicy: 'no-cache', - query: logEntryHighlightsQuery, - variables: { - sourceId, - startKey: getPreviousTimeKey(startKey), // interval boundaries are exclusive - endKey: getNextTimeKey(endKey), // interval boundaries are exclusive - filterQuery, - highlights: [ - { - query: highlightTerms[0], - countBefore: 1, - countAfter: 1, - }, - ], - }, + return await fetchLogEntriesHighlights({ + sourceId, + startTimestamp, + endTimestamp, + center: centerPoint, + size, + query: filterQuery || undefined, + highlightTerms, }); }, onResolve: response => { - setLogEntryHighlights(response.data.source.logEntryHighlights); + setLogEntryHighlights(response.data); }, }, - [apolloClient, sourceId, startKey, endKey, filterQuery, highlightTerms] + [sourceId, startTimestamp, endTimestamp, centerPoint, size, filterQuery, highlightTerms] ); useEffect(() => { @@ -71,24 +56,31 @@ export const useLogEntryHighlights = ( useEffect(() => { if ( highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length && - startKey && - endKey + startTimestamp && + endTimestamp ) { loadLogEntryHighlights(); } else { setLogEntryHighlights([]); } - }, [endKey, filterQuery, highlightTerms, loadLogEntryHighlights, sourceVersion, startKey]); + }, [ + endTimestamp, + filterQuery, + highlightTerms, + loadLogEntryHighlights, + sourceVersion, + startTimestamp, + ]); const logEntryHighlightsById = useMemo( () => - logEntryHighlights.reduce( - (accumulatedLogEntryHighlightsById, { entries }) => { - return entries.reduce((singleHighlightLogEntriesById, entry) => { - const highlightsForId = singleHighlightLogEntriesById[entry.gid] || []; + logEntryHighlights.reduce>( + (accumulatedLogEntryHighlightsById, highlightData) => { + return highlightData.entries.reduce((singleHighlightLogEntriesById, entry) => { + const highlightsForId = singleHighlightLogEntriesById[entry.id] || []; return { ...singleHighlightLogEntriesById, - [entry.gid]: [...highlightsForId, entry], + [entry.id]: [...highlightsForId, entry], }; }, accumulatedLogEntryHighlightsById); }, diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx index a4a94851ad383..941e89848131b 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx @@ -6,39 +6,38 @@ import createContainer from 'constate'; import { useState, useContext } from 'react'; +import { useThrottle } from 'react-use'; import { useLogEntryHighlights } from './log_entry_highlights'; import { useLogSummaryHighlights } from './log_summary_highlights'; import { useNextAndPrevious } from './next_and_previous'; -import { useLogSummaryBufferInterval } from '../log_summary'; -import { LogViewConfiguration } from '../log_view_configuration'; import { LogPositionState } from '../log_position'; import { TimeKey } from '../../../../common/time'; +const FETCH_THROTTLE_INTERVAL = 3000; + +interface UseLogHighlightsStateProps { + sourceId: string; + sourceVersion: string | undefined; + centerCursor: TimeKey | null; + size: number; + filterQuery: string | null; +} + export const useLogHighlightsState = ({ sourceId, sourceVersion, - entriesStart, - entriesEnd, + centerCursor, + size, filterQuery, -}: { - sourceId: string; - sourceVersion: string | undefined; - entriesStart: TimeKey | null; - entriesEnd: TimeKey | null; - filterQuery: string | null; -}) => { +}: UseLogHighlightsStateProps) => { const [highlightTerms, setHighlightTerms] = useState([]); - const { visibleMidpoint, jumpToTargetPosition } = useContext(LogPositionState.Context); - const { intervalSize: summaryIntervalSize } = useContext(LogViewConfiguration.Context); - const { - start: summaryStart, - end: summaryEnd, - bucketSize: summaryBucketSize, - } = useLogSummaryBufferInterval( - visibleMidpoint ? visibleMidpoint.time : null, - summaryIntervalSize + const { visibleMidpoint, jumpToTargetPosition, startTimestamp, endTimestamp } = useContext( + LogPositionState.Context ); + const throttledStartTimestamp = useThrottle(startTimestamp, FETCH_THROTTLE_INTERVAL); + const throttledEndTimestamp = useThrottle(endTimestamp, FETCH_THROTTLE_INTERVAL); + const { logEntryHighlights, logEntryHighlightsById, @@ -46,8 +45,10 @@ export const useLogHighlightsState = ({ } = useLogEntryHighlights( sourceId, sourceVersion, - entriesStart, - entriesEnd, + throttledStartTimestamp, + throttledEndTimestamp, + centerCursor, + size, filterQuery, highlightTerms ); @@ -55,9 +56,8 @@ export const useLogHighlightsState = ({ const { logSummaryHighlights, loadLogSummaryHighlightsRequest } = useLogSummaryHighlights( sourceId, sourceVersion, - summaryStart, - summaryEnd, - summaryBucketSize, + throttledStartTimestamp, + throttledEndTimestamp, filterQuery, highlightTerms ); diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts index 81639aba411ef..41ee63bf0e23d 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts @@ -10,13 +10,13 @@ import { debounce } from 'lodash'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; import { fetchLogSummaryHighlights } from './api/fetch_log_summary_highlights'; import { LogEntriesSummaryHighlightsResponse } from '../../../../common/http_api'; +import { useBucketSize } from '../log_summary/bucket_size'; export const useLogSummaryHighlights = ( sourceId: string, sourceVersion: string | undefined, - start: number | null, - end: number | null, - bucketSize: number, + startTimestamp: number | null, + endTimestamp: number | null, filterQuery: string | null, highlightTerms: string[] ) => { @@ -24,18 +24,20 @@ export const useLogSummaryHighlights = ( LogEntriesSummaryHighlightsResponse['data'] >([]); + const bucketSize = useBucketSize(startTimestamp, endTimestamp); + const [loadLogSummaryHighlightsRequest, loadLogSummaryHighlights] = useTrackedPromise( { cancelPreviousOn: 'resolution', createPromise: async () => { - if (!start || !end || !highlightTerms.length) { + if (!startTimestamp || !endTimestamp || !bucketSize || !highlightTerms.length) { throw new Error('Skipping request: Insufficient parameters'); } return await fetchLogSummaryHighlights({ sourceId, - startDate: start, - endDate: end, + startTimestamp, + endTimestamp, bucketSize, query: filterQuery, highlightTerms, @@ -45,7 +47,7 @@ export const useLogSummaryHighlights = ( setLogSummaryHighlights(response.data); }, }, - [sourceId, start, end, bucketSize, filterQuery, highlightTerms] + [sourceId, startTimestamp, endTimestamp, bucketSize, filterQuery, highlightTerms] ); const debouncedLoadSummaryHighlights = useMemo(() => debounce(loadLogSummaryHighlights, 275), [ @@ -57,7 +59,11 @@ export const useLogSummaryHighlights = ( }, [highlightTerms]); useEffect(() => { - if (highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length && start && end) { + if ( + highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length && + startTimestamp && + endTimestamp + ) { debouncedLoadSummaryHighlights(); } else { setLogSummaryHighlights([]); @@ -65,11 +71,11 @@ export const useLogSummaryHighlights = ( }, [ bucketSize, debouncedLoadSummaryHighlights, - end, filterQuery, highlightTerms, sourceVersion, - start, + startTimestamp, + endTimestamp, ]); return { diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx index 7557550883f11..689c30a52b597 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx @@ -13,7 +13,7 @@ import { getLogEntryIndexBeforeTime, getUniqueLogEntryKey, } from '../../../utils/log_entry'; -import { LogEntryHighlights } from './log_entry_highlights'; +import { LogEntriesHighlightsResponse } from '../../../../common/http_api'; export const useNextAndPrevious = ({ highlightTerms, @@ -23,7 +23,7 @@ export const useNextAndPrevious = ({ }: { highlightTerms: string[]; jumpToTargetPosition: (target: TimeKey) => void; - logEntryHighlights: LogEntryHighlights | undefined; + logEntryHighlights: LogEntriesHighlightsResponse['data'] | undefined; visibleMidpoint: TimeKey | null; }) => { const [currentTimeKey, setCurrentTimeKey] = useState(null); diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts index 1a8274024bd26..5ac34e5df70ec 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts @@ -6,10 +6,20 @@ import { useState, useMemo, useEffect, useCallback } from 'react'; import createContainer from 'constate'; +import { useSetState } from 'react-use'; import { TimeKey } from '../../../../common/time'; +import { datemathToEpochMillis, isValidDatemath } from '../../../utils/datemath'; type TimeKeyOrNull = TimeKey | null; +interface DateRange { + startDateExpression: string; + endDateExpression: string; + startTimestamp: number; + endTimestamp: number; + timestampsLastUpdate: number; +} + interface VisiblePositions { startKey: TimeKeyOrNull; middleKey: TimeKeyOrNull; @@ -19,24 +29,35 @@ interface VisiblePositions { } export interface LogPositionStateParams { + isInitialized: boolean; targetPosition: TimeKeyOrNull; - isAutoReloading: boolean; + isStreaming: boolean; firstVisiblePosition: TimeKeyOrNull; pagesBeforeStart: number; pagesAfterEnd: number; visibleMidpoint: TimeKeyOrNull; visibleMidpointTime: number | null; visibleTimeInterval: { start: number; end: number } | null; + startDateExpression: string; + endDateExpression: string; + startTimestamp: number | null; + endTimestamp: number | null; + timestampsLastUpdate: number; } export interface LogPositionCallbacks { + initialize: () => void; jumpToTargetPosition: (pos: TimeKeyOrNull) => void; jumpToTargetPositionTime: (time: number) => void; reportVisiblePositions: (visPos: VisiblePositions) => void; startLiveStreaming: () => void; stopLiveStreaming: () => void; + updateDateRange: (newDateRage: Partial) => void; } +const DEFAULT_DATE_RANGE = { startDateExpression: 'now-1d', endDateExpression: 'now' }; +const DESIRED_BUFFER_PAGES = 2; + const useVisibleMidpoint = (middleKey: TimeKeyOrNull, targetPosition: TimeKeyOrNull) => { // Of the two dependencies `middleKey` and `targetPosition`, return // whichever one was the most recently updated. This allows the UI controls @@ -60,8 +81,18 @@ const useVisibleMidpoint = (middleKey: TimeKeyOrNull, targetPosition: TimeKeyOrN }; export const useLogPositionState: () => LogPositionStateParams & LogPositionCallbacks = () => { + // Flag to determine if `LogPositionState` has been fully initialized. + // + // When the page loads, there might be initial state in the URL. We want to + // prevent the entries from showing until we have processed that initial + // state. That prevents double fetching. + const [isInitialized, setInitialized] = useState(false); + const initialize = useCallback(() => { + setInitialized(true); + }, [setInitialized]); + const [targetPosition, jumpToTargetPosition] = useState(null); - const [isAutoReloading, setIsAutoReloading] = useState(false); + const [isStreaming, setIsStreaming] = useState(false); const [visiblePositions, reportVisiblePositions] = useState({ endKey: null, middleKey: null, @@ -70,6 +101,15 @@ export const useLogPositionState: () => LogPositionStateParams & LogPositionCall pagesAfterEnd: Infinity, }); + // We group the `startDate` and `endDate` values in the same object to be able + // to set both at the same time, saving a re-render + const [dateRange, setDateRange] = useSetState({ + ...DEFAULT_DATE_RANGE, + startTimestamp: datemathToEpochMillis(DEFAULT_DATE_RANGE.startDateExpression)!, + endTimestamp: datemathToEpochMillis(DEFAULT_DATE_RANGE.endDateExpression, 'up')!, + timestampsLastUpdate: Date.now(), + }); + const { startKey, middleKey, endKey, pagesBeforeStart, pagesAfterEnd } = visiblePositions; const visibleMidpoint = useVisibleMidpoint(middleKey, targetPosition); @@ -79,26 +119,87 @@ export const useLogPositionState: () => LogPositionStateParams & LogPositionCall [startKey, endKey] ); + // Allow setting `startDate` and `endDate` separately, or together + const updateDateRange = useCallback( + (newDateRange: Partial) => { + // Prevent unnecessary re-renders + if (!('startDateExpression' in newDateRange) && !('endDateExpression' in newDateRange)) { + return; + } + + const nextStartDateExpression = + newDateRange.startDateExpression || dateRange.startDateExpression; + const nextEndDateExpression = newDateRange.endDateExpression || dateRange.endDateExpression; + + if (!isValidDatemath(nextStartDateExpression) || !isValidDatemath(nextEndDateExpression)) { + return; + } + + // Dates are valid, so the function cannot return `null` + const nextStartTimestamp = datemathToEpochMillis(nextStartDateExpression)!; + const nextEndTimestamp = datemathToEpochMillis(nextEndDateExpression, 'up')!; + + // Reset the target position if it doesn't fall within the new range. + if ( + targetPosition && + (nextStartTimestamp > targetPosition.time || nextEndTimestamp < targetPosition.time) + ) { + jumpToTargetPosition(null); + } + + setDateRange({ + ...newDateRange, + startTimestamp: nextStartTimestamp, + endTimestamp: nextEndTimestamp, + timestampsLastUpdate: Date.now(), + }); + }, + [setDateRange, dateRange, targetPosition] + ); + + // `endTimestamp` update conditions + useEffect(() => { + if (dateRange.endDateExpression !== 'now') { + return; + } + + // User is close to the bottom edge of the scroll. + if (visiblePositions.pagesAfterEnd <= DESIRED_BUFFER_PAGES) { + setDateRange({ + endTimestamp: datemathToEpochMillis(dateRange.endDateExpression, 'up')!, + timestampsLastUpdate: Date.now(), + }); + } + }, [dateRange.endDateExpression, visiblePositions, setDateRange]); + const state = { + isInitialized, targetPosition, - isAutoReloading, + isStreaming, firstVisiblePosition: startKey, pagesBeforeStart, pagesAfterEnd, visibleMidpoint, visibleMidpointTime: visibleMidpoint ? visibleMidpoint.time : null, visibleTimeInterval, + ...dateRange, }; const callbacks = { + initialize, jumpToTargetPosition, jumpToTargetPositionTime: useCallback( (time: number) => jumpToTargetPosition({ tiebreaker: 0, time }), [jumpToTargetPosition] ), reportVisiblePositions, - startLiveStreaming: useCallback(() => setIsAutoReloading(true), [setIsAutoReloading]), - stopLiveStreaming: useCallback(() => setIsAutoReloading(false), [setIsAutoReloading]), + startLiveStreaming: useCallback(() => { + setIsStreaming(true); + jumpToTargetPosition(null); + updateDateRange({ startDateExpression: 'now-1d', endDateExpression: 'now' }); + }, [setIsStreaming, updateDateRange]), + stopLiveStreaming: useCallback(() => setIsStreaming(false), [setIsStreaming]), + updateDateRange, }; return { ...state, ...callbacks }; diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx b/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx index 221dac95ef5f0..0d3586f9376f3 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx @@ -9,31 +9,40 @@ import React, { useContext, useMemo } from 'react'; import { pickTimeKey } from '../../../../common/time'; import { replaceStateKeyInQueryString, UrlStateContainer } from '../../../utils/url_state'; import { LogPositionState, LogPositionStateParams } from './log_position_state'; +import { isValidDatemath, datemathToEpochMillis } from '../../../utils/datemath'; /** * Url State */ - -interface LogPositionUrlState { - position: LogPositionStateParams['visibleMidpoint'] | undefined; +export interface LogPositionUrlState { + position?: LogPositionStateParams['visibleMidpoint']; streamLive: boolean; + start?: string; + end?: string; } +const ONE_HOUR = 3600000; + export const WithLogPositionUrlState = () => { const { visibleMidpoint, - isAutoReloading, + isStreaming, jumpToTargetPosition, - jumpToTargetPositionTime, startLiveStreaming, stopLiveStreaming, + startDateExpression, + endDateExpression, + updateDateRange, + initialize, } = useContext(LogPositionState.Context); const urlState = useMemo( () => ({ position: visibleMidpoint ? pickTimeKey(visibleMidpoint) : null, - streamLive: isAutoReloading, + streamLive: isStreaming, + start: startDateExpression, + end: endDateExpression, }), - [visibleMidpoint, isAutoReloading] + [visibleMidpoint, isStreaming, startDateExpression, endDateExpression] ); return ( { urlStateKey="logPosition" mapToUrlState={mapToUrlState} onChange={(newUrlState: LogPositionUrlState | undefined) => { - if (newUrlState && newUrlState.position) { + if (!newUrlState) { + return; + } + + if (newUrlState.start || newUrlState.end) { + updateDateRange({ + startDateExpression: newUrlState.start, + endDateExpression: newUrlState.end, + }); + } + + if (newUrlState.position) { jumpToTargetPosition(newUrlState.position); } - if (newUrlState && newUrlState.streamLive) { + + if (newUrlState.streamLive) { startLiveStreaming(); - } else if ( - newUrlState && - typeof newUrlState.streamLive !== 'undefined' && - !newUrlState.streamLive - ) { + } else if (typeof newUrlState.streamLive !== 'undefined' && !newUrlState.streamLive) { stopLiveStreaming(); } }} onInitialize={(initialUrlState: LogPositionUrlState | undefined) => { - if (initialUrlState && initialUrlState.position) { - jumpToTargetPosition(initialUrlState.position); - } else { - jumpToTargetPositionTime(Date.now()); - } - if (initialUrlState && initialUrlState.streamLive) { - startLiveStreaming(); + if (initialUrlState) { + const initialPosition = initialUrlState.position; + let initialStartDateExpression = initialUrlState.start; + let initialEndDateExpression = initialUrlState.end; + + if (!initialPosition) { + initialStartDateExpression = initialStartDateExpression || 'now-1d'; + initialEndDateExpression = initialEndDateExpression || 'now'; + } else { + const initialStartTimestamp = initialStartDateExpression + ? datemathToEpochMillis(initialStartDateExpression) + : undefined; + const initialEndTimestamp = initialEndDateExpression + ? datemathToEpochMillis(initialEndDateExpression, 'up') + : undefined; + + // Adjust the start-end range if the target position falls outside or if it's not set. + if (!initialStartTimestamp || initialStartTimestamp > initialPosition.time) { + initialStartDateExpression = new Date(initialPosition.time - ONE_HOUR).toISOString(); + } + + if (!initialEndTimestamp || initialEndTimestamp < initialPosition.time) { + initialEndDateExpression = new Date(initialPosition.time + ONE_HOUR).toISOString(); + } + + jumpToTargetPosition(initialPosition); + } + + if (initialStartDateExpression || initialEndDateExpression) { + updateDateRange({ + startDateExpression: initialStartDateExpression, + endDateExpression: initialEndDateExpression, + }); + } + + if (initialUrlState.streamLive) { + startLiveStreaming(); + } } + + initialize(); }} /> ); @@ -73,6 +123,8 @@ const mapToUrlState = (value: any): LogPositionUrlState | undefined => ? { position: mapToPositionUrlState(value.position), streamLive: mapToStreamLiveUrlState(value.streamLive), + start: mapToDate(value.start), + end: mapToDate(value.end), } : undefined; @@ -83,6 +135,7 @@ const mapToPositionUrlState = (value: any) => const mapToStreamLiveUrlState = (value: any) => (typeof value === 'boolean' ? value : false); +const mapToDate = (value: any) => (isValidDatemath(value) ? value : undefined); export const replaceLogPositionInQueryString = (time: number) => Number.isNaN(time) ? (value: string) => value diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts new file mode 100644 index 0000000000000..e46b304156f83 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo } from 'react'; + +const SUMMARY_BUCKET_COUNT = 100; + +export function useBucketSize( + startTimestamp: number | null, + endTimestamp: number | null +): number | null { + const bucketSize = useMemo(() => { + if (!startTimestamp || !endTimestamp) { + return null; + } + return (endTimestamp - startTimestamp) / SUMMARY_BUCKET_COUNT; + }, [startTimestamp, endTimestamp]); + + return bucketSize; +} diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts index 20c4267000a25..dc0437fa75a31 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts @@ -5,5 +5,4 @@ */ export * from './log_summary'; -export * from './use_log_summary_buffer_interval'; export * from './with_summary'; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx index 2bbcc22b150e4..73d0e5efdf06b 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx @@ -9,6 +9,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { useLogSummary } from './log_summary'; import { fetchLogSummary } from './api/fetch_log_summary'; +import { datemathToEpochMillis } from '../../../utils/datemath'; // Typescript doesn't know that `fetchLogSummary` is a jest mock. // We use a second variable with a type cast to help the compiler further down the line. @@ -21,20 +22,26 @@ describe('useLogSummary hook', () => { }); it('provides an empty list of buckets by default', () => { - const { result } = renderHook(() => useLogSummary('SOURCE_ID', null, 1000, null)); + const { result } = renderHook(() => useLogSummary('SOURCE_ID', null, null, null)); expect(result.current.buckets).toEqual([]); }); it('queries for new summary buckets when the source id changes', async () => { - const firstMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 1 }]); - const secondMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 2 }]); + const { startTimestamp, endTimestamp } = createMockDateRange(); + + const firstMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 1 }, + ]); + const secondMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 2 }, + ]); fetchLogSummaryMock .mockResolvedValueOnce(firstMockResponse) .mockResolvedValueOnce(secondMockResponse); const { result, waitForNextUpdate, rerender } = renderHook( - ({ sourceId }) => useLogSummary(sourceId, 100000, 1000, null), + ({ sourceId }) => useLogSummary(sourceId, startTimestamp, endTimestamp, null), { initialProps: { sourceId: 'INITIAL_SOURCE_ID' }, } @@ -63,15 +70,21 @@ describe('useLogSummary hook', () => { }); it('queries for new summary buckets when the filter query changes', async () => { - const firstMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 1 }]); - const secondMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 2 }]); + const { startTimestamp, endTimestamp } = createMockDateRange(); + + const firstMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 1 }, + ]); + const secondMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 2 }, + ]); fetchLogSummaryMock .mockResolvedValueOnce(firstMockResponse) .mockResolvedValueOnce(secondMockResponse); const { result, waitForNextUpdate, rerender } = renderHook( - ({ filterQuery }) => useLogSummary('SOURCE_ID', 100000, 1000, filterQuery), + ({ filterQuery }) => useLogSummary('SOURCE_ID', startTimestamp, endTimestamp, filterQuery), { initialProps: { filterQuery: 'INITIAL_FILTER_QUERY' }, } @@ -99,15 +112,17 @@ describe('useLogSummary hook', () => { expect(result.current.buckets).toEqual(secondMockResponse.data.buckets); }); - it('queries for new summary buckets when the midpoint time changes', async () => { + it('queries for new summary buckets when the start and end date changes', async () => { fetchLogSummaryMock .mockResolvedValueOnce(createMockResponse([])) .mockResolvedValueOnce(createMockResponse([])); + const firstRange = createMockDateRange(); const { waitForNextUpdate, rerender } = renderHook( - ({ midpointTime }) => useLogSummary('SOURCE_ID', midpointTime, 1000, null), + ({ startTimestamp, endTimestamp }) => + useLogSummary('SOURCE_ID', startTimestamp, endTimestamp, null), { - initialProps: { midpointTime: 100000 }, + initialProps: firstRange, } ); @@ -115,54 +130,21 @@ describe('useLogSummary hook', () => { expect(fetchLogSummaryMock).toHaveBeenCalledTimes(1); expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( expect.objectContaining({ - startDate: 98500, - endDate: 101500, - }) - ); - - rerender({ midpointTime: 200000 }); - await waitForNextUpdate(); - - expect(fetchLogSummaryMock).toHaveBeenCalledTimes(2); - expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - startDate: 198500, - endDate: 201500, + startTimestamp: firstRange.startTimestamp, + endTimestamp: firstRange.endTimestamp, }) ); - }); - it('queries for new summary buckets when the interval size changes', async () => { - fetchLogSummaryMock - .mockResolvedValueOnce(createMockResponse([])) - .mockResolvedValueOnce(createMockResponse([])); - - const { waitForNextUpdate, rerender } = renderHook( - ({ intervalSize }) => useLogSummary('SOURCE_ID', 100000, intervalSize, null), - { - initialProps: { intervalSize: 1000 }, - } - ); + const secondRange = createMockDateRange('now-20s', 'now'); - await waitForNextUpdate(); - expect(fetchLogSummaryMock).toHaveBeenCalledTimes(1); - expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - bucketSize: 10, - startDate: 98500, - endDate: 101500, - }) - ); - - rerender({ intervalSize: 2000 }); + rerender(secondRange); await waitForNextUpdate(); expect(fetchLogSummaryMock).toHaveBeenCalledTimes(2); expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( expect.objectContaining({ - bucketSize: 20, - startDate: 97000, - endDate: 103000, + startTimestamp: secondRange.startTimestamp, + endTimestamp: secondRange.endTimestamp, }) ); }); @@ -171,3 +153,12 @@ describe('useLogSummary hook', () => { const createMockResponse = ( buckets: Array<{ start: number; end: number; entriesCount: number }> ) => ({ data: { buckets, start: Number.NEGATIVE_INFINITY, end: Number.POSITIVE_INFINITY } }); + +const createMockDateRange = (startDate = 'now-10s', endDate = 'now') => { + return { + startDate, + endDate, + startTimestamp: datemathToEpochMillis(startDate)!, + endTimestamp: datemathToEpochMillis(endDate, 'up')!, + }; +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx index c39b7075af325..94723125cc0ec 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx @@ -7,34 +7,31 @@ import { useState } from 'react'; import { useCancellableEffect } from '../../../utils/cancellable_effect'; -import { useLogSummaryBufferInterval } from './use_log_summary_buffer_interval'; import { fetchLogSummary } from './api/fetch_log_summary'; import { LogEntriesSummaryResponse } from '../../../../common/http_api'; +import { useBucketSize } from './bucket_size'; export type LogSummaryBuckets = LogEntriesSummaryResponse['data']['buckets']; export const useLogSummary = ( sourceId: string, - midpointTime: number | null, - intervalSize: number, + startTimestamp: number | null, + endTimestamp: number | null, filterQuery: string | null ) => { const [logSummaryBuckets, setLogSummaryBuckets] = useState([]); - const { start: bufferStart, end: bufferEnd, bucketSize } = useLogSummaryBufferInterval( - midpointTime, - intervalSize - ); + const bucketSize = useBucketSize(startTimestamp, endTimestamp); useCancellableEffect( getIsCancelled => { - if (bufferStart === null || bufferEnd === null) { + if (startTimestamp === null || endTimestamp === null || bucketSize === null) { return; } fetchLogSummary({ sourceId, - startDate: bufferStart, - endDate: bufferEnd, + startTimestamp, + endTimestamp, bucketSize, query: filterQuery, }).then(response => { @@ -43,12 +40,12 @@ export const useLogSummary = ( } }); }, - [sourceId, filterQuery, bufferStart, bufferEnd, bucketSize] + [sourceId, filterQuery, startTimestamp, endTimestamp, bucketSize] ); return { buckets: logSummaryBuckets, - start: bufferStart, - end: bufferEnd, + start: startTimestamp, + end: endTimestamp, }; }; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts deleted file mode 100644 index 27af76b70f47a..0000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useMemo } from 'react'; - -const LOAD_BUCKETS_PER_PAGE = 100; -const UNKNOWN_BUFFER_INTERVAL = { - start: null, - end: null, - bucketSize: 0, -}; - -export const useLogSummaryBufferInterval = (midpointTime: number | null, intervalSize: number) => { - return useMemo(() => { - if (midpointTime === null || intervalSize <= 0) { - return UNKNOWN_BUFFER_INTERVAL; - } - - const halfIntervalSize = intervalSize / 2; - - return { - start: (Math.floor((midpointTime - halfIntervalSize) / intervalSize) - 0.5) * intervalSize, - end: (Math.ceil((midpointTime + halfIntervalSize) / intervalSize) + 0.5) * intervalSize, - bucketSize: intervalSize / LOAD_BUCKETS_PER_PAGE, - }; - }, [midpointTime, intervalSize]); -}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts index 4db0d2e645448..14da2b47bcfa2 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts @@ -5,14 +5,16 @@ */ import { useContext } from 'react'; +import { useThrottle } from 'react-use'; import { RendererFunction } from '../../../utils/typed_react'; import { Source } from '../../source'; -import { LogViewConfiguration } from '../log_view_configuration'; import { LogSummaryBuckets, useLogSummary } from './log_summary'; import { LogFilterState } from '../log_filter'; import { LogPositionState } from '../log_position'; +const FETCH_THROTTLE_INTERVAL = 3000; + export const WithSummary = ({ children, }: { @@ -22,15 +24,18 @@ export const WithSummary = ({ end: number | null; }>; }) => { - const { intervalSize } = useContext(LogViewConfiguration.Context); const { sourceId } = useContext(Source.Context); const { filterQuery } = useContext(LogFilterState.Context); - const { visibleMidpointTime } = useContext(LogPositionState.Context); + const { startTimestamp, endTimestamp } = useContext(LogPositionState.Context); + + // Keep it reasonably updated for the `now` case, but don't reload all the time when the user scrolls + const throttledStartTimestamp = useThrottle(startTimestamp, FETCH_THROTTLE_INTERVAL); + const throttledEndTimestamp = useThrottle(endTimestamp, FETCH_THROTTLE_INTERVAL); const { buckets, start, end } = useLogSummary( sourceId, - visibleMidpointTime, - intervalSize, + throttledStartTimestamp, + throttledEndTimestamp, filterQuery ); diff --git a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx index b6de1230d9a59..5954cb834a11d 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx @@ -45,35 +45,10 @@ describe('useLogViewConfiguration hook', () => { }); }); - describe('intervalSize state', () => { - it('has a default value', () => { - const { getLastHookValue } = mountHook(() => useLogViewConfiguration().intervalSize); - - expect(getLastHookValue()).toEqual(86400000); - }); - - it('can be updated', () => { - const { act, getLastHookValue } = mountHook(() => useLogViewConfiguration()); - - act(({ setIntervalSize }) => { - setIntervalSize(90000000); - }); - - expect(getLastHookValue().intervalSize).toEqual(90000000); - }); - }); - it('provides the available text scales', () => { const { getLastHookValue } = mountHook(() => useLogViewConfiguration().availableTextScales); expect(getLastHookValue()).toEqual(expect.any(Array)); expect(getLastHookValue().length).toBeGreaterThan(0); }); - - it('provides the available interval sizes', () => { - const { getLastHookValue } = mountHook(() => useLogViewConfiguration().availableIntervalSizes); - - expect(getLastHookValue()).toEqual(expect.any(Array)); - expect(getLastHookValue().length).toBeGreaterThan(0); - }); }); diff --git a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx index 8837078aa4a0d..e1351ad0b17ad 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; import createContainer from 'constate'; import { useState } from 'react'; @@ -17,18 +16,12 @@ export const useLogViewConfiguration = () => { // text wrap const [textWrap, setTextWrap] = useState(true); - // minimap interval - const [intervalSize, setIntervalSize] = useState(1000 * 60 * 60 * 24); - return { - availableIntervalSizes, availableTextScales, setTextScale, setTextWrap, textScale, textWrap, - intervalSize, - setIntervalSize, }; }; @@ -39,42 +32,3 @@ export const LogViewConfiguration = createContainer(useLogViewConfiguration); */ export const availableTextScales: TextScale[] = ['large', 'medium', 'small']; - -export const availableIntervalSizes = [ - { - label: i18n.translate('xpack.infra.mapLogs.oneYearLabel', { - defaultMessage: '1 Year', - }), - intervalSize: 1000 * 60 * 60 * 24 * 365, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneMonthLabel', { - defaultMessage: '1 Month', - }), - intervalSize: 1000 * 60 * 60 * 24 * 30, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneWeekLabel', { - defaultMessage: '1 Week', - }), - intervalSize: 1000 * 60 * 60 * 24 * 7, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneDayLabel', { - defaultMessage: '1 Day', - }), - intervalSize: 1000 * 60 * 60 * 24, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneHourLabel', { - defaultMessage: '1 Hour', - }), - intervalSize: 1000 * 60 * 60, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneMinuteLabel', { - defaultMessage: '1 Minute', - }), - intervalSize: 1000 * 60, - }, -]; diff --git a/x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx b/x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx deleted file mode 100644 index 3f2b4d7cc16f9..0000000000000 --- a/x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useContext, useMemo } from 'react'; - -import { UrlStateContainer } from '../../utils/url_state'; -import { LogViewConfiguration } from './log_view_configuration'; - -/** - * Url State - */ - -interface LogMinimapUrlState { - intervalSize?: number; -} - -export const WithLogMinimapUrlState = () => { - const { intervalSize, setIntervalSize } = useContext(LogViewConfiguration.Context); - - const urlState = useMemo(() => ({ intervalSize }), [intervalSize]); - - return ( - { - if (newUrlState && newUrlState.intervalSize) { - setIntervalSize(newUrlState.intervalSize); - } - }} - onInitialize={newUrlState => { - if (newUrlState && newUrlState.intervalSize) { - setIntervalSize(newUrlState.intervalSize); - } - }} - /> - ); -}; - -const mapToUrlState = (value: any): LogMinimapUrlState | undefined => - value - ? { - intervalSize: mapToIntervalSizeUrlState(value.intervalSize), - } - : undefined; - -const mapToIntervalSizeUrlState = (value: any) => - value && typeof value === 'number' ? value : undefined; diff --git a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts b/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts index 6da9cd7513cba..5c0e245448ce5 100644 --- a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts +++ b/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts @@ -6,12 +6,12 @@ import { useContext, useMemo } from 'react'; import { StreamItem, LogEntryStreamItem } from '../../components/logging/log_text_stream/item'; -import { LogEntry, LogEntryHighlight } from '../../utils/log_entry'; import { RendererFunction } from '../../utils/typed_react'; // deep inporting to avoid a circular import problem import { LogHighlightsState } from './log_highlights/log_highlights'; import { LogEntriesState, LogEntriesStateParams, LogEntriesCallbacks } from './log_entries'; import { UniqueTimeKey } from '../../../common/time'; +import { LogEntry } from '../../../common/http_api'; export const WithStreamItems: React.FunctionComponent<{ children: RendererFunction< @@ -30,7 +30,7 @@ export const WithStreamItems: React.FunctionComponent<{ logEntries.isReloading ? [] : logEntries.entries.map(logEntry => - createLogEntryStreamItem(logEntry, logEntryHighlightsById[logEntry.gid] || []) + createLogEntryStreamItem(logEntry, logEntryHighlightsById[logEntry.id] || []) ), [logEntries.entries, logEntries.isReloading, logEntryHighlightsById] @@ -46,7 +46,7 @@ export const WithStreamItems: React.FunctionComponent<{ const createLogEntryStreamItem = ( logEntry: LogEntry, - highlights: LogEntryHighlight[] + highlights: LogEntry[] ): LogEntryStreamItem => ({ kind: 'logEntry' as 'logEntry', logEntry, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx index 54609bcf8e2c2..023082154565c 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx @@ -44,11 +44,8 @@ export const CategoryExampleMessage: React.FunctionComponent<{ void; timeRange: TimeRange; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx index 5cb5f3a993d48..a9090a90c0b92 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx @@ -8,7 +8,7 @@ import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo, useState } from 'react'; - +import { useSet } from 'react-use'; import { euiStyled } from '../../../../../../../observability/public'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; import { @@ -64,9 +64,31 @@ export const AnomaliesTable: React.FunctionComponent<{ }); }, [results]); - const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState< - Record - >({}); + const [expandedDatasetIds, { add: expandDataset, remove: collapseDataset }] = useSet( + new Set() + ); + + const expandedDatasetRowContents = useMemo( + () => + [...expandedDatasetIds].reduce>( + (aggregatedDatasetRows, datasetId) => { + return { + ...aggregatedDatasetRows, + [getFriendlyNameForPartitionId(datasetId)]: ( + + ), + }; + }, + {} + ), + [expandedDatasetIds, jobId, results, setTimeRange, timeRange] + ); const [sorting, setSorting] = useState({ sort: { @@ -98,73 +120,43 @@ export const AnomaliesTable: React.FunctionComponent<{ return sorting.sort.direction === 'asc' ? sortedItems : sortedItems.reverse(); }, [tableItems, sorting]); - const expandItem = useCallback( - (item: TableItem) => { - const newItemIdToExpandedRowMap = { - ...itemIdToExpandedRowMap, - [item.partitionName]: ( - > = useMemo( + () => [ + { + field: 'partitionName', + name: partitionColumnName, + sortable: true, + truncateText: true, + }, + { + field: 'topAnomalyScore', + name: maxAnomalyScoreColumnName, + sortable: true, + truncateText: true, + dataType: 'number' as const, + }, + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + render: (item: TableItem) => ( + ), - }; - setItemIdToExpandedRowMap(newItemIdToExpandedRowMap); - }, - [itemIdToExpandedRowMap, jobId, results, setTimeRange, timeRange] + }, + ], + [collapseDataset, expandDataset, expandedDatasetIds] ); - const collapseItem = useCallback( - (item: TableItem) => { - if (itemIdToExpandedRowMap[item.partitionName]) { - const { - [item.partitionName]: toggledItem, - ...remainingExpandedRowMap - } = itemIdToExpandedRowMap; - setItemIdToExpandedRowMap(remainingExpandedRowMap); - } - }, - [itemIdToExpandedRowMap] - ); - - const columns: Array> = [ - { - field: 'partitionName', - name: partitionColumnName, - sortable: true, - truncateText: true, - }, - { - field: 'topAnomalyScore', - name: maxAnomalyScoreColumnName, - sortable: true, - truncateText: true, - dataType: 'number' as const, - }, - { - align: RIGHT_ALIGNMENT, - width: '40px', - isExpander: true, - render: (item: TableItem) => ( - - ), - }, - ]; - return ( { const { source, sourceId, version } = useContext(Source.Context); - const { intervalSize, textScale, textWrap } = useContext(LogViewConfiguration.Context); + const { textScale, textWrap } = useContext(LogViewConfiguration.Context); const { setFlyoutVisibility, flyoutVisible, @@ -44,17 +43,20 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { const { logSummaryHighlights } = useContext(LogHighlightsState.Context); const { applyLogFilterQuery } = useContext(LogFilterState.Context); const { - isAutoReloading, + isStreaming, targetPosition, visibleMidpointTime, visibleTimeInterval, reportVisiblePositions, jumpToTargetPosition, + startLiveStreaming, stopLiveStreaming, + startDateExpression, + endDateExpression, + updateDateRange, } = useContext(LogPositionState.Context); return ( <> - @@ -90,7 +92,7 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { hasMoreBeforeStart={hasMoreBeforeStart} isLoadingMore={isLoadingMore} isReloading={isReloading} - isStreaming={isAutoReloading} + isStreaming={isStreaming} items={items} jumpToTarget={jumpToTargetPosition} lastLoadedTime={lastLoadedTime} @@ -104,6 +106,10 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { setFlyoutVisibility={setFlyoutVisibility} highlightedItem={surroundingLogsId ? surroundingLogsId : null} currentHighlightKey={currentHighlightKey} + startDateExpression={startDateExpression} + endDateExpression={endDateExpression} + updateDateRange={updateDateRange} + startLiveStreaming={startLiveStreaming} /> )} @@ -113,14 +119,15 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { return ( - {({ buckets }) => ( + {({ buckets, start, end }) => ( {({ isReloading }) => ( { const LogEntriesStateProvider: React.FC = ({ children }) => { const { sourceId } = useContext(Source.Context); const { + startTimestamp, + endTimestamp, + timestampsLastUpdate, targetPosition, pagesBeforeStart, pagesAfterEnd, - isAutoReloading, + isStreaming, jumpToTargetPosition, + isInitialized, } = useContext(LogPositionState.Context); const { filterQuery } = useContext(LogFilterState.Context); + // Don't render anything if the date range is incorrect. + if (!startTimestamp || !endTimestamp) { + return null; + } + const entriesProps = { + startTimestamp, + endTimestamp, + timestampsLastUpdate, timeKey: targetPosition, pagesBeforeStart, pagesAfterEnd, filterQuery, sourceId, - isAutoReloading, + isStreaming, jumpToTargetPosition, }; + + // Don't initialize the entries until the position has been fully intialized. + // See `` + if (!isInitialized) { + return null; + } + return {children}; }; const LogHighlightsStateProvider: React.FC = ({ children }) => { const { sourceId, version } = useContext(Source.Context); - const [{ entriesStart, entriesEnd }] = useContext(LogEntriesState.Context); + const [{ topCursor, bottomCursor, centerCursor, entries }] = useContext(LogEntriesState.Context); const { filterQuery } = useContext(LogFilterState.Context); + const highlightsProps = { sourceId, sourceVersion: version, - entriesStart, - entriesEnd, + entriesStart: topCursor, + entriesEnd: bottomCursor, + centerCursor, + size: entries.length, filterQuery, }; return {children}; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx index 000dfd1065f12..2f9a76fd47490 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx @@ -13,30 +13,22 @@ import { Toolbar } from '../../../components/eui'; import { LogCustomizationMenu } from '../../../components/logging/log_customization_menu'; import { LogHighlightsMenu } from '../../../components/logging/log_highlights_menu'; import { LogHighlightsState } from '../../../containers/logs/log_highlights/log_highlights'; -import { LogMinimapScaleControls } from '../../../components/logging/log_minimap_scale_controls'; import { LogTextScaleControls } from '../../../components/logging/log_text_scale_controls'; import { LogTextWrapControls } from '../../../components/logging/log_text_wrap_controls'; -import { LogTimeControls } from '../../../components/logging/log_time_controls'; import { LogFlyout } from '../../../containers/logs/log_flyout'; import { LogViewConfiguration } from '../../../containers/logs/log_view_configuration'; import { LogFilterState } from '../../../containers/logs/log_filter'; import { LogPositionState } from '../../../containers/logs/log_position'; import { Source } from '../../../containers/source'; import { WithKueryAutocompletion } from '../../../containers/with_kuery_autocompletion'; +import { LogDatepicker } from '../../../components/logging/log_datepicker'; export const LogsToolbar = () => { const { createDerivedIndexPattern } = useContext(Source.Context); const derivedIndexPattern = createDerivedIndexPattern('logs'); - const { - availableIntervalSizes, - availableTextScales, - intervalSize, - setIntervalSize, - setTextScale, - setTextWrap, - textScale, - textWrap, - } = useContext(LogViewConfiguration.Context); + const { availableTextScales, setTextScale, setTextWrap, textScale, textWrap } = useContext( + LogViewConfiguration.Context + ); const { filterQueryDraft, isFilterQueryDraftValid, @@ -55,12 +47,14 @@ export const LogsToolbar = () => { goToNextHighlight, } = useContext(LogHighlightsState.Context); const { - visibleMidpointTime, - isAutoReloading, - jumpToTargetPositionTime, + isStreaming, startLiveStreaming, stopLiveStreaming, + startDateExpression, + endDateExpression, + updateDateRange, } = useContext(LogPositionState.Context); + return ( @@ -94,11 +88,6 @@ export const LogsToolbar = () => { - { /> - { - startLiveStreaming(); - setSurroundingLogsId(null); - }} - stopLiveStreaming={stopLiveStreaming} + diff --git a/x-pack/plugins/infra/public/utils/datemath.test.ts b/x-pack/plugins/infra/public/utils/datemath.test.ts new file mode 100644 index 0000000000000..0f272733c5f97 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/datemath.test.ts @@ -0,0 +1,401 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + isValidDatemath, + datemathToEpochMillis, + extendDatemath, + convertDate, + normalizeDate, +} from './datemath'; +import sinon from 'sinon'; + +describe('isValidDatemath()', () => { + it('Returns `false` for empty strings', () => { + expect(isValidDatemath('')).toBe(false); + }); + + it('Returns `false` for invalid strings', () => { + expect(isValidDatemath('wadus')).toBe(false); + expect(isValidDatemath('nowww-')).toBe(false); + expect(isValidDatemath('now-')).toBe(false); + expect(isValidDatemath('now-1')).toBe(false); + expect(isValidDatemath('now-1d/')).toBe(false); + }); + + it('Returns `true` for valid strings', () => { + expect(isValidDatemath('now')).toBe(true); + expect(isValidDatemath('now-1d')).toBe(true); + expect(isValidDatemath('now-1d/d')).toBe(true); + }); +}); + +describe('datemathToEpochMillis()', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers(Date.now()); + }); + + afterEach(() => { + clock.restore(); + }); + + it('Returns `0` for the dawn of time', () => { + expect(datemathToEpochMillis('1970-01-01T00:00:00+00:00')).toEqual(0); + }); + + it('Returns the current timestamp when `now`', () => { + expect(datemathToEpochMillis('now')).toEqual(Date.now()); + }); +}); + +describe('extendDatemath()', () => { + it('Returns `undefined` for invalid values', () => { + expect(extendDatemath('')).toBeUndefined(); + }); + + it('Keeps `"now"` stable', () => { + expect(extendDatemath('now')).toEqual({ value: 'now' }); + expect(extendDatemath('now', 'before')).toEqual({ value: 'now' }); + expect(extendDatemath('now', 'after')).toEqual({ value: 'now' }); + }); + + describe('moving before', () => { + describe('with a negative operator', () => { + it('doubles miliseconds', () => { + expect(extendDatemath('now-250ms')).toEqual({ + value: 'now-500ms', + diffAmount: 250, + diffUnit: 'ms', + }); + }); + + it('normalizes miliseconds', () => { + expect(extendDatemath('now-500ms')).toEqual({ + value: 'now-1s', + diffAmount: 500, + diffUnit: 'ms', + }); + }); + + it('doubles seconds', () => { + expect(extendDatemath('now-10s')).toEqual({ + value: 'now-20s', + diffAmount: 10, + diffUnit: 's', + }); + }); + + it('normalizes seconds', () => { + expect(extendDatemath('now-30s')).toEqual({ + value: 'now-1m', + diffAmount: 30, + diffUnit: 's', + }); + }); + + it('doubles minutes when amount is low', () => { + expect(extendDatemath('now-1m')).toEqual({ value: 'now-2m', diffAmount: 1, diffUnit: 'm' }); + expect(extendDatemath('now-2m')).toEqual({ value: 'now-4m', diffAmount: 2, diffUnit: 'm' }); + expect(extendDatemath('now-3m')).toEqual({ value: 'now-6m', diffAmount: 3, diffUnit: 'm' }); + }); + + it('adds half the minutes when the amount is high', () => { + expect(extendDatemath('now-20m')).toEqual({ + value: 'now-30m', + diffAmount: 10, + diffUnit: 'm', + }); + }); + + it('Adds half an hour when the amount is one hour', () => { + expect(extendDatemath('now-1h')).toEqual({ + value: 'now-90m', + diffAmount: 30, + diffUnit: 'm', + }); + }); + + it('Adds one hour when the amount more than one hour', () => { + expect(extendDatemath('now-2h')).toEqual({ + value: 'now-3h', + diffAmount: 1, + diffUnit: 'h', + }); + }); + + it('Adds one hour when the amount is one day', () => { + expect(extendDatemath('now-1d')).toEqual({ + value: 'now-25h', + diffAmount: 1, + diffUnit: 'h', + }); + }); + + it('Adds one day when the amount is more than one day', () => { + expect(extendDatemath('now-2d')).toEqual({ + value: 'now-3d', + diffAmount: 1, + diffUnit: 'd', + }); + expect(extendDatemath('now-3d')).toEqual({ + value: 'now-4d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Adds one day when the amount is one week', () => { + expect(extendDatemath('now-1w')).toEqual({ + value: 'now-8d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Adds one week when the amount is more than one week', () => { + expect(extendDatemath('now-2w')).toEqual({ + value: 'now-3w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Adds one week when the amount is one month', () => { + expect(extendDatemath('now-1M')).toEqual({ + value: 'now-5w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Adds one month when the amount is more than one month', () => { + expect(extendDatemath('now-2M')).toEqual({ + value: 'now-3M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Adds one month when the amount is one year', () => { + expect(extendDatemath('now-1y')).toEqual({ + value: 'now-13M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Adds one year when the amount is in years', () => { + expect(extendDatemath('now-2y')).toEqual({ + value: 'now-3y', + diffAmount: 1, + diffUnit: 'y', + }); + }); + }); + + describe('with a positive Operator', () => { + it('Halves miliseconds', () => { + expect(extendDatemath('now+250ms')).toEqual({ + value: 'now+125ms', + diffAmount: 125, + diffUnit: 'ms', + }); + }); + + it('Halves seconds', () => { + expect(extendDatemath('now+10s')).toEqual({ + value: 'now+5s', + diffAmount: 5, + diffUnit: 's', + }); + }); + + it('Halves minutes when the amount is low', () => { + expect(extendDatemath('now+2m')).toEqual({ value: 'now+1m', diffAmount: 1, diffUnit: 'm' }); + expect(extendDatemath('now+4m')).toEqual({ value: 'now+2m', diffAmount: 2, diffUnit: 'm' }); + expect(extendDatemath('now+6m')).toEqual({ value: 'now+3m', diffAmount: 3, diffUnit: 'm' }); + }); + + it('Decreases minutes in half ammounts when the amount is high', () => { + expect(extendDatemath('now+30m')).toEqual({ + value: 'now+20m', + diffAmount: 10, + diffUnit: 'm', + }); + }); + + it('Decreases half an hour when the amount is one hour', () => { + expect(extendDatemath('now+1h')).toEqual({ + value: 'now+30m', + diffAmount: 30, + diffUnit: 'm', + }); + }); + + it('Removes one hour when the amount is one day', () => { + expect(extendDatemath('now+1d')).toEqual({ + value: 'now+23h', + diffAmount: 1, + diffUnit: 'h', + }); + }); + + it('Removes one day when the amount is more than one day', () => { + expect(extendDatemath('now+2d')).toEqual({ + value: 'now+1d', + diffAmount: 1, + diffUnit: 'd', + }); + expect(extendDatemath('now+3d')).toEqual({ + value: 'now+2d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Removes one day when the amount is one week', () => { + expect(extendDatemath('now+1w')).toEqual({ + value: 'now+6d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Removes one week when the amount is more than one week', () => { + expect(extendDatemath('now+2w')).toEqual({ + value: 'now+1w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Removes one week when the amount is one month', () => { + expect(extendDatemath('now+1M')).toEqual({ + value: 'now+3w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Removes one month when the amount is more than one month', () => { + expect(extendDatemath('now+2M')).toEqual({ + value: 'now+1M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Removes one month when the amount is one year', () => { + expect(extendDatemath('now+1y')).toEqual({ + value: 'now+11M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Adds one year when the amount is in years', () => { + expect(extendDatemath('now+2y')).toEqual({ + value: 'now+1y', + diffAmount: 1, + diffUnit: 'y', + }); + }); + }); + }); +}); + +describe('convertDate()', () => { + it('returns same value if units are the same', () => { + expect(convertDate(1, 'h', 'h')).toEqual(1); + }); + + it('converts from big units to small units', () => { + expect(convertDate(1, 's', 'ms')).toEqual(1000); + expect(convertDate(1, 'm', 'ms')).toEqual(60000); + expect(convertDate(1, 'h', 'ms')).toEqual(3600000); + expect(convertDate(1, 'd', 'ms')).toEqual(86400000); + expect(convertDate(1, 'M', 'ms')).toEqual(2592000000); + expect(convertDate(1, 'y', 'ms')).toEqual(31536000000); + }); + + it('converts from small units to big units', () => { + expect(convertDate(1000, 'ms', 's')).toEqual(1); + expect(convertDate(60000, 'ms', 'm')).toEqual(1); + expect(convertDate(3600000, 'ms', 'h')).toEqual(1); + expect(convertDate(86400000, 'ms', 'd')).toEqual(1); + expect(convertDate(2592000000, 'ms', 'M')).toEqual(1); + expect(convertDate(31536000000, 'ms', 'y')).toEqual(1); + }); + + it('Handles days to years', () => { + expect(convertDate(1, 'y', 'd')).toEqual(365); + expect(convertDate(365, 'd', 'y')).toEqual(1); + }); + + it('Handles years to months', () => { + expect(convertDate(1, 'y', 'M')).toEqual(12); + expect(convertDate(12, 'M', 'y')).toEqual(1); + }); + + it('Handles days to months', () => { + expect(convertDate(1, 'M', 'd')).toEqual(30); + expect(convertDate(30, 'd', 'M')).toEqual(1); + }); + + it('Handles days to weeks', () => { + expect(convertDate(1, 'w', 'd')).toEqual(7); + expect(convertDate(7, 'd', 'w')).toEqual(1); + }); + + it('Handles weeks to years', () => { + expect(convertDate(1, 'y', 'w')).toEqual(52); + expect(convertDate(52, 'w', 'y')).toEqual(1); + }); +}); + +describe('normalizeDate()', () => { + it('keeps units under the conversion ratio the same', () => { + expect(normalizeDate(999, 'ms')).toEqual({ amount: 999, unit: 'ms' }); + expect(normalizeDate(59, 's')).toEqual({ amount: 59, unit: 's' }); + expect(normalizeDate(59, 'm')).toEqual({ amount: 59, unit: 'm' }); + expect(normalizeDate(23, 'h')).toEqual({ amount: 23, unit: 'h' }); + expect(normalizeDate(6, 'd')).toEqual({ amount: 6, unit: 'd' }); + expect(normalizeDate(3, 'w')).toEqual({ amount: 3, unit: 'w' }); + expect(normalizeDate(11, 'M')).toEqual({ amount: 11, unit: 'M' }); + }); + + it('Moves to the next unit for values equal to the conversion ratio', () => { + expect(normalizeDate(1000, 'ms')).toEqual({ amount: 1, unit: 's' }); + expect(normalizeDate(60, 's')).toEqual({ amount: 1, unit: 'm' }); + expect(normalizeDate(60, 'm')).toEqual({ amount: 1, unit: 'h' }); + expect(normalizeDate(24, 'h')).toEqual({ amount: 1, unit: 'd' }); + expect(normalizeDate(7, 'd')).toEqual({ amount: 1, unit: 'w' }); + expect(normalizeDate(4, 'w')).toEqual({ amount: 1, unit: 'M' }); + expect(normalizeDate(12, 'M')).toEqual({ amount: 1, unit: 'y' }); + }); + + it('keeps units slightly over the conversion ratio the same', () => { + expect(normalizeDate(1001, 'ms')).toEqual({ amount: 1001, unit: 'ms' }); + expect(normalizeDate(61, 's')).toEqual({ amount: 61, unit: 's' }); + expect(normalizeDate(61, 'm')).toEqual({ amount: 61, unit: 'm' }); + expect(normalizeDate(25, 'h')).toEqual({ amount: 25, unit: 'h' }); + expect(normalizeDate(8, 'd')).toEqual({ amount: 8, unit: 'd' }); + expect(normalizeDate(5, 'w')).toEqual({ amount: 5, unit: 'w' }); + expect(normalizeDate(13, 'M')).toEqual({ amount: 13, unit: 'M' }); + }); + + it('moves to the next unit for any value higher than twice the conversion ratio', () => { + expect(normalizeDate(2001, 'ms')).toEqual({ amount: 2, unit: 's' }); + expect(normalizeDate(121, 's')).toEqual({ amount: 2, unit: 'm' }); + expect(normalizeDate(121, 'm')).toEqual({ amount: 2, unit: 'h' }); + expect(normalizeDate(49, 'h')).toEqual({ amount: 2, unit: 'd' }); + expect(normalizeDate(15, 'd')).toEqual({ amount: 2, unit: 'w' }); + expect(normalizeDate(9, 'w')).toEqual({ amount: 2, unit: 'M' }); + expect(normalizeDate(25, 'M')).toEqual({ amount: 2, unit: 'y' }); + }); +}); diff --git a/x-pack/plugins/infra/public/utils/datemath.ts b/x-pack/plugins/infra/public/utils/datemath.ts new file mode 100644 index 0000000000000..50a9b6e4f6945 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/datemath.ts @@ -0,0 +1,266 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import dateMath, { Unit } from '@elastic/datemath'; + +export function isValidDatemath(value: string): boolean { + const parsedValue = dateMath.parse(value); + return !!(parsedValue && parsedValue.isValid()); +} + +export function datemathToEpochMillis(value: string, round: 'down' | 'up' = 'down'): number | null { + const parsedValue = dateMath.parse(value, { roundUp: round === 'up' }); + if (!parsedValue || !parsedValue.isValid()) { + return null; + } + return parsedValue.valueOf(); +} + +type DatemathExtension = + | { + value: string; + diffUnit: Unit; + diffAmount: number; + } + | { value: 'now' }; + +const datemathNowExpression = /(\+|\-)(\d+)(ms|s|m|h|d|w|M|y)$/; + +/** + * Extend a datemath value + * @param value The value to extend + * @param {'before' | 'after'} direction Should the value move before or after in time + * @param oppositeEdge For absolute values, the value of the other edge of the range + */ +export function extendDatemath( + value: string, + direction: 'before' | 'after' = 'before', + oppositeEdge?: string +): DatemathExtension | undefined { + if (!isValidDatemath(value)) { + return undefined; + } + + // `now` cannot be extended + if (value === 'now') { + return { value: 'now' }; + } + + // The unit is relative + if (value.startsWith('now')) { + return extendRelativeDatemath(value, direction); + } else if (oppositeEdge && isValidDatemath(oppositeEdge)) { + return extendAbsoluteDatemath(value, direction, oppositeEdge); + } + + return undefined; +} + +function extendRelativeDatemath( + value: string, + direction: 'before' | 'after' +): DatemathExtension | undefined { + const [, operator, amount, unit] = datemathNowExpression.exec(value) || []; + if (!operator || !amount || !unit) { + return undefined; + } + + const mustIncreaseAmount = operator === '-' && direction === 'before'; + const parsedAmount = parseInt(amount, 10); + let newUnit: Unit = unit as Unit; + let newAmount: number; + + // Extend the amount + switch (unit) { + // For small units, always double or halve the amount + case 'ms': + case 's': + newAmount = mustIncreaseAmount ? parsedAmount * 2 : Math.floor(parsedAmount / 2); + break; + // For minutes, increase or decrease in doubles or halves, depending on + // the amount of minutes + case 'm': + let ratio; + const MINUTES_LARGE = 10; + if (mustIncreaseAmount) { + ratio = parsedAmount >= MINUTES_LARGE ? 0.5 : 1; + newAmount = parsedAmount + Math.floor(parsedAmount * ratio); + } else { + newAmount = + parsedAmount >= MINUTES_LARGE + ? Math.floor(parsedAmount / 1.5) + : parsedAmount - Math.floor(parsedAmount * 0.5); + } + break; + + // For hours, increase or decrease half an hour for 1 hour. Otherwise + // increase full hours + case 'h': + if (parsedAmount === 1) { + newAmount = mustIncreaseAmount ? 90 : 30; + newUnit = 'm'; + } else { + newAmount = mustIncreaseAmount ? parsedAmount + 1 : parsedAmount - 1; + } + break; + + // For the rest of units, increase or decrease one smaller unit for + // amounts of 1. Otherwise increase or decrease the unit + case 'd': + case 'w': + case 'M': + case 'y': + if (parsedAmount === 1) { + newUnit = dateMath.unitsDesc[dateMath.unitsDesc.indexOf(unit) + 1]; + newAmount = mustIncreaseAmount + ? convertDate(1, unit, newUnit) + 1 + : convertDate(1, unit, newUnit) - 1; + } else { + newAmount = mustIncreaseAmount ? parsedAmount + 1 : parsedAmount - 1; + } + break; + + default: + throw new TypeError('Unhandled datemath unit'); + } + + // normalize amount and unit (i.e. 120s -> 2m) + const { unit: normalizedUnit, amount: normalizedAmount } = normalizeDate(newAmount, newUnit); + + // How much have we changed the time? + const diffAmount = Math.abs(normalizedAmount - convertDate(parsedAmount, unit, normalizedUnit)); + // if `diffAmount` is not an integer after normalization, express the difference in the original unit + const shouldKeepDiffUnit = diffAmount % 1 !== 0; + + return { + value: `now${operator}${normalizedAmount}${normalizedUnit}`, + diffUnit: shouldKeepDiffUnit ? unit : newUnit, + diffAmount: shouldKeepDiffUnit ? Math.abs(newAmount - parsedAmount) : diffAmount, + }; +} + +function extendAbsoluteDatemath( + value: string, + direction: 'before' | 'after', + oppositeEdge: string +): DatemathExtension { + const valueTimestamp = datemathToEpochMillis(value)!; + const oppositeEdgeTimestamp = datemathToEpochMillis(oppositeEdge)!; + const actualTimestampDiff = Math.abs(valueTimestamp - oppositeEdgeTimestamp); + const normalizedDiff = normalizeDate(actualTimestampDiff, 'ms'); + const normalizedTimestampDiff = convertDate(normalizedDiff.amount, normalizedDiff.unit, 'ms'); + + const newValue = + direction === 'before' + ? valueTimestamp - normalizedTimestampDiff + : valueTimestamp + normalizedTimestampDiff; + + return { + value: new Date(newValue).toISOString(), + diffUnit: normalizedDiff.unit, + diffAmount: normalizedDiff.amount, + }; +} + +const CONVERSION_RATIOS: Record> = { + wy: [ + ['w', 52], // 1 year = 52 weeks + ['y', 1], + ], + w: [ + ['ms', 1000], + ['s', 60], + ['m', 60], + ['h', 24], + ['d', 7], // 1 week = 7 days + ['w', 4], // 1 month = 4 weeks = 28 days + ['M', 12], // 1 year = 12 months = 52 weeks = 364 days + ['y', 1], + ], + M: [ + ['ms', 1000], + ['s', 60], + ['m', 60], + ['h', 24], + ['d', 30], // 1 month = 30 days + ['M', 12], // 1 year = 12 months = 360 days + ['y', 1], + ], + default: [ + ['ms', 1000], + ['s', 60], + ['m', 60], + ['h', 24], + ['d', 365], // 1 year = 365 days + ['y', 1], + ], +}; + +function getRatioScale(from: Unit, to?: Unit) { + if ((from === 'y' && to === 'w') || (from === 'w' && to === 'y')) { + return CONVERSION_RATIOS.wy; + } else if (from === 'w' || to === 'w') { + return CONVERSION_RATIOS.w; + } else if (from === 'M' || to === 'M') { + return CONVERSION_RATIOS.M; + } else { + return CONVERSION_RATIOS.default; + } +} + +export function convertDate(value: number, from: Unit, to: Unit): number { + if (from === to) { + return value; + } + + const ratioScale = getRatioScale(from, to); + const fromIdx = ratioScale.findIndex(ratio => ratio[0] === from); + const toIdx = ratioScale.findIndex(ratio => ratio[0] === to); + + let convertedValue = value; + + if (fromIdx > toIdx) { + // `from` is the bigger unit. Multiply the value + for (let i = toIdx; i < fromIdx; i++) { + convertedValue *= ratioScale[i][1]; + } + } else { + // `from` is the smaller unit. Divide the value + for (let i = fromIdx; i < toIdx; i++) { + convertedValue /= ratioScale[i][1]; + } + } + + return convertedValue; +} + +export function normalizeDate(amount: number, unit: Unit): { amount: number; unit: Unit } { + // There is nothing after years + if (unit === 'y') { + return { amount, unit }; + } + + const nextUnit = dateMath.unitsAsc[dateMath.unitsAsc.indexOf(unit) + 1]; + const ratioScale = getRatioScale(unit, nextUnit); + const ratio = ratioScale.find(r => r[0] === unit)![1]; + + const newAmount = amount / ratio; + + // Exact conversion + if (newAmount === 1) { + return { amount: newAmount, unit: nextUnit }; + } + + // Might be able to go one unit more, so try again, rounding the value + // 7200s => 120m => 2h + // 7249s ~> 120m ~> 2h + if (newAmount >= 2) { + return normalizeDate(Math.round(newAmount), nextUnit); + } + + // Cannot go one one unit above. Return as it is + return { amount, unit }; +} diff --git a/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts b/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts index be6b8c40753ae..bb528ee5b18c5 100644 --- a/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts +++ b/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts @@ -8,23 +8,26 @@ import { bisector } from 'd3-array'; import { compareToTimeKey, getIndexAtTimeKey, TimeKey, UniqueTimeKey } from '../../../common/time'; import { InfraLogEntryFields } from '../../graphql/types'; - -export type LogEntry = InfraLogEntryFields.Fragment; - -export type LogEntryColumn = InfraLogEntryFields.Columns; -export type LogEntryMessageColumn = InfraLogEntryFields.InfraLogEntryMessageColumnInlineFragment; -export type LogEntryTimestampColumn = InfraLogEntryFields.InfraLogEntryTimestampColumnInlineFragment; -export type LogEntryFieldColumn = InfraLogEntryFields.InfraLogEntryFieldColumnInlineFragment; +import { + LogEntry, + LogColumn, + LogTimestampColumn, + LogFieldColumn, + LogMessageColumn, + LogMessagePart, + LogMessageFieldPart, + LogMessageConstantPart, +} from '../../../common/http_api'; export type LogEntryMessageSegment = InfraLogEntryFields.Message; export type LogEntryConstantMessageSegment = InfraLogEntryFields.InfraLogMessageConstantSegmentInlineFragment; export type LogEntryFieldMessageSegment = InfraLogEntryFields.InfraLogMessageFieldSegmentInlineFragment; -export const getLogEntryKey = (entry: { key: TimeKey }) => entry.key; +export const getLogEntryKey = (entry: { cursor: TimeKey }) => entry.cursor; -export const getUniqueLogEntryKey = (entry: { gid: string; key: TimeKey }): UniqueTimeKey => ({ - ...entry.key, - gid: entry.gid, +export const getUniqueLogEntryKey = (entry: { id: string; cursor: TimeKey }): UniqueTimeKey => ({ + ...entry.cursor, + gid: entry.id, }); const logEntryTimeBisector = bisector(compareToTimeKey(getLogEntryKey)); @@ -39,19 +42,17 @@ export const getLogEntryAtTime = (entries: LogEntry[], time: TimeKey) => { return entryIndex !== null ? entries[entryIndex] : null; }; -export const isTimestampColumn = (column: LogEntryColumn): column is LogEntryTimestampColumn => +export const isTimestampColumn = (column: LogColumn): column is LogTimestampColumn => column != null && 'timestamp' in column; -export const isMessageColumn = (column: LogEntryColumn): column is LogEntryMessageColumn => +export const isMessageColumn = (column: LogColumn): column is LogMessageColumn => column != null && 'message' in column; -export const isFieldColumn = (column: LogEntryColumn): column is LogEntryFieldColumn => +export const isFieldColumn = (column: LogColumn): column is LogFieldColumn => column != null && 'field' in column; -export const isConstantSegment = ( - segment: LogEntryMessageSegment -): segment is LogEntryConstantMessageSegment => 'constant' in segment; +export const isConstantSegment = (segment: LogMessagePart): segment is LogMessageConstantPart => + 'constant' in segment; -export const isFieldSegment = ( - segment: LogEntryMessageSegment -): segment is LogEntryFieldMessageSegment => 'field' in segment && 'value' in segment; +export const isFieldSegment = (segment: LogMessagePart): segment is LogMessageFieldPart => + 'field' in segment && 'value' in segment; diff --git a/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts b/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts index 3361faa23a124..abb004911214b 100644 --- a/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts +++ b/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts @@ -5,8 +5,14 @@ */ import { InfraLogEntryHighlightFields } from '../../graphql/types'; - -export type LogEntryHighlight = InfraLogEntryHighlightFields.Fragment; +import { + LogEntry, + LogColumn, + LogMessageColumn, + LogFieldColumn, + LogMessagePart, + LogMessageFieldPart, +} from '../../../common/http_api'; export type LogEntryHighlightColumn = InfraLogEntryHighlightFields.Columns; export type LogEntryHighlightMessageColumn = InfraLogEntryHighlightFields.InfraLogEntryMessageColumnInlineFragment; @@ -16,18 +22,14 @@ export type LogEntryHighlightMessageSegment = InfraLogEntryHighlightFields.Messa export type LogEntryHighlightFieldMessageSegment = InfraLogEntryHighlightFields.InfraLogMessageFieldSegmentInlineFragment; export interface LogEntryHighlightsMap { - [entryId: string]: LogEntryHighlight[]; + [entryId: string]: LogEntry[]; } -export const isHighlightMessageColumn = ( - column: LogEntryHighlightColumn -): column is LogEntryHighlightMessageColumn => column != null && 'message' in column; +export const isHighlightMessageColumn = (column: LogColumn): column is LogMessageColumn => + column != null && 'message' in column; -export const isHighlightFieldColumn = ( - column: LogEntryHighlightColumn -): column is LogEntryHighlightFieldColumn => column != null && 'field' in column; +export const isHighlightFieldColumn = (column: LogColumn): column is LogFieldColumn => + column != null && 'field' in column; -export const isHighlightFieldSegment = ( - segment: LogEntryHighlightMessageSegment -): segment is LogEntryHighlightFieldMessageSegment => +export const isHighlightFieldSegment = (segment: LogMessagePart): segment is LogMessageFieldPart => segment && 'field' in segment && 'highlights' in segment; diff --git a/x-pack/plugins/infra/server/graphql/index.ts b/x-pack/plugins/infra/server/graphql/index.ts index 82fef41db1a73..f5150972a3a65 100644 --- a/x-pack/plugins/infra/server/graphql/index.ts +++ b/x-pack/plugins/infra/server/graphql/index.ts @@ -6,14 +6,7 @@ import { rootSchema } from '../../common/graphql/root/schema.gql'; import { sharedSchema } from '../../common/graphql/shared/schema.gql'; -import { logEntriesSchema } from './log_entries/schema.gql'; import { sourceStatusSchema } from './source_status/schema.gql'; import { sourcesSchema } from './sources/schema.gql'; -export const schemas = [ - rootSchema, - sharedSchema, - logEntriesSchema, - sourcesSchema, - sourceStatusSchema, -]; +export const schemas = [rootSchema, sharedSchema, sourcesSchema, sourceStatusSchema]; diff --git a/x-pack/plugins/infra/server/graphql/log_entries/resolvers.ts b/x-pack/plugins/infra/server/graphql/log_entries/resolvers.ts deleted file mode 100644 index edbb736b2c4fd..0000000000000 --- a/x-pack/plugins/infra/server/graphql/log_entries/resolvers.ts +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - InfraLogEntryColumn, - InfraLogEntryFieldColumn, - InfraLogEntryMessageColumn, - InfraLogEntryTimestampColumn, - InfraLogMessageConstantSegment, - InfraLogMessageFieldSegment, - InfraLogMessageSegment, - InfraSourceResolvers, -} from '../../graphql/types'; -import { InfraLogEntriesDomain } from '../../lib/domains/log_entries_domain'; -import { parseFilterQuery } from '../../utils/serialized_query'; -import { ChildResolverOf, InfraResolverOf } from '../../utils/typed_resolvers'; -import { QuerySourceResolver } from '../sources/resolvers'; - -export type InfraSourceLogEntriesAroundResolver = ChildResolverOf< - InfraResolverOf, - QuerySourceResolver ->; - -export type InfraSourceLogEntriesBetweenResolver = ChildResolverOf< - InfraResolverOf, - QuerySourceResolver ->; - -export type InfraSourceLogEntryHighlightsResolver = ChildResolverOf< - InfraResolverOf, - QuerySourceResolver ->; - -export const createLogEntriesResolvers = (libs: { - logEntries: InfraLogEntriesDomain; -}): { - InfraSource: { - logEntriesAround: InfraSourceLogEntriesAroundResolver; - logEntriesBetween: InfraSourceLogEntriesBetweenResolver; - logEntryHighlights: InfraSourceLogEntryHighlightsResolver; - }; - InfraLogEntryColumn: { - __resolveType( - logEntryColumn: InfraLogEntryColumn - ): - | 'InfraLogEntryTimestampColumn' - | 'InfraLogEntryMessageColumn' - | 'InfraLogEntryFieldColumn' - | null; - }; - InfraLogMessageSegment: { - __resolveType( - messageSegment: InfraLogMessageSegment - ): 'InfraLogMessageFieldSegment' | 'InfraLogMessageConstantSegment' | null; - }; -} => ({ - InfraSource: { - async logEntriesAround(source, args, { req }) { - const countBefore = args.countBefore || 0; - const countAfter = args.countAfter || 0; - - const { entriesBefore, entriesAfter } = await libs.logEntries.getLogEntriesAround( - req, - source.id, - args.key, - countBefore + 1, - countAfter + 1, - parseFilterQuery(args.filterQuery) - ); - - const hasMoreBefore = entriesBefore.length > countBefore; - const hasMoreAfter = entriesAfter.length > countAfter; - - const entries = [ - ...(hasMoreBefore ? entriesBefore.slice(1) : entriesBefore), - ...(hasMoreAfter ? entriesAfter.slice(0, -1) : entriesAfter), - ]; - - return { - start: entries.length > 0 ? entries[0].key : null, - end: entries.length > 0 ? entries[entries.length - 1].key : null, - hasMoreBefore, - hasMoreAfter, - filterQuery: args.filterQuery, - entries, - }; - }, - async logEntriesBetween(source, args, { req }) { - const entries = await libs.logEntries.getLogEntriesBetween( - req, - source.id, - args.startKey, - args.endKey, - parseFilterQuery(args.filterQuery) - ); - - return { - start: entries.length > 0 ? entries[0].key : null, - end: entries.length > 0 ? entries[entries.length - 1].key : null, - hasMoreBefore: true, - hasMoreAfter: true, - filterQuery: args.filterQuery, - entries, - }; - }, - async logEntryHighlights(source, args, { req }) { - const highlightedLogEntrySets = await libs.logEntries.getLogEntryHighlights( - req, - source.id, - args.startKey, - args.endKey, - args.highlights.filter(highlightInput => !!highlightInput.query), - parseFilterQuery(args.filterQuery) - ); - - return highlightedLogEntrySets.map(entries => ({ - start: entries.length > 0 ? entries[0].key : null, - end: entries.length > 0 ? entries[entries.length - 1].key : null, - hasMoreBefore: true, - hasMoreAfter: true, - filterQuery: args.filterQuery, - entries, - })); - }, - }, - InfraLogEntryColumn: { - __resolveType(logEntryColumn) { - if (isTimestampColumn(logEntryColumn)) { - return 'InfraLogEntryTimestampColumn'; - } - - if (isMessageColumn(logEntryColumn)) { - return 'InfraLogEntryMessageColumn'; - } - - if (isFieldColumn(logEntryColumn)) { - return 'InfraLogEntryFieldColumn'; - } - - return null; - }, - }, - InfraLogMessageSegment: { - __resolveType(messageSegment) { - if (isConstantSegment(messageSegment)) { - return 'InfraLogMessageConstantSegment'; - } - - if (isFieldSegment(messageSegment)) { - return 'InfraLogMessageFieldSegment'; - } - - return null; - }, - }, -}); - -const isTimestampColumn = (column: InfraLogEntryColumn): column is InfraLogEntryTimestampColumn => - 'timestamp' in column; - -const isMessageColumn = (column: InfraLogEntryColumn): column is InfraLogEntryMessageColumn => - 'message' in column; - -const isFieldColumn = (column: InfraLogEntryColumn): column is InfraLogEntryFieldColumn => - 'field' in column && 'value' in column; - -const isConstantSegment = ( - segment: InfraLogMessageSegment -): segment is InfraLogMessageConstantSegment => 'constant' in segment; - -const isFieldSegment = (segment: InfraLogMessageSegment): segment is InfraLogMessageFieldSegment => - 'field' in segment && 'value' in segment && 'highlights' in segment; diff --git a/x-pack/plugins/infra/server/graphql/log_entries/schema.gql.ts b/x-pack/plugins/infra/server/graphql/log_entries/schema.gql.ts deleted file mode 100644 index 945f2f85435e5..0000000000000 --- a/x-pack/plugins/infra/server/graphql/log_entries/schema.gql.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import gql from 'graphql-tag'; - -export const logEntriesSchema = gql` - "A segment of the log entry message that was derived from a field" - type InfraLogMessageFieldSegment { - "The field the segment was derived from" - field: String! - "The segment's message" - value: String! - "A list of highlighted substrings of the value" - highlights: [String!]! - } - - "A segment of the log entry message that was derived from a string literal" - type InfraLogMessageConstantSegment { - "The segment's message" - constant: String! - } - - "A segment of the log entry message" - union InfraLogMessageSegment = InfraLogMessageFieldSegment | InfraLogMessageConstantSegment - - "A special built-in column that contains the log entry's timestamp" - type InfraLogEntryTimestampColumn { - "The id of the corresponding column configuration" - columnId: ID! - "The timestamp" - timestamp: Float! - } - - "A special built-in column that contains the log entry's constructed message" - type InfraLogEntryMessageColumn { - "The id of the corresponding column configuration" - columnId: ID! - "A list of the formatted log entry segments" - message: [InfraLogMessageSegment!]! - } - - "A column that contains the value of a field of the log entry" - type InfraLogEntryFieldColumn { - "The id of the corresponding column configuration" - columnId: ID! - "The field name of the column" - field: String! - "The value of the field in the log entry" - value: String! - "A list of highlighted substrings of the value" - highlights: [String!]! - } - - "A column of a log entry" - union InfraLogEntryColumn = - InfraLogEntryTimestampColumn - | InfraLogEntryMessageColumn - | InfraLogEntryFieldColumn - - "A log entry" - type InfraLogEntry { - "A unique representation of the log entry's position in the event stream" - key: InfraTimeKey! - "The log entry's id" - gid: String! - "The source id" - source: String! - "The columns used for rendering the log entry" - columns: [InfraLogEntryColumn!]! - } - - "A highlighting definition" - input InfraLogEntryHighlightInput { - "The query to highlight by" - query: String! - "The number of highlighted documents to include beyond the beginning of the interval" - countBefore: Int! - "The number of highlighted documents to include beyond the end of the interval" - countAfter: Int! - } - - "A consecutive sequence of log entries" - type InfraLogEntryInterval { - "The key corresponding to the start of the interval covered by the entries" - start: InfraTimeKey - "The key corresponding to the end of the interval covered by the entries" - end: InfraTimeKey - "Whether there are more log entries available before the start" - hasMoreBefore: Boolean! - "Whether there are more log entries available after the end" - hasMoreAfter: Boolean! - "The query the log entries were filtered by" - filterQuery: String - "The query the log entries were highlighted with" - highlightQuery: String - "A list of the log entries" - entries: [InfraLogEntry!]! - } - - extend type InfraSource { - "A consecutive span of log entries surrounding a point in time" - logEntriesAround( - "The sort key that corresponds to the point in time" - key: InfraTimeKeyInput! - "The maximum number of preceding to return" - countBefore: Int = 0 - "The maximum number of following to return" - countAfter: Int = 0 - "The query to filter the log entries by" - filterQuery: String - ): InfraLogEntryInterval! - "A consecutive span of log entries within an interval" - logEntriesBetween( - "The sort key that corresponds to the start of the interval" - startKey: InfraTimeKeyInput! - "The sort key that corresponds to the end of the interval" - endKey: InfraTimeKeyInput! - "The query to filter the log entries by" - filterQuery: String - ): InfraLogEntryInterval! - "Sequences of log entries matching sets of highlighting queries within an interval" - logEntryHighlights( - "The sort key that corresponds to the start of the interval" - startKey: InfraTimeKeyInput! - "The sort key that corresponds to the end of the interval" - endKey: InfraTimeKeyInput! - "The query to filter the log entries by" - filterQuery: String - "The highlighting to apply to the log entries" - highlights: [InfraLogEntryHighlightInput!]! - ): [InfraLogEntryInterval!]! - } -`; diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index f058b9e52c75b..fb9dd172bf6ed 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -7,7 +7,6 @@ import { IResolvers, makeExecutableSchema } from 'graphql-tools'; import { initIpToHostName } from './routes/ip_to_hostname'; import { schemas } from './graphql'; -import { createLogEntriesResolvers } from './graphql/log_entries'; import { createSourceStatusResolvers } from './graphql/source_status'; import { createSourcesResolvers } from './graphql/sources'; import { InfraBackendLibs } from './lib/infra_types'; @@ -34,7 +33,6 @@ import { initInventoryMetaRoute } from './routes/inventory_metadata'; export const initInfraServer = (libs: InfraBackendLibs) => { const schema = makeExecutableSchema({ resolvers: [ - createLogEntriesResolvers(libs) as IResolvers, createSourcesResolvers(libs) as IResolvers, createSourceStatusResolvers(libs) as IResolvers, ], diff --git a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts index f48c949329b04..3a5dff75f004e 100644 --- a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts @@ -8,12 +8,11 @@ import { timeMilliseconds } from 'd3-time'; import * as runtimeTypes from 'io-ts'; -import { compact, first, get, has, zip } from 'lodash'; +import { compact, first, get, has } from 'lodash'; import { pipe } from 'fp-ts/lib/pipeable'; import { map, fold } from 'fp-ts/lib/Either'; import { identity, constant } from 'fp-ts/lib/function'; import { RequestHandlerContext } from 'src/core/server'; -import { compareTimeKeys, isTimeKey, TimeKey } from '../../../../common/time'; import { JsonObject, JsonValue } from '../../../../common/typed_json'; import { LogEntriesAdapter, @@ -27,8 +26,6 @@ import { InfraSourceConfiguration } from '../../sources'; import { SortedSearchHit } from '../framework'; import { KibanaFramework } from '../framework/kibana_framework_adapter'; -const DAY_MILLIS = 24 * 60 * 60 * 1000; -const LOOKUP_OFFSETS = [0, 1, 7, 30, 365, 10000, Infinity].map(days => days * DAY_MILLIS); const TIMESTAMP_FORMAT = 'epoch_millis'; interface LogItemHit { @@ -41,53 +38,13 @@ interface LogItemHit { export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { constructor(private readonly framework: KibanaFramework) {} - public async getAdjacentLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - direction: 'asc' | 'desc', - maxCount: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - if (maxCount <= 0) { - return []; - } - - const intervals = getLookupIntervals(start.time, direction); - - let documents: LogEntryDocument[] = []; - for (const [intervalStart, intervalEnd] of intervals) { - if (documents.length >= maxCount) { - break; - } - - const documentsInInterval = await this.getLogEntryDocumentsBetween( - requestContext, - sourceConfiguration, - fields, - intervalStart, - intervalEnd, - documents.length > 0 ? documents[documents.length - 1].key : start, - maxCount - documents.length, - filterQuery, - highlightQuery - ); - - documents = [...documents, ...documentsInInterval]; - } - - return direction === 'asc' ? documents : documents.reverse(); - } - public async getLogEntries( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, fields: string[], params: LogEntriesParams ): Promise { - const { startDate, endDate, query, cursor, size, highlightTerm } = params; + const { startTimestamp, endTimestamp, query, cursor, size, highlightTerm } = params; const { sortDirection, searchAfterClause } = processCursor(cursor); @@ -133,8 +90,8 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { { range: { [sourceConfiguration.fields.timestamp]: { - gte: startDate, - lte: endDate, + gte: startTimestamp, + lte: endTimestamp, format: TIMESTAMP_FORMAT, }, }, @@ -158,40 +115,19 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { return mapHitsToLogEntryDocuments(hits, sourceConfiguration.fields.timestamp, fields); } - /** @deprecated */ - public async getContainedLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - end: TimeKey, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - const documents = await this.getLogEntryDocumentsBetween( - requestContext, - sourceConfiguration, - fields, - start.time, - end.time, - start, - 10000, - filterQuery, - highlightQuery - ); - - return documents.filter(document => compareTimeKeys(document.key, end) < 0); - } - public async getContainedLogSummaryBuckets( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, - start: number, - end: number, + startTimestamp: number, + endTimestamp: number, bucketSize: number, filterQuery?: LogEntryQuery ): Promise { - const bucketIntervalStarts = timeMilliseconds(new Date(start), new Date(end), bucketSize); + const bucketIntervalStarts = timeMilliseconds( + new Date(startTimestamp), + new Date(endTimestamp), + bucketSize + ); const query = { allowNoIndices: true, @@ -229,8 +165,8 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { { range: { [sourceConfiguration.fields.timestamp]: { - gte: start, - lte: end, + gte: startTimestamp, + lte: endTimestamp, format: TIMESTAMP_FORMAT, }, }, @@ -288,112 +224,6 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { } return document; } - - private async getLogEntryDocumentsBetween( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: number, - end: number, - after: TimeKey | null, - maxCount: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - if (maxCount <= 0) { - return []; - } - - const sortDirection: 'asc' | 'desc' = start <= end ? 'asc' : 'desc'; - - const startRange = { - [sortDirection === 'asc' ? 'gte' : 'lte']: start, - }; - const endRange = - end === Infinity - ? {} - : { - [sortDirection === 'asc' ? 'lte' : 'gte']: end, - }; - - const highlightClause = highlightQuery - ? { - highlight: { - boundary_scanner: 'word', - fields: fields.reduce( - (highlightFieldConfigs, fieldName) => ({ - ...highlightFieldConfigs, - [fieldName]: {}, - }), - {} - ), - fragment_size: 1, - number_of_fragments: 100, - post_tags: [''], - pre_tags: [''], - highlight_query: highlightQuery, - }, - } - : {}; - - const searchAfterClause = isTimeKey(after) - ? { - search_after: [after.time, after.tiebreaker], - } - : {}; - - const query = { - allowNoIndices: true, - index: sourceConfiguration.logAlias, - ignoreUnavailable: true, - body: { - query: { - bool: { - filter: [ - ...createQueryFilterClauses(filterQuery), - { - range: { - [sourceConfiguration.fields.timestamp]: { - ...startRange, - ...endRange, - format: TIMESTAMP_FORMAT, - }, - }, - }, - ], - }, - }, - ...highlightClause, - ...searchAfterClause, - _source: fields, - size: maxCount, - sort: [ - { [sourceConfiguration.fields.timestamp]: sortDirection }, - { [sourceConfiguration.fields.tiebreaker]: sortDirection }, - ], - track_total_hits: false, - }, - }; - - const response = await this.framework.callWithRequest( - requestContext, - 'search', - query - ); - const hits = response.hits.hits; - const documents = hits.map(convertHitToLogEntryDocument(fields)); - - return documents; - } -} - -function getLookupIntervals(start: number, direction: 'asc' | 'desc'): Array<[number, number]> { - const offsetSign = direction === 'asc' ? 1 : -1; - const translatedOffsets = LOOKUP_OFFSETS.map(offset => start + offset * offsetSign); - const intervals = zip(translatedOffsets.slice(0, -1), translatedOffsets.slice(1)) as Array< - [number, number] - >; - return intervals; } function mapHitsToLogEntryDocuments( @@ -423,28 +253,6 @@ function mapHitsToLogEntryDocuments( }); } -/** @deprecated */ -const convertHitToLogEntryDocument = (fields: string[]) => ( - hit: SortedSearchHit -): LogEntryDocument => ({ - gid: hit._id, - fields: fields.reduce( - (flattenedFields, fieldName) => - has(hit._source, fieldName) - ? { - ...flattenedFields, - [fieldName]: get(hit._source, fieldName), - } - : flattenedFields, - {} as { [fieldName: string]: string | number | object | boolean | null } - ), - highlights: hit.highlight || {}, - key: { - time: hit.sort[0], - tiebreaker: hit.sort[1], - }, -}); - const convertDateRangeBucketToSummaryBucket = ( bucket: LogSummaryDateRangeBucket ): LogSummaryBucket => ({ diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index 2f71d56e1e0e3..9a2631e3c2f76 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import stringify from 'json-stable-stringify'; import { sortBy } from 'lodash'; import { RequestHandlerContext } from 'src/core/server'; @@ -18,13 +17,10 @@ import { LogEntriesCursor, LogColumn, } from '../../../../common/http_api'; -import { InfraLogEntry, InfraLogMessageSegment } from '../../../graphql/types'; import { InfraSourceConfiguration, InfraSources, SavedSourceConfigurationFieldColumnRuntimeType, - SavedSourceConfigurationMessageColumnRuntimeType, - SavedSourceConfigurationTimestampColumnRuntimeType, } from '../../sources'; import { getBuiltinRules } from './builtin_rules'; import { convertDocumentSourceToLogItemFields } from './convert_document_source_to_log_item_fields'; @@ -36,16 +32,16 @@ import { } from './message'; export interface LogEntriesParams { - startDate: number; - endDate: number; + startTimestamp: number; + endTimestamp: number; size?: number; query?: JsonObject; cursor?: { before: LogEntriesCursor | 'last' } | { after: LogEntriesCursor | 'first' }; highlightTerm?: string; } export interface LogEntriesAroundParams { - startDate: number; - endDate: number; + startTimestamp: number; + endTimestamp: number; size?: number; center: LogEntriesCursor; query?: JsonObject; @@ -67,7 +63,7 @@ export class InfraLogEntriesDomain { sourceId: string, params: LogEntriesAroundParams ) { - const { startDate, endDate, center, query, size, highlightTerm } = params; + const { startTimestamp, endTimestamp, center, query, size, highlightTerm } = params; /* * For odd sizes we will round this value down for the first half, and up @@ -80,8 +76,8 @@ export class InfraLogEntriesDomain { const halfSize = (size || LOG_ENTRIES_PAGE_SIZE) / 2; const entriesBefore = await this.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query, cursor: { before: center }, size: Math.floor(halfSize), @@ -101,8 +97,8 @@ export class InfraLogEntriesDomain { : { time: center.time - 1, tiebreaker: 0 }; const entriesAfter = await this.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query, cursor: { after: cursorAfter }, size: Math.ceil(halfSize), @@ -112,71 +108,6 @@ export class InfraLogEntriesDomain { return [...entriesBefore, ...entriesAfter]; } - /** @deprecated */ - public async getLogEntriesAround( - requestContext: RequestHandlerContext, - sourceId: string, - key: TimeKey, - maxCountBefore: number, - maxCountAfter: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise<{ entriesBefore: InfraLogEntry[]; entriesAfter: InfraLogEntry[] }> { - if (maxCountBefore <= 0 && maxCountAfter <= 0) { - return { - entriesBefore: [], - entriesAfter: [], - }; - } - - const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, - sourceId - ); - const messageFormattingRules = compileFormattingRules( - getBuiltinRules(configuration.fields.message) - ); - const requiredFields = getRequiredFields(configuration, messageFormattingRules); - - const documentsBefore = await this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - key, - 'desc', - Math.max(maxCountBefore, 1), - filterQuery, - highlightQuery - ); - const lastKeyBefore = - documentsBefore.length > 0 - ? documentsBefore[documentsBefore.length - 1].key - : { - time: key.time - 1, - tiebreaker: 0, - }; - - const documentsAfter = await this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - lastKeyBefore, - 'asc', - maxCountAfter, - filterQuery, - highlightQuery - ); - - return { - entriesBefore: (maxCountBefore > 0 ? documentsBefore : []).map( - convertLogDocumentToEntry(sourceId, configuration.logColumns, messageFormattingRules.format) - ), - entriesAfter: documentsAfter.map( - convertLogDocumentToEntry(sourceId, configuration.logColumns, messageFormattingRules.format) - ), - }; - } - public async getLogEntries( requestContext: RequestHandlerContext, sourceId: string, @@ -220,7 +151,7 @@ export class InfraLogEntriesDomain { return { columnId: column.fieldColumn.id, field: column.fieldColumn.field, - value: stringify(doc.fields[column.fieldColumn.field]), + value: doc.fields[column.fieldColumn.field], highlights: doc.highlights[column.fieldColumn.field] || [], }; } @@ -232,116 +163,6 @@ export class InfraLogEntriesDomain { return entries; } - /** @deprecated */ - public async getLogEntriesBetween( - requestContext: RequestHandlerContext, - sourceId: string, - startKey: TimeKey, - endKey: TimeKey, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, - sourceId - ); - const messageFormattingRules = compileFormattingRules( - getBuiltinRules(configuration.fields.message) - ); - const requiredFields = getRequiredFields(configuration, messageFormattingRules); - const documents = await this.adapter.getContainedLogEntryDocuments( - requestContext, - configuration, - requiredFields, - startKey, - endKey, - filterQuery, - highlightQuery - ); - const entries = documents.map( - convertLogDocumentToEntry(sourceId, configuration.logColumns, messageFormattingRules.format) - ); - return entries; - } - - /** @deprecated */ - public async getLogEntryHighlights( - requestContext: RequestHandlerContext, - sourceId: string, - startKey: TimeKey, - endKey: TimeKey, - highlights: Array<{ - query: string; - countBefore: number; - countAfter: number; - }>, - filterQuery?: LogEntryQuery - ): Promise { - const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, - sourceId - ); - const messageFormattingRules = compileFormattingRules( - getBuiltinRules(configuration.fields.message) - ); - const requiredFields = getRequiredFields(configuration, messageFormattingRules); - - const documentSets = await Promise.all( - highlights.map(async highlight => { - const highlightQuery = createHighlightQueryDsl(highlight.query, requiredFields); - const query = filterQuery - ? { - bool: { - filter: [filterQuery, highlightQuery], - }, - } - : highlightQuery; - const [documentsBefore, documents, documentsAfter] = await Promise.all([ - this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - startKey, - 'desc', - highlight.countBefore, - query, - highlightQuery - ), - this.adapter.getContainedLogEntryDocuments( - requestContext, - configuration, - requiredFields, - startKey, - endKey, - query, - highlightQuery - ), - this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - endKey, - 'asc', - highlight.countAfter, - query, - highlightQuery - ), - ]); - const entries = [...documentsBefore, ...documents, ...documentsAfter].map( - convertLogDocumentToEntry( - sourceId, - configuration.logColumns, - messageFormattingRules.format - ) - ); - - return entries; - }) - ); - - return documentSets; - } - public async getLogSummaryBucketsBetween( requestContext: RequestHandlerContext, sourceId: string, @@ -368,8 +189,8 @@ export class InfraLogEntriesDomain { public async getLogSummaryHighlightBucketsBetween( requestContext: RequestHandlerContext, sourceId: string, - start: number, - end: number, + startTimestamp: number, + endTimestamp: number, bucketSize: number, highlightQueries: string[], filterQuery?: LogEntryQuery @@ -396,8 +217,8 @@ export class InfraLogEntriesDomain { const summaryBuckets = await this.adapter.getContainedLogSummaryBuckets( requestContext, configuration, - start, - end, + startTimestamp, + endTimestamp, bucketSize, query ); @@ -445,17 +266,6 @@ interface LogItemHit { } export interface LogEntriesAdapter { - getAdjacentLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - direction: 'asc' | 'desc', - maxCount: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise; - getLogEntries( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, @@ -463,21 +273,11 @@ export interface LogEntriesAdapter { params: LogEntriesParams ): Promise; - getContainedLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - end: TimeKey, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise; - getContainedLogSummaryBuckets( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, - start: number, - end: number, + startTimestamp: number, + endTimestamp: number, bucketSize: number, filterQuery?: LogEntryQuery ): Promise; @@ -505,37 +305,6 @@ export interface LogSummaryBucket { topEntryKeys: TimeKey[]; } -/** @deprecated */ -const convertLogDocumentToEntry = ( - sourceId: string, - logColumns: InfraSourceConfiguration['logColumns'], - formatLogMessage: (fields: Fields, highlights: Highlights) => InfraLogMessageSegment[] -) => (document: LogEntryDocument): InfraLogEntry => ({ - key: document.key, - gid: document.gid, - source: sourceId, - columns: logColumns.map(logColumn => { - if (SavedSourceConfigurationTimestampColumnRuntimeType.is(logColumn)) { - return { - columnId: logColumn.timestampColumn.id, - timestamp: document.key.time, - }; - } else if (SavedSourceConfigurationMessageColumnRuntimeType.is(logColumn)) { - return { - columnId: logColumn.messageColumn.id, - message: formatLogMessage(document.fields, document.highlights), - }; - } else { - return { - columnId: logColumn.fieldColumn.id, - field: logColumn.fieldColumn.field, - highlights: document.highlights[logColumn.fieldColumn.field] || [], - value: stringify(document.fields[logColumn.fieldColumn.field] || null), - }; - } - }), -}); - const logSummaryBucketHasEntries = (bucket: LogSummaryBucket) => bucket.entriesCount > 0 && bucket.topEntryKeys.length > 0; diff --git a/x-pack/plugins/infra/server/routes/log_entries/entries.ts b/x-pack/plugins/infra/server/routes/log_entries/entries.ts index 93802468dd267..f33dfa71fedcd 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/entries.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/entries.ts @@ -38,13 +38,19 @@ export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) fold(throwErrors(Boom.badRequest), identity) ); - const { startDate, endDate, sourceId, query, size } = payload; + const { + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + sourceId, + query, + size, + } = payload; let entries; if ('center' in payload) { entries = await logEntries.getLogEntriesAround__new(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), center: payload.center, size, @@ -58,20 +64,22 @@ export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) } entries = await logEntries.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), cursor, size, }); } + const hasEntries = entries.length > 0; + return response.ok({ body: logEntriesResponseRT.encode({ data: { entries, - topCursor: entries[0].cursor, - bottomCursor: entries[entries.length - 1].cursor, + topCursor: hasEntries ? entries[0].cursor : null, + bottomCursor: hasEntries ? entries[entries.length - 1].cursor : null, }, }), }); diff --git a/x-pack/plugins/infra/server/routes/log_entries/highlights.ts b/x-pack/plugins/infra/server/routes/log_entries/highlights.ts index 8ee412d5acdd5..2e581d96cab9c 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/highlights.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/highlights.ts @@ -38,7 +38,7 @@ export const initLogEntriesHighlightsRoute = ({ framework, logEntries }: InfraBa fold(throwErrors(Boom.badRequest), identity) ); - const { startDate, endDate, sourceId, query, size, highlightTerms } = payload; + const { startTimestamp, endTimestamp, sourceId, query, size, highlightTerms } = payload; let entriesPerHighlightTerm; @@ -46,8 +46,8 @@ export const initLogEntriesHighlightsRoute = ({ framework, logEntries }: InfraBa entriesPerHighlightTerm = await Promise.all( highlightTerms.map(highlightTerm => logEntries.getLogEntriesAround__new(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), center: payload.center, size, @@ -66,8 +66,8 @@ export const initLogEntriesHighlightsRoute = ({ framework, logEntries }: InfraBa entriesPerHighlightTerm = await Promise.all( highlightTerms.map(highlightTerm => logEntries.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), cursor, size, diff --git a/x-pack/plugins/infra/server/routes/log_entries/summary.ts b/x-pack/plugins/infra/server/routes/log_entries/summary.ts index 3f5bc8e364a58..aa4421374ec12 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/summary.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/summary.ts @@ -36,13 +36,13 @@ export const initLogEntriesSummaryRoute = ({ framework, logEntries }: InfraBacke logEntriesSummaryRequestRT.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const { sourceId, startDate, endDate, bucketSize, query } = payload; + const { sourceId, startTimestamp, endTimestamp, bucketSize, query } = payload; const buckets = await logEntries.getLogSummaryBucketsBetween( requestContext, sourceId, - startDate, - endDate, + startTimestamp, + endTimestamp, bucketSize, parseFilterQuery(query) ); @@ -50,8 +50,8 @@ export const initLogEntriesSummaryRoute = ({ framework, logEntries }: InfraBacke return response.ok({ body: logEntriesSummaryResponseRT.encode({ data: { - start: startDate, - end: endDate, + start: startTimestamp, + end: endTimestamp, buckets, }, }), diff --git a/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts b/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts index 6c6f7a5a3dcd3..d92cddcdc415d 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts @@ -39,13 +39,20 @@ export const initLogEntriesSummaryHighlightsRoute = ({ logEntriesSummaryHighlightsRequestRT.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const { sourceId, startDate, endDate, bucketSize, query, highlightTerms } = payload; + const { + sourceId, + startTimestamp, + endTimestamp, + bucketSize, + query, + highlightTerms, + } = payload; const bucketsPerHighlightTerm = await logEntries.getLogSummaryHighlightBucketsBetween( requestContext, sourceId, - startDate, - endDate, + startTimestamp, + endTimestamp, bucketSize, highlightTerms, parseFilterQuery(query) @@ -54,8 +61,8 @@ export const initLogEntriesSummaryHighlightsRoute = ({ return response.ok({ body: logEntriesSummaryHighlightsResponseRT.encode({ data: bucketsPerHighlightTerm.map(buckets => ({ - start: startDate, - end: endDate, + start: startTimestamp, + end: endTimestamp, buckets, })), }), diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index 57f2a633e4524..16ae1b8da752b 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -5,6 +5,7 @@ */ export const PLUGIN_ID = 'lens'; +export const LENS_EMBEDDABLE_TYPE = 'lens'; export const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; export const BASE_APP_URL = '/app/kibana'; export const BASE_API_URL = '/api/lens'; diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts index 823d27e4617b2..bf8e3031db975 100644 --- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts +++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts @@ -81,7 +81,7 @@ export interface ModelPlotConfig { // TODO, finish this when it's needed export interface CustomRule { - actions: any; - scope: object; - conditions: object; + actions: string[]; + scope?: object; + conditions: any[]; } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js index 3bf128f84aa78..39cd25ba87d8c 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js @@ -66,6 +66,10 @@ export function createUrlOverrides(overrides, originalSettings) { ) { formattedOverrides.format = originalSettings.format; } + + if (Array.isArray(formattedOverrides.column_names)) { + formattedOverrides.column_names = formattedOverrides.column_names.join(); + } } if (formattedOverrides.format === '' && originalSettings.format === 'semi_structured_text') { @@ -82,11 +86,6 @@ export function createUrlOverrides(overrides, originalSettings) { formattedOverrides.column_names = ''; } - // escape grok pattern as it can contain bad characters - if (formattedOverrides.grok_pattern !== '') { - formattedOverrides.grok_pattern = encodeURIComponent(formattedOverrides.grok_pattern); - } - if (formattedOverrides.lines_to_sample === '') { formattedOverrides.lines_to_sample = overrides.linesToSample; } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts index 31155b0a96ed4..f115c203624eb 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts @@ -88,16 +88,13 @@ export class MultiMetricJobCreator extends JobCreator { // called externally to set the model memory limit based current detector configuration public async calculateModelMemoryLimit() { - if (this._splitField === null) { - // not split field, use the default + if (this.jobConfig.analysis_config.detectors.length === 0) { this.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; } else { const { modelMemoryLimit } = await ml.calculateModelMemoryLimit({ + analysisConfig: this.jobConfig.analysis_config, indexPattern: this._indexPatternTitle, - splitFieldName: this._splitField.name, query: this._datafeed_config.query, - fieldNames: this.fields.map(f => f.id), - influencerNames: this._influencers, timeFieldName: this._job_config.data_description.time_field, earliestMs: this._start, latestMs: this._end, diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index b8e21898a4bb3..cd4a97bd10ed4 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -22,6 +22,7 @@ import { Datafeed, CombinedJob, Detector, + AnalysisConfig, } from '../../../../common/types/anomaly_detection_jobs'; import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { FieldRequestConfig } from '../../datavisualizer/index_based/common'; @@ -532,30 +533,24 @@ export const ml = { }, calculateModelMemoryLimit({ + analysisConfig, indexPattern, - splitFieldName, query, - fieldNames, - influencerNames, timeFieldName, earliestMs, latestMs, }: { + analysisConfig: AnalysisConfig; indexPattern: string; - splitFieldName: string; query: any; - fieldNames: string[]; - influencerNames: string[]; timeFieldName: string; earliestMs: number; latestMs: number; }) { const body = JSON.stringify({ + analysisConfig, indexPattern, - splitFieldName, query, - fieldNames, - influencerNames, timeFieldName, earliestMs, latestMs, diff --git a/x-pack/plugins/ml/server/client/elasticsearch_ml.ts b/x-pack/plugins/ml/server/client/elasticsearch_ml.ts index 1d09a6c765e29..ed4dc64cde3bd 100644 --- a/x-pack/plugins/ml/server/client/elasticsearch_ml.ts +++ b/x-pack/plugins/ml/server/client/elasticsearch_ml.ts @@ -413,6 +413,14 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'POST', }); + ml.estimateModelMemory = ca({ + url: { + fmt: '/_ml/anomaly_detectors/_estimate_model_memory', + }, + needBody: true, + method: 'POST', + }); + ml.datafeedPreview = ca({ url: { fmt: '/_ml/datafeeds/<%=datafeedId%>/_preview', diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.d.ts b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.d.ts deleted file mode 100644 index 927728040bdd7..0000000000000 --- a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.d.ts +++ /dev/null @@ -1,20 +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 { APICaller } from 'kibana/server'; - -export function calculateModelMemoryLimitProvider( - callAsCurrentUser: APICaller -): ( - indexPattern: string, - splitFieldName: string, - query: any, - fieldNames: any, - influencerNames: any, // string[] ? - timeFieldName: string, - earliestMs: number, - latestMs: number -) => Promise; diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.js b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.js deleted file mode 100644 index 8a06895762dc2..0000000000000 --- a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.js +++ /dev/null @@ -1,117 +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. - */ - -// calculates the size of the model memory limit used in the job config -// based on the cardinality of the field being used to split the data. -// the limit should be 10MB plus 20kB per series, rounded up to the nearest MB. -import numeral from '@elastic/numeral'; -import { fieldsServiceProvider } from '../fields_service'; - -export function calculateModelMemoryLimitProvider(callAsCurrentUser) { - const fieldsService = fieldsServiceProvider(callAsCurrentUser); - - return function calculateModelMemoryLimit( - indexPattern, - splitFieldName, - query, - fieldNames, - influencerNames, - timeFieldName, - earliestMs, - latestMs, - allowMMLGreaterThanMax = false - ) { - return new Promise((response, reject) => { - const limits = {}; - callAsCurrentUser('ml.info') - .then(resp => { - if (resp.limits !== undefined && resp.limits.max_model_memory_limit !== undefined) { - limits.max_model_memory_limit = resp.limits.max_model_memory_limit; - } - }) - .catch(error => { - reject(error); - }); - - // find the cardinality of the split field - function splitFieldCardinality() { - return fieldsService.getCardinalityOfFields( - indexPattern, - [splitFieldName], - query, - timeFieldName, - earliestMs, - latestMs - ); - } - - // find the cardinality of an influencer field - function influencerCardinality(influencerName) { - return fieldsService.getCardinalityOfFields( - indexPattern, - [influencerName], - query, - timeFieldName, - earliestMs, - latestMs - ); - } - - const calculations = [ - splitFieldCardinality(), - ...influencerNames.map(inf => influencerCardinality(inf)), - ]; - - Promise.all(calculations) - .then(responses => { - let mmlMB = 0; - const MB = 1000; - responses.forEach((resp, i) => { - let mmlKB = 0; - if (i === 0) { - // first in the list is the basic calculation. - // a base of 10MB plus 64KB per series per detector - // i.e. 10000KB + (64KB * cardinality of split field * number or detectors) - const cardinality = resp[splitFieldName]; - mmlKB = 10000; - const SERIES_MULTIPLIER = 64; - const numberOfFields = fieldNames.length; - - if (cardinality !== undefined) { - mmlKB += SERIES_MULTIPLIER * cardinality * numberOfFields; - } - } else { - // the rest of the calculations are for influencers fields - // 10KB per series of influencer field - // i.e. 10KB * cardinality of influencer field - const cardinality = resp[splitFieldName]; - mmlKB = 0; - const SERIES_MULTIPLIER = 10; - if (cardinality !== undefined) { - mmlKB = SERIES_MULTIPLIER * cardinality; - } - } - // convert the total to MB, rounding up. - mmlMB += Math.ceil(mmlKB / MB); - }); - - // if max_model_memory_limit has been set, - // make sure the estimated value is not greater than it. - if (allowMMLGreaterThanMax === false && limits.max_model_memory_limit !== undefined) { - const maxBytes = numeral(limits.max_model_memory_limit.toUpperCase()).value(); - const mmlBytes = numeral(`${mmlMB}MB`).value(); - if (mmlBytes > maxBytes) { - mmlMB = Math.floor(maxBytes / numeral('1MB').value()); - } - } - response({ modelMemoryLimit: `${mmlMB}MB` }); - }) - .catch(error => { - reject(error); - }); - }); - }; -} diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts new file mode 100644 index 0000000000000..c97bbe07fffda --- /dev/null +++ b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import numeral from '@elastic/numeral'; +import { APICaller } from 'kibana/server'; +import { AnalysisConfig } from '../../../common/types/anomaly_detection_jobs'; +import { fieldsServiceProvider } from '../fields_service'; + +interface ModelMemoryEstimationResult { + /** + * Result model memory limit + */ + modelMemoryLimit: string; + /** + * Estimated model memory by elasticsearch ml endpoint + */ + estimatedModelMemoryLimit: string; + /** + * Maximum model memory limit + */ + maxModelMemoryLimit?: string; +} + +/** + * Response of the _estimate_model_memory endpoint. + */ +export interface ModelMemoryEstimate { + model_memory_estimate: string; +} + +/** + * Retrieves overall and max bucket cardinalities. + */ +async function getCardinalities( + callAsCurrentUser: APICaller, + analysisConfig: AnalysisConfig, + indexPattern: string, + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number +): Promise<{ + overallCardinality: { [key: string]: number }; + maxBucketCardinality: { [key: string]: number }; +}> { + /** + * Fields not involved in cardinality check + */ + const excludedKeywords = new Set( + /** + * The keyword which is used to mean the output of categorization, + * so it will have cardinality zero in the actual input data. + */ + 'mlcategory' + ); + + const fieldsService = fieldsServiceProvider(callAsCurrentUser); + + const { detectors, influencers, bucket_span: bucketSpan } = analysisConfig; + + let overallCardinality = {}; + let maxBucketCardinality = {}; + const overallCardinalityFields: Set = detectors.reduce( + ( + acc, + { + by_field_name: byFieldName, + partition_field_name: partitionFieldName, + over_field_name: overFieldName, + } + ) => { + [byFieldName, partitionFieldName, overFieldName] + .filter(field => field !== undefined && field !== '' && !excludedKeywords.has(field)) + .forEach(key => { + acc.add(key as string); + }); + return acc; + }, + new Set() + ); + + const maxBucketFieldCardinalities: string[] = influencers.filter( + influencerField => + typeof influencerField === 'string' && + !excludedKeywords.has(influencerField) && + !!influencerField && + !overallCardinalityFields.has(influencerField) + ) as string[]; + + if (overallCardinalityFields.size > 0) { + overallCardinality = await fieldsService.getCardinalityOfFields( + indexPattern, + [...overallCardinalityFields], + query, + timeFieldName, + earliestMs, + latestMs + ); + } + + if (maxBucketFieldCardinalities.length > 0) { + maxBucketCardinality = await fieldsService.getMaxBucketCardinalities( + indexPattern, + maxBucketFieldCardinalities, + query, + timeFieldName, + earliestMs, + latestMs, + bucketSpan + ); + } + + return { + overallCardinality, + maxBucketCardinality, + }; +} + +export function calculateModelMemoryLimitProvider(callAsCurrentUser: APICaller) { + /** + * Retrieves an estimated size of the model memory limit used in the job config + * based on the cardinality of the fields being used to split the data + * and influencers. + */ + return async function calculateModelMemoryLimit( + analysisConfig: AnalysisConfig, + indexPattern: string, + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number, + allowMMLGreaterThanMax = false + ): Promise { + let maxModelMemoryLimit; + try { + const resp = await callAsCurrentUser('ml.info'); + if (resp?.limits?.max_model_memory_limit !== undefined) { + maxModelMemoryLimit = resp.limits.max_model_memory_limit.toUpperCase(); + } + } catch (e) { + throw new Error('Unable to retrieve max model memory limit'); + } + + const { overallCardinality, maxBucketCardinality } = await getCardinalities( + callAsCurrentUser, + analysisConfig, + indexPattern, + query, + timeFieldName, + earliestMs, + latestMs + ); + + const estimatedModelMemoryLimit = ( + await callAsCurrentUser('ml.estimateModelMemory', { + body: { + analysis_config: analysisConfig, + overall_cardinality: overallCardinality, + max_bucket_cardinality: maxBucketCardinality, + }, + }) + ).model_memory_estimate.toUpperCase(); + + let modelMemoryLimit: string = estimatedModelMemoryLimit; + // if max_model_memory_limit has been set, + // make sure the estimated value is not greater than it. + if (!allowMMLGreaterThanMax && maxModelMemoryLimit !== undefined) { + // @ts-ignore + const maxBytes = numeral(maxModelMemoryLimit).value(); + // @ts-ignore + const mmlBytes = numeral(estimatedModelMemoryLimit).value(); + if (mmlBytes > maxBytes) { + // @ts-ignore + modelMemoryLimit = `${Math.floor(maxBytes / numeral('1MB').value())}MB`; + } + } + + return { + estimatedModelMemoryLimit, + modelMemoryLimit, + ...(maxModelMemoryLimit ? { maxModelMemoryLimit } : {}), + }; + }; +} diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.d.ts b/x-pack/plugins/ml/server/models/fields_service/fields_service.d.ts deleted file mode 100644 index 4a7e57d290b17..0000000000000 --- a/x-pack/plugins/ml/server/models/fields_service/fields_service.d.ts +++ /dev/null @@ -1,21 +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 { APICaller } from 'kibana/server'; - -export function fieldsServiceProvider( - callAsCurrentUser: APICaller -): { - getCardinalityOfFields: ( - index: string[] | string, - fieldNames: string[], - query: any, - timeFieldName: string, - earliestMs: number, - latestMs: number - ) => Promise; - getTimeFieldRange: (index: string[] | string, timeFieldName: string, query: any) => Promise; -}; diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.js b/x-pack/plugins/ml/server/models/fields_service/fields_service.js deleted file mode 100644 index a538693a92aba..0000000000000 --- a/x-pack/plugins/ml/server/models/fields_service/fields_service.js +++ /dev/null @@ -1,148 +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. - */ - -// Service for carrying out queries to obtain data -// specific to fields in Elasticsearch indices. - -export function fieldsServiceProvider(callAsCurrentUser) { - // Obtains the cardinality of one or more fields. - // Returns an Object whose keys are the names of the fields, - // with values equal to the cardinality of the field. - // Any of the supplied fieldNames which are not aggregatable will - // be omitted from the returned Object. - function getCardinalityOfFields(index, fieldNames, query, timeFieldName, earliestMs, latestMs) { - // First check that each of the supplied fieldNames are aggregatable, - // then obtain the cardinality for each of the aggregatable fields. - return new Promise((resolve, reject) => { - callAsCurrentUser('fieldCaps', { - index, - fields: fieldNames, - }) - .then(fieldCapsResp => { - const aggregatableFields = []; - fieldNames.forEach(fieldName => { - const fieldInfo = fieldCapsResp.fields[fieldName]; - const typeKeys = fieldInfo !== undefined ? Object.keys(fieldInfo) : []; - if (typeKeys.length > 0) { - const fieldType = typeKeys[0]; - const isFieldAggregatable = fieldInfo[fieldType].aggregatable; - if (isFieldAggregatable === true) { - aggregatableFields.push(fieldName); - } - } - }); - - if (aggregatableFields.length > 0) { - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range and the datafeed config query. - const mustCriteria = [ - { - range: { - [timeFieldName]: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - if (query) { - mustCriteria.push(query); - } - - const aggs = aggregatableFields.reduce((obj, field) => { - obj[field] = { cardinality: { field } }; - return obj; - }, {}); - - const body = { - query: { - bool: { - must: mustCriteria, - }, - }, - size: 0, - _source: { - excludes: [], - }, - aggs, - }; - - callAsCurrentUser('search', { - index, - body, - }) - .then(resp => { - const aggregations = resp.aggregations; - if (aggregations !== undefined) { - const results = aggregatableFields.reduce((obj, field) => { - obj[field] = (aggregations[field] || { value: 0 }).value; - return obj; - }, {}); - resolve(results); - } else { - resolve({}); - } - }) - .catch(resp => { - reject(resp); - }); - } else { - // None of the fields are aggregatable. Return empty Object. - resolve({}); - } - }) - .catch(resp => { - reject(resp); - }); - }); - } - - function getTimeFieldRange(index, timeFieldName, query) { - return new Promise((resolve, reject) => { - const obj = { success: true, start: { epoch: 0, string: '' }, end: { epoch: 0, string: '' } }; - - callAsCurrentUser('search', { - index, - size: 0, - body: { - query, - aggs: { - earliest: { - min: { - field: timeFieldName, - }, - }, - latest: { - max: { - field: timeFieldName, - }, - }, - }, - }, - }) - .then(resp => { - if (resp.aggregations && resp.aggregations.earliest && resp.aggregations.latest) { - obj.start.epoch = resp.aggregations.earliest.value; - obj.start.string = resp.aggregations.earliest.value_as_string; - - obj.end.epoch = resp.aggregations.latest.value; - obj.end.string = resp.aggregations.latest.value_as_string; - } - resolve(obj); - }) - .catch(resp => { - reject(resp); - }); - }); - } - - return { - getCardinalityOfFields, - getTimeFieldRange, - }; -} diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts new file mode 100644 index 0000000000000..d16984abc5d2a --- /dev/null +++ b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts @@ -0,0 +1,296 @@ +/* + * 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 Boom from 'boom'; +import { APICaller } from 'kibana/server'; +import { parseInterval } from '../../../common/util/parse_interval'; + +/** + * Service for carrying out queries to obtain data + * specific to fields in Elasticsearch indices. + */ +export function fieldsServiceProvider(callAsCurrentUser: APICaller) { + /** + * Gets aggregatable fields. + */ + async function getAggregatableFields( + index: string | string[], + fieldNames: string[] + ): Promise { + const fieldCapsResp = await callAsCurrentUser('fieldCaps', { + index, + fields: fieldNames, + }); + const aggregatableFields: string[] = []; + fieldNames.forEach(fieldName => { + const fieldInfo = fieldCapsResp.fields[fieldName]; + const typeKeys = fieldInfo !== undefined ? Object.keys(fieldInfo) : []; + if (typeKeys.length > 0) { + const fieldType = typeKeys[0]; + const isFieldAggregatable = fieldInfo[fieldType].aggregatable; + if (isFieldAggregatable === true) { + aggregatableFields.push(fieldName); + } + } + }); + return aggregatableFields; + } + + // Obtains the cardinality of one or more fields. + // Returns an Object whose keys are the names of the fields, + // with values equal to the cardinality of the field. + // Any of the supplied fieldNames which are not aggregatable will + // be omitted from the returned Object. + async function getCardinalityOfFields( + index: string[] | string, + fieldNames: string[], + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number + ): Promise<{ [key: string]: number }> { + const aggregatableFields = await getAggregatableFields(index, fieldNames); + + if (aggregatableFields.length === 0) { + return {}; + } + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range and the datafeed config query. + const mustCriteria = [ + { + range: { + [timeFieldName]: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + ]; + + if (query) { + mustCriteria.push(query); + } + + const aggs = aggregatableFields.reduce((obj, field) => { + obj[field] = { cardinality: { field } }; + return obj; + }, {} as { [field: string]: { cardinality: { field: string } } }); + + const body = { + query: { + bool: { + must: mustCriteria, + }, + }, + size: 0, + _source: { + excludes: [], + }, + aggs, + }; + + const aggregations = ( + await callAsCurrentUser('search', { + index, + body, + }) + )?.aggregations; + + if (!aggregations) { + return {}; + } + + return aggregatableFields.reduce((obj, field) => { + obj[field] = (aggregations[field] || { value: 0 }).value; + return obj; + }, {} as { [field: string]: number }); + } + + function getTimeFieldRange( + index: string[] | string, + timeFieldName: string, + query: any + ): Promise { + return new Promise((resolve, reject) => { + const obj = { success: true, start: { epoch: 0, string: '' }, end: { epoch: 0, string: '' } }; + + callAsCurrentUser('search', { + index, + size: 0, + body: { + query, + aggs: { + earliest: { + min: { + field: timeFieldName, + }, + }, + latest: { + max: { + field: timeFieldName, + }, + }, + }, + }, + }) + .then(resp => { + if (resp.aggregations && resp.aggregations.earliest && resp.aggregations.latest) { + obj.start.epoch = resp.aggregations.earliest.value; + obj.start.string = resp.aggregations.earliest.value_as_string; + + obj.end.epoch = resp.aggregations.latest.value; + obj.end.string = resp.aggregations.latest.value_as_string; + } + resolve(obj); + }) + .catch(resp => { + reject(resp); + }); + }); + } + + /** + * Caps provided time boundaries based on the interval. + * @param earliestMs + * @param latestMs + * @param interval + */ + function getSafeTimeRange( + earliestMs: number, + latestMs: number, + interval: string + ): { start: number; end: number } { + const maxNumberOfBuckets = 1000; + const end = latestMs; + + const intervalDuration = parseInterval(interval); + + if (intervalDuration === null) { + throw Boom.badRequest('Interval is invalid'); + } + + const start = Math.max( + earliestMs, + latestMs - maxNumberOfBuckets * intervalDuration.asMilliseconds() + ); + + return { start, end }; + } + + /** + * Retrieves max cardinalities for provided fields from date interval buckets + * using max bucket pipeline aggregation. + * + * @param index + * @param fieldNames - fields to perform cardinality aggregation on + * @param query + * @param timeFieldName + * @param earliestMs + * @param latestMs + * @param interval - a fixed interval for the date histogram aggregation + */ + async function getMaxBucketCardinalities( + index: string[] | string, + fieldNames: string[], + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number, + interval: string | undefined + ): Promise<{ [key: string]: number }> { + if (!interval) { + throw new Error('Interval is required to retrieve max bucket cardinalities.'); + } + + const aggregatableFields = await getAggregatableFields(index, fieldNames); + + if (aggregatableFields.length === 0) { + return {}; + } + + const { start, end } = getSafeTimeRange(earliestMs, latestMs, interval); + + const mustCriteria = [ + { + range: { + [timeFieldName]: { + gte: start, + lte: end, + format: 'epoch_millis', + }, + }, + }, + ]; + + if (query) { + mustCriteria.push(query); + } + + const dateHistogramAggKey = 'bucket_span_buckets'; + /** + * Replace any non-word characters + */ + const getSafeAggName = (field: string) => field.replace(/\W/g, ''); + const getMaxBucketAggKey = (field: string) => `max_bucket_${field}`; + + const fieldsCardinalityAggs = aggregatableFields.reduce((obj, field) => { + obj[getSafeAggName(field)] = { cardinality: { field } }; + return obj; + }, {} as { [field: string]: { cardinality: { field: string } } }); + + const maxBucketCardinalitiesAggs = Object.keys(fieldsCardinalityAggs).reduce((acc, field) => { + acc[getMaxBucketAggKey(field)] = { + max_bucket: { + buckets_path: `${dateHistogramAggKey}>${field}`, + }, + }; + return acc; + }, {} as { [key: string]: { max_bucket: { buckets_path: string } } }); + + const body = { + query: { + bool: { + filter: mustCriteria, + }, + }, + size: 0, + aggs: { + [dateHistogramAggKey]: { + date_histogram: { + field: timeFieldName, + fixed_interval: interval, + }, + aggs: fieldsCardinalityAggs, + }, + ...maxBucketCardinalitiesAggs, + }, + }; + + const aggregations = ( + await callAsCurrentUser('search', { + index, + body, + }) + )?.aggregations; + + if (!aggregations) { + return {}; + } + + return aggregatableFields.reduce((obj, field) => { + obj[field] = (aggregations[getMaxBucketAggKey(field)] || { value: 0 }).value ?? 0; + return obj; + }, {} as { [field: string]: number }); + } + + return { + getCardinalityOfFields, + getTimeFieldRange, + getMaxBucketCardinalities, + }; +} diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_job_object.js b/x-pack/plugins/ml/server/models/job_validation/validate_job_object.ts similarity index 96% rename from x-pack/plugins/ml/server/models/job_validation/validate_job_object.js rename to x-pack/plugins/ml/server/models/job_validation/validate_job_object.ts index 3205aba4fac4d..b0271fb5b4f45 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_job_object.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_job_object.ts @@ -5,8 +5,9 @@ */ import { i18n } from '@kbn/i18n'; +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; -export function validateJobObject(job) { +export function validateJobObject(job: CombinedJob | null) { if (job === null || typeof job !== 'object') { throw new Error( i18n.translate('xpack.ml.models.jobValidation.validateJobObject.jobIsNotObjectErrorMessage', { diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.js b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.js deleted file mode 100644 index 733ed9c3c22c6..0000000000000 --- a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.js +++ /dev/null @@ -1,170 +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 numeral from '@elastic/numeral'; -import { validateJobObject } from './validate_job_object'; -import { calculateModelMemoryLimitProvider } from '../../models/calculate_model_memory_limit'; -import { ALLOWED_DATA_UNITS } from '../../../common/constants/validation'; - -// The minimum value the backend expects is 1MByte -const MODEL_MEMORY_LIMIT_MINIMUM_BYTES = 1048576; - -export async function validateModelMemoryLimit(callWithRequest, job, duration) { - validateJobObject(job); - - // retrieve the max_model_memory_limit value from the server - // this will be unset unless the user has set this on their cluster - const mlInfo = await callWithRequest('ml.info'); - const maxModelMemoryLimit = - typeof mlInfo.limits === 'undefined' ? undefined : mlInfo.limits.max_model_memory_limit; - - // retrieve the model memory limit specified by the user in the job config. - // note, this will probably be the auto generated value, unless the user has - // over written it. - const mml = - typeof job.analysis_limits !== 'undefined' && - typeof job.analysis_limits.model_memory_limit !== 'undefined' - ? job.analysis_limits.model_memory_limit.toUpperCase() - : null; - - const splitFieldNames = {}; - let splitFieldName = ''; - const fieldNames = []; - let runCalcModelMemoryTest = true; - let validModelMemoryLimit = true; - - // extract the field names and partition field names from the detectors - // we only want to estimate the mml for multi-metric jobs. - // a multi-metric job will have one partition field, one or more field names - // and no over or by fields - job.analysis_config.detectors.forEach(d => { - if (typeof d.field_name !== 'undefined') { - fieldNames.push(d.field_name); - } - - // create a deduplicated list of partition field names. - if (typeof d.partition_field_name !== 'undefined') { - splitFieldNames[d.partition_field_name] = null; - } - - // if an over or by field is present, do not run the estimate test - if (typeof d.over_field_name !== 'undefined' || typeof d.by_field_name !== 'undefined') { - runCalcModelMemoryTest = false; - } - }); - - // if there are no or more than one partition fields, do not run the test - if (Object.keys(splitFieldNames).length === 1) { - splitFieldName = Object.keys(splitFieldNames)[0]; - } else { - runCalcModelMemoryTest = false; - } - - // if there is no duration, do not run the estimate test - if ( - typeof duration === 'undefined' || - typeof duration.start === 'undefined' || - typeof duration.end === 'undefined' - ) { - runCalcModelMemoryTest = false; - } - - const messages = []; - - // check that mml is a valid data format - if (mml !== null) { - const mmlSplit = mml.match(/\d+(\w+)/); - const unit = mmlSplit && mmlSplit.length === 2 ? mmlSplit[1] : null; - - if (ALLOWED_DATA_UNITS.indexOf(unit) === -1) { - messages.push({ - id: 'mml_value_invalid', - mml, - }); - // mml is not a valid data format. - // abort all other tests - validModelMemoryLimit = false; - } - } - - if (validModelMemoryLimit) { - if (runCalcModelMemoryTest) { - const mmlEstimate = await calculateModelMemoryLimitProvider(callWithRequest)( - job.datafeed_config.indices.join(','), - splitFieldName, - job.datafeed_config.query, - fieldNames, - job.analysis_config.influencers, - job.data_description.time_field, - duration.start, - duration.end, - true - ); - const mmlEstimateBytes = numeral(mmlEstimate.modelMemoryLimit).value(); - - let runEstimateGreaterThenMml = true; - // if max_model_memory_limit has been set, - // make sure the estimated value is not greater than it. - if (typeof maxModelMemoryLimit !== 'undefined') { - const maxMmlBytes = numeral(maxModelMemoryLimit.toUpperCase()).value(); - if (mmlEstimateBytes > maxMmlBytes) { - runEstimateGreaterThenMml = false; - messages.push({ - id: 'estimated_mml_greater_than_max_mml', - maxModelMemoryLimit, - mmlEstimate, - }); - } - } - - // check to see if the estimated mml is greater that the user - // specified mml - // do not run this if we've already found that it's larger than - // the max mml - if (runEstimateGreaterThenMml && mml !== null) { - const mmlBytes = numeral(mml).value(); - if (mmlBytes < MODEL_MEMORY_LIMIT_MINIMUM_BYTES) { - messages.push({ - id: 'mml_value_invalid', - mml, - }); - } else if (mmlEstimateBytes / 2 > mmlBytes) { - messages.push({ - id: 'half_estimated_mml_greater_than_mml', - maxModelMemoryLimit, - mml, - }); - } else if (mmlEstimateBytes > mmlBytes) { - messages.push({ - id: 'estimated_mml_greater_than_mml', - maxModelMemoryLimit, - mml, - }); - } - } - } - - // if max_model_memory_limit has been set, - // make sure the user defined MML is not greater than it - if (maxModelMemoryLimit !== undefined && mml !== null) { - const maxMmlBytes = numeral(maxModelMemoryLimit.toUpperCase()).value(); - const mmlBytes = numeral(mml).value(); - if (mmlBytes > maxMmlBytes) { - messages.push({ - id: 'mml_greater_than_max_mml', - maxModelMemoryLimit, - mml, - }); - } - } - } - - if (messages.length === 0 && runCalcModelMemoryTest === true) { - messages.push({ id: 'success_mml' }); - } - - return Promise.resolve(messages); -} diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_model_memory_limit.js b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts similarity index 60% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/validate_model_memory_limit.js rename to x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts index f2459fa339005..6b5d5614325bf 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_model_memory_limit.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts @@ -4,8 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { validateModelMemoryLimit } from '../validate_model_memory_limit'; +import { APICaller } from 'kibana/server'; +import { CombinedJob, Detector } from '../../../common/types/anomaly_detection_jobs'; +import { ModelMemoryEstimate } from '../calculate_model_memory_limit/calculate_model_memory_limit'; +import { validateModelMemoryLimit } from './validate_model_memory_limit'; describe('ML - validateModelMemoryLimit', () => { // mock info endpoint response @@ -61,29 +63,43 @@ describe('ML - validateModelMemoryLimit', () => { }, }; + // mock estimate model memory + const modelMemoryEstimateResponse: ModelMemoryEstimate = { + model_memory_estimate: '40mb', + }; + + interface MockAPICallResponse { + 'ml.estimateModelMemory'?: ModelMemoryEstimate; + } + // mock callWithRequest // used in three places: // - to retrieve the info endpoint // - to search for cardinality of split field // - to retrieve field capabilities used in search for split field cardinality - function callWithRequest(call) { - if (typeof call === undefined) { - return Promise.reject(); - } - - let response = {}; - if (call === 'ml.info') { - response = mlInfoResponse; - } else if (call === 'search') { - response = cardinalitySearchResponse; - } else if (call === 'fieldCaps') { - response = fieldCapsResponse; - } - return Promise.resolve(response); - } - - function getJobConfig(influencers = [], detectors = []) { - return { + const getMockCallWithRequest = ({ + 'ml.estimateModelMemory': estimateModelMemory, + }: MockAPICallResponse = {}) => + ((call: string) => { + if (typeof call === undefined) { + return Promise.reject(); + } + + let response = {}; + if (call === 'ml.info') { + response = mlInfoResponse; + } else if (call === 'search') { + response = cardinalitySearchResponse; + } else if (call === 'fieldCaps') { + response = fieldCapsResponse; + } else if (call === 'ml.estimateModelMemory') { + response = estimateModelMemory || modelMemoryEstimateResponse; + } + return Promise.resolve(response); + }) as APICaller; + + function getJobConfig(influencers: string[] = [], detectors: Detector[] = []) { + return ({ analysis_config: { detectors, influencers }, data_description: { time_field: '@timestamp' }, datafeed_config: { @@ -92,11 +108,11 @@ describe('ML - validateModelMemoryLimit', () => { analysis_limits: { model_memory_limit: '20mb', }, - }; + } as unknown) as CombinedJob; } // create a specified number of mock detectors - function createDetectors(numberOfDetectors) { + function createDetectors(numberOfDetectors: number): Detector[] { const dtrs = []; for (let i = 0; i < numberOfDetectors; i++) { dtrs.push({ @@ -105,28 +121,28 @@ describe('ML - validateModelMemoryLimit', () => { partition_field_name: 'instance', }); } - return dtrs; + return dtrs as Detector[]; } - // tests it('Called with no duration or split and mml within limit', () => { const job = getJobConfig(); const duration = undefined; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([]); + expect(ids).toEqual([]); }); }); it('Called with no duration or split and mml above limit', () => { const job = getJobConfig(); const duration = undefined; + // @ts-ignore job.analysis_limits.model_memory_limit = '31mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_greater_than_max_mml']); + expect(ids).toEqual(['mml_greater_than_max_mml']); }); }); @@ -134,11 +150,16 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(10); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '20mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit( + getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '66mb' } }), + job, + duration + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['estimated_mml_greater_than_max_mml']); + expect(ids).toEqual(['estimated_mml_greater_than_max_mml']); }); }); @@ -146,11 +167,16 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(2); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '30mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit( + getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '24mb' } }), + job, + duration + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_mml']); + expect(ids).toEqual(['success_mml']); }); }); @@ -158,11 +184,16 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(2); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '10mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit( + getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '22mb' } }), + job, + duration + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['half_estimated_mml_greater_than_mml']); + expect(ids).toEqual(['half_estimated_mml_greater_than_mml']); }); }); @@ -171,11 +202,12 @@ describe('ML - validateModelMemoryLimit', () => { const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; delete mlInfoResponse.limits.max_model_memory_limit; + // @ts-ignore job.analysis_limits.model_memory_limit = '10mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['half_estimated_mml_greater_than_mml']); + expect(ids).toEqual(['half_estimated_mml_greater_than_mml']); }); }); @@ -183,11 +215,16 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '20mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit( + getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '19mb' } }), + job, + duration + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_mml']); + expect(ids).toEqual(['success_mml']); }); }); @@ -195,11 +232,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '0mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -207,11 +245,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '10mbananas'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -219,11 +258,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '10'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -231,11 +271,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = 'mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -243,11 +284,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = 'asdf'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -255,11 +297,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '1023KB'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -267,11 +310,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '1024KB'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['half_estimated_mml_greater_than_mml']); + expect(ids).toEqual(['half_estimated_mml_greater_than_mml']); }); }); @@ -279,11 +323,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '6MB'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['half_estimated_mml_greater_than_mml']); + expect(ids).toEqual(['half_estimated_mml_greater_than_mml']); }); }); @@ -291,11 +336,16 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '20MB'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit( + getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '20mb' } }), + job, + duration + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_mml']); + expect(ids).toEqual(['success_mml']); }); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts new file mode 100644 index 0000000000000..0c431f6a07563 --- /dev/null +++ b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts @@ -0,0 +1,135 @@ +/* + * 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 numeral from '@elastic/numeral'; +import { APICaller } from 'kibana/server'; +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; +import { validateJobObject } from './validate_job_object'; +import { calculateModelMemoryLimitProvider } from '../calculate_model_memory_limit'; +import { ALLOWED_DATA_UNITS } from '../../../common/constants/validation'; + +// The minimum value the backend expects is 1MByte +const MODEL_MEMORY_LIMIT_MINIMUM_BYTES = 1048576; + +export async function validateModelMemoryLimit( + callWithRequest: APICaller, + job: CombinedJob, + duration?: { start?: number; end?: number } +) { + validateJobObject(job); + + // retrieve the model memory limit specified by the user in the job config. + // note, this will probably be the auto generated value, unless the user has + // over written it. + const mml = job?.analysis_limits?.model_memory_limit?.toUpperCase() ?? null; + + const messages = []; + + // check that mml is a valid data format + if (mml !== null) { + const mmlSplit = mml.match(/\d+(\w+)/); + const unit = mmlSplit && mmlSplit.length === 2 ? mmlSplit[1] : null; + + if (unit === null || !ALLOWED_DATA_UNITS.includes(unit)) { + messages.push({ + id: 'mml_value_invalid', + mml, + }); + // mml is not a valid data format. + // abort all other tests + return messages; + } + } + + // if there is no duration, do not run the estimate test + const runCalcModelMemoryTest = + duration && typeof duration?.start !== undefined && duration?.end !== undefined; + + // retrieve the max_model_memory_limit value from the server + // this will be unset unless the user has set this on their cluster + const maxModelMemoryLimit: string | undefined = ( + await callWithRequest('ml.info') + )?.limits?.max_model_memory_limit?.toUpperCase(); + + if (runCalcModelMemoryTest) { + const { modelMemoryLimit } = await calculateModelMemoryLimitProvider(callWithRequest)( + job.analysis_config, + job.datafeed_config.indices.join(','), + job.datafeed_config.query, + job.data_description.time_field, + duration!.start as number, + duration!.end as number, + true + ); + // @ts-ignore + const mmlEstimateBytes: number = numeral(modelMemoryLimit).value(); + + let runEstimateGreaterThenMml = true; + // if max_model_memory_limit has been set, + // make sure the estimated value is not greater than it. + if (typeof maxModelMemoryLimit !== 'undefined') { + // @ts-ignore + const maxMmlBytes: number = numeral(maxModelMemoryLimit).value(); + if (mmlEstimateBytes > maxMmlBytes) { + runEstimateGreaterThenMml = false; + messages.push({ + id: 'estimated_mml_greater_than_max_mml', + maxModelMemoryLimit, + modelMemoryLimit, + }); + } + } + + // check to see if the estimated mml is greater that the user + // specified mml + // do not run this if we've already found that it's larger than + // the max mml + if (runEstimateGreaterThenMml && mml !== null) { + // @ts-ignore + const mmlBytes: number = numeral(mml).value(); + if (mmlBytes < MODEL_MEMORY_LIMIT_MINIMUM_BYTES) { + messages.push({ + id: 'mml_value_invalid', + mml, + }); + } else if (mmlEstimateBytes / 2 > mmlBytes) { + messages.push({ + id: 'half_estimated_mml_greater_than_mml', + maxModelMemoryLimit, + mml, + }); + } else if (mmlEstimateBytes > mmlBytes) { + messages.push({ + id: 'estimated_mml_greater_than_mml', + maxModelMemoryLimit, + mml, + }); + } + } + } + + // if max_model_memory_limit has been set, + // make sure the user defined MML is not greater than it + if (maxModelMemoryLimit !== undefined && mml !== null) { + // @ts-ignore + const maxMmlBytes = numeral(maxModelMemoryLimit).value(); + // @ts-ignore + const mmlBytes = numeral(mml).value(); + if (mmlBytes > maxMmlBytes) { + messages.push({ + id: 'mml_greater_than_max_mml', + maxModelMemoryLimit, + mml, + }); + } + } + + if (messages.length === 0 && runCalcModelMemoryTest === true) { + messages.push({ id: 'success_mml' }); + } + + return messages; +} diff --git a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts index c6bb62aa34916..d03e76072c315 100644 --- a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts +++ b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts @@ -148,7 +148,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { params: schema.object({ jobId: schema.string(), }), - body: schema.object({ ...anomalyDetectionJobSchema }), + body: schema.object(anomalyDetectionJobSchema), }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { diff --git a/x-pack/plugins/ml/server/routes/job_validation.ts b/x-pack/plugins/ml/server/routes/job_validation.ts index fd5c8dc7e9a7a..75d9cdf375049 100644 --- a/x-pack/plugins/ml/server/routes/job_validation.ts +++ b/x-pack/plugins/ml/server/routes/job_validation.ts @@ -7,6 +7,7 @@ import Boom from 'boom'; import { RequestHandlerContext } from 'kibana/server'; import { schema, TypeOf } from '@kbn/config-schema'; +import { AnalysisConfig } from '../../common/types/anomaly_detection_jobs'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; import { @@ -29,23 +30,12 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, context: RequestHandlerContext, payload: CalculateModelMemoryLimitPayload ) { - const { - indexPattern, - splitFieldName, - query, - fieldNames, - influencerNames, - timeFieldName, - earliestMs, - latestMs, - } = payload; + const { analysisConfig, indexPattern, query, timeFieldName, earliestMs, latestMs } = payload; return calculateModelMemoryLimitProvider(context.ml!.mlClient.callAsCurrentUser)( + analysisConfig as AnalysisConfig, indexPattern, - splitFieldName, query, - fieldNames, - influencerNames, timeFieldName, earliestMs, latestMs @@ -102,7 +92,7 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, * * @api {post} /api/ml/validate/calculate_model_memory_limit Calculates model memory limit * @apiName CalculateModelMemoryLimit - * @apiDescription Calculates the model memory limit + * @apiDescription Calls _estimate_model_memory endpoint to retrieve model memory estimation. * * @apiSuccess {String} modelMemoryLimit */ diff --git a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts index a46ccd8664a62..6002bb218c41b 100644 --- a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts @@ -63,14 +63,16 @@ export const anomalyDetectionUpdateJobSchema = { groups: schema.maybe(schema.arrayOf(schema.maybe(schema.string()))), }; +export const analysisConfigSchema = schema.object({ + bucket_span: schema.maybe(schema.string()), + summary_count_field_name: schema.maybe(schema.string()), + detectors: schema.arrayOf(detectorSchema), + influencers: schema.arrayOf(schema.maybe(schema.string())), + categorization_field_name: schema.maybe(schema.string()), +}); + export const anomalyDetectionJobSchema = { - analysis_config: schema.object({ - bucket_span: schema.maybe(schema.string()), - summary_count_field_name: schema.maybe(schema.string()), - detectors: schema.arrayOf(detectorSchema), - influencers: schema.arrayOf(schema.maybe(schema.string())), - categorization_field_name: schema.maybe(schema.string()), - }), + analysis_config: analysisConfigSchema, analysis_limits: schema.maybe( schema.object({ categorization_examples_limit: schema.maybe(schema.number()), diff --git a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts index 5da825a905e8d..3ded6e770eed5 100644 --- a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts @@ -5,7 +5,7 @@ */ import { schema } from '@kbn/config-schema'; -import { anomalyDetectionJobSchema } from './anomaly_detectors_schema'; +import { analysisConfigSchema, anomalyDetectionJobSchema } from './anomaly_detectors_schema'; import { datafeedConfigSchema } from './datafeeds_schema'; export const estimateBucketSpanSchema = schema.object({ @@ -20,11 +20,9 @@ export const estimateBucketSpanSchema = schema.object({ }); export const modelMemoryLimitSchema = schema.object({ + analysisConfig: analysisConfigSchema, indexPattern: schema.string(), - splitFieldName: schema.string(), query: schema.any(), - fieldNames: schema.arrayOf(schema.string()), - influencerNames: schema.arrayOf(schema.maybe(schema.string())), timeFieldName: schema.string(), earliestMs: schema.number(), latestMs: schema.number(), diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 3855dd72dcdfd..03bfb089d8bd0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6409,7 +6409,6 @@ "xpack.infra.logs.analysisPage.unavailable.mlAppLink": "機械学習アプリ", "xpack.infra.logs.customizeLogs.customizeButtonLabel": "カスタマイズ", "xpack.infra.logs.customizeLogs.lineWrappingFormRowLabel": "改行", - "xpack.infra.logs.customizeLogs.minimapScaleFormRowLabel": "ミニマップスケール", "xpack.infra.logs.customizeLogs.textSizeFormRowLabel": "テキストサイズ", "xpack.infra.logs.customizeLogs.textSizeRadioGroup": "{textScale, select, small {小さい} 中くらい {Medium} 大きい {Large} その他の {{textScale}} }", "xpack.infra.logs.customizeLogs.wrapLongLinesSwitchLabel": "長い行を改行", @@ -6424,19 +6423,12 @@ "xpack.infra.logs.index.settingsTabTitle": "設定", "xpack.infra.logs.index.streamTabTitle": "ストリーム", "xpack.infra.logs.jumpToTailText": "最も新しいエントリーに移動", - "xpack.infra.logs.lastStreamingUpdateText": " 最終更新 {lastUpdateTime}", - "xpack.infra.logs.loadAgainButtonLabel": "再読み込み", - "xpack.infra.logs.loadingAdditionalEntriesText": "追加エントリーを読み込み中", - "xpack.infra.logs.noAdditionalEntriesFoundText": "追加エントリーが見つかりません", "xpack.infra.logs.scrollableLogTextStreamView.loadingEntriesLabel": "エントリーを読み込み中", "xpack.infra.logs.search.nextButtonLabel": "次へ", "xpack.infra.logs.search.previousButtonLabel": "前へ", "xpack.infra.logs.search.searchInLogsAriaLabel": "検索", "xpack.infra.logs.search.searchInLogsPlaceholder": "検索", "xpack.infra.logs.searchResultTooltip": "{bucketCount, plural, one {# 件のハイライトされたエントリー} other {# 件のハイライトされたエントリー}}", - "xpack.infra.logs.startStreamingButtonLabel": "ライブストリーム", - "xpack.infra.logs.stopStreamingButtonLabel": "ストリーム停止", - "xpack.infra.logs.streamingDescription": "新しいエントリーをストリーム中...", "xpack.infra.logs.streamingNewEntriesText": "新しいエントリーをストリーム中", "xpack.infra.logs.streamPage.documentTitle": "{previousTitle} | ストリーム", "xpack.infra.logsPage.noLoggingIndicesDescription": "追加しましょう!", @@ -6444,12 +6436,6 @@ "xpack.infra.logsPage.noLoggingIndicesTitle": "ログインデックスがないようです。", "xpack.infra.logsPage.toolbar.kqlSearchFieldAriaLabel": "ログエントリーを検索", "xpack.infra.logsPage.toolbar.kqlSearchFieldPlaceholder": "ログエントリーを検索中… (例: host.name:host-1)", - "xpack.infra.mapLogs.oneDayLabel": "1 日", - "xpack.infra.mapLogs.oneHourLabel": "1 時間", - "xpack.infra.mapLogs.oneMinuteLabel": "1 分", - "xpack.infra.mapLogs.oneMonthLabel": "1 か月", - "xpack.infra.mapLogs.oneWeekLabel": "1 週間", - "xpack.infra.mapLogs.oneYearLabel": "1 年", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "パーセント", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.sectionLabel": "CPU 使用状況", "xpack.infra.metricDetailPage.awsMetricsLayout.diskioBytesSection.readsSeriesLabel": "読み取り", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 030b27c2342cc..682ac4c0bba10 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6409,7 +6409,6 @@ "xpack.infra.logs.analysisPage.unavailable.mlAppLink": "Machine Learning 应用", "xpack.infra.logs.customizeLogs.customizeButtonLabel": "定制", "xpack.infra.logs.customizeLogs.lineWrappingFormRowLabel": "换行", - "xpack.infra.logs.customizeLogs.minimapScaleFormRowLabel": "迷你地图比例", "xpack.infra.logs.customizeLogs.textSizeFormRowLabel": "文本大小", "xpack.infra.logs.customizeLogs.textSizeRadioGroup": "{textScale, select, small {小} medium {Medium} large {Large} other {{textScale}} }", "xpack.infra.logs.customizeLogs.wrapLongLinesSwitchLabel": "长行换行", @@ -6424,19 +6423,12 @@ "xpack.infra.logs.index.settingsTabTitle": "设置", "xpack.infra.logs.index.streamTabTitle": "流式传输", "xpack.infra.logs.jumpToTailText": "跳到最近的条目", - "xpack.infra.logs.lastStreamingUpdateText": " 最后更新时间:{lastUpdateTime}", - "xpack.infra.logs.loadAgainButtonLabel": "重新加载", - "xpack.infra.logs.loadingAdditionalEntriesText": "正在加载其他条目", - "xpack.infra.logs.noAdditionalEntriesFoundText": "找不到其他条目", "xpack.infra.logs.scrollableLogTextStreamView.loadingEntriesLabel": "正在加载条目", "xpack.infra.logs.search.nextButtonLabel": "下一个", "xpack.infra.logs.search.previousButtonLabel": "上一页", "xpack.infra.logs.search.searchInLogsAriaLabel": "搜索", "xpack.infra.logs.search.searchInLogsPlaceholder": "搜索", "xpack.infra.logs.searchResultTooltip": "{bucketCount, plural, one {# 个高亮条目} other {# 个高亮条目}}", - "xpack.infra.logs.startStreamingButtonLabel": "实时流式传输", - "xpack.infra.logs.stopStreamingButtonLabel": "停止流式传输", - "xpack.infra.logs.streamingDescription": "正在流式传输新条目……", "xpack.infra.logs.streamingNewEntriesText": "正在流式传输新条目", "xpack.infra.logs.streamPage.documentTitle": "{previousTitle} | 流式传输", "xpack.infra.logsPage.noLoggingIndicesDescription": "让我们添加一些!", @@ -6444,12 +6436,6 @@ "xpack.infra.logsPage.noLoggingIndicesTitle": "似乎您没有任何日志索引。", "xpack.infra.logsPage.toolbar.kqlSearchFieldAriaLabel": "搜索日志条目", "xpack.infra.logsPage.toolbar.kqlSearchFieldPlaceholder": "搜索日志条目……(例如 host.name:host-1)", - "xpack.infra.mapLogs.oneDayLabel": "1 日", - "xpack.infra.mapLogs.oneHourLabel": "1 小时", - "xpack.infra.mapLogs.oneMinuteLabel": "1 分钟", - "xpack.infra.mapLogs.oneMonthLabel": "1 个月", - "xpack.infra.mapLogs.oneWeekLabel": "1 周", - "xpack.infra.mapLogs.oneYearLabel": "1 年", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "百分比", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.sectionLabel": "CPU 使用率", "xpack.infra.metricDetailPage.awsMetricsLayout.diskioBytesSection.readsSeriesLabel": "读取数", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index c409dead7c850..4bcfef78abd71 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -443,7 +443,7 @@ export const AlertsList: React.FunctionComponent = () => { addFlyoutVisible={alertFlyoutVisible} setAddFlyoutVisibility={setAlertFlyoutVisibility} /> - {editedAlertItem ? ( + {editFlyoutVisible && editedAlertItem ? ( { + const route: IRouter = {} as IRouter; + // these server/libs parameters don't have any functionality, which is fine + // because we aren't testing them here + const server: UptimeCoreSetup = { route }; + const libs: UMServerLibs = { requests: {} } as UMServerLibs; + libs.requests = { ...libs.requests, ...customRequests }; + return { server, libs }; +}; + +/** + * This function aims to provide an easy way to give mock props that will + * reduce boilerplate for tests. + * @param params the params received at alert creation time + * @param services the core services provided by kibana/alerting platforms + * @param state the state the alert maintains + */ +const mockOptions = ( + params = { numTimes: 5, locations: [], timerange: { from: 'now-15m', to: 'now' } }, + services = { callCluster: 'mockESFunction' }, + state = {} +): any => ({ + params, + services, + state, +}); + +describe('status check alert', () => { + describe('executor', () => { + it('does not trigger when there are no monitors down', async () => { + expect.assertions(4); + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([]); + const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs); + // @ts-ignore the executor can return `void`, but ours never does + const state: Record = await alert.executor(mockOptions()); + + expect(state).not.toBeUndefined(); + expect(state?.isTriggered).toBe(false); + expect(mockGetter).toHaveBeenCalledTimes(1); + expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "callES": "mockESFunction", + "locations": Array [], + "numTimes": 5, + "timerange": Object { + "from": "now-15m", + "to": "now", + }, + }, + ] + `); + }); + + it('triggers when monitors are down and provides expected state', async () => { + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([ + { + monitor_id: 'first', + location: 'harrisburg', + count: 234, + status: 'down', + }, + { + monitor_id: 'first', + location: 'fairbanks', + count: 234, + status: 'down', + }, + ]); + const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs); + const mockInstanceFactory = jest.fn(); + const mockReplaceState = jest.fn(); + const mockScheduleActions = jest.fn(); + mockInstanceFactory.mockReturnValue({ + replaceState: mockReplaceState, + scheduleActions: mockScheduleActions, + }); + const options = mockOptions(); + options.services = { + ...options.services, + alertInstanceFactory: mockInstanceFactory, + }; + // @ts-ignore the executor can return `void`, but ours never does + const state: Record = await alert.executor(options); + expect(mockGetter).toHaveBeenCalledTimes(1); + expect(mockInstanceFactory).toHaveBeenCalledTimes(1); + expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "callES": "mockESFunction", + "locations": Array [], + "numTimes": 5, + "timerange": Object { + "from": "now-15m", + "to": "now", + }, + }, + ] + `); + expect(mockReplaceState).toHaveBeenCalledTimes(1); + expect(mockReplaceState.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "monitors": Array [ + Object { + "count": 234, + "location": "fairbanks", + "monitor_id": "first", + "status": "down", + }, + Object { + "count": 234, + "location": "harrisburg", + "monitor_id": "first", + "status": "down", + }, + ], + }, + ] + `); + expect(mockScheduleActions).toHaveBeenCalledTimes(1); + expect(mockScheduleActions.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "xpack.uptime.alerts.actionGroups.monitorStatus", + Object { + "completeIdList": "first from fairbanks; first from harrisburg; ", + "message": "Down monitor: first", + "server": Object { + "route": Object {}, + }, + }, + ] + `); + }); + }); + + describe('fullListByIdAndLocation', () => { + it('renders a list of all monitors', () => { + const statuses: GetMonitorStatusResult[] = [ + { + location: 'harrisburg', + monitor_id: 'first', + status: 'down', + count: 34, + }, + { + location: 'fairbanks', + monitor_id: 'second', + status: 'down', + count: 23, + }, + { + location: 'fairbanks', + monitor_id: 'first', + status: 'down', + count: 23, + }, + { + location: 'harrisburg', + monitor_id: 'second', + status: 'down', + count: 34, + }, + ]; + expect(fullListByIdAndLocation(statuses)).toMatchInlineSnapshot( + `"first from fairbanks; first from harrisburg; second from fairbanks; second from harrisburg; "` + ); + }); + + it('renders a list of monitors when greater than limit', () => { + const statuses: GetMonitorStatusResult[] = [ + { + location: 'fairbanks', + monitor_id: 'second', + status: 'down', + count: 23, + }, + { + location: 'fairbanks', + monitor_id: 'first', + status: 'down', + count: 23, + }, + { + location: 'harrisburg', + monitor_id: 'first', + status: 'down', + count: 34, + }, + { + location: 'harrisburg', + monitor_id: 'second', + status: 'down', + count: 34, + }, + ]; + expect(fullListByIdAndLocation(statuses.slice(0, 2), 1)).toMatchInlineSnapshot( + `"first from fairbanks; ...and 1 other monitor/location"` + ); + }); + + it('renders expected list of monitors when limit difference > 1', () => { + const statuses: GetMonitorStatusResult[] = [ + { + location: 'fairbanks', + monitor_id: 'second', + status: 'down', + count: 23, + }, + { + location: 'harrisburg', + monitor_id: 'first', + status: 'down', + count: 34, + }, + { + location: 'harrisburg', + monitor_id: 'second', + status: 'down', + count: 34, + }, + { + location: 'harrisburg', + monitor_id: 'third', + status: 'down', + count: 34, + }, + { + location: 'fairbanks', + monitor_id: 'third', + status: 'down', + count: 23, + }, + { + location: 'fairbanks', + monitor_id: 'first', + status: 'down', + count: 23, + }, + ]; + expect(fullListByIdAndLocation(statuses, 4)).toMatchInlineSnapshot( + `"first from fairbanks; first from harrisburg; second from fairbanks; second from harrisburg; ...and 2 other monitors/locations"` + ); + }); + }); + + describe('alert factory', () => { + let alert: AlertType; + + beforeEach(() => { + const { server, libs } = bootstrapDependencies(); + alert = statusCheckAlertFactory(server, libs); + }); + + it('creates an alert with expected params', () => { + // @ts-ignore the `props` key here isn't described + expect(Object.keys(alert.validate?.params?.props ?? {})).toMatchInlineSnapshot(` + Array [ + "filters", + "numTimes", + "timerange", + "locations", + ] + `); + }); + + it('contains the expected static fields like id, name, etc.', () => { + expect(alert.id).toBe('xpack.uptime.alerts.monitorStatus'); + expect(alert.name).toBe('Uptime Monitor Status'); + expect(alert.defaultActionGroupId).toBe('xpack.uptime.alerts.actionGroups.monitorStatus'); + expect(alert.actionGroups).toMatchInlineSnapshot(` + Array [ + Object { + "id": "xpack.uptime.alerts.actionGroups.monitorStatus", + "name": "Uptime Down Monitor", + }, + ] + `); + }); + }); + + describe('updateState', () => { + let spy: jest.SpyInstance; + beforeEach(() => { + spy = jest.spyOn(Date.prototype, 'toISOString'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sets initial state values', () => { + spy.mockImplementation(() => 'foo date string'); + const result = updateState({}, false); + expect(spy).toHaveBeenCalledTimes(1); + expect(result).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "foo date string", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "foo date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + }); + + it('updates the correct field in subsequent calls', () => { + spy + .mockImplementationOnce(() => 'first date string') + .mockImplementationOnce(() => 'second date string'); + const firstState = updateState({}, false); + const secondState = updateState(firstState, true); + expect(spy).toHaveBeenCalledTimes(2); + expect(firstState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "first date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + expect(secondState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "second date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": true, + "lastCheckedAt": "second date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "second date string", + } + `); + }); + + it('correctly marks resolution times', () => { + spy + .mockImplementationOnce(() => 'first date string') + .mockImplementationOnce(() => 'second date string') + .mockImplementationOnce(() => 'third date string'); + const firstState = updateState({}, true); + const secondState = updateState(firstState, true); + const thirdState = updateState(secondState, false); + expect(spy).toHaveBeenCalledTimes(3); + expect(firstState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "first date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "first date string", + "isTriggered": true, + "lastCheckedAt": "first date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "first date string", + } + `); + expect(secondState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "first date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "first date string", + "isTriggered": true, + "lastCheckedAt": "second date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "second date string", + } + `); + expect(thirdState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": "first date string", + "isTriggered": false, + "lastCheckedAt": "third date string", + "lastResolvedAt": "third date string", + "lastTriggeredAt": "second date string", + } + `); + }); + + it('correctly marks state fields across multiple triggers/resolutions', () => { + spy + .mockImplementationOnce(() => 'first date string') + .mockImplementationOnce(() => 'second date string') + .mockImplementationOnce(() => 'third date string') + .mockImplementationOnce(() => 'fourth date string') + .mockImplementationOnce(() => 'fifth date string'); + const firstState = updateState({}, false); + const secondState = updateState(firstState, true); + const thirdState = updateState(secondState, false); + const fourthState = updateState(thirdState, true); + const fifthState = updateState(fourthState, false); + expect(spy).toHaveBeenCalledTimes(5); + expect(firstState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "first date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + expect(secondState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "second date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": true, + "lastCheckedAt": "second date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "second date string", + } + `); + expect(thirdState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": false, + "lastCheckedAt": "third date string", + "lastResolvedAt": "third date string", + "lastTriggeredAt": "second date string", + } + `); + expect(fourthState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "fourth date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": true, + "lastCheckedAt": "fourth date string", + "lastResolvedAt": "third date string", + "lastTriggeredAt": "fourth date string", + } + `); + expect(fifthState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": false, + "lastCheckedAt": "fifth date string", + "lastResolvedAt": "fifth date string", + "lastTriggeredAt": "fourth date string", + } + `); + }); + }); + + describe('uniqueMonitorIds', () => { + let items: GetMonitorStatusResult[]; + beforeEach(() => { + items = [ + { + monitor_id: 'first', + location: 'harrisburg', + count: 234, + status: 'down', + }, + { + monitor_id: 'first', + location: 'fairbanks', + count: 312, + status: 'down', + }, + { + monitor_id: 'second', + location: 'harrisburg', + count: 325, + status: 'down', + }, + { + monitor_id: 'second', + location: 'fairbanks', + count: 331, + status: 'down', + }, + { + monitor_id: 'third', + location: 'harrisburg', + count: 331, + status: 'down', + }, + { + monitor_id: 'third', + location: 'fairbanks', + count: 342, + status: 'down', + }, + { + monitor_id: 'fourth', + location: 'harrisburg', + count: 355, + status: 'down', + }, + { + monitor_id: 'fourth', + location: 'fairbanks', + count: 342, + status: 'down', + }, + { + monitor_id: 'fifth', + location: 'harrisburg', + count: 342, + status: 'down', + }, + { + monitor_id: 'fifth', + location: 'fairbanks', + count: 342, + status: 'down', + }, + ]; + }); + + it('creates a set of unique IDs from a list of composite-unique objects', () => { + expect(uniqueMonitorIds(items)).toEqual( + new Set(['first', 'second', 'third', 'fourth', 'fifth']) + ); + }); + }); + + describe('contextMessage', () => { + let ids: string[]; + beforeEach(() => { + ids = ['first', 'second', 'third', 'fourth', 'fifth']; + }); + + it('creates a message with appropriate number of monitors', () => { + expect(contextMessage(ids, 3)).toMatchInlineSnapshot( + `"Down monitors: first, second, third... and 2 other monitors"` + ); + }); + + it('throws an error if `max` is less than 2', () => { + expect(() => contextMessage(ids, 1)).toThrowErrorMatchingInlineSnapshot( + '"Maximum value must be greater than 2, received 1."' + ); + }); + + it('returns only the ids if length < max', () => { + expect(contextMessage(ids.slice(0, 2), 3)).toMatchInlineSnapshot( + `"Down monitors: first, second"` + ); + }); + + it('returns a default message when no monitors are provided', () => { + expect(contextMessage([], 3)).toMatchInlineSnapshot(`"No down monitor IDs received"`); + }); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/index.ts b/x-pack/plugins/uptime/server/lib/alerts/index.ts new file mode 100644 index 0000000000000..0e61fd70e0024 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UptimeAlertTypeFactory } from './types'; +import { statusCheckAlertFactory } from './status_check'; + +export const uptimeAlertTypeFactories: UptimeAlertTypeFactory[] = [statusCheckAlertFactory]; diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts new file mode 100644 index 0000000000000..3e90d2ce95a10 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { isRight } from 'fp-ts/lib/Either'; +import { ThrowReporter } from 'io-ts/lib/ThrowReporter'; +import { i18n } from '@kbn/i18n'; +import { AlertExecutorOptions } from '../../../../alerting/server'; +import { ACTION_GROUP_DEFINITIONS } from '../../../../../legacy/plugins/uptime/common/constants'; +import { UptimeAlertTypeFactory } from './types'; +import { GetMonitorStatusResult } from '../requests'; +import { + StatusCheckExecutorParamsType, + StatusCheckAlertStateType, + StatusCheckAlertState, +} from '../../../../../legacy/plugins/uptime/common/runtime_types'; + +const { MONITOR_STATUS } = ACTION_GROUP_DEFINITIONS; + +/** + * Reduce a composite-key array of status results to a set of unique IDs. + * @param items to reduce + */ +export const uniqueMonitorIds = (items: GetMonitorStatusResult[]): Set => + items.reduce((acc, { monitor_id }) => { + acc.add(monitor_id); + return acc; + }, new Set()); + +/** + * Generates a message to include in contexts of alerts. + * @param monitors the list of monitors to include in the message + * @param max + */ +export const contextMessage = (monitorIds: string[], max: number): string => { + const MIN = 2; + if (max < MIN) throw new Error(`Maximum value must be greater than ${MIN}, received ${max}.`); + + // generate the message + let message; + if (monitorIds.length === 1) { + message = i18n.translate('xpack.uptime.alerts.message.singularTitle', { + defaultMessage: 'Down monitor: ', + }); + } else if (monitorIds.length) { + message = i18n.translate('xpack.uptime.alerts.message.multipleTitle', { + defaultMessage: 'Down monitors: ', + }); + } + // this shouldn't happen because the function should only be called + // when > 0 monitors are down + else { + message = i18n.translate('xpack.uptime.alerts.message.emptyTitle', { + defaultMessage: 'No down monitor IDs received', + }); + } + + for (let i = 0; i < monitorIds.length; i++) { + const id = monitorIds[i]; + if (i === max) { + return ( + message + + i18n.translate('xpack.uptime.alerts.message.overflowBody', { + defaultMessage: `... and {overflowCount} other monitors`, + values: { + overflowCount: monitorIds.length - i, + }, + }) + ); + } else if (i === 0) { + message = message + id; + } else { + message = message + `, ${id}`; + } + } + + return message; +}; + +/** + * Creates an exhaustive list of all the down monitors. + * @param list all the monitors that are down + * @param sizeLimit the max monitors, we shouldn't allow an arbitrarily long string + */ +export const fullListByIdAndLocation = ( + list: GetMonitorStatusResult[], + sizeLimit: number = 1000 +) => { + return ( + list + // sort by id, then location + .sort((a, b) => { + if (a.monitor_id > b.monitor_id) { + return 1; + } else if (a.monitor_id < b.monitor_id) { + return -1; + } else if (a.location > b.location) { + return 1; + } + return -1; + }) + .slice(0, sizeLimit) + .reduce((cur, { monitor_id: id, location }) => cur + `${id} from ${location}; `, '') + + (sizeLimit < list.length + ? i18n.translate('xpack.uptime.alerts.message.fullListOverflow', { + defaultMessage: '...and {overflowCount} other {pluralizedMonitor}', + values: { + pluralizedMonitor: + list.length - sizeLimit === 1 ? 'monitor/location' : 'monitors/locations', + overflowCount: list.length - sizeLimit, + }, + }) + : '') + ); +}; + +export const updateState = ( + state: Record, + isTriggeredNow: boolean +): StatusCheckAlertState => { + const now = new Date().toISOString(); + const decoded = StatusCheckAlertStateType.decode(state); + if (!isRight(decoded)) { + const triggerVal = isTriggeredNow ? now : undefined; + return { + currentTriggerStarted: triggerVal, + firstCheckedAt: now, + firstTriggeredAt: triggerVal, + isTriggered: isTriggeredNow, + lastTriggeredAt: triggerVal, + lastCheckedAt: now, + lastResolvedAt: undefined, + }; + } + const { + currentTriggerStarted, + firstCheckedAt, + firstTriggeredAt, + lastTriggeredAt, + // this is the stale trigger status, we're naming it `wasTriggered` + // to differentiate it from the `isTriggeredNow` param + isTriggered: wasTriggered, + lastResolvedAt, + } = decoded.right; + + let cts: string | undefined; + if (isTriggeredNow && !currentTriggerStarted) { + cts = now; + } else if (isTriggeredNow) { + cts = currentTriggerStarted; + } + + return { + currentTriggerStarted: cts, + firstCheckedAt: firstCheckedAt ?? now, + firstTriggeredAt: isTriggeredNow && !firstTriggeredAt ? now : firstTriggeredAt, + lastCheckedAt: now, + lastTriggeredAt: isTriggeredNow ? now : lastTriggeredAt, + lastResolvedAt: !isTriggeredNow && wasTriggered ? now : lastResolvedAt, + isTriggered: isTriggeredNow, + }; +}; + +// Right now the maximum number of monitors shown in the message is hardcoded here. +// we might want to make this a parameter in the future +const DEFAULT_MAX_MESSAGE_ROWS = 3; + +export const statusCheckAlertFactory: UptimeAlertTypeFactory = (server, libs) => ({ + id: 'xpack.uptime.alerts.monitorStatus', + name: i18n.translate('xpack.uptime.alerts.monitorStatus', { + defaultMessage: 'Uptime Monitor Status', + }), + validate: { + params: schema.object({ + filters: schema.maybe(schema.string()), + numTimes: schema.number(), + timerange: schema.object({ + from: schema.string(), + to: schema.string(), + }), + locations: schema.arrayOf(schema.string()), + }), + }, + defaultActionGroupId: MONITOR_STATUS.id, + actionGroups: [ + { + id: MONITOR_STATUS.id, + name: MONITOR_STATUS.name, + }, + ], + async executor(options: AlertExecutorOptions) { + const { params: rawParams } = options; + const decoded = StatusCheckExecutorParamsType.decode(rawParams); + if (!isRight(decoded)) { + ThrowReporter.report(decoded); + return { + error: 'Alert param types do not conform to required shape.', + }; + } + + const params = decoded.right; + + /* This is called `monitorsByLocation` but it's really + * monitors by location by status. The query we run to generate this + * filters on the status field, so effectively there should be one and only one + * status represented in the result set. */ + const monitorsByLocation = await libs.requests.getMonitorStatus({ + callES: options.services.callCluster, + ...params, + }); + + // if no monitors are down for our query, we don't need to trigger an alert + if (monitorsByLocation.length) { + const uniqueIds = uniqueMonitorIds(monitorsByLocation); + const alertInstance = options.services.alertInstanceFactory(MONITOR_STATUS.id); + alertInstance.replaceState({ + ...options.state, + monitors: monitorsByLocation, + }); + alertInstance.scheduleActions(MONITOR_STATUS.id, { + message: contextMessage(Array.from(uniqueIds.keys()), DEFAULT_MAX_MESSAGE_ROWS), + server, + completeIdList: fullListByIdAndLocation(monitorsByLocation), + }); + } + + // this stateful data is at the cluster level, not an alert instance level, + // so any alert of this type will flush/overwrite the state when they return + return updateState(options.state, monitorsByLocation.length > 0); + }, +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/types.ts b/x-pack/plugins/uptime/server/lib/alerts/types.ts new file mode 100644 index 0000000000000..bc1e82224f7b0 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertType } from '../../../../alerting/server'; +import { UptimeCoreSetup } from '../adapters'; +import { UMServerLibs } from '../lib'; + +export type UptimeAlertTypeFactory = (server: UptimeCoreSetup, libs: UMServerLibs) => AlertType; diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts new file mode 100644 index 0000000000000..74b8c352c8553 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts @@ -0,0 +1,553 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { elasticsearchServiceMock } from '../../../../../../../src/core/server/mocks'; +import { getMonitorStatus } from '../get_monitor_status'; +import { ScopedClusterClient } from 'src/core/server/elasticsearch'; + +interface BucketItemCriteria { + monitor_id: string; + status: string; + location: string; + doc_count: number; +} + +interface BucketKey { + monitor_id: string; + status: string; + location: string; +} + +interface BucketItem { + key: BucketKey; + doc_count: number; +} + +interface MultiPageCriteria { + after_key?: BucketKey; + bucketCriteria: BucketItemCriteria[]; +} + +const genBucketItem = ({ + monitor_id, + status, + location, + doc_count, +}: BucketItemCriteria): BucketItem => ({ + key: { + monitor_id, + status, + location, + }, + doc_count, +}); + +type MockCallES = (method: any, params: any) => Promise; + +const setupMock = ( + criteria: MultiPageCriteria[] +): [MockCallES, jest.Mocked>] => { + const esMock = elasticsearchServiceMock.createScopedClusterClient(); + + criteria.forEach(({ after_key, bucketCriteria }) => { + const mockResponse = { + aggregations: { + monitors: { + after_key, + buckets: bucketCriteria.map(item => genBucketItem(item)), + }, + }, + }; + esMock.callAsCurrentUser.mockResolvedValueOnce(mockResponse); + }); + return [(method: any, params: any) => esMock.callAsCurrentUser(method, params), esMock]; +}; + +describe('getMonitorStatus', () => { + it('applies bool filters to params', async () => { + const [callES, esMock] = setupMock([]); + const exampleFilter = `{ + "bool": { + "should": [ + { + "bool": { + "should": [ + { + "match_phrase": { + "monitor.id": "apm-dev" + } + } + ], + "minimum_should_match": 1 + } + }, + { + "bool": { + "should": [ + { + "match_phrase": { + "monitor.id": "auto-http-0X8D6082B94BBE3B8A" + } + } + ], + "minimum_should_match": 1 + } + } + ], + "minimum_should_match": 1 + } + }`; + await getMonitorStatus({ + callES, + filters: exampleFilter, + locations: [], + numTimes: 5, + timerange: { + from: 'now-10m', + to: 'now-1m', + }, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitor_id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "status": Object { + "terms": Object { + "field": "monitor.status", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.status": "down", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-10m", + "lte": "now-1m", + }, + }, + }, + ], + "minimum_should_match": 1, + "should": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "monitor.id": "apm-dev", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "monitor.id": "auto-http-0X8D6082B94BBE3B8A", + }, + }, + ], + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + }); + + it('applies locations to params', async () => { + const [callES, esMock] = setupMock([]); + await getMonitorStatus({ + callES, + locations: ['fairbanks', 'harrisburg'], + numTimes: 1, + timerange: { + from: 'now-2m', + to: 'now', + }, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitor_id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "status": Object { + "terms": Object { + "field": "monitor.status", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.status": "down", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-2m", + "lte": "now", + }, + }, + }, + Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "observer.geo.name": "fairbanks", + }, + }, + Object { + "term": Object { + "observer.geo.name": "harrisburg", + }, + }, + ], + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + }); + + it('fetches single page of results', async () => { + const [callES, esMock] = setupMock([ + { + bucketCriteria: [ + { + monitor_id: 'foo', + status: 'down', + location: 'fairbanks', + doc_count: 43, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'harrisburg', + doc_count: 53, + }, + { + monitor_id: 'foo', + status: 'down', + location: 'harrisburg', + doc_count: 44, + }, + ], + }, + ]); + const clientParameters = { + filters: undefined, + locations: [], + numTimes: 5, + timerange: { + from: 'now-12m', + to: 'now-2m', + }, + }; + const result = await getMonitorStatus({ + callES, + ...clientParameters, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitor_id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "status": Object { + "terms": Object { + "field": "monitor.status", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.status": "down", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-12m", + "lte": "now-2m", + }, + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "count": 43, + "location": "fairbanks", + "monitor_id": "foo", + "status": "down", + }, + Object { + "count": 53, + "location": "harrisburg", + "monitor_id": "bar", + "status": "down", + }, + Object { + "count": 44, + "location": "harrisburg", + "monitor_id": "foo", + "status": "down", + }, + ] + `); + }); + + it('fetches multiple pages of results in the thing', async () => { + const criteria = [ + { + after_key: { + monitor_id: 'foo', + location: 'harrisburg', + status: 'down', + }, + bucketCriteria: [ + { + monitor_id: 'foo', + status: 'down', + location: 'fairbanks', + doc_count: 43, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'harrisburg', + doc_count: 53, + }, + { + monitor_id: 'foo', + status: 'down', + location: 'harrisburg', + doc_count: 44, + }, + ], + }, + { + after_key: { + monitor_id: 'bar', + status: 'down', + location: 'fairbanks', + }, + bucketCriteria: [ + { + monitor_id: 'sna', + status: 'down', + location: 'fairbanks', + doc_count: 21, + }, + { + monitor_id: 'fu', + status: 'down', + location: 'fairbanks', + doc_count: 21, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'fairbanks', + doc_count: 45, + }, + ], + }, + { + bucketCriteria: [ + { + monitor_id: 'sna', + status: 'down', + location: 'harrisburg', + doc_count: 21, + }, + { + monitor_id: 'fu', + status: 'down', + location: 'harrisburg', + doc_count: 21, + }, + ], + }, + ]; + const [callES] = setupMock(criteria); + const result = await getMonitorStatus({ + callES, + locations: [], + numTimes: 5, + timerange: { + from: 'now-10m', + to: 'now-1m', + }, + }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "count": 43, + "location": "fairbanks", + "monitor_id": "foo", + "status": "down", + }, + Object { + "count": 53, + "location": "harrisburg", + "monitor_id": "bar", + "status": "down", + }, + Object { + "count": 44, + "location": "harrisburg", + "monitor_id": "foo", + "status": "down", + }, + Object { + "count": 21, + "location": "fairbanks", + "monitor_id": "sna", + "status": "down", + }, + Object { + "count": 21, + "location": "fairbanks", + "monitor_id": "fu", + "status": "down", + }, + Object { + "count": 45, + "location": "fairbanks", + "monitor_id": "bar", + "status": "down", + }, + Object { + "count": 21, + "location": "harrisburg", + "monitor_id": "sna", + "status": "down", + }, + Object { + "count": 21, + "location": "harrisburg", + "monitor_id": "fu", + "status": "down", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts new file mode 100644 index 0000000000000..2cebd532fd29b --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UMElasticsearchQueryFn } from '../adapters'; +import { INDEX_NAMES } from '../../../../../legacy/plugins/uptime/common/constants'; + +export interface GetMonitorStatusParams { + filters?: string; + locations: string[]; + numTimes: number; + timerange: { from: string; to: string }; +} + +export interface GetMonitorStatusResult { + monitor_id: string; + status: string; + location: string; + count: number; +} + +interface MonitorStatusKey { + monitor_id: string; + status: string; + location: string; +} + +const formatBuckets = async ( + buckets: any[], + numTimes: number +): Promise => { + return buckets + .filter((monitor: any) => monitor?.doc_count > numTimes) + .map(({ key, doc_count }: any) => ({ ...key, count: doc_count })); +}; + +const getLocationClause = (locations: string[]) => ({ + bool: { + should: [ + ...locations.map(location => ({ + term: { + 'observer.geo.name': location, + }, + })), + ], + }, +}); + +export const getMonitorStatus: UMElasticsearchQueryFn< + GetMonitorStatusParams, + GetMonitorStatusResult[] +> = async ({ callES, filters, locations, numTimes, timerange: { from, to } }) => { + const queryResults: Array> = []; + let afterKey: MonitorStatusKey | undefined; + + do { + // today this value is hardcoded. In the future we may support + // multiple status types for this alert, and this will become a parameter + const STATUS = 'down'; + const esParams: any = { + index: INDEX_NAMES.HEARTBEAT, + body: { + query: { + bool: { + filter: [ + { + term: { + 'monitor.status': STATUS, + }, + }, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ], + }, + }, + size: 0, + aggs: { + monitors: { + composite: { + size: 2000, + sources: [ + { + monitor_id: { + terms: { + field: 'monitor.id', + }, + }, + }, + { + status: { + terms: { + field: 'monitor.status', + }, + }, + }, + { + location: { + terms: { + field: 'observer.geo.name', + missing_bucket: true, + }, + }, + }, + ], + }, + }, + }, + }, + }; + + /** + * `filters` are an unparsed JSON string. We parse them and append the bool fields of the query + * to the bool of the parsed filters. + */ + if (filters) { + const parsedFilters = JSON.parse(filters); + esParams.body.query.bool = Object.assign({}, esParams.body.query.bool, parsedFilters.bool); + } + + /** + * Perform a logical `and` against the selected location filters. + */ + if (locations.length) { + esParams.body.query.bool.filter.push(getLocationClause(locations)); + } + + /** + * We "paginate" results by utilizing the `afterKey` field + * to tell Elasticsearch where it should start on subsequent queries. + */ + if (afterKey) { + esParams.body.aggs.monitors.composite.after = afterKey; + } + + const result = await callES('search', esParams); + afterKey = result?.aggregations?.monitors?.after_key; + + queryResults.push(formatBuckets(result?.aggregations?.monitors?.buckets || [], numTimes)); + } while (afterKey !== undefined); + + return (await Promise.all(queryResults)).reduce((acc, cur) => acc.concat(cur), []); +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/index.ts b/x-pack/plugins/uptime/server/lib/requests/index.ts index b1d7ff2c2ce02..7225d329d3c7f 100644 --- a/x-pack/plugins/uptime/server/lib/requests/index.ts +++ b/x-pack/plugins/uptime/server/lib/requests/index.ts @@ -12,6 +12,8 @@ export { getMonitorDurationChart, GetMonitorChartsParams } from './get_monitor_d export { getMonitorDetails, GetMonitorDetailsParams } from './get_monitor_details'; export { getMonitorLocations, GetMonitorLocationsParams } from './get_monitor_locations'; export { getMonitorStates, GetMonitorStatesParams } from './get_monitor_states'; +export { getMonitorStatus, GetMonitorStatusParams } from './get_monitor_status'; +export * from './get_monitor_status'; export { getPings, GetPingsParams } from './get_pings'; export { getPingHistogram, GetPingHistogramParams } from './get_ping_histogram'; export { UptimeRequests } from './uptime_requests'; diff --git a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts b/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts index 7f192994bd075..ddf506786f145 100644 --- a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts +++ b/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts @@ -16,6 +16,8 @@ import { GetMonitorStatesParams, GetPingsParams, GetPingHistogramParams, + GetMonitorStatusParams, + GetMonitorStatusResult, } from '.'; import { OverviewFilters, @@ -42,6 +44,7 @@ export interface UptimeRequests { getMonitorDetails: ESQ; getMonitorLocations: ESQ; getMonitorStates: ESQ; + getMonitorStatus: ESQ; getPings: ESQ; getPingHistogram: ESQ; getSnapshotCount: ESQ; diff --git a/x-pack/plugins/uptime/server/uptime_server.ts b/x-pack/plugins/uptime/server/uptime_server.ts index 4dfa1373db8d9..d4b38b8ad27a0 100644 --- a/x-pack/plugins/uptime/server/uptime_server.ts +++ b/x-pack/plugins/uptime/server/uptime_server.ts @@ -8,12 +8,22 @@ import { makeExecutableSchema } from 'graphql-tools'; import { DEFAULT_GRAPHQL_PATH, resolvers, typeDefs } from './graphql'; import { UMServerLibs } from './lib/lib'; import { createRouteWithAuth, restApiRoutes, uptimeRouteWrapper } from './rest_api'; +import { UptimeCoreSetup, UptimeCorePlugins } from './lib/adapters'; +import { uptimeAlertTypeFactories } from './lib/alerts'; -export const initUptimeServer = (libs: UMServerLibs) => { +export const initUptimeServer = ( + server: UptimeCoreSetup, + libs: UMServerLibs, + plugins: UptimeCorePlugins +) => { restApiRoutes.forEach(route => libs.framework.registerRoute(uptimeRouteWrapper(createRouteWithAuth(libs, route))) ); + uptimeAlertTypeFactories.forEach(alertTypeFactory => + plugins.alerting.registerType(alertTypeFactory(server, libs)) + ); + const graphQLSchema = makeExecutableSchema({ resolvers: resolvers.map(createResolversFn => createResolversFn(libs)), typeDefs, diff --git a/x-pack/test/api_integration/apis/infra/index.js b/x-pack/test/api_integration/apis/infra/index.js index fad387130e044..f5bdf280c46d2 100644 --- a/x-pack/test/api_integration/apis/infra/index.js +++ b/x-pack/test/api_integration/apis/infra/index.js @@ -10,8 +10,8 @@ export default function({ loadTestFile }) { loadTestFile(require.resolve('./log_analysis')); loadTestFile(require.resolve('./log_entries')); loadTestFile(require.resolve('./log_entry_highlights')); - loadTestFile(require.resolve('./log_summary')); loadTestFile(require.resolve('./logs_without_millis')); + loadTestFile(require.resolve('./log_summary')); loadTestFile(require.resolve('./metrics')); loadTestFile(require.resolve('./sources')); loadTestFile(require.resolve('./waffle')); diff --git a/x-pack/test/api_integration/apis/infra/log_entries.ts b/x-pack/test/api_integration/apis/infra/log_entries.ts index 75e7750058a87..4f447d518a751 100644 --- a/x-pack/test/api_integration/apis/infra/log_entries.ts +++ b/x-pack/test/api_integration/apis/infra/log_entries.ts @@ -5,8 +5,6 @@ */ import expect from '@kbn/expect'; -import { ascending, pairs } from 'd3-array'; -import gql from 'graphql-tag'; import { v4 as uuidv4 } from 'uuid'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -19,10 +17,11 @@ import { LOG_ENTRIES_PATH, logEntriesRequestRT, logEntriesResponseRT, + LogTimestampColumn, + LogFieldColumn, + LogMessageColumn, } from '../../../../plugins/infra/common/http_api'; -import { sharedFragments } from '../../../../plugins/infra/common/graphql/shared'; -import { InfraTimeKey } from '../../../../plugins/infra/public/graphql/types'; import { FtrProviderContext } from '../../ftr_provider_context'; const KEY_WITHIN_DATA_RANGE = { @@ -38,75 +37,12 @@ const LATEST_KEY_WITH_DATA = { tiebreaker: 5603910, }; -const logEntriesAroundQuery = gql` - query LogEntriesAroundQuery( - $timeKey: InfraTimeKeyInput! - $countBefore: Int = 0 - $countAfter: Int = 0 - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesAround( - key: $timeKey - countBefore: $countBefore - countAfter: $countAfter - filterQuery: $filterQuery - ) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - -const logEntriesBetweenQuery = gql` - query LogEntriesBetweenQuery( - $startKey: InfraTimeKeyInput! - $endKey: InfraTimeKeyInput! - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesBetween(startKey: $startKey, endKey: $endKey, filterQuery: $filterQuery) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - const COMMON_HEADERS = { 'kbn-xsrf': 'some-xsrf-token', }; export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const client = getService('infraOpsGraphQLClient'); const supertest = getService('supertest'); const sourceConfigurationService = getService('infraOpsSourceConfiguration'); @@ -126,8 +62,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, }) ) .expect(200); @@ -154,6 +90,42 @@ export default function({ getService }: FtrProviderContext) { expect(lastEntry.cursor.time <= KEY_WITHIN_DATA_RANGE.time).to.be(true); }); + it('Returns the default columns', async () => { + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, + center: KEY_WITHIN_DATA_RANGE, + }) + ) + .expect(200); + + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + + const entries = logEntriesResponse.data.entries; + const entry = entries[0]; + expect(entry.columns).to.have.length(3); + + const timestampColumn = entry.columns[0] as LogTimestampColumn; + expect(timestampColumn).to.have.property('timestamp'); + + const eventDatasetColumn = entry.columns[1] as LogFieldColumn; + expect(eventDatasetColumn).to.have.property('field'); + expect(eventDatasetColumn.field).to.be('event.dataset'); + expect(eventDatasetColumn).to.have.property('value'); + + const messageColumn = entry.columns[2] as LogMessageColumn; + expect(messageColumn).to.have.property('message'); + expect(messageColumn.message.length).to.be.greaterThan(0); + }); + it('Paginates correctly with `after`', async () => { const { body: firstPageBody } = await supertest .post(LOG_ENTRIES_PATH) @@ -161,8 +133,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, size: 10, }) ); @@ -177,9 +149,9 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, - after: firstPage.data.bottomCursor, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, + after: firstPage.data.bottomCursor!, size: 10, }) ); @@ -194,8 +166,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, size: 20, }) ); @@ -220,8 +192,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: KEY_WITHIN_DATA_RANGE.time, - endDate: LATEST_KEY_WITH_DATA.time, + startTimestamp: KEY_WITHIN_DATA_RANGE.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, before: 'last', size: 10, }) @@ -237,9 +209,9 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: KEY_WITHIN_DATA_RANGE.time, - endDate: LATEST_KEY_WITH_DATA.time, - before: lastPage.data.topCursor, + startTimestamp: KEY_WITHIN_DATA_RANGE.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, + before: lastPage.data.topCursor!, size: 10, }) ); @@ -254,8 +226,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: KEY_WITHIN_DATA_RANGE.time, - endDate: LATEST_KEY_WITH_DATA.time, + startTimestamp: KEY_WITHIN_DATA_RANGE.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, before: 'last', size: 20, }) @@ -281,8 +253,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: LATEST_KEY_WITH_DATA.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, center: KEY_WITHIN_DATA_RANGE, }) ) @@ -300,101 +272,31 @@ export default function({ getService }: FtrProviderContext) { expect(firstEntry.cursor.time >= EARLIEST_KEY_WITH_DATA.time).to.be(true); expect(lastEntry.cursor.time <= LATEST_KEY_WITH_DATA.time).to.be(true); }); - }); - }); - - describe('logEntriesAround', () => { - describe('with the default source', () => { - before(() => esArchiver.load('empty_kibana')); - after(() => esArchiver.unload('empty_kibana')); - - it('should return newer and older log entries when present', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countBefore: 100, - countAfter: 100, - }, - }); - expect(logEntriesAround).to.have.property('entries'); - expect(logEntriesAround.entries).to.have.length(200); - expect(isSorted(ascendingTimeKey)(logEntriesAround.entries)).to.equal(true); + it('Handles empty responses', async () => { + const startTimestamp = Date.now() + 1000; + const endTimestamp = Date.now() + 5000; - expect(logEntriesAround.hasMoreBefore).to.equal(true); - expect(logEntriesAround.hasMoreAfter).to.equal(true); - }); - - it('should indicate if no older entries are present', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: EARLIEST_KEY_WITH_DATA, - countBefore: 100, - countAfter: 100, - }, - }); - - expect(logEntriesAround.hasMoreBefore).to.equal(false); - expect(logEntriesAround.hasMoreAfter).to.equal(true); - }); - - it('should indicate if no newer entries are present', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: LATEST_KEY_WITH_DATA, - countBefore: 100, - countAfter: 100, - }, - }); - - expect(logEntriesAround.hasMoreBefore).to.equal(true); - expect(logEntriesAround.hasMoreAfter).to.equal(false); - }); + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + }) + ) + .expect(200); - it('should return the default columns', async () => { - const { - data: { - source: { - logEntriesAround: { - entries: [entry], - }, - }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countAfter: 1, - }, - }); + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); - expect(entry.columns).to.have.length(3); - expect(entry.columns[0]).to.have.property('timestamp'); - expect(entry.columns[0].timestamp).to.be.a('number'); - expect(entry.columns[1]).to.have.property('field'); - expect(entry.columns[1].field).to.be('event.dataset'); - expect(entry.columns[1]).to.have.property('value'); - expect(JSON.parse) - .withArgs(entry.columns[1].value) - .to.not.throwException(); - expect(entry.columns[2]).to.have.property('message'); - expect(entry.columns[2].message).to.be.an('array'); - expect(entry.columns[2].message.length).to.be.greaterThan(0); + expect(logEntriesResponse.data.entries).to.have.length(0); + expect(logEntriesResponse.data.topCursor).to.be(null); + expect(logEntriesResponse.data.bottomCursor).to.be(null); }); }); @@ -431,120 +333,48 @@ export default function({ getService }: FtrProviderContext) { }); after(() => esArchiver.unload('empty_kibana')); - it('should return the configured columns', async () => { - const { - data: { - source: { - logEntriesAround: { - entries: [entry], - }, - }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countAfter: 1, - }, - }); + it('returns the configured columns', async () => { + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, + center: KEY_WITHIN_DATA_RANGE, + }) + ) + .expect(200); - expect(entry.columns).to.have.length(4); - expect(entry.columns[0]).to.have.property('timestamp'); - expect(entry.columns[0].timestamp).to.be.a('number'); - expect(entry.columns[1]).to.have.property('field'); - expect(entry.columns[1].field).to.be('host.name'); - expect(entry.columns[1]).to.have.property('value'); - expect(JSON.parse) - .withArgs(entry.columns[1].value) - .to.not.throwException(); - expect(entry.columns[2]).to.have.property('field'); - expect(entry.columns[2].field).to.be('event.dataset'); - expect(entry.columns[2]).to.have.property('value'); - expect(JSON.parse) - .withArgs(entry.columns[2].value) - .to.not.throwException(); - expect(entry.columns[3]).to.have.property('message'); - expect(entry.columns[3].message).to.be.an('array'); - expect(entry.columns[3].message.length).to.be.greaterThan(0); - }); - }); - }); + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); - describe('logEntriesBetween', () => { - describe('with the default source', () => { - before(() => esArchiver.load('empty_kibana')); - after(() => esArchiver.unload('empty_kibana')); + const entries = logEntriesResponse.data.entries; + const entry = entries[0]; - it('should return log entries between the start and end keys', async () => { - const { - data: { - source: { logEntriesBetween }, - }, - } = await client.query({ - query: logEntriesBetweenQuery, - variables: { - startKey: EARLIEST_KEY_WITH_DATA, - endKey: KEY_WITHIN_DATA_RANGE, - }, - }); + expect(entry.columns).to.have.length(4); - expect(logEntriesBetween).to.have.property('entries'); - expect(logEntriesBetween.entries).to.not.be.empty(); - expect(isSorted(ascendingTimeKey)(logEntriesBetween.entries)).to.equal(true); - - expect( - ascendingTimeKey(logEntriesBetween.entries[0], { key: EARLIEST_KEY_WITH_DATA }) - ).to.be.above(-1); - expect( - ascendingTimeKey(logEntriesBetween.entries[logEntriesBetween.entries.length - 1], { - key: KEY_WITHIN_DATA_RANGE, - }) - ).to.be.below(1); - }); + const timestampColumn = entry.columns[0] as LogTimestampColumn; + expect(timestampColumn).to.have.property('timestamp'); - it('should return results consistent with logEntriesAround', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countBefore: 100, - countAfter: 100, - }, - }); + const hostNameColumn = entry.columns[1] as LogFieldColumn; + expect(hostNameColumn).to.have.property('field'); + expect(hostNameColumn.field).to.be('host.name'); + expect(hostNameColumn).to.have.property('value'); - const { - data: { - source: { logEntriesBetween }, - }, - } = await client.query({ - query: logEntriesBetweenQuery, - variables: { - startKey: { - time: logEntriesAround.start.time, - tiebreaker: logEntriesAround.start.tiebreaker - 1, - }, - endKey: { - time: logEntriesAround.end.time, - tiebreaker: logEntriesAround.end.tiebreaker + 1, - }, - }, - }); + const eventDatasetColumn = entry.columns[2] as LogFieldColumn; + expect(eventDatasetColumn).to.have.property('field'); + expect(eventDatasetColumn.field).to.be('event.dataset'); + expect(eventDatasetColumn).to.have.property('value'); - expect(logEntriesBetween).to.eql(logEntriesAround); + const messageColumn = entry.columns[3] as LogMessageColumn; + expect(messageColumn).to.have.property('message'); + expect(messageColumn.message.length).to.be.greaterThan(0); }); }); }); }); } - -const isSorted = (comparator: (first: Value, second: Value) => number) => ( - values: Value[] -) => pairs(values, comparator).every(order => order <= 0); - -const ascendingTimeKey = (first: { key: InfraTimeKey }, second: { key: InfraTimeKey }) => - ascending(first.key.time, second.key.time) || - ascending(first.key.tiebreaker, second.key.tiebreaker); diff --git a/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts b/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts index a34cd89eb3262..94f9d31ae8923 100644 --- a/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts +++ b/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts @@ -5,8 +5,6 @@ */ import expect from '@kbn/expect'; -import { ascending, pairs } from 'd3-array'; -import gql from 'graphql-tag'; import { pipe } from 'fp-ts/lib/pipeable'; import { identity } from 'fp-ts/lib/function'; @@ -21,21 +19,11 @@ import { } from '../../../../plugins/infra/common/http_api'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { sharedFragments } from '../../../../plugins/infra/common/graphql/shared'; -import { InfraTimeKey } from '../../../../plugins/infra/public/graphql/types'; const KEY_BEFORE_START = { time: new Date('2000-01-01T00:00:00.000Z').valueOf(), tiebreaker: -1, }; -const KEY_AFTER_START = { - time: new Date('2000-01-01T00:00:04.000Z').valueOf(), - tiebreaker: -1, -}; -const KEY_BEFORE_END = { - time: new Date('2000-01-01T00:00:06.001Z').valueOf(), - tiebreaker: 0, -}; const KEY_AFTER_END = { time: new Date('2000-01-01T00:00:09.001Z').valueOf(), tiebreaker: 0, @@ -48,7 +36,6 @@ const COMMON_HEADERS = { export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - const client = getService('infraOpsGraphQLClient'); describe('log highlight apis', () => { before(() => esArchiver.load('infra/simple_logs')); @@ -66,8 +53,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesHighlightsRequestRT.encode({ sourceId: 'default', - startDate: KEY_BEFORE_START.time, - endDate: KEY_AFTER_END.time, + startTimestamp: KEY_BEFORE_START.time, + endTimestamp: KEY_AFTER_END.time, highlightTerms: ['message of document 0'], }) ) @@ -116,8 +103,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesHighlightsRequestRT.encode({ sourceId: 'default', - startDate: KEY_BEFORE_START.time, - endDate: KEY_AFTER_END.time, + startTimestamp: KEY_BEFORE_START.time, + endTimestamp: KEY_AFTER_END.time, highlightTerms: ['generate_test_data/simple_logs'], }) ) @@ -152,8 +139,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesHighlightsRequestRT.encode({ sourceId: 'default', - startDate: KEY_BEFORE_START.time, - endDate: KEY_AFTER_END.time, + startTimestamp: KEY_BEFORE_START.time, + endTimestamp: KEY_AFTER_END.time, query: JSON.stringify({ multi_match: { query: 'host-a', type: 'phrase', lenient: true }, }), @@ -185,236 +172,5 @@ export default function({ getService }: FtrProviderContext) { }); }); }); - - describe('logEntryHighlights', () => { - describe('with the default source', () => { - before(() => esArchiver.load('empty_kibana')); - after(() => esArchiver.unload('empty_kibana')); - - it('should return log highlights in the built-in message column', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_BEFORE_START, - endKey: KEY_AFTER_END, - highlights: [ - { - query: 'message of document 0', - countBefore: 0, - countAfter: 0, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // ten bundles with one highlight each - expect(logEntryHighlightSet.entries).to.have.length(10); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([ - 'message', - 'of', - 'document', - '0', - ]); - } - }); - - // https://github.com/elastic/kibana/issues/49959 - it.skip('should return log highlights in a field column', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_BEFORE_START, - endKey: KEY_AFTER_END, - highlights: [ - { - query: 'generate_test_data/simple_logs', - countBefore: 0, - countAfter: 0, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // ten bundles with five highlights each - expect(logEntryHighlightSet.entries).to.have.length(50); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([ - 'generate_test_data/simple_logs', - ]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([]); - } - }); - - it('should apply the filter query in addition to the highlight query', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_BEFORE_START, - endKey: KEY_AFTER_END, - filterQuery: JSON.stringify({ - multi_match: { query: 'host-a', type: 'phrase', lenient: true }, - }), - highlights: [ - { - query: 'message', - countBefore: 0, - countAfter: 0, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // half of the documenst - expect(logEntryHighlightSet.entries).to.have.length(25); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([ - 'message', - 'message', - ]); - } - }); - - it('should return highlights outside of the interval when requested', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_AFTER_START, - endKey: KEY_BEFORE_END, - highlights: [ - { - query: 'message of document 0', - countBefore: 2, - countAfter: 2, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // three bundles with one highlight each plus two beyond each interval boundary - expect(logEntryHighlightSet.entries).to.have.length(3 + 4); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([ - 'message', - 'of', - 'document', - '0', - ]); - } - }); - }); - }); }); } - -const logEntryHighlightsQuery = gql` - query LogEntryHighlightsQuery( - $sourceId: ID = "default" - $startKey: InfraTimeKeyInput! - $endKey: InfraTimeKeyInput! - $filterQuery: String - $highlights: [InfraLogEntryHighlightInput!]! - ) { - source(id: $sourceId) { - id - logEntryHighlights( - startKey: $startKey - endKey: $endKey - filterQuery: $filterQuery - highlights: $highlights - ) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - entries { - ...InfraLogEntryHighlightFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryHighlightFields} -`; - -const isSorted = (comparator: (first: Value, second: Value) => number) => ( - values: Value[] -) => pairs(values, comparator).every(order => order <= 0); - -const ascendingTimeKey = (first: { key: InfraTimeKey }, second: { key: InfraTimeKey }) => - ascending(first.key.time, second.key.time) || - ascending(first.key.tiebreaker, second.key.tiebreaker); diff --git a/x-pack/test/api_integration/apis/infra/log_summary.ts b/x-pack/test/api_integration/apis/infra/log_summary.ts index 15e503f7b4a5a..1f1b65fca6e5f 100644 --- a/x-pack/test/api_integration/apis/infra/log_summary.ts +++ b/x-pack/test/api_integration/apis/infra/log_summary.ts @@ -38,9 +38,10 @@ export default function({ getService }: FtrProviderContext) { after(() => esArchiver.unload('infra/metrics_and_logs')); it('should return empty and non-empty consecutive buckets', async () => { - const startDate = EARLIEST_TIME_WITH_DATA; - const endDate = LATEST_TIME_WITH_DATA + (LATEST_TIME_WITH_DATA - EARLIEST_TIME_WITH_DATA); - const bucketSize = Math.ceil((endDate - startDate) / 10); + const startTimestamp = EARLIEST_TIME_WITH_DATA; + const endTimestamp = + LATEST_TIME_WITH_DATA + (LATEST_TIME_WITH_DATA - EARLIEST_TIME_WITH_DATA); + const bucketSize = Math.ceil((endTimestamp - startTimestamp) / 10); const { body } = await supertest .post(LOG_ENTRIES_SUMMARY_PATH) @@ -48,8 +49,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesSummaryRequestRT.encode({ sourceId: 'default', - startDate, - endDate, + startTimestamp, + endTimestamp, bucketSize, query: null, }) diff --git a/x-pack/test/api_integration/apis/infra/logs_without_millis.ts b/x-pack/test/api_integration/apis/infra/logs_without_millis.ts index 9295380cfbec1..642f4fb42d324 100644 --- a/x-pack/test/api_integration/apis/infra/logs_without_millis.ts +++ b/x-pack/test/api_integration/apis/infra/logs_without_millis.ts @@ -5,8 +5,6 @@ */ import expect from '@kbn/expect'; -import { ascending, pairs } from 'd3-array'; -import gql from 'graphql-tag'; import { pipe } from 'fp-ts/lib/pipeable'; import { identity } from 'fp-ts/lib/function'; @@ -15,21 +13,18 @@ import { fold } from 'fp-ts/lib/Either'; import { createPlainError, throwErrors } from '../../../../plugins/infra/common/runtime_types'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { sharedFragments } from '../../../../plugins/infra/common/graphql/shared'; -import { InfraTimeKey } from '../../../../plugins/infra/public/graphql/types'; import { LOG_ENTRIES_SUMMARY_PATH, logEntriesSummaryRequestRT, logEntriesSummaryResponseRT, + LOG_ENTRIES_PATH, + logEntriesRequestRT, + logEntriesResponseRT, } from '../../../../plugins/infra/common/http_api/log_entries'; const COMMON_HEADERS = { 'kbn-xsrf': 'some-xsrf-token', }; -const KEY_WITHIN_DATA_RANGE = { - time: new Date('2019-01-06T00:00:00.000Z').valueOf(), - tiebreaker: 0, -}; const EARLIEST_KEY_WITH_DATA = { time: new Date('2019-01-05T23:59:23.000Z').valueOf(), tiebreaker: -1, @@ -38,153 +33,97 @@ const LATEST_KEY_WITH_DATA = { time: new Date('2019-01-06T23:59:23.000Z').valueOf(), tiebreaker: 2, }; +const KEY_WITHIN_DATA_RANGE = { + time: new Date('2019-01-06T00:00:00.000Z').valueOf(), + tiebreaker: 0, +}; export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const client = getService('infraOpsGraphQLClient'); const supertest = getService('supertest'); describe('logs without epoch_millis format', () => { before(() => esArchiver.load('infra/logs_without_epoch_millis')); after(() => esArchiver.unload('infra/logs_without_epoch_millis')); - it('logEntriesAround should return log entries', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countBefore: 1, - countAfter: 1, - }, + describe('/log_entries/summary', () => { + it('returns non-empty buckets', async () => { + const startTimestamp = EARLIEST_KEY_WITH_DATA.time; + const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive + const bucketSize = Math.ceil((endTimestamp - startTimestamp) / 10); + + const { body } = await supertest + .post(LOG_ENTRIES_SUMMARY_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesSummaryRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + bucketSize, + query: null, + }) + ) + .expect(200); + + const logSummaryResponse = pipe( + logEntriesSummaryResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + + expect( + logSummaryResponse.data.buckets.filter((bucket: any) => bucket.entriesCount > 0) + ).to.have.length(2); }); - - expect(logEntriesAround).to.have.property('entries'); - expect(logEntriesAround.entries).to.have.length(2); - expect(isSorted(ascendingTimeKey)(logEntriesAround.entries)).to.equal(true); - - expect(logEntriesAround.hasMoreBefore).to.equal(false); - expect(logEntriesAround.hasMoreAfter).to.equal(false); }); - it('logEntriesBetween should return log entries', async () => { - const { - data: { - source: { logEntriesBetween }, - }, - } = await client.query({ - query: logEntriesBetweenQuery, - variables: { - startKey: EARLIEST_KEY_WITH_DATA, - endKey: LATEST_KEY_WITH_DATA, - }, + describe('/log_entries/entries', () => { + it('returns log entries', async () => { + const startTimestamp = EARLIEST_KEY_WITH_DATA.time; + const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive + + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + }) + ) + .expect(200); + + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + expect(logEntriesResponse.data.entries).to.have.length(2); }); - expect(logEntriesBetween).to.have.property('entries'); - expect(logEntriesBetween.entries).to.have.length(2); - expect(isSorted(ascendingTimeKey)(logEntriesBetween.entries)).to.equal(true); - }); - - it('logSummaryBetween should return non-empty buckets', async () => { - const startDate = EARLIEST_KEY_WITH_DATA.time; - const endDate = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive - const bucketSize = Math.ceil((endDate - startDate) / 10); - - const { body } = await supertest - .post(LOG_ENTRIES_SUMMARY_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesSummaryRequestRT.encode({ - sourceId: 'default', - startDate, - endDate, - bucketSize, - query: null, - }) - ) - .expect(200); - - const logSummaryResponse = pipe( - logEntriesSummaryResponseRT.decode(body), - fold(throwErrors(createPlainError), identity) - ); - - expect( - logSummaryResponse.data.buckets.filter((bucket: any) => bucket.entriesCount > 0) - ).to.have.length(2); + it('returns log entries when centering around a point', async () => { + const startTimestamp = EARLIEST_KEY_WITH_DATA.time; + const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive + + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + center: KEY_WITHIN_DATA_RANGE, + }) + ) + .expect(200); + + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + expect(logEntriesResponse.data.entries).to.have.length(2); + }); }); }); } - -const logEntriesAroundQuery = gql` - query LogEntriesAroundQuery( - $timeKey: InfraTimeKeyInput! - $countBefore: Int = 0 - $countAfter: Int = 0 - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesAround( - key: $timeKey - countBefore: $countBefore - countAfter: $countAfter - filterQuery: $filterQuery - ) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - -const logEntriesBetweenQuery = gql` - query LogEntriesBetweenQuery( - $startKey: InfraTimeKeyInput! - $endKey: InfraTimeKeyInput! - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesBetween(startKey: $startKey, endKey: $endKey, filterQuery: $filterQuery) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - -const isSorted = (comparator: (first: Value, second: Value) => number) => ( - values: Value[] -) => pairs(values, comparator).every(order => order <= 0); - -const ascendingTimeKey = (first: { key: InfraTimeKey }, second: { key: InfraTimeKey }) => - ascending(first.key.time, second.key.time) || - ascending(first.key.tiebreaker, second.key.tiebreaker); diff --git a/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js b/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js index 30459be6ee1dd..3efb4d6600f7f 100644 --- a/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js +++ b/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js @@ -41,7 +41,7 @@ export default function({ getService }) { payload.remoteCluster = 'unknown-cluster'; const { body } = await createAutoFollowPattern(undefined, payload).expect(404); - expect(body.cause[0]).to.contain('no such remote cluster'); + expect(body.attributes.cause[0]).to.contain('no such remote cluster'); }); }); @@ -52,6 +52,7 @@ export default function({ getService }) { it('should create an auto-follow pattern when cluster is known', async () => { const name = getRandomString(); const { body } = await createAutoFollowPattern(name).expect(200); + console.log(body); expect(body.acknowledged).to.eql(true); }); @@ -62,7 +63,7 @@ export default function({ getService }) { const name = getRandomString(); const { body } = await getAutoFollowPattern(name).expect(404); - expect(body.cause).not.to.be(undefined); + expect(body.attributes.cause).not.to.be(undefined); }); it('should return an auto-follow pattern that was created', async () => { diff --git a/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js b/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js index a5b12668ad9b9..5f9ebbd2a0a3f 100644 --- a/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js +++ b/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js @@ -47,13 +47,13 @@ export default function({ getService }) { payload.remoteCluster = 'unknown-cluster'; const { body } = await createFollowerIndex(undefined, payload).expect(404); - expect(body.cause[0]).to.contain('no such remote cluster'); + expect(body.attributes.cause[0]).to.contain('no such remote cluster'); }); it('should throw a 404 error trying to follow an unknown index', async () => { const payload = getFollowerIndexPayload(); const { body } = await createFollowerIndex(undefined, payload).expect(404); - expect(body.cause[0]).to.contain('no such index'); + expect(body.attributes.cause[0]).to.contain('no such index'); }); it('should create a follower index that follows an existing remote index', async () => { @@ -75,7 +75,7 @@ export default function({ getService }) { const name = getRandomString(); const { body } = await getFollowerIndex(name).expect(404); - expect(body.cause[0]).to.contain('no such index'); + expect(body.attributes.cause[0]).to.contain('no such index'); }); it('should return a follower index that was created', async () => { diff --git a/x-pack/test/api_integration/apis/ml/calculate_model_memory_limit.ts b/x-pack/test/api_integration/apis/ml/calculate_model_memory_limit.ts index e67d87ca37c01..5d1a52e3c2c21 100644 --- a/x-pack/test/api_integration/apis/ml/calculate_model_memory_limit.ts +++ b/x-pack/test/api_integration/apis/ml/calculate_model_memory_limit.ts @@ -21,14 +21,20 @@ export default ({ getService }: FtrProviderContext) => { const testDataList = [ { - testTitleSuffix: 'with 0 metrics, 0 influencers and no split field', + testTitleSuffix: 'when no partition field is provided with regular function', user: USER.ML_POWERUSER, requestBody: { indexPattern: 'ecommerce', - splitFieldName: '', + analysisConfig: { + bucket_span: '15m', + detectors: [ + { + function: 'mean', + }, + ], + influencers: [], + }, query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } }, - fieldNames: ['__ml_event_rate_count__'], - influencerNames: [], timeFieldName: 'order_date', earliestMs: 1560297859000, latestMs: 1562975136000, @@ -38,7 +44,8 @@ export default ({ getService }: FtrProviderContext) => { responseBody: { statusCode: 400, error: 'Bad Request', - message: "[illegal_argument_exception] specified fields can't be null or empty", + message: + '[status_exception] Unless a count or temporal function is used one of field_name, by_field_name or over_field_name must be set', }, }, }, @@ -47,72 +54,79 @@ export default ({ getService }: FtrProviderContext) => { user: USER.ML_POWERUSER, requestBody: { indexPattern: 'ecommerce', - splitFieldName: 'geoip.city_name', - query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } }, - fieldNames: ['products.base_price'], - influencerNames: ['geoip.city_name'], - timeFieldName: 'order_date', - earliestMs: 1560297859000, - latestMs: 1562975136000, - }, - expected: { - responseCode: 200, - responseBody: { modelMemoryLimit: '12MB' }, - }, - }, - { - testTitleSuffix: 'with 3 metrics, 3 influencers, split by city', - user: USER.ML_POWERUSER, - requestBody: { - indexPattern: 'ecommerce', - splitFieldName: 'geoip.city_name', + analysisConfig: { + bucket_span: '15m', + detectors: [ + { + function: 'avg', + field_name: 'geoip.city_name', + by_field_name: 'geoip.city_name', + }, + ], + influencers: ['geoip.city_name'], + }, query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } }, - fieldNames: ['products.base_price', 'taxful_total_price', 'products.discount_amount'], - influencerNames: ['geoip.city_name', 'customer_gender', 'customer_full_name.keyword'], timeFieldName: 'order_date', earliestMs: 1560297859000, latestMs: 1562975136000, }, expected: { responseCode: 200, - responseBody: { modelMemoryLimit: '14MB' }, + responseBody: { modelMemoryLimit: '11MB', estimatedModelMemoryLimit: '11MB' }, }, }, { - testTitleSuffix: 'with 4 metrics, 4 influencers, split by customer_id', + testTitleSuffix: 'with 3 influencers, split by city', user: USER.ML_POWERUSER, requestBody: { indexPattern: 'ecommerce', - splitFieldName: 'customer_id', + analysisConfig: { + bucket_span: '15m', + detectors: [ + { + function: 'mean', + by_field_name: 'geoip.city_name', + field_name: 'geoip.city_name', + }, + ], + influencers: ['geoip.city_name', 'customer_gender', 'customer_full_name.keyword'], + }, query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } }, - fieldNames: [ - 'geoip.country_iso_code', - 'taxless_total_price', - 'taxful_total_price', - 'products.discount_amount', - ], - influencerNames: [ - 'customer_id', - 'geoip.country_iso_code', - 'products.discount_percentage', - 'products.discount_amount', - ], timeFieldName: 'order_date', earliestMs: 1560297859000, latestMs: 1562975136000, }, expected: { responseCode: 200, - responseBody: { modelMemoryLimit: '23MB' }, + responseBody: { estimatedModelMemoryLimit: '11MB', modelMemoryLimit: '11MB' }, }, }, { - testTitleSuffix: - 'with 4 metrics, 4 influencers, split by customer_id and filtering by country code', + testTitleSuffix: '4 influencers, split by customer_id and filtering by country code', user: USER.ML_POWERUSER, requestBody: { indexPattern: 'ecommerce', - splitFieldName: 'customer_id', + analysisConfig: { + bucket_span: '2d', + detectors: [ + { + function: 'mean', + by_field_name: 'customer_id.city_name', + field_name: 'customer_id.city_name', + }, + { + function: 'avg', + by_field_name: 'manufacturer.keyword', + field_name: 'manufacturer.keyword', + }, + ], + influencers: [ + 'geoip.country_iso_code', + 'products.discount_percentage', + 'products.discount_amount', + 'day_of_week', + ], + }, query: { bool: { filter: { @@ -122,25 +136,13 @@ export default ({ getService }: FtrProviderContext) => { }, }, }, - fieldNames: [ - 'geoip.country_iso_code', - 'taxless_total_price', - 'taxful_total_price', - 'products.discount_amount', - ], - influencerNames: [ - 'customer_id', - 'geoip.country_iso_code', - 'products.discount_percentage', - 'products.discount_amount', - ], timeFieldName: 'order_date', earliestMs: 1560297859000, latestMs: 1562975136000, }, expected: { responseCode: 200, - responseBody: { modelMemoryLimit: '14MB' }, + responseBody: { estimatedModelMemoryLimit: '12MB', modelMemoryLimit: '12MB' }, }, }, ]; diff --git a/x-pack/test/functional/apps/infra/constants.ts b/x-pack/test/functional/apps/infra/constants.ts index 947131a22d39b..cd91867faf9df 100644 --- a/x-pack/test/functional/apps/infra/constants.ts +++ b/x-pack/test/functional/apps/infra/constants.ts @@ -22,5 +22,9 @@ export const DATES = { withData: '10/17/2018 7:58:03 PM', withoutData: '10/09/2018 10:00:00 PM', }, + stream: { + startWithData: '2018-10-17T19:42:22.000Z', + endWithData: '2018-10-17T19:57:21.000Z', + }, }, }; diff --git a/x-pack/test/functional/apps/infra/link_to.ts b/x-pack/test/functional/apps/infra/link_to.ts index da41bf285c3e4..7e79f42ac94cb 100644 --- a/x-pack/test/functional/apps/infra/link_to.ts +++ b/x-pack/test/functional/apps/infra/link_to.ts @@ -7,22 +7,29 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +const ONE_HOUR = 60 * 60 * 1000; + export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common']); const retry = getService('retry'); const browser = getService('browser'); + const timestamp = Date.now(); + const startDate = new Date(timestamp - ONE_HOUR).toISOString(); + const endDate = new Date(timestamp + ONE_HOUR).toISOString(); + + const traceId = '433b4651687e18be2c6c8e3b11f53d09'; + describe('Infra link-to', function() { this.tags('smoke'); it('redirects to the logs app and parses URL search params correctly', async () => { const location = { hash: '', pathname: '/link-to/logs', - search: 'time=1565707203194&filter=trace.id:433b4651687e18be2c6c8e3b11f53d09', + search: `time=${timestamp}&filter=trace.id:${traceId}`, state: undefined, }; - const expectedSearchString = - "logFilter=(expression:'trace.id:433b4651687e18be2c6c8e3b11f53d09',kind:kuery)&logPosition=(position:(tiebreaker:0,time:1565707203194),streamLive:!f)&sourceId=default"; + const expectedSearchString = `logFilter=(expression:'trace.id:${traceId}',kind:kuery)&logPosition=(end:'${endDate}',position:(tiebreaker:0,time:${timestamp}),start:'${startDate}',streamLive:!f)&sourceId=default`; const expectedRedirectPath = '/logs/stream?'; await pageObjects.common.navigateToUrlWithBrowserHistory( diff --git a/x-pack/test/functional/apps/infra/logs_source_configuration.ts b/x-pack/test/functional/apps/infra/logs_source_configuration.ts index ecad5a40ec42e..f40c908f23c80 100644 --- a/x-pack/test/functional/apps/infra/logs_source_configuration.ts +++ b/x-pack/test/functional/apps/infra/logs_source_configuration.ts @@ -5,6 +5,7 @@ */ import expect from '@kbn/expect'; +import { DATES } from './constants'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -74,7 +75,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('renders the default log columns with their headers', async () => { - await logsUi.logStreamPage.navigateTo(); + await logsUi.logStreamPage.navigateTo({ + logPosition: { + start: DATES.metricsAndLogs.stream.startWithData, + end: DATES.metricsAndLogs.stream.endWithData, + }, + }); await retry.try(async () => { const columnHeaderLabels = await logsUi.logStreamPage.getColumnHeaderLabels(); @@ -108,7 +114,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('renders the changed log columns with their headers', async () => { - await logsUi.logStreamPage.navigateTo(); + await logsUi.logStreamPage.navigateTo({ + logPosition: { + start: DATES.metricsAndLogs.stream.startWithData, + end: DATES.metricsAndLogs.stream.endWithData, + }, + }); await retry.try(async () => { const columnHeaderLabels = await logsUi.logStreamPage.getColumnHeaderLabels(); diff --git a/x-pack/test/functional/page_objects/infra_logs_page.ts b/x-pack/test/functional/page_objects/infra_logs_page.ts index 8f554729328bb..10d86140fd121 100644 --- a/x-pack/test/functional/page_objects/infra_logs_page.ts +++ b/x-pack/test/functional/page_objects/infra_logs_page.ts @@ -6,8 +6,21 @@ // import testSubjSelector from '@kbn/test-subj-selector'; // import moment from 'moment'; - +import querystring from 'querystring'; +import { encode, RisonValue } from 'rison-node'; import { FtrProviderContext } from '../ftr_provider_context'; +import { LogPositionUrlState } from '../../../../x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state'; +import { FlyoutOptionsUrlState } from '../../../../x-pack/plugins/infra/public/containers/logs/log_flyout'; + +export interface TabsParams { + stream: { + logPosition?: Partial; + flyoutOptions?: Partial; + }; + settings: never; + 'log-categories': any; + 'log-rate': any; +} export function InfraLogsPageProvider({ getPageObjects, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); @@ -18,8 +31,26 @@ export function InfraLogsPageProvider({ getPageObjects, getService }: FtrProvide await pageObjects.common.navigateToApp('infraLogs'); }, - async navigateToTab(logsUiTab: LogsUiTab) { - await pageObjects.common.navigateToUrlWithBrowserHistory('infraLogs', `/${logsUiTab}`); + async navigateToTab(logsUiTab: T, params?: TabsParams[T]) { + let qs = ''; + if (params) { + const parsedParams: Record = {}; + + for (const key in params) { + if (params.hasOwnProperty(key)) { + const value = (params[key] as unknown) as RisonValue; + parsedParams[key] = encode(value); + } + } + qs = '?' + querystring.stringify(parsedParams); + } + + await pageObjects.common.navigateToUrlWithBrowserHistory( + 'infraLogs', + `/${logsUiTab}`, + qs, + { ensureCurrentUrl: false } // Test runner struggles with `rison-node` escaped values + ); }, async getLogStream() { diff --git a/x-pack/test/functional/page_objects/uptime_page.ts b/x-pack/test/functional/page_objects/uptime_page.ts index f6e93cd14e497..57842ffbb2c5d 100644 --- a/x-pack/test/functional/page_objects/uptime_page.ts +++ b/x-pack/test/functional/page_objects/uptime_page.ts @@ -24,11 +24,13 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo public async goToUptimeOverviewAndLoadData( datePickerStartValue: string, datePickerEndValue: string, - monitorIdToCheck: string + monitorIdToCheck?: string ) { await pageObjects.common.navigateToApp('uptime'); await pageObjects.timePicker.setAbsoluteRange(datePickerStartValue, datePickerEndValue); - await uptimeService.monitorIdExists(monitorIdToCheck); + if (monitorIdToCheck) { + await uptimeService.monitorIdExists(monitorIdToCheck); + } } public async loadDataAndGoToMonitorPage( @@ -96,5 +98,39 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo public locationMissingIsDisplayed() { return uptimeService.locationMissingExists(); } + + public async openAlertFlyoutAndCreateMonitorStatusAlert({ + alertInterval, + alertName, + alertNumTimes, + alertTags, + alertThrottleInterval, + alertTimerangeSelection, + filters, + }: { + alertName: string; + alertTags: string[]; + alertInterval: string; + alertThrottleInterval: string; + alertNumTimes: string; + alertTimerangeSelection: string; + filters?: string; + }) { + const { alerts, setKueryBarText } = uptimeService; + await alerts.openFlyout(); + await alerts.openMonitorStatusAlertType(); + await alerts.setAlertName(alertName); + await alerts.setAlertTags(alertTags); + await alerts.setAlertInterval(alertInterval); + await alerts.setAlertThrottleInterval(alertThrottleInterval); + if (filters) { + await setKueryBarText('xpack.uptime.alerts.monitorStatus.filterBar', filters); + } + await alerts.setAlertStatusNumTimes(alertNumTimes); + await alerts.setAlertTimerangeSelection(alertTimerangeSelection); + await alerts.setMonitorStatusSelectableToHours(); + await alerts.setLocationsSelectable(); + await alerts.clickSaveAlertButtion(); + } })(); } diff --git a/x-pack/test/functional/services/logs_ui/log_stream.ts b/x-pack/test/functional/services/logs_ui/log_stream.ts index ce37d2d5a60da..75486534cf5cc 100644 --- a/x-pack/test/functional/services/logs_ui/log_stream.ts +++ b/x-pack/test/functional/services/logs_ui/log_stream.ts @@ -6,6 +6,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper'; +import { TabsParams } from '../../page_objects/infra_logs_page'; export function LogStreamPageProvider({ getPageObjects, getService }: FtrProviderContext) { const pageObjects = getPageObjects(['infraLogs']); @@ -13,8 +14,8 @@ export function LogStreamPageProvider({ getPageObjects, getService }: FtrProvide const testSubjects = getService('testSubjects'); return { - async navigateTo() { - pageObjects.infraLogs.navigateToTab('stream'); + async navigateTo(params?: TabsParams['stream']) { + pageObjects.infraLogs.navigateToTab('stream', params); }, async getColumnHeaderLabels(): Promise { diff --git a/x-pack/test/functional/services/uptime.ts b/x-pack/test/functional/services/uptime.ts index 938be2c71ae74..7994a7e934033 100644 --- a/x-pack/test/functional/services/uptime.ts +++ b/x-pack/test/functional/services/uptime.ts @@ -12,6 +12,91 @@ export function UptimeProvider({ getService }: FtrProviderContext) { const retry = getService('retry'); return { + alerts: { + async openFlyout() { + await testSubjects.click('xpack.uptime.alertsPopover.toggleButton', 5000); + await testSubjects.click('xpack.uptime.toggleAlertFlyout', 5000); + }, + async openMonitorStatusAlertType() { + return testSubjects.click('xpack.uptime.alerts.monitorStatus-SelectOption', 5000); + }, + async setAlertTags(tags: string[]) { + for (let i = 0; i < tags.length; i += 1) { + await testSubjects.click('comboBoxSearchInput', 5000); + await testSubjects.setValue('comboBoxInput', tags[i]); + await browser.pressKeys(browser.keys.ENTER); + } + }, + async setAlertName(name: string) { + return testSubjects.setValue('alertNameInput', name); + }, + async setAlertInterval(value: string) { + return testSubjects.setValue('intervalInput', value); + }, + async setAlertThrottleInterval(value: string) { + return testSubjects.setValue('throttleInput', value); + }, + async setAlertExpressionValue( + expressionAttribute: string, + fieldAttribute: string, + value: string + ) { + await testSubjects.click(expressionAttribute); + await testSubjects.setValue(fieldAttribute, value); + return browser.pressKeys(browser.keys.ESCAPE); + }, + async setAlertStatusNumTimes(value: string) { + return this.setAlertExpressionValue( + 'xpack.uptime.alerts.monitorStatus.numTimesExpression', + 'xpack.uptime.alerts.monitorStatus.numTimesField', + value + ); + }, + async setAlertTimerangeSelection(value: string) { + return this.setAlertExpressionValue( + 'xpack.uptime.alerts.monitorStatus.timerangeValueExpression', + 'xpack.uptime.alerts.monitorStatus.timerangeValueField', + value + ); + }, + async setAlertExpressionSelectable( + expressionAttribute: string, + selectableAttribute: string, + optionAttributes: string[] + ) { + await testSubjects.click(expressionAttribute, 5000); + await testSubjects.click(selectableAttribute, 5000); + for (let i = 0; i < optionAttributes.length; i += 1) { + await testSubjects.click(optionAttributes[i], 5000); + } + return browser.pressKeys(browser.keys.ESCAPE); + }, + async setMonitorStatusSelectableToHours() { + return this.setAlertExpressionSelectable( + 'xpack.uptime.alerts.monitorStatus.timerangeUnitExpression', + 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable', + ['xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.hoursOption'] + ); + }, + async setLocationsSelectable() { + await testSubjects.click( + 'xpack.uptime.alerts.monitorStatus.locationsSelectionExpression', + 5000 + ); + await testSubjects.click( + 'xpack.uptime.alerts.monitorStatus.locationsSelectionSwitch', + 5000 + ); + await testSubjects.click( + 'xpack.uptime.alerts.monitorStatus.locationsSelectionSelectable', + 5000 + ); + return browser.pressKeys(browser.keys.ESCAPE); + }, + async clickSaveAlertButtion() { + return testSubjects.click('saveAlertButton'); + }, + }, async assertExists(key: string) { if (!(await testSubjects.exists(key))) { throw new Error(`Couldn't find expected element with key "${key}".`); @@ -35,11 +120,14 @@ export function UptimeProvider({ getService }: FtrProviderContext) { async getMonitorNameDisplayedOnPageTitle() { return await testSubjects.getVisibleText('monitor-page-title'); }, - async setFilterText(filterQuery: string) { - await testSubjects.click('xpack.uptime.filterBar'); - await testSubjects.setValue('xpack.uptime.filterBar', filterQuery); + async setKueryBarText(attribute: string, value: string) { + await testSubjects.click(attribute); + await testSubjects.setValue(attribute, value); await browser.pressKeys(browser.keys.ENTER); }, + async setFilterText(filterQuery: string) { + await this.setKueryBarText('xpack.uptime.filterBar', filterQuery); + }, async goToNextPage() { await testSubjects.click('xpack.uptime.monitorList.nextButton', 5000); }, diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts index 4354b19da24ac..79448fa535370 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts @@ -186,6 +186,45 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ]); }); + it('should reset alert when canceling an edit', async () => { + const createdAlert = await createAlert({ + alertTypeId: '.index-threshold', + name: generateUniqueKey(), + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000, 5000], + index: ['.kibana_1'], + timeField: 'alert', + }, + }); + await pageObjects.common.navigateToApp('triggersActions'); + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const editLink = await testSubjects.findAll('alertsTableCell-editLink'); + await editLink[0].click(); + + const updatedAlertName = 'Changed Alert Name'; + const nameInputToUpdate = await testSubjects.find('alertNameInput'); + await nameInputToUpdate.click(); + await nameInputToUpdate.clearValue(); + await nameInputToUpdate.type(updatedAlertName); + + await testSubjects.click('cancelSaveEditedAlertButton'); + await find.waitForDeletedByCssSelector('[data-test-subj="cancelSaveEditedAlertButton"]'); + + const editLinkPostCancel = await testSubjects.findAll('alertsTableCell-editLink'); + await editLinkPostCancel[0].click(); + + const nameInputAfterCancel = await testSubjects.find('alertNameInput'); + const textAfterCancel = await nameInputAfterCancel.getAttribute('value'); + expect(textAfterCancel).to.eql(createdAlert.name); + }); + it('should search for tags', async () => { const createdAlert = await createAlert(); await pageObjects.common.navigateToApp('triggersActions'); diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts new file mode 100644 index 0000000000000..2a0358160da51 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + describe('overview page alert flyout controls', function() { + const DEFAULT_DATE_START = 'Sep 10, 2019 @ 12:40:08.078'; + const DEFAULT_DATE_END = 'Sep 11, 2019 @ 19:40:08.078'; + const pageObjects = getPageObjects(['common', 'uptime']); + const supertest = getService('supertest'); + const retry = getService('retry'); + + it('posts an alert, verfies its presence, and deletes the alert', async () => { + await pageObjects.uptime.goToUptimeOverviewAndLoadData(DEFAULT_DATE_START, DEFAULT_DATE_END); + + await pageObjects.uptime.openAlertFlyoutAndCreateMonitorStatusAlert({ + alertInterval: '11', + alertName: 'uptime-test', + alertNumTimes: '3', + alertTags: ['uptime', 'another'], + alertThrottleInterval: '30', + alertTimerangeSelection: '1', + filters: 'monitor.id: "0001-up"', + }); + + // The creation of the alert could take some time, so the first few times we query after + // the previous line resolves, the API may not be done creating the alert yet, so we + // put the fetch code in a retry block with a timeout. + let alert: any; + await retry.tryForTime(15000, async () => { + const apiResponse = await supertest.get('/api/alert/_find'); + const alertsFromThisTest = apiResponse.body.data.filter( + ({ name }: { name: string }) => name === 'uptime-test' + ); + expect(alertsFromThisTest).to.have.length(1); + alert = alertsFromThisTest[0]; + }); + + // Ensure the parameters and other stateful data + // on the alert match up with the values we provided + // for our test helper to input into the flyout. + const { + actions, + alertTypeId, + consumer, + id, + params: { numTimes, timerange, locations, filters }, + schedule: { interval }, + tags, + } = alert; + + // we're not testing the flyout's ability to associate alerts with action connectors + expect(actions).to.eql([]); + + expect(alertTypeId).to.eql('xpack.uptime.alerts.monitorStatus'); + expect(consumer).to.eql('uptime'); + expect(interval).to.eql('11m'); + expect(tags).to.eql(['uptime', 'another']); + expect(numTimes).to.be(3); + expect(timerange.from).to.be('now-1h'); + expect(timerange.to).to.be('now'); + expect(locations).to.eql(['mpls']); + expect(filters).to.eql( + '{"bool":{"should":[{"match_phrase":{"monitor.id":"0001-up"}}],"minimum_should_match":1}}' + ); + + await supertest + .delete(`/api/alert/${id}`) + .set('kbn-xsrf', 'true') + .expect(204); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts new file mode 100644 index 0000000000000..a433175acae01 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FtrProviderContext } from '../../ftr_provider_context'; + +const ARCHIVE = 'uptime/full_heartbeat'; + +export default ({ getService, loadTestFile }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + + describe('Uptime app', function() { + this.tags('ciGroup6'); + + describe('with real-world data', () => { + before(async () => { + await esArchiver.load(ARCHIVE); + await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'UTC' }); + }); + after(async () => await esArchiver.unload(ARCHIVE)); + + loadTestFile(require.resolve('./alert_flyout')); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index b19ec95c68916..538817bd9d14c 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -28,7 +28,10 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { services, pageObjects, // list paths to the files that contain your plugins tests - testFiles: [resolve(__dirname, './apps/triggers_actions_ui')], + testFiles: [ + resolve(__dirname, './apps/triggers_actions_ui'), + resolve(__dirname, './apps/uptime'), + ], apps: { ...xpackFunctionalConfig.get('apps'), triggersActions: {