mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
e7c2983173
commit
874c93c329
7 changed files with 279 additions and 71 deletions
|
@ -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>
|
||||
|
|
|
@ -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) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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() {
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue