Frontend: Add notification persistence behind feature flag (#47871)

This commit is contained in:
kay delaney 2022-04-20 10:42:32 +01:00 committed by GitHub
parent be3f52abb1
commit c48d8d1d48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 529 additions and 80 deletions

View File

@ -54,4 +54,5 @@ export interface FeatureToggles {
storageLocalUpload?: boolean; storageLocalUpload?: boolean;
azureMonitorResourcePickerForMetrics?: boolean; azureMonitorResourcePickerForMetrics?: boolean;
explore2Dashboard?: boolean; explore2Dashboard?: boolean;
persistNotifications?: boolean;
} }

View File

@ -5,8 +5,8 @@ import { eventFactory } from '../events/eventFactory';
import { BusEventBase, BusEventWithPayload } from '../events/types'; import { BusEventBase, BusEventWithPayload } from '../events/types';
import { DataHoverPayload } from '../events'; import { DataHoverPayload } from '../events';
export type AlertPayload = [string, string?]; export type AlertPayload = [string, string?, string?];
export type AlertErrorPayload = [string, (string | Error)?]; export type AlertErrorPayload = [string, (string | Error)?, string?];
export const AppEvents = { export const AppEvents = {
alertSuccess: eventFactory<AlertPayload>('alert-success'), alertSuccess: eventFactory<AlertPayload>('alert-success'),

View File

@ -22,7 +22,7 @@ export interface Props extends HTMLAttributes<HTMLDivElement> {
topSpacing?: number; topSpacing?: number;
} }
function getIconFromSeverity(severity: AlertVariant): string { export function getIconFromSeverity(severity: AlertVariant): string {
switch (severity) { switch (severity) {
case 'error': case 'error':
case 'warning': case 'warning':
@ -150,7 +150,7 @@ const getStyles = (
color: ${theme.colors.text.secondary}; color: ${theme.colors.text.secondary};
padding-top: ${theme.spacing(1)}; padding-top: ${theme.spacing(1)};
max-height: 50vh; max-height: 50vh;
overflow-y: scroll; overflow-y: auto;
`, `,
buttonWrapper: css` buttonWrapper: css`
padding: ${theme.spacing(1)}; padding: ${theme.spacing(1)};

View File

@ -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() { if setting.AddChangePasswordLink() {
children = append(children, &dtos.NavLink{ children = append(children, &dtos.NavLink{
Text: "Change password", Id: "change-password", Url: hs.Cfg.AppSubURL + "/profile/password", Text: "Change password", Id: "change-password", Url: hs.Cfg.AppSubURL + "/profile/password",

View File

@ -213,5 +213,11 @@ var (
State: FeatureStateBeta, State: FeatureStateBeta,
FrontendOnly: true, FrontendOnly: true,
}, },
{
Name: "persistNotifications",
Description: "PoC Notifications page",
State: FeatureStateAlpha,
FrontendOnly: true,
},
} }
) )

View File

@ -158,4 +158,8 @@ const (
// FlagExplore2Dashboard // FlagExplore2Dashboard
// Experimental Explore to Dashboard workflow // Experimental Explore to Dashboard workflow
FlagExplore2Dashboard = "explore2Dashboard" FlagExplore2Dashboard = "explore2Dashboard"
// FlagPersistNotifications
// PoC Notifications page
FlagPersistNotifications = "persistNotifications"
) )

View File

@ -1,3 +1,3 @@
import { clearAppNotification, notifyApp } from '../reducers/appNotification'; import { hideAppNotification, notifyApp } from '../reducers/appNotification';
import { updateNavIndex, updateConfigurationSubtitle } from '../reducers/navModel'; import { updateNavIndex, updateConfigurationSubtitle } from '../reducers/navModel';
export { updateNavIndex, updateConfigurationSubtitle, notifyApp, clearAppNotification }; export { updateNavIndex, updateConfigurationSubtitle, notifyApp, hideAppNotification };

View File

@ -1,26 +1,23 @@
import React, { Component } from 'react'; import React from 'react';
import { AppNotification } from 'app/types'; import { useEffectOnce } from 'react-use';
import { Alert } from '@grafana/ui'; import { css } from '@emotion/css';
import { Alert, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { AppNotification, timeoutMap } from 'app/types';
interface Props { interface Props {
appNotification: AppNotification; appNotification: AppNotification;
onClearNotification: (id: string) => void; onClearNotification: (id: string) => void;
} }
export default class AppNotificationItem extends Component<Props> { export default function AppNotificationItem({ appNotification, onClearNotification }: Props) {
shouldComponentUpdate(nextProps: Props) { const styles = useStyles2(getStyles);
return this.props.appNotification.id !== nextProps.appNotification.id;
}
componentDidMount() { useEffectOnce(() => {
const { appNotification, onClearNotification } = this.props;
setTimeout(() => { setTimeout(() => {
onClearNotification(appNotification.id); onClearNotification(appNotification.id);
}, appNotification.timeout); }, timeoutMap[appNotification.severity]);
} });
render() {
const { appNotification, onClearNotification } = this.props;
return ( return (
<Alert <Alert
@ -29,8 +26,22 @@ export default class AppNotificationItem extends Component<Props> {
onRemove={() => onClearNotification(appNotification.id)} onRemove={() => onClearNotification(appNotification.id)}
elevated elevated
> >
{appNotification.component || appNotification.text} <div className={styles.wrapper}>
<span>{appNotification.component || appNotification.text}</span>
{appNotification.traceId && <span className={styles.trace}>Trace ID: {appNotification.traceId}</span>}
</div>
</Alert> </Alert>
); );
} }
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
display: 'flex',
flexDirection: 'column',
}),
trace: css({
fontSize: theme.typography.pxToRem(10),
}),
};
} }

View File

@ -1,8 +1,8 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import AppNotificationItem from './AppNotificationItem'; import AppNotificationItem from './AppNotificationItem';
import { notifyApp, clearAppNotification } from 'app/core/actions'; import { notifyApp, hideAppNotification } from 'app/core/actions';
import { selectAll } from 'app/core/reducers/appNotification'; import { selectVisible } from 'app/core/reducers/appNotification';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { import {
@ -17,12 +17,12 @@ import { VerticalGroup } from '@grafana/ui';
export interface OwnProps {} export interface OwnProps {}
const mapStateToProps = (state: StoreState, props: OwnProps) => ({ const mapStateToProps = (state: StoreState, props: OwnProps) => ({
appNotifications: selectAll(state.appNotifications), appNotifications: selectVisible(state.appNotifications),
}); });
const mapDispatchToProps = { const mapDispatchToProps = {
notifyApp, notifyApp,
clearAppNotification, hideAppNotification,
}; };
const connector = connect(mapStateToProps, mapDispatchToProps); const connector = connect(mapStateToProps, mapDispatchToProps);
@ -39,7 +39,7 @@ export class AppNotificationListUnConnected extends PureComponent<Props> {
} }
onClearAppNotification = (id: string) => { onClearAppNotification = (id: string) => {
this.props.clearAppNotification(id); this.props.hideAppNotification(id);
}; };
render() { render() {

View File

@ -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 (
<div className={styles.wrapper}>
<div className={styles.icon}>
<Icon size="xl" name={getIconFromSeverity(severity) as IconName} />
</div>
<div className={styles.title}>{title}</div>
<div className={styles.body}>{children}</div>
<span className={styles.trace}>{traceId && `Trace ID: ${traceId}`}</span>
<div className={styles.close}>
<IconButton aria-label="Close alert" name="times" onClick={onRemove} size="lg" type="button" />
</div>
{timestamp && <span className={styles.timestamp}>{formatDistanceToNow(timestamp, { addSuffix: true })}</span>}
</div>
);
};
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,
}),
};
};

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react'; 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 { getMessageFromError } from 'app/core/utils/errors';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { notifyApp } from '../actions'; import { notifyApp } from '../actions';
@ -9,7 +9,6 @@ const defaultSuccessNotification = {
text: '', text: '',
severity: AppNotificationSeverity.Success, severity: AppNotificationSeverity.Success,
icon: 'check', icon: 'check',
timeout: AppNotificationTimeout.Success,
}; };
const defaultWarningNotification = { const defaultWarningNotification = {
@ -17,7 +16,6 @@ const defaultWarningNotification = {
text: '', text: '',
severity: AppNotificationSeverity.Warning, severity: AppNotificationSeverity.Warning,
icon: 'exclamation-triangle', icon: 'exclamation-triangle',
timeout: AppNotificationTimeout.Warning,
}; };
const defaultErrorNotification = { const defaultErrorNotification = {
@ -25,19 +23,21 @@ const defaultErrorNotification = {
text: '', text: '',
severity: AppNotificationSeverity.Error, severity: AppNotificationSeverity.Error,
icon: 'exclamation-triangle', icon: 'exclamation-triangle',
timeout: AppNotificationTimeout.Error,
}; };
export const createSuccessNotification = (title: string, text = ''): AppNotification => ({ export const createSuccessNotification = (title: string, text = '', traceId?: string): AppNotification => ({
...defaultSuccessNotification, ...defaultSuccessNotification,
title: title, title,
text: text, text,
id: uuidv4(), id: uuidv4(),
timestamp: Date.now(),
showing: true,
}); });
export const createErrorNotification = ( export const createErrorNotification = (
title: string, title: string,
text: string | Error = '', text: string | Error = '',
traceId?: string,
component?: React.ReactElement component?: React.ReactElement
): AppNotification => { ): AppNotification => {
return { return {
@ -45,15 +45,21 @@ export const createErrorNotification = (
text: getMessageFromError(text), text: getMessageFromError(text),
title, title,
id: uuidv4(), id: uuidv4(),
traceId,
component, component,
timestamp: Date.now(),
showing: true,
}; };
}; };
export const createWarningNotification = (title: string, text = ''): AppNotification => ({ export const createWarningNotification = (title: string, text = '', traceId?: string): AppNotification => ({
...defaultWarningNotification, ...defaultWarningNotification,
title: title, title,
text: text, text,
traceId,
id: uuidv4(), id: uuidv4(),
timestamp: Date.now(),
showing: true,
}); });
/** Hook for showing toast notifications with varying severity (success, warning error). /** Hook for showing toast notifications with varying severity (success, warning error).
@ -70,11 +76,11 @@ export function useAppNotification() {
success: (title: string, text = '') => { success: (title: string, text = '') => {
dispatch(notifyApp(createSuccessNotification(title, text))); dispatch(notifyApp(createSuccessNotification(title, text)));
}, },
warning: (title: string, text = '') => { warning: (title: string, text = '', traceId?: string) => {
dispatch(notifyApp(createWarningNotification(title, text))); dispatch(notifyApp(createWarningNotification(title, text, traceId)));
}, },
error: (title: string, text = '') => { error: (title: string, text = '', traceId?: string) => {
dispatch(notifyApp(createErrorNotification(title, text))); dispatch(notifyApp(createErrorNotification(title, text, traceId)));
}, },
}), }),
[dispatch] [dispatch]

View File

@ -1,6 +1,7 @@
import { appNotificationsReducer, clearAppNotification, notifyApp } from './appNotification'; import { appNotificationsReducer, clearNotification, notifyApp } from './appNotification';
import { AppNotificationSeverity, AppNotificationsState, AppNotificationTimeout } from 'app/types/'; import { AppNotificationSeverity, AppNotificationsState } from 'app/types/';
const timestamp = 1649849468889;
describe('clear alert', () => { describe('clear alert', () => {
it('should filter alert', () => { it('should filter alert', () => {
const id1 = '1767d3d9-4b99-40eb-ab46-de734a66f21d'; const id1 = '1767d3d9-4b99-40eb-ab46-de734a66f21d';
@ -14,7 +15,8 @@ describe('clear alert', () => {
icon: 'success', icon: 'success',
title: 'test', title: 'test',
text: 'test alert', text: 'test alert',
timeout: AppNotificationTimeout.Success, showing: true,
timestamp,
}, },
[id2]: { [id2]: {
id: id2, id: id2,
@ -22,12 +24,14 @@ describe('clear alert', () => {
icon: 'warning', icon: 'warning',
title: 'test2', title: 'test2',
text: 'test alert fail 2', 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 = { const expectedResult: AppNotificationsState = {
byId: { byId: {
@ -37,9 +41,11 @@ describe('clear alert', () => {
icon: 'success', icon: 'success',
title: 'test', title: 'test',
text: 'test alert', text: 'test alert',
timeout: AppNotificationTimeout.Success, showing: true,
timestamp,
}, },
}, },
lastRead: timestamp - 10,
}; };
expect(result).toEqual(expectedResult); expect(result).toEqual(expectedResult);
@ -60,7 +66,8 @@ describe('notify', () => {
icon: 'success', icon: 'success',
title: 'test', title: 'test',
text: 'test alert', text: 'test alert',
timeout: AppNotificationTimeout.Success, showing: true,
timestamp,
}, },
[id2]: { [id2]: {
id: id2, id: id2,
@ -68,9 +75,11 @@ describe('notify', () => {
icon: 'warning', icon: 'warning',
title: 'test2', title: 'test2',
text: 'test alert fail 2', text: 'test alert fail 2',
timeout: AppNotificationTimeout.Warning, showing: true,
timestamp,
}, },
}, },
lastRead: timestamp - 10,
}; };
const result = appNotificationsReducer( const result = appNotificationsReducer(
@ -81,7 +90,8 @@ describe('notify', () => {
icon: 'info', icon: 'info',
title: 'test3', title: 'test3',
text: 'test alert info 3', text: 'test alert info 3',
timeout: AppNotificationTimeout.Success, showing: true,
timestamp: 1649802870373,
}) })
); );
@ -93,7 +103,8 @@ describe('notify', () => {
icon: 'success', icon: 'success',
title: 'test', title: 'test',
text: 'test alert', text: 'test alert',
timeout: AppNotificationTimeout.Success, timestamp,
showing: true,
}, },
[id2]: { [id2]: {
id: id2, id: id2,
@ -101,7 +112,8 @@ describe('notify', () => {
icon: 'warning', icon: 'warning',
title: 'test2', title: 'test2',
text: 'test alert fail 2', text: 'test alert fail 2',
timeout: AppNotificationTimeout.Warning, timestamp,
showing: true,
}, },
[id3]: { [id3]: {
id: id3, id: id3,
@ -109,9 +121,11 @@ describe('notify', () => {
icon: 'info', icon: 'info',
title: 'test3', title: 'test3',
text: 'test alert info 3', text: 'test alert info 3',
timeout: AppNotificationTimeout.Success, timestamp: 1649802870373,
showing: true,
}, },
}, },
lastRead: timestamp - 10,
}; };
expect(result).toEqual(expectedResult); expect(result).toEqual(expectedResult);
@ -126,9 +140,11 @@ describe('notify', () => {
icon: 'success', icon: 'success',
title: 'test', title: 'test',
text: 'test alert', text: 'test alert',
timeout: AppNotificationTimeout.Success, showing: true,
timestamp,
}, },
}, },
lastRead: timestamp - 10,
}; };
const result = appNotificationsReducer( const result = appNotificationsReducer(
@ -139,7 +155,8 @@ describe('notify', () => {
icon: 'success', icon: 'success',
title: 'test', title: 'test',
text: 'test alert', text: 'test alert',
timeout: AppNotificationTimeout.Success, showing: true,
timestamp,
}) })
); );
@ -151,9 +168,11 @@ describe('notify', () => {
icon: 'success', icon: 'success',
title: 'test', title: 'test',
text: 'test alert', text: 'test alert',
timeout: AppNotificationTimeout.Success, showing: true,
timestamp,
}, },
}, },
lastRead: timestamp - 10,
}; };
expect(result).toEqual(expectedResult); expect(result).toEqual(expectedResult);

View File

@ -1,8 +1,14 @@
import { config } from '@grafana/runtime';
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 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<AppNotification, 'component'>;
export const initialState: AppNotificationsState = { 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, initialState,
reducers: { reducers: {
notifyApp: (state, { payload: newAlert }: PayloadAction<AppNotification>) => { notifyApp: (state, { payload: newAlert }: PayloadAction<AppNotification>) => {
if (Object.values(state.byId).some((alert) => isSimilar(newAlert, alert))) { if (Object.values(state.byId).some((alert) => isSimilar(newAlert, alert) && alert.showing)) {
return; return;
} }
state.byId[newAlert.id] = newAlert; state.byId[newAlert.id] = newAlert;
serializeNotifications(state.byId);
}, },
clearAppNotification: (state, { payload: alertId }: PayloadAction<string>) => { hideAppNotification: (state, { payload: alertId }: PayloadAction<string>) => {
if (!(alertId in state.byId)) {
return;
}
state.byId[alertId].showing = false;
serializeNotifications(state.byId);
},
clearNotification: (state, { payload: alertId }: PayloadAction<string>) => {
delete state.byId[alertId]; delete state.byId[alertId];
serializeNotifications(state.byId);
},
clearAllNotifications: (state) => {
state.byId = {};
serializeNotifications(state.byId);
},
readAllNotifications: (state, { payload: timestamp }: PayloadAction<number>) => {
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 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 { 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 ( return (
a.icon === b.icon && typeof obj.id === 'string' &&
a.severity === b.severity && typeof obj.icon === 'string' &&
a.text === b.text && typeof obj.title === 'string' &&
a.title === b.title && typeof obj.text === 'string'
a.component === b.component
); );
} }
// (De)serialization
export function deserializeNotifications(): Record<string, StoredNotification> {
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<string, StoredNotification>) {
if (!config.featureToggles?.persistNotifications) {
return;
}
const reducedNotifs = Object.values(notifs)
.filter(isAtLeastWarning)
.reduce<Record<string, StoredNotification>>((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));
}

View File

@ -310,6 +310,7 @@ export class BackendSrv implements BackendService {
this.dependencies.appEvents.emit(err.status < 500 ? AppEvents.alertWarning : AppEvents.alertError, [ this.dependencies.appEvents.emit(err.status < 500 ? AppEvents.alertWarning : AppEvents.alertError, [
message, message,
description, description,
err.data.traceID,
]); ]);
} }

View File

@ -264,10 +264,15 @@ describe('backendSrv', () => {
data: { data: {
message: 'Something failed', message: 'Something failed',
error: 'Error', error: 'Error',
traceID: 'bogus-trace-id',
}, },
} as FetchError } as FetchError
); );
expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvents.alertError, ['Something failed', '']); expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvents.alertError, [
'Something failed',
'',
'bogus-trace-id',
]);
}); });
}); });
}); });

View File

@ -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<typeof connector>;
export const NotificationsPage = ({ navModel }: Props) => {
if (!config.featureToggles.persistNotifications) {
return null;
}
return (
<Page navModel={navModel}>
<Page.Contents>
<StoredNotifications />
</Page.Contents>
</Page>
);
};
export default connect(mapStateToProps)(NotificationsPage);

View File

@ -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 (
<div className={styles.noNotifsWrapper}>
<Icon name="bell" size="xxl" />
<span>Notifications you have received will appear here.</span>
</div>
);
}
return (
<div className={styles.wrapper}>
<Button variant="destructive" onClick={clearAllNotifs} className={styles.clearAll}>
Clear all notifications
</Button>
<ul className={styles.list}>
{notifications.map((notif) => (
<li
key={notif.id}
className={cx(styles.listItem, { [styles.newItem]: notif.timestamp > lastReadTimestamp.current })}
>
<StoredNotificationItem
severity={notif.severity}
title={notif.title}
onRemove={() => onClearNotification(notif.id)}
timestamp={notif.timestamp}
traceId={notif.traceId}
>
<span>{notif.text}</span>
</StoredNotificationItem>
</li>
))}
</ul>
</div>
);
}
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',
}),
};
}

View File

@ -6,7 +6,7 @@ import {
NavModelItem, NavModelItem,
PanelData, PanelData,
} from '@grafana/data'; } from '@grafana/data';
import { Table } from '@grafana/ui'; import { Button, Table } from '@grafana/ui';
import { config } from 'app/core/config'; import { config } from 'app/core/config';
import React, { FC, useMemo, useState } from 'react'; import React, { FC, useMemo, useState } from 'react';
import { useObservable } from 'react-use'; import { useObservable } from 'react-use';
@ -16,6 +16,7 @@ import { QueryGroupOptions } from 'app/types';
import Page from '../../core/components/Page/Page'; import Page from '../../core/components/Page/Page';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { PanelRenderer } from '../panel/components/PanelRenderer'; import { PanelRenderer } from '../panel/components/PanelRenderer';
import { useAppNotification } from 'app/core/copy/appNotification';
interface State { interface State {
queryRunner: PanelQueryRunner; queryRunner: PanelQueryRunner;
@ -58,6 +59,8 @@ export const TestStuffPage: FC = () => {
url: 'sandbox/test', url: 'sandbox/test',
}; };
const notifyApp = useAppNotification();
return ( return (
<Page navModel={{ node: node, main: node }}> <Page navModel={{ node: node, main: node }}>
<Page.Contents> <Page.Contents>
@ -90,6 +93,23 @@ export const TestStuffPage: FC = () => {
onOptionsChange={onOptionsChange} onOptionsChange={onOptionsChange}
/> />
</div> </div>
<div style={{ display: 'flex', gap: '1em' }}>
<Button onClick={() => notifyApp.success('Success toast', 'some more text goes here')} variant="primary">
Success
</Button>
<Button
onClick={() => notifyApp.warning('Warning toast', 'some more text goes here', 'bogus-trace-99999')}
variant="secondary"
>
Warning
</Button>
<Button
onClick={() => notifyApp.error('Error toast', 'some more text goes here', 'bogus-trace-fdsfdfsfds')}
variant="destructive"
>
Error
</Button>
</div>
</Page.Contents> </Page.Contents>
</Page> </Page>
); );

View File

@ -73,6 +73,7 @@ const displayAlert = (datasourceName: string, region: string) =>
createErrorNotification( createErrorNotification(
`CloudWatch request limit reached in ${region} for data source ${datasourceName}`, `CloudWatch request limit reached in ${region} for data source ${datasourceName}`,
'', '',
undefined,
React.createElement(ThrottlingErrorMessage, { region }, null) React.createElement(ThrottlingErrorMessage, { region }, null)
) )
) )

View File

@ -418,6 +418,12 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "LibraryPanelsPage"*/ 'app/features/library-panels/LibraryPanelsPage') () => import(/* webpackChunkName: "LibraryPanelsPage"*/ 'app/features/library-panels/LibraryPanelsPage')
), ),
}, },
{
path: '/notifications',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsPage"*/ 'app/features/notifications/NotificationsPage')
),
},
...getPluginCatalogRoutes(), ...getPluginCatalogRoutes(),
...getLiveRoutes(), ...getLiveRoutes(),
...getAlertingRoutes(), ...getAlertingRoutes(),

View File

@ -4,8 +4,10 @@ export interface AppNotification {
icon: string; icon: string;
title: string; title: string;
text: string; text: string;
traceId?: string;
component?: React.ReactElement; component?: React.ReactElement;
timeout: AppNotificationTimeout; showing: boolean;
timestamp: number;
} }
export enum AppNotificationSeverity { export enum AppNotificationSeverity {
@ -16,11 +18,19 @@ export enum AppNotificationSeverity {
} }
export enum AppNotificationTimeout { export enum AppNotificationTimeout {
Warning = 5000,
Success = 3000, Success = 3000,
Warning = 5000,
Error = 7000, Error = 7000,
} }
export const timeoutMap = {
[AppNotificationSeverity.Success]: AppNotificationTimeout.Success,
[AppNotificationSeverity.Warning]: AppNotificationTimeout.Warning,
[AppNotificationSeverity.Error]: AppNotificationTimeout.Error,
[AppNotificationSeverity.Info]: AppNotificationTimeout.Success,
};
export interface AppNotificationsState { export interface AppNotificationsState {
byId: Record<string, AppNotification>; byId: Record<string, AppNotification>;
lastRead: number;
} }