fix(web): scan progress — correct percentages, no layout shift, refresh-survivable
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.
This commit is contained in:
1 parent
9bc149f3d9
commit
fd6f0e3353
11 files changed
+245
-38
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user