mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
3151e7e5e4
commit
6519b83e48
37 changed files with 1657 additions and 27 deletions
|
@ -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];
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
>;
|
||||
|
||||
|
|
|
@ -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';
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
):
|
||||
|
|
|
@ -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';
|
|
@ -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}</>;
|
||||
};
|
|
@ -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 };
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
@import 'legend';
|
|
@ -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';
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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';
|
|
@ -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>
|
||||
);
|
|
@ -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 };
|
||||
}
|
|
@ -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';
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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>({
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -6,3 +6,4 @@
|
|||
|
||||
export { analyticsAuditMessagesProvider } from './analytics_audit_messages';
|
||||
export { modelsProvider } from './models_provider';
|
||||
export { AnalyticsManager } from './analytics_manager';
|
||||
|
|
|
@ -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');
|
||||
};
|
|
@ -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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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()) })
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue