From d59b8789065eccbd56ec2d732e38fce07fd64fa9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 03:18:02 +0000 Subject: [PATCH 1/2] Initial plan From d864402de22b8fb9059ae7d44acbbf7e6127bdda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 03:24:07 +0000 Subject: [PATCH 2/2] feat: Add Manual Backup button in Hub sidebar with drive detection and progress streaming Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/14dc5955-19b2-4e5b-965a-2795285a22fd Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- .../scripts/sovran-hub-backup.sh | 173 ++++++++++++++ app/sovran_systemsos_web/server.py | 124 ++++++++++ .../static/css/support.css | 14 ++ app/sovran_systemsos_web/static/js/support.js | 212 ++++++++++++++++++ app/sovran_systemsos_web/static/js/tiles.js | 21 +- 5 files changed, 539 insertions(+), 5 deletions(-) create mode 100755 app/sovran_systemsos_web/scripts/sovran-hub-backup.sh diff --git a/app/sovran_systemsos_web/scripts/sovran-hub-backup.sh b/app/sovran_systemsos_web/scripts/sovran-hub-backup.sh new file mode 100755 index 0000000..2627f2b --- /dev/null +++ b/app/sovran_systemsos_web/scripts/sovran-hub-backup.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# ── Sovran Hub Backup Script ───────────────────────────────────── +# Backs up Sovran_SystemsOS data to an external USB hard drive. +# Designed for the Hub web UI (no GUI dependencies). +# +# Usage: +# BACKUP_TARGET=/run/media// bash sovran-hub-backup.sh +# (or run with no env var to auto-detect the first external drive) + +set -euo pipefail + +BACKUP_LOG="/var/log/sovran-hub-backup.log" +BACKUP_STATUS="/var/log/sovran-hub-backup.status" +MEDIA_ROOT="/run/media" +MIN_FREE_GB=10 + +# ── Logging helpers ────────────────────────────────────────────── + +log() { + local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*" + echo "$msg" | tee -a "$BACKUP_LOG" +} + +set_status() { + echo "$1" > "$BACKUP_STATUS" +} + +fail() { + log "ERROR: $*" + set_status "FAILED" + exit 1 +} + +# ── Initialise log file ────────────────────────────────────────── + +: > "$BACKUP_LOG" +set_status "RUNNING" + +log "=== Sovran_SystemsOS Hub Backup ===" +log "Starting backup process…" + +# ── Detect target drive ────────────────────────────────────────── + +if [[ -n "${BACKUP_TARGET:-}" ]]; then + TARGET="$BACKUP_TARGET" + log "Using specified backup target: $TARGET" +else + log "Auto-detecting external drives under $MEDIA_ROOT …" + TARGET="" + if [[ -d "$MEDIA_ROOT" ]]; then + # Walk /run/media// + while IFS= read -r -d '' mnt; do + if mountpoint -q "$mnt" 2>/dev/null; then + TARGET="$mnt" + break + fi + done < <(find "$MEDIA_ROOT" -mindepth 2 -maxdepth 2 -type d -print0 2>/dev/null) + fi + if [[ -z "$TARGET" ]]; then + fail "No external drive detected under $MEDIA_ROOT. " \ + "Please plug in an exFAT-formatted USB drive (≥500 GB) and try again." + fi + log "Detected external drive: $TARGET" +fi + +# ── Verify mount point ─────────────────────────────────────────── + +[[ -d "$TARGET" ]] || fail "Target path '$TARGET' does not exist." +mountpoint -q "$TARGET" || fail "Target path '$TARGET' is not a mount point." + +# ── Check free disk space (require ≥ 10 GB) ────────────────────── + +FREE_KB=$(df -k --output=avail "$TARGET" | tail -1) +FREE_GB=$(( FREE_KB / 1024 / 1024 )) +log "Free space on drive: ${FREE_GB} GB" +(( FREE_GB >= MIN_FREE_GB )) || \ + fail "Not enough free space on drive (${FREE_GB} GB available, ${MIN_FREE_GB} GB required)." + +# ── Create timestamped backup directory ───────────────────────── + +TIMESTAMP="$(date '+%Y%m%d_%H%M%S')" +BACKUP_DIR="${TARGET}/Sovran_SystemsOS_Backup/${TIMESTAMP}" +mkdir -p "$BACKUP_DIR" +log "Backup destination: $BACKUP_DIR" + +# ── Stage 1/4: NixOS configuration ────────────────────────────── + +log "" +log "── Stage 1/4: NixOS configuration (/etc/nixos) ──────────────" +if [[ -d /etc/nixos ]]; then + rsync -a --info=progress2 /etc/nixos/ "$BACKUP_DIR/nixos/" 2>&1 | tee -a "$BACKUP_LOG" || \ + fail "Stage 1 failed while copying /etc/nixos" + log "Stage 1 complete." +else + log "WARNING: /etc/nixos not found — skipping." +fi + +# ── Stage 2/4: Secrets ────────────────────────────────────────── + +log "" +log "── Stage 2/4: Secrets ───────────────────────────────────────" +mkdir -p "$BACKUP_DIR/secrets" + +for SRC in /etc/nix-bitcoin-secrets /var/lib/domains; do + if [[ -e "$SRC" ]]; then + rsync -a --info=progress2 "$SRC" "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \ + log "WARNING: Could not copy $SRC — continuing." + else + log " (not found: $SRC — skipping)" + fi +done + +# Hub state files from /var/lib/secrets/ +if [[ -d /var/lib/secrets ]]; then + mkdir -p "$BACKUP_DIR/secrets/hub-state" + rsync -a --info=progress2 /var/lib/secrets/ "$BACKUP_DIR/secrets/hub-state/" 2>&1 | tee -a "$BACKUP_LOG" || \ + log "WARNING: Could not copy /var/lib/secrets — continuing." +else + log " (not found: /var/lib/secrets — skipping)" +fi + +log "Stage 2 complete." + +# ── Stage 3/4: Home directory ──────────────────────────────────── + +log "" +log "── Stage 3/4: Home directory (/home) ───────────────────────" +if [[ -d /home ]]; then + rsync -a --info=progress2 \ + --exclude='.cache/' \ + --exclude='.local/share/Trash/' \ + --exclude='*/Trash/' \ + /home/ "$BACKUP_DIR/home/" 2>&1 | tee -a "$BACKUP_LOG" || \ + fail "Stage 3 failed while copying /home" + log "Stage 3 complete." +else + log "WARNING: /home not found — skipping." +fi + +# ── Stage 4/4: Wallet and node data ───────────────────────────── + +log "" +log "── Stage 4/4: Wallet and node data (/var/lib/lnd) ──────────" +if [[ -d /var/lib/lnd ]]; then + rsync -a --info=progress2 \ + --exclude='logs/' \ + /var/lib/lnd/ "$BACKUP_DIR/lnd/" 2>&1 | tee -a "$BACKUP_LOG" || \ + fail "Stage 4 failed while copying /var/lib/lnd" + log "Stage 4 complete." +else + log "WARNING: /var/lib/lnd not found — skipping." +fi + +# ── Generate manifest ──────────────────────────────────────────── + +log "" +log "Generating BACKUP_MANIFEST.txt …" +{ + echo "Sovran_SystemsOS Backup Manifest" + echo "Generated: $(date)" + echo "Hostname: $(hostname)" + echo "Target: $TARGET" + echo "" + echo "Contents:" + find "$BACKUP_DIR" -mindepth 1 -maxdepth 2 | sort +} > "$BACKUP_DIR/BACKUP_MANIFEST.txt" +log "Manifest written to $BACKUP_DIR/BACKUP_MANIFEST.txt" + +# ── Done ───────────────────────────────────────────────────────── + +log "" +log "All Finished! Please eject the drive before removing it from your Sovran Pro." +set_status "SUCCESS" diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index b37132d..4babf70 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -40,6 +40,10 @@ REBUILD_LOG = "/var/log/sovran-hub-rebuild.log" REBUILD_STATUS = "/var/log/sovran-hub-rebuild.status" REBUILD_UNIT = "sovran-hub-rebuild.service" +BACKUP_LOG = "/var/log/sovran-hub-backup.log" +BACKUP_STATUS = "/var/log/sovran-hub-backup.status" +BACKUP_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts", "sovran-hub-backup.sh") + CUSTOM_NIX = "/etc/nixos/custom.nix" HUB_BEGIN = " # ── Hub Managed (do not edit) ──────────────" HUB_END = " # ── End Hub Managed ────────────────────────" @@ -743,6 +747,66 @@ def _read_rebuild_log(offset: int = 0) -> tuple[str, int]: return "", 0 +# ── Backup helpers ──────────────────────────────────────────────── + +def _read_backup_status() -> str: + """Read the backup status file. Returns RUNNING, SUCCESS, FAILED, or IDLE.""" + try: + with open(BACKUP_STATUS, "r") as f: + return f.read().strip() + except FileNotFoundError: + return "IDLE" + + +def _read_backup_log(offset: int = 0) -> tuple[str, int]: + """Read the backup log file from the given byte offset. + Returns (new_text, new_offset).""" + try: + with open(BACKUP_LOG, "rb") as f: + f.seek(0, 2) + size = f.tell() + if offset > size: + offset = 0 + f.seek(offset) + chunk = f.read() + return chunk.decode(errors="replace"), offset + len(chunk) + except FileNotFoundError: + return "", 0 + + +def _detect_external_drives() -> list[dict]: + """Scan /run/media/ for mounted external drives. + Returns a list of dicts with name, path, free_gb, total_gb.""" + drives = [] + media_root = "/run/media" + if not os.path.isdir(media_root): + return drives + try: + for user in os.listdir(media_root): + user_path = os.path.join(media_root, user) + if not os.path.isdir(user_path): + continue + for drive in os.listdir(user_path): + drive_path = os.path.join(user_path, drive) + if not os.path.isdir(drive_path): + continue + try: + st = os.statvfs(drive_path) + total_gb = round((st.f_blocks * st.f_frsize) / (1024 ** 3), 1) + free_gb = round((st.f_bavail * st.f_frsize) / (1024 ** 3), 1) + drives.append({ + "name": drive, + "path": drive_path, + "free_gb": free_gb, + "total_gb": total_gb, + }) + except OSError: + pass + except OSError: + pass + return drives + + # ── custom.nix Hub Managed section helpers ──────────────────────── def _read_hub_overrides() -> tuple[dict, str | None]: @@ -1856,6 +1920,66 @@ async def api_support_audit_log(limit: int = 100): return {"entries": lines} +# ── Backup endpoints ────────────────────────────────────────────── + +@app.get("/api/backup/status") +async def api_backup_status(offset: int = 0): + """Poll endpoint: reads backup status file + log file.""" + loop = asyncio.get_event_loop() + status = await loop.run_in_executor(None, _read_backup_status) + new_log, new_offset = await loop.run_in_executor(None, _read_backup_log, offset) + running = (status == "RUNNING") + result = "pending" if running else status.lower() + return { + "running": running, + "result": result, + "log": new_log, + "offset": new_offset, + } + + +@app.get("/api/backup/drives") +async def api_backup_drives(): + """Return a list of detected external drives under /run/media/.""" + loop = asyncio.get_event_loop() + drives = await loop.run_in_executor(None, _detect_external_drives) + return {"drives": drives} + + +@app.post("/api/backup/run") +async def api_backup_run(target: str = ""): + """Start the backup script as a background subprocess. + Returns immediately; progress is read via /api/backup/status. + """ + loop = asyncio.get_event_loop() + status = await loop.run_in_executor(None, _read_backup_status) + if status == "RUNNING": + return {"ok": True, "status": "already_running"} + + # Clear stale log before starting + try: + with open(BACKUP_LOG, "w") as f: + f.write("") + except OSError: + pass + + env = dict(os.environ) + if target: + env["BACKUP_TARGET"] = target + + # Fire-and-forget: the script writes its own status/log files. + # Progress is read by the client via /api/backup/status (same pattern + # as /api/updates/run and the rebuild feature). + await asyncio.create_subprocess_exec( + "/usr/bin/env", "bash", BACKUP_SCRIPT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + env=env, + ) + + return {"ok": True, "status": "started"} + + # ── Feature Manager endpoints ───────────────────────────────────── @app.get("/api/features") diff --git a/app/sovran_systemsos_web/static/css/support.css b/app/sovran_systemsos_web/static/css/support.css index d47d068..e081579 100644 --- a/app/sovran_systemsos_web/static/css/support.css +++ b/app/sovran_systemsos_web/static/css/support.css @@ -360,3 +360,17 @@ border-color: #a8c8ff; border-style: solid; } + +/* ── Manual Backup ───────────────────────────────────────────────── */ + +.support-backup-steps { + padding-left: 20px; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.8; + margin: 8px 0 0 0; +} + +.support-backup-steps li { + margin-bottom: 4px; +} diff --git a/app/sovran_systemsos_web/static/js/support.js b/app/sovran_systemsos_web/static/js/support.js index 039f5bf..c0e816e 100644 --- a/app/sovran_systemsos_web/static/js/support.js +++ b/app/sovran_systemsos_web/static/js/support.js @@ -259,3 +259,215 @@ function closeSupportModal() { stopSupportTimer(); stopWalletUnlockTimer(); } + +// ── Manual Backup modal ─────────────────────────────────────────── + +var _backupPollTimer = null; +var _backupLogOffset = 0; + +function openBackupModal() { + if (!$supportModal) return; + $supportModal.classList.add("open"); + $supportBody.innerHTML = '

