feat: add batch selection UI for virtual machines and create new templates and controller logic
This commit is contained in:
parent
60422ac1a9
commit
7f306c713c
Binary file not shown.
@ -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')
|
||||
|
||||
371
vsphere_backup/templates/batch_job.html
Normal file
371
vsphere_backup/templates/batch_job.html
Normal file
@ -0,0 +1,371 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = 'vms' %}
|
||||
{% block title %}Batch Backup — vSphere Backup Manager{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.batch-wrap { max-width: 760px; }
|
||||
|
||||
.vm-list-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.vm-list-header {
|
||||
padding: 14px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: rgba(255,255,255,0.01);
|
||||
font-size: 14.5px; font-weight: 700;
|
||||
}
|
||||
.vm-list-item {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 13px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.vm-list-item:last-child { border-bottom: none; }
|
||||
.vm-list-num {
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: rgba(99,102,241,0.12);
|
||||
border: 1px solid rgba(99,102,241,0.25);
|
||||
font-size: 11px; font-weight: 700; color: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vm-list-name { font-weight: 600; font-size: 14px; flex: 1; }
|
||||
.vm-list-state { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
.section-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.section-card-header {
|
||||
padding: 14px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(255,255,255,0.01);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-size: 14.5px; font-weight: 700;
|
||||
}
|
||||
.section-card-body { padding: 22px; }
|
||||
|
||||
.disk-strategy-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.strategy-opt {
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
background: rgba(255,255,255,0.01);
|
||||
}
|
||||
.strategy-opt:hover { border-color: var(--border-bright); transform: translateY(-1px); }
|
||||
.strategy-opt.selected { border-color: var(--accent); background: rgba(99,102,241,0.08); box-shadow: 0 4px 12px rgba(99,102,241,0.08); }
|
||||
.strategy-opt input[type=radio] { display: none; }
|
||||
.strategy-icon { font-size: 24px; margin-bottom: 8px; }
|
||||
.strategy-title { font-size: 13px; font-weight: 700; }
|
||||
.strategy-desc { font-size: 11.5px; color: var(--text-muted); margin-top: 3px; }
|
||||
|
||||
.schedule-options {
|
||||
display: grid; grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px; margin-bottom: 16px;
|
||||
}
|
||||
.schedule-opt {
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
background: rgba(255,255,255,0.01);
|
||||
}
|
||||
.schedule-opt:hover { border-color: var(--border-bright); transform: translateY(-1px); }
|
||||
.schedule-opt.selected { border-color: var(--accent); background: rgba(99,102,241,0.08); }
|
||||
.schedule-opt input[type=radio] { display: none; }
|
||||
.schedule-opt-icon { height: 22px; display: flex; align-items: center; }
|
||||
.schedule-opt-title { font-size: 13.5px; font-weight: 700; }
|
||||
.schedule-opt-desc { font-size: 12px; color: var(--text-muted); margin-top: 3px; }
|
||||
.schedule-detail { display: none; margin-top: 14px; }
|
||||
.schedule-detail.visible { display: block; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
|
||||
.action-bar { display: flex; gap: 12px; align-items: center; padding: 20px 0 40px; }
|
||||
|
||||
.jobs-preview {
|
||||
background: rgba(99,102,241,0.05);
|
||||
border: 1px solid rgba(99,102,241,0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<div class="topbar-title">Batch Backup</div>
|
||||
<div class="topbar-subtitle">Configure and launch backups for {{ vm_names|length }} VMs simultaneously</div>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<a href="/vms" class="btn btn-ghost btn-sm">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px;"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
Back to VMs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="batch-wrap">
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }}">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Preview info banner -->
|
||||
<div class="jobs-preview">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle; margin-right:6px; color:var(--accent);"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||||
This will create <strong>{{ vm_names|length }} independent backup job{{ 's' if vm_names|length != 1 }}</strong>, each running in parallel with its own progress and log.
|
||||
</div>
|
||||
|
||||
<!-- VM list -->
|
||||
<div class="vm-list-card">
|
||||
<div class="vm-list-header">
|
||||
<div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle; margin-right:6px;"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
Selected VMs ({{ vm_names|length }})
|
||||
</div>
|
||||
<a href="/vms" style="font-size:12px;color:var(--text-muted);text-decoration:none;">Change selection</a>
|
||||
</div>
|
||||
{% for name in vm_names %}
|
||||
{% set vm = vms_by_name.get(name, {}) %}
|
||||
<div class="vm-list-item">
|
||||
<div class="vm-list-num">{{ loop.index }}</div>
|
||||
<div style="flex:1;">
|
||||
<div class="vm-list-name">{{ name }}</div>
|
||||
<div class="vm-list-state">{{ vm.get('guest_os','') or '' }}</div>
|
||||
</div>
|
||||
{% if vm.get('power_state') == 'poweredOn' %}
|
||||
<span class="badge badge-green">On</span>
|
||||
{% elif vm.get('power_state') == 'poweredOff' %}
|
||||
<span class="badge badge-red">Off</span>
|
||||
{% else %}
|
||||
<span class="badge badge-gray">{{ vm.get('power_state','—') }}</span>
|
||||
{% endif %}
|
||||
{% if vm.get('disks') %}
|
||||
<span style="font-size:12px;color:var(--text-muted);">{{ vm.disks|length }} disk{{ 's' if vm.disks|length != 1 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form method="post" action="/jobs/batch" id="batchForm">
|
||||
<!-- Pass VM names through form -->
|
||||
{% for name in vm_names %}
|
||||
<input type="hidden" name="vms" value="{{ name }}" />
|
||||
{% endfor %}
|
||||
|
||||
<!-- Destination -->
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
Destination
|
||||
</div>
|
||||
<div class="section-card-body">
|
||||
<!-- NFS quick-select -->
|
||||
<div id="nfsTargets" style="margin-bottom:14px; display:none;">
|
||||
<div class="form-label" style="margin-bottom:6px;">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:4px;"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||
NFS Mounts (click to use)
|
||||
</div>
|
||||
<div id="nfsMountList" style="display:flex; gap:8px; flex-wrap:wrap;"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="dest">Base backup path</label>
|
||||
<input id="dest" class="form-control" type="text" name="dest"
|
||||
value="./backups" placeholder="e.g. /mnt/nfs-backup or /data/vmbackups" required />
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-top:6px;">
|
||||
Each VM will be backed up into its own subfolder: <code style="font-family:'JetBrains Mono',monospace;">dest/VM_NAME/</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="compress" name="compress" />
|
||||
<label for="compress">Compress with zstd (smaller files, slower)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disk Strategy -->
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2" ry="2"/><path d="M12 18h.01"/><path d="M8 6h8"/><path d="M8 10h8"/></svg>
|
||||
Disk Strategy
|
||||
</div>
|
||||
<div class="section-card-body">
|
||||
<div class="disk-strategy-grid">
|
||||
<label class="strategy-opt selected" id="strat-all" onclick="selectStrategy('all')">
|
||||
<input type="radio" name="disk_strategy" value="all" checked />
|
||||
<div class="strategy-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><rect x="2" y="2" width="20" height="20" rx="2"/><path d="M8 6h8"/><path d="M8 10h8"/><path d="M8 14h4"/></svg>
|
||||
</div>
|
||||
<div class="strategy-title">All Disks</div>
|
||||
<div class="strategy-desc">Back up every VMDK attached</div>
|
||||
</label>
|
||||
<label class="strategy-opt" id="strat-os" onclick="selectStrategy('os')">
|
||||
<input type="radio" name="disk_strategy" value="os" />
|
||||
<div class="strategy-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--success);"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</div>
|
||||
<div class="strategy-title">OS Disk Only</div>
|
||||
<div class="strategy-desc">Smallest disk per VM<br><span style="color:var(--success);font-weight:600;">Best for ipcam VMs</span></div>
|
||||
</label>
|
||||
<label class="strategy-opt" id="strat-vmx" onclick="selectStrategy('vmx')">
|
||||
<input type="radio" name="disk_strategy" value="vmx" />
|
||||
<div class="strategy-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--text-muted);"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
</div>
|
||||
<div class="strategy-title">Config Only</div>
|
||||
<div class="strategy-desc">VMX file only, no disk data</div>
|
||||
</label>
|
||||
</div>
|
||||
<div style="margin-top:12px;padding:10px 14px;background:rgba(6,182,212,0.06);border:1px solid rgba(6,182,212,0.15);border-radius:var(--radius-sm);font-size:12.5px;color:var(--text-secondary);" id="stratHint">
|
||||
All VMDK disks will be backed up for each VM.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule -->
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
Schedule
|
||||
</div>
|
||||
<div class="section-card-body">
|
||||
<div class="schedule-options">
|
||||
<label class="schedule-opt selected" id="opt-now" onclick="selectSchedule('now')">
|
||||
<input type="radio" name="schedule_type" value="now" checked />
|
||||
<div>
|
||||
<div class="schedule-opt-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
</div>
|
||||
<div class="schedule-opt-title">Run Now</div>
|
||||
<div class="schedule-opt-desc">Start all backups immediately</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="schedule-opt" id="opt-daily" onclick="selectSchedule('daily')">
|
||||
<input type="radio" name="schedule_type" value="daily" />
|
||||
<div>
|
||||
<div class="schedule-opt-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
</div>
|
||||
<div class="schedule-opt-title">Daily</div>
|
||||
<div class="schedule-opt-desc">All VMs at the same time each day</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="schedule-detail" id="detail-daily">
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label class="form-label" for="daily_time">Time (24h)</label>
|
||||
<input id="daily_time" class="form-control" type="time" name="daily_time" value="02:00" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:16px; margin-bottom:0;">
|
||||
<label class="form-label" for="job_label">Label prefix (optional)</label>
|
||||
<input id="job_label" class="form-control" type="text" name="job_label"
|
||||
placeholder="e.g. Nightly ipcam backup" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-bar">
|
||||
<button type="submit" id="submitBtn" class="btn btn-primary">
|
||||
<span id="submitText">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
Launch {{ vm_names|length }} Backup{{ 's' if vm_names|length != 1 }}
|
||||
</span>
|
||||
<span id="submitSpinner" class="spinner" style="display:none;"></span>
|
||||
</button>
|
||||
<a href="/vms" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function selectSchedule(type) {
|
||||
['now','daily'].forEach(t => {
|
||||
document.getElementById('opt-' + t).classList.remove('selected');
|
||||
const d = document.getElementById('detail-' + t);
|
||||
if (d) d.classList.remove('visible');
|
||||
});
|
||||
document.getElementById('opt-' + type).classList.add('selected');
|
||||
document.getElementById('opt-' + type).querySelector('input').checked = true;
|
||||
const detail = document.getElementById('detail-' + type);
|
||||
if (detail) detail.classList.add('visible');
|
||||
}
|
||||
|
||||
const stratHints = {
|
||||
all: 'All VMDK disks will be backed up for each VM.',
|
||||
os: 'Only the smallest disk per VM (OS disk) will be backed up — ideal for ipcam/DVR VMs with large video storage.',
|
||||
vmx: 'Only the VM configuration file (.vmx) will be saved — no disk data.',
|
||||
};
|
||||
function selectStrategy(type) {
|
||||
['all','os','vmx'].forEach(t => document.getElementById('strat-' + t).classList.remove('selected'));
|
||||
document.getElementById('strat-' + type).classList.add('selected');
|
||||
document.getElementById('strat-' + type).querySelector('input').checked = true;
|
||||
document.getElementById('stratHint').textContent = stratHints[type];
|
||||
}
|
||||
|
||||
document.getElementById('batchForm').addEventListener('submit', function() {
|
||||
document.getElementById('submitText').style.display = 'none';
|
||||
document.getElementById('submitSpinner').style.display = 'inline-block';
|
||||
document.getElementById('submitBtn').disabled = true;
|
||||
});
|
||||
|
||||
// Load NFS mounts
|
||||
fetch('/api/nfs')
|
||||
.then(r => r.json())
|
||||
.then(mounts => {
|
||||
if (!mounts || !mounts.length) return;
|
||||
const wrap = document.getElementById('nfsTargets');
|
||||
const list = document.getElementById('nfsMountList');
|
||||
mounts.forEach(m => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-secondary btn-sm';
|
||||
btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg> ${m.mountpoint} <span style="color:var(--text-muted);font-size:11px;margin-left:4px;">${m.free_gb}GB free</span>`;
|
||||
btn.onclick = () => {
|
||||
document.getElementById('dest').value = m.mountpoint;
|
||||
list.querySelectorAll('button').forEach(b => b.style.borderColor = '');
|
||||
btn.style.borderColor = 'var(--accent)';
|
||||
};
|
||||
list.appendChild(btn);
|
||||
});
|
||||
wrap.style.display = '';
|
||||
})
|
||||
.catch(() => {});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -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 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px;"><path d="M23 4v6h-6"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
Refresh
|
||||
</a>
|
||||
<button id="selectModeBtn" class="btn btn-secondary btn-sm" onclick="toggleSelectMode()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px;"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
Select VMs
|
||||
</button>
|
||||
<a href="/jobs/create" class="btn btn-primary btn-sm">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px;"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Create Job
|
||||
@ -202,7 +267,14 @@
|
||||
{% for vm in vms %}
|
||||
<div class="vm-card"
|
||||
data-name="{{ vm.name|lower }}"
|
||||
data-power="{{ vm.power_state }}">
|
||||
data-vmname="{{ vm.name }}"
|
||||
data-power="{{ vm.power_state }}"
|
||||
onclick="handleCardClick(this, event)">
|
||||
|
||||
<!-- Selection checkbox overlay -->
|
||||
<div class="vm-check">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
|
||||
<div class="vm-card-header">
|
||||
<div>
|
||||
@ -246,14 +318,14 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="vm-footer">
|
||||
<div class="vm-footer" id="footer-{{ loop.index }}">
|
||||
<a href="/jobs/create?vm={{ vm.name|urlencode }}"
|
||||
class="btn btn-primary btn-sm">
|
||||
class="btn btn-primary btn-sm" onclick="event.stopPropagation()">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
Backup Now
|
||||
</a>
|
||||
<a href="/jobs/create?vm={{ vm.name|urlencode }}&schedule=1"
|
||||
class="btn btn-secondary btn-sm">
|
||||
class="btn btn-secondary btn-sm" onclick="event.stopPropagation()">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
Schedule
|
||||
</a>
|
||||
@ -269,11 +341,34 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Floating Batch Action Bar ── -->
|
||||
<div class="batch-bar" id="batchBar">
|
||||
<div class="batch-bar-info">
|
||||
<div class="batch-count" id="batchCount">0</div>
|
||||
<div class="batch-divider"></div>
|
||||
<div>
|
||||
<div class="batch-label">VMs selected</div>
|
||||
<div class="batch-vm-list" id="batchVmList"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="batch-actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick="selectAllVisible()">Select All</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="clearSelection()">Clear</button>
|
||||
<button class="btn btn-primary" onclick="batchBackup()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
Backup <span id="batchCountBtn">0</span> VMs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let activeFilter = 'all';
|
||||
let selectMode = false;
|
||||
let selected = new Set();
|
||||
|
||||
function setFilter(filter) {
|
||||
activeFilter = filter;
|
||||
@ -290,5 +385,71 @@
|
||||
card.style.display = (matchFilter && matchSearch) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Batch selection ──────────────────────────────────────────────────────────
|
||||
function toggleSelectMode() {
|
||||
selectMode = !selectMode;
|
||||
const btn = document.getElementById('selectModeBtn');
|
||||
|
||||
if (selectMode) {
|
||||
btn.style.background = 'rgba(99,102,241,0.15)';
|
||||
btn.style.borderColor = 'var(--accent)';
|
||||
btn.style.color = '#a5b4fc';
|
||||
} else {
|
||||
btn.style.background = '';
|
||||
btn.style.borderColor = '';
|
||||
btn.style.color = '';
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.vm-card').forEach(card => {
|
||||
card.classList.toggle('selectable', selectMode);
|
||||
});
|
||||
}
|
||||
|
||||
function handleCardClick(card, e) {
|
||||
if (!selectMode) return;
|
||||
const vmName = card.dataset.vmname;
|
||||
if (selected.has(vmName)) {
|
||||
selected.delete(vmName);
|
||||
card.classList.remove('selected');
|
||||
} else {
|
||||
selected.add(vmName);
|
||||
card.classList.add('selected');
|
||||
}
|
||||
updateBatchBar();
|
||||
}
|
||||
|
||||
function selectAllVisible() {
|
||||
document.querySelectorAll('.vm-card').forEach(card => {
|
||||
if (card.style.display === 'none') return;
|
||||
const vmName = card.dataset.vmname;
|
||||
selected.add(vmName);
|
||||
card.classList.add('selected');
|
||||
});
|
||||
updateBatchBar();
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selected.clear();
|
||||
document.querySelectorAll('.vm-card').forEach(c => c.classList.remove('selected'));
|
||||
updateBatchBar();
|
||||
}
|
||||
|
||||
function updateBatchBar() {
|
||||
const n = selected.size;
|
||||
document.getElementById('batchCount').textContent = n;
|
||||
document.getElementById('batchCountBtn').textContent = n;
|
||||
const names = [...selected].join(', ');
|
||||
document.getElementById('batchVmList').textContent = names;
|
||||
document.getElementById('batchBar').classList.toggle('visible', n > 0);
|
||||
}
|
||||
|
||||
function batchBackup() {
|
||||
if (!selected.size) return;
|
||||
const params = new URLSearchParams();
|
||||
selected.forEach(vm => params.append('vms', vm));
|
||||
window.location.href = '/jobs/batch?' + params.toString();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user