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 ( + + +
+ + +
+
+ + +
+
+
+

Notifications

+
+
+ {userNotifications?.length > 0 && ( + + )} +
+
+
+ {userNotifications?.length > 0 ? ( + <> + {userNotifications.map((notification) => ( + onDelete(notification.id)} + /> + ))} + +
+ View all notifications +
+ + ) : ( +
+ +
+

You have no notifications yet.

+
+
+ )} +
+
+ ); + + 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) && ( - - )} +