feat: real-time progress with per-folder bar, speed and ETA

- orchestrator: progress events now carry account-level cumulative copied/
  skipped plus current folder done/total, throttled to ~3/sec per account
- web: RUN CONTROL counters and account copied/skipped read live WS values
  (DB only advances per folder, so the summary lagged); new Progress column
  shows a bar, percent, avg messages/sec and folder ETA while running

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd
This commit is contained in:
2026-07-02 13:14:55 +07:00
parent 6395099b53
commit e1911ef13b
3 changed files with 149 additions and 9 deletions
+26 -2
View File
@@ -5,6 +5,7 @@ import (
"errors"
"log/slog"
"sync"
"time"
"github.com/vasyansk/imap-copier/internal/crypto"
"github.com/vasyansk/imap-copier/internal/imapx"
@@ -267,15 +268,36 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
}
var copied, skipped, errs int64
// Account-level live progress state (all callbacks run on this goroutine,
// so plain vars are race-free). base* = totals from completed folders;
// c/s inside OnProgress are cumulative within the current folder.
var baseCopied, baseSkipped int64
var curFolder string
var curTotal int64
var lastEmit time.Time
deps := imapx.CopyDeps{
IsMigrated: func(k string) (bool, error) { return o.store.IsMigrated(ctx, a.ID, k) },
MarkMigrated: func(folder, k string) error { return o.store.MarkMigrated(ctx, a.ID, folder, k) },
OnProgress: func(c, s int) {
o.hub.Publish(wshub.Event{Type: "progress", TaskID: task.ID,
Data: map[string]any{"account_id": a.ID, "copied": c, "skipped": s}})
now := time.Now()
done := c + s
// throttle to ~3/sec per account, but always emit folder completion
if now.Sub(lastEmit) < 350*time.Millisecond && int64(done) < curTotal {
return
}
lastEmit = now
o.hub.Publish(wshub.Event{Type: "progress", TaskID: task.ID, Data: map[string]any{
"account_id": a.ID,
"copied": baseCopied + int64(c),
"skipped": baseSkipped + int64(s),
"folder": curFolder,
"folder_done": done,
"folder_total": curTotal,
}})
},
// Fires after EXAMINE (before the long fetch) with the folder's message count.
OnFolder: func(srcFolder, dstFolder string, total int64) {
curFolder, curTotal = srcFolder, total
o.hub.Publish(wshub.Event{Type: "folder", TaskID: task.ID, Data: map[string]any{
"account_id": a.ID, "src_login": a.SrcLogin,
"folder": srcFolder, "dst_folder": dstFolder, "messages": total,
@@ -301,6 +323,8 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
copied += int64(res.Copied)
skipped += int64(res.Skipped)
errs += int64(res.Errors)
baseCopied += int64(res.Copied)
baseSkipped += int64(res.Skipped)
_ = o.store.IncAccountCounters(ctx, a.ID, int64(res.Copied), int64(res.Skipped), int64(res.Errors))
}