From 79c46c2f46cf98c754d7915232a0ae71ccc57089 Mon Sep 17 00:00:00 2001 From: Borislav Pantaleev Date: Tue, 25 Feb 2025 23:03:49 +0200 Subject: [PATCH 01/12] Add ServerCommandsPanel to ServerStatusPage --- src/components/AdminLayout.tsx | 14 +- .../etke.cc/ServerCommandsPanel.tsx | 141 ++++++++++++++++++ .../etke.cc/ServerNotificationsBadge.tsx | 32 +++- src/components/etke.cc/ServerStatusBadge.tsx | 58 ++++--- src/components/etke.cc/ServerStatusPage.tsx | 3 + src/synapse/dataProvider.ts | 90 ++++++++++- src/utils/icons.ts | 10 ++ 7 files changed, 314 insertions(+), 34 deletions(-) create mode 100644 src/components/etke.cc/ServerCommandsPanel.tsx diff --git a/src/components/AdminLayout.tsx b/src/components/AdminLayout.tsx index b87ddb6..e70135e 100644 --- a/src/components/AdminLayout.tsx +++ b/src/components/AdminLayout.tsx @@ -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 { useEffect, useState, Suspense } from "react"; import { Icons, DefaultIcon } from "../utils/icons"; @@ -6,6 +6,8 @@ import { MenuItem, GetConfig, ClearConfig } from "../utils/config"; import Footer from "./Footer"; import ServerStatusBadge from "./etke.cc/ServerStatusBadge"; import { ServerNotificationsBadge } from "./etke.cc/ServerNotificationsBadge"; +import { ServerProcessResponse, ServerStatusResponse } from "../synapse/dataProvider"; +import { ServerStatusStyledBadge } from "./etke.cc/ServerStatusBadge"; const AdminUserMenu = () => { const [open, setOpen] = useState(false); @@ -59,10 +61,20 @@ const AdminAppBar = () => { const AdminMenu = (props) => { const [menu, setMenu] = useState([] as MenuItem[]); useEffect(() => setMenu(GetConfig().menu), []); + const [serverProcess, setServerProcess] = useStore("serverProcess", { command: "", locked_at: "" }); + const [serverStatus, setServerStatus] = useStore("serverStatus", { success: false, ok: false, host: "", results: [] }); return ( + {menu && + } + primaryText="Server Status" /> + } {menu && menu.map((item, index) => { const { url, icon, label } = item; const IconComponent = Icons[icon] as React.ComponentType | undefined; diff --git a/src/components/etke.cc/ServerCommandsPanel.tsx b/src/components/etke.cc/ServerCommandsPanel.tsx new file mode 100644 index 0000000..3ec85d5 --- /dev/null +++ b/src/components/etke.cc/ServerCommandsPanel.tsx @@ -0,0 +1,141 @@ +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 | undefined; + return IconComponent ? : 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("serverProcess", { command: "", locked_at: "" }); + const [commandIsRunning, setCommandIsRunning] = useState(false); + const [commandResult, setCommandResult] = useState([]); + + 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] = serverCommandsResponse[command]; + serverCommands[command].additionalArgs = ""; + }); + setServerCommands(serverCommands); + } + setLoading(false); + } + fetchIsAdmin(); + }, []); + + const setCommandAdditionalArgs = (command: string, additionalArgs: string) => { + const updatedServerCommands = {...serverCommands}; + updatedServerCommands[command].additionalArgs = additionalArgs; + setServerCommands(updatedServerCommands); + } + + const runCommand = async (command: string) => { + setCommandResult([]); + setCommandIsRunning(true); + + const response = await dataProvider.runServerCommand(etkeccAdmin, command); + + setCommandIsRunning(false); + if (!response.success) { + return; + } + + const updatedServerCommands = {...serverCommands}; + const commandResult: React.ReactNode[] = []; + + let commandScheduledText = `Command scheduled: ${command}`; + if (serverCommands[command].additionalArgs) { + commandScheduledText += `, with additional args: ${serverCommands[command].additionalArgs}`; + } + commandResult.push({commandScheduledText}); + commandResult.push(Expect your result in the Notifications page soon.); + + updatedServerCommands[command].additionalArgs = ""; + setServerCommands(updatedServerCommands); + + setCommandResult(commandResult); + + const serverProcess: ServerProcessResponse = await dataProvider.getServerRunningProcess(etkeccAdmin, true); + setServerProcess({...serverProcess}); + }; + + if (isLoading) { + return + } + + return (<> +

Server Commands

+ + + + + Command + Description + + + + + {Object.entries(serverCommands).map(([command, { icon, args, description, additionalArgs }]) => ( + + + + {renderIcon(icon)} + {command} + + + {description} + + {args && { + setCommandAdditionalArgs(command, e.target.value); + }} + value={additionalArgs} + />} + + + + ))} + +
+
+ + {commandResult.length > 0 && } severity="success"> + {commandResult.map((result, index) => ( +
{result}
+ ))} +
} + + ) +}; + +export default ServerCommandsPanel; \ No newline at end of file diff --git a/src/components/etke.cc/ServerNotificationsBadge.tsx b/src/components/etke.cc/ServerNotificationsBadge.tsx index 9628d12..d492e3a 100644 --- a/src/components/etke.cc/ServerNotificationsBadge.tsx +++ b/src/components/etke.cc/ServerNotificationsBadge.tsx @@ -5,12 +5,16 @@ 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"; +import { ServerNotificationsResponse, ServerProcessResponse } from "../../synapse/dataProvider"; +// 5 minutes const SERVER_NOTIFICATIONS_INTERVAL_TIME = 300000; const useServerNotifications = () => { const [serverNotifications, setServerNotifications] = useStore("serverNotifications", { notifications: [], success: false }); + const [ serverProcess, setServerProcess ] = useStore("serverProcess", { command: "", locked_at: "" }); + const { command, locked_at } = serverProcess; + const { etkeccAdmin } = useAppContext(); const dataProvider = useDataProvider(); const { notifications, success } = serverNotifications; @@ -19,7 +23,7 @@ const useServerNotifications = () => { const notificationsResponse: ServerNotificationsResponse = await dataProvider.getServerNotifications(etkeccAdmin); setServerNotifications({ ...notificationsResponse, - notifications: notificationsResponse.notifications, + notifications: notificationsResponse.notifications.reverse(), success: notificationsResponse.success }); }; @@ -35,21 +39,26 @@ const useServerNotifications = () => { }; useEffect(() => { - let serverNotificationsInterval: NodeJS.Timeout; + let serverNotificationsInterval: NodeJS.Timeout | null = null; + let timeoutId: NodeJS.Timeout | null = null; + if (etkeccAdmin) { fetchNotifications(); - setTimeout(() => { + timeoutId = 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 (timeoutId) { + clearTimeout(timeoutId); + } if (serverNotificationsInterval) { clearInterval(serverNotificationsInterval); } } - }, [etkeccAdmin]); + }, [etkeccAdmin, command, locked_at]); return { success, notifications, deleteServerNotifications }; }; @@ -108,6 +117,7 @@ export const ServerNotificationsBadge = () => { sx={{ p: 1, maxHeight: "350px", + paddingTop: 0, overflowY: "auto", minWidth: "300px", maxWidth: { @@ -126,7 +136,6 @@ export const ServerNotificationsBadge = () => { justifyContent: "space-between", alignItems: "center", fontWeight: "bold", - backgroundColor: "inherit", }} > Notifications @@ -134,10 +143,14 @@ export const ServerNotificationsBadge = () => { {notifications.map((notification, index) => { - return ( + return ( handleSeeAllNotifications()} sx={{ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + overflow: "hidden", "&:hover": { backgroundColor: "action.hover", cursor: "pointer" @@ -157,6 +170,11 @@ export const ServerNotificationsBadge = () => { /> } /> + {notification.sent_at} + } + /> diff --git a/src/components/etke.cc/ServerStatusBadge.tsx b/src/components/etke.cc/ServerStatusBadge.tsx index 4d0a461..de6f038 100644 --- a/src/components/etke.cc/ServerStatusBadge.tsx +++ b/src/components/etke.cc/ServerStatusBadge.tsx @@ -68,10 +68,12 @@ const useServerStatus = () => { }; useEffect(() => { - let serverStatusInterval: NodeJS.Timeout; + let serverStatusInterval: NodeJS.Timeout | null = null; + let timeoutId: NodeJS.Timeout | null = null; + if (etkeccAdmin) { checkServerStatus(); - setTimeout(() => { + timeoutId = setTimeout(() => { // start the interval after 10 seconds to avoid too many requests serverStatusInterval = setInterval(checkServerStatus, SERVER_STATUS_INTERVAL_TIME); }, 10000); @@ -80,6 +82,9 @@ const useServerStatus = () => { } return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } if (serverStatusInterval) { clearInterval(serverStatusInterval); } @@ -105,10 +110,12 @@ const useCurrentServerProcess = () => { } useEffect(() => { - let serverCheckInterval: NodeJS.Timeout; + let serverCheckInterval: NodeJS.Timeout | null = null; + let timeoutId: NodeJS.Timeout | null = null; + if (etkeccAdmin) { checkServerRunningProcess(); - setTimeout(() => { + timeoutId = setTimeout(() => { serverCheckInterval = setInterval(checkServerRunningProcess, SERVER_CURRENT_PROCCESS_INTERVAL_TIME); }, 5000); } else { @@ -116,6 +123,9 @@ const useCurrentServerProcess = () => { } return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } if (serverCheckInterval) { clearInterval(serverCheckInterval); } @@ -125,10 +135,32 @@ const useCurrentServerProcess = () => { return { command, locked_at }; }; +export const ServerStatusStyledBadge = ({ command, locked_at, isOkay }: { command: string, locked_at: string, isOkay: 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; + } + + return + + + + +}; + const ServerStatusBadge = () => { const { isOkay, successCheck } = useServerStatus(); const { command, locked_at } = useCurrentServerProcess(); - const theme = useTheme(); const navigate = useNavigate(); if (!successCheck) { @@ -140,28 +172,14 @@ const ServerStatusBadge = () => { }; 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 }; diff --git a/src/components/etke.cc/ServerStatusPage.tsx b/src/components/etke.cc/ServerStatusPage.tsx index 9b75fca..b06dbfb 100644 --- a/src/components/etke.cc/ServerStatusPage.tsx +++ b/src/components/etke.cc/ServerStatusPage.tsx @@ -4,6 +4,7 @@ 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"; +import ServerCommandsPanel from "./ServerCommandsPanel"; const getTimeSince = (date: string) => { const now = new Date(); @@ -102,6 +103,8 @@ const ServerStatusPage = () => { )} + + {Object.keys(groupedResults).map((category, idx) => ( diff --git a/src/synapse/dataProvider.ts b/src/synapse/dataProvider.ts index a099d76..83d46ba 100644 --- a/src/synapse/dataProvider.ts +++ b/src/synapse/dataProvider.ts @@ -300,8 +300,8 @@ export interface ServerStatusResponse { } export interface ServerProcessResponse { - locked_at?: string; - command?: string; + locked_at: string; + command: string; } export interface ServerNotification { @@ -315,6 +315,18 @@ export interface ServerNotificationsResponse { notifications: ServerNotification[]; } +export interface ServerCommand { + icon: string; + name: string; + description: string; + args: boolean; + additionalArgs?: string; +} + +export interface ServerCommandsResponse { + [command: string]: ServerCommand; +} + export interface SynapseDataProvider extends DataProvider { deleteMedia: (params: DeleteMediaParams) => Promise; purgeRemoteMedia: (params: DeleteMediaParams) => Promise; @@ -329,6 +341,7 @@ export interface SynapseDataProvider extends DataProvider { getServerStatus: (etkeAdminUrl: string) => Promise; getServerNotifications: (etkeAdminUrl: string) => Promise; deleteServerNotifications: (etkeAdminUrl: string) => Promise<{ success: boolean }>; + getServerCommands: (etkeAdminUrl: string) => Promise; } const resourceMap = { @@ -994,12 +1007,17 @@ const baseDataProvider: SynapseDataProvider = { throw error; } }, - getServerRunningProcess: async (runningProcessUrl: string): Promise => { + getServerRunningProcess: async (etkeAdminUrl: string, burstCache: boolean = false): Promise => { const locked_at = ""; const command = ""; + let serverURL = `${etkeAdminUrl}/lock`; + if (burstCache) { + serverURL += `?time=${new Date().getTime()}`; + } + try { - const response = await fetch(`${runningProcessUrl}/lock`, { + const response = await fetch(serverURL, { headers: { "Authorization": `Bearer ${localStorage.getItem("access_token")}` } @@ -1024,9 +1042,9 @@ const baseDataProvider: SynapseDataProvider = { return { locked_at, command }; }, - getServerStatus: async (serverStatusUrl: string): Promise => { + getServerStatus: async (etkeAdminUrl: string): Promise => { try { - const response = await fetch(`${serverStatusUrl}/status`, { + const response = await fetch(`${etkeAdminUrl}/status`, { headers: { "Authorization": `Bearer ${localStorage.getItem("access_token")}` } @@ -1101,6 +1119,66 @@ const baseDataProvider: SynapseDataProvider = { } 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 = {}) => { + 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, + } } }; diff --git a/src/utils/icons.ts b/src/utils/icons.ts index 133dfe5..a0653ed 100644 --- a/src/utils/icons.ts +++ b/src/utils/icons.ts @@ -3,6 +3,11 @@ import EngineeringIcon from '@mui/icons-material/Engineering'; import HelpCenterIcon from '@mui/icons-material/HelpCenter'; import SupportAgentIcon from '@mui/icons-material/SupportAgent'; 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 = { Announcement: AnnouncementIcon, @@ -10,6 +15,11 @@ export const Icons = { HelpCenter: HelpCenterIcon, SupportAgent: SupportAgentIcon, Default: OpenInNewIcon, + PieChart: PieChartIcon, + Upgrade: UpgradeIcon, + Router: RouterIcon, + PriceCheck: PriceCheckIcon, + RestartAlt: RestartAltIcon, // Add more icons as needed }; From c080f72062856161a22b1007f301992da30c83aa Mon Sep 17 00:00:00 2001 From: Borislav Pantaleev Date: Tue, 25 Feb 2025 23:08:11 +0200 Subject: [PATCH 02/12] Fix tooltip for ServerStatusBadge --- src/components/etke.cc/ServerStatusBadge.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/etke.cc/ServerStatusBadge.tsx b/src/components/etke.cc/ServerStatusBadge.tsx index de6f038..d9b825b 100644 --- a/src/components/etke.cc/ServerStatusBadge.tsx +++ b/src/components/etke.cc/ServerStatusBadge.tsx @@ -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 { useAppContext } from "../../Context"; import { Button, useDataProvider, useStore } from "react-admin"; @@ -179,7 +179,9 @@ const ServerStatusBadge = () => { return }; From 233c50571bc6b39b7543b84c54f62ea176531b63 Mon Sep 17 00:00:00 2001 From: Borislav Pantaleev Date: Wed, 26 Feb 2025 22:51:18 +0200 Subject: [PATCH 03/12] Move Server Status on top of Sidebar and fix icon background --- src/components/AdminLayout.tsx | 5 +++-- src/components/etke.cc/ServerStatusBadge.tsx | 10 +++++++--- src/synapse/authProvider.ts | 13 ++++++++++--- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/components/AdminLayout.tsx b/src/components/AdminLayout.tsx index e70135e..f067606 100644 --- a/src/components/AdminLayout.tsx +++ b/src/components/AdminLayout.tsx @@ -66,15 +66,16 @@ const AdminMenu = (props) => { return ( - - {menu && 0 && } primaryText="Server Status" /> } + {menu && menu.map((item, index) => { const { url, icon, label } = item; const IconComponent = Icons[icon] as React.ComponentType | undefined; diff --git a/src/components/etke.cc/ServerStatusBadge.tsx b/src/components/etke.cc/ServerStatusBadge.tsx index d9b825b..4dd31c7 100644 --- a/src/components/etke.cc/ServerStatusBadge.tsx +++ b/src/components/etke.cc/ServerStatusBadge.tsx @@ -135,7 +135,7 @@ const useCurrentServerProcess = () => { return { command, locked_at }; }; -export const ServerStatusStyledBadge = ({ command, locked_at, isOkay }: { command: string, locked_at: string, isOkay: boolean }) => { +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; @@ -144,6 +144,10 @@ export const ServerStatusStyledBadge = ({ command, locked_at, isOkay }: { comman 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 - + @@ -180,7 +184,7 @@ const ServerStatusBadge = () => { return diff --git a/src/synapse/authProvider.ts b/src/synapse/authProvider.ts index fc0ffb9..f26a2f3 100644 --- a/src/synapse/authProvider.ts +++ b/src/synapse/authProvider.ts @@ -2,7 +2,7 @@ import { AuthProvider, HttpError, Options, fetchUtils } from "react-admin"; import { MatrixError, displayError } from "../utils/error"; import { fetchAuthenticatedMedia } from "../utils/fetchMedia"; -import { FetchConfig, ClearConfig } from "../utils/config"; +import { FetchConfig, ClearConfig, GetConfig } from "../utils/config"; import decodeURLComponent from "../utils/decodeURLComponent"; const authProvider: AuthProvider = { @@ -81,9 +81,16 @@ 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"); - 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) { const error = err as HttpError; const errorStatus = error.status; From 341c9950f7635016b8bd03e13111a76ff775b551 Mon Sep 17 00:00:00 2001 From: Borislav Pantaleev Date: Wed, 26 Feb 2025 23:25:05 +0200 Subject: [PATCH 04/12] Fix notifications ASC order and proper display of time since --- .../etke.cc/ServerNotificationsBadge.tsx | 5 ++-- .../etke.cc/ServerNotificationsPage.tsx | 7 +++-- src/components/etke.cc/ServerStatusPage.tsx | 11 +------- src/utils/date.ts | 26 +++++++++++++++++++ 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/components/etke.cc/ServerNotificationsBadge.tsx b/src/components/etke.cc/ServerNotificationsBadge.tsx index d492e3a..6e3d26d 100644 --- a/src/components/etke.cc/ServerNotificationsBadge.tsx +++ b/src/components/etke.cc/ServerNotificationsBadge.tsx @@ -6,6 +6,7 @@ import { useNavigate } from "react-router"; import { Fragment, useEffect, useState } from "react"; import { useAppContext } from "../../Context"; import { ServerNotificationsResponse, ServerProcessResponse } from "../../synapse/dataProvider"; +import { getTimeSince } from "../../utils/date"; // 5 minutes const SERVER_NOTIFICATIONS_INTERVAL_TIME = 300000; @@ -23,7 +24,7 @@ const useServerNotifications = () => { const notificationsResponse: ServerNotificationsResponse = await dataProvider.getServerNotifications(etkeccAdmin); setServerNotifications({ ...notificationsResponse, - notifications: notificationsResponse.notifications.reverse(), + notifications: notificationsResponse.notifications, success: notificationsResponse.success }); }; @@ -172,7 +173,7 @@ export const ServerNotificationsBadge = () => { /> {notification.sent_at} + {getTimeSince(notification.sent_at) + " ago"} } /> diff --git a/src/components/etke.cc/ServerNotificationsPage.tsx b/src/components/etke.cc/ServerNotificationsPage.tsx index 2476af8..0713329 100644 --- a/src/components/etke.cc/ServerNotificationsPage.tsx +++ b/src/components/etke.cc/ServerNotificationsPage.tsx @@ -4,9 +4,12 @@ import { useStore } from "react-admin" import dataProvider, { ServerNotificationsResponse } from "../../synapse/dataProvider" import { useAppContext } from "../../Context"; import DeleteIcon from "@mui/icons-material/Delete"; +import { getTimeSince } from "../../utils/date"; +import { Tooltip } from "@mui/material"; + const DisplayTime = ({ date }: { date: string }) => { - const dateFromDateString = new Date(date); - return <>{dateFromDateString.toLocaleString()}; + const dateFromDateString = new Date(date.replace(" ", "T") + "Z"); + return {{getTimeSince(date) + " ago"}}; }; const ServerNotificationsPage = () => { diff --git a/src/components/etke.cc/ServerStatusPage.tsx b/src/components/etke.cc/ServerStatusPage.tsx index b06dbfb..1bcfd88 100644 --- a/src/components/etke.cc/ServerStatusPage.tsx +++ b/src/components/etke.cc/ServerStatusPage.tsx @@ -5,16 +5,7 @@ import CloseIcon from "@mui/icons-material/Close"; import EngineeringIcon from '@mui/icons-material/Engineering'; import { ServerProcessResponse, ServerStatusComponent, ServerStatusResponse } from "../../synapse/dataProvider"; import ServerCommandsPanel from "./ServerCommandsPanel"; - -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`; -}; +import { getTimeSince } from "../../utils/date"; const StatusChip = ({ isOkay, size = "medium", command }: { isOkay: boolean, size?: "small" | "medium", command?: string }) => { let label = "OK"; diff --git a/src/utils/date.ts b/src/utils/date.ts index 0a64b3c..e000e12 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -26,3 +26,29 @@ export const dateFormatter = (v: string | number | Date | undefined | null): str // target format yyyy-MM-ddThh:mm 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 = (date: string) => { + const nowUTC = new Date().getTime(); + const past = new Date(date.replace(" ", "T") + "Z"); + + const pastUTC = past.getTime(); + const diffInMs = nowUTC - pastUTC; + + const diffInMinutes = Math.floor(diffInMs / (1000 * 60)); + + // Remove or comment out the console.log for production + console.log("FOR now, date", new Date(nowUTC).toISOString(), new Date(pastUTC).toISOString(), "diffInMinutes", diffInMinutes); + + 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`; +}; \ No newline at end of file From 201da849671c8434a60ebf229018767b40538442 Mon Sep 17 00:00:00 2001 From: Borislav Pantaleev Date: Thu, 27 Feb 2025 00:03:01 +0200 Subject: [PATCH 05/12] Fix handling of date string that are ISO formatted --- src/utils/date.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/utils/date.ts b/src/utils/date.ts index e000e12..cddad4b 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -28,18 +28,18 @@ export const dateFormatter = (v: string | number | Date | undefined | null): str }; // assuming date is in format "2025-02-26 20:52:00" where no timezone is specified -export const getTimeSince = (date: string) => { +export const getTimeSince = (dateToCompare: string) => { const nowUTC = new Date().getTime(); - const past = new Date(date.replace(" ", "T") + "Z"); + 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)); - // Remove or comment out the console.log for production - console.log("FOR now, date", new Date(nowUTC).toISOString(), new Date(pastUTC).toISOString(), "diffInMinutes", diffInMinutes); - if (diffInMinutes < 1) return "a couple of seconds"; if (diffInMinutes === 1) return "1 minute"; if (diffInMinutes < 60) return `${diffInMinutes} minutes`; From ee7aa12fd033dcb83f59346a3c3fca85fbcb7817 Mon Sep 17 00:00:00 2001 From: Borislav Pantaleev Date: Thu, 27 Feb 2025 00:06:01 +0200 Subject: [PATCH 06/12] burst cache for status, lock, notifications if command is running --- src/components/etke.cc/ServerCommandsPanel.tsx | 9 +++++++-- .../etke.cc/ServerNotificationsBadge.tsx | 2 +- src/components/etke.cc/ServerStatusBadge.tsx | 10 ++++++---- src/synapse/dataProvider.ts | 18 ++++++++++++++---- 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/components/etke.cc/ServerCommandsPanel.tsx b/src/components/etke.cc/ServerCommandsPanel.tsx index 3ec85d5..9713499 100644 --- a/src/components/etke.cc/ServerCommandsPanel.tsx +++ b/src/components/etke.cc/ServerCommandsPanel.tsx @@ -31,7 +31,6 @@ const ServerCommandsPanel = () => { if (serverCommandsResponse) { const serverCommands = serverCommandsResponse; Object.keys(serverCommandsResponse).forEach((command: string) => { - // serverCommands[command] = serverCommandsResponse[command]; serverCommands[command].additionalArgs = ""; }); setServerCommands(serverCommands); @@ -41,6 +40,12 @@ const ServerCommandsPanel = () => { fetchIsAdmin(); }, []); + useEffect(() => { + if (serverProcess.command === "") { + setCommandIsRunning(false); + } + }, [serverProcess]); + const setCommandAdditionalArgs = (command: string, additionalArgs: string) => { const updatedServerCommands = {...serverCommands}; updatedServerCommands[command].additionalArgs = additionalArgs; @@ -53,7 +58,6 @@ const ServerCommandsPanel = () => { const response = await dataProvider.runServerCommand(etkeccAdmin, command); - setCommandIsRunning(false); if (!response.success) { return; } @@ -108,6 +112,7 @@ const ServerCommandsPanel = () => { {args && { setCommandAdditionalArgs(command, e.target.value); }} diff --git a/src/components/etke.cc/ServerNotificationsBadge.tsx b/src/components/etke.cc/ServerNotificationsBadge.tsx index 6e3d26d..a1e2d1d 100644 --- a/src/components/etke.cc/ServerNotificationsBadge.tsx +++ b/src/components/etke.cc/ServerNotificationsBadge.tsx @@ -21,7 +21,7 @@ const useServerNotifications = () => { const { notifications, success } = serverNotifications; const fetchNotifications = async () => { - const notificationsResponse: ServerNotificationsResponse = await dataProvider.getServerNotifications(etkeccAdmin); + const notificationsResponse: ServerNotificationsResponse = await dataProvider.getServerNotifications(etkeccAdmin, command !== ""); setServerNotifications({ ...notificationsResponse, notifications: notificationsResponse.notifications, diff --git a/src/components/etke.cc/ServerStatusBadge.tsx b/src/components/etke.cc/ServerStatusBadge.tsx index 4dd31c7..e82588d 100644 --- a/src/components/etke.cc/ServerStatusBadge.tsx +++ b/src/components/etke.cc/ServerStatusBadge.tsx @@ -52,13 +52,15 @@ const SERVER_CURRENT_PROCCESS_INTERVAL_TIME = 5 * 1000; const useServerStatus = () => { const [serverStatus, setServerStatus] = useStore("serverStatus", { ok: false, success: false, host: "", results: [] }); + const [serverProcess, setServerProcess] = useStore("serverProcess", { command: "", locked_at: "" }); + const { command, locked_at } = serverProcess; const { etkeccAdmin } = useAppContext(); const dataProvider = useDataProvider(); const isOkay = serverStatus.ok; const successCheck = serverStatus.success; const checkServerStatus = async () => { - const serverStatus: ServerStatusResponse = await dataProvider.getServerStatus(etkeccAdmin); + const serverStatus: ServerStatusResponse = await dataProvider.getServerStatus(etkeccAdmin, command !== ""); setServerStatus({ ok: serverStatus.ok, success: serverStatus.success, @@ -89,7 +91,7 @@ const useServerStatus = () => { clearInterval(serverStatusInterval); } } - }, [etkeccAdmin]); + }, [etkeccAdmin, command]); return { isOkay, successCheck }; }; @@ -101,7 +103,7 @@ const useCurrentServerProcess = () => { const { command, locked_at } = serverProcess; const checkServerRunningProcess = async () => { - const serverProcess: ServerProcessResponse = await dataProvider.getServerRunningProcess(etkeccAdmin); + const serverProcess: ServerProcessResponse = await dataProvider.getServerRunningProcess(etkeccAdmin, command !== ""); setServerProcess({ ...serverProcess, command: serverProcess.command, @@ -130,7 +132,7 @@ const useCurrentServerProcess = () => { clearInterval(serverCheckInterval); } } - }, [etkeccAdmin]); + }, [etkeccAdmin, command]); return { command, locked_at }; }; diff --git a/src/synapse/dataProvider.ts b/src/synapse/dataProvider.ts index 83d46ba..e3e5d88 100644 --- a/src/synapse/dataProvider.ts +++ b/src/synapse/dataProvider.ts @@ -1042,9 +1042,14 @@ const baseDataProvider: SynapseDataProvider = { return { locked_at, command }; }, - getServerStatus: async (etkeAdminUrl: string): Promise => { + getServerStatus: async (etkeAdminUrl: string, burstCache: boolean = false): Promise => { + let serverURL = `${etkeAdminUrl}/status`; + if (burstCache) { + serverURL += `?time=${new Date().getTime()}`; + } + try { - const response = await fetch(`${etkeAdminUrl}/status`, { + const response = await fetch(serverURL, { headers: { "Authorization": `Bearer ${localStorage.getItem("access_token")}` } @@ -1066,9 +1071,14 @@ const baseDataProvider: SynapseDataProvider = { return { success: false, ok: false, host: "", results: [] }; }, - getServerNotifications: async (serverNotificationsUrl: string): Promise => { + getServerNotifications: async (serverNotificationsUrl: string, burstCache: boolean = false): Promise => { + let serverURL = `${serverNotificationsUrl}/notifications`; + if (burstCache) { + serverURL += `?time=${new Date().getTime()}`; + } + try { - const response = await fetch(`${serverNotificationsUrl}/notifications`, { + const response = await fetch(serverURL, { headers: { "Authorization": `Bearer ${localStorage.getItem("access_token")}` } From a8f5f917ddac7614e94beeeffdd228fff5fef692 Mon Sep 17 00:00:00 2001 From: Borislav Pantaleev Date: Thu, 27 Feb 2025 20:16:30 +0200 Subject: [PATCH 07/12] reverse order of notifications so it's ASC --- src/components/etke.cc/ServerNotificationsBadge.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/etke.cc/ServerNotificationsBadge.tsx b/src/components/etke.cc/ServerNotificationsBadge.tsx index a1e2d1d..7b5ff67 100644 --- a/src/components/etke.cc/ServerNotificationsBadge.tsx +++ b/src/components/etke.cc/ServerNotificationsBadge.tsx @@ -22,9 +22,11 @@ const useServerNotifications = () => { const fetchNotifications = async () => { const notificationsResponse: ServerNotificationsResponse = await dataProvider.getServerNotifications(etkeccAdmin, command !== ""); + const serverNotifications = [...notificationsResponse.notifications]; + serverNotifications.reverse(); setServerNotifications({ ...notificationsResponse, - notifications: notificationsResponse.notifications, + notifications: serverNotifications, success: notificationsResponse.success }); }; From 092d8109b0b56ede1e65858c77e9a06bca7e32f1 Mon Sep 17 00:00:00 2001 From: Borislav Pantaleev Date: Thu, 27 Feb 2025 21:31:58 +0200 Subject: [PATCH 08/12] Add args param to run command --- src/components/etke.cc/ServerCommandsPanel.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/etke.cc/ServerCommandsPanel.tsx b/src/components/etke.cc/ServerCommandsPanel.tsx index 9713499..28462ae 100644 --- a/src/components/etke.cc/ServerCommandsPanel.tsx +++ b/src/components/etke.cc/ServerCommandsPanel.tsx @@ -56,7 +56,8 @@ const ServerCommandsPanel = () => { setCommandResult([]); setCommandIsRunning(true); - const response = await dataProvider.runServerCommand(etkeccAdmin, command); + const additionalArgs = serverCommands[command].additionalArgs; + const response = await dataProvider.runServerCommand(etkeccAdmin, command, { ...(additionalArgs && { args: additionalArgs }) }); if (!response.success) { return; From 9fd72907579f55a94174c76794b9f986619acf4a Mon Sep 17 00:00:00 2001 From: Borislav Pantaleev Date: Tue, 4 Mar 2025 23:42:41 +0200 Subject: [PATCH 09/12] Set server command manually for commands with_lock --- .../etke.cc/ServerCommandsPanel.tsx | 62 +++++++++++++++---- src/synapse/dataProvider.ts | 1 + 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/components/etke.cc/ServerCommandsPanel.tsx b/src/components/etke.cc/ServerCommandsPanel.tsx index 28462ae..94bdcc9 100644 --- a/src/components/etke.cc/ServerCommandsPanel.tsx +++ b/src/components/etke.cc/ServerCommandsPanel.tsx @@ -20,7 +20,7 @@ const ServerCommandsPanel = () => { const [ isLoading, setLoading ] = useState(true); const [serverCommands, setServerCommands] = useState<{ [key: string]: ServerCommand }>({}); const [serverProcess, setServerProcess] = useStore("serverProcess", { command: "", locked_at: "" }); - const [commandIsRunning, setCommandIsRunning] = useState(false); + const [commandIsRunning, setCommandIsRunning] = useState(serverProcess.command !== ""); const [commandResult, setCommandResult] = useState([]); const dataProvider = useDataProvider(); @@ -56,30 +56,66 @@ const ServerCommandsPanel = () => { setCommandResult([]); setCommandIsRunning(true); - const additionalArgs = serverCommands[command].additionalArgs; - const response = await dataProvider.runServerCommand(etkeccAdmin, command, { ...(additionalArgs && { args: additionalArgs }) }); + try { + const additionalArgs = serverCommands[command].additionalArgs || ""; + const requestParams = additionalArgs ? { args: additionalArgs } : {}; - if (!response.success) { - return; + 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 updatedServerCommands = {...serverCommands}; - const commandResult: React.ReactNode[] = []; + const buildCommandResultMessages = (command: string, additionalArgs: string): React.ReactNode[] => { + const results: React.ReactNode[] = []; let commandScheduledText = `Command scheduled: ${command}`; - if (serverCommands[command].additionalArgs) { - commandScheduledText += `, with additional args: ${serverCommands[command].additionalArgs}`; + if (additionalArgs) { + commandScheduledText += `, with additional args: ${additionalArgs}`; } - commandResult.push({commandScheduledText}); - commandResult.push(Expect your result in the Notifications page soon.); + results.push({commandScheduledText}); + results.push( + + Expect your result in the Notifications page soon. + + ); + + return results; + }; + + const resetCommandArgs = (command: string) => { + const updatedServerCommands = {...serverCommands}; updatedServerCommands[command].additionalArgs = ""; setServerCommands(updatedServerCommands); + }; - setCommandResult(commandResult); + 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(); + } - const serverProcess: ServerProcessResponse = await dataProvider.getServerRunningProcess(etkeccAdmin, true); setServerProcess({...serverProcess}); + console.log("serverProcess fecht notifications"); }; if (isLoading) { diff --git a/src/synapse/dataProvider.ts b/src/synapse/dataProvider.ts index e3e5d88..a44bd30 100644 --- a/src/synapse/dataProvider.ts +++ b/src/synapse/dataProvider.ts @@ -320,6 +320,7 @@ export interface ServerCommand { name: string; description: string; args: boolean; + with_lock: boolean; additionalArgs?: string; } From 73028da430ef8c10bd7ad9283cce2f70d1e625dd Mon Sep 17 00:00:00 2001 From: Aine Date: Wed, 5 Mar 2025 14:14:41 +0200 Subject: [PATCH 10/12] add docs --- README.md | 1 + screenshots/etke.cc/server-commands/panel.webp | Bin 0 -> 30498 bytes .../server-status/indicator-sidebar.webp | Bin 0 -> 2528 bytes src/components/etke.cc/README.md | 11 +++++++++++ 4 files changed, 12 insertions(+) create mode 100644 screenshots/etke.cc/server-commands/panel.webp create mode 100644 screenshots/etke.cc/server-status/indicator-sidebar.webp diff --git a/README.md b/README.md index 7d95a69..40300d4 100644 --- a/README.md +++ b/README.md @@ -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 Notifications indicator and page](https://github.com/etkecc/synapse-admin/pull/240) +* 🛠️ [Server Commands panel](https://github.com/etkecc/synapse-admin/pull/365) ### Development diff --git a/screenshots/etke.cc/server-commands/panel.webp b/screenshots/etke.cc/server-commands/panel.webp new file mode 100644 index 0000000000000000000000000000000000000000..10883042ef62c5e961ca95762ca0764fb9da8a11 GIT binary patch literal 30498 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 Date: Wed, 5 Mar 2025 14:16:18 +0200 Subject: [PATCH 11/12] update docs --- src/components/etke.cc/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/etke.cc/README.md b/src/components/etke.cc/README.md index c13332c..5965915 100644 --- a/src/components/etke.cc/README.md +++ b/src/components/etke.cc/README.md @@ -11,7 +11,6 @@ Due to the specifics mentioned above, these components are documented here rathe ### Server Status icon ![Server Status icon](../../../screenshots/etke.cc/server-status/indicator.webp) -![Server Status icon in sidebar](../../../screenshots/etke.cc/server-status/indicator-sidebar.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): @@ -19,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 * 🔴 (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](../../../screenshots/etke.cc/server-status/page.webp) From ad8aa21dc214b60bceeb5de32da65739cca46cad Mon Sep 17 00:00:00 2001 From: Borislav Pantaleev Date: Wed, 5 Mar 2025 14:53:39 +0200 Subject: [PATCH 12/12] cleanup --- src/components/etke.cc/ServerCommandsPanel.tsx | 1 - src/components/etke.cc/ServerNotificationsBadge.tsx | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/etke.cc/ServerCommandsPanel.tsx b/src/components/etke.cc/ServerCommandsPanel.tsx index 94bdcc9..7f86ce2 100644 --- a/src/components/etke.cc/ServerCommandsPanel.tsx +++ b/src/components/etke.cc/ServerCommandsPanel.tsx @@ -115,7 +115,6 @@ const ServerCommandsPanel = () => { } setServerProcess({...serverProcess}); - console.log("serverProcess fecht notifications"); }; if (isLoading) { diff --git a/src/components/etke.cc/ServerNotificationsBadge.tsx b/src/components/etke.cc/ServerNotificationsBadge.tsx index 7b5ff67..07c142c 100644 --- a/src/components/etke.cc/ServerNotificationsBadge.tsx +++ b/src/components/etke.cc/ServerNotificationsBadge.tsx @@ -167,7 +167,6 @@ export const ServerNotificationsBadge = () => { sx={{ overflow: "hidden", textOverflow: "ellipsis", - whiteSpace: "nowrap" }} dangerouslySetInnerHTML={{ __html: notification.output.split("\n")[0] }} /> @@ -175,7 +174,7 @@ export const ServerNotificationsBadge = () => { /> {getTimeSince(notification.sent_at) + " ago"} + {getTimeSince(notification.sent_at) + " ago"} } />