From 7f306c713c1b2a86d6afa8a604c36f9a63c9a705 Mon Sep 17 00:00:00 2001 From: Rizqi Date: Sun, 21 Jun 2026 12:15:30 +0700 Subject: [PATCH] feat: add batch selection UI for virtual machines and create new templates and controller logic --- .../__pycache__/gui_app.cpython-310.pyc | Bin 17606 -> 19215 bytes vsphere_backup/gui_app.py | 75 ++++ vsphere_backup/templates/batch_job.html | 371 ++++++++++++++++++ vsphere_backup/templates/vms.html | 169 +++++++- 4 files changed, 611 insertions(+), 4 deletions(-) create mode 100644 vsphere_backup/templates/batch_job.html diff --git a/vsphere_backup/__pycache__/gui_app.cpython-310.pyc b/vsphere_backup/__pycache__/gui_app.cpython-310.pyc index 2867b216eb5517cb66bd13eb0c442b0b76cbf01c..9f937a0f7e982a35de5495f849a39a364172c47e 100644 GIT binary patch delta 5032 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 delta 3526 zcmb7`4Nz3q7037P!m@y@_`#Qe0*bJHB0@yO1uTM)YAoW{8hlxK4_IJ#@$Rk)V*;(F zMy*NA$(XcF8)X;1wojbgDV)Kiqy!899lmi-$I113omt|UBhw!dqKjS@vxT27i@72| z>DK4$`|I_F0YPW2jmtag^;P=3Hldg8u^Kh{YJJUi%Qmr1cvc#P`&a2}2ZZj?*Fol4 z&60Zk{sF;u&HDObyER53`_OLspbp!NGBz`mS*zYQY_`rQ(Azt$j|+K*C)}_5^bS2q z@7!Y(7Tvl+{anGpzu5NKg@?YGuwHluFD1kYQ8sub(JMq1H6-m~o==+Ztl>SHA}llc zzx>)@mb1Vr=2A^^bMk$VEC;>;te_*wx~QPkF&~Jw!J#p3A(m3{xQ4ewK<=SHW`D}}ZW_CUC#G8l+YSk7kS)v8r$QYGtX58gCBie~L#^R%EfO-M znWTo@-cV?BQ0i)Fms4iJG#hXMw|lzfNInbgeul@P{B#;UrlTv^rgeM6Y3=d{P^-;5 zT73ausNK+IBMoKMl`dqB>1eob(F(1mzEO^bVFkk#BllrE0I2D1j8q<;pKBMuda9%X!IJY{2KV7{>iTI>U_By}qs&=)*#HV{=!4lKfZfubUsD83*QXQNv0S*H7z-nMD&`f(L=c&xNQvZ)L zyR~aGVY6X$cJ*kwx5sq)0%1dL@^)$M!3fu`YI zM~k5y_KubVikUZ}5v5_gYt}Bq;+QtR7|b#?P9) z!Q0Xq=?+Dk%rt0Jrx~fh=xNSkg_)r4sD_T)8(UOdUQ{Gsr~R|X$G^c^kF~hOLvPKV znRZOswn)PpHZ(p}c?ez)lPH={gY$MJeWgnzU&nMTF??l6D=N-B&d+9ox-&+YOXrX< zWBG|&-Me(SsKZr)g@i1{w0i(mDhlX(az5jqd=??9oRcrg$(U0)@0U=V0MtR;x<3xc z;u)T}HY42S3x(yI5TV1$etLh-9t#RXb2|M@Xbv7X-z z)>%c-zG(2fEhP~)kCXo`CFvXTT-6E5MeqJ6)SMQ`b{J2cn z@HM~05Ar3dT#zkn)VyH1_#?fzVCvjILwtL5W;_IKefHsnfM zkuB`54fd)JH}Qz?6D;n?yqOqi4QYOFz{~HhIyjACBfE{y|5MB0pB7CNlQv_P%HS4^ zmH|CLJJ3rxHD$TSF#MFkVJclOk9!?U@quLMIkJ;_Y9{K(A#^HXPAfMMLod(aWT|F+ z0v^;U+@%M|2cbpMTz0AA83~1pk~gr4>l8~$H+@!<%^#l`i(S_17OGkNmR$wxqqy3M zStDWgCaetjZwB`nCLX8i+U%nL9mPWh?vl*vpday;)qF>pzP)|s{ zj0n`1Lq3m8M|YPu+!~aADe0BE2ZTv!OD2gXnzy8T!mBVytoL^ZWmtWzo3Y)JTc3>3 zu_cZ61Mu_;Wz^@`aXV!_Ro2h!tHQ7VP?txgUF~{1WIF(~g6Jm;`mrZg1|!@hk3!l9 zoC0!zv%q`61>g#B5f}ol0@oOx(P6`njTl<)aLfG!I$S4t9rzr$0fab@Q~C8<*09&= KSW53Uxc>wCm3QX= diff --git a/vsphere_backup/gui_app.py b/vsphere_backup/gui_app.py index 0619ff5..b46b4ee 100644 --- a/vsphere_backup/gui_app.py +++ b/vsphere_backup/gui_app.py @@ -503,6 +503,81 @@ def create_job(): ) +# ── Batch Jobs ──────────────────────────────────────────────────────────────── + +@app.route('/jobs/batch', methods=['GET', 'POST']) +@login_required +def batch_jobs(): + vm_list, _, _ = get_cached_vms( + session['host'], session['user'], session['password'], + no_verify_ssl=session.get('no_verify_ssl', False) + ) + vms_by_name = {v['name']: v for v in vm_list} + + if request.method == 'POST': + vm_names = request.form.getlist('vms') + dest = request.form.get('dest', './backups').strip() + compress = 'compress' in request.form + no_verify_ssl = session.get('no_verify_ssl', False) + disk_strategy = request.form.get('disk_strategy', 'all') + schedule_type = request.form.get('schedule_type', 'now') + daily_time = request.form.get('daily_time', '02:00') + label_prefix = request.form.get('job_label', '').strip() + sched_time = daily_time if schedule_type == 'daily' else '' + + if not vm_names: + flash('No VMs selected.', 'danger') + return redirect(url_for('vms')) + + created = [] + for vm_name in vm_names: + # Resolve disk_filter from strategy + if disk_strategy == 'os': + # Smallest disk = OS disk + vm_info = vms_by_name.get(vm_name, {}) + disks = sorted(vm_info.get('disks', []), key=lambda d: d.get('size_gb', 0)) + disk_filter = [disks[0]['path']] if disks else None + elif disk_strategy == 'vmx': + disk_filter = [] # empty list = VMX only + else: + disk_filter = None # all disks + + label = f'{label_prefix} — {vm_name}' if label_prefix else vm_name + + jid = create_and_start_job( + vm_name=vm_name, + dest=dest, + compress=compress, + no_verify_ssl=no_verify_ssl, + sftp_host=None, + sftp_user=None, + sftp_password=None, + schedule_type=schedule_type, + schedule_time=sched_time, + weekly_day='0', + interval_hours='24', + label=label, + disk_filter=disk_filter, + ) + created.append(jid) + + strat_label = {'all': 'all disks', 'os': 'OS disk only', 'vmx': 'VMX config only'}.get(disk_strategy, disk_strategy) + flash(f'{len(created)} backup job{"s" if len(created)!=1 else ""} created ({strat_label}).', 'success') + return redirect(url_for('jobs')) + + # GET: show batch config form + vm_names = request.args.getlist('vms') + if not vm_names: + flash('No VMs specified for batch backup.', 'danger') + return redirect(url_for('vms')) + + return render_template( + 'batch_job.html', + vm_names=vm_names, + vms_by_name=vms_by_name, + ) + + # ── Jobs Dashboard ──────────────────────────────────────────────────────────── @app.route('/jobs') diff --git a/vsphere_backup/templates/batch_job.html b/vsphere_backup/templates/batch_job.html new file mode 100644 index 0000000..bb100a3 --- /dev/null +++ b/vsphere_backup/templates/batch_job.html @@ -0,0 +1,371 @@ +{% extends "base.html" %} +{% set active_page = 'vms' %} +{% block title %}Batch Backup — vSphere Backup Manager{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
Batch Backup
+
Configure and launch backups for {{ vm_names|length }} VMs simultaneously
+
+ +
+ +
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endif %} + {% endwith %} + + +
+ + This will create {{ vm_names|length }} independent backup job{{ 's' if vm_names|length != 1 }}, each running in parallel with its own progress and log. +
+ + +
+
+
+ + Selected VMs ({{ vm_names|length }}) +
+ Change selection +
+ {% for name in vm_names %} + {% set vm = vms_by_name.get(name, {}) %} +
+
{{ loop.index }}
+
+
{{ name }}
+
{{ vm.get('guest_os','') or '' }}
+
+ {% if vm.get('power_state') == 'poweredOn' %} + On + {% elif vm.get('power_state') == 'poweredOff' %} + Off + {% else %} + {{ vm.get('power_state','—') }} + {% endif %} + {% if vm.get('disks') %} + {{ vm.disks|length }} disk{{ 's' if vm.disks|length != 1 }} + {% endif %} +
+ {% endfor %} +
+ +
+ + {% for name in vm_names %} + + {% endfor %} + + +
+
+ + Destination +
+
+ + + +
+ + +
+ Each VM will be backed up into its own subfolder: dest/VM_NAME/ +
+
+
+ + +
+
+
+ + +
+
+ + Disk Strategy +
+
+
+ + + +
+
+ All VMDK disks will be backed up for each VM. +
+
+
+ + +
+
+ + Schedule +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + Cancel +
+
+ +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/vsphere_backup/templates/vms.html b/vsphere_backup/templates/vms.html index 791c185..4712f8e 100644 --- a/vsphere_backup/templates/vms.html +++ b/vsphere_backup/templates/vms.html @@ -36,6 +36,67 @@ } .vm-card:hover::before { opacity: 1; } + /* ── Batch selection mode ── */ + .vm-card.selectable { cursor: pointer; user-select: none; } + .vm-card.selected { + border-color: var(--accent) !important; + background: rgba(99,102,241,0.07); + transform: translateY(-2px) scale(1.005) !important; + box-shadow: 0 0 0 2px rgba(99,102,241,0.2), var(--shadow) !important; + } + .vm-card.selected::before { opacity: 1; } + + .vm-check { + display: none; + position: absolute; top: 14px; right: 14px; + width: 22px; height: 22px; + border: 2px solid rgba(255,255,255,0.15); + border-radius: 6px; + background: rgba(8,10,16,0.6); + align-items: center; justify-content: center; + transition: all 0.15s ease; + z-index: 10; + flex-shrink: 0; + } + .vm-card.selectable .vm-check { display: flex; } + .vm-card.selected .vm-check { + background: var(--accent); + border-color: var(--accent); + box-shadow: 0 0 8px rgba(99,102,241,0.4); + } + .vm-check svg { opacity: 0; transition: opacity 0.15s; } + .vm-card.selected .vm-check svg { opacity: 1; } + + /* ── Floating batch bar ── */ + .batch-bar { + position: fixed; bottom: 0; left: var(--sidebar-w); right: 0; + background: rgba(10, 12, 20, 0.96); + backdrop-filter: blur(24px); + border-top: 1px solid rgba(99,102,241,0.25); + padding: 14px 40px; + display: flex; align-items: center; justify-content: space-between; + gap: 16px; + transform: translateY(100%); + transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 200; + box-shadow: 0 -12px 40px rgba(0,0,0,0.5); + } + .batch-bar.visible { transform: translateY(0); } + .batch-bar-info { display: flex; align-items: center; gap: 14px; } + .batch-count { + font-size: 24px; font-weight: 800; color: var(--accent); + letter-spacing: -0.03em; min-width: 2ch; text-align: right; + } + .batch-divider { width: 1px; height: 32px; background: var(--border); } + .batch-label { font-size: 14px; color: var(--text-secondary); font-weight: 500; } + .batch-vm-list { + font-size: 12px; color: var(--text-muted); + font-family: 'JetBrains Mono', monospace; + max-width: 400px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .batch-actions { display: flex; align-items: center; gap: 10px; } + .vm-card-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 18px; @@ -144,6 +205,10 @@ Refresh + Create Job @@ -202,7 +267,14 @@ {% for vm in vms %}
+ data-vmname="{{ vm.name }}" + data-power="{{ vm.power_state }}" + onclick="handleCardClick(this, event)"> + + +
+ +
@@ -246,14 +318,14 @@
{% endif %} -
+ + +
+
+
0
+
+
+
VMs selected
+
+
+
+
+ + + +
+
+ {% endblock %} {% block scripts %} {% endblock %}