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

355 lines
13 KiB
HTML

{% extends "base.html" %}
{% set active_page = 'jobs' %}
{% block title %}Job {{ job.id[:8] }} — vSphere Backup Manager{% endblock %}
{% block head %}
<style>
.detail-grid {
display: grid; grid-template-columns: 1fr 1fr;
gap: 16px; margin-bottom: 24px;
}
.detail-item {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 16px 20px;
box-shadow: var(--shadow);
backdrop-filter: blur(8px);
}
.detail-item-label { font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin-bottom: 6px; font-weight: 700; }
.detail-item-val { font-size: 15px; font-weight: 600; }
/* Progress card */
.progress-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px 28px;
margin-bottom: 24px;
box-shadow: var(--shadow);
backdrop-filter: blur(8px);
}
.progress-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px;
}
.progress-title { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
.progress-pct { font-size: 24px; font-weight: 800; color: var(--accent-2); letter-spacing: -0.02em; }
.progress-bar-wrap {
height: 12px; border-radius: 100px;
background: rgba(8, 10, 16, 0.5);
overflow: hidden; margin-bottom: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2) inset;
}
.progress-bar-fill {
height: 100%; border-radius: 100px;
background: var(--accent-gradient);
transition: width .5s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 0 10px var(--accent-glow);
position: relative;
}
.progress-bar-fill.pulse::after {
content: '';
position: absolute; top: 0; right: 0; bottom: 0; width: 80px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
animation: shimmer 1.5s linear infinite;
}
@keyframes shimmer {
0% { transform: translateX(-150px); }
100% { transform: translateX(150px); }
}
.progress-bar-fill.done {
background: linear-gradient(90deg, var(--success), #059669);
box-shadow: 0 0 12px rgba(16, 185, 129, 0.3);
}
.progress-bar-fill.failed {
background: linear-gradient(90deg, var(--danger), #dc2626);
box-shadow: 0 0 12px rgba(239, 68, 68, 0.3);
}
.progress-detail {
font-size: 13px; color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
background: rgba(8, 10, 16, 0.3);
padding: 8px 12px;
border-radius: var(--radius-sm);
}
.phase-steps {
display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;
}
.phase-step {
padding: 4px 12px; border-radius: 100px; font-size: 11px; font-weight: 700;
background: rgba(255,255,255,0.02); color: var(--text-muted);
border: 1px solid var(--border);
text-transform: uppercase;
letter-spacing: 0.02em;
transition: all 0.2s ease;
}
.phase-step.active { background: rgba(99, 102, 241, .15); color: #a5b4fc; border-color: var(--accent); box-shadow: 0 2px 8px rgba(99,102,241,0.1); }
.phase-step.done { background: rgba(16, 185, 129, .08); color: #34d399; border-color: rgba(16, 185, 129, .25); }
.log-wrap {
background: #06080c;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
position: relative;
min-height: 200px;
box-shadow: var(--shadow);
}
.log-toolbar {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.log-title { font-size: 14px; font-weight: 700; letter-spacing: -0.01em; }
pre#logContent {
font-family: 'JetBrains Mono', monospace;
font-size: 12.5px;
color: #cbd5e1;
white-space: pre-wrap;
word-break: break-all;
max-height: 520px;
overflow-y: auto;
line-height: 1.7;
margin: 0;
padding: 4px 8px;
}
.running-badge { animation: pulse 1.5s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:.5;} }
</style>
{% endblock %}
{% block content %}
<div class="topbar">
<div>
<div class="topbar-title">
{{ job.label or 'Backup Job' }}
<span id="statusBadge">
{% if job.status == 'running' %}
<span class="badge badge-purple running-badge" style="margin-left:10px; vertical-align:middle;">⏳ Running</span>
{% elif job.status == 'finished' %}
<span class="badge badge-green" style="margin-left:10px; vertical-align:middle;">✓ Finished</span>
{% elif job.status == 'queued' %}
<span class="badge badge-yellow" style="margin-left:10px; vertical-align:middle;">⏱ Queued</span>
{% elif job.status.startswith('failed') %}
<span class="badge badge-red" style="margin-left:10px; vertical-align:middle;">✕ Failed</span>
{% else %}
<span class="badge badge-gray" style="margin-left:10px; vertical-align:middle;">{{ job.status }}</span>
{% endif %}
</span>
</div>
<div class="topbar-subtitle">Job ID: <span class="mono">{{ job.id }}</span></div>
</div>
<div class="topbar-actions">
<a href="/jobs" class="btn btn-ghost btn-sm">← All Jobs</a>
</div>
</div>
<div class="content">
<div class="detail-grid">
<div class="detail-item">
<div class="detail-item-label">Virtual Machine</div>
<div class="detail-item-val">{{ job.vm_name }}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Status</div>
<div class="detail-item-val" id="statusText">{{ job.status }}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Schedule</div>
<div class="detail-item-val">
{% if job.schedule_type and job.schedule_type != 'now' %}
🔁 {{ job.schedule_type|capitalize }}
{% if job.schedule_time %}at {{ job.schedule_time }}{% endif %}
{% else %}
One-time (Run Now)
{% endif %}
</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Started</div>
<div class="detail-item-val mono" style="font-size:13px;">{{ job.started_fmt }}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Destination</div>
<div class="detail-item-val mono" style="font-size:12px; word-break:break-all;">{{ job.dest or '—' }}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Options</div>
<div class="detail-item-val" style="font-size:13px;">
{% if job.compress %}🗜 Compressed{% else %}Raw{% endif %}
{% if job.sftp_host %} · 📤 SFTP: {{ job.sftp_host }}{% endif %}
</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Disks</div>
<div class="detail-item-val" style="font-size:13px;">
{% if job.disks_count is none %}
All disks
{% elif job.disks_count == 0 %}
<span style="color:var(--warning);">VMX only (0 disks)</span>
{% else %}
{{ job.disks_count }} disk{{ 's' if job.disks_count != 1 else '' }} selected
{% endif %}
</div>
</div>
</div>
{% if job.schedule_id %}
<div class="alert alert-info" style="margin-bottom:20px;">
🔁 This job has an active recurring schedule. Future backups will run automatically.
<form method="post" action="/job/{{ job.id }}/cancel-schedule"
style="display:inline; margin-left:12px;"
onsubmit="return confirm('Cancel recurring schedule?')">
<button class="btn btn-danger btn-sm" type="submit">Cancel Schedule</button>
</form>
</div>
{% endif %}
<!-- ── Progress card ── -->
<div class="progress-card" id="progressCard">
<div class="progress-header">
<div class="progress-title">⚡ Backup Progress</div>
<div class="progress-pct" id="progressPct">0%</div>
</div>
<div class="progress-bar-wrap">
<div class="progress-bar-fill pulse" id="progressBar" style="width:0%"></div>
</div>
<div class="progress-detail" id="progressDetail">Waiting to start…</div>
<div class="phase-steps">
<span class="phase-step" id="phase-connecting">🔌 Connect</span>
<span class="phase-step" id="phase-snapshot">📸 Snapshot</span>
<span class="phase-step" id="phase-downloading">⬇ Download</span>
<span class="phase-step" id="phase-compressing">🗜 Compress</span>
<span class="phase-step" id="phase-uploading">📤 Upload</span>
<span class="phase-step" id="phase-cleanup">🧹 Cleanup</span>
<span class="phase-step" id="phase-done">✓ Done</span>
</div>
</div>
<!-- ── Log viewer ── -->
<div class="log-wrap">
<div class="log-toolbar">
<div class="log-title">📄 Backup Log</div>
<div class="flex-center gap-2">
<span id="spinnerWrap" class="spinner" style="display:none;"></span>
<span id="autoRefreshLabel" class="text-small text-muted" style="display:none;">Live</span>
<button class="btn btn-ghost btn-sm" onclick="scrollLogBottom()">⬇ Bottom</button>
</div>
</div>
<pre id="logContent">(Loading…)</pre>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const jobId = {{ job.id | tojson }};
const initStatus = {{ job.status | tojson }};
const PHASES = ['connecting','snapshot','downloading','compressing','uploading','cleanup','done'];
function setProgress(prog) {
const pct = Math.min(100, Math.max(0, prog.pct || 0));
const phase = prog.phase || '';
const detail = prog.detail || '';
document.getElementById('progressPct').textContent = pct + '%';
document.getElementById('progressDetail').textContent = detail || 'Working…';
const bar = document.getElementById('progressBar');
bar.style.width = pct + '%';
bar.className = 'progress-bar-fill';
if (pct >= 100) {
bar.classList.add('done');
} else if (phase === 'failed') {
bar.classList.add('failed');
} else {
bar.classList.add('pulse');
}
// Update phase step indicators
const phaseIdx = PHASES.indexOf(phase);
PHASES.forEach((p, i) => {
const el = document.getElementById('phase-' + p);
if (!el) return;
el.className = 'phase-step';
if (i < phaseIdx) el.classList.add('done');
else if (i === phaseIdx) el.classList.add('active');
});
}
function setStatusBadge(status) {
let html = '';
if (status === 'running') html = '<span class="badge badge-purple running-badge" style="margin-left:10px;vertical-align:middle;">⏳ Running</span>';
else if (status === 'finished') html = '<span class="badge badge-green" style="margin-left:10px;vertical-align:middle;">✓ Finished</span>';
else if (status === 'queued') html = '<span class="badge badge-yellow" style="margin-left:10px;vertical-align:middle;">⏱ Queued</span>';
else if (status.startsWith('failed')) html = '<span class="badge badge-red" style="margin-left:10px;vertical-align:middle;">✕ Failed</span>';
document.getElementById('statusBadge').innerHTML = html;
document.getElementById('statusText').textContent = status;
}
async function fetchLog() {
try {
const res = await fetch('/job/' + jobId + '/log');
const text = await res.text();
const pre = document.getElementById('logContent');
if (text.trim()) pre.textContent = text;
} catch(e) {}
}
function scrollLogBottom() {
const pre = document.getElementById('logContent');
pre.scrollTop = pre.scrollHeight;
}
async function poll() {
try {
const res = await fetch('/api/job/' + jobId + '/status');
const data = await res.json();
const status = data.status || '';
const prog = data.progress || {};
setStatusBadge(status);
setProgress(prog);
await fetchLog();
scrollLogBottom();
const isActive = (status === 'running' || status === 'queued');
document.getElementById('spinnerWrap').style.display = isActive ? 'inline-block' : 'none';
document.getElementById('autoRefreshLabel').style.display = isActive ? '' : 'none';
if (!isActive) {
// Final state — stop polling
clearInterval(pollTimer);
// Show full-width done/fail indicator
if (status === 'finished') {
setProgress({ pct: 100, phase: 'done', detail: 'Backup completed successfully ✓' });
}
}
} catch(e) {}
}
// Initial load
fetchLog().then(scrollLogBottom);
// Apply initial progress if already running
{% if job.status == 'running' or job.status == 'queued' %}
document.getElementById('spinnerWrap').style.display = 'inline-block';
document.getElementById('autoRefreshLabel').style.display = '';
const pollTimer = setInterval(poll, 2000);
poll();
{% elif job.status == 'finished' %}
setProgress({ pct: 100, phase: 'done', detail: 'Backup completed successfully ✓' });
{% elif job.status.startsWith('failed') %}
setProgress({ pct: 0, phase: 'failed', detail: '{{ job.status }}' });
{% else %}
var pollTimer;
{% endif %}
</script>
{% endblock %}