diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 450daeebd24d1..abd63289e0480 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -187,10 +187,11 @@ /x-pack/test/screenshot_creation/apps/ml_docs @elastic/ml-ui /x-pack/test/screenshot_creation/services/ml_screenshots.ts @elastic/ml-ui -# ML team owns and maintains the transform plugin despite it living in the Data management section. -/x-pack/plugins/transform/ @elastic/ml-ui +# Additional plugins maintained by the ML team. +/x-pack/plugins/aiops/ @elastic/ml-ui /x-pack/plugins/data_visualizer/ @elastic/ml-ui /x-pack/plugins/file_upload/ @elastic/ml-ui +/x-pack/plugins/transform/ @elastic/ml-ui /x-pack/test/accessibility/apps/transform.ts @elastic/ml-ui /x-pack/test/api_integration/apis/transform/ @elastic/ml-ui /x-pack/test/api_integration_basic/apis/transform/ @elastic/ml-ui diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index a91776fde65ba..0d2d69123b5f3 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -376,6 +376,10 @@ The plugin exposes the static DefaultEditorController class to consume. |The Kibana actions plugin provides a framework to create executable actions. You can: +|{kib-repo}blob/{branch}/x-pack/plugins/aiops/README.md[aiops] +|The plugin provides APIs and components for AIOps features, including the “Explain log rate spikes” UI, maintained by the ML team. + + |{kib-repo}blob/{branch}/x-pack/plugins/alerting/README.md[alerting] |The Kibana Alerting plugin provides a common place to set up rules. You can: diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 7fbe272e01bb0..c9460f7bab4ea 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -1,6 +1,7 @@ pageLoadAssetSize: advancedSettings: 27596 actions: 20000 + aiops: 10000 alerting: 106936 apm: 64385 canvas: 1066647 diff --git a/tsconfig.base.json b/tsconfig.base.json index 78023a603276a..daf7bf78903c1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -277,6 +277,8 @@ "@kbn/ui-actions-enhanced-examples-plugin/*": ["x-pack/examples/ui_actions_enhanced_examples/*"], "@kbn/actions-plugin": ["x-pack/plugins/actions"], "@kbn/actions-plugin/*": ["x-pack/plugins/actions/*"], + "@kbn/aiops-plugin": ["x-pack/plugins/aiops"], + "@kbn/aiops-plugin/*": ["x-pack/plugins/aiops/*"], "@kbn/alerting-plugin": ["x-pack/plugins/alerting"], "@kbn/alerting-plugin/*": ["x-pack/plugins/alerting/*"], "@kbn/apm-plugin": ["x-pack/plugins/apm"], diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index b1464f5cfbe2e..738c5242813be 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -37,6 +37,7 @@ "xpack.logstash": ["plugins/logstash"], "xpack.main": "legacy/plugins/xpack_main", "xpack.maps": ["plugins/maps"], + "xpack.aiops": ["plugins/aiops"], "xpack.ml": ["plugins/ml"], "xpack.monitoring": ["plugins/monitoring"], "xpack.osquery": ["plugins/osquery"], diff --git a/x-pack/plugins/aiops/README.md b/x-pack/plugins/aiops/README.md new file mode 100755 index 0000000000000..9bfd64f9bf3a3 --- /dev/null +++ b/x-pack/plugins/aiops/README.md @@ -0,0 +1,9 @@ +# aiops + +The plugin provides APIs and components for AIOps features, including the “Explain log rate spikes” UI, maintained by the ML team. + +--- + +## Development + +See the [kibana contributing guide](https://github.com/elastic/kibana/blob/main/CONTRIBUTING.md) for instructions setting up your development environment. diff --git a/x-pack/plugins/aiops/common/api/example_stream.ts b/x-pack/plugins/aiops/common/api/example_stream.ts new file mode 100644 index 0000000000000..1210cccf55487 --- /dev/null +++ b/x-pack/plugins/aiops/common/api/example_stream.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const aiopsExampleStreamSchema = schema.object({ + /** Boolean flag to enable/disabling simulation of response errors. */ + simulateErrors: schema.maybe(schema.boolean()), + /** Maximum timeout between streaming messages. */ + timeout: schema.maybe(schema.number()), +}); + +export type AiopsExampleStreamSchema = TypeOf; + +export const API_ACTION_NAME = { + UPDATE_PROGRESS: 'update_progress', + ADD_TO_ENTITY: 'add_to_entity', + DELETE_ENTITY: 'delete_entity', +} as const; +export type ApiActionName = typeof API_ACTION_NAME[keyof typeof API_ACTION_NAME]; + +interface ApiActionUpdateProgress { + type: typeof API_ACTION_NAME.UPDATE_PROGRESS; + payload: number; +} + +export function updateProgressAction(payload: number): ApiActionUpdateProgress { + return { + type: API_ACTION_NAME.UPDATE_PROGRESS, + payload, + }; +} + +interface ApiActionAddToEntity { + type: typeof API_ACTION_NAME.ADD_TO_ENTITY; + payload: { + entity: string; + value: number; + }; +} + +export function addToEntityAction(entity: string, value: number): ApiActionAddToEntity { + return { + type: API_ACTION_NAME.ADD_TO_ENTITY, + payload: { + entity, + value, + }, + }; +} + +interface ApiActionDeleteEntity { + type: typeof API_ACTION_NAME.DELETE_ENTITY; + payload: string; +} + +export function deleteEntityAction(payload: string): ApiActionDeleteEntity { + return { + type: API_ACTION_NAME.DELETE_ENTITY, + payload, + }; +} + +export type ApiAction = ApiActionUpdateProgress | ApiActionAddToEntity | ApiActionDeleteEntity; diff --git a/x-pack/plugins/aiops/common/api/index.ts b/x-pack/plugins/aiops/common/api/index.ts new file mode 100644 index 0000000000000..da1e091d3fb54 --- /dev/null +++ b/x-pack/plugins/aiops/common/api/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AiopsExampleStreamSchema } from './example_stream'; + +export const API_ENDPOINT = { + EXAMPLE_STREAM: '/internal/aiops/example_stream', + ANOTHER: '/internal/aiops/another', +} as const; +export type ApiEndpoint = typeof API_ENDPOINT[keyof typeof API_ENDPOINT]; + +export interface ApiEndpointOptions { + [API_ENDPOINT.EXAMPLE_STREAM]: AiopsExampleStreamSchema; + [API_ENDPOINT.ANOTHER]: { anotherOption: string }; +} diff --git a/x-pack/plugins/aiops/common/index.ts b/x-pack/plugins/aiops/common/index.ts new file mode 100755 index 0000000000000..0f4835d67ecc7 --- /dev/null +++ b/x-pack/plugins/aiops/common/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * PLUGIN_ID is used as a unique identifier for the aiops plugin + */ +export const PLUGIN_ID = 'aiops'; + +/** + * PLUGIN_NAME is used as the display name for the aiops plugin + */ +export const PLUGIN_NAME = 'AIOps'; + +/** + * This is an internal hard coded feature flag so we can easily turn on/off the + * "Explain log rate spikes UI" during development until the first release. + */ +export const AIOPS_ENABLED = true; diff --git a/x-pack/plugins/aiops/jest.config.js b/x-pack/plugins/aiops/jest.config.js new file mode 100644 index 0000000000000..4b92cb8dc86cb --- /dev/null +++ b/x-pack/plugins/aiops/jest.config.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/aiops'], + coverageDirectory: '/target/kibana-coverage/jest/x-pack/plugins/aiops', + coverageReporters: ['text', 'html'], + collectCoverageFrom: ['/x-pack/plugins/aiops/{common,public,server}/**/*.{js,ts,tsx}'], +}; diff --git a/x-pack/plugins/aiops/kibana.json b/x-pack/plugins/aiops/kibana.json new file mode 100755 index 0000000000000..b74a23bf2bc9e --- /dev/null +++ b/x-pack/plugins/aiops/kibana.json @@ -0,0 +1,16 @@ +{ + "id": "aiops", + "version": "1.0.0", + "kibanaVersion": "kibana", + "owner": { + "name": "Machine Learning UI", + "githubTeam": "ml-ui" + }, + "description": "AIOps plugin maintained by ML team.", + "server": true, + "ui": true, + "requiredPlugins": [], + "optionalPlugins": [], + "requiredBundles": ["kibanaReact"], + "extraPublicDirs": ["common"] +} diff --git a/x-pack/plugins/aiops/public/api/index.ts b/x-pack/plugins/aiops/public/api/index.ts new file mode 100644 index 0000000000000..6aa171df5286c --- /dev/null +++ b/x-pack/plugins/aiops/public/api/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazyLoadModules } from '../lazy_load_bundle'; + +import type { ExplainLogRateSpikesSpec } from '../components/explain_log_rate_spikes'; + +export async function getExplainLogRateSpikesComponent(): Promise<() => ExplainLogRateSpikesSpec> { + const modules = await lazyLoadModules(); + return () => modules.ExplainLogRateSpikes; +} diff --git a/x-pack/plugins/aiops/public/components/app.tsx b/x-pack/plugins/aiops/public/components/app.tsx new file mode 100755 index 0000000000000..963253b154e27 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/app.tsx @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; + +import { Chart, Settings, Axis, BarSeries, Position, ScaleType } from '@elastic/charts'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +import { + EuiBadge, + EuiButton, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiProgress, + EuiSpacer, + EuiTitle, + EuiText, +} from '@elastic/eui'; + +import { getStatusMessage } from './get_status_message'; +import { initialState, resetStream, streamReducer } from './stream_reducer'; +import { useStreamFetchReducer } from './use_stream_fetch_reducer'; + +export const AiopsApp = () => { + const { notifications } = useKibana(); + + const [simulateErrors, setSimulateErrors] = useState(false); + + const { dispatch, start, cancel, data, isCancelled, isRunning } = useStreamFetchReducer( + '/internal/aiops/example_stream', + streamReducer, + initialState, + { simulateErrors } + ); + + const { errors, progress, entities } = data; + + const onClickHandler = async () => { + if (isRunning) { + cancel(); + } else { + dispatch(resetStream()); + start(); + } + }; + + useEffect(() => { + if (errors.length > 0) { + notifications.toasts.danger({ body: errors[errors.length - 1] }); + } + }, [errors, notifications.toasts]); + + const buttonLabel = isRunning + ? i18n.translate('xpack.aiops.stopbuttonText', { + defaultMessage: 'Stop development', + }) + : i18n.translate('xpack.aiops.startbuttonText', { + defaultMessage: 'Start development', + }); + + return ( + + + + + +

