Add upload progress bar with SSE extraction status
ISO uploads now show a progress bar during file transfer (via XHR upload.onprogress) and real-time extraction status (via SSE events through the existing Hub). Falls back to plain form POST if JS is disabled. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+10
-2
@@ -160,7 +160,8 @@ func imageUploadForm(errMsg string) string {
|
||||
return layout("Upload Image", fmt.Sprintf(`
|
||||
<h2>Upload Boot Image</h2>
|
||||
%s
|
||||
<form method="POST" action="/images/upload" enctype="multipart/form-data" class="form">
|
||||
<form id="upload-form" method="POST" action="/images/upload" enctype="multipart/form-data" class="form">
|
||||
<input type="hidden" name="upload_id" id="upload-id" value="">
|
||||
<label>Name<input type="text" name="name" placeholder="proxmox-8.2" required pattern="[a-z0-9][a-z0-9.\-]*"></label>
|
||||
<label>Version<input type="text" name="version" placeholder="8.2-1" required></label>
|
||||
<label>Kind
|
||||
@@ -169,9 +170,16 @@ func imageUploadForm(errMsg string) string {
|
||||
</select>
|
||||
</label>
|
||||
<label>ISO File<input type="file" name="iso" accept=".iso" required></label>
|
||||
<p style="font-size:0.8rem;color:var(--text-muted);margin-bottom:0.5rem">Upload may take several minutes for large ISOs. The kernel and initrd will be extracted automatically.</p>
|
||||
<button type="submit" class="btn">Upload & Extract</button>
|
||||
</form>
|
||||
<div id="upload-progress" style="display:none" class="upload-progress">
|
||||
<h3 id="progress-title">Uploading ISO...</h3>
|
||||
<div class="progress-bar-track">
|
||||
<div class="progress-bar-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="progress-text">Preparing upload...</div>
|
||||
<div class="progress-detail" id="progress-detail"></div>
|
||||
</div>
|
||||
`, errHTML))
|
||||
}
|
||||
|
||||
|
||||
@@ -167,34 +167,64 @@ func (u *UI) NewImageForm(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (u *UI) UploadImage(w http.ResponseWriter, r *http.Request) {
|
||||
isXHR := r.Header.Get("X-Requested-With") == "XMLHttpRequest"
|
||||
|
||||
if err := r.ParseMultipartForm(0); err != nil {
|
||||
if isXHR {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "Invalid form submission"})
|
||||
} else {
|
||||
renderHTML(w, imageUploadForm("Invalid form submission"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
version := strings.TrimSpace(r.FormValue("version"))
|
||||
kind := strings.TrimSpace(r.FormValue("kind"))
|
||||
uploadID := strings.TrimSpace(r.FormValue("upload_id"))
|
||||
|
||||
file, _, err := r.FormFile("iso")
|
||||
if err != nil {
|
||||
if isXHR {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "ISO file is required"})
|
||||
} else {
|
||||
renderHTML(w, imageUploadForm("ISO file is required"))
|
||||
}
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var progressFn image.ProgressFunc
|
||||
if uploadID != "" {
|
||||
progressFn = func(stage, detail string) {
|
||||
u.Hub.Publish(events.Event{
|
||||
Name: "image.upload_progress",
|
||||
Payload: fmt.Sprintf(`{"upload_id":%q,"stage":%q,"detail":%q}`, uploadID, stage, detail),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_, err = u.ImageSvc.Upload(r.Context(), image.UploadParams{
|
||||
Name: name,
|
||||
Kind: kind,
|
||||
Version: version,
|
||||
ISO: file,
|
||||
OnProgress: progressFn,
|
||||
})
|
||||
if err != nil {
|
||||
if isXHR {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
|
||||
} else {
|
||||
renderHTML(w, imageUploadForm(err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if isXHR {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
} else {
|
||||
http.Redirect(w, r, "/images", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UI) SetDefaultImage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -15,10 +15,24 @@ type ExtractResult struct {
|
||||
InitrdFilename string
|
||||
}
|
||||
|
||||
type ProgressFunc func(stage string, detail string)
|
||||
|
||||
var kernelCandidates = []string{"linux26", "vmlinuz", "bzImage"}
|
||||
var initrdCandidates = []string{"initrd.img", "initrd", "initrd.gz"}
|
||||
|
||||
func ExtractFromISO(r io.Reader, destDir string) (*ExtractResult, error) {
|
||||
return ExtractFromISOWithProgress(r, destDir, nil)
|
||||
}
|
||||
|
||||
func ExtractFromISOWithProgress(r io.Reader, destDir string, progress ProgressFunc) (*ExtractResult, error) {
|
||||
report := func(stage, detail string) {
|
||||
if progress != nil {
|
||||
progress(stage, detail)
|
||||
}
|
||||
}
|
||||
|
||||
report("receiving", "Writing ISO to disk...")
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(destDir), "iso-upload-*.tmp")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp file: %w", err)
|
||||
@@ -32,6 +46,8 @@ func ExtractFromISO(r io.Reader, destDir string) (*ExtractResult, error) {
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
report("parsing", "Parsing ISO image...")
|
||||
|
||||
f, err := os.Open(tmpPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open temp ISO: %w", err)
|
||||
@@ -69,13 +85,20 @@ func ExtractFromISO(r io.Reader, destDir string) (*ExtractResult, error) {
|
||||
return nil, fmt.Errorf("no initrd found in ISO (looked for %s)", strings.Join(initrdCandidates, ", "))
|
||||
}
|
||||
|
||||
report("extracting", "Extracting kernel...")
|
||||
|
||||
if err := extractFile(kernelFile, filepath.Join(destDir, kernelName)); err != nil {
|
||||
return nil, fmt.Errorf("extract kernel: %w", err)
|
||||
}
|
||||
|
||||
report("extracting", "Extracting initrd...")
|
||||
|
||||
if err := extractFile(initrdFile, filepath.Join(destDir, initrdName)); err != nil {
|
||||
return nil, fmt.Errorf("extract initrd: %w", err)
|
||||
}
|
||||
|
||||
report("complete", "Extraction complete")
|
||||
|
||||
return &ExtractResult{
|
||||
KernelFilename: kernelName,
|
||||
InitrdFilename: initrdName,
|
||||
|
||||
@@ -22,6 +22,7 @@ type UploadParams struct {
|
||||
Kind string
|
||||
Version string
|
||||
ISO io.Reader
|
||||
OnProgress ProgressFunc
|
||||
}
|
||||
|
||||
var slugRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]*$`)
|
||||
@@ -46,7 +47,7 @@ func (s *Service) Upload(ctx context.Context, p UploadParams) (*model.Image, err
|
||||
return nil, fmt.Errorf("create image dir: %w", err)
|
||||
}
|
||||
|
||||
result, err := ExtractFromISO(p.ISO, destDir)
|
||||
result, err := ExtractFromISOWithProgress(p.ISO, destDir, p.OnProgress)
|
||||
if err != nil {
|
||||
os.RemoveAll(destDir)
|
||||
return nil, fmt.Errorf("extract ISO: %w", err)
|
||||
|
||||
@@ -125,3 +125,24 @@ main { padding: 1.5rem; max-width: 1200px; margin: 0 auto; }
|
||||
.inline { display: inline; }
|
||||
h2 { margin-bottom: 1rem; }
|
||||
h3 { margin: 1.5rem 0 0.75rem; color: var(--text-muted); font-size: 0.95rem; }
|
||||
|
||||
.upload-progress { max-width: 400px; }
|
||||
.progress-bar-track {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin: 0.75rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--accent);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.progress-bar-fill.complete { background: var(--green); }
|
||||
.progress-text { font-size: 0.85rem; color: var(--text); margin-bottom: 0.25rem; }
|
||||
.progress-detail { font-size: 0.8rem; color: var(--text-muted); }
|
||||
|
||||
+102
-7
@@ -1,18 +1,16 @@
|
||||
(function() {
|
||||
const dot = document.getElementById('sse-dot');
|
||||
let es;
|
||||
var dot = document.getElementById('sse-dot');
|
||||
var es;
|
||||
|
||||
function connect() {
|
||||
es = new EventSource('/events');
|
||||
es.addEventListener('hello', () => {
|
||||
es.addEventListener('hello', function() {
|
||||
dot.classList.remove('disconnected');
|
||||
});
|
||||
es.addEventListener('host.state_changed', (e) => {
|
||||
// Reload the page to reflect state changes
|
||||
// Future: HTMX swap individual tiles
|
||||
es.addEventListener('host.state_changed', function() {
|
||||
window.location.reload();
|
||||
});
|
||||
es.onerror = () => {
|
||||
es.onerror = function() {
|
||||
dot.classList.add('disconnected');
|
||||
es.close();
|
||||
setTimeout(connect, 3000);
|
||||
@@ -21,3 +19,100 @@
|
||||
|
||||
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;
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user