Merge pull request #58 from naturallaw777/copilot/add-manual-backup-button
[WIP] Add manual backup button in Hub sidebar for Sovran_SystemsOS
This commit is contained in:
173
app/sovran_systemsos_web/scripts/sovran-hub-backup.sh
Executable file
173
app/sovran_systemsos_web/scripts/sovran-hub-backup.sh
Executable file
@@ -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/<user>/<drive> 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/<user>/<drive>
|
||||||
|
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"
|
||||||
@@ -40,6 +40,10 @@ REBUILD_LOG = "/var/log/sovran-hub-rebuild.log"
|
|||||||
REBUILD_STATUS = "/var/log/sovran-hub-rebuild.status"
|
REBUILD_STATUS = "/var/log/sovran-hub-rebuild.status"
|
||||||
REBUILD_UNIT = "sovran-hub-rebuild.service"
|
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"
|
CUSTOM_NIX = "/etc/nixos/custom.nix"
|
||||||
HUB_BEGIN = " # ── Hub Managed (do not edit) ──────────────"
|
HUB_BEGIN = " # ── Hub Managed (do not edit) ──────────────"
|
||||||
HUB_END = " # ── End Hub Managed ────────────────────────"
|
HUB_END = " # ── End Hub Managed ────────────────────────"
|
||||||
@@ -743,6 +747,66 @@ def _read_rebuild_log(offset: int = 0) -> tuple[str, int]:
|
|||||||
return "", 0
|
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 ────────────────────────
|
# ── custom.nix Hub Managed section helpers ────────────────────────
|
||||||
|
|
||||||
def _read_hub_overrides() -> tuple[dict, str | None]:
|
def _read_hub_overrides() -> tuple[dict, str | None]:
|
||||||
@@ -1856,6 +1920,66 @@ async def api_support_audit_log(limit: int = 100):
|
|||||||
return {"entries": lines}
|
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 ─────────────────────────────────────
|
# ── Feature Manager endpoints ─────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/features")
|
@app.get("/api/features")
|
||||||
|
|||||||
@@ -360,3 +360,17 @@
|
|||||||
border-color: #a8c8ff;
|
border-color: #a8c8ff;
|
||||||
border-style: solid;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -259,3 +259,215 @@ function closeSupportModal() {
|
|||||||
stopSupportTimer();
|
stopSupportTimer();
|
||||||
stopWalletUnlockTimer();
|
stopWalletUnlockTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Manual Backup modal ───────────────────────────────────────────
|
||||||
|
|
||||||
|
var _backupPollTimer = null;
|
||||||
|
var _backupLogOffset = 0;
|
||||||
|
|
||||||
|
function openBackupModal() {
|
||||||
|
if (!$supportModal) return;
|
||||||
|
$supportModal.classList.add("open");
|
||||||
|
$supportBody.innerHTML = '<p class="creds-loading">Detecting external drives\u2026</p>';
|
||||||
|
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 = '<p class="creds-empty">Could not detect drives. Please try again.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBackupReady(drives) {
|
||||||
|
var driveSelector = "";
|
||||||
|
if (drives.length > 0) {
|
||||||
|
driveSelector = [
|
||||||
|
'<label class="support-info-label" style="display:block;margin-bottom:6px;">Select drive:</label>',
|
||||||
|
'<select id="backup-drive-select" class="support-unlock-select" style="width:100%;margin-bottom:14px;">',
|
||||||
|
].join("");
|
||||||
|
for (var i = 0; i < drives.length; i++) {
|
||||||
|
var d = drives[i];
|
||||||
|
driveSelector += '<option value="' + escHtml(d.path) + '">' +
|
||||||
|
escHtml(d.name) + ' \u2014 ' + d.free_gb + ' GB free / ' + d.total_gb + ' GB total' +
|
||||||
|
'</option>';
|
||||||
|
}
|
||||||
|
driveSelector += '</select>';
|
||||||
|
driveSelector += '<button class="btn support-btn-enable" id="btn-start-backup">Start Backup</button>';
|
||||||
|
} else {
|
||||||
|
driveSelector = [
|
||||||
|
'<div class="support-wallet-box support-wallet-warning">',
|
||||||
|
'<div class="support-wallet-header">',
|
||||||
|
'<span class="support-wallet-icon">\u26a0\ufe0f</span>',
|
||||||
|
'<span class="support-wallet-title">No External Drive Detected</span>',
|
||||||
|
'</div>',
|
||||||
|
'<p class="support-wallet-desc">',
|
||||||
|
'No USB drive was found under /run/media/. ',
|
||||||
|
'Make sure the drive is plugged in and mounted, then click Refresh.',
|
||||||
|
'</p>',
|
||||||
|
'</div>',
|
||||||
|
'<button class="btn support-btn-auditlog" id="btn-backup-refresh">↻ Refresh</button>',
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
$supportBody.innerHTML = [
|
||||||
|
'<div class="support-section">',
|
||||||
|
'<div class="support-icon-big">\ud83d\udcbe</div>',
|
||||||
|
'<h3 class="support-heading">Manual Backup</h3>',
|
||||||
|
'<p class="support-desc">Back up your Sovran_SystemsOS data to an external USB hard drive.</p>',
|
||||||
|
|
||||||
|
'<div class="support-steps">',
|
||||||
|
'<div class="support-steps-title">Requirements</div>',
|
||||||
|
'<ol class="support-backup-steps">',
|
||||||
|
'<li>USB hard drive plugged into one of the open USB ports on your Sovran Pro</li>',
|
||||||
|
'<li>At least 500 GB of free space on the drive</li>',
|
||||||
|
'<li>Drive must be formatted as <strong>exFAT</strong></li>',
|
||||||
|
'</ol>',
|
||||||
|
'</div>',
|
||||||
|
|
||||||
|
'<div class="support-steps">',
|
||||||
|
'<div class="support-steps-title">What gets backed up</div>',
|
||||||
|
'<ol class="support-backup-steps">',
|
||||||
|
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
|
||||||
|
'<li>Bitcoin & Lightning wallet data (<code>/var/lib/lnd</code>)</li>',
|
||||||
|
'<li>nix-bitcoin secrets (<code>/etc/nix-bitcoin-secrets</code>)</li>',
|
||||||
|
'<li>Domain configurations (<code>/var/lib/domains</code>)</li>',
|
||||||
|
'<li>Home directory (<code>/home</code>)</li>',
|
||||||
|
'</ol>',
|
||||||
|
'</div>',
|
||||||
|
|
||||||
|
'<div class="support-wallet-box support-wallet-protected">',
|
||||||
|
'<div class="support-wallet-header">',
|
||||||
|
'<span class="support-wallet-icon">\u23f1\ufe0f</span>',
|
||||||
|
'<span class="support-wallet-title">Time Estimate</span>',
|
||||||
|
'</div>',
|
||||||
|
'<p class="support-wallet-desc">This backup can take <strong>up to 4 hours</strong> depending on the amount of data stored on your Sovran Pro and the speed of your external hard drive. Be patient\u2026</p>',
|
||||||
|
'</div>',
|
||||||
|
|
||||||
|
driveSelector,
|
||||||
|
'</div>',
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
if (drives.length > 0) {
|
||||||
|
document.getElementById("btn-start-backup").addEventListener("click", startBackup);
|
||||||
|
} else {
|
||||||
|
document.getElementById("btn-backup-refresh").addEventListener("click", function() {
|
||||||
|
$supportBody.innerHTML = '<p class="creds-loading">Detecting external drives\u2026</p>';
|
||||||
|
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 = [
|
||||||
|
'<div class="support-section">',
|
||||||
|
'<div class="support-icon-big support-active-icon">\ud83d\udcbe</div>',
|
||||||
|
'<h3 class="support-heading support-active-heading">Backup In Progress</h3>',
|
||||||
|
'<div class="support-wallet-box support-wallet-warning">',
|
||||||
|
'<div class="support-wallet-header">',
|
||||||
|
'<span class="support-wallet-icon">\u26a0\ufe0f</span>',
|
||||||
|
'<span class="support-wallet-title">Do Not Unplug</span>',
|
||||||
|
'</div>',
|
||||||
|
'<p class="support-wallet-desc">Do not remove the USB drive while the backup is running. This could corrupt the backup and your drive.</p>',
|
||||||
|
'</div>',
|
||||||
|
'<div class="modal-log" id="backup-log" style="text-align:left;"></div>',
|
||||||
|
'</div>',
|
||||||
|
].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 = [
|
||||||
|
'<div class="support-section">',
|
||||||
|
'<div class="support-icon-big">\u2705</div>',
|
||||||
|
'<h3 class="support-heading">All Finished!</h3>',
|
||||||
|
'<div class="support-wallet-box support-wallet-protected">',
|
||||||
|
'<div class="support-wallet-header">',
|
||||||
|
'<span class="support-wallet-icon">\u23cf\ufe0f</span>',
|
||||||
|
'<span class="support-wallet-title">Eject Your Drive</span>',
|
||||||
|
'</div>',
|
||||||
|
'<p class="support-wallet-desc">Please eject the drive before removing it from your Sovran Pro.</p>',
|
||||||
|
'</div>',
|
||||||
|
'<div class="modal-log" id="backup-log-done" style="text-align:left;"></div>',
|
||||||
|
'<button class="btn support-btn-done" id="btn-backup-close">Close</button>',
|
||||||
|
'</div>',
|
||||||
|
].join("");
|
||||||
|
var doneLog = document.getElementById("backup-log-done");
|
||||||
|
if (doneLog) { doneLog.textContent = logContent; doneLog.scrollTop = doneLog.scrollHeight; }
|
||||||
|
} else {
|
||||||
|
$supportBody.innerHTML = [
|
||||||
|
'<div class="support-section">',
|
||||||
|
'<div class="support-icon-big">\u26a0\ufe0f</div>',
|
||||||
|
'<h3 class="support-heading">Backup Failed</h3>',
|
||||||
|
'<p class="support-desc">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.</p>',
|
||||||
|
'<div class="modal-log" id="backup-log-fail" style="text-align:left;"></div>',
|
||||||
|
'<button class="btn support-btn-done" id="btn-backup-close">Close</button>',
|
||||||
|
'</div>',
|
||||||
|
].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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,12 +58,23 @@ function renderSidebarSupport(supportServices) {
|
|||||||
btn.addEventListener("click", function() { openSupportModal(); });
|
btn.addEventListener("click", function() { openSupportModal(); });
|
||||||
$sidebarSupport.appendChild(btn);
|
$sidebarSupport.appendChild(btn);
|
||||||
}
|
}
|
||||||
if (supportServices.length > 0) {
|
|
||||||
|
// ── Manual Backup button
|
||||||
|
var backupBtn = document.createElement("button");
|
||||||
|
backupBtn.className = "sidebar-support-btn";
|
||||||
|
backupBtn.innerHTML =
|
||||||
|
'<span class="sidebar-support-icon">💾</span>' +
|
||||||
|
'<span class="sidebar-support-text">' +
|
||||||
|
'<span class="sidebar-support-title">Manual Backup</span>' +
|
||||||
|
'<span class="sidebar-support-hint">Back up to external drive</span>' +
|
||||||
|
'</span>';
|
||||||
|
backupBtn.addEventListener("click", function() { openBackupModal(); });
|
||||||
|
$sidebarSupport.appendChild(backupBtn);
|
||||||
|
|
||||||
var hr = document.createElement("hr");
|
var hr = document.createElement("hr");
|
||||||
hr.className = "sidebar-divider";
|
hr.className = "sidebar-divider";
|
||||||
$sidebarSupport.appendChild(hr);
|
$sidebarSupport.appendChild(hr);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function buildTile(svc) {
|
function buildTile(svc) {
|
||||||
var isSupport = svc.type === "support";
|
var isSupport = svc.type === "support";
|
||||||
|
|||||||
Reference in New Issue
Block a user