+ +

+
+
+ + + + + + {buttonLabel} + + + + + {progress}% + + + + + + + +
+ + + + + + { + return { + x, + y, + }; + }) + .sort((a, b) => b.y - a.y)} + /> + +
+

{getStatusMessage(isRunning, isCancelled, data.progress)}

+ setSimulateErrors(!simulateErrors)} + compressed + /> +
+
+
+
+
+ ); +}; diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes.tsx b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes.tsx new file mode 100644 index 0000000000000..21d7b39a2a148 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; + +import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { I18nProvider } from '@kbn/i18n-react'; + +import { getCoreStart } from '../kibana_services'; + +import { AiopsApp } from './app'; + +/** + * Spec used for lazy loading in the ML plugin + */ +export type ExplainLogRateSpikesSpec = typeof ExplainLogRateSpikes; + +export const ExplainLogRateSpikes: FC = () => { + const coreStart = getCoreStart(); + + return ( + + + + + + + + ); +}; diff --git a/x-pack/plugins/aiops/public/components/get_status_message.tsx b/x-pack/plugins/aiops/public/components/get_status_message.tsx new file mode 100644 index 0000000000000..e63748d03600a --- /dev/null +++ b/x-pack/plugins/aiops/public/components/get_status_message.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function getStatusMessage(isRunning: boolean, isCancelled: boolean, progress: number) { + if (!isRunning && !isCancelled && progress === 0) { + return 'Development did not start yet.'; + } else if (isRunning && !isCancelled) { + return 'Development is ongoing, the hype is real!'; + } else if (!isRunning && isCancelled) { + return 'Oh no, development got cancelled!'; + } else if (!isRunning && progress === 100) { + return 'Development clompeted, the release got out the door!'; + } + + // When the process stops but wasn't cancelled by the user and progress is not yet at 100%, + // this indicates there must have been a problem with the stream. + return 'Oh no, looks like there was a bug?!'; +} diff --git a/x-pack/plugins/aiops/public/components/stream_fetch.ts b/x-pack/plugins/aiops/public/components/stream_fetch.ts new file mode 100644 index 0000000000000..37d7c13dd3b55 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/stream_fetch.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type React from 'react'; + +import type { ApiEndpoint, ApiEndpointOptions } from '../../common/api'; + +export async function* streamFetch( + endpoint: E, + abortCtrl: React.MutableRefObject, + options: ApiEndpointOptions[ApiEndpoint], + basePath = '' +) { + const stream = await fetch(`${basePath}${endpoint}`, { + signal: abortCtrl.current.signal, + method: 'POST', + headers: { + // This refers to the format of the request body, + // not the response, which will be a uint8array Buffer. + 'Content-Type': 'application/json', + 'kbn-xsrf': 'stream', + }, + body: JSON.stringify(options), + }); + + if (stream.body !== null) { + // Note that Firefox 99 doesn't support `TextDecoderStream` yet. + // That's why we skip it here and use `TextDecoder` later to decode each chunk. + // Once Firefox supports it, we can use the following alternative: + // const reader = stream.body.pipeThrough(new TextDecoderStream()).getReader(); + const reader = stream.body.getReader(); + + const bufferBounce = 100; + let partial = ''; + let actionBuffer: A[] = []; + let lastCall = 0; + + while (true) { + try { + const { value: uint8array, done } = await reader.read(); + if (done) break; + + const value = new TextDecoder().decode(uint8array); + + const full = `${partial}${value}`; + const parts = full.split('\n'); + const last = parts.pop(); + + partial = last ?? ''; + + const actions = parts.map((p) => JSON.parse(p)); + actionBuffer.push(...actions); + + const now = Date.now(); + + if (now - lastCall >= bufferBounce && actionBuffer.length > 0) { + yield actionBuffer; + actionBuffer = []; + lastCall = now; + } + } catch (error) { + if (error.name !== 'AbortError') { + yield { type: 'error', payload: error.toString() }; + } + break; + } + } + + // The reader might finish with a partially filled actionBuffer so + // we need to clear it once more after the request is done. + if (actionBuffer.length > 0) { + yield actionBuffer; + actionBuffer.length = 0; + } + } +} diff --git a/x-pack/plugins/aiops/public/components/stream_reducer.ts b/x-pack/plugins/aiops/public/components/stream_reducer.ts new file mode 100644 index 0000000000000..3e68e139ceeca --- /dev/null +++ b/x-pack/plugins/aiops/public/components/stream_reducer.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ApiAction, API_ACTION_NAME } from '../../common/api/example_stream'; + +export const UI_ACTION_NAME = { + ERROR: 'error', + RESET: 'reset', +} as const; +export type UiActionName = typeof UI_ACTION_NAME[keyof typeof UI_ACTION_NAME]; + +export interface StreamState { + errors: string[]; + progress: number; + entities: Record; +} +export const initialState: StreamState = { + errors: [], + progress: 0, + entities: {}, +}; + +interface UiActionError { + type: typeof UI_ACTION_NAME.ERROR; + payload: string; +} +interface UiActionResetStream { + type: typeof UI_ACTION_NAME.RESET; +} + +export function resetStream(): UiActionResetStream { + return { type: UI_ACTION_NAME.RESET }; +} + +type UiAction = UiActionResetStream | UiActionError; +export type ReducerAction = ApiAction | UiAction; +export function streamReducer( + state: StreamState, + action: ReducerAction | ReducerAction[] +): StreamState { + if (Array.isArray(action)) { + return action.reduce(streamReducer, state); + } + + switch (action.type) { + case API_ACTION_NAME.UPDATE_PROGRESS: + return { + ...state, + progress: action.payload, + }; + case API_ACTION_NAME.DELETE_ENTITY: + const deleteFromEntities = { ...state.entities }; + delete deleteFromEntities[action.payload]; + return { + ...state, + entities: deleteFromEntities, + }; + case API_ACTION_NAME.ADD_TO_ENTITY: + const addToEntities = { ...state.entities }; + if (addToEntities[action.payload.entity] === undefined) { + addToEntities[action.payload.entity] = action.payload.value; + } else { + addToEntities[action.payload.entity] += action.payload.value; + } + return { + ...state, + entities: addToEntities, + }; + case UI_ACTION_NAME.RESET: + return initialState; + case UI_ACTION_NAME.ERROR: + return { + ...state, + errors: [...state.errors, action.payload], + }; + default: + return { + ...state, + errors: [...state.errors, 'UNKNOWN_ACTION_ERROR'], + }; + } +} diff --git a/x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.ts b/x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.ts new file mode 100644 index 0000000000000..77ac09e0ff429 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useReducer, useRef, useState, Reducer, ReducerAction, ReducerState } from 'react'; + +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +import type { ApiEndpoint, ApiEndpointOptions } from '../../common/api'; + +import { streamFetch } from './stream_fetch'; + +export const useStreamFetchReducer = , E = ApiEndpoint>( + endpoint: E, + reducer: R, + initialState: ReducerState, + options: ApiEndpointOptions[ApiEndpoint] +) => { + const kibana = useKibana(); + + const [isCancelled, setIsCancelled] = useState(false); + const [isRunning, setIsRunning] = useState(false); + + const [data, dispatch] = useReducer(reducer, initialState); + + const abortCtrl = useRef(new AbortController()); + + const start = async () => { + if (isRunning) { + throw new Error('Restart not supported yet'); + } + + setIsRunning(true); + setIsCancelled(false); + + abortCtrl.current = new AbortController(); + + for await (const actions of streamFetch( + endpoint, + abortCtrl, + options, + kibana.services.http?.basePath.get() + )) { + dispatch(actions as ReducerAction); + } + + setIsRunning(false); + }; + + const cancel = () => { + abortCtrl.current.abort(); + setIsCancelled(true); + setIsRunning(false); + }; + + return { + cancel, + data, + dispatch, + isCancelled, + isRunning, + start, + }; +}; diff --git a/x-pack/plugins/aiops/public/index.ts b/x-pack/plugins/aiops/public/index.ts new file mode 100755 index 0000000000000..30bcaf5afabdc --- /dev/null +++ b/x-pack/plugins/aiops/public/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AiopsPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. +export function plugin() { + return new AiopsPlugin(); +} + +export type { AiopsPluginSetup, AiopsPluginStart } from './types'; + +export type { ExplainLogRateSpikesSpec } from './components/explain_log_rate_spikes'; diff --git a/x-pack/plugins/aiops/public/kibana_services.ts b/x-pack/plugins/aiops/public/kibana_services.ts new file mode 100644 index 0000000000000..9a43d2de5e5a1 --- /dev/null +++ b/x-pack/plugins/aiops/public/kibana_services.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreStart } from '@kbn/core/public'; +import { AppPluginStartDependencies } from './types'; + +let coreStart: CoreStart; +let pluginsStart: AppPluginStartDependencies; +export function setStartServices(core: CoreStart, plugins: AppPluginStartDependencies) { + coreStart = core; + pluginsStart = plugins; +} + +export const getCoreStart = () => coreStart; +export const getPluginsStart = () => pluginsStart; diff --git a/x-pack/plugins/aiops/public/lazy_load_bundle/index.ts b/x-pack/plugins/aiops/public/lazy_load_bundle/index.ts new file mode 100644 index 0000000000000..0072336080175 --- /dev/null +++ b/x-pack/plugins/aiops/public/lazy_load_bundle/index.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ExplainLogRateSpikesSpec } from '../components/explain_log_rate_spikes'; + +let loadModulesPromise: Promise; + +interface LazyLoadedModules { + ExplainLogRateSpikes: ExplainLogRateSpikesSpec; +} + +export async function lazyLoadModules(): Promise { + if (typeof loadModulesPromise !== 'undefined') { + return loadModulesPromise; + } + + loadModulesPromise = new Promise(async (resolve, reject) => { + try { + const lazyImports = await import('./lazy'); + resolve({ ...lazyImports }); + } catch (error) { + reject(error); + } + }); + return loadModulesPromise; +} diff --git a/x-pack/plugins/aiops/public/lazy_load_bundle/lazy/index.ts b/x-pack/plugins/aiops/public/lazy_load_bundle/lazy/index.ts new file mode 100644 index 0000000000000..967525de9bd6e --- /dev/null +++ b/x-pack/plugins/aiops/public/lazy_load_bundle/lazy/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { ExplainLogRateSpikesSpec } from '../../components/explain_log_rate_spikes'; +export { ExplainLogRateSpikes } from '../../components/explain_log_rate_spikes'; diff --git a/x-pack/plugins/aiops/public/plugin.ts b/x-pack/plugins/aiops/public/plugin.ts new file mode 100755 index 0000000000000..3c3cff39abb80 --- /dev/null +++ b/x-pack/plugins/aiops/public/plugin.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; + +import { getExplainLogRateSpikesComponent } from './api'; +import { setStartServices } from './kibana_services'; +import { AiopsPluginSetup, AiopsPluginStart } from './types'; + +export class AiopsPlugin implements Plugin { + public setup(core: CoreSetup) {} + + public start(core: CoreStart) { + setStartServices(core, {}); + return { + getExplainLogRateSpikesComponent, + }; + } + + public stop() {} +} diff --git a/x-pack/plugins/aiops/public/types.ts b/x-pack/plugins/aiops/public/types.ts new file mode 100755 index 0000000000000..fae18dc1d3106 --- /dev/null +++ b/x-pack/plugins/aiops/public/types.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AiopsPlugin } from './plugin'; + +/** + * aiops plugin public setup contract + */ +export type AiopsPluginSetup = ReturnType; + +/** + * aiops plugin public start contract + */ +export type AiopsPluginStart = ReturnType; + +// eslint-disable-next-line +export type AppPluginStartDependencies = {}; diff --git a/x-pack/plugins/aiops/server/index.ts b/x-pack/plugins/aiops/server/index.ts new file mode 100755 index 0000000000000..8dca6eb397d5e --- /dev/null +++ b/x-pack/plugins/aiops/server/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext } from '@kbn/core/server'; +import { AiopsPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new AiopsPlugin(initializerContext); +} + +export type { AiopsPluginSetup, AiopsPluginStart } from './types'; diff --git a/x-pack/plugins/aiops/server/plugin.ts b/x-pack/plugins/aiops/server/plugin.ts new file mode 100755 index 0000000000000..c6b1b8b22a187 --- /dev/null +++ b/x-pack/plugins/aiops/server/plugin.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from '@kbn/core/server'; + +import { AiopsPluginSetup, AiopsPluginStart } from './types'; +import { defineRoutes } from './routes'; + +export class AiopsPlugin implements Plugin { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup(core: CoreSetup) { + this.logger.debug('aiops: Setup'); + const router = core.http.createRouter(); + + // Register server side APIs + defineRoutes(router, this.logger); + + return {}; + } + + public start(core: CoreStart) { + this.logger.debug('aiops: Started'); + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/aiops/server/routes/index.ts b/x-pack/plugins/aiops/server/routes/index.ts new file mode 100755 index 0000000000000..e87c27e2af81e --- /dev/null +++ b/x-pack/plugins/aiops/server/routes/index.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Readable } from 'stream'; + +import type { IRouter, Logger } from '@kbn/core/server'; + +import { AIOPS_ENABLED } from '../../common'; +import type { ApiAction } from '../../common/api/example_stream'; +import { + aiopsExampleStreamSchema, + updateProgressAction, + addToEntityAction, + deleteEntityAction, +} from '../../common/api/example_stream'; + +// We need this otherwise Kibana server will crash with a 'ERR_METHOD_NOT_IMPLEMENTED' error. +class ResponseStream extends Readable { + _read(): void {} +} + +const delimiter = '\n'; + +export function defineRoutes(router: IRouter, logger: Logger) { + if (AIOPS_ENABLED) { + router.post( + { + path: '/internal/aiops/example_stream', + validate: { + body: aiopsExampleStreamSchema, + }, + }, + async (context, request, response) => { + const maxTimeoutMs = request.body.timeout ?? 250; + const simulateError = request.body.simulateErrors ?? false; + + let shouldStop = false; + request.events.aborted$.subscribe(() => { + shouldStop = true; + }); + request.events.completed$.subscribe(() => { + shouldStop = true; + }); + + const stream = new ResponseStream(); + + function streamPush(d: ApiAction) { + try { + const line = JSON.stringify(d); + stream.push(`${line}${delimiter}`); + } catch (error) { + logger.error('Could not serialize or stream a message.'); + logger.error(error); + } + } + + const entities = [ + 'kimchy', + 's1monw', + 'martijnvg', + 'jasontedor', + 'nik9000', + 'javanna', + 'rjernst', + 'jrodewig', + ]; + + const actions = [...Array(19).fill('add'), 'delete']; + + if (simulateError) { + actions.push('server-only-error'); + actions.push('server-to-client-error'); + actions.push('client-error'); + } + + let progress = 0; + + async function pushStreamUpdate() { + setTimeout(() => { + try { + progress++; + + if (progress > 100 || shouldStop) { + stream.push(null); + return; + } + + streamPush(updateProgressAction(progress)); + + const randomEntity = entities[Math.floor(Math.random() * entities.length)]; + const randomAction = actions[Math.floor(Math.random() * actions.length)]; + + if (randomAction === 'add') { + const randomCommits = Math.floor(Math.random() * 100); + streamPush(addToEntityAction(randomEntity, randomCommits)); + } else if (randomAction === 'delete') { + streamPush(deleteEntityAction(randomEntity)); + } else if (randomAction === 'server-to-client-error') { + // Throw an error. It should not crash Kibana! + throw new Error('There was a (simulated) server side error!'); + } else if (randomAction === 'client-error') { + // Return not properly encoded JSON to the client. + stream.push(`{body:'Not valid JSON${delimiter}`); + } + + pushStreamUpdate(); + } catch (error) { + stream.push( + `${JSON.stringify({ type: 'error', payload: error.toString() })}${delimiter}` + ); + stream.push(null); + } + }, Math.floor(Math.random() * maxTimeoutMs)); + } + + // do not call this using `await` so it will run asynchronously while we return the stream already. + pushStreamUpdate(); + + return response.ok({ + body: stream, + }); + } + ); + } +} diff --git a/x-pack/plugins/aiops/server/types.ts b/x-pack/plugins/aiops/server/types.ts new file mode 100755 index 0000000000000..526e7280e9495 --- /dev/null +++ b/x-pack/plugins/aiops/server/types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * aiops plugin server setup contract + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AiopsPluginSetup {} + +/** + * aiops plugin server start contract + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AiopsPluginStart {} diff --git a/x-pack/plugins/aiops/tsconfig.json b/x-pack/plugins/aiops/tsconfig.json new file mode 100644 index 0000000000000..2545c0e21ed03 --- /dev/null +++ b/x-pack/plugins/aiops/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "../../../typings/**/*", + "common/**/*", + "public/**/*", + "scripts/**/*", + "server/**/*", + "types/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../../../src/plugins/custom_integrations/tsconfig.json" }, + { "path": "../../../src/plugins/navigation/tsconfig.json" }, + { "path": "../../../src/plugins/unified_search/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/ml/common/constants/locator.ts b/x-pack/plugins/ml/common/constants/locator.ts index 0a1c2638e684a..0c19c5b59766c 100644 --- a/x-pack/plugins/ml/common/constants/locator.ts +++ b/x-pack/plugins/ml/common/constants/locator.ts @@ -51,6 +51,8 @@ export const ML_PAGES = { FILTER_LISTS_EDIT: 'settings/filter_lists/edit_filter_list', ACCESS_DENIED: 'access-denied', OVERVIEW: 'overview', + AIOPS: 'aiops', + AIOPS_EXPLAIN_LOG_RATE_SPIKES: 'aiops/explain_log_rate_spikes', } as const; export type MlPages = typeof ML_PAGES[keyof typeof ML_PAGES]; diff --git a/x-pack/plugins/ml/common/types/locator.ts b/x-pack/plugins/ml/common/types/locator.ts index 33ec94b825303..a440aaa349bcc 100644 --- a/x-pack/plugins/ml/common/types/locator.ts +++ b/x-pack/plugins/ml/common/types/locator.ts @@ -60,7 +60,9 @@ export type MlGenericUrlState = MLPageState< | typeof ML_PAGES.ACCESS_DENIED | typeof ML_PAGES.DATA_VISUALIZER | typeof ML_PAGES.DATA_VISUALIZER_FILE - | typeof ML_PAGES.DATA_VISUALIZER_INDEX_SELECT, + | typeof ML_PAGES.DATA_VISUALIZER_INDEX_SELECT + | typeof ML_PAGES.AIOPS + | typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES, MlGenericUrlPageState | undefined >; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index eb00ca117f01a..f62cec0ec0fca 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -7,6 +7,7 @@ "ml" ], "requiredPlugins": [ + "aiops", "cloud", "data", "dataViews", diff --git a/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx new file mode 100644 index 0000000000000..473525d40ca9a --- /dev/null +++ b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { ExplainLogRateSpikesSpec } from '@kbn/aiops-plugin/public'; +import { useMlKibana, useTimefilter } from '../contexts/kibana'; +import { HelpMenu } from '../components/help_menu'; + +import { MlPageHeader } from '../components/page_header'; + +export const ExplainLogRateSpikesPage: FC = () => { + useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); + const { + services: { docLinks, aiops }, + } = useMlKibana(); + + const [ExplainLogRateSpikes, setExplainLogRateSpikes] = useState( + null + ); + + useEffect(() => { + if (aiops !== undefined) { + const { getExplainLogRateSpikesComponent } = aiops; + getExplainLogRateSpikesComponent().then(setExplainLogRateSpikes); + } + }, []); + + return ( + <> + {ExplainLogRateSpikes !== null ? ( + <> + + + + + + ) : null} + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/aiops/index.ts b/x-pack/plugins/ml/public/application/aiops/index.ts new file mode 100644 index 0000000000000..fa47ae09822e2 --- /dev/null +++ b/x-pack/plugins/ml/public/application/aiops/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ExplainLogRateSpikesPage } from './explain_log_rate_spikes'; diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 833a4fade128b..50417aafab9b6 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -82,6 +82,7 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { maps: deps.maps, triggersActionsUi: deps.triggersActionsUi, dataVisualizer: deps.dataVisualizer, + aiops: deps.aiops, usageCollection: deps.usageCollection, fieldFormats: deps.fieldFormats, dashboard: deps.dashboard, @@ -135,6 +136,7 @@ export const renderApp = ( dashboard: deps.dashboard, maps: deps.maps, dataVisualizer: deps.dataVisualizer, + aiops: deps.aiops, dataViews: deps.data.dataViews, }); diff --git a/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx b/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx index 301939fb6fdbc..d41ca59255467 100644 --- a/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx @@ -71,7 +71,10 @@ export const MlPage: FC<{ pageDeps: PageDependencies }> = React.memo(({ pageDeps ); const routeList = useMemo( - () => Object.values(routes).map((routeFactory) => routeFactory(navigateToPath, basePath.get())), + () => + Object.values(routes) + .map((routeFactory) => routeFactory(navigateToPath, basePath.get())) + .filter((d) => !d.disabled), [] ); diff --git a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx index e5c67de96f494..84474e85330d6 100644 --- a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import type { EuiSideNavItemType } from '@elastic/eui'; import { useCallback, useMemo } from 'react'; +import { AIOPS_ENABLED } from '@kbn/aiops-plugin/common'; import type { MlLocatorParams } from '../../../../common/types/locator'; import { useUrlState } from '../../util/url_state'; import { useMlLocator, useNavigateToPath } from '../../contexts/kibana'; @@ -64,7 +65,7 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) { const tabsDefinition: Tab[] = useMemo((): Tab[] => { const disableLinks = mlFeaturesDisabled; - return [ + const mlTabs: Tab[] = [ { id: 'main_section', name: '', @@ -218,6 +219,28 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) { ], }, ]; + + if (AIOPS_ENABLED) { + mlTabs.push({ + id: 'aiops_section', + name: i18n.translate('xpack.ml.navMenu.aiopsTabLinkText', { + defaultMessage: 'AIOps', + }), + items: [ + { + id: 'explainlogratespikes', + pathId: ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES, + name: i18n.translate('xpack.ml.navMenu.explainLogRateSpikesLinkText', { + defaultMessage: 'Explain log rate spikes', + }), + disabled: disableLinks, + testSubj: 'mlMainTab explainLogRateSpikes', + }, + ], + }); + } + + return mlTabs; }, [mlFeaturesDisabled, canViewMlNodes]); const getTabItem: (tab: Tab) => EuiSideNavItemType = useCallback( diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index bfb27e6d4dbbc..fdfcd9106e8e0 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -16,6 +16,7 @@ import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import type { EmbeddableStart } from '@kbn/embeddable-plugin/public'; import type { MapsStartApi } from '@kbn/maps-plugin/public'; import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public'; +import type { AiopsPluginStart } from '@kbn/aiops-plugin/public'; import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; import type { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common'; import type { DashboardSetup } from '@kbn/dashboard-plugin/public'; @@ -32,6 +33,7 @@ interface StartPlugins { maps?: MapsStartApi; triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; dataVisualizer?: DataVisualizerPluginStart; + aiops?: AiopsPluginStart; usageCollection?: UsageCollectionSetup; fieldFormats: FieldFormatsRegistry; dashboard: DashboardSetup; diff --git a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts index e563831d16376..54aedb4a71857 100644 --- a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts +++ b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts @@ -55,6 +55,13 @@ export const DATA_VISUALIZER_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ href: '/datavisualizer', }); +export const AIOPS_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ + text: i18n.translate('xpack.ml.aiopsBreadcrumbLabel', { + defaultMessage: 'AIOps', + }), + href: '/aiops', +}); + export const CREATE_JOB_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ text: i18n.translate('xpack.ml.createJobsBreadcrumbLabel', { defaultMessage: 'Create job', @@ -83,6 +90,7 @@ const breadcrumbs = { DATA_FRAME_ANALYTICS_BREADCRUMB, TRAINED_MODELS, DATA_VISUALIZER_BREADCRUMB, + AIOPS_BREADCRUMB, CREATE_JOB_BREADCRUMB, CALENDAR_MANAGEMENT_BREADCRUMB, FILTER_LISTS_BREADCRUMB, diff --git a/x-pack/plugins/ml/public/application/routing/router.tsx b/x-pack/plugins/ml/public/application/routing/router.tsx index e4e7daa9ee0e1..a761bce2ce38a 100644 --- a/x-pack/plugins/ml/public/application/routing/router.tsx +++ b/x-pack/plugins/ml/public/application/routing/router.tsx @@ -48,6 +48,7 @@ export interface MlRoute { enableDatePicker?: boolean; 'data-test-subj'?: string; actionMenu?: React.ReactNode; + disabled?: boolean; } export interface PageProps { diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx b/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx new file mode 100644 index 0000000000000..ca670df258a6a --- /dev/null +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { parse } from 'query-string'; + +import { i18n } from '@kbn/i18n'; + +import { AIOPS_ENABLED } from '@kbn/aiops-plugin/common'; + +import { NavigateToPath } from '../../../contexts/kibana'; + +import { MlRoute, PageLoader, PageProps } from '../../router'; +import { useResolver } from '../../use_resolver'; +import { ExplainLogRateSpikesPage as Page } from '../../../aiops/explain_log_rate_spikes'; + +import { checkBasicLicense } from '../../../license'; +import { checkGetJobsCapabilitiesResolver } from '../../../capabilities/check_capabilities'; +import { cacheDataViewsContract } from '../../../util/index_utils'; +import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; + +export const explainLogRateSpikesRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ + id: 'explain_log_rate_spikes', + path: '/aiops/explain_log_rate_spikes', + title: i18n.translate('xpack.ml.aiops.explainLogRateSpikes.docTitle', { + defaultMessage: 'Explain log rate spikes', + }), + render: (props, deps) => , + breadcrumbs: [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB', navigateToPath, basePath), + { + text: i18n.translate('xpack.ml.AiopsBreadcrumbs.explainLogRateSpikesLabel', { + defaultMessage: 'Explain log rate spikes', + }), + }, + ], + disabled: !AIOPS_ENABLED, +}); + +const PageWrapper: FC = ({ location, deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + + const { index, savedSearchId }: Record = parse(location.search, { sort: false }); + const { context } = useResolver(index, savedSearchId, deps.config, deps.dataViewsContract, { + checkBasicLicense, + cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + }); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts b/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts new file mode 100644 index 0000000000000..f2b192a4cd097 --- /dev/null +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './explain_log_rate_spikes'; diff --git a/x-pack/plugins/ml/public/application/routing/routes/index.ts b/x-pack/plugins/ml/public/application/routing/routes/index.ts index 31a8d863e3086..12ddc39e0e23e 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/index.ts +++ b/x-pack/plugins/ml/public/application/routing/routes/index.ts @@ -11,6 +11,7 @@ export * from './new_job'; export * from './datavisualizer'; export * from './settings'; export * from './data_frame_analytics'; +export * from './aiops'; export { timeSeriesExplorerRouteFactory } from './timeseriesexplorer'; export * from './explorer'; export * from './access_denied'; diff --git a/x-pack/plugins/ml/public/application/util/dependency_cache.ts b/x-pack/plugins/ml/public/application/util/dependency_cache.ts index 3680f8b63b0c9..00895cdb3990e 100644 --- a/x-pack/plugins/ml/public/application/util/dependency_cache.ts +++ b/x-pack/plugins/ml/public/application/util/dependency_cache.ts @@ -27,6 +27,7 @@ import type { DataViewsContract } from '@kbn/data-views-plugin/public'; import type { SecurityPluginSetup } from '@kbn/security-plugin/public'; import type { MapsStartApi } from '@kbn/maps-plugin/public'; import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public'; +import type { AiopsPluginStart } from '@kbn/aiops-plugin/public'; export interface DependencyCache { timefilter: DataPublicPluginSetup['query']['timefilter'] | null; @@ -48,6 +49,7 @@ export interface DependencyCache { dashboard: DashboardStart | null; maps: MapsStartApi | null; dataVisualizer: DataVisualizerPluginStart | null; + aiops: AiopsPluginStart | null; dataViews: DataViewsContract | null; } @@ -71,6 +73,7 @@ const cache: DependencyCache = { dashboard: null, maps: null, dataVisualizer: null, + aiops: null, dataViews: null, }; @@ -93,6 +96,7 @@ export function setDependencyCache(deps: Partial) { cache.i18n = deps.i18n || null; cache.dashboard = deps.dashboard || null; cache.dataVisualizer = deps.dataVisualizer || null; + cache.aiops = deps.aiops || null; cache.dataViews = deps.dataViews || null; } diff --git a/x-pack/plugins/ml/public/locator/ml_locator.ts b/x-pack/plugins/ml/public/locator/ml_locator.ts index b1ea2549c3347..01d63aa0ebf3f 100644 --- a/x-pack/plugins/ml/public/locator/ml_locator.ts +++ b/x-pack/plugins/ml/public/locator/ml_locator.ts @@ -83,6 +83,8 @@ export class MlLocatorDefinition implements LocatorDefinition { case ML_PAGES.DATA_VISUALIZER_FILE: case ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER: case ML_PAGES.DATA_VISUALIZER_INDEX_SELECT: + case ML_PAGES.AIOPS: + case ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES: case ML_PAGES.OVERVIEW: case ML_PAGES.SETTINGS: case ML_PAGES.FILTER_LISTS_MANAGE: diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 1ef7c73d2189a..79f386d521da1 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -37,6 +37,7 @@ import { TriggersAndActionsUIPublicPluginStart, } from '@kbn/triggers-actions-ui-plugin/public'; import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public'; +import type { AiopsPluginStart } from '@kbn/aiops-plugin/public'; import type { PluginSetupContract as AlertingSetup } from '@kbn/alerting-plugin/public'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import type { FieldFormatsSetup, FieldFormatsStart } from '@kbn/field-formats-plugin/public'; @@ -59,6 +60,7 @@ export interface MlStartDependencies { maps?: MapsStartApi; triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; dataVisualizer: DataVisualizerPluginStart; + aiops: AiopsPluginStart; fieldFormats: FieldFormatsStart; dashboard: DashboardStart; charts: ChartsPluginStart; @@ -125,6 +127,7 @@ export class MlPlugin implements Plugin { kibanaVersion, triggersActionsUi: pluginsStart.triggersActionsUi, dataVisualizer: pluginsStart.dataVisualizer, + aiops: pluginsStart.aiops, usageCollection: pluginsSetup.usageCollection, fieldFormats: pluginsStart.fieldFormats, }, diff --git a/x-pack/plugins/ml/tsconfig.json b/x-pack/plugins/ml/tsconfig.json index a937586369ef4..bd89d383adcef 100644 --- a/x-pack/plugins/ml/tsconfig.json +++ b/x-pack/plugins/ml/tsconfig.json @@ -24,6 +24,7 @@ { "path": "../cloud/tsconfig.json" }, { "path": "../features/tsconfig.json" }, { "path": "../data_visualizer/tsconfig.json"}, + { "path": "../aiops/tsconfig.json"}, { "path": "../license_management/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, { "path": "../maps/tsconfig.json" }, diff --git a/x-pack/test/api_integration/apis/aiops/example_stream.ts b/x-pack/test/api_integration/apis/aiops/example_stream.ts new file mode 100644 index 0000000000000..693a6de2c6716 --- /dev/null +++ b/x-pack/test/api_integration/apis/aiops/example_stream.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import fetch from 'node-fetch'; +import { format as formatUrl } from 'url'; + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const config = getService('config'); + const kibanaServerUrl = formatUrl(config.get('servers.kibana')); + + describe('POST /internal/aiops/example_stream', () => { + it('should return full data without streaming', async () => { + const resp = await supertest + .post(`/internal/aiops/example_stream`) + .set('kbn-xsrf', 'kibana') + .send({ + timeout: 1, + }) + .expect(200); + + expect(Buffer.isBuffer(resp.body)).to.be(true); + + const chunks: string[] = resp.body.toString().split('\n'); + + expect(chunks.length).to.be(201); + + const lastChunk = chunks.pop(); + expect(lastChunk).to.be(''); + + let data: any[] = []; + + expect(() => { + data = chunks.map((c) => JSON.parse(c)); + }).not.to.throwError(); + + data.forEach((d) => { + expect(typeof d.type).to.be('string'); + }); + + const progressData = data.filter((d) => d.type === 'update_progress'); + expect(progressData.length).to.be(100); + expect(progressData[0].payload).to.be(1); + expect(progressData[progressData.length - 1].payload).to.be(100); + }); + + it('should return data in chunks with streaming', async () => { + const response = await fetch(`${kibanaServerUrl}/internal/aiops/example_stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'kbn-xsrf': 'stream', + }, + body: JSON.stringify({ timeout: 1 }), + }); + + const stream = response.body; + + expect(stream).not.to.be(null); + + if (stream !== null) { + let partial = ''; + let threw = false; + const progressData: any[] = []; + + try { + for await (const value of stream) { + const full = `${partial}${value}`; + const parts = full.split('\n'); + const last = parts.pop(); + + partial = last ?? ''; + + const actions = parts.map((p) => JSON.parse(p)); + + actions.forEach((action) => { + expect(typeof action.type).to.be('string'); + + if (action.type === 'update_progress') { + progressData.push(action); + } + }); + } + } catch (e) { + threw = true; + } + + expect(threw).to.be(false); + + expect(progressData.length).to.be(100); + expect(progressData[0].payload).to.be(1); + expect(progressData[progressData.length - 1].payload).to.be(100); + } + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/aiops/index.ts b/x-pack/test/api_integration/apis/aiops/index.ts new file mode 100644 index 0000000000000..04b4181906dbf --- /dev/null +++ b/x-pack/test/api_integration/apis/aiops/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('AIOps', function () { + this.tags(['ml']); + + loadTestFile(require.resolve('./example_stream')); + }); +} diff --git a/x-pack/test/api_integration/apis/index.ts b/x-pack/test/api_integration/apis/index.ts index 645cc81560682..b3566ff30aea2 100644 --- a/x-pack/test/api_integration/apis/index.ts +++ b/x-pack/test/api_integration/apis/index.ts @@ -31,6 +31,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./searchprofiler')); loadTestFile(require.resolve('./painless_lab')); loadTestFile(require.resolve('./file_upload')); + loadTestFile(require.resolve('./aiops')); loadTestFile(require.resolve('./ml')); loadTestFile(require.resolve('./watcher')); loadTestFile(require.resolve('./logs_ui'));