[ML] Explain log rate spikes: Plugin setup (#131317)

Sets up the boilerplate code for the aiops plugin and adds a demo page within the ML app to demonstrate single API request data streaming from Kibana server to UI client.
This commit is contained in:
Walter Rafelsberger 2022-05-12 13:36:53 +02:00 committed by GitHub
parent d6805f9a8a
commit 6df1b28a82
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 1286 additions and 5 deletions

5
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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:

View file

@ -1,6 +1,7 @@
pageLoadAssetSize:
advancedSettings: 27596
actions: 20000
aiops: 10000
alerting: 106936
apm: 64385
canvas: 1066647

View file

@ -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"],

View file

@ -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"],

9
x-pack/plugins/aiops/README.md Executable file
View file

@ -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.

View file

@ -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<typeof aiopsExampleStreamSchema>;
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;

View file

@ -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 };
}

View file

@ -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;

View file

@ -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: ['<rootDir>/x-pack/plugins/aiops'],
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/x-pack/plugins/aiops',
coverageReporters: ['text', 'html'],
collectCoverageFrom: ['<rootDir>/x-pack/plugins/aiops/{common,public,server}/**/*.{js,ts,tsx}'],
};

View file

@ -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"]
}

View file

@ -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;
}

View file

@ -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 (
<EuiPage restrictWidth="1000px">
<EuiPageBody>
<EuiPageContent>
<EuiPageContentHeader>
<EuiTitle>
<h2>
<FormattedMessage
id="xpack.aiops.congratulationsTitle"
defaultMessage="Single endpoint streaming demo"
/>
</h2>
</EuiTitle>
</EuiPageContentHeader>
<EuiPageContentBody>
<EuiText>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiButton
type="primary"
size="s"
onClick={onClickHandler}
aria-label={buttonLabel}
>
{buttonLabel}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<EuiBadge>{progress}%</EuiBadge>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiProgress value={progress} max={100} size="xs" />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<div style={{ height: '300px' }}>
<Chart>
<Settings rotation={90} />
<Axis
id="entities"
position={Position.Bottom}
title={i18n.translate('xpack.aiops.barChart.commitsTitle', {
defaultMessage: 'Commits',
})}
showOverlappingTicks
/>
<Axis
id="left2"
title={i18n.translate('xpack.aiops.barChart.developersTitle', {
defaultMessage: 'Developers',
})}
position={Position.Left}
/>
<BarSeries
id="commits"
xScaleType={ScaleType.Linear}
yScaleType={ScaleType.Linear}
xAccessor="x"
yAccessors={['y']}
data={Object.entries(entities)
.map(([x, y]) => {
return {
x,
y,
};
})
.sort((a, b) => b.y - a.y)}
/>
</Chart>
</div>
<p>{getStatusMessage(isRunning, isCancelled, data.progress)}</p>
<EuiCheckbox
id="aiopSimulateErrorsCheckbox"
label={i18n.translate(
'xpack.aiops.explainLogRateSpikes.simulateErrorsCheckboxLabel',
{
defaultMessage:
'Simulate errors (gets applied to new streams only, not currently running ones).',
}
)}
checked={simulateErrors}
onChange={(e) => setSimulateErrors(!simulateErrors)}
compressed
/>
</EuiText>
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
);
};

View file

@ -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 (
<KibanaThemeProvider theme$={coreStart.theme.theme$}>
<KibanaContextProvider services={coreStart}>
<I18nProvider>
<AiopsApp />
</I18nProvider>
</KibanaContextProvider>
</KibanaThemeProvider>
);
};

View file

@ -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?!';
}

View file

@ -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<A = unknown, E = ApiEndpoint>(
endpoint: E,
abortCtrl: React.MutableRefObject<AbortController>,
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;
}
}
}

View file

@ -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<string, number>;
}
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'],
};
}
}

View file

