Add Manual Backup improvements: lsblk drive filtering, UI instructions, CSS border fixes

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/a43d270d-eb78-4ad3-b721-fe958883c305

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-05 03:41:53 +00:00
committed by GitHub
parent 34db1439fa
commit cc72968583
5 changed files with 232 additions and 32 deletions

View File

@@ -1,11 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ── Sovran Hub Backup Script ───────────────────────────────────── # ── Sovran Hub External Backup Script ────────────────────────────
# Backs up Sovran_SystemsOS data to an external USB hard drive. # Backs up Sovran_SystemsOS data to an external USB hard drive.
# Designed for the Hub web UI (no GUI dependencies). # Designed for the Hub web UI (no GUI dependencies).
# #
# Your Sovran Pro already backs up your data automatically to its
# internal second drive (BTCEcoandBackup at /run/media/Second_Drive).
# This script creates an additional copy on an external USB drive —
# storing your data in a third location for maximum protection.
#
# Usage: # Usage:
# BACKUP_TARGET=/run/media/<user>/<drive> bash sovran-hub-backup.sh # BACKUP_TARGET=/run/media/<user>/<drive> bash sovran-hub-backup.sh
# (or run with no env var to auto-detect the first external drive) # (or run with no env var to auto-detect the first external USB drive)
set -euo pipefail set -euo pipefail
@@ -14,6 +19,10 @@ BACKUP_STATUS="/var/log/sovran-hub-backup.status"
MEDIA_ROOT="/run/media" MEDIA_ROOT="/run/media"
MIN_FREE_GB=10 MIN_FREE_GB=10
# ── Internal drive labels/paths to NEVER use as backup targets ───
INTERNAL_LABELS=("BTCEcoandBackup" "sovran_systemsos")
INTERNAL_MOUNTS=("/run/media/Second_Drive" "/boot/efi" "/")
# ── Logging helpers ────────────────────────────────────────────── # ── Logging helpers ──────────────────────────────────────────────
log() { log() {
@@ -31,33 +40,113 @@ fail() {
exit 1 exit 1
} }
# ── Check whether a mount point is an internal drive ────────────
is_internal() {
local mnt="$1"
# Reject known internal mount points and their subdirectories
for internal in "${INTERNAL_MOUNTS[@]}"; do
if [[ "$mnt" == "$internal" || "$mnt" == "${internal}/"* ]]; then
return 0
fi
done
return 1
}
# ── Use lsblk to find the first genuine external USB drive ───────
find_external_drive() {
local target=""
# lsblk JSON output: NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE
if command -v lsblk &>/dev/null; then
while IFS=$'\t' read -r dev_type hotplug removable label mountpoint; do
# Must be a partition or disk, and be removable/hotplug
[[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue
[[ "$hotplug" == "1" || "$removable" == "1" ]] || continue
[[ -n "$mountpoint" ]] || continue
# Filter out internal labels
local skip=0
for lbl in "${INTERNAL_LABELS[@]}"; do
[[ "$label" == "$lbl" ]] && skip=1 && break
done
[[ "$skip" -eq 1 ]] && continue
# Filter out internal mount points
is_internal "$mountpoint" && continue
if mountpoint -q "$mountpoint" 2>/dev/null; then
target="$mountpoint"
break
fi
done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \
python3 -c "
import sys, json
data = json.load(sys.stdin)
def flatten(devs):
for d in devs:
yield d
for c in d.get('children', []):
yield from flatten([c])
for d in flatten(data.get('blockdevices', [])):
print('\t'.join([
d.get('type') or '',
str(d.get('hotplug') or '0'),
str(d.get('rm') or '0'),
d.get('label') or '',
d.get('mountpoint') or '',
]))
" 2>/dev/null || true)
fi
# Fallback: walk /run/media/ if lsblk produced nothing
if [[ -z "$target" && -d "$MEDIA_ROOT" ]]; then
while IFS= read -r -d '' mnt; do
is_internal "$mnt" && continue
# Check label via lsblk on the device backing this mount
local dev
dev=$(findmnt -n -o SOURCE "$mnt" 2>/dev/null || true)
if [[ -n "$dev" ]]; then
local lbl
lbl=$(lsblk -n -o LABEL "$dev" 2>/dev/null || true)
local skip=0
for internal_lbl in "${INTERNAL_LABELS[@]}"; do
[[ "$lbl" == "$internal_lbl" ]] && skip=1 && break
done
[[ "$skip" -eq 1 ]] && continue
fi
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
echo "$target"
}
# ── Initialise log file ────────────────────────────────────────── # ── Initialise log file ──────────────────────────────────────────
: > "$BACKUP_LOG" : > "$BACKUP_LOG"
set_status "RUNNING" set_status "RUNNING"
log "=== Sovran_SystemsOS Hub Backup ===" log "=== Sovran_SystemsOS External Hub Backup ==="
log "Starting backup process…" log "Starting backup process…"
# ── Detect target drive ────────────────────────────────────────── # ── Detect target drive ──────────────────────────────────────────
if [[ -n "${BACKUP_TARGET:-}" ]]; then if [[ -n "${BACKUP_TARGET:-}" ]]; then
TARGET="$BACKUP_TARGET" TARGET="$BACKUP_TARGET"
# Safety: never allow internal drives even if explicitly passed
if is_internal "$TARGET"; then
fail "Target '$TARGET' is an internal system drive and cannot be used for external backup."
fi
log "Using specified backup target: $TARGET" log "Using specified backup target: $TARGET"
else else
log "Auto-detecting external drives under $MEDIA_ROOT" log "Auto-detecting external USB drives"
TARGET="" TARGET="$(find_external_drive)"
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 if [[ -z "$TARGET" ]]; then
fail "No external drive detected under $MEDIA_ROOT. " \ fail "No external USB drive detected. " \
"Please plug in an exFAT-formatted USB drive (≥500 GB) and try again." "Please plug in an exFAT-formatted USB drive (≥500 GB) and try again."
fi fi
log "Detected external drive: $TARGET" log "Detected external drive: $TARGET"
@@ -169,5 +258,6 @@ log "Manifest written to $BACKUP_DIR/BACKUP_MANIFEST.txt"
# ── Done ───────────────────────────────────────────────────────── # ── Done ─────────────────────────────────────────────────────────
log "" log ""
log "All Finished! Please eject the drive before removing it from your Sovran Pro." log "All Finished! Your data is now backed up to a third location."
log "Please eject the drive safely before removing it from your Sovran Pro."
set_status "SUCCESS" set_status "SUCCESS"

View File

@@ -774,10 +774,94 @@ def _read_backup_log(offset: int = 0) -> tuple[str, int]:
return "", 0 return "", 0
_INTERNAL_LABELS = {"BTCEcoandBackup", "sovran_systemsos"}
_INTERNAL_MOUNTS = {"/", "/boot/efi"}
_INTERNAL_MOUNT_PREFIX = "/run/media/Second_Drive"
def _is_internal_mount(mnt: str) -> bool:
"""Return True if *mnt* is a known internal system path."""
if mnt in _INTERNAL_MOUNTS:
return True
if mnt == _INTERNAL_MOUNT_PREFIX or mnt.startswith(_INTERNAL_MOUNT_PREFIX + "/"):
return True
return False
def _detect_external_drives() -> list[dict]: def _detect_external_drives() -> list[dict]:
"""Scan /run/media/ for mounted external drives. """Scan for mounted external USB drives.
Returns a list of dicts with name, path, free_gb, total_gb."""
drives = [] Uses ``lsblk`` to identify genuinely removable/hotplug devices and
filters out internal system drives (BTCEcoandBackup, sovran_systemsos,
/boot/efi, /run/media/Second_Drive). Falls back to scanning
/run/media/ directly if lsblk is unavailable, applying the same
label/path filters.
Returns a list of dicts with name, path, free_gb, total_gb.
"""
import json as _json
import subprocess as _subprocess
drives: list[dict] = []
seen_paths: set[str] = set()
# ── Primary path: lsblk JSON ────────────────────────────────
try:
result = _subprocess.run(
["lsblk", "-J", "-o", "NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
data = _json.loads(result.stdout)
def _flatten(devs: list) -> list:
out = []
for d in devs:
out.append(d)
out.extend(_flatten(d.get("children") or []))
return out
for dev in _flatten(data.get("blockdevices", [])):
dev_type = dev.get("type", "")
hotplug = str(dev.get("hotplug", "0"))
rm = str(dev.get("rm", "0"))
label = dev.get("label") or ""
mountpoint = dev.get("mountpoint") or ""
if dev_type not in ("part", "disk"):
continue
if hotplug != "1" and rm != "1":
continue
if not mountpoint:
continue
if label in _INTERNAL_LABELS:
continue
if _is_internal_mount(mountpoint):
continue
if mountpoint in seen_paths:
continue
try:
st = os.statvfs(mountpoint)
total_gb = round((st.f_blocks * st.f_frsize) / (1024 ** 3), 1)
free_gb = round((st.f_bavail * st.f_frsize) / (1024 ** 3), 1)
name = label if label else os.path.basename(mountpoint)
drives.append({
"name": name,
"path": mountpoint,
"free_gb": free_gb,
"total_gb": total_gb,
})
seen_paths.add(mountpoint)
except OSError:
pass
if drives:
return drives
except Exception: # lsblk not available or JSON parse error
pass
# ── Fallback: scan /run/media/ ───────────────────────────────
media_root = "/run/media" media_root = "/run/media"
if not os.path.isdir(media_root): if not os.path.isdir(media_root):
return drives return drives
@@ -786,20 +870,27 @@ def _detect_external_drives() -> list[dict]:
user_path = os.path.join(media_root, user) user_path = os.path.join(media_root, user)
if not os.path.isdir(user_path): if not os.path.isdir(user_path):
continue continue
for drive in os.listdir(user_path): for drive_name in os.listdir(user_path):
drive_path = os.path.join(user_path, drive) drive_path = os.path.join(user_path, drive_name)
if not os.path.isdir(drive_path): if not os.path.isdir(drive_path):
continue continue
if drive_name in _INTERNAL_LABELS:
continue
if _is_internal_mount(drive_path):
continue
if drive_path in seen_paths:
continue
try: try:
st = os.statvfs(drive_path) st = os.statvfs(drive_path)
total_gb = round((st.f_blocks * st.f_frsize) / (1024 ** 3), 1) total_gb = round((st.f_blocks * st.f_frsize) / (1024 ** 3), 1)
free_gb = round((st.f_bavail * st.f_frsize) / (1024 ** 3), 1) free_gb = round((st.f_bavail * st.f_frsize) / (1024 ** 3), 1)
drives.append({ drives.append({
"name": drive, "name": drive_name,
"path": drive_path, "path": drive_path,
"free_gb": free_gb, "free_gb": free_gb,
"total_gb": total_gb, "total_gb": total_gb,
}) })
seen_paths.add(drive_path)
except OSError: except OSError:
pass pass
except OSError: except OSError:

View File

@@ -34,7 +34,7 @@
gap: 10px; gap: 10px;
width: 100%; width: 100%;
background-color: var(--card-color); background-color: var(--card-color);
border: 2px dashed var(--accent-color); border: 1px solid var(--border-color);
border-radius: 12px; border-radius: 12px;
padding: 12px 14px; padding: 12px 14px;
color: var(--text-primary); color: var(--text-primary);
@@ -44,11 +44,15 @@
} }
.sidebar-support-btn:hover { .sidebar-support-btn:hover {
border-color: var(--accent-color);
border-style: solid; border-style: solid;
border-color: #a8c8ff;
background-color: #35354a; background-color: #35354a;
} }
.sidebar-support-btn + .sidebar-support-btn {
margin-top: 8px;
}
.sidebar-support-icon { .sidebar-support-icon {
font-size: 1.5rem; font-size: 1.5rem;
flex-shrink: 0; flex-shrink: 0;

View File

@@ -351,13 +351,13 @@
/* ── Tech Support tile ───────────────────────────────────────────── */ /* ── Tech Support tile ───────────────────────────────────────────── */
.support-tile { .support-tile {
border-color: var(--accent-color); border-color: var(--border-color);
border-width: 2px; border-width: 1px;
border-style: dashed; border-style: solid;
} }
.support-tile:hover { .support-tile:hover {
border-color: #a8c8ff; border-color: var(--accent-color);
border-style: solid; border-style: solid;
} }

View File

@@ -301,7 +301,8 @@ function renderBackupReady(drives) {
if (drives.length > 0) { if (drives.length > 0) {
driveSelector = [ driveSelector = [
'<label class="support-info-label" style="display:block;margin-bottom:6px;">Select drive:</label>', '<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;">', '<div style="display:flex;gap:8px;align-items:center;margin-bottom:14px;">',
'<select id="backup-drive-select" class="support-unlock-select" style="flex:1;">',
].join(""); ].join("");
for (var i = 0; i < drives.length; i++) { for (var i = 0; i < drives.length; i++) {
var d = drives[i]; var d = drives[i];
@@ -310,6 +311,8 @@ function renderBackupReady(drives) {
'</option>'; '</option>';
} }
driveSelector += '</select>'; driveSelector += '</select>';
driveSelector += '<button class="btn support-btn-auditlog" id="btn-backup-refresh" style="white-space:nowrap;">&#x21bb; Refresh</button>';
driveSelector += '</div>';
driveSelector += '<button class="btn support-btn-enable" id="btn-start-backup">Start Backup</button>'; driveSelector += '<button class="btn support-btn-enable" id="btn-start-backup">Start Backup</button>';
} else { } else {
driveSelector = [ driveSelector = [
@@ -331,7 +334,15 @@ function renderBackupReady(drives) {
'<div class="support-section">', '<div class="support-section">',
'<div class="support-icon-big">\ud83d\udcbe</div>', '<div class="support-icon-big">\ud83d\udcbe</div>',
'<h3 class="support-heading">Manual Backup</h3>', '<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-wallet-box support-wallet-protected" style="margin-bottom:16px;">',
'<p class="support-wallet-desc">',
'Your Sovran Pro already backs up your data automatically to its internal second drive. ',
'This manual backup lets you create an additional copy on an external USB drive \u2014 ',
'storing your data in a third location, outside the computer, for maximum protection ',
'against hardware failure or physical damage.',
'</p>',
'</div>',
'<div class="support-steps">', '<div class="support-steps">',
'<div class="support-steps-title">Requirements</div>', '<div class="support-steps-title">Requirements</div>',
@@ -353,7 +364,7 @@ function renderBackupReady(drives) {
'</ol>', '</ol>',
'</div>', '</div>',
'<div class="support-wallet-box support-wallet-protected">', '<div class="support-wallet-box support-wallet-warning">',
'<div class="support-wallet-header">', '<div class="support-wallet-header">',
'<span class="support-wallet-icon">\u23f1\ufe0f</span>', '<span class="support-wallet-icon">\u23f1\ufe0f</span>',
'<span class="support-wallet-title">Time Estimate</span>', '<span class="support-wallet-title">Time Estimate</span>',
@@ -367,9 +378,13 @@ function renderBackupReady(drives) {
if (drives.length > 0) { if (drives.length > 0) {
document.getElementById("btn-start-backup").addEventListener("click", startBackup); document.getElementById("btn-start-backup").addEventListener("click", startBackup);
document.getElementById("btn-backup-refresh").addEventListener("click", function() {
$supportBody.innerHTML = '<p class="creds-loading">Scanning for external drives\u2026</p>';
detectDrivesAndRender();
});
} else { } else {
document.getElementById("btn-backup-refresh").addEventListener("click", function() { document.getElementById("btn-backup-refresh").addEventListener("click", function() {
$supportBody.innerHTML = '<p class="creds-loading">Detecting external drives\u2026</p>'; $supportBody.innerHTML = '<p class="creds-loading">Scanning for external drives\u2026</p>';
detectDrivesAndRender(); detectDrivesAndRender();
}); });
} }