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
+
+
+
+
+
+
+
+ {% 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.
+
+
+
+
+
+ {% 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 %}
+
+
+
+
+
+
+{% 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
+
+
+ Select VMs
+
Create Job
@@ -202,7 +267,14 @@
{% for vm in vms %}
+ data-vmname="{{ vm.name }}"
+ data-power="{{ vm.power_state }}"
+ onclick="handleCardClick(this, event)">
+
+
+