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:
John O'Keefe committed 2026-10-15 08:29:04 -04:00
1 parent 9bc149f3d9
commit fd6f0e3353
11 files changed
+245 -38

No files matched your search

+32
View File
@@ -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()
+2 -2
View File
@@ -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 -1
View File
@@ -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)
+30 -6
View File
@@ -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()
+60
View File
@@ -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))
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ templ Admin(user User, stats AdminStats) {
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body x-data="admin" x-init="loadWatchStatus()" class="theme-{ user.Theme }">
<body x-data="admin" x-init="loadWatchStatus(); checkActiveScans()" class="theme-{ user.Theme }">
@Header(user, "/admin")
<main class="p-8">
<div class="max-w-4xl">
+1 -1
View File
@@ -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, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Admin Dashboard - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"admin\" x-init=\"loadWatchStatus()\" class=\"theme-{ user.Theme }\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Admin Dashboard - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"admin\" x-init=\"loadWatchStatus(); checkActiveScans()\" class=\"theme-{ user.Theme }\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+19 -13
View File
@@ -24,11 +24,24 @@ templ Header(user User, currentPath string) {
@Icon("book-open", "h-6 w-6")
</span>
<span class="font-bold text-lg tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
<!-- Scanning indicator: lives INSIDE the fixed-height logo row so
it can never push the nav entries down. Spinner sits last so
the growing percent extends leftward, not into the spinner. -->
<div
x-show="scanning"
x-cloak
x-transition
class="ml-auto flex items-center gap-1.5 shrink-0"
aria-live="polite"
>
<span
x-show="scanProgress > 0"
class="text-xs font-medium"
style="color: var(--text-secondary);"
x-text="scanProgress + '%'"
></span>
<svg
x-show="scanning"
x-cloak
x-transition
class="h-4 w-4 animate-spin ml-auto"
class="h-4 w-4 animate-spin"
style="color: var(--accent);"
fill="none"
viewBox="0 0 24 24"
@@ -36,15 +49,8 @@ templ Header(user User, currentPath string) {
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</a>
<span
x-show="scanning && scanProgress > 0"
x-cloak
x-transition
class="block px-5 py-1.5 text-xs font-medium -mt-1"
style="color: var(--text-secondary);"
x-text="'Scanning… ' + scanProgress + '%'"
></span>
</div>
</a>
<!-- Primary navigation -->
<nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
<a href="/dashboard" class={ activeClass(currentPath, "/dashboard") }>
+11 -11
View File
@@ -37,7 +37,7 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span> <span class=\"font-bold text-lg tracking-tight\" style=\"color: var(--text-primary)\">Bookhoard</span> <svg x-show=\"scanning\" x-cloak x-transition class=\"h-4 w-4 animate-spin ml-auto\" style=\"color: var(--accent);\" fill=\"none\" viewBox=\"0 0 24 24\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path></svg></a> <span x-show=\"scanning && scanProgress > 0\" x-cloak x-transition class=\"block px-5 py-1.5 text-xs font-medium -mt-1\" style=\"color: var(--text-secondary);\" x-text=\"'Scanning… ' + scanProgress + '%'\"></span><!-- Primary navigation --><nav class=\"flex-1 px-3 py-4 space-y-0.5 overflow-y-auto\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span> <span class=\"font-bold text-lg tracking-tight\" style=\"color: var(--text-primary)\">Bookhoard</span><!-- Scanning indicator: lives INSIDE the fixed-height logo row so\n\t\t\t it can never push the nav entries down. Spinner sits last so\n\t\t\t the growing percent extends leftward, not into the spinner. --><div x-show=\"scanning\" x-cloak x-transition class=\"ml-auto flex items-center gap-1.5 shrink-0\" aria-live=\"polite\"><span x-show=\"scanProgress > 0\" class=\"text-xs font-medium\" style=\"color: var(--text-secondary);\" x-text=\"scanProgress + '%'\"></span> <svg class=\"h-4 w-4 animate-spin\" style=\"color: var(--accent);\" fill=\"none\" viewBox=\"0 0 24 24\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path></svg></div></a><!-- Primary navigation --><nav class=\"flex-1 px-3 py-4 space-y-0.5 overflow-y-auto\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -290,7 +290,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeTheme('" + opt.Name + "'); openPanel = null")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 107, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 113, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
@@ -303,7 +303,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 109, Col: 29}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 115, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -316,7 +316,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 112, Col: 130}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 118, Col: 130}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -329,7 +329,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 113, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 119, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -382,7 +382,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 124, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 130, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
@@ -395,7 +395,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 126, Col: 26}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 132, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
@@ -418,7 +418,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 132, Col: 191}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 138, Col: 191}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@@ -436,7 +436,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 134, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 140, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
@@ -676,7 +676,7 @@ func SidebarUserMenu(user User) templ.Component {
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 224, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 230, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
@@ -760,7 +760,7 @@ func SidebarSignIn(currentPath string) templ.Component {
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 272, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 278, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
if templ_7745c5c3_Err != nil {
+59 -3
View File
@@ -138,7 +138,8 @@ function pollScanProgress(
}
}
const overallProgress = Math.round(totalProgress / jobIds.length);
// status.progress arrives 0-1 from the server; render as percent.
const overallProgress = Math.round((totalProgress / jobIds.length) * 100);
const progressBar = document.getElementById(
"scan-progress-bar",
) as HTMLElement;
@@ -172,14 +173,18 @@ function updateLibraryProgress(jobId: string, status: any): void {
const bar = document.getElementById(`bar-${jobId}`) as HTMLElement;
const statusText = document.getElementById(`status-${jobId}`) as HTMLElement;
// status.progress arrives 0-1 from the server; render as percent,
// otherwise the bar sits at sub-1% forever and never fills.
const percent = Math.round((status.progress || 0) * 100);
if (bar) {
bar.style.width = (status.progress || 0) + "%";
bar.style.width = percent + "%";
}
if (statusText) {
const statusMessages: Record<string, string> = {
pending: "Pending...",
running: `Scanning... ${status.progress || 0}%`,
running: `Scanning... ${percent}%`,
completed: `✓ Complete (${status.new_items || 0} items)`,
failed: `✗ Failed`,
};
@@ -187,6 +192,55 @@ function updateLibraryProgress(jobId: string, status: any): void {
}
}
// On page load: if a scan is already running (started before this page
// was opened, or the page was refreshed mid-scan), surface the progress
// widget and re-attach polling. Websocket start-events only reach pages
// that were already open when the scan began.
async function checkActiveScans(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const resp = await fetch("/api/scanner/active", {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) return;
const data = await resp.json();
const jobs = (data.jobs || []) as Array<{
job_id: string;
library_id?: string;
}>;
if (jobs.length === 0) return;
// Label rows by library name where we can resolve one.
const libraryNames: Record<string, string> = {};
try {
const libsResp = await fetch("/api/libraries", {
headers: { Authorization: `Bearer ${token}` },
});
if (libsResp.ok) {
const libsData = await libsResp.json();
for (const lib of libsData.data || []) {
libraryNames[lib.id] = lib.name;
}
}
} catch {
// Names fall back to ids below.
}
const jobIds = jobs.map((job) => job.job_id);
const names: Record<string, string> = {};
for (const job of jobs) {
names[job.job_id] =
(job.library_id && libraryNames[job.library_id]) ||
`Library ${(job.library_id || job.job_id).slice(0, 8)}`;
}
showScanProgress(jobIds, names);
pollScanProgress(jobIds, names);
} catch (error) {
console.error("Failed to check for active scans:", error);
}
}
function showScanResults(
libCount: number,
files: number,
@@ -389,6 +443,7 @@ document.querySelectorAll<HTMLElement>("[data-merge-source]").forEach((el) => {
});
export {
checkActiveScans,
hideScanProgress,
loadWatchStatus,
scanAllLibraries,
@@ -396,6 +451,7 @@ export {
};
Alpine.data("admin", () => ({
checkActiveScans,
hideScanProgress,
loadWatchStatus,
scanAllLibraries,
+23
View File
@@ -41,6 +41,29 @@ const restoreLibrarySelection = (): void => {
};
const initializeScanListener = function (this: any) {
// Restore state on page load: a scan that started before this page
// was opened (or a refresh mid-scan) emits no websocket event until
// its next progress tick, so ask the server directly first.
const restoreActiveScan = async () => {
try {
const token = localStorage.getItem("token");
if (!token) return;
const resp = await fetch("/api/scanner/active-summary", {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) return;
const data = await resp.json();
if (data.scanning) {
this.scanning = true;
this.scanProgress = Math.round((data.progress || 0) * 100);
}
} catch {
// Offline or endpoint unavailable — websocket events still drive
// the state once they arrive.
}
};
restoreActiveScan();
const handler = (message: any) => {
switch (message.type) {
case "scan_progress":