diff --git a/x-pack/packages/ml/data_frame_analytics_utils/src/constants.ts b/x-pack/packages/ml/data_frame_analytics_utils/src/constants.ts index 16c07a57e2c91..3b2e540730fa1 100644 --- a/x-pack/packages/ml/data_frame_analytics_utils/src/constants.ts +++ b/x-pack/packages/ml/data_frame_analytics_utils/src/constants.ts @@ -36,6 +36,7 @@ export const DEFAULT_RESULTS_FIELD = 'ml'; */ export const JOB_MAP_NODE_TYPES = { ANALYTICS: 'analytics', + ANALYTICS_JOB_MISSING: 'analytics-job-missing', TRANSFORM: 'transform', INDEX: 'index', TRAINED_MODEL: 'trainedModel', diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_legend.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_legend.scss index 5a3af985446c8..7e7168595a44e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_legend.scss +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_legend.scss @@ -37,6 +37,15 @@ display: 'inline-block'; } +.mlJobMapLegend__analyticsMissing { + height: $euiSizeM; + width: $euiSizeM; + background-color: $euiColorGhost; + border: $euiBorderWidthThick solid $euiColorFullShade; + border-radius: 50%; + display: 'inline-block'; +} + .mlJobMapLegend__sourceNode { height: $euiSizeM; width: $euiSizeM; @@ -44,4 +53,4 @@ border: $euiBorderThin; border-radius: $euiBorderRadius; display: 'inline-block'; -} +} \ No newline at end of file diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx index 82695b39e0066..2786046610fc7 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx @@ -196,7 +196,17 @@ export const Controls: FC = React.memo( // Set up Cytoscape event handlers useEffect(() => { const selectHandler: cytoscape.EventHandler = (event) => { - setSelectedNode(event.target); + const targetNode = event.target; + if (targetNode._private.data.type === JOB_MAP_NODE_TYPES.ANALYTICS_JOB_MISSING) { + toasts.addWarning( + i18n.translate('xpack.ml.dataframe.analyticsMap.flyout.jobMissingMessage', { + defaultMessage: 'There is no data available for job {label}.', + values: { label: targetNode._private.data.label }, + }) + ); + return; + } + setSelectedNode(targetNode); setShowFlyout(true); }; @@ -211,7 +221,7 @@ export const Controls: FC = React.memo( cy.removeListener('unselect', 'node', deselect); } }; - }, [cy, deselect]); + }, [cy, deselect, toasts]); useEffect( function updateElementsOnClose() { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/cytoscape_options.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/cytoscape_options.tsx index 9cdd41dfb9c88..b7ca710fb43f3 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/cytoscape_options.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/cytoscape_options.tsx @@ -28,6 +28,8 @@ function shapeForNode(el: cytoscape.NodeSingular, theme: EuiThemeType): MapShape switch (type) { case JOB_MAP_NODE_TYPES.ANALYTICS: return MAP_SHAPES.ELLIPSE; + case JOB_MAP_NODE_TYPES.ANALYTICS_JOB_MISSING: + return MAP_SHAPES.ELLIPSE; case JOB_MAP_NODE_TYPES.TRANSFORM: return MAP_SHAPES.RECTANGLE; case JOB_MAP_NODE_TYPES.INDEX: @@ -65,6 +67,8 @@ function borderColorForNode(el: cytoscape.NodeSingular, theme: EuiThemeType) { const type = el.data('type'); switch (type) { + case JOB_MAP_NODE_TYPES.ANALYTICS_JOB_MISSING: + return theme.euiColorFullShade; case JOB_MAP_NODE_TYPES.ANALYTICS: return theme.euiColorSuccess; case JOB_MAP_NODE_TYPES.TRANSFORM: diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/legend.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/legend.tsx index 9494f7fc08799..53379c4809d9a 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/legend.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/legend.tsx @@ -32,7 +32,10 @@ const getJobTypeList = () => ( ); -export const JobMapLegend: FC<{ theme: EuiThemeType }> = ({ theme }) => { +export const JobMapLegend: FC<{ hasMissingJobNode: boolean; theme: EuiThemeType }> = ({ + hasMissingJobNode, + theme, +}) => { const [showJobTypes, setShowJobTypes] = useState(false); return ( @@ -122,6 +125,23 @@ export const JobMapLegend: FC<{ theme: EuiThemeType }> = ({ theme }) => { + {hasMissingJobNode ? ( + + + + + + + + + + + + + ) : null} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/job_map.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/job_map.tsx index ae1adea958959..f4ba7e3a4c1d7 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/job_map.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/job_map.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FC, useEffect, useState } from 'react'; +import React, { FC, useEffect, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; @@ -150,6 +150,10 @@ export const JobMap: FC = ({ defaultHeight, analyticsId, modelId, forceRe const { ref, width, height } = useRefDimensions(); const refreshCallback = () => fetchAndSetElementsWrapper({ analyticsId, modelId }); + const hasMissingJobNode = useMemo( + () => elements.map(({ data }) => data.type).includes(JOB_MAP_NODE_TYPES.ANALYTICS_JOB_MISSING), + [elements] + ); const h = defaultHeight ?? height; return ( @@ -157,7 +161,7 @@ export const JobMap: FC = ({ defaultHeight, analyticsId, modelId, forceRe - + { @@ -106,12 +116,8 @@ export class AnalyticsManager { ); } - private findJob(id: string): estypes.MlDataframeAnalyticsSummary { - const job = this._jobs.find((js) => js.id === id); - if (job === undefined) { - throw Error(`No known job with id '${id}'`); - } - return job; + private findJob(id: string): estypes.MlDataframeAnalyticsSummary | undefined { + return this._jobs.find((js) => js.id === id); } private findTrainedModel(id: string): estypes.MlTrainedModelConfig { @@ -156,14 +162,16 @@ export class AnalyticsManager { private getAnalyticsModelElements( analyticsId: string, - analyticsCreateTime: number + analyticsCreateTime?: number ): { modelElement?: AnalyticsMapNodeElement; modelDetails?: any; edgeElement?: AnalyticsMapEdgeElement; } { // Get trained model for analytics job and create model node - const analyticsModel = this.findJobModel(analyticsId, analyticsCreateTime); + const analyticsModel = analyticsCreateTime + ? this.findJobModel(analyticsId, analyticsCreateTime) + : undefined; let modelElement; let edgeElement; @@ -221,7 +229,7 @@ export class AnalyticsManager { const resultElements = []; const modelElements = []; const details: any = {}; - let data: estypes.MlTrainedModelConfig | estypes.MlDataframeAnalyticsSummary; + let data: estypes.MlTrainedModelConfig | estypes.MlDataframeAnalyticsSummary | undefined; // fetch model data and create model elements data = this.findTrainedModel(modelId); const modelNodeId = `${data.model_id}-${JOB_MAP_NODE_TYPES.TRAINED_MODEL}`; @@ -243,37 +251,35 @@ export class AnalyticsManager { details[modelNodeId] = data; // fetch source job data and create elements if (sourceJobId !== undefined) { - try { - data = this.findJob(sourceJobId); - - nextLinkId = data?.source?.index[0]; - nextType = JOB_MAP_NODE_TYPES.INDEX; - - 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, - }, - }); + data = this.findJob(sourceJobId); + + nextLinkId = data?.source?.index[0]; + nextType = JOB_MAP_NODE_TYPES.INDEX; + previousNodeId = `${data?.id ?? sourceJobId}-${JOB_MAP_NODE_TYPES.ANALYTICS}`; + // If data is undefined - job wasn't found. Create missing job node. + resultElements.push( + data === undefined + ? this.getMissingJobNode(sourceJobId) + : { + 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, + }, + }); + if (data) { details[previousNodeId] = data; - } catch (error) { - // fail silently if job doesn't exist - if (error.statusCode !== 404) { - throw error.body ?? error; - } } } @@ -295,21 +301,25 @@ export class AnalyticsManager { const nextLinkId = data?.source?.index[0]; const nextType: JobMapNodeTypes = JOB_MAP_NODE_TYPES.INDEX; + const previousNodeId = `${data?.id ?? jobId}-${JOB_MAP_NODE_TYPES.ANALYTICS}`; - const 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), - isRoot: true, - }, - }); - - details[previousNodeId] = data; + resultElements.push( + data === undefined + ? this.getMissingJobNode(jobId) + : { + data: { + id: previousNodeId, + label: data.id, + type: JOB_MAP_NODE_TYPES.ANALYTICS, + analysisType: getAnalysisType(data?.analysis), + isRoot: true, + }, + } + ); + if (data) { + details[previousNodeId] = data; + } const { modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements( jobId, jobCreateTime @@ -429,33 +439,40 @@ export class AnalyticsManager { nextType = JOB_MAP_NODE_TYPES.TRANSFORM; } } else if (isJobDataLinkReturnType(link) && link.isJob === true) { + // Create missing job node here if job is undefined data = link.jobData; - const nodeId = `${data.id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`; + const nodeId = `${data?.id ?? nextLinkId}-${JOB_MAP_NODE_TYPES.ANALYTICS}`; previousNodeId = nodeId; - result.elements.unshift({ - data: { - id: nodeId, - label: data.id, - type: JOB_MAP_NODE_TYPES.ANALYTICS, - analysisType: getAnalysisType(data?.analysis), - }, - }); + result.elements.unshift( + data === undefined + ? this.getMissingJobNode(nextLinkId) + : { + data: { + id: nodeId, + label: data.id, + type: JOB_MAP_NODE_TYPES.ANALYTICS, + analysisType: getAnalysisType(data?.analysis), + }, + } + ); result.details[nodeId] = data; nextLinkId = data?.source?.index[0]; nextType = JOB_MAP_NODE_TYPES.INDEX; - // Get trained model for analytics job and create model node - ({ modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements( - data.id, - data.create_time - )); - if (isAnalyticsMapNodeElement(modelElement)) { - modelElements.push(modelElement); - result.details[modelElement.data.id] = modelDetails; - } - if (isAnalyticsMapEdgeElement(edgeElement)) { - modelElements.push(edgeElement); + if (data) { + // Get trained model for analytics job and create model node + ({ modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements( + data.id, + data.create_time + )); + if (isAnalyticsMapNodeElement(modelElement)) { + modelElements.push(modelElement); + result.details[modelElement.data.id] = modelDetails; + } + if (isAnalyticsMapEdgeElement(edgeElement)) { + modelElements.push(edgeElement); + } } } else if (isTransformLinkReturnType(link) && link.isTransform === true) { data = link.transformData; @@ -626,7 +643,7 @@ export class AnalyticsManager { if (analyticsId !== undefined) { const jobData = this.findJob(analyticsId); - const currentJobNodeId = `${jobData.id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`; + const currentJobNodeId = `${jobData?.id ?? analyticsId}-${JOB_MAP_NODE_TYPES.ANALYTICS}`; rootIndex = Array.isArray(jobData?.dest?.index) ? jobData?.dest?.index[0] : jobData?.dest?.index; @@ -635,7 +652,7 @@ export class AnalyticsManager { // Fetch trained model for incoming job id and add node and edge const { modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements( analyticsId, - jobData.create_time! + jobData?.create_time ); if (isAnalyticsMapNodeElement(modelElement)) { result.elements.push(modelElement); diff --git a/x-pack/test/api_integration/apis/ml/data_frame_analytics/get.ts b/x-pack/test/api_integration/apis/ml/data_frame_analytics/get.ts index 464014f99648e..2459f81b188b7 100644 --- a/x-pack/test/api_integration/apis/ml/data_frame_analytics/get.ts +++ b/x-pack/test/api_integration/apis/ml/data_frame_analytics/get.ts @@ -273,9 +273,7 @@ export default ({ getService }: FtrProviderContext) => { expect(body.elements.length).to.eql(0); expect(body.details).to.eql({}); - expect(body.error).to.eql(`No known job with id '${jobId}_fake'`); - - expect(body).to.have.keys('elements', 'details', 'error'); + expect(body).to.have.keys('elements', 'details'); }); }); diff --git a/x-pack/test/api_integration/apis/ml/data_frame_analytics/get_spaces.ts b/x-pack/test/api_integration/apis/ml/data_frame_analytics/get_spaces.ts index e294ddb51d7ea..4b981b14a381c 100644 --- a/x-pack/test/api_integration/apis/ml/data_frame_analytics/get_spaces.ts +++ b/x-pack/test/api_integration/apis/ml/data_frame_analytics/get_spaces.ts @@ -207,7 +207,6 @@ export default ({ getService }: FtrProviderContext) => { `Expected 0 map elements, got ${body.elements.length}` ); expect(body.details).to.eql({}); - expect(body.error).to.eql(`No known job with id '${jobIdSpace1}'`); }); }); });