[ML] Auto-scalable ML node integrations improvements (#112264)

* [ML] Lazy ML node integrations improvements

* adding checks to metric and uptime

* updating callout

* adding callout to security

* cleaning up logs changes

* further clean up

* cleaning up callout code

* reverting churn

* linting

* improvements to bundle size

* adding link to cloud

* text changes

* fixing test

* translation id

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
James Gowdy 2021-10-05 14:02:18 +01:00 committed by GitHub
parent cf6bb10bb1
commit 02ca808dc2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 285 additions and 27 deletions

View file

@ -25,6 +25,7 @@ import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt';
import { ITableColumn, ManagedTable } from '../../../shared/managed_table';
import { AnomalyDetectionApiResponse } from './index';
import { LegacyJobsCallout } from './legacy_jobs_callout';
import { MLJobsAwaitingNodeWarning } from '../../../../../../ml/public';
type Jobs = AnomalyDetectionApiResponse['jobs'];
@ -67,6 +68,7 @@ export function JobsList({ data, status, onAddEnvironments }: Props) {
return (
<>
<MLJobsAwaitingNodeWarning jobIds={jobs.map((j) => j.job_id)} />
<EuiText color="subdued">
<FormattedMessage
id="xpack.apm.settings.anomalyDetection.jobList.mlDescriptionText"

View file

@ -12,6 +12,7 @@ export type JobStatus =
| 'initializing'
| 'stopped'
| 'started'
| 'starting'
| 'finished'
| 'failed';
@ -35,10 +36,10 @@ export type SetupStatus =
* before this state was reached.
*/
export const isJobStatusWithResults = (jobStatus: JobStatus) =>
['started', 'finished', 'stopped', 'failed'].includes(jobStatus);
['started', 'starting', 'finished', 'stopped', 'failed'].includes(jobStatus);
export const isHealthyJobStatus = (jobStatus: JobStatus) =>
['started', 'finished'].includes(jobStatus);
['started', 'starting', 'finished'].includes(jobStatus);
/**
* Maps a setup status to the possibility that results have already been

View file

@ -12,6 +12,7 @@ export type JobStatus =
| 'initializing'
| 'stopped'
| 'started'
| 'starting'
| 'finished'
| 'failed';
@ -35,10 +36,10 @@ export type SetupStatus =
* before this state was reached.
*/
export const isJobStatusWithResults = (jobStatus: JobStatus) =>
['started', 'finished', 'stopped', 'failed'].includes(jobStatus);
['started', 'starting', 'finished', 'stopped', 'failed'].includes(jobStatus);
export const isHealthyJobStatus = (jobStatus: JobStatus) =>
['started', 'finished'].includes(jobStatus);
['started', 'starting', 'finished'].includes(jobStatus);
/**
* Maps a setup status to the possibility that results have already been

View file

@ -41,6 +41,7 @@ export type FetchJobStatusRequestPayload = rt.TypeOf<typeof fetchJobStatusReques
const datafeedStateRT = rt.keyof({
started: null,
starting: null,
stopped: null,
stopping: null,
'': null,
@ -89,6 +90,7 @@ export const jobSummaryRT = rt.intersection([
jobState: jobStateRT,
}),
rt.partial({
awaitingNodeAssignment: rt.boolean,
datafeedIndices: rt.array(rt.string),
datafeedState: datafeedStateRT,
fullJob: rt.partial({

View file

@ -117,6 +117,7 @@ const datafeedSetupResponseRT = rt.intersection([
success: rt.boolean,
}),
rt.partial({
awaitingNodeAssignment: rt.boolean,
error: setupErrorResponseRT,
}),
]);

View file

@ -99,7 +99,7 @@ const createStatusReducer =
{} as Record<JobType, JobStatus>
);
const nextSetupStatus: SetupStatus = Object.values<JobStatus>(nextJobStatus).every(
(jobState) => jobState === 'started'
(jobState) => jobState === 'started' || jobState === 'starting'
)
? { type: 'succeeded' }
: {
@ -224,9 +224,17 @@ const getJobStatus =
jobSummary.datafeedState === 'stopped'
) {
return 'stopped';
} else if (jobSummary.jobState === 'opening') {
} else if (
jobSummary.jobState === 'opening' &&
jobSummary.awaitingNodeAssignment === false
) {
return 'initializing';
} else if (jobSummary.jobState === 'opened' && jobSummary.datafeedState === 'started') {
} else if (
(jobSummary.jobState === 'opened' && jobSummary.datafeedState === 'started') ||
(jobSummary.jobState === 'opening' &&
jobSummary.datafeedState === 'starting' &&
jobSummary.awaitingNodeAssignment === true)
) {
return 'started';
}

View file

@ -41,6 +41,7 @@ export type FetchJobStatusRequestPayload = rt.TypeOf<typeof fetchJobStatusReques
const datafeedStateRT = rt.keyof({
started: null,
starting: null,
stopped: null,
stopping: null,
'': null,
@ -77,6 +78,7 @@ export const jobSummaryRT = rt.intersection([
jobState: jobStateRT,
}),
rt.partial({
awaitingNodeAssignment: rt.boolean,
datafeedIndices: rt.array(rt.string),
datafeedState: datafeedStateRT,
fullJob: rt.partial({

View file

@ -224,9 +224,17 @@ const getJobStatus =
jobSummary.datafeedState === 'stopped'
) {
return 'stopped';
} else if (jobSummary.jobState === 'opening') {
} else if (
jobSummary.jobState === 'opening' &&
jobSummary.awaitingNodeAssignment === false
) {
return 'initializing';
} else if (jobSummary.jobState === 'opened' && jobSummary.datafeedState === 'started') {
} else if (
(jobSummary.jobState === 'opened' && jobSummary.datafeedState === 'started') ||
(jobSummary.jobState === 'opening' &&
jobSummary.datafeedState === 'starting' &&
jobSummary.awaitingNodeAssignment === true)
) {
return 'started';
}

View file

@ -32,6 +32,7 @@ import { RecreateJobButton } from '../../../components/logging/log_analysis_setu
import { AnalyzeInMlButton } from '../../../components/logging/log_analysis_results';
import { useMlHref, ML_PAGES } from '../../../../../ml/public';
import { DatasetsSelector } from '../../../components/logging/log_analysis_results/datasets_selector';
import { MLJobsAwaitingNodeWarning } from '../../../../../ml/public';
const JOB_STATUS_POLLING_INTERVAL = 30000;
@ -246,6 +247,7 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent<LogEntryC
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<MLJobsAwaitingNodeWarning jobIds={Object.values(jobIds)} />
<CategoryJobNoticesSection
hasOutdatedJobConfigurations={hasOutdatedJobConfigurations}
hasOutdatedJobDefinitions={hasOutdatedJobDefinitions}

View file

@ -33,6 +33,7 @@ import { useLogAnalysisResultsUrlState } from './use_log_entry_rate_results_url_
import { isJobStatusWithResults } from '../../../../common/log_analysis';
import { LogsPageTemplate } from '../page_template';
import { ManageJobsButton } from '../../../components/logging/log_analysis_setup/manage_jobs_button';
import { MLJobsAwaitingNodeWarning } from '../../../../../ml/public';
export const SORT_DEFAULTS = {
direction: 'desc' as const,
@ -234,6 +235,7 @@ export const LogEntryRateResultsContent: React.FunctionComponent<{
onRecreateMlJobForReconfiguration={showLogEntryRateSetup}
onRecreateMlJobForUpdate={showLogEntryRateSetup}
/>
<MLJobsAwaitingNodeWarning jobIds={jobIds} />
<CategoryJobNoticesSection
hasOutdatedJobConfigurations={hasOutdatedLogEntryCategoriesJobConfigurations}
hasOutdatedJobDefinitions={hasOutdatedLogEntryCategoriesJobDefinitions}

View file

@ -16,6 +16,7 @@ import { EuiButtonEmpty } from '@elastic/eui';
import moment from 'moment';
import { EuiTabs } from '@elastic/eui';
import { EuiTab } from '@elastic/eui';
import { MLJobsAwaitingNodeWarning } from '../../../../../../../../ml/public';
import { SubscriptionSplashPrompt } from '../../../../../../components/subscription_splash_content';
import { useInfraMLCapabilitiesContext } from '../../../../../../containers/ml/infra_ml_capabilities';
import {
@ -120,14 +121,18 @@ export const FlyoutHome = (props: Props) => {
<EuiFlyoutBody
banner={
tab === 'jobs' &&
hasJobs && (
<JobsEnabledCallout
hasHostJobs={hostJobSummaries.length > 0}
hasK8sJobs={k8sJobSummaries.length > 0}
jobIds={jobIds}
/>
)
<>
{tab === 'jobs' && hasJobs && (
<>
<JobsEnabledCallout
hasHostJobs={hostJobSummaries.length > 0}
hasK8sJobs={k8sJobSummaries.length > 0}
jobIds={jobIds}
/>
</>
)}
<MLJobsAwaitingNodeWarning jobIds={jobIds} />
</>
}
>
{tab === 'jobs' && (

View file

@ -7,3 +7,4 @@
export { JobsAwaitingNodeWarning } from './jobs_awaiting_node_warning';
export { NewJobAwaitingNodeWarning } from './new_job_awaiting_node';
export { MLJobsAwaitingNodeWarning } from './new_job_awaiting_node_shared';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { Fragment, FC } from 'react';
import React, { FC } from 'react';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
@ -21,7 +21,7 @@ export const JobsAwaitingNodeWarning: FC<Props> = ({ jobCount }) => {
}
return (
<Fragment>
<>
<EuiCallOut
title={
<FormattedMessage
@ -43,6 +43,6 @@ export const JobsAwaitingNodeWarning: FC<Props> = ({ jobCount }) => {
</div>
</EuiCallOut>
<EuiSpacer size="m" />
</Fragment>
</>
);
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { Fragment, FC } from 'react';
import React, { FC } from 'react';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
@ -22,7 +22,7 @@ export const NewJobAwaitingNodeWarning: FC<Props> = () => {
}
return (
<Fragment>
<>
<EuiCallOut
title={
<FormattedMessage
@ -41,6 +41,6 @@ export const NewJobAwaitingNodeWarning: FC<Props> = () => {
</div>
</EuiCallOut>
<EuiSpacer size="m" />
</Fragment>
</>
);
};

View file

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

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
const MLJobsAwaitingNodeWarningComponent = React.lazy(
() => import('./new_job_awaiting_node_shared')
);
interface Props {
jobIds: string[];
}
export const MLJobsAwaitingNodeWarning: FC<Props> = ({ jobIds }) => {
return (
<React.Suspense fallback={<div />}>
<MLJobsAwaitingNodeWarningComponent jobIds={jobIds} />
</React.Suspense>
);
};

View file

@ -0,0 +1,164 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useState, useEffect, useCallback, useMemo } from 'react';
import { estypes } from '@elastic/elasticsearch';
import { EuiCallOut, EuiSpacer, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { JOB_STATE } from '../../../../../common/constants/states';
import { mlApiServicesProvider } from '../../../services/ml_api_service';
import { HttpService } from '../../../services/http_service';
import { extractDeploymentId, CloudInfo } from '../../../services/ml_server_info';
interface Props {
jobIds: string[];
}
function isJobAwaitingNodeAssignment(job: estypes.MlJobStats) {
return job.node === undefined && job.state === JOB_STATE.OPENING;
}
const MLJobsAwaitingNodeWarning: FC<Props> = ({ jobIds }) => {
const { http } = useKibana().services;
const ml = useMemo(() => mlApiServicesProvider(new HttpService(http!)), [http]);
const [unassignedJobCount, setUnassignedJobCount] = useState<number>(0);
const [cloudInfo, setCloudInfo] = useState<CloudInfo | null>(null);
const checkNodes = useCallback(async () => {
try {
if (jobIds.length === 0) {
setUnassignedJobCount(0);
return;
}
const { lazyNodeCount } = await ml.mlNodeCount();
if (lazyNodeCount === 0) {
setUnassignedJobCount(0);
return;
}
const { jobs } = await ml.getJobStats({ jobId: jobIds.join(',') });
const unassignedJobs = jobs.filter(isJobAwaitingNodeAssignment);
setUnassignedJobCount(unassignedJobs.length);
} catch (error) {
setUnassignedJobCount(0);
// eslint-disable-next-line no-console
console.error('Could not determine ML node information', error);
}
}, [jobIds]);
const checkCloudInfo = useCallback(async () => {
if (unassignedJobCount === 0) {
return;
}
try {
const resp = await ml.mlInfo();
const cloudId = resp.cloudId ?? null;
setCloudInfo({
isCloud: cloudId !== null,
cloudId,
deploymentId: cloudId === null ? null : extractDeploymentId(cloudId),
});
} catch (error) {
setCloudInfo(null);
// eslint-disable-next-line no-console
console.error('Could not determine cloud information', error);
}
}, [unassignedJobCount]);
useEffect(() => {
checkCloudInfo();
}, [unassignedJobCount]);
useEffect(() => {
checkNodes();
}, [jobIds]);
if (unassignedJobCount === 0) {
return null;
}
return (
<>
<EuiCallOut
title={
<FormattedMessage
id="xpack.ml.jobsAwaitingNodeWarningShared.title"
defaultMessage="Awaiting machine learning node"
/>
}
color="primary"
iconType="iInCircle"
>
<div>
<FormattedMessage
id="xpack.ml.jobsAwaitingNodeWarningShared.noMLNodesAvailableDescription"
defaultMessage="There {jobCount, plural, one {is} other {are}} {jobCount, plural, one {# job} other {# jobs}} waiting for machine learning nodes to start."
values={{
jobCount: unassignedJobCount,
}}
/>
<EuiSpacer size="s" />
{cloudInfo &&
(cloudInfo.isCloud ? (
<>
<FormattedMessage
id="xpack.ml.jobsAwaitingNodeWarningShared.isCloud"
defaultMessage="Elastic Cloud deployments can autoscale to add more ML capacity. This may take 5-20 minutes. "
/>
{cloudInfo.deploymentId === null ? null : (
<FormattedMessage
id="xpack.ml.jobsAwaitingNodeWarningShared.isCloud.link"
defaultMessage="You can monitor progress in the {link}."
values={{
link: (
<EuiLink
href={`https://cloud.elastic.co/deployments?q=${cloudInfo.deploymentId}`}
>
<FormattedMessage
id="xpack.ml.jobsAwaitingNodeWarningShared.linkToCloud.linkText"
defaultMessage="Elastic Cloud admin console"
/>
</EuiLink>
),
}}
/>
)}
</>
) : (
<FormattedMessage
id="xpack.ml.jobsAwaitingNodeWarningShared.notCloud"
defaultMessage="Only Elastic Cloud deployments can autoscale; you must add machine learning nodes. {link}"
values={{
link: (
<EuiLink
href={
'https://www.elastic.co/guide/en/elasticsearch/reference/master/modules-node.html#ml-node'
}
>
<FormattedMessage
id="xpack.ml.jobsAwaitingNodeWarningShared.linkToCloud.learnMore"
defaultMessage="Learn more."
/>
</EuiLink>
),
}}
/>
))}
</div>
</EuiCallOut>
<EuiSpacer size="m" />
</>
);
};
// eslint-disable-next-line import/no-default-export
export default MLJobsAwaitingNodeWarning;

View file

@ -11,6 +11,7 @@ import { MlServerDefaults, MlServerLimits } from '../../../common/types/ml_serve
export interface CloudInfo {
cloudId: string | null;
isCloud: boolean;
deploymentId: string | null;
}
let defaults: MlServerDefaults = {
@ -22,6 +23,7 @@ let limits: MlServerLimits = {};
const cloudInfo: CloudInfo = {
cloudId: null,
isCloud: false,
deploymentId: null,
};
export async function loadMlServerInfo() {
@ -31,6 +33,7 @@ export async function loadMlServerInfo() {
limits = resp.limits;
cloudInfo.cloudId = resp.cloudId || null;
cloudInfo.isCloud = resp.cloudId !== undefined;
cloudInfo.deploymentId = !resp.cloudId ? null : extractDeploymentId(resp.cloudId);
return { defaults, limits, cloudId: cloudInfo };
} catch (error) {
return { defaults, limits, cloudId: cloudInfo };
@ -54,7 +57,7 @@ export function isCloud(): boolean {
}
export function getCloudDeploymentId(): string | null {
return cloudInfo.cloudId === null ? null : extractDeploymentId(cloudInfo.cloudId);
return cloudInfo.deploymentId;
}
export function extractDeploymentId(cloudId: string) {

View file

@ -64,3 +64,5 @@ export const getMlSharedImports = async () => {
// Helper to get Type returned by getMlSharedImports.
type AwaitReturnType<T> = T extends PromiseLike<infer U> ? U : T;
export type GetMlSharedImportsReturnType = AwaitReturnType<ReturnType<typeof getMlSharedImports>>;
export { MLJobsAwaitingNodeWarning } from './application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared';

View file

@ -14,7 +14,7 @@ import {
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import moment from 'moment';
import React, { Dispatch, useCallback, useReducer, useState } from 'react';
import React, { Dispatch, useCallback, useReducer, useState, useMemo } from 'react';
import styled from 'styled-components';
import { useKibana } from '../../lib/kibana';
@ -30,6 +30,7 @@ import * as i18n from './translations';
import { JobsFilters, SecurityJob } from './types';
import { UpgradeContents } from './upgrade_contents';
import { useSecurityJobs } from './hooks/use_security_jobs';
import { MLJobsAwaitingNodeWarning } from '../../../../../ml/public';
const PopoverContentsDiv = styled.div`
max-width: 684px;
@ -116,6 +117,10 @@ export const MlPopover = React.memo(() => {
});
const incompatibleJobCount = jobs.filter((j) => !j.isCompatible).length;
const installedJobsIds = useMemo(
() => jobs.filter((j) => j.isInstalled).map((j) => j.id),
[jobs]
);
if (!isLicensed) {
// If the user does not have platinum show upgrade UI
@ -216,6 +221,7 @@ export const MlPopover = React.memo(() => {
</>
)}
<MLJobsAwaitingNodeWarning jobIds={installedJobsIds} />
<JobsTable
isLoading={isLoadingSecurityJobs || isLoading}
jobs={filteredJobs}

View file

@ -41,6 +41,7 @@ const showMLJobNotification = (
basePath: string,
range: { to: string; from: string },
success: boolean,
awaitingNodeAssignment: boolean,
error?: Error
) => {
if (success) {
@ -51,7 +52,9 @@ const showMLJobNotification = (
),
text: toMountPoint(
<p>
{labels.JOB_CREATED_SUCCESS_MESSAGE}
{awaitingNodeAssignment
? labels.JOB_CREATED_LAZY_SUCCESS_MESSAGE
: labels.JOB_CREATED_SUCCESS_MESSAGE}
<MLJobLink monitorId={monitorId} basePath={basePath} dateRange={range}>
{labels.VIEW_JOB}
</MLJobLink>
@ -107,7 +110,8 @@ export const MachineLearningFlyout: React.FC<Props> = ({ onClose }) => {
monitorId as string,
basePath,
{ to: dateRangeEnd, from: dateRangeStart },
true
true,
hasMLJob.awaitingNodeAssignment
);
const loadMLJob = (jobId: string) =>
dispatch(getExistingMLJobAction.get({ monitorId: monitorId as string }));
@ -123,6 +127,7 @@ export const MachineLearningFlyout: React.FC<Props> = ({ onClose }) => {
basePath,
{ to: dateRangeEnd, from: dateRangeStart },
false,
false,
error as Error
);
}

View file

@ -22,6 +22,14 @@ export const JOB_CREATED_SUCCESS_MESSAGE = i18n.translate(
}
);
export const JOB_CREATED_LAZY_SUCCESS_MESSAGE = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.jobCreatedLazyNotificationText',
{
defaultMessage:
'The analysis is waiting for an ML node to become available. It might take a while before results are added to the response times graph.',
}
);
export const JOB_CREATION_FAILED = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.jobCreationFailedNotificationTitle',
{

View file

@ -48,6 +48,7 @@ export interface MonitorDetailsActionPayload {
export interface CreateMLJobSuccess {
count: number;
jobId: string;
awaitingNodeAssignment: boolean;
}
export interface DeleteJobResults {

View file

@ -57,10 +57,12 @@ export const createMLJob = async ({
const response: DataRecognizerConfigResponse = await apiService.post(url, data);
if (response?.jobs?.[0]?.id === getMLJobId(monitorId)) {
const jobResponse = response.jobs[0];
const datafeedResponse = response.datafeeds[0];
if (jobResponse.success) {
return {
count: 1,
jobId: jobResponse.id,
awaitingNodeAssignment: datafeedResponse.awaitingMlNodeAllocation === true,
};
} else {
const { error } = jobResponse;