diff --git a/app/portainer/services/notifications.ts b/app/portainer/services/notifications.ts
index cadbae501..c9531b5c9 100644
--- a/app/portainer/services/notifications.ts
+++ b/app/portainer/services/notifications.ts
@@ -1,6 +1,14 @@
import _ from 'lodash';
import toastr from 'toastr';
import sanitize from 'sanitize-html';
+import jwtDecode from 'jwt-decode';
+import { v4 as uuid } from 'uuid';
+
+import { get as localStorageGet } from '@/portainer/hooks/useLocalStorage';
+import { notificationsStore } from '@/react/portainer/notifications/notifications-store';
+import { ToastNotification } from '@/react/portainer/notifications/types';
+
+const { addNotification } = notificationsStore.getState();
toastr.options = {
timeOut: 3000,
@@ -25,15 +33,18 @@ toastr.options = {
};
export function notifySuccess(title: string, text: string) {
+ saveNotification(title, text, 'success');
toastr.success(sanitize(_.escape(text)), sanitize(_.escape(title)));
}
export function notifyWarning(title: string, text: string) {
+ saveNotification(title, text, 'warning');
toastr.warning(sanitize(_.escape(text)), sanitize(title), { timeOut: 6000 });
}
export function notifyError(title: string, e?: Error, fallbackText = '') {
const msg = pickErrorMsg(e) || fallbackText;
+ saveNotification(title, msg, 'error');
// eslint-disable-next-line no-console
console.error(e);
@@ -86,3 +97,20 @@ function pickErrorMsg(e?: Error) {
return msg;
}
+
+function saveNotification(title: string, text: string, type: string) {
+ const notif: ToastNotification = {
+ id: uuid(),
+ title,
+ details: text,
+ type,
+ timeStamp: new Date(),
+ };
+ const jwt = localStorageGet('JWT', '');
+ if (jwt !== '') {
+ const { id } = jwtDecode(jwt) as { id: number };
+ if (id) {
+ addNotification(id, notif);
+ }
+ }
+}
diff --git a/app/portainer/user-activity/index.js b/app/portainer/user-activity/index.js
index 27056da2f..d45c307e4 100644
--- a/app/portainer/user-activity/index.js
+++ b/app/portainer/user-activity/index.js
@@ -1,9 +1,10 @@
import angular from 'angular';
+import { NotificationsViewAngular } from '@/react/portainer/notifications/NotificationsView';
import authLogsViewModule from './auth-logs-view';
import activityLogsViewModule from './activity-logs-view';
-export default angular.module('portainer.app.user-activity', [authLogsViewModule, activityLogsViewModule]).config(config).name;
+export default angular.module('portainer.app.user-activity', [authLogsViewModule, activityLogsViewModule]).component('notifications', NotificationsViewAngular).config(config).name;
/* @ngInject */
function config($stateRegistryProvider) {
@@ -26,4 +27,14 @@ function config($stateRegistryProvider) {
},
},
});
+
+ $stateRegistryProvider.register({
+ name: 'portainer.notifications',
+ url: '/notifications',
+ views: {
+ 'content@': {
+ component: 'notifications',
+ },
+ },
+ });
}
diff --git a/app/react/components/PageHeader/ContextHelp/ContextHelp.tsx b/app/react/components/PageHeader/ContextHelp/ContextHelp.tsx
index 0412d05e2..9a02a1890 100644
--- a/app/react/components/PageHeader/ContextHelp/ContextHelp.tsx
+++ b/app/react/components/PageHeader/ContextHelp/ContextHelp.tsx
@@ -3,6 +3,7 @@ import clsx from 'clsx';
import { getDocURL } from '@@/PageHeader/ContextHelp/docURLs';
+import headerStyles from '../HeaderTitle.module.css';
import './ContextHelp.css';
export function ContextHelp() {
@@ -12,16 +13,19 @@ export function ContextHelp() {
}
return (
-
-
+
);
}
diff --git a/app/react/components/PageHeader/HeaderTitle.module.css b/app/react/components/PageHeader/HeaderTitle.module.css
index 6b4ef49a0..862032b64 100644
--- a/app/react/components/PageHeader/HeaderTitle.module.css
+++ b/app/react/components/PageHeader/HeaderTitle.module.css
@@ -2,20 +2,21 @@
border: 0px;
font-size: 17px;
background: none;
- margin-right: 15px;
+ margin-right: 8px;
display: flex;
align-items: center;
}
.menu-icon {
background: var(--user-menu-icon-color);
+ position: relative;
}
.menu-list {
background: var(--bg-dropdown-menu-color);
border-radius: 8px;
border: 1px solid var(--ui-gray-5) !important;
- width: 180px;
+ min-width: 180px;
padding: 5px !important;
box-shadow: 0 6px 12px rgb(0 0 0 / 18%);
@apply th-dark:!border-none;
diff --git a/app/react/components/PageHeader/HeaderTitle.tsx b/app/react/components/PageHeader/HeaderTitle.tsx
index 6d207cc3e..3c0d4f50b 100644
--- a/app/react/components/PageHeader/HeaderTitle.tsx
+++ b/app/react/components/PageHeader/HeaderTitle.tsx
@@ -3,6 +3,7 @@ import { PropsWithChildren } from 'react';
import { ContextHelp } from '@@/PageHeader/ContextHelp';
import { useHeaderContext } from './HeaderContainer';
+import { NotificationsMenu } from './NotificationsMenu';
import { UserMenu } from './UserMenu';
interface Props {
@@ -20,7 +21,8 @@ export function HeaderTitle({ title, children }: PropsWithChildren
) {
{children && {children}}
-
+
+
{!window.ddExtension && }
diff --git a/app/react/components/PageHeader/NotificationsMenu.module.css b/app/react/components/PageHeader/NotificationsMenu.module.css
new file mode 100644
index 000000000..d905d743a
--- /dev/null
+++ b/app/react/components/PageHeader/NotificationsMenu.module.css
@@ -0,0 +1,53 @@
+.badge {
+ position: absolute;
+ top: 8px;
+ right: 10px;
+ width: 6px;
+ height: 6px;
+ background: red;
+ color: #ffffff;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 50%;
+}
+
+.notification-container {
+ display: flex;
+ border-bottom: 1px solid var(--ui-gray-4);
+ padding: 5px 10px;
+ margin-bottom: 15px;
+}
+
+.item-last {
+ margin-left: auto;
+}
+
+.container {
+ display: flex;
+}
+.notificationIcon {
+ flex-basis: 5rem;
+ width: 5rem;
+}
+.notificationBody {
+ flex-basis: 30rem;
+}
+.deleteButton {
+ flex-basis: 5rem;
+}
+
+.container > div {
+ padding: 0px 10px;
+ margin: auto;
+}
+
+.notification-title {
+ font-weight: 700;
+}
+
+.notification-link {
+ border-top: 1px solid var(--ui-gray-4);
+ padding: 10px;
+ text-align: center;
+}
diff --git a/app/react/components/PageHeader/NotificationsMenu.tsx b/app/react/components/PageHeader/NotificationsMenu.tsx
new file mode 100644
index 000000000..afa36b33d
--- /dev/null
+++ b/app/react/components/PageHeader/NotificationsMenu.tsx
@@ -0,0 +1,206 @@
+import clsx from 'clsx';
+import {
+ Menu,
+ MenuButton,
+ MenuList,
+ MenuLink as ReachMenuLink,
+} from '@reach/menu-button';
+import { UISrefProps, useSref } from '@uirouter/react';
+import Moment from 'moment';
+import { useEffect, useState } from 'react';
+import { useStore } from 'zustand';
+
+import { AutomationTestingProps } from '@/types';
+import { useUser } from '@/portainer/hooks/useUser';
+import { ToastNotification } from '@/react/portainer/notifications/types';
+
+import { Icon } from '@@/Icon';
+import { Link } from '@@/Link';
+import { Button } from '@@/buttons';
+
+import { notificationsStore } from '../../portainer/notifications/notifications-store';
+
+import headerStyles from './HeaderTitle.module.css';
+import notificationStyles from './NotificationsMenu.module.css';
+
+export function NotificationsMenu() {
+ const notificationsStoreState = useStore(notificationsStore);
+ const { removeNotification } = notificationsStoreState;
+ const { clearUserNotifications } = notificationsStoreState;
+
+ const { user } = useUser();
+ const userNotifications: ToastNotification[] = useStore(
+ notificationsStore,
+ (state) => state.userNotifications[user.Id]
+ );
+
+ const [badge, setBadge] = useState(false);
+
+ useEffect(() => {
+ if (userNotifications?.length > 0) {
+ setBadge(true);
+ } else {
+ setBadge(false);
+ }
+ }, [userNotifications]);
+
+ return (
+
+ );
+
+ function onDelete(notificationId: string) {
+ removeNotification(user.Id, notificationId);
+ }
+
+ function onClear() {
+ clearUserNotifications(user.Id);
+ }
+}
+
+interface MenuLinkProps extends AutomationTestingProps, UISrefProps {
+ notification: ToastNotification;
+ onDelete: () => void;
+}
+
+function MenuLink({
+ to,
+ params,
+ options,
+ notification,
+ onDelete,
+}: MenuLinkProps) {
+ const anchorProps = useSref(to, params, options);
+
+ return (
+
+
+
+ {notification.type === 'success' ? (
+
+ ) : (
+
+ )}
+
+
+
+ {notification.title}
+
+
{notification.details}
+
+ {formatTime(notification.timeStamp)}
+
+
+
+
+
+
+
+ );
+}
+
+function formatTime(timeCreated: Date) {
+ const timeStamp = new Date(timeCreated).valueOf().toString();
+
+ const diff = Math.floor((Date.now() - parseInt(timeStamp, 10)) / 1000);
+
+ if (diff <= 86400) {
+ let interval = Math.floor(diff / 3600);
+ if (interval >= 1) {
+ return `${interval} hours ago`;
+ }
+ interval = Math.floor(diff / 60);
+ if (interval >= 1) {
+ return `${interval} min ago`;
+ }
+ }
+ if (diff > 86400) {
+ const formatDate = Moment(timeCreated).format('YYYY-MM-DD h:mm:ss');
+ return formatDate;
+ }
+ return 'Just now';
+}
diff --git a/app/react/portainer/notifications/NotificationsView.tsx b/app/react/portainer/notifications/NotificationsView.tsx
new file mode 100644
index 000000000..8b97d91a3
--- /dev/null
+++ b/app/react/portainer/notifications/NotificationsView.tsx
@@ -0,0 +1,79 @@
+import { Bell, Trash2 } from 'react-feather';
+import { useStore } from 'zustand';
+
+import { withCurrentUser } from '@/react-tools/withCurrentUser';
+import { react2angular } from '@/react-tools/react2angular';
+import { useUser } from '@/portainer/hooks/useUser';
+import { withUIRouter } from '@/react-tools/withUIRouter';
+import { withReactQuery } from '@/react-tools/withReactQuery';
+
+import { PageHeader } from '@@/PageHeader';
+import { Datatable } from '@@/datatables';
+import { Button } from '@@/buttons';
+
+import { notificationsStore } from './notifications-store';
+import { ToastNotification } from './types';
+import { columns } from './columns';
+import { createStore } from './datatable-store';
+
+const storageKey = 'notifications-list';
+const useSettingsStore = createStore(storageKey);
+
+export function NotificationsView() {
+ const settingsStore = useSettingsStore();
+ const { user } = useUser();
+
+ const userNotifications: ToastNotification[] = useStore(
+ notificationsStore,
+ (state) => state.userNotifications[user.Id]
+ );
+
+ const breadcrumbs = 'Notifications';
+
+ return (
+ <>
+
+
(
+
+ )}
+ />
+ >
+ );
+}
+
+function TableActions({ selectedRows }: { selectedRows: ToastNotification[] }) {
+ const { user } = useUser();
+ const notificationsStoreState = useStore(notificationsStore);
+ return (
+
+ );
+
+ function handleRemove() {
+ const { removeNotifications } = notificationsStoreState;
+ const ids = selectedRows.map((row) => row.id);
+ removeNotifications(user.Id, ids);
+ }
+}
+
+export const NotificationsViewAngular = react2angular(
+ withUIRouter(withReactQuery(withCurrentUser(NotificationsView))),
+ []
+);
diff --git a/app/react/portainer/notifications/columns/details.tsx b/app/react/portainer/notifications/columns/details.tsx
new file mode 100644
index 000000000..46504bdab
--- /dev/null
+++ b/app/react/portainer/notifications/columns/details.tsx
@@ -0,0 +1,11 @@
+import { Column } from 'react-table';
+
+import { ToastNotification } from '../types';
+
+export const details: Column = {
+ Header: 'Details',
+ accessor: 'details',
+ id: 'details',
+ disableFilters: true,
+ canHide: true,
+};
diff --git a/app/react/portainer/notifications/columns/index.tsx b/app/react/portainer/notifications/columns/index.tsx
new file mode 100644
index 000000000..fa60a8cd3
--- /dev/null
+++ b/app/react/portainer/notifications/columns/index.tsx
@@ -0,0 +1,6 @@
+import { type } from './type';
+import { title } from './title';
+import { details } from './details';
+import { time } from './time';
+
+export const columns = [type, title, details, time];
diff --git a/app/react/portainer/notifications/columns/time.tsx b/app/react/portainer/notifications/columns/time.tsx
new file mode 100644
index 000000000..35a830667
--- /dev/null
+++ b/app/react/portainer/notifications/columns/time.tsx
@@ -0,0 +1,13 @@
+import { Column } from 'react-table';
+
+import { isoDate } from '@/portainer/filters/filters';
+
+import { ToastNotification } from '../types';
+
+export const time: Column = {
+ Header: 'Time',
+ accessor: (row) => (row.timeStamp ? isoDate(row.timeStamp) : '-'),
+ id: 'time',
+ disableFilters: true,
+ canHide: true,
+};
diff --git a/app/react/portainer/notifications/columns/title.tsx b/app/react/portainer/notifications/columns/title.tsx
new file mode 100644
index 000000000..638b373c8
--- /dev/null
+++ b/app/react/portainer/notifications/columns/title.tsx
@@ -0,0 +1,11 @@
+import { Column } from 'react-table';
+
+import { ToastNotification } from '../types';
+
+export const title: Column = {
+ Header: 'Title',
+ accessor: 'title',
+ id: 'title',
+ disableFilters: true,
+ canHide: true,
+};
diff --git a/app/react/portainer/notifications/columns/type.tsx b/app/react/portainer/notifications/columns/type.tsx
new file mode 100644
index 000000000..07ab6104f
--- /dev/null
+++ b/app/react/portainer/notifications/columns/type.tsx
@@ -0,0 +1,11 @@
+import { Column } from 'react-table';
+
+import { ToastNotification } from '../types';
+
+export const type: Column = {
+ Header: 'Type',
+ accessor: (row) => row.type.charAt(0).toUpperCase() + row.type.slice(1),
+ id: 'type',
+ disableFilters: true,
+ canHide: true,
+};
diff --git a/app/react/portainer/notifications/datatable-store.ts b/app/react/portainer/notifications/datatable-store.ts
new file mode 100644
index 000000000..4a07d742e
--- /dev/null
+++ b/app/react/portainer/notifications/datatable-store.ts
@@ -0,0 +1,36 @@
+import create from 'zustand';
+import { persist } from 'zustand/middleware';
+
+import { keyBuilder } from '@/portainer/hooks/useLocalStorage';
+import {
+ paginationSettings,
+ sortableSettings,
+ refreshableSettings,
+ hiddenColumnsSettings,
+ PaginationTableSettings,
+ RefreshableTableSettings,
+ SettableColumnsTableSettings,
+ SortableTableSettings,
+} from '@/react/components/datatables/types';
+
+interface TableSettings
+ extends SortableTableSettings,
+ PaginationTableSettings,
+ SettableColumnsTableSettings,
+ RefreshableTableSettings {}
+
+export function createStore(storageKey: string) {
+ return create()(
+ persist(
+ (set) => ({
+ ...sortableSettings(set),
+ ...paginationSettings(set),
+ ...hiddenColumnsSettings(set),
+ ...refreshableSettings(set),
+ }),
+ {
+ name: keyBuilder(storageKey),
+ }
+ )
+ );
+}
diff --git a/app/react/portainer/notifications/notifications-store.ts b/app/react/portainer/notifications/notifications-store.ts
new file mode 100644
index 000000000..ef9a75c4a
--- /dev/null
+++ b/app/react/portainer/notifications/notifications-store.ts
@@ -0,0 +1,64 @@
+import create from 'zustand/vanilla';
+import { persist } from 'zustand/middleware';
+
+import { keyBuilder } from '@/portainer/hooks/useLocalStorage';
+
+import { ToastNotification } from './types';
+
+interface NotificationsState {
+ userNotifications: Record;
+ addNotification: (userId: number, notification: ToastNotification) => void;
+ removeNotification: (userId: number, notificationId: string) => void;
+ removeNotifications: (userId: number, notifications: string[]) => void;
+ clearUserNotifications: (userId: number) => void;
+}
+
+export const notificationsStore = create()(
+ persist(
+ (set) => ({
+ userNotifications: {},
+ addNotification: (userId: number, notification: ToastNotification) => {
+ set((state) => ({
+ userNotifications: {
+ ...state.userNotifications,
+ [userId]: [
+ ...(state.userNotifications[userId] || []),
+ notification,
+ ],
+ },
+ }));
+ },
+ removeNotification: (userId: number, notificationId: string) => {
+ set((state) => ({
+ userNotifications: {
+ ...state.userNotifications,
+ [userId]: state.userNotifications[userId].filter(
+ (notif) => notif.id !== notificationId
+ ),
+ },
+ }));
+ },
+ removeNotifications: (userId: number, notificationIds: string[]) => {
+ set((state) => ({
+ userNotifications: {
+ ...state.userNotifications,
+ [userId]: state.userNotifications[userId].filter(
+ (notification) => !notificationIds.includes(notification.id)
+ ),
+ },
+ }));
+ },
+ clearUserNotifications: (userId: number) => {
+ set((state) => ({
+ userNotifications: {
+ ...state.userNotifications,
+ [userId]: [],
+ },
+ }));
+ },
+ }),
+ {
+ name: keyBuilder('notifications'),
+ }
+ )
+);
diff --git a/app/react/portainer/notifications/types.ts b/app/react/portainer/notifications/types.ts
new file mode 100644
index 000000000..428544967
--- /dev/null
+++ b/app/react/portainer/notifications/types.ts
@@ -0,0 +1,7 @@
+export type ToastNotification = {
+ id: string;
+ title: string;
+ details: string;
+ type: string;
+ timeStamp: Date;
+};
diff --git a/app/react/sidebar/SettingsSidebar.tsx b/app/react/sidebar/SettingsSidebar.tsx
index 75c350379..974cca075 100644
--- a/app/react/sidebar/SettingsSidebar.tsx
+++ b/app/react/sidebar/SettingsSidebar.tsx
@@ -5,6 +5,7 @@ import {
HardDrive,
Radio,
FileText,
+ Bell,
} from 'react-feather';
import { usePublicSettings } from '@/react/portainer/settings/queries';
@@ -113,50 +114,57 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
data-cy="portainerSidebar-activityLogs"
/>
-
-
- {!window.ddExtension && (
-
- )}
- {process.env.PORTAINER_EDITION !== 'CE' && (
-
- )}
-
-
-
-
- Help / About
-
-
-
>
)}
+
+ {isAdmin && (
+
+ {!window.ddExtension && (
+
+ )}
+ {process.env.PORTAINER_EDITION !== 'CE' && (
+
+ )}
+
+
+
+
+ Help / About
+
+
+
+ )}
);
}
diff --git a/app/react/sidebar/Sidebar.tsx b/app/react/sidebar/Sidebar.tsx
index 507622eb8..efb8e9ac7 100644
--- a/app/react/sidebar/Sidebar.tsx
+++ b/app/react/sidebar/Sidebar.tsx
@@ -46,9 +46,7 @@ export function Sidebar() {
{isAdmin && EnableEdgeComputeFeatures && }
- {(isAdmin || isTeamLeader) && (
-
- )}
+