Initial commit: Minecraft dashboard
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>
This commit is contained in:
commit
dd69c17c3b
77 changed files with 7007 additions and 0 deletions
56
scripts/backup-world.sh
Executable file
56
scripts/backup-world.sh
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
#!/bin/bash
|
||||
# Automated world backup — called by cron every 6 hours
|
||||
|
||||
BACKUP_DIR="/home/minecraft/server/backups"
|
||||
WORLD_DIR="/home/minecraft/server/world"
|
||||
MAX_BACKUPS=20
|
||||
RCON_PASS="23991818cc169249f181436f2a29a013"
|
||||
TIMESTAMP=$(date +%Y-%m-%d_%H-%M)
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Check if server is running
|
||||
if systemctl is-active --quiet minecraft.service; then
|
||||
# Disable saving and flush
|
||||
node -e "
|
||||
const { Rcon } = require('/home/minecraft/dashboard/node_modules/rcon-client');
|
||||
(async () => {
|
||||
try {
|
||||
const rcon = await Rcon.connect({ host: '127.0.0.1', port: 25575, password: '$RCON_PASS' });
|
||||
await rcon.send('save-off');
|
||||
await rcon.send('save-all flush');
|
||||
rcon.end();
|
||||
} catch {}
|
||||
})();
|
||||
"
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
# Create backup
|
||||
tar czf "$BACKUP_DIR/world_${TIMESTAMP}.tar.gz" -C /home/minecraft/server world
|
||||
|
||||
# Validate backup was created and is non-empty
|
||||
if [ ! -s "$BACKUP_DIR/world_${TIMESTAMP}.tar.gz" ]; then
|
||||
echo "ERROR: Backup file is empty or missing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Re-enable saving
|
||||
if systemctl is-active --quiet minecraft.service; then
|
||||
node -e "
|
||||
const { Rcon } = require('/home/minecraft/dashboard/node_modules/rcon-client');
|
||||
(async () => {
|
||||
try {
|
||||
const rcon = await Rcon.connect({ host: '127.0.0.1', port: 25575, password: '$RCON_PASS' });
|
||||
await rcon.send('save-on');
|
||||
rcon.end();
|
||||
} catch {}
|
||||
})();
|
||||
"
|
||||
fi
|
||||
|
||||
# Prune old backups
|
||||
cd "$BACKUP_DIR"
|
||||
ls -t world_*.tar.gz 2>/dev/null | tail -n +$((MAX_BACKUPS + 1)) | xargs -r rm -f
|
||||
|
||||
echo "Backup complete: world_${TIMESTAMP}.tar.gz"
|
||||
103
scripts/collect-metrics.js
Executable file
103
scripts/collect-metrics.js
Executable file
|
|
@ -0,0 +1,103 @@
|
|||
#!/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));
|
||||
83
scripts/collect-metrics.sh
Normal file
83
scripts/collect-metrics.sh
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#!/bin/bash
|
||||
# Collect server metrics every minute — called by cron
|
||||
# Appends JSON lines to analytics.jsonl
|
||||
|
||||
ANALYTICS_FILE="/home/minecraft/server/analytics.jsonl"
|
||||
RCON_PASS="23991818cc169249f181436f2a29a013"
|
||||
|
||||
# Check if server is running
|
||||
if ! systemctl is-active --quiet minecraft.service; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get MC server PID
|
||||
MC_PID=$(pgrep -f 'minecraft.*server' | head -1)
|
||||
if [ -z "$MC_PID" ]; then
|
||||
MC_PID=$(pgrep -f 'java.*forge' | head -1)
|
||||
fi
|
||||
|
||||
# TPS via RCON
|
||||
TPS="0"
|
||||
TPS_RAW=$(echo -e "\x00" | timeout 3 python3 -c "
|
||||
from rcon.source import Client
|
||||
try:
|
||||
with Client('127.0.0.1', 25575, passwd='${RCON_PASS}') as c:
|
||||
r = c.run('forge tps')
|
||||
print(r)
|
||||
except: pass
|
||||
" 2>/dev/null)
|
||||
|
||||
if echo "$TPS_RAW" | grep -q "Overall"; then
|
||||
TPS=$(echo "$TPS_RAW" | grep "Overall" | grep -oP '[\d.]+(?= TPS)' | head -1)
|
||||
elif echo "$TPS_RAW" | grep -q "Dim 0"; then
|
||||
TPS=$(echo "$TPS_RAW" | grep "Dim 0\|overworld" | grep -oP '[\d.]+(?= TPS)' | head -1)
|
||||
fi
|
||||
[ -z "$TPS" ] && TPS="0"
|
||||
|
||||
# RAM from /proc
|
||||
RAM_USED=0
|
||||
RAM_TOTAL=8192
|
||||
if [ -n "$MC_PID" ] && [ -f "/proc/$MC_PID/status" ]; then
|
||||
RAM_KB=$(grep VmRSS "/proc/$MC_PID/status" 2>/dev/null | awk '{print $2}')
|
||||
[ -n "$RAM_KB" ] && RAM_USED=$((RAM_KB / 1024))
|
||||
fi
|
||||
|
||||
# CPU
|
||||
CPU="0"
|
||||
if [ -n "$MC_PID" ]; then
|
||||
CPU=$(ps -p "$MC_PID" -o %cpu --no-headers 2>/dev/null | tr -d ' ')
|
||||
fi
|
||||
[ -z "$CPU" ] && CPU="0"
|
||||
|
||||
# Players via RCON
|
||||
PLAYERS_JSON="[]"
|
||||
PLAYERS_ONLINE=0
|
||||
PLAYERS_RAW=$(timeout 3 python3 -c "
|
||||
from rcon.source import Client
|
||||
try:
|
||||
with Client('127.0.0.1', 25575, passwd='${RCON_PASS}') as c:
|
||||
r = c.run('list')
|
||||
print(r)
|
||||
except: pass
|
||||
" 2>/dev/null)
|
||||
|
||||
if echo "$PLAYERS_RAW" | grep -q "players online"; then
|
||||
PLAYERS_ONLINE=$(echo "$PLAYERS_RAW" | grep -oP '\d+(?= of)' | head -1)
|
||||
NAMES=$(echo "$PLAYERS_RAW" | sed 's/.*: //')
|
||||
if [ "$PLAYERS_ONLINE" -gt 0 ] 2>/dev/null && [ -n "$NAMES" ]; then
|
||||
PLAYERS_JSON=$(echo "$NAMES" | python3 -c "import sys,json; print(json.dumps([n.strip() for n in sys.stdin.read().split(',') if n.strip()]))")
|
||||
fi
|
||||
fi
|
||||
[ -z "$PLAYERS_ONLINE" ] && PLAYERS_ONLINE=0
|
||||
|
||||
# Timestamp
|
||||
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# Write JSON line
|
||||
echo "{\"ts\":\"$TS\",\"tps\":$TPS,\"ramUsedMB\":$RAM_USED,\"ramTotalMB\":$RAM_TOTAL,\"cpuPercent\":$CPU,\"playersOnline\":$PLAYERS_ONLINE,\"players\":$PLAYERS_JSON}" >> "$ANALYTICS_FILE"
|
||||
|
||||
# Keep only last 48 hours of data (2880 lines at 1/min)
|
||||
if [ $(wc -l < "$ANALYTICS_FILE") -gt 3000 ]; then
|
||||
tail -n 2880 "$ANALYTICS_FILE" > "$ANALYTICS_FILE.tmp"
|
||||
mv "$ANALYTICS_FILE.tmp" "$ANALYTICS_FILE"
|
||||
fi
|
||||
32
scripts/scheduled-restart.sh
Executable file
32
scripts/scheduled-restart.sh
Executable file
|
|
@ -0,0 +1,32 @@
|
|||
#!/bin/bash
|
||||
# Scheduled server restart with player warnings
|
||||
|
||||
RCON_PASS="23991818cc169249f181436f2a29a013"
|
||||
|
||||
send_rcon() {
|
||||
node -e "
|
||||
const { Rcon } = require('/home/minecraft/dashboard/node_modules/rcon-client');
|
||||
(async () => {
|
||||
try {
|
||||
const rcon = await Rcon.connect({ host: '127.0.0.1', port: 25575, password: '$RCON_PASS' });
|
||||
await rcon.send('say $1');
|
||||
rcon.end();
|
||||
} catch {}
|
||||
})();
|
||||
"
|
||||
}
|
||||
|
||||
# 5 minute warning
|
||||
send_rcon '§c[Server] Restarting in 5 minutes!'
|
||||
sleep 240
|
||||
|
||||
# 1 minute warning
|
||||
send_rcon '§c[Server] Restarting in 1 minute!'
|
||||
sleep 50
|
||||
|
||||
# 10 second warning
|
||||
send_rcon '§c[Server] Restarting in 10 seconds!'
|
||||
sleep 10
|
||||
|
||||
# Restart
|
||||
sudo systemctl restart minecraft.service
|
||||
Loading…
Add table
Add a link
Reference in a new issue