// 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 = '
  • No backups yet
  • '; return; } els.list.innerHTML = data.backups.map(b => `
  • ${escape(b.name)}
    ${fmtSize(b.sizeBytes)} ยท ${fmtDate(b.createdAt)}
  • `).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); }