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
-
+
+
Uploading ISO...
+
+
Preparing upload...
+
+
`, 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;
+ }
+})();