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

@@ -115,6 +115,7 @@ We at [etke.cc](https://etke.cc) attempting to develop everything open-source, b
The following list contains such features - they are only available for [etke.cc](https://etke.cc) customers. The following list contains such features - they are only available for [etke.cc](https://etke.cc) customers.
* 📊 [Server Status indicator and page](https://github.com/etkecc/synapse-admin/pull/182) * 📊 [Server Status indicator and page](https://github.com/etkecc/synapse-admin/pull/182)
* 📬 [Server Notifications indicator and page](https://github.com/etkecc/synapse-admin/pull/240)
### Development ### Development

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -25,6 +25,7 @@ import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider"; import dataProvider from "./synapse/dataProvider";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ServerStatusPage from "./components/etke.cc/ServerStatusPage"; import ServerStatusPage from "./components/etke.cc/ServerStatusPage";
import ServerNotificationsPage from "./components/etke.cc/ServerNotificationsPage";
// TODO: Can we use lazy loading together with browser locale? // TODO: Can we use lazy loading together with browser locale?
const messages = { const messages = {
@@ -65,6 +66,7 @@ export const App = () => (
<CustomRoutes> <CustomRoutes>
<Route path="/import_users" element={<UserImport />} /> <Route path="/import_users" element={<UserImport />} />
<Route path="/server_status" element={<ServerStatusPage />} /> <Route path="/server_status" element={<ServerStatusPage />} />
<Route path="/server_notifications" element={<ServerNotificationsPage />} />
</CustomRoutes> </CustomRoutes>
<Resource {...users} /> <Resource {...users} />
<Resource {...rooms} /> <Resource {...rooms} />

View File

@@ -5,6 +5,7 @@ import { Icons, DefaultIcon } from "../utils/icons";
import { MenuItem, GetConfig, ClearConfig } from "../utils/config"; import { MenuItem, GetConfig, ClearConfig } from "../utils/config";
import Footer from "./Footer"; import Footer from "./Footer";
import ServerStatusBadge from "./etke.cc/ServerStatusBadge"; import ServerStatusBadge from "./etke.cc/ServerStatusBadge";
import { ServerNotificationsBadge } from "./etke.cc/ServerNotificationsBadge";
const AdminUserMenu = () => { const AdminUserMenu = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -50,6 +51,7 @@ const AdminAppBar = () => {
return (<AppBar userMenu={<AdminUserMenu />}> return (<AppBar userMenu={<AdminUserMenu />}>
<TitlePortal /> <TitlePortal />
<ServerStatusBadge /> <ServerStatusBadge />
<ServerNotificationsBadge />
<InspectorButton /> <InspectorButton />
</AppBar>); </AppBar>);
}; };

View File

@@ -28,3 +28,15 @@ Server Status page. This page contains the following information:
* Overall server status (up/updating/has issues) * Overall server status (up/updating/has issues)
* Details about the currently running command (if any) * 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 * 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;

View File

@@ -80,11 +80,7 @@ const authProvider: AuthProvider = {
localStorage.setItem("access_token", accessToken ? accessToken : json.access_token); localStorage.setItem("access_token", accessToken ? accessToken : json.access_token);
localStorage.setItem("device_id", json.device_id); localStorage.setItem("device_id", json.device_id);
localStorage.setItem("login_type", accessToken ? "accessToken" : "credentials"); localStorage.setItem("login_type", accessToken ? "accessToken" : "credentials");
// when doing access token auth, config is not fetched, so we need to do it here
if (accessToken) {
await FetchConfig(); await FetchConfig();
}
return Promise.resolve({redirectTo: "/"}); return Promise.resolve({redirectTo: "/"});
} catch(err) { } catch(err) {

View File

@@ -291,6 +291,17 @@ export interface ServerProcessResponse {
command?: string; command?: string;
} }
export interface ServerNotification {
event_id: string;
output: string;
sent_at: string;
}
export interface ServerNotificationsResponse {
success: boolean;
notifications: ServerNotification[];
}
export interface SynapseDataProvider extends DataProvider { export interface SynapseDataProvider extends DataProvider {
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>; deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
purgeRemoteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>; purgeRemoteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
@@ -302,6 +313,8 @@ export interface SynapseDataProvider extends DataProvider {
makeRoomAdmin: (room_id: string, user_id: string) => Promise<{ success: boolean; error?: string; errcode?: string }>; makeRoomAdmin: (room_id: string, user_id: string) => Promise<{ success: boolean; error?: string; errcode?: string }>;
getServerRunningProcess: (etkeAdminUrl: string) => Promise<ServerProcessResponse>; getServerRunningProcess: (etkeAdminUrl: string) => Promise<ServerProcessResponse>;
getServerStatus: (etkeAdminUrl: string) => Promise<ServerStatusResponse>; getServerStatus: (etkeAdminUrl: string) => Promise<ServerStatusResponse>;
getServerNotifications: (etkeAdminUrl: string) => Promise<ServerNotificationsResponse>;
deleteServerNotifications: (etkeAdminUrl: string) => Promise<{ success: boolean }>;
} }
const resourceMap = { const resourceMap = {
@@ -995,6 +1008,60 @@ const baseDataProvider: SynapseDataProvider = {
} }
return { success: false, ok: false, host: "", results: [] }; return { success: false, ok: false, host: "", results: [] };
},
getServerNotifications: async (serverNotificationsUrl: string): Promise<ServerNotificationsResponse> => {
try {
const response = await fetch(`${serverNotificationsUrl}/notifications`, {
headers: {
"Authorization": `Bearer ${localStorage.getItem("access_token")}`
}
});
if (!response.ok) {
console.error(`Error getting server notifications: ${response.status} ${response.statusText}`);
return { success: false, notifications: [] };
}
const status = response.status;
if (status === 204) {
return { success: true, notifications: [] };
}
if (status === 200) {
const json = await response.json();
const result = { success: true, notifications: json } as ServerNotificationsResponse;
return result;
}
return { success: true, notifications: [] };
} catch (error) {
console.error("Error getting server notifications", error);
}
return { success: false, notifications: [] };
},
deleteServerNotifications: async (serverNotificationsUrl: string) => {
try {
const response = await fetch(`${serverNotificationsUrl}/notifications`, {
headers: {
"Authorization": `Bearer ${localStorage.getItem("access_token")}`
},
method: "DELETE"
});
if (!response.ok) {
console.error(`Error deleting server notifications: ${response.status} ${response.statusText}`);
return { success: false };
}
const status = response.status;
if (status === 204) {
const result = { success: true }
return result;
}
} catch (error) {
console.error("Error deleting server notifications", error);
}
return { success: false };
} }
}; };

View File

@@ -53,7 +53,6 @@ export const FetchConfig = async () => {
console.log(`${protocol}://${homeserver}/.well-known/matrix/client not found, skipping`, e); console.log(`${protocol}://${homeserver}/.well-known/matrix/client not found, skipping`, e);
} }
} }
} }
// load config from context // load config from context

