Add server status page and a badge in AppBar (#182)

* WIP: Add server status and running process indicators

* Finish ServerStatusPage and ServerRunningProcess

* fix typos, add running process info to the server status page

* Remove ServerRunningProcess and integrate it into ServerStatusBadge

* remove divider in menu

* display time as started X minutes ago

* add documentation; clearly state what new components are; update readme

* change wording a bit, cross-link with docs/README.md

* use returned HTML

* Finish ServerStatus page and badges

* Fix types

* cleanup

* remove some code

* adjust config load
This commit is contained in:
Borislav Pantaleev 2024-12-04 00:14:29 +02:00 committed by GitHub
parent 3b69e78bb8
commit 7c21692a1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 449 additions and 4 deletions

View File

@ -101,6 +101,7 @@ The following changes are already implemented:
* 🖼️ [Add rooms' avatars](https://github.com/etkecc/synapse-admin/pull/158) * 🖼️ [Add rooms' avatars](https://github.com/etkecc/synapse-admin/pull/158)
* 🤖 [User Badges](https://github.com/etkecc/synapse-admin/pull/160) * 🤖 [User Badges](https://github.com/etkecc/synapse-admin/pull/160)
* 🔑 [Allow prefilling any fields on the login form via GET params](https://github.com/etkecc/synapse-admin/pull/181) * 🔑 [Allow prefilling any fields on the login form via GET params](https://github.com/etkecc/synapse-admin/pull/181)
* _(for [etke.cc](https://etke.cc) customers only)_ [Server Status indicator and page](https://github.com/etkecc/synapse-admin/pull/182)
_the list will be updated as new changes are added_ _the list will be updated as new changes are added_

View File

@ -26,6 +26,14 @@ Specific configuration options:
* [User Badges](./user-badges.md) * [User Badges](./user-badges.md)
* [Prefilling the Login Form](./prefill-login-form.md) * [Prefilling the Login Form](./prefill-login-form.md)
for [etke.cc](https://etke.cc) customers only:
> **Note:** The following features are only available for etke.cc customers. Due to specifics of the implementation,
they are not available for any other Synapse Admin deployments.
* [Server Status icon](../src/components/etke.cc/README.md#server-status-icon)
* [Server Status page](../src/components/etke.cc/README.md#server-status-page)
## Deployment ## Deployment
* [Serving Synapse Admin behind a reverse proxy](./reverse-proxy.md) * [Serving Synapse Admin behind a reverse proxy](./reverse-proxy.md)

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -22,9 +22,10 @@ import rooms from "./resources/rooms";
import userMediaStats from "./resources/user_media_statistics"; import userMediaStats from "./resources/user_media_statistics";
import users from "./resources/users"; import users from "./resources/users";
import authProvider from "./synapse/authProvider"; import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider"; import dataProvider, { ServerStatusResponse } from "./synapse/dataProvider";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Config } from "./utils/config"; import { Config } from "./utils/config";
import ServerStatusPage from "./components/etke.cc/ServerStatusPage";
// TODO: Can we use lazy loading together with browser locale? // TODO: Can we use lazy loading together with browser locale?
const messages = { const messages = {
@ -64,6 +65,7 @@ export const App = () => (
> >
<CustomRoutes> <CustomRoutes>
<Route path="/import_users" element={<UserImport />} /> <Route path="/import_users" element={<UserImport />} />
<Route path="/server_status" element={<ServerStatusPage />} />
</CustomRoutes> </CustomRoutes>
<Resource {...users} /> <Resource {...users} />
<Resource {...rooms} /> <Resource {...rooms} />

View File

@ -4,6 +4,7 @@ import { useEffect, useState, Suspense } from "react";
import { Icons, DefaultIcon } from "../utils/icons"; 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";
const AdminUserMenu = () => { const AdminUserMenu = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -48,6 +49,7 @@ const AdminUserMenu = () => {
const AdminAppBar = () => { const AdminAppBar = () => {
return (<AppBar userMenu={<AdminUserMenu />}> return (<AppBar userMenu={<AdminUserMenu />}>
<TitlePortal /> <TitlePortal />
<ServerStatusBadge />
<InspectorButton /> <InspectorButton />
</AppBar>); </AppBar>);
}; };

View File

@ -0,0 +1,30 @@
# etke.cc-specific components
This directory contains [etke.cc](https://etke.cc)-specific components, unusable for any other purposes and/or configuration.
We at [etke.cc](https://etke.cc) attempting to develop everything open-source, but some things are too specific to be used by anyone else. This directory contains such components - they are only available for [etke.cc](https://etke.cc) customers.
Due to the specifics mentioned above, these components are documented here rather than in the [docs](../../../docs/README.md), plus they are not supported as part of the Synapse Admin open-source project (i.e.: no issues, no PRs, no support, no requests, etc.).
## Components
### Server Status icon
![Server Status icon](../../../screenshots/etke.cc/server-status/indicator.webp)
In the application bar the new monitoring icon is displayed that shows the current server status, and has the following color dot (and tooltip indicators):
* 🟢 (green) - the server is up and running, everything is fine, no issues detected
* 🟡 (yellow) - the server is up and running, but there is a command in progress (likely [maintenance](https://etke.cc/help/extras/scheduler/#maintenance)), so some temporary issues may occur - that's totally fine
* 🔴 (red) - there is at least 1 issue with one of the server's components
### Server Status page
![Server Status Page](../../../screenshots/etke.cc/server-status/page.webp)
When you click on the [Server Status icon](#server-status-icon) in the application bar, you will be redirected to the
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

View File

@ -0,0 +1,169 @@
import { Avatar, Badge, Theme, Tooltip } from "@mui/material";
import { useEffect } from "react";
import { useAppContext } from "../../App";
import { Button, useDataProvider, useStore } from "react-admin";
import { styled } from '@mui/material/styles';
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import { BadgeProps } from "@mui/material/Badge";
import { useNavigate } from "react-router";
import { useTheme } from "@mui/material/styles";
import { ServerProcessResponse, ServerStatusResponse } from "../../synapse/dataProvider";
interface StyledBadgeProps extends BadgeProps {
backgroundColor: string;
badgeColor: string
theme?: Theme;
}
const StyledBadge = styled(Badge, { shouldForwardProp: (prop) => !['badgeColor', 'backgroundColor'].includes(prop as string) })<StyledBadgeProps>
(({ theme, backgroundColor, badgeColor }) => ({
'& .MuiBadge-badge': {
backgroundColor: backgroundColor,
color: badgeColor,
boxShadow: `0 0 0 2px ${theme.palette.background.paper}`,
'&::after': {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
borderRadius: '50%',
animation: 'ripple 2.5s infinite ease-in-out',
border: '1px solid currentColor',
content: '""',
},
},
'@keyframes ripple': {
'0%': {
transform: 'scale(.8)',
opacity: 1,
},
'100%': {
transform: 'scale(2.4)',
opacity: 0,
},
},
}));
// every 5 minutes
const SERVER_STATUS_INTERVAL_TIME = 5 * 60 * 1000;
// every 5 seconds
const SERVER_CURRENT_PROCCESS_INTERVAL_TIME = 5 * 1000;
const useServerStatus = () => {
const [serverStatus, setServerStatus] = useStore<ServerStatusResponse>("serverStatus", { ok: false, success: false, host: "", results: [] });
const { etkeccAdmin } = useAppContext();
const dataProvider = useDataProvider();
const isOkay = serverStatus.ok;
const successCheck = serverStatus.success;
const checkServerStatus = async () => {
const serverStatus: ServerStatusResponse = await dataProvider.getServerStatus(etkeccAdmin);
setServerStatus({
ok: serverStatus.ok,
success: serverStatus.success,
host: serverStatus.host,
results: serverStatus.results,
});
};
useEffect(() => {
let serverStatusInterval: NodeJS.Timeout;
if (etkeccAdmin) {
checkServerStatus();
setTimeout(() => {
// start the interval after 10 seconds to avoid too many requests
serverStatusInterval = setInterval(checkServerStatus, SERVER_STATUS_INTERVAL_TIME);
}, 10000);
} else {
setServerStatus({ ok: false, success: false, host: "", results: [] });
}
return () => {
if (serverStatusInterval) {
clearInterval(serverStatusInterval);
}
}
}, [etkeccAdmin]);
return { isOkay, successCheck };
};
const useCurrentServerProcess = () => {
const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", { command: "", locked_at: "" });
const { etkeccAdmin } = useAppContext();
const dataProvider = useDataProvider();
const { command, locked_at } = serverProcess;
const checkServerRunningProcess = async () => {
const serverProcess: ServerProcessResponse = await dataProvider.getServerRunningProcess(etkeccAdmin);
setServerProcess({
...serverProcess,
command: serverProcess.command,
locked_at: serverProcess.locked_at
});
}
useEffect(() => {
let serverCheckInterval: NodeJS.Timeout;
if (etkeccAdmin) {
checkServerRunningProcess();
setTimeout(() => {
serverCheckInterval = setInterval(checkServerRunningProcess, SERVER_CURRENT_PROCCESS_INTERVAL_TIME);
}, 5000);
} else {
setServerProcess({ command: "", locked_at: "" });
}
return () => {
if (serverCheckInterval) {
clearInterval(serverCheckInterval);
}
}
}, [etkeccAdmin]);
return { command, locked_at };
};
const ServerStatusBadge = () => {
const { isOkay, successCheck } = useServerStatus();
const { command, locked_at } = useCurrentServerProcess();
const theme = useTheme();
const navigate = useNavigate();
if (!successCheck) {
return null;
}
const handleServerStatusClick = () => {
navigate("/server_status");
};
let tooltipText = "Click to view Server Status";
let badgeBackgroundColor = isOkay ? theme.palette.success.light : theme.palette.error.main;
let badgeColor = isOkay ? theme.palette.success.light : theme.palette.error.main;
if (command && locked_at) {
badgeBackgroundColor = theme.palette.warning.main;
badgeColor = theme.palette.warning.main;
tooltipText = `Running: ${command}; ${tooltipText}`;
}
return <Button onClick={handleServerStatusClick} size="medium" sx={{ minWidth: "auto", ".MuiButton-startIcon": { m: 0 }}}>
<Tooltip title={tooltipText} sx={{ cursor: "pointer" }}>
<StyledBadge
overlap="circular"
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
variant="dot"
backgroundColor={badgeBackgroundColor}
badgeColor={badgeColor}
>
<Avatar sx={{ height: 24, width: 24, background: theme.palette.mode === "dark" ? theme.palette.background.default : "#2196f3" }}>
<MonitorHeartIcon sx={{ height: 22, width: 22, color: theme.palette.common.white }} />
</Avatar>
</StyledBadge>
</Tooltip>
</Button>
};
export default ServerStatusBadge;

View File

@ -0,0 +1,144 @@
import { useStore } from "ra-core";
import { Box, Stack, Typography, Paper, Link, Chip, Divider, Tooltip, ChipProps } from "@mui/material";
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from "@mui/icons-material/Close";
import EngineeringIcon from '@mui/icons-material/Engineering';
import { ServerProcessResponse, ServerStatusComponent, ServerStatusResponse } from "../../synapse/dataProvider";
const getTimeSince = (date: string) => {
const now = new Date();
const past = new Date(date);
const diffInMinutes = Math.floor((now.getTime() - past.getTime()) / (1000 * 60));
if (diffInMinutes < 1) return "a couple of seconds";
if (diffInMinutes === 1) return "1 minute";
return `${diffInMinutes} minutes`;
};
const StatusChip = ({ isOkay, size = "medium", command }: { isOkay: boolean, size?: "small" | "medium", command?: string }) => {
let label = "OK";
let icon = <CheckIcon />;
let color: ChipProps["color"] = "success";
if (!isOkay) {
label = "Error";
icon = <CloseIcon />;
color = "error";
}
if (command) {
label = command;
color = "warning";
icon = <EngineeringIcon />;
}
return (
<Chip icon={icon} label={label} color={color} variant="outlined" size={size} />
);
};
const ServerComponentText = ({ text }: { text: string }) => {
return <Typography variant="body1" dangerouslySetInnerHTML={{ __html: text }} />;
};
const ServerStatusPage = () => {
const [serverStatus, setServerStatus] = useStore<ServerStatusResponse>("serverStatus", {
ok: false,
success: false,
host: "",
results: [],
});
const [ serverProcess, setServerProcess ] = useStore<ServerProcessResponse>("serverProcess", { command: "", locked_at: "" });
const { command, locked_at } = serverProcess;
const successCheck = serverStatus.success;
const isOkay = serverStatus.ok;
const host = serverStatus.host;
const results = serverStatus.results;
let groupedResults: Record<string, ServerStatusComponent[]> = {};
for (const result of results) {
if (!groupedResults[result.category]) {
groupedResults[result.category] = [];
}
groupedResults[result.category].push(result);
}
if (!successCheck) {
return (
<Paper elevation={3} sx={{ p: 3, mt: 3 }}>
<Stack direction="row" spacing={2} alignItems="center">
<CloseIcon color="error" />
<Typography color="error">
Unable to fetch server status. Please try again later.
</Typography>
</Stack>
</Paper>
);
}
return (
<Stack spacing={3} mt={3}>
<Stack spacing={1} direction="row" alignItems="center">
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography variant="h4">Status:</Typography>
<StatusChip isOkay={isOkay} command={command} />
</Box>
<Typography variant="h5" color="primary" fontWeight="medium">
{host}
</Typography>
</Stack>
{command && locked_at && (
<Stack spacing={1} direction="row" alignItems="center">
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography variant="h5">Currently running:</Typography>
<Typography variant="h5" color="text.secondary">
<Link href={"https://etke.cc/help/extras/scheduler/#"+command} target="_blank">
{command}
</Link>
<Tooltip title={locked_at.toString()}>
<Typography component="span" color="text.secondary" sx={{ display: "inline-block", ml: 1 }}>(started {getTimeSince(locked_at)} ago)</Typography>
</Tooltip>
</Typography>
</Box>
</Stack>
)}
<Stack spacing={2} direction="row">
{Object.keys(groupedResults).map((category, idx) => (
<Box key={`category_${category}`} sx={{ flex: 1 }}>
<Typography variant="h5" mb={1}>
{category}
</Typography>
<Paper elevation={2} sx={{ p: 3 }}>
<Stack spacing={1} divider={<Divider />}>
{groupedResults[category].map((result, idx) => (
<Box key={`${category}_${idx}`}>
<Stack spacing={2}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<StatusChip isOkay={result.ok} size="small" />
{result.label.url ? (
<Link href={result.label.url} target="_blank" rel="noopener noreferrer">
<ServerComponentText text={result.label.text} />
</Link>
) : (
<ServerComponentText text={result.label.text} />
)}
</Box>
{result.reason && <Typography color="text.secondary" dangerouslySetInnerHTML={{ __html: result.reason }}/>}
{(!result.ok && result.help) && (
<Link href={result.help} target="_blank" rel="noopener noreferrer" sx={{ mt: 1 }}>
Learn more
</Link>
)}
</Stack>
</Box>
))}
</Stack>
</Paper>
</Box>
))}
</Stack>
</Stack>
);
};
export default ServerStatusPage;

View File

@ -265,6 +265,31 @@ export interface UsernameAvailabilityResult {
errcode?: string; errcode?: string;
} }
export interface ServerStatusComponent {
ok: boolean;
category: string;
reason: string;
url: string;
help: string;
label: {
url: string;
icon: string;
text: string;
}
}
export interface ServerStatusResponse {
success: boolean;
ok: boolean;
host: string;
results: ServerStatusComponent[];
}
export interface ServerProcessResponse {
locked_at?: string;
command?: string;
}
export interface SynapseDataProvider extends DataProvider { export interface SynapseDataProvider extends DataProvider {
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>; deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>; uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>;
@ -273,6 +298,8 @@ export interface SynapseDataProvider extends DataProvider {
setRateLimits: (id: Identifier, rateLimits: RateLimitsModel) => Promise<void>; setRateLimits: (id: Identifier, rateLimits: RateLimitsModel) => Promise<void>;
checkUsernameAvailability: (username: string) => Promise<UsernameAvailabilityResult>; checkUsernameAvailability: (username: string) => Promise<UsernameAvailabilityResult>;
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>;
getServerStatus: (etkeAdminUrl: string) => Promise<ServerStatusResponse>;
} }
const resourceMap = { const resourceMap = {
@ -880,6 +907,60 @@ const baseDataProvider: SynapseDataProvider = {
} }
throw error; throw error;
} }
},
getServerRunningProcess: async (runningProcessUrl: string): Promise<ServerProcessResponse> => {
const locked_at = "";
const command = "";
try {
const response = await fetch(`${runningProcessUrl}/lock`, {
headers: {
"Authorization": `Bearer ${localStorage.getItem("access_token")}`
}
});
if (!response.ok) {
console.error(`Error getting server running process: ${response.status} ${response.statusText}`);
return { locked_at, command };
}
const status = response.status;
if (status === 200) {
const json = await response.json();
return json as { locked_at: string; command: string };
}
if (status === 204) {
return { locked_at, command };
}
} catch (error) {
console.error("Error getting server running process", error);
}
return { locked_at, command };
},
getServerStatus: async (serverStatusUrl: string): Promise<ServerStatusResponse> => {
try {
const response = await fetch(`${serverStatusUrl}/status`, {
headers: {
"Authorization": `Bearer ${localStorage.getItem("access_token")}`
}
});
if (!response.ok) {
console.error(`Error getting server status: ${response.status} ${response.statusText}`);
return { success: false, ok: false, host: "", results: [] };
}
const status = response.status;
if (status === 200) {
const json = await response.json();
const result = { success: true, ...json } as ServerStatusResponse;
return result;
}
} catch (error) {
console.error("Error getting server status", error);
}
return { success: false, ok: false, host: "", results: [] };
} }
}; };

View File

@ -2,6 +2,7 @@ export interface Config {
restrictBaseUrl: string | string[]; restrictBaseUrl: string | string[];
asManagedUsers: RegExp[]; asManagedUsers: RegExp[];
menu: MenuItem[]; menu: MenuItem[];
etkeccAdmin?: string;
} }
export interface MenuItem { export interface MenuItem {
@ -17,6 +18,7 @@ let config: Config = {
restrictBaseUrl: "", restrictBaseUrl: "",
asManagedUsers: [], asManagedUsers: [],
menu: [], menu: [],
etkeccAdmin: ""
}; };
export const FetchConfig = async () => { export const FetchConfig = async () => {
@ -38,9 +40,9 @@ export const FetchConfig = async () => {
if (!configWK[WellKnownKey]) { if (!configWK[WellKnownKey]) {
console.log(`Loaded https://${homeserver}.well-known/matrix/client, but it doesn't contain ${WellKnownKey} key, skipping`, configWK); console.log(`Loaded https://${homeserver}.well-known/matrix/client, but it doesn't contain ${WellKnownKey} key, skipping`, configWK);
} else { } else {
console.log(`Loaded https://${homeserver}.well-known/matrix/client`, configWK); console.log(`Loaded https://${homeserver}.well-known/matrix/client`, configWK);
LoadConfig(configWK[WellKnownKey]); LoadConfig(configWK[WellKnownKey]);
} }
} catch (e) { } catch (e) {
console.log(`https://${homeserver}/.well-known/matrix/client not found, skipping`, e); console.log(`https://${homeserver}/.well-known/matrix/client not found, skipping`, e);
} }
@ -49,6 +51,8 @@ export const FetchConfig = async () => {
} }
// load config from context // load config from context
// we deliberately processing each key separately to avoid overwriting the whole config, loosing some keys, and messing
// with typescript types
export const LoadConfig = (context: any) => { export const LoadConfig = (context: any) => {
if (context?.restrictBaseUrl) { if (context?.restrictBaseUrl) {
config.restrictBaseUrl = context.restrictBaseUrl as string | string[]; config.restrictBaseUrl = context.restrictBaseUrl as string | string[];
@ -65,6 +69,10 @@ export const LoadConfig = (context: any) => {
if (menu.length > 0) { if (menu.length > 0) {
config.menu = menu; config.menu = menu;
} }
if (context?.etkeccAdmin) {
config.etkeccAdmin = context.etkeccAdmin;
}
} }
// get config // get config