[i18n] Translate ML - time series explorer (#28066)

* Translate timeseries explorer

* Fix issues

* Fix tslint errors

* Fix tslint error

* Fix test

* Update tests

* Fix tests

* Update snapshot

* Resolve issues from review comments

* Resolve issues from review comments
This commit is contained in:
Nox911 2019-01-14 10:44:20 +03:00 committed by GitHub
parent b03e63b5de
commit 9b3a216f6f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 601 additions and 152 deletions

View file

@ -36,7 +36,7 @@ describe('ML - <ml-timeseries-chart>', () => {
document.getElementsByTagName('body')[0].append(mockClassedElement);
// spy the TimeseriesChart component's unmount method to be able to test if it was called
const componentWillUnmountSpy = sinon.spy(TimeseriesChart.prototype, 'componentWillUnmount');
const componentWillUnmountSpy = sinon.spy(TimeseriesChart.WrappedComponent.prototype, 'componentWillUnmount');
$element = $compile('<ml-timeseries-chart show-forecast="true" />')($scope);
const scope = $element.isolateScope();

View file

@ -6,9 +6,9 @@
import mockAnnotations from '../../../components/annotations_table/__mocks__/mock_annotations.json';
import { shallow } from 'enzyme';
import moment from 'moment-timezone';
import React from 'react';
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { AnnotationDescriptionList } from './index';
@ -21,7 +21,12 @@ describe('AnnotationDescriptionList', () => {
});
test('Initialization with annotation.', () => {
const wrapper = shallow(<AnnotationDescriptionList annotation={mockAnnotations[0]} />);
const wrapper = shallowWithIntl(
<AnnotationDescriptionList.WrappedComponent
annotation={mockAnnotations[0]}
intl={null as any}
/>
);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -13,47 +13,70 @@ import React from 'react';
import { EuiDescriptionList } from '@elastic/eui';
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { Annotation } from '../../../../common/types/annotations';
import { formatHumanReadableDateTimeSeconds } from '../../../util/date_utils';
interface Props {
annotation: Annotation;
intl: InjectedIntl;
}
export const AnnotationDescriptionList: React.SFC<Props> = ({ annotation }) => {
export const AnnotationDescriptionList = injectI18n(({ annotation, intl }: Props) => {
const listItems = [
{
title: 'Job ID',
title: intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.annotationDescriptionList.jobIdTitle',
defaultMessage: 'Job ID',
}),
description: annotation.job_id,
},
{
title: 'Start',
title: intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.annotationDescriptionList.startTitle',
defaultMessage: 'Start',
}),
description: formatHumanReadableDateTimeSeconds(annotation.timestamp),
},
];
if (annotation.end_timestamp !== undefined) {
listItems.push({
title: 'End',
title: intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.annotationDescriptionList.endTitle',
defaultMessage: 'End',
}),
description: formatHumanReadableDateTimeSeconds(annotation.end_timestamp),
});
}
if (annotation.create_time !== undefined && annotation.modified_time !== undefined) {
listItems.push({
title: 'Created',
title: intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.annotationDescriptionList.createdTitle',
defaultMessage: 'Created',
}),
description: formatHumanReadableDateTimeSeconds(annotation.create_time),
});
listItems.push({
title: 'Created by',
title: intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.annotationDescriptionList.createdByTitle',
defaultMessage: 'Created by',
}),
description: annotation.create_username,
});
listItems.push({
title: 'Last modified',
title: intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.annotationDescriptionList.lastModifiedTitle',
defaultMessage: 'Last modified',
}),
description: formatHumanReadableDateTimeSeconds(annotation.modified_time),
});
listItems.push({
title: 'Modified by',
title: intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.annotationDescriptionList.modifiedByTitle',
defaultMessage: 'Modified by',
}),
description: annotation.modified_username,
});
}
@ -65,4 +88,4 @@ export const AnnotationDescriptionList: React.SFC<Props> = ({ annotation }) => {
listItems={listItems}
/>
);
};
});

View file

@ -20,13 +20,16 @@ exports[`AnnotationFlyout Initialization. 1`] = `
<h2
id="mlAnnotationFlyoutTitle"
>
Edit
annotation
<FormattedMessage
defaultMessage="Edit annotation"
id="xpack.ml.timeSeriesExplorer.annotationFlyout.editAnnotationTitle"
values={Object {}}
/>
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<Component
<InjectIntl(Component)
annotation={
Object {
"_id": "KCCkDWgB_ZdQ1MFDSYPi",
@ -49,7 +52,13 @@ exports[`AnnotationFlyout Initialization. 1`] = `
describedByIds={Array []}
fullWidth={true}
hasEmptyLabelSpace={false}
label="Annotation text"
label={
<FormattedMessage
defaultMessage="Annotation text"
id="xpack.ml.timeSeriesExplorer.annotationFlyout.annotationTextLabel"
values={Object {}}
/>
}
>
<EuiTextArea
fullWidth={true}
@ -83,7 +92,11 @@ exports[`AnnotationFlyout Initialization. 1`] = `
onClick={[MockFunction]}
type="button"
>
Cancel
<FormattedMessage
defaultMessage="Cancel"
id="xpack.ml.timeSeriesExplorer.annotationFlyout.cancelButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem
@ -96,7 +109,11 @@ exports[`AnnotationFlyout Initialization. 1`] = `
onClick={[Function]}
type="button"
>
Delete
<FormattedMessage
defaultMessage="Delete"
id="xpack.ml.timeSeriesExplorer.annotationFlyout.deleteButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem
@ -111,7 +128,11 @@ exports[`AnnotationFlyout Initialization. 1`] = `
onClick={[Function]}
type="button"
>
Update
<FormattedMessage
defaultMessage="Update"
id="xpack.ml.timeSeriesExplorer.annotationFlyout.updateButtonLabel"
values={Object {}}
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -6,8 +6,8 @@
import mockAnnotations from '../../../components/annotations_table/__mocks__/mock_annotations.json';
import { shallow } from 'enzyme';
import React from 'react';
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { AnnotationFlyout } from './index';
@ -21,7 +21,7 @@ describe('AnnotationFlyout', () => {
saveAction: jest.fn(),
};
const wrapper = shallow(<AnnotationFlyout {...props} />);
const wrapper = shallowWithIntl(<AnnotationFlyout {...props} />);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -25,6 +25,8 @@ import { AnnotationDescriptionList } from '../annotation_description_list';
import { Annotation } from '../../../../common/types/annotations';
import { FormattedMessage } from '@kbn/i18n/react';
interface Props {
annotation: Annotation;
cancelAction: () => {};
@ -43,19 +45,37 @@ export const AnnotationFlyout: React.SFC<Props> = ({
const saveActionWrapper = () => saveAction(annotation);
const deleteActionWrapper = () => deleteAction(annotation);
const isExistingAnnotation = typeof annotation._id !== 'undefined';
const titlePrefix = isExistingAnnotation ? 'Edit' : 'Add';
return (
<EuiFlyout onClose={cancelAction} size="s" aria-labelledby="Add annotation">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 id="mlAnnotationFlyoutTitle">{titlePrefix} annotation</h2>
<h2 id="mlAnnotationFlyoutTitle">
{isExistingAnnotation ? (
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.annotationFlyout.editAnnotationTitle"
defaultMessage="Edit annotation"
/>
) : (
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.annotationFlyout.addAnnotationTitle"
defaultMessage="Add annotation"
/>
)}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<AnnotationDescriptionList annotation={annotation} />
<EuiSpacer size="m" />
<EuiFormRow label="Annotation text" fullWidth>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.annotationFlyout.annotationTextLabel"
defaultMessage="Annotation text"
/>
}
fullWidth
>
<EuiTextArea
fullWidth
isInvalid={annotation.annotation === ''}
@ -69,19 +89,35 @@ export const AnnotationFlyout: React.SFC<Props> = ({
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={cancelAction} flush="left">
Cancel
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.annotationFlyout.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{isExistingAnnotation && (
<EuiButtonEmpty color="danger" onClick={deleteActionWrapper}>
Delete
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.annotationFlyout.deleteButtonLabel"
defaultMessage="Delete"
/>
</EuiButtonEmpty>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill isDisabled={annotation.annotation === ''} onClick={saveActionWrapper}>
{isExistingAnnotation ? 'Update' : 'Create'}
{isExistingAnnotation ? (
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.annotationFlyout.updateButtonLabel"
defaultMessage="Update"
/>
) : (
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.annotationFlyout.createButtonLabel"
defaultMessage="Create"
/>
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -14,6 +14,8 @@ import {
EUI_MODAL_CONFIRM_BUTTON,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
export function DeleteAnnotationModal({
cancelAction,
deleteAction,
@ -24,11 +26,20 @@ export function DeleteAnnotationModal({
{isVisible === true &&
<EuiOverlayMask>
<EuiConfirmModal
title="Delete this annotation?"
title={<FormattedMessage
id="xpack.ml.timeSeriesExplorer.deleteAnnotationModal.deleteAnnotationTitle"
defaultMessage="Delete this annotation?"
/>}
onCancel={cancelAction}
onConfirm={deleteAction}
cancelButtonText="Cancel"
confirmButtonText="Delete"
cancelButtonText={<FormattedMessage
id="xpack.ml.timeSeriesExplorer.deleteAnnotationModal.cancelButtonLabel"
defaultMessage="Cancel"
/>}
confirmButtonText={<FormattedMessage
id="xpack.ml.timeSeriesExplorer.deleteAnnotationModal.deleteButtonLabel"
defaultMessage="Delete"
/>}
buttonColor="danger"
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
className="eui-textBreakWord"

View file

@ -25,6 +25,7 @@ import {
import { ProgressIcon } from './progress_icon';
import { PROGRESS_STATES } from './progress_states';
import { FormattedMessage } from '@kbn/i18n/react';
export function ForecastProgress({
@ -38,7 +39,12 @@ export function ForecastProgress({
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiText size="xs">
<h3>Opening job...</h3>
<h3>
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.forecastingModal.openingJobTitle"
defaultMessage="Opening job…"
/>
</h3>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -53,7 +59,12 @@ export function ForecastProgress({
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiText size="xs">
<h3>Running forecast...</h3>
<h3>
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.forecastingModal.runningForecastTitle"
defaultMessage="Running forecast…"
/>
</h3>
</EuiText>
</EuiFlexItem>
{forecastProgress >= 0 &&
@ -80,7 +91,12 @@ export function ForecastProgress({
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiText size="xs">
<h3>Closing job...</h3>
<h3>
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.forecastingModal.closingJobTitle"
defaultMessage="Closing job…"
/>
</h3>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>

View file

@ -33,6 +33,7 @@ import { PROGRESS_STATES } from './progress_states';
import { ml } from 'plugins/ml/services/ml_api_service';
import { mlJobService } from 'plugins/ml/services/job_service';
import { mlForecastService } from 'plugins/ml/services/forecast_service';
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
const FORECAST_JOB_MIN_VERSION = '6.1.0'; // Forecasting only allowed for jobs created >= 6.1.0.
const FORECASTS_VIEW_MAX = 5; // Display links to a maximum of 5 forecasts.
@ -58,7 +59,16 @@ function getDefaultState() {
}
class ForecastingModal extends Component {
export const ForecastingModal = injectI18n(class ForecastingModal extends Component {
static propTypes = {
isDisabled: PropTypes.bool,
job: PropTypes.object,
detectorIndex: PropTypes.number,
entities: PropTypes.array,
loadForForecastId: PropTypes.func,
timefilter: PropTypes.object,
};
constructor(props) {
super(props);
this.state = getDefaultState();
@ -81,18 +91,34 @@ class ForecastingModal extends Component {
};
onNewForecastDurationChange = (event) => {
const { intl } = this.props;
const newForecastDurationErrors = [];
let isNewForecastDurationValid = true;
const duration = parseInterval(event.target.value);
if(duration === null) {
isNewForecastDurationValid = false;
newForecastDurationErrors.push('Invalid duration format');
newForecastDurationErrors.push(
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.forecastingModal.invalidDurationFormatErrorMessage',
defaultMessage: 'Invalid duration format',
})
);
} else if (duration.asMilliseconds() > FORECAST_DURATION_MAX_MS) {
isNewForecastDurationValid = false;
newForecastDurationErrors.push('Forecast duration must not be greater than 8 weeks');
newForecastDurationErrors.push(
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.forecastingModal.forecastDurationMustNotBeGreaterThanMaximumErrorMessage',
defaultMessage: 'Forecast duration must not be greater than {maximumForecastDurationValue} weeks',
}, { maximumForecastDurationValue: 8 })
);
} else if (duration.asMilliseconds() === 0) {
isNewForecastDurationValid = false;
newForecastDurationErrors.push('Forecast duration must not be zero');
newForecastDurationErrors.push(
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.forecastingModal.forecastDurationMustNotBeZeroErrorMessage',
defaultMessage: 'Forecast duration must not be zero',
})
);
}
this.setState({
@ -133,7 +159,13 @@ class ForecastingModal extends Component {
})
.catch((resp) => {
console.log('Time series forecast modal - could not open job:', resp);
this.addMessage('Error opening job before running forecast', MESSAGE_LEVEL.ERROR);
this.addMessage(
this.props.intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.forecastingModal.errorWithOpeningJobBeforeRunningForecastErrorMessage',
defaultMessage: 'Error opening job before running forecast',
}),
MESSAGE_LEVEL.ERROR
);
this.setState({
jobOpeningState: PROGRESS_STATES.ERROR
});
@ -146,7 +178,11 @@ class ForecastingModal extends Component {
if (resp && resp.message) {
this.addMessage(resp.message, MESSAGE_LEVEL.ERROR, true);
} else {
this.addMessage('Unexpected response from running forecast. The request may have failed.',
this.addMessage(
this.props.intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.forecastingModal.unexpectedResponseFromRunningForecastErrorMessage',
defaultMessage: 'Unexpected response from running forecast. The request may have failed.',
}),
MESSAGE_LEVEL.ERROR, true);
}
};
@ -177,6 +213,7 @@ class ForecastingModal extends Component {
// Obtain the stats for the forecast request and check forecast is progressing.
// When the stats show the forecast is finished, load the
// forecast results into the view.
const { intl } = this.props;
let previousProgress = 0;
let noProgressMs = 0;
this.forecastChecker = setInterval(() => {
@ -210,7 +247,13 @@ class ForecastingModal extends Component {
// Load the forecast data in the main page,
// but leave this dialog open so the error can be viewed.
console.log('Time series forecast modal - could not close job:', response);
this.addMessage('Error closing job after running forecast', MESSAGE_LEVEL.ERROR);
this.addMessage(
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.forecastingModal.errorWithClosingJobAfterRunningForecastErrorMessage',
defaultMessage: 'Error closing job after running forecast',
}),
MESSAGE_LEVEL.ERROR
);
this.setState({
jobClosingState: PROGRESS_STATES.ERROR
});
@ -227,8 +270,14 @@ class ForecastingModal extends Component {
noProgressMs += FORECAST_STATS_POLL_FREQUENCY;
if (noProgressMs > WARN_NO_PROGRESS_MS) {
console.log(`Forecast request has not progressed for ${WARN_NO_PROGRESS_MS}ms. Cancelling check.`);
this.addMessage(`No progress reported for the new forecast for ${WARN_NO_PROGRESS_MS}ms. ` +
'An error may have occurred whilst running the forecast.', MESSAGE_LEVEL.ERROR);
this.addMessage(
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.forecastingModal.noProgressReportedForNewForecastErrorMessage',
defaultMessage: 'No progress reported for the new forecast for {WarnNoProgressMs}ms.' +
'An error may have occurred whilst running the forecast.'
}, { WarnNoProgressMs: WARN_NO_PROGRESS_MS }),
MESSAGE_LEVEL.ERROR
);
// Try and load any results which may have been created.
this.props.loadForForecastId(forecastId);
@ -243,7 +292,13 @@ class ForecastingModal extends Component {
}).catch((resp) => {
console.log('Time series forecast modal - error loading stats of forecast from elasticsearch:', resp);
this.addMessage('Error loading stats of running forecast.', MESSAGE_LEVEL.ERROR);
this.addMessage(
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.forecastingModal.errorWithLoadingStatsOfRunningForecastErrorMessage',
defaultMessage: 'Error loading stats of running forecast.',
}),
MESSAGE_LEVEL.ERROR
);
this.setState({
forecastProgress: PROGRESS_STATES.ERROR
});
@ -253,6 +308,7 @@ class ForecastingModal extends Component {
};
openModal = () => {
const { intl } = this.props;
const job = this.props.job;
if (typeof job === 'object') {
@ -275,7 +331,13 @@ class ForecastingModal extends Component {
})
.catch((resp) => {
console.log('Time series forecast modal - error obtaining forecasts summary:', resp);
this.addMessage('Error obtaining list of previous forecasts', MESSAGE_LEVEL.ERROR);
this.addMessage(
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.forecastingModal.errorWithObtainingListOfPreviousForecastsErrorMessage',
defaultMessage: 'Error obtaining list of previous forecasts',
}),
MESSAGE_LEVEL.ERROR
);
});
// Display a warning about running a forecast if there is high number
@ -297,9 +359,13 @@ class ForecastingModal extends Component {
});
if (numPartitions > WARN_NUM_PARTITIONS) {
this.addMessage(
`Note that this data contains more than ${WARN_NUM_PARTITIONS} ` +
`partitions so running a forecast may take a long time and consume a high amount of resource`,
MESSAGE_LEVEL.WARNING);
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.forecastingModal.dataContainsMorePartitionsMessage',
defaultMessage: 'Note that this data contains more than {warnNumPartitions} ' +
'partitions so running a forecast may take a long time and consume a high amount of resource',
}, { warnNumPartitions: WARN_NUM_PARTITIONS }),
MESSAGE_LEVEL.WARNING
);
}
})
.catch((resp) => {
@ -335,17 +401,22 @@ class ForecastingModal extends Component {
// Forecasting disabled if detector has an over field or job created < 6.1.0.
let isForecastingDisabled = false;
let forecastingDisabledMessage = null;
const job = this.props.job;
const { intl, job } = this.props;
if (job !== undefined) {
const detector = job.analysis_config.detectors[this.props.detectorIndex];
const overFieldName = detector.over_field_name;
if (overFieldName !== undefined) {
isForecastingDisabled = true;
forecastingDisabledMessage = 'Forecasting is not available for population detectors with an over field';
forecastingDisabledMessage = intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.forecastingModal.forecastingNotAvailableForPopulationDetectorsMessage',
defaultMessage: 'Forecasting is not available for population detectors with an over field',
});
} else if (isJobVersionGte(job, FORECAST_JOB_MIN_VERSION) === false) {
isForecastingDisabled = true;
forecastingDisabledMessage = `Forecasting is only available for jobs created in version ` +
`${FORECAST_JOB_MIN_VERSION} or later`;
forecastingDisabledMessage = intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.forecastingModal.forecastingOnlyAvailableForJobsCreatedInSpecifiedVersionMessage',
defaultMessage: 'Forecasting is only available for jobs created in version {minVersion} or later',
}, { minVersion: FORECAST_JOB_MIN_VERSION });
}
}
@ -356,7 +427,10 @@ class ForecastingModal extends Component {
isDisabled={isForecastingDisabled}
fill
>
Forecast
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.forecastingModal.forecastButtonLabel"
defaultMessage="Forecast"
/>
</EuiButton>
);
@ -392,14 +466,4 @@ class ForecastingModal extends Component {
</div>
);
}
}
ForecastingModal.propTypes = {
isDisabled: PropTypes.bool,
job: PropTypes.object,
detectorIndex: PropTypes.number,
entities: PropTypes.array,
loadForForecastId: PropTypes.func,
timefilter: PropTypes.object,
};
export { ForecastingModal };
});

View file

@ -13,11 +13,15 @@ const module = uiModules.get('apps/ml', ['react']);
import { ForecastingModal } from './forecasting_modal';
import { injectI18nProvider } from '@kbn/i18n/react';
module.directive('mlForecastingModal', function ($injector) {
const reactDirective = $injector.get('reactDirective');
return reactDirective(
ForecastingModal,
undefined,
injectI18nProvider(ForecastingModal),
// reactDirective service requires for react component to have propTypes, but injectI18n doesn't copy propTypes from wrapped component.
// That's why we pass propTypes directly to reactDirective service.
Object.keys(ForecastingModal.WrappedComponent.propTypes || {}),
{ restrict: 'E' },
{ timefilter }
);

View file

@ -22,32 +22,42 @@ import {
} from '@elastic/eui';
import { formatHumanReadableDateTimeSeconds } from '../../../util/date_utils';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
function getColumns(viewForecast) {
return [
{
field: 'forecast_create_timestamp',
name: 'Created',
name: i18n.translate('xpack.ml.timeSeriesExplorer.forecastsList.createdColumnName', {
defaultMessage: 'Created'
}),
dataType: 'date',
render: (date) => formatHumanReadableDateTimeSeconds(date),
sortable: true
},
{
field: 'forecast_start_timestamp',
name: 'From',
name: i18n.translate('xpack.ml.timeSeriesExplorer.forecastsList.fromColumnName', {
defaultMessage: 'From'
}),
dataType: 'date',
render: (date) => formatHumanReadableDateTimeSeconds(date),
sortable: true
},
{
field: 'forecast_end_timestamp',
name: 'To',
name: i18n.translate('xpack.ml.timeSeriesExplorer.forecastsList.toColumnName', {
defaultMessage: 'To'
}),
dataType: 'date',
render: (date) => formatHumanReadableDateTimeSeconds(date),
sortable: true
},
{
name: 'View',
name: i18n.translate('xpack.ml.timeSeriesExplorer.forecastsList.viewColumnName', {
defaultMessage: 'View'
}),
render: (forecast) => (
<EuiButton
className="view-forecast-btn"
@ -69,11 +79,17 @@ export function ForecastsList({ forecasts, viewForecast }) {
aria-describedby="ml_aria_description_forecasting_modal_view_list"
style={{ display: 'inline', paddingRight: '5px' }}
>
Previous forecasts
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.forecastsList.previousForecastsTitle"
defaultMessage="Previous forecasts"
/>
</h3>
<EuiToolTip
position="right"
content="Lists a maximum of five of the most recently run forecasts."
content={<FormattedMessage
id="xpack.ml.timeSeriesExplorer.forecastsList.listsOfFiveRecentlyRunForecastsTooltip"
defaultMessage="Lists a maximum of five of the most recently run forecasts."
/>}
>
<EuiIcon
type="questionInCircle"

View file

@ -29,6 +29,8 @@ import { MessageCallOut } from 'plugins/ml/components/message_call_out';
import { ForecastsList } from './forecasts_list';
import { RunControls } from './run_controls';
import { FormattedMessage } from '@kbn/i18n/react';
export function Modal(props) {
return (
@ -39,7 +41,12 @@ export function Modal(props) {
>
<EuiModalHeader>
<EuiModalHeaderTitle>Forecasting</EuiModalHeaderTitle>
<EuiModalHeaderTitle>
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.forecastingModal.forecastingTitle"
defaultMessage="Forecasting"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
@ -70,7 +77,10 @@ export function Modal(props) {
onClick={props.close}
size="s"
>
Close
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.forecastingModal.closeButtonLabel"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiModalFooter>
</EuiModal>

View file

@ -32,6 +32,8 @@ import { JOB_STATE } from '../../../../common/constants/states';
import { ForecastProgress } from './forecast_progress';
import { mlNodesAvailable } from 'plugins/ml/ml_nodes_check/check_ml_nodes';
import { checkPermission, createPermissionFailureMessage } from 'plugins/ml/privilege/check_privilege';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
function getRunInputDisabledState(
@ -45,7 +47,9 @@ function getRunInputDisabledState(
if (mlNodesAvailable() === false) {
return {
isDisabled: true,
isDisabledToolTipText: 'There are no ML nodes available.'
isDisabledToolTipText: i18n.translate('xpack.ml.timeSeriesExplorer.runControls.noMLNodesAvailableTooltip', {
defaultMessage: 'There are no ML nodes available.'
})
};
}
@ -60,7 +64,10 @@ function getRunInputDisabledState(
if (job.state !== JOB_STATE.OPENED && job.state !== JOB_STATE.CLOSED) {
return {
isDisabled: true,
isDisabledToolTipText: `Forecasts cannot be run on ${job.state} jobs`
isDisabledToolTipText: i18n.translate('xpack.ml.timeSeriesExplorer.runControls.forecastsCanNotBeRunOnJobsTooltip', {
defaultMessage: 'Forecasts cannot be run on {jobState} jobs',
values: { jobState: job.state }
})
};
}
@ -100,26 +107,40 @@ export function RunControls({
onClick={runForecast}
isDisabled={disabledState.isDisabled || !isNewForecastDurationValid}
>
Run
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.runControls.runButtonLabel"
defaultMessage="Run"
/>
</EuiButton>
);
return (
<div>
<EuiText>
<h3>Run a new forecast</h3>
<h3>
<FormattedMessage
id="xpack.ml.timeSeriesExplorer.runControls.runNewForecastTitle"
defaultMessage="Run a new forecast"
/>
</h3>
</EuiText>
<EuiSpacer size="s" />
<EuiForm>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
label="Duration"
label={<FormattedMessage
id="xpack.ml.timeSeriesExplorer.runControls.durationLabel"
defaultMessage="Duration"
/>}
fullWidth
isInvalid={!isNewForecastDurationValid}
error={newForecastDurationErrors}
helpText={'Length of forecast, up to a maximum of 8 weeks. ' +
'Use s for seconds, m for minutes, h for hours, d for days, w for weeks.'}
helpText={<FormattedMessage
id="xpack.ml.timeSeriesExplorer.runControls.forecastMaximumLengthHelpText"
defaultMessage="Length of forecast, up to a maximum of 8 weeks.
Use s for seconds, m for minutes, h for hours, d for days, w for weeks."
/>}
>
{disabledState.isDisabledToolTipText === undefined ? durationInput
: (

View file

@ -56,6 +56,8 @@ import {
unhighlightFocusChartAnnotation
} from './timeseries_chart_annotations';
import { injectI18n } from '@kbn/i18n/react';
const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false);
const focusZoomPanelHeight = 25;
@ -89,7 +91,7 @@ function getSvgHeight() {
return focusHeight + contextChartHeight + swimlaneHeight + chartSpacing + margin.top + margin.bottom;
}
export class TimeseriesChart extends React.Component {
export const TimeseriesChart = injectI18n(class TimeseriesChart extends React.Component {
static propTypes = {
indexAnnotation: PropTypes.func,
autoZoomDuration: PropTypes.number,
@ -160,17 +162,28 @@ export class TimeseriesChart extends React.Component {
const {
deleteAnnotation,
refresh,
toastNotifications
toastNotifications,
intl
} = this.props;
const { annotation } = this.state;
try {
await deleteAnnotation(annotation._id);
toastNotifications.addSuccess(`Deleted annotation for job with ID ${annotation.job_id}.`);
toastNotifications.addSuccess(
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.deletedAnnotationNotificationMessage',
defaultMessage: 'Deleted annotation for job with ID {jobId}.',
}, { jobId: annotation.job_id })
);
} catch (err) {
toastNotifications
.addDanger(`An error occured deleting the annotation for job with ID ${annotation.job_id}: ${JSON.stringify(err)}`);
.addDanger(
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithDeletingAnnotationNotificationErrorMessage',
defaultMessage: 'An error occured deleting the annotation for job with ID {jobId}: {error}',
}, { jobId: annotation.job_id, error: JSON.stringify(err) })
);
}
this.closeDeleteModal();
@ -183,7 +196,8 @@ export class TimeseriesChart extends React.Component {
const {
indexAnnotation,
refresh,
toastNotifications
toastNotifications,
intl
} = this.props;
this.closeFlyout();
@ -191,17 +205,38 @@ export class TimeseriesChart extends React.Component {
indexAnnotation(annotation)
.then(() => {
refresh();
const action = (typeof annotation._id === 'undefined') ? 'Added an' : 'Updated';
if (typeof annotation._id === 'undefined') {
toastNotifications.addSuccess(`${action} annotation for job with ID ${annotation.job_id}.`);
toastNotifications.addSuccess(
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.addedAnnotationNotificationMessage',
defaultMessage: 'Added an annotation for job with ID {jobId}.',
}, { jobId: annotation.job_id })
);
} else {
toastNotifications.addSuccess(`${action} annotation for job with ID ${annotation.job_id}.`);
toastNotifications.addSuccess(
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.updatedAnnotationNotificationMessage',
defaultMessage: 'Updated annotation for job with ID {jobId}.',
}, { jobId: annotation.job_id })
);
}
})
.catch((resp) => {
const action = (typeof annotation._id === 'undefined') ? 'creating' : 'updating';
toastNotifications
.addDanger(`An error occured ${action} the annotation for job with ID ${annotation.job_id}: ${JSON.stringify(resp)}`);
if (typeof annotation._id === 'undefined') {
toastNotifications.addDanger(
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithCreatingAnnotationNotificationErrorMessage',
defaultMessage: 'An error occured creating the annotation for job with ID {jobId}: {error}',
}, { jobId: annotation.job_id, error: JSON.stringify(resp) })
);
} else {
toastNotifications.addDanger(
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithUpdatingAnnotationNotificationErrorMessage',
defaultMessage: 'An error occured updating the annotation for job with ID {jobId}: {error}',
}, { jobId: annotation.job_id, error: JSON.stringify(resp) })
);
}
});
}
@ -580,7 +615,8 @@ export class TimeseriesChart extends React.Component {
selectedJob,
showAnnotations,
showForecast,
showModelBounds
showModelBounds,
intl
} = this.props;
if (focusChartData === undefined) {
@ -600,7 +636,11 @@ export class TimeseriesChart extends React.Component {
const bucketSpan = selectedJob.analysis_config.bucket_span;
const chartElement = d3.select(this.rootNode);
chartElement.select('.zoom-aggregation-interval').text(
`(aggregation interval: ${focusAggInt}, bucket span: ${bucketSpan})`);
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.zoomAggregationIntervalLabel',
defaultMessage: '(aggregation interval: {focusAggInt}, bucket span: {bucketSpan})',
}, { focusAggInt, bucketSpan })
);
// Render the axes.
@ -818,7 +858,8 @@ export class TimeseriesChart extends React.Component {
const {
autoZoomDuration,
modelPlotEnabled,
timefilter
timefilter,
intl
} = this.props;
const setZoomInterval = this.setZoomInterval.bind(this);
@ -834,7 +875,12 @@ export class TimeseriesChart extends React.Component {
.attr('x', xPos)
.attr('y', 17)
.attr('class', 'zoom-info-text')
.text('Zoom:');
.text(
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.zoomLabel',
defaultMessage: 'Zoom:',
})
);
const zoomOptions = [{ durationMs: autoZoomDuration, label: 'auto' }];
_.each(ZOOM_INTERVAL_OPTIONS, (option) => {
@ -862,14 +908,22 @@ export class TimeseriesChart extends React.Component {
.attr('x', (xPos + 6))
.attr('y', 17)
.attr('class', 'zoom-info-text zoom-aggregation-interval')
.text('(aggregation interval: , bucket span: )');
.text(
intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.zoomGroupAggregationIntervalLabel',
defaultMessage: '(aggregation interval: , bucket span: )',
})
);
if (modelPlotEnabled === false) {
const modelPlotLabel = zoomGroup.append('text')
.attr('x', 300)
.attr('y', 17)
.attr('class', 'zoom-info-text')
.text('Model bounds are not available');
.text(intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.modelBoundsNotAvailableLabel',
defaultMessage: 'Model bounds are not available',
}));
modelPlotLabel.attr('x', (fcsWidth - (modelPlotLabel.node().getBBox().width + 10)));
}
@ -1285,7 +1339,8 @@ export class TimeseriesChart extends React.Component {
showFocusChartTooltip(marker, circle) {
const {
modelPlotEnabled
modelPlotEnabled,
intl
} = this.props;
const fieldFormat = this.fieldFormat;
@ -1298,10 +1353,19 @@ export class TimeseriesChart extends React.Component {
if (_.has(marker, 'anomalyScore')) {
const score = parseInt(marker.anomalyScore);
const displayScore = (score > 0 ? score : '< 1');
contents += `anomaly score: ${displayScore}<br/>`;
contents += intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.anomalyScoreLabel',
defaultMessage: 'anomaly score: {displayScore}{br}'
}, { displayScore, br: '<br />' });
if (showMultiBucketAnomalyTooltip(marker) === true) {
contents += `multi-bucket impact: ${getMultiBucketImpactLabel(marker.multiBucketImpact)}<br/>`;
contents += intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.multiBucketImpactLabel',
defaultMessage: 'multi-bucket impact: {multiBucketImpactLabel}{br}'
}, {
br: '<br />',
multiBucketImpactLabel: getMultiBucketImpactLabel(marker.multiBucketImpact)
});
}
if (modelPlotEnabled === false) {
@ -1311,43 +1375,108 @@ export class TimeseriesChart extends React.Component {
if (_.has(marker, 'actual') && marker.function !== 'rare') {
// Display the record actual in preference to the chart value, which may be
// different depending on the aggregation interval of the chart.
contents += `actual: ${formatValue(marker.actual, marker.function, fieldFormat)}`;
contents += `<br/>typical: ${formatValue(marker.typical, marker.function, fieldFormat)}`;
contents += intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.actualLabel',
defaultMessage: 'actual: {actualValue}'
}, {
actualValue: formatValue(marker.actual, marker.function, fieldFormat)
});
contents += intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.typicalLabel',
defaultMessage: '{br}typical: {typicalValue}'
}, {
br: '<br />',
typicalValue: formatValue(marker.typical, marker.function, fieldFormat)
});
} else {
contents += `value: ${formatValue(marker.value, marker.function, fieldFormat)}`;
contents += intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.valueLabel',
defaultMessage: 'value: {value}'
}, {
value: formatValue(marker.value, marker.function, fieldFormat)
});
if (_.has(marker, 'byFieldName') && _.has(marker, 'numberOfCauses')) {
const numberOfCauses = marker.numberOfCauses;
// If numberOfCauses === 1, won't go into this block as actual/typical copied to top level fields.
const byFieldName = mlEscape(marker.byFieldName);
if (numberOfCauses < 10) {
// If numberOfCauses === 1, won't go into this block as actual/typical copied to top level fields.
contents += `<br/> ${numberOfCauses} unusual ${byFieldName} values`;
} else {
contents += intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.moreThanOneUnusualByFieldValuesLabel',
defaultMessage: '{br} {numberOfCauses}{plusSign} unusual {byFieldName} values'
}, {
br: '<br />',
numberOfCauses,
byFieldName,
// Maximum of 10 causes are stored in the record, so '10' may mean more than 10.
contents += `<br/> ${numberOfCauses}+ unusual ${byFieldName} values`;
}
plusSign: numberOfCauses < 10 ? '' : '+'
});
}
}
} else {
contents += `value: ${formatValue(marker.value, marker.function, fieldFormat)}`;
contents += `<br/>upper bounds: ${formatValue(marker.upper, marker.function, fieldFormat)}`;
contents += `<br/>lower bounds: ${formatValue(marker.lower, marker.function, fieldFormat)}`;
contents += intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.valueLabel',
defaultMessage: 'value: {value}'
}, {
value: formatValue(marker.value, marker.function, fieldFormat)
});
contents += intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.upperBoundsLabel',
defaultMessage: '{br}upper bounds: {upperBoundsValue}'
}, {
br: '<br />',
upperBoundsValue: formatValue(marker.upper, marker.function, fieldFormat)
});
contents += intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.lowerBoundsLabel',
defaultMessage: '{br}lower bounds: {lowerBoundsValue}'
}, {
br: '<br />',
lowerBoundsValue: formatValue(marker.lower, marker.function, fieldFormat)
});
}
} else {
// TODO - need better formatting for small decimals.
if (_.get(marker, 'isForecast', false) === true) {
contents += `prediction: ${formatValue(marker.value, marker.function, fieldFormat)}`;
contents += intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.withoutAnomalyScore.predictionLabel',
defaultMessage: 'prediction: {predictionValue}'
}, {
predictionValue: formatValue(marker.value, marker.function, fieldFormat)
});
} else {
contents += `value: ${formatValue(marker.value, marker.function, fieldFormat)}`;
contents += intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.withoutAnomalyScore.valueLabel',
defaultMessage: 'value: {value}'
}, {
value: formatValue(marker.value, marker.function, fieldFormat)
});
}
if (modelPlotEnabled === true) {
contents += `<br/>upper bounds: ${formatValue(marker.upper, marker.function, fieldFormat)}`;
contents += `<br/>lower bounds: ${formatValue(marker.lower, marker.function, fieldFormat)}`;
contents += intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.withoutAnomalyScoreAndModelPlotEnabled.upperBoundsLabel',
defaultMessage: '{br}upper bounds: {upperBoundsValue}'
}, {
br: '<br />',
upperBoundsValue: formatValue(marker.upper, marker.function, fieldFormat)
});
contents += intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.withoutAnomalyScoreAndModelPlotEnabled.lowerBoundsLabel',
defaultMessage: '{br}lower bounds: {lowerBoundsValue}'
}, {
br: '<br />',
lowerBoundsValue: formatValue(marker.lower, marker.function, fieldFormat)
});
}
}
if (_.has(marker, 'scheduledEvents')) {
contents += `<br/><hr/>Scheduled events:<br/>${marker.scheduledEvents.map(mlEscape).join('<br/>')}`;
contents += '<br/><hr/>' + intl.formatMessage({
id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.scheduledEventsLabel',
defaultMessage: 'Scheduled events:{br}{scheduledEventsValue}'
}, {
br: '<br />',
scheduledEventsValue: marker.scheduledEvents.map(mlEscape).join('<br/>')
});
}
if (mlAnnotationsEnabled && _.has(marker, 'annotation')) {
@ -1451,4 +1580,4 @@ export class TimeseriesChart extends React.Component {
</React.Fragment>
);
}
}
});

View file

@ -7,7 +7,7 @@
//import mockOverallSwimlaneData from './__mocks__/mock_overall_swimlane.json';
import moment from 'moment-timezone';
import { mount } from 'enzyme';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import React from 'react';
import { TimeseriesChart } from './timeseries_chart';
@ -62,7 +62,7 @@ describe('TimeseriesChart', () => {
test('Minimal initialization', () => {
const props = getTimeseriesChartPropsMock();
const wrapper = mount(<TimeseriesChart {...props}/>);
const wrapper = mountWithIntl(<TimeseriesChart.WrappedComponent {...props}/>);
expect(wrapper.html()).toBe(
`<div class="ml-timeseries-chart-react"></div>`

View file

@ -27,6 +27,8 @@ const module = uiModules.get('apps/ml');
import { ml } from 'plugins/ml/services/ml_api_service';
import { I18nProvider } from '@kbn/i18n/react';
import chrome from 'ui/chrome';
const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false);
@ -73,7 +75,9 @@ module.directive('mlTimeseriesChart', function () {
};
ReactDOM.render(
React.createElement(TimeseriesChart, props),
<I18nProvider>
{React.createElement(TimeseriesChart, props)}
</I18nProvider>,
element[0]
);
}

View file

@ -10,13 +10,27 @@
<div class="no-results-container" ng-if="jobs.length === 0 && loading === false">
<div class="no-results">
<div><i class="fa fa-exclamation-triangle"></i>No single metric jobs found</div>
<div><a href="ml#/jobs">Create new single metric job</a></div>
<div
i18n-id="xpack.ml.timeSeriesExplorer.noSingleMetricJobsFoundLabel"
i18n-default-message="{icon} No single metric jobs found"
i18n-values="{ html_icon: '<i class=\'fa fa-exclamation-triangle\'></i>' }">
</div>
<div>
<a
href="ml#/jobs"
i18n-id="xpack.ml.timeSeriesExplorer.createNewSingleMetricJobLinkText"
i18n-default-message="Create new single metric job"
></a></div>
</div>
</div>
<div class="series-controls" ng-show="jobs.length > 0">
<label for="selectDetector" class="kuiLabel">Detector:</label>
<label
for="selectDetector"
class="kuiLabel"
i18n-id="xpack.ml.timeSeriesExplorer.detectorLabel"
i18n-default-message="Detector:"
></label>
<select id="selectDetector" class="kuiSelect kuiSelect--large" ng-model="detectorId" ng-change="detectorIndexChanged()">
<option ng-repeat="detector in detectors track by detector.index" value="{{detector.index}}">{{detector.detector_description}}</option>
</select>
@ -29,11 +43,16 @@
<label for="select{{entity.fieldName}}" class="kuiLabel">{{entity.fieldName}}:</label>
<input id="select{{entity.fieldName}}" class="kuiTextInput entity-input"
ng-class="{ 'entity-input-blank': entity.fieldValue.length === 0 }"
ng-model="entity.fieldValue" ng-model-options="{ updateOn: 'blur' }" placeholder="Enter value"
ng-model="entity.fieldValue" ng-model-options="{ updateOn: 'blur' }"
placeholder="{{ ::'xpack.ml.timeSeriesExplorer.enterValuePlaceholder' | i18n: {defaultMessage: 'Enter value'} }}"
list='{{entity.fieldName}}_datalist' />
</div>
<button class="kuiButton kuiButton--primary" ng-click="saveSeriesPropertiesAndRefresh()" aria-label="refresh">
<button
class="kuiButton kuiButton--primary"
ng-click="saveSeriesPropertiesAndRefresh()"
aria-label="{{ ::'xpack.ml.timeSeriesExplorer.refreshButtonAriLabel' | i18n: {defaultMessage: 'refresh'} }}"
>
<i class="fa fa-play" ></i>
</button>
@ -54,25 +73,44 @@
<div class="no-results-container" ng-show="jobs.length > 0 && loading === false && hasResults === false">
<div class="no-results">
<div><i class="fa fa-info-circle" ></i>No results found</div>
<div>Try widening the time selection or moving further back in time</div>
<div
i18n-id="xpack.ml.timeSeriesExplorer.noResultsFoundLabel"
i18n-default-message="{icon} No results found"
i18n-values="{ html_icon: '<i class=\'fa fa-info-circle\'></i>' }"
></div>
<div
i18n-id="xpack.ml.timeSeriesExplorer.tryWideningTheTimeSelectionDescription"
i18n-default-message="Try widening the time selection or moving further back in time"
></div>
</div>
</div>
<div ng-show="jobs.length > 0 && loading === false && hasResults === true">
<div class="results-container">
<span class="panel-title euiText">
Single time series analysis of {{chartDetails.functionLabel}}
</span>
<span
class="panel-title euiText"
i18n-id="xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle"
i18n-default-message="Single time series analysis of {functionLabel}"
i18n-values="{ functionLabel: chartDetails.functionLabel }"
></span>
<span ng-if="chartDetails.entityData.count === 1" class="entity-count-text">
<span ng-repeat="entity in chartDetails.entityData.entities">
{{$first ? '(' : ''}}{{entity.fieldName}}: {{entity.fieldValue}}{{$last ? ')' : ', '}}
</span>
</span>
<span ng-if="chartDetails.entityData.count !== 1" class="entity-count-text">
<span ng-repeat="countData in chartDetails.entityData.entities">
{{$first ? '(' : ''}}{{countData.cardinality}} distinct {{countData.fieldName}}{{countData.cardinality > 1 ? ' values' : ''}}{{$last ? ')' : ', '}}
<span
ng-repeat="countData in chartDetails.entityData.entities"
i18n-id="xpack.ml.timeSeriesExplorer.countDataInChartDetailsDescription"
i18n-default-message="{openBrace}{cardinality} distinct {fieldName} {cardinality, plural, one {} other { values}}{closeBrace}"
i18n-values="{
openBrace: $first ? '(' : '',
closeBrace: $last ? ')' : ', ',
cardinality: countData.cardinality,
fieldName: countData.fieldName
}"
>
</span>
</span>
@ -83,7 +121,12 @@
class="kuiCheckBox"
ng-click="toggleShowModelBounds()"
ng-checked="showModelBounds === true">
<label for="toggleShowModelBoundsCheckbox" class="kuiCheckBoxLabel">show model bounds</label>
<label
for="toggleShowModelBoundsCheckbox"
class="kuiCheckBoxLabel"
i18n-id="xpack.ml.timeSeriesExplorer.showModelBoundsLabel"
i18n-default-message="show model bounds"
></label>
</div>
<div ng-show="showAnnotationsCheckbox === true">
@ -92,7 +135,12 @@
class="kuiCheckBox"
ng-click="toggleShowAnnotations()"
ng-checked="showAnnotations === true">
<label for="toggleAnnotationsCheckbox" class="kuiCheckBoxLabel">annotations</label>
<label
for="toggleAnnotationsCheckbox"
class="kuiCheckBoxLabel"
i18n-id="xpack.ml.timeSeriesExplorer.annotationsLabel"
i18n-default-message="annotations"
></label>
</div>
<div ng-show="showForecastCheckbox === true">
@ -101,7 +149,12 @@
class="kuiCheckBox"
ng-click="toggleShowForecast()"
ng-checked="showForecast === true">
<label for="toggleShowForecastCheckbox" class="kuiCheckBoxLabel">show forecast</label>
<label
for="toggleShowForecastCheckbox"
class="kuiCheckBoxLabel"
i18n-id="xpack.ml.timeSeriesExplorer.showForecastLabel"
i18n-default-message="show forecast"
></label>
</div>
</div>
@ -131,9 +184,11 @@
</div>
<div ng-show="showAnnotations && focusAnnotationData.length > 0">
<span class="panel-title euiText">
Annotations
</span>
<span
class="panel-title euiText"
i18n-id="xpack.ml.timeSeriesExplorer.annotationsTitle"
i18n-default-message="Annotations"
></span>
<ml-annotation-table
annotations="focusAnnotationData"
@ -144,14 +199,21 @@
<br /><br />
</div>
<span class="panel-title euiText">
Anomalies
</span>
<span
class="panel-title euiText"
i18n-id="xpack.ml.timeSeriesExplorer.anomaliesTitle"
i18n-default-message="Anomalies"
></span>
<div class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--directionRow euiFlexGroup--responsive ml-anomalies-controls">
<div class="euiFlexItem euiFlexItem--flexGrowZero" style="width:170px">
<div class="euiFormRow" id="select_severity_control">
<label class="euiFormLabel" for="select_severity">Severity threshold</label>
<label
class="euiFormLabel"
for="select_severity"
i18n-id="xpack.ml.timeSeriesExplorer.severityThresholdLabel"
i18n-default-message="Severity threshold"
></label>
<div class="euiFormControlLayout">
<ml-select-severity id="select_severity" />
</div>
@ -159,7 +221,12 @@
</div>
<div class="euiFlexItem euiFlexItem--flexGrowZero" style="width:170px">
<div class="euiFormRow" id="select_interval_control">
<label class="euiFormLabel" for="select_interval">Interval</label>
<label
class="euiFormLabel"
for="select_interval"
i18n-id="xpack.ml.timeSeriesExplorer.intervalLabel"
i18n-default-message="Interval"
></label>
<div class="euiFormControlLayout">
<ml-select-interval id="select_interval" />
</div>

View file

@ -81,7 +81,8 @@ module.controller('MlTimeSeriesExplorerController', function (
AppState,
config,
mlSelectIntervalService,
mlSelectSeverityService) {
mlSelectSeverityService,
i18n) {
$scope.timeFieldName = 'timestamp';
timefilter.enableTimeRangeSelector();
@ -143,10 +144,17 @@ module.controller('MlTimeSeriesExplorerController', function (
const invalidIds = _.difference(selectedJobIds, timeSeriesJobIds);
selectedJobIds = _.without(selectedJobIds, ...invalidIds);
if (invalidIds.length > 0) {
const s = invalidIds.length === 1 ? '' : 's';
let warningText = `You can't view requested job${s} ${invalidIds} in this dashboard`;
let warningText = i18n('xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage', {
defaultMessage: `You can't view requested {invalidIdsCount, plural, one {job} other {jobs}} {invalidIds} in this dashboard`,
values: {
invalidIdsCount: invalidIds.length,
invalidIds
}
});
if (selectedJobIds.length === 0 && timeSeriesJobIds.length > 0) {
warningText += ', auto selecting first job';
warningText += i18n('xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText', {
defaultMessage: ', auto selecting first job'
});
}
toastNotifications.addWarning(warningText);
}
@ -155,13 +163,21 @@ module.controller('MlTimeSeriesExplorerController', function (
// if more than one job or a group has been loaded from the URL
if (selectedJobIds.length > 1) {
// if more than one job, select the first job from the selection.
toastNotifications.addWarning('You can only view one job at a time in this dashboard');
toastNotifications.addWarning(
i18n('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', {
defaultMessage: 'You can only view one job at a time in this dashboard'
})
);
mlJobSelectService.setJobIds([selectedJobIds[0]]);
} else {
// if a group has been loaded
if (selectedJobIds.length > 0) {
// if the group contains valid jobs, select the first
toastNotifications.addWarning('You can only view one job at a time in this dashboard');
toastNotifications.addWarning(
i18n('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', {
defaultMessage: 'You can only view one job at a time in this dashboard'
})
);
mlJobSelectService.setJobIds([selectedJobIds[0]]);
} else if ($scope.jobs.length > 0) {
// if there are no valid jobs in the group but there are valid jobs
@ -714,7 +730,13 @@ module.controller('MlTimeSeriesExplorerController', function (
const appStateDtrIdx = $scope.appState.mlTimeSeriesExplorer.detectorIndex;
let detectorIndex = appStateDtrIdx !== undefined ? appStateDtrIdx : +(viewableDetectors[0].index);
if (_.find(viewableDetectors, { 'index': '' + detectorIndex }) === undefined) {
const warningText = `Requested detector index ${detectorIndex} is not valid for job ${$scope.selectedJob.job_id}`;
const warningText = i18n('xpack.ml.timeSeriesExplorer.requestedDetectorIndexNotValidWarningMessage', {
defaultMessage: 'Requested detector index {detectorIndex} is not valid for job {jobId}',
values: {
detectorIndex,
jobId: $scope.selectedJob.job_id
}
});
toastNotifications.addWarning(warningText);
detectorIndex = +(viewableDetectors[0].index);
$scope.appState.mlTimeSeriesExplorer.detectorIndex = detectorIndex;