UI fixes and menu

This commit is contained in:
JiriUhlir
2026-06-15 11:00:09 +02:00
parent 3bb762d08a
commit bf4796d153
10 changed files with 485 additions and 119 deletions
+55 -25
View File
@@ -7,7 +7,7 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from ..auth import require_user
from ..backups import backup_dir, is_backup_path, list_backups
from ..config import BACKUP_SCRIPT
from ..config import BACKUP_SCRIPT, RESTORE_SCRIPT
from ..db.audit import log_audit_event
from ..shell import run_command
from ..templates.layout import page, render_result
@@ -30,14 +30,24 @@ def backups_page(request: Request, user=Depends(require_user)):
backup_path = html.escape(str(path), quote=True)
backup_url_name = quote(path.name, safe="")
download_action = (
f'<p><a class="btn btn-secondary" href="/portal/backups/download/{backup_url_name}">Stáhnout</a></p>'
f'<a class="btn btn-secondary" href="/portal/backups/download/{backup_url_name}">'
f'<i class="fa-solid fa-download" aria-hidden="true"></i> Stáhnout</a>'
if can_download
else ""
)
restore_cmd = html.escape(
f"sudo /home/jiri/workspace/appfactory-tools/scripts/restore-appfactory.sh --force {path}",
quote=True,
# Restore overwrites live data, so it is admin-only and guarded by an explicit confirm.
restore_action = (
f"""
<form method="post" action="/portal/backups/restore"
onsubmit="return confirm('Obnovit zálohu {backup_name}?\\n\\nTato akce přepíše aktuální data AppFactory a může restartovat běžící služby. Akce je nevratná.');">
<input type="hidden" name="backup_path" value="{backup_path}">
<button type="submit" class="btn btn-secondary">
<i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i> Obnovit
</button>
</form>
"""
if can_download
else ""
)
rows += f"""
@@ -49,42 +59,46 @@ def backups_page(request: Request, user=Depends(require_user)):
<td>
<div class="copy-row">
<input readonly value="{backup_path}">
<button type="button" class="copy-button" data-copy-value="{backup_path}" onclick="copyText(this)">Kopírovat</button>
<button type="button" class="copy-button" data-copy-value="{backup_path}" onclick="copyText(this)">
<i class="fa-solid fa-copy" aria-hidden="true"></i> Kopírovat
</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)">Kopírovat</button>
</div>
<div class="muted">Obnova je nebezpečná a musí se spustit ručně přes SSH.</div>
</td>
<td class="actions-cell">
{download_action}
{restore_action}
<form method="post" action="/portal/backups/delete" onsubmit="return confirm('Smazat zálohu {backup_name}?');">
<input type="hidden" name="backup_path" value="{backup_path}">
<button type="submit" class="danger">Smazat</button>
<button type="submit" class="danger">
<i class="fa-solid fa-trash" aria-hidden="true"></i> Smazat
</button>
</form>
</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="4">Nebyly nalezeny žádné zálohy.</td></tr>'
rows = '<tr><td colspan="3">Nebyly nalezeny žádné zálohy.</td></tr>'
restore_hint = (
"Obnovu lze spustit přímo z portálu tlačítkem Obnovit (pouze administrátor)."
if can_download
else "Obnovu zálohy může spustit pouze administrátor."
)
return page(
"Zálohy",
f"""
<div class="card">
<h2>Správa záloh</h2>
<h2><i class="fa-solid fa-box-archive" aria-hidden="true"></i> Správa záloh</h2>
<p class="muted">
Zálohy neobsahují samotný adresář se zálohami.
Obnova starší zálohy by neměla smazat novější soubory .tar.gz.
Obnova zůstává ruční, aby nedošlo k nechtěnému přepsání dat.
{restore_hint} Obnova přepíše aktuální data a je nevratná.
</p>
<form method="post" action="/portal/backups/create">
<button type="submit">Vytvořit zálohu</button>
<button type="submit"><i class="fa-solid fa-plus" aria-hidden="true"></i> Vytvořit zálohu</button>
</form>
</div>
@@ -94,7 +108,6 @@ def backups_page(request: Request, user=Depends(require_user)):
<tr>
<th>Záloha</th>
<th>Cesta</th>
<th>Příkaz pro obnovu</th>
<th>Akce</th>
</tr>
{rows}
@@ -169,21 +182,38 @@ def delete_backup(backup_path: str = Form(...), user=Depends(require_user)):
return RedirectResponse(url="/portal/backups", status_code=303)
@router.post("/backups/restore-trigger")
def restore_trigger(backup_path: str = Form(...), user=Depends(require_user)):
target = Path(backup_path)
@router.post("/backups/restore", response_class=HTMLResponse)
def restore_backup(backup_path: str = Form(...), user=Depends(require_user)):
# Restoring overwrites live data and may restart services, so it is restricted to admins.
if not is_admin(user):
raise HTTPException(status_code=403, detail="Only admins can restore backups")
target = Path(backup_path)
if not is_backup_path(target):
return HTMLResponse("Neplatná cesta k záloze", status_code=400)
target = target.resolve()
if not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail="Backup not found")
result = run_command([RESTORE_SCRIPT, "--force", str(target)])
status = "OK" if result.returncode == 0 else "FAILED"
log_audit_event(
user,
action="restore_trigger",
action="backup_restore",
target_type="backup",
target_id=target.name,
metadata={
"backup_path": str(target),
"status": status,
"returncode": result.returncode,
},
)
return RedirectResponse(url="/portal/backups", status_code=303)
return render_result(
title=f"Obnova zálohy: {status}",
back_url="/portal/backups",
sections=[("Výstup", result.stdout), ("Chyba", result.stderr)],
user=user,
)