Detecting external drives\u2026

'; + detectDrivesAndRender(); +} + +async function detectDrivesAndRender() { + try { + // Check whether a backup is already in progress + var status = await apiFetch("/api/backup/status?offset=0"); + if (status.running) { + renderBackupRunning(); + _backupLogOffset = status.offset || 0; + if (status.log) { + var logDiv = document.getElementById("backup-log"); + if (logDiv) { logDiv.insertAdjacentText("beforeend", status.log); logDiv.scrollTop = logDiv.scrollHeight; } + } + startBackupPoll(); + return; + } + } catch (_) {} + + try { + var data = await apiFetch("/api/backup/drives"); + renderBackupReady(data.drives || []); + } catch (err) { + $supportBody.innerHTML = '

Could not detect drives. Please try again.

'; + } +} + +function renderBackupReady(drives) { + var driveSelector = ""; + if (drives.length > 0) { + driveSelector = [ + '', + ''; + driveSelector += ''; + } else { + driveSelector = [ + '
', + '
', + '\u26a0\ufe0f', + 'No External Drive Detected', + '
', + '

', + 'No USB drive was found under /run/media/. ', + 'Make sure the drive is plugged in and mounted, then click Refresh.', + '

', + '
', + '', + ].join(""); + } + + $supportBody.innerHTML = [ + '
', + '
\ud83d\udcbe
', + '

