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,22 +62,45 @@ def list_vms(host, user, password, no_verify_ssl=False):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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({
|
vms.append({
|
||||||
'name': config.name,
|
'name': config.name,
|
||||||
'power_state': power_state,
|
'power_state': power_state,
|
||||||
'num_cpu': config.numCpu,
|
'num_cpu': config.numCpu,
|
||||||
'memory_mb': config.memorySizeMB,
|
'memory_mb': config.memorySizeMB,
|
||||||
'guest_os': config.guestFullName or config.guestId or 'Unknown',
|
'guest_os': config.guestFullName or config.guestId or 'Unknown',
|
||||||
'ip_address': (guest.ipAddress or '') if guest else '',
|
'ip_address': (guest.ipAddress or '') if guest else '',
|
||||||
'datastores': ds_names,
|
'datastores': ds_names,
|
||||||
'committed_gb': round((storage.committed or 0) / (1024 ** 3), 2),
|
'committed_gb': round((storage.committed or 0) / (1024 ** 3), 2),
|
||||||
'tools_status': (guest.toolsStatus or 'unknown') if guest else 'unknown',
|
'tools_status': (guest.toolsStatus or 'unknown') if guest else 'unknown',
|
||||||
|
'disks': disks,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
vms.append({'name': getattr(vm, 'name', '?'), 'error': str(e),
|
vms.append({'name': getattr(vm, 'name', '?'), 'error': str(e),
|
||||||
'power_state': 'unknown', 'num_cpu': 0,
|
'power_state': 'unknown', 'num_cpu': 0,
|
||||||
'memory_mb': 0, 'guest_os': '', 'ip_address': '',
|
'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()
|
obj_view.Destroy()
|
||||||
return vms
|
return vms
|
||||||
finally:
|
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,
|
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,
|
sftp_host=None, sftp_user=None, sftp_password=None, sftp_key=None,
|
||||||
log_path=None, progress_cb=None):
|
log_path=None, progress_cb=None, disk_filter=None):
|
||||||
"""Run full backup flow. progress_cb(phase, pct, detail) is called with live status updates."""
|
"""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:
|
if log_path:
|
||||||
logfile = open(log_path, 'ab')
|
logfile = open(log_path, 'ab')
|
||||||
def _wrap():
|
def _wrap():
|
||||||
with redirect_stdout(logfile), redirect_stderr(logfile):
|
with redirect_stdout(logfile), redirect_stderr(logfile):
|
||||||
return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl,
|
return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl,
|
||||||
sftp_host, sftp_user, sftp_password, sftp_key,
|
sftp_host, sftp_user, sftp_password, sftp_key,
|
||||||
progress_cb=progress_cb)
|
progress_cb=progress_cb, disk_filter=disk_filter)
|
||||||
try:
|
try:
|
||||||
return _wrap()
|
return _wrap()
|
||||||
finally:
|
finally:
|
||||||
@ -246,11 +272,12 @@ def run_backup(host, user, password, vm_name, dest, compress=False, no_verify_ss
|
|||||||
else:
|
else:
|
||||||
return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl,
|
return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl,
|
||||||
sftp_host, sftp_user, sftp_password, sftp_key,
|
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,
|
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=''):
|
def _prog(phase, pct, detail=''):
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
try:
|
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')
|
raise Exception('Could not extract session cookie for downloads')
|
||||||
|
|
||||||
vmdk_refs = vm_disk_vmdk_paths(vm)
|
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[:]
|
all_refs = vmdk_refs[:]
|
||||||
if vmx_ref:
|
if vmx_ref:
|
||||||
all_refs.append(vmx_ref)
|
all_refs.append(vmx_ref)
|
||||||
|
|||||||
@ -203,6 +203,7 @@ def fmt_time(ts):
|
|||||||
|
|
||||||
def job_to_display(jid, info):
|
def job_to_display(jid, info):
|
||||||
"""Convert internal job dict to template-friendly dict."""
|
"""Convert internal job dict to template-friendly dict."""
|
||||||
|
disk_filter = info.get('disk_filter')
|
||||||
return {
|
return {
|
||||||
'id': jid,
|
'id': jid,
|
||||||
'label': info.get('label', ''),
|
'label': info.get('label', ''),
|
||||||
@ -215,6 +216,8 @@ def job_to_display(jid, info):
|
|||||||
'schedule_type': info.get('schedule_type', 'now'),
|
'schedule_type': info.get('schedule_type', 'now'),
|
||||||
'schedule_time': info.get('schedule_time', ''),
|
'schedule_time': info.get('schedule_time', ''),
|
||||||
'schedule_id': info.get('schedule_id'),
|
'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,
|
sftp_key=None,
|
||||||
log_path=log_path,
|
log_path=log_path,
|
||||||
progress_cb=progress_cb,
|
progress_cb=progress_cb,
|
||||||
|
disk_filter=info.get('disk_filter'), # None = all disks
|
||||||
)
|
)
|
||||||
info['status'] = 'finished'
|
info['status'] = 'finished'
|
||||||
info['progress'] = {'pct': 100, 'phase': 'done', 'detail': 'Backup completed successfully'}
|
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,
|
vm_name, dest, compress, no_verify_ssl,
|
||||||
sftp_host, sftp_user, sftp_password,
|
sftp_host, sftp_user, sftp_password,
|
||||||
schedule_type, schedule_time, weekly_day, interval_hours,
|
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]
|
jid = datetime.now().strftime('%Y%m%d%H%M%S') + '-' + uuid.uuid4().hex[:6]
|
||||||
job_dir = JOBS_DIR / jid
|
job_dir = JOBS_DIR / jid
|
||||||
job_dir.mkdir(parents=True, exist_ok=True)
|
job_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@ -282,6 +288,7 @@ def create_and_start_job(
|
|||||||
'schedule_type': schedule_type,
|
'schedule_type': schedule_type,
|
||||||
'schedule_time': schedule_time,
|
'schedule_time': schedule_time,
|
||||||
'schedule_id': None,
|
'schedule_id': None,
|
||||||
|
'disk_filter': disk_filter, # None = back up all disks
|
||||||
}
|
}
|
||||||
jobs[jid] = info
|
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})
|
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 ────────────────────────────────────────────────────────────────
|
# ── Create Job ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route('/jobs/create', methods=['GET', 'POST'])
|
@app.route('/jobs/create', methods=['GET', 'POST'])
|
||||||
@ -435,6 +456,14 @@ def create_job():
|
|||||||
else:
|
else:
|
||||||
sched_time = ''
|
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(
|
jid = create_and_start_job(
|
||||||
vm_name=vm_name,
|
vm_name=vm_name,
|
||||||
dest=dest,
|
dest=dest,
|
||||||
@ -448,8 +477,10 @@ def create_job():
|
|||||||
weekly_day=weekly_day,
|
weekly_day=weekly_day,
|
||||||
interval_hours=interval_hrs,
|
interval_hours=interval_hrs,
|
||||||
label=label,
|
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))
|
return redirect(url_for('job_detail', jobid=jid))
|
||||||
|
|
||||||
# GET: load VM list for the dropdown
|
# GET: load VM list for the dropdown
|
||||||
|
|||||||
@ -135,7 +135,8 @@
|
|||||||
<div class="section-card-body">
|
<div class="section-card-body">
|
||||||
<div class="form-group" style="margin:0;">
|
<div class="form-group" style="margin:0;">
|
||||||
<label class="form-label" for="vm_name">Select VM to back up</label>
|
<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>
|
<option value="">— Choose a VM —</option>
|
||||||
{% for vm in vms %}
|
{% for vm in vms %}
|
||||||
<option value="{{ vm.name }}"
|
<option value="{{ vm.name }}"
|
||||||
@ -150,6 +151,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Destination -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div class="section-card-header">📁 Destination</div>
|
<div class="section-card-header">📁 Destination</div>
|
||||||
@ -338,10 +360,98 @@
|
|||||||
selectSchedule('daily');
|
selectSchedule('daily');
|
||||||
{% endif %}
|
{% 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');
|
const urlDest = new URLSearchParams(window.location.search).get('dest');
|
||||||
if (urlDest) document.getElementById('dest').value = urlDest;
|
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
|
// Load NFS mounts for quick-select
|
||||||
fetch('/api/nfs')
|
fetch('/api/nfs')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
|
|||||||
@ -184,6 +184,18 @@
|
|||||||
{% if job.sftp_host %} · 📤 SFTP: {{ job.sftp_host }}{% endif %}
|
{% if job.sftp_host %} · 📤 SFTP: {{ job.sftp_host }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{% if job.schedule_id %}
|
{% if job.schedule_id %}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user