feat: unified configure page — VM settings + multi-disk selection with per-disk shrink
- New /session/configure/{analysis_id} page with VM config + disk table
- Each disk has radio (boot selection) + shrink checkbox + size input
- Per-disk shrink control — check to enable, set custom size
- Disks sorted smallest first, boot disk pre-selected as largest
- Submit via /session/configure/submit, then /session/status/{job_id} polling
- Landpage download/SCP flow redirects to configure after analysis completes
This commit is contained in:
parent
d4726acd65
commit
b000641827
5 changed files with 334 additions and 23 deletions
|
|
@ -346,8 +346,8 @@ async def session_list():
|
||||||
return JSONResponse({"sessions": []})
|
return JSONResponse({"sessions": []})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/session/analyze/result/{analysis_id}", response_class=HTMLResponse)
|
@app.get("/session/configure/{analysis_id}", response_class=HTMLResponse)
|
||||||
async def session_analyze_result(
|
async def session_configure(
|
||||||
request: Request,
|
request: Request,
|
||||||
analysis_id: str,
|
analysis_id: str,
|
||||||
vmid: str = "",
|
vmid: str = "",
|
||||||
|
|
@ -355,16 +355,18 @@ async def session_analyze_result(
|
||||||
vm_name: str = "",
|
vm_name: str = "",
|
||||||
session_id: str = "",
|
session_id: str = "",
|
||||||
):
|
):
|
||||||
"""Render the analysis result after polling completes."""
|
"""Full page with VM configuration + disk selection after analysis."""
|
||||||
try:
|
try:
|
||||||
status = api.get_analysis(analysis_id)
|
status = api.get_analysis(analysis_id)
|
||||||
result = status.get("result")
|
result = status.get("result")
|
||||||
if not result:
|
if not result:
|
||||||
return render("_analysis.html", request=request,
|
return render("configure.html", request=request, analysis_id=analysis_id,
|
||||||
error="Analysis result not ready.")
|
source_filename=source_filename, session_id=session_id,
|
||||||
|
disks=[], error="Analysis result not ready.")
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
return render("_analysis.html", request=request,
|
return render("configure.html", request=request, analysis_id=analysis_id,
|
||||||
error=f"Failed to fetch analysis: {exc.detail}")
|
source_filename=source_filename, session_id=session_id,
|
||||||
|
disks=[], error=f"Failed: {exc.detail}")
|
||||||
|
|
||||||
pools = []
|
pools = []
|
||||||
try:
|
try:
|
||||||
|
|
@ -376,12 +378,36 @@ async def session_analyze_result(
|
||||||
analysis = result
|
analysis = result
|
||||||
name = vm_name.strip()
|
name = vm_name.strip()
|
||||||
if not name:
|
if not name:
|
||||||
name = (analysis.get("os_type") or "vm") + f"-{vmid}"
|
name = (analysis.get("os_type") or "vm")
|
||||||
|
|
||||||
return render("_analysis.html", request=request,
|
disks = analysis.get("all_disks", [])
|
||||||
vmid=vmid, source_filename=source_filename,
|
if not disks:
|
||||||
vm_name=name, analysis=analysis, storage_pools=pools,
|
disks = [{"filename": analysis.get("filename", ""), "format": analysis.get("disk_format", ""), "size_gb": analysis.get("disk_size_gb", 0)}]
|
||||||
session_id=session_id)
|
|
||||||
|
suggested_vmid = vmid if vmid else ""
|
||||||
|
|
||||||
|
return render("configure.html", request=request,
|
||||||
|
analysis_id=analysis_id, source_filename=source_filename,
|
||||||
|
session_id=session_id, vm_name=name, storage_pools=pools,
|
||||||
|
disks=disks, suggested_vmid=suggested_vmid)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/session/configure/submit")
|
||||||
|
async def configure_submit(request: Request):
|
||||||
|
"""Receive config + disk selection, submit job to backend."""
|
||||||
|
body = await request.json()
|
||||||
|
try:
|
||||||
|
result = api.create_job(body)
|
||||||
|
return result
|
||||||
|
except ApiError as exc:
|
||||||
|
return JSONResponse({"error": exc.detail}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/session/status/{job_id}", response_class=HTMLResponse)
|
||||||
|
async def session_status_page(request: Request, job_id: str):
|
||||||
|
"""Full page showing job progress with polling."""
|
||||||
|
return render("job_polling.html", request=request, job_id=job_id,
|
||||||
|
vmid=0, vm_name="", session_id="")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/session/confirm", response_class=HTMLResponse)
|
@app.post("/session/confirm", response_class=HTMLResponse)
|
||||||
|
|
|
||||||
197
frontend/templates/configure.html
Normal file
197
frontend/templates/configure.html
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="pve-hero" style="padding-bottom:0.5rem;">
|
||||||
|
<h1 style="font-size:1.1rem;">VM Configuration</h1>
|
||||||
|
<p>Source: <span class="pve-code">{{ source_filename }}</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pve-panel">
|
||||||
|
<div class="pve-panel-header">
|
||||||
|
<span class="pve-panel-title">VM Settings</span>
|
||||||
|
</div>
|
||||||
|
<div class="pve-panel-body">
|
||||||
|
|
||||||
|
<div class="pve-row">
|
||||||
|
<div class="pve-form-group">
|
||||||
|
<label>VM ID *</label>
|
||||||
|
<input type="number" id="config-vmid" class="pve-input" required
|
||||||
|
min="21000" max="21100" placeholder="e.g. 21050" value="{{ suggested_vmid }}">
|
||||||
|
</div>
|
||||||
|
<div class="pve-form-group">
|
||||||
|
<label>VM Name *</label>
|
||||||
|
<input type="text" id="config-vmname" class="pve-input" required
|
||||||
|
placeholder="e.g. my-vm" value="{{ vm_name or '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pve-row">
|
||||||
|
<div class="pve-form-group">
|
||||||
|
<label>Storage Pool</label>
|
||||||
|
{% if storage_pools %}
|
||||||
|
<select id="config-storage" class="pve-select">
|
||||||
|
{% for pool in storage_pools %}
|
||||||
|
<option value="{{ pool.name }}">{{ pool.name }} ({{ pool.type }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
<option value="__custom__">Custom...</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="config-storage-custom" class="pve-input pve-hidden"
|
||||||
|
placeholder="Enter pool name" style="margin-top:0.3rem;">
|
||||||
|
{% else %}
|
||||||
|
<input type="text" id="config-storage" class="pve-input">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="pve-form-group">
|
||||||
|
<label>Boot Detection</label>
|
||||||
|
<select id="config-bootdetect" class="pve-select">
|
||||||
|
<option value="auto" selected>Auto-detect (recommended)</option>
|
||||||
|
<option value="uefi">UEFI (OVMF)</option>
|
||||||
|
<option value="legacy">Legacy BIOS (SeaBIOS)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pve-row">
|
||||||
|
<div class="pve-form-group">
|
||||||
|
<label>CPU Cores</label>
|
||||||
|
<input type="number" id="config-cores" class="pve-input" value="2" min="1">
|
||||||
|
</div>
|
||||||
|
<div class="pve-form-group">
|
||||||
|
<label>RAM (MB)</label>
|
||||||
|
<input type="number" id="config-ram" class="pve-input" value="4096" min="512">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pve-panel">
|
||||||
|
<div class="pve-panel-header">
|
||||||
|
<span class="pve-panel-title">Disks Found</span>
|
||||||
|
</div>
|
||||||
|
<div class="pve-panel-body">
|
||||||
|
<table class="pve-table">
|
||||||
|
<thead>
|
||||||
|
<tr style="font-size:0.7rem;color:var(--pve-muted);">
|
||||||
|
<td style="width:40px;">Boot</td>
|
||||||
|
<td>Filename</td>
|
||||||
|
<td style="width:60px;">Format</td>
|
||||||
|
<td style="width:80px;">Size</td>
|
||||||
|
<td style="width:140px;">Resize</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="disks-tbody">
|
||||||
|
{% for d in disks %}
|
||||||
|
<tr>
|
||||||
|
<td><input type="radio" name="boot_disk" value="{{ loop.index0 }}" {% if loop.first %}checked{% endif %}></td>
|
||||||
|
<td>{{ d.filename }}</td>
|
||||||
|
<td>{{ d.format }}</td>
|
||||||
|
<td>{{ d.size_gb }} GB</td>
|
||||||
|
<td>
|
||||||
|
<label style="display:flex;align-items:center;gap:0.3rem;font-size:0.72rem;white-space:nowrap;">
|
||||||
|
<input type="checkbox" class="disk-shrink-cb" onchange="toggleShrink(this, {{ loop.index0 }})">
|
||||||
|
to <input type="number" class="pve-input disk-shrink-size" id="shrink-{{ loop.index0 }}"
|
||||||
|
value="{{ d.size_gb }}" disabled style="width:70px;padding:0.2rem 0.3rem;font-size:0.72rem;"> GB
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="pve-alert pve-alert-info" style="margin-top:0.75rem;">
|
||||||
|
<strong>TIP:</strong> The disk with the operating system should be the boot disk.
|
||||||
|
Data disks will be attached as additional drives.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="config-submit-btn" class="pve-btn pve-btn-primary" style="margin-top:0.75rem;" onclick="submitConfig()">Start Conversion</button>
|
||||||
|
<div id="config-submit-status" class="pve-hidden" style="margin-top:0.5rem;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var _analysisId = '{{ analysis_id }}';
|
||||||
|
var _sessionId = '{{ session_id }}';
|
||||||
|
|
||||||
|
function toggleShrink(cb, idx) {
|
||||||
|
var inp = document.getElementById('shrink-' + idx);
|
||||||
|
if (cb.checked) { inp.removeAttribute('disabled'); inp.focus(); }
|
||||||
|
else { inp.setAttribute('disabled', ''); }
|
||||||
|
}
|
||||||
|
|
||||||
|
(function(){
|
||||||
|
var ss = document.getElementById('config-storage');
|
||||||
|
var sc = document.getElementById('config-storage-custom');
|
||||||
|
if (ss && sc) {
|
||||||
|
ss.addEventListener('change', function() {
|
||||||
|
if (this.value === '__custom__') {
|
||||||
|
sc.classList.remove('pve-hidden'); sc.focus();
|
||||||
|
} else {
|
||||||
|
sc.classList.add('pve-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function submitConfig() {
|
||||||
|
var vmid = parseInt(document.getElementById('config-vmid').value) || 0;
|
||||||
|
var vmName = document.getElementById('config-vmname').value.trim();
|
||||||
|
if (!vmid || vmid < 21000 || vmid > 21100) { alert('VM ID must be 21000-21100.'); return; }
|
||||||
|
if (!vmName) { alert('Please enter a VM name.'); return; }
|
||||||
|
|
||||||
|
var storage = document.getElementById('config-storage');
|
||||||
|
var sc = document.getElementById('config-storage-custom');
|
||||||
|
var targetStorage = sc && !sc.classList.contains('pve-hidden') ? sc.value : (storage ? storage.value : '');
|
||||||
|
|
||||||
|
var bootDetect = document.getElementById('config-bootdetect').value;
|
||||||
|
var autoBoot = bootDetect === 'auto';
|
||||||
|
var bootType = autoBoot ? 'uefi' : bootDetect;
|
||||||
|
var cores = parseInt(document.getElementById('config-cores').value) || 2;
|
||||||
|
var ram = parseInt(document.getElementById('config-ram').value) || 4096;
|
||||||
|
|
||||||
|
var bootIdx = 0;
|
||||||
|
var radios = document.getElementsByName('boot_disk');
|
||||||
|
for (var i = 0; i < radios.length; i++) {
|
||||||
|
if (radios[i].checked) { bootIdx = parseInt(radios[i].value); break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = {
|
||||||
|
vmid: vmid, vm_name: vmName, cpu_cores: cores, ram_mb: ram,
|
||||||
|
target_storage: targetStorage, auto_detect_boot: autoBoot, boot_type: bootType,
|
||||||
|
session_id: _sessionId, analysis_id: _analysisId,
|
||||||
|
boot_disk: null, additional_disks: []
|
||||||
|
};
|
||||||
|
|
||||||
|
var shrinkCbs = document.getElementsByClassName('disk-shrink-cb');
|
||||||
|
var diskFiles = [{% for d in disks %}{{ d|tojson }},{% endfor %}];
|
||||||
|
for (var i = 0; i < shrinkCbs.length; i++) {
|
||||||
|
var d = diskFiles[i];
|
||||||
|
var spec = { disk_type: 'image_file', source_filename: d.filename, format: d.format };
|
||||||
|
if (shrinkCbs[i].checked) {
|
||||||
|
var si = document.getElementById('shrink-' + i);
|
||||||
|
var sz = si ? parseInt(si.value) || null : null;
|
||||||
|
if (sz) spec.target_disk_size_gb = sz;
|
||||||
|
}
|
||||||
|
if (i === bootIdx) payload.boot_disk = spec;
|
||||||
|
else payload.additional_disks.push(spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
var btn = document.getElementById('config-submit-btn');
|
||||||
|
var status = document.getElementById('config-submit-status');
|
||||||
|
btn.disabled = true;
|
||||||
|
status.classList.remove('pve-hidden');
|
||||||
|
status.innerHTML = '<div class="pve-spinner"></div> Submitting job...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/session/configure/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||||
|
var data = await resp.json();
|
||||||
|
if (data.error) { throw new Error(data.error); }
|
||||||
|
window.location.href = '/session/status/' + data.job_id;
|
||||||
|
} catch (err) {
|
||||||
|
status.innerHTML = '<div class="pve-alert pve-alert-error">Failed: ' + err.message + '</div>';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -287,13 +287,8 @@ async function runAnalysis(vmid, filename, vmName, sessionOverride) {
|
||||||
setPhase('Analysing... ' + (sdata.message || '') + ' (' + elapsed + 's)', sdata.status === 'completed' ? 100 : 50, sdata.status !== 'completed');
|
setPhase('Analysing... ' + (sdata.message || '') + ' (' + elapsed + 's)', sdata.status === 'completed' ? 100 : 50, sdata.status !== 'completed');
|
||||||
|
|
||||||
if (sdata.status === 'completed') {
|
if (sdata.status === 'completed') {
|
||||||
await sleep(500);
|
|
||||||
var sid = window.RESUMED_SID || window.VM_BENCH_SID || '';
|
var sid = window.RESUMED_SID || window.VM_BENCH_SID || '';
|
||||||
var rr = await fetch('/session/analyze/result/' + analysisId + '?source_filename=' + encodeURIComponent(filename) + '&vm_name=' + encodeURIComponent(vmName) + '&vmid=' + vmid + '&session_id=' + encodeURIComponent(sid));
|
window.location.href = '/session/configure/' + analysisId + '?source_filename=' + encodeURIComponent(filename) + '&vm_name=' + encodeURIComponent(vmName) + '&vmid=' + vmid + '&session_id=' + encodeURIComponent(sid);
|
||||||
var html = await rr.text();
|
|
||||||
document.getElementById('analysis-section').innerHTML = html;
|
|
||||||
document.getElementById('session-status').classList.add('pve-hidden');
|
|
||||||
initConfirmForm();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (sdata.status === 'failed') {
|
if (sdata.status === 'failed') {
|
||||||
|
|
|
||||||
97
frontend/templates/job_polling.html
Normal file
97
frontend/templates/job_polling.html
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="pve-panel pve-center">
|
||||||
|
<div class="pve-panel-header">
|
||||||
|
<span class="pve-panel-title">Converting VM {{ vmid or '...' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pve-panel-body">
|
||||||
|
<p class="pve-dim">Job: <span class="pve-code">{{ job_id }}</span> | {{ vm_name }}</p>
|
||||||
|
|
||||||
|
<div id="status-badge" class="pve-badge pve-badge-queued">
|
||||||
|
<span class="pve-spinner"></span> Queued
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pve-progress">
|
||||||
|
<div class="pve-progress-bar" id="pve-progress-bar" style="width:0%">0%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="status-message" class="pve-dim">Waiting for backend...</p>
|
||||||
|
<p id="elapsed" class="pve-dim">Elapsed: 0s</p>
|
||||||
|
<div id="error-block" class="pve-alert pve-alert-error pve-hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="reuse-section" class="pve-panel pve-hidden">
|
||||||
|
<div class="pve-panel-header">
|
||||||
|
<span class="pve-panel-title">Conversion Complete — VM {{ vmid or '...' }} Ready</span>
|
||||||
|
</div>
|
||||||
|
<div class="pve-panel-body">
|
||||||
|
<p style="margin-bottom:1rem;">Create another VM from this disk?</p>
|
||||||
|
<button id="btn-cleanup" class="pve-btn" onclick="finishCleanup()">Clean Up & Finish</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var jobId = '{{ job_id }}';
|
||||||
|
var startTime = Date.now();
|
||||||
|
var completed = false;
|
||||||
|
|
||||||
|
function finishCleanup() {
|
||||||
|
fetch('/session/cleanup/' + jobId, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ delete_staging_files: true, session_id: '{{ session_id }}' }) });
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/session/status/' + jobId);
|
||||||
|
var data = await resp.json();
|
||||||
|
var 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 || '';
|
||||||
|
|
||||||
|
document.getElementById('elapsed').textContent = 'Elapsed: ' + Math.round((Date.now() - startTime) / 1000) + 's';
|
||||||
|
|
||||||
|
var badge = document.getElementById('status-badge');
|
||||||
|
var map = {
|
||||||
|
'queued': ['pve-badge pve-badge-queued', 'Queued'],
|
||||||
|
'processing_conversion': ['pve-badge pve-badge-running', 'Converting...'],
|
||||||
|
'importing_storage': ['pve-badge pve-badge-running', 'Importing...'],
|
||||||
|
'completed': ['pve-badge pve-badge-completed', 'Completed'],
|
||||||
|
'failed': ['pve-badge pve-badge-failed', 'Failed']
|
||||||
|
};
|
||||||
|
var clsText = map[data.status] || ['pve-badge pve-badge-queued', data.status];
|
||||||
|
if (badge) {
|
||||||
|
badge.className = 'badge ' + clsText[0];
|
||||||
|
badge.innerHTML = (data.status === 'processing_conversion' || data.status === 'importing_storage')
|
||||||
|
? '<span class="pve-spinner"></span> ' + clsText[1] : clsText[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.error_details) {
|
||||||
|
var eb = document.getElementById('error-block');
|
||||||
|
if (eb) { eb.classList.remove('pve-hidden'); eb.textContent = data.error_details; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'completed' || data.status === 'failed') {
|
||||||
|
completed = true;
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
var rs = document.getElementById('reuse-section');
|
||||||
|
if (rs) { rs.classList.remove('pve-hidden'); rs.scrollIntoView({ behavior: 'smooth' }); }
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
var eb = document.getElementById('error-block');
|
||||||
|
if (eb) { eb.classList.remove('pve-hidden'); eb.textContent = 'Polling error: ' + err.message; }
|
||||||
|
}
|
||||||
|
if (!completed) setTimeout(poll, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(poll, 3000);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -135,11 +135,7 @@ async function startScpPull(e) {
|
||||||
var elapsed = Math.round((Date.now() - astart) / 1000);
|
var elapsed = Math.round((Date.now() - astart) / 1000);
|
||||||
setScpPhase('Analysing... ' + (sdata.message || '') + ' (' + elapsed + 's)', sdata.status === 'completed' ? 100 : 50, sdata.status !== 'completed');
|
setScpPhase('Analysing... ' + (sdata.message || '') + ' (' + elapsed + 's)', sdata.status === 'completed' ? 100 : 50, sdata.status !== 'completed');
|
||||||
if (sdata.status === 'completed') {
|
if (sdata.status === 'completed') {
|
||||||
await new Promise(r => setTimeout(r, 500));
|
window.location.href = '/session/configure/' + analysisId + '?source_filename=' + encodeURIComponent(filename) + '&vm_name=' + encodeURIComponent(vmName) + '&vmid=' + vmid + '&session_id=' + SESSION_ID;
|
||||||
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;
|
btn.disabled = false; return;
|
||||||
}
|
}
|
||||||
if (sdata.status === 'failed') {
|
if (sdata.status === 'failed') {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue