From f6f6158811defc333dcb482ac5f5bc29a731288a Mon Sep 17 00:00:00 2001 From: Rizqi Date: Mon, 22 Jun 2026 02:49:24 +0700 Subject: [PATCH] feat: implement backup cancellation support and add job management UI templates --- backup_core.py | 23 ++++++++++++++++---- gui_app.py | 45 +++++++++++++++++++++++++++++++++++---- templates/job_detail.html | 10 +++++++++ templates/jobs.html | 10 +++++++++ 4 files changed, 80 insertions(+), 8 deletions(-) diff --git a/backup_core.py b/backup_core.py index d08e751..8db6fe9 100644 --- a/backup_core.py +++ b/backup_core.py @@ -327,7 +327,8 @@ def upload_via_sftp(host, user, password, key_filename, local_path, remote_dir): def run_backup(host, user, password, vm_name, dest, compress=False, no_verify_ssl=False, sftp_host=None, sftp_user=None, sftp_password=None, sftp_key=None, - log_path=None, progress_cb=None, disk_filter=None, job_id=None): + log_path=None, progress_cb=None, disk_filter=None, job_id=None, + is_cancelled_cb=None): """Run full backup flow. disk_filter: if not None, a set/list of VMDK file-ref strings to include. The VMX config file is always included regardless. @@ -338,7 +339,8 @@ def run_backup(host, user, password, vm_name, dest, compress=False, no_verify_ss with redirect_stdout(logfile), redirect_stderr(logfile): return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl, sftp_host, sftp_user, sftp_password, sftp_key, - progress_cb=progress_cb, disk_filter=disk_filter, job_id=job_id) + progress_cb=progress_cb, disk_filter=disk_filter, job_id=job_id, + is_cancelled_cb=is_cancelled_cb) try: return _wrap() finally: @@ -346,12 +348,14 @@ def run_backup(host, user, password, vm_name, dest, compress=False, no_verify_ss else: return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl, sftp_host, sftp_user, sftp_password, sftp_key, - progress_cb=progress_cb, disk_filter=disk_filter, job_id=job_id) + progress_cb=progress_cb, disk_filter=disk_filter, job_id=job_id, + is_cancelled_cb=is_cancelled_cb) def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl, sftp_host, sftp_user, sftp_password, sftp_key, - progress_cb=None, disk_filter=None, job_id=None): + progress_cb=None, disk_filter=None, job_id=None, + is_cancelled_cb=None): def _prog(phase, pct, detail=''): if progress_cb: try: @@ -428,6 +432,8 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss downloaded_files = [] files_manifest_info = [] for file_idx, ref in enumerate(all_refs): + if is_cancelled_cb and is_cancelled_cb(): + raise RuntimeError("Backup cancelled by user") ds_name, ds_path = parse_datastore_path(ref) dc = find_datacenter_for_datastore(content, ds_name) if not dc: @@ -441,6 +447,8 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss def make_dl_cb(fidx, total, base_pct, share, fname): def _dl_cb(done, total_b): + if is_cancelled_cb and is_cancelled_cb(): + raise RuntimeError("Backup cancelled by user") if total_b > 0: file_pct = done / total_b overall_pct = int(base_pct + file_pct * share) @@ -482,6 +490,8 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss }) _prog('compressing', 90, 'Downloads complete. Creating manifest…') + if is_cancelled_cb and is_cancelled_cb(): + raise RuntimeError("Backup cancelled by user") # Write manifest.json finished_iso = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') @@ -501,6 +511,8 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss final_files = [] for f in downloaded_files: + if is_cancelled_cb and is_cancelled_cb(): + raise RuntimeError("Backup cancelled by user") if compress: _prog('compressing', 92, f'Compressing {os.path.basename(f)}…') cf = maybe_compress(f) @@ -515,6 +527,9 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss if not sftp_user: raise Exception('SFTP user required') + if is_cancelled_cb and is_cancelled_cb(): + raise RuntimeError("Backup cancelled by user") + # Verify checksums before upload _prog('uploading', 94, 'Verifying local checksums before SFTP upload…') print("Running pre-upload checksum verification...") diff --git a/gui_app.py b/gui_app.py index 9f5b848..1c5a660 100644 --- a/gui_app.py +++ b/gui_app.py @@ -408,6 +408,8 @@ def run_job_thread(jid): info['started'] = time.time() info['progress'] = {'pct': 0, 'phase': 'starting', 'detail': 'Initializing…'} + is_cancelled = lambda: jobs.get(jid, {}).get('status') == 'cancelling' + vm_names = info.get('vm_names') log_path = str(JOBS_DIR / jid / 'backup.log') @@ -421,6 +423,12 @@ def run_job_thread(jid): failed_vms = [] for idx, vm in enumerate(vm_names): + if is_cancelled(): + failed_vms.append((vm, "Cancelled by user")) + with open(log_path, 'a', encoding='utf-8') as f: + f.write(f"\nSkipping VM {idx+1}/{total_vms} ({vm}): Backup cancelled by user\n") + continue + vm_pct_start = int((idx / total_vms) * 100) vm_pct_end = int(((idx + 1) / total_vms) * 100) @@ -465,6 +473,7 @@ def run_job_thread(jid): progress_cb=make_vm_progress_cb(vm, vm_pct_start, vm_pct_end, idx, total_vms), disk_filter=disk_filter, job_id=jid, + is_cancelled_cb=is_cancelled, ) success_vms.append(vm) @@ -477,9 +486,16 @@ def run_job_thread(jid): } enforce_retention_policy(vm_info, log_path=log_path) except Exception as e: - failed_vms.append((vm, str(e))) - with open(log_path, 'a', encoding='utf-8') as f: - f.write(f"\nERROR backing up VM {vm}: {e}\n\n") + if "cancelled by user" in str(e).lower(): + failed_vms.append((vm, "Cancelled by user")) + info['status'] = 'failed (Cancelled)' + info['progress'] = {'pct': 100, 'phase': 'failed', 'detail': 'Backup cancelled by user'} + save_jobs_db() + break + else: + failed_vms.append((vm, str(e))) + with open(log_path, 'a', encoding='utf-8') as f: + f.write(f"\nERROR backing up VM {vm}: {e}\n\n") if failed_vms: if success_vms: @@ -523,6 +539,7 @@ def run_job_thread(jid): progress_cb=progress_cb, disk_filter=info.get('disk_filter'), # None = all disks job_id=jid, + is_cancelled_cb=is_cancelled, ) info['status'] = 'finished' info['progress'] = {'pct': 100, 'phase': 'done', 'detail': 'Backup completed successfully'} @@ -531,7 +548,11 @@ def run_job_thread(jid): # Enforce retention policy enforce_retention_policy(info, log_path=log_path) except Exception as e: - info['status'] = f'failed ({e})' + if "cancelled by user" in str(e).lower(): + info['status'] = 'failed (Cancelled)' + info['progress'] = {'pct': 100, 'phase': 'failed', 'detail': 'Backup cancelled by user'} + else: + info['status'] = f'failed ({e})' save_jobs_db() @@ -1023,6 +1044,22 @@ def run_job_now(jobid): return redirect(url_for('job_detail', jobid=jobid)) +@app.route('/job//stop', methods=['POST']) +@login_required +def stop_job(jobid): + info = jobs.get(jobid) + if not info: + abort(404) + if info.get('status') in ('running', 'queued'): + info['status'] = 'cancelling' + info['progress'] = {'pct': info.get('progress', {}).get('pct', 0), 'phase': 'cancelling', 'detail': 'Stopping backup execution…'} + save_jobs_db() + flash('Request to stop backup sent.', 'info') + else: + flash('Job is not running or queued.', 'warning') + return redirect(url_for('job_detail', jobid=jobid)) + + @app.route('/job//delete', methods=['POST']) @login_required def delete_job(jobid): diff --git a/templates/job_detail.html b/templates/job_detail.html index 33ad168..fb209b3 100644 --- a/templates/job_detail.html +++ b/templates/job_detail.html @@ -159,6 +159,16 @@ {% endif %} + {% if job.status == 'running' or job.status == 'queued' %} +
+ +
+ {% endif %} All Jobs diff --git a/templates/jobs.html b/templates/jobs.html index f874a9c..c45410e 100644 --- a/templates/jobs.html +++ b/templates/jobs.html @@ -174,6 +174,16 @@ {% endif %} + {% if job.status == 'running' or job.status == 'queued' %} +
+ +
+ {% endif %} {% if job.schedule_id %}