diff --git a/vsphere_backup/__pycache__/backup_core.cpython-310.pyc b/vsphere_backup/__pycache__/backup_core.cpython-310.pyc index 5efcc76..d18e10d 100644 Binary files a/vsphere_backup/__pycache__/backup_core.cpython-310.pyc and b/vsphere_backup/__pycache__/backup_core.cpython-310.pyc differ diff --git a/vsphere_backup/__pycache__/gui_app.cpython-310.pyc b/vsphere_backup/__pycache__/gui_app.cpython-310.pyc index 1dcbf16..2867b21 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/backup_core.py b/vsphere_backup/backup_core.py index 035d8c2..fda62fb 100644 --- a/vsphere_backup/backup_core.py +++ b/vsphere_backup/backup_core.py @@ -62,22 +62,45 @@ def list_vms(host, user, password, no_verify_ssl=False): except Exception: pass + # Disk info (label, path, size) + disks = [] + try: + for dev in vm.config.hardware.device: + if isinstance(dev, vim.vm.device.VirtualDisk): + fn = getattr(dev.backing, 'fileName', None) + if not fn: + continue + label = '' + if dev.deviceInfo: + label = dev.deviceInfo.label or '' + size_kb = getattr(dev, 'capacityInKB', 0) or 0 + disks.append({ + 'label': label or f'Hard disk {dev.unitNumber}', + 'path': fn, + 'size_gb': round(size_kb / (1024 * 1024), 1), + 'unit': dev.unitNumber, + }) + except Exception: + pass + vms.append({ - 'name': config.name, + 'name': config.name, 'power_state': power_state, - 'num_cpu': config.numCpu, - 'memory_mb': config.memorySizeMB, - 'guest_os': config.guestFullName or config.guestId or 'Unknown', - 'ip_address': (guest.ipAddress or '') if guest else '', - 'datastores': ds_names, + 'num_cpu': config.numCpu, + 'memory_mb': config.memorySizeMB, + 'guest_os': config.guestFullName or config.guestId or 'Unknown', + 'ip_address': (guest.ipAddress or '') if guest else '', + 'datastores': ds_names, 'committed_gb': round((storage.committed or 0) / (1024 ** 3), 2), 'tools_status': (guest.toolsStatus or 'unknown') if guest else 'unknown', + 'disks': disks, }) except Exception as e: vms.append({'name': getattr(vm, 'name', '?'), 'error': str(e), 'power_state': 'unknown', 'num_cpu': 0, 'memory_mb': 0, 'guest_os': '', 'ip_address': '', - 'datastores': [], 'committed_gb': 0, 'tools_status': 'unknown'}) + 'datastores': [], 'committed_gb': 0, + 'tools_status': 'unknown', 'disks': []}) obj_view.Destroy() return vms finally: @@ -230,15 +253,18 @@ 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): - """Run full backup flow. progress_cb(phase, pct, detail) is called with live status updates.""" + log_path=None, progress_cb=None, disk_filter=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. + """ if log_path: logfile = open(log_path, 'ab') def _wrap(): 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) + progress_cb=progress_cb, disk_filter=disk_filter) try: return _wrap() finally: @@ -246,11 +272,12 @@ 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) + progress_cb=progress_cb, disk_filter=disk_filter) 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): + sftp_host, sftp_user, sftp_password, sftp_key, + progress_cb=None, disk_filter=None): def _prog(phase, pct, detail=''): if progress_cb: try: @@ -288,7 +315,18 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss raise Exception('Could not extract session cookie for downloads') vmdk_refs = vm_disk_vmdk_paths(vm) - vmx_ref = vm_config_vmx_path(vm) + vmx_ref = vm_config_vmx_path(vm) + + # Apply disk filter — only download selected VMDKs + if disk_filter is not None: + disk_filter_set = set(disk_filter) + skipped = [r for r in vmdk_refs if r not in disk_filter_set] + vmdk_refs = [r for r in vmdk_refs if r in disk_filter_set] + if skipped: + print(f"Skipping {len(skipped)} disk(s) per disk_filter: {skipped}") + if not vmdk_refs: + print("Warning: no disks selected — backing up VMX config only.") + all_refs = vmdk_refs[:] if vmx_ref: all_refs.append(vmx_ref) diff --git a/vsphere_backup/gui_app.py b/vsphere_backup/gui_app.py index 170de2a..0619ff5 100644 --- a/vsphere_backup/gui_app.py +++ b/vsphere_backup/gui_app.py @@ -203,6 +203,7 @@ def fmt_time(ts): def job_to_display(jid, info): """Convert internal job dict to template-friendly dict.""" + disk_filter = info.get('disk_filter') return { 'id': jid, 'label': info.get('label', ''), @@ -215,6 +216,8 @@ def job_to_display(jid, info): 'schedule_type': info.get('schedule_type', 'now'), 'schedule_time': info.get('schedule_time', ''), 'schedule_id': info.get('schedule_id'), + 'disk_filter': disk_filter, + 'disks_count': len(disk_filter) if disk_filter is not None else None, } @@ -246,6 +249,7 @@ def run_job_thread(jid): sftp_key=None, log_path=log_path, progress_cb=progress_cb, + disk_filter=info.get('disk_filter'), # None = all disks ) info['status'] = 'finished' info['progress'] = {'pct': 100, 'phase': 'done', 'detail': 'Backup completed successfully'} @@ -257,9 +261,11 @@ def create_and_start_job( vm_name, dest, compress, no_verify_ssl, sftp_host, sftp_user, sftp_password, schedule_type, schedule_time, weekly_day, interval_hours, - label='' + label='', disk_filter=None ): - """Create a job entry and either run immediately or register schedule.""" + """Create a job entry and either run immediately or register schedule. + disk_filter: list of VMDK path strings to include, or None for all. + """ jid = datetime.now().strftime('%Y%m%d%H%M%S') + '-' + uuid.uuid4().hex[:6] job_dir = JOBS_DIR / jid job_dir.mkdir(parents=True, exist_ok=True) @@ -282,6 +288,7 @@ def create_and_start_job( 'schedule_type': schedule_type, 'schedule_time': schedule_time, 'schedule_id': None, + 'disk_filter': disk_filter, # None = back up all disks } jobs[jid] = info @@ -403,6 +410,20 @@ def api_vms(): return jsonify({'vms': vm_list, 'cache_age': int(time.time() - cache_ts) if cache_ts else None}) +@app.route('/api/vm//disks') +@login_required +def api_vm_disks(vm_name): + """Return disk list for a specific VM (from cache).""" + vm_list, error, _ = get_cached_vms( + session['host'], session['user'], session['password'], + no_verify_ssl=session.get('no_verify_ssl', False) + ) + for vm in vm_list: + if vm['name'] == vm_name: + return jsonify(vm.get('disks', [])) + return jsonify({'error': f'VM "{vm_name}" not found'}), 404 + + # ── Create Job ──────────────────────────────────────────────────────────────── @app.route('/jobs/create', methods=['GET', 'POST']) @@ -435,6 +456,14 @@ def create_job(): else: sched_time = '' + # disk_filter: None = all disks; list = selected disks only + disk_selection_shown = 'disk_selection_shown' in request.form + if disk_selection_shown: + raw_filter = request.form.getlist('disk_filter') + disk_filter = raw_filter if raw_filter else None + else: + disk_filter = None # disks not shown yet = backup all + jid = create_and_start_job( vm_name=vm_name, dest=dest, @@ -448,8 +477,10 @@ def create_job(): weekly_day=weekly_day, interval_hours=interval_hrs, label=label, + disk_filter=disk_filter, ) - flash(f'Job created successfully!', 'success') + n_disks = len(disk_filter) if disk_filter is not None else 'all' + flash(f'Job created — {n_disks} disk(s) selected.', 'success') return redirect(url_for('job_detail', jobid=jid)) # GET: load VM list for the dropdown diff --git a/vsphere_backup/templates/create_job.html b/vsphere_backup/templates/create_job.html index 7da9dec..8dc9f8f 100644 --- a/vsphere_backup/templates/create_job.html +++ b/vsphere_backup/templates/create_job.html @@ -135,7 +135,8 @@
- {% for vm in vms %}
+ + + +
📁 Destination
@@ -338,10 +360,98 @@ selectSchedule('daily'); {% endif %} - // Pre-fill dest from ?dest= query param (e.g. from NFS manager "Use as Target") + // Pre-fill dest from ?dest= query param const urlDest = new URLSearchParams(window.location.search).get('dest'); if (urlDest) document.getElementById('dest').value = urlDest; + // ── Disk Selection ────────────────────────────────────────────────────────── + function escHtml(s) { + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + function onVmChange(vmName) { + const diskCard = document.getElementById('diskCard'); + if (!vmName) { + diskCard.style.display = 'none'; + document.getElementById('disk_selection_shown').value = ''; + return; + } + diskCard.style.display = ''; + document.getElementById('disk_selection_shown').value = '1'; + document.getElementById('diskLoader').style.display = 'block'; + document.getElementById('diskList').innerHTML = ''; + document.getElementById('diskTip').style.display = 'none'; + document.getElementById('diskCardBadge').textContent = ''; + + fetch('/api/vm/' + encodeURIComponent(vmName) + '/disks') + .then(r => r.json()) + .then(disks => { + document.getElementById('diskLoader').style.display = 'none'; + if (!Array.isArray(disks) || !disks.length) { + document.getElementById('diskList').innerHTML = + '
No virtual disks found on this VM.
'; + return; + } + + // Sort smallest first (OS disk is usually smallest) + disks.sort((a, b) => a.size_gb - b.size_gb); + + let html = ''; + disks.forEach((disk, i) => { + const sizeLabel = disk.size_gb >= 1000 + ? (disk.size_gb/1024).toFixed(1) + ' TB' + : disk.size_gb + ' GB'; + const sizeColor = disk.size_gb > 100 ? 'var(--warning)' : 'var(--success)'; + const hint = i === 0 + ? ' OS' + : ''; + html += `
+ + + ${sizeLabel} +
`; + }); + html += `
+ + + +
`; + + document.getElementById('diskList').innerHTML = html; + document.getElementById('diskTip').style.display = ''; + document.getElementById('diskCardBadge').textContent = + disks.length + ' disk' + (disks.length > 1 ? 's' : '') + ' found'; + }) + .catch(() => { + document.getElementById('diskLoader').style.display = 'none'; + document.getElementById('diskList').innerHTML = + '
⚠ Failed to load disk list
'; + }); + } + + function selectAllDisks(checked) { + document.querySelectorAll('input[name="disk_filter"]').forEach(cb => cb.checked = checked); + } + function selectOsOnly() { + // Smallest disk is first (sorted above) + const cbs = document.querySelectorAll('input[name="disk_filter"]'); + cbs.forEach((cb, i) => { cb.checked = (i === 0); }); + } + + // Auto-trigger disk load if VM pre-selected (from ?vm=) + const initVm = document.getElementById('vm_name').value; + if (initVm) onVmChange(initVm); + // Load NFS mounts for quick-select fetch('/api/nfs') .then(r => r.json()) diff --git a/vsphere_backup/templates/job_detail.html b/vsphere_backup/templates/job_detail.html index 37e9ff8..46c0cba 100644 --- a/vsphere_backup/templates/job_detail.html +++ b/vsphere_backup/templates/job_detail.html @@ -184,6 +184,18 @@ {% if job.sftp_host %} · 📤 SFTP: {{ job.sftp_host }}{% endif %}
+
+
Disks
+
+ {% if job.disks_count is none %} + All disks + {% elif job.disks_count == 0 %} + VMX only (0 disks) + {% else %} + {{ job.disks_count }} disk{{ 's' if job.disks_count != 1 else '' }} selected + {% endif %} +
+
{% if job.schedule_id %}