feat: implement Flask-based web UI for vSphere VM management, job scheduling, and NFS status monitoring

This commit is contained in:
Rizqi 2026-06-21 04:02:24 +07:00
parent d0d6230d69
commit 8ae38a42fb
6 changed files with 209 additions and 18 deletions

View File

@ -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)

View File

@ -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/<vm_name>/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

View File

@ -135,7 +135,8 @@
<div class="section-card-body">
<div class="form-group" style="margin:0;">
<label class="form-label" for="vm_name">Select VM to back up</label>
<select id="vm_name" name="vm_name" class="form-control" required>
<select id="vm_name" name="vm_name" class="form-control" required
onchange="onVmChange(this.value)">
<option value="">— Choose a VM —</option>
{% for vm in vms %}
<option value="{{ vm.name }}"
@ -150,6 +151,27 @@
</div>
</div>
<!-- Disk Selection (shown after VM pick) -->
<input type="hidden" name="disk_selection_shown" id="disk_selection_shown" value="" />
<div class="section-card" id="diskCard" style="display:none;">
<div class="section-card-header">
💾 Select Disks to Backup
<span id="diskCardBadge" style="margin-left:auto;font-size:12px;color:var(--text-muted);font-weight:500;"></span>
</div>
<div class="section-card-body">
<div id="diskLoader" style="text-align:center;padding:24px;color:var(--text-muted);">
<span class="spinner" style="vertical-align:middle;margin-right:8px;"></span> Loading disk info…
</div>
<div id="diskList"></div>
<div id="diskTip" style="display:none;margin-top:14px;padding:12px 16px;
background:rgba(6,182,212,0.06);border:1px solid rgba(6,182,212,0.15);
border-radius:var(--radius-sm);font-size:13px;color:var(--text-secondary);">
💡 <strong>Tip:</strong> Uncheck large data disks (e.g. video storage) to skip them.
The VM config (.vmx) is always included. For ipcam VMs, keep only the small OS disk checked.
</div>
</div>
</div>
<!-- Destination -->
<div class="section-card">
<div class="section-card-header">📁 Destination</div>
@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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 =
'<div style="color:var(--text-muted);font-size:13px;">No virtual disks found on this VM.</div>';
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
? ' <span style="font-size:10px;color:var(--success);font-weight:700;margin-left:4px;">OS</span>'
: '';
html += `<div style="display:flex;align-items:center;gap:12px;padding:12px 0;
border-bottom:${i < disks.length-1 ? '1px solid var(--border)' : 'none'}">
<input type="checkbox" id="disk_${i}" name="disk_filter"
value="${escHtml(disk.path)}" checked
style="width:18px;height:18px;accent-color:var(--accent);flex-shrink:0;cursor:pointer;" />
<label for="disk_${i}" style="flex:1;cursor:pointer;">
<div style="font-weight:600;font-size:13.5px;">${escHtml(disk.label)}${hint}</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:11.5px;
color:var(--text-muted);margin-top:3px;">${escHtml(disk.path)}</div>
</label>
<span style="background:rgba(255,255,255,0.04);border:1px solid var(--border);
padding:4px 10px;border-radius:100px;font-size:12px;
font-weight:700;color:${sizeColor};flex-shrink:0;">${sizeLabel}</span>
</div>`;
});
html += `<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap;">
<button type="button" class="btn btn-secondary btn-sm" onclick="selectAllDisks(true)">☑ All</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="selectAllDisks(false)">☐ None</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="selectOsOnly()"
title="Select only the smallest disk (usually the OS disk)">🖥 OS Only</button>
</div>`;
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 =
'<div style="color:var(--text-muted);font-size:13px;">⚠ Failed to load disk list</div>';
});
}
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())

View File

@ -184,6 +184,18 @@
{% if job.sftp_host %} · 📤 SFTP: {{ job.sftp_host }}{% endif %}
</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Disks</div>
<div class="detail-item-val" style="font-size:13px;">
{% if job.disks_count is none %}
All disks
{% elif job.disks_count == 0 %}
<span style="color:var(--warning);">VMX only (0 disks)</span>
{% else %}
{{ job.disks_count }} disk{{ 's' if job.disks_count != 1 else '' }} selected
{% endif %}
</div>
</div>
</div>
{% if job.schedule_id %}