mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[ML] Converts the custom URL editor to EUI / React (#21094)
This commit is contained in:
parent
1e7ce26303
commit
8464caf2a6
16 changed files with 1147 additions and 55 deletions
|
@ -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'
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
@ -5,7 +5,5 @@
|
|||
*/
|
||||
|
||||
|
||||
|
||||
|
||||
import './custom_url_editor_directive';
|
||||
import './styles/main.less';
|
||||
export { CustomUrlList } from './list';
|
||||
export { CustomUrlEditor } from './editor';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
}
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue