[ML] Model snapshot management (#68182)

* [ML] Model snapshot management

* more updates

* adding calendar range

* updating layout

* multiple calendars

* moving calendar creator

* fixing chart issues

* fixing chart issues

* improving calendar rendering

* adding capabilities checks

* code clean up

* fixing end time argument type

* fix translations

* code clean up

* comments based on review

* changes based on review

* fixing include

* adding useMemo to theme function
This commit is contained in:
James Gowdy 2020-06-16 14:03:17 +01:00 committed by GitHub
parent 63506834f2
commit 7a60f18ef9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 2175 additions and 64 deletions

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';

View file

@ -10,3 +10,4 @@ export * from './datafeed';
export * from './datafeed_stats';
export * from './combined_job';
export * from './summary_job';
export * from './model_snapshot';

View file

@ -0,0 +1,21 @@
/*
* 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 { JobId } from './job';
import { ModelSizeStats } from './job_stats';
export interface ModelSnapshot {
job_id: JobId;
min_version: string;
timestamp: number;
description: string;
snapshot_id: string;
snapshot_doc_count: number;
model_size_stats: ModelSizeStats;
latest_record_time_stamp: number;
latest_result_time_stamp: number;
retain: boolean;
}

View file

@ -43,6 +43,7 @@ import {
getLatestDataOrBucketTimestamp,
isTimeSeriesViewJob,
} from '../../../../../common/util/job_utils';
import { TIME_FORMAT } from '../../../../../common/constants/time_format';
import {
annotation$,
@ -50,8 +51,6 @@ import {
annotationsRefreshed,
} from '../../../services/annotations_service';
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
/**
* Table component for rendering the lists of annotations for an ML job.
*/

View file

@ -11,4 +11,5 @@ export {
useColorRange,
COLOR_RANGE,
COLOR_RANGE_SCALE,
useCurrentEuiTheme,
} from './use_color_range';

View file

@ -5,7 +5,7 @@
*/
import d3 from 'd3';
import { useMemo } from 'react';
import euiThemeLight from '@elastic/eui/dist/eui_theme_light.json';
import euiThemeDark from '@elastic/eui/dist/eui_theme_dark.json';
@ -150,7 +150,7 @@ export const useColorRange = (
colorRangeScale = COLOR_RANGE_SCALE.LINEAR,
featureCount = 1
) => {
const euiTheme = useUiSettings().get('theme:darkMode') ? euiThemeDark : euiThemeLight;
const { euiTheme } = useCurrentEuiTheme();
const colorRanges: Record<COLOR_RANGE, string[]> = {
[COLOR_RANGE.BLUE]: [
@ -186,3 +186,11 @@ export const useColorRange = (
return scaleTypes[colorRangeScale];
};
export function useCurrentEuiTheme() {
const uiSettings = useUiSettings();
return useMemo(
() => ({ euiTheme: uiSettings.get('theme:darkMode') ? euiThemeDark : euiThemeLight }),
[uiSettings]
);
}

View file

@ -13,10 +13,9 @@ import { i18n } from '@kbn/i18n';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import { JobMessage } from '../../../../common/types/audit_message';
import { TIME_FORMAT } from '../../../../common/constants/time_format';
import { JobIcon } from '../job_message_icon';
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
interface JobMessagesProps {
messages: JobMessage[];
loading: boolean;

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui';
import { COMBINED_JOB_STATE } from '../model_snapshots_table';
interface Props {
combinedJobState: COMBINED_JOB_STATE;
hideCloseJobModalVisible(): void;
forceCloseJob(): void;
}
export const CloseJobConfirm: FC<Props> = ({
combinedJobState,
hideCloseJobModalVisible,
forceCloseJob,
}) => {
return (
<EuiOverlayMask>
<EuiConfirmModal
title={
combinedJobState === COMBINED_JOB_STATE.OPEN_AND_RUNNING
? i18n.translate('xpack.ml.modelSnapshotTable.closeJobConfirm.stopAndClose.title', {
defaultMessage: 'Stop datafeed and close job?',
})
: i18n.translate('xpack.ml.modelSnapshotTable.closeJobConfirm.close.title', {
defaultMessage: 'Close job?',
})
}
onCancel={hideCloseJobModalVisible}
onConfirm={forceCloseJob}
cancelButtonText={i18n.translate(
'xpack.ml.modelSnapshotTable.closeJobConfirm.cancelButton',
{
defaultMessage: 'Cancel',
}
)}
confirmButtonText={
combinedJobState === COMBINED_JOB_STATE.OPEN_AND_RUNNING
? i18n.translate('xpack.ml.modelSnapshotTable.closeJobConfirm.stopAndClose.button', {
defaultMessage: 'Force stop and close',
})
: i18n.translate('xpack.ml.modelSnapshotTable.closeJobConfirm.close.button', {
defaultMessage: 'Force close',
})
}
defaultFocusedButton="confirm"
>
<p>
{combinedJobState === COMBINED_JOB_STATE.OPEN_AND_RUNNING && (
<FormattedMessage
id="xpack.ml.modelSnapshotTable.closeJobConfirm.contentOpenAndRunning"
defaultMessage="Job is currently open and running."
/>
)}
{combinedJobState === COMBINED_JOB_STATE.OPEN_AND_STOPPED && (
<FormattedMessage
id="xpack.ml.modelSnapshotTable.closeJobConfirm.contentOpen"
defaultMessage="Job is currently open."
/>
)}
<br />
<FormattedMessage
id="xpack.ml.modelSnapshotTable.closeJobConfirm.content"
defaultMessage="Snapshot revert can only happen on jobs which are closed."
/>
</p>
</EuiConfirmModal>
</EuiOverlayMask>
);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { CloseJobConfirm } from './close_job_confirm';

View file

@ -0,0 +1,218 @@
/*
* 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.
*/
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useCallback, useState, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFlyout,
EuiFlyoutFooter,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiButtonEmpty,
EuiTitle,
EuiFlyoutBody,
EuiSpacer,
EuiTextArea,
EuiFormRow,
EuiSwitch,
EuiConfirmModal,
EuiOverlayMask,
EuiCallOut,
} from '@elastic/eui';
import {
ModelSnapshot,
CombinedJobWithStats,
} from '../../../../../common/types/anomaly_detection_jobs';
import { ml } from '../../../services/ml_api_service';
import { useNotifications } from '../../../contexts/kibana';
interface Props {
snapshot: ModelSnapshot;
job: CombinedJobWithStats;
closeFlyout(reload: boolean): void;
}
export const EditModelSnapshotFlyout: FC<Props> = ({ snapshot, job, closeFlyout }) => {
const { toasts } = useNotifications();
const [description, setDescription] = useState(snapshot.description);
const [retain, setRetain] = useState(snapshot.retain);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [isCurrentSnapshot, setIsCurrentSnapshot] = useState(
snapshot.snapshot_id === job.model_snapshot_id
);
useEffect(() => {
setIsCurrentSnapshot(snapshot.snapshot_id === job.model_snapshot_id);
}, [snapshot]);
const updateSnapshot = useCallback(async () => {
try {
await ml.updateModelSnapshot(snapshot.job_id, snapshot.snapshot_id, {
description,
retain,
});
closeWithReload();
} catch (error) {
toasts.addError(new Error(error.body.message), {
title: i18n.translate('xpack.ml.editModelSnapshotFlyout.saveErrorTitle', {
defaultMessage: 'Model snapshot update failed',
}),
});
}
}, [retain, description, snapshot]);
const deleteSnapshot = useCallback(async () => {
try {
await ml.deleteModelSnapshot(snapshot.job_id, snapshot.snapshot_id);
hideDeleteModal();
closeWithReload();
} catch (error) {
toasts.addError(new Error(error.body.message), {
title: i18n.translate('xpack.ml.editModelSnapshotFlyout.deleteErrorTitle', {
defaultMessage: 'Model snapshot deletion failed',
}),
});
}
}, [snapshot]);
function closeWithReload() {
closeFlyout(true);
}
function closeWithoutReload() {
closeFlyout(false);
}
function showDeleteModal() {
setDeleteModalVisible(true);
}
function hideDeleteModal() {
setDeleteModalVisible(false);
}
return (
<>
<EuiFlyout onClose={closeWithoutReload} hideCloseButton size="m">
<EuiFlyoutBody>
<EuiFlexItem>
<EuiTitle size="s">
<h5>
<FormattedMessage
id="xpack.ml.editModelSnapshotFlyout.title"
defaultMessage="Edit snapshot {ssId}"
values={{ ssId: snapshot.snapshot_id }}
/>
</h5>
</EuiTitle>
{isCurrentSnapshot && (
<>
<EuiSpacer size="m" />
<EuiCallOut
size="s"
title={i18n.translate('xpack.ml.editModelSnapshotFlyout.calloutTitle', {
defaultMessage: 'Current snapshot',
})}
>
<FormattedMessage
id="xpack.ml.editModelSnapshotFlyout.calloutText"
defaultMessage="This is the current snapshot being used by job {jobId} and so cannot be deleted."
values={{ jobId: job.job_id }}
/>
</EuiCallOut>
</>
)}
<EuiSpacer size="l" />
<EuiFormRow
label={i18n.translate('xpack.ml.editModelSnapshotFlyout.descriptionTitle', {
defaultMessage: 'Description',
})}
fullWidth
>
<EuiTextArea
fullWidth
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow fullWidth>
<EuiSwitch
label={i18n.translate('xpack.ml.editModelSnapshotFlyout.retainSwitchLabel', {
defaultMessage: 'Retain snapshot during automatic snapshot cleanup process',
})}
checked={retain}
onChange={(e) => setRetain(e.target.checked)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={closeWithoutReload} flush="left">
<FormattedMessage
id="xpack.ml.editModelSnapshotFlyout.closeButton"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={true} />
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={showDeleteModal}
color="danger"
disabled={isCurrentSnapshot === true}
>
<FormattedMessage
id="xpack.ml.editModelSnapshotFlyout.useDefaultButton"
defaultMessage="Delete"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={updateSnapshot} fill>
<FormattedMessage
id="xpack.ml.editModelSnapshotFlyout.saveButton"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
{deleteModalVisible && (
<EuiOverlayMask>
<EuiConfirmModal
title={i18n.translate('xpack.ml.editModelSnapshotFlyout.deleteTitle', {
defaultMessage: 'Delete snapshot?',
})}
onCancel={hideDeleteModal}
onConfirm={deleteSnapshot}
cancelButtonText={i18n.translate('xpack.ml.editModelSnapshotFlyout.cancelButton', {
defaultMessage: 'Cancel',
})}
confirmButtonText={i18n.translate('xpack.ml.editModelSnapshotFlyout.deleteButton', {
defaultMessage: 'Delete',
})}
buttonColor="danger"
defaultFocusedButton="confirm"
/>
</EuiOverlayMask>
)}
</>
);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { EditModelSnapshotFlyout } from './edit_model_snapshot_flyout';

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { ModelSnapshotTable } from './model_snapshots_table';

View file

@ -0,0 +1,267 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useEffect, useCallback, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiLoadingSpinner,
EuiBasicTableColumn,
formatDate,
} from '@elastic/eui';
import { checkPermission } from '../../capabilities/check_capabilities';
import { EditModelSnapshotFlyout } from './edit_model_snapshot_flyout';
import { RevertModelSnapshotFlyout } from './revert_model_snapshot_flyout';
import { ml } from '../../services/ml_api_service';
import { JOB_STATE, DATAFEED_STATE } from '../../../../common/constants/states';
import { TIME_FORMAT } from '../../../../common/constants/time_format';
import { CloseJobConfirm } from './close_job_confirm';
import {
ModelSnapshot,
CombinedJobWithStats,
} from '../../../../common/types/anomaly_detection_jobs';
interface Props {
job: CombinedJobWithStats;
refreshJobList: () => void;
}
export enum COMBINED_JOB_STATE {
OPEN_AND_RUNNING,
OPEN_AND_STOPPED,
CLOSED,
UNKNOWN,
}
export const ModelSnapshotTable: FC<Props> = ({ job, refreshJobList }) => {
const canCreateJob = checkPermission('canCreateJob');
const canStartStopDatafeed = checkPermission('canStartStopDatafeed');
const [snapshots, setSnapshots] = useState<ModelSnapshot[]>([]);
const [snapshotsLoaded, setSnapshotsLoaded] = useState<boolean>(false);
const [editSnapshot, setEditSnapshot] = useState<ModelSnapshot | null>(null);
const [revertSnapshot, setRevertSnapshot] = useState<ModelSnapshot | null>(null);
const [closeJobModalVisible, setCloseJobModalVisible] = useState<ModelSnapshot | null>(null);
const [combinedJobState, setCombinedJobState] = useState<COMBINED_JOB_STATE | null>(null);
useEffect(() => {
loadModelSnapshots();
}, []);
const loadModelSnapshots = useCallback(async () => {
const { model_snapshots: ms } = await ml.getModelSnapshots(job.job_id);
setSnapshots(ms);
setSnapshotsLoaded(true);
}, [job]);
const checkJobIsClosed = useCallback(
async (snapshot: ModelSnapshot) => {
const state = await getCombinedJobState(job.job_id);
if (state === COMBINED_JOB_STATE.UNKNOWN) {
// this will only happen if the job has been deleted by another user
// between the time the row has been expended and now
// eslint-disable-next-line no-console
console.error(`Error retrieving state for job ${job.job_id}`);
return;
}
setCombinedJobState(state);
if (state === COMBINED_JOB_STATE.CLOSED) {
// show flyout
setRevertSnapshot(snapshot);
} else {
// show close job modal
setCloseJobModalVisible(snapshot);
}
},
[job]
);
function hideCloseJobModalVisible() {
setCombinedJobState(null);
setCloseJobModalVisible(null);
}
const forceCloseJob = useCallback(async () => {
await ml.jobs.forceStopAndCloseJob(job.job_id);
if (closeJobModalVisible !== null) {
const state = await getCombinedJobState(job.job_id);
if (state === COMBINED_JOB_STATE.CLOSED) {
setRevertSnapshot(closeJobModalVisible);
}
}
hideCloseJobModalVisible();
}, [job, closeJobModalVisible]);
const closeEditFlyout = useCallback((reload: boolean) => {
setEditSnapshot(null);
if (reload) {
loadModelSnapshots();
}
}, []);
const closeRevertFlyout = useCallback((reload: boolean) => {
setRevertSnapshot(null);
if (reload) {
loadModelSnapshots();
// wait half a second before refreshing the jobs list
setTimeout(refreshJobList, 500);
}
}, []);
const columns: Array<EuiBasicTableColumn<any>> = [
{
field: 'snapshot_id',
name: i18n.translate('xpack.ml.modelSnapshotTable.id', {
defaultMessage: 'ID',
}),
sortable: true,
},
{
field: 'description',
name: i18n.translate('xpack.ml.modelSnapshotTable.description', {
defaultMessage: 'Description',
}),
sortable: true,
},
{
field: 'timestamp',
name: i18n.translate('xpack.ml.modelSnapshotTable.time', {
defaultMessage: 'Date created',
}),
dataType: 'date',
render: renderDate,
sortable: true,
},
{
field: 'latest_record_time_stamp',
name: i18n.translate('xpack.ml.modelSnapshotTable.latestTimestamp', {
defaultMessage: 'Latest timestamp',
}),
dataType: 'date',
render: renderDate,
sortable: true,
},
{
field: 'retain',
name: i18n.translate('xpack.ml.modelSnapshotTable.retain', {
defaultMessage: 'Retain',
}),
width: '100px',
sortable: true,
},
{
field: '',
width: '100px',
name: i18n.translate('xpack.ml.modelSnapshotTable.actions', {
defaultMessage: 'Actions',
}),
actions: [
{
name: i18n.translate('xpack.ml.modelSnapshotTable.actions.revert.name', {
defaultMessage: 'Revert',
}),
description: i18n.translate('xpack.ml.modelSnapshotTable.actions.revert.description', {
defaultMessage: 'Revert to this snapshot',
}),
enabled: () => canCreateJob && canStartStopDatafeed,
type: 'icon',
icon: 'crosshairs',
onClick: checkJobIsClosed,
},
{
name: i18n.translate('xpack.ml.modelSnapshotTable.actions.edit.name', {
defaultMessage: 'Edit',
}),
description: i18n.translate('xpack.ml.modelSnapshotTable.actions.edit.description', {
defaultMessage: 'Edit this snapshot',
}),
enabled: () => canCreateJob,
type: 'icon',
icon: 'pencil',
onClick: setEditSnapshot,
},
],
},
];
if (snapshotsLoaded === false) {
return (
<>
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="l" />
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}
return (
<>
<EuiInMemoryTable
className="eui-textOverflowWrap"
compressed={true}
items={snapshots}
columns={columns}
pagination={{
pageSizeOptions: [5, 10, 25],
}}
sorting={{
sort: {
field: 'timestamp',
direction: 'asc',
},
}}
/>
{editSnapshot !== null && (
<EditModelSnapshotFlyout snapshot={editSnapshot} job={job} closeFlyout={closeEditFlyout} />
)}
{revertSnapshot !== null && (
<RevertModelSnapshotFlyout
snapshot={revertSnapshot}
snapshots={snapshots}
job={job}
closeFlyout={closeRevertFlyout}
/>
)}
{closeJobModalVisible !== null && combinedJobState !== null && (
<CloseJobConfirm
combinedJobState={combinedJobState}
hideCloseJobModalVisible={hideCloseJobModalVisible}
forceCloseJob={forceCloseJob}
/>
)}
</>
);
};
function renderDate(date: number) {
return formatDate(date, TIME_FORMAT);
}
async function getCombinedJobState(jobId: string) {
const jobs = await ml.jobs.jobs([jobId]);
if (jobs.length !== 1) {
return COMBINED_JOB_STATE.UNKNOWN;
}
if (jobs[0].state !== JOB_STATE.CLOSED) {
if (jobs[0].datafeed_config.state !== DATAFEED_STATE.STOPPED) {
return COMBINED_JOB_STATE.OPEN_AND_RUNNING;
}
return COMBINED_JOB_STATE.OPEN_AND_STOPPED;
}
return COMBINED_JOB_STATE.CLOSED;
}

View file

@ -0,0 +1,87 @@
/*
* 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 { MlResultsService } from '../../../services/results_service';
import { CombinedJobWithStats } from '../../../../../common/types/anomaly_detection_jobs';
import { getSeverityType } from '../../../../../common/util/anomaly_utils';
import { Anomaly } from '../../../jobs/new_job/common/results_loader/results_loader';
import { LineChartPoint } from '../../../jobs/new_job/common/chart_loader/chart_loader';
export function chartLoaderProvider(mlResultsService: MlResultsService) {
async function loadEventRateForJob(
job: CombinedJobWithStats,
bucketSpanMs: number,
bars: number
): Promise<LineChartPoint[]> {
const intervalMs = Math.max(
Math.floor(
(job.data_counts.latest_record_timestamp - job.data_counts.earliest_record_timestamp) / bars
),
bucketSpanMs
);
const resp = await mlResultsService.getEventRateData(
job.datafeed_config.indices.join(),
job.datafeed_config.query,
job.data_description.time_field,
job.data_counts.earliest_record_timestamp,
job.data_counts.latest_record_timestamp,
intervalMs
);
if (resp.error !== undefined) {
throw resp.error;
}
const events = Object.entries(resp.results).map(([time, value]) => ({
time: +time,
value: value as number,
}));
if (events.length) {
// add one extra bucket with a value of 0
// so that an extra blank bar gets drawn at the end of the chart
// this solves an issue with elastic charts where the rect annotation
// never covers the last bar.
events.push({ time: events[events.length - 1].time + intervalMs, value: 0 });
}
return events;
}
async function loadAnomalyDataForJob(
job: CombinedJobWithStats,
bucketSpanMs: number,
bars: number
) {
const intervalMs = Math.max(
Math.floor(
(job.data_counts.latest_record_timestamp - job.data_counts.earliest_record_timestamp) / bars
),
bucketSpanMs
);
const resp = await mlResultsService.getScoresByBucket(
[job.job_id],
job.data_counts.earliest_record_timestamp,
job.data_counts.latest_record_timestamp,
intervalMs,
1
);
const results = resp.results[job.job_id];
if (results === undefined) {
return [];
}
const anomalies: Record<number, Anomaly[]> = {};
anomalies[0] = Object.entries(results).map(
([time, value]) =>
({ time: +time, value, severity: getSeverityType(value as number) } as Anomaly)
);
return anomalies;
}
return { loadEventRateForJob, loadAnomalyDataForJob };
}

View file

@ -0,0 +1,310 @@
/*
* 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.
*/
/*
* 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.
*/
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, Fragment, useCallback, memo } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import moment from 'moment';
import { XYBrushArea } from '@elastic/charts';
import {
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiFormRow,
EuiFieldText,
EuiDatePicker,
EuiButtonIcon,
EuiPanel,
} from '@elastic/eui';
import { EventRateChart } from '../../../jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart';
import { Anomaly } from '../../../jobs/new_job/common/results_loader/results_loader';
import { useCurrentEuiTheme } from '../../../components/color_range_legend';
import { LineChartPoint } from '../../../jobs/new_job/common/chart_loader/chart_loader';
export interface CalendarEvent {
start: moment.Moment | null;
end: moment.Moment | null;
description: string;
}
interface Props {
calendarEvents: CalendarEvent[];
setCalendarEvents: (calendars: CalendarEvent[]) => void;
minSelectableTimeStamp: number;
maxSelectableTimeStamp: number;
eventRateData: LineChartPoint[];
anomalies: Anomaly[];
chartReady: boolean;
}
export const CreateCalendar: FC<Props> = ({
calendarEvents,
setCalendarEvents,
minSelectableTimeStamp,
maxSelectableTimeStamp,
eventRateData,
anomalies,
chartReady,
}) => {
const maxSelectableTimeMoment = moment(maxSelectableTimeStamp);
const minSelectableTimeMoment = moment(minSelectableTimeStamp);
const { euiTheme } = useCurrentEuiTheme();
const onBrushEnd = useCallback(
({ x }: XYBrushArea) => {
if (x && x.length === 2) {
const end = x[1] < minSelectableTimeStamp ? null : x[1];
if (end !== null) {
const start = x[0] < minSelectableTimeStamp ? minSelectableTimeStamp : x[0];
setCalendarEvents([
...calendarEvents,
{
start: moment(start),
end: moment(end),
description: createDefaultEventDescription(calendarEvents.length + 1),
},
]);
}
}
},
[calendarEvents]
);
const setStartDate = useCallback(
(start: moment.Moment | null, index: number) => {
const event = calendarEvents[index];
if (event === undefined) {
setCalendarEvents([
...calendarEvents,
{ start, end: null, description: createDefaultEventDescription(index) },
]);
} else {
event.start = start;
setCalendarEvents([...calendarEvents]);
}
},
[calendarEvents]
);
const setEndDate = useCallback(
(end: moment.Moment | null, index: number) => {
const event = calendarEvents[index];
if (event === undefined) {
setCalendarEvents([
...calendarEvents,
{ start: null, end, description: createDefaultEventDescription(index) },
]);
} else {
event.end = end;
setCalendarEvents([...calendarEvents]);
}
},
[calendarEvents]
);
const setDescription = useCallback(
(description: string, index: number) => {
const event = calendarEvents[index];
if (event !== undefined) {
event.description = description;
setCalendarEvents([...calendarEvents]);
}
},
[calendarEvents]
);
const removeCalendarEvent = useCallback(
(index: number) => {
if (calendarEvents[index] !== undefined) {
const ce = [...calendarEvents];
ce.splice(index, 1);
setCalendarEvents(ce);
}
},
[calendarEvents]
);
return (
<>
<EuiSpacer size="l" />
<div>
<FormattedMessage
id="xpack.ml.revertModelSnapshotFlyout.createCalendar.title"
defaultMessage="Select time range for calendar event."
/>
</div>
<EuiSpacer size="m" />
<Chart
eventRateData={eventRateData}
anomalies={anomalies}
loading={chartReady === false}
overlayRanges={calendarEvents.filter(filterIncompleteEvents).map((c) => ({
start: c.start!.valueOf(),
end: c.end!.valueOf(),
}))}
onBrushEnd={onBrushEnd}
overlayColor={euiTheme.euiColorPrimary}
/>
<EuiSpacer size="s" />
{calendarEvents.map((c, i) => (
<Fragment key={i}>
<EuiPanel paddingSize="s">
<EuiFlexGroup>
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate(
'xpack.ml.revertModelSnapshotFlyout.createCalendar.fromLabel',
{
defaultMessage: 'From',
}
)}
>
<EuiDatePicker
showTimeSelect
selected={c.start}
minDate={minSelectableTimeMoment}
maxDate={c.end ?? maxSelectableTimeMoment}
onChange={(d) => setStartDate(d, i)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate(
'xpack.ml.revertModelSnapshotFlyout.createCalendar.toLabel',
{
defaultMessage: 'To',
}
)}
>
<EuiDatePicker
showTimeSelect
selected={c.end}
minDate={c.start ?? minSelectableTimeMoment}
maxDate={maxSelectableTimeMoment}
onChange={(d) => setEndDate(d, i)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.ml.revertModelSnapshotFlyout.createCalendar.descriptionLabel',
{
defaultMessage: 'Description',
}
)}
>
<EuiFieldText
fullWidth
value={c.description}
onChange={(e) => setDescription(e.target.value, i)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem
grow={false}
style={{
borderLeft: `1px solid ${euiTheme.euiColorLightShade}`,
marginRight: '0px',
}}
/>
<EuiFlexItem grow={false}>
<EuiButtonIcon
style={{ margin: 'auto' }}
color={'danger'}
onClick={() => removeCalendarEvent(i)}
iconType="trash"
aria-label={i18n.translate(
'xpack.ml.revertModelSnapshotFlyout.createCalendar.deleteLabel',
{
defaultMessage: 'Delete event',
}
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
<EuiSpacer size="m" />
</Fragment>
))}
</>
);
};
interface ChartProps {
eventRateData: LineChartPoint[];
anomalies: Anomaly[];
loading: boolean;
onBrushEnd(area: XYBrushArea): void;
overlayRanges: Array<{ start: number; end: number }>;
overlayColor: string;
}
const Chart: FC<ChartProps> = memo(
({ eventRateData, anomalies, loading, onBrushEnd, overlayRanges, overlayColor }) => (
<EventRateChart
eventRateChartData={eventRateData}
anomalyData={anomalies}
loading={loading}
height={'100px'}
width={'100%'}
fadeChart={true}
overlayRanges={overlayRanges.map((c) => ({
start: c.start,
end: c.end,
color: overlayColor,
showMarker: false,
}))}
onBrushEnd={onBrushEnd}
/>
),
(prev: ChartProps, next: ChartProps) => {
// only redraw if the calendar ranges have changes
return (
prev.overlayRanges.length === next.overlayRanges.length &&
JSON.stringify(prev.overlayRanges) === JSON.stringify(next.overlayRanges)
);
}
);
function filterIncompleteEvents(event: CalendarEvent): event is CalendarEvent {
return event.start !== null && event.end !== null;
}
function createDefaultEventDescription(index: number) {
return i18n.translate(
'xpack.ml.revertModelSnapshotFlyout.createCalendar.defaultEventDescription',
{
defaultMessage: 'Auto created event {index}',
values: { index },
}
);
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { RevertModelSnapshotFlyout } from './revert_model_snapshot_flyout';

View file

@ -0,0 +1,409 @@
/*
* 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.
*/
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useState, useCallback, useMemo, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import {} from 'lodash';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFlyout,
EuiFlyoutHeader,
EuiFlyoutFooter,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiButtonEmpty,
EuiTitle,
EuiFlyoutBody,
EuiSpacer,
EuiFormRow,
EuiSwitch,
EuiConfirmModal,
EuiOverlayMask,
EuiCallOut,
EuiHorizontalRule,
EuiSuperSelect,
EuiText,
formatDate,
} from '@elastic/eui';
import {
ModelSnapshot,
CombinedJobWithStats,
} from '../../../../../common/types/anomaly_detection_jobs';
import { ml } from '../../../services/ml_api_service';
import { useNotifications } from '../../../contexts/kibana';
import { chartLoaderProvider } from './chart_loader';
import { mlResultsService } from '../../../services/results_service';
import { LineChartPoint } from '../../../jobs/new_job/common/chart_loader';
import { EventRateChart } from '../../../jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart';
import { Anomaly } from '../../../jobs/new_job/common/results_loader/results_loader';
import { parseInterval } from '../../../../../common/util/parse_interval';
import { TIME_FORMAT } from '../../../../../common/constants/time_format';
import { CreateCalendar, CalendarEvent } from './create_calendar';
interface Props {
snapshot: ModelSnapshot;
snapshots: ModelSnapshot[];
job: CombinedJobWithStats;
closeFlyout(reload: boolean): void;
}
export const RevertModelSnapshotFlyout: FC<Props> = ({ snapshot, snapshots, job, closeFlyout }) => {
const { toasts } = useNotifications();
const { loadAnomalyDataForJob, loadEventRateForJob } = useMemo(
() => chartLoaderProvider(mlResultsService),
[]
);
const [currentSnapshot, setCurrentSnapshot] = useState(snapshot);
const [revertModalVisible, setRevertModalVisible] = useState(false);
const [replay, setReplay] = useState(false);
const [runInRealTime, setRunInRealTime] = useState(false);
const [createCalendar, setCreateCalendar] = useState(false);
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
const [calendarEventsValid, setCalendarEventsValid] = useState(true);
const [eventRateData, setEventRateData] = useState<LineChartPoint[]>([]);
const [anomalies, setAnomalies] = useState<Anomaly[]>([]);
const [chartReady, setChartReady] = useState(false);
const [applying, setApplying] = useState(false);
useEffect(() => {
createChartData();
}, [currentSnapshot]);
useEffect(() => {
const invalid = calendarEvents.some(
(c) => c.description === '' || c.end === null || c.start === null
);
setCalendarEventsValid(invalid === false);
// a bug in elastic charts selection can
// cause duplicate selected areas to be added
// dedupe the calendars based on start and end times
const calMap = new Map(
calendarEvents.map((c) => [`${c.start?.valueOf()}${c.end?.valueOf()}`, c])
);
const dedupedCalendarEvents = [...calMap.values()];
if (dedupedCalendarEvents.length < calendarEvents.length) {
// deduped list is shorter, we must have removed something.
setCalendarEvents(dedupedCalendarEvents);
}
}, [calendarEvents]);
const createChartData = useCallback(async () => {
const bucketSpanMs = parseInterval(job.analysis_config.bucket_span)!.asMilliseconds();
const eventRate = await loadEventRateForJob(job, bucketSpanMs, 100);
const anomalyData = await loadAnomalyDataForJob(job, bucketSpanMs, 100);
setEventRateData(eventRate);
if (anomalyData[0] !== undefined) {
setAnomalies(anomalyData[0]);
}
setChartReady(true);
}, [job]);
function closeWithReload() {
closeFlyout(true);
}
function closeWithoutReload() {
closeFlyout(false);
}
function showRevertModal() {
setRevertModalVisible(true);
}
function hideRevertModal() {
setRevertModalVisible(false);
}
async function applyRevert() {
setApplying(true);
const end =
replay && runInRealTime === false ? job.data_counts.latest_record_timestamp : undefined;
try {
const events =
replay && createCalendar
? calendarEvents.filter(filterIncompleteEvents).map((c) => ({
start: c.start!.valueOf(),
end: c.end!.valueOf(),
description: c.description,
}))
: undefined;
await ml.jobs.revertModelSnapshot(
job.job_id,
currentSnapshot.snapshot_id,
replay,
end,
events
);
hideRevertModal();
closeWithReload();
} catch (error) {
setApplying(false);
toasts.addError(new Error(error.body.message), {
title: i18n.translate('xpack.ml.revertModelSnapshotFlyout.revertErrorTitle', {
defaultMessage: 'Model snapshot revert failed',
}),
});
}
}
function onSnapshotChange(ssId: string) {
const ss = snapshots.find((s) => s.snapshot_id === ssId);
if (ss !== undefined) {
setCurrentSnapshot(ss);
}
}
return (
<>
<EuiFlyout onClose={closeWithoutReload} hideCloseButton size="m">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h5>
<FormattedMessage
id="xpack.ml.newJob.wizard.revertModelSnapshotFlyout.title"
defaultMessage="Revert to model snapshot {ssId}"
values={{ ssId: currentSnapshot.snapshot_id }}
/>
</h5>
</EuiTitle>
<EuiText size="s">
<p>{currentSnapshot.description}</p>
</EuiText>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{false && ( // disabled for now
<>
<EuiSpacer size="s" />
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.ml.newJob.wizard.revertModelSnapshotFlyout.changeSnapshotLabel',
{
defaultMessage: 'Change snapshot',
}
)}
>
<EuiSuperSelect
options={snapshots
.map((s) => ({
value: s.snapshot_id,
inputDisplay: s.snapshot_id,
dropdownDisplay: (
<>
<strong>{s.snapshot_id}</strong>
<EuiText size="s" color="subdued">
<p className="euiTextColor--subdued">{s.description}</p>
</EuiText>
</>
),
}))
.reverse()}
valueOfSelected={currentSnapshot.snapshot_id}
onChange={onSnapshotChange}
itemLayoutAlign="top"
hasDividers
/>
</EuiFormRow>
<EuiHorizontalRule margin="m" />
<EuiSpacer size="l" />
</>
)}
<EventRateChart
eventRateChartData={eventRateData}
anomalyData={anomalies}
loading={chartReady === false}
height={'100px'}
width={'100%'}
fadeChart={true}
overlayRanges={[
{
start: currentSnapshot.latest_record_time_stamp,
end: job.data_counts.latest_record_timestamp,
color: '#ff0000',
},
]}
/>
<EuiSpacer size="l" />
<EuiSpacer size="l" />
<EuiCallOut
title={i18n.translate(
'xpack.ml.newJob.wizard.revertModelSnapshotFlyout.warningCallout.title',
{
defaultMessage: 'Anomaly data will be deleted',
}
)}
color="warning"
iconType="alert"
>
<FormattedMessage
id="xpack.ml.newJob.wizard.revertModelSnapshotFlyout.warningCallout.contents"
defaultMessage="All anomaly detection results after {date} will be deleted."
values={{ date: formatDate(currentSnapshot.latest_record_time_stamp, TIME_FORMAT) }}
/>
</EuiCallOut>
<EuiHorizontalRule margin="xl" />
<EuiFormRow
fullWidth
helpText={i18n.translate(
'xpack.ml.newJob.wizard.revertModelSnapshotFlyout.replaySwitchHelp',
{
defaultMessage: 'Reopen job and replay analysis after the revert has been applied.',
}
)}
>
<EuiSwitch
id="replaySwitch"
label={i18n.translate(
'xpack.ml.newJob.wizard.revertModelSnapshotFlyout.replaySwitchLabel',
{
defaultMessage: 'Replay analysis',
}
)}
checked={replay}
onChange={(e) => setReplay(e.target.checked)}
/>
</EuiFormRow>
{replay && (
<>
<EuiFormRow
fullWidth
helpText={i18n.translate(
'xpack.ml.newJob.wizard.revertModelSnapshotFlyout.realTimeSwitchHelp',
{
defaultMessage:
'Job will continue to run until manually stopped. All new data added to the index will be analyzed.',
}
)}
>
<EuiSwitch
id="realTimeSwitch"
label={i18n.translate(
'xpack.ml.newJob.wizard.revertModelSnapshotFlyout.realTimeSwitchLabel',
{
defaultMessage: 'Run job in real time',
}
)}
checked={runInRealTime}
onChange={(e) => setRunInRealTime(e.target.checked)}
/>
</EuiFormRow>
<EuiFormRow
fullWidth
helpText={i18n.translate(
'xpack.ml.newJob.wizard.revertModelSnapshotFlyout.createCalendarSwitchHelp',
{
defaultMessage:
'Create a new calendar and event to skip over a period of time when analyzing the data.',
}
)}
>
<EuiSwitch
id="createCalendarSwitch"
label={i18n.translate(
'xpack.ml.newJob.wizard.revertModelSnapshotFlyout.createCalendarSwitchLabel',
{
defaultMessage: 'Create calendar to skip a range of time',
}
)}
checked={createCalendar}
onChange={(e) => setCreateCalendar(e.target.checked)}
/>
</EuiFormRow>
{createCalendar && (
<CreateCalendar
calendarEvents={calendarEvents}
setCalendarEvents={setCalendarEvents}
minSelectableTimeStamp={snapshot.latest_record_time_stamp}
maxSelectableTimeStamp={job.data_counts.latest_record_timestamp}
eventRateData={eventRateData}
anomalies={anomalies}
chartReady={chartReady}
/>
)}
</>
)}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={closeWithoutReload} flush="left">
<FormattedMessage
id="xpack.ml.newJob.wizard.revertModelSnapshotFlyout.closeButton"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={true} />
<EuiFlexItem grow={false}>
<EuiButton
onClick={showRevertModal}
disabled={createCalendar === true && calendarEventsValid === false}
fill
>
<FormattedMessage
id="xpack.ml.newJob.wizard.revertModelSnapshotFlyout.saveButton"
defaultMessage="Apply"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
{revertModalVisible && (
<EuiOverlayMask>
<EuiConfirmModal
title={i18n.translate('xpack.ml.newJob.wizard.revertModelSnapshotFlyout.deleteTitle', {
defaultMessage: 'Apply snapshot revert',
})}
onCancel={hideRevertModal}
onConfirm={applyRevert}
cancelButtonText={i18n.translate(
'xpack.ml.newJob.wizard.revertModelSnapshotFlyout.cancelButton',
{
defaultMessage: 'Cancel',
}
)}
confirmButtonText={i18n.translate(
'xpack.ml.newJob.wizard.revertModelSnapshotFlyout.deleteButton',
{
defaultMessage: 'Apply',
}
)}
confirmButtonDisabled={applying}
buttonColor="danger"
defaultFocusedButton="confirm"
/>
</EuiOverlayMask>
)}
</>
);
};
function filterIncompleteEvents(event: CalendarEvent): event is CalendarEvent {
return event.start !== null && event.end !== null;
}

View file

@ -21,6 +21,7 @@ import {
import { formatDate, formatNumber } from '@elastic/eui/lib/services/format';
import { FORECAST_REQUEST_STATE } from '../../../../../../../common/constants/states';
import { TIME_FORMAT } from '../../../../../../../common/constants/time_format';
import { addItemToRecentlyAccessed } from '../../../../../util/recently_accessed';
import { mlForecastService } from '../../../../../services/forecast_service';
import { i18n } from '@kbn/i18n';
@ -31,7 +32,6 @@ import {
} from '../../../../../../../common/util/job_utils';
const MAX_FORECASTS = 500;
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
/**
* Table component for rendering the lists of forecasts run on an ML job.

View file

@ -8,8 +8,8 @@ import numeral from '@elastic/numeral';
import { formatDate } from '@elastic/eui/lib/services/format';
import { roundToDecimalPlace } from '../../../../formatters/round_to_decimal_place';
import { toLocaleString } from '../../../../util/string_utils';
import { TIME_FORMAT } from '../../../../../../common/constants/time_format';
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
const DATA_FORMAT = '0.0 b';
function formatData(txt) {

View file

@ -14,6 +14,7 @@ import { JsonPane } from './json_tab';
import { DatafeedPreviewPane } from './datafeed_preview_tab';
import { AnnotationsTable } from '../../../../components/annotations/annotations_table';
import { AnnotationFlyout } from '../../../../components/annotations/annotation_flyout';
import { ModelSnapshotTable } from '../../../../components/model_snapshots';
import { ForecastsTable } from './forecasts_table';
import { JobDetailsPane } from './job_details_pane';
import { JobMessagesPane } from './job_messages_pane';
@ -25,7 +26,7 @@ export class JobDetails extends Component {
this.state = {};
if (this.props.addYourself) {
this.props.addYourself(props.jobId, this);
this.props.addYourself(props.jobId, (j) => this.updateJob(j));
}
}
@ -33,9 +34,8 @@ export class JobDetails extends Component {
this.props.removeYourself(this.props.jobId);
}
static getDerivedStateFromProps(props) {
const { job, loading } = props;
return { job, loading };
updateJob(job) {
this.setState({ job });
}
render() {
@ -64,8 +64,7 @@ export class JobDetails extends Component {
datafeedTimingStats,
} = extractJobDetails(job);
const { showFullDetails } = this.props;
const { showFullDetails, refreshJobList } = this.props;
const tabs = [
{
id: 'job-settings',
@ -175,6 +174,19 @@ export class JobDetails extends Component {
</Fragment>
),
});
tabs.push({
id: 'modelSnapshots',
'data-test-subj': 'mlJobListTab-modelSnapshots',
name: i18n.translate('xpack.ml.jobsList.jobDetails.tabs.modelSnapshotsLabel', {
defaultMessage: 'Model snapshots',
}),
content: (
<Fragment>
<ModelSnapshotTable job={job} refreshJobList={refreshJobList} />
</Fragment>
),
});
}
return (
@ -191,4 +203,5 @@ JobDetails.propTypes = {
addYourself: PropTypes.func.isRequired,
removeYourself: PropTypes.func.isRequired,
showFullDetails: PropTypes.bool,
refreshJobList: PropTypes.func,
};

View file

@ -15,6 +15,7 @@ import { ResultLinks, actionsMenuContent } from '../job_actions';
import { JobDescription } from './job_description';
import { JobIcon } from '../../../../components/job_message_icon';
import { getJobIdUrl } from '../../../../util/get_job_id_url';
import { TIME_FORMAT } from '../../../../../../common/constants/time_format';
import { EuiBadge, EuiBasicTable, EuiButtonIcon, EuiLink, EuiScreenReaderOnly } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -22,7 +23,6 @@ import { FormattedMessage } from '@kbn/i18n/react';
const PAGE_SIZE = 10;
const PAGE_SIZE_OPTIONS = [10, 25, 50];
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
// 'isManagementTable' bool prop to determine when to configure table for use in Kibana management page
export class JobsList extends Component {

View file

@ -112,6 +112,7 @@ export class JobsListView extends Component {
addYourself={this.addUpdateFunction}
removeYourself={this.removeUpdateFunction}
showFullDetails={this.props.isManagementTable !== true}
refreshJobList={this.onRefreshClick}
/>
);
} else {
@ -121,6 +122,7 @@ export class JobsListView extends Component {
addYourself={this.addUpdateFunction}
removeYourself={this.removeUpdateFunction}
showFullDetails={this.props.isManagementTable !== true}
refreshJobList={this.onRefreshClick}
/>
);
}
@ -143,10 +145,13 @@ export class JobsListView extends Component {
addYourself={this.addUpdateFunction}
removeYourself={this.removeUpdateFunction}
showFullDetails={this.props.isManagementTable !== true}
refreshJobList={this.onRefreshClick}
/>
);
}
this.setState({ itemIdToExpandedRowMap });
this.setState({ itemIdToExpandedRowMap }, () => {
this.updateFunctions[jobId](job);
});
});
})
.catch((error) => {
@ -254,7 +259,7 @@ export class JobsListView extends Component {
);
Object.keys(this.updateFunctions).forEach((j) => {
this.updateFunctions[j].setState({ job: fullJobsList[j] });
this.updateFunctions[j](fullJobsList[j]);
});
jobs.forEach((job) => {

View file

@ -13,8 +13,7 @@ import { EuiDatePicker, EuiFieldText } from '@elastic/eui';
import moment from 'moment';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
import { TIME_FORMAT } from '../../../../../../../common/constants/time_format';
export class TimeRangeSelector extends Component {
constructor(props) {

View file

@ -5,13 +5,22 @@
*/
import React, { FC } from 'react';
import { BarSeries, Chart, ScaleType, Settings, TooltipType } from '@elastic/charts';
import {
HistogramBarSeries,
Chart,
ScaleType,
Settings,
TooltipType,
BrushEndListener,
PartialTheme,
} from '@elastic/charts';
import { Axes } from '../common/axes';
import { LineChartPoint } from '../../../../common/chart_loader';
import { Anomaly } from '../../../../common/results_loader';
import { useChartColors } from '../common/settings';
import { LoadingWrapper } from '../loading_wrapper';
import { Anomalies } from '../common/anomalies';
import { OverlayRange } from './overlay_range';
interface Props {
eventRateChartData: LineChartPoint[];
@ -21,6 +30,13 @@ interface Props {
showAxis?: boolean;
loading?: boolean;
fadeChart?: boolean;
overlayRanges?: Array<{
start: number;
end: number;
color: string;
showMarker?: boolean;
}>;
onBrushEnd?: BrushEndListener;
}
export const EventRateChart: FC<Props> = ({
@ -31,10 +47,16 @@ export const EventRateChart: FC<Props> = ({
showAxis,
loading = false,
fadeChart,
overlayRanges,
onBrushEnd,
}) => {
const { EVENT_RATE_COLOR_WITH_ANOMALIES, EVENT_RATE_COLOR } = useChartColors();
const barColor = fadeChart ? EVENT_RATE_COLOR_WITH_ANOMALIES : EVENT_RATE_COLOR;
const theme: PartialTheme = {
scales: { histogramPadding: 0.2 },
};
return (
<div
style={{ width, height }}
@ -44,9 +66,27 @@ export const EventRateChart: FC<Props> = ({
<Chart>
{showAxis === true && <Axes />}
<Settings tooltip={TooltipType.None} />
{onBrushEnd === undefined ? (
<Settings tooltip={TooltipType.None} theme={theme} />
) : (
<Settings tooltip={TooltipType.None} onBrushEnd={onBrushEnd} theme={theme} />
)}
{overlayRanges &&
overlayRanges.map((range, i) => (
<OverlayRange
key={i}
overlayKey={i}
eventRateChartData={eventRateChartData}
start={range.start}
end={range.end}
color={range.color}
showMarker={range.showMarker}
/>
))}
<Anomalies anomalyData={anomalyData} />
<BarSeries
<HistogramBarSeries
id="event_rate"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
// @ts-ignore
import { formatDate } from '@elastic/eui/lib/services/format';
import { EuiIcon } from '@elastic/eui';
import { RectAnnotation, LineAnnotation, AnnotationDomainTypes } from '@elastic/charts';
import { LineChartPoint } from '../../../../common/chart_loader';
import { TIME_FORMAT } from '../../../../../../../../common/constants/time_format';
interface Props {
overlayKey: number;
eventRateChartData: LineChartPoint[];
start: number;
end: number;
color: string;
showMarker?: boolean;
}
export const OverlayRange: FC<Props> = ({
overlayKey,
eventRateChartData,
start,
end,
color,
showMarker = true,
}) => {
const maxHeight = Math.max(...eventRateChartData.map((e) => e.value));
return (
<>
<RectAnnotation
id={`rect_annotation_${overlayKey}`}
zIndex={1}
hideTooltips={true}
dataValues={[
{
coordinates: {
x0: start,
x1: end,
y0: 0,
y1: maxHeight,
},
},
]}
style={{
fill: color,
strokeWidth: 0,
}}
/>
<LineAnnotation
id="annotation_1"
domainType={AnnotationDomainTypes.XDomain}
dataValues={[{ dataValue: start }]}
style={{
line: {
strokeWidth: 1,
stroke: '#343741',
opacity: 0,
},
}}
marker={
showMarker ? (
<>
<div style={{ marginLeft: '20px' }}>
<div style={{ textAlign: 'center' }}>
<EuiIcon type="arrowUp" />
</div>
<div style={{ fontWeight: 'normal', color: '#343741' }}>
{formatDate(start, TIME_FORMAT)}
</div>
</div>
</>
) : undefined
}
/>
</>
);
};

View file

@ -67,7 +67,7 @@ export const CreateResultCallout: FC<CreateResultCalloutProps> = memo(
color="primary"
fill={false}
aria-label={i18n.translate(
'xpack.ml.newJi18n(ob.recognize.jobsCreationFailed.resetButtonAriaLabel',
'xpack.ml.newJob.recognize.jobsCreationFailed.resetButtonAriaLabel',
{ defaultMessage: 'Reset' }
)}
onClick={onReset}

View file

@ -13,6 +13,7 @@ import { ml } from './ml_api_service';
import { mlMessageBarService } from '../components/messagebar';
import { isWebUrl } from '../util/url_utils';
import { ML_DATA_PREVIEW_COUNT } from '../../../common/util/job_utils';
import { TIME_FORMAT } from '../../../common/constants/time_format';
import { parseInterval } from '../../../common/util/parse_interval';
const msgs = mlMessageBarService;
@ -929,10 +930,8 @@ function createResultsUrlForJobs(jobsList, resultsPage) {
}
}
const timeFormat = 'YYYY-MM-DD HH:mm:ss';
const fromString = moment(from).format(timeFormat); // Defaults to 'now' if 'from' is undefined
const toString = moment(to).format(timeFormat); // Defaults to 'now' if 'to' is undefined
const fromString = moment(from).format(TIME_FORMAT); // Defaults to 'now' if 'from' is undefined
const toString = moment(to).format(TIME_FORMAT); // Defaults to 'now' if 'to' is undefined
const jobIds = jobsList.map((j) => j.id);
return createResultsUrl(jobIds, fromString, toString, resultsPage);

View file

@ -23,6 +23,7 @@ import {
CombinedJob,
Detector,
AnalysisConfig,
ModelSnapshot,
} from '../../../../common/types/anomaly_detection_jobs';
import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types';
import { FieldRequestConfig } from '../../datavisualizer/index_based/common';
@ -77,6 +78,11 @@ export interface CardinalityModelPlotHigh {
export type CardinalityValidationResult = SuccessCardinality | CardinalityModelPlotHigh;
export type CardinalityValidationResults = CardinalityValidationResult[];
export interface GetModelSnapshotsResponse {
count: number;
model_snapshots: ModelSnapshot[];
}
export function basePath() {
return '/api/ml';
}
@ -119,6 +125,13 @@ export const ml = {
});
},
forceCloseJob({ jobId }: { jobId: string }) {
return http<any>({
path: `${basePath()}/anomaly_detectors/${jobId}/_close?force=true`,
method: 'POST',
});
},
deleteJob({ jobId }: { jobId: string }) {
return http<any>({
path: `${basePath()}/anomaly_detectors/${jobId}`,
@ -242,6 +255,13 @@ export const ml = {
});
},
forceStopDatafeed({ datafeedId }: { datafeedId: string }) {
return http<any>({
path: `${basePath()}/datafeeds/${datafeedId}/_stop?force=true`,
method: 'POST',
});
},
datafeedPreview({ datafeedId }: { datafeedId: string }) {
return http<any>({
path: `${basePath()}/datafeeds/${datafeedId}/_preview`,
@ -640,6 +660,33 @@ export const ml = {
});
},
getModelSnapshots(jobId: string, snapshotId?: string) {
return http<GetModelSnapshotsResponse>({
path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots${
snapshotId !== undefined ? `/${snapshotId}` : ''
}`,
});
},
updateModelSnapshot(
jobId: string,
snapshotId: string,
body: { description?: string; retain?: boolean }
) {
return http<any>({
path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}/_update`,
method: 'POST',
body: JSON.stringify(body),
});
},
deleteModelSnapshot(jobId: string, snapshotId: string) {
return http<any>({
path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}`,
method: 'DELETE',
});
},
annotations,
dataFrameAnalytics,
filters,

View file

@ -8,7 +8,11 @@ import { http } from '../http_service';
import { basePath } from './index';
import { Dictionary } from '../../../../common/types/common';
import { MlJobWithTimeRange, MlSummaryJobs } from '../../../../common/types/anomaly_detection_jobs';
import {
MlJobWithTimeRange,
MlSummaryJobs,
CombinedJobWithStats,
} from '../../../../common/types/anomaly_detection_jobs';
import { JobMessage } from '../../../../common/types/audit_message';
import { AggFieldNamePair } from '../../../../common/types/fields';
import { ExistingJobsAndGroups } from '../job_service';
@ -41,7 +45,7 @@ export const jobs = {
jobs(jobIds: string[]) {
const body = JSON.stringify({ jobIds });
return http<any>({
return http<CombinedJobWithStats[]>({
path: `${basePath()}/jobs/jobs`,
method: 'POST',
body,
@ -95,6 +99,7 @@ export const jobs = {
body,
});
},
closeJobs(jobIds: string[]) {
const body = JSON.stringify({ jobIds });
return http<any>({
@ -104,6 +109,15 @@ export const jobs = {
});
},
forceStopAndCloseJob(jobId: string) {
const body = JSON.stringify({ jobId });
return http<{ success: boolean }>({
path: `${basePath()}/jobs/force_stop_and_close_job`,
method: 'POST',
body,
});
},
jobAuditMessages(jobId: string, from?: number) {
const jobIdString = jobId !== undefined ? `/${jobId}` : '';
const query = from !== undefined ? { from } : {};
@ -255,4 +269,19 @@ export const jobs = {
body,
});
},
revertModelSnapshot(
jobId: string,
snapshotId: string,
replay: boolean,
end?: number,
calendarEvents?: Array<{ start: number; end: number; description: string }>
) {
const body = JSON.stringify({ jobId, snapshotId, replay, end, calendarEvents });
return http<{ total: number; categories: Array<{ count?: number; category: Category }> }>({
path: `${basePath()}/jobs/revert_model_snapshot`,
method: 'POST',
body,
});
},
};

View file

@ -12,8 +12,7 @@ import { EuiButton, EuiButtonEmpty, EuiInMemoryTable, EuiSpacer } from '@elastic
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
export const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
import { TIME_FORMAT } from '../../../../../../common/constants/time_format';
function DeleteButton({ onClick, canDeleteCalendar }) {
return (

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { EventsTable, TIME_FORMAT } from './events_table';
export { EventsTable } from './events_table';

View file

@ -24,7 +24,7 @@ import {
EuiFlexItem,
} from '@elastic/eui';
import moment from 'moment';
import { TIME_FORMAT } from '../events_table';
import { TIME_FORMAT } from '../../../../../../common/constants/time_format';
import { generateTempId } from '../utils';
import { i18n } from '@kbn/i18n';

View file

@ -393,6 +393,17 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any)
ml.stopDatafeed = ca({
urls: [
{
fmt: '/_ml/datafeeds/<%=datafeedId%>/_stop?force=<%=force%>',
req: {
datafeedId: {
type: 'string',
},
force: {
type: 'boolean',
},
},
},
{
fmt: '/_ml/datafeeds/<%=datafeedId%>/_stop',
req: {
@ -823,4 +834,81 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any)
],
method: 'GET',
});
ml.modelSnapshots = ca({
urls: [
{
fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/model_snapshots/<%=snapshotId%>',
req: {
jobId: {
type: 'string',
},
snapshotId: {
type: 'string',
},
},
},
{
fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/model_snapshots',
req: {
jobId: {
type: 'string',
},
},
},
],
method: 'GET',
});
ml.updateModelSnapshot = ca({
urls: [
{
fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/model_snapshots/<%=snapshotId%>/_update',
req: {
jobId: {
type: 'string',
},
snapshotId: {
type: 'string',
},
},
},
],
method: 'POST',
needBody: true,
});
ml.deleteModelSnapshot = ca({
urls: [
{
fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/model_snapshots/<%=snapshotId%>',
req: {
jobId: {
type: 'string',
},
snapshotId: {
type: 'string',
},
},
},
],
method: 'DELETE',
});
ml.revertModelSnapshot = ca({
urls: [
{
fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/model_snapshots/<%=snapshotId%>/_revert',
req: {
jobId: {
type: 'string',
},
snapshotId: {
type: 'string',
},
},
},
],
method: 'POST',
});
};

View file

@ -5,7 +5,7 @@
*/
import { difference } from 'lodash';
import { IScopedClusterClient } from 'kibana/server';
import { APICaller } from 'kibana/server';
import { EventManager, CalendarEvent } from './event_manager';
interface BasicCalendar {
@ -23,16 +23,16 @@ export interface FormCalendar extends BasicCalendar {
}
export class CalendarManager {
private _client: IScopedClusterClient['callAsCurrentUser'];
private _eventManager: any;
private _callAsCurrentUser: APICaller;
private _eventManager: EventManager;
constructor(client: any) {
this._client = client;
this._eventManager = new EventManager(client);
constructor(callAsCurrentUser: APICaller) {
this._callAsCurrentUser = callAsCurrentUser;
this._eventManager = new EventManager(callAsCurrentUser);
}
async getCalendar(calendarId: string) {
const resp = await this._client('ml.calendars', {
const resp = await this._callAsCurrentUser('ml.calendars', {
calendarId,
});
@ -43,7 +43,7 @@ export class CalendarManager {
}
async getAllCalendars() {
const calendarsResp = await this._client('ml.calendars');
const calendarsResp = await this._callAsCurrentUser('ml.calendars');
const events: CalendarEvent[] = await this._eventManager.getAllEvents();
const calendars: Calendar[] = calendarsResp.calendars;
@ -74,7 +74,7 @@ export class CalendarManager {
const events = calendar.events;
delete calendar.calendarId;
delete calendar.events;
await this._client('ml.addCalendar', {
await this._callAsCurrentUser('ml.addCalendar', {
calendarId,
body: calendar,
});
@ -109,7 +109,7 @@ export class CalendarManager {
// add all new jobs
if (jobsToAdd.length) {
await this._client('ml.addJobToCalendar', {
await this._callAsCurrentUser('ml.addJobToCalendar', {
calendarId,
jobId: jobsToAdd.join(','),
});
@ -117,7 +117,7 @@ export class CalendarManager {
// remove all removed jobs
if (jobsToRemove.length) {
await this._client('ml.removeJobFromCalendar', {
await this._callAsCurrentUser('ml.removeJobFromCalendar', {
calendarId,
jobId: jobsToRemove.join(','),
});
@ -131,7 +131,7 @@ export class CalendarManager {
// remove all removed events
await Promise.all(
eventsToRemove.map(async (event) => {
await this._eventManager.deleteEvent(calendarId, event.event_id);
await this._eventManager.deleteEvent(calendarId, event.event_id!);
})
);
@ -140,6 +140,6 @@ export class CalendarManager {
}
async deleteCalendar(calendarId: string) {
return this._client('ml.deleteCalendar', { calendarId });
return this._callAsCurrentUser('ml.deleteCalendar', { calendarId });
}
}

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { APICaller } from 'kibana/server';
import { GLOBAL_CALENDAR } from '../../../common/constants/calendars';
export interface CalendarEvent {
@ -15,13 +16,10 @@ export interface CalendarEvent {
}
export class EventManager {
private _client: any;
constructor(client: any) {
this._client = client;
}
constructor(private _callAsCurrentUser: APICaller) {}
async getCalendarEvents(calendarId: string) {
const resp = await this._client('ml.events', { calendarId });
const resp = await this._callAsCurrentUser('ml.events', { calendarId });
return resp.events;
}
@ -29,7 +27,7 @@ export class EventManager {
// jobId is optional
async getAllEvents(jobId?: string) {
const calendarId = GLOBAL_CALENDAR;
const resp = await this._client('ml.events', {
const resp = await this._callAsCurrentUser('ml.events', {
calendarId,
jobId,
});
@ -40,14 +38,14 @@ export class EventManager {
async addEvents(calendarId: string, events: CalendarEvent[]) {
const body = { events };
return await this._client('ml.addEvent', {
return await this._callAsCurrentUser('ml.addEvent', {
calendarId,
body,
});
}
async deleteEvent(calendarId: string, eventId: string) {
return this._client('ml.deleteEvent', { calendarId, eventId });
return this._callAsCurrentUser('ml.deleteEvent', { calendarId, eventId });
}
isEqual(ev1: CalendarEvent, ev2: CalendarEvent) {

View file

@ -5,3 +5,4 @@
*/
export { CalendarManager, Calendar, FormCalendar } from './calendar_manager';
export { CalendarEvent } from './event_manager';

View file

@ -27,7 +27,7 @@ interface Results {
}
export function datafeedsProvider(callAsCurrentUser: APICaller) {
async function forceStartDatafeeds(datafeedIds: string[], start: number, end: number) {
async function forceStartDatafeeds(datafeedIds: string[], start?: number, end?: number) {
const jobIds = await getJobIdsByDatafeedId();
const doStartsCalled = datafeedIds.reduce((acc, cur) => {
acc[cur] = false;
@ -96,7 +96,7 @@ export function datafeedsProvider(callAsCurrentUser: APICaller) {
return opened;
}
async function startDatafeed(datafeedId: string, start: number, end: number) {
async function startDatafeed(datafeedId: string, start?: number, end?: number) {
return callAsCurrentUser('ml.startDatafeed', { datafeedId, start, end });
}

View file

@ -10,6 +10,7 @@ import { jobsProvider } from './jobs';
import { groupsProvider } from './groups';
import { newJobCapsProvider } from './new_job_caps';
import { newJobChartsProvider, topCategoriesProvider } from './new_job';
import { modelSnapshotProvider } from './model_snapshots';
export function jobServiceProvider(callAsCurrentUser: APICaller) {
return {
@ -19,5 +20,6 @@ export function jobServiceProvider(callAsCurrentUser: APICaller) {
...newJobCapsProvider(callAsCurrentUser),
...newJobChartsProvider(callAsCurrentUser),
...topCategoriesProvider(callAsCurrentUser),
...modelSnapshotProvider(callAsCurrentUser),
};
}

View file

@ -6,6 +6,7 @@
import { i18n } from '@kbn/i18n';
import { uniq } from 'lodash';
import Boom from 'boom';
import { APICaller } from 'kibana/server';
import { JOB_STATE, DATAFEED_STATE } from '../../../common/constants/states';
import {
@ -128,6 +129,23 @@ export function jobsProvider(callAsCurrentUser: APICaller) {
return results;
}
async function forceStopAndCloseJob(jobId: string) {
const datafeedIds = await getDatafeedIdsByJobId();
const datafeedId = datafeedIds[jobId];
if (datafeedId === undefined) {
throw Boom.notFound(`Cannot find datafeed for job ${jobId}`);
}
const dfResult = await callAsCurrentUser('ml.stopDatafeed', { datafeedId, force: true });
if (!dfResult || dfResult.stopped !== true) {
return { success: false };
}
await callAsCurrentUser('ml.closeJob', { jobId, force: true });
return { success: true };
}
async function jobsSummary(jobIds: string[] = []) {
const fullJobsList: CombinedJobWithStats[] = await createFullJobsList();
const fullJobsIds = fullJobsList.map((job) => job.job_id);
@ -472,6 +490,7 @@ export function jobsProvider(callAsCurrentUser: APICaller) {
forceDeleteJob,
deleteJobs,
closeJobs,
forceStopAndCloseJob,
jobsSummary,
jobsWithTimerange,
createFullJobsList,

View file

@ -0,0 +1,102 @@
/*
* 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 Boom from 'boom';
import { i18n } from '@kbn/i18n';
import { APICaller } from 'kibana/server';
import { ModelSnapshot } from '../../../common/types/anomaly_detection_jobs';
import { datafeedsProvider, MlDatafeedsResponse } from './datafeeds';
import { MlJobsResponse } from './jobs';
import { FormCalendar, CalendarManager } from '../calendar';
export interface ModelSnapshotsResponse {
count: number;
model_snapshots: ModelSnapshot[];
}
export interface RevertModelSnapshotResponse {
model: ModelSnapshot;
}
export function modelSnapshotProvider(callAsCurrentUser: APICaller) {
const { forceStartDatafeeds, getDatafeedIdsByJobId } = datafeedsProvider(callAsCurrentUser);
async function revertModelSnapshot(
jobId: string,
snapshotId: string,
replay: boolean,
end?: number,
deleteInterveningResults: boolean = true,
calendarEvents?: [{ start: number; end: number; description: string }]
) {
let datafeedId = `datafeed-${jobId}`;
// ensure job exists
await callAsCurrentUser<MlJobsResponse>('ml.jobs', { jobId: [jobId] });
try {
// ensure the datafeed exists
// the datafeed is probably called datafeed-<jobId>
await callAsCurrentUser<MlDatafeedsResponse>('ml.datafeeds', {
datafeedId: [datafeedId],
});
} catch (e) {
// if the datafeed isn't called datafeed-<jobId>
// check all datafeeds to see if one exists that is matched to this job id
const datafeedIds = await getDatafeedIdsByJobId();
datafeedId = datafeedIds[jobId];
if (datafeedId === undefined) {
throw Boom.notFound(`Cannot find datafeed for job ${jobId}`);
}
}
// ensure the snapshot exists
const snapshot = await callAsCurrentUser<ModelSnapshotsResponse>('ml.modelSnapshots', {
jobId,
snapshotId,
});
// apply the snapshot revert
const { model } = await callAsCurrentUser<RevertModelSnapshotResponse>(
'ml.revertModelSnapshot',
{
jobId,
snapshotId,
body: {
delete_intervening_results: deleteInterveningResults,
},
}
);
// create calendar (if specified) and replay datafeed
if (replay && model.snapshot_id === snapshotId && snapshot.model_snapshots.length) {
// create calendar before starting restarting the datafeed
if (calendarEvents !== undefined && calendarEvents.length) {
const calendar: FormCalendar = {
calendarId: String(Date.now()),
job_ids: [jobId],
description: i18n.translate(
'xpack.ml.models.jobService.revertModelSnapshot.autoCreatedCalendar.description',
{
defaultMessage: 'Auto created',
}
),
events: calendarEvents.map((s) => ({
description: s.description,
start_time: s.start,
end_time: s.end,
})),
};
const cm = new CalendarManager(callAsCurrentUser);
await cm.newCalendar(calendar);
}
forceStartDatafeeds([datafeedId], snapshot.model_snapshots[0].latest_record_time_stamp, end);
}
return { success: true };
}
return { revertModelSnapshot };
}

View file

@ -17,6 +17,8 @@ import {
getCategoriesSchema,
forecastAnomalyDetector,
getBucketParamsSchema,
getModelSnapshotsSchema,
updateModelSnapshotSchema,
} from './schemas/anomaly_detectors_schema';
/**
@ -526,7 +528,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) {
/**
* @apiGroup AnomalyDetectors
*
* @api {get} /api/ml/anomaly_detectors/:jobId/results/categories/:categoryId Get results category data by job id and category id
* @api {get} /api/ml/anomaly_detectors/:jobId/results/categories/:categoryId Get results category data by job ID and category ID
* @apiName GetCategories
* @apiDescription Returns the categories results for the specified job ID and category ID.
*
@ -544,11 +546,148 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) {
},
mlLicense.fullLicenseAPIGuard(async (context, request, response) => {
try {
const options = {
const results = await context.ml!.mlClient.callAsCurrentUser('ml.categories', {
jobId: request.params.jobId,
categoryId: request.params.categoryId,
};
const results = await context.ml!.mlClient.callAsCurrentUser('ml.categories', options);
});
return response.ok({
body: results,
});
} catch (e) {
return response.customError(wrapError(e));
}
})
);
/**
* @apiGroup AnomalyDetectors
*
* @api {get} /api/ml/anomaly_detectors/:jobId/model_snapshots Get model snapshots by job ID
* @apiName GetModelSnapshots
* @apiDescription Returns the model snapshots for the specified job ID
*
* @apiSchema (params) getModelSnapshotsSchema
*/
router.get(
{
path: '/api/ml/anomaly_detectors/{jobId}/model_snapshots',
validate: {
params: getModelSnapshotsSchema,
},
options: {
tags: ['access:ml:canGetJobs'],
},
},
mlLicense.fullLicenseAPIGuard(async (context, request, response) => {
try {
const results = await context.ml!.mlClient.callAsCurrentUser('ml.modelSnapshots', {
jobId: request.params.jobId,
});
return response.ok({
body: results,
});
} catch (e) {
return response.customError(wrapError(e));
}
})
);
/**
* @apiGroup AnomalyDetectors
*
* @api {get} /api/ml/anomaly_detectors/:jobId/model_snapshots/:snapshotId Get model snapshots by job ID and snapshot ID
* @apiName GetModelSnapshotsById
* @apiDescription Returns the model snapshots for the specified job ID and snapshot ID
*
* @apiSchema (params) getModelSnapshotsSchema
*/
router.get(
{
path: '/api/ml/anomaly_detectors/{jobId}/model_snapshots/{snapshotId}',
validate: {
params: getModelSnapshotsSchema,
},
options: {
tags: ['access:ml:canGetJobs'],
},
},
mlLicense.fullLicenseAPIGuard(async (context, request, response) => {
try {
const results = await context.ml!.mlClient.callAsCurrentUser('ml.modelSnapshots', {
jobId: request.params.jobId,
snapshotId: request.params.snapshotId,
});
return response.ok({
body: results,
});
} catch (e) {
return response.customError(wrapError(e));
}
})
);
/**
* @apiGroup AnomalyDetectors
*
* @api {post} /api/ml/anomaly_detectors/:jobId/model_snapshots/:snapshotId/_update update model snapshot by snapshot ID
* @apiName UpdateModelSnapshotsById
* @apiDescription Updates the model snapshot for the specified snapshot ID
*
* @apiSchema (params) getModelSnapshotsSchema
* @apiSchema (body) updateModelSnapshotSchema
*/
router.post(
{
path: '/api/ml/anomaly_detectors/{jobId}/model_snapshots/{snapshotId}/_update',
validate: {
params: getModelSnapshotsSchema,
body: updateModelSnapshotSchema,
},
options: {
tags: ['access:ml:canCreateJob'],
},
},
mlLicense.fullLicenseAPIGuard(async (context, request, response) => {
try {
const results = await context.ml!.mlClient.callAsCurrentUser('ml.updateModelSnapshot', {
jobId: request.params.jobId,
snapshotId: request.params.snapshotId,
body: request.body,
});
return response.ok({
body: results,
});
} catch (e) {
return response.customError(wrapError(e));
}
})
);
/**
* @apiGroup AnomalyDetectors
*
* @api {delete} /api/ml/anomaly_detectors/:jobId/model_snapshots/:snapshotId Delete model snapshots by snapshot ID
* @apiName GetModelSnapshotsById
* @apiDescription Deletes the model snapshot for the specified snapshot ID
*
* @apiSchema (params) getModelSnapshotsSchema
*/
router.delete(
{
path: '/api/ml/anomaly_detectors/{jobId}/model_snapshots/{snapshotId}',
validate: {
params: getModelSnapshotsSchema,
},
options: {
tags: ['access:ml:canCreateJob'],
},
},
mlLicense.fullLicenseAPIGuard(async (context, request, response) => {
try {
const results = await context.ml!.mlClient.callAsCurrentUser('ml.deleteModelSnapshot', {
jobId: request.params.jobId,
snapshotId: request.params.snapshotId,
});
return response.ok({
body: results,
});

View file

@ -17,8 +17,11 @@ import {
lookBackProgressSchema,
topCategoriesSchema,
updateGroupsSchema,
revertModelSnapshotSchema,
} from './schemas/job_service_schema';
import { jobIdSchema } from './schemas/anomaly_detectors_schema';
import { jobServiceProvider } from '../models/job_service';
import { categorizationExamplesProvider } from '../models/job_service/new_job';
@ -162,6 +165,40 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) {
})
);
/**
* @apiGroup JobService
*
* @api {post} /api/ml/jobs/force_stop_and_close_job Force stop and close job
* @apiName ForceStopAndCloseJob
* @apiDescription Force stops the datafeed and then force closes the anomaly detection job specified by job ID
*
* @apiSchema (body) jobIdSchema
*/
router.post(
{
path: '/api/ml/jobs/force_stop_and_close_job',
validate: {
body: jobIdSchema,
},
options: {
tags: ['access:ml:canCloseJob', 'access:ml:canStartStopDatafeed'],
},
},
mlLicense.fullLicenseAPIGuard(async (context, request, response) => {
try {
const { forceStopAndCloseJob } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser);
const { jobId } = request.body;
const resp = await forceStopAndCloseJob(jobId);
return response.ok({
body: resp,
});
} catch (e) {
return response.customError(wrapError(e));
}
})
);
/**
* @apiGroup JobService
*
@ -691,4 +728,52 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) {
}
})
);
/**
* @apiGroup JobService
*
* @api {post} /api/ml/jobs/revert_model_snapshot Revert model snapshot
* @apiName RevertModelSnapshot
* @apiDescription Reverts a job to a specified snapshot. Also allows the job to replayed to a specified date and to auto create calendars to skip analysis of specified date ranges
*
* @apiSchema (body) revertModelSnapshotSchema
*/
router.post(
{
path: '/api/ml/jobs/revert_model_snapshot',
validate: {
body: revertModelSnapshotSchema,
},
options: {
tags: ['access:ml:canCreateJob', 'access:ml:canStartStopDatafeed'],
},
},
mlLicense.fullLicenseAPIGuard(async (context, request, response) => {
try {
const { revertModelSnapshot } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser);
const {
jobId,
snapshotId,
replay,
end,
deleteInterveningResults,
calendarEvents,
} = request.body;
const resp = await revertModelSnapshot(
jobId,
snapshotId,
replay,
end,
deleteInterveningResults,
calendarEvents
);
return response.ok({
body: resp,
});
} catch (e) {
return response.customError(wrapError(e));
}
})
);
}

View file

@ -178,10 +178,24 @@ export const getOverallBucketsSchema = schema.object({
});
export const getCategoriesSchema = schema.object({
/** Category id */
/** Category ID */
categoryId: schema.string(),
/** Job id */
/** Job ID */
jobId: schema.string(),
});
export const getModelSnapshotsSchema = schema.object({
/** Snapshot ID */
snapshotId: schema.maybe(schema.string()),
/** Job ID */
jobId: schema.string(),
});
export const updateModelSnapshotSchema = schema.object({
/** description */
description: schema.maybe(schema.string()),
/** retain */
retain: schema.maybe(schema.boolean()),
});
export const forecastAnomalyDetector = schema.object({ duration: schema.any() });

View file

@ -66,3 +66,20 @@ export const updateGroupsSchema = {
)
),
};
export const revertModelSnapshotSchema = schema.object({
jobId: schema.string(),
snapshotId: schema.string(),
replay: schema.boolean(),
end: schema.maybe(schema.number()),
deleteInterveningResults: schema.maybe(schema.boolean()),
calendarEvents: schema.maybe(
schema.arrayOf(
schema.object({
start: schema.number(),
end: schema.number(),
description: schema.string(),
})
)
),
});

View file

@ -10455,7 +10455,6 @@
"xpack.ml.navMenu.dataVisualizerTabLinkText": "データビジュアライザー",
"xpack.ml.navMenu.overviewTabLinkText": "概要",
"xpack.ml.navMenu.settingsTabLinkText": "設定",
"xpack.ml.newJi18n(ob.recognize.jobsCreationFailed.resetButtonAriaLabel": "リセット",
"xpack.ml.newJob.page.createJob": "ジョブを作成",
"xpack.ml.newJob.recognize.advancedLabel": "高度な設定",
"xpack.ml.newJob.recognize.advancedSettingsAriaLabel": "高度な設定",

View file

@ -10459,7 +10459,6 @@
"xpack.ml.navMenu.dataVisualizerTabLinkText": "数据可视化工具",
"xpack.ml.navMenu.overviewTabLinkText": "概览",
"xpack.ml.navMenu.settingsTabLinkText": "设置",
"xpack.ml.newJi18n(ob.recognize.jobsCreationFailed.resetButtonAriaLabel": "重置",
"xpack.ml.newJob.page.createJob": "创建作业",
"xpack.ml.newJob.recognize.advancedLabel": "高级",
"xpack.ml.newJob.recognize.advancedSettingsAriaLabel": "高级设置",