From 443a3db9e10e74d09dc3a9a4b54e6cf890dc0ac9 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 10 May 2026 19:16:19 -0400 Subject: [PATCH] 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 --- internal/api/render.go | 12 +++- internal/api/ui.go | 46 ++++++++++++--- internal/image/extract.go | 23 ++++++++ internal/image/service.go | 11 ++-- internal/web/static/app.css | 21 +++++++ internal/web/static/app.js | 109 +++++++++++++++++++++++++++++++++--- 6 files changed, 200 insertions(+), 22 deletions(-) diff --git a/internal/api/render.go b/internal/api/render.go index 66d133f..fc41971 100644 --- a/internal/api/render.go +++ b/internal/api/render.go @@ -160,7 +160,8 @@ func imageUploadForm(errMsg string) string { return layout("Upload Image", fmt.Sprintf(`

Upload Boot Image

%s -
+ + -

Upload may take several minutes for large ISOs. The kernel and initrd will be extracted automatically.

+ `, errHTML)) } diff --git a/internal/api/ui.go b/internal/api/ui.go index 50086b0..c2052b0 100644 --- a/internal/api/ui.go +++ b/internal/api/ui.go @@ -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 { - renderHTML(w, imageUploadForm("Invalid form submission")) + 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 { - renderHTML(w, imageUploadForm("ISO file is required")) + 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, + Name: name, + Kind: kind, + Version: version, + ISO: file, + OnProgress: progressFn, }) if err != nil { - renderHTML(w, imageUploadForm(err.Error())) + if isXHR { + writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()}) + } else { + renderHTML(w, imageUploadForm(err.Error())) + } return } - http.Redirect(w, r, "/images", http.StatusSeeOther) + 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) { diff --git a/internal/image/extract.go b/internal/image/extract.go index e48ef7d..fd29f0c 100644 --- a/internal/image/extract.go +++ b/internal/image/extract.go @@ -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, diff --git a/internal/image/service.go b/internal/image/service.go index 5677ea0..cbe8c02 100644 --- a/internal/image/service.go +++ b/internal/image/service.go @@ -18,10 +18,11 @@ type Service struct { } type UploadParams struct { - Name string - Kind string - Version string - ISO io.Reader + Name string + 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) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index d85039f..80d078b 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -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); } diff --git a/internal/web/static/app.js b/internal/web/static/app.js index c4d2d48..743d617 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -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; + } +})();