mc-dashboard/lib/snapshots.ts
hurkicorgi 19d66c2de6 Pass 3 next slice: snapshot polish, analytics depth, player drawer
- 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>
2026-04-13 05:39:32 -06:00

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);
}
}
}