116 lines
3.7 KiB
Python
116 lines
3.7 KiB
Python
import html
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Form
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
|
|
from ..backups import is_backup_path, list_backups
|
|
from ..config import BACKUP_SCRIPT
|
|
from ..shell import run_command
|
|
from ..templates.layout import page, render_result
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/backups", response_class=HTMLResponse)
|
|
def backups_page():
|
|
rows = ""
|
|
|
|
for path in list_backups():
|
|
size_mb = path.stat().st_size / 1024 / 1024
|
|
backup_name = html.escape(path.name)
|
|
backup_path = html.escape(str(path), quote=True)
|
|
|
|
restore_cmd = html.escape(
|
|
f"sudo /home/jiri/workspace/appfactory-tools/scripts/restore-appfactory.sh --force {path}",
|
|
quote=True,
|
|
)
|
|
|
|
rows += f"""
|
|
<tr class="backup-row">
|
|
<td>
|
|
<strong>{backup_name}</strong><br>
|
|
<span class="muted">{size_mb:.2f} MB</span>
|
|
</td>
|
|
<td>
|
|
<div class="copy-row">
|
|
<input readonly value="{backup_path}">
|
|
<button type="button" class="copy-button" data-copy-value="{backup_path}" onclick="copyText(this)">Copy</button>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="copy-row">
|
|
<input readonly value="{restore_cmd}">
|
|
<button type="button" class="copy-button" data-copy-value="{restore_cmd}" onclick="copyText(this)">Copy</button>
|
|
</div>
|
|
<div class="muted">Restore is dangerous and must be run manually over SSH.</div>
|
|
</td>
|
|
<td class="actions-cell">
|
|
<form method="post" action="/portal/backups/delete" onsubmit="return confirm('Delete backup {backup_name}?');">
|
|
<input type="hidden" name="backup_path" value="{backup_path}">
|
|
<button type="submit" class="danger">Delete</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
"""
|
|
|
|
if not rows:
|
|
rows = '<tr><td colspan="4">No backups found.</td></tr>'
|
|
|
|
return page(
|
|
"Backups",
|
|
f"""
|
|
<div class="card">
|
|
<h2>Backup Management</h2>
|
|
<p class="muted">
|
|
Backups do not include the backup directory itself.
|
|
Restoring an older backup should not delete newer .tar.gz files.
|
|
Restore remains manual to avoid accidental overwrite.
|
|
</p>
|
|
|
|
<form method="post" action="/portal/backups/create">
|
|
<button type="submit">Create Backup</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Available Backups</h2>
|
|
<table>
|
|
<tr>
|
|
<th>Backup</th>
|
|
<th>Path</th>
|
|
<th>Restore Command</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
{rows}
|
|
</table>
|
|
</div>
|
|
""",
|
|
)
|
|
|
|
|
|
@router.post("/backups/create", response_class=HTMLResponse)
|
|
def create_backup():
|
|
result = run_command([BACKUP_SCRIPT])
|
|
status = "OK" if result.returncode == 0 else "FAILED"
|
|
|
|
return render_result(
|
|
title=f"Create Backup: {status}",
|
|
back_url="/portal/backups",
|
|
sections=[("Output", result.stdout), ("Error", result.stderr)],
|
|
)
|
|
|
|
|
|
@router.post("/backups/delete")
|
|
def delete_backup(backup_path: str = Form(...)):
|
|
target = Path(backup_path)
|
|
|
|
if not is_backup_path(target):
|
|
return HTMLResponse("Invalid backup path", status_code=400)
|
|
|
|
target = target.resolve()
|
|
if target.exists():
|
|
target.unlink()
|
|
|
|
return RedirectResponse(url="/portal/backups", status_code=303)
|