Initial commit: Brass & Sigil monorepo
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.
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user