[ML] Show an info callout for new notifications (#142245)

* storage context callbacks

* ml notifications context

* ml notifications context usage

* info callout

* update icon

* add unit tests
This commit is contained in:
Dima Arnautov 2022-09-30 18:46:03 +02:00 committed by GitHub
parent e7c2983173
commit 874c93c329
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 279 additions and 71 deletions

View file

@ -15,6 +15,7 @@ import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { MlStorageContextProvider } from './contexts/storage';
import { setDependencyCache, clearCache } from './util/dependency_cache';
import { setLicenseCache } from './license';
import type { MlSetupDependencies, MlStartDependencies } from '../plugin';
@ -109,7 +110,9 @@ const App: FC<AppProps> = ({ coreStart, deps, appMountParams }) => {
mlServices: getMlGlobalServices(coreStart.http, deps.usageCollection),
}}
>
<MlRouter pageDeps={pageDeps} />
<MlStorageContextProvider>
<MlRouter pageDeps={pageDeps} />
</MlStorageContextProvider>
</KibanaContextProvider>
</KibanaThemeProvider>
</I18nContext>

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { FC, useEffect, useState } from 'react';
import React, { FC } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import {
@ -15,60 +15,14 @@ import {
EuiNotificationBadge,
EuiToolTip,
} from '@elastic/eui';
import { combineLatest, of, timer } from 'rxjs';
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';
import { useStorage } from '../../contexts/storage';
import { ML_NOTIFICATIONS_LAST_CHECKED_AT } from '../../../../common/types/storage';
const NOTIFICATIONS_CHECK_INTERVAL = 60000;
import { useMlNotifications } from '../../contexts/ml/ml_notifications_context';
export const NotificationsIndicator: FC = () => {
const {
services: {
mlServices: { mlApiServices },
},
} = useMlKibana();
const { notificationsCounts, latestRequestedAt } = useMlNotifications();
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$, timer(0, NOTIFICATIONS_CHECK_INTERVAL)])
.pipe(
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);
})
)
.subscribe((response) => {
setNotificationsCounts(response);
});
return () => {
subscription.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const errorsAndWarningCount =
(notificationsCounts?.error ?? 0) + (notificationsCounts?.warning ?? 0);
const hasUnread = notificationsCounts && Object.values(notificationsCounts).some((v) => v > 0);
@ -91,7 +45,7 @@ export const NotificationsIndicator: FC = () => {
defaultMessage="There {count, plural, one {is # notification} other {are # notifications}} with error or warning level since {lastCheckedAt}"
values={{
count: errorsAndWarningCount,
lastCheckedAt: dateFormatter(lastCheckRequested),
lastCheckedAt: dateFormatter(latestRequestedAt),
}}
/>
}
@ -115,7 +69,7 @@ export const NotificationsIndicator: FC = () => {
<FormattedMessage
id="xpack.ml.notificationsIndicator.unreadLabel"
defaultMessage="You have unread notifications since {lastCheckedAt}"
values={{ lastCheckedAt: dateFormatter(lastCheckRequested) }}
values={{ lastCheckedAt: dateFormatter(latestRequestedAt) }}
/>
}
>

View file

@ -0,0 +1,140 @@
/*
* 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 { renderHook, act } from '@testing-library/react-hooks';
import { of } from 'rxjs';
import { useMlNotifications, MlNotificationsContextProvider } from './ml_notifications_context';
import { useStorage } from '../storage';
const mockCountMessages = jest.fn(() => {
return of({ info: 1, error: 0, warning: 0 });
});
jest.mock('../kibana', () => ({
useMlKibana: () => {
return {
services: {
mlServices: {
mlApiServices: {
notifications: {
countMessages$: mockCountMessages,
},
},
},
},
};
},
}));
const mockSetStorageValue = jest.fn();
jest.mock('../storage', () => ({
useStorage: jest.fn(() => {
return [undefined, mockSetStorageValue];
}),
}));
describe('useMlNotifications', () => {
beforeEach(() => {
jest.useFakeTimers('modern');
jest.setSystemTime(1663945337063);
});
afterEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
jest.useRealTimers();
});
test('returns the default values', () => {
const { result } = renderHook(useMlNotifications, { wrapper: MlNotificationsContextProvider });
expect(result.current.notificationsCounts).toEqual({ info: 0, error: 0, warning: 0 });
expect(result.current.latestRequestedAt).toEqual(null);
expect(result.current.lastCheckedAt).toEqual(undefined);
});
test('starts polling for notifications with a 1 minute interval during the last week by default ', () => {
const { result } = renderHook(useMlNotifications, {
wrapper: MlNotificationsContextProvider,
});
act(() => {
jest.advanceTimersByTime(0);
});
expect(mockCountMessages).toHaveBeenCalledTimes(1);
expect(mockCountMessages).toHaveBeenCalledWith({ lastCheckedAt: 1663340537063 });
expect(result.current.notificationsCounts).toEqual({ info: 1, error: 0, warning: 0 });
expect(result.current.latestRequestedAt).toEqual(1663340537063);
expect(result.current.lastCheckedAt).toEqual(undefined);
act(() => {
mockCountMessages.mockReturnValueOnce(of({ info: 1, error: 2, warning: 0 }));
jest.advanceTimersByTime(60000);
});
expect(mockCountMessages).toHaveBeenCalledTimes(2);
expect(mockCountMessages).toHaveBeenCalledWith({ lastCheckedAt: 1663340537063 + 60000 });
expect(result.current.notificationsCounts).toEqual({ info: 1, error: 2, warning: 0 });
expect(result.current.latestRequestedAt).toEqual(1663340537063 + 60000);
expect(result.current.lastCheckedAt).toEqual(undefined);
});
test('starts polling for notifications with a 1 minute interval using the lastCheckedAt from storage', () => {
(useStorage as jest.MockedFunction<typeof useStorage>).mockReturnValue([
1664551009292,
mockSetStorageValue,
]);
const { result } = renderHook(useMlNotifications, {
wrapper: MlNotificationsContextProvider,
});
act(() => {
jest.advanceTimersByTime(0);
});
expect(mockCountMessages).toHaveBeenCalledTimes(1);
expect(mockCountMessages).toHaveBeenCalledWith({ lastCheckedAt: 1664551009292 });
expect(result.current.notificationsCounts).toEqual({ info: 1, error: 0, warning: 0 });
expect(result.current.latestRequestedAt).toEqual(1664551009292);
expect(result.current.lastCheckedAt).toEqual(1664551009292);
});
test('switches to polling with the lastCheckedAt from storage when available', () => {
(useStorage as jest.MockedFunction<typeof useStorage>).mockReturnValue([
undefined,
mockSetStorageValue,
]);
const { result, rerender } = renderHook(useMlNotifications, {
wrapper: MlNotificationsContextProvider,
});
act(() => {
jest.advanceTimersByTime(0);
});
expect(mockCountMessages).toHaveBeenCalledTimes(1);
expect(mockCountMessages).toHaveBeenCalledWith({ lastCheckedAt: 1663340537063 });
expect(result.current.notificationsCounts).toEqual({ info: 1, error: 0, warning: 0 });
expect(result.current.latestRequestedAt).toEqual(1663340537063);
expect(result.current.lastCheckedAt).toEqual(undefined);
act(() => {
(useStorage as jest.MockedFunction<typeof useStorage>).mockReturnValue([
1664551009292,
mockSetStorageValue,
]);
});
rerender();
expect(mockCountMessages).toHaveBeenCalledTimes(2);
expect(mockCountMessages).toHaveBeenCalledWith({ lastCheckedAt: 1664551009292 });
expect(result.current.notificationsCounts).toEqual({ info: 1, error: 0, warning: 0 });
expect(result.current.latestRequestedAt).toEqual(1664551009292);
expect(result.current.lastCheckedAt).toEqual(1664551009292);
});
});

View file

@ -0,0 +1,91 @@
/*
* 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, useContext, useEffect, useState } from 'react';
import { combineLatest, of, timer } from 'rxjs';
import { catchError, switchMap, map, tap } from 'rxjs/operators';
import moment from 'moment';
import { useMlKibana } from '../kibana';
import { useStorage } from '../storage';
import { ML_NOTIFICATIONS_LAST_CHECKED_AT } from '../../../../common/types/storage';
import { useAsObservable } from '../../hooks';
import type { NotificationsCountResponse } from '../../../../common/types/notifications';
const NOTIFICATIONS_CHECK_INTERVAL = 60000;
export const MlNotificationsContext = React.createContext<{
notificationsCounts: NotificationsCountResponse;
/** Timestamp of the latest notification checked by the user */
lastCheckedAt: number | null;
/** Holds the value used for the actual request */
latestRequestedAt: number | null;
setLastCheckedAt: (v: number) => void;
}>({
notificationsCounts: { info: 0, error: 0, warning: 0 },
lastCheckedAt: null,
latestRequestedAt: null,
setLastCheckedAt: () => {},
});
export const MlNotificationsContextProvider: FC = ({ children }) => {
const {
services: {
mlServices: { mlApiServices },
},
} = useMlKibana();
const [lastCheckedAt, setLastCheckedAt] = useStorage(ML_NOTIFICATIONS_LAST_CHECKED_AT);
const lastCheckedAt$ = useAsObservable(lastCheckedAt);
/** Holds the value used for the actual request */
const [latestRequestedAt, setLatestRequestedAt] = useState<number | null>(null);
const [notificationsCounts, setNotificationsCounts] = useState<NotificationsCountResponse>({
info: 0,
error: 0,
warning: 0,
});
useEffect(function startPollingNotifications() {
const subscription = combineLatest([lastCheckedAt$, timer(0, NOTIFICATIONS_CHECK_INTERVAL)])
.pipe(
// Use the latest check time or 7 days ago by default.
map(([lastChecked]) => lastChecked ?? moment().subtract(7, 'd').valueOf()),
tap((lastCheckedAtQuery) => {
setLatestRequestedAt(lastCheckedAtQuery);
}),
switchMap((lastCheckedAtQuery) =>
mlApiServices.notifications.countMessages$({
lastCheckedAt: lastCheckedAtQuery,
})
),
catchError((error) => {
// Fail silently for now
return of({} as NotificationsCountResponse);
})
)
.subscribe((response) => {
setNotificationsCounts(response);
});
return () => {
subscription.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<MlNotificationsContext.Provider
value={{ notificationsCounts, lastCheckedAt, setLastCheckedAt, latestRequestedAt }}
>
{children}
</MlNotificationsContext.Provider>
);
};
export function useMlNotifications() {
return useContext(MlNotificationsContext);
}

View file

@ -33,10 +33,12 @@ export const MlStorageContextProvider: FC = ({ children }) => {
services: { storage },
} = useMlKibana();
const initialValue = ML_STORAGE_KEYS.reduce((acc, curr) => {
acc[curr as MlStorageKey] = storage.get(curr);
return acc;
}, {} as Exclude<MlStorage, null>);
const initialValue = useMemo(() => {
return ML_STORAGE_KEYS.reduce((acc, curr) => {
acc[curr as MlStorageKey] = storage.get(curr);
return acc;
}, {} as Exclude<MlStorage, null>);
}, [storage]);
const [state, setState] = useState<MlStorage>(initialValue);
@ -44,21 +46,20 @@ export const MlStorageContextProvider: FC = ({ children }) => {
<K extends MlStorageKey, T extends TMlStorageMapped<K>>(key: K, value: T) => {
storage.set(key, value);
const update = {
...state,
setState((prevState) => ({
...prevState,
[key]: value,
};
setState(update);
}));
},
[state, storage]
[storage]
);
const removeStorageValue = useCallback(
(key: MlStorageKey) => {
storage.remove(key);
setState(omit(state, key));
setState((prevState) => omit(prevState, key));
},
[state, storage]
[storage]
);
useEffect(function updateStorageOnExternalChange() {

View file

@ -22,9 +22,8 @@ import {
import { EuiBasicTableColumn } from '@elastic/eui/src/components/basic_table/basic_table';
import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common';
import useDebounce from 'react-use/lib/useDebounce';
import { useMlNotifications } from '../../contexts/ml/ml_notifications_context';
import { ML_NOTIFICATIONS_MESSAGE_LEVEL } from '../../../../common/constants/notifications';
import { ML_NOTIFICATIONS_LAST_CHECKED_AT } from '../../../../common/types/storage';
import { useStorage } from '../../contexts/storage';
import { SavedObjectsWarning } from '../../components/saved_objects_warning';
import { useTimefilter, useTimeRangeUpdates } from '../../contexts/kibana/use_timefilter';
import { useToastNotificationService } from '../../services/toast_notification_service';
@ -61,7 +60,8 @@ export const NotificationsList: FC = () => {
} = useMlKibana();
const { displayErrorToast } = useToastNotificationService();
const [lastCheckedAt, setLastCheckedAt] = useStorage(ML_NOTIFICATIONS_LAST_CHECKED_AT);
const { lastCheckedAt, setLastCheckedAt, notificationsCounts, latestRequestedAt } =
useMlNotifications();
const timeFilter = useTimefilter();
const timeRange = useTimeRangeUpdates();
@ -280,10 +280,29 @@ export const NotificationsList: FC = () => {
];
}, []);
const newNotificationsCount = Object.values(notificationsCounts).reduce((a, b) => a + b);
return (
<>
<SavedObjectsWarning onCloseFlyout={fetchNotifications} forceRefresh={isLoading} />
{newNotificationsCount ? (
<>
<EuiCallOut
size="s"
title={
<FormattedMessage
id="xpack.ml.notifications.newNotificationsMessage"
defaultMessage="There {newNotificationsCount, plural, one {is # notification} other {are # notifications}} since {sinceDate}. Refresh the page to view updates."
values={{ sinceDate: dateFormatter(latestRequestedAt), newNotificationsCount }}
/>
}
iconType="bell"
/>
<EuiSpacer size={'m'} />
</>
) : null}
<EuiSearchBar
query={queryInstance}
box={{

View file

@ -18,7 +18,7 @@ import type {
import type { DataViewsContract } from '@kbn/data-views-plugin/public';
import { EuiLoadingContent } from '@elastic/eui';
import { MlStorageContextProvider } from '../contexts/storage';
import { MlNotificationsContextProvider } from '../contexts/ml/ml_notifications_context';
import { MlContext, MlContextValue } from '../contexts/ml';
import { UrlStateProvider } from '../util/url_state';
@ -105,11 +105,11 @@ export const MlRouter: FC<{
}> = ({ pageDeps }) => (
<Router history={pageDeps.history}>
<LegacyHashUrlRedirect>
<MlStorageContextProvider>
<UrlStateProvider>
<UrlStateProvider>
<MlNotificationsContextProvider>
<MlPage pageDeps={pageDeps} />
</UrlStateProvider>
</MlStorageContextProvider>
</MlNotificationsContextProvider>
</UrlStateProvider>
</LegacyHashUrlRedirect>
</Router>
);