diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts index 9b41193ea540..699eac07aca9 100644 --- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts +++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts @@ -32,6 +32,7 @@ export interface MlSummaryJob { deleting?: boolean; latestTimestampSortValue?: number; earliestStartTimestampMs?: number; + awaitingNodeAssignment: boolean; } export interface AuditMessage { diff --git a/x-pack/plugins/ml/common/types/ml_server_info.ts b/x-pack/plugins/ml/common/types/ml_server_info.ts index 66142f53add3..caa814706750 100644 --- a/x-pack/plugins/ml/common/types/ml_server_info.ts +++ b/x-pack/plugins/ml/common/types/ml_server_info.ts @@ -31,3 +31,8 @@ export interface MlInfoResponse { upgrade_mode: boolean; cloudId?: string; } + +export interface MlNodeCount { + count: number; + lazyNodeCount: number; +} diff --git a/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/index.ts b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/index.ts new file mode 100644 index 000000000000..a43d9640bf19 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/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 { JobsAwaitingNodeWarning } from './jobs_awaiting_node_warning'; +export { NewJobAwaitingNodeWarning } from './new_job_awaiting_node'; diff --git a/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/jobs_awaiting_node_warning.tsx b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/jobs_awaiting_node_warning.tsx new file mode 100644 index 000000000000..8eef232dc672 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/jobs_awaiting_node_warning.tsx @@ -0,0 +1,47 @@ +/* + * 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, { Fragment, FC } from 'react'; + +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { isCloud } from '../../services/ml_server_info'; + +interface Props { + jobCount: number; +} + +export const JobsAwaitingNodeWarning: FC = ({ jobCount }) => { + if (isCloud() === false || jobCount === 0) { + return null; + } + + return ( + + + } + color="primary" + iconType="iInCircle" + > +
+ +
+
+ +
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node.tsx b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node.tsx new file mode 100644 index 000000000000..dde1b449858d --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node.tsx @@ -0,0 +1,40 @@ +/* + * 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, { Fragment, FC } from 'react'; + +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { JobType } from '../../../../common/types/saved_objects'; + +interface Props { + jobType: JobType; +} + +export const NewJobAwaitingNodeWarning: FC = () => { + return ( + + + } + color="primary" + iconType="iInCircle" + > +
+ +
+
+ +
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/components/node_available_warning/node_available_warning.tsx b/x-pack/plugins/ml/public/application/components/node_available_warning/node_available_warning.tsx index f9f9ee347fd7..f44c1801d6f8 100644 --- a/x-pack/plugins/ml/public/application/components/node_available_warning/node_available_warning.tsx +++ b/x-pack/plugins/ml/public/application/components/node_available_warning/node_available_warning.tsx @@ -41,7 +41,7 @@ export const NodeAvailableWarning: FC = () => { defaultMessage="You will not be able to create or run jobs." /> - {isCloud && id !== null && ( + {isCloud() && id !== null && (
= ({ jobId, jobType, showProgress }) => const [currentProgress, setCurrentProgress] = useState( undefined ); + const [showJobAssignWarning, setShowJobAssignWarning] = useState(false); const { services: { notifications }, @@ -58,6 +60,10 @@ export const CreateStepFooter: FC = ({ jobId, jobType, showProgress }) => ? analyticsStats.data_frame_analytics[0] : undefined; + setShowJobAssignWarning( + jobStats?.state === DATA_FRAME_TASK_STATE.STARTING && jobStats?.node === undefined + ); + if (jobStats !== undefined) { const progressStats = getDataFrameAnalyticsProgressPhase(jobStats); @@ -106,25 +112,28 @@ export const CreateStepFooter: FC = ({ jobId, jobType, showProgress }) => }, [initialized]); return ( - - - {showProgress && ( - - )} - - - - - - - - {jobFinished === true && ( - - - + <> + {showJobAssignWarning && } + + + {showProgress && ( + )} - - - + + + + + + + + {jobFinished === true && ( + + + + )} + + + + ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index f4cd64aa8c49..a2469dd9d826 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -36,6 +36,7 @@ import { AnalyticsEmptyPrompt } from './empty_prompt'; import { useTableSettings } from './use_table_settings'; import { RefreshAnalyticsListButton } from '../refresh_analytics_list_button'; import { ListingPageUrlState } from '../../../../../../../common/types/common'; +import { JobsAwaitingNodeWarning } from '../../../../../components/jobs_awaiting_node_warning'; const filters: EuiSearchBarProps['filters'] = [ { @@ -114,6 +115,7 @@ export const DataFrameAnalyticsList: FC = ({ ); const [expandedRowItemIds, setExpandedRowItemIds] = useState([]); const [errorMessage, setErrorMessage] = useState(undefined); + const [jobsAwaitingNodeCount, setJobsAwaitingNodeCount] = useState(0); const disabled = !checkPermission('canCreateDataFrameAnalytics') || @@ -124,6 +126,7 @@ export const DataFrameAnalyticsList: FC = ({ setAnalyticsStats, setErrorMessage, setIsInitialized, + setJobsAwaitingNodeCount, blockRefresh, isManagementTable ); @@ -261,6 +264,7 @@ export const DataFrameAnalyticsList: FC = ({
{modals} {!isManagementTable && } + {!isManagementTable && stats} {isManagementTable && managementStats} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts index 2d251d94e9ca..d99c2d4e1883 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts @@ -26,6 +26,7 @@ import { } from '../../components/analytics_list/common'; import { AnalyticStatsBarStats } from '../../../../../components/stats_bar'; import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; +import { DATA_FRAME_TASK_STATE } from '../../../../../../../common/constants/data_frame_analytics'; export const isGetDataFrameAnalyticsStatsResponseOk = ( arg: any @@ -106,6 +107,7 @@ export const getAnalyticsFactory = ( React.SetStateAction >, setIsInitialized: React.Dispatch>, + setJobsAwaitingNodeCount: React.Dispatch>, blockRefresh: boolean, isManagementTable: boolean ): GetAnalytics => { @@ -134,6 +136,8 @@ export const getAnalyticsFactory = ( ? getAnalyticsJobsStats(analyticsStats) : undefined; + let jobsAwaitingNodeCount = 0; + const tableRows = analyticsConfigs.data_frame_analytics.reduce( (reducedtableRows, config) => { const stats = isGetDataFrameAnalyticsStatsResponseOk(analyticsStats) @@ -146,6 +150,10 @@ export const getAnalyticsFactory = ( return reducedtableRows; } + if (stats.state === DATA_FRAME_TASK_STATE.STARTING && stats.node === undefined) { + jobsAwaitingNodeCount++; + } + // Table with expandable rows requires `id` on the outer most level reducedtableRows.push({ checkpointing: {}, @@ -166,6 +174,7 @@ export const getAnalyticsFactory = ( setAnalyticsStats(analyticsStatsResult); setErrorMessage(undefined); setIsInitialized(true); + setJobsAwaitingNodeCount(jobsAwaitingNodeCount); refreshAnalyticsList$.next(REFRESH_ANALYTICS_LIST_STATE.IDLE); } catch (e) { // An error is followed immediately by setting the state to idle. diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index ab8fd3d8ab44..21542bf43c6a 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -32,6 +32,7 @@ import { MultiJobActions } from '../multi_job_actions'; import { NewJobButton } from '../new_job_button'; import { JobStatsBar } from '../jobs_stats_bar'; import { NodeAvailableWarning } from '../../../../components/node_available_warning'; +import { JobsAwaitingNodeWarning } from '../../../../components/jobs_awaiting_node_warning'; import { SavedObjectsWarning } from '../../../../components/saved_objects_warning'; import { DatePickerWrapper } from '../../../../components/navigation_menu/date_picker_wrapper'; import { UpgradeWarning } from '../../../../components/upgrade'; @@ -56,6 +57,7 @@ export class JobsListView extends Component { itemIdToExpandedRowMap: {}, filterClauses: [], deletingJobIds: [], + jobsAwaitingNodeCount: 0, }; this.spacesEnabled = props.spacesEnabled ?? false; @@ -272,6 +274,7 @@ export class JobsListView extends Component { spaces = allSpaces['anomaly-detector']; } + let jobsAwaitingNodeCount = 0; const jobs = await ml.jobs.jobsSummary(expandedJobsIds); const fullJobsList = {}; const jobsSummaryList = jobs.map((job) => { @@ -287,11 +290,21 @@ export class JobsListView extends Component { spaces[job.id] !== undefined ? spaces[job.id] : []; + + if (job.awaitingNodeAssignment === true) { + jobsAwaitingNodeCount++; + } return job; }); const filteredJobsSummaryList = filterJobs(jobsSummaryList, this.state.filterClauses); this.setState( - { jobsSummaryList, filteredJobsSummaryList, fullJobsList, loading: false }, + { + jobsSummaryList, + filteredJobsSummaryList, + fullJobsList, + loading: false, + jobsAwaitingNodeCount, + }, () => { this.refreshSelectedJobs(); } @@ -407,7 +420,7 @@ export class JobsListView extends Component { } renderJobsListComponents() { - const { isRefreshing, loading, jobsSummaryList } = this.state; + const { isRefreshing, loading, jobsSummaryList, jobsAwaitingNodeCount } = this.state; const jobIds = jobsSummaryList.map((j) => j.id); return ( @@ -440,6 +453,7 @@ export class JobsListView extends Component { + diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_runner/job_runner.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_runner/job_runner.ts index 9afc6e5bfca9..bd5ae8e02b0b 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_runner/job_runner.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_runner/job_runner.ts @@ -11,12 +11,14 @@ import { JobCreator } from '../job_creator'; import { DatafeedId, JobId } from '../../../../../../common/types/anomaly_detection_jobs'; import { DATAFEED_STATE } from '../../../../../../common/constants/states'; -const REFRESH_INTERVAL_MS = 100; +const REFRESH_INTERVAL_MS = 250; +const NODE_ASSIGNMENT_CHECK_REFRESH_INTERVAL_MS = 2000; const TARGET_PROGRESS_DELTA = 2; const REFRESH_RATE_ADJUSTMENT_DELAY_MS = 2000; type Progress = number; export type ProgressSubscriber = (progress: number) => void; +export type JobAssignmentSubscriber = (assigned: boolean) => void; export class JobRunner { private _jobId: JobId; @@ -35,6 +37,8 @@ export class JobRunner { private _datafeedStartTime: number = 0; private _performRefreshRateAdjustment: boolean = false; + private _jobAssignedToNode: boolean = false; + private _jobAssignedToNode$: BehaviorSubject; constructor(jobCreator: JobCreator) { this._jobId = jobCreator.jobId; @@ -45,6 +49,7 @@ export class JobRunner { this._stopRefreshPoll = jobCreator.stopAllRefreshPolls; this._progress$ = new BehaviorSubject(this._percentageComplete); + this._jobAssignedToNode$ = new BehaviorSubject(this._jobAssignedToNode); this._subscribers = jobCreator.subscribers; } @@ -62,7 +67,9 @@ export class JobRunner { private async openJob(): Promise { try { - await mlJobService.openJob(this._jobId); + const { node }: { node?: string } = await mlJobService.openJob(this._jobId); + this._jobAssignedToNode = node !== undefined && node.length > 0; + this._jobAssignedToNode$.next(this._jobAssignedToNode); } catch (error) { throw error; } @@ -94,7 +101,7 @@ export class JobRunner { this._datafeedState = DATAFEED_STATE.STARTED; this._percentageComplete = 0; - const check = async () => { + const checkProgress = async () => { const { isRunning, progress: prog, isJobClosed } = await this.getProgress(); // if the progress has reached 100% but the job is still running, @@ -109,7 +116,9 @@ export class JobRunner { if ((isRunning === true || isJobClosed === false) && this._stopRefreshPoll.stop === false) { setTimeout(async () => { - await check(); + if (this._stopRefreshPoll.stop === false) { + await checkProgress(); + } }, this._refreshInterval); } else { // job has finished running, set progress to 100% @@ -121,11 +130,30 @@ export class JobRunner { subscriptions.forEach((s) => s.unsubscribe()); } }; + + const checkJobIsAssigned = async () => { + this._jobAssignedToNode = await this._isJobAssigned(); + this._jobAssignedToNode$.next(this._jobAssignedToNode); + if (this._jobAssignedToNode === true) { + await checkProgress(); + } else { + setTimeout(async () => { + if (this._stopRefreshPoll.stop === false) { + await checkJobIsAssigned(); + } + }, NODE_ASSIGNMENT_CHECK_REFRESH_INTERVAL_MS); + } + }; // wait for the first check to run and then return success. // all subsequent checks will update the observable if (pollProgress === true) { - await check(); + if (this._jobAssignedToNode === true) { + await checkProgress(); + } else { + await checkJobIsAssigned(); + } } + return started; } catch (error) { throw error; @@ -159,6 +187,11 @@ export class JobRunner { } } + private async _isJobAssigned(): Promise { + const { jobs } = await ml.getJobStats({ jobId: this._jobId }); + return jobs.length > 0 && jobs[0].node !== undefined; + } + public async startDatafeed() { return await this._startDatafeed(this._start, this._end, true); } @@ -185,4 +218,12 @@ export class JobRunner { const { isRunning } = await this.getProgress(); return isRunning; } + + public isJobAssignedToNode() { + return this._jobAssignedToNode; + } + + public subscribeToJobAssignment(func: JobAssignmentSubscriber) { + return this._jobAssignedToNode$.subscribe(func); + } } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx index 021039c06e32..ea805541297a 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx @@ -5,6 +5,7 @@ */ import React, { Fragment, FC, useContext, useState, useEffect } from 'react'; +import { Subscription } from 'rxjs'; import { EuiButton, EuiButtonEmpty, @@ -29,6 +30,7 @@ import { DetectorChart } from './components/detector_chart'; import { JobProgress } from './components/job_progress'; import { PostSaveOptions } from './components/post_save_options'; import { StartDatafeedSwitch } from './components/start_datafeed_switch'; +import { NewJobAwaitingNodeWarning } from '../../../../../components/jobs_awaiting_node_warning'; import { toastNotificationServiceProvider } from '../../../../../services/toast_notification_service'; import { convertToAdvancedJob, @@ -55,6 +57,7 @@ export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => const [isValid, setIsValid] = useState(jobValidator.validationSummary.basic); const [jobRunner, setJobRunner] = useState(null); const [startDatafeed, setStartDatafeed] = useState(true); + const [showJobAssignWarning, setShowJobAssignWarning] = useState(false); const isAdvanced = isAdvancedJobCreator(jobCreator); const jsonEditorMode = isAdvanced ? EDITOR_MODE.EDITABLE : EDITOR_MODE.READONLY; @@ -63,6 +66,20 @@ export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => jobCreator.subscribeToProgress(setProgress); }, []); + useEffect(() => { + let s: Subscription | null = null; + if (jobRunner !== null) { + s = jobRunner.subscribeToJobAssignment((assigned: boolean) => + setShowJobAssignWarning(!assigned) + ); + } + return () => { + if (s !== null) { + s?.unsubscribe(); + } + }; + }, [jobRunner]); + async function start() { setCreatingJob(true); if (isAdvanced) { @@ -155,6 +172,7 @@ export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => )} + {showJobAssignWarning && } {progress < 100 && ( diff --git a/x-pack/plugins/ml/public/application/ml_nodes_check/check_ml_nodes.ts b/x-pack/plugins/ml/public/application/ml_nodes_check/check_ml_nodes.ts index d0cfd16d7562..d403197b68ba 100644 --- a/x-pack/plugins/ml/public/application/ml_nodes_check/check_ml_nodes.ts +++ b/x-pack/plugins/ml/public/application/ml_nodes_check/check_ml_nodes.ts @@ -5,14 +5,16 @@ */ import { ml } from '../services/ml_api_service'; +import { MlNodeCount } from '../../../common/types/ml_server_info'; let mlNodeCount: number = 0; +let lazyMlNodeCount: number = 0; let userHasPermissionToViewMlNodeCount: boolean = false; export async function checkMlNodesAvailable(redirectToJobsManagementPage: () => Promise) { try { - const nodes = await getMlNodeCount(); - if (nodes.count !== undefined && nodes.count > 0) { + const { count, lazyNodeCount } = await getMlNodeCount(); + if (count > 0 || lazyNodeCount > 0) { Promise.resolve(); } else { throw Error('Cannot load count of ML nodes'); @@ -25,23 +27,24 @@ export async function checkMlNodesAvailable(redirectToJobsManagementPage: () => } } -export async function getMlNodeCount() { +export async function getMlNodeCount(): Promise { try { const nodes = await ml.mlNodeCount(); mlNodeCount = nodes.count; + lazyMlNodeCount = nodes.lazyNodeCount; userHasPermissionToViewMlNodeCount = true; - return Promise.resolve(nodes); + return nodes; } catch (error) { mlNodeCount = 0; if (error.statusCode === 403) { userHasPermissionToViewMlNodeCount = false; } - return Promise.resolve({ count: 0 }); + return { count: 0, lazyNodeCount: 0 }; } } export function mlNodesAvailable() { - return mlNodeCount !== 0; + return mlNodeCount !== 0 || lazyMlNodeCount !== 0; } export function permissionToViewMlNodeCount() { diff --git a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx index 5748e4bba95c..9b8bac24845d 100644 --- a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx @@ -28,8 +28,9 @@ import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; interface Props { jobCreationDisabled: boolean; + setLazyJobCount: React.Dispatch>; } -export const AnalyticsPanel: FC = ({ jobCreationDisabled }) => { +export const AnalyticsPanel: FC = ({ jobCreationDisabled, setLazyJobCount }) => { const [analytics, setAnalytics] = useState([]); const [analyticsStats, setAnalyticsStats] = useState( undefined @@ -52,6 +53,7 @@ export const AnalyticsPanel: FC = ({ jobCreationDisabled }) => { setAnalyticsStats, setErrorMessage, setIsInitialized, + setLazyJobCount, false, false ); diff --git a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx index 1cb6bab7fd76..dbf0fcc1874f 100644 --- a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx @@ -51,9 +51,10 @@ function getDefaultAnomalyScores(groups: Group[]): MaxScoresByGroup { interface Props { jobCreationDisabled: boolean; + setLazyJobCount: React.Dispatch>; } -export const AnomalyDetectionPanel: FC = ({ jobCreationDisabled }) => { +export const AnomalyDetectionPanel: FC = ({ jobCreationDisabled, setLazyJobCount }) => { const { services: { notifications }, } = useMlKibana(); @@ -84,10 +85,14 @@ export const AnomalyDetectionPanel: FC = ({ jobCreationDisabled }) => { const loadJobs = async () => { setIsLoading(true); + let lazyJobCount = 0; try { const jobsResult: MlSummaryJobs = await ml.jobs.jobsSummary([]); const jobsSummaryList = jobsResult.map((job: MlSummaryJob) => { job.latestTimestampSortValue = job.latestTimestampMs || 0; + if (job.awaitingNodeAssignment) { + lazyJobCount++; + } return job; }); const { groups: jobsGroups, count } = getGroupsFromJobs(jobsSummaryList); @@ -100,6 +105,7 @@ export const AnomalyDetectionPanel: FC = ({ jobCreationDisabled }) => { setGroups(jobsGroups); setJobsList(jobsWithTimerange); loadMaxAnomalyScores(jobsGroups); + setLazyJobCount(lazyJobCount); } catch (e) { setErrorMessage(e.message !== undefined ? e.message : JSON.stringify(e)); setIsLoading(false); diff --git a/x-pack/plugins/ml/public/application/overview/components/content.tsx b/x-pack/plugins/ml/public/application/overview/components/content.tsx index 8d2e4865ee6f..18e7d7c00986 100644 --- a/x-pack/plugins/ml/public/application/overview/components/content.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/content.tsx @@ -12,20 +12,30 @@ import { AnalyticsPanel } from './analytics_panel'; interface Props { createAnomalyDetectionJobDisabled: boolean; createAnalyticsJobDisabled: boolean; + setAdLazyJobCount: React.Dispatch>; + setDfaLazyJobCount: React.Dispatch>; } // Fetch jobs and determine what to show export const OverviewContent: FC = ({ createAnomalyDetectionJobDisabled, createAnalyticsJobDisabled, + setAdLazyJobCount, + setDfaLazyJobCount, }) => ( - + - + diff --git a/x-pack/plugins/ml/public/application/overview/overview_page.tsx b/x-pack/plugins/ml/public/application/overview/overview_page.tsx index bf293ee38ea1..5feb414f1ffd 100644 --- a/x-pack/plugins/ml/public/application/overview/overview_page.tsx +++ b/x-pack/plugins/ml/public/application/overview/overview_page.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, FC } from 'react'; +import React, { Fragment, FC, useState } from 'react'; import { EuiFlexGroup, EuiPage, EuiPageBody } from '@elastic/eui'; import { checkPermission } from '../capabilities/check_capabilities'; import { mlNodesAvailable } from '../ml_nodes_check/check_ml_nodes'; @@ -12,6 +12,7 @@ import { NavigationMenu } from '../components/navigation_menu'; import { OverviewSideBar } from './components/sidebar'; import { OverviewContent } from './components/content'; import { NodeAvailableWarning } from '../components/node_available_warning'; +import { JobsAwaitingNodeWarning } from '../components/jobs_awaiting_node_warning'; import { SavedObjectsWarning } from '../components/saved_objects_warning'; import { UpgradeWarning } from '../components/upgrade'; import { HelpMenu } from '../components/help_menu'; @@ -27,12 +28,17 @@ export const OverviewPage: FC = () => { services: { docLinks }, } = useMlKibana(); const helpLink = docLinks.links.ml.guide; + + const [adLazyJobCount, setAdLazyJobCount] = useState(0); + const [dfaLazyJobCount, setDfaLazyJobCount] = useState(0); + return ( + @@ -41,6 +47,8 @@ export const OverviewPage: FC = () => { diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index b67b5015dbd6..ea09801d8a23 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -15,12 +15,17 @@ import { resultsApiProvider } from './results'; import { jobsApiProvider } from './jobs'; import { fileDatavisualizer } from './datavisualizer'; import { savedObjectsApiProvider } from './saved_objects'; -import { MlServerDefaults, MlServerLimits } from '../../../../common/types/ml_server_info'; +import { + MlServerDefaults, + MlServerLimits, + MlNodeCount, +} from '../../../../common/types/ml_server_info'; import { MlCapabilitiesResponse } from '../../../../common/types/capabilities'; import { Calendar, CalendarId, UpdateCalendar } from '../../../../common/types/calendars'; import { Job, + JobStats, Datafeed, CombinedJob, Detector, @@ -116,14 +121,14 @@ export function mlApiServicesProvider(httpService: HttpService) { return { getJobs(obj?: { jobId?: string }) { const jobId = obj && obj.jobId ? `/${obj.jobId}` : ''; - return httpService.http({ + return httpService.http<{ jobs: Job[]; count: number }>({ path: `${basePath()}/anomaly_detectors${jobId}`, }); }, getJobStats(obj: { jobId?: string }) { const jobId = obj && obj.jobId ? `/${obj.jobId}` : ''; - return httpService.http({ + return httpService.http<{ jobs: JobStats[]; count: number }>({ path: `${basePath()}/anomaly_detectors${jobId}/_stats`, }); }, @@ -614,7 +619,7 @@ export function mlApiServicesProvider(httpService: HttpService) { }, mlNodeCount() { - return httpService.http<{ count: number }>({ + return httpService.http({ path: `${basePath()}/ml_node_count`, method: 'GET', }); diff --git a/x-pack/plugins/ml/server/lib/node_utils.ts b/x-pack/plugins/ml/server/lib/node_utils.ts new file mode 100644 index 000000000000..729c8c0448d2 --- /dev/null +++ b/x-pack/plugins/ml/server/lib/node_utils.ts @@ -0,0 +1,79 @@ +/* + * 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 { IScopedClusterClient } from 'kibana/server'; +import { MlNodeCount } from '../../common/types/ml_server_info'; + +export async function getMlNodeCount(client: IScopedClusterClient): Promise { + const { body } = await client.asInternalUser.nodes.info({ + filter_path: 'nodes.*.attributes', + }); + + let count = 0; + if (typeof body.nodes === 'object') { + Object.keys(body.nodes).forEach((k) => { + if (body.nodes[k].attributes !== undefined) { + const maxOpenJobs = body.nodes[k].attributes['ml.max_open_jobs']; + if (maxOpenJobs !== null && maxOpenJobs > 0) { + count++; + } + } + }); + } + + const lazyNodeCount = await getLazyMlNodeCount(client); + + return { count, lazyNodeCount }; +} + +export async function getLazyMlNodeCount(client: IScopedClusterClient) { + const { body } = await client.asInternalUser.cluster.getSettings({ + include_defaults: true, + filter_path: '**.xpack.ml.max_lazy_ml_nodes', + }); + + const lazyMlNodesString: string | undefined = ( + body.defaults || + body.persistent || + body.transient || + {} + ).xpack?.ml.max_lazy_ml_nodes; + + const count = lazyMlNodesString === undefined ? 0 : +lazyMlNodesString; + + if (count === 0 || isNaN(count)) { + return 0; + } + + return count; +} + +export async function countJobsLazyStarting( + client: IScopedClusterClient, + startingJobsCount: number +) { + const lazyMlNodesCount = await getLazyMlNodeCount(client); + const { count: currentMlNodeCount } = await getMlNodeCount(client); + + const availableLazyMlNodes = lazyMlNodesCount ?? lazyMlNodesCount - currentMlNodeCount; + + let lazilyStartingJobsCount = startingJobsCount; + + if (startingJobsCount > availableLazyMlNodes) { + if (lazyMlNodesCount > currentMlNodeCount) { + lazilyStartingJobsCount = availableLazyMlNodes; + } + } + + const response = { + availableLazyMlNodes, + currentMlNodeCount, + lazilyStartingJobsCount, + totalStartingJobs: startingJobsCount, + }; + + return response; +} diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts index 3f9587749d33..5632b4266def 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts @@ -35,12 +35,12 @@ import { import type { MlClient } from '../../lib/ml_client'; export class AnalyticsManager { - private _client: IScopedClusterClient['asInternalUser']; + private _client: IScopedClusterClient; private _mlClient: MlClient; private _inferenceModels: TrainedModelConfigResponse[]; private _jobStats: DataFrameAnalyticsStats[]; - constructor(mlClient: MlClient, client: IScopedClusterClient['asInternalUser']) { + constructor(mlClient: MlClient, client: IScopedClusterClient) { this._client = client; this._mlClient = mlClient; this._inferenceModels = []; @@ -112,7 +112,9 @@ export class AnalyticsManager { } private async getAnalyticsStats() { - const resp = await this._mlClient.getDataFrameAnalyticsStats({ size: 1000 }); + const resp = await this._mlClient.getDataFrameAnalyticsStats<{ + data_frame_analytics: DataFrameAnalyticsStats[]; + }>({ size: 1000 }); const stats = resp?.body?.data_frame_analytics; return stats; } @@ -142,15 +144,14 @@ export class AnalyticsManager { } private async getIndexData(index: string) { - const indexData = await this._client.indices.get({ + const indexData = await this._client.asInternalUser.indices.get({ index, }); - return indexData?.body; } private async getTransformData(transformId: string) { - const transform = await this._client.transform.getTransform({ + const transform = await this._client.asInternalUser.transform.getTransform({ transform_id: transformId, }); const transformData = transform?.body?.transforms[0]; diff --git a/x-pack/plugins/ml/server/models/job_service/jobs.ts b/x-pack/plugins/ml/server/models/job_service/jobs.ts index 6ab4af63004b..80bc723952a6 100644 --- a/x-pack/plugins/ml/server/models/job_service/jobs.ts +++ b/x-pack/plugins/ml/server/models/job_service/jobs.ts @@ -204,6 +204,7 @@ export function jobsProvider(client: IScopedClusterClient, mlClient: MlClient) { isNotSingleMetricViewerJobMessage: errorMessage, nodeName: job.node ? job.node.name : undefined, deleting: job.deleting || undefined, + awaitingNodeAssignment: isJobAwaitingNodeAssignment(job), }; if (jobIds.find((j) => j === tempJob.id)) { tempJob.fullJob = job; @@ -519,6 +520,10 @@ export function jobsProvider(client: IScopedClusterClient, mlClient: MlClient) { return false; } + function isJobAwaitingNodeAssignment(job: CombinedJobWithStats) { + return job.node === undefined && job.state === JOB_STATE.OPENING; + } + return { forceDeleteJob, deleteJobs, 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 4d504f4f2ef2..c0dba1a88254 100644 --- a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts +++ b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts @@ -44,7 +44,7 @@ function getAnalyticsMap( client: IScopedClusterClient, idOptions: GetAnalyticsMapArgs ) { - const analytics = new AnalyticsManager(mlClient, client.asInternalUser); + const analytics = new AnalyticsManager(mlClient, client); return analytics.getAnalyticsMap(idOptions); } @@ -53,7 +53,7 @@ function getExtendedMap( client: IScopedClusterClient, idOptions: ExtendAnalyticsMapArgs ) { - const analytics = new AnalyticsManager(mlClient, client.asInternalUser); + const analytics = new AnalyticsManager(mlClient, client); return analytics.extendAnalyticsMapForAnalyticsJob(idOptions); } diff --git a/x-pack/plugins/ml/server/routes/system.ts b/x-pack/plugins/ml/server/routes/system.ts index 8802f51d938e..78574c3c9ea6 100644 --- a/x-pack/plugins/ml/server/routes/system.ts +++ b/x-pack/plugins/ml/server/routes/system.ts @@ -5,12 +5,13 @@ */ import { schema } from '@kbn/config-schema'; -import { IScopedClusterClient } from 'kibana/server'; + import { wrapError } from '../client/error_wrapper'; import { mlLog } from '../lib/log'; import { capabilitiesProvider } from '../lib/capabilities'; import { spacesUtilsProvider } from '../lib/spaces_utils'; import { RouteInitialization, SystemRouteDeps } from '../types'; +import { getMlNodeCount } from '../lib/node_utils'; /** * System routes @@ -19,25 +20,6 @@ export function systemRoutes( { router, mlLicense, routeGuard }: RouteInitialization, { getSpaces, cloud, resolveMlCapabilities }: SystemRouteDeps ) { - async function getNodeCount(client: IScopedClusterClient) { - const { body } = await client.asInternalUser.nodes.info({ - filter_path: 'nodes.*.attributes', - }); - - let count = 0; - if (typeof body.nodes === 'object') { - Object.keys(body.nodes).forEach((k) => { - if (body.nodes[k].attributes !== undefined) { - const maxOpenJobs = body.nodes[k].attributes['ml.max_open_jobs']; - if (maxOpenJobs !== null && maxOpenJobs > 0) { - count++; - } - } - }); - } - return { count }; - } - /** * @apiGroup SystemRoutes * @@ -156,7 +138,7 @@ export function systemRoutes( routeGuard.basicLicenseAPIGuard(async ({ client, response }) => { try { return response.ok({ - body: await getNodeCount(client), + body: await getMlNodeCount(client), }); } catch (e) { return response.customError(wrapError(e)); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.test.ts b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.test.ts index 72690a177392..5c31a12d07e5 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml/hooks/use_installed_security_jobs.test.ts @@ -43,6 +43,7 @@ describe('useInstalledSecurityJobs', () => { expect(result.current.jobs).toEqual( expect.arrayContaining([ { + awaitingNodeAssignment: false, datafeedId: 'datafeed-siem-api-rare_process_linux_ecs', datafeedIndices: ['auditbeat-*'], datafeedState: 'stopped', diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/api.mock.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/api.mock.ts index 0e8f033ff0cf..aeea5d1af366 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/api.mock.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/api.mock.ts @@ -46,6 +46,7 @@ export const mockOpenedJob: MlSummaryJob = { memory_status: 'hard_limit', nodeName: 'siem-es', processed_record_count: 3425264, + awaitingNodeAssignment: false, }; export const mockJobsSummaryResponse: MlSummaryJob[] = [ @@ -64,6 +65,7 @@ export const mockJobsSummaryResponse: MlSummaryJob[] = [ latestTimestampMs: 1561402325194, earliestTimestampMs: 1554327458406, isSingleMetricViewerJob: true, + awaitingNodeAssignment: false, }, { id: 'siem-api-rare_process_linux_ecs', @@ -79,6 +81,7 @@ export const mockJobsSummaryResponse: MlSummaryJob[] = [ latestTimestampMs: 1557434782207, earliestTimestampMs: 1557353420495, isSingleMetricViewerJob: true, + awaitingNodeAssignment: false, }, { id: 'siem-api-rare_process_windows_ecs', @@ -92,6 +95,7 @@ export const mockJobsSummaryResponse: MlSummaryJob[] = [ datafeedIndices: ['winlogbeat-*'], datafeedState: 'stopped', isSingleMetricViewerJob: true, + awaitingNodeAssignment: false, }, { id: 'siem-api-suspicious_login_activity_ecs', @@ -105,6 +109,7 @@ export const mockJobsSummaryResponse: MlSummaryJob[] = [ datafeedIndices: ['auditbeat-*'], datafeedState: 'stopped', isSingleMetricViewerJob: true, + awaitingNodeAssignment: false, }, ]; @@ -513,6 +518,7 @@ export const mockSecurityJobs: SecurityJob[] = [ isCompatible: true, isInstalled: true, isElasticJob: true, + awaitingNodeAssignment: false, }, { id: 'rare_process_by_host_linux_ecs', @@ -531,6 +537,7 @@ export const mockSecurityJobs: SecurityJob[] = [ isCompatible: true, isInstalled: true, isElasticJob: true, + awaitingNodeAssignment: false, }, { datafeedId: '', @@ -549,5 +556,6 @@ export const mockSecurityJobs: SecurityJob[] = [ isCompatible: false, isInstalled: false, isElasticJob: true, + awaitingNodeAssignment: false, }, ]; diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts index 80f50912a84f..cfdadf4bc761 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs.test.ts @@ -65,6 +65,7 @@ describe('useSecurityJobs', () => { memory_status: 'hard_limit', moduleId: '', processed_record_count: 582251, + awaitingNodeAssignment: false, }; const { result, waitForNextUpdate } = renderHook(() => useSecurityJobs(false)); diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.test.tsx index 7fb4e6f59d9f..b37839e50d1f 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.test.tsx @@ -29,6 +29,7 @@ describe('useSecurityJobsHelpers', () => { false ); expect(securityJob).toEqual({ + awaitingNodeAssignment: false, datafeedId: '', datafeedIndices: [], datafeedState: '', diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.tsx index d0109fd29b5f..6d9870aa427c 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/hooks/use_security_jobs_helpers.tsx @@ -42,6 +42,7 @@ export const moduleToSecurityJob = ( isCompatible, isInstalled: false, isElasticJob: true, + awaitingNodeAssignment: false, }; }; diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/__snapshots__/jobs_table.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/__snapshots__/jobs_table.test.tsx.snap index a40ccfa7cd1b..d64fb474c1fb 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/__snapshots__/jobs_table.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/__snapshots__/jobs_table.test.tsx.snap @@ -25,6 +25,7 @@ exports[`JobsTableComponent renders correctly against snapshot 1`] = ` items={ Array [ Object { + "awaitingNodeAssignment": false, "datafeedId": "datafeed-linux_anomalous_network_activity_ecs", "datafeedIndices": Array [ "auditbeat-*", @@ -52,6 +53,7 @@ exports[`JobsTableComponent renders correctly against snapshot 1`] = ` "processed_record_count": 32010, }, Object { + "awaitingNodeAssignment": false, "datafeedId": "datafeed-rare_process_by_host_linux_ecs", "datafeedIndices": Array [ "auditbeat-*", @@ -76,6 +78,7 @@ exports[`JobsTableComponent renders correctly against snapshot 1`] = ` "processed_record_count": 0, }, Object { + "awaitingNodeAssignment": false, "datafeedId": "", "datafeedIndices": Array [], "datafeedState": "", diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/filters/__snapshots__/jobs_table_filters.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/filters/__snapshots__/jobs_table_filters.test.tsx.snap index 9bee321e9fbd..7367dbf7bdc0 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/filters/__snapshots__/jobs_table_filters.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/filters/__snapshots__/jobs_table_filters.test.tsx.snap @@ -28,6 +28,7 @@ exports[`JobsTableFilters renders correctly against snapshot 1`] = ` securityJobs={ Array [ Object { + "awaitingNodeAssignment": false, "datafeedId": "datafeed-linux_anomalous_network_activity_ecs", "datafeedIndices": Array [ "auditbeat-*", @@ -55,6 +56,7 @@ exports[`JobsTableFilters renders correctly against snapshot 1`] = ` "processed_record_count": 32010, }, Object { + "awaitingNodeAssignment": false, "datafeedId": "datafeed-rare_process_by_host_linux_ecs", "datafeedIndices": Array [ "auditbeat-*", @@ -79,6 +81,7 @@ exports[`JobsTableFilters renders correctly against snapshot 1`] = ` "processed_record_count": 0, }, Object { + "awaitingNodeAssignment": false, "datafeedId": "", "datafeedIndices": Array [], "datafeedState": "",