- Backend /api/v1/analyze now returns 202 with analysis_id immediately
- New GET /api/v1/analyze/{id} for polling analysis status
- Background thread handles extraction, disk discovery, OS/EFI detection
- Nested archive extraction: handles chained zips and split zips (.z01-.zNN)
- Frontend polls /session/analyze/status/{id} every 2s until complete
- SCP page now has complete confirm form flow with job polling
241 lines
9.7 KiB
HTML
241 lines
9.7 KiB
HTML
{% extends "base.html" %}
|
|
{% block content %}
|
|
|
|
<div class="pve-panel" id="scp-form-panel">
|
|
<div class="pve-panel-header">
|
|
<span class="pve-panel-title">SCP Pull — Fetch File from Remote Server</span>
|
|
</div>
|
|
<div class="pve-panel-body">
|
|
<p class="pve-dim" style="margin-bottom:1rem;">
|
|
Pull a disk image directly from a customer server via SCP.
|
|
No laptop middle-hop — files land directly in the staging area.
|
|
<strong>Supports files of any size.</strong>
|
|
</p>
|
|
|
|
<form id="scp-form" onsubmit="startScpPull(event)">
|
|
<div class="pve-row">
|
|
<div class="pve-form-group">
|
|
<label>Remote Host</label>
|
|
<input type="text" name="scp_host" required class="pve-input"
|
|
placeholder="10.0.0.50 or server.example.com">
|
|
</div>
|
|
<div class="pve-form-group">
|
|
<label>SSH Port</label>
|
|
<input type="number" name="scp_port" class="pve-input" value="22" min="1" max="65535">
|
|
</div>
|
|
</div>
|
|
<div class="pve-row">
|
|
<div class="pve-form-group">
|
|
<label>Username</label>
|
|
<input type="text" name="scp_user" required class="pve-input" placeholder="root">
|
|
</div>
|
|
<div class="pve-form-group">
|
|
<label>Password</label>
|
|
<input type="password" name="scp_pass" required class="pve-input" placeholder="••••••••">
|
|
</div>
|
|
</div>
|
|
<div class="pve-form-group">
|
|
<label>Remote File Path *</label>
|
|
<input type="text" name="scp_path" required class="pve-input"
|
|
placeholder="/mnt/vmware/exchange-server.vmdk">
|
|
<span class="pve-hint">Full path to the disk image or archive on the remote server.</span>
|
|
</div>
|
|
<div class="pve-row">
|
|
<div class="pve-form-group">
|
|
<label>VM Name *</label>
|
|
<input type="text" id="scp-vm-name" name="vm_name" required class="pve-input"
|
|
placeholder="e.g. exchange-prod">
|
|
</div>
|
|
<div class="pve-form-group">
|
|
<label>VM ID *</label>
|
|
<input type="number" id="scp-vmid" name="vmid" required class="pve-input"
|
|
placeholder="e.g. 21050" min="21000" max="21100">
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" id="scp-start-btn" class="pve-btn pve-btn-primary">Start SCP Pull</button>
|
|
</form>
|
|
|
|
<div id="scp-status" class="pve-hidden" style="margin-top:1rem;">
|
|
<div id="scp-phase-label" style="margin-bottom:0.5rem;"></div>
|
|
<div class="pve-progress">
|
|
<div class="pve-progress-bar" id="scp-progress-fill" style="width:0%">0%</div>
|
|
</div>
|
|
<div id="scp-error"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="scp-analysis-section"></div>
|
|
|
|
<script>
|
|
const SESSION_ID = '{{ session_id }}';
|
|
|
|
async function startScpPull(e) {
|
|
e.preventDefault();
|
|
const btn = document.getElementById('scp-start-btn');
|
|
const status = document.getElementById('scp-status');
|
|
const analysis = document.getElementById('scp-analysis-section');
|
|
btn.disabled = true;
|
|
analysis.innerHTML = '';
|
|
status.classList.remove('pve-hidden');
|
|
document.getElementById('scp-error').textContent = '';
|
|
|
|
const formData = new FormData(e.target);
|
|
formData.append('session_id', SESSION_ID);
|
|
setScpPhase('Connecting to remote server...', 0, true);
|
|
|
|
try {
|
|
const resp = await fetch('/scp/start', { method: 'POST', body: formData });
|
|
const data = await resp.json();
|
|
if (data.phase === 'error') {
|
|
setScpPhase('SCP pull failed', 0, false);
|
|
showScpError(data.error);
|
|
btn.disabled = false; return;
|
|
}
|
|
|
|
const filename = data.filename;
|
|
const totalGb = data.total_size_gb;
|
|
setScpPhase('Pulling file...' + (totalGb ? ' (' + totalGb + ' GiB total)' : ''), 0, true);
|
|
for (;;) {
|
|
await new Promise(r => setTimeout(r, 2000));
|
|
try {
|
|
const pr = await fetch('/scp/progress/' + SESSION_ID + '/' + encodeURIComponent(filename));
|
|
const pdata = await pr.json();
|
|
if (pdata.phase === 'complete') {
|
|
setScpPhase('Pull complete (' + (pdata.file_size_gb || 0) + ' GiB)', 100, false);
|
|
await new Promise(r => setTimeout(r, 500));
|
|
setScpPhase('Step 2/2: Analysing source image...', 0, true);
|
|
var fd = new FormData();
|
|
fd.append('vmid', document.getElementById('scp-vmid').value);
|
|
fd.append('filename', filename);
|
|
fd.append('vm_name', document.getElementById('scp-vm-name').value.trim());
|
|
fd.append('session_id', SESSION_ID);
|
|
var ar = await fetch('/session/analyze', { method: 'POST', body: fd });
|
|
var adata = await ar.json();
|
|
if (adata.phase === 'error') {
|
|
setScpPhase('Analysis failed', 0, false);
|
|
showScpError(adata.error);
|
|
btn.disabled = false; return;
|
|
}
|
|
var analysisId = adata.analysis_id;
|
|
var vmid = document.getElementById('scp-vmid').value;
|
|
var vmName = document.getElementById('scp-vm-name').value.trim();
|
|
var astart = Date.now();
|
|
for (;;) {
|
|
await new Promise(r => setTimeout(r, 2000));
|
|
var sr = await fetch('/session/analyze/status/' + analysisId);
|
|
var sdata = await sr.json();
|
|
var elapsed = Math.round((Date.now() - astart) / 1000);
|
|
setScpPhase('Analysing... ' + (sdata.message || '') + ' (' + elapsed + 's)', sdata.status === 'completed' ? 100 : 50, sdata.status !== 'completed');
|
|
if (sdata.status === 'completed') {
|
|
await new Promise(r => setTimeout(r, 500));
|
|
var rr = await fetch('/session/analyze/result/' + analysisId + '?source_filename=' + encodeURIComponent(filename) + '&vm_name=' + encodeURIComponent(vmName) + '&vmid=' + vmid);
|
|
document.getElementById('scp-analysis-section').innerHTML = await rr.text();
|
|
document.getElementById('scp-status').classList.add('pve-hidden');
|
|
scpInitConfirmForm();
|
|
btn.disabled = false; return;
|
|
}
|
|
if (sdata.status === 'failed') {
|
|
setScpPhase('Analysis failed', 0, false);
|
|
showScpError(sdata.error_details || 'Analysis failed');
|
|
btn.disabled = false; return;
|
|
}
|
|
}
|
|
}
|
|
if (pdata.phase === 'error') {
|
|
setScpPhase('SCP pull failed', 0, false);
|
|
showScpError(pdata.error); btn.disabled = false; return;
|
|
}
|
|
if (pdata.phase === 'pulling') {
|
|
const gb = (pdata.file_size_gb || 0).toFixed(1);
|
|
let label = 'Pulling file... ' + gb + ' GiB';
|
|
if (pdata.speed_mbps) label += ' (' + pdata.speed_mbps + ' MB/s)';
|
|
if (pdata.eta) label += ' — ' + pdata.eta;
|
|
setScpPhase(label, pdata.pct || 0, !pdata.pct);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
} catch (err) {
|
|
setScpPhase('SCP pull failed', 0, false);
|
|
showScpError('Network error: ' + err.message);
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function setScpPhase(label, pct, spinner) {
|
|
const el = document.getElementById('scp-phase-label');
|
|
if (el) el.innerHTML = (spinner ? '<span class="pve-spinner"></span> ' : '') + label;
|
|
const fill = document.getElementById('scp-progress-fill');
|
|
if (fill) { fill.style.width = pct + '%'; fill.textContent = pct > 0 ? pct + '%' : ''; }
|
|
}
|
|
|
|
function showScpError(msg) {
|
|
const el = document.getElementById('scp-error');
|
|
if (el) el.innerHTML += '<div class="pve-alert pve-alert-error" style="margin-top:0.5rem;">' + msg + '</div>';
|
|
}
|
|
|
|
function scpInitConfirmForm() {
|
|
const form = document.getElementById('confirm-form');
|
|
if (!form) return;
|
|
form.addEventListener('submit', scpSubmitJob);
|
|
const bootSel = form.querySelector('select[name="auto_detect_boot"]');
|
|
if (bootSel) {
|
|
bootSel.addEventListener('change', function() {
|
|
document.getElementById('boot-type-group').style.display =
|
|
this.value === 'false' ? 'block' : 'none';
|
|
});
|
|
}
|
|
}
|
|
|
|
async function scpSubmitJob(e) {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const btn = form.querySelector('#submit-btn');
|
|
btn.disabled = true;
|
|
const formData = new FormData(form);
|
|
formData.append('session_id', SESSION_ID);
|
|
try {
|
|
const resp = await fetch('/session/confirm', { method: 'POST', body: formData });
|
|
const html = await resp.text();
|
|
document.getElementById('scp-form-panel').style.display = 'none';
|
|
document.getElementById('scp-analysis-section').innerHTML = '';
|
|
document.getElementById('scp-analysis-section').innerHTML = html;
|
|
scpInitPolling();
|
|
} catch (err) {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
var _scpPollTimer = null;
|
|
function scpInitPolling() {
|
|
const container = document.getElementById('polling-container');
|
|
if (!container) return;
|
|
const jobId = container.dataset.jobId;
|
|
const startTime = Date.now();
|
|
var completed = false;
|
|
|
|
async function poll() {
|
|
try {
|
|
const resp = await fetch('/session/status/' + jobId);
|
|
const data = await resp.json();
|
|
const pct = data.progress_percentage || 0;
|
|
var fill = document.getElementById('pve-progress-bar');
|
|
if (fill) { fill.style.width = pct + '%'; fill.textContent = pct + '%'; }
|
|
var elMsg = document.getElementById('status-message');
|
|
if (elMsg) elMsg.textContent = data.message || '';
|
|
var badge = document.getElementById('status-badge');
|
|
if (badge) {
|
|
if (data.status === 'completed') { badge.className = 'pve-badge pve-badge-completed'; badge.textContent = 'Completed'; }
|
|
else if (data.status === 'failed') { badge.className = 'pve-badge pve-badge-failed'; badge.textContent = 'Failed'; }
|
|
else { badge.className = 'pve-badge pve-badge-running'; badge.innerHTML = '<span class="pve-spinner"></span> Processing'; }
|
|
}
|
|
if (data.status === 'completed' || data.status === 'failed') { completed = true; return; }
|
|
} catch (err) {}
|
|
if (!completed) _scpPollTimer = setTimeout(poll, 2000);
|
|
}
|
|
_scpPollTimer = setTimeout(poll, 3000);
|
|
}
|
|
</script>
|
|
|
|
{% endblock %}
|