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
+
+
+