[ML] Update the Notification indicator tooltip, add functional tests (#141775)

This commit is contained in:
Dima Arnautov 2022-09-28 12:34:30 +02:00 committed by GitHub
parent 6993716021
commit 042c76687c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 557 additions and 32 deletions

View file

@ -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>

View file

@ -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) => {

View file

@ -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
*/

View file

@ -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'));
});
}

View file

@ -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);
});
});
};

View file

@ -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);
});
});
};

View 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'));
});
}

View file

@ -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'));
});
}

View file

@ -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'));
});
}

View file

@ -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);
});
});
}

View file

@ -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(

View 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);
},
};
}

View file

@ -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,
};
}

View 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'
),
};
}