Merge remote-tracking branch 'upstream/master'
|
@ -355,6 +355,7 @@ allowing you to easily see how these events are impacting the performance of you
|
|||
|
||||
By default, annotations are stored in a newly created `observability-annotations` index.
|
||||
The name of this index can be changed in your `config.yml` by editing `xpack.observability.annotations.index`.
|
||||
If you change the default index name, you'll also need to <<apm-app-annotation-user-create,update your user privileges>> accordingly.
|
||||
|
||||
The following APIs are available:
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
:beat_default_index_prefix: apm
|
||||
:beat_kib_app: APM app
|
||||
:annotation_index: `observability-annotations`
|
||||
:annotation_index: observability-annotations
|
||||
|
||||
++++
|
||||
<titleabbrev>Users and privileges</titleabbrev>
|
||||
|
@ -102,6 +102,54 @@ Here are two examples:
|
|||
*********************************** ***********************************
|
||||
////
|
||||
|
||||
[role="xpack"]
|
||||
[[apm-app-annotation-user-create]]
|
||||
=== APM app annotation user
|
||||
|
||||
++++
|
||||
<titleabbrev>Create an annotation user</titleabbrev>
|
||||
++++
|
||||
|
||||
NOTE: By default, the `apm_user` built-in role provides access to Observability annotations.
|
||||
You only need to create an annotation user if the default annotation index
|
||||
defined in <<apm-settings-kb,`xpack.observability.annotations.index`>> has been customized.
|
||||
|
||||
[[apm-app-annotation-user]]
|
||||
==== Annotation user
|
||||
|
||||
View deployment annotations in the APM app.
|
||||
|
||||
. Create a new role, named something like `annotation_user`,
|
||||
and assign the following privileges:
|
||||
+
|
||||
[options="header"]
|
||||
|====
|
||||
|Type | Privilege | Purpose
|
||||
|
||||
|Index
|
||||
|`read` on +\{ANNOTATION_INDEX\}+^1^
|
||||
|Read-only access to the observability annotation index
|
||||
|
||||
|Index
|
||||
|`view_index_metadata` on +\{ANNOTATION_INDEX\}+^1^
|
||||
|Read-only access to observability annotation index metadata
|
||||
|====
|
||||
+
|
||||
^1^ +\{ANNOTATION_INDEX\}+ should be the index name you've defined in
|
||||
<<apm-settings-kb,`xpack.observability.annotations.index`>>.
|
||||
|
||||
. Assign the `annotation_user` created previously, and the built-in roles necessary to create
|
||||
a <<apm-app-reader-full,full>> or <<apm-app-reader-partial,partial>> APM reader to any users that need to view annotations in the APM app
|
||||
|
||||
[[apm-app-annotation-api]]
|
||||
==== Annotation API
|
||||
|
||||
See <<apm-app-api-user>>.
|
||||
|
||||
////
|
||||
*********************************** ***********************************
|
||||
////
|
||||
|
||||
[role="xpack"]
|
||||
[[apm-app-central-config-user]]
|
||||
=== APM app central config user
|
||||
|
|
|
@ -455,9 +455,10 @@
|
|||
"is-path-inside": "^2.1.0",
|
||||
"istanbul-instrumenter-loader": "3.0.1",
|
||||
"jest": "^25.5.4",
|
||||
"jest-environment-jsdom-thirteen": "^1.0.1",
|
||||
"jest-canvas-mock": "^2.2.0",
|
||||
"jest-circus": "^25.5.4",
|
||||
"jest-cli": "^25.5.4",
|
||||
"jest-environment-jsdom-thirteen": "^1.0.1",
|
||||
"jest-raw-loader": "^1.0.1",
|
||||
"jimp": "^0.9.6",
|
||||
"json5": "^1.0.1",
|
||||
|
|
|
@ -24,7 +24,7 @@ import { inspect } from 'util';
|
|||
|
||||
import cpy from 'cpy';
|
||||
import del from 'del';
|
||||
import { toArray, tap } from 'rxjs/operators';
|
||||
import { toArray, tap, filter } from 'rxjs/operators';
|
||||
import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils';
|
||||
import { runOptimizer, OptimizerConfig, OptimizerUpdate, logOptimizerState } from '@kbn/optimizer';
|
||||
|
||||
|
@ -63,8 +63,7 @@ afterAll(async () => {
|
|||
await del(TMP_DIR);
|
||||
});
|
||||
|
||||
// FLAKY: https://github.com/elastic/kibana/issues/70762
|
||||
it.skip('builds expected bundles, saves bundle counts to metadata', async () => {
|
||||
it('builds expected bundles, saves bundle counts to metadata', async () => {
|
||||
const config = OptimizerConfig.create({
|
||||
repoRoot: MOCK_REPO_DIR,
|
||||
pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins')],
|
||||
|
@ -75,7 +74,11 @@ it.skip('builds expected bundles, saves bundle counts to metadata', async () =>
|
|||
expect(config).toMatchSnapshot('OptimizerConfig');
|
||||
|
||||
const msgs = await runOptimizer(config)
|
||||
.pipe(logOptimizerState(log, config), toArray())
|
||||
.pipe(
|
||||
logOptimizerState(log, config),
|
||||
filter((x) => x.event?.type !== 'worker stdio'),
|
||||
toArray()
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
const assert = (statement: string, truth: boolean, altStates?: OptimizerUpdate[]) => {
|
||||
|
@ -168,8 +171,7 @@ it.skip('builds expected bundles, saves bundle counts to metadata', async () =>
|
|||
`);
|
||||
});
|
||||
|
||||
// FLAKY: https://github.com/elastic/kibana/issues/70764
|
||||
it.skip('uses cache on second run and exist cleanly', async () => {
|
||||
it('uses cache on second run and exist cleanly', async () => {
|
||||
const config = OptimizerConfig.create({
|
||||
repoRoot: MOCK_REPO_DIR,
|
||||
pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins')],
|
||||
|
|
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 8.8 KiB |
Before Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 10 KiB |
|
@ -1,202 +0,0 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import ngMock from 'ng_mock';
|
||||
import { ImageComparator } from 'test_utils/image_comparator';
|
||||
import basicdrawPng from './basicdraw.png';
|
||||
import afterresizePng from './afterresize.png';
|
||||
import afterparamChange from './afterparamchange.png';
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { ExprVis } from '../../../../../../plugins/visualizations/public/expressions/vis';
|
||||
|
||||
// Replace with mock when converting to jest tests
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { seedColors } from '../../../../../../plugins/charts/public/services/colors/seed_colors';
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { BaseVisType } from '../../../../../../plugins/visualizations/public/vis_types/base_vis_type';
|
||||
// Will be replaced with new path when tests are moved
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { createTagCloudVisTypeDefinition } from '../../../../../../plugins/vis_type_tagcloud/public/tag_cloud_type';
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { createTagCloudVisualization } from '../../../../../../plugins/vis_type_tagcloud/public/components/tag_cloud_visualization';
|
||||
import { npStart } from 'ui/new_platform';
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { setFormatService } from '../../../../../../plugins/vis_type_tagcloud/public/services';
|
||||
|
||||
const THRESHOLD = 0.65;
|
||||
const PIXEL_DIFF = 64;
|
||||
describe('TagCloudVisualizationTest', function () {
|
||||
let domNode;
|
||||
let vis;
|
||||
let imageComparator;
|
||||
|
||||
const dummyTableGroup = {
|
||||
columns: [
|
||||
{
|
||||
id: 'col-0',
|
||||
title: 'geo.dest: Descending',
|
||||
},
|
||||
{
|
||||
id: 'col-1',
|
||||
title: 'Count',
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{ 'col-0': 'CN', 'col-1': 26 },
|
||||
{ 'col-0': 'IN', 'col-1': 17 },
|
||||
{ 'col-0': 'US', 'col-1': 6 },
|
||||
{ 'col-0': 'DE', 'col-1': 4 },
|
||||
{ 'col-0': 'BR', 'col-1': 3 },
|
||||
],
|
||||
};
|
||||
const TagCloudVisualization = createTagCloudVisualization({
|
||||
colors: {
|
||||
seedColors,
|
||||
},
|
||||
});
|
||||
|
||||
before(() => setFormatService(npStart.plugins.data.fieldFormats));
|
||||
|
||||
beforeEach(ngMock.module('kibana'));
|
||||
|
||||
describe('TagCloudVisualization - basics', function () {
|
||||
beforeEach(async function () {
|
||||
const visType = new BaseVisType(createTagCloudVisTypeDefinition({ colors: seedColors }));
|
||||
setupDOM('512px', '512px');
|
||||
imageComparator = new ImageComparator();
|
||||
vis = new ExprVis({
|
||||
type: visType,
|
||||
params: {
|
||||
bucket: { accessor: 0, format: {} },
|
||||
metric: { accessor: 0, format: {} },
|
||||
},
|
||||
data: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
teardownDOM();
|
||||
imageComparator.destroy();
|
||||
});
|
||||
|
||||
it('simple draw', async function () {
|
||||
const tagcloudVisualization = new TagCloudVisualization(domNode, vis);
|
||||
|
||||
await tagcloudVisualization.render(dummyTableGroup, vis.params, {
|
||||
resize: false,
|
||||
params: true,
|
||||
aggs: true,
|
||||
data: true,
|
||||
uiState: false,
|
||||
});
|
||||
|
||||
const svgNode = domNode.querySelector('svg');
|
||||
const mismatchedPixels = await imageComparator.compareDOMContents(
|
||||
svgNode.outerHTML,
|
||||
512,
|
||||
512,
|
||||
basicdrawPng,
|
||||
THRESHOLD
|
||||
);
|
||||
expect(mismatchedPixels).to.be.lessThan(PIXEL_DIFF);
|
||||
});
|
||||
|
||||
it('with resize', async function () {
|
||||
const tagcloudVisualization = new TagCloudVisualization(domNode, vis);
|
||||
await tagcloudVisualization.render(dummyTableGroup, vis.params, {
|
||||
resize: false,
|
||||
params: true,
|
||||
aggs: true,
|
||||
data: true,
|
||||
uiState: false,
|
||||
});
|
||||
|
||||
domNode.style.width = '256px';
|
||||
domNode.style.height = '368px';
|
||||
await tagcloudVisualization.render(dummyTableGroup, vis.params, {
|
||||
resize: true,
|
||||
params: false,
|
||||
aggs: false,
|
||||
data: false,
|
||||
uiState: false,
|
||||
});
|
||||
|
||||
const svgNode = domNode.querySelector('svg');
|
||||
const mismatchedPixels = await imageComparator.compareDOMContents(
|
||||
svgNode.outerHTML,
|
||||
256,
|
||||
368,
|
||||
afterresizePng,
|
||||
THRESHOLD
|
||||
);
|
||||
expect(mismatchedPixels).to.be.lessThan(PIXEL_DIFF);
|
||||
});
|
||||
|
||||
it('with param change', async function () {
|
||||
const tagcloudVisualization = new TagCloudVisualization(domNode, vis);
|
||||
await tagcloudVisualization.render(dummyTableGroup, vis.params, {
|
||||
resize: false,
|
||||
params: true,
|
||||
aggs: true,
|
||||
data: true,
|
||||
uiState: false,
|
||||
});
|
||||
|
||||
domNode.style.width = '256px';
|
||||
domNode.style.height = '368px';
|
||||
vis.params.orientation = 'right angled';
|
||||
vis.params.minFontSize = 70;
|
||||
await tagcloudVisualization.render(dummyTableGroup, vis.params, {
|
||||
resize: true,
|
||||
params: true,
|
||||
aggs: false,
|
||||
data: false,
|
||||
uiState: false,
|
||||
});
|
||||
|
||||
const svgNode = domNode.querySelector('svg');
|
||||
const mismatchedPixels = await imageComparator.compareDOMContents(
|
||||
svgNode.outerHTML,
|
||||
256,
|
||||
368,
|
||||
afterparamChange,
|
||||
THRESHOLD
|
||||
);
|
||||
expect(mismatchedPixels).to.be.lessThan(PIXEL_DIFF);
|
||||
});
|
||||
});
|
||||
|
||||
function setupDOM(width, height) {
|
||||
domNode = document.createElement('div');
|
||||
domNode.style.top = '0';
|
||||
domNode.style.left = '0';
|
||||
domNode.style.width = width;
|
||||
domNode.style.height = height;
|
||||
domNode.style.position = 'fixed';
|
||||
domNode.style.border = '1px solid blue';
|
||||
domNode.style['pointer-events'] = 'none';
|
||||
document.body.appendChild(domNode);
|
||||
}
|
||||
|
||||
function teardownDOM() {
|
||||
domNode.innerHTML = '';
|
||||
document.body.removeChild(domNode);
|
||||
}
|
||||
});
|
|
@ -18,7 +18,7 @@
|
|||
*/
|
||||
|
||||
import { keys, last, mapValues, reduce, zipObject } from 'lodash';
|
||||
import { Executor } from '../executor';
|
||||
import { Executor, ExpressionExecOptions } from '../executor';
|
||||
import { createExecutionContainer, ExecutionContainer } from './container';
|
||||
import { createError } from '../util';
|
||||
import { Defer, now } from '../../../kibana_utils/common';
|
||||
|
@ -31,6 +31,7 @@ import {
|
|||
parse,
|
||||
formatExpression,
|
||||
parseExpression,
|
||||
ExpressionAstNode,
|
||||
} from '../ast';
|
||||
import { ExecutionContext, DefaultInspectorAdapters } from './types';
|
||||
import { getType, ExpressionValue } from '../expression_types';
|
||||
|
@ -382,7 +383,7 @@ export class Execution<
|
|||
const resolveArgFns = mapValues(argAstsWithDefaults, (asts, argName) => {
|
||||
return asts.map((item: ExpressionAstExpression) => {
|
||||
return async (subInput = input) => {
|
||||
const output = await this.params.executor.interpret(item, subInput, {
|
||||
const output = await this.interpret(item, subInput, {
|
||||
debug: this.params.debug,
|
||||
});
|
||||
if (isExpressionValueError(output)) throw output.error;
|
||||
|
@ -415,4 +416,28 @@ export class Execution<
|
|||
// function which would be treated as a promise
|
||||
return { resolvedArgs };
|
||||
}
|
||||
|
||||
public async interpret<T>(
|
||||
ast: ExpressionAstNode,
|
||||
input: T,
|
||||
options?: ExpressionExecOptions
|
||||
): Promise<unknown> {
|
||||
switch (getType(ast)) {
|
||||
case 'expression':
|
||||
const execution = this.params.executor.createExecution(
|
||||
ast as ExpressionAstExpression,
|
||||
this.context,
|
||||
options
|
||||
);
|
||||
execution.start(input);
|
||||
return await execution.result;
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'null':
|
||||
case 'boolean':
|
||||
return ast;
|
||||
default:
|
||||
throw new Error(`Unknown AST object: ${JSON.stringify(ast)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,8 +26,7 @@ import { Execution, ExecutionParams } from '../execution/execution';
|
|||
import { IRegistry } from '../types';
|
||||
import { ExpressionType } from '../expression_types/expression_type';
|
||||
import { AnyExpressionTypeDefinition } from '../expression_types/types';
|
||||
import { getType } from '../expression_types';
|
||||
import { ExpressionAstExpression, ExpressionAstNode } from '../ast';
|
||||
import { ExpressionAstExpression } from '../ast';
|
||||
import { typeSpecs } from '../expression_types/specs';
|
||||
import { functionSpecs } from '../expression_functions/specs';
|
||||
|
||||
|
@ -154,34 +153,6 @@ export class Executor<Context extends Record<string, unknown> = Record<string, u
|
|||
return this.state.selectors.getContext();
|
||||
}
|
||||
|
||||
public async interpret<T>(
|
||||
ast: ExpressionAstNode,
|
||||
input: T,
|
||||
options?: ExpressionExecOptions
|
||||
): Promise<unknown> {
|
||||
switch (getType(ast)) {
|
||||
case 'expression':
|
||||
return await this.interpretExpression(ast as ExpressionAstExpression, input, options);
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'null':
|
||||
case 'boolean':
|
||||
return ast;
|
||||
default:
|
||||
throw new Error(`Unknown AST object: ${JSON.stringify(ast)}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async interpretExpression<T>(
|
||||
ast: string | ExpressionAstExpression,
|
||||
input: T,
|
||||
options?: ExpressionExecOptions
|
||||
): Promise<unknown> {
|
||||
const execution = this.createExecution(ast, undefined, options);
|
||||
execution.start(input);
|
||||
return await execution.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute expression and return result.
|
||||
*
|
||||
|
|
3
src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud.test.js.snap
generated
Normal file
|
@ -0,0 +1,3 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`tag cloud tests tagcloudscreenshot should render simple image 1`] = `"<svg width=\\"512\\" height=\\"512\\"><g width=\\"512\\" height=\\"512\\"><text style=\\"font-size: 10px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #00a69b;\\" text-anchor=\\"middle\\" transform=\\"translate(1166, 1310)rotate(0)\\" data-test-subj=\\"foo\\">foo</text><text style=\\"font-size: 23px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #57c17b;\\" text-anchor=\\"middle\\" transform=\\"translate(1166, 1310)rotate(0)\\" data-test-subj=\\"bar\\">bar</text><text style=\\"font-size: 36px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #6f87d8;\\" text-anchor=\\"middle\\" transform=\\"translate(256, 256)rotate(0)\\" data-test-subj=\\"foobar\\">foobar</text></g></svg>"`;
|
|
@ -0,0 +1,7 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`TagCloudVisualizationTest TagCloudVisualization - basics simple draw 1`] = `"<svg width=\\"512\\" height=\\"512\\"><g width=\\"512\\" height=\\"512\\"><text style=\\"font-size: 0px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #00a69b;\\" text-anchor=\\"middle\\" transform=\\"translate(256, 256)rotate(0)\\" data-test-subj=\\"CN\\">CN</text><text style=\\"font-size: 0px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #57c17b;\\" text-anchor=\\"middle\\" transform=\\"translate(1166, 1310)rotate(0)\\" data-test-subj=\\"IN\\">IN</text><text style=\\"font-size: 0px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #6f87d8;\\" text-anchor=\\"middle\\" transform=\\"translate(1166, 1310)rotate(0)\\" data-test-subj=\\"US\\">US</text><text style=\\"font-size: 0px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #663db8;\\" text-anchor=\\"middle\\" transform=\\"translate(1166, 1310)rotate(0)\\" data-test-subj=\\"DE\\">DE</text><text style=\\"font-size: 0px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #bc52bc;\\" text-anchor=\\"middle\\" transform=\\"translate(1166, 1310)rotate(0)\\" data-test-subj=\\"BR\\">BR</text></g></svg>"`;
|
||||
|
||||
exports[`TagCloudVisualizationTest TagCloudVisualization - basics with param change 1`] = `"<svg width=\\"256\\" height=\\"386\\"><g width=\\"256\\" height=\\"386\\"><text style=\\"font-size: 0px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #00a69b;\\" text-anchor=\\"middle\\" transform=\\"\\" data-test-subj=\\"CN\\">CN</text><text style=\\"font-size: 0px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #57c17b;\\" text-anchor=\\"middle\\" transform=\\"\\" data-test-subj=\\"IN\\">IN</text><text style=\\"font-size: 0px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #6f87d8;\\" text-anchor=\\"middle\\" transform=\\"\\" data-test-subj=\\"US\\">US</text><text style=\\"font-size: 0px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #663db8;\\" text-anchor=\\"middle\\" transform=\\"\\" data-test-subj=\\"DE\\">DE</text><text style=\\"font-size: 0px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #bc52bc;\\" text-anchor=\\"middle\\" transform=\\"\\" data-test-subj=\\"BR\\">BR</text></g></svg>"`;
|
||||
|
||||
exports[`TagCloudVisualizationTest TagCloudVisualization - basics with resize 1`] = `"<svg width=\\"512\\" height=\\"512\\"><g width=\\"512\\" height=\\"512\\"><text style=\\"font-size: 0px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #00a69b;\\" text-anchor=\\"middle\\" transform=\\"translate(256, 256)rotate(0)\\" data-test-subj=\\"CN\\">CN</text><text style=\\"font-size: 0px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #57c17b;\\" text-anchor=\\"middle\\" transform=\\"translate(1166, 1310)rotate(0)\\" data-test-subj=\\"IN\\">IN</text><text style=\\"font-size: 0px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #6f87d8;\\" text-anchor=\\"middle\\" transform=\\"translate(1166, 1310)rotate(0)\\" data-test-subj=\\"US\\">US</text><text style=\\"font-size: 0px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #663db8;\\" text-anchor=\\"middle\\" transform=\\"translate(1166, 1310)rotate(0)\\" data-test-subj=\\"DE\\">DE</text><text style=\\"font-size: 0px; font-style: normal; font-weight: normal; font-family: Open Sans, sans-serif; fill: #bc52bc;\\" text-anchor=\\"middle\\" transform=\\"translate(1166, 1310)rotate(0)\\" data-test-subj=\\"BR\\">BR</text></g></svg>"`;
|
|
@ -17,22 +17,27 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import _ from 'lodash';
|
||||
import d3 from 'd3';
|
||||
import 'jest-canvas-mock';
|
||||
|
||||
import { fromNode, delay } from 'bluebird';
|
||||
import { ImageComparator } from 'test_utils/image_comparator';
|
||||
import simpleloadPng from './simpleload.png';
|
||||
import { TagCloud } from './tag_cloud';
|
||||
import { setHTMLElementOffset, setSVGElementGetBBox } from '../../../../test_utils/public';
|
||||
|
||||
// Replace with mock when converting to jest tests
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { seedColors } from '../../../../../../plugins/charts/public/services/colors/seed_colors';
|
||||
// Will be replaced with new path when tests are moved
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { TagCloud } from '../../../../../../plugins/vis_type_tagcloud/public/components/tag_cloud';
|
||||
describe('tag cloud tests', () => {
|
||||
let SVGElementGetBBoxSpyInstance;
|
||||
let HTMLElementOffsetMockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
setupDOM();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SVGElementGetBBoxSpyInstance.mockRestore();
|
||||
HTMLElementOffsetMockInstance.mockRestore();
|
||||
});
|
||||
|
||||
describe('tag cloud tests', function () {
|
||||
const minValue = 1;
|
||||
const maxValue = 9;
|
||||
const midValue = (minValue + maxValue) / 2;
|
||||
|
@ -100,16 +105,15 @@ describe('tag cloud tests', function () {
|
|||
let domNode;
|
||||
let tagCloud;
|
||||
|
||||
const colorScale = d3.scale.ordinal().range(seedColors);
|
||||
const colorScale = d3.scale
|
||||
.ordinal()
|
||||
.range(['#00a69b', '#57c17b', '#6f87d8', '#663db8', '#bc52bc', '#9e3533', '#daa05d']);
|
||||
|
||||
function setupDOM() {
|
||||
domNode = document.createElement('div');
|
||||
domNode.style.top = '0';
|
||||
domNode.style.left = '0';
|
||||
domNode.style.width = '512px';
|
||||
domNode.style.height = '512px';
|
||||
domNode.style.position = 'fixed';
|
||||
domNode.style['pointer-events'] = 'none';
|
||||
SVGElementGetBBoxSpyInstance = setSVGElementGetBBox();
|
||||
HTMLElementOffsetMockInstance = setHTMLElementOffset(512, 512);
|
||||
|
||||
document.body.appendChild(domNode);
|
||||
}
|
||||
|
||||
|
@ -126,42 +130,39 @@ describe('tag cloud tests', function () {
|
|||
sqrtScaleTest,
|
||||
biggerFontTest,
|
||||
trimDataTest,
|
||||
].forEach(function (test) {
|
||||
].forEach(function (currentTest) {
|
||||
describe(`should position elements correctly for options: ${JSON.stringify(
|
||||
test.options
|
||||
)}`, function () {
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
currentTest.options
|
||||
)}`, () => {
|
||||
beforeEach(async () => {
|
||||
tagCloud = new TagCloud(domNode, colorScale);
|
||||
tagCloud.setData(test.data);
|
||||
tagCloud.setOptions(test.options);
|
||||
tagCloud.setData(currentTest.data);
|
||||
tagCloud.setOptions(currentTest.options);
|
||||
await fromNode((cb) => tagCloud.once('renderComplete', cb));
|
||||
});
|
||||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it(
|
||||
test(
|
||||
'completeness should be ok',
|
||||
handleExpectedBlip(function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
|
||||
handleExpectedBlip(() => {
|
||||
expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE);
|
||||
})
|
||||
);
|
||||
|
||||
it(
|
||||
test(
|
||||
'positions should be ok',
|
||||
handleExpectedBlip(function () {
|
||||
handleExpectedBlip(() => {
|
||||
const textElements = domNode.querySelectorAll('text');
|
||||
verifyTagProperties(test.expected, textElements, tagCloud);
|
||||
verifyTagProperties(currentTest.expected, textElements, tagCloud);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
[5, 100, 200, 300, 500].forEach(function (timeout) {
|
||||
describe(`should only send single renderComplete event at the very end, using ${timeout}ms timeout`, function () {
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
|
||||
[5, 100, 200, 300, 500].forEach((timeout) => {
|
||||
describe(`should only send single renderComplete event at the very end, using ${timeout}ms timeout`, () => {
|
||||
beforeEach(async () => {
|
||||
//TagCloud takes at least 600ms to complete (due to d3 animation)
|
||||
//renderComplete should only notify at the last one
|
||||
tagCloud = new TagCloud(domNode, colorScale);
|
||||
|
@ -176,16 +177,16 @@ describe('tag cloud tests', function () {
|
|||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it(
|
||||
test(
|
||||
'completeness should be ok',
|
||||
handleExpectedBlip(function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
|
||||
handleExpectedBlip(() => {
|
||||
expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE);
|
||||
})
|
||||
);
|
||||
|
||||
it(
|
||||
test(
|
||||
'positions should be ok',
|
||||
handleExpectedBlip(function () {
|
||||
handleExpectedBlip(() => {
|
||||
const textElements = domNode.querySelectorAll('text');
|
||||
verifyTagProperties(logScaleTest.expected, textElements, tagCloud);
|
||||
})
|
||||
|
@ -193,9 +194,8 @@ describe('tag cloud tests', function () {
|
|||
});
|
||||
});
|
||||
|
||||
describe('should use the latest state before notifying (when modifying options multiple times)', function () {
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
describe('should use the latest state before notifying (when modifying options multiple times)', () => {
|
||||
beforeEach(async () => {
|
||||
tagCloud = new TagCloud(domNode, colorScale);
|
||||
tagCloud.setData(baseTest.data);
|
||||
tagCloud.setOptions(baseTest.options);
|
||||
|
@ -205,53 +205,53 @@ describe('tag cloud tests', function () {
|
|||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it(
|
||||
test(
|
||||
'completeness should be ok',
|
||||
handleExpectedBlip(function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
|
||||
handleExpectedBlip(() => {
|
||||
expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE);
|
||||
})
|
||||
);
|
||||
it(
|
||||
test(
|
||||
'positions should be ok',
|
||||
handleExpectedBlip(function () {
|
||||
handleExpectedBlip(() => {
|
||||
const textElements = domNode.querySelectorAll('text');
|
||||
verifyTagProperties(logScaleTest.expected, textElements, tagCloud);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('should use the latest state before notifying (when modifying data multiple times)', function () {
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
describe('should use the latest state before notifying (when modifying data multiple times)', () => {
|
||||
beforeEach(async () => {
|
||||
tagCloud = new TagCloud(domNode, colorScale);
|
||||
tagCloud.setData(baseTest.data);
|
||||
tagCloud.setOptions(baseTest.options);
|
||||
tagCloud.setData(trimDataTest.data);
|
||||
|
||||
await fromNode((cb) => tagCloud.once('renderComplete', cb));
|
||||
});
|
||||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it(
|
||||
test(
|
||||
'completeness should be ok',
|
||||
handleExpectedBlip(function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
|
||||
handleExpectedBlip(() => {
|
||||
expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE);
|
||||
})
|
||||
);
|
||||
it(
|
||||
test(
|
||||
'positions should be ok',
|
||||
handleExpectedBlip(function () {
|
||||
handleExpectedBlip(() => {
|
||||
const textElements = domNode.querySelectorAll('text');
|
||||
verifyTagProperties(trimDataTest.expected, textElements, tagCloud);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('should not get multiple render-events', function () {
|
||||
describe('should not get multiple render-events', () => {
|
||||
let counter;
|
||||
beforeEach(function () {
|
||||
beforeEach(() => {
|
||||
counter = 0;
|
||||
setupDOM();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
tagCloud = new TagCloud(domNode, colorScale);
|
||||
tagCloud.setData(baseTest.data);
|
||||
|
@ -281,31 +281,32 @@ describe('tag cloud tests', function () {
|
|||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it(
|
||||
test(
|
||||
'completeness should be ok',
|
||||
handleExpectedBlip(function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
|
||||
handleExpectedBlip(() => {
|
||||
expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE);
|
||||
})
|
||||
);
|
||||
it(
|
||||
test(
|
||||
'positions should be ok',
|
||||
handleExpectedBlip(function () {
|
||||
handleExpectedBlip(() => {
|
||||
const textElements = domNode.querySelectorAll('text');
|
||||
verifyTagProperties(logScaleTest.expected, textElements, tagCloud);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('should show correct data when state-updates are interleaved with resize event', function () {
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
describe('should show correct data when state-updates are interleaved with resize event', () => {
|
||||
beforeEach(async () => {
|
||||
tagCloud = new TagCloud(domNode, colorScale);
|
||||
tagCloud.setData(logScaleTest.data);
|
||||
tagCloud.setOptions(logScaleTest.options);
|
||||
|
||||
await delay(1000); //let layout run
|
||||
domNode.style.width = '600px';
|
||||
domNode.style.height = '600px';
|
||||
|
||||
SVGElementGetBBoxSpyInstance.mockRestore();
|
||||
SVGElementGetBBoxSpyInstance = setSVGElementGetBBox(600, 600);
|
||||
|
||||
tagCloud.resize(); //triggers new layout
|
||||
setTimeout(() => {
|
||||
//change the options at the very end too
|
||||
|
@ -317,26 +318,23 @@ describe('tag cloud tests', function () {
|
|||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it(
|
||||
test(
|
||||
'completeness should be ok',
|
||||
handleExpectedBlip(function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
|
||||
handleExpectedBlip(() => {
|
||||
expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE);
|
||||
})
|
||||
);
|
||||
it(
|
||||
test(
|
||||
'positions should be ok',
|
||||
handleExpectedBlip(function () {
|
||||
handleExpectedBlip(() => {
|
||||
const textElements = domNode.querySelectorAll('text');
|
||||
verifyTagProperties(baseTest.expected, textElements, tagCloud);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe(`should not put elements in view when container is too small`, function () {
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
domNode.style.width = '1px';
|
||||
domNode.style.height = '1px';
|
||||
describe(`should not put elements in view when container is too small`, () => {
|
||||
beforeEach(async () => {
|
||||
tagCloud = new TagCloud(domNode, colorScale);
|
||||
tagCloud.setData(baseTest.data);
|
||||
tagCloud.setOptions(baseTest.options);
|
||||
|
@ -345,10 +343,10 @@ describe('tag cloud tests', function () {
|
|||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it('completeness should not be ok', function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.INCOMPLETE);
|
||||
test('completeness should not be ok', () => {
|
||||
expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.INCOMPLETE);
|
||||
});
|
||||
it('positions should not be ok', function () {
|
||||
test('positions should not be ok', () => {
|
||||
const textElements = domNode.querySelectorAll('text');
|
||||
for (let i = 0; i < textElements; i++) {
|
||||
const bbox = textElements[i].getBoundingClientRect();
|
||||
|
@ -357,96 +355,73 @@ describe('tag cloud tests', function () {
|
|||
});
|
||||
});
|
||||
|
||||
describe(`tags should fit after making container bigger`, function () {
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
domNode.style.width = '1px';
|
||||
domNode.style.height = '1px';
|
||||
|
||||
describe(`tags should fit after making container bigger`, () => {
|
||||
beforeEach(async () => {
|
||||
tagCloud = new TagCloud(domNode, colorScale);
|
||||
tagCloud.setData(baseTest.data);
|
||||
tagCloud.setOptions(baseTest.options);
|
||||
await fromNode((cb) => tagCloud.once('renderComplete', cb));
|
||||
|
||||
//make bigger
|
||||
domNode.style.width = '512px';
|
||||
domNode.style.height = '512px';
|
||||
tagCloud._size = [600, 600];
|
||||
tagCloud.resize();
|
||||
await fromNode((cb) => tagCloud.once('renderComplete', cb));
|
||||
});
|
||||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it(
|
||||
test(
|
||||
'completeness should be ok',
|
||||
handleExpectedBlip(function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
|
||||
handleExpectedBlip(() => {
|
||||
expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe(`tags should no longer fit after making container smaller`, function () {
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
describe(`tags should no longer fit after making container smaller`, () => {
|
||||
beforeEach(async () => {
|
||||
tagCloud = new TagCloud(domNode, colorScale);
|
||||
tagCloud.setData(baseTest.data);
|
||||
tagCloud.setOptions(baseTest.options);
|
||||
await fromNode((cb) => tagCloud.once('renderComplete', cb));
|
||||
|
||||
//make smaller
|
||||
domNode.style.width = '1px';
|
||||
domNode.style.height = '1px';
|
||||
tagCloud._size = [];
|
||||
tagCloud.resize();
|
||||
await fromNode((cb) => tagCloud.once('renderComplete', cb));
|
||||
});
|
||||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it('completeness should not be ok', function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.INCOMPLETE);
|
||||
test('completeness should not be ok', () => {
|
||||
expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.INCOMPLETE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tagcloudscreenshot', function () {
|
||||
let imageComparator;
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
imageComparator = new ImageComparator();
|
||||
});
|
||||
describe('tagcloudscreenshot', () => {
|
||||
afterEach(teardownDOM);
|
||||
|
||||
afterEach(() => {
|
||||
imageComparator.destroy();
|
||||
teardownDOM();
|
||||
});
|
||||
|
||||
it('should render simple image', async function () {
|
||||
test('should render simple image', async () => {
|
||||
tagCloud = new TagCloud(domNode, colorScale);
|
||||
tagCloud.setData(baseTest.data);
|
||||
tagCloud.setOptions(baseTest.options);
|
||||
|
||||
await fromNode((cb) => tagCloud.once('renderComplete', cb));
|
||||
|
||||
const mismatchedPixels = await imageComparator.compareDOMContents(
|
||||
domNode.innerHTML,
|
||||
512,
|
||||
512,
|
||||
simpleloadPng,
|
||||
0.5
|
||||
);
|
||||
expect(mismatchedPixels).to.be.lessThan(64);
|
||||
expect(domNode.innerHTML).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
function verifyTagProperties(expectedValues, actualElements, tagCloud) {
|
||||
expect(actualElements.length).to.equal(expectedValues.length);
|
||||
expect(actualElements.length).toEqual(expectedValues.length);
|
||||
expectedValues.forEach((test, index) => {
|
||||
try {
|
||||
expect(actualElements[index].style.fontSize).to.equal(test.fontSize);
|
||||
expect(actualElements[index].style.fontSize).toEqual(test.fontSize);
|
||||
} catch (e) {
|
||||
throw new Error('fontsize is not correct: ' + e.message);
|
||||
}
|
||||
try {
|
||||
expect(actualElements[index].innerHTML).to.equal(test.text);
|
||||
expect(actualElements[index].innerHTML).toEqual(test.text);
|
||||
} catch (e) {
|
||||
throw new Error('fontsize is not correct: ' + e.message);
|
||||
}
|
||||
|
@ -470,14 +445,14 @@ describe('tag cloud tests', function () {
|
|||
debugInfo: ${JSON.stringify(tagCloud.getDebugInfo())}`;
|
||||
|
||||
try {
|
||||
expect(bbox.top >= 0 && bbox.top <= domNode.offsetHeight).to.be(shouldBeInside);
|
||||
expect(bbox.top >= 0 && bbox.top <= domNode.offsetHeight).toBe(shouldBeInside);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
'top boundary of tag should have been ' + (shouldBeInside ? 'inside' : 'outside') + message
|
||||
);
|
||||
}
|
||||
try {
|
||||
expect(bbox.bottom >= 0 && bbox.bottom <= domNode.offsetHeight).to.be(shouldBeInside);
|
||||
expect(bbox.bottom >= 0 && bbox.bottom <= domNode.offsetHeight).toBe(shouldBeInside);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
'bottom boundary of tag should have been ' +
|
||||
|
@ -486,14 +461,14 @@ describe('tag cloud tests', function () {
|
|||
);
|
||||
}
|
||||
try {
|
||||
expect(bbox.left >= 0 && bbox.left <= domNode.offsetWidth).to.be(shouldBeInside);
|
||||
expect(bbox.left >= 0 && bbox.left <= domNode.offsetWidth).toBe(shouldBeInside);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
'left boundary of tag should have been ' + (shouldBeInside ? 'inside' : 'outside') + message
|
||||
);
|
||||
}
|
||||
try {
|
||||
expect(bbox.right >= 0 && bbox.right <= domNode.offsetWidth).to.be(shouldBeInside);
|
||||
expect(bbox.right >= 0 && bbox.right <= domNode.offsetWidth).toBe(shouldBeInside);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
'right boundary of tag should have been ' +
|
||||
|
@ -532,7 +507,7 @@ describe('tag cloud tests', function () {
|
|||
}
|
||||
|
||||
function handleExpectedBlip(assertion) {
|
||||
return function () {
|
||||
return () => {
|
||||
if (!shouldAssert()) {
|
||||
return;
|
||||
}
|
|
@ -0,0 +1,176 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import 'jest-canvas-mock';
|
||||
|
||||
import { createTagCloudVisTypeDefinition } from '../tag_cloud_type';
|
||||
import { createTagCloudVisualization } from './tag_cloud_visualization';
|
||||
import { setFormatService } from '../services';
|
||||
import { dataPluginMock } from '../../../data/public/mocks';
|
||||
import { setHTMLElementOffset, setSVGElementGetBBox } from '../../../../test_utils/public';
|
||||
|
||||
const seedColors = ['#00a69b', '#57c17b', '#6f87d8', '#663db8', '#bc52bc', '#9e3533', '#daa05d'];
|
||||
|
||||
describe('TagCloudVisualizationTest', () => {
|
||||
let domNode;
|
||||
let vis;
|
||||
let SVGElementGetBBoxSpyInstance;
|
||||
let HTMLElementOffsetMockInstance;
|
||||
|
||||
const dummyTableGroup = {
|
||||
columns: [
|
||||
{
|
||||
id: 'col-0',
|
||||
title: 'geo.dest: Descending',
|
||||
},
|
||||
{
|
||||
id: 'col-1',
|
||||
title: 'Count',
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{ 'col-0': 'CN', 'col-1': 26 },
|
||||
{ 'col-0': 'IN', 'col-1': 17 },
|
||||
{ 'col-0': 'US', 'col-1': 6 },
|
||||
{ 'col-0': 'DE', 'col-1': 4 },
|
||||
{ 'col-0': 'BR', 'col-1': 3 },
|
||||
],
|
||||
};
|
||||
const TagCloudVisualization = createTagCloudVisualization({
|
||||
colors: {
|
||||
seedColors,
|
||||
},
|
||||
});
|
||||
|
||||
const originTransformSVGElement = window.SVGElement.prototype.transform;
|
||||
|
||||
beforeAll(() => {
|
||||
setFormatService(dataPluginMock.createStartContract().fieldFormats);
|
||||
Object.defineProperties(window.SVGElement.prototype, {
|
||||
transform: {
|
||||
get: () => ({
|
||||
baseVal: {
|
||||
consolidate: () => {},
|
||||
},
|
||||
}),
|
||||
configurable: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
SVGElementGetBBoxSpyInstance.mockRestore();
|
||||
HTMLElementOffsetMockInstance.mockRestore();
|
||||
window.SVGElement.prototype.transform = originTransformSVGElement;
|
||||
});
|
||||
|
||||
describe('TagCloudVisualization - basics', () => {
|
||||
beforeEach(async () => {
|
||||
const visType = createTagCloudVisTypeDefinition({ colors: seedColors });
|
||||
setupDOM(512, 512);
|
||||
|
||||
vis = {
|
||||
type: visType,
|
||||
params: {
|
||||
bucket: { accessor: 0, format: {} },
|
||||
metric: { accessor: 0, format: {} },
|
||||
scale: 'linear',
|
||||
orientation: 'single',
|
||||
},
|
||||
data: {},
|
||||
};
|
||||
});
|
||||
|
||||
test('simple draw', async () => {
|
||||
const tagcloudVisualization = new TagCloudVisualization(domNode, vis);
|
||||
|
||||
await tagcloudVisualization.render(dummyTableGroup, vis.params, {
|
||||
resize: false,
|
||||
params: true,
|
||||
aggs: true,
|
||||
data: true,
|
||||
uiState: false,
|
||||
});
|
||||
|
||||
const svgNode = domNode.querySelector('svg');
|
||||
expect(svgNode.outerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('with resize', async () => {
|
||||
const tagcloudVisualization = new TagCloudVisualization(domNode, vis);
|
||||
await tagcloudVisualization.render(dummyTableGroup, vis.params, {
|
||||
resize: false,
|
||||
params: true,
|
||||
aggs: true,
|
||||
data: true,
|
||||
uiState: false,
|
||||
});
|
||||
|
||||
domNode.style.width = '256px';
|
||||
domNode.style.height = '368px';
|
||||
await tagcloudVisualization.render(dummyTableGroup, vis.params, {
|
||||
resize: true,
|
||||
params: false,
|
||||
aggs: false,
|
||||
data: false,
|
||||
uiState: false,
|
||||
});
|
||||
|
||||
const svgNode = domNode.querySelector('svg');
|
||||
expect(svgNode.outerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('with param change', async function () {
|
||||
const tagcloudVisualization = new TagCloudVisualization(domNode, vis);
|
||||
await tagcloudVisualization.render(dummyTableGroup, vis.params, {
|
||||
resize: false,
|
||||
params: true,
|
||||
aggs: true,
|
||||
data: true,
|
||||
uiState: false,
|
||||
});
|
||||
|
||||
SVGElementGetBBoxSpyInstance.mockRestore();
|
||||
SVGElementGetBBoxSpyInstance = setSVGElementGetBBox(256, 368);
|
||||
|
||||
HTMLElementOffsetMockInstance.mockRestore();
|
||||
HTMLElementOffsetMockInstance = setHTMLElementOffset(256, 386);
|
||||
|
||||
vis.params.orientation = 'right angled';
|
||||
vis.params.minFontSize = 70;
|
||||
await tagcloudVisualization.render(dummyTableGroup, vis.params, {
|
||||
resize: true,
|
||||
params: true,
|
||||
aggs: false,
|
||||
data: false,
|
||||
uiState: false,
|
||||
});
|
||||
|
||||
const svgNode = domNode.querySelector('svg');
|
||||
expect(svgNode.outerHTML).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
function setupDOM(width, height) {
|
||||
domNode = document.createElement('div');
|
||||
|
||||
HTMLElementOffsetMockInstance = setHTMLElementOffset(width, height);
|
||||
SVGElementGetBBoxSpyInstance = setSVGElementGetBBox(width, height);
|
||||
}
|
||||
});
|
|
@ -24,3 +24,5 @@ export { WithStore } from './redux_helpers';
|
|||
export { WithMemoryRouter, WithRoute, reactRouterMock } from './router_helpers';
|
||||
|
||||
export * from './utils';
|
||||
|
||||
export { setSVGElementGetBBox, setHTMLElementOffset } from './jsdom_svg_mocks';
|
||||
|
|
57
src/test_utils/public/helpers/jsdom_svg_mocks.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export const setSVGElementGetBBox = (
|
||||
width: number,
|
||||
height: number,
|
||||
x: number = 0,
|
||||
y: number = 0
|
||||
) => {
|
||||
const SVGElementPrototype = SVGElement.prototype as any;
|
||||
const originalGetBBox = SVGElementPrototype.getBBox;
|
||||
|
||||
// getBBox is not in the SVGElement.prototype object by default, so we cannot use jest.spyOn for that case
|
||||
SVGElementPrototype.getBBox = jest.fn(() => ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}));
|
||||
|
||||
return {
|
||||
mockRestore: () => {
|
||||
SVGElementPrototype.getBBox = originalGetBBox;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const setHTMLElementOffset = (width: number, height: number) => {
|
||||
const offsetWidthSpy = jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get');
|
||||
offsetWidthSpy.mockReturnValue(width);
|
||||
|
||||
const offsetHeightSpy = jest.spyOn(window.HTMLElement.prototype, 'offsetHeight', 'get');
|
||||
offsetHeightSpy.mockReturnValue(height);
|
||||
|
||||
return {
|
||||
mockRestore: () => {
|
||||
offsetWidthSpy.mockRestore();
|
||||
offsetHeightSpy.mockRestore();
|
||||
},
|
||||
};
|
||||
};
|
20
src/test_utils/public/index.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export { setSVGElementGetBBox, setHTMLElementOffset } from './helpers';
|
|
@ -9,15 +9,7 @@ import { monitoring } from './legacy/plugins/monitoring';
|
|||
import { security } from './legacy/plugins/security';
|
||||
import { beats } from './legacy/plugins/beats_management';
|
||||
import { spaces } from './legacy/plugins/spaces';
|
||||
import { ingestManager } from './legacy/plugins/ingest_manager';
|
||||
|
||||
module.exports = function (kibana) {
|
||||
return [
|
||||
xpackMain(kibana),
|
||||
monitoring(kibana),
|
||||
spaces(kibana),
|
||||
security(kibana),
|
||||
ingestManager(kibana),
|
||||
beats(kibana),
|
||||
];
|
||||
return [xpackMain(kibana), monitoring(kibana), spaces(kibana), security(kibana), beats(kibana)];
|
||||
};
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { resolve } from 'path';
|
||||
|
||||
export function ingestManager(kibana: any) {
|
||||
return new kibana.Plugin({
|
||||
id: 'ingestManager',
|
||||
require: ['kibana', 'elasticsearch', 'xpack_main'],
|
||||
publicDir: resolve(__dirname, '../../../plugins/ingest_manager/public'),
|
||||
});
|
||||
}
|
|
@ -52,8 +52,12 @@ export function dropdownControl(): ExpressionFunctionDefinition<
|
|||
fn: (input, { valueColumn, filterColumn, filterGroup }) => {
|
||||
let choices = [];
|
||||
|
||||
if (input.rows[0][valueColumn]) {
|
||||
choices = uniq(input.rows.map((row) => row[valueColumn])).sort();
|
||||
const filteredRows = input.rows.filter(
|
||||
(row) => row[valueColumn] !== null && row[valueColumn] !== undefined
|
||||
);
|
||||
|
||||
if (filteredRows.length > 0) {
|
||||
choices = uniq(filteredRows.map((row) => row[valueColumn])).sort();
|
||||
}
|
||||
|
||||
const column = filterColumn || valueColumn;
|
||||
|
|
|
@ -1,161 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiBadge, EuiButton, EuiPopover, EuiPopoverTitle, EuiSelectable } from '@elastic/eui';
|
||||
import { EuiSelectableOption } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { LogColumnConfiguration } from '../../utils/source_configuration';
|
||||
import { useVisibilityState } from '../../utils/use_visibility_state';
|
||||
import { euiStyled } from '../../../../observability/public';
|
||||
|
||||
interface SelectableColumnOption {
|
||||
optionProps: EuiSelectableOption;
|
||||
columnConfiguration: LogColumnConfiguration;
|
||||
}
|
||||
|
||||
export const AddLogColumnButtonAndPopover: React.FunctionComponent<{
|
||||
addLogColumn: (logColumnConfiguration: LogColumnConfiguration) => void;
|
||||
availableFields: string[];
|
||||
isDisabled?: boolean;
|
||||
}> = ({ addLogColumn, availableFields, isDisabled }) => {
|
||||
const { isVisible: isOpen, show: openPopover, hide: closePopover } = useVisibilityState(false);
|
||||
|
||||
const availableColumnOptions = useMemo<SelectableColumnOption[]>(
|
||||
() => [
|
||||
{
|
||||
optionProps: {
|
||||
append: <SystemColumnBadge />,
|
||||
'data-test-subj': 'addTimestampLogColumn',
|
||||
// this key works around EuiSelectable using a lowercased label as
|
||||
// key, which leads to conflicts with field names
|
||||
key: 'timestamp',
|
||||
label: 'Timestamp',
|
||||
},
|
||||
columnConfiguration: {
|
||||
timestampColumn: {
|
||||
id: uuidv4(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
optionProps: {
|
||||
'data-test-subj': 'addMessageLogColumn',
|
||||
append: <SystemColumnBadge />,
|
||||
// this key works around EuiSelectable using a lowercased label as
|
||||
// key, which leads to conflicts with field names
|
||||
key: 'message',
|
||||
label: 'Message',
|
||||
},
|
||||
columnConfiguration: {
|
||||
messageColumn: {
|
||||
id: uuidv4(),
|
||||
},
|
||||
},
|
||||
},
|
||||
...availableFields.map<SelectableColumnOption>((field) => ({
|
||||
optionProps: {
|
||||
'data-test-subj': `addFieldLogColumn addFieldLogColumn:${field}`,
|
||||
// this key works around EuiSelectable using a lowercased label as
|
||||
// key, which leads to conflicts with fields that only differ in the
|
||||
// case (e.g. the metricbeat mongodb module)
|
||||
key: `field-${field}`,
|
||||
label: field,
|
||||
},
|
||||
columnConfiguration: {
|
||||
fieldColumn: {
|
||||
id: uuidv4(),
|
||||
field,
|
||||
},
|
||||
},
|
||||
})),
|
||||
],
|
||||
[availableFields]
|
||||
);
|
||||
|
||||
const availableOptions = useMemo<EuiSelectableOption[]>(
|
||||
() => availableColumnOptions.map((availableColumnOption) => availableColumnOption.optionProps),
|
||||
[availableColumnOptions]
|
||||
);
|
||||
|
||||
const handleColumnSelection = useCallback(
|
||||
(selectedOptions: EuiSelectableOption[]) => {
|
||||
closePopover();
|
||||
|
||||
const selectedOptionIndex = selectedOptions.findIndex(
|
||||
(selectedOption) => selectedOption.checked === 'on'
|
||||
);
|
||||
const selectedOption = availableColumnOptions[selectedOptionIndex];
|
||||
|
||||
addLogColumn(selectedOption.columnConfiguration);
|
||||
},
|
||||
[addLogColumn, availableColumnOptions, closePopover]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
anchorPosition="downRight"
|
||||
button={
|
||||
<EuiButton
|
||||
data-test-subj="addLogColumnButton"
|
||||
isDisabled={isDisabled}
|
||||
iconType="plusInCircle"
|
||||
onClick={openPopover}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.addLogColumnButtonLabel"
|
||||
defaultMessage="Add column"
|
||||
/>
|
||||
</EuiButton>
|
||||
}
|
||||
closePopover={closePopover}
|
||||
id="addLogColumn"
|
||||
isOpen={isOpen}
|
||||
ownFocus
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<EuiSelectable
|
||||
height={600}
|
||||
listProps={selectableListProps}
|
||||
onChange={handleColumnSelection}
|
||||
options={availableOptions}
|
||||
searchable
|
||||
searchProps={searchProps}
|
||||
singleSelection
|
||||
>
|
||||
{(list, search) => (
|
||||
<SelectableContent data-test-subj="addLogColumnPopover">
|
||||
<EuiPopoverTitle>{search}</EuiPopoverTitle>
|
||||
{list}
|
||||
</SelectableContent>
|
||||
)}
|
||||
</EuiSelectable>
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
||||
|
||||
const searchProps = {
|
||||
'data-test-subj': 'fieldSearchInput',
|
||||
};
|
||||
|
||||
const selectableListProps = {
|
||||
showIcons: false,
|
||||
};
|
||||
|
||||
const SystemColumnBadge: React.FunctionComponent = () => (
|
||||
<EuiBadge>
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.systemColumnBadgeLabel"
|
||||
defaultMessage="System"
|
||||
/>
|
||||
</EuiBadge>
|
||||
);
|
||||
|
||||
const SelectableContent = euiStyled.div`
|
||||
width: 400px;
|
||||
`;
|
|
@ -27,9 +27,7 @@ interface FieldsConfigurationPanelProps {
|
|||
isLoading: boolean;
|
||||
readOnly: boolean;
|
||||
podFieldProps: InputFieldProps;
|
||||
tiebreakerFieldProps: InputFieldProps;
|
||||
timestampFieldProps: InputFieldProps;
|
||||
displaySettings: 'metrics' | 'logs';
|
||||
}
|
||||
|
||||
export const FieldsConfigurationPanel = ({
|
||||
|
@ -38,15 +36,12 @@ export const FieldsConfigurationPanel = ({
|
|||
isLoading,
|
||||
readOnly,
|
||||
podFieldProps,
|
||||
tiebreakerFieldProps,
|
||||
timestampFieldProps,
|
||||
displaySettings,
|
||||
}: FieldsConfigurationPanelProps) => {
|
||||
const isHostValueDefault = hostFieldProps.value === 'host.name';
|
||||
const isContainerValueDefault = containerFieldProps.value === 'container.id';
|
||||
const isPodValueDefault = podFieldProps.value === 'kubernetes.pod.uid';
|
||||
const isTimestampValueDefault = timestampFieldProps.value === '@timestamp';
|
||||
const isTiebreakerValueDefault = tiebreakerFieldProps.value === '_doc';
|
||||
return (
|
||||
<EuiForm>
|
||||
<EuiTitle size="s">
|
||||
|
@ -139,194 +134,141 @@ export const FieldsConfigurationPanel = ({
|
|||
/>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
{displaySettings === 'logs' && (
|
||||
<>
|
||||
<EuiDescribedFormGroup
|
||||
title={
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.tiebreakerFieldLabel"
|
||||
defaultMessage="Tiebreaker"
|
||||
/>
|
||||
</h4>
|
||||
}
|
||||
description={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.tiebreakerFieldDescription"
|
||||
defaultMessage="Field used to break ties between two entries with the same timestamp"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFormRow
|
||||
error={tiebreakerFieldProps.error}
|
||||
fullWidth
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.tiebreakerFieldRecommendedValue"
|
||||
defaultMessage="The recommended value is {defaultValue}"
|
||||
values={{
|
||||
defaultValue: <EuiCode>_doc</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
isInvalid={tiebreakerFieldProps.isInvalid}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.tiebreakerFieldLabel"
|
||||
defaultMessage="Tiebreaker"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
disabled={isLoading || isTiebreakerValueDefault}
|
||||
readOnly={readOnly}
|
||||
isLoading={isLoading}
|
||||
{...tiebreakerFieldProps}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
</>
|
||||
)}
|
||||
{displaySettings === 'metrics' && (
|
||||
<>
|
||||
<EuiDescribedFormGroup
|
||||
title={
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.containerFieldLabel"
|
||||
defaultMessage="Container ID"
|
||||
/>
|
||||
</h4>
|
||||
}
|
||||
description={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.containerFieldDescription"
|
||||
defaultMessage="Field used to identify Docker containers"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFormRow
|
||||
error={containerFieldProps.error}
|
||||
fullWidth
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.containerFieldRecommendedValue"
|
||||
defaultMessage="The recommended value is {defaultValue}"
|
||||
values={{
|
||||
defaultValue: <EuiCode>container.id</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
isInvalid={containerFieldProps.isInvalid}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.containerFieldLabel"
|
||||
defaultMessage="Container ID"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
disabled={isLoading || isContainerValueDefault}
|
||||
readOnly={readOnly}
|
||||
isLoading={isLoading}
|
||||
{...containerFieldProps}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
<EuiDescribedFormGroup
|
||||
title={
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.hostNameFieldLabel"
|
||||
defaultMessage="Host name"
|
||||
/>
|
||||
</h4>
|
||||
}
|
||||
description={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.hostNameFieldDescription"
|
||||
defaultMessage="Field used to identify hosts"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFormRow
|
||||
error={hostFieldProps.error}
|
||||
fullWidth
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.hostFieldDescription"
|
||||
defaultMessage="The recommended value is {defaultValue}"
|
||||
values={{
|
||||
defaultValue: <EuiCode>host.name</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
isInvalid={hostFieldProps.isInvalid}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.hostFieldLabel"
|
||||
defaultMessage="Host name"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
disabled={isLoading || isHostValueDefault}
|
||||
readOnly={readOnly}
|
||||
isLoading={isLoading}
|
||||
{...hostFieldProps}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
<EuiDescribedFormGroup
|
||||
title={
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.podFieldLabel"
|
||||
defaultMessage="Pod ID"
|
||||
/>
|
||||
</h4>
|
||||
}
|
||||
description={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.podFieldDescription"
|
||||
defaultMessage="Field used to identify Kubernetes pods"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFormRow
|
||||
error={podFieldProps.error}
|
||||
fullWidth
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.podFieldRecommendedValue"
|
||||
defaultMessage="The recommended value is {defaultValue}"
|
||||
values={{
|
||||
defaultValue: <EuiCode>kubernetes.pod.uid</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
isInvalid={podFieldProps.isInvalid}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.podFieldLabel"
|
||||
defaultMessage="Pod ID"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
disabled={isLoading || isPodValueDefault}
|
||||
readOnly={readOnly}
|
||||
isLoading={isLoading}
|
||||
{...podFieldProps}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
</>
|
||||
)}
|
||||
<EuiDescribedFormGroup
|
||||
title={
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.containerFieldLabel"
|
||||
defaultMessage="Container ID"
|
||||
/>
|
||||
</h4>
|
||||
}
|
||||
description={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.containerFieldDescription"
|
||||
defaultMessage="Field used to identify Docker containers"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFormRow
|
||||
error={containerFieldProps.error}
|
||||
fullWidth
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.containerFieldRecommendedValue"
|
||||
defaultMessage="The recommended value is {defaultValue}"
|
||||
values={{
|
||||
defaultValue: <EuiCode>container.id</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
isInvalid={containerFieldProps.isInvalid}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.containerFieldLabel"
|
||||
defaultMessage="Container ID"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
disabled={isLoading || isContainerValueDefault}
|
||||
readOnly={readOnly}
|
||||
isLoading={isLoading}
|
||||
{...containerFieldProps}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
<EuiDescribedFormGroup
|
||||
title={
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.hostNameFieldLabel"
|
||||
defaultMessage="Host name"
|
||||
/>
|
||||
</h4>
|
||||
}
|
||||
description={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.hostNameFieldDescription"
|
||||
defaultMessage="Field used to identify hosts"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFormRow
|
||||
error={hostFieldProps.error}
|
||||
fullWidth
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.hostFieldDescription"
|
||||
defaultMessage="The recommended value is {defaultValue}"
|
||||
values={{
|
||||
defaultValue: <EuiCode>host.name</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
isInvalid={hostFieldProps.isInvalid}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.hostFieldLabel"
|
||||
defaultMessage="Host name"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
disabled={isLoading || isHostValueDefault}
|
||||
readOnly={readOnly}
|
||||
isLoading={isLoading}
|
||||
{...hostFieldProps}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
<EuiDescribedFormGroup
|
||||
title={
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.podFieldLabel"
|
||||
defaultMessage="Pod ID"
|
||||
/>
|
||||
</h4>
|
||||
}
|
||||
description={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.podFieldDescription"
|
||||
defaultMessage="Field used to identify Kubernetes pods"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFormRow
|
||||
error={podFieldProps.error}
|
||||
fullWidth
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.podFieldRecommendedValue"
|
||||
defaultMessage="The recommended value is {defaultValue}"
|
||||
values={{
|
||||
defaultValue: <EuiCode>kubernetes.pod.uid</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
isInvalid={podFieldProps.isInvalid}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.podFieldLabel"
|
||||
defaultMessage="Pod ID"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
disabled={isLoading || isPodValueDefault}
|
||||
readOnly={readOnly}
|
||||
isLoading={isLoading}
|
||||
{...podFieldProps}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
</EuiForm>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -21,17 +21,13 @@ import { InputFieldProps } from './input_fields';
|
|||
interface IndicesConfigurationPanelProps {
|
||||
isLoading: boolean;
|
||||
readOnly: boolean;
|
||||
logAliasFieldProps: InputFieldProps;
|
||||
metricAliasFieldProps: InputFieldProps;
|
||||
displaySettings: 'metrics' | 'logs';
|
||||
}
|
||||
|
||||
export const IndicesConfigurationPanel = ({
|
||||
isLoading,
|
||||
readOnly,
|
||||
logAliasFieldProps,
|
||||
metricAliasFieldProps,
|
||||
displaySettings,
|
||||
}: IndicesConfigurationPanelProps) => (
|
||||
<EuiForm>
|
||||
<EuiTitle size="s">
|
||||
|
@ -43,101 +39,51 @@ export const IndicesConfigurationPanel = ({
|
|||
</h3>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="m" />
|
||||
{displaySettings === 'metrics' && (
|
||||
<EuiDescribedFormGroup
|
||||
title={
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.metricIndicesTitle"
|
||||
defaultMessage="Metric indices"
|
||||
/>
|
||||
</h4>
|
||||
}
|
||||
description={
|
||||
<EuiDescribedFormGroup
|
||||
title={
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.metricIndicesDescription"
|
||||
defaultMessage="Index pattern for matching indices that contain Metricbeat data"
|
||||
id="xpack.infra.sourceConfiguration.metricIndicesTitle"
|
||||
defaultMessage="Metrics indices"
|
||||
/>
|
||||
</h4>
|
||||
}
|
||||
description={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.metricIndicesDescription"
|
||||
defaultMessage="Index pattern for matching indices that contain metrics data"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFormRow
|
||||
error={metricAliasFieldProps.error}
|
||||
fullWidth
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.metricIndicesRecommendedValue"
|
||||
defaultMessage="The recommended value is {defaultValue}"
|
||||
values={{
|
||||
defaultValue: <EuiCode>metrics-*,metricbeat-*</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
isInvalid={metricAliasFieldProps.isInvalid}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.metricIndicesLabel"
|
||||
defaultMessage="Metrics indices"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFormRow
|
||||
error={metricAliasFieldProps.error}
|
||||
<EuiFieldText
|
||||
data-test-subj="metricIndicesInput"
|
||||
fullWidth
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.metricIndicesRecommendedValue"
|
||||
defaultMessage="The recommended value is {defaultValue}"
|
||||
values={{
|
||||
defaultValue: <EuiCode>metricbeat-*</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
isInvalid={metricAliasFieldProps.isInvalid}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.metricIndicesLabel"
|
||||
defaultMessage="Metric indices"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
data-test-subj="metricIndicesInput"
|
||||
fullWidth
|
||||
disabled={isLoading}
|
||||
readOnly={readOnly}
|
||||
isLoading={isLoading}
|
||||
{...metricAliasFieldProps}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
)}
|
||||
{displaySettings === 'logs' && (
|
||||
<EuiDescribedFormGroup
|
||||
title={
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.logIndicesTitle"
|
||||
defaultMessage="Log indices"
|
||||
/>
|
||||
</h4>
|
||||
}
|
||||
description={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.logIndicesDescription"
|
||||
defaultMessage="Index pattern for matching indices that contain log data"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFormRow
|
||||
error={logAliasFieldProps.error}
|
||||
fullWidth
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.logIndicesRecommendedValue"
|
||||
defaultMessage="The recommended value is {defaultValue}"
|
||||
values={{
|
||||
defaultValue: <EuiCode>filebeat-*</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
isInvalid={logAliasFieldProps.isInvalid}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.logIndicesLabel"
|
||||
defaultMessage="Log indices"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
data-test-subj="logIndicesInput"
|
||||
fullWidth
|
||||
disabled={isLoading}
|
||||
isLoading={isLoading}
|
||||
readOnly={readOnly}
|
||||
{...logAliasFieldProps}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
)}
|
||||
disabled={isLoading}
|
||||
readOnly={readOnly}
|
||||
isLoading={isLoading}
|
||||
{...metricAliasFieldProps}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
</EuiForm>
|
||||
);
|
||||
|
|
|
@ -1,279 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
EuiEmptyPrompt,
|
||||
EuiForm,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiDragDropContext,
|
||||
EuiDraggable,
|
||||
EuiDroppable,
|
||||
EuiIcon,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { DragHandleProps, DropResult } from '../../../../observability/public';
|
||||
|
||||
import { AddLogColumnButtonAndPopover } from './add_log_column_popover';
|
||||
import {
|
||||
FieldLogColumnConfigurationProps,
|
||||
LogColumnConfigurationProps,
|
||||
} from './log_columns_configuration_form_state';
|
||||
import { LogColumnConfiguration } from '../../utils/source_configuration';
|
||||
|
||||
interface LogColumnsConfigurationPanelProps {
|
||||
availableFields: string[];
|
||||
isLoading: boolean;
|
||||
logColumnConfiguration: LogColumnConfigurationProps[];
|
||||
addLogColumn: (logColumn: LogColumnConfiguration) => void;
|
||||
moveLogColumn: (sourceIndex: number, destinationIndex: number) => void;
|
||||
}
|
||||
|
||||
export const LogColumnsConfigurationPanel: React.FunctionComponent<LogColumnsConfigurationPanelProps> = ({
|
||||
addLogColumn,
|
||||
moveLogColumn,
|
||||
availableFields,
|
||||
isLoading,
|
||||
logColumnConfiguration,
|
||||
}) => {
|
||||
const onDragEnd = useCallback(
|
||||
({ source, destination }: DropResult) =>
|
||||
destination && moveLogColumn(source.index, destination.index),
|
||||
[moveLogColumn]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiForm>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="s" data-test-subj="sourceConfigurationLogColumnsSectionTitle">
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.logColumnsSectionTitle"
|
||||
defaultMessage="Log Columns"
|
||||
/>
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<AddLogColumnButtonAndPopover
|
||||
addLogColumn={addLogColumn}
|
||||
availableFields={availableFields}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
{logColumnConfiguration.length > 0 ? (
|
||||
<EuiDragDropContext onDragEnd={onDragEnd}>
|
||||
<EuiDroppable droppableId="COLUMN_CONFIG_DROPPABLE_AREA">
|
||||
<>
|
||||
{/* Fragment here necessary for typechecking */}
|
||||
{logColumnConfiguration.map((column, index) => (
|
||||
<EuiDraggable
|
||||
key={`logColumnConfigurationPanel-${column.logColumnConfiguration.id}`}
|
||||
index={index}
|
||||
draggableId={column.logColumnConfiguration.id}
|
||||
customDragHandle
|
||||
>
|
||||
{(provided) => (
|
||||
<LogColumnConfigurationPanel
|
||||
dragHandleProps={provided.dragHandleProps}
|
||||
logColumnConfigurationProps={column}
|
||||
/>
|
||||
)}
|
||||
</EuiDraggable>
|
||||
))}
|
||||
</>
|
||||
</EuiDroppable>
|
||||
</EuiDragDropContext>
|
||||
) : (
|
||||
<LogColumnConfigurationEmptyPrompt />
|
||||
)}
|
||||
</EuiForm>
|
||||
);
|
||||
};
|
||||
|
||||
interface LogColumnConfigurationPanelProps {
|
||||
logColumnConfigurationProps: LogColumnConfigurationProps;
|
||||
dragHandleProps: DragHandleProps;
|
||||
}
|
||||
|
||||
const LogColumnConfigurationPanel: React.FunctionComponent<LogColumnConfigurationPanelProps> = (
|
||||
props
|
||||
) => (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
{props.logColumnConfigurationProps.type === 'timestamp' ? (
|
||||
<TimestampLogColumnConfigurationPanel {...props} />
|
||||
) : props.logColumnConfigurationProps.type === 'message' ? (
|
||||
<MessageLogColumnConfigurationPanel {...props} />
|
||||
) : (
|
||||
<FieldLogColumnConfigurationPanel
|
||||
logColumnConfigurationProps={props.logColumnConfigurationProps}
|
||||
dragHandleProps={props.dragHandleProps}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const TimestampLogColumnConfigurationPanel: React.FunctionComponent<LogColumnConfigurationPanelProps> = ({
|
||||
logColumnConfigurationProps,
|
||||
dragHandleProps,
|
||||
}) => (
|
||||
<ExplainedLogColumnConfigurationPanel
|
||||
fieldName="Timestamp"
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
tagName="span"
|
||||
id="xpack.infra.sourceConfiguration.timestampLogColumnDescription"
|
||||
defaultMessage="This system field shows the log entry's time as determined by the {timestampSetting} field setting."
|
||||
values={{
|
||||
timestampSetting: <code>timestamp</code>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
removeColumn={logColumnConfigurationProps.remove}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
|
||||
const MessageLogColumnConfigurationPanel: React.FunctionComponent<LogColumnConfigurationPanelProps> = ({
|
||||
logColumnConfigurationProps,
|
||||
dragHandleProps,
|
||||
}) => (
|
||||
<ExplainedLogColumnConfigurationPanel
|
||||
fieldName="Message"
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
tagName="span"
|
||||
id="xpack.infra.sourceConfiguration.messageLogColumnDescription"
|
||||
defaultMessage="This system field shows the log entry message as derived from the document fields."
|
||||
/>
|
||||
}
|
||||
removeColumn={logColumnConfigurationProps.remove}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
|
||||
const FieldLogColumnConfigurationPanel: React.FunctionComponent<{
|
||||
logColumnConfigurationProps: FieldLogColumnConfigurationProps;
|
||||
dragHandleProps: DragHandleProps;
|
||||
}> = ({
|
||||
logColumnConfigurationProps: {
|
||||
logColumnConfiguration: { field },
|
||||
remove,
|
||||
},
|
||||
dragHandleProps,
|
||||
}) => {
|
||||
const fieldLogColumnTitle = i18n.translate(
|
||||
'xpack.infra.sourceConfiguration.fieldLogColumnTitle',
|
||||
{
|
||||
defaultMessage: 'Field',
|
||||
}
|
||||
);
|
||||
return (
|
||||
<EuiPanel data-test-subj={`logColumnPanel fieldLogColumnPanel fieldLogColumnPanel:${field}`}>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<div data-test-subj="moveLogColumnHandle" {...dragHandleProps}>
|
||||
<EuiIcon type="grab" />
|
||||
</div>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={1}>{fieldLogColumnTitle}</EuiFlexItem>
|
||||
<EuiFlexItem grow={3}>
|
||||
<code>{field}</code>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<RemoveLogColumnButton
|
||||
onClick={remove}
|
||||
columnDescription={`${fieldLogColumnTitle} - ${field}`}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
||||
|
||||
const ExplainedLogColumnConfigurationPanel: React.FunctionComponent<{
|
||||
fieldName: React.ReactNode;
|
||||
helpText: React.ReactNode;
|
||||
removeColumn: () => void;
|
||||
dragHandleProps: DragHandleProps;
|
||||
}> = ({ fieldName, helpText, removeColumn, dragHandleProps }) => (
|
||||
<EuiPanel
|
||||
data-test-subj={`logColumnPanel systemLogColumnPanel systemLogColumnPanel:${fieldName}`}
|
||||
>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<div data-test-subj="moveLogColumnHandle" {...dragHandleProps}>
|
||||
<EuiIcon type="grab" />
|
||||
</div>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={1}>{fieldName}</EuiFlexItem>
|
||||
<EuiFlexItem grow={3}>
|
||||
<EuiText size="s" color="subdued">
|
||||
{helpText}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<RemoveLogColumnButton onClick={removeColumn} columnDescription={String(fieldName)} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
|
||||
const RemoveLogColumnButton: React.FunctionComponent<{
|
||||
onClick?: () => void;
|
||||
columnDescription: string;
|
||||
}> = ({ onClick, columnDescription }) => {
|
||||
const removeColumnLabel = i18n.translate(
|
||||
'xpack.infra.sourceConfiguration.removeLogColumnButtonLabel',
|
||||
{
|
||||
defaultMessage: 'Remove {columnDescription} column',
|
||||
values: { columnDescription },
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiButtonIcon
|
||||
color="danger"
|
||||
data-test-subj="removeLogColumnButton"
|
||||
iconType="trash"
|
||||
onClick={onClick}
|
||||
title={removeColumnLabel}
|
||||
aria-label={removeColumnLabel}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const LogColumnConfigurationEmptyPrompt: React.FunctionComponent = () => (
|
||||
<EuiEmptyPrompt
|
||||
iconType="list"
|
||||
title={
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.noLogColumnsTitle"
|
||||
defaultMessage="No columns"
|
||||
/>
|
||||
</h2>
|
||||
}
|
||||
body={
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.infra.sourceConfiguration.noLogColumnsDescription"
|
||||
defaultMessage="Add a column to this list using the button above."
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
);
|
|
@ -22,19 +22,16 @@ import { Source } from '../../containers/source';
|
|||
import { FieldsConfigurationPanel } from './fields_configuration_panel';
|
||||
import { IndicesConfigurationPanel } from './indices_configuration_panel';
|
||||
import { NameConfigurationPanel } from './name_configuration_panel';
|
||||
import { LogColumnsConfigurationPanel } from './log_columns_configuration_panel';
|
||||
import { useSourceConfigurationFormState } from './source_configuration_form_state';
|
||||
import { SourceLoadingPage } from '../source_loading_page';
|
||||
import { Prompt } from '../../utils/navigation_warning_prompt';
|
||||
|
||||
interface SourceConfigurationSettingsProps {
|
||||
shouldAllowEdit: boolean;
|
||||
displaySettings: 'metrics' | 'logs';
|
||||
}
|
||||
|
||||
export const SourceConfigurationSettings = ({
|
||||
shouldAllowEdit,
|
||||
displaySettings,
|
||||
}: SourceConfigurationSettingsProps) => {
|
||||
const {
|
||||
createSourceConfiguration,
|
||||
|
@ -45,16 +42,8 @@ export const SourceConfigurationSettings = ({
|
|||
updateSourceConfiguration,
|
||||
} = useContext(Source.Context);
|
||||
|
||||
const availableFields = useMemo(
|
||||
() => (source && source.status ? source.status.indexFields.map((field) => field.name) : []),
|
||||
[source]
|
||||
);
|
||||
|
||||
const {
|
||||
addLogColumn,
|
||||
moveLogColumn,
|
||||
indicesConfigurationProps,
|
||||
logColumnConfigurationProps,
|
||||
errors,
|
||||
resetForm,
|
||||
isFormDirty,
|
||||
|
@ -119,10 +108,8 @@ export const SourceConfigurationSettings = ({
|
|||
<EuiPanel paddingSize="l">
|
||||
<IndicesConfigurationPanel
|
||||
isLoading={isLoading}
|
||||
logAliasFieldProps={indicesConfigurationProps.logAlias}
|
||||
metricAliasFieldProps={indicesConfigurationProps.metricAlias}
|
||||
readOnly={!isWriteable}
|
||||
displaySettings={displaySettings}
|
||||
/>
|
||||
</EuiPanel>
|
||||
<EuiSpacer />
|
||||
|
@ -133,23 +120,10 @@ export const SourceConfigurationSettings = ({
|
|||
isLoading={isLoading}
|
||||
podFieldProps={indicesConfigurationProps.podField}
|
||||
readOnly={!isWriteable}
|
||||
tiebreakerFieldProps={indicesConfigurationProps.tiebreakerField}
|
||||
timestampFieldProps={indicesConfigurationProps.timestampField}
|
||||
displaySettings={displaySettings}
|
||||
/>
|
||||
</EuiPanel>
|
||||
<EuiSpacer />
|
||||
{displaySettings === 'logs' && (
|
||||
<EuiPanel paddingSize="l">
|
||||
<LogColumnsConfigurationPanel
|
||||
addLogColumn={addLogColumn}
|
||||
moveLogColumn={moveLogColumn}
|
||||
availableFields={availableFields}
|
||||
isLoading={isLoading}
|
||||
logColumnConfiguration={logColumnConfigurationProps}
|
||||
/>
|
||||
</EuiPanel>
|
||||
)}
|
||||
{errors.length > 0 ? (
|
||||
<>
|
||||
<EuiCallOut color="danger">
|
||||
|
|
|
@ -62,7 +62,7 @@ export const IndicesConfigurationPanel = ({
|
|||
id="xpack.infra.sourceConfiguration.logIndicesRecommendedValue"
|
||||
defaultMessage="The recommended value is {defaultValue}"
|
||||
values={{
|
||||
defaultValue: <EuiCode>filebeat-*</EuiCode>,
|
||||
defaultValue: <EuiCode>logs-*,filebeat-*</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
|
|
@ -15,7 +15,6 @@ export const MetricsSettingsPage = () => {
|
|||
<EuiErrorBoundary>
|
||||
<SourceConfigurationSettings
|
||||
shouldAllowEdit={uiCapabilities?.infrastructure?.configureSource as boolean}
|
||||
displaySettings="metrics"
|
||||
/>
|
||||
</EuiErrorBoundary>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { MetricExpressionParams } from '../types';
|
||||
import { getElasticsearchMetricQuery } from './metric_query';
|
||||
|
||||
describe("The Metric Threshold Alert's getElasticsearchMetricQuery", () => {
|
||||
const expressionParams = {
|
||||
metric: 'system.is.a.good.puppy.dog',
|
||||
aggType: 'avg',
|
||||
timeUnit: 'm',
|
||||
timeSize: 1,
|
||||
} as MetricExpressionParams;
|
||||
|
||||
const timefield = '@timestamp';
|
||||
const groupBy = 'host.doggoname';
|
||||
|
||||
describe('when passed no filterQuery', () => {
|
||||
const searchBody = getElasticsearchMetricQuery(expressionParams, timefield, groupBy);
|
||||
test('includes a range filter', () => {
|
||||
expect(
|
||||
searchBody.query.bool.filter.find((filter) => filter.hasOwnProperty('range'))
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('includes a metric field filter', () => {
|
||||
expect(searchBody.query.bool.filter).toMatchObject(
|
||||
expect.arrayContaining([{ exists: { field: 'system.is.a.good.puppy.dog' } }])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when passed a filterQuery', () => {
|
||||
const filterQuery =
|
||||
// This is adapted from a real-world query that previously broke alerts
|
||||
// We want to make sure it doesn't override any existing filters
|
||||
'{"bool":{"filter":[{"bool":{"filter":[{"bool":{"must_not":[{"bool":{"should":[{"query_string":{"query":"bark*","fields":["host.name^1.0"],"type":"best_fields","default_operator":"or","max_determinized_states":10000,"enable_position_increments":true,"fuzziness":"AUTO","fuzzy_prefix_length":0,"fuzzy_max_expansions":50,"phrase_slop":0,"escape":false,"auto_generate_synonyms_phrase_query":true,"fuzzy_transpositions":true,"boost":1}}],"adjust_pure_negative":true,"minimum_should_match":"1","boost":1}}],"adjust_pure_negative":true,"boost":1}},{"bool":{"must_not":[{"bool":{"should":[{"query_string":{"query":"woof*","fields":["host.name^1.0"],"type":"best_fields","default_operator":"or","max_determinized_states":10000,"enable_position_increments":true,"fuzziness":"AUTO","fuzzy_prefix_length":0,"fuzzy_max_expansions":50,"phrase_slop":0,"escape":false,"auto_generate_synonyms_phrase_query":true,"fuzzy_transpositions":true,"boost":1}}],"adjust_pure_negative":true,"minimum_should_match":"1","boost":1}}],"adjust_pure_negative":true,"boost":1}}],"adjust_pure_negative":true,"boost":1}}],"adjust_pure_negative":true,"boost":1}}';
|
||||
|
||||
const searchBody = getElasticsearchMetricQuery(
|
||||
expressionParams,
|
||||
timefield,
|
||||
groupBy,
|
||||
filterQuery
|
||||
);
|
||||
test('includes a range filter', () => {
|
||||
expect(
|
||||
searchBody.query.bool.filter.find((filter) => filter.hasOwnProperty('range'))
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('includes a metric field filter', () => {
|
||||
expect(searchBody.query.bool.filter).toMatchObject(
|
||||
expect.arrayContaining([{ exists: { field: 'system.is.a.good.puppy.dog' } }])
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -11,11 +11,11 @@ import { createPercentileAggregation } from './create_percentile_aggregation';
|
|||
|
||||
const MINIMUM_BUCKETS = 5;
|
||||
|
||||
const getParsedFilterQuery: (
|
||||
filterQuery: string | undefined
|
||||
) => Record<string, any> | Array<Record<string, any>> = (filterQuery) => {
|
||||
if (!filterQuery) return {};
|
||||
return JSON.parse(filterQuery).bool;
|
||||
const getParsedFilterQuery: (filterQuery: string | undefined) => Record<string, any> | null = (
|
||||
filterQuery
|
||||
) => {
|
||||
if (!filterQuery) return null;
|
||||
return JSON.parse(filterQuery);
|
||||
};
|
||||
|
||||
export const getElasticsearchMetricQuery = (
|
||||
|
@ -129,9 +129,8 @@ export const getElasticsearchMetricQuery = (
|
|||
filter: [
|
||||
...rangeFilters,
|
||||
...metricFieldFilters,
|
||||
...(Array.isArray(parsedFilterQuery) ? parsedFilterQuery : []),
|
||||
...(parsedFilterQuery ? [parsedFilterQuery] : []),
|
||||
],
|
||||
...(!Array.isArray(parsedFilterQuery) ? parsedFilterQuery : {}),
|
||||
},
|
||||
},
|
||||
size: 0,
|
||||
|
|
|
@ -9,8 +9,8 @@ import { InfraSourceConfiguration } from '../../../common/http_api/source_api';
|
|||
export const defaultSourceConfiguration: InfraSourceConfiguration = {
|
||||
name: 'Default',
|
||||
description: '',
|
||||
metricAlias: 'metricbeat-*',
|
||||
logAlias: 'filebeat-*,kibana_sample_data_logs*',
|
||||
metricAlias: 'metrics-*,metricbeat-*',
|
||||
logAlias: 'logs-*,filebeat-*,kibana_sample_data_logs*',
|
||||
fields: {
|
||||
container: 'container.id',
|
||||
host: 'host.name',
|
||||
|
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { migrationMocks } from 'src/core/server/mocks';
|
||||
import { addNewIndexingStrategyIndexNames } from './7_9_0_add_new_indexing_strategy_index_names';
|
||||
import { infraSourceConfigurationSavedObjectName } from '../saved_object_type';
|
||||
|
||||
describe('infra source configuration migration function for 7.9.0', () => {
|
||||
test('adds "logs-*" when the logAlias contains "filebeat-*"', () => {
|
||||
const unmigratedConfiguration = createTestSourceConfiguration(
|
||||
'filebeat-*,custom-log-index-*',
|
||||
'custom-metric-index-*'
|
||||
);
|
||||
|
||||
const migratedConfiguration = addNewIndexingStrategyIndexNames(
|
||||
unmigratedConfiguration,
|
||||
migrationMocks.createContext()
|
||||
);
|
||||
|
||||
expect(migratedConfiguration).toStrictEqual(
|
||||
createTestSourceConfiguration('filebeat-*,custom-log-index-*,logs-*', 'custom-metric-index-*')
|
||||
);
|
||||
});
|
||||
|
||||
test('doesn\'t add "logs-*" when the logAlias doesn\'t contain "filebeat-*"', () => {
|
||||
const unmigratedConfiguration = createTestSourceConfiguration(
|
||||
'custom-log-index-*',
|
||||
'custom-metric-index-*'
|
||||
);
|
||||
|
||||
const migratedConfiguration = addNewIndexingStrategyIndexNames(
|
||||
unmigratedConfiguration,
|
||||
migrationMocks.createContext()
|
||||
);
|
||||
|
||||
expect(migratedConfiguration).toStrictEqual(unmigratedConfiguration);
|
||||
});
|
||||
|
||||
test('doesn\'t add "logs-*" when the logAlias already contains it', () => {
|
||||
const unmigratedConfiguration = createTestSourceConfiguration(
|
||||
'filebeat-*,logs-*,custom-log-index-*',
|
||||
'custom-metric-index-*'
|
||||
);
|
||||
|
||||
const migratedConfiguration = addNewIndexingStrategyIndexNames(
|
||||
unmigratedConfiguration,
|
||||
migrationMocks.createContext()
|
||||
);
|
||||
|
||||
expect(migratedConfiguration).toStrictEqual(unmigratedConfiguration);
|
||||
});
|
||||
|
||||
test('adds "metrics-*" when the logAlias contains "metricbeat-*"', () => {
|
||||
const unmigratedConfiguration = createTestSourceConfiguration(
|
||||
'custom-log-index-*',
|
||||
'metricbeat-*,custom-metric-index-*'
|
||||
);
|
||||
|
||||
const migratedConfiguration = addNewIndexingStrategyIndexNames(
|
||||
unmigratedConfiguration,
|
||||
migrationMocks.createContext()
|
||||
);
|
||||
|
||||
expect(migratedConfiguration).toStrictEqual(
|
||||
createTestSourceConfiguration(
|
||||
'custom-log-index-*',
|
||||
'metricbeat-*,custom-metric-index-*,metrics-*'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('doesn\'t add "metrics-*" when the logAlias doesn\'t contain "metricbeat-*"', () => {
|
||||
const unmigratedConfiguration = createTestSourceConfiguration(
|
||||
'custom-log-index-*',
|
||||
'custom-metric-index-*'
|
||||
);
|
||||
|
||||
const migratedConfiguration = addNewIndexingStrategyIndexNames(
|
||||
unmigratedConfiguration,
|
||||
migrationMocks.createContext()
|
||||
);
|
||||
|
||||
expect(migratedConfiguration).toStrictEqual(unmigratedConfiguration);
|
||||
});
|
||||
|
||||
test('doesn\'t add "metrics-*" when the metricAlias already contains it', () => {
|
||||
const unmigratedConfiguration = createTestSourceConfiguration(
|
||||
'custom-log-index-*',
|
||||
'metrics-*,metricbeat-*,custom-metric-index-*'
|
||||
);
|
||||
|
||||
const migratedConfiguration = addNewIndexingStrategyIndexNames(
|
||||
unmigratedConfiguration,
|
||||
migrationMocks.createContext()
|
||||
);
|
||||
|
||||
expect(migratedConfiguration).toStrictEqual(unmigratedConfiguration);
|
||||
});
|
||||
});
|
||||
|
||||
const createTestSourceConfiguration = (logAlias: string, metricAlias: string) => ({
|
||||
attributes: {
|
||||
name: 'TEST CONFIGURATION',
|
||||
description: '',
|
||||
fields: {
|
||||
pod: 'TEST POD FIELD',
|
||||
host: 'TEST HOST FIELD',
|
||||
message: ['TEST MESSAGE FIELD'],
|
||||
container: 'TEST CONTAINER FIELD',
|
||||
timestamp: 'TEST TIMESTAMP FIELD',
|
||||
tiebreaker: 'TEST TIEBREAKER FIELD',
|
||||
},
|
||||
inventoryDefaultView: '0',
|
||||
metricsExplorerDefaultView: '0',
|
||||
logColumns: [
|
||||
{
|
||||
fieldColumn: {
|
||||
id: 'TEST FIELD COLUMN ID',
|
||||
field: 'TEST FIELD COLUMN FIELD',
|
||||
},
|
||||
},
|
||||
],
|
||||
logAlias,
|
||||
metricAlias,
|
||||
},
|
||||
id: 'TEST_ID',
|
||||
type: infraSourceConfigurationSavedObjectName,
|
||||
});
|
|
@ -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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { SavedObjectMigrationFn } from 'src/core/server';
|
||||
import { InfraSourceConfiguration } from '../../../../common/http_api/source_api';
|
||||
|
||||
export const addNewIndexingStrategyIndexNames: SavedObjectMigrationFn<
|
||||
InfraSourceConfiguration,
|
||||
InfraSourceConfiguration
|
||||
> = (sourceConfigurationDocument) => {
|
||||
const oldLogAliasSegments = sourceConfigurationDocument.attributes.logAlias.split(',');
|
||||
const oldMetricAliasSegments = sourceConfigurationDocument.attributes.metricAlias.split(',');
|
||||
|
||||
const newLogAliasSegment = 'logs-*';
|
||||
const newMetricAliasSegment = 'metrics-*';
|
||||
|
||||
return {
|
||||
...sourceConfigurationDocument,
|
||||
attributes: {
|
||||
...sourceConfigurationDocument.attributes,
|
||||
logAlias:
|
||||
oldLogAliasSegments.includes('filebeat-*') &&
|
||||
!oldLogAliasSegments.includes(newLogAliasSegment)
|
||||
? [...oldLogAliasSegments, newLogAliasSegment].join(',')
|
||||
: sourceConfigurationDocument.attributes.logAlias,
|
||||
metricAlias:
|
||||
oldMetricAliasSegments.includes('metricbeat-*') &&
|
||||
!oldMetricAliasSegments.includes(newMetricAliasSegment)
|
||||
? [...oldMetricAliasSegments, newMetricAliasSegment].join(',')
|
||||
: sourceConfigurationDocument.attributes.metricAlias,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -4,8 +4,8 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { SavedObjectsType } from 'src/core/server';
|
||||
import { addNewIndexingStrategyIndexNames } from './migrations/7_9_0_add_new_indexing_strategy_index_names';
|
||||
|
||||
export const infraSourceConfigurationSavedObjectName = 'infrastructure-ui-source';
|
||||
|
||||
|
@ -86,4 +86,7 @@ export const infraSourceConfigurationSavedObjectType: SavedObjectsType = {
|
|||
},
|
||||
},
|
||||
},
|
||||
migrations: {
|
||||
'7.9.0': addNewIndexingStrategyIndexNames,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -175,6 +175,12 @@ export interface Dataset {
|
|||
package: string;
|
||||
path: string;
|
||||
ingest_pipeline: string;
|
||||
elasticsearch?: RegistryElasticsearch;
|
||||
}
|
||||
|
||||
export interface RegistryElasticsearch {
|
||||
'index_template.settings'?: object;
|
||||
'index_template.mappings'?: object;
|
||||
}
|
||||
|
||||
// EPR types this as `[]map[string]interface{}`
|
||||
|
@ -272,6 +278,7 @@ export interface IndexTemplate {
|
|||
data_stream: {
|
||||
timestamp_field: string;
|
||||
};
|
||||
composed_of: string[];
|
||||
_meta: object;
|
||||
}
|
||||
|
||||
|
|
|
@ -55,9 +55,14 @@ export interface NewPackageConfig {
|
|||
inputs: NewPackageConfigInput[];
|
||||
}
|
||||
|
||||
export interface UpdatePackageConfig extends NewPackageConfig {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface PackageConfig extends Omit<NewPackageConfig, 'inputs'> {
|
||||
id: string;
|
||||
inputs: PackageConfigInput[];
|
||||
version?: string;
|
||||
revision: number;
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
|
@ -65,4 +70,4 @@ export interface PackageConfig extends Omit<NewPackageConfig, 'inputs'> {
|
|||
created_by: string;
|
||||
}
|
||||
|
||||
export type PackageConfigSOAttributes = Omit<PackageConfig, 'id'>;
|
||||
export type PackageConfigSOAttributes = Omit<PackageConfig, 'id' | 'version'>;
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { PackageConfig, NewPackageConfig } from '../models';
|
||||
import { PackageConfig, NewPackageConfig, UpdatePackageConfig } from '../models';
|
||||
|
||||
export interface GetPackageConfigsRequest {
|
||||
query: {
|
||||
|
@ -42,7 +42,7 @@ export interface CreatePackageConfigResponse {
|
|||
}
|
||||
|
||||
export type UpdatePackageConfigRequest = GetOnePackageConfigRequest & {
|
||||
body: NewPackageConfig;
|
||||
body: UpdatePackageConfig;
|
||||
};
|
||||
|
||||
export type UpdatePackageConfigResponse = CreatePackageConfigResponse;
|
||||
|
|
|
@ -53,7 +53,7 @@ export const PAGE_ROUTING_PATHS = {
|
|||
fleet_agent_details_events: '/fleet/agents/:agentId',
|
||||
fleet_agent_details_details: '/fleet/agents/:agentId/details',
|
||||
fleet_enrollment_tokens: '/fleet/enrollment-tokens',
|
||||
data_streams: '/data-streams',
|
||||
data_streams: '/datasets',
|
||||
};
|
||||
|
||||
export const pagePathGetters: {
|
||||
|
@ -80,5 +80,5 @@ export const pagePathGetters: {
|
|||
fleet_agent_details: ({ agentId, tabId }) =>
|
||||
`/fleet/agents/${agentId}${tabId ? `/${tabId}` : ''}`,
|
||||
fleet_enrollment_tokens: () => '/fleet/enrollment-tokens',
|
||||
data_streams: () => '/data-streams',
|
||||
data_streams: () => '/datasets',
|
||||
};
|
||||
|
|
|
@ -207,7 +207,7 @@ const breadcrumbGetters: {
|
|||
BASE_BREADCRUMB,
|
||||
{
|
||||
text: i18n.translate('xpack.ingestManager.breadcrumbs.datastreamsPageTitle', {
|
||||
defaultMessage: 'Data streams',
|
||||
defaultMessage: 'Datasets',
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
|
|
@ -17,33 +17,39 @@ let httpClient: HttpSetup;
|
|||
|
||||
export type UseRequestConfig = _UseRequestConfig;
|
||||
|
||||
interface RequestError extends Error {
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
export const setHttpClient = (client: HttpSetup) => {
|
||||
httpClient = client;
|
||||
};
|
||||
|
||||
export const sendRequest = <D = any>(
|
||||
export const sendRequest = <D = any, E = RequestError>(
|
||||
config: SendRequestConfig
|
||||
): Promise<SendRequestResponse<D>> => {
|
||||
): Promise<SendRequestResponse<D, E>> => {
|
||||
if (!httpClient) {
|
||||
throw new Error('sendRequest has no http client set');
|
||||
}
|
||||
return _sendRequest<D>(httpClient, config);
|
||||
return _sendRequest<D, E>(httpClient, config);
|
||||
};
|
||||
|
||||
export const useRequest = <D = any>(config: UseRequestConfig) => {
|
||||
export const useRequest = <D = any, E = RequestError>(config: UseRequestConfig) => {
|
||||
if (!httpClient) {
|
||||
throw new Error('sendRequest has no http client set');
|
||||
}
|
||||
return _useRequest<D>(httpClient, config);
|
||||
return _useRequest<D, E>(httpClient, config);
|
||||
};
|
||||
|
||||
export type SendConditionalRequestConfig =
|
||||
| (SendRequestConfig & { shouldSendRequest: true })
|
||||
| (Partial<SendRequestConfig> & { shouldSendRequest: false });
|
||||
|
||||
export const useConditionalRequest = <D = any>(config: SendConditionalRequestConfig) => {
|
||||
export const useConditionalRequest = <D = any, E = RequestError>(
|
||||
config: SendConditionalRequestConfig
|
||||
) => {
|
||||
const [state, setState] = useState<{
|
||||
error: Error | null;
|
||||
error: RequestError | null;
|
||||
data: D | null;
|
||||
isLoading: boolean;
|
||||
}>({
|
||||
|
@ -70,7 +76,7 @@ export const useConditionalRequest = <D = any>(config: SendConditionalRequestCon
|
|||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
const res = await sendRequest<D>({
|
||||
const res = await sendRequest<D, E>({
|
||||
method: config.method,
|
||||
path: config.path,
|
||||
query: config.query,
|
||||
|
|
|
@ -103,7 +103,7 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({
|
|||
<EuiTab isSelected={section === 'data_stream'} href={getHref('data_streams')}>
|
||||
<FormattedMessage
|
||||
id="xpack.ingestManager.appNavigation.dataStreamsLinkText"
|
||||
defaultMessage="Data streams"
|
||||
defaultMessage="Datasets"
|
||||
/>
|
||||
</EuiTab>
|
||||
</EuiTabs>
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
EuiFlexItem,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import { AgentConfig, PackageInfo, NewPackageConfig } from '../../../types';
|
||||
import { AgentConfig, PackageInfo, UpdatePackageConfig } from '../../../types';
|
||||
import {
|
||||
useLink,
|
||||
useBreadcrumbs,
|
||||
|
@ -72,7 +72,7 @@ export const EditPackageConfigPage: React.FunctionComponent = () => {
|
|||
const [loadingError, setLoadingError] = useState<Error>();
|
||||
const [agentConfig, setAgentConfig] = useState<AgentConfig>();
|
||||
const [packageInfo, setPackageInfo] = useState<PackageInfo>();
|
||||
const [packageConfig, setPackageConfig] = useState<NewPackageConfig>({
|
||||
const [packageConfig, setPackageConfig] = useState<UpdatePackageConfig>({
|
||||
name: '',
|
||||
description: '',
|
||||
namespace: '',
|
||||
|
@ -80,6 +80,7 @@ export const EditPackageConfigPage: React.FunctionComponent = () => {
|
|||
enabled: true,
|
||||
output_id: '',
|
||||
inputs: [],
|
||||
version: '',
|
||||
});
|
||||
|
||||
// Retrieve agent config, package, and package config info
|
||||
|
@ -160,7 +161,7 @@ export const EditPackageConfigPage: React.FunctionComponent = () => {
|
|||
const hasErrors = validationResults ? validationHasErrors(validationResults) : false;
|
||||
|
||||
// Update package config method
|
||||
const updatePackageConfig = (updatedFields: Partial<NewPackageConfig>) => {
|
||||
const updatePackageConfig = (updatedFields: Partial<UpdatePackageConfig>) => {
|
||||
const newPackageConfig = {
|
||||
...packageConfig,
|
||||
...updatedFields,
|
||||
|
@ -178,7 +179,7 @@ export const EditPackageConfigPage: React.FunctionComponent = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const updatePackageConfigValidation = (newPackageConfig?: NewPackageConfig) => {
|
||||
const updatePackageConfigValidation = (newPackageConfig?: UpdatePackageConfig) => {
|
||||
if (packageInfo) {
|
||||
const newValidationResult = validatePackageConfig(
|
||||
newPackageConfig || packageConfig,
|
||||
|
@ -234,9 +235,31 @@ export const EditPackageConfigPage: React.FunctionComponent = () => {
|
|||
: undefined,
|
||||
});
|
||||
} else {
|
||||
notifications.toasts.addError(error, {
|
||||
title: 'Error',
|
||||
});
|
||||
if (error.statusCode === 409) {
|
||||
notifications.toasts.addError(error, {
|
||||
title: i18n.translate('xpack.ingestManager.editPackageConfig.failedNotificationTitle', {
|
||||
defaultMessage: `Error updating '{packageConfigName}'`,
|
||||
values: {
|
||||
packageConfigName: packageConfig.name,
|
||||
},
|
||||
}),
|
||||
toastMessage: i18n.translate(
|
||||
'xpack.ingestManager.editPackageConfig.failedConflictNotificationMessage',
|
||||
{
|
||||
defaultMessage: `Data is out of date. Refresh the page to get the latest configuration.`,
|
||||
}
|
||||
),
|
||||
});
|
||||
} else {
|
||||
notifications.toasts.addError(error, {
|
||||
title: i18n.translate('xpack.ingestManager.editPackageConfig.failedNotificationTitle', {
|
||||
defaultMessage: `Error updating '{packageConfigName}'`,
|
||||
values: {
|
||||
packageConfigName: packageConfig.name,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
setFormState('VALID');
|
||||
}
|
||||
};
|
||||
|
|
|
@ -32,7 +32,7 @@ const DataStreamListPageLayout: React.FunctionComponent = ({ children }) => (
|
|||
<h1>
|
||||
<FormattedMessage
|
||||
id="xpack.ingestManager.dataStreamList.pageTitle"
|
||||
defaultMessage="Data streams"
|
||||
defaultMessage="Datasets"
|
||||
/>
|
||||
</h1>
|
||||
</EuiText>
|
||||
|
@ -177,7 +177,7 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => {
|
|||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.ingestManager.dataStreamList.noDataStreamsPrompt"
|
||||
defaultMessage="No data streams"
|
||||
defaultMessage="No datasets"
|
||||
/>
|
||||
</h2>
|
||||
}
|
||||
|
@ -220,14 +220,14 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => {
|
|||
isLoading ? (
|
||||
<FormattedMessage
|
||||
id="xpack.ingestManager.dataStreamList.loadingDataStreamsMessage"
|
||||
defaultMessage="Loading data streams…"
|
||||
defaultMessage="Loading datasets…"
|
||||
/>
|
||||
) : dataStreamsData && !dataStreamsData.data_streams.length ? (
|
||||
emptyPrompt
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.ingestManager.dataStreamList.noFilteredDataStreamsMessage"
|
||||
defaultMessage="No matching data streams found"
|
||||
defaultMessage="No matching datasets found"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -257,7 +257,7 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => {
|
|||
placeholder: i18n.translate(
|
||||
'xpack.ingestManager.dataStreamList.searchPlaceholderTitle',
|
||||
{
|
||||
defaultMessage: 'Filter data streams',
|
||||
defaultMessage: 'Filter datasets',
|
||||
}
|
||||
),
|
||||
incremental: true,
|
||||
|
|
|
@ -13,10 +13,7 @@ const removeRelativePath = (relativePath: string): string =>
|
|||
export function useLinks() {
|
||||
const { http } = useCore();
|
||||
return {
|
||||
toAssets: (path: string) =>
|
||||
http.basePath.prepend(
|
||||
`/plugins/${PLUGIN_ID}/applications/ingest_manager/sections/epm/assets/${path}`
|
||||
),
|
||||
toAssets: (path: string) => http.basePath.prepend(`/plugins/${PLUGIN_ID}/assets/${path}`),
|
||||
toImage: (path: string) => http.basePath.prepend(epmRouteService.getFilePath(path)),
|
||||
toRelativeImage: ({
|
||||
path,
|
||||
|
|
|
@ -51,14 +51,14 @@ export const OverviewDatastreamSection: React.FC = () => {
|
|||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.ingestManager.overviewPageDataStreamsPanelTitle"
|
||||
defaultMessage="Data streams"
|
||||
defaultMessage="Datasets"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<EuiButtonEmpty size="xs" flush="right" href={getHref('data_streams')}>
|
||||
<FormattedMessage
|
||||
id="xpack.ingestManager.overviewPageDataStreamsPanelAction"
|
||||
defaultMessage="View data streams"
|
||||
defaultMessage="View datasets"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</header>
|
||||
|
@ -70,7 +70,7 @@ export const OverviewDatastreamSection: React.FC = () => {
|
|||
<EuiDescriptionListTitle>
|
||||
<FormattedMessage
|
||||
id="xpack.ingestManager.overviewDatastreamTotalTitle"
|
||||
defaultMessage="Data streams"
|
||||
defaultMessage="Datasets"
|
||||
/>
|
||||
</EuiDescriptionListTitle>
|
||||
<EuiDescriptionListDescription>
|
||||
|
|
|
@ -15,6 +15,7 @@ export {
|
|||
EnrollmentAPIKey,
|
||||
PackageConfig,
|
||||
NewPackageConfig,
|
||||
UpdatePackageConfig,
|
||||
PackageConfigInput,
|
||||
PackageConfigInputStream,
|
||||
PackageConfigConfigRecordEntry,
|
||||
|
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 245 KiB |
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 245 KiB |
|
@ -178,7 +178,7 @@ export const updatePackageConfigHandler: RequestHandler<
|
|||
});
|
||||
} catch (e) {
|
||||
return response.customError({
|
||||
statusCode: 500,
|
||||
statusCode: e.statusCode || 500,
|
||||
body: { message: e.message },
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { validateAgentVersion } from './enroll';
|
||||
import { appContextService } from '../app_context';
|
||||
import { IngestManagerAppContext } from '../../plugin';
|
||||
|
||||
describe('validateAgentVersion', () => {
|
||||
it('should throw with agent > kibana version', () => {
|
||||
appContextService.start(({
|
||||
kibanaVersion: '8.0.0',
|
||||
} as unknown) as IngestManagerAppContext);
|
||||
expect(() =>
|
||||
validateAgentVersion({
|
||||
local: { elastic: { agent: { version: '8.8.0' } } },
|
||||
userProvided: {},
|
||||
})
|
||||
).toThrowError(/Agent version is not compatible with kibana version/);
|
||||
});
|
||||
it('should work with agent < kibana version', () => {
|
||||
appContextService.start(({
|
||||
kibanaVersion: '8.0.0',
|
||||
} as unknown) as IngestManagerAppContext);
|
||||
validateAgentVersion({ local: { elastic: { agent: { version: '7.8.0' } } }, userProvided: {} });
|
||||
});
|
||||
|
||||
it('should work with agent = kibana version', () => {
|
||||
appContextService.start(({
|
||||
kibanaVersion: '8.0.0',
|
||||
} as unknown) as IngestManagerAppContext);
|
||||
validateAgentVersion({ local: { elastic: { agent: { version: '8.0.0' } } }, userProvided: {} });
|
||||
});
|
||||
|
||||
it('should work with SNAPSHOT version', () => {
|
||||
appContextService.start(({
|
||||
kibanaVersion: '8.0.0-SNAPSHOT',
|
||||
} as unknown) as IngestManagerAppContext);
|
||||
validateAgentVersion({
|
||||
local: { elastic: { agent: { version: '8.0.0-SNAPSHOT' } } },
|
||||
userProvided: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should work with a agent using SNAPSHOT version', () => {
|
||||
appContextService.start(({
|
||||
kibanaVersion: '7.8.0',
|
||||
} as unknown) as IngestManagerAppContext);
|
||||
validateAgentVersion({
|
||||
local: { elastic: { agent: { version: '7.8.0-SNAPSHOT' } } },
|
||||
userProvided: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should work with a kibana using SNAPSHOT version', () => {
|
||||
appContextService.start(({
|
||||
kibanaVersion: '7.8.0-SNAPSHOT',
|
||||
} as unknown) as IngestManagerAppContext);
|
||||
validateAgentVersion({
|
||||
local: { elastic: { agent: { version: '7.8.0' } } },
|
||||
userProvided: {},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -20,11 +20,7 @@ export async function enroll(
|
|||
metadata?: { local: any; userProvided: any },
|
||||
sharedId?: string
|
||||
): Promise<Agent> {
|
||||
const kibanaVersion = appContextService.getKibanaVersion();
|
||||
const version: string | undefined = metadata?.local?.elastic?.agent?.version;
|
||||
if (!version || semver.compare(version, kibanaVersion) === 1) {
|
||||
throw Boom.badRequest('Agent version is not compatible with kibana version');
|
||||
}
|
||||
validateAgentVersion(metadata);
|
||||
|
||||
const existingAgent = sharedId ? await getAgentBySharedId(soClient, sharedId) : null;
|
||||
|
||||
|
@ -92,3 +88,25 @@ async function getAgentBySharedId(soClient: SavedObjectsClientContract, sharedId
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateAgentVersion(metadata?: { local: any; userProvided: any }) {
|
||||
const kibanaVersion = semver.parse(appContextService.getKibanaVersion());
|
||||
if (!kibanaVersion) {
|
||||
throw Boom.badRequest('Kibana version is not set');
|
||||
}
|
||||
const version = semver.parse(metadata?.local?.elastic?.agent?.version);
|
||||
if (!version) {
|
||||
throw Boom.badRequest('Agent version not provided in metadata.');
|
||||
}
|
||||
|
||||
if (!version || !semver.lte(formatVersion(version), formatVersion(kibanaVersion))) {
|
||||
throw Boom.badRequest('Agent version is not compatible with kibana version');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* used to remove prelease from version as includePrerelease in not working as expected
|
||||
*/
|
||||
function formatVersion(version: semver.SemVer) {
|
||||
return `${version.major}.${version.minor}.${version.patch}`;
|
||||
}
|
||||
|
|
|
@ -94,6 +94,7 @@ exports[`tests loading base.yml: base.yml 1`] = `
|
|||
"data_stream": {
|
||||
"timestamp_field": "@timestamp"
|
||||
},
|
||||
"composed_of": [],
|
||||
"_meta": {
|
||||
"package": {
|
||||
"name": "nginx"
|
||||
|
@ -197,6 +198,7 @@ exports[`tests loading coredns.logs.yml: coredns.logs.yml 1`] = `
|
|||
"data_stream": {
|
||||
"timestamp_field": "@timestamp"
|
||||
},
|
||||
"composed_of": [],
|
||||
"_meta": {
|
||||
"package": {
|
||||
"name": "coredns"
|
||||
|
@ -1684,6 +1686,7 @@ exports[`tests loading system.yml: system.yml 1`] = `
|
|||
"data_stream": {
|
||||
"timestamp_field": "@timestamp"
|
||||
},
|
||||
"composed_of": [],
|
||||
"_meta": {
|
||||
"package": {
|
||||
"name": "system"
|
||||
|
|
|
@ -5,7 +5,13 @@
|
|||
*/
|
||||
|
||||
import Boom from 'boom';
|
||||
import { Dataset, RegistryPackage, ElasticsearchAssetType, TemplateRef } from '../../../../types';
|
||||
import {
|
||||
Dataset,
|
||||
RegistryPackage,
|
||||
ElasticsearchAssetType,
|
||||
TemplateRef,
|
||||
RegistryElasticsearch,
|
||||
} from '../../../../types';
|
||||
import { CallESAsCurrentUser } from '../../../../types';
|
||||
import { Field, loadFieldsFromYaml, processFields } from '../../fields/field';
|
||||
import { getPipelineNameForInstallation } from '../ingest_pipeline/install';
|
||||
|
@ -157,6 +163,98 @@ export async function installTemplateForDataset({
|
|||
});
|
||||
}
|
||||
|
||||
function putComponentTemplate(
|
||||
body: object | undefined,
|
||||
name: string,
|
||||
callCluster: CallESAsCurrentUser
|
||||
): { clusterPromise: Promise<any>; name: string } | undefined {
|
||||
if (body) {
|
||||
const callClusterParams: {
|
||||
method: string;
|
||||
path: string;
|
||||
ignore: number[];
|
||||
body: any;
|
||||
} = {
|
||||
method: 'PUT',
|
||||
path: `/_component_template/${name}`,
|
||||
ignore: [404],
|
||||
body,
|
||||
};
|
||||
|
||||
return { clusterPromise: callCluster('transport.request', callClusterParams), name };
|
||||
}
|
||||
}
|
||||
|
||||
function buildComponentTemplates(registryElasticsearch: RegistryElasticsearch | undefined) {
|
||||
let mappingsTemplate;
|
||||
let settingsTemplate;
|
||||
|
||||
if (registryElasticsearch && registryElasticsearch['index_template.mappings']) {
|
||||
mappingsTemplate = {
|
||||
template: {
|
||||
mappings: {
|
||||
...registryElasticsearch['index_template.mappings'],
|
||||
// temporary change until https://github.com/elastic/elasticsearch/issues/58956 is resolved
|
||||
// hopefully we'll be able to remove the entire properties section once that issue is resolved
|
||||
properties: {
|
||||
// if the timestamp_field changes here: https://github.com/elastic/kibana/blob/master/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts#L309
|
||||
// we'll need to update this as well
|
||||
'@timestamp': {
|
||||
type: 'date',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (registryElasticsearch && registryElasticsearch['index_template.settings']) {
|
||||
settingsTemplate = {
|
||||
template: {
|
||||
settings: registryElasticsearch['index_template.settings'],
|
||||
},
|
||||
};
|
||||
}
|
||||
return { settingsTemplate, mappingsTemplate };
|
||||
}
|
||||
|
||||
async function installDatasetComponentTemplates(
|
||||
templateName: string,
|
||||
registryElasticsearch: RegistryElasticsearch | undefined,
|
||||
callCluster: CallESAsCurrentUser
|
||||
) {
|
||||
const templates: string[] = [];
|
||||
const componentPromises: Array<Promise<any>> = [];
|
||||
|
||||
const compTemplates = buildComponentTemplates(registryElasticsearch);
|
||||
|
||||
const mappings = putComponentTemplate(
|
||||
compTemplates.mappingsTemplate,
|
||||
`${templateName}-mappings`,
|
||||
callCluster
|
||||
);
|
||||
|
||||
const settings = putComponentTemplate(
|
||||
compTemplates.settingsTemplate,
|
||||
`${templateName}-settings`,
|
||||
callCluster
|
||||
);
|
||||
|
||||
if (mappings) {
|
||||
templates.push(mappings.name);
|
||||
componentPromises.push(mappings.clusterPromise);
|
||||
}
|
||||
|
||||
if (settings) {
|
||||
templates.push(settings.name);
|
||||
componentPromises.push(settings.clusterPromise);
|
||||
}
|
||||
|
||||
// TODO: Check return values for errors
|
||||
await Promise.all(componentPromises);
|
||||
return templates;
|
||||
}
|
||||
|
||||
export async function installTemplate({
|
||||
callCluster,
|
||||
fields,
|
||||
|
@ -180,13 +278,22 @@ export async function installTemplate({
|
|||
packageVersion,
|
||||
});
|
||||
}
|
||||
|
||||
const composedOfTemplates = await installDatasetComponentTemplates(
|
||||
templateName,
|
||||
dataset.elasticsearch,
|
||||
callCluster
|
||||
);
|
||||
|
||||
const template = getTemplate({
|
||||
type: dataset.type,
|
||||
templateName,
|
||||
mappings,
|
||||
pipelineName,
|
||||
packageName,
|
||||
composedOfTemplates,
|
||||
});
|
||||
|
||||
// TODO: Check return values for errors
|
||||
const callClusterParams: {
|
||||
method: string;
|
||||
|
|
|
@ -29,10 +29,37 @@ test('get template', () => {
|
|||
templateName,
|
||||
packageName: 'nginx',
|
||||
mappings: { properties: {} },
|
||||
composedOfTemplates: [],
|
||||
});
|
||||
expect(template.index_patterns).toStrictEqual([`${templateName}-*`]);
|
||||
});
|
||||
|
||||
test('adds composed_of correctly', () => {
|
||||
const composedOfTemplates = ['component1', 'component2'];
|
||||
|
||||
const template = getTemplate({
|
||||
type: 'logs',
|
||||
templateName: 'name',
|
||||
packageName: 'nginx',
|
||||
mappings: { properties: {} },
|
||||
composedOfTemplates,
|
||||
});
|
||||
expect(template.composed_of).toStrictEqual(composedOfTemplates);
|
||||
});
|
||||
|
||||
test('adds empty composed_of correctly', () => {
|
||||
const composedOfTemplates: string[] = [];
|
||||
|
||||
const template = getTemplate({
|
||||
type: 'logs',
|
||||
templateName: 'name',
|
||||
packageName: 'nginx',
|
||||
mappings: { properties: {} },
|
||||
composedOfTemplates,
|
||||
});
|
||||
expect(template.composed_of).toStrictEqual(composedOfTemplates);
|
||||
});
|
||||
|
||||
test('tests loading base.yml', () => {
|
||||
const ymlPath = path.join(__dirname, '../../fields/tests/base.yml');
|
||||
const fieldsYML = readFileSync(ymlPath, 'utf-8');
|
||||
|
@ -45,6 +72,7 @@ test('tests loading base.yml', () => {
|
|||
templateName: 'foo',
|
||||
packageName: 'nginx',
|
||||
mappings,
|
||||
composedOfTemplates: [],
|
||||
});
|
||||
|
||||
expect(template).toMatchSnapshot(path.basename(ymlPath));
|
||||
|
@ -62,6 +90,7 @@ test('tests loading coredns.logs.yml', () => {
|
|||
templateName: 'foo',
|
||||
packageName: 'coredns',
|
||||
mappings,
|
||||
composedOfTemplates: [],
|
||||
});
|
||||
|
||||
expect(template).toMatchSnapshot(path.basename(ymlPath));
|
||||
|
@ -79,6 +108,7 @@ test('tests loading system.yml', () => {
|
|||
templateName: 'whatsthis',
|
||||
packageName: 'system',
|
||||
mappings,
|
||||
composedOfTemplates: [],
|
||||
});
|
||||
|
||||
expect(template).toMatchSnapshot(path.basename(ymlPath));
|
||||
|
|
|
@ -43,14 +43,16 @@ export function getTemplate({
|
|||
mappings,
|
||||
pipelineName,
|
||||
packageName,
|
||||
composedOfTemplates,
|
||||
}: {
|
||||
type: string;
|
||||
templateName: string;
|
||||
mappings: IndexTemplateMappings;
|
||||
pipelineName?: string | undefined;
|
||||
packageName: string;
|
||||
composedOfTemplates: string[];
|
||||
}): IndexTemplate {
|
||||
const template = getBaseTemplate(type, templateName, mappings, packageName);
|
||||
const template = getBaseTemplate(type, templateName, mappings, packageName, composedOfTemplates);
|
||||
if (pipelineName) {
|
||||
template.template.settings.index.default_pipeline = pipelineName;
|
||||
}
|
||||
|
@ -244,7 +246,8 @@ function getBaseTemplate(
|
|||
type: string,
|
||||
templateName: string,
|
||||
mappings: IndexTemplateMappings,
|
||||
packageName: string
|
||||
packageName: string,
|
||||
composedOfTemplates: string[]
|
||||
): IndexTemplate {
|
||||
return {
|
||||
// This takes precedence over all index templates installed by ES by default (logs-*-* and metrics-*-*)
|
||||
|
@ -308,6 +311,7 @@ function getBaseTemplate(
|
|||
data_stream: {
|
||||
timestamp_field: '@timestamp',
|
||||
},
|
||||
composed_of: composedOfTemplates,
|
||||
_meta: {
|
||||
package: {
|
||||
name: packageName,
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import {
|
||||
SavedObject,
|
||||
SavedObjectsBulkCreateObject,
|
||||
SavedObjectsClientContract,
|
||||
} from 'src/core/server';
|
||||
import * as Registry from '../../registry';
|
||||
import { AssetType, KibanaAssetType, AssetReference } from '../../../../types';
|
||||
|
||||
type SavedObjectToBe = Required<SavedObjectsBulkCreateObject> & { type: AssetType };
|
||||
export type ArchiveAsset = Pick<
|
||||
SavedObject,
|
||||
'id' | 'attributes' | 'migrationVersion' | 'references'
|
||||
> & {
|
||||
type: AssetType;
|
||||
};
|
||||
|
||||
export async function getKibanaAsset(key: string) {
|
||||
const buffer = Registry.getAsset(key);
|
||||
|
||||
// cache values are buffers. convert to string / JSON
|
||||
return JSON.parse(buffer.toString('utf8'));
|
||||
}
|
||||
|
||||
export function createSavedObjectKibanaAsset(
|
||||
jsonAsset: ArchiveAsset,
|
||||
pkgName: string
|
||||
): SavedObjectToBe {
|
||||
// convert that to an object
|
||||
const asset = changeAssetIds(jsonAsset, pkgName);
|
||||
|
||||
return {
|
||||
type: asset.type,
|
||||
id: asset.id,
|
||||
attributes: asset.attributes,
|
||||
references: asset.references || [],
|
||||
migrationVersion: asset.migrationVersion || {},
|
||||
};
|
||||
}
|
||||
|
||||
// modifies id property and the id property of references objects (not index-pattern)
|
||||
// to be prepended with the package name to distinguish assets from Beats modules' assets
|
||||
export const changeAssetIds = (asset: ArchiveAsset, pkgName: string): ArchiveAsset => {
|
||||
const references = asset.references.map((ref) => {
|
||||
if (ref.type === KibanaAssetType.indexPattern) return ref;
|
||||
const id = getAssetId(ref.id, pkgName);
|
||||
return { ...ref, id };
|
||||
});
|
||||
return {
|
||||
...asset,
|
||||
id: getAssetId(asset.id, pkgName),
|
||||
references,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAssetId = (id: string, pkgName: string) => {
|
||||
return `${pkgName}-${id}`;
|
||||
};
|
||||
|
||||
// TODO: make it an exhaustive list
|
||||
// e.g. switch statement with cases for each enum key returning `never` for default case
|
||||
export async function installKibanaAssets(options: {
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
pkgName: string;
|
||||
paths: string[];
|
||||
}) {
|
||||
const { savedObjectsClient, paths, pkgName } = options;
|
||||
|
||||
// Only install Kibana assets during package installation.
|
||||
const kibanaAssetTypes = Object.values(KibanaAssetType);
|
||||
const installationPromises = kibanaAssetTypes.map((assetType) =>
|
||||
installKibanaSavedObjects({ savedObjectsClient, assetType, paths, pkgName })
|
||||
);
|
||||
|
||||
// installKibanaSavedObjects returns AssetReference[], so .map creates AssetReference[][]
|
||||
// call .flat to flatten into one dimensional array
|
||||
return Promise.all(installationPromises).then((results) => results.flat());
|
||||
}
|
||||
|
||||
async function installKibanaSavedObjects({
|
||||
savedObjectsClient,
|
||||
assetType,
|
||||
paths,
|
||||
pkgName,
|
||||
}: {
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
assetType: KibanaAssetType;
|
||||
paths: string[];
|
||||
pkgName: string;
|
||||
}) {
|
||||
const isSameType = (path: string) => assetType === Registry.pathParts(path).type;
|
||||
const pathsOfType = paths.filter((path) => isSameType(path));
|
||||
const kibanaAssets = await Promise.all(pathsOfType.map((path) => getKibanaAsset(path)));
|
||||
const toBeSavedObjects = await Promise.all(
|
||||
kibanaAssets.map((asset) => createSavedObjectKibanaAsset(asset, pkgName))
|
||||
);
|
||||
|
||||
if (toBeSavedObjects.length === 0) {
|
||||
return [];
|
||||
} else {
|
||||
const createResults = await savedObjectsClient.bulkCreate(toBeSavedObjects, {
|
||||
overwrite: true,
|
||||
});
|
||||
const createdObjects = createResults.saved_objects;
|
||||
const installed = createdObjects.map(toAssetReference);
|
||||
return installed;
|
||||
}
|
||||
}
|
||||
|
||||
function toAssetReference({ id, type }: SavedObject) {
|
||||
const reference: AssetReference = { id, type: type as KibanaAssetType };
|
||||
|
||||
return reference;
|
||||
}
|
133
x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/tests/__snapshots__/install.test.ts.snap
generated
Normal file
|
@ -0,0 +1,133 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`a kibana asset id and its reference ids are appended with package name changeAssetIds output matches snapshot: dashboard.json 1`] = `
|
||||
{
|
||||
"attributes": {
|
||||
"description": "Overview dashboard for the Nginx integration in Metrics",
|
||||
"hits": 0,
|
||||
"kibanaSavedObjectMeta": {
|
||||
"searchSourceJSON": {
|
||||
"filter": [],
|
||||
"highlightAll": true,
|
||||
"query": {
|
||||
"language": "kuery",
|
||||
"query": ""
|
||||
},
|
||||
"version": true
|
||||
}
|
||||
},
|
||||
"optionsJSON": {
|
||||
"darkTheme": false,
|
||||
"hidePanelTitles": false,
|
||||
"useMargins": true
|
||||
},
|
||||
"panelsJSON": [
|
||||
{
|
||||
"embeddableConfig": {},
|
||||
"gridData": {
|
||||
"h": 12,
|
||||
"i": "1",
|
||||
"w": 24,
|
||||
"x": 24,
|
||||
"y": 0
|
||||
},
|
||||
"panelIndex": "1",
|
||||
"panelRefName": "panel_0",
|
||||
"version": "7.3.0"
|
||||
},
|
||||
{
|
||||
"embeddableConfig": {},
|
||||
"gridData": {
|
||||
"h": 12,
|
||||
"i": "2",
|
||||
"w": 24,
|
||||
"x": 24,
|
||||
"y": 12
|
||||
},
|
||||
"panelIndex": "2",
|
||||
"panelRefName": "panel_1",
|
||||
"version": "7.3.0"
|
||||
},
|
||||
{
|
||||
"embeddableConfig": {},
|
||||
"gridData": {
|
||||
"h": 12,
|
||||
"i": "3",
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 12
|
||||
},
|
||||
"panelIndex": "3",
|
||||
"panelRefName": "panel_2",
|
||||
"version": "7.3.0"
|
||||
},
|
||||
{
|
||||
"embeddableConfig": {},
|
||||
"gridData": {
|
||||
"h": 12,
|
||||
"i": "4",
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"panelIndex": "4",
|
||||
"panelRefName": "panel_3",
|
||||
"version": "7.3.0"
|
||||
},
|
||||
{
|
||||
"embeddableConfig": {},
|
||||
"gridData": {
|
||||
"h": 12,
|
||||
"i": "5",
|
||||
"w": 48,
|
||||
"x": 0,
|
||||
"y": 24
|
||||
},
|
||||
"panelIndex": "5",
|
||||
"panelRefName": "panel_4",
|
||||
"version": "7.3.0"
|
||||
}
|
||||
],
|
||||
"timeRestore": false,
|
||||
"title": "[Metrics Nginx] Overview ECS",
|
||||
"version": 1
|
||||
},
|
||||
"id": "nginx-023d2930-f1a5-11e7-a9ef-93c69af7b129-ecs",
|
||||
"migrationVersion": {
|
||||
"dashboard": "7.3.0"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"id": "metrics-*",
|
||||
"name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index",
|
||||
"type": "index-pattern"
|
||||
},
|
||||
{
|
||||
"id": "nginx-555df8a0-f1a1-11e7-a9ef-93c69af7b129-ecs",
|
||||
"name": "panel_0",
|
||||
"type": "search"
|
||||
},
|
||||
{
|
||||
"id": "nginx-a1d92240-f1a1-11e7-a9ef-93c69af7b129-ecs",
|
||||
"name": "panel_1",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"id": "nginx-d763a570-f1a1-11e7-a9ef-93c69af7b129-ecs",
|
||||
"name": "panel_2",
|
||||
"type": "dashboard"
|
||||
},
|
||||
{
|
||||
"id": "nginx-47a8e0f0-f1a4-11e7-a9ef-93c69af7b129-ecs",
|
||||
"name": "panel_3",
|
||||
"type": "visualization"
|
||||
},
|
||||
{
|
||||
"id": "nginx-dcbffe30-f1a4-11e7-a9ef-93c69af7b129-ecs",
|
||||
"name": "panel_4",
|
||||
"type": "visualization"
|
||||
}
|
||||
],
|
||||
"type": "dashboard"
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,129 @@
|
|||
{
|
||||
"attributes": {
|
||||
"description": "Overview dashboard for the Nginx integration in Metrics",
|
||||
"hits": 0,
|
||||
"kibanaSavedObjectMeta": {
|
||||
"searchSourceJSON": {
|
||||
"filter": [],
|
||||
"highlightAll": true,
|
||||
"query": {
|
||||
"language": "kuery",
|
||||
"query": ""
|
||||
},
|
||||
"version": true
|
||||
}
|
||||
},
|
||||
"optionsJSON": {
|
||||
"darkTheme": false,
|
||||
"hidePanelTitles": false,
|
||||
"useMargins": true
|
||||
},
|
||||
"panelsJSON": [
|
||||
{
|
||||
"embeddableConfig": {},
|
||||
"gridData": {
|
||||
"h": 12,
|
||||
"i": "1",
|
||||
"w": 24,
|
||||
"x": 24,
|
||||
"y": 0
|
||||
},
|
||||
"panelIndex": "1",
|
||||
"panelRefName": "panel_0",
|
||||
"version": "7.3.0"
|
||||
},
|
||||
{
|
||||
"embeddableConfig": {},
|
||||
"gridData": {
|
||||
"h": 12,
|
||||
"i": "2",
|
||||
"w": 24,
|
||||
"x": 24,
|
||||
"y": 12
|
||||
},
|
||||
"panelIndex": "2",
|
||||
"panelRefName": "panel_1",
|
||||
"version": "7.3.0"
|
||||
},
|
||||
{
|
||||
"embeddableConfig": {},
|
||||
"gridData": {
|
||||
"h": 12,
|
||||
"i": "3",
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 12
|
||||
},
|
||||
"panelIndex": "3",
|
||||
"panelRefName": "panel_2",
|
||||
"version": "7.3.0"
|
||||
},
|
||||
{
|
||||
"embeddableConfig": {},
|
||||
"gridData": {
|
||||
"h": 12,
|
||||
"i": "4",
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"panelIndex": "4",
|
||||
"panelRefName": "panel_3",
|
||||
"version": "7.3.0"
|
||||
},
|
||||
{
|
||||
"embeddableConfig": {},
|
||||
"gridData": {
|
||||
"h": 12,
|
||||
"i": "5",
|
||||
"w": 48,
|
||||
"x": 0,
|
||||
"y": 24
|
||||
},
|
||||
"panelIndex": "5",
|
||||
"panelRefName": "panel_4",
|
||||
"version": "7.3.0"
|
||||
}
|
||||
],
|
||||
"timeRestore": false,
|
||||
"title": "[Metrics Nginx] Overview ECS",
|
||||
"version": 1
|
||||
},
|
||||
"id": "023d2930-f1a5-11e7-a9ef-93c69af7b129-ecs",
|
||||
"migrationVersion": {
|
||||
"dashboard": "7.3.0"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"id": "metrics-*",
|
||||
"name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index",
|
||||
"type": "index-pattern"
|
||||
},
|
||||
{
|
||||
"id": "555df8a0-f1a1-11e7-a9ef-93c69af7b129-ecs",
|
||||
"name": "panel_0",
|
||||
"type": "search"
|
||||
},
|
||||
{
|
||||
"id": "a1d92240-f1a1-11e7-a9ef-93c69af7b129-ecs",
|
||||
"name": "panel_1",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"id": "d763a570-f1a1-11e7-a9ef-93c69af7b129-ecs",
|
||||
"name": "panel_2",
|
||||
"type": "dashboard"
|
||||
},
|
||||
{
|
||||
"id": "47a8e0f0-f1a4-11e7-a9ef-93c69af7b129-ecs",
|
||||
"name": "panel_3",
|
||||
"type": "visualization"
|
||||
},
|
||||
{
|
||||
"id": "dcbffe30-f1a4-11e7-a9ef-93c69af7b129-ecs",
|
||||
"name": "panel_4",
|
||||
"type": "visualization"
|
||||
}
|
||||
],
|
||||
"type": "dashboard"
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { getAssetId, changeAssetIds } from '../install';
|
||||
|
||||
expect.addSnapshotSerializer({
|
||||
print(val) {
|
||||
return JSON.stringify(val, null, 2);
|
||||
},
|
||||
|
||||
test(val) {
|
||||
return val;
|
||||
},
|
||||
});
|
||||
|
||||
describe('a kibana asset id and its reference ids are appended with package name', () => {
|
||||
const assetPath = path.join(__dirname, './dashboard.json');
|
||||
const kibanaAsset = JSON.parse(readFileSync(assetPath, 'utf-8'));
|
||||
const pkgName = 'nginx';
|
||||
const modifiedAssetObject = changeAssetIds(kibanaAsset, pkgName);
|
||||
|
||||
test('changeAssetIds output matches snapshot', () => {
|
||||
expect(modifiedAssetObject).toMatchSnapshot(path.basename(assetPath));
|
||||
});
|
||||
|
||||
test('getAssetId', () => {
|
||||
const id = '47a8e0f0-f1a4-11e7-a9ef-93c69af7b129-ecs';
|
||||
expect(getAssetId(id, pkgName)).toBe(`${pkgName}-${id}`);
|
||||
});
|
||||
});
|
|
@ -1,32 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { SavedObject, SavedObjectsBulkCreateObject } from 'src/core/server';
|
||||
import { AssetType } from '../../../types';
|
||||
import * as Registry from '../registry';
|
||||
|
||||
type ArchiveAsset = Pick<SavedObject, 'attributes' | 'migrationVersion' | 'references'>;
|
||||
type SavedObjectToBe = Required<SavedObjectsBulkCreateObject> & { type: AssetType };
|
||||
|
||||
export async function getObject(key: string) {
|
||||
const buffer = Registry.getAsset(key);
|
||||
|
||||
// cache values are buffers. convert to string / JSON
|
||||
const json = buffer.toString('utf8');
|
||||
// convert that to an object
|
||||
const asset: ArchiveAsset = JSON.parse(json);
|
||||
|
||||
const { type, file } = Registry.pathParts(key);
|
||||
const savedObject: SavedObjectToBe = {
|
||||
type,
|
||||
id: file.replace('.json', ''),
|
||||
attributes: asset.attributes,
|
||||
references: asset.references || [],
|
||||
migrationVersion: asset.migrationVersion || {},
|
||||
};
|
||||
|
||||
return savedObject;
|
||||
}
|
|
@ -23,7 +23,7 @@ export {
|
|||
SearchParams,
|
||||
} from './get';
|
||||
|
||||
export { installKibanaAssets, installPackage, ensureInstalledPackage } from './install';
|
||||
export { installPackage, ensureInstalledPackage } from './install';
|
||||
export { removeInstallation } from './remove';
|
||||
|
||||
type RequiredPackage = 'system' | 'endpoint';
|
||||
|
|
|
@ -4,13 +4,12 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { SavedObject, SavedObjectsClientContract } from 'src/core/server';
|
||||
import { SavedObjectsClientContract } from 'src/core/server';
|
||||
import Boom from 'boom';
|
||||
import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants';
|
||||
import {
|
||||
AssetReference,
|
||||
Installation,
|
||||
KibanaAssetType,
|
||||
CallESAsCurrentUser,
|
||||
DefaultPackages,
|
||||
ElasticsearchAssetType,
|
||||
|
@ -18,7 +17,7 @@ import {
|
|||
} from '../../../types';
|
||||
import { installIndexPatterns } from '../kibana/index_pattern/install';
|
||||
import * as Registry from '../registry';
|
||||
import { getObject } from './get_objects';
|
||||
import { installKibanaAssets } from '../kibana/assets/install';
|
||||
import { getInstallation, getInstallationObject, isRequiredPackage } from './index';
|
||||
import { installTemplates } from '../elasticsearch/template/install';
|
||||
import { generateESIndexPatterns } from '../elasticsearch/template/template';
|
||||
|
@ -121,7 +120,6 @@ export async function installPackage(options: {
|
|||
installKibanaAssets({
|
||||
savedObjectsClient,
|
||||
pkgName,
|
||||
pkgVersion,
|
||||
paths,
|
||||
}),
|
||||
installPipelines(registryPackageInfo, paths, callCluster),
|
||||
|
@ -185,27 +183,6 @@ export async function installPackage(options: {
|
|||
});
|
||||
}
|
||||
|
||||
// TODO: make it an exhaustive list
|
||||
// e.g. switch statement with cases for each enum key returning `never` for default case
|
||||
export async function installKibanaAssets(options: {
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
pkgName: string;
|
||||
pkgVersion: string;
|
||||
paths: string[];
|
||||
}) {
|
||||
const { savedObjectsClient, paths } = options;
|
||||
|
||||
// Only install Kibana assets during package installation.
|
||||
const kibanaAssetTypes = Object.values(KibanaAssetType);
|
||||
const installationPromises = kibanaAssetTypes.map(async (assetType) =>
|
||||
installKibanaSavedObjects({ savedObjectsClient, assetType, paths })
|
||||
);
|
||||
|
||||
// installKibanaSavedObjects returns AssetReference[], so .map creates AssetReference[][]
|
||||
// call .flat to flatten into one dimensional array
|
||||
return Promise.all(installationPromises).then((results) => results.flat());
|
||||
}
|
||||
|
||||
export async function saveInstallationReferences(options: {
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
pkgName: string;
|
||||
|
@ -240,34 +217,3 @@ export async function saveInstallationReferences(options: {
|
|||
|
||||
return toSaveAssetRefs;
|
||||
}
|
||||
|
||||
async function installKibanaSavedObjects({
|
||||
savedObjectsClient,
|
||||
assetType,
|
||||
paths,
|
||||
}: {
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
assetType: KibanaAssetType;
|
||||
paths: string[];
|
||||
}) {
|
||||
const isSameType = (path: string) => assetType === Registry.pathParts(path).type;
|
||||
const pathsOfType = paths.filter((path) => isSameType(path));
|
||||
const toBeSavedObjects = await Promise.all(pathsOfType.map(getObject));
|
||||
|
||||
if (toBeSavedObjects.length === 0) {
|
||||
return [];
|
||||
} else {
|
||||
const createResults = await savedObjectsClient.bulkCreate(toBeSavedObjects, {
|
||||
overwrite: true,
|
||||
});
|
||||
const createdObjects = createResults.saved_objects;
|
||||
const installed = createdObjects.map(toAssetReference);
|
||||
return installed;
|
||||
}
|
||||
}
|
||||
|
||||
function toAssetReference({ id, type }: SavedObject) {
|
||||
const reference: AssetReference = { id, type: type as KibanaAssetType };
|
||||
|
||||
return reference;
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
import { PACKAGE_CONFIG_SAVED_OBJECT_TYPE } from '../constants';
|
||||
import {
|
||||
NewPackageConfig,
|
||||
UpdatePackageConfig,
|
||||
PackageConfig,
|
||||
ListWithKuery,
|
||||
PackageConfigSOAttributes,
|
||||
|
@ -60,6 +61,7 @@ class PackageConfigService {
|
|||
|
||||
return {
|
||||
id: newSo.id,
|
||||
version: newSo.version,
|
||||
...newSo.attributes,
|
||||
};
|
||||
}
|
||||
|
@ -71,7 +73,7 @@ class PackageConfigService {
|
|||
options?: { user?: AuthenticatedUser }
|
||||
): Promise<PackageConfig[]> {
|
||||
const isoDate = new Date().toISOString();
|
||||
const { saved_objects: newSos } = await soClient.bulkCreate<Omit<PackageConfig, 'id'>>(
|
||||
const { saved_objects: newSos } = await soClient.bulkCreate<PackageConfigSOAttributes>(
|
||||
packageConfigs.map((packageConfig) => ({
|
||||
type: SAVED_OBJECT_TYPE,
|
||||
attributes: {
|
||||
|
@ -98,6 +100,7 @@ class PackageConfigService {
|
|||
|
||||
return newSos.map((newSo) => ({
|
||||
id: newSo.id,
|
||||
version: newSo.version,
|
||||
...newSo.attributes,
|
||||
}));
|
||||
}
|
||||
|
@ -117,6 +120,7 @@ class PackageConfigService {
|
|||
|
||||
return {
|
||||
id: packageConfigSO.id,
|
||||
version: packageConfigSO.version,
|
||||
...packageConfigSO.attributes,
|
||||
};
|
||||
}
|
||||
|
@ -137,6 +141,7 @@ class PackageConfigService {
|
|||
|
||||
return packageConfigSO.saved_objects.map((so) => ({
|
||||
id: so.id,
|
||||
version: so.version,
|
||||
...so.attributes,
|
||||
}));
|
||||
}
|
||||
|
@ -163,8 +168,9 @@ class PackageConfigService {
|
|||
});
|
||||
|
||||
return {
|
||||
items: packageConfigs.saved_objects.map<PackageConfig>((packageConfigSO) => ({
|
||||
items: packageConfigs.saved_objects.map((packageConfigSO) => ({
|
||||
id: packageConfigSO.id,
|
||||
version: packageConfigSO.version,
|
||||
...packageConfigSO.attributes,
|
||||
})),
|
||||
total: packageConfigs.total,
|
||||
|
@ -176,21 +182,29 @@ class PackageConfigService {
|
|||
public async update(
|
||||
soClient: SavedObjectsClientContract,
|
||||
id: string,
|
||||
packageConfig: NewPackageConfig,
|
||||
packageConfig: UpdatePackageConfig,
|
||||
options?: { user?: AuthenticatedUser }
|
||||
): Promise<PackageConfig> {
|
||||
const oldPackageConfig = await this.get(soClient, id);
|
||||
const { version, ...restOfPackageConfig } = packageConfig;
|
||||
|
||||
if (!oldPackageConfig) {
|
||||
throw new Error('Package config not found');
|
||||
}
|
||||
|
||||
await soClient.update<PackageConfigSOAttributes>(SAVED_OBJECT_TYPE, id, {
|
||||
...packageConfig,
|
||||
revision: oldPackageConfig.revision + 1,
|
||||
updated_at: new Date().toISOString(),
|
||||
updated_by: options?.user?.username ?? 'system',
|
||||
});
|
||||
await soClient.update<PackageConfigSOAttributes>(
|
||||
SAVED_OBJECT_TYPE,
|
||||
id,
|
||||
{
|
||||
...restOfPackageConfig,
|
||||
revision: oldPackageConfig.revision + 1,
|
||||
updated_at: new Date().toISOString(),
|
||||
updated_by: options?.user?.username ?? 'system',
|
||||
},
|
||||
{
|
||||
version,
|
||||
}
|
||||
);
|
||||
|
||||
// Bump revision of associated agent config
|
||||
await agentConfigService.bumpRevision(soClient, packageConfig.config_id, {
|
||||
|
|
|
@ -21,6 +21,7 @@ export {
|
|||
PackageConfigInput,
|
||||
PackageConfigInputStream,
|
||||
NewPackageConfig,
|
||||
UpdatePackageConfig,
|
||||
PackageConfigSOAttributes,
|
||||
FullAgentConfigInput,
|
||||
FullAgentConfig,
|
||||
|
@ -40,6 +41,7 @@ export {
|
|||
PackageInfo,
|
||||
RegistryVarsEntry,
|
||||
Dataset,
|
||||
RegistryElasticsearch,
|
||||
AssetReference,
|
||||
ElasticsearchAssetType,
|
||||
IngestAssetType,
|
||||
|
|
|
@ -66,7 +66,13 @@ export const NewPackageConfigSchema = schema.object({
|
|||
...PackageConfigBaseSchema,
|
||||
});
|
||||
|
||||
export const UpdatePackageConfigSchema = schema.object({
|
||||
...PackageConfigBaseSchema,
|
||||
version: schema.maybe(schema.string()),
|
||||
});
|
||||
|
||||
export const PackageConfigSchema = schema.object({
|
||||
...PackageConfigBaseSchema,
|
||||
id: schema.string(),
|
||||
version: schema.maybe(schema.string()),
|
||||
});
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { NewPackageConfigSchema } from '../models';
|
||||
import { NewPackageConfigSchema, UpdatePackageConfigSchema } from '../models';
|
||||
import { ListWithKuerySchema } from './index';
|
||||
|
||||
export const GetPackageConfigsRequestSchema = {
|
||||
|
@ -23,7 +23,7 @@ export const CreatePackageConfigRequestSchema = {
|
|||
|
||||
export const UpdatePackageConfigRequestSchema = {
|
||||
...GetOnePackageConfigRequestSchema,
|
||||
body: NewPackageConfigSchema,
|
||||
body: UpdatePackageConfigSchema,
|
||||
};
|
||||
|
||||
export const DeletePackageConfigsRequestSchema = {
|
||||
|
|
|
@ -67,6 +67,8 @@ export function requiredValidator() {
|
|||
|
||||
export type ValidationResult = object | null;
|
||||
|
||||
export type MemoryInputValidatorResult = { invalidUnits: { allowedUnits: string } } | null;
|
||||
|
||||
export function memoryInputValidator(allowedUnits = ALLOWED_DATA_UNITS) {
|
||||
return (value: any) => {
|
||||
if (typeof value !== 'string' || value === '') {
|
||||
|
|
|
@ -45,6 +45,7 @@ function getError(error) {
|
|||
export function CustomSelectionTable({
|
||||
checkboxDisabledCheck,
|
||||
columns,
|
||||
currentPage = 0,
|
||||
filterDefaultFields,
|
||||
filters,
|
||||
items,
|
||||
|
@ -52,6 +53,7 @@ export function CustomSelectionTable({
|
|||
onTableChange,
|
||||
radioDisabledCheck,
|
||||
selectedIds,
|
||||
setCurrentPaginationData,
|
||||
singleSelection,
|
||||
sortableProperties,
|
||||
tableItemId = 'id',
|
||||
|
@ -80,7 +82,7 @@ export function CustomSelectionTable({
|
|||
}, [selectedIds]); // eslint-disable-line
|
||||
|
||||
useEffect(() => {
|
||||
const tablePager = new Pager(currentItems.length, itemsPerPage);
|
||||
const tablePager = new Pager(currentItems.length, itemsPerPage, currentPage);
|
||||
setPagerSettings({
|
||||
itemsPerPage: itemsPerPage,
|
||||
firstItemIndex: tablePager.getFirstItemIndex(),
|
||||
|
@ -124,6 +126,13 @@ export function CustomSelectionTable({
|
|||
}
|
||||
}
|
||||
|
||||
if (setCurrentPaginationData) {
|
||||
setCurrentPaginationData({
|
||||
pageIndex: pager.getCurrentPageIndex(),
|
||||
itemsPerPage: pagerSettings.itemsPerPage,
|
||||
});
|
||||
}
|
||||
|
||||
onTableChange(currentSelected);
|
||||
}
|
||||
|
||||
|
@ -389,6 +398,7 @@ export function CustomSelectionTable({
|
|||
CustomSelectionTable.propTypes = {
|
||||
checkboxDisabledCheck: PropTypes.func,
|
||||
columns: PropTypes.array.isRequired,
|
||||
currentPage: PropTypes.number,
|
||||
filterDefaultFields: PropTypes.array,
|
||||
filters: PropTypes.array,
|
||||
items: PropTypes.array.isRequired,
|
||||
|
@ -396,6 +406,7 @@ CustomSelectionTable.propTypes = {
|
|||
onTableChange: PropTypes.func.isRequired,
|
||||
radioDisabledCheck: PropTypes.func,
|
||||
selectedId: PropTypes.array,
|
||||
setCurrentPaginationData: PropTypes.func,
|
||||
singleSelection: PropTypes.bool,
|
||||
sortableProperties: PropTypes.object,
|
||||
tableItemId: PropTypes.string,
|
||||
|
|
|
@ -327,9 +327,14 @@ export const isClassificationEvaluateResponse = (
|
|||
);
|
||||
};
|
||||
|
||||
export interface UpdateDataFrameAnalyticsConfig {
|
||||
allow_lazy_start?: string;
|
||||
description?: string;
|
||||
model_memory_limit?: string;
|
||||
}
|
||||
|
||||
export interface DataFrameAnalyticsConfig {
|
||||
id: DataFrameAnalyticsId;
|
||||
// Description attribute is not supported yet
|
||||
description?: string;
|
||||
dest: {
|
||||
index: IndexName;
|
||||
|
|
|
@ -13,6 +13,7 @@ export {
|
|||
useRefreshAnalyticsList,
|
||||
DataFrameAnalyticsId,
|
||||
DataFrameAnalyticsConfig,
|
||||
UpdateDataFrameAnalyticsConfig,
|
||||
IndexName,
|
||||
IndexPattern,
|
||||
REFRESH_ANALYTICS_LIST_STATE,
|
||||
|
|
|
@ -24,6 +24,7 @@ export const useResultsViewConfig = (jobId: string) => {
|
|||
const mlContext = useMlContext();
|
||||
const [indexPattern, setIndexPattern] = useState<IndexPattern | undefined>(undefined);
|
||||
const [isInitialized, setIsInitialized] = useState<boolean>(false);
|
||||
const [needsDestIndexPattern, setNeedsDestIndexPattern] = useState<boolean>(false);
|
||||
const [isLoadingJobConfig, setIsLoadingJobConfig] = useState<boolean>(false);
|
||||
const [jobConfig, setJobConfig] = useState<DataFrameAnalyticsConfig | undefined>(undefined);
|
||||
const [jobCapsServiceErrorMessage, setJobCapsServiceErrorMessage] = useState<undefined | string>(
|
||||
|
@ -68,6 +69,7 @@ export const useResultsViewConfig = (jobId: string) => {
|
|||
}
|
||||
|
||||
if (indexP === undefined) {
|
||||
setNeedsDestIndexPattern(true);
|
||||
const sourceIndex = jobConfigUpdate.source.index[0];
|
||||
const sourceIndexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex;
|
||||
indexP = await mlContext.indexPatterns.get(sourceIndexPatternId);
|
||||
|
@ -100,5 +102,6 @@ export const useResultsViewConfig = (jobId: string) => {
|
|||
jobConfig,
|
||||
jobConfigErrorMessage,
|
||||
jobStatus,
|
||||
needsDestIndexPattern,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -19,14 +19,19 @@ export const AdvancedStep: FC<CreateAnalyticsStepProps> = ({
|
|||
setCurrentStep,
|
||||
stepActivated,
|
||||
}) => {
|
||||
const showForm = step === ANALYTICS_STEPS.ADVANCED;
|
||||
const showDetails = step !== ANALYTICS_STEPS.ADVANCED && stepActivated === true;
|
||||
|
||||
const dataTestSubj = `mlAnalyticsCreateJobWizardAdvancedStep${showForm ? ' active' : ''}${
|
||||
showDetails ? ' summary' : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<EuiForm>
|
||||
{step === ANALYTICS_STEPS.ADVANCED && (
|
||||
<EuiForm data-test-subj={dataTestSubj}>
|
||||
{showForm && (
|
||||
<AdvancedStepForm actions={actions} state={state} setCurrentStep={setCurrentStep} />
|
||||
)}
|
||||
{step !== ANALYTICS_STEPS.ADVANCED && stepActivated === true && (
|
||||
<AdvancedStepDetails setCurrentStep={setCurrentStep} state={state} />
|
||||
)}
|
||||
{showDetails && <AdvancedStepDetails setCurrentStep={setCurrentStep} state={state} />}
|
||||
</EuiForm>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -47,7 +47,7 @@ export const AdvancedStepForm: FC<CreateAnalyticsStepProps> = ({
|
|||
const [advancedParamErrors, setAdvancedParamErrors] = useState<AdvancedParamErrors>({});
|
||||
const [fetchingAdvancedParamErrors, setFetchingAdvancedParamErrors] = useState<boolean>(false);
|
||||
|
||||
const { setFormState } = actions;
|
||||
const { setEstimatedModelMemoryLimit, setFormState } = actions;
|
||||
const { form, isJobCreated } = state;
|
||||
const {
|
||||
computeFeatureInfluence,
|
||||
|
@ -87,10 +87,15 @@ export const AdvancedStepForm: FC<CreateAnalyticsStepProps> = ({
|
|||
useEffect(() => {
|
||||
setFetchingAdvancedParamErrors(true);
|
||||
(async function () {
|
||||
const { success, errorMessage } = await fetchExplainData(form);
|
||||
const { success, errorMessage, expectedMemory } = await fetchExplainData(form);
|
||||
const paramErrors: AdvancedParamErrors = {};
|
||||
|
||||
if (!success) {
|
||||
if (success) {
|
||||
if (modelMemoryLimit !== expectedMemory) {
|
||||
setEstimatedModelMemoryLimit(expectedMemory);
|
||||
setFormState({ modelMemoryLimit: expectedMemory });
|
||||
}
|
||||
} else {
|
||||
// Check which field is invalid
|
||||
Object.values(ANALYSIS_ADVANCED_FIELDS).forEach((param) => {
|
||||
if (errorMessage.includes(`[${param}]`)) {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { FC, Fragment, memo, useEffect, useState } from 'react';
|
||||
import React, { FC, Fragment, useEffect, useState } from 'react';
|
||||
import { EuiCallOut, EuiFormRow, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui';
|
||||
// @ts-ignore no declaration
|
||||
import { LEFT_ALIGNMENT, CENTER_ALIGNMENT, SortableProperties } from '@elastic/eui/lib/services';
|
||||
|
@ -14,6 +14,13 @@ import { FieldSelectionItem } from '../../../../common/analytics';
|
|||
// @ts-ignore could not find declaration file
|
||||
import { CustomSelectionTable } from '../../../../../components/custom_selection_table';
|
||||
|
||||
const minimumFieldsMessage = i18n.translate(
|
||||
'xpack.ml.dataframe.analytics.create.analysisFieldsTable.minimumFieldsMessage',
|
||||
{
|
||||
defaultMessage: 'At least one field must be selected.',
|
||||
}
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
id: 'checkbox',
|
||||
|
@ -22,9 +29,12 @@ const columns = [
|
|||
width: '32px',
|
||||
},
|
||||
{
|
||||
label: i18n.translate('xpack.ml.dataframe.analytics.create.analyticsTable.fieldNameColumn', {
|
||||
defaultMessage: 'Field name',
|
||||
}),
|
||||
label: i18n.translate(
|
||||
'xpack.ml.dataframe.analytics.create.analysisFieldsTable.fieldNameColumn',
|
||||
{
|
||||
defaultMessage: 'Field name',
|
||||
}
|
||||
),
|
||||
id: 'name',
|
||||
isSortable: true,
|
||||
alignment: LEFT_ALIGNMENT,
|
||||
|
@ -68,140 +78,154 @@ const columns = [
|
|||
];
|
||||
|
||||
const checkboxDisabledCheck = (item: FieldSelectionItem) =>
|
||||
(item.is_included === false && !item.reason?.includes('in excludes list')) ||
|
||||
item.is_required === true;
|
||||
item.is_required === true || (item.reason && item.reason.includes('unsupported type'));
|
||||
|
||||
export const MemoizedAnalysisFieldsTable: FC<{
|
||||
excludes: string[];
|
||||
export const AnalysisFieldsTable: FC<{
|
||||
dependentVariable?: string;
|
||||
includes: string[];
|
||||
loadingItems: boolean;
|
||||
setFormState: any;
|
||||
setFormState: React.Dispatch<React.SetStateAction<any>>;
|
||||
tableItems: FieldSelectionItem[];
|
||||
}> = memo(
|
||||
({ excludes, loadingItems, setFormState, tableItems }) => {
|
||||
const [sortableProperties, setSortableProperties] = useState();
|
||||
const [currentSelection, setCurrentSelection] = useState<any[]>([]);
|
||||
}> = ({ dependentVariable, includes, loadingItems, setFormState, tableItems }) => {
|
||||
const [sortableProperties, setSortableProperties] = useState();
|
||||
const [currentPaginationData, setCurrentPaginationData] = useState<{
|
||||
pageIndex: number;
|
||||
itemsPerPage: number;
|
||||
}>({ pageIndex: 0, itemsPerPage: 5 });
|
||||
const [minimumFieldsRequiredMessage, setMinimumFieldsRequiredMessage] = useState<
|
||||
undefined | string
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (excludes.length > 0) {
|
||||
setCurrentSelection(excludes);
|
||||
}
|
||||
}, [tableItems]);
|
||||
useEffect(() => {
|
||||
if (includes.length === 0 && tableItems.length > 0) {
|
||||
const includedFields: string[] = [];
|
||||
tableItems.forEach((field) => {
|
||||
if (field.is_included === true) {
|
||||
includedFields.push(field.name);
|
||||
}
|
||||
});
|
||||
setFormState({ includes: includedFields });
|
||||
} else if (includes.length > 0) {
|
||||
setFormState({ includes });
|
||||
}
|
||||
setMinimumFieldsRequiredMessage(undefined);
|
||||
}, [tableItems]);
|
||||
|
||||
// Only set form state on unmount to prevent re-renders due to props changing if exludes was updated on each selection
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setFormState({ excludes: currentSelection });
|
||||
};
|
||||
}, [currentSelection]);
|
||||
useEffect(() => {
|
||||
let sortablePropertyItems = [];
|
||||
const defaultSortProperty = 'name';
|
||||
|
||||
useEffect(() => {
|
||||
let sortablePropertyItems = [];
|
||||
const defaultSortProperty = 'name';
|
||||
|
||||
sortablePropertyItems = [
|
||||
{
|
||||
name: 'name',
|
||||
getValue: (item: any) => item.name.toLowerCase(),
|
||||
isAscending: true,
|
||||
},
|
||||
{
|
||||
name: 'is_included',
|
||||
getValue: (item: any) => item.is_included,
|
||||
isAscending: true,
|
||||
},
|
||||
{
|
||||
name: 'is_required',
|
||||
getValue: (item: any) => item.is_required,
|
||||
isAscending: true,
|
||||
},
|
||||
];
|
||||
const sortableProps = new SortableProperties(sortablePropertyItems, defaultSortProperty);
|
||||
|
||||
setSortableProperties(sortableProps);
|
||||
}, []);
|
||||
|
||||
const filters = [
|
||||
sortablePropertyItems = [
|
||||
{
|
||||
type: 'field_value_selection',
|
||||
field: 'is_included',
|
||||
name: i18n.translate('xpack.ml.dataframe.analytics.create.excludedFilterLabel', {
|
||||
defaultMessage: 'Is included',
|
||||
}),
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{
|
||||
value: true,
|
||||
view: (
|
||||
<EuiText grow={false}>
|
||||
{i18n.translate('xpack.ml.dataframe.analytics.create.isIncludedOption', {
|
||||
defaultMessage: 'Yes',
|
||||
})}
|
||||
</EuiText>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
view: (
|
||||
<EuiText grow={false}>
|
||||
{i18n.translate('xpack.ml.dataframe.analytics.create.isNotIncludedOption', {
|
||||
defaultMessage: 'No',
|
||||
})}
|
||||
</EuiText>
|
||||
),
|
||||
},
|
||||
],
|
||||
name: 'name',
|
||||
getValue: (item: any) => item.name.toLowerCase(),
|
||||
isAscending: true,
|
||||
},
|
||||
{
|
||||
name: 'is_included',
|
||||
getValue: (item: any) => item.is_included,
|
||||
isAscending: true,
|
||||
},
|
||||
{
|
||||
name: 'is_required',
|
||||
getValue: (item: any) => item.is_required,
|
||||
isAscending: true,
|
||||
},
|
||||
];
|
||||
const sortableProps = new SortableProperties(sortablePropertyItems, defaultSortProperty);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.ml.dataframe.analytics.create.excludedFieldsLabel', {
|
||||
defaultMessage: 'Excluded fields',
|
||||
setSortableProperties(sortableProps);
|
||||
}, []);
|
||||
|
||||
const filters = [
|
||||
{
|
||||
type: 'field_value_toggle_group',
|
||||
field: 'is_included',
|
||||
items: [
|
||||
{
|
||||
value: true,
|
||||
name: i18n.translate('xpack.ml.dataframe.analytics.create.isIncludedOption', {
|
||||
defaultMessage: 'Is included',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
name: i18n.translate('xpack.ml.dataframe.analytics.create.isNotIncludedOption', {
|
||||
defaultMessage: 'Is not included',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.ml.dataframe.analytics.create.includedFieldsLabel', {
|
||||
defaultMessage: 'Included fields',
|
||||
})}
|
||||
isInvalid={minimumFieldsRequiredMessage !== undefined}
|
||||
error={minimumFieldsRequiredMessage}
|
||||
>
|
||||
<Fragment />
|
||||
</EuiFormRow>
|
||||
{tableItems.length > 0 && minimumFieldsRequiredMessage === undefined && (
|
||||
<EuiText size="xs">
|
||||
{i18n.translate('xpack.ml.dataframe.analytics.create.includedFieldsCount', {
|
||||
defaultMessage:
|
||||
'{numFields, plural, one {# field} other {# fields}} included in the analysis',
|
||||
values: { numFields: includes.length },
|
||||
})}
|
||||
</EuiText>
|
||||
)}
|
||||
{tableItems.length === 0 && (
|
||||
<EuiCallOut
|
||||
title={i18n.translate('xpack.ml.dataframe.analytics.create.calloutTitle', {
|
||||
defaultMessage: 'Analysis fields not available',
|
||||
})}
|
||||
helpText={i18n.translate(
|
||||
'xpack.ml.dataframe.analytics.create.excludedFieldsLabelHelpText',
|
||||
{
|
||||
defaultMessage: 'From included fields, select fields to exclude from analysis.',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Fragment />
|
||||
</EuiFormRow>
|
||||
{tableItems.length === 0 && (
|
||||
<EuiCallOut
|
||||
title={i18n.translate('xpack.ml.dataframe.analytics.create.calloutTitle', {
|
||||
defaultMessage: 'Analysis fields not available',
|
||||
})}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.dataframe.analytics.create.calloutMessage"
|
||||
defaultMessage="Additional data required to load analysis fields."
|
||||
/>
|
||||
</EuiCallOut>
|
||||
)}
|
||||
{tableItems.length > 0 && (
|
||||
<EuiPanel paddingSize="m" data-test-subj="mlAnalyticsCreateJobWizardExcludesSelect">
|
||||
<CustomSelectionTable
|
||||
data-test-subj="mlAnalyticsCreationAnalysisFieldsTable"
|
||||
checkboxDisabledCheck={checkboxDisabledCheck}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
items={tableItems}
|
||||
itemsPerPage={5}
|
||||
onTableChange={(selection: FieldSelectionItem[]) => {
|
||||
setCurrentSelection(selection);
|
||||
}}
|
||||
selectedIds={currentSelection}
|
||||
singleSelection={false}
|
||||
sortableProperties={sortableProperties}
|
||||
tableItemId={'name'}
|
||||
/>
|
||||
</EuiPanel>
|
||||
)}
|
||||
<EuiSpacer />
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => prevProps.tableItems.length === nextProps.tableItems.length
|
||||
);
|
||||
<FormattedMessage
|
||||
id="xpack.ml.dataframe.analytics.create.calloutMessage"
|
||||
defaultMessage="Additional data required to load analysis fields."
|
||||
/>
|
||||
</EuiCallOut>
|
||||
)}
|
||||
{tableItems.length > 0 && (
|
||||
<EuiPanel paddingSize="m" data-test-subj="mlAnalyticsCreateJobWizardIncludesSelect">
|
||||
<CustomSelectionTable
|
||||
currentPage={currentPaginationData.pageIndex}
|
||||
data-test-subj="mlAnalyticsCreationAnalysisFieldsTable"
|
||||
checkboxDisabledCheck={checkboxDisabledCheck}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
items={tableItems}
|
||||
itemsPerPage={currentPaginationData.itemsPerPage}
|
||||
onTableChange={(selection: string[]) => {
|
||||
// dependent variable must always be in includes
|
||||
if (
|
||||
dependentVariable !== undefined &&
|
||||
dependentVariable !== '' &&
|
||||
selection.length === 0
|
||||
) {
|
||||
selection = [dependentVariable];
|
||||
}
|
||||
// If nothing selected show minimum fields required message and don't update form yet
|
||||
if (selection.length === 0) {
|
||||
setMinimumFieldsRequiredMessage(minimumFieldsMessage);
|
||||
} else {
|
||||
setMinimumFieldsRequiredMessage(undefined);
|
||||
setFormState({ includes: selection });
|
||||
}
|
||||
}}
|
||||
selectedIds={includes}
|
||||
setCurrentPaginationData={setCurrentPaginationData}
|
||||
singleSelection={false}
|
||||
sortableProperties={sortableProperties}
|
||||
tableItemId={'name'}
|
||||
/>
|
||||
</EuiPanel>
|
||||
)}
|
||||
<EuiSpacer />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -19,17 +19,19 @@ export const ConfigurationStep: FC<CreateAnalyticsStepProps> = ({
|
|||
step,
|
||||
stepActivated,
|
||||
}) => {
|
||||
const showForm = step === ANALYTICS_STEPS.CONFIGURATION;
|
||||
const showDetails = step !== ANALYTICS_STEPS.CONFIGURATION && stepActivated === true;
|
||||
|
||||
const dataTestSubj = `mlAnalyticsCreateJobWizardConfigurationStep${showForm ? ' active' : ''}${
|
||||
showDetails ? ' summary' : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<EuiForm
|
||||
className="mlDataFrameAnalyticsCreateForm"
|
||||
data-test-subj="mlAnalyticsCreateJobWizardConfigurationStep"
|
||||
>
|
||||
{step === ANALYTICS_STEPS.CONFIGURATION && (
|
||||
<EuiForm className="mlDataFrameAnalyticsCreateForm" data-test-subj={dataTestSubj}>
|
||||
{showForm && (
|
||||
<ConfigurationStepForm actions={actions} state={state} setCurrentStep={setCurrentStep} />
|
||||
)}
|
||||
{step !== ANALYTICS_STEPS.CONFIGURATION && stepActivated === true && (
|
||||
<ConfigurationStepDetails setCurrentStep={setCurrentStep} state={state} />
|
||||
)}
|
||||
{showDetails && <ConfigurationStepDetails setCurrentStep={setCurrentStep} state={state} />}
|
||||
</EuiForm>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -21,6 +21,8 @@ import { ANALYSIS_CONFIG_TYPE } from '../../../../common/analytics';
|
|||
import { useMlContext } from '../../../../../contexts/ml';
|
||||
import { ANALYTICS_STEPS } from '../../page';
|
||||
|
||||
const MAX_INCLUDES_LENGTH = 5;
|
||||
|
||||
interface Props {
|
||||
setCurrentStep: React.Dispatch<React.SetStateAction<any>>;
|
||||
state: State;
|
||||
|
@ -30,7 +32,7 @@ export const ConfigurationStepDetails: FC<Props> = ({ setCurrentStep, state }) =
|
|||
const mlContext = useMlContext();
|
||||
const { currentIndexPattern } = mlContext;
|
||||
const { form, isJobCreated } = state;
|
||||
const { dependentVariable, excludes, jobConfigQueryString, jobType, trainingPercent } = form;
|
||||
const { dependentVariable, includes, jobConfigQueryString, jobType, trainingPercent } = form;
|
||||
|
||||
const isJobTypeWithDepVar =
|
||||
jobType === ANALYSIS_CONFIG_TYPE.REGRESSION || jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION;
|
||||
|
@ -61,10 +63,15 @@ export const ConfigurationStepDetails: FC<Props> = ({ setCurrentStep, state }) =
|
|||
|
||||
const detailsThirdCol = [
|
||||
{
|
||||
title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.excludedFields', {
|
||||
defaultMessage: 'Excluded fields',
|
||||
title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.includedFields', {
|
||||
defaultMessage: 'Included fields',
|
||||
}),
|
||||
description: excludes.length > 0 ? excludes.join(', ') : UNSET_CONFIG_ITEM,
|
||||
description:
|
||||
includes.length > MAX_INCLUDES_LENGTH
|
||||
? `${includes.slice(0, MAX_INCLUDES_LENGTH).join(', ')} ... (and ${
|
||||
includes.length - MAX_INCLUDES_LENGTH
|
||||
} more)`
|
||||
: includes.join(', '),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@ import { ANALYTICS_STEPS } from '../../page';
|
|||
import { ContinueButton } from '../continue_button';
|
||||
import { JobType } from './job_type';
|
||||
import { SupportedFieldsMessage } from './supported_fields_message';
|
||||
import { MemoizedAnalysisFieldsTable } from './analysis_fields_table';
|
||||
import { AnalysisFieldsTable } from './analysis_fields_table';
|
||||
import { DataGrid } from '../../../../../components/data_grid';
|
||||
import { fetchExplainData } from '../shared';
|
||||
import { useIndexData } from '../../hooks';
|
||||
|
@ -49,7 +49,8 @@ import { useSavedSearch } from './use_saved_search';
|
|||
const requiredFieldsErrorText = i18n.translate(
|
||||
'xpack.ml.dataframe.analytics.createWizard.requiredFieldsErrorMessage',
|
||||
{
|
||||
defaultMessage: 'At least one field must be included in the analysis.',
|
||||
defaultMessage:
|
||||
'At least one field must be included in the analysis in addition to the dependent variable.',
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -69,17 +70,20 @@ export const ConfigurationStepForm: FC<CreateAnalyticsStepProps> = ({
|
|||
const [dependentVariableOptions, setDependentVariableOptions] = useState<
|
||||
EuiComboBoxOptionOption[]
|
||||
>([]);
|
||||
const [excludesTableItems, setExcludesTableItems] = useState<FieldSelectionItem[]>([]);
|
||||
const [includesTableItems, setIncludesTableItems] = useState<FieldSelectionItem[]>([]);
|
||||
const [maxDistinctValuesError, setMaxDistinctValuesError] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [unsupportedFieldsError, setUnsupportedFieldsError] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const { setEstimatedModelMemoryLimit, setFormState } = actions;
|
||||
const { estimatedModelMemoryLimit, form, isJobCreated, requestMessages } = state;
|
||||
const firstUpdate = useRef<boolean>(true);
|
||||
const {
|
||||
dependentVariable,
|
||||
excludes,
|
||||
includes,
|
||||
jobConfigQuery,
|
||||
jobConfigQueryString,
|
||||
jobType,
|
||||
|
@ -117,7 +121,8 @@ export const ConfigurationStepForm: FC<CreateAnalyticsStepProps> = ({
|
|||
dependentVariableEmpty ||
|
||||
jobType === undefined ||
|
||||
maxDistinctValuesError !== undefined ||
|
||||
requiredFieldsError !== undefined;
|
||||
requiredFieldsError !== undefined ||
|
||||
unsupportedFieldsError !== undefined;
|
||||
|
||||
const loadDepVarOptions = async (formState: State['form']) => {
|
||||
setLoadingDepVarOptions(true);
|
||||
|
@ -187,7 +192,8 @@ export const ConfigurationStepForm: FC<CreateAnalyticsStepProps> = ({
|
|||
setLoadingFieldOptions(false);
|
||||
setFieldOptionsFetchFail(false);
|
||||
setMaxDistinctValuesError(undefined);
|
||||
setExcludesTableItems(fieldSelection ? fieldSelection : []);
|
||||
setUnsupportedFieldsError(undefined);
|
||||
setIncludesTableItems(fieldSelection ? fieldSelection : []);
|
||||
setFormState({
|
||||
...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemory } : {}),
|
||||
requiredFieldsError: !hasRequiredFields ? requiredFieldsErrorText : undefined,
|
||||
|
@ -200,6 +206,7 @@ export const ConfigurationStepForm: FC<CreateAnalyticsStepProps> = ({
|
|||
}
|
||||
} else {
|
||||
let maxDistinctValuesErrorMessage;
|
||||
let unsupportedFieldsErrorMessage;
|
||||
if (
|
||||
jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION &&
|
||||
errorMessage.includes('status_exception') &&
|
||||
|
@ -208,6 +215,10 @@ export const ConfigurationStepForm: FC<CreateAnalyticsStepProps> = ({
|
|||
maxDistinctValuesErrorMessage = errorMessage;
|
||||
}
|
||||
|
||||
if (errorMessage.includes('status_exception') && errorMessage.includes('unsupported type')) {
|
||||
unsupportedFieldsErrorMessage = errorMessage;
|
||||
}
|
||||
|
||||
if (
|
||||
errorMessage.includes('status_exception') &&
|
||||
errorMessage.includes('Unable to estimate memory usage as no documents')
|
||||
|
@ -231,6 +242,7 @@ export const ConfigurationStepForm: FC<CreateAnalyticsStepProps> = ({
|
|||
setLoadingFieldOptions(false);
|
||||
setFieldOptionsFetchFail(true);
|
||||
setMaxDistinctValuesError(maxDistinctValuesErrorMessage);
|
||||
setUnsupportedFieldsError(unsupportedFieldsErrorMessage);
|
||||
setFormState({
|
||||
...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: fallbackModelMemoryLimit } : {}),
|
||||
});
|
||||
|
@ -267,7 +279,7 @@ export const ConfigurationStepForm: FC<CreateAnalyticsStepProps> = ({
|
|||
return () => {
|
||||
debouncedGetExplainData.cancel();
|
||||
};
|
||||
}, [jobType, dependentVariable, trainingPercent, JSON.stringify(excludes), jobConfigQueryString]);
|
||||
}, [jobType, dependentVariable, trainingPercent, JSON.stringify(includes), jobConfigQueryString]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
@ -392,21 +404,32 @@ export const ConfigurationStepForm: FC<CreateAnalyticsStepProps> = ({
|
|||
)}
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
isInvalid={requiredFieldsError !== undefined}
|
||||
error={
|
||||
requiredFieldsError !== undefined && [
|
||||
i18n.translate('xpack.ml.dataframe.analytics.create.requiredFieldsError', {
|
||||
defaultMessage: 'Invalid. {message}',
|
||||
values: { message: requiredFieldsError },
|
||||
}),
|
||||
]
|
||||
}
|
||||
isInvalid={requiredFieldsError !== undefined || unsupportedFieldsError !== undefined}
|
||||
error={[
|
||||
...(requiredFieldsError !== undefined
|
||||
? [
|
||||
i18n.translate('xpack.ml.dataframe.analytics.create.requiredFieldsError', {
|
||||
defaultMessage: 'Invalid. {message}',
|
||||
values: { message: requiredFieldsError },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(unsupportedFieldsError !== undefined
|
||||
? [
|
||||
i18n.translate('xpack.ml.dataframe.analytics.create.unsupportedFieldsError', {
|
||||
defaultMessage: 'Invalid. {message}',
|
||||
values: { message: unsupportedFieldsError },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
>
|
||||
<Fragment />
|
||||
</EuiFormRow>
|
||||
<MemoizedAnalysisFieldsTable
|
||||
excludes={excludes}
|
||||
tableItems={excludesTableItems}
|
||||
<AnalysisFieldsTable
|
||||
dependentVariable={dependentVariable}
|
||||
includes={includes}
|
||||
tableItems={includesTableItems}
|
||||
loadingItems={loadingFieldOptions}
|
||||
setFormState={setFormState}
|
||||
/>
|
||||
|
|
|
@ -71,7 +71,7 @@ export const JobType: FC<Props> = ({ type, setFormState }) => {
|
|||
setFormState({
|
||||
previousJobType: type,
|
||||
jobType: value,
|
||||
excludes: [],
|
||||
includes: [],
|
||||
requiredFieldsError: undefined,
|
||||
});
|
||||
}}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { FC, Fragment, useState } from 'react';
|
||||
import React, { FC, useState } from 'react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiCheckbox,
|
||||
|
@ -45,7 +45,7 @@ export const CreateStep: FC<Props> = ({ actions, state, step }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div data-test-subj="mlAnalyticsCreateJobWizardCreateStep active">
|
||||
{!isJobCreated && !isJobStarted && (
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
|
@ -88,6 +88,6 @@ export const CreateStep: FC<Props> = ({ actions, state, step }) => {
|
|||
<Messages messages={requestMessages} />
|
||||
{isJobCreated === true && showProgress && <ProgressStats jobId={jobId} />}
|
||||
{isJobCreated === true && <BackToListPanel />}
|
||||
</Fragment>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -19,14 +19,19 @@ export const DetailsStep: FC<CreateAnalyticsStepProps> = ({
|
|||
step,
|
||||
stepActivated,
|
||||
}) => {
|
||||
const showForm = step === ANALYTICS_STEPS.DETAILS;
|
||||
const showDetails = step !== ANALYTICS_STEPS.DETAILS && stepActivated === true;
|
||||
|
||||
const dataTestSubj = `mlAnalyticsCreateJobWizardDetailsStep${showForm ? ' active' : ''}${
|
||||
showDetails ? ' summary' : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<EuiForm className="mlDataFrameAnalyticsCreateForm">
|
||||
{step === ANALYTICS_STEPS.DETAILS && (
|
||||
<EuiForm className="mlDataFrameAnalyticsCreateForm" data-test-subj={dataTestSubj}>
|
||||
{showForm && (
|
||||
<DetailsStepForm actions={actions} state={state} setCurrentStep={setCurrentStep} />
|
||||
)}
|
||||
{step !== ANALYTICS_STEPS.DETAILS && stepActivated === true && (
|
||||
<DetailsStepDetails setCurrentStep={setCurrentStep} state={state} />
|
||||
)}
|
||||
{showDetails && <DetailsStepDetails setCurrentStep={setCurrentStep} state={state} />}
|
||||
</EuiForm>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,7 +5,15 @@
|
|||
*/
|
||||
|
||||
import React, { FC, Fragment, useRef } from 'react';
|
||||
import { EuiFieldText, EuiFormRow, EuiLink, EuiSpacer, EuiSwitch, EuiTextArea } from '@elastic/eui';
|
||||
import {
|
||||
EuiFieldText,
|
||||
EuiFormRow,
|
||||
EuiLink,
|
||||
EuiSpacer,
|
||||
EuiSwitch,
|
||||
EuiText,
|
||||
EuiTextArea,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { useMlKibana } from '../../../../../contexts/kibana';
|
||||
|
@ -188,15 +196,32 @@ export const DetailsStepForm: FC<CreateAnalyticsStepProps> = ({
|
|||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
isInvalid={createIndexPattern && destinationIndexPatternTitleExists}
|
||||
error={
|
||||
createIndexPattern &&
|
||||
destinationIndexPatternTitleExists && [
|
||||
i18n.translate('xpack.ml.dataframe.analytics.create.indexPatternExistsError', {
|
||||
defaultMessage: 'An index pattern with this title already exists.',
|
||||
}),
|
||||
]
|
||||
fullWidth
|
||||
isInvalid={
|
||||
(createIndexPattern && destinationIndexPatternTitleExists) || !createIndexPattern
|
||||
}
|
||||
error={[
|
||||
...(createIndexPattern && destinationIndexPatternTitleExists
|
||||
? [
|
||||
i18n.translate('xpack.ml.dataframe.analytics.create.indexPatternExistsError', {
|
||||
defaultMessage: 'An index pattern with this title already exists.',
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(!createIndexPattern
|
||||
? [
|
||||
<EuiText size="xs" color="warning">
|
||||
{i18n.translate(
|
||||
'xpack.ml.dataframe.analytics.create.shouldCreateIndexPatternMessage',
|
||||
{
|
||||
defaultMessage:
|
||||
'You may not be able to view job results if an index pattern is not created for the destination index.',
|
||||
}
|
||||
)}
|
||||
</EuiText>,
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
>
|
||||
<EuiSwitch
|
||||
disabled={isJobCreated}
|
||||
|
|
|
@ -109,7 +109,6 @@ export const Page: FC<Props> = ({ jobId }) => {
|
|||
/>
|
||||
),
|
||||
status: currentStep >= ANALYTICS_STEPS.ADVANCED ? undefined : ('incomplete' as EuiStepStatus),
|
||||
'data-test-subj': 'mlAnalyticsCreateJobWizardAdvancedStep',
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.ml.dataframe.analytics.creation.detailsStepTitle', {
|
||||
|
@ -124,7 +123,6 @@ export const Page: FC<Props> = ({ jobId }) => {
|
|||
/>
|
||||
),
|
||||
status: currentStep >= ANALYTICS_STEPS.DETAILS ? undefined : ('incomplete' as EuiStepStatus),
|
||||
'data-test-subj': 'mlAnalyticsCreateJobWizardDetailsStep',
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.ml.dataframe.analytics.creation.createStepTitle', {
|
||||
|
@ -132,7 +130,6 @@ export const Page: FC<Props> = ({ jobId }) => {
|
|||
}),
|
||||
children: <CreateStep {...createAnalyticsForm} step={currentStep} />,
|
||||
status: currentStep >= ANALYTICS_STEPS.CREATE ? undefined : ('incomplete' as EuiStepStatus),
|
||||
'data-test-subj': 'mlAnalyticsCreateJobWizardCreateStep',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -38,6 +38,7 @@ export const ExplorationPageWrapper: FC<Props> = ({ jobId, title, EvaluatePanel
|
|||
jobConfig,
|
||||
jobConfigErrorMessage,
|
||||
jobStatus,
|
||||
needsDestIndexPattern,
|
||||
} = useResultsViewConfig(jobId);
|
||||
const [searchQuery, setSearchQuery] = useState<ResultsSearchQuery>(defaultSearchQuery);
|
||||
|
||||
|
@ -64,9 +65,10 @@ export const ExplorationPageWrapper: FC<Props> = ({ jobId, title, EvaluatePanel
|
|||
indexPattern !== undefined &&
|
||||
isInitialized === true && (
|
||||
<ExplorationResultsTable
|
||||
jobConfig={jobConfig}
|
||||
indexPattern={indexPattern}
|
||||
jobConfig={jobConfig}
|
||||
jobStatus={jobStatus}
|
||||
needsDestIndexPattern={needsDestIndexPattern}
|
||||
setEvaluateSearchQuery={setSearchQuery}
|
||||
title={title}
|
||||
/>
|
||||
|
|
|
@ -33,6 +33,7 @@ import { getTaskStateBadge } from '../../../analytics_management/components/anal
|
|||
import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common';
|
||||
import { ExplorationTitle } from '../exploration_title';
|
||||
import { ExplorationQueryBar } from '../exploration_query_bar';
|
||||
import { IndexPatternPrompt } from '../index_pattern_prompt';
|
||||
|
||||
import { useExplorationResults } from './use_exploration_results';
|
||||
|
||||
|
@ -55,12 +56,20 @@ interface Props {
|
|||
indexPattern: IndexPattern;
|
||||
jobConfig: DataFrameAnalyticsConfig;
|
||||
jobStatus?: DATA_FRAME_TASK_STATE;
|
||||
needsDestIndexPattern: boolean;
|
||||
setEvaluateSearchQuery: React.Dispatch<React.SetStateAction<object>>;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const ExplorationResultsTable: FC<Props> = React.memo(
|
||||
({ indexPattern, jobConfig, jobStatus, setEvaluateSearchQuery, title }) => {
|
||||
({
|
||||
indexPattern,
|
||||
jobConfig,
|
||||
jobStatus,
|
||||
needsDestIndexPattern,
|
||||
setEvaluateSearchQuery,
|
||||
title,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState<SavedSearchQuery>(defaultSearchQuery);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -119,6 +128,7 @@ export const ExplorationResultsTable: FC<Props> = React.memo(
|
|||
id="mlDataFrameAnalyticsTableResultsPanel"
|
||||
data-test-subj="mlDFAnalyticsExplorationTablePanel"
|
||||
>
|
||||
{needsDestIndexPattern && <IndexPatternPrompt destIndex={jobConfig.dest.index} />}
|
||||
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export { IndexPatternPrompt } from './index_pattern_prompt';
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiLink, EuiSpacer, EuiText } from '@elastic/eui';
|
||||
import { useMlKibana } from '../../../../../contexts/kibana';
|
||||
|
||||
interface Props {
|
||||
destIndex: string;
|
||||
}
|
||||
|
||||
export const IndexPatternPrompt: FC<Props> = ({ destIndex }) => {
|
||||
const {
|
||||
services: {
|
||||
http: { basePath },
|
||||
},
|
||||
} = useMlKibana();
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiText size="xs" color="warning">
|
||||
<FormattedMessage
|
||||
id="xpack.ml.dataframe.analytics.indexPatternPromptMessage"
|
||||
defaultMessage="No index pattern exists for index {destIndex}. {linkToIndexPatternManagement} for {destIndex}."
|
||||
values={{
|
||||
destIndex,
|
||||
linkToIndexPatternManagement: (
|
||||
<EuiLink
|
||||
href={`${basePath.get()}/app/management/kibana/indexPatterns/create`}
|
||||
target="_blank"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.dataframe.analytics.indexPatternPromptLinkText"
|
||||
defaultMessage="Create an index pattern"
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -33,6 +33,7 @@ import { getTaskStateBadge } from '../../../analytics_management/components/anal
|
|||
|
||||
import { ExplorationQueryBar } from '../exploration_query_bar';
|
||||
import { ExplorationTitle } from '../exploration_title';
|
||||
import { IndexPatternPrompt } from '../index_pattern_prompt';
|
||||
|
||||
import { getFeatureCount } from './common';
|
||||
import { useOutlierData } from './use_outlier_data';
|
||||
|
@ -49,7 +50,7 @@ export const OutlierExploration: FC<ExplorationProps> = React.memo(({ jobId }) =
|
|||
values: { jobId },
|
||||
});
|
||||
|
||||
const { indexPattern, jobConfig, jobStatus } = useResultsViewConfig(jobId);
|
||||
const { indexPattern, jobConfig, jobStatus, needsDestIndexPattern } = useResultsViewConfig(jobId);
|
||||
const [searchQuery, setSearchQuery] = useState<SavedSearchQuery>(defaultSearchQuery);
|
||||
const outlierData = useOutlierData(indexPattern, jobConfig, searchQuery);
|
||||
|
||||
|
@ -82,6 +83,9 @@ export const OutlierExploration: FC<ExplorationProps> = React.memo(({ jobId }) =
|
|||
|
||||
return (
|
||||
<EuiPanel data-test-subj="mlDFAnalyticsOutlierExplorationTablePanel">
|
||||
{jobConfig !== undefined && needsDestIndexPattern && (
|
||||
<IndexPatternPrompt destIndex={jobConfig.dest.index} />
|
||||
)}
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
justifyContent="spaceBetween"
|
||||
|
|
|
@ -64,7 +64,7 @@ describe('Analytics job clone action', () => {
|
|||
},
|
||||
analyzed_fields: {
|
||||
includes: [],
|
||||
excludes: ['id', 'outlier'],
|
||||
excludes: [],
|
||||
},
|
||||
model_memory_limit: '1mb',
|
||||
allow_lazy_start: false,
|
||||
|
@ -96,7 +96,7 @@ describe('Analytics job clone action', () => {
|
|||
},
|
||||
},
|
||||
analyzed_fields: {
|
||||
includes: [],
|
||||
includes: ['included_field', 'other_included_field'],
|
||||
excludes: [],
|
||||
},
|
||||
model_memory_limit: '150mb',
|
||||
|
@ -140,6 +140,40 @@ describe('Analytics job clone action', () => {
|
|||
expect(isAdvancedConfig(advancedClassificationJob)).toBe(true);
|
||||
});
|
||||
|
||||
test('should detect advanced classification job with excludes set', () => {
|
||||
const advancedClassificationJob = {
|
||||
description: "Classification job with 'bank-marketing' dataset",
|
||||
source: {
|
||||
index: ['bank-marketing'],
|
||||
query: {
|
||||
match_all: {},
|
||||
},
|
||||
},
|
||||
dest: {
|
||||
index: 'dest_bank_1',
|
||||
results_field: 'ml',
|
||||
},
|
||||
analysis: {
|
||||
classification: {
|
||||
dependent_variable: 'y',
|
||||
num_top_classes: 2,
|
||||
num_top_feature_importance_values: 4,
|
||||
prediction_field_name: 'y_prediction',
|
||||
training_percent: 2,
|
||||
randomize_seed: 6233212276062807000,
|
||||
},
|
||||
},
|
||||
analyzed_fields: {
|
||||
includes: [],
|
||||
excludes: ['excluded_field', 'other_excluded_field'],
|
||||
},
|
||||
model_memory_limit: '350mb',
|
||||
allow_lazy_start: false,
|
||||
};
|
||||
|
||||
expect(isAdvancedConfig(advancedClassificationJob)).toBe(true);
|
||||
});
|
||||
|
||||
test('should detect advanced regression job', () => {
|
||||
const advancedRegressionJob = {
|
||||
description: "Outlier detection job with 'glass' dataset",
|
||||
|
@ -161,7 +195,7 @@ describe('Analytics job clone action', () => {
|
|||
},
|
||||
analyzed_fields: {
|
||||
includes: [],
|
||||
excludes: ['id', 'outlier'],
|
||||
excludes: [],
|
||||
},
|
||||
model_memory_limit: '1mb',
|
||||
allow_lazy_start: false,
|
||||
|
|
|
@ -217,11 +217,11 @@ const getAnalyticsJobMeta = (config: CloneDataFrameAnalyticsConfig): AnalyticsJo
|
|||
analyzed_fields: {
|
||||
excludes: {
|
||||
optional: true,
|
||||
formKey: 'excludes',
|
||||
defaultValue: [],
|
||||
},
|
||||
includes: {
|
||||
optional: true,
|
||||
formKey: 'includes',
|
||||
defaultValue: [],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useState, FC } from 'react';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui';
|
||||
|
||||
import { checkPermission } from '../../../../../capabilities/check_capabilities';
|
||||
import { DataFrameAnalyticsListRow } from './common';
|
||||
|
||||
import { EditAnalyticsFlyout } from './edit_analytics_flyout';
|
||||
|
||||
interface EditActionProps {
|
||||
item: DataFrameAnalyticsListRow;
|
||||
}
|
||||
|
||||
export const EditAction: FC<EditActionProps> = ({ item }) => {
|
||||
const canCreateDataFrameAnalytics: boolean = checkPermission('canCreateDataFrameAnalytics');
|
||||
|
||||
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
|
||||
const closeFlyout = () => setIsFlyoutVisible(false);
|
||||
const showFlyout = () => setIsFlyoutVisible(true);
|
||||
|
||||
const buttonEditText = i18n.translate('xpack.ml.dataframe.analyticsList.editActionName', {
|
||||
defaultMessage: 'Edit',
|
||||
});
|
||||
|
||||
const editButton = (
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="mlAnalyticsJobEditButton"
|
||||
size="xs"
|
||||
color="text"
|
||||
disabled={!canCreateDataFrameAnalytics}
|
||||
iconType="copy"
|
||||
onClick={showFlyout}
|
||||
aria-label={buttonEditText}
|
||||
>
|
||||
{buttonEditText}
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
|
||||
if (!canCreateDataFrameAnalytics) {
|
||||
return (
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={i18n.translate('xpack.ml.dataframe.analyticsList.editActionPermissionTooltip', {
|
||||
defaultMessage: 'You do not have permission to edit analytics jobs.',
|
||||
})}
|
||||
>
|
||||
{editButton}
|
||||
</EuiToolTip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{editButton}
|
||||
{isFlyoutVisible && <EditAnalyticsFlyout closeFlyout={closeFlyout} item={item} />}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -27,6 +27,7 @@ import { getResultsUrl, isDataFrameAnalyticsRunning, DataFrameAnalyticsListRow }
|
|||
import { stopAnalytics } from '../../services/analytics_service';
|
||||
|
||||
import { StartAction } from './action_start';
|
||||
import { EditAction } from './action_edit';
|
||||
import { DeleteAction } from './action_delete';
|
||||
|
||||
interface Props {
|
||||
|
@ -133,6 +134,11 @@ export const getActions = (
|
|||
return stopButton;
|
||||
},
|
||||
},
|
||||
{
|
||||
render: (item: DataFrameAnalyticsListRow) => {
|
||||
return <EditAction item={item} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
render: (item: DataFrameAnalyticsListRow) => {
|
||||
return <DeleteAction item={item} />;
|
||||
|
|
|
@ -0,0 +1,270 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiFieldText,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
EuiFlyoutHeader,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiOverlayMask,
|
||||
EuiSelect,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { useMlKibana } from '../../../../../contexts/kibana';
|
||||
import { ml } from '../../../../../services/ml_api_service';
|
||||
import {
|
||||
memoryInputValidator,
|
||||
MemoryInputValidatorResult,
|
||||
} from '../../../../../../../common/util/validators';
|
||||
import { extractErrorMessage } from '../../../../../../../common/util/errors';
|
||||
import { DataFrameAnalyticsListRow, DATA_FRAME_TASK_STATE } from './common';
|
||||
import {
|
||||
useRefreshAnalyticsList,
|
||||
UpdateDataFrameAnalyticsConfig,
|
||||
} from '../../../../common/analytics';
|
||||
|
||||
interface EditAnalyticsJobFlyoutProps {
|
||||
closeFlyout: () => void;
|
||||
item: DataFrameAnalyticsListRow;
|
||||
}
|
||||
|
||||
let mmLValidator: (value: any) => MemoryInputValidatorResult;
|
||||
|
||||
export const EditAnalyticsFlyout: FC<EditAnalyticsJobFlyoutProps> = ({ closeFlyout, item }) => {
|
||||
const { id: jobId, config } = item;
|
||||
const { state } = item.stats;
|
||||
const initialAllowLazyStart =
|
||||
config.allow_lazy_start !== undefined ? String(config.allow_lazy_start) : '';
|
||||
|
||||
const [allowLazyStart, setAllowLazyStart] = useState<string>(initialAllowLazyStart);
|
||||
const [description, setDescription] = useState<string>(config.description || '');
|
||||
const [modelMemoryLimit, setModelMemoryLimit] = useState<string>(config.model_memory_limit);
|
||||
const [mmlValidationError, setMmlValidationError] = useState<string | undefined>();
|
||||
|
||||
const {
|
||||
services: { notifications },
|
||||
} = useMlKibana();
|
||||
const { refresh } = useRefreshAnalyticsList();
|
||||
|
||||
// Disable if mml is not valid
|
||||
const updateButtonDisabled = mmlValidationError !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (mmLValidator === undefined) {
|
||||
mmLValidator = memoryInputValidator();
|
||||
}
|
||||
// validate mml and create validation message
|
||||
if (modelMemoryLimit !== '') {
|
||||
const validationResult = mmLValidator(modelMemoryLimit);
|
||||
if (validationResult !== null && validationResult.invalidUnits) {
|
||||
setMmlValidationError(
|
||||
i18n.translate('xpack.ml.dataframe.analytics.create.modelMemoryUnitsInvalidError', {
|
||||
defaultMessage: 'Model memory limit data unit unrecognized. It must be {str}',
|
||||
values: { str: validationResult.invalidUnits.allowedUnits },
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setMmlValidationError(undefined);
|
||||
}
|
||||
} else {
|
||||
setMmlValidationError(
|
||||
i18n.translate('xpack.ml.dataframe.analytics.create.modelMemoryEmptyError', {
|
||||
defaultMessage: 'Model memory limit must not be empty',
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [modelMemoryLimit]);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const updateConfig: UpdateDataFrameAnalyticsConfig = Object.assign(
|
||||
{
|
||||
allow_lazy_start: allowLazyStart,
|
||||
description,
|
||||
},
|
||||
modelMemoryLimit && { model_memory_limit: modelMemoryLimit }
|
||||
);
|
||||
|
||||
try {
|
||||
await ml.dataFrameAnalytics.updateDataFrameAnalytics(jobId, updateConfig);
|
||||
notifications.toasts.addSuccess(
|
||||
i18n.translate('xpack.ml.dataframe.analyticsList.editFlyoutSuccessMessage', {
|
||||
defaultMessage: 'Analytics job {jobId} has been updated.',
|
||||
values: { jobId },
|
||||
})
|
||||
);
|
||||
refresh();
|
||||
closeFlyout();
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line
|
||||
console.error(e);
|
||||
|
||||
notifications.toasts.addDanger({
|
||||
title: i18n.translate('xpack.ml.dataframe.analyticsList.editFlyoutErrorMessage', {
|
||||
defaultMessage: 'Could not save changes to analytics job {jobId}',
|
||||
values: {
|
||||
jobId,
|
||||
},
|
||||
}),
|
||||
text: extractErrorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiOverlayMask>
|
||||
<EuiFlyout
|
||||
onClose={closeFlyout}
|
||||
hideCloseButton
|
||||
aria-labelledby="analyticsEditFlyoutTitle"
|
||||
data-test-subj="analyticsEditFlyout"
|
||||
>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="m">
|
||||
<h2 id="analyticsEditFlyoutTitle">
|
||||
{i18n.translate('xpack.ml.dataframe.analyticsList.editFlyoutTitle', {
|
||||
defaultMessage: 'Edit {jobId}',
|
||||
values: {
|
||||
jobId,
|
||||
},
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<EuiForm>
|
||||
<EuiFormRow
|
||||
label={i18n.translate(
|
||||
'xpack.ml.dataframe.analyticsList.editFlyout.allowLazyStartLabel',
|
||||
{
|
||||
defaultMessage: 'Allow lazy start',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<EuiSelect
|
||||
aria-label={i18n.translate(
|
||||
'xpack.ml.dataframe.analyticsList.editFlyout.allowLazyStartAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Update allow lazy start.',
|
||||
}
|
||||
)}
|
||||
data-test-subj="mlAnalyticsEditFlyoutAllowLazyStartInput"
|
||||
options={[
|
||||
{
|
||||
value: 'true',
|
||||
text: i18n.translate(
|
||||
'xpack.ml.dataframe.analyticsList.editFlyout.allowLazyStartTrueValue',
|
||||
{
|
||||
defaultMessage: 'True',
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'false',
|
||||
text: i18n.translate(
|
||||
'xpack.ml.dataframe.analyticsList.editFlyout.allowLazyStartFalseValue',
|
||||
{
|
||||
defaultMessage: 'False',
|
||||
}
|
||||
),
|
||||
},
|
||||
]}
|
||||
value={allowLazyStart}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setAllowLazyStart(e.target.value)
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
label={i18n.translate(
|
||||
'xpack.ml.dataframe.analyticsList.editFlyout.descriptionLabel',
|
||||
{
|
||||
defaultMessage: 'Description',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<EuiFieldText
|
||||
data-test-subj="mlAnalyticsEditFlyoutDescriptionInput"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
aria-label={i18n.translate(
|
||||
'xpack.ml.dataframe.analyticsList.editFlyout.descriptionAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Update the job description.',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
helpText={
|
||||
state !== DATA_FRAME_TASK_STATE.STOPPED &&
|
||||
i18n.translate('xpack.ml.dataframe.analyticsList.editFlyout.modelMemoryHelpText', {
|
||||
defaultMessage: 'Model memory limit cannot be edited while the job is running.',
|
||||
})
|
||||
}
|
||||
label={i18n.translate(
|
||||
'xpack.ml.dataframe.analyticsList.editFlyout.modelMemoryLimitLabel',
|
||||
{
|
||||
defaultMessage: 'Model memory limit',
|
||||
}
|
||||
)}
|
||||
isInvalid={mmlValidationError !== undefined}
|
||||
error={mmlValidationError}
|
||||
>
|
||||
<EuiFieldText
|
||||
data-test-subj="mlAnalyticsEditFlyoutmodelMemoryLimitInput"
|
||||
isInvalid={mmlValidationError !== undefined}
|
||||
readOnly={state !== DATA_FRAME_TASK_STATE.STOPPED}
|
||||
value={modelMemoryLimit}
|
||||
onChange={(e) => setModelMemoryLimit(e.target.value)}
|
||||
aria-label={i18n.translate(
|
||||
'xpack.ml.dataframe.analyticsList.editFlyout.modelMemoryLimitAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Update the model memory limit.',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty iconType="cross" onClick={closeFlyout} flush="left">
|
||||
{i18n.translate('xpack.ml.dataframe.analyticsList.editFlyoutCancelButtonText', {
|
||||
defaultMessage: 'Cancel',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
data-test-subj="analyticsEditFlyoutUpdateButton"
|
||||
onClick={onSubmit}
|
||||
fill
|
||||
isDisabled={updateButtonDisabled}
|
||||
>
|
||||
{i18n.translate('xpack.ml.dataframe.analyticsList.editFlyoutUpdateButtonText', {
|
||||
defaultMessage: 'Update',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
</EuiOverlayMask>
|
||||
);
|
||||
};
|
|
@ -116,7 +116,7 @@ export const validateNumTopFeatureImportanceValues = (
|
|||
};
|
||||
|
||||
export const validateAdvancedEditor = (state: State): State => {
|
||||
const { jobIdEmpty, jobIdValid, jobIdExists, jobType, createIndexPattern, excludes } = state.form;
|
||||
const { jobIdEmpty, jobIdValid, jobIdExists, jobType, createIndexPattern, includes } = state.form;
|
||||
const { jobConfig } = state;
|
||||
|
||||
state.advancedEditorMessages = [];
|
||||
|
@ -152,7 +152,7 @@ export const validateAdvancedEditor = (state: State): State => {
|
|||
}
|
||||
|
||||
let dependentVariableEmpty = false;
|
||||
let excludesValid = true;
|
||||
let includesValid = true;
|
||||
let trainingPercentValid = true;
|
||||
let numTopFeatureImportanceValuesValid = true;
|
||||
|
||||
|
@ -170,14 +170,19 @@ export const validateAdvancedEditor = (state: State): State => {
|
|||
const dependentVariableName = getDependentVar(jobConfig.analysis) || '';
|
||||
dependentVariableEmpty = dependentVariableName === '';
|
||||
|
||||
if (!dependentVariableEmpty && excludes.includes(dependentVariableName)) {
|
||||
excludesValid = false;
|
||||
if (
|
||||
!dependentVariableEmpty &&
|
||||
includes !== undefined &&
|
||||
includes.length > 0 &&
|
||||
!includes.includes(dependentVariableName)
|
||||
) {
|
||||
includesValid = false;
|
||||
|
||||
state.advancedEditorMessages.push({
|
||||
error: i18n.translate(
|
||||
'xpack.ml.dataframe.analytics.create.advancedEditorMessage.excludesInvalid',
|
||||
'xpack.ml.dataframe.analytics.create.advancedEditorMessage.includesInvalid',
|
||||
{
|
||||
defaultMessage: 'The dependent variable cannot be excluded.',
|
||||
defaultMessage: 'The dependent variable must be included.',
|
||||
}
|
||||
),
|
||||
message: '',
|
||||
|
@ -321,7 +326,7 @@ export const validateAdvancedEditor = (state: State): State => {
|
|||
state.form.destinationIndexPatternTitleExists = destinationIndexPatternTitleExists;
|
||||
|
||||
state.isValid =
|
||||
excludesValid &&
|
||||
includesValid &&
|
||||
trainingPercentValid &&
|
||||
state.form.modelMemoryLimitUnitValid &&
|
||||
!jobIdEmpty &&
|
||||
|
|
|
@ -42,6 +42,37 @@ const regJobConfig = {
|
|||
allow_lazy_start: false,
|
||||
};
|
||||
|
||||
const outlierJobConfig = {
|
||||
id: 'outlier-test-01',
|
||||
description: 'outlier test job description',
|
||||
source: {
|
||||
index: ['outlier-test-index'],
|
||||
query: {
|
||||
match_all: {},
|
||||
},
|
||||
},
|
||||
dest: {
|
||||
index: 'outlier-test-01-index',
|
||||
results_field: 'ml',
|
||||
},
|
||||
analysis: {
|
||||
outlier_detection: {
|
||||
feature_influence_threshold: 0.01,
|
||||
outlier_fraction: 0.05,
|
||||
compute_feature_influence: false,
|
||||
method: 'lof',
|
||||
},
|
||||
},
|
||||
analyzed_fields: {
|
||||
includes: ['field', 'other_field'],
|
||||
excludes: [],
|
||||
},
|
||||
model_memory_limit: '22mb',
|
||||
create_time: 1590514291395,
|
||||
version: '8.0.0',
|
||||
allow_lazy_start: false,
|
||||
};
|
||||
|
||||
describe('useCreateAnalyticsForm', () => {
|
||||
test('state: getJobConfigFromFormState()', () => {
|
||||
const state = getInitialState();
|
||||
|
@ -53,8 +84,8 @@ describe('useCreateAnalyticsForm', () => {
|
|||
|
||||
expect(jobConfig?.dest?.index).toBe('the-destination-index');
|
||||
expect(jobConfig?.source?.index).toBe('the-source-index');
|
||||
expect(jobConfig?.analyzed_fields?.excludes).toStrictEqual([]);
|
||||
expect(typeof jobConfig?.analyzed_fields?.includes).toBe('undefined');
|
||||
expect(jobConfig?.analyzed_fields?.includes).toStrictEqual([]);
|
||||
expect(typeof jobConfig?.analyzed_fields?.excludes).toBe('undefined');
|
||||
|
||||
// test the conversion of comma-separated Kibana index patterns to ES array based index patterns
|
||||
state.form.sourceIndex = 'the-source-index-1,the-source-index-2';
|
||||
|
@ -65,11 +96,11 @@ describe('useCreateAnalyticsForm', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
test('state: getCloneFormStateFromJobConfig()', () => {
|
||||
test('state: getCloneFormStateFromJobConfig() regression', () => {
|
||||
const clonedState = getCloneFormStateFromJobConfig(regJobConfig);
|
||||
|
||||
expect(clonedState?.sourceIndex).toBe('reg-test-index');
|
||||
expect(clonedState?.excludes).toStrictEqual([]);
|
||||
expect(clonedState?.includes).toStrictEqual([]);
|
||||
expect(clonedState?.dependentVariable).toBe('price');
|
||||
expect(clonedState?.numTopFeatureImportanceValues).toBe(2);
|
||||
expect(clonedState?.predictionFieldName).toBe('airbnb_test');
|
||||
|
@ -80,4 +111,19 @@ describe('useCreateAnalyticsForm', () => {
|
|||
expect(clonedState?.destinationIndex).toBe(undefined);
|
||||
expect(clonedState?.jobId).toBe(undefined);
|
||||
});
|
||||
|
||||
test('state: getCloneFormStateFromJobConfig() outlier detection', () => {
|
||||
const clonedState = getCloneFormStateFromJobConfig(outlierJobConfig);
|
||||
|
||||
expect(clonedState?.sourceIndex).toBe('outlier-test-index');
|
||||
expect(clonedState?.includes).toStrictEqual(['field', 'other_field']);
|
||||
expect(clonedState?.featureInfluenceThreshold).toBe(0.01);
|
||||
expect(clonedState?.outlierFraction).toBe(0.05);
|
||||
expect(clonedState?.computeFeatureInfluence).toBe(false);
|
||||
expect(clonedState?.method).toBe('lof');
|
||||
expect(clonedState?.modelMemoryLimit).toBe('22mb');
|
||||
// destination index and job id should be undefined
|
||||
expect(clonedState?.destinationIndex).toBe(undefined);
|
||||
expect(clonedState?.jobId).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,11 +7,8 @@
|
|||
import { DeepPartial, DeepReadonly } from '../../../../../../../common/types/common';
|
||||
import { checkPermission } from '../../../../../capabilities/check_capabilities';
|
||||
import { mlNodesAvailable } from '../../../../../ml_nodes_check';
|
||||
import { newJobCapsService } from '../../../../../services/new_job_capabilities_service';
|
||||
|
||||
import {
|
||||
isClassificationAnalysis,
|
||||
isRegressionAnalysis,
|
||||
DataFrameAnalyticsId,
|
||||
DataFrameAnalyticsConfig,
|
||||
ANALYSIS_CONFIG_TYPE,
|
||||
|
@ -57,10 +54,10 @@ export interface State {
|
|||
destinationIndexNameValid: boolean;
|
||||
destinationIndexPatternTitleExists: boolean;
|
||||
eta: undefined | number;
|
||||
excludes: string[];
|
||||
featureBagFraction: undefined | number;
|
||||
featureInfluenceThreshold: undefined | number;
|
||||
gamma: undefined | number;
|
||||
includes: string[];
|
||||
jobId: DataFrameAnalyticsId;
|
||||
jobIdExists: boolean;
|
||||
jobIdEmpty: boolean;
|
||||
|
@ -122,10 +119,10 @@ export const getInitialState = (): State => ({
|
|||
destinationIndexNameValid: false,
|
||||
destinationIndexPatternTitleExists: false,
|
||||
eta: undefined,
|
||||
excludes: [],
|
||||
featureBagFraction: undefined,
|
||||
featureInfluenceThreshold: undefined,
|
||||
gamma: undefined,
|
||||
includes: [],
|
||||
jobId: '',
|
||||
jobIdExists: false,
|
||||
jobIdEmpty: true,
|
||||
|
@ -175,55 +172,6 @@ export const getInitialState = (): State => ({
|
|||
estimatedModelMemoryLimit: '',
|
||||
});
|
||||
|
||||
const getExcludesFields = (excluded: string[]) => {
|
||||
const { fields } = newJobCapsService;
|
||||
const updatedExcluded: string[] = [];
|
||||
// Loop through excluded fields to check for multiple types of same field
|
||||
for (let i = 0; i < excluded.length; i++) {
|
||||
const fieldName = excluded[i];
|
||||
let mainField;
|
||||
|
||||
// No dot in fieldName - it is the main field
|
||||
if (fieldName.includes('.') === false) {
|
||||
mainField = fieldName;
|
||||
} else {
|
||||
// Dot in fieldName - check if there's a field whose name equals the fieldName with the last dot suffix removed
|
||||
const regex = /\.[^.]*$/;
|
||||
const suffixRemovedField = fieldName.replace(regex, '');
|
||||
const fieldMatch = newJobCapsService.getFieldById(suffixRemovedField);
|
||||
|
||||
// There's a match - set as the main field
|
||||
if (fieldMatch !== null) {
|
||||
mainField = suffixRemovedField;
|
||||
} else {
|
||||
// No main field to be found - add the fieldName to updatedExcluded array if it's not already there
|
||||
if (updatedExcluded.includes(fieldName) === false) {
|
||||
updatedExcluded.push(fieldName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mainField !== undefined) {
|
||||
// Add the main field to the updatedExcluded array if it's not already there
|
||||
if (updatedExcluded.includes(mainField) === false) {
|
||||
updatedExcluded.push(mainField);
|
||||
}
|
||||
// Create regex to find all other fields whose names begin with main field followed by a dot
|
||||
const regex = new RegExp(`${mainField}\\..+`);
|
||||
|
||||
// Loop through fields and add fields matching the pattern to updatedExcluded array
|
||||
for (let j = 0; j < fields.length; j++) {
|
||||
const field = fields[j].name;
|
||||
if (updatedExcluded.includes(field) === false && field.match(regex) !== null) {
|
||||
updatedExcluded.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedExcluded;
|
||||
};
|
||||
|
||||
export const getJobConfigFromFormState = (
|
||||
formState: State['form']
|
||||
): DeepPartial<DataFrameAnalyticsConfig> => {
|
||||
|
@ -242,7 +190,7 @@ export const getJobConfigFromFormState = (
|
|||
index: formState.destinationIndex,
|
||||
},
|
||||
analyzed_fields: {
|
||||
excludes: getExcludesFields(formState.excludes),
|
||||
includes: formState.includes,
|
||||
},
|
||||
analysis: {
|
||||
outlier_detection: {},
|
||||
|
@ -333,21 +281,16 @@ export function getCloneFormStateFromJobConfig(
|
|||
? analyticsJobConfig.source.index.join(',')
|
||||
: analyticsJobConfig.source.index,
|
||||
modelMemoryLimit: analyticsJobConfig.model_memory_limit,
|
||||
excludes: analyticsJobConfig.analyzed_fields.excludes,
|
||||
includes: analyticsJobConfig.analyzed_fields.includes,
|
||||
};
|
||||
|
||||
if (
|
||||
isRegressionAnalysis(analyticsJobConfig.analysis) ||
|
||||
isClassificationAnalysis(analyticsJobConfig.analysis)
|
||||
) {
|
||||
const analysisConfig = analyticsJobConfig.analysis[jobType];
|
||||
const analysisConfig = analyticsJobConfig.analysis[jobType];
|
||||
|
||||
for (const key in analysisConfig) {
|
||||
if (analysisConfig.hasOwnProperty(key)) {
|
||||
const camelCased = toCamelCase(key);
|
||||
// @ts-ignore
|
||||
resultState[camelCased] = analysisConfig[key];
|
||||
}
|
||||
for (const key in analysisConfig) {
|
||||
if (analysisConfig.hasOwnProperty(key)) {
|
||||
const camelCased = toCamelCase(key);
|
||||
// @ts-ignore
|
||||
resultState[camelCased] = analysisConfig[key];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,7 +8,10 @@ import { http } from '../http_service';
|
|||
|
||||
import { basePath } from './index';
|
||||
import { DataFrameAnalyticsStats } from '../../data_frame_analytics/pages/analytics_management/components/analytics_list/common';
|
||||
import { DataFrameAnalyticsConfig } from '../../data_frame_analytics/common';
|
||||
import {
|
||||
DataFrameAnalyticsConfig,
|
||||
UpdateDataFrameAnalyticsConfig,
|
||||
} from '../../data_frame_analytics/common';
|
||||
import { DeepPartial } from '../../../../common/types/common';
|
||||
import { DeleteDataFrameAnalyticsWithIndexStatus } from '../../../../common/types/data_frame_analytics';
|
||||
|
||||
|
@ -72,6 +75,14 @@ export const dataFrameAnalytics = {
|
|||
body,
|
||||
});
|
||||
},
|
||||
updateDataFrameAnalytics(analyticsId: string, updateConfig: UpdateDataFrameAnalyticsConfig) {
|
||||
const body = JSON.stringify(updateConfig);
|
||||
return http<any>({
|
||||
path: `${basePath()}/data_frame/analytics/${analyticsId}/_update`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
evaluateDataFrameAnalytics(evaluateConfig: any) {
|
||||
const body = JSON.stringify(evaluateConfig);
|
||||
return http<any>({
|
||||
|
|
|
@ -223,6 +223,21 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any)
|
|||
method: 'POST',
|
||||
});
|
||||
|
||||
ml.updateDataFrameAnalytics = ca({
|
||||
urls: [
|
||||
{
|
||||
fmt: '/_ml/data_frame/analytics/<%=analyticsId%>/_update',
|
||||
req: {
|
||||
analyticsId: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
needBody: true,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
ml.deleteJob = ca({
|
||||
urls: [
|
||||
{
|
||||
|
|
|
@ -10,6 +10,7 @@ import { analyticsAuditMessagesProvider } from '../models/data_frame_analytics/a
|
|||
import { RouteInitialization } from '../types';
|
||||
import {
|
||||
dataAnalyticsJobConfigSchema,
|
||||
dataAnalyticsJobUpdateSchema,
|
||||
dataAnalyticsEvaluateSchema,
|
||||
dataAnalyticsExplainSchema,
|
||||
analyticsIdSchema,
|
||||
|
@ -483,6 +484,45 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat
|
|||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* @apiGroup DataFrameAnalytics
|
||||
*
|
||||
* @api {post} /api/ml/data_frame/analytics/:analyticsId/_update Update specified analytics job
|
||||
* @apiName UpdateDataFrameAnalyticsJob
|
||||
* @apiDescription Updates a data frame analytics job.
|
||||
*
|
||||
* @apiSchema (params) analyticsIdSchema
|
||||
*/
|
||||
router.post(
|
||||
{
|
||||
path: '/api/ml/data_frame/analytics/{analyticsId}/_update',
|
||||
validate: {
|
||||
params: analyticsIdSchema,
|
||||
body: dataAnalyticsJobUpdateSchema,
|
||||
},
|
||||
options: {
|
||||
tags: ['access:ml:canCreateDataFrameAnalytics'],
|
||||
},
|
||||
},
|
||||
mlLicense.fullLicenseAPIGuard(async (context, request, response) => {
|
||||
try {
|
||||
const { analyticsId } = request.params;
|
||||
const results = await context.ml!.mlClient.callAsCurrentUser(
|
||||
'ml.updateDataFrameAnalytics',
|
||||
{
|
||||
body: request.body,
|
||||
analyticsId,
|
||||
}
|
||||
);
|
||||
return response.ok({
|
||||
body: results,
|
||||
});
|
||||
} catch (e) {
|
||||
return response.customError(wrapError(e));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* @apiGroup DataFrameAnalytics
|
||||
*
|
||||
|
|
|
@ -69,6 +69,12 @@ export const deleteDataFrameAnalyticsJobSchema = schema.object({
|
|||
deleteDestIndexPattern: schema.maybe(schema.boolean()),
|
||||
});
|
||||
|
||||
export const dataAnalyticsJobUpdateSchema = schema.object({
|
||||
description: schema.maybe(schema.string()),
|
||||
model_memory_limit: schema.maybe(schema.string()),
|
||||
allow_lazy_start: schema.maybe(schema.boolean()),
|
||||
});
|
||||
|
||||
export const stopsDataFrameAnalyticsJobQuerySchema = schema.object({
|
||||
force: schema.maybe(schema.boolean()),
|
||||
});
|
||||
|
|