Compare commits
3
Commits
2623bc8815
...
2bdb4904d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bdb4904d6
|
||
|
|
7ad326fa36
|
||
|
|
45b0ff2358
|
@@ -6,3 +6,7 @@
|
|||||||
!/internal/httpapi/webdist/index.html
|
!/internal/httpapi/webdist/index.html
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# local cache of the impeccable design hook
|
||||||
|
.impeccable/
|
||||||
|
**/.impeccable/
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
AUTH_PASS: ${AUTH_PASS}
|
AUTH_PASS: ${AUTH_PASS}
|
||||||
ENC_KEY: ${ENC_KEY}
|
ENC_KEY: ${ENC_KEY}
|
||||||
SESSION_SECRET: ${SESSION_SECRET}
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-4}
|
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-2}
|
||||||
PPROF_ADDR: ${PPROF_ADDR:-:6060}
|
PPROF_ADDR: ${PPROF_ADDR:-:6060}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
@@ -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: <subject>"
|
||||||
|
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.
|
||||||
@@ -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.
|
// handleSetAccountFolderMapping persists one account's rename map + excluded set.
|
||||||
func (s *Server) handleSetAccountFolderMapping(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSetAccountFolderMapping(w http.ResponseWriter, r *http.Request) {
|
||||||
taskID, err := pathID(r, "id")
|
taskID, err := pathID(r, "id")
|
||||||
|
|||||||
@@ -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}/folder-mapping", s.handleSetFolderMapping)
|
||||||
api.HandleFunc("PUT /api/tasks/{id}/schedule", s.handleSetSchedule)
|
api.HandleFunc("PUT /api/tasks/{id}/schedule", s.handleSetSchedule)
|
||||||
api.HandleFunc("GET /api/tasks/{id}/runs", s.handleListRuns)
|
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("DELETE /api/tasks/{id}/accounts/{accountId}", s.handleDeleteAccount)
|
||||||
api.HandleFunc("POST /api/tasks/{id}/import", s.handleImportCSV)
|
api.HandleFunc("POST /api/tasks/{id}/import", s.handleImportCSV)
|
||||||
api.HandleFunc("POST /api/tasks/{id}/test", s.handleTestAccounts)
|
api.HandleFunc("POST /api/tasks/{id}/test", s.handleTestAccounts)
|
||||||
|
|||||||
+29
-1
@@ -27,6 +27,11 @@ type CopyDeps struct {
|
|||||||
// folder's messages have been examined so far — so the UI shows movement
|
// folder's messages have been examined so far — so the UI shows movement
|
||||||
// while dedup decisions are made, before bodies start copying.
|
// while dedup decisions are made, before bodies start copying.
|
||||||
OnScan func(scanned, total int64)
|
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.
|
// CopyResult summarizes the outcome of one CopyFolder run.
|
||||||
@@ -44,6 +49,15 @@ type CopyResult struct {
|
|||||||
// checked between windows.
|
// checked between windows.
|
||||||
const metaScanBatch = 1000
|
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
|
// metaBatches tiles 1..total into contiguous, non-overlapping windows of at
|
||||||
// most batchSize, covering every sequence number exactly once.
|
// most batchSize, covering every sequence number exactly once.
|
||||||
func metaBatches(total, batchSize uint32) []imap.SeqRange {
|
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 {
|
type queued struct {
|
||||||
uid imap.UID
|
uid imap.UID
|
||||||
key string
|
key string
|
||||||
|
subject string
|
||||||
flags []imap.Flag
|
flags []imap.Flag
|
||||||
internalDate time.Time
|
internalDate time.Time
|
||||||
}
|
}
|
||||||
var todo []queued
|
var todo []queued
|
||||||
var scanned int64
|
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
|
// Scan metadata in bounded windows instead of one FETCH 1:*, so each
|
||||||
// command is short (the server stays responsive) and ctx is checked on
|
// command is short (the server stays responsive) and ctx is checked on
|
||||||
// every window boundary — not just between messages of one giant command.
|
// 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()
|
buf, err := msg.Collect()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
res.Errors++
|
res.Errors++
|
||||||
|
reportErr("", "read message metadata: "+err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
scanned++
|
scanned++
|
||||||
key := MessageKey(buf.Envelope, buf.RFC822Size)
|
key := MessageKey(buf.Envelope, buf.RFC822Size)
|
||||||
|
subject := ""
|
||||||
|
if buf.Envelope != nil {
|
||||||
|
subject = buf.Envelope.Subject
|
||||||
|
}
|
||||||
already, err := deps.IsMigrated(key)
|
already, err := deps.IsMigrated(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
res.Errors++
|
res.Errors++
|
||||||
|
reportErr(msgRef(buf.UID, subject), "dedup lookup: "+err.Error())
|
||||||
} else if already {
|
} else if already {
|
||||||
res.Skipped++
|
res.Skipped++
|
||||||
if deps.OnProgress != nil {
|
if deps.OnProgress != nil {
|
||||||
deps.OnProgress(res.Copied, res.Skipped)
|
deps.OnProgress(res.Copied, res.Skipped)
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
if deps.OnScan != nil {
|
||||||
deps.OnScan(scanned, total)
|
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 {
|
if err := streamOne(src, dst, dstFolder, q.uid, q.flags, q.internalDate); err != nil {
|
||||||
res.Errors++
|
res.Errors++
|
||||||
|
reportErr(msgRef(q.uid, q.subject), "copy message: "+err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := deps.MarkMigrated(dstFolder, q.key); err != nil {
|
if err := deps.MarkMigrated(dstFolder, q.key); err != nil {
|
||||||
res.Errors++
|
res.Errors++
|
||||||
|
reportErr(msgRef(q.uid, q.subject), "mark migrated: "+err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
res.Copied++
|
res.Copied++
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ import (
|
|||||||
var ErrNotTested = errors.New("accounts not fully tested")
|
var ErrNotTested = errors.New("accounts not fully tested")
|
||||||
var ErrAlreadyRunning = errors.New("task already running")
|
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.
|
// folderPlan is one source folder scheduled for copy and its destination name.
|
||||||
type folderPlan struct {
|
type folderPlan struct {
|
||||||
src, dst string
|
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.SetAccountStatus(ctx, a.ID, "running")
|
||||||
_ = o.store.SetAccountError(ctx, a.ID, "") // clear any error from a previous run
|
_ = 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.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
|
// Per-account cancellable context: IMAP work uses actx (so CancelAccount
|
||||||
// stops it); DB writes keep the parent ctx so status/counters persist even
|
// 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)
|
srcPass, err := crypto.Decrypt(o.encKey, a.SrcPassEnc)
|
||||||
if err != nil {
|
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)
|
dstPass, err := crypto.Decrypt(o.encKey, a.DstPassEnc)
|
||||||
if err != nil {
|
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)
|
src, err := imapx.Connect(actx, srcEP)
|
||||||
if err != nil {
|
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() }()
|
defer func() { _ = src.Logout().Wait() }()
|
||||||
if err := src.Login(a.SrcLogin, string(srcPass)).Wait(); err != nil {
|
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)
|
dst, err := imapx.Connect(actx, dstEP)
|
||||||
if err != nil {
|
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() }()
|
defer func() { _ = dst.Logout().Wait() }()
|
||||||
if err := dst.Login(a.DstLogin, string(dstPass)).Wait(); err != nil {
|
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
|
// 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)
|
folders, err := imapx.ListFolders(src)
|
||||||
if err != nil {
|
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
|
// 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 curFolder string
|
||||||
var curTotal int64
|
var curTotal int64
|
||||||
var lastEmit, lastScanEmit time.Time
|
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{
|
deps := imapx.CopyDeps{
|
||||||
IsMigrated: func(k string) (bool, error) { return o.store.IsMigrated(ctx, a.ID, k) },
|
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) },
|
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) {
|
OnProgress: func(c, s int) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
done := c + s
|
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)
|
slog.Warn("folder copy error", "account", a.ID, "src_login", a.SrcLogin, "folder", fp.src, "err", err)
|
||||||
folderErr = 1
|
folderErr = 1
|
||||||
_ = o.store.SetAccountError(ctx, a.ID, "folder \""+fp.src+"\": "+err.Error())
|
_ = 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{
|
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(),
|
"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
|
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.
|
// A cancellation surfacing as an error is a cancel, not a failure.
|
||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) {
|
||||||
_ = o.store.SetAccountStatus(ctx, a.ID, "cancelled")
|
_ = 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)
|
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.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,
|
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()}})
|
Data: map[string]any{"account_id": a.ID, "side": side, "login": login, "host": host, "port": port, "error": err.Error()}})
|
||||||
return 0, 0, 1
|
return 0, 0, 1
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ func testStore(t *testing.T) *Store {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
s.Pool.Exec(context.Background(),
|
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()
|
s.Pool.Close()
|
||||||
})
|
})
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE account_errors;
|
||||||
@@ -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);
|
||||||
@@ -157,6 +157,20 @@ export const setTaskSchedule = (taskId: number, intervalSeconds: number) =>
|
|||||||
|
|
||||||
export const listRuns = (taskId: number) => api<Run[]>(`/api/tasks/${taskId}/runs`)
|
export const listRuns = (taskId: number) => api<Run[]>(`/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<AccountError[]>(`/api/tasks/${taskId}/accounts/${accountId}/errors`)
|
||||||
|
|
||||||
export const importCSV = (id: number, file: File) => {
|
export const importCSV = (id: number, file: File) => {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
|
|||||||
@@ -255,11 +255,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* per-account live progress */
|
/* per-account live progress */
|
||||||
|
/* merged Source/Destination "Account" cell: single line when src == dst,
|
||||||
|
stacked (src over dst) when they differ. */
|
||||||
|
.acct-ident {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acct-dst {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.acct-progress {
|
.acct-progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
|
/* reserve room for the bar + two meta lines so toggling the scan line
|
||||||
|
during a run doesn't change the row height */
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Running rows get a fixed wide size up front so live progress updates
|
||||||
|
(which change text length and can wrap) never resize the row mid-scan.
|
||||||
|
The size reverts only when the run finishes and the class drops. */
|
||||||
|
.progress-cell--live {
|
||||||
|
min-width: 340px;
|
||||||
|
height: 48px;
|
||||||
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pbar {
|
.pbar {
|
||||||
@@ -283,6 +308,8 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
|
/* keep progress text on one line so its length never reflows the row */
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pscan {
|
.pscan {
|
||||||
@@ -300,6 +327,16 @@
|
|||||||
white-space: nowrap;
|
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 */
|
/* clear-log button: mirrors the .panel-label tab on the right edge */
|
||||||
.log-clear {
|
.log-clear {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -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<AccountError[] | null>(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 (
|
||||||
|
<Modal open={open} title={account ? `Errors — ${account.src_login}` : 'Errors'} onClose={onClose} size="lg">
|
||||||
|
<div className="tbl-wrap">
|
||||||
|
<table className="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Folder</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{failed ? (
|
||||||
|
<tr className="empty-row"><td colSpan={5}>failed to load errors</td></tr>
|
||||||
|
) : errors === null ? (
|
||||||
|
<tr className="empty-row"><td colSpan={5}>loading…</td></tr>
|
||||||
|
) : errors.length === 0 ? (
|
||||||
|
<tr className="empty-row"><td colSpan={5}>no errors recorded</td></tr>
|
||||||
|
) : (
|
||||||
|
errors.map((e) => (
|
||||||
|
<tr key={e.id}>
|
||||||
|
<td>{fmt(e.created_at)}</td>
|
||||||
|
<td>{e.kind}</td>
|
||||||
|
<td>{e.folder || '—'}</td>
|
||||||
|
<td>{e.message_ref || '—'}</td>
|
||||||
|
<td className="err-cell">{e.error}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { StatusBadge } from '../components/StatusBadge'
|
|||||||
import { useConfirm } from '../components/ConfirmProvider'
|
import { useConfirm } from '../components/ConfirmProvider'
|
||||||
import { FolderMappingModal } from '../components/FolderMappingModal'
|
import { FolderMappingModal } from '../components/FolderMappingModal'
|
||||||
import { RunLogModal } from '../components/RunLogModal'
|
import { RunLogModal } from '../components/RunLogModal'
|
||||||
|
import { AccountErrorsModal } from '../components/AccountErrorsModal'
|
||||||
|
|
||||||
const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' }
|
const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' }
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [live, setLive] = useState<Record<number, LiveProgress>>({})
|
const [live, setLive] = useState<Record<number, LiveProgress>>({})
|
||||||
const [showRuns, setShowRuns] = useState(false)
|
const [showRuns, setShowRuns] = useState(false)
|
||||||
|
const [errorsFor, setErrorsFor] = useState<{ id: number; src_login: string } | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
@@ -536,8 +538,7 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
<table className="tbl">
|
<table className="tbl">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Source</th>
|
<th>Account</th>
|
||||||
<th>Destination</th>
|
|
||||||
<th>Src test</th>
|
<th>Src test</th>
|
||||||
<th>Dst test</th>
|
<th>Dst test</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
@@ -551,20 +552,24 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{accounts.length === 0 ? (
|
{accounts.length === 0 ? (
|
||||||
<tr className="empty-row">
|
<tr className="empty-row">
|
||||||
<td colSpan={10}>no accounts yet — add one or import a CSV above</td>
|
<td colSpan={9}>no accounts yet — add one or import a CSV above</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
accounts.map((a) => (
|
accounts.map((a) => (
|
||||||
<tr key={a.id}>
|
<tr key={a.id}>
|
||||||
<td>
|
<td>
|
||||||
{a.src_login}
|
<div className="acct-ident">
|
||||||
|
<span>{a.src_login}</span>
|
||||||
|
{a.dst_login !== a.src_login && (
|
||||||
|
<span className="acct-dst">→ {a.dst_login}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{a.last_error && (
|
{a.last_error && (
|
||||||
<div className="acct-error" title={a.last_error}>
|
<div className="acct-error" title={a.last_error}>
|
||||||
{a.last_error}
|
{a.last_error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{a.dst_login}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<StatusBadge status={a.test_src_status} />
|
<StatusBadge status={a.test_src_status} />
|
||||||
</td>
|
</td>
|
||||||
@@ -574,7 +579,7 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
<td>
|
<td>
|
||||||
<StatusBadge status={a.status} />
|
<StatusBadge status={a.status} />
|
||||||
</td>
|
</td>
|
||||||
<td className="progress-cell">
|
<td className={`progress-cell${a.status === 'running' ? ' progress-cell--live' : ''}`}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const lv = live[a.id]
|
const lv = live[a.id]
|
||||||
if (!lv || !lv.total) return <span className="muted-note">—</span>
|
if (!lv || !lv.total) return <span className="muted-note">—</span>
|
||||||
@@ -609,7 +614,19 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="num-cell">{live[a.id]?.copied ?? a.copied}</td>
|
<td className="num-cell">{live[a.id]?.copied ?? a.copied}</td>
|
||||||
<td className="num-cell">{live[a.id]?.skipped ?? a.skipped}</td>
|
<td className="num-cell">{live[a.id]?.skipped ?? a.skipped}</td>
|
||||||
<td className="num-cell">{a.errors}</td>
|
<td className="num-cell">
|
||||||
|
{a.errors > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link-btn danger"
|
||||||
|
onClick={() => setErrorsFor({ id: a.id, src_login: a.src_login })}
|
||||||
|
>
|
||||||
|
{a.errors}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
a.errors
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="num-cell">
|
<td className="num-cell">
|
||||||
<div className="row-actions">
|
<div className="row-actions">
|
||||||
{a.status !== 'running' && data?.task.status !== 'running' && (
|
{a.status !== 'running' && data?.task.status !== 'running' && (
|
||||||
@@ -671,6 +688,7 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<RunLogModal taskId={id} open={showRuns} onClose={() => setShowRuns(false)} />
|
<RunLogModal taskId={id} open={showRuns} onClose={() => setShowRuns(false)} />
|
||||||
|
<AccountErrorsModal taskId={id} account={errorsFor} onClose={() => setErrorsFor(null)} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user