[ML] Data frame analytics: Adds map view (#81666)

* add analytics map endpoint and server model

* add map action to job and models list

* wip:fetch models for jobs. Use url generator

* get models when extending node. deduplicate elements

* add job type icons. disable map action if job not finished.

* move shared const to common dir

* persist map tab. handle indexPattern from visualizer

* use url generator in models list

* temporarily disable delete action in flyout

* update legend style. make map horizontal

* update dfa model to use spaces changes

* format creation time

* update from indexPattern to index.remove refresh button

* handle index patterns with wildcard
This commit is contained in:
Melissa Alvarez 2020-11-12 12:40:08 -05:00 committed by GitHub
parent 3151e7e5e4
commit 6519b83e48
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1657 additions and 27 deletions

View file

@ -10,3 +10,12 @@ export const ANALYSIS_CONFIG_TYPE = {
CLASSIFICATION: 'classification',
} as const;
export const DEFAULT_RESULTS_FIELD = 'ml';
export const JOB_MAP_NODE_TYPES = {
ANALYTICS: 'analytics',
TRANSFORM: 'transform',
INDEX: 'index',
INFERENCE_MODEL: 'inferenceModel',
} as const;
export type JobMapNodeTypes = typeof JOB_MAP_NODE_TYPES[keyof typeof JOB_MAP_NODE_TYPES];

View file

@ -12,6 +12,7 @@ export const ML_PAGES = {
SINGLE_METRIC_VIEWER: 'timeseriesexplorer',
DATA_FRAME_ANALYTICS_JOBS_MANAGE: 'data_frame_analytics',
DATA_FRAME_ANALYTICS_EXPLORATION: 'data_frame_analytics/exploration',
DATA_FRAME_ANALYTICS_MAP: 'data_frame_analytics/map',
/**
* Page: Data Visualizer
*/

View file

@ -159,7 +159,7 @@ export interface DataFrameAnalyticsQueryState {
}
export type DataFrameAnalyticsUrlState = MLPageState<
typeof ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE,
typeof ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE | typeof ML_PAGES.DATA_FRAME_ANALYTICS_MAP,
DataFrameAnalyticsQueryState | undefined
>;

View file

@ -10,7 +10,8 @@ import {
OutlierAnalysis,
RegressionAnalysis,
} from '../types/data_frame_analytics';
import { ANALYSIS_CONFIG_TYPE } from '../../common/constants/data_frame_analytics';
import { ANALYSIS_CONFIG_TYPE } from '../constants/data_frame_analytics';
import { DataFrameAnalysisConfigType } from '../types/data_frame_analytics';
export const isOutlierAnalysis = (arg: any): arg is OutlierAnalysis => {
if (typeof arg !== 'object' || arg === null) return false;
@ -80,3 +81,15 @@ export const getPredictedFieldName = (
}`;
return predictedField;
};
export const getAnalysisType = (
analysis: AnalysisConfig
): DataFrameAnalysisConfigType | 'unknown' => {
const keys = Object.keys(analysis || {});
if (keys.length === 1) {
return keys[0] as DataFrameAnalysisConfigType;
}
return 'unknown';
};

View file

@ -1,4 +1,5 @@
@import 'pages/analytics_exploration/components/regression_exploration/index';
@import 'pages/job_map/components/index';
@import 'pages/analytics_management/components/analytics_list/index';
@import 'pages/analytics_management/components/create_analytics_button/index';
@import 'pages/analytics_creation/components/index';

View file

@ -27,6 +27,8 @@ import {
getPredictedFieldName,
} from '../../../../common/util/analytics_utils';
import { ANALYSIS_CONFIG_TYPE } from '../../../../common/constants/data_frame_analytics';
export { getAnalysisType } from '../../../../common/util/analytics_utils';
export type IndexPattern = string;
export enum ANALYSIS_ADVANCED_FIELDS {
@ -159,18 +161,6 @@ interface LoadEvaluateResult {
error: string | null;
}
export const getAnalysisType = (
analysis: AnalysisConfig
): DataFrameAnalysisConfigType | 'unknown' => {
const keys = Object.keys(analysis);
if (keys.length === 1) {
return keys[0] as DataFrameAnalysisConfigType;
}
return 'unknown';
};
export const getTrainingPercent = (
analysis: AnalysisConfig
):

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { MapButton } from './map_button';
export { useMapAction } from './use_map_action';

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiToolTip } from '@elastic/eui';
import {
isRegressionAnalysis,
isOutlierAnalysis,
isClassificationAnalysis,
} from '../../../../common/analytics';
import { DataFrameAnalyticsListRow } from '../analytics_list/common';
export const mapActionButtonText = i18n.translate(
'xpack.ml.dataframe.analyticsList.mapActionName',
{
defaultMessage: 'Map',
}
);
interface MapButtonProps {
item: DataFrameAnalyticsListRow;
}
export const MapButton: FC<MapButtonProps> = ({ item }) => {
const disabled =
!isRegressionAnalysis(item.config.analysis) &&
!isOutlierAnalysis(item.config.analysis) &&
!isClassificationAnalysis(item.config.analysis);
if (disabled) {
const toolTipContent = i18n.translate(
'xpack.ml.dataframe.analyticsList.mapActionDisabledTooltipContent',
{
defaultMessage: 'Unknown analysis type.',
}
);
return (
<EuiToolTip position="top" content={toolTipContent}>
<>{mapActionButtonText}</>
</EuiToolTip>
);
}
return <>{mapActionButtonText}</>;
};

View file

@ -0,0 +1,44 @@
/*
* 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, { useCallback, useMemo } from 'react';
import { useMlUrlGenerator, useNavigateToPath } from '../../../../../contexts/kibana';
import { DataFrameAnalyticsListAction, DataFrameAnalyticsListRow } from '../analytics_list/common';
import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator';
import { getViewLinkStatus } from '../action_view/get_view_link_status';
import { mapActionButtonText, MapButton } from './map_button';
export type MapAction = ReturnType<typeof useMapAction>;
export const useMapAction = () => {
const mlUrlGenerator = useMlUrlGenerator();
const navigateToPath = useNavigateToPath();
const clickHandler = useCallback(async (item: DataFrameAnalyticsListRow) => {
const path = await mlUrlGenerator.createUrl({
page: ML_PAGES.DATA_FRAME_ANALYTICS_MAP,
pageState: { jobId: item.id },
});
await navigateToPath(path, false);
}, []);
const action: DataFrameAnalyticsListAction = useMemo(
() => ({
isPrimary: true,
name: (item: DataFrameAnalyticsListRow) => <MapButton item={item} />,
enabled: (item: DataFrameAnalyticsListRow) => !getViewLinkStatus(item).disabled,
description: mapActionButtonText,
icon: 'graphApp',
type: 'icon',
onClick: clickHandler,
'data-test-subj': 'mlAnalyticsJobMapButton',
}),
[clickHandler]
);
return { action };
};

View file

@ -16,6 +16,7 @@ import { isEditActionFlyoutVisible, useEditAction, EditActionFlyout } from '../a
import { useStartAction, StartActionModal } from '../action_start';
import { useStopAction, StopActionModal } from '../action_stop';
import { useViewAction } from '../action_view';
import { useMapAction } from '../action_map';
import { DataFrameAnalyticsListRow } from './common';
@ -30,6 +31,7 @@ export const useActions = (
const canStartStopDataFrameAnalytics: boolean = checkPermission('canStartStopDataFrameAnalytics');
const viewAction = useViewAction();
const mapAction = useMapAction();
const cloneAction = useCloneAction(canCreateDataFrameAnalytics);
const deleteAction = useDeleteAction(canDeleteDataFrameAnalytics);
const editAction = useEditAction(canStartStopDataFrameAnalytics);
@ -40,6 +42,7 @@ export const useActions = (
const actions: EuiTableActionsColumnType<DataFrameAnalyticsListRow>['actions'] = [
viewAction.action,
mapAction.action,
];
// isManagementTable will be the same for the lifecycle of the component

View file

@ -15,11 +15,14 @@ interface Tab {
path: string;
}
export const AnalyticsNavigationBar: FC<{ selectedTabId?: string }> = ({ selectedTabId }) => {
export const AnalyticsNavigationBar: FC<{ selectedTabId?: string; jobId?: string }> = ({
jobId,
selectedTabId,
}) => {
const navigateToPath = useNavigateToPath();
const tabs = useMemo(
() => [
const tabs = useMemo(() => {
const navTabs = [
{
id: 'data_frame_analytics',
name: i18n.translate('xpack.ml.dataframe.jobsTabLabel', {
@ -34,12 +37,21 @@ export const AnalyticsNavigationBar: FC<{ selectedTabId?: string }> = ({ selecte
}),
path: '/data_frame_analytics/models',
},
],
[]
);
];
if (jobId !== undefined) {
navTabs.push({
id: 'map',
name: i18n.translate('xpack.ml.dataframe.mapTabLabel', {
defaultMessage: 'Map',
}),
path: '/data_frame_analytics/map',
});
}
return navTabs;
}, [jobId !== undefined]);
const onTabClick = useCallback(async (tab: Tab) => {
await navigateToPath(tab.path);
await navigateToPath(tab.path, true);
}, []);
return (

View file

@ -28,8 +28,14 @@ import { StatsBar, ModelsBarStats } from '../../../../../components/stats_bar';
import { useTrainedModelsApiService } from '../../../../../services/ml_api_service/trained_models';
import { ModelsTableToConfigMapping } from './index';
import { DeleteModelsModal } from './delete_models_modal';
import { useMlKibana, useMlUrlGenerator, useNotifications } from '../../../../../contexts/kibana';
import {
useMlKibana,
useMlUrlGenerator,
useNavigateToPath,
useNotifications,
} from '../../../../../contexts/kibana';
import { ExpandedRow } from './expanded_row';
import {
TrainedModelConfigResponse,
ModelPipelines,
@ -80,6 +86,9 @@ export const ModelsList: FC = () => {
{}
);
const mlUrlGenerator = useMlUrlGenerator();
const navigateToPath = useNavigateToPath();
const updateFilteredItems = (queryClauses: any) => {
if (queryClauses.length) {
const filtered = filterAnalyticsModels(items, queryClauses);
@ -298,6 +307,26 @@ export const ModelsList: FC = () => {
},
isPrimary: true,
},
{
name: i18n.translate('xpack.ml.inference.modelsList.analyticsMapActionLabel', {
defaultMessage: 'Analytics map',
}),
description: i18n.translate('xpack.ml.inference.modelsList.analyticsMapActionLabel', {
defaultMessage: 'Analytics map',
}),
icon: 'graphApp',
type: 'icon',
isPrimary: true,
available: (item) => item.metadata?.analytics_config?.id,
onClick: async (item) => {
const path = await mlUrlGenerator.createUrl({
page: ML_PAGES.DATA_FRAME_ANALYTICS_MAP,
pageState: { jobId: item.metadata?.analytics_config.id },
});
await navigateToPath(path, false);
},
},
{
name: i18n.translate('xpack.ml.trainedModels.modelsList.deleteModelActionLabel', {
defaultMessage: 'Delete model',

View file

@ -22,6 +22,7 @@ import {
} from '@elastic/eui';
import { useLocation } from 'react-router-dom';
import { useUrlState } from '../../../util/url_state';
import { NavigationMenu } from '../../../components/navigation_menu';
import { DatePickerWrapper } from '../../../components/navigation_menu/date_picker_wrapper';
import { DataFrameAnalyticsList } from './components/analytics_list';
@ -31,14 +32,17 @@ import { NodeAvailableWarning } from '../../../components/node_available_warning
import { UpgradeWarning } from '../../../components/upgrade';
import { AnalyticsNavigationBar } from './components/analytics_navigation_bar';
import { ModelsList } from './components/models_management';
import { JobMap } from '../job_map';
export const Page: FC = () => {
const [blockRefresh, setBlockRefresh] = useState(false);
const [globalState] = useUrlState('_g');
useRefreshInterval(setBlockRefresh);
const location = useLocation();
const selectedTabId = useMemo(() => location.pathname.split('/').pop(), [location]);
const mapJobId = globalState?.ml?.jobId;
return (
<Fragment>
@ -73,9 +77,11 @@ export const Page: FC = () => {
</EuiPageHeaderSection>
<EuiPageHeaderSection>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<RefreshAnalyticsListButton />
</EuiFlexItem>
{selectedTabId !== 'map' && (
<EuiFlexItem grow={false}>
<RefreshAnalyticsListButton />
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<DatePickerWrapper />
</EuiFlexItem>
@ -87,8 +93,8 @@ export const Page: FC = () => {
<UpgradeWarning />
<EuiPageContent>
<AnalyticsNavigationBar selectedTabId={selectedTabId} />
<AnalyticsNavigationBar selectedTabId={selectedTabId} jobId={mapJobId} />
{selectedTabId === 'map' && mapJobId && <JobMap analyticsId={mapJobId} />}
{selectedTabId === 'data_frame_analytics' && (
<DataFrameAnalyticsList blockRefresh={blockRefresh} />
)}

View file

@ -0,0 +1,38 @@
.mlJobMapLegend__container {
background-color: '#FFFFFF';
}
.mlJobMapLegend__indexPattern {
height: $euiSizeM;
width: $euiSizeM;
background-color: '#FFFFFF';
border: 1px solid $euiColorVis2;
transform: rotate(45deg);
display: 'inline-block';
}
.mlJobMapLegend__transform {
height: $euiSizeM;
width: $euiSizeM;
background-color: '#FFFFFF';
border: 1px solid $euiColorVis1;
display: 'inline-block';
}
.mlJobMapLegend__analytics {
height: $euiSizeM;
width: $euiSizeM;
background-color: '#FFFFFF';
border: 1px solid $euiColorVis0;
border-radius: 50%;
display: 'inline-block';
}
.mlJobMapLegend__inferenceModel {
height: $euiSizeM;
width: $euiSizeM;
background-color: '#FFFFFF';
border: 1px solid $euiColorMediumShade;
border-radius: 50%;
display: 'inline-block';
}

View file

@ -0,0 +1,165 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useEffect, useState, useContext, useCallback } from 'react';
import cytoscape from 'cytoscape';
import { FormattedMessage } from '@kbn/i18n/react';
import moment from 'moment-timezone';
import {
EuiButtonEmpty,
EuiCodeBlock,
EuiDescriptionList,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiPortal,
EuiTitle,
} from '@elastic/eui';
import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list';
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';
interface Props {
analyticsId: string;
details: any;
getNodeData: any;
}
function getListItems(details: object): EuiDescriptionListProps['listItems'] {
return Object.entries(details).map(([key, value]) => {
let description;
if (key === 'create_time') {
description = formatHumanReadableDateTimeSeconds(moment(value).unix() * 1000);
} else {
description =
typeof value === 'object' ? (
<EuiCodeBlock language="json" fontSize="s" paddingSize="s">
{JSON.stringify(value, null, 2)}
</EuiCodeBlock>
) : (
value
);
}
return {
title: key,
description,
};
});
}
export const Controls: FC<Props> = ({ analyticsId, details, getNodeData }) => {
const [showFlyout, setShowFlyout] = useState<boolean>(false);
const [selectedNode, setSelectedNode] = useState<cytoscape.NodeSingular | undefined>();
const cy = useContext(CytoscapeContext);
const deselect = useCallback(() => {
if (cy) {
cy.elements().unselect();
}
setShowFlyout(false);
setSelectedNode(undefined);
}, [cy, setSelectedNode]);
const nodeId = selectedNode?.data('id');
const nodeLabel = selectedNode?.data('label');
const nodeType = selectedNode?.data('type');
// Set up Cytoscape event handlers
useEffect(() => {
const selectHandler: cytoscape.EventHandler = (event) => {
setSelectedNode(event.target);
setShowFlyout(true);
};
if (cy) {
cy.on('select', 'node', selectHandler);
cy.on('unselect', 'node', deselect);
}
return () => {
if (cy) {
cy.removeListener('select', 'node', selectHandler);
cy.removeListener('unselect', 'node', deselect);
}
};
}, [cy, deselect]);
if (showFlyout === false) {
return null;
}
const nodeDataButton =
analyticsId !== nodeLabel && nodeType === JOB_MAP_NODE_TYPES.ANALYTICS ? (
<EuiButtonEmpty
onClick={() => {
getNodeData(nodeLabel);
setShowFlyout(false);
}}
iconType="branch"
>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.fetchRelatedNodesButton"
defaultMessage="Fetch related nodes"
/>
</EuiButtonEmpty>
) : null;
return (
<EuiPortal>
<EuiFlyout
ownFocus
size="m"
onClose={() => setShowFlyout(false)}
data-test-subj="mlAnalyticsJobMapFlyout"
>
<EuiFlyoutHeader>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiTitle size="s">
<h3 data-test-subj="mlDataFrameAnalyticsNodeDetailsTitle">
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyoutHeaderTitle"
defaultMessage="Details for {type} {id}"
values={{ id: nodeLabel, type: nodeType }}
/>
</h3>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiDescriptionList
compressed
type="column"
listItems={
nodeType === 'index-pattern'
? getListItems(details[nodeId][nodeLabel])
: getListItems(details[nodeId])
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>{nodeDataButton}</EuiFlexItem>
{/* <EuiFlexItem grow={false}>
<DeleteButton id={nodeLabel} type={nodeType} />
</EuiFlexItem> */}
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</EuiPortal>
);
};

View file

@ -0,0 +1,113 @@
/*
* 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, {
CSSProperties,
useState,
useRef,
useEffect,
ReactNode,
createContext,
useCallback,
} from 'react';
import cytoscape from 'cytoscape';
// @ts-ignore no declaration file
import dagre from 'cytoscape-dagre';
import { cytoscapeOptions } from './cytoscape_options';
cytoscape.use(dagre);
export const CytoscapeContext = createContext<cytoscape.Core | undefined>(undefined);
interface CytoscapeProps {
children?: ReactNode;
elements: cytoscape.ElementDefinition[];
height: number;
style?: CSSProperties;
width: number;
}
function useCytoscape(options: cytoscape.CytoscapeOptions) {
const [cy, setCy] = useState<cytoscape.Core | undefined>();
const ref = useRef(null);
useEffect(() => {
if (!cy) {
setCy(cytoscape({ ...options, container: ref.current }));
}
}, [options, cy]);
// Destroy the cytoscape instance on unmount
useEffect(() => {
return () => {
if (cy) {
cy.destroy();
}
};
}, [cy]);
return [ref, cy] as [React.MutableRefObject<any>, cytoscape.Core | undefined];
}
function getLayoutOptions(width: number, height: number) {
return {
name: 'dagre',
rankDir: 'LR',
fit: true,
padding: 30,
spacingFactor: 0.85,
boundingBox: { x1: 0, y1: 0, w: width, h: height },
};
}
export function Cytoscape({ children, elements, height, style, width }: CytoscapeProps) {
const [ref, cy] = useCytoscape({
...cytoscapeOptions,
elements,
});
// Add the height to the div style. The height is a separate prop because it
// is required and can trigger rendering when changed.
const divStyle = { ...style, height };
const dataHandler = useCallback<cytoscape.EventHandler>(
(event) => {
if (cy && height > 0) {
cy.layout(getLayoutOptions(width, height)).run();
}
},
[cy, height, width]
);
// Set up cytoscape event handlers
useEffect(() => {
if (cy) {
cy.on('data', dataHandler);
}
return () => {
if (cy) {
cy.removeListener('data', undefined, dataHandler as cytoscape.EventHandler);
}
};
}, [cy, elements, height, width]);
// Trigger a custom "data" event when data changes
useEffect(() => {
if (cy) {
cy.add(elements);
cy.trigger('data');
}
}, [cy, elements]);
return (
<CytoscapeContext.Provider value={cy}>
<div ref={ref} style={divStyle}>
{children}
</div>
</CytoscapeContext.Provider>
);
}

View file

@ -0,0 +1,125 @@
/*
* 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 cytoscape from 'cytoscape';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import {
ANALYSIS_CONFIG_TYPE,
JOB_MAP_NODE_TYPES,
} from '../../../../../../common/constants/data_frame_analytics';
import classificationJobIcon from './icons/ml_classification_job.svg';
import outlierDetectionJobIcon from './icons/ml_outlier_detection_job.svg';
import regressionJobIcon from './icons/ml_regression_job.svg';
const lineColor = '#C5CCD7';
const MAP_SHAPES = {
ELLIPSE: 'ellipse',
RECTANGLE: 'rectangle',
DIAMOND: 'diamond',
} as const;
type MapShapes = typeof MAP_SHAPES[keyof typeof MAP_SHAPES];
function shapeForNode(el: cytoscape.NodeSingular): MapShapes {
const type = el.data('type');
switch (type) {
case JOB_MAP_NODE_TYPES.ANALYTICS:
return MAP_SHAPES.ELLIPSE;
case JOB_MAP_NODE_TYPES.TRANSFORM:
return MAP_SHAPES.RECTANGLE;
case JOB_MAP_NODE_TYPES.INDEX:
return MAP_SHAPES.DIAMOND;
default:
return MAP_SHAPES.ELLIPSE;
}
}
function iconForNode(el: cytoscape.NodeSingular) {
const type = el.data('analysisType');
switch (type) {
case ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION:
return outlierDetectionJobIcon;
case ANALYSIS_CONFIG_TYPE.CLASSIFICATION:
return classificationJobIcon;
case ANALYSIS_CONFIG_TYPE.REGRESSION:
return regressionJobIcon;
default:
return undefined;
}
}
function borderColorForNode(el: cytoscape.NodeSingular) {
if (el.selected()) {
return theme.euiColorPrimary;
}
const type = el.data('type');
switch (type) {
case JOB_MAP_NODE_TYPES.ANALYTICS:
return theme.euiColorSecondary;
case JOB_MAP_NODE_TYPES.TRANSFORM:
return theme.euiColorVis1;
case JOB_MAP_NODE_TYPES.INDEX:
return theme.euiColorVis2;
default:
return theme.euiColorMediumShade;
}
}
export const cytoscapeOptions: cytoscape.CytoscapeOptions = {
autoungrabify: true,
boxSelectionEnabled: false,
maxZoom: 3,
minZoom: 0.2,
style: [
{
selector: 'node',
style: {
'background-color': theme.euiColorGhost,
'background-height': '60%',
'background-width': '60%',
'border-color': (el: cytoscape.NodeSingular) => borderColorForNode(el),
'border-style': 'solid',
// @ts-ignore
'background-image': (el: cytoscape.NodeSingular) => iconForNode(el),
'border-width': (el: cytoscape.NodeSingular) => (el.selected() ? 2 : 1),
// @ts-ignore
color: theme.textColors.default,
'font-family': 'Inter UI, Segoe UI, Helvetica, Arial, sans-serif',
'font-size': theme.euiFontSizeXS,
'min-zoomed-font-size': parseInt(theme.euiSizeL, 10),
label: 'data(label)',
shape: (el: cytoscape.NodeSingular) => shapeForNode(el),
'text-background-color': theme.euiColorLightestShade,
'text-background-opacity': 0,
'text-background-padding': theme.paddingSizes.xs,
'text-background-shape': 'roundrectangle',
'text-margin-y': parseInt(theme.paddingSizes.s, 10),
'text-max-width': '200px',
'text-valign': 'bottom',
'text-wrap': 'wrap',
},
},
{
selector: 'edge',
style: {
'curve-style': 'taxi',
// @ts-ignore
'taxi-direction': 'rightward',
'line-color': lineColor,
'overlay-opacity': 0,
'target-arrow-color': lineColor,
'target-arrow-shape': 'triangle',
// @ts-ignore
'target-distance-from-node': theme.paddingSizes.xs,
width: 1,
'source-arrow-shape': 'none',
},
},
],
};

View file

@ -0,0 +1,58 @@
/*
* 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

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="#343741">
<path d="M6.99985 16v5.0002H9.0379C9.0128 21.3301 9 21.6636 9 22c0 .3364.0128.6699.0379.9998H6.99985V28h-1.9997v-5.0002H0v-1.9996h5.00015V16h1.9997zm0-16v5.00015H12v1.9997H6.99985V12h-1.9997V6.99985H0v-1.9997h5.00015V0h1.9997zM22.9998 0v5.00015H28v1.9997h-5.0002V9.0379C22.6699 9.0128 22.3364 9 22 9c-.3364 0-.6699.0128-.9998.0379V6.99985H16v-1.9997h5.0002V0h1.9996z" />
<path
fill="#017D73"
d="M22 10c3.0734 0 5.877 1.1554 8 3.0556v3.2525c-.315-.4427-.6682-.8613-1.0575-1.2506-1.8413-1.8413-4.3386-2.8757-6.9425-2.8757-5.4224 0-9.8182 4.3958-9.8182 9.8182 0 3.3001 1.6282 6.2199 4.1251 8h-3.2513C11.1554 27.877 10 25.0734 10 22c0-6.6274 5.3726-12 12-12zm.9998 8v3.0002H26v1.9996h-3.0002V26h-1.9996v-3.0002H18v-1.9996h3.0002V18h1.9996z"
/>
</svg>

After

Width:  |  Height:  |  Size: 868 B

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="#343741">
<path d="M2 12v6h6v2H0v-8h2zm18 0v8h-8v-2h6v-6h2zM8 0v2H2v6H0V0h8zm12 0v8h-2V2h-6V0h8z" />
<path
fill="#017D73"
d="M16 24c2.2091 0 4 1.7909 4 4 0 2.2091-1.7909 4-4 4-2.2091 0-4-1.7909-4-4 0-2.2091 1.7909-4 4-4zm12 0c2.2091 0 4 1.7909 4 4 0 2.2091-1.7909 4-4 4-2.2091 0-4-1.7909-4-4 0-2.2091 1.7909-4 4-4zm-12 1.75c-1.2426 0-2.25 1.0074-2.25 2.25s1.0074 2.25 2.25 2.25 2.25-1.0074 2.25-2.25-1.0074-2.25-2.25-2.25zm12 0c-1.2426 0-2.25 1.0074-2.25 2.25s1.0074 2.25 2.25 2.25 2.25-1.0074 2.25-2.25-1.0074-2.25-2.25-2.25zM28 12c2.2091 0 4 1.7909 4 4 0 2.2091-1.7909 4-4 4-2.2091 0-4-1.7909-4-4 0-2.2091 1.7909-4 4-4zm0 1.75c-1.2426 0-2.25 1.0074-2.25 2.25s1.0074 2.25 2.25 2.25 2.25-1.0074 2.25-2.25-1.0074-2.25-2.25-2.25zM10 6c2.2091 0 4 1.79086 4 4 0 2.2091-1.7909 4-4 4-2.20914 0-4-1.7909-4-4 0-2.20914 1.79086-4 4-4zm0 1.75c-1.24264 0-2.25 1.00736-2.25 2.25 0 1.2426 1.00736 2.25 2.25 2.25 1.2426 0 2.25-1.0074 2.25-2.25 0-1.24264-1.0074-2.25-2.25-2.25z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="#343741">
<path d="M24 0c4.4183 0 8 3.58172 8 8 0 2.1217-.8429 4.1566-2.3431 5.6569C28.1566 15.1571 26.1217 16 24 16c-1.8487 0-3.5509-.6271-4.9056-1.6801l-4.7745 4.7745C15.3729 20.4491 16 22.1513 16 24c0 2.1217-.8429 4.1566-2.3431 5.6569C12.1566 31.1571 10.1217 32 8 32c-4.41828 0-8-3.5817-8-8s3.58172-8 8-8c1.84872 0 3.551.6271 4.9057 1.6802l4.7745-4.7745C16.6271 11.551 16 9.84872 16 8c0-4.41828 3.5817-8 8-8zM8 18c-3.31371 0-6 2.6863-6 6s2.68629 6 6 6c3.3137 0 6-2.6863 6-6 0-1.5913-.6321-3.1174-1.7574-4.2426C11.1174 18.6321 9.5913 18 8 18zM24 2c-3.3137 0-6 2.68629-6 6 0 3.3137 2.6863 6 6 6s6-2.6863 6-6c0-1.5913-.6321-3.11742-1.7574-4.24264C27.1174 2.63214 25.5913 2 24 2z"/>
<path fill="#017D73" d="M32 20v12H20V20h12zm-2 2h-8v8h8v-8zM12 0v12H0V0h12zm-2 2H2v8h8V2z"/>
</svg>

After

Width:  |  Height:  |  Size: 876 B

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { Cytoscape, CytoscapeContext } from './cytoscape';
export { Controls } from './controls';
export { JobMapLegend } from './legend';

View file

@ -0,0 +1,62 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { JOB_MAP_NODE_TYPES } from '../../../../../../common/constants/data_frame_analytics';
export const JobMapLegend: FC = () => (
<EuiFlexGroup className="mlJobMapLegend__container" alignItems="center">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<span className="mlJobMapLegend__indexPattern" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
{JOB_MAP_NODE_TYPES.INDEX}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<span className="mlJobMapLegend__transform" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
{JOB_MAP_NODE_TYPES.TRANSFORM}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<span className="mlJobMapLegend__analytics" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
{JOB_MAP_NODE_TYPES.ANALYTICS}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<span className="mlJobMapLegend__inferenceModel" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
{'inference model'}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useRef } from 'react';
import useWindowSize from 'react-use/lib/useWindowSize';
export function useRefDimensions() {
const ref = useRef<HTMLDivElement>(null);
const windowHeight = useWindowSize().height;
if (!ref.current) {
return { ref, width: 0, height: 0 };
}
const { top, width } = ref.current.getBoundingClientRect();
const height = windowHeight - top;
return { ref, width, height };
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { JobMap } from './job_map';

View file

@ -0,0 +1,134 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useEffect, 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 { Cytoscape, Controls, JobMapLegend } from './components';
import { ml } from '../../../services/ml_api_service';
import { useMlKibana } from '../../../contexts/kibana';
import { useRefDimensions } from './components/use_ref_dimensions';
const cytoscapeDivStyle = {
background: `linear-gradient(
90deg,
${theme.euiPageBackgroundColor}
calc(${theme.euiSizeL} - calc(${theme.euiSizeXS} / 2)),
transparent 1%
)
center,
linear-gradient(
${theme.euiPageBackgroundColor}
calc(${theme.euiSizeL} - calc(${theme.euiSizeXS} / 2)),
transparent 1%
)
center,
${theme.euiColorLightShade}`,
backgroundSize: `${theme.euiSizeL} ${theme.euiSizeL}`,
margin: `-${theme.gutterTypes.gutterLarge}`,
marginTop: 0,
};
export const JobMapTitle: React.FC<{ analyticsId: string }> = ({ analyticsId }) => (
<EuiTitle size="xs">
<span>
{i18n.translate('xpack.ml.dataframe.analyticsMap.analyticsIdTitle', {
defaultMessage: 'Map for analytics ID {analyticsId}',
values: { analyticsId },
})}
</span>
</EuiTitle>
);
interface Props {
analyticsId: string;
}
export const JobMap: FC<Props> = ({ analyticsId }) => {
const [elements, setElements] = useState<cytoscape.ElementDefinition[]>([]);
const [nodeDetails, setNodeDetails] = useState({});
const [error, setError] = useState(undefined);
const {
services: { notifications },
} = useMlKibana();
const getData = async (id?: string) => {
const treatAsRoot = id !== undefined;
const idToUse = treatAsRoot ? id : analyticsId;
// Pass in treatAsRoot flag - endpoint will take job destIndex to grab jobs created from it
// TODO: update analyticsMap return type here
const analyticsMap: any = await ml.dataFrameAnalytics.getDataFrameAnalyticsMap(
idToUse,
treatAsRoot
);
const { elements: nodeElements, details, error: fetchError } = analyticsMap;
if (fetchError !== null) {
setError(fetchError);
}
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 (id === undefined) {
setElements(nodeElements);
setNodeDetails(details);
} else {
const uniqueElements = uniqWith([...nodeElements, ...elements], isEqual);
setElements(uniqueElements);
setNodeDetails({ ...details, ...nodeDetails });
}
}
};
useEffect(() => {
getData();
}, [analyticsId]);
if (error !== undefined) {
notifications.toasts.addDanger(
i18n.translate('xpack.ml.dataframe.analyticsMap.fetchDataErrorMessage', {
defaultMessage: 'Unable to fetch some data. An error occurred: {error}',
values: { error: JSON.stringify(error) },
})
);
setError(undefined);
}
const { ref, width, height } = useRefDimensions();
return (
<>
<EuiSpacer size="m" />
<div style={{ height: height - parseInt(theme.gutterTypes.gutterLarge, 10) }} ref={ref}>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<JobMapTitle analyticsId={analyticsId} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<JobMapLegend />
</EuiFlexItem>
</EuiFlexGroup>
<Cytoscape height={height} elements={elements} width={width} style={cytoscapeDivStyle}>
<Controls details={nodeDetails} getNodeData={getData} analyticsId={analyticsId} />
</Cytoscape>
</div>
</>
);
};

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import { i18n } from '@kbn/i18n';
import { NavigateToPath } from '../../../contexts/kibana';
import { MlRoute, PageLoader, PageProps } from '../../router';
import { useResolver } from '../../use_resolver';
import { basicResolvers } from '../../resolvers';
import { Page } from '../../../data_frame_analytics/pages/analytics_management';
import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs';
export const analyticsMapRouteFactory = (
navigateToPath: NavigateToPath,
basePath: string
): MlRoute => ({
path: '/data_frame_analytics/map',
render: (props, deps) => <PageWrapper {...props} deps={deps} />,
breadcrumbs: [
getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath),
getBreadcrumbWithUrlForApp('DATA_FRAME_ANALYTICS_BREADCRUMB', navigateToPath, basePath),
{
text: i18n.translate('xpack.ml.dataFrameAnalyticsBreadcrumbs.analyticsMapLabel', {
defaultMessage: 'Analytics Map',
}),
href: '',
},
],
});
const PageWrapper: FC<PageProps> = ({ deps }) => {
const { context } = useResolver('', undefined, deps.config, basicResolvers(deps));
return (
<PageLoader context={context}>
<Page />
</PageLoader>
);
};

View file

@ -8,3 +8,4 @@ export * from './analytics_jobs_list';
export * from './analytics_job_exploration';
export * from './analytics_job_creation';
export * from './models_list';
export * from './analytics_map';

View file

@ -83,6 +83,14 @@ export const dataFrameAnalytics = {
body,
});
},
getDataFrameAnalyticsMap(analyticsId?: string, treatAsRoot?: boolean) {
const analyticsIdString = analyticsId !== undefined ? `/${analyticsId}` : '';
return http({
path: `${basePath()}/data_frame/analytics/map${analyticsIdString}`,
method: 'GET',
query: { treatAsRoot },
});
},
evaluateDataFrameAnalytics(evaluateConfig: any) {
const body = JSON.stringify(evaluateConfig);
return http<any>({

View file

@ -82,3 +82,35 @@ export function createDataFrameAnalyticsExplorationUrl(
return url;
}
/**
* Creates URL to the DataFrameAnalytics Map page
*/
export function createDataFrameAnalyticsMapUrl(
appBasePath: string,
mlUrlGeneratorState: DataFrameAnalyticsExplorationUrlState['pageState']
): string {
let url = `${appBasePath}/${ML_PAGES.DATA_FRAME_ANALYTICS_MAP}`;
if (mlUrlGeneratorState) {
const { jobId, analysisType, defaultIsTraining, globalState } = mlUrlGeneratorState;
const queryState: DataFrameAnalyticsExplorationQueryState = {
ml: {
jobId,
analysisType,
defaultIsTraining,
},
...globalState,
};
url = setStateToKbnUrl<DataFrameAnalyticsExplorationQueryState>(
'_g',
queryState,
{ useHash: false, storeInHashQuery: false },
url
);
}
return url;
}

View file

@ -23,6 +23,7 @@ import {
import {
createDataFrameAnalyticsJobManagementUrl,
createDataFrameAnalyticsExplorationUrl,
createDataFrameAnalyticsMapUrl,
} from './data_frame_analytics_urls_generator';
import { createGenericMlUrl } from './common';
import { createEditCalendarUrl, createEditFilterUrl } from './settings_urls_generator';
@ -68,6 +69,10 @@ 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_MAP:
// @ts-ignore // TODO: fix type
return createDataFrameAnalyticsMapUrl(appBasePath, mlUrlGeneratorState.pageState);
case ML_PAGES.DATA_FRAME_ANALYTICS_EXPLORATION:
return createDataFrameAnalyticsExplorationUrl(appBasePath, mlUrlGeneratorState.pageState);
case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB:

View file

@ -0,0 +1,495 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from '@hapi/boom';
import { IScopedClusterClient } from 'kibana/server';
import {
JOB_MAP_NODE_TYPES,
JobMapNodeTypes,
} from '../../../common/constants/data_frame_analytics';
import { INDEX_META_DATA_CREATED_BY } from '../../../common/constants/file_datavisualizer';
import { getAnalysisType } from '../../../common/util/analytics_utils';
import {
AnalyticsMapEdgeElement,
AnalyticsMapReturnType,
AnalyticsMapNodeElement,
isAnalyticsMapEdgeElement,
isAnalyticsMapNodeElement,
isIndexPatternLinkReturnType,
isJobDataLinkReturnType,
isTransformLinkReturnType,
MapElements,
NextLinkReturnType,
} from './types';
import type { MlClient } from '../../lib/ml_client';
export class AnalyticsManager {
private _client: IScopedClusterClient['asInternalUser'];
private _mlClient: MlClient;
public _inferenceModels: any; // TODO: update types
constructor(mlClient: MlClient, client: IScopedClusterClient['asInternalUser']) {
this._client = client;
this._mlClient = mlClient;
this._inferenceModels = [];
}
public set inferenceModels(models: any) {
this._inferenceModels = models;
}
public get inferenceModels(): any {
return this._inferenceModels;
}
async setInferenceModels() {
try {
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);
}
}
private isDuplicateElement(analyticsId: string, elements: any[]): boolean {
let isDuplicate = false;
elements.forEach((elem: any) => {
if (elem.data.label === analyticsId && elem.data.type === JOB_MAP_NODE_TYPES.ANALYTICS) {
isDuplicate = true;
}
});
return isDuplicate;
}
// @ts-ignore // TODO: is this needed?
private async getAnalyticsModelData(modelId: string) {
const resp = await this._mlClient.getTrainedModels({
model_id: modelId,
});
const modelData = resp?.body?.trained_model_configs[0];
return modelData;
}
private async getAnalyticsModels() {
const resp = await this._mlClient.getTrainedModels();
const models = resp?.body?.trained_model_configs;
return models;
}
private async getAnalyticsJobData(analyticsId: string) {
const resp = await this._mlClient.getDataFrameAnalytics({
id: analyticsId,
});
const jobData = resp?.body?.data_frame_analytics[0];
return jobData;
}
private async getIndexData(index: string) {
const indexData = await this._client.indices.get({
index,
});
return indexData?.body;
}
private async getTransformData(transformId: string) {
const transform = await this._client.transform.getTransform({
transform_id: transformId,
});
const transformData = transform?.body?.transforms[0];
return transformData;
}
private findJobModel(analyticsId: string): any {
return this.inferenceModels.find(
(model: any) => model.metadata?.analytics_config?.id === analyticsId
);
}
private async getNextLink({
id,
type,
}: {
id: string;
type: JobMapNodeTypes;
}): Promise<NextLinkReturnType> {
try {
if (type === JOB_MAP_NODE_TYPES.INDEX) {
// fetch index data
const indexData = await this.getIndexData(id);
let isWildcardIndexPattern = false;
if (id.includes('*')) {
isWildcardIndexPattern = true;
}
const meta = indexData[id]?.mappings?._meta;
return { isWildcardIndexPattern, isIndexPattern: true, indexData, meta };
} else if (type.includes(JOB_MAP_NODE_TYPES.ANALYTICS)) {
// fetch job associated with this index
const jobData = await this.getAnalyticsJobData(id);
return { jobData, isJob: true };
} else if (type === JOB_MAP_NODE_TYPES.TRANSFORM) {
// fetch transform so we can get original index pattern
const transformData = await this.getTransformData(id);
return { transformData, isTransform: true };
}
} catch (error) {
throw Boom.badData(error.message ? error.message : error);
}
}
private getAnalyticsModelElements(
analyticsId: string
): {
modelElement?: AnalyticsMapNodeElement;
modelDetails?: any;
edgeElement?: AnalyticsMapEdgeElement;
} {
// Get inference model for analytics job and create model node
const analyticsModel = this.findJobModel(analyticsId);
let modelElement;
let edgeElement;
if (analyticsModel !== undefined) {
const modelId = `${analyticsModel.model_id}-${JOB_MAP_NODE_TYPES.INFERENCE_MODEL}`;
modelElement = {
data: {
id: modelId,
label: analyticsModel.model_id,
type: JOB_MAP_NODE_TYPES.INFERENCE_MODEL,
},
};
// Create edge for job and corresponding model
edgeElement = {
data: {
id: `${analyticsId}-${JOB_MAP_NODE_TYPES.ANALYTICS}~${modelId}`,
source: `${analyticsId}-${JOB_MAP_NODE_TYPES.ANALYTICS}`,
target: modelId,
},
};
}
return { modelElement, modelDetails: analyticsModel, edgeElement };
}
private getIndexPatternElements(indexData: Record<string, object>, previousNodeId: string) {
const result: any = { elements: [], details: {} };
Object.keys(indexData).forEach((indexId) => {
// Create index node
const nodeId = `${indexId}-${JOB_MAP_NODE_TYPES.INDEX}`;
result.elements.push({
data: { id: nodeId, label: indexId, type: JOB_MAP_NODE_TYPES.INDEX },
});
result.details[nodeId] = indexData[indexId];
// create edge node
result.elements.push({
data: {
id: `${previousNodeId}~${nodeId}`,
source: nodeId,
target: previousNodeId,
},
});
});
return result;
}
/**
* Works backward from jobId to return related jobs from source indices
* @param jobId
*/
async getAnalyticsMap(analyticsId: string): Promise<AnalyticsMapReturnType> {
const result: any = { elements: [], details: {}, error: null };
const modelElements: MapElements[] = [];
const indexPatternElements: MapElements[] = [];
try {
await this.setInferenceModels();
// Create first node for incoming analyticsId
let data = await this.getAnalyticsJobData(analyticsId);
let nextLinkId = data?.source?.index[0];
let nextType: JobMapNodeTypes = JOB_MAP_NODE_TYPES.INDEX;
let complete = false;
let link: NextLinkReturnType;
let count = 0;
let rootTransform;
let rootIndexPattern;
let previousNodeId = `${data.id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`;
result.elements.push({
data: {
id: previousNodeId,
label: data.id,
type: JOB_MAP_NODE_TYPES.ANALYTICS,
analysisType: getAnalysisType(data?.analysis),
},
});
result.details[previousNodeId] = data;
let { modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements(analyticsId);
if (isAnalyticsMapNodeElement(modelElement)) {
modelElements.push(modelElement);
result.details[modelElement.data.id] = modelDetails;
}
if (isAnalyticsMapEdgeElement(edgeElement)) {
modelElements.push(edgeElement);
}
// Add a safeguard against infinite loops.
while (complete === false) {
count++;
if (count >= 100) {
break;
}
try {
link = await this.getNextLink({
id: nextLinkId,
type: nextType,
});
} catch (error) {
result.error = error.message || 'Something went wrong';
break;
}
// If it's index pattern, check meta data to see what to fetch next
if (isIndexPatternLinkReturnType(link) && link.isIndexPattern === true) {
if (link.isWildcardIndexPattern === true) {
// Create index nodes for each of the indices included in the index pattern then break
const { details, elements } = this.getIndexPatternElements(
link.indexData,
previousNodeId
);
indexPatternElements.push(...elements);
result.details = { ...result.details, ...details };
complete = true;
} else {
const nodeId = `${nextLinkId}-${JOB_MAP_NODE_TYPES.INDEX}`;
result.elements.unshift({
data: { id: nodeId, label: nextLinkId, type: JOB_MAP_NODE_TYPES.INDEX },
});
result.details[nodeId] = link.indexData;
}
// Check meta data
if (
link.isWildcardIndexPattern === false &&
(link.meta === undefined || link.meta?.created_by === INDEX_META_DATA_CREATED_BY)
) {
rootIndexPattern = nextLinkId;
complete = true;
break;
}
if (link.meta?.created_by === 'data-frame-analytics') {
nextLinkId = link.meta.analytics;
nextType = JOB_MAP_NODE_TYPES.ANALYTICS;
}
if (link.meta?.created_by === JOB_MAP_NODE_TYPES.TRANSFORM) {
nextLinkId = link.meta._transform?.transform;
nextType = JOB_MAP_NODE_TYPES.TRANSFORM;
}
} else if (isJobDataLinkReturnType(link) && link.isJob === true) {
data = link.jobData;
const nodeId = `${data.id}-${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.details[nodeId] = data;
nextLinkId = data?.source?.index[0];
nextType = JOB_MAP_NODE_TYPES.INDEX;
// Get inference model for analytics job and create model node
({ modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements(data.id));
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;
const nodeId = `${data.id}-${JOB_MAP_NODE_TYPES.TRANSFORM}`;
previousNodeId = nodeId;
rootTransform = data.dest.index;
result.elements.unshift({
data: { id: nodeId, label: data.id, type: JOB_MAP_NODE_TYPES.TRANSFORM },
});
result.details[nodeId] = data;
nextLinkId = data?.source?.index[0];
nextType = JOB_MAP_NODE_TYPES.INDEX;
}
} // end while
// create edge elements
const elemLength = result.elements.length - 1;
for (let i = 0; i < elemLength; i++) {
const currentElem = result.elements[i];
const nextElem = result.elements[i + 1];
if (
currentElem !== undefined &&
nextElem !== undefined &&
currentElem?.data?.id.includes('*') === false &&
nextElem?.data?.id.includes('*') === false
) {
result.elements.push({
data: {
id: `${currentElem.data.id}~${nextElem.data.id}`,
source: currentElem.data.id,
target: nextElem.data.id,
},
});
}
}
// fetch all jobs associated with root transform if defined, otherwise check root index
if (rootTransform !== undefined || rootIndexPattern !== undefined) {
const analyticsJobs = await this._mlClient.getDataFrameAnalytics();
const jobs = analyticsJobs?.body?.data_frame_analytics || [];
const comparator = rootTransform !== undefined ? rootTransform : rootIndexPattern;
for (let i = 0; i < jobs.length; i++) {
if (
jobs[i]?.source?.index[0] === comparator &&
this.isDuplicateElement(jobs[i].id, result.elements) === false
) {
const nodeId = `${jobs[i].id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`;
result.elements.push({
data: {
id: nodeId,
label: jobs[i].id,
type: JOB_MAP_NODE_TYPES.ANALYTICS,
analysisType: getAnalysisType(jobs[i]?.analysis),
},
});
result.details[nodeId] = jobs[i];
const source = `${comparator}-${JOB_MAP_NODE_TYPES.INDEX}`;
result.elements.push({
data: {
id: `${source}~${nodeId}`,
source,
target: nodeId,
},
});
// Get inference model for analytics job and create model node
({ modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements(
jobs[i].id
));
if (isAnalyticsMapNodeElement(modelElement)) {
modelElements.push(modelElement);
result.details[modelElement.data.id] = modelDetails;
}
if (isAnalyticsMapEdgeElement(edgeElement)) {
modelElements.push(edgeElement);
}
}
}
}
// Include model and index pattern nodes in result elements now that all other nodes have been created
result.elements.push(...modelElements, ...indexPatternElements);
return result;
} catch (error) {
result.error = error.message || 'An error occurred fetching map';
return result;
}
}
async extendAnalyticsMapForAnalyticsJob(analyticsId: string): Promise<AnalyticsMapReturnType> {
const result: any = { elements: [], details: {}, error: null };
try {
await this.setInferenceModels();
const jobData = await this.getAnalyticsJobData(analyticsId);
const currentJobNodeId = `${jobData.id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`;
const destIndex = Array.isArray(jobData?.dest?.index)
? jobData?.dest?.index[0]
: jobData?.dest?.index;
const destIndexNodeId = `${destIndex}-${JOB_MAP_NODE_TYPES.INDEX}`;
const analyticsJobs = await this._mlClient.getDataFrameAnalytics();
const jobs = analyticsJobs?.body?.data_frame_analytics || [];
// Fetch inference model for incoming job id and add node and edge
const { modelElement, modelDetails, edgeElement } = this.getAnalyticsModelElements(
analyticsId
);
if (isAnalyticsMapNodeElement(modelElement)) {
result.elements.push(modelElement);
result.details[modelElement.data.id] = modelDetails;
}
if (isAnalyticsMapEdgeElement(edgeElement)) {
result.elements.push(edgeElement);
}
// If destIndex node has not been created, create it
const destIndexDetails = await this.getIndexData(destIndex);
result.elements.push({
data: {
id: destIndexNodeId,
label: destIndex,
type: JOB_MAP_NODE_TYPES.INDEX,
},
});
result.details[destIndexNodeId] = destIndexDetails;
// Connect incoming job to destIndex
result.elements.push({
data: {
id: `${currentJobNodeId}~${destIndexNodeId}`,
source: currentJobNodeId,
target: destIndexNodeId,
},
});
for (let i = 0; i < jobs.length; i++) {
if (
jobs[i]?.source?.index[0] === destIndex &&
this.isDuplicateElement(jobs[i].id, result.elements) === false
) {
// Create node for associated job
const nodeId = `${jobs[i].id}-${JOB_MAP_NODE_TYPES.ANALYTICS}`;
result.elements.push({
data: {
id: nodeId,
label: jobs[i].id,
type: JOB_MAP_NODE_TYPES.ANALYTICS,
analysisType: getAnalysisType(jobs[i]?.analysis),
},
});
result.details[nodeId] = jobs[i];
result.elements.push({
data: {
id: `${destIndexNodeId}~${nodeId}`,
source: destIndexNodeId,
target: nodeId,
},
});
}
}
} catch (error) {
result.error = error.message || 'An error occurred fetching map';
return result;
}
return result;
}
}

View file

@ -6,3 +6,4 @@
export { analyticsAuditMessagesProvider } from './analytics_audit_messages';
export { modelsProvider } from './models_provider';
export { AnalyticsManager } from './analytics_manager';

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface IndexPatternLinkReturnType {
isWildcardIndexPattern: boolean;
isIndexPattern: boolean;
indexData: any;
meta: any;
}
export interface JobDataLinkReturnType {
isJob: boolean;
jobData: any;
}
export interface TransformLinkReturnType {
isTransform: boolean;
transformData: any;
}
export type NextLinkReturnType =
| IndexPatternLinkReturnType
| JobDataLinkReturnType
| TransformLinkReturnType
| undefined;
export type MapElements = AnalyticsMapNodeElement | AnalyticsMapEdgeElement;
export interface AnalyticsMapReturnType {
elements: MapElements[];
details: object; // transform, job, or index details
error: null | any;
}
export interface AnalyticsMapNodeElement {
data: {
id: string;
label: string;
type: string;
analysisType?: string;
};
}
export interface AnalyticsMapEdgeElement {
data: {
id: string;
source: string;
target: string;
};
}
export const isAnalyticsMapNodeElement = (arg: any): arg is AnalyticsMapNodeElement => {
if (typeof arg !== 'object' || arg === null) return false;
const keys = Object.keys(arg);
return keys.length > 0 && keys.includes('data') && arg.data.label !== undefined;
};
export const isAnalyticsMapEdgeElement = (arg: any): arg is AnalyticsMapEdgeElement => {
if (typeof arg !== 'object' || arg === null) return false;
const keys = Object.keys(arg);
return keys.length > 0 && keys.includes('data') && arg.data.target !== undefined;
};
export const isIndexPatternLinkReturnType = (arg: any): arg is IndexPatternLinkReturnType => {
if (typeof arg !== 'object' || arg === null) return false;
const keys = Object.keys(arg);
return keys.length > 0 && keys.includes('isIndexPattern');
};
export const isJobDataLinkReturnType = (arg: any): arg is JobDataLinkReturnType => {
if (typeof arg !== 'object' || arg === null) return false;
const keys = Object.keys(arg);
return keys.length > 0 && keys.includes('isJob');
};
export const isTransformLinkReturnType = (arg: any): arg is TransformLinkReturnType => {
if (typeof arg !== 'object' || arg === null) return false;
const keys = Object.keys(arg);
return keys.length > 0 && keys.includes('isTransform');
};

View file

@ -14,14 +14,17 @@ import {
dataAnalyticsEvaluateSchema,
dataAnalyticsExplainSchema,
analyticsIdSchema,
analyticsMapQuerySchema,
stopsDataFrameAnalyticsJobQuerySchema,
deleteDataFrameAnalyticsJobSchema,
jobsExistSchema,
} from './schemas/data_analytics_schema';
import { IndexPatternHandler } from '../models/data_frame_analytics/index_patterns';
import { AnalyticsManager } from '../models/data_frame_analytics/analytics_manager';
import { DeleteDataFrameAnalyticsWithIndexStatus } from '../../common/types/data_frame_analytics';
import { getAuthorizationHeader } from '../lib/request_authorization';
import { DataFrameAnalyticsConfig } from '../../common/types/data_frame_analytics';
import type { MlClient } from '../lib/ml_client';
function getIndexPatternId(context: RequestHandlerContext, patternName: string) {
const iph = new IndexPatternHandler(context.core.savedObjects.client);
@ -33,6 +36,16 @@ function deleteDestIndexPatternById(context: RequestHandlerContext, indexPattern
return iph.deleteIndexPatternById(indexPatternId);
}
function getAnalyticsMap(mlClient: MlClient, client: IScopedClusterClient, analyticsId: string) {
const analytics = new AnalyticsManager(mlClient, client.asInternalUser);
return analytics.getAnalyticsMap(analyticsId);
}
function getExtendedMap(mlClient: MlClient, client: IScopedClusterClient, analyticsId: string) {
const analytics = new AnalyticsManager(mlClient, client.asInternalUser);
return analytics.extendAnalyticsMapForAnalyticsJob(analyticsId);
}
/**
* Routes for the data frame analytics
*/
@ -598,4 +611,39 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense, routeGuard }: Rout
}
})
);
/**
* @apiGroup DataFrameAnalytics
*
* @api {get} /api/ml/data_frame/analytics/map/:analyticsId Get objects leading up to analytics job
* @apiName GetDataFrameAnalyticsIdMap
* @apiDescription Returns map of objects leading up to analytics job.
*
* @apiParam {String} analyticsId Analytics ID.
*/
router.get(
{
path: '/api/ml/data_frame/analytics/map/{analyticsId}',
validate: {
params: analyticsIdSchema,
query: analyticsMapQuerySchema,
},
},
routeGuard.fullLicenseAPIGuard(async ({ mlClient, client, request, response }) => {
try {
const { analyticsId } = request.params;
const treatAsRoot = request.query?.treatAsRoot;
const caller =
treatAsRoot === 'true' || treatAsRoot === true ? getExtendedMap : getAnalyticsMap;
const results = await caller(mlClient, client, analyticsId);
return response.ok({
body: results,
});
} catch (e) {
return response.customError(wrapError(e));
}
})
);
}

View file

@ -38,6 +38,7 @@ export const dataAnalyticsEvaluateSchema = schema.object({
schema.object({
regression: schema.maybe(schema.any()),
classification: schema.maybe(schema.any()),
outlier_detection: schema.maybe(schema.any()),
})
),
});
@ -86,3 +87,7 @@ export const jobsExistSchema = schema.object({
analyticsIds: schema.arrayOf(schema.string()),
allSpaces: schema.maybe(schema.boolean()),
});
export const analyticsMapQuerySchema = schema.maybe(
schema.object({ treatAsRoot: schema.maybe(schema.any()) })
);