Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Tiago Costa 2020-07-06 22:09:54 +01:00
commit c5dd942b72
No known key found for this signature in database
GPG key ID: BAECC2D04A04C6EA
136 changed files with 9741 additions and 3619 deletions

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View file

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

View file

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

View file

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

View 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>"`;

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,7 +15,6 @@ export const MetricsSettingsPage = () => {
<EuiErrorBoundary>
<SourceConfigurationSettings
shouldAllowEdit={uiCapabilities?.infrastructure?.configureSource as boolean}
displaySettings="metrics"
/>
</EuiErrorBoundary>
);

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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,
},
};
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -207,7 +207,7 @@ const breadcrumbGetters: {
BASE_BREADCRUMB,
{
text: i18n.translate('xpack.ingestManager.breadcrumbs.datastreamsPageTitle', {
defaultMessage: 'Data streams',
defaultMessage: 'Datasets',
}),
},
],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,6 +15,7 @@ export {
EnrollmentAPIKey,
PackageConfig,
NewPackageConfig,
UpdatePackageConfig,
PackageConfigInput,
PackageConfigInputStream,
PackageConfigConfigRecordEntry,

View file

@ -178,7 +178,7 @@ export const updatePackageConfigHandler: RequestHandler<
});
} catch (e) {
return response.customError({
statusCode: 500,
statusCode: e.statusCode || 500,
body: { message: e.message },
});
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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"
}
`;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,6 +21,7 @@ export {
PackageConfigInput,
PackageConfigInputStream,
NewPackageConfig,
UpdatePackageConfig,
PackageConfigSOAttributes,
FullAgentConfigInput,
FullAgentConfig,
@ -40,6 +41,7 @@ export {
PackageInfo,
RegistryVarsEntry,
Dataset,
RegistryElasticsearch,
AssetReference,
ElasticsearchAssetType,
IngestAssetType,

View file

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

View file

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

View file

@ -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 === '') {

View file

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

View file

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

View file

@ -13,6 +13,7 @@ export {
useRefreshAnalyticsList,
DataFrameAnalyticsId,
DataFrameAnalyticsConfig,
UpdateDataFrameAnalyticsConfig,
IndexName,
IndexPattern,
REFRESH_ANALYTICS_LIST_STATE,

View file

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

View file

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

View file

@ -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}]`)) {

View file

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

View file

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

View file

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

View file

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

View file

@ -71,7 +71,7 @@ export const JobType: FC<Props> = ({ type, setFormState }) => {
setFormState({
previousJobType: type,
jobType: value,
excludes: [],
includes: [],
requiredFieldsError: undefined,
});
}}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -217,11 +217,11 @@ const getAnalyticsJobMeta = (config: CloneDataFrameAnalyticsConfig): AnalyticsJo
analyzed_fields: {
excludes: {
optional: true,
formKey: 'excludes',
defaultValue: [],
},
includes: {
optional: true,
formKey: 'includes',
defaultValue: [],
},
},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more