diff --git a/internal/httpapi/accounts.go b/internal/httpapi/accounts.go index 73ef363..ccceb16 100644 --- a/internal/httpapi/accounts.go +++ b/internal/httpapi/accounts.go @@ -21,6 +21,7 @@ type AccountView struct { Copied int64 `json:"copied"` Skipped int64 `json:"skipped"` Errors int64 `json:"errors"` + LastError string `json:"last_error,omitempty"` } func accountDTO(a store.Account) AccountView { @@ -28,6 +29,7 @@ func accountDTO(a store.Account) AccountView { ID: a.ID, SrcLogin: a.SrcLogin, DstLogin: a.DstLogin, TestSrcStatus: a.TestSrcStatus, TestDstStatus: a.TestDstStatus, Status: a.Status, Copied: a.Copied, Skipped: a.Skipped, Errors: a.Errors, + LastError: a.LastError, } } diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 590160f..136a873 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -217,6 +217,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in "dst_login": a.DstLogin, "dst_host": dstEP.Host, "dst_port": dstEP.Port, }}) _ = o.store.SetAccountStatus(ctx, a.ID, "running") + _ = o.store.SetAccountError(ctx, a.ID, "") // clear any error from a previous run // Per-account cancellable context: IMAP work uses actx (so CancelAccount // stops it); DB writes keep the parent ctx so status/counters persist even @@ -349,19 +350,23 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in break // cancelled — stop scheduling more folders } res, err := imapx.CopyFolder(actx, src, dst, fp.src, fp.dst, deps) + folderErr := int64(0) if err != nil && actx.Err() == nil { slog.Warn("folder copy error", "account", a.ID, "src_login", a.SrcLogin, "folder", fp.src, "err", err) - errs++ + folderErr = 1 + _ = o.store.SetAccountError(ctx, a.ID, "folder \""+fp.src+"\": "+err.Error()) o.hub.Publish(wshub.Event{Type: "error", TaskID: task.ID, Data: map[string]any{ "account_id": a.ID, "src_login": a.SrcLogin, "folder": fp.src, "error": err.Error(), }}) } copied += int64(res.Copied) skipped += int64(res.Skipped) - errs += int64(res.Errors) + errs += int64(res.Errors) + folderErr baseCopied += int64(res.Copied) baseSkipped += int64(res.Skipped) - _ = o.store.IncAccountCounters(ctx, a.ID, int64(res.Copied), int64(res.Skipped), int64(res.Errors)) + // Persist message-level AND folder-level errors so the account row's + // error count matches the task status (done_with_errors). + _ = o.store.IncAccountCounters(ctx, a.ID, int64(res.Copied), int64(res.Skipped), int64(res.Errors)+folderErr) } if actx.Err() != nil { @@ -373,7 +378,11 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in return copied, skipped, errs } - _ = o.store.SetAccountStatus(ctx, a.ID, "done") + acctStatus := "done" + if errs > 0 { + acctStatus = "done_with_errors" + } + _ = o.store.SetAccountStatus(ctx, a.ID, acctStatus) o.hub.Publish(wshub.Event{Type: "account_done", TaskID: task.ID, Data: map[string]any{"account_id": a.ID, "src_login": a.SrcLogin, "dst_login": a.DstLogin, "copied": copied, "skipped": skipped, "errors": errs}}) @@ -395,6 +404,7 @@ func (o *Orchestrator) accountFailed(ctx context.Context, taskID int64, a store. } slog.Error("account failed", "account", a.ID, "side", side, "login", login, "host", host, "port", port, "err", err) _ = o.store.SetAccountStatus(ctx, a.ID, "error") + _ = o.store.SetAccountError(ctx, a.ID, side+" "+login+"@"+host+": "+err.Error()) o.hub.Publish(wshub.Event{Type: "error", TaskID: taskID, Data: map[string]any{"account_id": a.ID, "side": side, "login": login, "host": host, "port": port, "error": err.Error()}}) return 0, 0, 1 diff --git a/internal/store/accounts.go b/internal/store/accounts.go index 3727ad4..bddb3a4 100644 --- a/internal/store/accounts.go +++ b/internal/store/accounts.go @@ -18,6 +18,7 @@ type Account struct { Copied int64 Skipped int64 Errors int64 + LastError string } func (s *Store) CreateAccount(ctx context.Context, a Account) (int64, error) { @@ -38,7 +39,7 @@ func (s *Store) DeleteAccount(ctx context.Context, id int64) error { func (s *Store) ListAccountsByTask(ctx context.Context, taskID int64) ([]Account, error) { rows, err := s.Pool.Query(ctx, `SELECT id, task_id, src_login, src_pass_enc, dst_login, dst_pass_enc, - test_src_status, test_dst_status, status, copied_count, skipped_count, error_count + test_src_status, test_dst_status, status, copied_count, skipped_count, error_count, last_error FROM accounts WHERE task_id=$1 ORDER BY id`, taskID) if err != nil { return nil, err @@ -48,7 +49,7 @@ func (s *Store) ListAccountsByTask(ctx context.Context, taskID int64) ([]Account for rows.Next() { var a Account if err := rows.Scan(&a.ID, &a.TaskID, &a.SrcLogin, &a.SrcPassEnc, &a.DstLogin, &a.DstPassEnc, - &a.TestSrcStatus, &a.TestDstStatus, &a.Status, &a.Copied, &a.Skipped, &a.Errors); err != nil { + &a.TestSrcStatus, &a.TestDstStatus, &a.Status, &a.Copied, &a.Skipped, &a.Errors, &a.LastError); err != nil { return nil, err } out = append(out, a) @@ -56,6 +57,13 @@ func (s *Store) ListAccountsByTask(ctx context.Context, taskID int64) ([]Account return out, rows.Err() } +// SetAccountError stores (or clears, with "") the last error message shown for +// an account, so it survives a page reload after the run's live log is gone. +func (s *Store) SetAccountError(ctx context.Context, id int64, msg string) error { + _, err := s.Pool.Exec(ctx, `UPDATE accounts SET last_error=$2 WHERE id=$1`, id, msg) + return err +} + // side = "src" | "dst" func (s *Store) SetAccountTestStatus(ctx context.Context, id int64, side, status string) error { col := "test_src_status" diff --git a/migrations/0002_account_last_error.down.sql b/migrations/0002_account_last_error.down.sql new file mode 100644 index 0000000..f4538ca --- /dev/null +++ b/migrations/0002_account_last_error.down.sql @@ -0,0 +1 @@ +ALTER TABLE accounts DROP COLUMN last_error; diff --git a/migrations/0002_account_last_error.up.sql b/migrations/0002_account_last_error.up.sql new file mode 100644 index 0000000..cb015f8 --- /dev/null +++ b/migrations/0002_account_last_error.up.sql @@ -0,0 +1 @@ +ALTER TABLE accounts ADD COLUMN last_error TEXT NOT NULL DEFAULT ''; diff --git a/web/src/api.ts b/web/src/api.ts index f488af9..562d2c9 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -32,6 +32,7 @@ export interface Account { copied: number skipped: number errors: number + last_error?: string } export interface TaskDetail { diff --git a/web/src/app.css b/web/src/app.css index aca8ece..8238449 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -286,6 +286,17 @@ color: var(--info); } +.acct-error { + margin-top: 3px; + max-width: 260px; + font-size: 10px; + line-height: 1.35; + color: var(--fail); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* clear-log button: mirrors the .panel-label tab on the right edge */ .log-clear { position: absolute; diff --git a/web/src/pages/TaskDetail.tsx b/web/src/pages/TaskDetail.tsx index 24230bc..25fd733 100644 --- a/web/src/pages/TaskDetail.tsx +++ b/web/src/pages/TaskDetail.tsx @@ -473,7 +473,14 @@ export function TaskDetail({ id }: { id: number }) { ) : ( accounts.map((a) => (