Add notifications badge and page (#240)

* WIP on server notifications

* WIP: Add server notifications page and removal of notifications

* improve design

* fix missing notifications case; add tooltop

* Fix api response

* fix tests

* add docs; update readme
This commit is contained in:
Borislav Pantaleev
2024-12-19 11:24:42 +02:00
committed by GitHub
parent c643bdcfce
commit c596d38d7a
12 changed files with 333 additions and 11 deletions

View File

@@ -28,3 +28,15 @@ Server Status page. This page contains the following information:
* Overall server status (up/updating/has issues)
* Details about the currently running command (if any)
* Details about the server's components statuses (up/down with error details and suggested actions) by categories
### Server Notifications icon
![Server Notifications icon](../../../screenshots/etke.cc/server-notifications/badge.webp)
In the application bar the new notifications icon is displayed that shows the number of unread (not removed) notifications
### Server Notifications page
![Server Notifications Page](../../../screenshots/etke.cc/server-notifications/page.webp)
When you click on a notification from the [Server Notifications icon](#server-notifications-icon)'s list in the application bar, you will be redirected to the Server Notifications page. This page contains the full text of all the notifications you have about your server.

View File

@@ -0,0 +1,184 @@
import { Badge, useTheme, Button, Paper, Popper, ClickAwayListener, Box, List, ListItem, ListItemText, Typography, ListSubheader, IconButton, Divider, Tooltip } from "@mui/material";
import NotificationsIcon from '@mui/icons-material/Notifications';
import DeleteIcon from "@mui/icons-material/Delete";
import { useDataProvider, useStore } from "react-admin";
import { useNavigate } from "react-router";
import { Fragment, useEffect, useState } from "react";
import { useAppContext } from "../../Context";
import { ServerNotificationsResponse } from "../../synapse/dataProvider";
const SERVER_NOTIFICATIONS_INTERVAL_TIME = 300000;
const useServerNotifications = () => {
const [serverNotifications, setServerNotifications] = useStore<ServerNotificationsResponse>("serverNotifications", { notifications: [], success: false });
const { etkeccAdmin } = useAppContext();
const dataProvider = useDataProvider();
const { notifications, success } = serverNotifications;
const fetchNotifications = async () => {
const notificationsResponse: ServerNotificationsResponse = await dataProvider.getServerNotifications(etkeccAdmin);
setServerNotifications({
...notificationsResponse,
notifications: notificationsResponse.notifications,
success: notificationsResponse.success
});
};
const deleteServerNotifications = async () => {
const deleteResponse = await dataProvider.deleteServerNotifications(etkeccAdmin);
if (deleteResponse.success) {
await fetchNotifications();
}
};
useEffect(() => {
let serverNotificationsInterval: NodeJS.Timeout;
if (etkeccAdmin) {
fetchNotifications();
setTimeout(() => {
// start the interval after the SERVER_NOTIFICATIONS_INTERVAL_TIME to avoid too many requests
serverNotificationsInterval = setInterval(fetchNotifications, SERVER_NOTIFICATIONS_INTERVAL_TIME);
}, SERVER_NOTIFICATIONS_INTERVAL_TIME);
}
return () => {
if (serverNotificationsInterval) {
clearInterval(serverNotificationsInterval);
}
}
}, [etkeccAdmin]);
return { success, notifications, deleteServerNotifications };
};
export const ServerNotificationsBadge = () => {
const navigate = useNavigate();
const { success, notifications, deleteServerNotifications } = useServerNotifications();
const theme = useTheme();
// Modify menu state to work with Popper
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(anchorEl ? null : event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleSeeAllNotifications = () => {
handleClose();
navigate("/server_notifications");
};
const handleClearAllNotifications = async () => {
deleteServerNotifications()
handleClose();
};
if (!success) {
return null;
}
return (
<Box>
<IconButton onClick={handleOpen} sx={{ color: theme.palette.common.white }}>
<Tooltip title={notifications && notifications.length > 0 ? `${notifications.length} new notifications` : `No notifications yet`}>
{notifications && notifications.length > 0 && (
<Badge badgeContent={notifications.length} color="error">
<NotificationsIcon />
</Badge>
) || <NotificationsIcon />}
</Tooltip>
</IconButton>
<Popper
open={open}
anchorEl={anchorEl}
placement="bottom-end"
style={{ zIndex: 1300 }}
>
<ClickAwayListener onClickAway={handleClose}>
<Paper
elevation={3}
sx={{
p: 1,
maxHeight: "350px",
overflowY: "auto",
minWidth: "300px",
maxWidth: {
xs: "100vw", // Full width on mobile
sm: "400px" // Fixed width on desktop
}
}}
>
{(!notifications || notifications.length === 0) ? (
<Typography sx={{ p: 1 }} variant="body2">No new notifications</Typography>
) : (
<List sx={{ p: 0 }} dense={true}>
<ListSubheader
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontWeight: "bold",
backgroundColor: "inherit",
}}
>
<Typography variant="h6">Notifications</Typography>
<Box sx={{ cursor: "pointer", color: theme.palette.primary.main }} onClick={() => handleSeeAllNotifications()}>See all notifications</Box>
</ListSubheader>
<Divider />
{notifications.map((notification, index) => {
return (<Fragment key={notification.event_id ? notification.event_id : index }>
<ListItem
onClick={() => handleSeeAllNotifications()}
sx={{
"&:hover": {
backgroundColor: "action.hover",
cursor: "pointer"
}
}}
>
<ListItemText
primary={
<Typography
variant="body2"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}
dangerouslySetInnerHTML={{ __html: notification.output.split("\n")[0] }}
/>
}
/>
</ListItem>
<Divider />
</Fragment>
)})}
<ListItem>
<Button
key="clear-all-notifications"
onClick={() => handleClearAllNotifications()}
size="small"
color="error"
sx={{
pl: 0,
pt: 1,
verticalAlign: "middle"
}}
>
<DeleteIcon fontSize="small" sx={{ mr: 1 }} />
Clear all
</Button>
</ListItem>
</List>
)}
</Paper>
</ClickAwayListener>
</Popper>
</Box>
);
};

