// retoor import { Http } from "./Http.js"; import { Poller } from "./Poller.js"; import { JobPoller } from "./JobPoller.js"; import { DateFormat } from "./DateFormat.js"; class BackupMonitor { constructor() { this.root = document.getElementById("backups-root"); this.generated = document.querySelector("[data-meta='generated']"); this.runForm = document.getElementById("backup-run-form"); this.scheduleForm = document.getElementById("backup-schedule-form"); this.scheduleModal = document.getElementById("backup-schedule-modal"); this.scheduleKind = document.getElementById("backup-schedule-kind"); this.scheduleTitle = document.getElementById("backup-schedule-title"); this.pollMs = 30000; this.targets = []; this.canDownload = false; } start() { if (!this.root) return; this.bindRunForm(); this.bindScheduleForm(); this.bindActions(); this.subscribe(); this.poller = new Poller(() => this.poll(), this.pollMs, { pauseHidden: true }); } subscribe() { const pubsub = window.app && window.app.pubsub; if (!pubsub) return; if (this.unsubscribe) this.unsubscribe(); this.unsubscribe = pubsub.subscribe("admin.backups", (data) => this.applyPush(data)); } applyPush(data) { this.targets = data.targets || this.targets; this.render(data); } async poll() { try { const data = await Http.getJson("/admin/backups/data"); this.canDownload = !!data.can_download_backups; this.targets = data.targets || []; this.render(data); } catch { // ignore transient poll errors } } bindRunForm() { if (!this.runForm) return; this.runForm.addEventListener("submit", async (event) => { event.preventDefault(); const target = this.runForm.querySelector("[name='target']").value; const button = this.runForm.querySelector("button[type='submit']"); button.disabled = true; try { const result = await Http.send("/admin/backups/run", { target }); window.app.toast.show("Backup started", { type: "info" }); this.followJob(result.status_url); } catch { // Http.send already surfaced the error } finally { button.disabled = false; } }); } followJob(statusUrl) { if (!statusUrl) return; this.poll(); JobPoller.run(statusUrl, { onDone: () => { window.app.toast.show("Backup completed", { type: "success" }); this.poll(); }, onFailed: (status) => { Http.notifyError((status && status.error) || "Backup failed"); this.poll(); }, }); } bindScheduleForm() { if (!this.scheduleForm) return; const newButton = document.getElementById("backup-schedule-new"); if (newButton) { newButton.addEventListener("click", () => this.resetScheduleForm()); } if (this.scheduleKind) { this.scheduleKind.addEventListener("change", () => this.syncScheduleKind()); } this.scheduleForm.addEventListener("submit", async (event) => { event.preventDefault(); const params = Object.fromEntries(new FormData(this.scheduleForm).entries()); try { await Http.send(this.scheduleForm.action, params); this.scheduleModal.classList.remove("visible"); window.app.toast.show("Schedule saved", { type: "success" }); this.poll(); } catch { // error already surfaced } }); } syncScheduleKind() { const kind = this.scheduleKind.value; this.scheduleForm.querySelectorAll("[data-kind]").forEach((node) => { node.hidden = node.dataset.kind !== kind; }); } resetScheduleForm() { this.scheduleForm.reset(); this.scheduleForm.action = "/admin/backups/schedules/create"; this.scheduleTitle.textContent = "New backup schedule"; this.syncScheduleKind(); } openScheduleEdit(schedule) { const form = this.scheduleForm; form.action = `/admin/backups/schedules/${schedule.uid}/edit`; form.querySelector("[name='name']").value = schedule.name || ""; form.querySelector("[name='target']").value = schedule.target || "full"; form.querySelector("[name='kind']").value = schedule.kind || "interval"; form.querySelector("[name='every_seconds']").value = schedule.every_seconds || 86400; form.querySelector("[name='cron']").value = schedule.cron || ""; form.querySelector("[name='keep_last']").value = schedule.keep_last || 0; this.scheduleTitle.textContent = `Edit schedule: ${schedule.name || ""}`; this.syncScheduleKind(); this.scheduleModal.classList.add("visible"); } bindActions() { this.root.addEventListener("click", async (event) => { const button = event.target.closest("[data-action]"); if (!button) return; const action = button.dataset.action; const uid = button.dataset.uid; if (action === "edit-schedule") { const schedule = this.schedulesByUid[uid]; if (schedule) this.openScheduleEdit(schedule); return; } const prompts = { "delete-backup": "Permanently delete this backup archive?", "delete-schedule": "Delete this backup schedule?", "toggle-schedule": null, "run-schedule": null, }; const message = prompts[action]; if (message) { const ok = await window.app.dialog.confirm({ message, danger: true }); if (!ok) return; } const urls = { "delete-backup": `/admin/backups/${uid}/delete`, "delete-schedule": `/admin/backups/schedules/${uid}/delete`, "toggle-schedule": `/admin/backups/schedules/${uid}/toggle`, "run-schedule": `/admin/backups/schedules/${uid}/run`, }; try { await Http.send(urls[action], {}); this.poll(); } catch { // error already surfaced } }); } el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = String(text); return node; } cards(items) { const grid = this.el("div", "metric-cards"); for (const item of items) { const card = this.el("div", "metric-card"); card.appendChild(this.el("span", "metric-value", item.value)); card.appendChild(this.el("span", "metric-label", item.label)); grid.appendChild(card); } return grid; } section(title, ...nodes) { const wrap = this.el("div", "backups-section"); wrap.appendChild(this.el("h3", null, title)); for (const node of nodes) if (node) wrap.appendChild(node); return wrap; } storageSection(storage) { const disk = storage.disk || {}; const totals = storage.backups_total || {}; const data = storage.data_dir || {}; const overview = this.cards([ { label: "Disk used", value: `${disk.used_human || "0"} / ${disk.total_human || "0"}` }, { label: "Disk free", value: disk.free_human || "0" }, { label: "Disk usage", value: `${disk.used_percent || 0}%` }, { label: "Data directory", value: data.size_human || "0" }, { label: "Backups stored", value: totals.count || 0 }, { label: "Backups size", value: totals.size_human || "0" }, ]); const table = this.el("table", "metric-table"); const head = this.el("thead"); const headRow = this.el("tr"); for (const col of ["Path", "Size", "Files", "Location"]) headRow.appendChild(this.el("th", null, col)); head.appendChild(headRow); table.appendChild(head); const body = this.el("tbody"); for (const path of storage.paths || []) { const tr = this.el("tr"); tr.appendChild(this.el("td", null, path.label)); tr.appendChild(this.el("td", null, path.size_human)); tr.appendChild(this.el("td", null, path.file_count)); const location = this.el("td", "backups-path", path.path); if (!path.exists) location.classList.add("backups-missing"); tr.appendChild(location); body.appendChild(tr); } table.appendChild(body); return this.section("Storage", overview, table); } backupsSection(backups, metrics) { const overview = this.cards([ { label: "Total", value: metrics.total || 0 }, { label: "Done", value: metrics.done || 0 }, { label: "Running", value: metrics.running || 0 }, { label: "Pending", value: metrics.pending || 0 }, { label: "Failed", value: metrics.failed || 0 }, { label: "Archive size", value: metrics.size_human || "0" }, ]); if (!backups.length) { return this.section("Backups", overview, this.el("p", "admin-empty", "No backups yet.")); } const table = this.el("table", "metric-table backups-table"); const head = this.el("thead"); const headRow = this.el("tr"); for (const col of ["Created", "Target", "Status", "Size", "Files", "Checksum", "Actions"]) { headRow.appendChild(this.el("th", null, col)); } head.appendChild(headRow); table.appendChild(head); const body = this.el("tbody"); for (const backup of backups) { const tr = this.el("tr"); tr.appendChild(this.el("td", null, DateFormat.format(backup.created_at, true))); tr.appendChild(this.el("td", null, backup.label || backup.target)); const status = this.el("td"); status.appendChild(this.el("span", `backups-status backups-status-${backup.status}`, backup.status)); tr.appendChild(status); tr.appendChild(this.el("td", null, backup.size_human || "-")); tr.appendChild(this.el("td", null, backup.file_count || 0)); tr.appendChild(this.el("td", "backups-sha", backup.sha256 ? backup.sha256.slice(0, 12) : "-")); tr.appendChild(this.backupActions(backup)); body.appendChild(tr); } table.appendChild(body); return this.section("Backups", overview, table); } backupActions(backup) { const cell = this.el("td", "backups-actions"); if (backup.status === "done") { if (this.canDownload) { const link = this.el("a", "admin-btn admin-btn-sm", "Download"); link.href = `/admin/backups/${backup.uid}/download`; cell.appendChild(link); } else { const blocked = this.el("button", "admin-btn admin-btn-sm", "Download"); blocked.disabled = true; blocked.setAttribute("aria-disabled", "true"); blocked.title = "Not available"; cell.appendChild(blocked); } } const remove = this.el("button", "admin-btn admin-btn-sm admin-btn-danger", "Delete"); remove.dataset.action = "delete-backup"; remove.dataset.uid = backup.uid; cell.appendChild(remove); return cell; } schedulesSection(schedules) { this.schedulesByUid = {}; for (const schedule of schedules) this.schedulesByUid[schedule.uid] = schedule; if (!schedules.length) { return this.section("Schedules", this.el("p", "admin-empty", "No backup schedules configured.")); } const table = this.el("table", "metric-table backups-schedules"); const head = this.el("thead"); const headRow = this.el("tr"); for (const col of ["Name", "Target", "Trigger", "Keep", "Next run", "Last run", "Runs", "State", "Actions"]) { headRow.appendChild(this.el("th", null, col)); } head.appendChild(headRow); table.appendChild(head); const body = this.el("tbody"); for (const schedule of schedules) { const tr = this.el("tr"); tr.appendChild(this.el("td", null, schedule.name)); tr.appendChild(this.el("td", null, schedule.target)); const trigger = schedule.kind === "cron" ? `cron ${schedule.cron}` : `every ${schedule.every_seconds}s`; tr.appendChild(this.el("td", null, trigger)); tr.appendChild(this.el("td", null, schedule.keep_last || "all")); tr.appendChild(this.el("td", null, schedule.next_run_at ? DateFormat.format(schedule.next_run_at, true) : "-")); tr.appendChild(this.el("td", null, schedule.last_run_at ? DateFormat.format(schedule.last_run_at, true) : "-")); tr.appendChild(this.el("td", null, schedule.run_count || 0)); const state = this.el("td"); state.appendChild(this.el("span", `backups-status backups-status-${schedule.enabled ? "done" : "failed"}`, schedule.enabled ? "enabled" : "disabled")); tr.appendChild(state); tr.appendChild(this.scheduleActions(schedule)); body.appendChild(tr); } table.appendChild(body); return this.section("Schedules", table); } scheduleActions(schedule) { const cell = this.el("td", "backups-actions"); const definitions = [ ["run-schedule", "Run", ""], ["edit-schedule", "Edit", ""], ["toggle-schedule", schedule.enabled ? "Disable" : "Enable", ""], ["delete-schedule", "Delete", "admin-btn-danger"], ]; for (const [action, label, extra] of definitions) { const button = this.el("button", `admin-btn admin-btn-sm ${extra}`.trim(), label); button.dataset.action = action; button.dataset.uid = schedule.uid; cell.appendChild(button); } return cell; } render(data) { if (this.generated && data.generated_at) { this.generated.textContent = `Updated ${DateFormat.format(data.generated_at, true)}`; } this.root.textContent = ""; this.root.appendChild(this.storageSection(data.storage || {})); this.root.appendChild(this.backupsSection(data.backups || [], data.metrics || {})); this.root.appendChild(this.schedulesSection(data.schedules || [])); } } window.BackupMonitor = BackupMonitor;