Merge pull request #365 from etkecc/add-etke-server-commands

Add etke server commands panel inside server status page
This commit is contained in:
Aine 2025-03-05 13:04:41 +00:00 committed by GitHub
commit 4404840b16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 443 additions and 58 deletions

View File

@ -118,6 +118,7 @@ The following list contains such features - they are only available for [etke.cc
* 📊 [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) * 📬 [Server Notifications indicator and page](https://github.com/etkecc/synapse-admin/pull/240)
* 🛠️ [Server Commands panel](https://github.com/etkecc/synapse-admin/pull/365)
### Development ### Development

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1,4 +1,4 @@
import { CheckForApplicationUpdate, AppBar, TitlePortal, InspectorButton, Confirm, Layout, Logout, Menu, useLogout, UserMenu } from "react-admin"; import { CheckForApplicationUpdate, AppBar, TitlePortal, InspectorButton, Confirm, Layout, Logout, Menu, useLogout, UserMenu, useStore } from "react-admin";
import { LoginMethod } from "../pages/LoginPage"; import { LoginMethod } from "../pages/LoginPage";
import { useEffect, useState, Suspense } from "react"; import { useEffect, useState, Suspense } from "react";
import { Icons, DefaultIcon } from "../utils/icons"; import { Icons, DefaultIcon } from "../utils/icons";
@ -6,6 +6,8 @@ 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"; import { ServerNotificationsBadge } from "./etke.cc/ServerNotificationsBadge";
import { ServerProcessResponse, ServerStatusResponse } from "../synapse/dataProvider";
import { ServerStatusStyledBadge } from "./etke.cc/ServerStatusBadge";
const AdminUserMenu = () => { const AdminUserMenu = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -59,9 +61,20 @@ const AdminAppBar = () => {
const AdminMenu = (props) => { const AdminMenu = (props) => {
const [menu, setMenu] = useState([] as MenuItem[]); const [menu, setMenu] = useState([] as MenuItem[]);
useEffect(() => setMenu(GetConfig().menu), []); useEffect(() => setMenu(GetConfig().menu), []);
const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", { command: "", locked_at: "" });
const [serverStatus, setServerStatus] = useStore<ServerStatusResponse>("serverStatus", { success: false, ok: false, host: "", results: [] });
return ( return (
<Menu {...props}> <Menu {...props}>
{menu && menu.length > 0 && <Menu.Item to="/server_status" leftIcon={
<ServerStatusStyledBadge
inSidebar={true}
command={serverProcess.command}
locked_at={serverProcess.locked_at}
isOkay={serverStatus.ok} />
}
primaryText="Server Status" />
}
<Menu.ResourceItems /> <Menu.ResourceItems />
{menu && menu.map((item, index) => { {menu && menu.map((item, index) => {
const { url, icon, label } = item; const { url, icon, label } = item;

View File

@ -18,6 +18,10 @@ In the application bar the new monitoring icon is displayed that shows the curre
* 🟡 (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 * 🟡 (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 * 🔴 (red) - there is at least 1 issue with one of the server's components
![Server Status icon in sidebar](../../../screenshots/etke.cc/server-status/indicator-sidebar.webp)
The same icon (and link to the [Server Status page](#server-status-page)) is displayed in the sidebar.
### Server Status page ### Server Status page
![Server Status Page](../../../screenshots/etke.cc/server-status/page.webp) ![Server Status Page](../../../screenshots/etke.cc/server-status/page.webp)
@ -29,6 +33,8 @@ Server Status page. This page contains the following information:
* 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
This is [a Monitoring report](https://etke.cc/services/monitoring/)
### Server Notifications icon ### Server Notifications icon
![Server Notifications icon](../../../screenshots/etke.cc/server-notifications/badge.webp) ![Server Notifications icon](../../../screenshots/etke.cc/server-notifications/badge.webp)
@ -40,3 +46,11 @@ In the application bar the new notifications icon is displayed that shows the nu
![Server Notifications Page](../../../screenshots/etke.cc/server-notifications/page.webp) ![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. 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.
### Server Commands Panel
![Server Commands Panel](../../../screenshots/etke.cc/server-commands/panel.webp)
When you open [Server Status page](#server-status-page), you will see the Server Commands panel. This panel contains all
[the commands](https://etke.cc/help/extras/scheduler/#commands) you can run on your server in 1 click. Once command is finished, you will get a notification about the
result.

View File

@ -0,0 +1,182 @@
import { useEffect, useState } from "react";
import { Button, Loading, useDataProvider, useCreatePath, useStore } from "react-admin";
import { useAppContext } from "../../Context";
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Alert, TextField, Box } from "@mui/material";
import { PlayArrow, CheckCircle } from "@mui/icons-material";
import { Icons } from "../../utils/icons";
import { ServerCommand, ServerProcessResponse } from "../../synapse/dataProvider";
import { Link } from 'react-router-dom';
const renderIcon = (icon: string) => {
const IconComponent = Icons[icon] as React.ComponentType<any> | undefined;
return IconComponent ? <IconComponent sx={{ verticalAlign: "middle", mr: 1 }} /> : null;
}
const ServerCommandsPanel = () => {
const { etkeccAdmin } = useAppContext();
const createPath = useCreatePath();
const [ isLoading, setLoading ] = useState(true);
const [serverCommands, setServerCommands] = useState<{ [key: string]: ServerCommand }>({});
const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", { command: "", locked_at: "" });
const [commandIsRunning, setCommandIsRunning] = useState<boolean>(serverProcess.command !== "");
const [commandResult, setCommandResult] = useState<any[]>([]);
const dataProvider = useDataProvider();
useEffect(() => {
const fetchIsAdmin = async () => {
const serverCommandsResponse = await dataProvider.getServerCommands(etkeccAdmin);
if (serverCommandsResponse) {
const serverCommands = serverCommandsResponse;
Object.keys(serverCommandsResponse).forEach((command: string) => {
serverCommands[command].additionalArgs = "";
});
setServerCommands(serverCommands);
}
setLoading(false);
}
fetchIsAdmin();
}, []);
useEffect(() => {
if (serverProcess.command === "") {
setCommandIsRunning(false);
}
}, [serverProcess]);
const setCommandAdditionalArgs = (command: string, additionalArgs: string) => {
const updatedServerCommands = {...serverCommands};
updatedServerCommands[command].additionalArgs = additionalArgs;
setServerCommands(updatedServerCommands);
}
const runCommand = async (command: string) => {
setCommandResult([]);
setCommandIsRunning(true);
try {
const additionalArgs = serverCommands[command].additionalArgs || "";
const requestParams = additionalArgs ? { args: additionalArgs } : {};
const response = await dataProvider.runServerCommand(etkeccAdmin, command, requestParams);
if (!response.success) {
setCommandIsRunning(false);
return;
}
// Update UI with success message
const commandResults = buildCommandResultMessages(command, additionalArgs);
setCommandResult(commandResults);
// Reset the additional args field
resetCommandArgs(command);
// Update server process status
await updateServerProcessStatus(serverCommands[command]);
} catch (error) {
setCommandIsRunning(false);
}
};
const buildCommandResultMessages = (command: string, additionalArgs: string): React.ReactNode[] => {
const results: React.ReactNode[] = [];
let commandScheduledText = `Command scheduled: ${command}`;
if (additionalArgs) {
commandScheduledText += `, with additional args: ${additionalArgs}`;
}
results.push(<Box>{commandScheduledText}</Box>);
results.push(
<Box>
Expect your result in the <Link to={createPath({ resource: "server_notifications", type: "list" })}>Notifications</Link> page soon.
</Box>
);
return results;
};
const resetCommandArgs = (command: string) => {
const updatedServerCommands = {...serverCommands};
updatedServerCommands[command].additionalArgs = "";
setServerCommands(updatedServerCommands);
};
const updateServerProcessStatus = async (command: ServerCommand) => {
const commandIsLocking = command.with_lock;
const serverProcess = await dataProvider.getServerRunningProcess(etkeccAdmin, true);
if (!commandIsLocking && serverProcess.command === "") {
// if command is not locking, we simulate the "lock" mechanism so notifications will be refetched
serverProcess["command"] = command.name;
serverProcess["locked_at"] = new Date().toISOString();
}
setServerProcess({...serverProcess});
};
if (isLoading) {
return <Loading />
}
return (<>
<h2>Server Commands</h2>
<TableContainer component={Paper}>
<Table sx={{ minWidth: 450 }} size="small" aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>Command</TableCell>
<TableCell>Description</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(serverCommands).map(([command, { icon, args, description, additionalArgs }]) => (
<TableRow
key={command}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell scope="row">
<Box>
{renderIcon(icon)}
{command}
</Box>
</TableCell>
<TableCell>{description}</TableCell>
<TableCell>
{args && <TextField
size="small"
variant="standard"
onChange={(e) => {
setCommandAdditionalArgs(command, e.target.value);
}}
value={additionalArgs}
/>}
<Button
size="small"
variant="contained"
color="primary"
label="Run"
startIcon={<PlayArrow />}
onClick={() => { runCommand(command); }}
disabled={commandIsRunning || (args && typeof additionalArgs === 'string' && additionalArgs.length === 0)}
></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{commandResult.length > 0 && <Alert icon={<CheckCircle fontSize="inherit" />} severity="success">
{commandResult.map((result, index) => (
<div key={index}>{result}</div>
))}
</Alert>}
</>
)
};
export default ServerCommandsPanel;

View File

@ -5,21 +5,28 @@ import { useDataProvider, useStore } from "react-admin";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { Fragment, useEffect, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import { useAppContext } from "../../Context"; import { useAppContext } from "../../Context";
import { ServerNotificationsResponse } from "../../synapse/dataProvider"; import { ServerNotificationsResponse, ServerProcessResponse } from "../../synapse/dataProvider";
import { getTimeSince } from "../../utils/date";
// 5 minutes
const SERVER_NOTIFICATIONS_INTERVAL_TIME = 300000; const SERVER_NOTIFICATIONS_INTERVAL_TIME = 300000;
const useServerNotifications = () => { const useServerNotifications = () => {
const [serverNotifications, setServerNotifications] = useStore<ServerNotificationsResponse>("serverNotifications", { notifications: [], success: false }); const [serverNotifications, setServerNotifications] = useStore<ServerNotificationsResponse>("serverNotifications", { notifications: [], success: false });
const [ serverProcess, setServerProcess ] = useStore<ServerProcessResponse>("serverProcess", { command: "", locked_at: "" });
const { command, locked_at } = serverProcess;
const { etkeccAdmin } = useAppContext(); const { etkeccAdmin } = useAppContext();
const dataProvider = useDataProvider(); const dataProvider = useDataProvider();
const { notifications, success } = serverNotifications; const { notifications, success } = serverNotifications;
const fetchNotifications = async () => { const fetchNotifications = async () => {
const notificationsResponse: ServerNotificationsResponse = await dataProvider.getServerNotifications(etkeccAdmin); const notificationsResponse: ServerNotificationsResponse = await dataProvider.getServerNotifications(etkeccAdmin, command !== "");
const serverNotifications = [...notificationsResponse.notifications];
serverNotifications.reverse();
setServerNotifications({ setServerNotifications({
...notificationsResponse, ...notificationsResponse,
notifications: notificationsResponse.notifications, notifications: serverNotifications,
success: notificationsResponse.success success: notificationsResponse.success
}); });
}; };
@ -35,21 +42,26 @@ const useServerNotifications = () => {
}; };
useEffect(() => { useEffect(() => {
let serverNotificationsInterval: NodeJS.Timeout; let serverNotificationsInterval: NodeJS.Timeout | null = null;
let timeoutId: NodeJS.Timeout | null = null;
if (etkeccAdmin) { if (etkeccAdmin) {
fetchNotifications(); fetchNotifications();
setTimeout(() => { timeoutId = setTimeout(() => {
// start the interval after the SERVER_NOTIFICATIONS_INTERVAL_TIME to avoid too many requests // start the interval after the SERVER_NOTIFICATIONS_INTERVAL_TIME to avoid too many requests
serverNotificationsInterval = setInterval(fetchNotifications, SERVER_NOTIFICATIONS_INTERVAL_TIME); serverNotificationsInterval = setInterval(fetchNotifications, SERVER_NOTIFICATIONS_INTERVAL_TIME);
}, SERVER_NOTIFICATIONS_INTERVAL_TIME); }, SERVER_NOTIFICATIONS_INTERVAL_TIME);
} }
return () => { return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (serverNotificationsInterval) { if (serverNotificationsInterval) {
clearInterval(serverNotificationsInterval); clearInterval(serverNotificationsInterval);
} }
} }
}, [etkeccAdmin]); }, [etkeccAdmin, command, locked_at]);
return { success, notifications, deleteServerNotifications }; return { success, notifications, deleteServerNotifications };
}; };
@ -108,6 +120,7 @@ export const ServerNotificationsBadge = () => {
sx={{ sx={{
p: 1, p: 1,
maxHeight: "350px", maxHeight: "350px",
paddingTop: 0,
overflowY: "auto", overflowY: "auto",
minWidth: "300px", minWidth: "300px",
maxWidth: { maxWidth: {
@ -126,7 +139,6 @@ export const ServerNotificationsBadge = () => {
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
fontWeight: "bold", fontWeight: "bold",
backgroundColor: "inherit",
}} }}
> >
<Typography variant="h6">Notifications</Typography> <Typography variant="h6">Notifications</Typography>
@ -134,10 +146,14 @@ export const ServerNotificationsBadge = () => {
</ListSubheader> </ListSubheader>
<Divider /> <Divider />
{notifications.map((notification, index) => { {notifications.map((notification, index) => {
return (<Fragment key={notification.event_id ? notification.event_id : index }> return (<Fragment key={notification.event_id ? notification.event_id + index : index }>
<ListItem <ListItem
onClick={() => handleSeeAllNotifications()} onClick={() => handleSeeAllNotifications()}
sx={{ sx={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
overflow: "hidden",
"&:hover": { "&:hover": {
backgroundColor: "action.hover", backgroundColor: "action.hover",
cursor: "pointer" cursor: "pointer"
@ -151,12 +167,16 @@ export const ServerNotificationsBadge = () => {
sx={{ sx={{
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap"
}} }}
dangerouslySetInnerHTML={{ __html: notification.output.split("\n")[0] }} dangerouslySetInnerHTML={{ __html: notification.output.split("\n")[0] }}
/> />
} }
/> />
<ListItemText
primary={
<Typography variant="body2" sx={{ color: theme.palette.text.secondary }}>{getTimeSince(notification.sent_at) + " ago"}</Typography>
}
/>
</ListItem> </ListItem>
<Divider /> <Divider />
</Fragment> </Fragment>

View File

@ -4,9 +4,12 @@ import { useStore } from "react-admin"
import dataProvider, { ServerNotificationsResponse } from "../../synapse/dataProvider" import dataProvider, { ServerNotificationsResponse } from "../../synapse/dataProvider"
import { useAppContext } from "../../Context"; import { useAppContext } from "../../Context";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import { getTimeSince } from "../../utils/date";
import { Tooltip } from "@mui/material";
const DisplayTime = ({ date }: { date: string }) => { const DisplayTime = ({ date }: { date: string }) => {
const dateFromDateString = new Date(date); const dateFromDateString = new Date(date.replace(" ", "T") + "Z");
return <>{dateFromDateString.toLocaleString()}</>; return <Tooltip title={dateFromDateString.toLocaleString()}>{<span>{getTimeSince(date) + " ago"}</span>}</Tooltip>;
}; };
const ServerNotificationsPage = () => { const ServerNotificationsPage = () => {

View File

@ -1,4 +1,4 @@
import { Avatar, Badge, Theme, Tooltip } from "@mui/material"; import { Avatar, Badge, Box, Theme, Tooltip } from "@mui/material";
import { useEffect } from "react"; import { useEffect } from "react";
import { useAppContext } from "../../Context"; import { useAppContext } from "../../Context";
import { Button, useDataProvider, useStore } from "react-admin"; import { Button, useDataProvider, useStore } from "react-admin";
@ -52,13 +52,15 @@ const SERVER_CURRENT_PROCCESS_INTERVAL_TIME = 5 * 1000;
const useServerStatus = () => { const useServerStatus = () => {
const [serverStatus, setServerStatus] = useStore<ServerStatusResponse>("serverStatus", { ok: false, success: false, host: "", results: [] }); 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 { etkeccAdmin } = useAppContext(); const { etkeccAdmin } = useAppContext();
const dataProvider = useDataProvider(); const dataProvider = useDataProvider();
const isOkay = serverStatus.ok; const isOkay = serverStatus.ok;
const successCheck = serverStatus.success; const successCheck = serverStatus.success;
const checkServerStatus = async () => { const checkServerStatus = async () => {
const serverStatus: ServerStatusResponse = await dataProvider.getServerStatus(etkeccAdmin); const serverStatus: ServerStatusResponse = await dataProvider.getServerStatus(etkeccAdmin, command !== "");
setServerStatus({ setServerStatus({
ok: serverStatus.ok, ok: serverStatus.ok,
success: serverStatus.success, success: serverStatus.success,
@ -68,10 +70,12 @@ const useServerStatus = () => {
}; };
useEffect(() => { useEffect(() => {
let serverStatusInterval: NodeJS.Timeout; let serverStatusInterval: NodeJS.Timeout | null = null;
let timeoutId: NodeJS.Timeout | null = null;
if (etkeccAdmin) { if (etkeccAdmin) {
checkServerStatus(); checkServerStatus();
setTimeout(() => { timeoutId = setTimeout(() => {
// start the interval after 10 seconds to avoid too many requests // start the interval after 10 seconds to avoid too many requests
serverStatusInterval = setInterval(checkServerStatus, SERVER_STATUS_INTERVAL_TIME); serverStatusInterval = setInterval(checkServerStatus, SERVER_STATUS_INTERVAL_TIME);
}, 10000); }, 10000);
@ -80,11 +84,14 @@ const useServerStatus = () => {
} }
return () => { return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (serverStatusInterval) { if (serverStatusInterval) {
clearInterval(serverStatusInterval); clearInterval(serverStatusInterval);
} }
} }
}, [etkeccAdmin]); }, [etkeccAdmin, command]);
return { isOkay, successCheck }; return { isOkay, successCheck };
}; };
@ -96,7 +103,7 @@ const useCurrentServerProcess = () => {
const { command, locked_at } = serverProcess; const { command, locked_at } = serverProcess;
const checkServerRunningProcess = async () => { const checkServerRunningProcess = async () => {
const serverProcess: ServerProcessResponse = await dataProvider.getServerRunningProcess(etkeccAdmin); const serverProcess: ServerProcessResponse = await dataProvider.getServerRunningProcess(etkeccAdmin, command !== "");
setServerProcess({ setServerProcess({
...serverProcess, ...serverProcess,
command: serverProcess.command, command: serverProcess.command,
@ -105,10 +112,12 @@ const useCurrentServerProcess = () => {
} }
useEffect(() => { useEffect(() => {
let serverCheckInterval: NodeJS.Timeout; let serverCheckInterval: NodeJS.Timeout | null = null;
let timeoutId: NodeJS.Timeout | null = null;
if (etkeccAdmin) { if (etkeccAdmin) {
checkServerRunningProcess(); checkServerRunningProcess();
setTimeout(() => { timeoutId = setTimeout(() => {
serverCheckInterval = setInterval(checkServerRunningProcess, SERVER_CURRENT_PROCCESS_INTERVAL_TIME); serverCheckInterval = setInterval(checkServerRunningProcess, SERVER_CURRENT_PROCCESS_INTERVAL_TIME);
}, 5000); }, 5000);
} else { } else {
@ -116,19 +125,48 @@ const useCurrentServerProcess = () => {
} }
return () => { return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (serverCheckInterval) { if (serverCheckInterval) {
clearInterval(serverCheckInterval); clearInterval(serverCheckInterval);
} }
} }
}, [etkeccAdmin]); }, [etkeccAdmin, command]);
return { command, locked_at }; return { command, locked_at };
}; };
export const ServerStatusStyledBadge = ({ command, locked_at, isOkay, inSidebar = false }: { command: string, locked_at: string, isOkay: boolean, inSidebar: boolean }) => {
const theme = useTheme();
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;
}
let avatarBackgroundColor = theme.palette.mode === "dark" ? theme.palette.background.default : "#2196f3";
if (inSidebar) {
avatarBackgroundColor = theme.palette.grey[600];
}
return <StyledBadge
overlap="circular"
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
variant="dot"
backgroundColor={badgeBackgroundColor}
badgeColor={badgeColor}
>
<Avatar sx={{ height: 24, width: 24, background: avatarBackgroundColor }}>
<MonitorHeartIcon sx={{ height: 22, width: 22, color: theme.palette.common.white }} />
</Avatar>
</StyledBadge>
};
const ServerStatusBadge = () => { const ServerStatusBadge = () => {
const { isOkay, successCheck } = useServerStatus(); const { isOkay, successCheck } = useServerStatus();
const { command, locked_at } = useCurrentServerProcess(); const { command, locked_at } = useCurrentServerProcess();
const theme = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
if (!successCheck) { if (!successCheck) {
@ -140,28 +178,16 @@ const ServerStatusBadge = () => {
}; };
let tooltipText = "Click to view 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) { if (command && locked_at) {
badgeBackgroundColor = theme.palette.warning.main;
badgeColor = theme.palette.warning.main;
tooltipText = `Running: ${command}; ${tooltipText}`; tooltipText = `Running: ${command}; ${tooltipText}`;
} }
return <Button onClick={handleServerStatusClick} size="medium" sx={{ minWidth: "auto", ".MuiButton-startIcon": { m: 0 }}}> return <Button onClick={handleServerStatusClick} size="medium" sx={{ minWidth: "auto", ".MuiButton-startIcon": { m: 0 }}}>
<Tooltip title={tooltipText} sx={{ cursor: "pointer" }}> <Tooltip title={tooltipText} sx={{ cursor: "pointer" }}>
<StyledBadge <Box>
overlap="circular" <ServerStatusStyledBadge inSidebar={false} command={command || ""} locked_at={locked_at || ""} isOkay={isOkay} />
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} </Box>
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> </Tooltip>
</Button> </Button>
}; };

View File

@ -4,16 +4,8 @@ import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import EngineeringIcon from '@mui/icons-material/Engineering'; import EngineeringIcon from '@mui/icons-material/Engineering';
import { ServerProcessResponse, ServerStatusComponent, ServerStatusResponse } from "../../synapse/dataProvider"; import { ServerProcessResponse, ServerStatusComponent, ServerStatusResponse } from "../../synapse/dataProvider";
import ServerCommandsPanel from "./ServerCommandsPanel";
const getTimeSince = (date: string) => { import { getTimeSince } from "../../utils/date";
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 }) => { const StatusChip = ({ isOkay, size = "medium", command }: { isOkay: boolean, size?: "small" | "medium", command?: string }) => {
let label = "OK"; let label = "OK";
@ -102,6 +94,8 @@ const ServerStatusPage = () => {
</Stack> </Stack>
)} )}
<ServerCommandsPanel />
<Stack spacing={2} direction="row"> <Stack spacing={2} direction="row">
{Object.keys(groupedResults).map((category, idx) => ( {Object.keys(groupedResults).map((category, idx) => (
<Box key={`category_${category}`} sx={{ flex: 1 }}> <Box key={`category_${category}`} sx={{ flex: 1 }}>

View File

@ -2,7 +2,7 @@ import { AuthProvider, HttpError, Options, fetchUtils } from "react-admin";
import { MatrixError, displayError } from "../utils/error"; import { MatrixError, displayError } from "../utils/error";
import { fetchAuthenticatedMedia } from "../utils/fetchMedia"; import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
import { FetchConfig, ClearConfig } from "../utils/config"; import { FetchConfig, ClearConfig, GetConfig } from "../utils/config";
import decodeURLComponent from "../utils/decodeURLComponent"; import decodeURLComponent from "../utils/decodeURLComponent";
const authProvider: AuthProvider = { const authProvider: AuthProvider = {
@ -81,9 +81,16 @@ 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");
await FetchConfig();
return Promise.resolve({redirectTo: "/"}); await FetchConfig();
const config = GetConfig();
let pageToRedirectTo = "/";
if (config && config.etkeccAdmin) {
pageToRedirectTo = "/server_status";
}
return Promise.resolve({redirectTo: pageToRedirectTo});
} catch(err) { } catch(err) {
const error = err as HttpError; const error = err as HttpError;
const errorStatus = error.status; const errorStatus = error.status;

View File

@ -300,8 +300,8 @@ export interface ServerStatusResponse {
} }
export interface ServerProcessResponse { export interface ServerProcessResponse {
locked_at?: string; locked_at: string;
command?: string; command: string;
} }
export interface ServerNotification { export interface ServerNotification {
@ -315,6 +315,19 @@ export interface ServerNotificationsResponse {
notifications: ServerNotification[]; notifications: ServerNotification[];
} }
export interface ServerCommand {
icon: string;
name: string;
description: string;
args: boolean;
with_lock: boolean;
additionalArgs?: string;
}
export interface ServerCommandsResponse {
[command: string]: ServerCommand;
}
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>;
@ -329,6 +342,7 @@ export interface SynapseDataProvider extends DataProvider {
getServerStatus: (etkeAdminUrl: string) => Promise<ServerStatusResponse>; getServerStatus: (etkeAdminUrl: string) => Promise<ServerStatusResponse>;
getServerNotifications: (etkeAdminUrl: string) => Promise<ServerNotificationsResponse>; getServerNotifications: (etkeAdminUrl: string) => Promise<ServerNotificationsResponse>;
deleteServerNotifications: (etkeAdminUrl: string) => Promise<{ success: boolean }>; deleteServerNotifications: (etkeAdminUrl: string) => Promise<{ success: boolean }>;
getServerCommands: (etkeAdminUrl: string) => Promise<ServerCommandsResponse>;
} }
const resourceMap = { const resourceMap = {
@ -994,12 +1008,17 @@ const baseDataProvider: SynapseDataProvider = {
throw error; throw error;
} }
}, },
getServerRunningProcess: async (runningProcessUrl: string): Promise<ServerProcessResponse> => { getServerRunningProcess: async (etkeAdminUrl: string, burstCache: boolean = false): Promise<ServerProcessResponse> => {
const locked_at = ""; const locked_at = "";
const command = ""; const command = "";
let serverURL = `${etkeAdminUrl}/lock`;
if (burstCache) {
serverURL += `?time=${new Date().getTime()}`;
}
try { try {
const response = await fetch(`${runningProcessUrl}/lock`, { const response = await fetch(serverURL, {
headers: { headers: {
"Authorization": `Bearer ${localStorage.getItem("access_token")}` "Authorization": `Bearer ${localStorage.getItem("access_token")}`
} }
@ -1024,9 +1043,14 @@ const baseDataProvider: SynapseDataProvider = {
return { locked_at, command }; return { locked_at, command };
}, },
getServerStatus: async (serverStatusUrl: string): Promise<ServerStatusResponse> => { getServerStatus: async (etkeAdminUrl: string, burstCache: boolean = false): Promise<ServerStatusResponse> => {
let serverURL = `${etkeAdminUrl}/status`;
if (burstCache) {
serverURL += `?time=${new Date().getTime()}`;
}
try { try {
const response = await fetch(`${serverStatusUrl}/status`, { const response = await fetch(serverURL, {
headers: { headers: {
"Authorization": `Bearer ${localStorage.getItem("access_token")}` "Authorization": `Bearer ${localStorage.getItem("access_token")}`
} }
@ -1048,9 +1072,14 @@ const baseDataProvider: SynapseDataProvider = {
return { success: false, ok: false, host: "", results: [] }; return { success: false, ok: false, host: "", results: [] };
}, },
getServerNotifications: async (serverNotificationsUrl: string): Promise<ServerNotificationsResponse> => { getServerNotifications: async (serverNotificationsUrl: string, burstCache: boolean = false): Promise<ServerNotificationsResponse> => {
let serverURL = `${serverNotificationsUrl}/notifications`;
if (burstCache) {
serverURL += `?time=${new Date().getTime()}`;
}
try { try {
const response = await fetch(`${serverNotificationsUrl}/notifications`, { const response = await fetch(serverURL, {
headers: { headers: {
"Authorization": `Bearer ${localStorage.getItem("access_token")}` "Authorization": `Bearer ${localStorage.getItem("access_token")}`
} }
@ -1101,6 +1130,66 @@ const baseDataProvider: SynapseDataProvider = {
} }
return { success: false }; return { success: false };
},
getServerCommands: async (serverCommandsUrl: string) => {
try {
const response = await fetch(`${serverCommandsUrl}/commands`, {
headers: {
"Authorization": `Bearer ${localStorage.getItem("access_token")}`
}
});
if (!response.ok) {
console.error(`Error fetching server commands: ${response.status} ${response.statusText}`);
return {};
}
const status = response.status;
if (status === 200) {
const json = await response.json();
return json as ServerCommandsResponse;
}
return {};
} catch (error) {
console.error("Error fetching server commands, error");
}
return {};
},
runServerCommand: async (serverCommandsUrl: string, command: string, additionalArgs: Record<string, any> = {}) => {
const endpoint_url = `${serverCommandsUrl}/commands`;
const body = {
command: command,
...additionalArgs
}
const response = await fetch(endpoint_url, {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${localStorage.getItem("access_token")}`
}
});
if (!response.ok) {
console.error(`Error running server command: ${response.status} ${response.statusText}`);
return {
success: false,
};
}
const status = response.status;
if (status === 204) {
return {
success: true,
}
}
return {
success: false,
}
} }
}; };

View File

@ -26,3 +26,29 @@ export const dateFormatter = (v: string | number | Date | undefined | null): str
// target format yyyy-MM-ddThh:mm // target format yyyy-MM-ddThh:mm
return `${year}-${month}-${day}T${hour}:${minute}`; return `${year}-${month}-${day}T${hour}:${minute}`;
}; };
// assuming date is in format "2025-02-26 20:52:00" where no timezone is specified
export const getTimeSince = (dateToCompare: string) => {
const nowUTC = new Date().getTime();
if (!dateToCompare.includes("Z")) {
dateToCompare = dateToCompare + "Z";
}
const past = new Date(dateToCompare);
const pastUTC = past.getTime();
const diffInMs = nowUTC - pastUTC;
const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
if (diffInMinutes < 1) return "a couple of seconds";
if (diffInMinutes === 1) return "1 minute";
if (diffInMinutes < 60) return `${diffInMinutes} minutes`;
if (diffInMinutes < 120) return "1 hour";
if (diffInMinutes < 24 * 60) return `${Math.floor(diffInMinutes / 60)} hours`;
if (diffInMinutes < 48 * 60) return "1 day";
if (diffInMinutes < 7 * 24 * 60) return `${Math.floor(diffInMinutes / (24 * 60))} days`;
if (diffInMinutes < 14 * 24 * 60) return "1 week";
if (diffInMinutes < 30 * 24 * 60) return `${Math.floor(diffInMinutes / (7 * 24 * 60))} weeks`;
if (diffInMinutes < 60 * 24 * 60) return "1 month";
return `${Math.floor(diffInMinutes / (30 * 24 * 60))} months`;
};

View File

@ -3,6 +3,11 @@ import EngineeringIcon from '@mui/icons-material/Engineering';
import HelpCenterIcon from '@mui/icons-material/HelpCenter'; import HelpCenterIcon from '@mui/icons-material/HelpCenter';
import SupportAgentIcon from '@mui/icons-material/SupportAgent'; import SupportAgentIcon from '@mui/icons-material/SupportAgent';
import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import PieChartIcon from '@mui/icons-material/PieChart';
import UpgradeIcon from '@mui/icons-material/Upgrade';
import RouterIcon from '@mui/icons-material/Router';
import PriceCheckIcon from '@mui/icons-material/PriceCheck';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
export const Icons = { export const Icons = {
Announcement: AnnouncementIcon, Announcement: AnnouncementIcon,
@ -10,6 +15,11 @@ export const Icons = {
HelpCenter: HelpCenterIcon, HelpCenter: HelpCenterIcon,
SupportAgent: SupportAgentIcon, SupportAgent: SupportAgentIcon,
Default: OpenInNewIcon, Default: OpenInNewIcon,
PieChart: PieChartIcon,
Upgrade: UpgradeIcon,
Router: RouterIcon,
PriceCheck: PriceCheckIcon,
RestartAlt: RestartAltIcon,
// Add more icons as needed // Add more icons as needed
}; };