diff --git a/x-pack/plugins/ml/common/constants/data_frame_analytics.ts b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts index 9a7af2496c03f..5c8000566bb38 100644 --- a/x-pack/plugins/ml/common/constants/data_frame_analytics.ts +++ b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts @@ -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]; diff --git a/x-pack/plugins/ml/common/constants/ml_url_generator.ts b/x-pack/plugins/ml/common/constants/ml_url_generator.ts index 541b8af6fc0fc..a79e72a84c08e 100644 --- a/x-pack/plugins/ml/common/constants/ml_url_generator.ts +++ b/x-pack/plugins/ml/common/constants/ml_url_generator.ts @@ -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 */ diff --git a/x-pack/plugins/ml/common/types/ml_url_generator.ts b/x-pack/plugins/ml/common/types/ml_url_generator.ts index aa38fb2ec6fbb..b188ac0a87571 100644 --- a/x-pack/plugins/ml/common/types/ml_url_generator.ts +++ b/x-pack/plugins/ml/common/types/ml_url_generator.ts @@ -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 >; diff --git a/x-pack/plugins/ml/common/util/analytics_utils.ts b/x-pack/plugins/ml/common/util/analytics_utils.ts index 94797efdfcfad..5da9d270a44a7 100644 --- a/x-pack/plugins/ml/common/util/analytics_utils.ts +++ b/x-pack/plugins/ml/common/util/analytics_utils.ts @@ -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'; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss index 231d0f6a0d8c5..a043a691c9ef6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss @@ -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'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 6e42e3e2f51fa..a99270a3e3be6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -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 ): diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_map/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_map/index.ts new file mode 100644 index 0000000000000..4004f019c732b --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_map/index.ts @@ -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'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_map/map_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_map/map_button.tsx new file mode 100644 index 0000000000000..28016b421aff3 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_map/map_button.tsx @@ -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 = ({ 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 ( + + <>{mapActionButtonText} + + ); + } + + return <>{mapActionButtonText}; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_map/use_map_action.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_map/use_map_action.tsx new file mode 100644 index 0000000000000..f77f71dcee4e7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_map/use_map_action.tsx @@ -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; +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) => , + enabled: (item: DataFrameAnalyticsListRow) => !getViewLinkStatus(item).disabled, + description: mapActionButtonText, + icon: 'graphApp', + type: 'icon', + onClick: clickHandler, + 'data-test-subj': 'mlAnalyticsJobMapButton', + }), + [clickHandler] + ); + + return { action }; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx index 20ae48a12ecf9..74b367cc7ab13 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx @@ -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['actions'] = [ viewAction.action, + mapAction.action, ]; // isManagementTable will be the same for the lifecycle of the component diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_navigation_bar/analytics_navigation_bar.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_navigation_bar/analytics_navigation_bar.tsx index bd59749517052..eaeae6cc64520 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_navigation_bar/analytics_navigation_bar.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_navigation_bar/analytics_navigation_bar.tsx @@ -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 ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx index 713cbbc814c32..a87f11df937d3 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx @@ -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', diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx index 7ffd477039e78..44085384f7536 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx @@ -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 ( @@ -73,9 +77,11 @@ export const Page: FC = () => { - - - + {selectedTabId !== 'map' && ( + + + + )} @@ -87,8 +93,8 @@ export const Page: FC = () => { - - + + {selectedTabId === 'map' && mapJobId && } {selectedTabId === 'data_frame_analytics' && ( )} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_index.scss new file mode 100644 index 0000000000000..2bcc91f34d382 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_index.scss @@ -0,0 +1 @@ +@import 'legend'; 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 new file mode 100644 index 0000000000000..d54b5214f7448 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_legend.scss @@ -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'; +} 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 new file mode 100644 index 0000000000000..ed25ea6cbf02c --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx @@ -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' ? ( + + {JSON.stringify(value, null, 2)} + + ) : ( + value + ); + } + + return { + title: key, + description, + }; + }); +} + +export const Controls: FC = ({ analyticsId, details, getNodeData }) => { + const [showFlyout, setShowFlyout] = useState(false); + const [selectedNode, setSelectedNode] = useState(); + + 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 ? ( + { + getNodeData(nodeLabel); + setShowFlyout(false); + }} + iconType="branch" + > + + + ) : null; + + return ( + + setShowFlyout(false)} + data-test-subj="mlAnalyticsJobMapFlyout" + > + + + + +

+ +

+
+
+
+
+ + + + + + + + + + {nodeDataButton} + {/* + + */} + + +
+
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/cytoscape.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/cytoscape.tsx new file mode 100644 index 0000000000000..a901e2be06dc0 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/cytoscape.tsx @@ -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(undefined); + +interface CytoscapeProps { + children?: ReactNode; + elements: cytoscape.ElementDefinition[]; + height: number; + style?: CSSProperties; + width: number; +} + +function useCytoscape(options: cytoscape.CytoscapeOptions) { + const [cy, setCy] = useState(); + 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, 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( + (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 ( + +
+ {children} +
+
+ ); +} 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 new file mode 100644 index 0000000000000..85d10aa897415 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/cytoscape_options.tsx @@ -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', + }, + }, + ], +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/delete_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/delete_button.tsx new file mode 100644 index 0000000000000..523d24a3a3981 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/delete_button.tsx @@ -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 = ({ 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 ( + + {i18n.translate('xpack.ml.dataframe.analyticsMap.flyout.deleteJobButton', { + defaultMessage: 'Delete job', + })} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/icons/ml_classification_job.svg b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/icons/ml_classification_job.svg new file mode 100644 index 0000000000000..5659de836b1db --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/icons/ml_classification_job.svg @@ -0,0 +1,7 @@ + + + + diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/icons/ml_outlier_detection_job.svg b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/icons/ml_outlier_detection_job.svg new file mode 100644 index 0000000000000..293a0fff7b1ec --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/icons/ml_outlier_detection_job.svg @@ -0,0 +1,7 @@ + + + + diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/icons/ml_regression_job.svg b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/icons/ml_regression_job.svg new file mode 100644 index 0000000000000..9bdc5a79522f7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/icons/ml_regression_job.svg @@ -0,0 +1,4 @@ + + + + diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/index.ts new file mode 100644 index 0000000000000..7f99bb88ec0c8 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/index.ts @@ -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'; 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 new file mode 100644 index 0000000000000..c29b6aca804d7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/legend.tsx @@ -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 = () => ( + + + + + + + + + {JOB_MAP_NODE_TYPES.INDEX} + + + + + + + + + + + + {JOB_MAP_NODE_TYPES.TRANSFORM} + + + + + + + + + + + + {JOB_MAP_NODE_TYPES.ANALYTICS} + + + + + + + + + + + + {'inference model'} + + + + + +); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/use_ref_dimensions.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/use_ref_dimensions.ts new file mode 100644 index 0000000000000..fc478e27ccac3 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/use_ref_dimensions.ts @@ -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(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 }; +} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/index.ts new file mode 100644 index 0000000000000..59d94bb22980c --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/index.ts @@ -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'; 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 new file mode 100644 index 0000000000000..53d47937409d8 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/job_map.tsx @@ -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 }) => ( + + + {i18n.translate('xpack.ml.dataframe.analyticsMap.analyticsIdTitle', { + defaultMessage: 'Map for analytics ID {analyticsId}', + values: { analyticsId }, + })} + + +); + +interface Props { + analyticsId: string; +} + +export const JobMap: FC = ({ analyticsId }) => { + const [elements, setElements] = useState([]); + 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 ( + <> + +
+ + + + + + + + + + + +
+ + ); +}; diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_map.tsx b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_map.tsx new file mode 100644 index 0000000000000..18002648cfaa6 --- /dev/null +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_map.tsx @@ -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) => , + 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 = ({ deps }) => { + const { context } = useResolver('', undefined, deps.config, basicResolvers(deps)); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/index.ts b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/index.ts index c75a8240d28fb..eedcaaf41292b 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/index.ts +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/index.ts @@ -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'; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts index 7de39d91047ef..21556a4702b4e 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts @@ -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({ diff --git a/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts index 2408290e76773..6c58a9d28bcc2 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts @@ -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( + '_g', + queryState, + { useHash: false, storeInHashQuery: false }, + url + ); + } + + return url; +} diff --git a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.ts index 351e366d1f1d8..a3f1ed6f78e8d 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.ts @@ -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 { + 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 { + 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, 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 { + 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 { + 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; + } +} diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/index.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/index.ts index fc18436ff5216..95c5d2848ae7a 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/index.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/index.ts @@ -6,3 +6,4 @@ export { analyticsAuditMessagesProvider } from './analytics_audit_messages'; export { modelsProvider } from './models_provider'; +export { AnalyticsManager } from './analytics_manager'; diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/types.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/types.ts new file mode 100644 index 0000000000000..5d6cec8cdfa61 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/types.ts @@ -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'); +}; diff --git a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts index 4364202a5c9af..8e00ae7068403 100644 --- a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts +++ b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts @@ -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)); + } + }) + ); } diff --git a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts index 846f79fbe0d8a..d8226b70eb2c3 100644 --- a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts @@ -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()) }) +);