diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 23e997e4dd6..f0d01d1dba6 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -54,4 +54,5 @@ export interface FeatureToggles { storageLocalUpload?: boolean; azureMonitorResourcePickerForMetrics?: boolean; explore2Dashboard?: boolean; + persistNotifications?: boolean; } diff --git a/packages/grafana-data/src/types/legacyEvents.ts b/packages/grafana-data/src/types/legacyEvents.ts index d7e09095a1f..df93d2f5f15 100644 --- a/packages/grafana-data/src/types/legacyEvents.ts +++ b/packages/grafana-data/src/types/legacyEvents.ts @@ -5,8 +5,8 @@ import { eventFactory } from '../events/eventFactory'; import { BusEventBase, BusEventWithPayload } from '../events/types'; import { DataHoverPayload } from '../events'; -export type AlertPayload = [string, string?]; -export type AlertErrorPayload = [string, (string | Error)?]; +export type AlertPayload = [string, string?, string?]; +export type AlertErrorPayload = [string, (string | Error)?, string?]; export const AppEvents = { alertSuccess: eventFactory('alert-success'), diff --git a/packages/grafana-ui/src/components/Alert/Alert.tsx b/packages/grafana-ui/src/components/Alert/Alert.tsx index c6e73b7fe2b..ddf70797e43 100644 --- a/packages/grafana-ui/src/components/Alert/Alert.tsx +++ b/packages/grafana-ui/src/components/Alert/Alert.tsx @@ -22,7 +22,7 @@ export interface Props extends HTMLAttributes { topSpacing?: number; } -function getIconFromSeverity(severity: AlertVariant): string { +export function getIconFromSeverity(severity: AlertVariant): string { switch (severity) { case 'error': case 'warning': @@ -150,7 +150,7 @@ const getStyles = ( color: ${theme.colors.text.secondary}; padding-top: ${theme.spacing(1)}; max-height: 50vh; - overflow-y: scroll; + overflow-y: auto; `, buttonWrapper: css` padding: ${theme.spacing(1)}; diff --git a/pkg/api/index.go b/pkg/api/index.go index 0d7f0412e01..f46a3c3c353 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -38,6 +38,12 @@ func (hs *HTTPServer) getProfileNode(c *models.ReqContext) *dtos.NavLink { }, } + if hs.Features.IsEnabled(featuremgmt.FlagPersistNotifications) { + children = append(children, &dtos.NavLink{ + Text: "Notifications", Id: "notifications", Url: hs.Cfg.AppSubURL + "/notifications", Icon: "bell", + }) + } + if setting.AddChangePasswordLink() { children = append(children, &dtos.NavLink{ Text: "Change password", Id: "change-password", Url: hs.Cfg.AppSubURL + "/profile/password", diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 60b46d46b6a..2e7ce48be55 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -213,5 +213,11 @@ var ( State: FeatureStateBeta, FrontendOnly: true, }, + { + Name: "persistNotifications", + Description: "PoC Notifications page", + State: FeatureStateAlpha, + FrontendOnly: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 7548ed76643..8d0c9764b32 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -158,4 +158,8 @@ const ( // FlagExplore2Dashboard // Experimental Explore to Dashboard workflow FlagExplore2Dashboard = "explore2Dashboard" + + // FlagPersistNotifications + // PoC Notifications page + FlagPersistNotifications = "persistNotifications" ) diff --git a/public/app/core/actions/index.ts b/public/app/core/actions/index.ts index 4df4144e94c..88c6c68ab2e 100644 --- a/public/app/core/actions/index.ts +++ b/public/app/core/actions/index.ts @@ -1,3 +1,3 @@ -import { clearAppNotification, notifyApp } from '../reducers/appNotification'; +import { hideAppNotification, notifyApp } from '../reducers/appNotification'; import { updateNavIndex, updateConfigurationSubtitle } from '../reducers/navModel'; -export { updateNavIndex, updateConfigurationSubtitle, notifyApp, clearAppNotification }; +export { updateNavIndex, updateConfigurationSubtitle, notifyApp, hideAppNotification }; diff --git a/public/app/core/components/AppNotifications/AppNotificationItem.tsx b/public/app/core/components/AppNotifications/AppNotificationItem.tsx index c1c227f4fd4..4b309a5b775 100644 --- a/public/app/core/components/AppNotifications/AppNotificationItem.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationItem.tsx @@ -1,36 +1,47 @@ -import React, { Component } from 'react'; -import { AppNotification } from 'app/types'; -import { Alert } from '@grafana/ui'; +import React from 'react'; +import { useEffectOnce } from 'react-use'; +import { css } from '@emotion/css'; +import { Alert, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { AppNotification, timeoutMap } from 'app/types'; interface Props { appNotification: AppNotification; onClearNotification: (id: string) => void; } -export default class AppNotificationItem extends Component { - shouldComponentUpdate(nextProps: Props) { - return this.props.appNotification.id !== nextProps.appNotification.id; - } +export default function AppNotificationItem({ appNotification, onClearNotification }: Props) { + const styles = useStyles2(getStyles); - componentDidMount() { - const { appNotification, onClearNotification } = this.props; + useEffectOnce(() => { setTimeout(() => { onClearNotification(appNotification.id); - }, appNotification.timeout); - } + }, timeoutMap[appNotification.severity]); + }); - render() { - const { appNotification, onClearNotification } = this.props; - - return ( - onClearNotification(appNotification.id)} - elevated - > - {appNotification.component || appNotification.text} - - ); - } + return ( + onClearNotification(appNotification.id)} + elevated + > +
+ {appNotification.component || appNotification.text} + {appNotification.traceId && Trace ID: {appNotification.traceId}} +
+
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + wrapper: css({ + display: 'flex', + flexDirection: 'column', + }), + trace: css({ + fontSize: theme.typography.pxToRem(10), + }), + }; } diff --git a/public/app/core/components/AppNotifications/AppNotificationList.tsx b/public/app/core/components/AppNotifications/AppNotificationList.tsx index 4fe53b56801..d9aba49843a 100644 --- a/public/app/core/components/AppNotifications/AppNotificationList.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationList.tsx @@ -1,8 +1,8 @@ import React, { PureComponent } from 'react'; import appEvents from 'app/core/app_events'; import AppNotificationItem from './AppNotificationItem'; -import { notifyApp, clearAppNotification } from 'app/core/actions'; -import { selectAll } from 'app/core/reducers/appNotification'; +import { notifyApp, hideAppNotification } from 'app/core/actions'; +import { selectVisible } from 'app/core/reducers/appNotification'; import { StoreState } from 'app/types'; import { @@ -17,12 +17,12 @@ import { VerticalGroup } from '@grafana/ui'; export interface OwnProps {} const mapStateToProps = (state: StoreState, props: OwnProps) => ({ - appNotifications: selectAll(state.appNotifications), + appNotifications: selectVisible(state.appNotifications), }); const mapDispatchToProps = { notifyApp, - clearAppNotification, + hideAppNotification, }; const connector = connect(mapStateToProps, mapDispatchToProps); @@ -39,7 +39,7 @@ export class AppNotificationListUnConnected extends PureComponent { } onClearAppNotification = (id: string) => { - this.props.clearAppNotification(id); + this.props.hideAppNotification(id); }; render() { diff --git a/public/app/core/components/AppNotifications/StoredNotificationItem.tsx b/public/app/core/components/AppNotifications/StoredNotificationItem.tsx new file mode 100644 index 00000000000..ea044c362b8 --- /dev/null +++ b/public/app/core/components/AppNotifications/StoredNotificationItem.tsx @@ -0,0 +1,111 @@ +import React, { ReactNode } from 'react'; +import { formatDistanceToNow } from 'date-fns'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { getIconFromSeverity } from '@grafana/ui/src/components/Alert/Alert'; +import { Icon, IconButton, IconName, useTheme2 } from '@grafana/ui'; + +export type AlertVariant = 'success' | 'warning' | 'error' | 'info'; + +export interface Props { + title: string; + severity?: AlertVariant; + timestamp?: number; + traceId?: string; + children?: ReactNode; + onRemove?: (event: React.MouseEvent) => void; +} + +export const StoredNotificationItem = ({ + title, + severity = 'error', + traceId, + timestamp, + children, + onRemove, +}: Props) => { + const theme = useTheme2(); + const styles = getStyles(theme, severity); + + return ( +
+
+ +
+
{title}
+
{children}
+ {traceId && `Trace ID: ${traceId}`} +
+ +
+ {timestamp && {formatDistanceToNow(timestamp, { addSuffix: true })}} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2, severity: AlertVariant) => { + const color = theme.colors[severity]; + const borderRadius = theme.shape.borderRadius(); + + return { + wrapper: css({ + display: 'grid', + gridTemplateColumns: 'auto 1fr auto', + gridTemplateRows: 'auto 1fr auto', + gridTemplateAreas: ` + 'icon title close' + 'icon body body' + 'icon trace timestamp'`, + gap: `0 ${theme.spacing(2)}`, + background: theme.colors.background.secondary, + borderRadius: borderRadius, + }), + icon: css({ + gridArea: 'icon', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: theme.spacing(2, 3), + background: color.main, + color: color.contrastText, + borderRadius: `${borderRadius} 0 0 ${borderRadius}`, + }), + title: css({ + gridArea: 'title', + alignSelf: 'center', + fontWeight: theme.typography.fontWeightMedium, + color: theme.colors.text.primary, + }), + body: css({ + gridArea: 'body', + maxHeight: '50vh', + marginRight: theme.spacing(1), + overflowY: 'auto', + overflowWrap: 'break-word', + wordBreak: 'break-word', + color: theme.colors.text.secondary, + }), + trace: css({ + gridArea: 'trace', + justifySelf: 'start', + alignSelf: 'end', + paddingBottom: theme.spacing(1), + fontSize: theme.typography.pxToRem(10), + color: theme.colors.text.secondary, + }), + close: css({ + gridArea: 'close', + display: 'flex', + justifySelf: 'end', + padding: theme.spacing(1, 0.5), + background: 'none', + }), + timestamp: css({ + gridArea: 'timestamp', + alignSelf: 'end', + padding: theme.spacing(1), + fontSize: theme.typography.pxToRem(10), + color: theme.colors.text.secondary, + }), + }; +}; diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts index 2c94b701653..987f7194eeb 100644 --- a/public/app/core/copy/appNotification.ts +++ b/public/app/core/copy/appNotification.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { AppNotification, AppNotificationSeverity, AppNotificationTimeout, useDispatch } from 'app/types'; +import { AppNotification, AppNotificationSeverity, useDispatch } from 'app/types'; import { getMessageFromError } from 'app/core/utils/errors'; import { v4 as uuidv4 } from 'uuid'; import { notifyApp } from '../actions'; @@ -9,7 +9,6 @@ const defaultSuccessNotification = { text: '', severity: AppNotificationSeverity.Success, icon: 'check', - timeout: AppNotificationTimeout.Success, }; const defaultWarningNotification = { @@ -17,7 +16,6 @@ const defaultWarningNotification = { text: '', severity: AppNotificationSeverity.Warning, icon: 'exclamation-triangle', - timeout: AppNotificationTimeout.Warning, }; const defaultErrorNotification = { @@ -25,19 +23,21 @@ const defaultErrorNotification = { text: '', severity: AppNotificationSeverity.Error, icon: 'exclamation-triangle', - timeout: AppNotificationTimeout.Error, }; -export const createSuccessNotification = (title: string, text = ''): AppNotification => ({ +export const createSuccessNotification = (title: string, text = '', traceId?: string): AppNotification => ({ ...defaultSuccessNotification, - title: title, - text: text, + title, + text, id: uuidv4(), + timestamp: Date.now(), + showing: true, }); export const createErrorNotification = ( title: string, text: string | Error = '', + traceId?: string, component?: React.ReactElement ): AppNotification => { return { @@ -45,15 +45,21 @@ export const createErrorNotification = ( text: getMessageFromError(text), title, id: uuidv4(), + traceId, component, + timestamp: Date.now(), + showing: true, }; }; -export const createWarningNotification = (title: string, text = ''): AppNotification => ({ +export const createWarningNotification = (title: string, text = '', traceId?: string): AppNotification => ({ ...defaultWarningNotification, - title: title, - text: text, + title, + text, + traceId, id: uuidv4(), + timestamp: Date.now(), + showing: true, }); /** Hook for showing toast notifications with varying severity (success, warning error). @@ -70,11 +76,11 @@ export function useAppNotification() { success: (title: string, text = '') => { dispatch(notifyApp(createSuccessNotification(title, text))); }, - warning: (title: string, text = '') => { - dispatch(notifyApp(createWarningNotification(title, text))); + warning: (title: string, text = '', traceId?: string) => { + dispatch(notifyApp(createWarningNotification(title, text, traceId))); }, - error: (title: string, text = '') => { - dispatch(notifyApp(createErrorNotification(title, text))); + error: (title: string, text = '', traceId?: string) => { + dispatch(notifyApp(createErrorNotification(title, text, traceId))); }, }), [dispatch] diff --git a/public/app/core/reducers/appNotification.test.ts b/public/app/core/reducers/appNotification.test.ts index edbf431ba2e..d0228683d14 100644 --- a/public/app/core/reducers/appNotification.test.ts +++ b/public/app/core/reducers/appNotification.test.ts @@ -1,6 +1,7 @@ -import { appNotificationsReducer, clearAppNotification, notifyApp } from './appNotification'; -import { AppNotificationSeverity, AppNotificationsState, AppNotificationTimeout } from 'app/types/'; +import { appNotificationsReducer, clearNotification, notifyApp } from './appNotification'; +import { AppNotificationSeverity, AppNotificationsState } from 'app/types/'; +const timestamp = 1649849468889; describe('clear alert', () => { it('should filter alert', () => { const id1 = '1767d3d9-4b99-40eb-ab46-de734a66f21d'; @@ -14,7 +15,8 @@ describe('clear alert', () => { icon: 'success', title: 'test', text: 'test alert', - timeout: AppNotificationTimeout.Success, + showing: true, + timestamp, }, [id2]: { id: id2, @@ -22,12 +24,14 @@ describe('clear alert', () => { icon: 'warning', title: 'test2', text: 'test alert fail 2', - timeout: AppNotificationTimeout.Warning, + showing: true, + timestamp, }, }, + lastRead: timestamp - 10, }; - const result = appNotificationsReducer(initialState, clearAppNotification(id2)); + const result = appNotificationsReducer(initialState, clearNotification(id2)); const expectedResult: AppNotificationsState = { byId: { @@ -37,9 +41,11 @@ describe('clear alert', () => { icon: 'success', title: 'test', text: 'test alert', - timeout: AppNotificationTimeout.Success, + showing: true, + timestamp, }, }, + lastRead: timestamp - 10, }; expect(result).toEqual(expectedResult); @@ -60,7 +66,8 @@ describe('notify', () => { icon: 'success', title: 'test', text: 'test alert', - timeout: AppNotificationTimeout.Success, + showing: true, + timestamp, }, [id2]: { id: id2, @@ -68,9 +75,11 @@ describe('notify', () => { icon: 'warning', title: 'test2', text: 'test alert fail 2', - timeout: AppNotificationTimeout.Warning, + showing: true, + timestamp, }, }, + lastRead: timestamp - 10, }; const result = appNotificationsReducer( @@ -81,7 +90,8 @@ describe('notify', () => { icon: 'info', title: 'test3', text: 'test alert info 3', - timeout: AppNotificationTimeout.Success, + showing: true, + timestamp: 1649802870373, }) ); @@ -93,7 +103,8 @@ describe('notify', () => { icon: 'success', title: 'test', text: 'test alert', - timeout: AppNotificationTimeout.Success, + timestamp, + showing: true, }, [id2]: { id: id2, @@ -101,7 +112,8 @@ describe('notify', () => { icon: 'warning', title: 'test2', text: 'test alert fail 2', - timeout: AppNotificationTimeout.Warning, + timestamp, + showing: true, }, [id3]: { id: id3, @@ -109,9 +121,11 @@ describe('notify', () => { icon: 'info', title: 'test3', text: 'test alert info 3', - timeout: AppNotificationTimeout.Success, + timestamp: 1649802870373, + showing: true, }, }, + lastRead: timestamp - 10, }; expect(result).toEqual(expectedResult); @@ -126,9 +140,11 @@ describe('notify', () => { icon: 'success', title: 'test', text: 'test alert', - timeout: AppNotificationTimeout.Success, + showing: true, + timestamp, }, }, + lastRead: timestamp - 10, }; const result = appNotificationsReducer( @@ -139,7 +155,8 @@ describe('notify', () => { icon: 'success', title: 'test', text: 'test alert', - timeout: AppNotificationTimeout.Success, + showing: true, + timestamp, }) ); @@ -151,9 +168,11 @@ describe('notify', () => { icon: 'success', title: 'test', text: 'test alert', - timeout: AppNotificationTimeout.Success, + showing: true, + timestamp, }, }, + lastRead: timestamp - 10, }; expect(result).toEqual(expectedResult); diff --git a/public/app/core/reducers/appNotification.ts b/public/app/core/reducers/appNotification.ts index 5da53d31c81..ff78a4bf14a 100644 --- a/public/app/core/reducers/appNotification.ts +++ b/public/app/core/reducers/appNotification.ts @@ -1,8 +1,14 @@ +import { config } from '@grafana/runtime'; import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { AppNotification, AppNotificationsState } from 'app/types/'; +import { AppNotification, AppNotificationSeverity, AppNotificationsState } from 'app/types/'; + +export const STORAGE_KEY = 'notifications'; +export const NEW_NOTIFS_KEY = `${STORAGE_KEY}/lastRead`; +type StoredNotification = Omit; export const initialState: AppNotificationsState = { - byId: {}, + byId: deserializeNotifications(), + lastRead: Number.parseInt(window.localStorage.getItem(NEW_NOTIFS_KEY) ?? `${Date.now()}`, 10), }; /** @@ -16,30 +22,107 @@ const appNotificationsSlice = createSlice({ initialState, reducers: { notifyApp: (state, { payload: newAlert }: PayloadAction) => { - if (Object.values(state.byId).some((alert) => isSimilar(newAlert, alert))) { + if (Object.values(state.byId).some((alert) => isSimilar(newAlert, alert) && alert.showing)) { return; } state.byId[newAlert.id] = newAlert; + serializeNotifications(state.byId); }, - clearAppNotification: (state, { payload: alertId }: PayloadAction) => { + hideAppNotification: (state, { payload: alertId }: PayloadAction) => { + if (!(alertId in state.byId)) { + return; + } + + state.byId[alertId].showing = false; + serializeNotifications(state.byId); + }, + clearNotification: (state, { payload: alertId }: PayloadAction) => { delete state.byId[alertId]; + serializeNotifications(state.byId); + }, + clearAllNotifications: (state) => { + state.byId = {}; + serializeNotifications(state.byId); + }, + readAllNotifications: (state, { payload: timestamp }: PayloadAction) => { + state.lastRead = timestamp; }, }, }); -export const { notifyApp, clearAppNotification } = appNotificationsSlice.actions; +export const { notifyApp, hideAppNotification, clearNotification, clearAllNotifications, readAllNotifications } = + appNotificationsSlice.actions; export const appNotificationsReducer = appNotificationsSlice.reducer; -export const selectAll = (state: AppNotificationsState) => Object.values(state.byId); +// Selectors + +export const selectLastReadTimestamp = (state: AppNotificationsState) => state.lastRead; +export const selectAll = (state: AppNotificationsState) => + Object.values(state.byId).sort((a, b) => b.timestamp - a.timestamp); +export const selectWarningsAndErrors = (state: AppNotificationsState) => selectAll(state).filter(isAtLeastWarning); +export const selectVisible = (state: AppNotificationsState) => Object.values(state.byId).filter((n) => n.showing); + +// Helper functions function isSimilar(a: AppNotification, b: AppNotification): boolean { + return a.icon === b.icon && a.severity === b.severity && a.text === b.text && a.title === b.title; +} + +function isAtLeastWarning(notif: AppNotification) { + return notif.severity === AppNotificationSeverity.Warning || notif.severity === AppNotificationSeverity.Error; +} + +function isStoredNotification(obj: any): obj is StoredNotification { return ( - a.icon === b.icon && - a.severity === b.severity && - a.text === b.text && - a.title === b.title && - a.component === b.component + typeof obj.id === 'string' && + typeof obj.icon === 'string' && + typeof obj.title === 'string' && + typeof obj.text === 'string' ); } + +// (De)serialization + +export function deserializeNotifications(): Record { + if (!config.featureToggles?.persistNotifications) { + return {}; + } + + const storedNotifsRaw = window.localStorage.getItem(STORAGE_KEY); + if (!storedNotifsRaw) { + return {}; + } + + const parsed = JSON.parse(storedNotifsRaw); + if (!Object.values(parsed).every((v) => isStoredNotification(v))) { + return {}; + } + + return parsed; +} + +function serializeNotifications(notifs: Record) { + if (!config.featureToggles?.persistNotifications) { + return; + } + + const reducedNotifs = Object.values(notifs) + .filter(isAtLeastWarning) + .reduce>((prev, cur) => { + prev[cur.id] = { + id: cur.id, + severity: cur.severity, + icon: cur.icon, + title: cur.title, + text: cur.text, + traceId: cur.traceId, + timestamp: cur.timestamp, + showing: cur.showing, + }; + + return prev; + }, {}); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(reducedNotifs)); +} diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 72e7c92b993..6da5e10f6d2 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -310,6 +310,7 @@ export class BackendSrv implements BackendService { this.dependencies.appEvents.emit(err.status < 500 ? AppEvents.alertWarning : AppEvents.alertError, [ message, description, + err.data.traceID, ]); } diff --git a/public/app/core/specs/backend_srv.test.ts b/public/app/core/specs/backend_srv.test.ts index c66a4acd70e..5176f4959d0 100644 --- a/public/app/core/specs/backend_srv.test.ts +++ b/public/app/core/specs/backend_srv.test.ts @@ -264,10 +264,15 @@ describe('backendSrv', () => { data: { message: 'Something failed', error: 'Error', + traceID: 'bogus-trace-id', }, } as FetchError ); - expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvents.alertError, ['Something failed', '']); + expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvents.alertError, [ + 'Something failed', + '', + 'bogus-trace-id', + ]); }); }); }); diff --git a/public/app/features/notifications/NotificationsPage.tsx b/public/app/features/notifications/NotificationsPage.tsx new file mode 100644 index 00000000000..e2e5c49338d --- /dev/null +++ b/public/app/features/notifications/NotificationsPage.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { connect, ConnectedProps } from 'react-redux'; + +import { config } from '@grafana/runtime'; + +import { GrafanaRouteComponentProps } from '../../core/navigation/types'; +import { StoreState } from '../../types'; +import { getNavModel } from '../../core/selectors/navModel'; +import Page from '../../core/components/Page/Page'; +import { StoredNotifications } from './StoredNotifications'; + +const mapStateToProps = (state: StoreState) => ({ + navModel: getNavModel(state.navIndex, 'notifications'), +}); + +const connector = connect(mapStateToProps, undefined); + +interface OwnProps extends GrafanaRouteComponentProps {} + +type Props = OwnProps & ConnectedProps; + +export const NotificationsPage = ({ navModel }: Props) => { + if (!config.featureToggles.persistNotifications) { + return null; + } + + return ( + + + + + + ); +}; + +export default connect(mapStateToProps)(NotificationsPage); diff --git a/public/app/features/notifications/StoredNotifications.tsx b/public/app/features/notifications/StoredNotifications.tsx new file mode 100644 index 00000000000..1fa99b3b903 --- /dev/null +++ b/public/app/features/notifications/StoredNotifications.tsx @@ -0,0 +1,123 @@ +import React, { useRef } from 'react'; +import { useEffectOnce } from 'react-use'; +import { css, cx } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, Icon, useStyles2 } from '@grafana/ui'; +import { useDispatch, useSelector } from 'app/types'; +import { + clearAllNotifications, + clearNotification, + readAllNotifications, + selectWarningsAndErrors, + selectLastReadTimestamp, +} from 'app/core/reducers/appNotification'; +import { StoredNotificationItem } from 'app/core/components/AppNotifications/StoredNotificationItem'; + +export function StoredNotifications() { + const dispatch = useDispatch(); + const notifications = useSelector((state) => selectWarningsAndErrors(state.appNotifications)); + const lastReadTimestamp = useRef(useSelector((state) => selectLastReadTimestamp(state.appNotifications))); + const styles = useStyles2(getStyles); + + useEffectOnce(() => { + dispatch(readAllNotifications(Date.now())); + }); + + const onClearNotification = (id: string) => { + dispatch(clearNotification(id)); + }; + + const clearAllNotifs = () => { + dispatch(clearAllNotifications()); + }; + + if (notifications.length === 0) { + return ( +
+ + Notifications you have received will appear here. +
+ ); + } + + return ( +
+ +
    + {notifications.map((notif) => ( +
  • lastReadTimestamp.current })} + > + onClearNotification(notif.id)} + timestamp={notif.timestamp} + traceId={notif.traceId} + > + {notif.text} + +
  • + ))} +
+
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + smallText: css({ + fontSize: theme.typography.pxToRem(10), + color: theme.colors.text.secondary, + }), + side: css({ + display: 'flex', + flexDirection: 'column', + padding: '3px 6px', + paddingTop: theme.spacing(1), + alignItems: 'flex-end', + justifyContent: 'space-between', + flexShrink: 0, + }), + list: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + }), + listItem: css({ + listStyle: 'none', + gap: theme.spacing(1), + alignItems: 'center', + position: 'relative', + }), + newItem: css({ + '&::before': { + content: '""', + height: '100%', + position: 'absolute', + left: '-7px', + top: 0, + background: theme.colors.gradients.brandVertical, + width: theme.spacing(0.5), + borderRadius: theme.shape.borderRadius(1), + }, + }), + noNotifsWrapper: css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: theme.spacing(1), + }), + wrapper: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + }), + clearAll: css({ + alignSelf: 'flex-end', + }), + }; +} diff --git a/public/app/features/sandbox/TestStuffPage.tsx b/public/app/features/sandbox/TestStuffPage.tsx index 360402b78dd..7da9e64ac90 100644 --- a/public/app/features/sandbox/TestStuffPage.tsx +++ b/public/app/features/sandbox/TestStuffPage.tsx @@ -6,7 +6,7 @@ import { NavModelItem, PanelData, } from '@grafana/data'; -import { Table } from '@grafana/ui'; +import { Button, Table } from '@grafana/ui'; import { config } from 'app/core/config'; import React, { FC, useMemo, useState } from 'react'; import { useObservable } from 'react-use'; @@ -16,6 +16,7 @@ import { QueryGroupOptions } from 'app/types'; import Page from '../../core/components/Page/Page'; import AutoSizer from 'react-virtualized-auto-sizer'; import { PanelRenderer } from '../panel/components/PanelRenderer'; +import { useAppNotification } from 'app/core/copy/appNotification'; interface State { queryRunner: PanelQueryRunner; @@ -58,6 +59,8 @@ export const TestStuffPage: FC = () => { url: 'sandbox/test', }; + const notifyApp = useAppNotification(); + return ( @@ -90,6 +93,23 @@ export const TestStuffPage: FC = () => { onOptionsChange={onOptionsChange} /> +
+ + + +
); diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index f1ae76f8aef..059377a8c17 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -73,6 +73,7 @@ const displayAlert = (datasourceName: string, region: string) => createErrorNotification( `CloudWatch request limit reached in ${region} for data source ${datasourceName}`, '', + undefined, React.createElement(ThrottlingErrorMessage, { region }, null) ) ) diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index b749467036d..b2e353f71b4 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -418,6 +418,12 @@ export function getAppRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName: "LibraryPanelsPage"*/ 'app/features/library-panels/LibraryPanelsPage') ), }, + { + path: '/notifications', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "NotificationsPage"*/ 'app/features/notifications/NotificationsPage') + ), + }, ...getPluginCatalogRoutes(), ...getLiveRoutes(), ...getAlertingRoutes(), diff --git a/public/app/types/appNotifications.ts b/public/app/types/appNotifications.ts index 7976b447986..a3a7ea2aed7 100644 --- a/public/app/types/appNotifications.ts +++ b/public/app/types/appNotifications.ts @@ -4,8 +4,10 @@ export interface AppNotification { icon: string; title: string; text: string; + traceId?: string; component?: React.ReactElement; - timeout: AppNotificationTimeout; + showing: boolean; + timestamp: number; } export enum AppNotificationSeverity { @@ -16,11 +18,19 @@ export enum AppNotificationSeverity { } export enum AppNotificationTimeout { - Warning = 5000, Success = 3000, + Warning = 5000, Error = 7000, } +export const timeoutMap = { + [AppNotificationSeverity.Success]: AppNotificationTimeout.Success, + [AppNotificationSeverity.Warning]: AppNotificationTimeout.Warning, + [AppNotificationSeverity.Error]: AppNotificationTimeout.Error, + [AppNotificationSeverity.Info]: AppNotificationTimeout.Success, +}; + export interface AppNotificationsState { byId: Record; + lastRead: number; }