mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[ML] Update the Notification indicator tooltip, add functional tests (#141775)
This commit is contained in:
parent
6993716021
commit
042c76687c
14 changed files with 557 additions and 32 deletions
|
@ -16,7 +16,10 @@ import {
|
|||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
import { combineLatest, of, timer } from 'rxjs';
|
||||
import { catchError, filter, switchMap } from 'rxjs/operators';
|
||||
import { catchError, switchMap } from 'rxjs/operators';
|
||||
import moment from 'moment';
|
||||
import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common';
|
||||
import { useFieldFormatter } from '../../contexts/kibana/use_field_formatter';
|
||||
import { useAsObservable } from '../../hooks';
|
||||
import { NotificationsCountResponse } from '../../../../common/types/notifications';
|
||||
import { useMlKibana } from '../../contexts/kibana';
|
||||
|
@ -31,21 +34,26 @@ export const NotificationsIndicator: FC = () => {
|
|||
mlServices: { mlApiServices },
|
||||
},
|
||||
} = useMlKibana();
|
||||
const [lastCheckedAt] = useStorage(ML_NOTIFICATIONS_LAST_CHECKED_AT);
|
||||
const dateFormatter = useFieldFormatter(FIELD_FORMAT_IDS.DATE);
|
||||
|
||||
const [lastCheckedAt] = useStorage(ML_NOTIFICATIONS_LAST_CHECKED_AT);
|
||||
const lastCheckedAt$ = useAsObservable(lastCheckedAt);
|
||||
|
||||
/** Holds the value used for the actual request */
|
||||
const [lastCheckRequested, setLastCheckRequested] = useState<number>();
|
||||
const [notificationsCounts, setNotificationsCounts] = useState<NotificationsCountResponse>();
|
||||
|
||||
useEffect(function startPollingNotifications() {
|
||||
const subscription = combineLatest([
|
||||
lastCheckedAt$.pipe(filter((v): v is number => !!v)),
|
||||
timer(0, NOTIFICATIONS_CHECK_INTERVAL),
|
||||
])
|
||||
const subscription = combineLatest([lastCheckedAt$, timer(0, NOTIFICATIONS_CHECK_INTERVAL)])
|
||||
.pipe(
|
||||
switchMap(([lastChecked]) =>
|
||||
mlApiServices.notifications.countMessages$({ lastCheckedAt: lastChecked })
|
||||
),
|
||||
switchMap(([lastChecked]) => {
|
||||
const lastCheckedAtQuery = lastChecked ?? moment().subtract(7, 'd').valueOf();
|
||||
setLastCheckRequested(lastCheckedAtQuery);
|
||||
// Use the latest check time or 7 days ago by default.
|
||||
return mlApiServices.notifications.countMessages$({
|
||||
lastCheckedAt: lastCheckedAtQuery,
|
||||
});
|
||||
}),
|
||||
catchError((error) => {
|
||||
// Fail silently for now
|
||||
return of({} as NotificationsCountResponse);
|
||||
|
@ -80,12 +88,22 @@ export const NotificationsIndicator: FC = () => {
|
|||
content={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.notificationsIndicator.errorsAndWarningLabel"
|
||||
defaultMessage="There {count, plural, one {is # notification} other {are # notifications}} with error or warning level"
|
||||
values={{ count: errorsAndWarningCount }}
|
||||
defaultMessage="There {count, plural, one {is # notification} other {are # notifications}} with error or warning level since {lastCheckedAt}"
|
||||
values={{
|
||||
count: errorsAndWarningCount,
|
||||
lastCheckedAt: dateFormatter(lastCheckRequested),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiNotificationBadge>{errorsAndWarningCount}</EuiNotificationBadge>
|
||||
<EuiNotificationBadge
|
||||
aria-label={i18n.translate('xpack.ml.notificationsIndicator.unreadErrors', {
|
||||
defaultMessage: 'Unread errors or warnings indicator.',
|
||||
})}
|
||||
data-test-subj={'mlNotificationErrorsIndicator'}
|
||||
>
|
||||
{errorsAndWarningCount}
|
||||
</EuiNotificationBadge>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
|
@ -96,7 +114,8 @@ export const NotificationsIndicator: FC = () => {
|
|||
content={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.notificationsIndicator.unreadLabel"
|
||||
defaultMessage="You have unread notifications"
|
||||
defaultMessage="You have unread notifications since {lastCheckedAt}"
|
||||
values={{ lastCheckedAt: dateFormatter(lastCheckRequested) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
@ -106,6 +125,7 @@ export const NotificationsIndicator: FC = () => {
|
|||
aria-label={i18n.translate('xpack.ml.notificationsIndicator.unreadIcon', {
|
||||
defaultMessage: 'Unread notifications indicator.',
|
||||
})}
|
||||
data-test-subj={'mlNotificationsIndicator'}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -162,6 +162,7 @@ export const NotificationsList: FC = () => {
|
|||
|
||||
const columns: Array<EuiBasicTableColumn<NotificationItem>> = [
|
||||
{
|
||||
id: 'timestamp',
|
||||
field: 'timestamp',
|
||||
name: <FormattedMessage id="xpack.ml.notifications.timeLabel" defaultMessage="Time" />,
|
||||
sortable: true,
|
||||
|
@ -175,7 +176,7 @@ export const NotificationsList: FC = () => {
|
|||
name: <FormattedMessage id="xpack.ml.notifications.levelLabel" defaultMessage="Level" />,
|
||||
sortable: true,
|
||||
truncateText: false,
|
||||
'data-test-subj': 'mlNotificationLabel',
|
||||
'data-test-subj': 'mlNotificationLevel',
|
||||
render: (value: MlNotificationMessageLevel) => {
|
||||
return <EuiBadge color={levelBadgeMap[value]}>{value}</EuiBadge>;
|
||||
},
|
||||
|
@ -194,7 +195,7 @@ export const NotificationsList: FC = () => {
|
|||
},
|
||||
{
|
||||
field: 'job_id',
|
||||
name: <FormattedMessage id="xpack.ml.notifications.entityLabe" defaultMessage="Entity ID" />,
|
||||
name: <FormattedMessage id="xpack.ml.notifications.entityLabel" defaultMessage="Entity ID" />,
|
||||
sortable: true,
|
||||
truncateText: false,
|
||||
'data-test-subj': 'mlNotificationEntity',
|
||||
|
@ -320,6 +321,7 @@ export const NotificationsList: FC = () => {
|
|||
},
|
||||
},
|
||||
},
|
||||
'data-test-subj': 'mlNotificationsSearchBarInput',
|
||||
}}
|
||||
filters={filters}
|
||||
onChange={(e) => {
|
||||
|
|
|
@ -8,26 +8,10 @@
|
|||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
|
||||
export const getNotificationsQuerySchema = schema.object({
|
||||
/**
|
||||
* Message level, e.g. info, error
|
||||
*/
|
||||
level: schema.maybe(schema.string()),
|
||||
/**
|
||||
* Message type, e.g. anomaly_detector
|
||||
*/
|
||||
type: schema.maybe(schema.string()),
|
||||
/**
|
||||
* Search string for the message content
|
||||
*/
|
||||
queryString: schema.maybe(schema.string()),
|
||||
/**
|
||||
* Page numer, zero-indexed
|
||||
*/
|
||||
from: schema.number({ defaultValue: 0 }),
|
||||
/**
|
||||
* Number of messages to return
|
||||
*/
|
||||
size: schema.number({ defaultValue: 10 }),
|
||||
/**
|
||||
* Sort field
|
||||
*/
|
||||
|
|
|
@ -67,5 +67,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
|
|||
loadTestFile(require.resolve('./saved_objects'));
|
||||
loadTestFile(require.resolve('./system'));
|
||||
loadTestFile(require.resolve('./trained_models'));
|
||||
loadTestFile(require.resolve('./notifications'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import moment from 'moment';
|
||||
import type { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
import { USER } from '../../../../functional/services/ml/security_common';
|
||||
import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api';
|
||||
|
||||
export default ({ getService }: FtrProviderContext) => {
|
||||
const supertest = getService('supertestWithoutAuth');
|
||||
const ml = getService('ml');
|
||||
|
||||
describe('GET notifications count', () => {
|
||||
before(async () => {
|
||||
await ml.api.initSavedObjects();
|
||||
await ml.testResources.setKibanaTimeZoneToUTC();
|
||||
|
||||
const adJobConfig = ml.commonConfig.getADFqSingleMetricJobConfig('fq_job');
|
||||
await ml.api.createAnomalyDetectionJob(adJobConfig);
|
||||
|
||||
await ml.api.waitForJobNotificationsToIndex('fq_job');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await ml.api.cleanMlIndices();
|
||||
await ml.testResources.cleanMLSavedObjects();
|
||||
});
|
||||
|
||||
it('return notifications count by level', async () => {
|
||||
const { body, status } = await supertest
|
||||
.get(`/api/ml/notifications/count`)
|
||||
.query({ lastCheckedAt: moment().subtract(7, 'd').valueOf() })
|
||||
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
|
||||
.set(COMMON_REQUEST_HEADERS);
|
||||
ml.api.assertResponseStatusCode(200, status, body);
|
||||
|
||||
expect(body.info).to.eql(1);
|
||||
expect(body.warning).to.eql(0);
|
||||
expect(body.error).to.eql(0);
|
||||
});
|
||||
|
||||
it('returns an error for unauthorized user', async () => {
|
||||
const { body, status } = await supertest
|
||||
.get(`/api/ml/notifications/count`)
|
||||
.auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED))
|
||||
.set(COMMON_REQUEST_HEADERS);
|
||||
ml.api.assertResponseStatusCode(403, status, body);
|
||||
});
|
||||
});
|
||||
};
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import type {
|
||||
NotificationItem,
|
||||
NotificationsSearchResponse,
|
||||
} from '@kbn/ml-plugin/common/types/notifications';
|
||||
import type { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
import { USER } from '../../../../functional/services/ml/security_common';
|
||||
import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api';
|
||||
|
||||
export default ({ getService }: FtrProviderContext) => {
|
||||
const supertest = getService('supertestWithoutAuth');
|
||||
const esArchiver = getService('esArchiver');
|
||||
const ml = getService('ml');
|
||||
|
||||
describe('GET notifications', () => {
|
||||
before(async () => {
|
||||
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/bm_classification');
|
||||
await ml.api.initSavedObjects();
|
||||
await ml.testResources.setKibanaTimeZoneToUTC();
|
||||
|
||||
const adJobConfig = ml.commonConfig.getADFqSingleMetricJobConfig('fq_job');
|
||||
await ml.api.createAnomalyDetectionJob(adJobConfig);
|
||||
|
||||
const dfaJobConfig = ml.commonConfig.getDFABmClassificationJobConfig('df_job');
|
||||
await ml.api.createDataFrameAnalyticsJob(dfaJobConfig);
|
||||
|
||||
// wait for notification to index
|
||||
|
||||
await ml.api.waitForJobNotificationsToIndex('fq_job');
|
||||
await ml.api.waitForJobNotificationsToIndex('df_job');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await ml.api.cleanMlIndices();
|
||||
await ml.testResources.cleanMLSavedObjects();
|
||||
});
|
||||
|
||||
it('return all notifications ', async () => {
|
||||
const { body, status } = await supertest
|
||||
.get(`/api/ml/notifications`)
|
||||
.query({ earliest: 'now-1d', latest: 'now' })
|
||||
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
|
||||
.set(COMMON_REQUEST_HEADERS);
|
||||
ml.api.assertResponseStatusCode(200, status, body);
|
||||
|
||||
expect((body as NotificationsSearchResponse).total).to.eql(2);
|
||||
});
|
||||
|
||||
it('return notifications based on the query string', async () => {
|
||||
const { body, status } = await supertest
|
||||
.get(`/api/ml/notifications`)
|
||||
.query({ earliest: 'now-1d', latest: 'now', queryString: 'job_type:anomaly_detector' })
|
||||
.auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER))
|
||||
.set(COMMON_REQUEST_HEADERS);
|
||||
ml.api.assertResponseStatusCode(200, status, body);
|
||||
|
||||
expect((body as NotificationsSearchResponse).total).to.eql(1);
|
||||
expect(
|
||||
(body as NotificationsSearchResponse).results.filter(
|
||||
(result: NotificationItem) => result.job_type === 'anomaly_detector'
|
||||
)
|
||||
).to.length(body.total);
|
||||
});
|
||||
|
||||
it('supports sorting asc sorting by field', async () => {
|
||||
const { body, status } = await supertest
|
||||
.get(`/api/ml/notifications`)
|
||||
.query({ earliest: 'now-1d', latest: 'now', sortField: 'job_id', sortDirection: 'asc' })
|
||||
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
|
||||
.set(COMMON_REQUEST_HEADERS);
|
||||
ml.api.assertResponseStatusCode(200, status, body);
|
||||
|
||||
expect(body.results[0].job_id).to.eql('df_job');
|
||||
});
|
||||
|
||||
it('supports sorting desc sorting by field', async () => {
|
||||
const { body, status } = await supertest
|
||||
.get(`/api/ml/notifications`)
|
||||
.query({ earliest: 'now-1h', latest: 'now', sortField: 'job_id', sortDirection: 'desc' })
|
||||
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
|
||||
.set(COMMON_REQUEST_HEADERS);
|
||||
ml.api.assertResponseStatusCode(200, status, body);
|
||||
|
||||
expect(body.results[0].job_id).to.eql('fq_job');
|
||||
});
|
||||
|
||||
it('returns an error for unauthorized user', async () => {
|
||||
const { body, status } = await supertest
|
||||
.get(`/api/ml/notifications`)
|
||||
.auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED))
|
||||
.set(COMMON_REQUEST_HEADERS);
|
||||
ml.api.assertResponseStatusCode(403, status, body);
|
||||
});
|
||||
});
|
||||
};
|
15
x-pack/test/api_integration/apis/ml/notifications/index.ts
Normal file
15
x-pack/test/api_integration/apis/ml/notifications/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('Notifications', function () {
|
||||
loadTestFile(require.resolve('./get_notifications'));
|
||||
loadTestFile(require.resolve('./count_notifications'));
|
||||
});
|
||||
}
|
|
@ -33,5 +33,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
|
|||
loadTestFile(require.resolve('./model_management'));
|
||||
loadTestFile(require.resolve('./feature_controls'));
|
||||
loadTestFile(require.resolve('./settings'));
|
||||
loadTestFile(require.resolve('./notifications'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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 { FtrProviderContext } from '../../../../ftr_provider_context';
|
||||
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('Notifcations', function () {
|
||||
this.tags(['ml', 'skipFirefox']);
|
||||
|
||||
loadTestFile(require.resolve('./notification_list'));
|
||||
});
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* 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 { FtrProviderContext } from '../../../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const PageObjects = getPageObjects(['common']);
|
||||
const esArchiver = getService('esArchiver');
|
||||
const ml = getService('ml');
|
||||
const browser = getService('browser');
|
||||
|
||||
describe('Notifications list', function () {
|
||||
before(async () => {
|
||||
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote');
|
||||
await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp');
|
||||
await ml.testResources.setKibanaTimeZoneToUTC();
|
||||
|
||||
// Prepare jobs to generate notifications
|
||||
await Promise.all(
|
||||
[
|
||||
{ jobId: 'fq_001', spaceId: undefined },
|
||||
{ jobId: 'fq_002', spaceId: 'space1' },
|
||||
].map(async (v) => {
|
||||
const datafeedConfig = ml.commonConfig.getADFqDatafeedConfig(v.jobId);
|
||||
|
||||
await ml.api.createAnomalyDetectionJob(
|
||||
ml.commonConfig.getADFqSingleMetricJobConfig(v.jobId),
|
||||
v.spaceId
|
||||
);
|
||||
await ml.api.openAnomalyDetectionJob(v.jobId);
|
||||
await ml.api.createDatafeed(datafeedConfig, v.spaceId);
|
||||
await ml.api.startDatafeed(datafeedConfig.datafeed_id);
|
||||
})
|
||||
);
|
||||
|
||||
await ml.securityUI.loginAsMlPowerUser();
|
||||
await PageObjects.common.navigateToApp('ml', {
|
||||
basePath: '',
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await ml.api.cleanMlIndices();
|
||||
await ml.testResources.cleanMLSavedObjects();
|
||||
await ml.testResources.deleteIndexPatternByTitle('ft_farequote');
|
||||
});
|
||||
|
||||
it('displays a generic notification indicator', async () => {
|
||||
await ml.notifications.assertNotificationIndicatorExist();
|
||||
});
|
||||
|
||||
it('opens the Notifications page', async () => {
|
||||
await ml.navigation.navigateToNotifications();
|
||||
|
||||
await ml.notifications.table.waitForTableToLoad();
|
||||
await ml.notifications.table.assertRowsNumberPerPage(25);
|
||||
});
|
||||
|
||||
it('does not show notifications from another space', async () => {
|
||||
await ml.notifications.table.filterWithSearchString('Job created', 1);
|
||||
});
|
||||
|
||||
it('display a number of errors in the notification indicator', async () => {
|
||||
await ml.navigation.navigateToOverview();
|
||||
|
||||
const jobConfig = ml.commonConfig.getADFqSingleMetricJobConfig('fq_fail');
|
||||
jobConfig.analysis_config = {
|
||||
bucket_span: '15m',
|
||||
influencers: ['airline'],
|
||||
detectors: [
|
||||
{ function: 'mean', field_name: 'responsetime', partition_field_name: 'airline' },
|
||||
{ function: 'min', field_name: 'responsetime', partition_field_name: 'airline' },
|
||||
{ function: 'max', field_name: 'responsetime', partition_field_name: 'airline' },
|
||||
],
|
||||
};
|
||||
// Set extremely low memory limit to trigger an error
|
||||
jobConfig.analysis_limits!.model_memory_limit = '1024kb';
|
||||
|
||||
const datafeedConfig = ml.commonConfig.getADFqDatafeedConfig(jobConfig.job_id);
|
||||
|
||||
await ml.api.createAnomalyDetectionJob(jobConfig);
|
||||
await ml.api.openAnomalyDetectionJob(jobConfig.job_id);
|
||||
await ml.api.createDatafeed(datafeedConfig);
|
||||
await ml.api.startDatafeed(datafeedConfig.datafeed_id);
|
||||
await ml.api.waitForJobMemoryStatus(jobConfig.job_id, 'hard_limit');
|
||||
|
||||
// refresh the page to avoid 1m wait
|
||||
await browser.refresh();
|
||||
await ml.notifications.assertNotificationErrorsCount(0);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -273,6 +273,16 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) {
|
|||
return state;
|
||||
},
|
||||
|
||||
async getJobMemoryStatus(jobId: string): Promise<'hard_limit' | 'soft_limit' | 'ok'> {
|
||||
const jobStats = await this.getADJobStats(jobId);
|
||||
|
||||
expect(jobStats.jobs).to.have.length(
|
||||
1,
|
||||
`Expected job stats to have exactly one job (got '${jobStats.length}')`
|
||||
);
|
||||
return jobStats.jobs[0].model_size_stats.memory_status;
|
||||
},
|
||||
|
||||
async getADJobStats(jobId: string): Promise<any> {
|
||||
log.debug(`Fetching anomaly detection job stats for job ${jobId}...`);
|
||||
const { body: jobStats, status } = await esSupertest.get(
|
||||
|
@ -299,6 +309,27 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) {
|
|||
});
|
||||
},
|
||||
|
||||
async waitForJobMemoryStatus(
|
||||
jobId: string,
|
||||
expectedMemoryStatus: 'hard_limit' | 'soft_limit' | 'ok',
|
||||
timeout: number = 2 * 60 * 1000
|
||||
) {
|
||||
await retry.waitForWithTimeout(
|
||||
`job memory status to be ${expectedMemoryStatus}`,
|
||||
timeout,
|
||||
async () => {
|
||||
const memoryStatus = await this.getJobMemoryStatus(jobId);
|
||||
if (memoryStatus === expectedMemoryStatus) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(
|
||||
`expected job memory status to be ${expectedMemoryStatus} but got ${memoryStatus}`
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
async getDatafeedState(datafeedId: string): Promise<DATAFEED_STATE> {
|
||||
log.debug(`Fetching datafeed state for datafeed ${datafeedId}`);
|
||||
const { body: datafeedStats, status } = await esSupertest.get(
|
||||
|
@ -576,6 +607,18 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) {
|
|||
return response;
|
||||
},
|
||||
|
||||
async hasNotifications(query: object) {
|
||||
const body = await es.search({
|
||||
index: '.ml-notifications*',
|
||||
body: {
|
||||
size: 10000,
|
||||
query,
|
||||
},
|
||||
});
|
||||
|
||||
return body.hits.hits.length > 0;
|
||||
},
|
||||
|
||||
async adJobExist(jobId: string) {
|
||||
this.validateJobId(jobId);
|
||||
try {
|
||||
|
@ -608,6 +651,24 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) {
|
|||
});
|
||||
},
|
||||
|
||||
async waitForJobNotificationsToIndex(jobId: string, timeout: number = 60 * 1000) {
|
||||
await retry.waitForWithTimeout(`Notifications for '${jobId}' to exist`, timeout, async () => {
|
||||
if (
|
||||
await this.hasNotifications({
|
||||
term: {
|
||||
job_id: {
|
||||
value: jobId,
|
||||
},
|
||||
},
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(`expected '${jobId}' notifications to exist`);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async createAnomalyDetectionJob(jobConfig: Job, space?: string) {
|
||||
const jobId = jobConfig.job_id;
|
||||
log.debug(
|
||||
|
|
118
x-pack/test/functional/services/ml/common_table_service.ts
Normal file
118
x-pack/test/functional/services/ml/common_table_service.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export type MlTableService = ReturnType<typeof MlTableServiceProvider>;
|
||||
|
||||
export function MlTableServiceProvider({ getPageObject, getService }: FtrProviderContext) {
|
||||
const testSubjects = getService('testSubjects');
|
||||
const commonPage = getPageObject('common');
|
||||
|
||||
const TableService = class {
|
||||
constructor(
|
||||
public readonly tableTestSubj: string,
|
||||
public readonly tableRowSubj: string,
|
||||
public readonly columns: Array<{ id: string; testSubj: string }>,
|
||||
public readonly searchInputSubj: string
|
||||
) {}
|
||||
|
||||
public async assertTableLoaded() {
|
||||
await testSubjects.existOrFail(`~${this.tableTestSubj} loaded`);
|
||||
}
|
||||
|
||||
public async assertTableLoading() {
|
||||
await testSubjects.existOrFail(`~${this.tableTestSubj} loading`);
|
||||
}
|
||||
|
||||
public async parseTable() {
|
||||
const table = await testSubjects.find(`~${this.tableTestSubj}`);
|
||||
const $ = await table.parseDomContent();
|
||||
const rows = [];
|
||||
|
||||
for (const tr of $.findTestSubjects(`~${this.tableRowSubj}`).toArray()) {
|
||||
const $tr = $(tr);
|
||||
|
||||
const rowObject = this.columns.reduce((acc, curr) => {
|
||||
acc[curr.id] = $tr
|
||||
.findTestSubject(curr.testSubj)
|
||||
.find('.euiTableCellContent')
|
||||
.text()
|
||||
.trim();
|
||||
return acc;
|
||||
}, {} as Record<typeof this.columns[number]['id'], string>);
|
||||
|
||||
rows.push(rowObject);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
public async assertRowsNumberPerPage(rowsNumber: 10 | 25 | 50 | 100) {
|
||||
const textContent = await testSubjects.getVisibleText(
|
||||
`~${this.tableTestSubj} > tablePaginationPopoverButton`
|
||||
);
|
||||
expect(textContent).to.be(`Rows per page: ${rowsNumber}`);
|
||||
}
|
||||
|
||||
public async waitForTableToStartLoading() {
|
||||
await testSubjects.existOrFail(`~${this.tableTestSubj}`, { timeout: 60 * 1000 });
|
||||
await testSubjects.existOrFail(`${this.tableTestSubj} loading`, { timeout: 30 * 1000 });
|
||||
}
|
||||
|
||||
public async waitForTableToLoad() {
|
||||
await testSubjects.existOrFail(`~${this.tableTestSubj}`, { timeout: 60 * 1000 });
|
||||
await testSubjects.existOrFail(`${this.tableTestSubj} loaded`, { timeout: 30 * 1000 });
|
||||
}
|
||||
|
||||
async getSearchInput(): Promise<WebElementWrapper> {
|
||||
return await testSubjects.find(this.searchInputSubj);
|
||||
}
|
||||
|
||||
public async assertSearchInputValue(expectedSearchValue: string) {
|
||||
const searchBarInput = await this.getSearchInput();
|
||||
const actualSearchValue = await searchBarInput.getAttribute('value');
|
||||
expect(actualSearchValue).to.eql(
|
||||
expectedSearchValue,
|
||||
`Search input value should be '${expectedSearchValue}' (got '${actualSearchValue}')`
|
||||
);
|
||||
}
|
||||
|
||||
public async filterWithSearchString(queryString: string, expectedRowCount: number = 1) {
|
||||
await this.waitForTableToLoad();
|
||||
const searchBarInput = await this.getSearchInput();
|
||||
await searchBarInput.clearValueWithKeyboard();
|
||||
await searchBarInput.type(queryString);
|
||||
await commonPage.pressEnterKey();
|
||||
await this.assertSearchInputValue(queryString);
|
||||
await this.waitForTableToStartLoading();
|
||||
await this.waitForTableToLoad();
|
||||
|
||||
const rows = await this.parseTable();
|
||||
|
||||
expect(rows).to.have.length(
|
||||
expectedRowCount,
|
||||
`Filtered table should have ${expectedRowCount} row(s) for filter '${queryString}' (got ${rows.length} matching items)`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getServiceInstance(
|
||||
name: string,
|
||||
tableTestSubj: string,
|
||||
tableRowSubj: string,
|
||||
columns: Array<{ id: string; testSubj: string }>,
|
||||
searchInputSubj: string
|
||||
) {
|
||||
Object.defineProperty(TableService, 'name', { value: name });
|
||||
return new TableService(tableTestSubj, tableRowSubj, columns, searchInputSubj);
|
||||
},
|
||||
};
|
||||
}
|
|
@ -59,6 +59,8 @@ import { MachineLearningJobAnnotationsProvider } from './job_annotations_table';
|
|||
import { MlNodesPanelProvider } from './ml_nodes_list';
|
||||
import { MachineLearningCasesProvider } from './cases';
|
||||
import { AnomalyChartsProvider } from './anomaly_charts';
|
||||
import { NotificationsProvider } from './notifications';
|
||||
import { MlTableServiceProvider } from './common_table_service';
|
||||
|
||||
export function MachineLearningProvider(context: FtrProviderContext) {
|
||||
const commonAPI = MachineLearningCommonAPIProvider(context);
|
||||
|
@ -123,6 +125,7 @@ export function MachineLearningProvider(context: FtrProviderContext) {
|
|||
const settingsFilterList = MachineLearningSettingsFilterListProvider(context, commonUI);
|
||||
const singleMetricViewer = MachineLearningSingleMetricViewerProvider(context, commonUI);
|
||||
const stackManagementJobs = MachineLearningStackManagementJobsProvider(context);
|
||||
const tableService = MlTableServiceProvider(context);
|
||||
const testExecution = MachineLearningTestExecutionProvider(context);
|
||||
const testResources = MachineLearningTestResourcesProvider(context, api);
|
||||
const alerting = MachineLearningAlertingProvider(context, api, commonUI);
|
||||
|
@ -130,6 +133,7 @@ export function MachineLearningProvider(context: FtrProviderContext) {
|
|||
const trainedModels = TrainedModelsProvider(context, commonUI);
|
||||
const trainedModelsTable = TrainedModelsTableProvider(context, commonUI);
|
||||
const mlNodesPanel = MlNodesPanelProvider(context);
|
||||
const notifications = NotificationsProvider(context, commonUI, tableService);
|
||||
|
||||
const cases = MachineLearningCasesProvider(context, swimLane, anomalyCharts);
|
||||
|
||||
|
@ -171,7 +175,9 @@ export function MachineLearningProvider(context: FtrProviderContext) {
|
|||
jobWizardMultiMetric,
|
||||
jobWizardPopulation,
|
||||
lensVisualizations,
|
||||
mlNodesPanel,
|
||||
navigation,
|
||||
notifications,
|
||||
overviewPage,
|
||||
securityCommon,
|
||||
securityUI,
|
||||
|
@ -181,10 +187,10 @@ export function MachineLearningProvider(context: FtrProviderContext) {
|
|||
singleMetricViewer,
|
||||
stackManagementJobs,
|
||||
swimLane,
|
||||
tableService,
|
||||
testExecution,
|
||||
testResources,
|
||||
trainedModels,
|
||||
trainedModelsTable,
|
||||
mlNodesPanel,
|
||||
};
|
||||
}
|
||||
|
|
48
x-pack/test/functional/services/ml/notifications.ts
Normal file
48
x-pack/test/functional/services/ml/notifications.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
import { MlCommonUI } from './common_ui';
|
||||
import { MlTableService } from './common_table_service';
|
||||
|
||||
export function NotificationsProvider(
|
||||
{ getService }: FtrProviderContext,
|
||||
mlCommonUI: MlCommonUI,
|
||||
tableService: MlTableService
|
||||
) {
|
||||
const testSubjects = getService('testSubjects');
|
||||
|
||||
return {
|
||||
async assertNotificationIndicatorExist(expectExist = true) {
|
||||
if (expectExist) {
|
||||
await testSubjects.existOrFail('mlNotificationsIndicator');
|
||||
} else {
|
||||
await testSubjects.missingOrFail('mlNotificationsIndicator');
|
||||
}
|
||||
},
|
||||
|
||||
async assertNotificationErrorsCount(expectedCount: number) {
|
||||
const actualCount = await testSubjects.getVisibleText('mlNotificationErrorsIndicator');
|
||||
expect(actualCount).to.greaterThan(expectedCount);
|
||||
},
|
||||
|
||||
table: tableService.getServiceInstance(
|
||||
'NotificationsTable',
|
||||
'mlNotificationsTable',
|
||||
'mlNotificationsTableRow',
|
||||
[
|
||||
{ id: 'timestamp', testSubj: 'mlNotificationTime' },
|
||||
{ id: 'level', testSubj: 'mlNotificationLevel' },
|
||||
{ id: 'job_type', testSubj: 'mlNotificationType' },
|
||||
{ id: 'job_id', testSubj: 'mlNotificationEntity' },
|
||||
{ id: 'message', testSubj: 'mlNotificationMessage' },
|
||||
],
|
||||
'mlNotificationsSearchBarInput'
|
||||
),
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue