diff --git a/vsphere_backup/__pycache__/gui_app.cpython-310.pyc b/vsphere_backup/__pycache__/gui_app.cpython-310.pyc index 2867b21..9f937a0 100644 Binary files a/vsphere_backup/__pycache__/gui_app.cpython-310.pyc and b/vsphere_backup/__pycache__/gui_app.cpython-310.pyc differ 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
+
+
+ + + Back to VMs + +
+
+ +
+
+ + {% 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 %}