View File

@@ -0,0 +1,58 @@
import { Box, Typography, Paper, Button } from "@mui/material"
import { Stack } from "@mui/material"
import { useStore } from "react-admin"
import dataProvider, { ServerNotificationsResponse } from "../../synapse/dataProvider"
import { useAppContext } from "../../Context";
import DeleteIcon from "@mui/icons-material/Delete";
const DisplayTime = ({ date }: { date: string }) => {
const dateFromDateString = new Date(date);
return <>{dateFromDateString.toLocaleString()}</>;
};
const ServerNotificationsPage = () => {
const { etkeccAdmin } = useAppContext();
const [serverNotifications, setServerNotifications] = useStore<ServerNotificationsResponse>("serverNotifications", {
notifications: [],
success: false,
});
const notifications = serverNotifications.notifications;
return (
<Stack spacing={3} mt={3}>
<Stack spacing={1} direction="row" alignItems="center">
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", gap: 1 }}>
<Typography variant="h4">Server Notifications</Typography>
<Button variant="text" color="error" onClick={async () => {
await dataProvider.deleteServerNotifications(etkeccAdmin);
setServerNotifications({
notifications: [],
success: true,
});
}}>
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> Clear
</Button>
</Box>
</Stack>
{notifications.length === 0 ? (
<Paper sx={{ p: 2 }}>
<Typography>No new notifications.</Typography>
</Paper>
) : (
notifications.map((notification, index) => (
<Paper key={notification.event_id ? notification.event_id : index} sx={{ p: 2 }}>
<Stack spacing={1}>
<Typography variant="subtitle1" fontWeight="bold" color="text.secondary">
<DisplayTime date={notification.sent_at} />
</Typography>
<Typography dangerouslySetInnerHTML={{ __html: notification.output }} />
</Stack>
</Paper>
))
)}
</Stack>
);
};
export default ServerNotificationsPage;