diff --git a/README.md b/README.md index fb4a28e..977104d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Highly recommended to install [Bun](https://bun.sh/) for the package management. 1. Clone the repository: ```bash - git clone https://github.com/Rishon/Server-Tracker + $ git clone https://github.com/Rishon/Server-Tracker ``` ### Client @@ -19,8 +19,8 @@ Highly recommended to install [Bun](https://bun.sh/) for the package management. 2. Install the dependencies: ```bash - cd Server-Tracker/client - bun install + $ cd Server-Tracker/client + $ bun install ``` 3. Adjust the environment variables: @@ -33,7 +33,7 @@ Highly recommended to install [Bun](https://bun.sh/) for the package management. 4. Start the development Next.JS server: ```bash - bun dev + $ bun dev ``` ### Server @@ -41,14 +41,14 @@ Highly recommended to install [Bun](https://bun.sh/) for the package management. 4. Install the dependencies: ```bash - cd Server-Tracker/server - bun install + $ cd Server-Tracker/server + $ bun install ``` 5. Assign the environment variables: ```bash - cp .env.example .env + $ cp .env.example .env ``` 6. Adjust the environment variables: @@ -61,7 +61,7 @@ Highly recommended to install [Bun](https://bun.sh/) for the package management. 7. Start the development server: ```bash - bun dev + $ bun dev ``` ## Production @@ -71,15 +71,15 @@ To deploy the Server Tracker, follow these steps: 1. Build the Next.JS application: ```bash - cd Server-Tracker/client - bun run build + $ cd Server-Tracker/client + $ bun run build ``` 2. Run the docker-compose file: ```bash - cd Server-Tracker - docker-compose up -d + $ cd Server-Tracker + $ docker-compose up -d ``` ## Issues @@ -91,7 +91,7 @@ If you discover a bug, please open up an [issue](https://github.com/Rishon/Serve To contribute to the Server Tracker, follow these steps: 1. Fork this repository. -2. Create a branch: `git checkout -b `. -3. Make your changes and commit them: `git commit -m ''`. -4. Push to the branch: `git push origin `. +2. Create a new branch: `git checkout -b `. +3. Make your changes and commit them: `git commit -m ""`. +4. Push to the branch: `git push `. 5. Create the pull request. diff --git a/client/package.json b/client/package.json index c476f6b..1d19e7e 100644 --- a/client/package.json +++ b/client/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@next/third-parties": "^14.2.35", - "next": "^16.1.6", + "next": "^16.2.3", "next-sitemap": "^4.2.3", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -20,10 +20,10 @@ }, "devDependencies": { "typescript": "^5.9.3", - "@types/node": "^20.19.37", + "@types/node": "^20.19.39", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", - "postcss": "^8.5.8", + "postcss": "^8.5.9", "tailwindcss": "^3.4.19", "eslint": "^8.57.1", "eslint-config-next": "14.2.4" diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 501c626..9d2c3e6 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -9,19 +9,49 @@ import { GoogleAnalytics } from "@next/third-parties/google"; import Navbar from "@/components/Navbar"; import Footer from "@/components/Footer"; -const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { +interface LayoutProps { + children: React.ReactNode; + seo?: { + title?: string; + description?: string; + image?: string; + }; +} + +const RootLayout = ({ children, seo }: LayoutProps) => { const domain = process.env.NEXT_PUBLIC_HOSTNAME; + const fallbackTitle = "Server Tracker"; + const fallbackDescription = + "Live tracking of player counts, MOTDs, and historical uptime."; + + const title = seo?.title ? `${seo.title} | Server Tracker` : fallbackTitle; + const description = seo?.description || fallbackDescription; + const image = seo?.image; + return (
- Server Tracker - - + {title} + + + {/* OpenGraph / Social Media */} + + + + + {domain && } + + {/* Twitter */} + + + + + @@ -36,10 +66,10 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { "@context": "https://schema.org", "@type": "WebSite", url: domain, - name: "Tracker", + name: "Server Tracker", author: { "@type": "Organization", - name: "Sela Development", + name: "Server Tracker", }, description: "Track Minecraft servers", potentialAction: { diff --git a/client/src/components/Navbar.tsx b/client/src/components/Navbar.tsx index 8bf15b8..e53af7a 100644 --- a/client/src/components/Navbar.tsx +++ b/client/src/components/Navbar.tsx @@ -16,7 +16,6 @@ import { HiOutlineExclamationTriangle } from "react-icons/hi2"; // Context import { useGraphColor } from "@/contexts/GraphColorContext"; -import { HiOutlineBeaker } from "react-icons/hi"; import { useCurrentList } from "@/contexts/CurrentListContext"; const Navbar = () => { @@ -30,9 +29,6 @@ const Navbar = () => { // Current list const { currentList, setCurrentList } = useCurrentList(); - // Experimental - const [showExperimental, setShowExperimental] = useState(false); - // Snackbar const [notification, setNotification] = useState(null); const [snackbarType, setSnackbarType] = useState<"success" | "error">( @@ -69,10 +65,7 @@ const Navbar = () => { useEffect(() => { const cachedGraphColor = getCache("graphColor"); if (cachedGraphColor) setGraphColor(cachedGraphColor); - - const cachedExperimental = getCache("experimental"); - if (cachedExperimental) setShowExperimental(cachedExperimental); - }, [setGraphColor, setShowExperimental]); + }, [setGraphColor]); function setColor(color: string) { setCache("graphColor", color); @@ -143,7 +136,7 @@ const Navbar = () => { bg-[#f0cd31]/5 border border-[#f0cd31]/30 hover:border-[#f0cd31]/60 shadow-[0_0_15px_-3px_rgba(240,205,49,0.2)] hover:shadow-[0_0_25px_-3px_rgba(240,205,49,0.5)]" > - Try Zeraph + Protect your server
@@ -159,48 +152,32 @@ const Navbar = () => { "success", ); }} - className="p-2 rounded-xl bg-white/5 hover:bg-white/10 transition" + className="p-2 rounded-xl bg-white/5 hover:bg-white/10 transition flex items-center justify-center w-[40px] h-[40px]" > - Game Toggle +
+ Game Toggle +
- - {/* */}
diff --git a/client/src/components/ServerGraph.tsx b/client/src/components/ServerGraph.tsx index ca607b4..0809668 100644 --- a/client/src/components/ServerGraph.tsx +++ b/client/src/components/ServerGraph.tsx @@ -23,6 +23,10 @@ export default function ServerGraph({ totalPlayers, pings, graphColor, + uptimePercentage, + last24hAveragePlayers, + allTimeAveragePlayers, + version, }: Readonly<{ isOnline: boolean; image: string; @@ -35,6 +39,10 @@ export default function ServerGraph({ totalPlayers: number; pings: Array<{ currentPlayers: number; timestamp: number }>; graphColor: string; + uptimePercentage?: number; + last24hAveragePlayers?: number; + allTimeAveragePlayers?: number; + version?: string; }>) { const maxPings = 1440; @@ -139,6 +147,15 @@ export default function ServerGraph({ setHoverData(null); }; + const cleanVersion = (ver?: string) => { + if (!ver) return ""; + let cleaned = ver.replace(/[&§][0-9a-fk-or]/gi, ""); + if (cleaned.length > 17) { + cleaned = cleaned.substring(0, 17) + "..."; + } + return cleaned; + }; + return (
-

{name}

+

+ {name} + {version && ( + + {cleanVersion(version)} + + )} +

{ipAddress} {port ? `:${port}` : ""} @@ -196,7 +220,7 @@ export default function ServerGraph({

{/* Motd */} -
+
{isOnline ? ( @@ -214,7 +238,7 @@ export default function ServerGraph({ {/* Graph */}
- {/* Player Count */} -
-
+ {/* Player Count & Stats */} +
+
Current -

{currentPlayers}

+

{currentPlayers}

-
+
24h Peak -

{maxPlayers}

+

{maxPlayers}

-
+
All Time -

{totalPlayers}

+

{totalPlayers}

+
+
+ 24h Avg +

+ {last24hAveragePlayers ?? "N/A"} +

+
+
+ All Time Avg +

+ {allTimeAveragePlayers ?? "N/A"} +

+
+
+ Uptime +

95 + ? "text-emerald-400" + : (uptimePercentage ?? 0) > 80 + ? "text-yellow-400" + : "text-red-400" + }`} + > + {uptimePercentage ? `${uptimePercentage}%` : "N/A"} +

diff --git a/client/src/components/SortingControls.tsx b/client/src/components/SortingControls.tsx new file mode 100644 index 0000000..f7a3209 --- /dev/null +++ b/client/src/components/SortingControls.tsx @@ -0,0 +1,40 @@ +import { HiUsers, HiClock } from "react-icons/hi2"; + +interface SortingControlsProps { + sortBy: "players" | "uptime"; + setSortBy: (sort: "players" | "uptime") => void; +} + +export default function SortingControls({ + sortBy, + setSortBy, +}: SortingControlsProps) { + return ( +
+
+ + +
+
+ ); +} diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx index 561fccd..90a5102 100644 --- a/client/src/pages/index.tsx +++ b/client/src/pages/index.tsx @@ -1,6 +1,7 @@ // Components import Layout from "@/components/Layout"; import ServerGraph from "@/components/ServerGraph"; +import SortingControls from "@/components/SortingControls"; import { useEffect, useState } from "react"; // Cache @@ -22,6 +23,11 @@ type ServerData = { maxPlayers: number; totalPlayers: number; pings: any[]; + uptimePercentage?: number; + last24hAveragePlayers?: number; + allTimeAveragePlayers?: number; + dailyMetrics?: Array<{ timestamp: number; maxPlayers: number; averagePlayers: number }>; + version?: string; }; type ServersData = { @@ -35,8 +41,8 @@ export default function Home() { hytale: [], }); const [hasLoadedOnce, setHasLoadedOnce] = useState(false); - const [isLoading, setIsLoading] = useState(true); + const [sortBy, setSortBy] = useState<"players" | "uptime">("players"); // Context const { graphColor, setGraphColor } = useGraphColor(); @@ -77,7 +83,14 @@ export default function Home() { return ( -
+
+ {/* Sorting Controls */} + + {(() => { const visibleServers = serversData[currentList] .filter((server) => server.name !== "") @@ -85,10 +98,17 @@ export default function Home() { if (a.isOnline !== b.isOnline) { return a.isOnline ? -1 : 1; } - if (b.currentPlayers !== a.currentPlayers) { - return b.currentPlayers - a.currentPlayers; + + switch (sortBy) { + case "players": + return b.currentPlayers - a.currentPlayers; + case "uptime": + const aUptime = a.uptimePercentage || 0; + const bUptime = b.uptimePercentage || 0; + return bUptime - aUptime; + default: + return 0; } - return b.name.localeCompare(a.name); }); if (isLoading && !hasLoadedOnce) { @@ -123,6 +143,10 @@ export default function Home() { totalPlayers={server.totalPlayers} pings={server.pings} graphColor={graphColor} + uptimePercentage={server.uptimePercentage} + last24hAveragePlayers={server.last24hAveragePlayers} + allTimeAveragePlayers={server.allTimeAveragePlayers} + version={server.version} /> ))}
diff --git a/server/handler/ResponseHandler.ts b/server/handler/ResponseHandler.ts index 91e61e3..3f85916 100644 --- a/server/handler/ResponseHandler.ts +++ b/server/handler/ResponseHandler.ts @@ -3,6 +3,7 @@ export default class ResponseHandler { static INVALID_METHOD = { message: "Invalid method", status: 405 }; static INVALID_BODY = { message: "Invalid body", status: 400 }; static INTERNAL_ERROR = { message: "Internal server error", status: 500 }; + static SERVER_NOT_FOUND = { message: "Server not found", status: 404 }; static CORS_HEADERS = { headers: { "Access-Control-Allow-Origin": "*", diff --git a/server/index.ts b/server/index.ts index 50d2d77..28ffacd 100644 --- a/server/index.ts +++ b/server/index.ts @@ -13,8 +13,7 @@ dotenv.config(); // Environment Variables const PORT = process.env.BACKEND_PORT || 3005; -const MONGODB_URL = - process.env.MONGODB_URL || "mongodb://localhost:27017/tracker-db"; +const MONGODB_URL = process.env.MONGODB_URL; // StatusChecker const statusChecker = new StatusChecker(); @@ -46,6 +45,27 @@ const server = serve({ // Handle GET requests if (method === "GET") { + if (path.startsWith("servers/") && path.length > "servers/".length) { + const serverName = decodeURIComponent(path.split("/")[1]); + const allServers = statusChecker.getServersData(); + + let foundServer = null; + for (const [platform, servers] of Object.entries(allServers)) { + foundServer = (servers as any[]).find( + (s: any) => s.name.toLowerCase() === serverName.toLowerCase(), + ); + if (foundServer) break; + } + + if (foundServer) { + return ResponseHandler.successResponse(foundServer); + } else { + return ResponseHandler.invalidResponse( + ResponseHandler.SERVER_NOT_FOUND, + ); + } + } + switch (path) { case "servers": // Return servers data @@ -66,7 +86,10 @@ const server = serve({ async function init() { // Connect to MongoDB - await MongoDB.connect(MONGODB_URL); + await MongoDB.connect(MONGODB_URL || "").catch((err) => { + console.error("Error connecting to MongoDB", err); + process.exit(1); + }); // Fetch servers data initially await statusChecker.refreshAllServers(); diff --git a/server/models/ServerModel.ts b/server/models/ServerModel.ts index 10ab460..ca141cd 100644 --- a/server/models/ServerModel.ts +++ b/server/models/ServerModel.ts @@ -9,6 +9,14 @@ interface IServer extends Document { image: String; motd: String; ping: Array<{ currentPlayers: Number; timestamp: Number }>; + uptimeStats: { + totalChecks: number; + successfulChecks: number; + firstCheckAdded: Number; + }; + dailyMetrics: Array<{ timestamp: Number; maxPlayers: Number; averagePlayers: Number }>; + last24hAveragePlayers: Number; + allTimeAveragePlayers: Number; } const serverSchema: Schema = new Schema({ @@ -20,6 +28,14 @@ const serverSchema: Schema = new Schema({ image: { type: String, required: false }, motd: { type: String, required: false }, ping: { type: Array, required: false }, + uptimeStats: { + totalChecks: { type: Number, default: 0 }, + successfulChecks: { type: Number, default: 0 }, + firstCheckAdded: { type: Number, default: Date.now }, + }, + dailyMetrics: { type: Array, required: false, default: [] }, + last24hAveragePlayers: { type: Number, required: false, default: 0 }, + allTimeAveragePlayers: { type: Number, required: false, default: 0 }, }); const Server = mongoose.model("Server", serverSchema); diff --git a/server/servers.json b/server/servers.json index ed161ce..807efa1 100644 --- a/server/servers.json +++ b/server/servers.json @@ -44,10 +44,6 @@ "name": "OnlyGood", "address": "only-good.xyz" }, - { - "name": "Ranb Realm", - "address": "play.ranbrealm.net" - }, { "name": "Israel Practice", "address": "play.israelpractice.net" @@ -65,10 +61,5 @@ "address": "play.shotist.net" } ], - "hytale": [ - { - "name": "FrenzyTale", - "address": "play.frenzytale.com" - } - ] -} \ No newline at end of file + "hytale": [] +} diff --git a/server/services/MongoDB.ts b/server/services/MongoDB.ts index 853aca3..802e5a1 100644 --- a/server/services/MongoDB.ts +++ b/server/services/MongoDB.ts @@ -33,6 +33,7 @@ class MongoService { image: String, motd: String, platform: Platform, + isOnline: boolean = false, ) { let server = await MongoService.getServerData(address); let currentDateTime = new Date().getTime() as Number; @@ -48,9 +49,27 @@ class MongoService { ping: [], image, motd, + uptimeStats: { + totalChecks: 0, + successfulChecks: 0, + firstCheckAdded: Date.now(), + }, + dailyMetrics: [], + last24hAveragePlayers: 0, }); } + // Update uptime stats + if (!server.uptimeStats) { + server.uptimeStats = { + totalChecks: 0, + successfulChecks: 0, + firstCheckAdded: Date.now(), + }; + } + server.uptimeStats.totalChecks += 1; + if (isOnline) server.uptimeStats.successfulChecks += 1; + // Update address server.address = address; @@ -76,13 +95,64 @@ class MongoService { if (server.maxPlayers === undefined) server.maxPlayers = 0; let currentMaxPlayers = 0 as Number; + let total24hPlayers = 0 as number; server.ping.forEach((ping) => { + total24hPlayers += Number(ping.currentPlayers); if (ping.currentPlayers > currentMaxPlayers) currentMaxPlayers = ping.currentPlayers; }); server.maxPlayers = currentMaxPlayers; + if (server.ping.length > 0) { + server.last24hAveragePlayers = Math.round( + total24hPlayers / server.ping.length, + ); + } else { + server.last24hAveragePlayers = 0; + } + + // Timestamp + const now = new Date(); + const startOfDay = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ).getTime(); + + if (!server.dailyMetrics) { + server.dailyMetrics = []; + } + + const todayMetricIndex = server.dailyMetrics.findIndex( + (m: any) => m.timestamp === startOfDay, + ); + + if (todayMetricIndex !== -1) { + if (currentPlayers > server.dailyMetrics[todayMetricIndex].maxPlayers) { + server.dailyMetrics[todayMetricIndex].maxPlayers = currentPlayers; + } + + server.dailyMetrics[todayMetricIndex].averagePlayers = + server.last24hAveragePlayers; + server.markModified("dailyMetrics"); + } else { + server.dailyMetrics.push({ + timestamp: startOfDay, + maxPlayers: currentPlayers, + averagePlayers: currentPlayers, + }); + } + + if (server.dailyMetrics && server.dailyMetrics.length > 0) { + const allTimeAvgSum = server.dailyMetrics.reduce( + (sum, m) => sum + (Number(m.averagePlayers) || 0), + 0, + ); + server.allTimeAveragePlayers = Math.round( + allTimeAvgSum / server.dailyMetrics.length, + ); + } else { + server.allTimeAveragePlayers = currentPlayers; + } // Update total players if (server.totalPlayers === undefined) server.totalPlayers = 0; diff --git a/server/services/StatusChecker.ts b/server/services/StatusChecker.ts index cdc5083..5c89df3 100644 --- a/server/services/StatusChecker.ts +++ b/server/services/StatusChecker.ts @@ -17,6 +17,7 @@ interface ServerData { currentPlayers: Number; image: String; motd: String; + version?: String; } interface ServerInfo { @@ -26,6 +27,7 @@ interface ServerInfo { currentPlayers: Number; image: String; motd: String; + version?: String; } type ExtraObject = { @@ -110,11 +112,17 @@ class StatusChecker { image, motd, platform, + info.isOnline !== undefined ? (info.isOnline as boolean) : false, ); const mongoServer = await MongoDB.getServerData(server.address); if (!mongoServer) return null; + // Calculate uptime percentage + const totalChecks = mongoServer.uptimeStats?.totalChecks || 1; + const successfulChecks = mongoServer.uptimeStats?.successfulChecks || 0; + const uptimePercentage = totalChecks > 0 ? (successfulChecks / totalChecks) * 100 : 0; + return { ...server, platform, @@ -125,6 +133,11 @@ class StatusChecker { image: mongoServer.image, motd: mongoServer.motd, pings: mongoServer.ping, + uptimePercentage, + last24hAveragePlayers: mongoServer.last24hAveragePlayers, + allTimeAveragePlayers: mongoServer.allTimeAveragePlayers, + dailyMetrics: mongoServer.dailyMetrics, + version: info.version, }; } catch (err) { console.error(`[${platform}] Failed fetching ${server.name}`, err); @@ -152,6 +165,7 @@ class StatusChecker { image: data.favicon || "", motd: motd, currentPlayers: data.players.online, + version: data.version?.name || "Unknown", }; } catch { if (attempt === MAX_RETRIES) { @@ -160,6 +174,7 @@ class StatusChecker { image: "", motd: "", currentPlayers: 0, + version: "Offline", }; } @@ -172,6 +187,7 @@ class StatusChecker { image: "", motd: "", currentPlayers: 0, + version: "Offline", }; } @@ -187,6 +203,7 @@ class StatusChecker { image: "", // Hytale does not provide icons yet motd: info.motd || "", currentPlayers: info.currentPlayers || 0, + version: "Hytale Beta", }; } catch (e) { return { @@ -194,6 +211,7 @@ class StatusChecker { image: "", motd: "", currentPlayers: 0, + version: "Offline", }; } }