[ML] Data frame analytics: Adds job deletion and creation to map view (#84299)

* wip: delete job node and update map

* adds ability to delete job and update map

* create job from index node

* can clone job from map

* reset map button

* remove trained model node when deleting job

* remove related model node. remove map tab when root node deleted

* ensure model with no job shows up correctly

* update types and naming

* use urlGenerator

* fix inner scrollbar

* Adjust cytoscapeOptions after EUI update

Co-authored-by: Robert Oskamp <robert.oskamp@elastic.co>
This commit is contained in:
Melissa Alvarez 2020-12-09 10:11:08 -05:00 committed by GitHub
parent 33c552feee
commit 93670ec81f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 702 additions and 338 deletions

View file

@ -10,6 +10,15 @@ export const ANALYSIS_CONFIG_TYPE = {
CLASSIFICATION: 'classification',
} as const;
export const DATA_FRAME_TASK_STATE = {
ANALYZING: 'analyzing',
FAILED: 'failed',
REINDEXING: 'reindexing',
STARTED: 'started',
STARTING: 'starting',
STOPPED: 'stopped',
} as const;
export const DEFAULT_RESULTS_FIELD = 'ml';
export const JOB_MAP_NODE_TYPES = {

View file

@ -11,6 +11,7 @@ export const ML_PAGES = {
ANOMALY_EXPLORER: 'explorer',
SINGLE_METRIC_VIEWER: 'timeseriesexplorer',
DATA_FRAME_ANALYTICS_JOBS_MANAGE: 'data_frame_analytics',
DATA_FRAME_ANALYTICS_CREATE_JOB: 'data_frame_analytics/new_job',
DATA_FRAME_ANALYTICS_MODELS_MANAGE: 'data_frame_analytics/models',
DATA_FRAME_ANALYTICS_EXPLORATION: 'data_frame_analytics/exploration',
DATA_FRAME_ANALYTICS_MAP: 'data_frame_analytics/map',

View file

@ -7,6 +7,7 @@
import Boom from '@hapi/boom';
import { EsErrorBody } from '../util/errors';
import { ANALYSIS_CONFIG_TYPE } from '../constants/data_frame_analytics';
import { DATA_FRAME_TASK_STATE } from '../constants/data_frame_analytics';
export interface DeleteDataFrameAnalyticsWithIndexStatus {
success: boolean;
@ -85,3 +86,54 @@ export interface DataFrameAnalyticsConfig {
}
export type DataFrameAnalysisConfigType = typeof ANALYSIS_CONFIG_TYPE[keyof typeof ANALYSIS_CONFIG_TYPE];
export type DataFrameTaskStateType = typeof DATA_FRAME_TASK_STATE[keyof typeof DATA_FRAME_TASK_STATE];
interface ProgressSection {
phase: string;
progress_percent: number;
}
export interface DataFrameAnalyticsStats {
assignment_explanation?: string;
id: DataFrameAnalyticsId;
memory_usage?: {
timestamp?: string;
peak_usage_bytes: number;
status: string;
};
node?: {
attributes: Record<string, any>;
ephemeral_id: string;
id: string;
name: string;
transport_address: string;
};
progress: ProgressSection[];
failure_reason?: string;
state: DataFrameTaskStateType;
}
export interface AnalyticsMapNodeElement {
data: {
id: string;
label: string;
type: string;
analysisType?: string;
};
}
export interface AnalyticsMapEdgeElement {
data: {
id: string;
source: string;
target: string;
};
}
export type MapElements = AnalyticsMapNodeElement | AnalyticsMapEdgeElement;
export interface AnalyticsMapReturnType {
elements: MapElements[];
details: Record<string, any>; // transform, job, or index details
error: null | any;
}

View file

@ -43,6 +43,7 @@ export type MlGenericUrlState = MLPageState<
| typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB
| typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE
| typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX
| typeof ML_PAGES.DATA_FRAME_ANALYTICS_CREATE_JOB
| typeof ML_PAGES.OVERVIEW
| typeof ML_PAGES.CALENDARS_MANAGE
| typeof ML_PAGES.CALENDARS_NEW
@ -158,6 +159,7 @@ export type TimeSeriesExplorerUrlState = MLPageState<
>;
export interface DataFrameAnalyticsQueryState {
analysisType?: DataFrameAnalysisConfigType;
jobId?: JobId | JobId[];
modelId?: string;
groupIds?: string[];
@ -165,7 +167,9 @@ export interface DataFrameAnalyticsQueryState {
}
export type DataFrameAnalyticsUrlState = MLPageState<
typeof ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE | typeof ML_PAGES.DATA_FRAME_ANALYTICS_MAP,
| typeof ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE
| typeof ML_PAGES.DATA_FRAME_ANALYTICS_MAP
| typeof ML_PAGES.DATA_FRAME_ANALYTICS_CREATE_JOB,
DataFrameAnalyticsQueryState | undefined
>;

View file

@ -20,7 +20,7 @@ import { useMlContext } from '../../contexts/ml';
import { DataFrameAnalyticsConfig } from '../common';
import { isGetDataFrameAnalyticsStatsResponseOk } from '../pages/analytics_management/services/analytics_service/get_analytics';
import { DATA_FRAME_TASK_STATE } from '../pages/analytics_management/components/analytics_list/common';
import { DataFrameTaskStateType } from '../pages/analytics_management/components/analytics_list/common';
import { useTrainedModelsApiService } from '../../services/ml_api_service/trained_models';
import { TotalFeatureImportance } from '../../../../common/types/feature_importance';
import { getToastNotificationService } from '../../services/toast_notification_service';
@ -45,7 +45,7 @@ export const useResultsViewConfig = (jobId: string) => {
undefined
);
const [jobConfigErrorMessage, setJobConfigErrorMessage] = useState<undefined | string>(undefined);
const [jobStatus, setJobStatus] = useState<DATA_FRAME_TASK_STATE | undefined>(undefined);
const [jobStatus, setJobStatus] = useState<DataFrameTaskStateType | undefined>(undefined);
const [totalFeatureImportance, setTotalFeatureImportance] = useState<
TotalFeatureImportance[] | undefined

View file

@ -29,7 +29,7 @@ import {
DataFrameAnalyticsConfig,
} from '../../../../common';
import { isKeywordAndTextType } from '../../../../common/fields';
import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common';
import { DataFrameTaskStateType } from '../../../analytics_management/components/analytics_list/common';
import {
isResultsSearchBoolQuery,
isClassificationEvaluateResponse,
@ -49,7 +49,7 @@ import {
export interface EvaluatePanelProps {
jobConfig: DataFrameAnalyticsConfig;
jobStatus?: DATA_FRAME_TASK_STATE;
jobStatus?: DataFrameTaskStateType;
searchQuery: ResultsSearchQuery;
}

View file

@ -16,7 +16,7 @@ import {
} from '../../../../common';
import { ResultsSearchQuery } from '../../../../common/analytics';
import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common';
import { DataFrameTaskStateType } from '../../../analytics_management/components/analytics_list/common';
import { ExpandableSectionAnalytics } from '../expandable_section';
import { ExplorationResultsTable } from '../exploration_results_table';
@ -48,7 +48,7 @@ const filters = {
export interface EvaluatePanelProps {
jobConfig: DataFrameAnalyticsConfig;
jobStatus?: DATA_FRAME_TASK_STATE;
jobStatus?: DataFrameTaskStateType;
searchQuery: ResultsSearchQuery;
}

View file

@ -14,7 +14,7 @@ import { useMlKibana } from '../../../../../contexts/kibana';
import { DataFrameAnalyticsConfig } from '../../../../common';
import { ResultsSearchQuery } from '../../../../common/analytics';
import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common';
import { DataFrameTaskStateType } from '../../../analytics_management/components/analytics_list/common';
import { ExpandableSectionResults } from '../expandable_section';
@ -23,7 +23,7 @@ import { useExplorationResults } from './use_exploration_results';
interface Props {
indexPattern: IndexPattern;
jobConfig: DataFrameAnalyticsConfig;
jobStatus?: DATA_FRAME_TASK_STATE;
jobStatus?: DataFrameTaskStateType;
needsDestIndexPattern: boolean;
searchQuery: ResultsSearchQuery;
}

View file

@ -26,7 +26,7 @@ import {
Eval,
DataFrameAnalyticsConfig,
} from '../../../../common';
import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common';
import { DataFrameTaskStateType } from '../../../analytics_management/components/analytics_list/common';
import {
isResultsSearchBoolQuery,
isRegressionEvaluateResponse,
@ -41,7 +41,7 @@ import { EvaluateStat } from './evaluate_stat';
interface Props {
jobConfig: DataFrameAnalyticsConfig;
jobStatus?: DATA_FRAME_TASK_STATE;
jobStatus?: DataFrameTaskStateType;
searchQuery: SavedSearchQuery;
}

View file

@ -343,7 +343,7 @@ export const useNavigateToWizardWithClonedJob = () => {
const savedObjectsClient = savedObjects.client;
return async (item: DataFrameAnalyticsListRow) => {
return async (item: Pick<DataFrameAnalyticsListRow, 'config' | 'stats'>) => {
const sourceIndex = Array.isArray(item.config.source.index)
? item.config.source.index.join(',')
: item.config.source.index;

View file

@ -29,11 +29,13 @@ import {
import { deleteActionNameText, DeleteActionName } from './delete_action_name';
type DataFrameAnalyticsListRowEssentials = Pick<DataFrameAnalyticsListRow, 'config' | 'stats'>;
export type DeleteAction = ReturnType<typeof useDeleteAction>;
export const useDeleteAction = (canDeleteDataFrameAnalytics: boolean) => {
const [item, setItem] = useState<DataFrameAnalyticsListRow>();
const [item, setItem] = useState<DataFrameAnalyticsListRowEssentials>();
const [isModalVisible, setModalVisible] = useState(false);
const [deleteItem, setDeleteItem] = useState(false);
const [deleteTargetIndex, setDeleteTargetIndex] = useState<boolean>(true);
const [deleteIndexPattern, setDeleteIndexPattern] = useState<boolean>(true);
const [userCanDeleteIndex, setUserCanDeleteIndex] = useState<boolean>(false);
@ -111,25 +113,27 @@ export const useDeleteAction = (canDeleteDataFrameAnalytics: boolean) => {
const closeModal = () => setModalVisible(false);
const deleteAndCloseModal = () => {
setDeleteItem(true);
setModalVisible(false);
if (item !== undefined) {
if ((userCanDeleteIndex && deleteTargetIndex) || (userCanDeleteIndex && deleteIndexPattern)) {
deleteAnalyticsAndDestIndex(
item,
item.config,
item.stats,
deleteTargetIndex,
indexPatternExists && deleteIndexPattern,
toastNotificationService
);
} else {
deleteAnalytics(item, toastNotificationService);
deleteAnalytics(item.config, item.stats, toastNotificationService);
}
}
};
const toggleDeleteIndex = () => setDeleteTargetIndex(!deleteTargetIndex);
const toggleDeleteIndexPattern = () => setDeleteIndexPattern(!deleteIndexPattern);
const openModal = (newItem: DataFrameAnalyticsListRow) => {
const openModal = (newItem: DataFrameAnalyticsListRowEssentials) => {
setItem(newItem);
setModalVisible(true);
};
@ -159,6 +163,7 @@ export const useDeleteAction = (canDeleteDataFrameAnalytics: boolean) => {
deleteAndCloseModal,
deleteTargetIndex,
deleteIndexPattern,
deleteItem,
indexPatternExists,
isModalVisible,
item,

View file

@ -6,11 +6,18 @@
import { EuiTableActionsColumnType, Query, Ast } from '@elastic/eui';
import { DATA_FRAME_TASK_STATE } from './data_frame_task_state';
import { DATA_FRAME_TASK_STATE } from '../../../../../../../common/constants/data_frame_analytics';
import { DataFrameTaskStateType } from '../../../../../../../common/types/data_frame_analytics';
export { DATA_FRAME_TASK_STATE };
export { DataFrameTaskStateType };
import { DataFrameAnalyticsId, DataFrameAnalyticsConfig } from '../../../../common';
import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics';
import {
DataFrameAnalysisConfigType,
DataFrameAnalyticsStats,
} from '../../../../../../../common/types/data_frame_analytics';
export { DataFrameAnalyticsStats } from '../../../../../../../common/types/data_frame_analytics';
export enum DATA_FRAME_MODE {
BATCH = 'batch',
@ -25,36 +32,11 @@ export type TermClause = ExtractClauseType<typeof Ast['Term']['isInstance']>;
export type FieldClause = ExtractClauseType<typeof Ast['Field']['isInstance']>;
export type Value = Parameters<typeof Ast['Term']['must']>[0];
interface ProgressSection {
phase: string;
progress_percent: number;
}
export interface DataFrameAnalyticsStats {
assignment_explanation?: string;
id: DataFrameAnalyticsId;
memory_usage?: {
timestamp?: string;
peak_usage_bytes: number;
status: string;
};
node?: {
attributes: Record<string, any>;
ephemeral_id: string;
id: string;
name: string;
transport_address: string;
};
progress: ProgressSection[];
failure_reason?: string;
state: DATA_FRAME_TASK_STATE;
}
export function isDataFrameAnalyticsFailed(state: DATA_FRAME_TASK_STATE) {
export function isDataFrameAnalyticsFailed(state: DataFrameTaskStateType) {
return state === DATA_FRAME_TASK_STATE.FAILED;
}
export function isDataFrameAnalyticsRunning(state: DATA_FRAME_TASK_STATE) {
export function isDataFrameAnalyticsRunning(state: DataFrameTaskStateType) {
return (
state === DATA_FRAME_TASK_STATE.ANALYZING ||
state === DATA_FRAME_TASK_STATE.REINDEXING ||
@ -63,7 +45,7 @@ export function isDataFrameAnalyticsRunning(state: DATA_FRAME_TASK_STATE) {
);
}
export function isDataFrameAnalyticsStopped(state: DATA_FRAME_TASK_STATE) {
export function isDataFrameAnalyticsStopped(state: DataFrameTaskStateType) {
return state === DATA_FRAME_TASK_STATE.STOPPED;
}

View file

@ -1,17 +0,0 @@
/*
* 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.
*/
// DATA_FRAME_TASK_STATE is used by x-pack functional test setup/config
// and that config cannot import from './common.ts' because it has imports dependant on a browser-environment
export enum DATA_FRAME_TASK_STATE {
ANALYZING = 'analyzing',
FAILED = 'failed',
REINDEXING = 'reindexing',
STARTED = 'started',
STARTING = 'starting',
STOPPED = 'stopped',
}

View file

@ -92,13 +92,15 @@ export const Page: FC = () => {
<EuiPageHeaderSection>
<EuiFlexGroup alignItems="center" gutterSize="s">
{selectedTabId !== 'map' && (
<EuiFlexItem grow={false}>
<RefreshAnalyticsListButton />
</EuiFlexItem>
<>
<EuiFlexItem grow={false}>
<RefreshAnalyticsListButton />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<DatePickerWrapper />
</EuiFlexItem>
</>
)}
<EuiFlexItem grow={false}>
<DatePickerWrapper />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageHeaderSection>
</EuiPageHeader>

View file

@ -14,18 +14,19 @@ import {
} from '../../components/analytics_list/common';
export const deleteAnalytics = async (
d: DataFrameAnalyticsListRow,
analyticsConfig: DataFrameAnalyticsListRow['config'],
analyticsStats: DataFrameAnalyticsListRow['stats'],
toastNotificationService: ToastNotificationService
) => {
try {
if (isDataFrameAnalyticsFailed(d.stats.state)) {
await ml.dataFrameAnalytics.stopDataFrameAnalytics(d.config.id, true);
if (isDataFrameAnalyticsFailed(analyticsStats.state)) {
await ml.dataFrameAnalytics.stopDataFrameAnalytics(analyticsConfig.id, true);
}
await ml.dataFrameAnalytics.deleteDataFrameAnalytics(d.config.id);
await ml.dataFrameAnalytics.deleteDataFrameAnalytics(analyticsConfig.id);
toastNotificationService.displaySuccessToast(
i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsSuccessMessage', {
defaultMessage: 'Request to delete data frame analytics job {analyticsId} acknowledged.',
values: { analyticsId: d.config.id },
values: { analyticsId: analyticsConfig.id },
})
);
} catch (e) {
@ -33,7 +34,7 @@ export const deleteAnalytics = async (
e,
i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage', {
defaultMessage: 'An error occurred deleting the data frame analytics job {analyticsId}',
values: { analyticsId: d.config.id },
values: { analyticsId: analyticsConfig.id },
})
);
}
@ -41,20 +42,21 @@ export const deleteAnalytics = async (
};
export const deleteAnalyticsAndDestIndex = async (
d: DataFrameAnalyticsListRow,
analyticsConfig: DataFrameAnalyticsListRow['config'],
analyticsStats: DataFrameAnalyticsListRow['stats'],
deleteDestIndex: boolean,
deleteDestIndexPattern: boolean,
toastNotificationService: ToastNotificationService
) => {
const destinationIndex = Array.isArray(d.config.dest.index)
? d.config.dest.index[0]
: d.config.dest.index;
const destinationIndex = Array.isArray(analyticsConfig.dest.index)
? analyticsConfig.dest.index[0]
: analyticsConfig.dest.index;
try {
if (isDataFrameAnalyticsFailed(d.stats.state)) {
await ml.dataFrameAnalytics.stopDataFrameAnalytics(d.config.id, true);
if (isDataFrameAnalyticsFailed(analyticsStats.state)) {
await ml.dataFrameAnalytics.stopDataFrameAnalytics(analyticsConfig.id, true);
}
const status = await ml.dataFrameAnalytics.deleteDataFrameAnalyticsAndDestIndex(
d.config.id,
analyticsConfig.id,
deleteDestIndex,
deleteDestIndexPattern
);
@ -62,7 +64,7 @@ export const deleteAnalyticsAndDestIndex = async (
toastNotificationService.displaySuccessToast(
i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsSuccessMessage', {
defaultMessage: 'Request to delete data frame analytics job {analyticsId} acknowledged.',
values: { analyticsId: d.config.id },
values: { analyticsId: analyticsConfig.id },
})
);
}
@ -71,7 +73,7 @@ export const deleteAnalyticsAndDestIndex = async (
status.analyticsJobDeleted.error,
i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage', {
defaultMessage: 'An error occurred deleting the data frame analytics job {analyticsId}',
values: { analyticsId: d.config.id },
values: { analyticsId: analyticsConfig.id },
})
);
}
@ -122,7 +124,7 @@ export const deleteAnalyticsAndDestIndex = async (
e,
i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage', {
defaultMessage: 'An error occurred deleting the data frame analytics job {analyticsId}',
values: { analyticsId: d.config.id },
values: { analyticsId: analyticsConfig.id },
})
);
}

View file

@ -6,7 +6,7 @@
height: $euiSizeM;
width: $euiSizeM;
background-color: $euiColorGhost;
border: 1px solid $euiColorVis2;
border: $euiBorderWidthThick solid $euiColorVis2;
transform: rotate(45deg);
display: 'inline-block';
}
@ -15,7 +15,7 @@
height: $euiSizeM;
width: $euiSizeM;
background-color: $euiColorGhost;
border: 1px solid $euiColorVis1;
border: $euiBorderWidthThick solid $euiColorVis1;
display: 'inline-block';
}
@ -23,17 +23,17 @@
height: $euiSizeM;
width: $euiSizeM;
background-color: $euiColorGhost;
border: 1px solid $euiColorVis0;
border-radius: $euiBorderRadius;
border: $euiBorderWidthThick solid $euiColorVis0;
border-radius: 50%;
display: 'inline-block';
}
.mlJobMapLegend__trainedModel {
height: $euiSizeM;
width: $euiSizeM;
background-color: $euiColorGhost;
border: $euiBorderThin;
border-radius: $euiBorderRadius;
width: 0;
height: 0;
border-left: $euiSizeS solid $euiColorGhost;
border-right: $euiSizeS solid $euiColorGhost;
border-bottom: $euiSizeM solid $euiColorVis3;
display: 'inline-block';
}

View file

@ -7,10 +7,13 @@
import React, { FC, useEffect, useState, useContext, useCallback } from 'react';
import cytoscape from 'cytoscape';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import moment from 'moment-timezone';
import {
EuiButtonEmpty,
EuiButton,
EuiCodeBlock,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiDescriptionList,
EuiFlexGroup,
EuiFlexItem,
@ -18,6 +21,7 @@ import {
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiPopover,
EuiPortal,
EuiTitle,
} from '@elastic/eui';
@ -25,13 +29,26 @@ import { EuiDescriptionListProps } from '@elastic/eui/src/components/description
import { CytoscapeContext } from './cytoscape';
import { formatHumanReadableDateTimeSeconds } from '../../../../../../common/util/date_utils';
import { JOB_MAP_NODE_TYPES } from '../../../../../../common/constants/data_frame_analytics';
// import { DeleteButton } from './delete_button'; // TODO: add delete functionality in followup
import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator';
import { checkPermission } from '../../../../capabilities/check_capabilities';
import {
useMlUrlGenerator,
useNotifications,
useNavigateToPath,
} from '../../../../contexts/kibana';
import { getIndexPatternIdFromName } from '../../../../util/index_utils';
import { useNavigateToWizardWithClonedJob } from '../../analytics_management/components/action_clone/clone_action_name';
import {
useDeleteAction,
DeleteActionModal,
} from '../../analytics_management/components/action_delete';
interface Props {
analyticsId?: string;
modelId?: string;
details: any;
getNodeData: any;
modelId?: string;
updateElements: (nodeId: string, nodeLabel: string, destIndexNode?: string) => void;
}
function getListItems(details: object): EuiDescriptionListProps['listItems'] {
@ -57,9 +74,24 @@ function getListItems(details: object): EuiDescriptionListProps['listItems'] {
});
}
export const Controls: FC<Props> = ({ analyticsId, modelId, details, getNodeData }) => {
export const Controls: FC<Props> = ({
analyticsId,
details,
getNodeData,
modelId,
updateElements,
}) => {
const [showFlyout, setShowFlyout] = useState<boolean>(false);
const [selectedNode, setSelectedNode] = useState<cytoscape.NodeSingular | undefined>();
const [isPopoverOpen, setPopover] = useState(false);
const canDeleteDataFrameAnalytics: boolean = checkPermission('canDeleteDataFrameAnalytics');
const deleteAction = useDeleteAction(canDeleteDataFrameAnalytics);
const { deleteItem, deleteTargetIndex, isModalVisible, openModal } = deleteAction;
const { toasts } = useNotifications();
const mlUrlGenerator = useMlUrlGenerator();
const navigateToPath = useNavigateToPath();
const navigateToWizardWithClonedJob = useNavigateToWizardWithClonedJob();
const cy = useContext(CytoscapeContext);
const deselect = useCallback(() => {
@ -74,6 +106,39 @@ export const Controls: FC<Props> = ({ analyticsId, modelId, details, getNodeData
const nodeLabel = selectedNode?.data('label');
const nodeType = selectedNode?.data('type');
const onCreateJobClick = useCallback(async () => {
const indexId = getIndexPatternIdFromName(nodeLabel);
if (indexId) {
const path = await mlUrlGenerator.createUrl({
page: ML_PAGES.DATA_FRAME_ANALYTICS_CREATE_JOB,
pageState: { index: indexId },
});
await navigateToPath(path);
} else {
toasts.addDanger(
i18n.translate('xpack.ml.dataframe.analyticsMap.flyout.indexPatternMissingMessage', {
defaultMessage:
'To create a job from this index please create an index pattern for {indexTitle}.',
values: { indexTitle: nodeLabel },
})
);
}
}, [nodeLabel]);
const onCloneJobClick = useCallback(async () => {
navigateToWizardWithClonedJob({ config: details[nodeId], stats: details[nodeId]?.stats });
}, [nodeId]);
const onActionsButtonClick = () => {
setPopover(!isPopoverOpen);
};
const closePopover = () => {
setPopover(false);
};
// Set up Cytoscape event handlers
useEffect(() => {
const selectHandler: cytoscape.EventHandler = (event) => {
@ -94,27 +159,93 @@ export const Controls: FC<Props> = ({ analyticsId, modelId, details, getNodeData
};
}, [cy, deselect]);
useEffect(
function updateElementsOnClose() {
if (isModalVisible === false && deleteItem === true) {
let destIndexNode;
if (deleteTargetIndex === true) {
const jobDetails = details[nodeId];
const destIndex = jobDetails.dest.index;
destIndexNode = `${destIndex}-${JOB_MAP_NODE_TYPES.INDEX}`;
}
updateElements(nodeId, nodeLabel, destIndexNode);
setShowFlyout(false);
}
},
[isModalVisible, deleteItem]
);
if (showFlyout === false) {
return null;
}
const nodeDataButton =
analyticsId !== nodeLabel &&
const button = (
<EuiButton size="s" iconType="arrowDown" iconSide="right" onClick={onActionsButtonClick}>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.nodeActionsButton"
defaultMessage="Node actions"
/>
</EuiButton>
);
const items = [
...(nodeType === JOB_MAP_NODE_TYPES.ANALYTICS
? [
<EuiContextMenuItem
key={`${nodeId}-delete`}
icon="trash"
onClick={() => {
openModal({ config: details[nodeId], stats: details[nodeId]?.stats });
}}
>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.deleteJobButton"
defaultMessage="Delete job"
/>
</EuiContextMenuItem>,
<EuiContextMenuItem key={`${nodeId}-clone`} icon="copy" onClick={onCloneJobClick}>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.cloneJobButton"
defaultMessage="Clone job"
/>
</EuiContextMenuItem>,
]
: []),
...(nodeType === JOB_MAP_NODE_TYPES.INDEX
? [
<EuiContextMenuItem
key={`${nodeId}-create`}
icon="plusInCircle"
onClick={onCreateJobClick}
>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.createJobButton"
defaultMessage="Create job from this index"
/>
</EuiContextMenuItem>,
]
: []),
...(analyticsId !== nodeLabel &&
modelId !== nodeLabel &&
(nodeType === JOB_MAP_NODE_TYPES.ANALYTICS || nodeType === JOB_MAP_NODE_TYPES.INDEX) ? (
<EuiButtonEmpty
onClick={() => {
getNodeData({ id: nodeLabel, type: nodeType });
setShowFlyout(false);
}}
iconType="branch"
>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.fetchRelatedNodesButton"
defaultMessage="Fetch related nodes"
/>
</EuiButtonEmpty>
) : null;
(nodeType === JOB_MAP_NODE_TYPES.ANALYTICS || nodeType === JOB_MAP_NODE_TYPES.INDEX)
? [
<EuiContextMenuItem
key={`${nodeId}-fetch-related`}
icon="branch"
onClick={() => {
getNodeData({ id: nodeLabel, type: nodeType });
setShowFlyout(false);
setPopover(false);
}}
>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.fetchRelatedNodesButton"
defaultMessage="Fetch related nodes"
/>
</EuiContextMenuItem>,
]
: []),
];
return (
<EuiPortal>
@ -155,14 +286,20 @@ export const Controls: FC<Props> = ({ analyticsId, modelId, details, getNodeData
</EuiFlexGroup>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>{nodeDataButton}</EuiFlexItem>
{/* <EuiFlexItem grow={false}>
<DeleteButton id={nodeLabel} type={nodeType} />
</EuiFlexItem> */}
</EuiFlexGroup>
{nodeType !== JOB_MAP_NODE_TYPES.TRAINED_MODEL && (
<EuiPopover
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="s"
anchorPosition="downLeft"
>
<EuiContextMenuPanel items={items} />
</EuiPopover>
)}
</EuiFlyoutFooter>
</EuiFlyout>
{isModalVisible && <DeleteActionModal {...deleteAction} />}
</EuiPortal>
);
};

View file

@ -26,6 +26,8 @@ interface CytoscapeProps {
children?: ReactNode;
elements: cytoscape.ElementDefinition[];
height: number;
itemsDeleted: boolean;
resetCy: boolean;
style?: CSSProperties;
width: number;
}
@ -63,7 +65,15 @@ function getLayoutOptions(width: number, height: number) {
};
}
export function Cytoscape({ children, elements, height, style, width }: CytoscapeProps) {
export function Cytoscape({
children,
elements,
height,
itemsDeleted,
resetCy,
style,
width,
}: CytoscapeProps) {
const [ref, cy] = useCytoscape({
...cytoscapeOptions,
elements,
@ -76,7 +86,8 @@ export function Cytoscape({ children, elements, height, style, width }: Cytoscap
const dataHandler = useCallback<cytoscape.EventHandler>(
(event) => {
if (cy && height > 0) {
cy.layout(getLayoutOptions(width, height)).run();
// temporary workaround for single 'row' maps showing up outside of the graph bounds
setTimeout(() => cy.layout(getLayoutOptions(width, height)).run(), 150);
}
},
[cy, height, width]
@ -98,11 +109,24 @@ export function Cytoscape({ children, elements, height, style, width }: Cytoscap
// Trigger a custom "data" event when data changes
useEffect(() => {
if (cy) {
cy.add(elements);
if (itemsDeleted === false) {
cy.add(elements);
} else {
cy.elements().remove();
cy.add(elements);
}
cy.trigger('data');
}
}, [cy, elements]);
// Reset the graph to original zoom and pan
useEffect(() => {
if (cy) {
cy.reset();
}
}, [cy, resetCy]);
return (
<CytoscapeContext.Provider value={cy}>
<div ref={ref} style={divStyle}>

View file

@ -20,6 +20,7 @@ const MAP_SHAPES = {
ELLIPSE: 'ellipse',
RECTANGLE: 'rectangle',
DIAMOND: 'diamond',
TRIANGLE: 'triangle',
} as const;
type MapShapes = typeof MAP_SHAPES[keyof typeof MAP_SHAPES];
@ -32,6 +33,8 @@ function shapeForNode(el: cytoscape.NodeSingular): MapShapes {
return MAP_SHAPES.RECTANGLE;
case JOB_MAP_NODE_TYPES.INDEX:
return MAP_SHAPES.DIAMOND;
case JOB_MAP_NODE_TYPES.TRAINED_MODEL:
return MAP_SHAPES.TRIANGLE;
default:
return MAP_SHAPES.ELLIPSE;
}
@ -66,6 +69,8 @@ function borderColorForNode(el: cytoscape.NodeSingular) {
return theme.euiColorVis1;
case JOB_MAP_NODE_TYPES.INDEX:
return theme.euiColorVis2;
case JOB_MAP_NODE_TYPES.TRAINED_MODEL:
return theme.euiColorVis3;
default:
return theme.euiColorMediumShade;
}
@ -88,7 +93,8 @@ export const cytoscapeOptions: cytoscape.CytoscapeOptions = {
'border-style': 'solid',
// @ts-ignore
'background-image': (el: cytoscape.NodeSingular) => iconForNode(el),
'border-width': (el: cytoscape.NodeSingular) => (el.selected() ? 2 : 1),
'border-width': (el: cytoscape.NodeSingular) => (el.selected() ? 4 : 3),
// @ts-ignore
color: theme.euiTextColors.default,
'font-family': 'Inter UI, Segoe UI, Helvetica, Arial, sans-serif',
'font-size': theme.euiFontSizeXS,

View file

@ -1,58 +0,0 @@
/*
* 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 { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ml } from '../../../../services/ml_api_service';
import { getToastNotifications } from '../../../../util/dependency_cache';
import {
JOB_MAP_NODE_TYPES,
JobMapNodeTypes,
} from '../../../../../../common/constants/data_frame_analytics';
interface Props {
id: string;
type: JobMapNodeTypes;
}
export const DeleteButton: FC<Props> = ({ id, type }) => {
const toastNotifications = getToastNotifications();
const onDelete = async () => {
try {
// if (isDataFrameAnalyticsFailed(d.stats.state)) {
// await ml.dataFrameAnalytics.stopDataFrameAnalytics(d.config.id, true);
// }
await ml.dataFrameAnalytics.deleteDataFrameAnalytics(id);
toastNotifications.addSuccess(
i18n.translate('xpack.ml.dataframe.analyticsMap.flyout.deleteAnalyticsSuccessMessage', {
defaultMessage: 'Request to delete data frame analytics {id} acknowledged.',
values: { id },
})
);
} catch (e) {
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.analyticsMap.flyout.deleteAnalyticsErrorMessage', {
defaultMessage: 'An error occurred deleting the data frame analytics {id}: {error}',
values: { id, error: JSON.stringify(e) },
})
);
}
};
if (type !== JOB_MAP_NODE_TYPES.ANALYTICS) {
return null;
}
return (
<EuiButton onClick={onDelete} iconType="trash" color="danger" size="s">
{i18n.translate('xpack.ml.dataframe.analyticsMap.flyout.deleteJobButton', {
defaultMessage: 'Delete job',
})}
</EuiButton>
);
};

View file

@ -7,15 +7,15 @@
import React, { FC, useEffect, useState } from 'react';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
import cytoscape from 'cytoscape';
import { uniqWith, isEqual } from 'lodash';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { Cytoscape, Controls, JobMapLegend } from './components';
import { ml } from '../../../services/ml_api_service';
import { useMlKibana } from '../../../contexts/kibana';
import { useRefDimensions } from './components/use_ref_dimensions';
import { useMlKibana, useMlUrlGenerator } from '../../../contexts/kibana';
import { JOB_MAP_NODE_TYPES } from '../../../../../common/constants/data_frame_analytics';
import { ML_PAGES } from '../../../../../common/constants/ml_url_generator';
import { useRefDimensions } from './components/use_ref_dimensions';
import { useFetchAnalyticsMapData } from './use_fetch_analytics_map_data';
import { JobMapTitle } from './job_map_title';
const cytoscapeDivStyle = {
background: `linear-gradient(
@ -37,104 +37,84 @@ ${theme.euiColorLightShade}`,
marginTop: 0,
};
export const JobMapTitle: React.FC<{ analyticsId?: string; modelId?: string }> = ({
analyticsId,
modelId,
}) => (
<EuiTitle size="xs">
<span>
{analyticsId
? i18n.translate('xpack.ml.dataframe.analyticsMap.analyticsIdTitle', {
defaultMessage: 'Map for analytics ID {analyticsId}',
values: { analyticsId },
})
: i18n.translate('xpack.ml.dataframe.analyticsMap.modelIdTitle', {
defaultMessage: 'Map for trained model ID {modelId}',
values: { modelId },
})}
</span>
</EuiTitle>
);
interface GetDataObjectParameter {
id: string;
type: string;
}
interface Props {
analyticsId?: string;
modelId?: string;
}
export const JobMap: FC<Props> = ({ analyticsId, modelId }) => {
const [elements, setElements] = useState<cytoscape.ElementDefinition[]>([]);
const [nodeDetails, setNodeDetails] = useState({});
const [error, setError] = useState(undefined);
// itemsDeleted will reset to false when Controls component calls updateElements to remove nodes deleted from map
const [itemsDeleted, setItemsDeleted] = useState<boolean>(false);
const [resetCyToggle, setResetCyToggle] = useState<boolean>(false);
const {
elements,
error,
fetchAndSetElementsWrapper,
isLoading,
message,
nodeDetails,
setElements,
setError,
} = useFetchAnalyticsMapData();
const {
services: { notifications },
services: {
notifications,
application: { navigateToUrl },
},
} = useMlKibana();
const urlGenerator = useMlUrlGenerator();
const getDataWrapper = async (params?: GetDataObjectParameter) => {
const { id, type } = params ?? {};
const treatAsRoot = id !== undefined;
let idToUse: string;
if (id !== undefined) {
idToUse = id;
} else if (modelId !== undefined) {
idToUse = modelId;
} else {
idToUse = analyticsId as string;
}
await getData(
idToUse,
treatAsRoot,
modelId !== undefined && treatAsRoot === false ? JOB_MAP_NODE_TYPES.TRAINED_MODEL : type
);
const redirectToAnalyticsManagementPage = async () => {
const url = await urlGenerator.createUrl({ page: ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE });
await navigateToUrl(url);
};
const getData = async (idToUse: string, treatAsRoot: boolean, type?: string) => {
// Pass in treatAsRoot flag - endpoint will take job or index to grab jobs created from it
// TODO: update analyticsMap return type here
const analyticsMap: any = await ml.dataFrameAnalytics.getDataFrameAnalyticsMap(
idToUse,
treatAsRoot,
type
);
const updateElements = (nodeId: string, nodeLabel: string, destIndexNode?: string) => {
// If removing the root job just go back to the jobs list
if (nodeLabel === analyticsId) {
redirectToAnalyticsManagementPage();
} else {
// Remove job element
const filteredElements = elements.filter((e) => {
// Filter out job node and related edges, including trained model node.
let isNotDeletedNodeOrRelated =
e.data.id !== nodeId && e.data.target !== nodeId && e.data.source !== nodeId;
const { elements: nodeElements, details, error: fetchError } = analyticsMap;
if (e.data.id !== undefined && e.data.type === JOB_MAP_NODE_TYPES.TRAINED_MODEL) {
// remove training model node related to that job
isNotDeletedNodeOrRelated =
isNotDeletedNodeOrRelated &&
nodeDetails[e.data.id]?.metadata?.analytics_config?.id !== nodeLabel;
}
if (fetchError !== null) {
setError(fetchError);
}
if (destIndexNode !== undefined) {
// Filter out destination index node for that job
return (
isNotDeletedNodeOrRelated &&
e.data.id !== destIndexNode &&
e.data.target !== destIndexNode &&
e.data.source !== destIndexNode
);
}
if (nodeElements && nodeElements.length === 0) {
notifications.toasts.add(
i18n.translate('xpack.ml.dataframe.analyticsMap.emptyResponseMessage', {
defaultMessage: 'No related analytics jobs found for {id}.',
values: { id: idToUse },
})
);
}
if (nodeElements && nodeElements.length > 0) {
if (treatAsRoot === false) {
setElements(nodeElements);
setNodeDetails(details);
} else {
const uniqueElements = uniqWith([...nodeElements, ...elements], isEqual);
setElements(uniqueElements);
setNodeDetails({ ...details, ...nodeDetails });
}
return isNotDeletedNodeOrRelated;
});
setItemsDeleted(true);
setElements(filteredElements);
}
};
useEffect(() => {
getDataWrapper();
fetchAndSetElementsWrapper({ analyticsId, modelId });
}, [analyticsId, modelId]);
useEffect(() => {
if (message !== undefined) {
notifications.toasts.add(message);
}
}, [message]);
if (error !== undefined) {
notifications.toasts.addDanger(
i18n.translate('xpack.ml.dataframe.analyticsMap.fetchDataErrorMessage', {
@ -150,21 +130,63 @@ export const JobMap: FC<Props> = ({ analyticsId, modelId }) => {
return (
<>
<EuiSpacer size="m" />
<div style={{ height: height - parseInt(theme.gutterTypes.gutterLarge, 10) }} ref={ref}>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<JobMapTitle analyticsId={analyticsId} modelId={modelId} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<JobMapLegend />
</EuiFlexItem>
</EuiFlexGroup>
<Cytoscape height={height} elements={elements} width={width} style={cytoscapeDivStyle}>
<EuiFlexGroup direction="column" gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<JobMapTitle analyticsId={analyticsId} modelId={modelId} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<JobMapLegend />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" component="span">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
data-test-subj={`mlAnalyticsRefreshMapButton${isLoading ? ' loading' : ' loaded'}`}
onClick={() => fetchAndSetElementsWrapper({ analyticsId, modelId })}
isLoading={isLoading}
>
<FormattedMessage
id="xpack.ml.dataframe.analyticsList.refreshMapButtonLabel"
defaultMessage="Refresh"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
data-test-subj="mlAnalyticsResetGraphButton"
// trigger reset on value change
onClick={() => setResetCyToggle(!resetCyToggle)}
>
<FormattedMessage
id="xpack.ml.dataframe.analyticsList.resetMapButtonLabel"
defaultMessage="Reset"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<div style={{ height: height - parseInt(theme.gutterTypes.gutterLarge, 10) - 20 }} ref={ref}>
<Cytoscape
height={height - 20}
elements={elements}
width={width}
style={cytoscapeDivStyle}
itemsDeleted={itemsDeleted}
resetCy={resetCyToggle}
>
<Controls
details={nodeDetails}
getNodeData={getDataWrapper}
getNodeData={fetchAndSetElementsWrapper}
analyticsId={analyticsId}
modelId={modelId}
updateElements={updateElements}
/>
</Cytoscape>
</div>

View file

@ -0,0 +1,32 @@
/*
* 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 { EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
export const JobMapTitle: FC<{ analyticsId?: string; modelId?: string }> = ({
analyticsId,
modelId,
}) => (
<EuiTitle size="xs">
<span>
{analyticsId ? (
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.analyticsIdTitle"
defaultMessage="Map for analytics ID {analyticsId}"
values={{ analyticsId }}
/>
) : (
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.modelIdTitle"
defaultMessage="Map for trained model ID {modelId}"
values={{ modelId }}
/>
)}
</span>
</EuiTitle>
);

View file

@ -0,0 +1,97 @@
/*
* 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 { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { uniqWith, isEqual } from 'lodash';
import cytoscape from 'cytoscape';
import { ml } from '../../../services/ml_api_service';
import { JOB_MAP_NODE_TYPES } from '../../../../../common/constants/data_frame_analytics';
import { AnalyticsMapReturnType } from '../../../../../common/types/data_frame_analytics';
interface GetDataObjectParameter {
analyticsId?: string;
id?: string;
modelId?: string;
type?: string;
}
export const useFetchAnalyticsMapData = () => {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [elements, setElements] = useState<cytoscape.ElementDefinition[]>([]);
const [nodeDetails, setNodeDetails] = useState<Record<string, any>>({});
const [error, setError] = useState<any>();
const [message, setMessage] = useState<string | undefined>();
const fetchAndSetElements = async (idToUse: string, treatAsRoot: boolean, type?: string) => {
setIsLoading(true);
// Pass in treatAsRoot flag - endpoint will take job or index to grab jobs created from it
const analyticsMap: AnalyticsMapReturnType = await ml.dataFrameAnalytics.getDataFrameAnalyticsMap(
idToUse,
treatAsRoot,
type
);
const { elements: nodeElements, details, error: fetchError } = analyticsMap;
if (fetchError !== null) {
setIsLoading(false);
setError(fetchError);
}
if (nodeElements?.length === 0) {
setMessage(
i18n.translate('xpack.ml.dataframe.analyticsMap.emptyResponseMessage', {
defaultMessage: 'No related analytics jobs found for {id}.',
values: { id: idToUse },
})
);
}
if (nodeElements?.length > 0) {
if (treatAsRoot === false) {
setElements(nodeElements);
setNodeDetails(details);
} else {
const uniqueElements = uniqWith([...nodeElements, ...elements], isEqual);
setElements(uniqueElements);
setNodeDetails({ ...details, ...nodeDetails });
}
}
setIsLoading(false);
};
const fetchAndSetElementsWrapper = async (params?: GetDataObjectParameter) => {
const { analyticsId, id, modelId, type } = params ?? {};
const treatAsRoot = id !== undefined;
let idToUse: string;
if (id !== undefined) {
idToUse = id;
} else if (modelId !== undefined) {
idToUse = modelId;
} else {
idToUse = analyticsId as string;
}
await fetchAndSetElements(
idToUse,
treatAsRoot,
modelId !== undefined && treatAsRoot === false ? JOB_MAP_NODE_TYPES.TRAINED_MODEL : type
);
};
return {
elements,
error,
fetchAndSetElementsWrapper,
isLoading,
message,
nodeDetails,
setElements,
setError,
};
};

View file

@ -13,7 +13,10 @@ import {
UpdateDataFrameAnalyticsConfig,
} from '../../data_frame_analytics/common';
import { DeepPartial } from '../../../../common/types/common';
import { DeleteDataFrameAnalyticsWithIndexStatus } from '../../../../common/types/data_frame_analytics';
import {
DeleteDataFrameAnalyticsWithIndexStatus,
AnalyticsMapReturnType,
} from '../../../../common/types/data_frame_analytics';
export interface GetDataFrameAnalyticsStatsResponseOk {
node_failures?: object;
@ -83,7 +86,11 @@ export const dataFrameAnalytics = {
body,
});
},
getDataFrameAnalyticsMap(id: string, treatAsRoot: boolean, type?: string) {
getDataFrameAnalyticsMap(
id: string,
treatAsRoot: boolean,
type?: string
): Promise<AnalyticsMapReturnType> {
const idString = id !== undefined ? `/${id}` : '';
return http({
path: `${basePath()}/data_frame/analytics/map${idString}`,

View file

@ -13,8 +13,10 @@ import {
DataFrameAnalyticsExplorationUrlState,
DataFrameAnalyticsUrlState,
ExplorationPageUrlState,
MlGenericUrlState,
MlCommonGlobalState,
} from '../../common/types/ml_url_generator';
import { createGenericMlUrl } from './common';
import { ML_PAGES } from '../../common/constants/ml_url_generator';
import { setStateToKbnUrl } from '../../../../../src/plugins/kibana_utils/public';
import { getGroupQueryText, getJobQueryText } from '../../common/util/string_utils';
@ -110,6 +112,16 @@ export function createDataFrameAnalyticsExplorationUrl(
return url;
}
/**
* Creates URL to the DataFrameAnalytics creation wizard
*/
export function createDataFrameAnalyticsCreateJobUrl(
appBasePath: string,
pageState: MlGenericUrlState['pageState']
): string {
return createGenericMlUrl(appBasePath, ML_PAGES.DATA_FRAME_ANALYTICS_CREATE_JOB, pageState);
}
/**
* Creates URL to the DataFrameAnalytics Map page
*/

View file

@ -22,6 +22,7 @@ import {
} from './anomaly_detection_urls_generator';
import {
createDataFrameAnalyticsJobManagementUrl,
createDataFrameAnalyticsCreateJobUrl,
createDataFrameAnalyticsExplorationUrl,
createDataFrameAnalyticsMapUrl,
} from './data_frame_analytics_urls_generator';
@ -69,7 +70,8 @@ export class MlUrlGenerator implements UrlGeneratorsDefinition<typeof ML_APP_URL
return createSingleMetricViewerUrl(appBasePath, mlUrlGeneratorState.pageState);
case ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE:
return createDataFrameAnalyticsJobManagementUrl(appBasePath, mlUrlGeneratorState.pageState);
// @ts-ignore // TODO: fix type
case ML_PAGES.DATA_FRAME_ANALYTICS_CREATE_JOB:
return createDataFrameAnalyticsCreateJobUrl(appBasePath, mlUrlGeneratorState.pageState);
case ML_PAGES.DATA_FRAME_ANALYTICS_MAP:
// @ts-ignore // TODO: fix type
return createDataFrameAnalyticsMapUrl(appBasePath, mlUrlGeneratorState.pageState);

View file

@ -11,12 +11,16 @@ import {
JobMapNodeTypes,
} from '../../../common/constants/data_frame_analytics';
import { TrainedModelConfigResponse } from '../../../common/types/trained_models';
import { INDEX_META_DATA_CREATED_BY } from '../../../common/constants/file_datavisualizer';
import { getAnalysisType } from '../../../common/util/analytics_utils';
import {
AnalyticsMapEdgeElement,
AnalyticsMapReturnType,
AnalyticsMapNodeElement,
DataFrameAnalyticsStats,
MapElements,
} from '../../../common/types/data_frame_analytics';
import { INDEX_META_DATA_CREATED_BY } from '../../../common/constants/file_datavisualizer';
import { getAnalysisType } from '../../../common/util/analytics_utils';
import {
ExtendAnalyticsMapArgs,
GetAnalyticsMapArgs,
InitialElementsReturnType,
@ -26,7 +30,6 @@ import {
isIndexPatternLinkReturnType,
isJobDataLinkReturnType,
isTransformLinkReturnType,
MapElements,
NextLinkReturnType,
} from './types';
import type { MlClient } from '../../lib/ml_client';
@ -34,12 +37,22 @@ import type { MlClient } from '../../lib/ml_client';
export class AnalyticsManager {
private _client: IScopedClusterClient['asInternalUser'];
private _mlClient: MlClient;
public _inferenceModels: TrainedModelConfigResponse[];
private _inferenceModels: TrainedModelConfigResponse[];
private _jobStats: DataFrameAnalyticsStats[];
constructor(mlClient: MlClient, client: IScopedClusterClient['asInternalUser']) {
this._client = client;
this._mlClient = mlClient;
this._inferenceModels = [];
this._jobStats = [];
}
public set jobStats(stats) {
this._jobStats = stats;
}
public get jobStats() {
return this._jobStats;
}
public set inferenceModels(models) {
@ -55,12 +68,21 @@ export class AnalyticsManager {
const models = await this.getAnalyticsModels();
this.inferenceModels = models;
} catch (error) {
// TODO: bubble up this error?
// eslint-disable-next-line
console.error('Unable to fetch inference models', error);
}
}
async setJobStats() {
try {
const jobStats = await this.getAnalyticsStats();
this.jobStats = jobStats;
} catch (error) {
// eslint-disable-next-line
console.error('Unable to fetch job stats', error);
}
}
private isDuplicateElement(analyticsId: string, elements: MapElements[]): boolean {
let isDuplicate = false;
elements.forEach((elem) => {
@ -89,6 +111,12 @@ export class AnalyticsManager {
return models;
}
private async getAnalyticsStats() {
const resp = await this._mlClient.getDataFrameAnalyticsStats({ size: 1000 });
const stats = resp?.body?.data_frame_analytics;
return stats;
}
private async getAnalyticsData(analyticsId?: string) {
const options = analyticsId
? {
@ -96,10 +124,20 @@ export class AnalyticsManager {
}
: undefined;
const resp = await this._mlClient.getDataFrameAnalytics(options);
const jobData = analyticsId
let jobData = analyticsId
? resp?.body?.data_frame_analytics[0]
: resp?.body?.data_frame_analytics;
if (analyticsId !== undefined) {
const jobStats = this.findJobStats(analyticsId);
jobData = { ...jobData, stats: { ...jobStats } };
} else {
jobData = jobData.map((job: any) => {
const jobStats = this.findJobStats(job.id);
return { ...job, stats: { ...jobStats } };
});
}
return jobData;
}
@ -121,10 +159,14 @@ export class AnalyticsManager {
private findJobModel(analyticsId: string): any {
return this.inferenceModels.find(
(model: any) => model.metadata?.analytics_config?.id === analyticsId
(model) => model.metadata?.analytics_config?.id === analyticsId
);
}
private findJobStats(analyticsId: string): DataFrameAnalyticsStats | undefined {
return this.jobStats.find((js) => js.id === analyticsId);
}
private async getNextLink({
id,
type,
@ -243,31 +285,37 @@ export class AnalyticsManager {
details[modelNodeId] = data;
// fetch source job data and create elements
if (sourceJobId !== undefined) {
data = await this.getAnalyticsData(sourceJobId);
try {
data = await this.getAnalyticsData(sourceJobId);
nextLinkId = data?.source?.index[0];
nextType = JOB_MAP_NODE_TYPES.INDEX;
nextLinkId = data?.source?.index[0];
nextType = JOB_MAP_NODE_TYPES.INDEX;
previousNodeId = `${data.id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`;
previousNodeId = `${data.id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`;
resultElements.push({
data: {
id: previousNodeId,
label: data.id,
type: JOB_MAP_NODE_TYPES.ANALYTICS,
analysisType: getAnalysisType(data?.analysis),
},
});
// Create edge between job and model
modelElements.push({
data: {
id: `${previousNodeId}~${modelNodeId}`,
source: previousNodeId,
target: modelNodeId,
},
});
resultElements.push({
data: {
id: previousNodeId,
label: data.id,
type: JOB_MAP_NODE_TYPES.ANALYTICS,
analysisType: getAnalysisType(data?.analysis),
},
});
// Create edge between job and model
modelElements.push({
data: {
id: `${previousNodeId}~${modelNodeId}`,
source: previousNodeId,
target: modelNodeId,
},
});
details[previousNodeId] = data;
details[previousNodeId] = data;
} catch (error) {
// fail silently if job doesn't exist
if (error.statusCode !== 404) {
throw error.body ?? error;
}
}
}
return { data, details, resultElements, modelElements, nextLinkId, nextType, previousNodeId };
@ -325,7 +373,7 @@ export class AnalyticsManager {
const indexPatternElements: MapElements[] = [];
try {
await this.setInferenceModels();
await Promise.all([this.setInferenceModels(), this.setJobStats()]);
// Create first node for incoming analyticsId or modelId
let initialData: InitialElementsReturnType = {} as InitialElementsReturnType;
if (analyticsId !== undefined) {
@ -532,7 +580,7 @@ export class AnalyticsManager {
}: ExtendAnalyticsMapArgs): Promise<AnalyticsMapReturnType> {
const result: AnalyticsMapReturnType = { elements: [], details: {}, error: null };
try {
await this.setInferenceModels();
await Promise.all([this.setInferenceModels(), this.setJobStats()]);
const jobs = await this.getAnalyticsData();
let rootIndex;
let rootIndexNodeId;

View file

@ -5,6 +5,17 @@
*/
import { JobMapNodeTypes } from '../../../common/constants/data_frame_analytics';
import {
MapElements,
AnalyticsMapNodeElement,
AnalyticsMapEdgeElement,
} from '../../../common/types/data_frame_analytics';
export {
MapElements,
AnalyticsMapReturnType,
AnalyticsMapNodeElement,
AnalyticsMapEdgeElement,
} from '../../../common/types/data_frame_analytics';
interface AnalyticsMapArg {
analyticsId: string;
@ -46,12 +57,6 @@ export type NextLinkReturnType =
| JobDataLinkReturnType
| TransformLinkReturnType
| undefined;
export type MapElements = AnalyticsMapNodeElement | AnalyticsMapEdgeElement;
export interface AnalyticsMapReturnType {
elements: MapElements[];
details: Record<string, any>; // transform, job, or index details
error: null | any;
}
interface BasicInitialElementsReturnType {
data: any;
@ -70,29 +75,18 @@ interface CompleteInitialElementsReturnType extends BasicInitialElementsReturnTy
nextType: JobMapNodeTypes;
previousNodeId: string;
}
export interface AnalyticsMapNodeElement {
data: {
id: string;
label: string;
type: string;
analysisType?: string;
};
}
export interface AnalyticsMapEdgeElement {
data: {
id: string;
source: string;
target: string;
};
}
export const isCompleteInitialReturnType = (arg: any): arg is CompleteInitialElementsReturnType => {
if (typeof arg !== 'object' || arg === null) return false;
const keys = Object.keys(arg);
return (
keys.length > 0 &&
keys.includes('nextLinkId') &&
arg.nextLinkId !== undefined &&
keys.includes('nextType') &&
keys.includes('previousNodeId')
arg.nextType !== undefined &&
keys.includes('previousNodeId') &&
arg.previousNodeId !== undefined
);
};
export const isAnalyticsMapNodeElement = (arg: any): arg is AnalyticsMapNodeElement => {

View file

@ -11,7 +11,8 @@ import { Annotation } from '../../../../plugins/ml/common/types/annotations';
import { DataFrameAnalyticsConfig } from '../../../../plugins/ml/public/application/data_frame_analytics/common';
import { FtrProviderContext } from '../../ftr_provider_context';
import { DATAFEED_STATE, JOB_STATE } from '../../../../plugins/ml/common/constants/states';
import { DATA_FRAME_TASK_STATE } from '../../../../plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/data_frame_task_state';
import { DataFrameTaskStateType } from '../../../../plugins/ml/common/types/data_frame_analytics';
import { DATA_FRAME_TASK_STATE } from '../../../../plugins/ml/common/constants/data_frame_analytics';
import { Datafeed, Job } from '../../../../plugins/ml/common/types/anomaly_detection_jobs';
import { JobType } from '../../../../plugins/ml/common/types/saved_objects';
export type MlApi = ProvidedType<typeof MachineLearningAPIProvider>;
@ -245,7 +246,7 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) {
return analyticsStats;
},
async getAnalyticsState(analyticsId: string): Promise<DATA_FRAME_TASK_STATE> {
async getAnalyticsState(analyticsId: string): Promise<DataFrameTaskStateType> {
log.debug(`Fetching analytics state for job ${analyticsId}`);
const analyticsStats = await this.getDFAJobStats(analyticsId);
@ -254,7 +255,7 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) {
`Expected dataframe analytics stats to have exactly one object (got '${analyticsStats.data_frame_analytics.length}')`
);
const state: DATA_FRAME_TASK_STATE = analyticsStats.data_frame_analytics[0].state;
const state: DataFrameTaskStateType = analyticsStats.data_frame_analytics[0].state;
return state;
},
@ -291,7 +292,7 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) {
async waitForAnalyticsState(
analyticsId: string,
expectedAnalyticsState: DATA_FRAME_TASK_STATE
expectedAnalyticsState: DataFrameTaskStateType
) {
await retry.waitForWithTimeout(
`analytics state to be ${expectedAnalyticsState}`,

View file

@ -9,7 +9,7 @@ import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
import { MlApi } from './api';
import { DATA_FRAME_TASK_STATE } from '../../../../plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/data_frame_task_state';
import { DATA_FRAME_TASK_STATE } from '../../../../plugins/ml/common/constants/data_frame_analytics';
export function MachineLearningDataFrameAnalyticsProvider(
{ getService }: FtrProviderContext,