Manual Backup

', + '

Back up your Sovran_SystemsOS data to an external USB hard drive.

', + + '
', + '
Requirements
', + '
    ', + '
  1. USB hard drive plugged into one of the open USB ports on your Sovran Pro
  2. ', + '
  3. At least 500 GB of free space on the drive
  4. ', + '
  5. Drive must be formatted as exFAT
  6. ', + '
', + '
', + + '
', + '
What gets backed up
', + '
    ', + '
  1. NixOS configuration (/etc/nixos)
  2. ', + '
  3. Bitcoin & Lightning wallet data (/var/lib/lnd)
  4. ', + '
  5. nix-bitcoin secrets (/etc/nix-bitcoin-secrets)
  6. ', + '
  7. Domain configurations (/var/lib/domains)
  8. ', + '
  9. Home directory (/home)
  10. ', + '
', + '
', + + '
', + '
', + '\u23f1\ufe0f', + 'Time Estimate', + '
', + '

This backup can take up to 4 hours depending on the amount of data stored on your Sovran Pro and the speed of your external hard drive. Be patient\u2026

', + '
', + + driveSelector, + '
', + ].join(""); + + if (drives.length > 0) { + document.getElementById("btn-start-backup").addEventListener("click", startBackup); + } else { + document.getElementById("btn-backup-refresh").addEventListener("click", function() { + $supportBody.innerHTML = '

