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:

committed by
GitHub

parent
c643bdcfce
commit
c596d38d7a
@@ -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.
|
||||
|
||||
* 📊 [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
|
||||
|
||||
|
BIN
screenshots/etke.cc/server-notifications/badge.webp
Normal file
BIN
screenshots/etke.cc/server-notifications/badge.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.7 KiB |
BIN
screenshots/etke.cc/server-notifications/page.webp
Normal file
BIN
screenshots/etke.cc/server-notifications/page.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
@@ -25,6 +25,7 @@ import authProvider from "./synapse/authProvider";
|
||||
import dataProvider from "./synapse/dataProvider";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import ServerStatusPage from "./components/etke.cc/ServerStatusPage";
|
||||
import ServerNotificationsPage from "./components/etke.cc/ServerNotificationsPage";
|
||||
|
||||
// TODO: Can we use lazy loading together with browser locale?
|
||||
const messages = {
|
||||
@@ -65,6 +66,7 @@ export const App = () => (
|
||||
<CustomRoutes>
|
||||
<Route path="/import_users" element={<UserImport />} />
|
||||
<Route path="/server_status" element={<ServerStatusPage />} />
|
||||
<Route path="/server_notifications" element={<ServerNotificationsPage />} />
|
||||
</CustomRoutes>
|
||||
<Resource {...users} />
|
||||
<Resource {...rooms} />
|
||||
|
@@ -5,6 +5,7 @@ import { Icons, DefaultIcon } from "../utils/icons";
|
||||
import { MenuItem, GetConfig, ClearConfig } from "../utils/config";
|
||||
import Footer from "./Footer";
|
||||
import ServerStatusBadge from "./etke.cc/ServerStatusBadge";
|
||||
import { ServerNotificationsBadge } from "./etke.cc/ServerNotificationsBadge";
|
||||
|
||||
const AdminUserMenu = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -50,6 +51,7 @@ const AdminAppBar = () => {
|
||||
return (<AppBar userMenu={<AdminUserMenu />}>
|
||||
<TitlePortal />
|
||||
<ServerStatusBadge />
|
||||
<ServerNotificationsBadge />
|
||||
<InspectorButton />
|
||||
</AppBar>);
|
||||
};
|
||||
|
@@ -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
|
||||
|
||||

|
||||
|
||||
In the application bar the new notifications icon is displayed that shows the number of unread (not removed) notifications
|
||||
|
||||
### Server Notifications page
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
184
src/components/etke.cc/ServerNotificationsBadge.tsx
Normal file
184
src/components/etke.cc/ServerNotificationsBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
58
src/components/etke.cc/ServerNotificationsPage.tsx
Normal file
58
src/components/etke.cc/ServerNotificationsPage.tsx
Normal 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;
|
@@ -80,11 +80,7 @@ const authProvider: AuthProvider = {
|
||||
localStorage.setItem("access_token", accessToken ? accessToken : json.access_token);
|
||||
localStorage.setItem("device_id", json.device_id);
|
||||
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: "/"});
|
||||
} catch(err) {
|
||||
|
@@ -291,6 +291,17 @@ export interface ServerProcessResponse {
|
||||
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 {
|
||||
deleteMedia: (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 }>;
|
||||
getServerRunningProcess: (etkeAdminUrl: string) => Promise<ServerProcessResponse>;
|
||||
getServerStatus: (etkeAdminUrl: string) => Promise<ServerStatusResponse>;
|
||||
getServerNotifications: (etkeAdminUrl: string) => Promise<ServerNotificationsResponse>;
|
||||
deleteServerNotifications: (etkeAdminUrl: string) => Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
const resourceMap = {
|
||||
@@ -995,6 +1008,60 @@ const baseDataProvider: SynapseDataProvider = {
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -53,7 +53,6 @@ export const FetchConfig = async () => {
|
||||
console.log(`${protocol}://${homeserver}/.well-known/matrix/client not found, skipping`, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// load config from context
|
||||
|
@@ -42,19 +42,20 @@ export function generateRandomMXID(): string {
|
||||
* @returns full MXID as 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
|
||||
if (input.endsWith(homeserver) && input.startsWith("@")) {
|
||||
return input as string; // Already a valid MXID
|
||||
if (homeserver != "" && inputStr.endsWith(homeserver) && inputStr.startsWith("@")) {
|
||||
return inputStr; // Already a valid MXID
|
||||
}
|
||||
|
||||
// Check if the input already looks like a valid MXID (i.e., starts with "@" and contains ":")
|
||||
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
|
||||
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}`;
|
||||
}
|
||||
|
Reference in New Issue
Block a user