vSphere-Backup-Manager/vsphere_backup/templates/create_job.html

533 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% set active_page = 'create_job' %}
{% block title %}Create Backup Job — vSphere Backup Manager{% endblock %}
{% block head %}
<style>
.wizard-wrap { max-width: 720px; }
.wizard-steps {
display: flex; align-items: center; gap: 0;
margin-bottom: 32px; counter-reset: step;
}
.step {
display: flex; align-items: center; gap: 12px;
flex: 1; position: relative;
}
.step:not(:last-child)::after {
content: '';
flex: 1; height: 2px;
background: var(--border);
margin: 0 16px;
border-radius: 2px;
}
.step.done:not(:last-child)::after { background: var(--accent); }
.step-num {
width: 32px; height: 32px; border-radius: 50%;
border: 2px solid var(--border);
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 700; flex-shrink: 0;
color: var(--text-muted);
transition: all 0.25s ease;
}
.step.active .step-num { border-color: var(--accent); color: #ffffff; background: var(--accent); box-shadow: 0 0 12px var(--accent-glow); }
.step.done .step-num { border-color: var(--accent-2); background: var(--accent-2); color: #ffffff; box-shadow: 0 0 12px rgba(6, 182, 212, 0.25); }
.step-label { font-size: 13.5px; font-weight: 600; color: var(--text-muted); }
.step.active .step-label { color: var(--text-primary); }
.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: 16px 24px;
border-bottom: 1px solid var(--border);
background: rgba(255,255,255,0.01);
display: flex; align-items: center; gap: 12px;
font-size: 14.5px; font-weight: 700;
letter-spacing: -0.01em;
}
.section-card-body { padding: 24px; }
.schedule-options {
display: grid; grid-template-columns: repeat(2, 1fr);
gap: 12px; margin-bottom: 20px;
}
.schedule-opt {
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
padding: 16px 20px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex; align-items: flex-start; gap: 12px;
background: rgba(255, 255, 255, 0.01);
}
.schedule-opt:hover { border-color: var(--border-bright); background: var(--bg-card-hover); transform: translateY(-1px); }
.schedule-opt.selected { border-color: var(--accent); background: rgba(99, 102, 241, 0.08); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.05); }
.schedule-opt input[type=radio] { display: none; }
.schedule-opt-icon { font-size: 22px; }
.schedule-opt-title { font-size: 14.5px; font-weight: 700; }
.schedule-opt-desc { font-size: 12px; color: var(--text-muted); margin-top: 3px; font-weight: 500; }
.schedule-detail { display: none; }
.schedule-detail.visible { display: block; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.sftp-toggle-btn {
background: none; border: none; cursor: pointer;
color: var(--accent-2); font-size: 13.5px; font-weight: 600;
display: inline-flex; align-items: center; gap: 8px;
padding: 4px 8px; margin-top: 8px;
transition: color 0.2s;
border-radius: var(--radius-sm);
}
.sftp-toggle-btn:hover {
color: var(--accent);
background: rgba(255,255,255,0.02);
}
.sftp-section { display: none; margin-top: 20px; }
.sftp-section.visible { display: block; }
.action-bar {
display: flex; gap: 14px; align-items: center;
padding: 24px 0 0;
}
</style>
{% endblock %}
{% block content %}
<div class="topbar">
<div>
<div class="topbar-title">Create Backup Job</div>
<div class="topbar-subtitle">Configure a new backup job for a virtual machine</div>
</div>
</div>
<div class="content">
<div class="wizard-wrap">
<!-- Progress steps -->
<div class="wizard-steps">
<div class="step done">
<div class="step-num"></div>
<div class="step-label">Connected</div>
</div>
<div class="step active">
<div class="step-num">2</div>
<div class="step-label">Configure Job</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-label">Review & Run</div>
</div>
</div>
<form method="post" action="/jobs/create" id="jobForm">
<!-- VM Selection -->
<div class="section-card">
<div class="section-card-header">
<svg width="16" height="16" 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>
Virtual Machine
</div>
<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
onchange="onVmChange(this.value)">
<option value="">— Choose a VM —</option>
{% for vm in vms %}
<option value="{{ vm.name }}"
{% if vm.name == selected_vm %}selected{% endif %}>
{{ vm.name }}
{% if vm.power_state == 'poweredOn' %}🟢{% elif vm.power_state == 'poweredOff' %}🔴{% else %}🟡{% endif %}
({{ vm.guest_os[:30] if vm.guest_os else 'Unknown' }})
</option>
{% endfor %}
</select>
</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">
<svg width="16" height="16" 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="2" width="20" height="20" rx="2" ry="2"/><path d="M12 18h.01"/><path d="M8 6h8"/><path d="M8 10h8"/></svg>
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);">
<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: 4px;"><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>
<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">
<svg width="16" height="16" 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;"><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="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="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 as destination)
</div>
<div id="nfsMountList" style="display:flex; gap:8px; flex-wrap:wrap;"></div>
</div>
<div class="form-group">
<label class="form-label" for="dest">Local 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>
<div class="form-check">
<input type="checkbox" id="compress" name="compress" />
<label for="compress">Compress with zstd (smaller files, slower)</label>
</div>
<div class="form-check">
<input type="checkbox" id="no_verify_ssl" name="no_verify_ssl"
{% if session.get('no_verify_ssl') %}checked{% endif %} />
<label for="no_verify_ssl">Skip SSL certificate verification</label>
</div>
<!-- SFTP Toggle -->
<button type="button" class="sftp-toggle-btn" onclick="toggleSFTP()">
<span id="sftpToggleIcon"></span>
Upload to SFTP server (optional)
</button>
<div class="sftp-section" id="sftpSection">
<div class="form-row" style="margin-top:14px;">
<div class="form-group" style="margin:0;">
<label class="form-label" for="sftp_host">SFTP Host</label>
<input id="sftp_host" class="form-control" type="text" name="sftp_host" placeholder="sftp.example.com" />
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" for="sftp_user">SFTP Username</label>
<input id="sftp_user" class="form-control" type="text" name="sftp_user" placeholder="backupuser" />
</div>
</div>
<div class="form-group" style="margin-top:12px;">
<label class="form-label" for="sftp_password">SFTP Password</label>
<input id="sftp_password" class="form-control" type="password" name="sftp_password" placeholder="••••••••" />
</div>
</div>
</div>
</div>
<!-- Schedule -->
<div class="section-card">
<div class="section-card-header">
<svg width="16" height="16" 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;"><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 {% if not show_schedule %}selected{% endif %}" id="opt-now" onclick="selectSchedule('now')">
<input type="radio" name="schedule_type" value="now" {% if not show_schedule %}checked{% endif %} />
<div>
<div class="schedule-opt-icon" style="height: 22px; display: flex; align-items: center;">
<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 the backup immediately</div>
</div>
</label>
<label class="schedule-opt {% if show_schedule %}selected{% endif %}" id="opt-daily" onclick="selectSchedule('daily')">
<input type="radio" name="schedule_type" value="daily" {% if show_schedule %}checked{% endif %}/>
<div>
<div class="schedule-opt-icon" style="height: 22px; display: flex; align-items: center;">
<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">Repeat every day at a set time</div>
</div>
</label>
<label class="schedule-opt" id="opt-weekly" onclick="selectSchedule('weekly')">
<input type="radio" name="schedule_type" value="weekly" />
<div>
<div class="schedule-opt-icon" style="height: 22px; display: flex; align-items: center;">
<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"/><path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/><path d="M8 18h.01"/><path d="M12 18h.01"/><path d="M16 18h.01"/></svg>
</div>
<div class="schedule-opt-title">Weekly</div>
<div class="schedule-opt-desc">Repeat every week on a specific day</div>
</div>
</label>
<label class="schedule-opt" id="opt-interval" onclick="selectSchedule('interval')">
<input type="radio" name="schedule_type" value="interval" />
<div>
<div class="schedule-opt-icon" style="height: 22px; display: flex; align-items: center;">
<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);"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
</div>
<div class="schedule-opt-title">Interval</div>
<div class="schedule-opt-desc">Repeat every N hours</div>
</div>
</label>
<label class="schedule-opt" id="opt-monthly" onclick="selectSchedule('monthly')">
<input type="radio" name="schedule_type" value="monthly" />
<div>
<div class="schedule-opt-icon" style="height: 22px; display: flex; align-items: center;">
<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"/><path d="M8 14h4"/><path d="M8 18h2"/></svg>
</div>
<div class="schedule-opt-title">Monthly</div>
<div class="schedule-opt-desc">Specific day each month</div>
</div>
</label>
</div>
<!-- Daily detail -->
<div class="schedule-detail {% if show_schedule %}visible{% endif %}" 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>
<!-- Weekly detail -->
<div class="schedule-detail" id="detail-weekly">
<div class="form-row">
<div class="form-group" style="margin:0;">
<label class="form-label" for="weekly_day">Day of Week</label>
<select id="weekly_day" class="form-control" name="weekly_day">
<option value="0">Monday</option>
<option value="1">Tuesday</option>
<option value="2">Wednesday</option>
<option value="3">Thursday</option>
<option value="4">Friday</option>
<option value="5">Saturday</option>
<option value="6">Sunday</option>
</select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" for="weekly_time">Time (24h)</label>
<input id="weekly_time" class="form-control" type="time" name="weekly_time" value="02:00" />
</div>
</div>
</div>
<!-- Interval detail -->
<div class="schedule-detail" id="detail-interval">
<div class="form-row">
<div class="form-group" style="margin:0;">
<label class="form-label" for="interval_hours">Every (hours)</label>
<input id="interval_hours" class="form-control" type="number"
name="interval_hours" value="24" min="1" max="8760" />
<div style="font-size:12px;color:var(--text-muted);margin-top:4px;">
e.g. 6 = every 6h &middot; 12 = twice daily &middot; 168 = weekly
</div>
</div>
</div>
</div>
<!-- Monthly detail -->
<div class="schedule-detail" id="detail-monthly">
<div class="form-row">
<div class="form-group" style="margin:0;">
<label class="form-label" for="monthly_day">Day of month (128)</label>
<input id="monthly_day" class="form-control" type="number"
name="monthly_day" min="1" max="28" value="1" />
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" for="monthly_time">Time (24h)</label>
<input id="monthly_time" class="form-control" type="time" name="monthly_time" value="02:00" />
</div>
</div>
</div>
<!-- Job label -->
<div class="form-group" style="margin-top: 18px; margin-bottom:0">
<label class="form-label" for="job_label">Job label (optional)</label>
<input id="job_label" class="form-control" type="text" name="job_label"
placeholder="e.g. Nightly web-server 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; vertical-align: middle;"><polyline points="20 6 9 17 4 12"/></svg>
Create Job
</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','weekly','monthly','interval'].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');
}
function toggleSFTP() {
const sec = document.getElementById('sftpSection');
const ico = document.getElementById('sftpToggleIcon');
sec.classList.toggle('visible');
ico.textContent = sec.classList.contains('visible') ? '▼' : '▶';
}
document.getElementById('jobForm').addEventListener('submit', function() {
document.getElementById('submitText').textContent = 'Starting…';
document.getElementById('submitSpinner').style.display = 'inline-block';
document.getElementById('submitBtn').disabled = true;
});
{% if show_schedule %}
selectSchedule('daily');
{% endif %}
// 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())
.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: 6px; vertical-align: middle;"><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 %}