mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
63506834f2
commit
7a60f18ef9
46 changed files with 2175 additions and 64 deletions
7
x-pack/plugins/ml/common/constants/time_format.ts
Normal file
7
x-pack/plugins/ml/common/constants/time_format.ts
Normal 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';
|
|
@ -10,3 +10,4 @@ export * from './datafeed';
|
|||
export * from './datafeed_stats';
|
||||
export * from './combined_job';
|
||||
export * from './summary_job';
|
||||
export * from './model_snapshot';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -11,4 +11,5 @@ export {
|
|||
useColorRange,
|
||||
COLOR_RANGE,
|
||||
COLOR_RANGE_SCALE,
|
||||
useCurrentEuiTheme,
|
||||
} from './use_color_range';
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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 },
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -5,3 +5,4 @@
|
|||
*/
|
||||
|
||||
export { CalendarManager, Calendar, FormCalendar } from './calendar_manager';
|
||||
export { CalendarEvent } from './event_manager';
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
102
x-pack/plugins/ml/server/models/job_service/model_snapshots.ts
Normal file
102
x-pack/plugins/ml/server/models/job_service/model_snapshots.ts
Normal 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 };
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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() });
|
||||
|
|
|
@ -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(),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
|
|
@ -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": "高度な設定",
|
||||
|
|
|
@ -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": "高级设置",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue