feat: implement Flask-based web UI for vSphere VM management, job scheduling, and NFS status monitoring
This commit is contained in:
parent
d0d6230d69
commit
8ae38a42fb
Binary file not shown.
Binary file not shown.
@ -62,6 +62,27 @@ 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,
|
||||
'power_state': power_state,
|
||||
@ -72,12 +93,14 @@ def list_vms(host, user, password, no_verify_ssl=False):
|
||||
'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:
|
||||
@ -289,6 +316,17 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss
|
||||
|
||||
vmdk_refs = vm_disk_vmdk_paths(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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,'&').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 =
|
||||
'<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())
|
||||
|
||||
@ -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 %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user