[ML] Converts the custom URL editor to EUI / React (#21094)

This commit is contained in:
Pete Harverson 2018-07-24 10:14:10 +01:00 committed by GitHub
parent 1e7ce26303
commit 8464caf2a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1147 additions and 55 deletions

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const URL_TYPE = {
KIBANA_DASHBOARD: 'KIBANA_DASHBOARD',
KIBANA_DISCOVER: 'KIBANA_DISCOVER',
OTHER: 'OTHER'
};
export const TIME_RANGE_TYPE = {
AUTO: 'auto',
INTERVAL: 'interval'
};

View file

@ -0,0 +1,317 @@
/*
* 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.
*/
/*
* React component for the form for editing a custom URL.
*/
import PropTypes from 'prop-types';
import React from 'react';
import {
EuiComboBox,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiRadioGroup,
EuiSelect,
EuiSpacer,
EuiTextArea,
EuiTitle,
} from '@elastic/eui';
import {
isValidCustomUrlLabel,
isValidCustomUrlSettingsTimeRange
} from 'plugins/ml/jobs/components/custom_url_editor/utils';
import './styles/main.less';
import {
TIME_RANGE_TYPE,
URL_TYPE
} from './constants';
function getLinkToOptions() {
return [{
id: URL_TYPE.KIBANA_DASHBOARD,
label: 'Kibana dashboard',
}, {
id: URL_TYPE.KIBANA_DISCOVER,
label: 'Discover',
}, {
id: URL_TYPE.OTHER,
label: 'Other',
}];
}
function onLabelChange(e, customUrl, setEditCustomUrl) {
setEditCustomUrl({
...customUrl,
label: e.target.value
});
}
function onTypeChange(linkType, customUrl, setEditCustomUrl) {
setEditCustomUrl({
...customUrl,
type: linkType
});
}
function onDashboardChange(e, customUrl, setEditCustomUrl) {
const kibanaSettings = customUrl.kibanaSettings;
setEditCustomUrl({
...customUrl,
kibanaSettings: {
...kibanaSettings,
dashboardId: e.target.value
}
});
}
function onDiscoverIndexPatternChange(e, customUrl, setEditCustomUrl) {
const kibanaSettings = customUrl.kibanaSettings;
setEditCustomUrl({
...customUrl,
kibanaSettings: {
...kibanaSettings,
discoverIndexPatternId: e.target.value
}
});
}
function onQueryEntitiesChange(selectedOptions, customUrl, setEditCustomUrl) {
const selectedFieldNames = selectedOptions.map(option => option.label);
const kibanaSettings = customUrl.kibanaSettings;
setEditCustomUrl({
...customUrl,
kibanaSettings: {
...kibanaSettings,
queryFieldNames: selectedFieldNames
}
});
}
function onOtherUrlValueChange(e, customUrl, setEditCustomUrl) {
setEditCustomUrl({
...customUrl,
otherUrlSettings: {
urlValue: e.target.value
}
});
}
function onTimeRangeTypeChange(e, customUrl, setEditCustomUrl) {
const timeRange = customUrl.timeRange;
setEditCustomUrl({
...customUrl,
timeRange: {
...timeRange,
type: e.target.value
}
});
}
function onTimeRangeIntervalChange(e, customUrl, setEditCustomUrl) {
const timeRange = customUrl.timeRange;
setEditCustomUrl({
...customUrl,
timeRange: {
...timeRange,
interval: e.target.value
}
});
}
export function CustomUrlEditor({
customUrl,
setEditCustomUrl,
dashboards,
indexPatterns,
queryEntityFieldNames }) {
if (customUrl === undefined) {
return;
}
const {
label,
type,
timeRange,
kibanaSettings,
otherUrlSettings
} = customUrl;
const dashboardOptions = dashboards.map((dashboard) => {
return { value: dashboard.id, text: dashboard.title };
});
const indexPatternOptions = indexPatterns.map((indexPattern) => {
return { value: indexPattern.id, text: indexPattern.title };
});
const entityOptions = queryEntityFieldNames.map(fieldName => ({ label: fieldName }));
const queryFieldNames = kibanaSettings.queryFieldNames;
let selectedEntityOptions = [];
if (queryFieldNames !== undefined) {
selectedEntityOptions = queryFieldNames.map(fieldName => ({ label: fieldName }));
}
const timeRangeOptions = Object.keys(TIME_RANGE_TYPE).map((timeRangeType) => {
return { value: TIME_RANGE_TYPE[timeRangeType], text: TIME_RANGE_TYPE[timeRangeType] };
});
const isInvalidTimeRange = !isValidCustomUrlSettingsTimeRange(timeRange);
const invalidIntervalError = (isInvalidTimeRange === true) ?
['Invalid interval format'] : [];
return (
<React.Fragment>
<EuiTitle size="xs">
<h4>Create new custom URL</h4>
</EuiTitle>
<EuiSpacer size="m" />
<EuiForm>
<EuiFormRow
label="Label"
className="url-label"
compressed
>
<EuiFieldText
value={label}
onChange={(e) => onLabelChange(e, customUrl, setEditCustomUrl)}
isInvalid={!isValidCustomUrlLabel(label)}
compressed
/>
</EuiFormRow>
<EuiFormRow
label="Link to"
compressed
>
<EuiRadioGroup
options={getLinkToOptions()}
idSelected={type}
onChange={(linkType) => onTypeChange(linkType, customUrl, setEditCustomUrl)}
className="url-link-to-radio"
/>
</EuiFormRow>
{type === URL_TYPE.KIBANA_DASHBOARD &&
<EuiFormRow
label="Dashboard name"
compressed
>
<EuiSelect
options={dashboardOptions}
value={kibanaSettings.dashboardId}
onChange={(e) => onDashboardChange(e, customUrl, setEditCustomUrl)}
compressed
/>
</EuiFormRow>
}
{type === URL_TYPE.KIBANA_DISCOVER &&
<EuiFormRow
label="Index pattern"
compressed
>
<EuiSelect
options={indexPatternOptions}
value={kibanaSettings.discoverIndexPatternId}
onChange={(e) => onDiscoverIndexPatternChange(e, customUrl, setEditCustomUrl)}
compressed
/>
</EuiFormRow>
}
{(type === URL_TYPE.KIBANA_DASHBOARD || type === URL_TYPE.KIBANA_DISCOVER) &&
entityOptions.length > 0 &&
<EuiFormRow
label="Query entities"
>
<EuiComboBox
placeholder="Select entities"
options={entityOptions}
selectedOptions={selectedEntityOptions}
onChange={(e) => onQueryEntitiesChange(e, customUrl, setEditCustomUrl)}
isClearable={true}
/>
</EuiFormRow>
}
{(type === URL_TYPE.KIBANA_DASHBOARD || type === URL_TYPE.KIBANA_DISCOVER) &&
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiFormRow
label="Time range"
className="url-time-range"
compressed
>
<EuiSelect
options={timeRangeOptions}
value={timeRange.type}
onChange={(e) => onTimeRangeTypeChange(e, customUrl, setEditCustomUrl)}
compressed
/>
</EuiFormRow>
</EuiFlexItem>
{(timeRange.type === TIME_RANGE_TYPE.INTERVAL) &&
<EuiFlexItem>
<EuiFormRow
label="Interval"
className="url-time-range"
error={invalidIntervalError}
isInvalid={isInvalidTimeRange}
compressed
>
<EuiFieldText
value={timeRange.interval}
onChange={(e) => onTimeRangeIntervalChange(e, customUrl, setEditCustomUrl)}
isInvalid={isInvalidTimeRange}
compressed
/>
</EuiFormRow>
</EuiFlexItem>
}
</EuiFlexGroup>
}
{type === URL_TYPE.OTHER &&
<EuiFormRow
label="URL"
compressed
fullWidth={true}
>
<EuiTextArea
fullWidth={true}
rows={2}
value={otherUrlSettings.urlValue}
onChange={(e) => onOtherUrlValueChange(e, customUrl, setEditCustomUrl)}
compressed
/>
</EuiFormRow>
}
</EuiForm>
</React.Fragment>
);
}
CustomUrlEditor.propTypes = {
customUrl: PropTypes.object,
setEditCustomUrl: PropTypes.func.isRequired,
dashboards: PropTypes.array.isRequired,
indexPatterns: PropTypes.array.isRequired,
queryEntityFieldNames: PropTypes.array.isRequired,
};

View file

@ -5,7 +5,5 @@
*/
import './custom_url_editor_directive';
import './styles/main.less';
export { CustomUrlList } from './list';
export { CustomUrlEditor } from './editor';

View file

@ -0,0 +1,175 @@
/*
* 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.
*/
/*
* React component for listing the custom URLs added to a job,
* with buttons for testing and deleting each custom URL.
*/
import PropTypes from 'prop-types';
import React from 'react';
import {
EuiButtonIcon,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
} from '@elastic/eui';
import { toastNotifications } from 'ui/notify';
import { getTestUrl } from 'plugins/ml/jobs/components/custom_url_editor/utils';
import { parseInterval } from 'plugins/ml/../common/util/parse_interval';
import { TIME_RANGE_TYPE } from './constants';
function onLabelChange(e, index, customUrls, setCustomUrls) {
if (index < customUrls.length) {
customUrls[index] = {
...customUrls[index],
url_name: e.target.value,
};
setCustomUrls(customUrls);
}
}
function onUrlValueChange(e, index, customUrls, setCustomUrls) {
if (index < customUrls.length) {
customUrls[index] = {
...customUrls[index],
url_value: e.target.value,
};
setCustomUrls(customUrls);
}
}
function onTimeRangeChange(e, index, customUrls, setCustomUrls) {
if (index < customUrls.length) {
customUrls[index] = {
...customUrls[index],
time_range: e.target.value,
};
setCustomUrls(customUrls);
}
}
function onDeleteButtonClick(index, customUrls, setCustomUrls) {
if (index < customUrls.length) {
customUrls.splice(index, 1);
setCustomUrls(customUrls);
}
}
function onTestButtonClick(index, customUrls, job) {
if (index < customUrls.length) {
getTestUrl(job, customUrls[index])
.then((testUrl) => {
window.open(testUrl, '_blank');
})
.catch((resp) => {
console.log('onTestButtonClick() - error obtaining URL for test:', resp);
toastNotifications.addDanger('An error occurred obtaining the URL to test the configuration');
});
}
}
function isValidTimeRange(timeRange) {
// Allow empty timeRange string, which gives the 'auto' behaviour.
if ((timeRange === undefined) || (timeRange.length === 0) || (timeRange === TIME_RANGE_TYPE.AUTO)) {
return true;
}
const interval = parseInterval(timeRange);
return (interval !== null);
}
export function CustomUrlList({
job,
customUrls,
setCustomUrls }) {
// TODO - swap URL input to a textarea on focus / blur.
const customUrlRows = customUrls.map((customUrl, index) => {
// Validate the time range.
const timeRange = customUrl.time_range;
const isInvalidTimeRange = !(isValidTimeRange(timeRange));
const invalidIntervalError = (isInvalidTimeRange === true) ? ['Invalid format'] : [];
return (
<EuiFlexGroup key={`url_${index}`}>
<EuiFlexItem grow={false}>
<EuiFormRow label="Label">
<EuiFieldText
value={customUrl.url_name}
onChange={(e) => onLabelChange(e, index, customUrls, setCustomUrls)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow label="URL">
<EuiFieldText
value={customUrl.url_value}
onChange={(e) => onUrlValueChange(e, index, customUrls, setCustomUrls)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow
label="Time range"
error={invalidIntervalError}
isInvalid={isInvalidTimeRange}
>
<EuiFieldText
value={customUrl.time_range}
isInvalid={isInvalidTimeRange}
placeholder={TIME_RANGE_TYPE.AUTO}
onChange={(e) => onTimeRangeChange(e, index, customUrls, setCustomUrls)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow hasEmptyLabelSpace>
<EuiButtonIcon
size="s"
color="primary"
onClick={() => onTestButtonClick(index, customUrls, job)}
iconType="popout"
aria-label="Test custom URL"
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow hasEmptyLabelSpace>
<EuiButtonIcon
size="s"
color="danger"
onClick={() => onDeleteButtonClick(index, customUrls, setCustomUrls)}
iconType="trash"
aria-label="Delete custom URL"
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
);
});
return (
<React.Fragment>
{customUrlRows}
</React.Fragment>
);
}
CustomUrlList.propTypes = {
job: PropTypes.object.isRequired,
customUrls: PropTypes.array.isRequired,
setCustomUrls: PropTypes.func.isRequired,
};

View file

@ -1,45 +1,16 @@
@import (reference) "~ui/styles/variables";
.ml-custom-url-manager {
.add-url-input {
display: block;
.ml-custom-url-editor {
.url-label {
width: 250px;
}
select {
width: 100%;
}
.link-to-label {
padding-right: 20px;
}
label.disabled {
color: @globalColorLightGray;
}
.entity-input {
display: inline-block;
margin-bottom: 5px;
}
.custom-url, .add-url-time-range {
display: flex;
position: relative;
padding-right: 30px;
button.remove-button {
top: 27px;
position: absolute;
right: 6px;
}
select {
height: 32px;
}
.form-group {
margin-right: 10px;
.url-link-to-radio {
.euiRadio {
display: inline-block;
padding-right: 20px;
}
}
.url-time-range {
width: 150px;
}
}

View file

@ -0,0 +1,380 @@
/*
* 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 {
TIME_RANGE_TYPE,
URL_TYPE
} from './constants';
import chrome from 'ui/chrome';
import rison from 'rison-node';
import { ML_RESULTS_INDEX_PATTERN } from 'plugins/ml/../common/constants/index_patterns';
import { getPartitioningFieldNames } from 'plugins/ml/../common/util/job_utils';
import { parseInterval } from 'plugins/ml/../common/util/parse_interval';
import { replaceTokensInUrlValue } from 'plugins/ml/util/custom_url_utils';
import { ml } from 'plugins/ml/services/ml_api_service';
import { mlJobService } from 'plugins/ml/services/job_service';
import { escapeForElasticsearchQuery } from 'plugins/ml/util/string_utils';
export function getNewCustomUrlDefaults(job, dashboards, indexPatterns) {
// Returns the settings object in the format used by the custom URL editor
// for a new custom URL.
const kibanaSettings = {
queryFieldNames: []
};
// Set the default type.
let urlType = URL_TYPE.OTHER;
if (dashboards !== undefined && dashboards.length > 0) {
urlType = URL_TYPE.KIBANA_DASHBOARD;
kibanaSettings.dashboardId = dashboards[0].id;
} else if (indexPatterns !== undefined && indexPatterns.length > 0) {
urlType = URL_TYPE.KIBANA_DISCOVER;
}
// For the Discover option, set the default index pattern to that
// which matches the (first) index configured in the job datafeed.
const datafeedConfig = job.datafeed_config;
if (indexPatterns !== undefined && indexPatterns.length > 0 &&
datafeedConfig !== undefined &&
datafeedConfig.indices !== undefined &&
datafeedConfig.indices.length > 0) {
const datafeedIndex = datafeedConfig.indices[0];
let defaultIndexPattern = indexPatterns.find((indexPattern) => {
return indexPattern.title === datafeedIndex;
});
if (defaultIndexPattern === undefined) {
defaultIndexPattern = indexPatterns[0];
}
kibanaSettings.discoverIndexPatternId = defaultIndexPattern.id;
}
return {
label: '',
type: urlType,
// Note timeRange is only editable in new URLs for Dashboard and Discover URLs,
// as for other URLs we have no way of knowing how the field will be used in the URL.
timeRange: {
type: TIME_RANGE_TYPE.AUTO,
interval: ''
},
kibanaSettings,
otherUrlSettings: {
urlValue: ''
}
};
}
export function getQueryEntityFieldNames(job) {
// Returns the list of partitioning and influencer field names that can be used
// as entities to add to the query used when linking to a Kibana dashboard or Discover.
const influencers = job.analysis_config.influencers;
const detectors = job.analysis_config.detectors;
const entityFieldNames = [];
if (influencers !== undefined) {
entityFieldNames.push(...influencers);
}
detectors.forEach((detector, detectorIndex) => {
const partitioningFields = getPartitioningFieldNames(job, detectorIndex);
partitioningFields.forEach((fieldName) => {
if (entityFieldNames.indexOf(fieldName) === -1) {
entityFieldNames.push(fieldName);
}
});
});
return entityFieldNames;
}
export function isValidCustomUrlLabel(label) {
// TODO - check label is unique for the job.
return (label !== undefined) && (label.trim().length > 0);
}
export function isValidCustomUrlSettingsTimeRange(timeRangeSettings) {
if (timeRangeSettings.type === TIME_RANGE_TYPE.INTERVAL) {
const interval = parseInterval(timeRangeSettings.interval);
return (interval !== null);
}
return true;
}
export function isValidCustomUrlSettings(settings) {
let isValid = isValidCustomUrlLabel(settings.label);
if (isValid === true) {
isValid = isValidCustomUrlSettingsTimeRange(settings.timeRange);
}
return isValid;
}
export function buildCustomUrlFromSettings(settings, job) {
// Dashboard URL returns a Promise as a query is made to obtain the full dashboard config.
// So wrap the other two return types in a Promise for consistent return type.
if (settings.type === URL_TYPE.KIBANA_DASHBOARD) {
return buildDashboardUrlFromSettings(settings);
} else if (settings.type === URL_TYPE.KIBANA_DISCOVER) {
return Promise.resolve(buildDiscoverUrlFromSettings(settings, job));
} else {
const urlToAdd = {
url_name: settings.label,
url_value: settings.otherUrlSettings.urlValue
};
return Promise.resolve(urlToAdd);
}
}
function buildDashboardUrlFromSettings(settings) {
// Get the complete list of attributes for the selected dashboard (query, filters).
return new Promise((resolve, reject) => {
const { dashboardId, queryFieldNames } = settings.kibanaSettings;
const savedObjectsClient = chrome.getSavedObjectsClient();
savedObjectsClient.get('dashboard', dashboardId)
.then((response) => {
// Use the filters from the saved dashboard if there are any.
let filters = [];
// Use the query from the dashboard only if no job entities are selected.
let query = undefined;
const searchSourceJSON = response.get('kibanaSavedObjectMeta.searchSourceJSON');
if (searchSourceJSON !== undefined) {
const searchSourceData = JSON.parse(searchSourceJSON);
if (searchSourceData.filter !== undefined) {
filters = searchSourceData.filter;
}
query = searchSourceData.query;
}
// Add time settings to the global state URL parameter with $earliest$ and
// $latest$ tokens which get substituted for times around the time of the
// anomaly on which the URL will be run against.
const _g = rison.encode({
time: {
from: '$earliest$',
to: '$latest$',
mode: 'absolute'
}
});
const appState = {
filters
};
// To put entities in filters section would involve creating parameters of the form
// filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:b30fd340-efb4-11e7-a600-0f58b1422b87,
// key:airline,negate:!f,params:(query:AAL,type:phrase),type:phrase,value:AAL),query:(match:(airline:(query:AAL,type:phrase)))))
// which includes the ID of the index holding the field used in the filter.
// So for simplicity, put entities in the query, replacing any query which is there already.
// e.g. query:(language:lucene,query:'region:us-east-1%20AND%20instance:i-20d061fa')
if (queryFieldNames !== undefined && queryFieldNames.length > 0) {
let queryString = '';
queryFieldNames.forEach((fieldName, index) => {
if (index > 0) {
queryString += ' AND ';
}
queryString += `${escapeForElasticsearchQuery(fieldName)}:"$${fieldName}$"`;
});
query = {
language: 'lucene',
query: queryString
};
}
if (query !== undefined) {
appState.query = query;
}
const _a = rison.encode(appState);
const urlValue = `kibana#/dashboard/${dashboardId}?_g=${_g}&_a=${_a}`;
const urlToAdd = {
url_name: settings.label,
url_value: urlValue,
time_range: TIME_RANGE_TYPE.AUTO
};
if (settings.timeRange.type === TIME_RANGE_TYPE.INTERVAL) {
urlToAdd.time_range = settings.timeRange.interval;
}
resolve(urlToAdd);
})
.catch((resp) => {
reject(resp);
});
});
}
function buildDiscoverUrlFromSettings(settings, job) {
const { discoverIndexPatternId, queryFieldNames } = settings.kibanaSettings;
// Add time settings to the global state URL parameter with $earliest$ and
// $latest$ tokens which get substituted for times around the time of the
// anomaly on which the URL will be run against.
const _g = rison.encode({
time: {
from: '$earliest$',
to: '$latest$',
mode: 'absolute'
}
});
// Add the index pattern and query to the appState part of the URL.
const appState = {
index: discoverIndexPatternId
};
// Use the query from the datafeed only if no job entities are selected.
let query = job.datafeed_config.query;
// To put entities in filters section would involve creating parameters of the form
// filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:b30fd340-efb4-11e7-a600-0f58b1422b87,
// key:airline,negate:!f,params:(query:AAL,type:phrase),type:phrase,value:AAL),query:(match:(airline:(query:AAL,type:phrase)))))
// which includes the ID of the index holding the field used in the filter.
// So for simplicity, put entities in the query, replacing any query which is there already.
// e.g. query:(language:lucene,query:'region:us-east-1%20AND%20instance:i-20d061fa')
if (queryFieldNames !== undefined && queryFieldNames.length > 0) {
let queryString = '';
queryFieldNames.forEach((fieldName, i) => {
if (i > 0) {
queryString += ' AND ';
}
queryString += `${escapeForElasticsearchQuery(fieldName)}:"$${fieldName}$"`;
});
query = {
language: 'lucene',
query: queryString
};
}
if (query !== undefined) {
appState.query = query;
}
const _a = rison.encode(appState);
const urlValue = `kibana#/discover?_g=${_g}&_a=${_a}`;
const urlToAdd = {
url_name: settings.label,
url_value: urlValue
};
if (settings.timeRange.type === TIME_RANGE_TYPE.INTERVAL) {
urlToAdd.time_range = settings.timeRange.interval;
}
return urlToAdd;
}
// Builds the full URL for testing out a custom URL configuration, which
// may contain dollar delimited partition / influencer entity tokens and
// drilldown time range settings.
export function getTestUrl(job, customUrl) {
const urlValue = customUrl.url_value;
const bucketSpanSecs = parseInterval(job.analysis_config.bucket_span).asSeconds();
// By default, return configured url_value. Look to substitute any dollar-delimited
// tokens with values from the highest scoring anomaly, or if no anomalies, with
// values from a document returned by the search in the job datafeed.
let testUrl = customUrl.url_value;
// Query to look for the highest scoring anomaly.
const body = {
query: {
bool: {
must: [
{ term: { job_id: job.job_id } },
{ term: { result_type: 'record' } }
]
}
},
size: 1,
_source: {
excludes: []
},
sort: [
{ record_score: { order: 'desc' } }
]
};
return new Promise((resolve, reject) => {
ml.esSearch({
index: ML_RESULTS_INDEX_PATTERN,
body
})
.then((resp) => {
if (resp.hits.total > 0) {
const record = resp.hits.hits[0]._source;
testUrl = replaceTokensInUrlValue(customUrl, bucketSpanSecs, record, 'timestamp');
resolve(testUrl);
} else {
// No anomalies yet for this job, so do a preview of the search
// configured in the job datafeed to obtain sample docs.
mlJobService.searchPreview(job)
.then((response) => {
let testDoc;
const docTimeFieldName = job.data_description.time_field;
// Handle datafeeds which use aggregations or documents.
if (response.aggregations) {
// Create a dummy object which contains the fields necessary to build the URL.
const firstBucket = response.aggregations.buckets.buckets[0];
testDoc = {
[docTimeFieldName]: firstBucket.key
};
// Look for bucket aggregations which match the tokens in the URL.
urlValue.replace((/\$([^?&$\'"]{1,40})\$/g), (match, name) => {
if (name !== 'earliest' && name !== 'latest' && firstBucket[name] !== undefined) {
const tokenBuckets = firstBucket[name];
if (tokenBuckets.buckets) {
testDoc[name] = tokenBuckets.buckets[0].key;
}
}
});
} else {
if (response.hits.total > 0) {
testDoc = response.hits.hits[0]._source;
}
}
if (testDoc !== undefined) {
testUrl = replaceTokensInUrlValue(customUrl, bucketSpanSecs, testDoc, docTimeFieldName);
}
resolve(testUrl);
});
}
})
.catch((resp) => {
reject(resp);
});
});
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './custom_url_editor_directive';
import './styles/main.less';

View file

@ -0,0 +1,45 @@
@import (reference) "~ui/styles/variables";
.ml-custom-url-manager {
.add-url-input {
display: block;
}
select {
width: 100%;
}
.link-to-label {
padding-right: 20px;
}
label.disabled {
color: @globalColorLightGray;
}
.entity-input {
display: inline-block;
margin-bottom: 5px;
}
.custom-url, .add-url-time-range {
display: flex;
position: relative;
padding-right: 30px;
button.remove-button {
top: 27px;
position: absolute;
right: 6px;
}
select {
height: 32px;
}
.form-group {
margin-right: 10px;
}
}
}

View file

@ -14,7 +14,7 @@ import numeral from '@elastic/numeral';
import { calculateDatafeedFrequencyDefaultSeconds } from 'plugins/ml/../common/util/job_utils';
import { parseInterval } from 'plugins/ml/../common/util/parse_interval';
import { customUrlEditorService } from 'plugins/ml/jobs/components/custom_url_editor/custom_url_editor_service';
import { customUrlEditorService } from 'plugins/ml/jobs/components/custom_url_editor_old/custom_url_editor_service';
import { isWebUrl } from 'plugins/ml/util/string_utils';
import { newJobLimits } from 'plugins/ml/jobs/new_job/utils/new_job_defaults';
import { mlJobService } from 'plugins/ml/services/job_service';

View file

@ -7,5 +7,5 @@
import './edit_job_modal_controller';
import 'plugins/ml/jobs/components/custom_url_editor';
import 'plugins/ml/jobs/components/custom_url_editor_old';
import 'plugins/ml/components/job_group_select';

View file

@ -154,12 +154,9 @@ export class EditJobFlyout extends Component {
}
setCustomUrls = (jobCustomUrls) => {
this.setState({
...jobCustomUrls
});
this.setState({ jobCustomUrls });
}
save = () => {
const newJobData = {
description: this.state.jobDescription,
@ -170,6 +167,7 @@ export class EditJobFlyout extends Component {
datafeedQueryDelay: this.state.datafeedQueryDelay,
datafeedFrequency: this.state.datafeedFrequency,
datafeedScrollSize: this.state.datafeedScrollSize,
customUrls: this.state.jobCustomUrls,
};
saveJob(this.state.job, newJobData)

View file

@ -6,6 +6,7 @@
import { difference } from 'lodash';
import chrome from 'ui/chrome';
import { newJobLimits } from 'plugins/ml/jobs/new_job/utils/new_job_defaults';
import { mlJobService } from 'plugins/ml/services/job_service';
@ -76,6 +77,68 @@ function saveDatafeed(datafeedData, job) {
});
}
export function loadSavedDashboards(maxNumber) {
// Loads the list of saved dashboards, as used in editing custom URLs.
return new Promise((resolve, reject) => {
const savedObjectsClient = chrome.getSavedObjectsClient();
savedObjectsClient.find({
type: 'dashboard',
fields: ['title'],
perPage: maxNumber
})
.then((resp)=> {
const savedObjects = resp.savedObjects;
if (savedObjects !== undefined) {
const dashboards = savedObjects.map(savedObj => {
return { id: savedObj.id, title: savedObj.attributes.title };
});
dashboards.sort((dash1, dash2) => {
return dash1.title.localeCompare(dash2.title);
});
resolve(dashboards);
}
})
.catch((resp) => {
reject(resp);
});
});
}
export function loadIndexPatterns(maxNumber) {
// Loads the list of Kibana index patterns, as used in editing custom URLs.
// TODO - amend loadIndexPatterns in index_utils.js to do the request,
// without needing an Angular Provider.
return new Promise((resolve, reject) => {
const savedObjectsClient = chrome.getSavedObjectsClient();
savedObjectsClient.find({
type: 'index-pattern',
fields: ['title'],
perPage: maxNumber
})
.then((resp)=> {
const savedObjects = resp.savedObjects;
if (savedObjects !== undefined) {
const indexPatterns = savedObjects.map(savedObj => {
return { id: savedObj.id, title: savedObj.attributes.title };
});
indexPatterns.sort((dash1, dash2) => {
return dash1.title.localeCompare(dash2.title);
});
resolve(indexPatterns);
}
})
.catch((resp) => {
reject(resp);
});
});
}
function extractDescription(job, newJobData) {
const description = newJobData.description;
if (newJobData.description !== job.description) {
@ -131,8 +194,9 @@ function extractDetectorDescriptions(job, newJobData) {
function extractCustomSettings(job, newJobData) {
const settingsData = {};
if (job.custom_settings && newJobData) {
if (job.custom_settings && newJobData && newJobData.customUrls) {
settingsData.custom_settings = job.custom_settings;
settingsData.custom_settings.custom_urls = newJobData.customUrls;
}
return settingsData;
}

View file

@ -4,21 +4,50 @@
* you may not use this file except in compliance with the Elastic License.
*/
import PropTypes from 'prop-types';
import React, {
Component
} from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiPanel,
EuiSpacer,
} from '@elastic/eui';
import { toastNotifications } from 'ui/notify';
import {
CustomUrlEditor,
CustomUrlList
} from 'plugins/ml/jobs/components/custom_url_editor';
import {
getNewCustomUrlDefaults,
getQueryEntityFieldNames,
isValidCustomUrlSettings,
buildCustomUrlFromSettings
} from 'plugins/ml/jobs/components/custom_url_editor/utils';
import {
loadSavedDashboards,
loadIndexPatterns,
} from '../edit_utils';
import '../styles/main.less';
const MAX_NUMBER_DASHBOARDS = 1000;
const MAX_NUMBER_INDEX_PATTERNS = 1000;
export class CustomUrls extends Component {
constructor(props) {
super(props);
this.state = {
job: {},
customUrls: [],
dashboards: [],
indexPatterns: [],
queryEntityFieldNames: [],
editorOpen: false,
};
this.setCustomUrls = props.setCustomUrls;
@ -29,20 +58,108 @@ export class CustomUrls extends Component {
return {
job: props.job,
customUrls: props.jobCustomUrls,
queryEntityFieldNames: getQueryEntityFieldNames(props.job),
};
}
componentDidMount() {
loadSavedDashboards(MAX_NUMBER_DASHBOARDS)
.then((dashboards)=> {
this.setState({ dashboards });
})
.catch((resp) => {
console.log('Error loading list of dashboards:', resp);
toastNotifications.addDanger('An error occurred loading the list of saved Kibana dashboards');
});
onCustomUrlsChange = (urls) => {
this.setCustomUrls({ jobCustomUrls: urls });
loadIndexPatterns(MAX_NUMBER_INDEX_PATTERNS)
.then((indexPatterns) => {
this.setState({ indexPatterns });
})
.catch((resp) => {
console.log('Error loading list of dashboards:', resp);
toastNotifications.addDanger('An error occurred loading the list of saved index patterns');
});
}
editNewCustomUrl = () => {
// Opens the editor for configuring a new custom URL.
this.setState((prevState) => {
const { dashboards, indexPatterns } = prevState;
return {
editorOpen: true,
editorSettings: getNewCustomUrlDefaults(this.props.job, dashboards, indexPatterns)
};
});
}
setEditCustomUrl = (customUrl) => {
this.setState({
editorSettings: customUrl
});
}
addNewCustomUrl = () => {
buildCustomUrlFromSettings(this.state.editorSettings, this.props.job)
.then((customUrl) => {
const customUrls = [...this.state.customUrls, customUrl];
this.setCustomUrls(customUrls);
this.setState({ editorOpen: false });
})
.catch((resp) => {
console.log('Error building custom URL from settings:', resp);
toastNotifications.addDanger('An error occurred building the new custom URL from the supplied settings');
});
}
render() {
const {
customUrls,
editorOpen,
editorSettings,
dashboards,
indexPatterns,
queryEntityFieldNames,
} = this.state;
return (
<React.Fragment>
<div />
<EuiSpacer size="m" />
<CustomUrlList
job={this.props.job}
customUrls={customUrls}
setCustomUrls={this.setCustomUrls}
/>
{editorOpen === false ? (
<EuiButtonEmpty
onClick={() => this.editNewCustomUrl()}
>
Add custom URL
</EuiButtonEmpty>
) : (
<React.Fragment>
<EuiSpacer size="l" />
<EuiPanel className="ml-custom-url-editor">
<CustomUrlEditor
customUrl={editorSettings}
setEditCustomUrl={this.setEditCustomUrl}
dashboards={dashboards}
indexPatterns={indexPatterns}
queryEntityFieldNames={queryEntityFieldNames}
/>
<EuiSpacer size="m" />
<EuiButton
onClick={() => this.addNewCustomUrl()}
isDisabled={!isValidCustomUrlSettings(editorSettings)}
>
Add
</EuiButton>
</EuiPanel>
</React.Fragment>
)}
</React.Fragment>
);
}