Skip to content

Commit

Permalink
[ML] Trained models: adds a missing job node to models map view when …
Browse files Browse the repository at this point in the history
…original job has been deleted (#171590)

## Summary

Fixes #164626

Instead of throwing an error when a model's source job has been deleted
- return a 'missing job' node.


<img width="1448" alt="image"
src="https://github.com/elastic/kibana/assets/6446462/0eb542fd-4297-4f70-a1d0-e038c565f1d4">



### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
alvarezmelissa87 and kibanamachine authored Nov 29, 2023
1 parent cadabe5 commit f89f980
Show file tree
Hide file tree
Showing 9 changed files with 145 additions and 83 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,20 @@
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;
background-color: $euiColorWarning;
border: $euiBorderThin;
border-radius: $euiBorderRadius;
display: 'inline-block';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,17 @@ export const Controls: FC<Props> = 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);
};

Expand All @@ -211,7 +221,7 @@ export const Controls: FC<Props> = React.memo(
cy.removeListener('unselect', 'node', deselect);
}
};
}, [cy, deselect]);
}, [cy, deselect, toasts]);

useEffect(
function updateElementsOnClose() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>(false);

return (
Expand Down Expand Up @@ -122,6 +125,23 @@ export const JobMapLegend: FC<{ theme: EuiThemeType }> = ({ theme }) => {
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{hasMissingJobNode ? (
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<span className="mlJobMapLegend__analyticsMissing" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.legend.missingAnalyticsJobLabel"
defaultMessage="missing analytics job"
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
) : null}
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -150,14 +150,18 @@ export const JobMap: FC<Props> = ({ 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 (
<div data-test-subj="mlPageDataFrameAnalyticsMap">
<EuiSpacer size="m" />
<EuiFlexGroup direction="row" gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem>
<JobMapLegend theme={euiTheme} />
<JobMapLegend theme={euiTheme} hasMissingJobNode={hasMissingJobNode} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ export class AnalyticsManager {
this._jobs = jobs.data_frame_analytics;
}

private getMissingJobNode(label: string): AnalyticsMapNodeElement {
return {
data: {
id: `${label}-${JOB_MAP_NODE_TYPES.ANALYTICS}`,
label,
type: JOB_MAP_NODE_TYPES.ANALYTICS_JOB_MISSING,
},
};
}

private isDuplicateElement(analyticsId: string, elements: MapElements[]): boolean {
let isDuplicate = false;
elements.forEach((elem) => {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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}`;
Expand All @@ -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;
}
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

Expand Down
Loading

0 comments on commit f89f980

Please sign in to comment.