mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[ML] Trained models: adds a missing job node to models map view when original job has been deleted (#171590)
## Summary
Fixes https://github.com/elastic/kibana/issues/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="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>
This commit is contained in:
parent
cadabe5be9
commit
f89f980b15
9 changed files with 144 additions and 82 deletions
|
@ -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',
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -211,7 +221,7 @@ export const Controls: FC<Props> = React.memo(
|
|||
cy.removeListener('unselect', 'node', deselect);
|
||||
}
|
||||
};
|
||||
}, [cy, deselect]);
|
||||
}, [cy, deselect, toasts]);
|
||||
|
||||
useEffect(
|
||||
function updateElementsOnClose() {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 (
|
||||
|
@ -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}>
|
||||
|
|
|
@ -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<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 (
|
||||
|
@ -157,7 +161,7 @@ export const JobMap: FC<Props> = ({ defaultHeight, analyticsId, modelId, forceRe
|
|||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup direction="row" gutterSize="none" justifyContent="spaceBetween">
|
||||
<EuiFlexItem>
|
||||
<JobMapLegend theme={euiTheme} />
|
||||
<JobMapLegend theme={euiTheme} hasMissingJobNode={hasMissingJobNode} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
|
|
|
@ -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) => {
|
||||
|
@ -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);
|
||||
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,
|
||||
},
|
||||
});
|
||||
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);
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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}'`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue