1317ff6369
Operations are now clickable from the host detail page, linking to
/ops/{id} which shows the operation info, host link, duration, and
activity log filtered to that operation. Active operations can be
cancelled, which transitions the host to failed and releases the lock.
SSE activity events now include operation_id for real-time filtering.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
142 lines
4.8 KiB
JavaScript
142 lines
4.8 KiB
JavaScript
(function() {
|
|
var dot = document.getElementById('sse-dot');
|
|
var es;
|
|
|
|
function connect() {
|
|
es = new EventSource('/events');
|
|
es.addEventListener('hello', function() {
|
|
dot.className = 'led led-green';
|
|
});
|
|
es.addEventListener('host.state_changed', function() {
|
|
window.location.reload();
|
|
});
|
|
es.addEventListener('activity.logged', function(e) {
|
|
var data;
|
|
try { data = JSON.parse(e.data); } catch(_) { return; }
|
|
var logDiv = document.getElementById('activity-log');
|
|
if (!logDiv) return;
|
|
var hostId = logDiv.getAttribute('data-host-id');
|
|
var opId = logDiv.getAttribute('data-operation-id');
|
|
if (opId) {
|
|
if (String(data.operation_id) !== opId) return;
|
|
} else if (hostId) {
|
|
if (String(data.host_id) !== hostId) return;
|
|
} else { return; }
|
|
var empty = logDiv.querySelector('.empty');
|
|
if (empty) empty.remove();
|
|
var entry = document.createElement('div');
|
|
entry.className = 'log-entry log-' + data.level;
|
|
var t = new Date(data.created_at);
|
|
var ts = t.getHours().toString().padStart(2,'0') + ':' + t.getMinutes().toString().padStart(2,'0');
|
|
entry.innerHTML = '<span class="log-time">' + ts + '</span>' +
|
|
'<span class="log-source">' + data.source + '</span>' +
|
|
'<span class="log-msg">' + data.message + '</span>';
|
|
logDiv.insertBefore(entry, logDiv.firstChild);
|
|
});
|
|
es.onerror = function() {
|
|
dot.className = 'led led-red';
|
|
es.close();
|
|
setTimeout(connect, 3000);
|
|
};
|
|
}
|
|
|
|
connect();
|
|
})();
|
|
|
|
(function() {
|
|
var form = document.getElementById('upload-form');
|
|
if (!form) return;
|
|
|
|
var uploadId = 'upload-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
|
document.getElementById('upload-id').value = uploadId;
|
|
|
|
var progressDiv = document.getElementById('upload-progress');
|
|
var progressFill = document.getElementById('progress-fill');
|
|
var progressTitle = document.getElementById('progress-title');
|
|
var progressText = document.getElementById('progress-text');
|
|
var progressDetail = document.getElementById('progress-detail');
|
|
|
|
var uploadES = new EventSource('/events');
|
|
uploadES.addEventListener('image.upload_progress', function(e) {
|
|
var data;
|
|
try { data = JSON.parse(e.data); } catch(_) { return; }
|
|
if (data.upload_id !== uploadId) return;
|
|
|
|
progressText.textContent = data.detail;
|
|
if (data.stage === 'parsing' || data.stage === 'extracting') {
|
|
progressTitle.textContent = 'Extracting boot files...';
|
|
progressDetail.textContent = 'Processing ISO on server...';
|
|
}
|
|
if (data.stage === 'complete') {
|
|
progressFill.classList.add('complete');
|
|
progressTitle.textContent = 'Complete';
|
|
progressDetail.textContent = 'Redirecting...';
|
|
}
|
|
});
|
|
|
|
form.addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
|
|
if (!form.checkValidity()) {
|
|
form.reportValidity();
|
|
return;
|
|
}
|
|
|
|
form.style.display = 'none';
|
|
progressDiv.style.display = 'block';
|
|
|
|
var formData = new FormData(form);
|
|
var xhr = new XMLHttpRequest();
|
|
|
|
xhr.upload.addEventListener('progress', function(ev) {
|
|
if (ev.lengthComputable) {
|
|
var pct = Math.round((ev.loaded / ev.total) * 100);
|
|
progressFill.style.width = pct + '%';
|
|
var mb = (ev.loaded / (1024 * 1024)).toFixed(1);
|
|
var totalMb = (ev.total / (1024 * 1024)).toFixed(1);
|
|
progressText.textContent = 'Uploading: ' + mb + ' / ' + totalMb + ' MB (' + pct + '%)';
|
|
}
|
|
});
|
|
|
|
xhr.upload.addEventListener('load', function() {
|
|
progressTitle.textContent = 'Processing ISO...';
|
|
progressText.textContent = 'Upload complete. Extracting kernel and initrd...';
|
|
progressDetail.textContent = 'This may take a minute...';
|
|
});
|
|
|
|
xhr.addEventListener('load', function() {
|
|
uploadES.close();
|
|
var resp;
|
|
try { resp = JSON.parse(xhr.responseText); } catch(_) { resp = {}; }
|
|
if (xhr.status >= 200 && xhr.status < 300 && resp.ok) {
|
|
window.location.href = '/images';
|
|
} else {
|
|
progressDiv.style.display = 'none';
|
|
form.style.display = 'block';
|
|
showUploadError(resp.error || 'Upload failed');
|
|
}
|
|
});
|
|
|
|
xhr.addEventListener('error', function() {
|
|
uploadES.close();
|
|
progressDiv.style.display = 'none';
|
|
form.style.display = 'block';
|
|
showUploadError('Network error during upload');
|
|
});
|
|
|
|
xhr.open('POST', '/images/upload');
|
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
|
xhr.send(formData);
|
|
});
|
|
|
|
function showUploadError(msg) {
|
|
var errDiv = form.parentNode.querySelector('.error');
|
|
if (!errDiv) {
|
|
errDiv = document.createElement('div');
|
|
errDiv.className = 'error';
|
|
form.parentNode.insertBefore(errDiv, form);
|
|
}
|
|
errDiv.textContent = msg;
|
|
}
|
|
})();
|