315 lines
15 KiB
Go
315 lines
15 KiB
Go
package webui
|
|
|
|
func renderInstallInline() string {
|
|
return `
|
|
<div class="alert alert-warn" style="margin-bottom:16px">
|
|
<strong>Warning:</strong> Installing will <strong>completely erase</strong> the selected
|
|
disk and write the live system onto it. All existing data on the target disk will be lost.
|
|
This operation cannot be undone.
|
|
</div>
|
|
<div id="install-loading" style="color:var(--muted);font-size:13px">Loading disk list…</div>
|
|
<div id="install-disk-section" style="display:none">
|
|
<div class="card" style="margin-bottom:0">
|
|
<table id="install-disk-table">
|
|
<thead><tr><th></th><th>Device</th><th>Model</th><th>Size</th><th>Status</th></tr></thead>
|
|
<tbody id="install-disk-tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
<div style="margin-top:12px">
|
|
<button class="btn btn-secondary btn-sm" onclick="installRefreshDisks()">↻ Refresh</button>
|
|
</div>
|
|
</div>
|
|
<div id="install-confirm-section" style="display:none;margin-top:20px">
|
|
<div id="install-confirm-warn" class="alert" style="background:#fff6f6;border:1px solid #e0b4b4;color:#9f3a38;font-size:13px"></div>
|
|
<div class="form-row" style="max-width:360px">
|
|
<label>Type the device name to confirm (e.g. /dev/sda)</label>
|
|
<input type="text" id="install-confirm-input" placeholder="/dev/..." oninput="installCheckConfirm()" autocomplete="off" spellcheck="false">
|
|
</div>
|
|
<button class="btn btn-danger" id="install-start-btn" disabled onclick="installStart()">Install to Disk</button>
|
|
<button class="btn btn-secondary" style="margin-left:8px" onclick="installDeselect()">Cancel</button>
|
|
</div>
|
|
<div id="install-progress-section" style="display:none;margin-top:20px">
|
|
<div class="card-head" style="margin-bottom:8px">Installation Progress</div>
|
|
<div id="install-terminal" class="terminal" style="max-height:500px"></div>
|
|
<div id="install-status" style="margin-top:12px;font-size:13px"></div>
|
|
</div>
|
|
|
|
<style>
|
|
#install-disk-tbody tr{cursor:pointer}
|
|
#install-disk-tbody tr.selected td{background:rgba(33,133,208,.1)}
|
|
#install-disk-tbody tr:hover td{background:rgba(33,133,208,.07)}
|
|
</style>
|
|
|
|
<script>
|
|
var _installSelected = null;
|
|
|
|
function installRefreshDisks() {
|
|
document.getElementById('install-loading').style.display = '';
|
|
document.getElementById('install-disk-section').style.display = 'none';
|
|
document.getElementById('install-confirm-section').style.display = 'none';
|
|
_installSelected = null;
|
|
fetch('/api/install/disks').then(function(r){ return r.json(); }).then(function(disks){
|
|
document.getElementById('install-loading').style.display = 'none';
|
|
var tbody = document.getElementById('install-disk-tbody');
|
|
tbody.innerHTML = '';
|
|
if (!disks || disks.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--muted);text-align:center">No installable disks found</td></tr>';
|
|
} else {
|
|
disks.forEach(function(d) {
|
|
var warnings = (d.warnings || []);
|
|
var statusHtml;
|
|
if (warnings.length === 0) {
|
|
statusHtml = '<span class="badge badge-ok">OK</span>';
|
|
} else {
|
|
var hasSmall = warnings.some(function(w){ return w.indexOf('too small') >= 0; });
|
|
statusHtml = warnings.map(function(w){
|
|
var cls = hasSmall ? 'badge-err' : 'badge-warn';
|
|
return '<span class="badge ' + cls + '" title="' + w.replace(/"/g,'"') + '">' +
|
|
(w.length > 40 ? w.substring(0,38)+'…' : w) + '</span>';
|
|
}).join(' ');
|
|
}
|
|
var mountedNote = (d.mounted_parts && d.mounted_parts.length > 0)
|
|
? ' <span style="color:var(--warn-fg);font-size:11px">(mounted)</span>' : '';
|
|
var tr = document.createElement('tr');
|
|
tr.dataset.device = d.device;
|
|
tr.dataset.model = d.model || 'Unknown';
|
|
tr.dataset.size = d.size;
|
|
tr.dataset.warnings = JSON.stringify(warnings);
|
|
tr.innerHTML =
|
|
'<td><input type="radio" name="install-disk" value="' + d.device + '"></td>' +
|
|
'<td><code>' + d.device + '</code>' + mountedNote + '</td>' +
|
|
'<td>' + (d.model || '—') + '</td>' +
|
|
'<td>' + d.size + '</td>' +
|
|
'<td>' + statusHtml + '</td>';
|
|
tr.addEventListener('click', function(){ installSelectDisk(this); });
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
document.getElementById('install-disk-section').style.display = '';
|
|
}).catch(function(e){
|
|
document.getElementById('install-loading').textContent = 'Failed to load disk list: ' + e;
|
|
});
|
|
}
|
|
|
|
function installSelectDisk(tr) {
|
|
document.querySelectorAll('#install-disk-tbody tr').forEach(function(r){ r.classList.remove('selected'); });
|
|
tr.classList.add('selected');
|
|
var radio = tr.querySelector('input[type=radio]');
|
|
if (radio) radio.checked = true;
|
|
_installSelected = {
|
|
device: tr.dataset.device,
|
|
model: tr.dataset.model,
|
|
size: tr.dataset.size,
|
|
warnings: JSON.parse(tr.dataset.warnings || '[]')
|
|
};
|
|
var warnBox = document.getElementById('install-confirm-warn');
|
|
var warnLines = '<strong>⚠ DANGER:</strong> ' + _installSelected.device +
|
|
' (' + _installSelected.model + ', ' + _installSelected.size + ')' +
|
|
' will be <strong>completely erased</strong> and repartitioned. All data will be lost.<br>';
|
|
if (_installSelected.warnings.length > 0) {
|
|
warnLines += '<br>' + _installSelected.warnings.map(function(w){ return '• ' + w; }).join('<br>');
|
|
}
|
|
warnBox.innerHTML = warnLines;
|
|
document.getElementById('install-confirm-input').value = '';
|
|
document.getElementById('install-start-btn').disabled = true;
|
|
document.getElementById('install-confirm-section').style.display = '';
|
|
document.getElementById('install-progress-section').style.display = 'none';
|
|
}
|
|
|
|
function installDeselect() {
|
|
_installSelected = null;
|
|
document.querySelectorAll('#install-disk-tbody tr').forEach(function(r){ r.classList.remove('selected'); });
|
|
document.querySelectorAll('#install-disk-tbody input[type=radio]').forEach(function(r){ r.checked = false; });
|
|
document.getElementById('install-confirm-section').style.display = 'none';
|
|
}
|
|
|
|
function installCheckConfirm() {
|
|
var val = document.getElementById('install-confirm-input').value.trim();
|
|
var ok = _installSelected && val === _installSelected.device;
|
|
document.getElementById('install-start-btn').disabled = !ok;
|
|
}
|
|
|
|
function installStart() {
|
|
if (!_installSelected) return;
|
|
document.getElementById('install-confirm-section').style.display = 'none';
|
|
document.getElementById('install-disk-section').style.display = 'none';
|
|
document.getElementById('install-loading').style.display = 'none';
|
|
var prog = document.getElementById('install-progress-section');
|
|
var term = document.getElementById('install-terminal');
|
|
var status = document.getElementById('install-status');
|
|
prog.style.display = '';
|
|
term.textContent = '';
|
|
status.textContent = 'Starting installation…';
|
|
status.style.color = 'var(--muted)';
|
|
|
|
fetch('/api/install/run', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({device: _installSelected.device})
|
|
}).then(function(r){
|
|
return r.json().then(function(j){
|
|
if (!r.ok) throw new Error(j.error || r.statusText);
|
|
return j;
|
|
});
|
|
}).then(function(j){
|
|
if (!j.task_id) throw new Error('missing task id');
|
|
installStreamLog(j.task_id);
|
|
}).catch(function(e){
|
|
status.textContent = 'Error: ' + e;
|
|
status.style.color = 'var(--crit-fg)';
|
|
});
|
|
}
|
|
|
|
function installStreamLog(taskId) {
|
|
var term = document.getElementById('install-terminal');
|
|
var status = document.getElementById('install-status');
|
|
var es = new EventSource('/api/tasks/' + taskId + '/stream');
|
|
es.onmessage = function(e) {
|
|
term.textContent += e.data + '\n';
|
|
term.scrollTop = term.scrollHeight;
|
|
};
|
|
es.addEventListener('done', function(e) {
|
|
es.close();
|
|
if (!e.data) {
|
|
status.innerHTML = '<span style="color:var(--ok-fg);font-weight:700">✓ Installation complete.</span> Remove the ISO and reboot.';
|
|
var rebootBtn = document.createElement('button');
|
|
rebootBtn.className = 'btn btn-primary btn-sm';
|
|
rebootBtn.style.marginLeft = '12px';
|
|
rebootBtn.textContent = 'Reboot now';
|
|
rebootBtn.onclick = function(){
|
|
fetch('/api/services/action', {method:'POST',headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({name:'', action:'reboot'})});
|
|
};
|
|
status.appendChild(rebootBtn);
|
|
} else {
|
|
status.textContent = '✗ Installation failed: ' + e.data;
|
|
status.style.color = 'var(--crit-fg)';
|
|
}
|
|
});
|
|
es.onerror = function() {
|
|
es.close();
|
|
status.textContent = '✗ Stream disconnected.';
|
|
status.style.color = 'var(--crit-fg)';
|
|
};
|
|
}
|
|
|
|
installRefreshDisks();
|
|
</script>
|
|
`
|
|
}
|
|
|
|
func renderInstall() string {
|
|
return `<div class="card"><div class="card-head">Install Live System to Disk</div><div class="card-body">` +
|
|
renderInstallInline() +
|
|
`</div></div>`
|
|
}
|
|
|
|
func renderTasks() string {
|
|
return `<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;flex-wrap:wrap">
|
|
<button class="btn btn-danger btn-sm" onclick="cancelAll()">Cancel All</button>
|
|
<button class="btn btn-sm" style="background:#b45309;color:#fff" onclick="killWorkers()" title="Send SIGKILL to all running test processes (bee-gpu-burn, stress-ng, stressapptest, memtester)">Kill Workers</button>
|
|
<span id="kill-toast" style="font-size:12px;color:var(--muted);display:none"></span>
|
|
<span style="font-size:12px;color:var(--muted)">Open a task to view its saved logs and charts.</span>
|
|
</div>
|
|
<div class="card">
|
|
<div id="tasks-table"><p style="color:var(--muted);font-size:13px;padding:16px">Loading...</p></div>
|
|
</div>
|
|
<script>
|
|
var _taskRefreshTimer = null;
|
|
var _tasksAll = [];
|
|
var _taskPage = 1;
|
|
var _taskPageSize = 50;
|
|
|
|
function loadTasks() {
|
|
fetch('/api/tasks').then(r=>r.json()).then(tasks => {
|
|
_tasksAll = Array.isArray(tasks) ? tasks : [];
|
|
if (_tasksAll.length === 0) {
|
|
_taskPage = 1;
|
|
document.getElementById('tasks-table').innerHTML = '<p style="color:var(--muted);font-size:13px;padding:16px">No tasks.</p>';
|
|
return;
|
|
}
|
|
const totalPages = Math.max(1, Math.ceil(_tasksAll.length / _taskPageSize));
|
|
if (_taskPage > totalPages) _taskPage = totalPages;
|
|
if (_taskPage < 1) _taskPage = 1;
|
|
const start = (_taskPage - 1) * _taskPageSize;
|
|
const pageTasks = _tasksAll.slice(start, start + _taskPageSize);
|
|
const rows = pageTasks.map(t => {
|
|
const dur = t.elapsed_sec ? formatDurSec(t.elapsed_sec) : '';
|
|
const statusClass = {running:'badge-ok',pending:'badge-unknown',done:'badge-ok',failed:'badge-err',cancelled:'badge-unknown'}[t.status]||'badge-unknown';
|
|
const statusLabel = {running:'▶ running',pending:'pending',done:'✓ done',failed:'✗ failed',cancelled:'cancelled'}[t.status]||t.status;
|
|
let actions = '<a class="btn btn-sm btn-secondary" href="/tasks/'+encodeURIComponent(t.id)+'">Open</a>';
|
|
if (t.status === 'running' || t.status === 'pending') {
|
|
actions += ' <button class="btn btn-sm btn-danger" onclick="cancelTask(\''+t.id+'\')">Cancel</button>';
|
|
}
|
|
if (t.status === 'pending') {
|
|
actions += ' <button class="btn btn-sm btn-secondary" onclick="setPriority(\''+t.id+'\',1)" title="Increase priority">⇧</button>';
|
|
actions += ' <button class="btn btn-sm btn-secondary" onclick="setPriority(\''+t.id+'\',-1)" title="Decrease priority">⇩</button>';
|
|
}
|
|
return '<tr><td><a href="/tasks/'+encodeURIComponent(t.id)+'">'+escHtml(t.name)+'</a></td>' +
|
|
'<td><span class="badge '+statusClass+'">'+statusLabel+'</span></td>' +
|
|
'<td style="font-size:12px;color:var(--muted)">'+fmtTime(t.created_at)+'</td>' +
|
|
'<td style="font-size:12px;color:var(--muted)">'+dur+'</td>' +
|
|
'<td>'+t.priority+'</td>' +
|
|
'<td>'+actions+'</td></tr>';
|
|
}).join('');
|
|
const showingFrom = start + 1;
|
|
const showingTo = Math.min(start + pageTasks.length, _tasksAll.length);
|
|
const pager =
|
|
'<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:12px 14px;border-top:1px solid var(--border-lite);background:var(--surface-2)">' +
|
|
'<div style="font-size:12px;color:var(--muted)">Showing '+showingFrom+'-'+showingTo+' of '+_tasksAll.length+' tasks</div>' +
|
|
'<div style="display:flex;align-items:center;gap:8px">' +
|
|
'<button class="btn btn-sm btn-secondary" onclick="setTaskPage('+(_taskPage-1)+')" '+(_taskPage <= 1 ? 'disabled' : '')+'>Previous</button>' +
|
|
'<span style="font-size:12px;color:var(--muted)">Page '+_taskPage+' / '+totalPages+'</span>' +
|
|
'<button class="btn btn-sm btn-secondary" onclick="setTaskPage('+(_taskPage+1)+')" '+(_taskPage >= totalPages ? 'disabled' : '')+'>Next</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
document.getElementById('tasks-table').innerHTML =
|
|
'<table><tr><th>Name</th><th>Status</th><th>Created</th><th>Duration</th><th>Priority</th><th>Actions</th></tr>'+rows+'</table>' + pager;
|
|
});
|
|
}
|
|
|
|
function escHtml(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
function fmtTime(s) { if (!s) return ''; try { return new Date(s).toLocaleTimeString(); } catch(e){ return s; } }
|
|
function formatDurSec(sec) {
|
|
sec = Math.max(0, Math.round(sec||0));
|
|
if (sec < 60) return sec+'s';
|
|
const m = Math.floor(sec/60), ss = sec%60;
|
|
return m+'m '+ss+'s';
|
|
}
|
|
function setTaskPage(page) {
|
|
const totalPages = Math.max(1, Math.ceil(_tasksAll.length / _taskPageSize));
|
|
_taskPage = Math.min(totalPages, Math.max(1, page));
|
|
loadTasks();
|
|
}
|
|
|
|
function cancelTask(id) {
|
|
fetch('/api/tasks/'+id+'/cancel',{method:'POST'}).then(()=>loadTasks());
|
|
}
|
|
function cancelAll() {
|
|
fetch('/api/tasks/cancel-all',{method:'POST'}).then(()=>loadTasks());
|
|
}
|
|
function killWorkers() {
|
|
if (!confirm('Send SIGKILL to all running test workers (bee-gpu-burn, stress-ng, stressapptest, memtester)?\n\nThis will also cancel all queued and running tasks.')) return;
|
|
fetch('/api/tasks/kill-workers',{method:'POST'})
|
|
.then(r=>r.json())
|
|
.then(d=>{
|
|
loadTasks();
|
|
var toast = document.getElementById('kill-toast');
|
|
var parts = [];
|
|
if (d.cancelled > 0) parts.push(d.cancelled+' task'+(d.cancelled===1?'':'s')+' cancelled');
|
|
if (d.killed > 0) parts.push(d.killed+' process'+(d.killed===1?'':'es')+' killed');
|
|
toast.textContent = parts.length ? parts.join(', ')+'.' : 'No processes found.';
|
|
toast.style.display = '';
|
|
setTimeout(()=>{ toast.style.display='none'; }, 5000);
|
|
});
|
|
}
|
|
function setPriority(id, delta) {
|
|
fetch('/api/tasks/'+id+'/priority',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delta:delta})})
|
|
.then(()=>loadTasks());
|
|
}
|
|
|
|
loadTasks();
|
|
_taskRefreshTimer = setInterval(loadTasks, 2000);
|
|
</script>`
|
|
}
|