Detecting external drives\u2026

'; + detectDrivesAndRender(); + }); + } +} + +async function startBackup() { + var btn = document.getElementById("btn-start-backup"); + if (btn) { btn.disabled = true; btn.textContent = "Starting\u2026"; } + var sel = document.getElementById("backup-drive-select"); + var target = sel ? sel.value : ""; + try { + _backupLogOffset = 0; + await apiFetch("/api/backup/run" + (target ? "?target=" + encodeURIComponent(target) : ""), { method: "POST" }); + renderBackupRunning(); + startBackupPoll(); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "Start Backup"; } + alert("Failed to start backup: " + (err.message || "Unknown error")); + } +} + +function renderBackupRunning() { + $supportBody.innerHTML = [ + '
', + '
\ud83d\udcbe
', + '

Backup In Progress

', + '
', + '
', + '\u26a0\ufe0f', + 'Do Not Unplug', + '
', + '

Do not remove the USB drive while the backup is running. This could corrupt the backup and your drive.

', + '
', + '', + '
', + ].join(""); +} + +function startBackupPoll() { + stopBackupPoll(); + _backupPollTimer = setInterval(pollBackupStatus, 2000); + pollBackupStatus(); +} + +function stopBackupPoll() { + if (_backupPollTimer) { clearInterval(_backupPollTimer); _backupPollTimer = null; } +} + +async function pollBackupStatus() { + try { + var data = await apiFetch("/api/backup/status?offset=" + _backupLogOffset); + var logDiv = document.getElementById("backup-log"); + if (logDiv && data.log) { + logDiv.insertAdjacentText("beforeend", data.log); + logDiv.scrollTop = logDiv.scrollHeight; + } + _backupLogOffset = data.offset; + if (!data.running) { + stopBackupPoll(); + renderBackupDone(data.result === "success"); + } + } catch (_) {} +} + +function renderBackupDone(success) { + var logDiv = document.getElementById("backup-log"); + var logContent = logDiv ? logDiv.textContent : ""; + + if (success) { + $supportBody.innerHTML = [ + '
', + '
\u2705
', + '

