427 lines
18 KiB
HTML
427 lines
18 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" style="display: flex; gap: 8px; align-items: center;">
|
|
{% if job.status != 'running' and job.status != 'queued' %}
|
|
<form method="post" action="/job/{{ job.id }}/run" style="margin: 0;">
|
|
<button class="btn btn-primary btn-sm" type="submit">
|
|
<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: 4px;"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
|
Run Now
|
|
</button>
|
|
</form>
|
|
<form method="post" action="/job/{{ job.id }}/delete"
|
|
style="margin: 0;"
|
|
onsubmit="return confirm('Are you sure you want to delete this job? This will cancel any active schedule and delete the job logs.')">
|
|
<button class="btn btn-danger btn-sm" type="submit">
|
|
<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: 4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
|
Delete
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
<a href="/jobs" 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>
|
|
All Jobs
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content">
|
|
|
|
<div class="detail-grid">
|
|
<div class="detail-item" {% if job.vm_names %}style="grid-column: span 2;"{% endif %}>
|
|
<div class="detail-item-label">Virtual Machine{% if job.vm_names %}s{% endif %}</div>
|
|
<div class="detail-item-val">
|
|
{% if job.vm_names %}
|
|
<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px;">
|
|
{% for vm in job.vm_names %}
|
|
<span class="badge badge-gray" style="font-family: 'JetBrains Mono', monospace; font-size: 12px; padding: 4px 10px;">{{ vm }}</span>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
{{ job.vm_name }}
|
|
{% endif %}
|
|
</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' %}
|
|
<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="vertical-align: middle; margin-right: 6px;"><path d="M23 4v6h-6"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
|
{{ job.schedule_type|capitalize }}
|
|
{% if job.schedule_type == 'monthly' and job.monthly_day is not none %}
|
|
(Day: {{ job.monthly_day }})
|
|
{% elif job.schedule_type == 'weekly' and job.weekly_day is not none %}
|
|
(Day: {{ ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'][job.weekly_day|int] if (job.weekly_day|string).isdigit() else job.weekly_day }})
|
|
{% endif %}
|
|
{% 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">Backup Location</div>
|
|
<div class="detail-item-val mono" style="font-size:12px; word-break:break-all;">
|
|
{% if job.run_dest %}
|
|
{{ job.run_dest }}
|
|
{% else %}
|
|
{{ job.dest or '—' }}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-item-label">Retention Policy</div>
|
|
<div class="detail-item-val" style="font-size:13px;">
|
|
{% if job.retention_type == 'keep_count' %}
|
|
Keep latest {{ job.retention_value }} successful backups
|
|
{% elif job.retention_type == 'keep_days' %}
|
|
Keep backups for {{ job.retention_value }} days
|
|
{% else %}
|
|
Keep all backups (No cleanup)
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-item-label">Options</div>
|
|
<div class="detail-item-val" style="font-size:13px; display:flex; align-items:center; gap:6px;">
|
|
{% if job.compress %}
|
|
<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="vertical-align: middle;"><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"/><path d="M12 11v4"/><path d="M10 13h4"/></svg>
|
|
Compressed
|
|
{% else %}
|
|
Raw
|
|
{% endif %}
|
|
{% if job.sftp_host %}
|
|
·
|
|
<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="vertical-align: middle;"><path d="M21.2 15c.18-.52.3-1.07.3-1.64C21.5 10.4 18.9 8 15.6 8c-1.06 0-2.07.3-2.93.85C11.8 6.5 9 5 5.8 5 3.1 5 1 7.1 1 9.8c0 .28.02.55.07.82C.46 11.2 0 12.05 0 13c0 2.2 1.8 4 4 4h16c1.66 0 3-1.34 3-3 0-1.3-.84-2.4-2-2.82z"/><polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/></svg>
|
|
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;">
|
|
<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: 8px;"><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 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" style="display:flex; align-items:center; gap:6px;">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--accent-2);"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
|
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" style="display:flex; align-items:center; gap:6px;">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
|
|
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()">
|
|
<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: 4px;"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
|
|
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 %}
|