View File

@@ -42,19 +42,20 @@ export function generateRandomMXID(): string {
* @returns full MXID as string * @returns full MXID as string
*/ */
export function returnMXID(input: string | Identifier): string { export function returnMXID(input: string | Identifier): string {
const homeserver = localStorage.getItem("home_server"); const inputStr = input as string;
const homeserver = localStorage.getItem("home_server") || "";
// when homeserver is not (just) a domain name, but a domain:port or even an IPv6 address // when homeserver is not (just) a domain name, but a domain:port or even an IPv6 address
if (input.endsWith(homeserver) && input.startsWith("@")) { if (homeserver != "" && inputStr.endsWith(homeserver) && inputStr.startsWith("@")) {
return input as string; // Already a valid MXID return inputStr; // Already a valid MXID
} }
// Check if the input already looks like a valid MXID (i.e., starts with "@" and contains ":") // Check if the input already looks like a valid MXID (i.e., starts with "@" and contains ":")
if (isMXID(input)) { if (isMXID(input)) {
return input as string; // Already a valid MXID return inputStr; // Already a valid MXID
} }
// If input is not a valid MXID, assume it's a localpart and construct the MXID // If input is not a valid MXID, assume it's a localpart and construct the MXID
const localpart = typeof input === 'string' && input.startsWith('@') ? input.slice(1) : input; const localpart = typeof input === 'string' && inputStr.startsWith('@') ? inputStr.slice(1) : inputStr;
return `@${localpart}:${homeserver}`; return `@${localpart}:${homeserver}`;
} }