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
+
+
+
+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
+
+
+
+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