diff --git a/.gitignore b/.gitignore index f28b0e5..c022eab 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ !/internal/httpapi/webdist/index.html .DS_Store + +# local cache of the impeccable design hook +.impeccable/ +**/.impeccable/ diff --git a/docs/superpowers/specs/2026-07-05-account-errors-modal-design.md b/docs/superpowers/specs/2026-07-05-account-errors-modal-design.md new file mode 100644 index 0000000..7fb5b28 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-account-errors-modal-design.md @@ -0,0 +1,100 @@ +# Account Errors Modal — Design + +Date: 2026-07-05 +Status: approved + +## Problem + +When an account finishes `done_with_errors`, the UI shows only an error **count** +(e.g. `2`) and a single persisted `last_error`. There is no way to see the +individual errors — which folder, which message, and the actual error text. The +event log holds them live but is lost on reload, and message-level errors +(`res.Errors`) currently record only a counter, discarding their text entirely. + +## Goal + +Click the ERRORS count of an account (when `> 0`) to open a modal listing the +concrete errors of that account's **most recent run**: folder, kind, message +reference, error text, and timestamp. + +## Scope decisions + +- **Coverage:** errors of the latest run only. The list is cleared at the start + of each run (like the per-account counters). +- **Granularity:** every error is its own record — folder-level, message-level, + and account-level (connect/login) are distinct rows. +- **Cap:** at most 500 error rows per account per run (guard against a corrupt + mailbox producing thousands). On overflow a final synthetic row records + "… N more errors suppressed". + +## Data model + +New table (chosen over a JSONB column on `accounts`): a table gives atomic +`INSERT` per error with no read-modify-write races, trivial per-run clearing, +`ON DELETE CASCADE` with the account, and matches the existing `runs` / +`migrated_messages` shape. + +Migration `0005_account_errors`: + +``` +account_errors( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + run_id BIGINT, -- run the error belongs to (context) + kind TEXT NOT NULL, -- 'folder' | 'message' | 'account' + folder TEXT NOT NULL DEFAULT '', + message_ref TEXT NOT NULL DEFAULT '', -- e.g. "UID 42: " + error TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +) +CREATE INDEX ON account_errors(account_id); +``` + +Down migration: `DROP TABLE account_errors`. + +## Backend + +**Store** (`internal/store/account_errors.go`): +- `AddAccountError(ctx, accountID, runID int64, kind, folder, ref, msg string) error` +- `ClearAccountErrors(ctx, accountID int64) error` +- `ListAccountErrors(ctx, accountID int64) ([]AccountError, error)` — ordered by id. +- `AccountError` struct mirrors the row. + +**Orchestrator** (`internal/orchestrator/orchestrator.go`): +- At account start (next to `ResetAccountCounters`): `ClearAccountErrors`. +- New `CopyDeps.OnError(kind, folder, ref, msg string)` callback. `CopyFolder` + invokes it on every message-level error (the `res.Errors++` sites in + `copy.go`) and folder-level error, passing UID/subject where available. The + orchestrator's `OnError` impl persists via `AddAccountError`, enforcing the + 500-row cap with an in-worker counter. +- Folder-level copy error (already persisted to `last_error`) also emits + `OnError(kind="folder", ...)`. +- `accountFailed` (connect/login/decrypt) emits `OnError(kind="account", ...)`. +- `last_error` behavior is unchanged (still the latest single error for the + inline row hint). + +**HTTP** (`internal/httpapi`): +- `GET /api/tasks/{id}/accounts/{accountId}/errors` → JSON `[]AccountError`. + +## Frontend + +- `web/src/api.ts`: `listAccountErrors(taskId, accountId)`. +- ERRORS cell becomes a button when the count `> 0`; opens `AccountErrorsModal`. +- `web/src/components/AccountErrorsModal.tsx` (built on existing `Modal`, + styled like `RunLogModal`): fetches on open, renders rows + `time · folder · kind · message_ref · error`, with loading / empty / + fetch-error states. + +## Testing + +- Store: `AddAccountError` / `ClearAccountErrors` / `ListAccountErrors` against + Postgres (existing store test harness). +- `CopyFolder`: `OnError` is invoked when a per-message step fails (e.g. + `IsMigrated` returns an error) — unit test with fakes, no server needed. +- HTTP: handler returns the account's errors as JSON. + +## Out of scope + +- Cross-run error history. +- Retrying individual failed messages from the modal. +- Exporting errors. diff --git a/internal/httpapi/accounts.go b/internal/httpapi/accounts.go index 6c69b39..822a6c7 100644 --- a/internal/httpapi/accounts.go +++ b/internal/httpapi/accounts.go @@ -226,6 +226,31 @@ func (s *Server) handleProbeAccountFolders(w http.ResponseWriter, r *http.Reques }) } +// handleListAccountErrors returns the individual errors recorded for an account +// during its most recent run, for the per-account error modal. +func (s *Server) handleListAccountErrors(w http.ResponseWriter, r *http.Request) { + taskID, err := pathID(r, "id") + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + accID, err := pathID(r, "accountId") + if err != nil { + http.Error(w, "bad account id", http.StatusBadRequest) + return + } + if _, ok := s.findAccount(r, taskID, accID); !ok { + http.Error(w, "account not found", http.StatusNotFound) + return + } + errs, err := s.store.ListAccountErrors(r.Context(), accID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, errs) +} + // handleSetAccountFolderMapping persists one account's rename map + excluded set. func (s *Server) handleSetAccountFolderMapping(w http.ResponseWriter, r *http.Request) { taskID, err := pathID(r, "id") diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index b7be610..ffad46d 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -24,6 +24,7 @@ func (s *Server) Router() http.Handler { api.HandleFunc("PUT /api/tasks/{id}/folder-mapping", s.handleSetFolderMapping) api.HandleFunc("PUT /api/tasks/{id}/schedule", s.handleSetSchedule) api.HandleFunc("GET /api/tasks/{id}/runs", s.handleListRuns) + api.HandleFunc("GET /api/tasks/{id}/accounts/{accountId}/errors", s.handleListAccountErrors) api.HandleFunc("DELETE /api/tasks/{id}/accounts/{accountId}", s.handleDeleteAccount) api.HandleFunc("POST /api/tasks/{id}/import", s.handleImportCSV) api.HandleFunc("POST /api/tasks/{id}/test", s.handleTestAccounts) diff --git a/internal/imapx/copy.go b/internal/imapx/copy.go index 5f6cdb2..2966273 100644 --- a/internal/imapx/copy.go +++ b/internal/imapx/copy.go @@ -27,6 +27,11 @@ type CopyDeps struct { // folder's messages have been examined so far — so the UI shows movement // while dedup decisions are made, before bodies start copying. OnScan func(scanned, total int64) + // OnError is called for each message-level error, with a message reference + // ("UID N: subject", empty when the envelope is unavailable) and the error + // text — so the orchestrator can persist individual errors for the + // per-account error modal. Folder-level errors are reported by the caller. + OnError func(ref, msg string) } // CopyResult summarizes the outcome of one CopyFolder run. @@ -44,6 +49,15 @@ type CopyResult struct { // checked between windows. const metaScanBatch = 1000 +// msgRef builds a human-readable reference for a message error: its UID plus +// subject when known, e.g. "UID 42: Invoice". Falls back to just the UID. +func msgRef(uid imap.UID, subject string) string { + if subject == "" { + return fmt.Sprintf("UID %d", uid) + } + return fmt.Sprintf("UID %d: %s", uid, subject) +} + // metaBatches tiles 1..total into contiguous, non-overlapping windows of at // most batchSize, covering every sequence number exactly once. func metaBatches(total, batchSize uint32) []imap.SeqRange { @@ -95,11 +109,17 @@ func CopyFolder(ctx context.Context, src, dst *imapclient.Client, srcFolder, dst type queued struct { uid imap.UID key string + subject string flags []imap.Flag internalDate time.Time } var todo []queued var scanned int64 + reportErr := func(ref, msg string) { + if deps.OnError != nil { + deps.OnError(ref, msg) + } + } // Scan metadata in bounded windows instead of one FETCH 1:*, so each // command is short (the server stays responsive) and ctx is checked on // every window boundary — not just between messages of one giant command. @@ -122,20 +142,26 @@ func CopyFolder(ctx context.Context, src, dst *imapclient.Client, srcFolder, dst buf, err := msg.Collect() if err != nil { res.Errors++ + reportErr("", "read message metadata: "+err.Error()) continue } scanned++ key := MessageKey(buf.Envelope, buf.RFC822Size) + subject := "" + if buf.Envelope != nil { + subject = buf.Envelope.Subject + } already, err := deps.IsMigrated(key) if err != nil { res.Errors++ + reportErr(msgRef(buf.UID, subject), "dedup lookup: "+err.Error()) } else if already { res.Skipped++ if deps.OnProgress != nil { deps.OnProgress(res.Copied, res.Skipped) } } else { - todo = append(todo, queued{uid: buf.UID, key: key, flags: buf.Flags, internalDate: buf.InternalDate}) + todo = append(todo, queued{uid: buf.UID, key: key, subject: subject, flags: buf.Flags, internalDate: buf.InternalDate}) } if deps.OnScan != nil { deps.OnScan(scanned, total) @@ -153,10 +179,12 @@ func CopyFolder(ctx context.Context, src, dst *imapclient.Client, srcFolder, dst } if err := streamOne(src, dst, dstFolder, q.uid, q.flags, q.internalDate); err != nil { res.Errors++ + reportErr(msgRef(q.uid, q.subject), "copy message: "+err.Error()) continue } if err := deps.MarkMigrated(dstFolder, q.key); err != nil { res.Errors++ + reportErr(msgRef(q.uid, q.subject), "mark migrated: "+err.Error()) continue } res.Copied++ diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 123b1a0..4faec64 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -16,6 +16,12 @@ import ( var ErrNotTested = errors.New("accounts not fully tested") var ErrAlreadyRunning = errors.New("task already running") +// maxAccountErrors caps how many individual error rows one account records per +// run, so a corrupt mailbox producing thousands of failures can't bloat the +// account_errors table. The cap'th row is a synthetic "further errors +// suppressed" note. +const maxAccountErrors = 500 + // folderPlan is one source folder scheduled for copy and its destination name. type folderPlan struct { src, dst string @@ -264,6 +270,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in _ = o.store.SetAccountStatus(ctx, a.ID, "running") _ = o.store.SetAccountError(ctx, a.ID, "") // clear any error from a previous run _ = o.store.ResetAccountCounters(ctx, a.ID) // start from zero; IncAccountCounters is additive + _ = o.store.ClearAccountErrors(ctx, a.ID) // drop last run's per-error rows // Per-account cancellable context: IMAP work uses actx (so CancelAccount // stops it); DB writes keep the parent ctx so status/counters persist even @@ -277,28 +284,28 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in srcPass, err := crypto.Decrypt(o.encKey, a.SrcPassEnc) if err != nil { - return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "src", err) + return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "src", err) } dstPass, err := crypto.Decrypt(o.encKey, a.DstPassEnc) if err != nil { - return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "dst", err) + return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "dst", err) } src, err := imapx.Connect(actx, srcEP) if err != nil { - return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "src", err) + return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "src", err) } defer func() { _ = src.Logout().Wait() }() if err := src.Login(a.SrcLogin, string(srcPass)).Wait(); err != nil { - return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "src", err) + return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "src", err) } dst, err := imapx.Connect(actx, dstEP) if err != nil { - return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "dst", err) + return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "dst", err) } defer func() { _ = dst.Logout().Wait() }() if err := dst.Login(a.DstLogin, string(dstPass)).Wait(); err != nil { - return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "dst", err) + return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "dst", err) } // On cancel, close the connections so any in-flight network read (a slow @@ -311,7 +318,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in folders, err := imapx.ListFolders(src) if err != nil { - return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "src", err) + return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "src", err) } // Planning pass: decide folders from the account's own config, then EXAMINE @@ -341,9 +348,24 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in var curFolder string var curTotal int64 var lastEmit, lastScanEmit time.Time + // Persist individual errors for the per-account error modal, capped so a + // corrupt mailbox can't write unbounded rows. Runs on this goroutine, so + // the counter is race-free (same reasoning as the progress vars above). + var persistedErrs int + addErr := func(kind, folder, ref, msg string) { + persistedErrs++ + switch { + case persistedErrs < maxAccountErrors: + _ = o.store.AddAccountError(ctx, a.ID, runID, kind, folder, ref, msg) + case persistedErrs == maxAccountErrors: + _ = o.store.AddAccountError(ctx, a.ID, runID, "account", "", "", + "too many errors — further errors suppressed") + } + } 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) }, + OnError: func(ref, msg string) { addErr("message", curFolder, ref, msg) }, OnProgress: func(c, s int) { now := time.Now() done := c + s @@ -393,6 +415,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in slog.Warn("folder copy error", "account", a.ID, "src_login", a.SrcLogin, "folder", fp.src, "err", err) folderErr = 1 _ = o.store.SetAccountError(ctx, a.ID, "folder \""+fp.src+"\": "+err.Error()) + addErr("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(), }}) @@ -428,7 +451,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in return copied, skipped, errs } -func (o *Orchestrator) accountFailed(ctx context.Context, taskID int64, a store.Account, srcEP, dstEP imapx.Endpoint, side string, err error) (int64, int64, int64) { +func (o *Orchestrator) accountFailed(ctx context.Context, taskID, runID int64, a store.Account, srcEP, dstEP imapx.Endpoint, side string, err error) (int64, int64, int64) { // A cancellation surfacing as an error is a cancel, not a failure. if errors.Is(err, context.Canceled) { _ = o.store.SetAccountStatus(ctx, a.ID, "cancelled") @@ -442,7 +465,9 @@ 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()) + failMsg := side + " " + login + "@" + host + ": " + err.Error() + _ = o.store.SetAccountError(ctx, a.ID, failMsg) + _ = o.store.AddAccountError(ctx, a.ID, runID, "account", "", "", failMsg) 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/account_errors.go b/internal/store/account_errors.go new file mode 100644 index 0000000..cda7d95 --- /dev/null +++ b/internal/store/account_errors.go @@ -0,0 +1,61 @@ +package store + +import ( + "context" + "time" +) + +// AccountError is one concrete error recorded during an account's run — +// folder-level, message-level, or account-level (connect/login). +type AccountError struct { + ID int64 `json:"id"` + AccountID int64 `json:"account_id"` + RunID int64 `json:"run_id"` + Kind string `json:"kind"` // folder | message | account + Folder string `json:"folder"` + MessageRef string `json:"message_ref"` + Error string `json:"error"` + CreatedAt time.Time `json:"created_at"` +} + +// AddAccountError appends one error row for an account's current run. +func (s *Store) AddAccountError(ctx context.Context, accountID, runID int64, kind, folder, ref, msg string) error { + _, err := s.Pool.Exec(ctx, + `INSERT INTO account_errors (account_id, run_id, kind, folder, message_ref, error) + VALUES ($1,$2,$3,$4,$5,$6)`, + accountID, runID, kind, folder, ref, msg) + return err +} + +// ClearAccountErrors removes an account's errors at the start of a run, so the +// list reflects only the current run (mirrors ResetAccountCounters). +func (s *Store) ClearAccountErrors(ctx context.Context, accountID int64) error { + _, err := s.Pool.Exec(ctx, `DELETE FROM account_errors WHERE account_id=$1`, accountID) + return err +} + +// ListAccountErrors returns an account's errors in insertion order, for the +// per-account error modal. +func (s *Store) ListAccountErrors(ctx context.Context, accountID int64) ([]AccountError, error) { + rows, err := s.Pool.Query(ctx, + `SELECT id, account_id, run_id, kind, folder, message_ref, error, created_at + FROM account_errors WHERE account_id=$1 ORDER BY id`, accountID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []AccountError{} + for rows.Next() { + var e AccountError + var runID *int64 + if err := rows.Scan(&e.ID, &e.AccountID, &runID, &e.Kind, &e.Folder, + &e.MessageRef, &e.Error, &e.CreatedAt); err != nil { + return nil, err + } + if runID != nil { + e.RunID = *runID + } + out = append(out, e) + } + return out, rows.Err() +} diff --git a/internal/store/account_errors_test.go b/internal/store/account_errors_test.go new file mode 100644 index 0000000..ba048fb --- /dev/null +++ b/internal/store/account_errors_test.go @@ -0,0 +1,75 @@ +package store + +import ( + "context" + "testing" +) + +// AddAccountError persists individual errors; ListAccountErrors returns them in +// insertion order; ClearAccountErrors wipes them for the next run. +func TestAccountErrorsAddListClear(t *testing.T) { + s := testStore(t) + ctx := context.Background() + epSrc, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "a", Port: 993, TLSMode: "ssl"}) + epDst, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "dst", Host: "b", Port: 993, TLSMode: "ssl"}) + taskID, _ := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: epSrc, DstEndpointID: epDst}) + runID, _ := s.CreateRun(ctx, taskID, "manual") + accID, _ := s.CreateAccount(ctx, Account{TaskID: taskID, SrcLogin: "u", SrcPassEnc: "x", DstLogin: "u2", DstPassEnc: "y"}) + + if err := s.AddAccountError(ctx, accID, runID, "folder", "INBOX", "", "examine failed"); err != nil { + t.Fatalf("add folder: %v", err) + } + if err := s.AddAccountError(ctx, accID, runID, "message", "INBOX", "UID 42: hi", "append rejected"); err != nil { + t.Fatalf("add message: %v", err) + } + + errs, err := s.ListAccountErrors(ctx, accID) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(errs) != 2 { + t.Fatalf("len=%d want 2", len(errs)) + } + if errs[0].Kind != "folder" || errs[0].Folder != "INBOX" || errs[0].Error != "examine failed" { + t.Fatalf("errs[0]=%+v", errs[0]) + } + if errs[1].Kind != "message" || errs[1].MessageRef != "UID 42: hi" || errs[1].Error != "append rejected" { + t.Fatalf("errs[1]=%+v", errs[1]) + } + if errs[0].ID >= errs[1].ID { + t.Fatalf("expected insertion order by id: %d then %d", errs[0].ID, errs[1].ID) + } + + if err := s.ClearAccountErrors(ctx, accID); err != nil { + t.Fatalf("clear: %v", err) + } + errs, err = s.ListAccountErrors(ctx, accID) + if err != nil { + t.Fatalf("list after clear: %v", err) + } + if len(errs) != 0 { + t.Fatalf("after clear len=%d want 0", len(errs)) + } +} + +// Deleting an account cascades its errors (ON DELETE CASCADE). +func TestAccountErrorsCascadeOnAccountDelete(t *testing.T) { + s := testStore(t) + ctx := context.Background() + epSrc, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "a", Port: 993, TLSMode: "ssl"}) + epDst, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "dst", Host: "b", Port: 993, TLSMode: "ssl"}) + taskID, _ := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: epSrc, DstEndpointID: epDst}) + accID, _ := s.CreateAccount(ctx, Account{TaskID: taskID, SrcLogin: "u", SrcPassEnc: "x", DstLogin: "u2", DstPassEnc: "y"}) + _ = s.AddAccountError(ctx, accID, 0, "account", "", "", "login failed") + + if err := s.DeleteAccount(ctx, accID); err != nil { + t.Fatalf("delete: %v", err) + } + var n int + if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM account_errors WHERE account_id=$1`, accID).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Fatalf("account_errors not cascaded: %d rows", n) + } +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 2a12c33..da61a74 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -17,7 +17,7 @@ func testStore(t *testing.T) *Store { } t.Cleanup(func() { s.Pool.Exec(context.Background(), - `TRUNCATE endpoints, tasks, accounts, runs, migrated_messages RESTART IDENTITY CASCADE`) + `TRUNCATE endpoints, tasks, accounts, runs, migrated_messages, account_errors RESTART IDENTITY CASCADE`) s.Pool.Close() }) return s diff --git a/migrations/0005_account_errors.down.sql b/migrations/0005_account_errors.down.sql new file mode 100644 index 0000000..179e7af --- /dev/null +++ b/migrations/0005_account_errors.down.sql @@ -0,0 +1 @@ +DROP TABLE account_errors; diff --git a/migrations/0005_account_errors.up.sql b/migrations/0005_account_errors.up.sql new file mode 100644 index 0000000..c2a5412 --- /dev/null +++ b/migrations/0005_account_errors.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE account_errors ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + run_id BIGINT, + kind TEXT NOT NULL CHECK (kind IN ('folder','message','account')), + folder TEXT NOT NULL DEFAULT '', + message_ref TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX account_errors_account_id_idx ON account_errors (account_id); diff --git a/web/src/api.ts b/web/src/api.ts index 1e5d0df..93e3e94 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -157,6 +157,20 @@ export const setTaskSchedule = (taskId: number, intervalSeconds: number) => export const listRuns = (taskId: number) => api(`/api/tasks/${taskId}/runs`) +export interface AccountError { + id: number + account_id: number + run_id: number + kind: string // folder | message | account + folder: string + message_ref: string + error: string + created_at: string +} + +export const listAccountErrors = (taskId: number, accountId: number) => + api(`/api/tasks/${taskId}/accounts/${accountId}/errors`) + export const importCSV = (id: number, file: File) => { const fd = new FormData() fd.append('file', file) diff --git a/web/src/app.css b/web/src/app.css index ce58e13..1025aa9 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -300,6 +300,16 @@ white-space: nowrap; } +/* error text inside the per-account errors modal: wrap long messages */ +.err-cell { + max-width: 420px; + white-space: normal; + word-break: break-word; + color: var(--fail); + font-size: 12px; + line-height: 1.4; +} + /* clear-log button: mirrors the .panel-label tab on the right edge */ .log-clear { position: absolute; diff --git a/web/src/components/AccountErrorsModal.tsx b/web/src/components/AccountErrorsModal.tsx new file mode 100644 index 0000000..fbb284e --- /dev/null +++ b/web/src/components/AccountErrorsModal.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react' +import { Modal } from './Modal' +import { listAccountErrors, type AccountError } from '../api' + +const fmt = (iso: string) => (iso ? new Date(iso).toLocaleString() : '—') + +export function AccountErrorsModal({ + taskId, + account, + onClose, +}: { + taskId: number + account: { id: number; src_login: string } | null + onClose: () => void +}) { + const [errors, setErrors] = useState(null) + const [failed, setFailed] = useState(false) + const open = account !== null + + useEffect(() => { + if (!account) return + setErrors(null) + setFailed(false) + listAccountErrors(taskId, account.id) + .then((e) => setErrors(e ?? [])) + .catch(() => setFailed(true)) + }, [taskId, account]) + + return ( + +
+ + + + + + + + + + + + {failed ? ( + + ) : errors === null ? ( + + ) : errors.length === 0 ? ( + + ) : ( + errors.map((e) => ( + + + + + + + + )) + )} + +
TimeKindFolderMessageError
failed to load errors
loading…
no errors recorded
{fmt(e.created_at)}{e.kind}{e.folder || '—'}{e.message_ref || '—'}{e.error}
+
+
+ ) +} diff --git a/web/src/pages/TaskDetail.tsx b/web/src/pages/TaskDetail.tsx index dd6f85d..b0b3f00 100644 --- a/web/src/pages/TaskDetail.tsx +++ b/web/src/pages/TaskDetail.tsx @@ -5,6 +5,7 @@ import { StatusBadge } from '../components/StatusBadge' import { useConfirm } from '../components/ConfirmProvider' import { FolderMappingModal } from '../components/FolderMappingModal' import { RunLogModal } from '../components/RunLogModal' +import { AccountErrorsModal } from '../components/AccountErrorsModal' const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' } @@ -87,6 +88,7 @@ export function TaskDetail({ id }: { id: number }) { const [error, setError] = useState(null) const [live, setLive] = useState>({}) const [showRuns, setShowRuns] = useState(false) + const [errorsFor, setErrorsFor] = useState<{ id: number; src_login: string } | null>(null) const fileInputRef = useRef(null) function reload() { @@ -609,7 +611,19 @@ export function TaskDetail({ id }: { id: number }) { {live[a.id]?.copied ?? a.copied} {live[a.id]?.skipped ?? a.skipped} - {a.errors} + + {a.errors > 0 ? ( + + ) : ( + a.errors + )} +
{a.status !== 'running' && data?.task.status !== 'running' && ( @@ -671,6 +685,7 @@ export function TaskDetail({ id }: { id: number }) { /> )} setShowRuns(false)} /> + setErrorsFor(null)} /> ) }