From fd6f0e3353b4934a1a0ba4cb3f0f583fb0f73c50 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 15 Oct 2026 08:29:04 -0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20scan=20progress=20=E2=80=94=20corre?= =?UTF-8?q?ct=20percentages,=20no=20layout=20shift,=20refresh-survivable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: the admin 'Scanning Libraries' widget fed the server's 0-1 progress straight into percent strings and bar widths, so the overall and per-library percentages capped at '1%' and the bars never filled. Both now scale by 100 (matching the setup page, which was already correct). Bug: scan state only reached clients via websocket events, so a page opened (or refreshed) mid-scan showed nothing until the next tick — and the admin widget never re-attached at all. The worker now exposes active jobs (with their library_id so rows keep their names), served as GET /api/scanner/active (admin, re-attaches the widget + polling) and GET /api/scanner/active-summary (any user, restores the sidebar indicator on load). Idle hides both, exactly as before. Annoyance: the sidebar's 'Scanning… N%' was a block element between the logo and the nav, shoving every menu entry down while a scan ran. The percent now lives inside the fixed-height logo row next to the spinner (spinner last, so growing digits extend leftward); nothing below it moves. --- internal/handlers/scanner.go | 32 +++++++++++++ internal/router/router.go | 4 +- internal/router/scanner.go | 8 +++- internal/services/worker.go | 36 +++++++++++--- internal/services/worker_active_test.go | 60 ++++++++++++++++++++++++ templates/admin.templ | 2 +- templates/admin_templ.go | 2 +- templates/header.templ | 32 +++++++------ templates/header_templ.go | 22 ++++----- web/src/admin.ts | 62 +++++++++++++++++++++++-- web/src/header.ts | 23 +++++++++ 11 files changed, 245 insertions(+), 38 deletions(-) create mode 100644 internal/services/worker_active_test.go diff --git a/internal/handlers/scanner.go b/internal/handlers/scanner.go index 684269bd..66f902ed 100644 --- a/internal/handlers/scanner.go +++ b/internal/handlers/scanner.go @@ -177,6 +177,38 @@ func (h *Handler) GetScanStatus(c *echo.Context) error { }) } +// GetActiveScans handles GET /api/scanner/active (admin): every +// pending/running job. Lets the admin page re-attach to in-flight +// scans after a page refresh instead of only tracking scans it +// started itself in this page load. +func (h *Handler) GetActiveScans(c *echo.Context) error { + jobs := h.worker.GetActiveScanJobs() + return c.JSON(http.StatusOK, map[string]interface{}{ + "scanning": len(jobs) > 0, + "jobs": jobs, + }) +} + +// GetScanSummary handles GET /api/scanner/active-summary (any +// authenticated user): whether a scan is running and its average +// progress (0-1). Powers the sitewide sidebar indicator, which must +// survive page refreshes — websocket events alone only reach pages +// that were already open when the scan started. +func (h *Handler) GetScanSummary(c *echo.Context) error { + jobs := h.worker.GetActiveScanJobs() + progress := 0.0 + if len(jobs) > 0 { + for _, job := range jobs { + progress += job.Progress + } + progress /= float64(len(jobs)) + } + return c.JSON(http.StatusOK, map[string]interface{}{ + "scanning": len(jobs) > 0, + "progress": progress, + }) +} + // StartWatchModeForLibrary starts watching a specific library's folders func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype.UUID, adminID pgtype.UUID) error { h.mu.Lock() diff --git a/internal/router/router.go b/internal/router/router.go index 7ff862a5..fe67ddd2 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -332,9 +332,9 @@ func RegisterRoutes(cfg *Config) *handlers.Handler { // Register progress routes with actual handler registerProgressRoutes(cfg, scannerHandler) - // Register scanner routes (admin only) + // Register scanner routes (admin only, plus the sitewide summary) admin := protected.Group("", handlers.AdminMiddleware) - registerScannerRoutes(admin, scannerHandler) + registerScannerRoutes(protected, admin, scannerHandler) // Hash conflict routes (admin only) admin.GET("/api/admin/hash-conflicts", cfg.HashConflictsHandler.ListHashConflicts) diff --git a/internal/router/scanner.go b/internal/router/scanner.go index bf75e007..d790e2b1 100644 --- a/internal/router/scanner.go +++ b/internal/router/scanner.go @@ -7,12 +7,18 @@ import ( ) // registerScannerRoutes registers all scanner-related endpoints -func registerScannerRoutes(admin *echo.Group, h *handlers.Handler) { +func registerScannerRoutes(protected, admin *echo.Group, h *handlers.Handler) { + // Any authenticated user: the sitewide sidebar indicator needs to + // know a scan is running after a page refresh (websocket events + // only reach pages that were already open). + protected.GET("/scanner/active-summary", h.GetScanSummary) + // Scanner routes (admin only) admin.POST("/scanner/scan", h.ScanLibrary) admin.POST("/scanner/start", h.StartScanner) admin.POST("/scanner/stop", h.StopScanner) admin.GET("/scanner/status/:jobId", h.GetScanStatus) + admin.GET("/scanner/active", h.GetActiveScans) // Watch mode routes (admin only) admin.POST("/scanner/watch/start", h.StartWatchMode) diff --git a/internal/services/worker.go b/internal/services/worker.go index 59b7b472..10ae136c 100644 --- a/internal/services/worker.go +++ b/internal/services/worker.go @@ -73,8 +73,12 @@ func (j *Job) UpdateProgress(progress float64, filesScanned, newItems, errors in } type JobResult struct { - JobID string `json:"job_id"` - Status JobStatus `json:"status"` + JobID string `json:"job_id"` + Status JobStatus `json:"status"` + // LibraryID carries the scan job's library (from Params) so clients + // can label progress rows after a page refresh, when the original + // start-request context is gone. + LibraryID string `json:"library_id,omitempty"` Error string `json:"error"` Result interface{} `json:"result"` Progress float64 `json:"progress"` @@ -237,10 +241,12 @@ func (w *Worker) worker() { } func (w *Worker) processJob(job *Job) { + libID, _ := job.Params["library_id"].(string) w.mu.Lock() w.results[job.ID] = &JobResult{ - JobID: job.ID, - Status: JobStatusRunning, + JobID: job.ID, + Status: JobStatusRunning, + LibraryID: libID, } w.mu.Unlock() @@ -313,8 +319,9 @@ func (w *Worker) processJob(job *Job) { } w.results[job.ID] = &JobResult{ - JobID: job.ID, - Status: status, + JobID: job.ID, + Status: status, + LibraryID: libID, Error: func() string { if err != nil { return err.Error() @@ -981,6 +988,23 @@ func (w *Worker) GetJobStatus(jobID string) (*JobResult, bool) { return result, exists } +// GetActiveScanJobs returns snapshots of every pending/running job. +// Clients use it to re-attach to in-flight scans (e.g. after a page +// refresh mid-scan) instead of only learning about scans from +// websocket events. +func (w *Worker) GetActiveScanJobs() []JobResult { + w.mu.RLock() + defer w.mu.RUnlock() + + active := make([]JobResult, 0) + for _, result := range w.results { + if result.Status == JobStatusRunning || result.Status == JobStatusPending { + active = append(active, *result) + } + } + return active +} + func (w *Worker) CancelJob(jobID string) error { w.mu.Lock() defer w.mu.Unlock() diff --git a/internal/services/worker_active_test.go b/internal/services/worker_active_test.go new file mode 100644 index 00000000..59acd1b4 --- /dev/null +++ b/internal/services/worker_active_test.go @@ -0,0 +1,60 @@ +package services + +import ( + "testing" +) + +// GetActiveScanJobs backs the refresh-survival contract: a client that +// loads the page mid-scan must be able to discover pending/running jobs +// (with their library) rather than only learning about scans from +// websocket events emitted after the page opened. +func TestGetActiveScanJobs(t *testing.T) { + w := NewWorker(1, nil) + + seed := map[string]*JobResult{ + "running-scan": {JobID: "running-scan", Status: JobStatusRunning, LibraryID: "lib-1", Progress: 0.42}, + "pending-scan": {JobID: "pending-scan", Status: JobStatusPending, LibraryID: "lib-2"}, + "done-scan": {JobID: "done-scan", Status: JobStatusCompleted, LibraryID: "lib-3"}, + "failed-scan": {JobID: "failed-scan", Status: JobStatusFailed}, + "cancelled": {JobID: "cancelled", Status: JobStatusCancelled}, + } + w.mu.Lock() + w.results = seed + w.mu.Unlock() + + active := w.GetActiveScanJobs() + if len(active) != 2 { + t.Fatalf("expected 2 active jobs, got %d (%+v)", len(active), active) + } + + seen := map[string]JobResult{} + for _, job := range active { + seen[job.JobID] = job + } + + running, ok := seen["running-scan"] + if !ok { + t.Fatal("running scan missing from active jobs") + } + if running.LibraryID != "lib-1" { + t.Errorf("expected library_id lib-1, got %q", running.LibraryID) + } + if running.Progress != 0.42 { + t.Errorf("expected progress 0.42, got %v", running.Progress) + } + if _, ok := seen["pending-scan"]; !ok { + t.Error("pending scan missing from active jobs") + } + for _, id := range []string{"done-scan", "failed-scan", "cancelled"} { + if _, ok := seen[id]; ok { + t.Errorf("terminal job %q must not appear as active", id) + } + } +} + +func TestGetActiveScanJobsEmpty(t *testing.T) { + w := NewWorker(1, nil) + if jobs := w.GetActiveScanJobs(); len(jobs) != 0 { + t.Fatalf("expected no active jobs, got %d", len(jobs)) + } +} diff --git a/templates/admin.templ b/templates/admin.templ index aef13964..3ffd5d70 100644 --- a/templates/admin.templ +++ b/templates/admin.templ @@ -10,7 +10,7 @@ templ Admin(user User, stats AdminStats) { - + @Header(user, "/admin")
diff --git a/templates/admin_templ.go b/templates/admin_templ.go index cba25c28..1e447af1 100644 --- a/templates/admin_templ.go +++ b/templates/admin_templ.go @@ -29,7 +29,7 @@ func Admin(user User, stats AdminStats) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Admin Dashboard - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Admin Dashboard - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/header.templ b/templates/header.templ index b6c40ff0..1025eefd 100644 --- a/templates/header.templ +++ b/templates/header.templ @@ -24,11 +24,24 @@ templ Header(user User, currentPath string) { @Icon("book-open", "h-6 w-6") Bookhoard + +
+ - - +
+