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>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-05 03:24:07 +00:00
committed by GitHub
parent d59b878906
commit d864402de2
5 changed files with 539 additions and 5 deletions

View File

@@ -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")