a1331212cb
Self-hosted Minecraft modpack distribution + administration system.
- launcher/ Avalonia 12 desktop client; single-file win-x64 publish.
Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
portable install path, sidecar settings.
- server/ brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
MC subprocess, embedded Kestrel admin panel with cookie
auth + rate limiting, RCON bridge, scheduled backups,
BlueMap CLI integration with player markers + skin proxy,
friend-side whitelist request flow, world wipe with seed
selection (keep current / random / custom).
- pack/ pack.lock.json (Modrinth + manual CurseForge entries),
data-only tweak source under tweaks/, build outputs in
overrides/ (gitignored).
- scripts/ Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
plus Deploy-Brass.ps1 unified one-shot deploy with
version-bump pre-flight and daemon-state detection.
184 lines
7.3 KiB
JavaScript
184 lines
7.3 KiB
JavaScript
// World backup management -- list, create, restore, delete.
|
|
//
|
|
// Backups are server-online (no downtime) -- the daemon issues `save-all flush`
|
|
// + `save-off`, archives the world, then `save-on`. Restore *does* stop the
|
|
// server (it has to), and snapshots the current world to a `-prerestore-*` dir
|
|
// before extracting so a wrong restore is recoverable.
|
|
"use strict";
|
|
|
|
import { api } from "./api.js";
|
|
|
|
const els = {};
|
|
let lastSchedule = null;
|
|
let lastKeep = null;
|
|
|
|
function fmtSize(bytes) {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
}
|
|
|
|
function fmtDate(iso) {
|
|
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
|
}
|
|
|
|
function fmtRelativeFuture(iso) {
|
|
if (!iso) return "--";
|
|
const target = new Date(iso).getTime();
|
|
const ms = target - Date.now();
|
|
if (ms <= 0) return "imminent";
|
|
const sec = Math.round(ms / 1000);
|
|
if (sec < 60) return `in ${sec}s`;
|
|
const min = Math.round(sec / 60);
|
|
if (min < 60) return `in ${min}m`;
|
|
const hr = Math.floor(min / 60);
|
|
const rem = min % 60;
|
|
if (hr < 24) return rem ? `in ${hr}h ${rem}m` : `in ${hr}h`;
|
|
const days = Math.floor(hr / 24);
|
|
return `in ${days}d ${hr % 24}h`;
|
|
}
|
|
|
|
async function refresh() {
|
|
let data;
|
|
try { data = await api("/api/backup/list"); }
|
|
catch { return; }
|
|
|
|
els.dir.textContent = data.dir || "--";
|
|
// Server returns a human description ("Daily at 04:00", "Every 6 hours", "Disabled").
|
|
els.schedule.textContent = data.description || (data.schedule ? `Daily at ${data.schedule}` : "Disabled");
|
|
els.next.textContent = data.nextRun ? fmtRelativeFuture(data.nextRun) : "--";
|
|
els.keep.textContent = data.keep != null ? `${data.keep} latest` : "--";
|
|
lastSchedule = data.schedule || "";
|
|
lastKeep = data.keep ?? 14;
|
|
|
|
// Right-sidebar badge: count of backups
|
|
const badge = document.getElementById("bkpBadge");
|
|
if (badge) badge.textContent = data.backups?.length ? `${data.backups.length}` : "0";
|
|
|
|
if (!data.backups || data.backups.length === 0) {
|
|
els.list.innerHTML = '<li class="empty-state">No backups yet</li>';
|
|
return;
|
|
}
|
|
|
|
els.list.innerHTML = data.backups.map(b => `
|
|
<li class="backup-item">
|
|
<div class="backup-meta">
|
|
<div class="backup-name">${escape(b.name)}</div>
|
|
<div class="backup-sub">${fmtSize(b.sizeBytes)} · ${fmtDate(b.createdAt)}</div>
|
|
</div>
|
|
<div class="backup-actions">
|
|
<button class="ghost-btn bkp-restore" data-name="${escape(b.name)}">Restore</button>
|
|
<button class="ghost-btn bkp-delete" data-name="${escape(b.name)}">Delete</button>
|
|
</div>
|
|
</li>
|
|
`).join("");
|
|
}
|
|
|
|
function escape(s) {
|
|
return String(s).replace(/[&<>"']/g, c =>
|
|
({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
|
}
|
|
|
|
function showMsg(text, ok = false) {
|
|
els.msg.className = ok ? "acct-msg ok" : "acct-msg";
|
|
els.msg.textContent = text;
|
|
}
|
|
|
|
async function postJson(path, body) {
|
|
const res = await fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return { ok: res.ok, status: res.status, body: await res.json().catch(() => ({})) };
|
|
}
|
|
|
|
export function setupBackup() {
|
|
els.dir = document.getElementById("backupDir");
|
|
els.list = document.getElementById("bkpList");
|
|
els.create = document.getElementById("bkpCreate");
|
|
els.msg = document.getElementById("bkpMsg");
|
|
els.schedule = document.getElementById("backupSchedule");
|
|
els.next = document.getElementById("backupNext");
|
|
els.keep = document.getElementById("backupKeep");
|
|
els.editBtn = document.getElementById("bkpEditSchedule");
|
|
els.form = document.getElementById("bkpScheduleForm");
|
|
els.input = document.getElementById("bkpScheduleInput");
|
|
els.keepInput = document.getElementById("bkpKeepInput");
|
|
els.saveBtn = document.getElementById("bkpScheduleSave");
|
|
els.cancelBtn = document.getElementById("bkpScheduleCancel");
|
|
if (!els.create) return;
|
|
|
|
els.editBtn?.addEventListener("click", () => {
|
|
els.form.hidden = !els.form.hidden;
|
|
if (!els.form.hidden) {
|
|
els.input.value = lastSchedule || "";
|
|
els.keepInput.value = lastKeep ?? 14;
|
|
els.input.focus();
|
|
}
|
|
});
|
|
|
|
els.cancelBtn?.addEventListener("click", () => {
|
|
els.form.hidden = true;
|
|
showMsg("");
|
|
});
|
|
|
|
els.saveBtn?.addEventListener("click", async () => {
|
|
const sched = els.input.value.trim();
|
|
const keep = parseInt(els.keepInput.value, 10);
|
|
const r = await postJson("/api/backup/schedule", {
|
|
schedule: sched,
|
|
keep: Number.isFinite(keep) ? keep : undefined,
|
|
});
|
|
if (!r.ok || r.body.ok === false) {
|
|
showMsg(r.body.error || `Error ${r.status}`);
|
|
} else {
|
|
showMsg(sched ? `Schedule saved: daily at ${sched}` : "Schedule disabled.", true);
|
|
els.form.hidden = true;
|
|
refresh();
|
|
}
|
|
});
|
|
|
|
els.create.addEventListener("click", async () => {
|
|
const reason = prompt("Optional reason / label for this backup (e.g. 'pre-update'). Leave blank for none:");
|
|
if (reason === null) return; // user cancelled
|
|
showMsg("Creating backup -- this may take a minute on a large world...");
|
|
els.create.disabled = true;
|
|
const r = await postJson("/api/backup/create", { reason: reason.trim() || null });
|
|
els.create.disabled = false;
|
|
if (!r.ok || r.body.ok === false) {
|
|
showMsg(r.body.error || `Error ${r.status}`);
|
|
} else {
|
|
showMsg(`Backup created: ${r.body.name} (${fmtSize(r.body.sizeBytes)})`, true);
|
|
refresh();
|
|
}
|
|
});
|
|
|
|
els.list.addEventListener("click", async e => {
|
|
const restore = e.target.closest(".bkp-restore");
|
|
const del = e.target.closest(".bkp-delete");
|
|
if (restore) {
|
|
const name = restore.dataset.name;
|
|
if (!confirm(`Restore from ${name}?\n\nServer will stop, current world is moved to a "-prerestore" folder for safety, then the backup is extracted and server starts again.`))
|
|
return;
|
|
showMsg("Restoring -- this stops the server...");
|
|
const r = await postJson("/api/backup/restore", { name });
|
|
if (!r.ok || r.body.ok === false) showMsg(r.body.error || `Error ${r.status}`);
|
|
else showMsg("Restore complete. Server is starting.", true);
|
|
}
|
|
if (del) {
|
|
const name = del.dataset.name;
|
|
if (!confirm(`Delete backup ${name}? This cannot be undone.`)) return;
|
|
const r = await postJson("/api/backup/delete", { name });
|
|
if (!r.ok || r.body.ok === false) showMsg(r.body.error || `Error ${r.status}`);
|
|
else { showMsg("Deleted.", true); refresh(); }
|
|
}
|
|
});
|
|
|
|
refresh();
|
|
// Backups don't change often; light poll to pick up new ones if scheduled
|
|
// backups are added later, or just to refresh after an external mv/rm.
|
|
setInterval(refresh, 30000);
|
|
}
|