All Finished!

', + '
', + '
', + '\u23cf\ufe0f', + 'Eject Your Drive', + '
', + '

Please eject the drive before removing it from your Sovran Pro.

', + '
', + '', + '', + '
', + ].join(""); + var doneLog = document.getElementById("backup-log-done"); + if (doneLog) { doneLog.textContent = logContent; doneLog.scrollTop = doneLog.scrollHeight; } + } else { + $supportBody.innerHTML = [ + '
', + '
\u26a0\ufe0f
', + '

Backup Failed

', + '

The backup did not complete successfully. Please check that the USB drive is still connected, has enough free space, and is formatted as exFAT. Then try again.

', + '', + '', + '
', + ].join(""); + var failLog = document.getElementById("backup-log-fail"); + if (failLog) { failLog.textContent = logContent; failLog.scrollTop = failLog.scrollHeight; } + } + document.getElementById("btn-backup-close").addEventListener("click", closeSupportModal); +} diff --git a/app/sovran_systemsos_web/static/js/tiles.js b/app/sovran_systemsos_web/static/js/tiles.js index b792b60..1bf7dad 100644 --- a/app/sovran_systemsos_web/static/js/tiles.js +++ b/app/sovran_systemsos_web/static/js/tiles.js @@ -58,11 +58,22 @@ function renderSidebarSupport(supportServices) { btn.addEventListener("click", function() { openSupportModal(); }); $sidebarSupport.appendChild(btn); } - if (supportServices.length > 0) { - var hr = document.createElement("hr"); - hr.className = "sidebar-divider"; - $sidebarSupport.appendChild(hr); - } + + // ── Manual Backup button + var backupBtn = document.createElement("button"); + backupBtn.className = "sidebar-support-btn"; + backupBtn.innerHTML = + '💾' + + '' + + 'Manual Backup' + + 'Back up to external drive' + + ''; + backupBtn.addEventListener("click", function() { openBackupModal(); }); + $sidebarSupport.appendChild(backupBtn); + + var hr = document.createElement("hr"); + hr.className = "sidebar-divider"; + $sidebarSupport.appendChild(hr); } function buildTile(svc) {