Add upload progress bar with SSE extraction status
build-and-push / test (push) Successful in 40s
build-and-push / build-and-push (push) Successful in 1m8s

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:
2026-05-10 19:16:19 -04:00
parent 4774600040
commit 443a3db9e1
6 changed files with 200 additions and 22 deletions
+10 -2
View File
@@ -160,7 +160,8 @@ func imageUploadForm(errMsg string) string {
return layout("Upload Image", fmt.Sprintf(` return layout("Upload Image", fmt.Sprintf(`
<h2>Upload Boot Image</h2> <h2>Upload Boot Image</h2>
%s %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>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>Version<input type="text" name="version" placeholder="8.2-1" required></label>
<label>Kind <label>Kind
@@ -169,9 +170,16 @@ func imageUploadForm(errMsg string) string {
</select> </select>
</label> </label>
<label>ISO File<input type="file" name="iso" accept=".iso" required></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 &amp; Extract</button> <button type="submit" class="btn">Upload &amp; Extract</button>
</form> </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)) `, errHTML))
} }
+30
View File
@@ -167,35 +167,65 @@ func (u *UI) NewImageForm(w http.ResponseWriter, r *http.Request) {
} }
func (u *UI) UploadImage(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 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")) renderHTML(w, imageUploadForm("Invalid form submission"))
}
return return
} }
name := strings.TrimSpace(r.FormValue("name")) name := strings.TrimSpace(r.FormValue("name"))
version := strings.TrimSpace(r.FormValue("version")) version := strings.TrimSpace(r.FormValue("version"))
kind := strings.TrimSpace(r.FormValue("kind")) kind := strings.TrimSpace(r.FormValue("kind"))
uploadID := strings.TrimSpace(r.FormValue("upload_id"))
file, _, err := r.FormFile("iso") file, _, err := r.FormFile("iso")
if err != nil { 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")) renderHTML(w, imageUploadForm("ISO file is required"))
}
return return
} }
defer file.Close() 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{ _, err = u.ImageSvc.Upload(r.Context(), image.UploadParams{
Name: name, Name: name,
Kind: kind, Kind: kind,
Version: version, Version: version,
ISO: file, ISO: file,
OnProgress: progressFn,
}) })
if err != nil { if err != nil {
if isXHR {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
} else {
renderHTML(w, imageUploadForm(err.Error())) renderHTML(w, imageUploadForm(err.Error()))
}
return return
} }
if isXHR {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} else {
http.Redirect(w, r, "/images", http.StatusSeeOther) http.Redirect(w, r, "/images", http.StatusSeeOther)
} }
}
func (u *UI) SetDefaultImage(w http.ResponseWriter, r *http.Request) { func (u *UI) SetDefaultImage(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")
+23
View File
@@ -15,10 +15,24 @@ type ExtractResult struct {
InitrdFilename string InitrdFilename string
} }
type ProgressFunc func(stage string, detail string)
var kernelCandidates = []string{"linux26", "vmlinuz", "bzImage"} var kernelCandidates = []string{"linux26", "vmlinuz", "bzImage"}
var initrdCandidates = []string{"initrd.img", "initrd", "initrd.gz"} var initrdCandidates = []string{"initrd.img", "initrd", "initrd.gz"}
func ExtractFromISO(r io.Reader, destDir string) (*ExtractResult, error) { 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") tmp, err := os.CreateTemp(filepath.Dir(destDir), "iso-upload-*.tmp")
if err != nil { if err != nil {
return nil, fmt.Errorf("create temp file: %w", err) return nil, fmt.Errorf("create temp file: %w", err)
@@ -32,6 +46,8 @@ func ExtractFromISO(r io.Reader, destDir string) (*ExtractResult, error) {
} }
tmp.Close() tmp.Close()
report("parsing", "Parsing ISO image...")
f, err := os.Open(tmpPath) f, err := os.Open(tmpPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("open temp ISO: %w", err) 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, ", ")) 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 { if err := extractFile(kernelFile, filepath.Join(destDir, kernelName)); err != nil {
return nil, fmt.Errorf("extract kernel: %w", err) return nil, fmt.Errorf("extract kernel: %w", err)
} }
report("extracting", "Extracting initrd...")
if err := extractFile(initrdFile, filepath.Join(destDir, initrdName)); err != nil { if err := extractFile(initrdFile, filepath.Join(destDir, initrdName)); err != nil {
return nil, fmt.Errorf("extract initrd: %w", err) return nil, fmt.Errorf("extract initrd: %w", err)
} }
report("complete", "Extraction complete")
return &ExtractResult{ return &ExtractResult{
KernelFilename: kernelName, KernelFilename: kernelName,
InitrdFilename: initrdName, InitrdFilename: initrdName,
+2 -1
View File
@@ -22,6 +22,7 @@ type UploadParams struct {
Kind string Kind string
Version string Version string
ISO io.Reader ISO io.Reader
OnProgress ProgressFunc
} }
var slugRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]*$`) 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) 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 { if err != nil {
os.RemoveAll(destDir) os.RemoveAll(destDir)
return nil, fmt.Errorf("extract ISO: %w", err) return nil, fmt.Errorf("extract ISO: %w", err)
+21
View File
@@ -125,3 +125,24 @@ main { padding: 1.5rem; max-width: 1200px; margin: 0 auto; }
.inline { display: inline; } .inline { display: inline; }
h2 { margin-bottom: 1rem; } h2 { margin-bottom: 1rem; }
h3 { margin: 1.5rem 0 0.75rem; color: var(--text-muted); font-size: 0.95rem; } 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
View File
@@ -1,18 +1,16 @@
(function() { (function() {
const dot = document.getElementById('sse-dot'); var dot = document.getElementById('sse-dot');
let es; var es;
function connect() { function connect() {
es = new EventSource('/events'); es = new EventSource('/events');
es.addEventListener('hello', () => { es.addEventListener('hello', function() {
dot.classList.remove('disconnected'); dot.classList.remove('disconnected');
}); });
es.addEventListener('host.state_changed', (e) => { es.addEventListener('host.state_changed', function() {
// Reload the page to reflect state changes
// Future: HTMX swap individual tiles
window.location.reload(); window.location.reload();
}); });
es.onerror = () => { es.onerror = function() {
dot.classList.add('disconnected'); dot.classList.add('disconnected');
es.close(); es.close();
setTimeout(connect, 3000); setTimeout(connect, 3000);
@@ -21,3 +19,100 @@
connect(); 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;
}
})();