@ -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 = <R extends Reducer<any, any>, E = ApiEndpoint>(
endpoint: E,
reducer: R,
initialState: ReducerState<R>,
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<R>);
}
setIsRunning(false);
};
const cancel = () => {
abortCtrl.current.abort();
setIsCancelled(true);
setIsRunning(false);
};
return {
cancel,
data,
dispatch,
isCancelled,
isRunning,
start,
};
};

View file

@ -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';

View file

@ -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;

View file

@ -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<LazyLoadedModules>;
interface LazyLoadedModules {
ExplainLogRateSpikes: ExplainLogRateSpikesSpec;
}
export async function lazyLoadModules(): Promise<LazyLoadedModules> {
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;
}

View file

@ -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';

View file

@ -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<AiopsPluginSetup, AiopsPluginStart> {
public setup(core: CoreSetup) {}
public start(core: CoreStart) {
setStartServices(core, {});
return {
getExplainLogRateSpikesComponent,
};
}
public stop() {}
}

View file

@ -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<AiopsPlugin['setup']>;
/**
* aiops plugin public start contract
*/
export type AiopsPluginStart = ReturnType<AiopsPlugin['start']>;
// eslint-disable-next-line
export type AppPluginStartDependencies = {};

View file

@ -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';

View file

@ -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<AiopsPluginSetup, AiopsPluginStart> {
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() {}
}

View file

@ -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,
});
}
);
}
}

View file

@ -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 {}

View file

@ -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" },
]
}

View file

@ -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];

View file

@ -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
>;

View file

@ -7,6 +7,7 @@
"ml"
],
"requiredPlugins": [
"aiops",
"cloud",
"data",
"dataViews",

View file

@ -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<ExplainLogRateSpikesSpec | null>(
null
);
useEffect(() => {
if (aiops !== undefined) {
const { getExplainLogRateSpikesComponent } = aiops;
getExplainLogRateSpikesComponent().then(setExplainLogRateSpikes);
}
}, []);
return (
<>
{ExplainLogRateSpikes !== null ? (
<>
<MlPageHeader>
<FormattedMessage
id="xpack.ml.explainLogRateSpikes.pageHeader"
defaultMessage="Explain log rate spikes"
/>
</MlPageHeader>
<ExplainLogRateSpikes />
</>
) : null}
<HelpMenu docLink={docLinks.links.ml.guide} />
</>
);
};

View file

@ -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';

View file

@ -82,6 +82,7 @@ const App: FC<AppProps> = ({ 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,
});

View file

@ -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),
[]
);

View file

@ -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<unknown> = useCallback(

View file

@ -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;

View file

@ -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,

View file

@ -48,6 +48,7 @@ export interface MlRoute {
enableDatePicker?: boolean;
'data-test-subj'?: string;
actionMenu?: React.ReactNode;
disabled?: boolean;
}
export interface PageProps {

View file

@ -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) => <PageWrapper {...props} deps={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<PageProps> = ({ location, deps }) => {
const { redirectToMlAccessDeniedPage } = deps;
const { index, savedSearchId }: Record<string, any> = parse(location.search, { sort: false });
const { context } = useResolver(index, savedSearchId, deps.config, deps.dataViewsContract, {
checkBasicLicense,
cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract),
checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage),
});
return (
<PageLoader context={context}>
<Page />
</PageLoader>
);
};

View file

@ -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';

View file

@ -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';

View file

@ -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<DependencyCache>) {
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;
}

View file

@ -83,6 +83,8 @@ export class MlLocatorDefinition implements LocatorDefinition<MlLocatorParams> {
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:

View file

@ -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<MlPluginSetup, MlPluginStart> {
kibanaVersion,
triggersActionsUi: pluginsStart.triggersActionsUi,
dataVisualizer: pluginsStart.dataVisualizer,
aiops: pluginsStart.aiops,
usageCollection: pluginsSetup.usageCollection,
fieldFormats: pluginsStart.fieldFormats,
},

View file

@ -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" },

View file

@ -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);
}
});
});
};

View file

@ -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'));
});
}

View file

@ -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'));