feat(errors): per-account error modal with persisted error list

Accounts finishing done_with_errors showed only a count and a single
last_error. This adds a modal listing every concrete error of the
account's most recent run.

- migration 0005: account_errors table (kind folder|message|account,
  folder, message_ref, error, created_at; ON DELETE CASCADE; indexed)
- store: AddAccountError / ClearAccountErrors / ListAccountErrors
- copy: OnError callback captures per-message error text (previously
  only counted), with a "UID N: subject" reference
- orchestrator: clear errors at run start; persist folder/message/
  account errors; cap 500 rows/account/run with a suppressed-note row
- api: GET /api/tasks/{id}/accounts/{accountId}/errors
- web: AccountErrorsModal, clickable ERRORS count, api + styles

Verified: migration applies on Postgres 18; store add/list/clear and
cascade tests pass against real pg; backend build/vet/test green; web
tsc+vite build and oxlint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9Eq4JtWjyNTv5qat3B3mM
This commit is contained in:
2026-07-05 12:16:12 +07:00
co-authored by Claude Opus 4.8
parent 2623bc8815
commit 45b0ff2358
15 changed files with 448 additions and 12 deletions
+14
View File
@@ -157,6 +157,20 @@ export const setTaskSchedule = (taskId: number, intervalSeconds: number) =>
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) => {
const fd = new FormData()
fd.append('file', file)
+10
View File
@@ -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;
+65
View File
@@ -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>
)
}
+16 -1
View File
@@ -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<string | null>(null)
const [live, setLive] = useState<Record<number, LiveProgress>>({})
const [showRuns, setShowRuns] = useState(false)
const [errorsFor, setErrorsFor] = useState<{ id: number; src_login: string } | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
function reload() {
@@ -609,7 +611,19 @@ export function TaskDetail({ id }: { id: number }) {
</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">{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">
<div className="row-actions">
{a.status !== 'running' && data?.task.status !== 'running' && (
@@ -671,6 +685,7 @@ export function TaskDetail({ id }: { id: number }) {
/>
)}
<RunLogModal taskId={id} open={showRuns} onClose={() => setShowRuns(false)} />
<AccountErrorsModal taskId={id} account={errorsFor} onClose={() => setErrorsFor(null)} />
</>
)
}