479 lines
20 KiB
HTML
479 lines
20 KiB
HTML
{% 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">🖥 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">
|
|
💾 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>
|
|
<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;">📡 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">🕐 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">⚡</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">📅</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">🗓</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">🔁</div>
|
|
<div class="schedule-opt-title">Interval</div>
|
|
<div class="schedule-opt-desc">Repeat every N hours</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>
|
|
</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">🚀 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','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,'&').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())
|
|
.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 = `📡 ${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 %}
|