- Snapshots now include recursive sizeBytes (lib/snapshots.ts dirSize),
rendered as a badge next to mod count.
- Snapshot restore/delete is now type-to-confirm: click Restore/Delete,
type the literal snapshot name, press Enter or click Confirm. Esc
cancels, matches the existing wizard Esc handler.
- Analytics card:
- Uptime ring showing % of datapoints with tps>0 (color-graded
green/amber/red) + numeric % over selected range.
- Peak-player marker dot on the Players sparkline + peak caption.
- "Online now" player list (up to 8) with small PlayerAvatar badges,
sourced from the latest analytics entry's players[] array.
- Player profile drawer (new):
- Slide-in right panel opened by clicking any PlayerAvatar.
- Shows Online / Op / Whitelisted / Banned badges, UUID, ban reason.
- Quick toggles for op/deop, whitelist add/remove, ban (with reason
input) and pardon — reuses /api/players POST contract.
- Global event bus (lib/events.ts) decouples avatars from drawer.
- Esc / backdrop / close-button dismiss.
- PlayerAvatar now renders as a <button> by default (stopPropagation on
click, focus-visible ring); pass interactive={false} to opt out (used
inside the drawer itself).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
179 lines
4.7 KiB
TypeScript
179 lines
4.7 KiB
TypeScript
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
copyFileSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
readFileSync,
|
|
unlinkSync,
|
|
} from "fs";
|
|
import { join } from "path";
|
|
import { MODS_DIR, CLIENT_MODS_DIR, MOD_METADATA_FILE } from "./constants";
|
|
|
|
function dirSize(dir: string): number {
|
|
let total = 0;
|
|
try {
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const p = join(dir, entry.name);
|
|
if (entry.isDirectory()) total += dirSize(p);
|
|
else if (entry.isFile()) {
|
|
try {
|
|
total += statSync(p).size;
|
|
} catch {}
|
|
}
|
|
}
|
|
} catch {}
|
|
return total;
|
|
}
|
|
|
|
const SNAPSHOTS_DIR = "/home/minecraft/server/snapshots";
|
|
const MAX_SNAPSHOTS = 10;
|
|
|
|
export type SnapshotMeta = {
|
|
name: string;
|
|
createdAt: string;
|
|
modCount: number;
|
|
mods: string[];
|
|
sizeBytes?: number;
|
|
};
|
|
|
|
function ensureDir(dir: string) {
|
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
export function createSnapshot(name: string): SnapshotMeta {
|
|
ensureDir(SNAPSHOTS_DIR);
|
|
|
|
// Sanitize name
|
|
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 60);
|
|
const dirName = `${safeName}_${Date.now()}`;
|
|
const snapDir = join(SNAPSHOTS_DIR, dirName);
|
|
mkdirSync(snapDir);
|
|
|
|
// Back up server mods
|
|
const mods = readdirSync(MODS_DIR).filter((f) => f.endsWith(".jar"));
|
|
for (const file of mods) {
|
|
copyFileSync(join(MODS_DIR, file), join(snapDir, file));
|
|
}
|
|
|
|
// Back up client mods
|
|
if (existsSync(CLIENT_MODS_DIR)) {
|
|
const clientMods = readdirSync(CLIENT_MODS_DIR).filter((f) =>
|
|
f.endsWith(".jar")
|
|
);
|
|
if (clientMods.length > 0) {
|
|
const clientSnapDir = join(snapDir, "client-mods");
|
|
mkdirSync(clientSnapDir);
|
|
for (const file of clientMods) {
|
|
copyFileSync(join(CLIENT_MODS_DIR, file), join(clientSnapDir, file));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Back up mod metadata
|
|
if (existsSync(MOD_METADATA_FILE)) {
|
|
copyFileSync(MOD_METADATA_FILE, join(snapDir, "mod-metadata.json"));
|
|
}
|
|
|
|
const meta: SnapshotMeta = {
|
|
name: safeName,
|
|
createdAt: new Date().toISOString(),
|
|
modCount: mods.length,
|
|
mods,
|
|
};
|
|
|
|
writeFileSync(join(snapDir, "meta.json"), JSON.stringify(meta, null, 2));
|
|
|
|
// Enforce max snapshots
|
|
pruneOldSnapshots();
|
|
|
|
return meta;
|
|
}
|
|
|
|
export function restoreSnapshot(dirName: string): void {
|
|
const snapDir = join(SNAPSHOTS_DIR, dirName);
|
|
if (!existsSync(snapDir)) {
|
|
throw new Error(`Snapshot "${dirName}" not found`);
|
|
}
|
|
|
|
// Clear current server mods
|
|
const currentMods = readdirSync(MODS_DIR).filter((f) => f.endsWith(".jar"));
|
|
for (const file of currentMods) {
|
|
unlinkSync(join(MODS_DIR, file));
|
|
}
|
|
|
|
// Copy snapshot server mods back
|
|
const snapFiles = readdirSync(snapDir).filter((f) => f.endsWith(".jar"));
|
|
for (const file of snapFiles) {
|
|
copyFileSync(join(snapDir, file), join(MODS_DIR, file));
|
|
}
|
|
|
|
// Restore client mods
|
|
if (existsSync(CLIENT_MODS_DIR)) {
|
|
const currentClient = readdirSync(CLIENT_MODS_DIR).filter((f) =>
|
|
f.endsWith(".jar")
|
|
);
|
|
for (const file of currentClient) {
|
|
unlinkSync(join(CLIENT_MODS_DIR, file));
|
|
}
|
|
}
|
|
|
|
const clientSnapDir = join(snapDir, "client-mods");
|
|
if (existsSync(clientSnapDir)) {
|
|
ensureDir(CLIENT_MODS_DIR);
|
|
const snapClientFiles = readdirSync(clientSnapDir).filter((f) =>
|
|
f.endsWith(".jar")
|
|
);
|
|
for (const file of snapClientFiles) {
|
|
copyFileSync(join(clientSnapDir, file), join(CLIENT_MODS_DIR, file));
|
|
}
|
|
}
|
|
|
|
// Restore mod metadata
|
|
const metaBackup = join(snapDir, "mod-metadata.json");
|
|
if (existsSync(metaBackup)) {
|
|
copyFileSync(metaBackup, MOD_METADATA_FILE);
|
|
}
|
|
}
|
|
|
|
export function listSnapshots(): (SnapshotMeta & { dirName: string })[] {
|
|
ensureDir(SNAPSHOTS_DIR);
|
|
|
|
const dirs = readdirSync(SNAPSHOTS_DIR).filter((d) => {
|
|
const metaPath = join(SNAPSHOTS_DIR, d, "meta.json");
|
|
return existsSync(metaPath);
|
|
});
|
|
|
|
return dirs
|
|
.map((dirName) => {
|
|
const meta = JSON.parse(
|
|
readFileSync(join(SNAPSHOTS_DIR, dirName, "meta.json"), "utf8")
|
|
) as SnapshotMeta;
|
|
const sizeBytes = dirSize(join(SNAPSHOTS_DIR, dirName));
|
|
return { ...meta, dirName, sizeBytes };
|
|
})
|
|
.sort(
|
|
(a, b) =>
|
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
);
|
|
}
|
|
|
|
export function deleteSnapshot(dirName: string): void {
|
|
const snapDir = join(SNAPSHOTS_DIR, dirName);
|
|
if (!existsSync(snapDir)) {
|
|
throw new Error(`Snapshot "${dirName}" not found`);
|
|
}
|
|
rmSync(snapDir, { recursive: true });
|
|
}
|
|
|
|
function pruneOldSnapshots(): void {
|
|
const snapshots = listSnapshots();
|
|
if (snapshots.length > MAX_SNAPSHOTS) {
|
|
const toDelete = snapshots.slice(MAX_SNAPSHOTS);
|
|
for (const snap of toDelete) {
|
|
deleteSnapshot(snap.dirName);
|
|
}
|
|
}
|
|
}
|