diff --git a/README.md b/README.md index 2bd494e..d98310c 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ The following changes are already implemented: * 🖼️ [Add rooms' avatars](https://github.com/etkecc/synapse-admin/pull/158) * 🤖 [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) +* _(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_ diff --git a/docs/README.md b/docs/README.md index e25d87e..9f15f52 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,14 @@ Specific configuration options: * [User Badges](./user-badges.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 * [Serving Synapse Admin behind a reverse proxy](./reverse-proxy.md) diff --git a/screenshots/etke.cc/server-status/indicator.webp b/screenshots/etke.cc/server-status/indicator.webp new file mode 100644 index 0000000..dab6f0e Binary files /dev/null and b/screenshots/etke.cc/server-status/indicator.webp differ diff --git a/screenshots/etke.cc/server-status/page.webp b/screenshots/etke.cc/server-status/page.webp new file mode 100644 index 0000000..033a2ef Binary files /dev/null and b/screenshots/etke.cc/server-status/page.webp differ diff --git a/src/App.tsx b/src/App.tsx index d1d6468..ab661bf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,9 +22,10 @@ import rooms from "./resources/rooms"; import userMediaStats from "./resources/user_media_statistics"; import users from "./resources/users"; import authProvider from "./synapse/authProvider"; -import dataProvider from "./synapse/dataProvider"; +import dataProvider, { ServerStatusResponse } from "./synapse/dataProvider"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Config } from "./utils/config"; +import ServerStatusPage from "./components/etke.cc/ServerStatusPage"; // TODO: Can we use lazy loading together with browser locale? const messages = { @@ -64,6 +65,7 @@ export const App = () => ( > } /> + } /> diff --git a/src/components/AdminLayout.tsx b/src/components/AdminLayout.tsx index 8d134fa..398162b 100644 --- a/src/components/AdminLayout.tsx +++ b/src/components/AdminLayout.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, Suspense } from "react"; import { Icons, DefaultIcon } from "../utils/icons"; import { MenuItem, GetConfig, ClearConfig } from "../utils/config"; import Footer from "./Footer"; +import ServerStatusBadge from "./etke.cc/ServerStatusBadge"; const AdminUserMenu = () => { const [open, setOpen] = useState(false); @@ -48,6 +49,7 @@ const AdminUserMenu = () => { const AdminAppBar = () => { return (}> + ); }; diff --git a/src/components/etke.cc/README.md b/src/components/etke.cc/README.md new file mode 100644 index 0000000..b7dfdf7 --- /dev/null +++ b/src/components/etke.cc/README.md @@ -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 diff --git a/src/components/etke.cc/ServerStatusBadge.tsx b/src/components/etke.cc/ServerStatusBadge.tsx new file mode 100644 index 0000000..2c65d82 --- /dev/null +++ b/src/components/etke.cc/ServerStatusBadge.tsx @@ -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) }) + (({ 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("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("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 +}; + +export default ServerStatusBadge; diff --git a/src/components/etke.cc/ServerStatusPage.tsx b/src/components/etke.cc/ServerStatusPage.tsx new file mode 100644 index 0000000..9b75fca --- /dev/null +++ b/src/components/etke.cc/ServerStatusPage.tsx @@ -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 = ; + let color: ChipProps["color"] = "success"; + if (!isOkay) { + label = "Error"; + icon = ; + color = "error"; + } + + if (command) { + label = command; + color = "warning"; + icon = ; + } + + return ( + + ); +}; + +const ServerComponentText = ({ text }: { text: string }) => { + return ; +}; + +const ServerStatusPage = () => { + 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 successCheck = serverStatus.success; + const isOkay = serverStatus.ok; + const host = serverStatus.host; + const results = serverStatus.results; + + let groupedResults: Record = {}; + for (const result of results) { + if (!groupedResults[result.category]) { + groupedResults[result.category] = []; + } + groupedResults[result.category].push(result); + } + + if (!successCheck) { + return ( + + + + + Unable to fetch server status. Please try again later. + + + + ); + } + + return ( + + + + Status: + + + + {host} + + + {command && locked_at && ( + + + Currently running: + + + {command} + + + (started {getTimeSince(locked_at)} ago) + + + + + )} + + + {Object.keys(groupedResults).map((category, idx) => ( + + + {category} + + + }> + {groupedResults[category].map((result, idx) => ( + + + + + {result.label.url ? ( + + + + ) : ( + + )} + + {result.reason && } + {(!result.ok && result.help) && ( + + Learn more + + )} + + + ))} + + + + ))} + + + ); +}; + +export default ServerStatusPage; diff --git a/src/synapse/dataProvider.ts b/src/synapse/dataProvider.ts index 270b6c5..0490a5b 100644 --- a/src/synapse/dataProvider.ts +++ b/src/synapse/dataProvider.ts @@ -265,6 +265,31 @@ export interface UsernameAvailabilityResult { 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 { deleteMedia: (params: DeleteMediaParams) => Promise; uploadMedia: (params: UploadMediaParams) => Promise; @@ -273,6 +298,8 @@ export interface SynapseDataProvider extends DataProvider { setRateLimits: (id: Identifier, rateLimits: RateLimitsModel) => Promise; checkUsernameAvailability: (username: string) => Promise; makeRoomAdmin: (room_id: string, user_id: string) => Promise<{ success: boolean; error?: string; errcode?: string }>; + getServerRunningProcess: (etkeAdminUrl: string) => Promise; + getServerStatus: (etkeAdminUrl: string) => Promise; } const resourceMap = { @@ -880,6 +907,60 @@ const baseDataProvider: SynapseDataProvider = { } throw error; } + }, + getServerRunningProcess: async (runningProcessUrl: string): Promise => { + 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 => { + 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: [] }; } }; diff --git a/src/utils/config.ts b/src/utils/config.ts index 23e56fa..7a5ec17 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -2,6 +2,7 @@ export interface Config { restrictBaseUrl: string | string[]; asManagedUsers: RegExp[]; menu: MenuItem[]; + etkeccAdmin?: string; } export interface MenuItem { @@ -17,6 +18,7 @@ let config: Config = { restrictBaseUrl: "", asManagedUsers: [], menu: [], + etkeccAdmin: "" }; export const FetchConfig = async () => { @@ -38,9 +40,9 @@ export const FetchConfig = async () => { if (!configWK[WellKnownKey]) { console.log(`Loaded https://${homeserver}.well-known/matrix/client, but it doesn't contain ${WellKnownKey} key, skipping`, configWK); } else { - console.log(`Loaded https://${homeserver}.well-known/matrix/client`, configWK); - LoadConfig(configWK[WellKnownKey]); - } + console.log(`Loaded https://${homeserver}.well-known/matrix/client`, configWK); + LoadConfig(configWK[WellKnownKey]); + } } catch (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 +// 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) => { if (context?.restrictBaseUrl) { config.restrictBaseUrl = context.restrictBaseUrl as string | string[]; @@ -65,6 +69,10 @@ export const LoadConfig = (context: any) => { if (menu.length > 0) { config.menu = menu; } + + if (context?.etkeccAdmin) { + config.etkeccAdmin = context.etkeccAdmin; + } } // get config