Next.js 16 + Tailwind v4 + shadcn v4 dashboard for managing a modded Forge 1.20.1 server. Includes server controls, player management, mod manager with Modrinth search and dependency resolution, world backups, snapshots, analytics, logs, and chat bridge. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
103 lines
2.9 KiB
JavaScript
Executable file
103 lines
2.9 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
// Collect server metrics — called by cron every minute
|
|
// Appends JSON lines to analytics.jsonl
|
|
|
|
const { Rcon } = require("rcon-client");
|
|
const { execSync } = require("child_process");
|
|
const { appendFileSync, readFileSync, writeFileSync } = require("fs");
|
|
|
|
const ANALYTICS_FILE = "/home/minecraft/server/analytics.jsonl";
|
|
const RCON_HOST = "127.0.0.1";
|
|
const RCON_PORT = 25575;
|
|
const RCON_PASS = "23991818cc169249f181436f2a29a013";
|
|
|
|
async function main() {
|
|
// Check if server is running
|
|
try {
|
|
const active = execSync("systemctl is-active minecraft.service", { encoding: "utf8" }).trim();
|
|
if (active !== "active") process.exit(0);
|
|
} catch {
|
|
process.exit(0);
|
|
}
|
|
|
|
// Get MC PID
|
|
let mcPid = "";
|
|
try {
|
|
mcPid = execSync("pgrep -f 'java.*forge' | head -1", { encoding: "utf8" }).trim();
|
|
} catch {}
|
|
|
|
let tps = 0;
|
|
let playersOnline = 0;
|
|
let players = [];
|
|
|
|
// RCON queries
|
|
try {
|
|
const rcon = await Rcon.connect({ host: RCON_HOST, port: RCON_PORT, password: RCON_PASS });
|
|
|
|
// TPS
|
|
try {
|
|
const tpsRaw = await rcon.send("forge tps");
|
|
const match = tpsRaw.match(/Overall.*Mean TPS:\s*(\d+\.?\d*)/i) || tpsRaw.match(/Mean TPS:\s*(\d+\.?\d*)/);
|
|
if (match) tps = parseFloat(match[1]);
|
|
} catch {}
|
|
|
|
// Players
|
|
try {
|
|
const listRaw = await rcon.send("list");
|
|
const countMatch = listRaw.match(/(\d+)\s+of/);
|
|
if (countMatch) playersOnline = parseInt(countMatch[1]);
|
|
if (playersOnline > 0) {
|
|
const namesStr = listRaw.split(":").pop() || "";
|
|
players = namesStr.split(",").map(n => n.trim()).filter(Boolean);
|
|
}
|
|
} catch {}
|
|
|
|
rcon.end();
|
|
} catch {
|
|
// RCON not available
|
|
}
|
|
|
|
// RAM
|
|
let ramUsedMB = 0;
|
|
const ramTotalMB = 8192;
|
|
if (mcPid) {
|
|
try {
|
|
const status = readFileSync(`/proc/${mcPid}/status`, "utf8");
|
|
const match = status.match(/VmRSS:\s+(\d+)/);
|
|
if (match) ramUsedMB = Math.round(parseInt(match[1]) / 1024);
|
|
} catch {}
|
|
}
|
|
|
|
// CPU (normalize to total system CPU — ps reports per-core)
|
|
let cpuPercent = 0;
|
|
const numCpus = parseInt(execSync("nproc", { encoding: "utf8" }).trim()) || 1;
|
|
if (mcPid) {
|
|
try {
|
|
const cpu = execSync(`ps -p ${mcPid} -o %cpu --no-headers`, { encoding: "utf8" }).trim();
|
|
cpuPercent = (parseFloat(cpu) || 0) / numCpus;
|
|
} catch {}
|
|
}
|
|
|
|
// Write
|
|
const entry = {
|
|
ts: new Date().toISOString(),
|
|
tps: Math.round(tps * 10) / 10,
|
|
ramUsedMB,
|
|
ramTotalMB,
|
|
cpuPercent: Math.round(cpuPercent * 10) / 10,
|
|
playersOnline,
|
|
players,
|
|
};
|
|
|
|
appendFileSync(ANALYTICS_FILE, JSON.stringify(entry) + "\n");
|
|
|
|
// Prune to last 2880 lines (48h)
|
|
try {
|
|
const lines = readFileSync(ANALYTICS_FILE, "utf8").split("\n").filter(Boolean);
|
|
if (lines.length > 3000) {
|
|
writeFileSync(ANALYTICS_FILE, lines.slice(-2880).join("\n") + "\n");
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
main().catch(() => process.exit(0));
|