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_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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = '<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,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 =
|
||||
'<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");
|
||||
hr.className = "sidebar-divider";
|
||||
$sidebarSupport.appendChild(hr);
|
||||
}
|
||||
|
||||
function buildTile(svc) {
|
||||
|
||||
Reference in New Issue
Block a user