fix(collection): correct run status during jobs and add retry-failed

Fix pending mislabeled as fail due to timezone and queue stale checks, show full error details, sync live progress counts, and add retry-failed endpoint for failed NEs only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-28 10:05:20 +08:00
parent 3024e13812
commit 3413d5bf32
10 changed files with 202 additions and 40 deletions

View file

@ -61,6 +61,7 @@ const en = {
started: "Collection job started: {{id}}",
paused: "Job paused",
restarted: "Collection restarted",
retryFailedDone: "Retrying failed devices",
deleted: "Job deleted",
nothingToRetry: "No failed devices to retry",
confirmDelete: "Delete this collection job and its log files?",
@ -80,6 +81,7 @@ const en = {
collapse: "Hide",
pause: "Pause",
restart: "Restart",
retryFailed: "Retry failed",
downloadResults: "Download results",
delete: "Delete",
autoRefresh: "Auto-refresh every 2s while jobs are running",

View file

@ -61,6 +61,7 @@ const zh = {
started: "采集任务已启动:{{id}}",
paused: "任务已暂停",
restarted: "已重新开始采集",
retryFailedDone: "已开始重采失败网元",
deleted: "任务已删除",
nothingToRetry: "没有可重试的失败网元",
confirmDelete: "确定删除该采集任务?相关日志文件将一并删除。",
@ -80,6 +81,7 @@ const zh = {
collapse: "收起",
pause: "暂停",
restart: "重新开始",
retryFailed: "重采失败网元",
downloadResults: "下载结果",
delete: "删除",
autoRefresh: "任务进行中,每 2 秒自动刷新",

View file

@ -957,6 +957,21 @@ pre {
background: #fff;
}
.collect-run-message {
max-width: 420px;
max-height: 160px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.45;
color: #334155;
padding: 6px 8px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
@media (max-width: 1200px) {
.cards {
grid-template-columns: 1fr;

View file

@ -9,6 +9,7 @@ import {
fetchNeCollections,
pauseCollectionJob,
restartCollectionJob,
retryFailedCollectionJob,
collectionJobDownloadUrl,
collectionRunDownloadUrl,
} from "../services/api";
@ -104,6 +105,16 @@ export function CollectPage() {
onError: (err) => showError(String(err)),
});
const retryFailedMutation = useMutation({
mutationFn: retryFailedCollectionJob,
onSuccess: async (job) => {
showOk(t("collect.retryFailedDone"));
setExpandedJobId(job.id);
await invalidateJobs(job.id);
},
onError: (err) => showError(String(err)),
});
const deleteMutation = useMutation({
mutationFn: deleteCollectionJob,
onSuccess: async (_, jobId) => {
@ -294,10 +305,16 @@ export function CollectPage() {
onToggle={() => setExpandedJobId(expandedJobId === job.id ? "" : job.id)}
onPause={() => pauseMutation.mutate(job.id)}
onRestart={() => restartMutation.mutate(job.id)}
onRetryFailed={() => retryFailedMutation.mutate(job.id)}
onDelete={() => {
if (window.confirm(t("collect.confirmDelete"))) deleteMutation.mutate(job.id);
}}
actionPending={pauseMutation.isPending || restartMutation.isPending || deleteMutation.isPending}
actionPending={
pauseMutation.isPending ||
restartMutation.isPending ||
retryFailedMutation.isPending ||
deleteMutation.isPending
}
/>
))}
</tbody>
@ -325,6 +342,7 @@ function JobRow({
onToggle,
onPause,
onRestart,
onRetryFailed,
onDelete,
actionPending,
}: {
@ -334,12 +352,14 @@ function JobRow({
onToggle: () => void;
onPause: () => void;
onRestart: () => void;
onRetryFailed: () => void;
onDelete: () => void;
actionPending: boolean;
}) {
const { t } = useI18n();
const canPause = job.status === "running";
const canRestart = job.status !== "running";
const canRetryFailed = job.status !== "running" && job.fail_count > 0;
const canDelete = job.status !== "running";
const hasResults = (job.output_count ?? 0) > 0;
@ -370,6 +390,11 @@ function JobRow({
{t("collect.jobs.restart")}
</button>
) : null}
{canRetryFailed ? (
<button type="button" className="link-btn" disabled={actionPending} onClick={onRetryFailed}>
{t("collect.jobs.retryFailed")}
</button>
) : null}
<button
type="button"
className="link-btn"
@ -388,7 +413,14 @@ function JobRow({
{expanded ? (
<tr>
<td colSpan={6}>
<JobRunsPanel jobId={job.id} jobStatus={job.status} commands={detail?.job.commands ?? ""} />
<JobRunsPanel
jobId={job.id}
jobStatus={job.status}
failCount={job.fail_count}
commands={detail?.job.commands ?? ""}
onRetryFailed={onRetryFailed}
retryPending={actionPending}
/>
</td>
</tr>
) : null}
@ -402,11 +434,17 @@ const RUN_STATUS_OPTIONS = ["pending", "running", "success", "fail", "cancelled"
function JobRunsPanel({
jobId,
jobStatus,
failCount,
commands,
onRetryFailed,
retryPending,
}: {
jobId: string;
jobStatus: string;
failCount: number;
commands: string;
onRetryFailed: () => void;
retryPending: boolean;
}) {
const { t } = useI18n();
const [runPage, setRunPage] = useState(1);
@ -478,6 +516,11 @@ function JobRunsPanel({
{t("common.clearFilters")}
</button>
) : null}
{jobStatus !== "running" && failCount > 0 ? (
<button type="button" className="link-btn" disabled={retryPending} onClick={onRetryFailed}>
{t("collect.jobs.retryFailed")}
</button>
) : null}
</div>
{runsQuery.isLoading ? <p>{t("common.refreshing")}</p> : null}
{!runsQuery.isLoading && runs.length === 0 ? <p>{t("common.empty")}</p> : null}
@ -498,7 +541,15 @@ function JobRunsPanel({
<td>{run.ne_name}</td>
<td>{run.ne_ip}</td>
<td>{run.status}</td>
<td title={run.message}>{run.message || t("common.empty")}</td>
<td>
{run.message ? (
<div className="collect-run-message" title={run.message}>
{run.message}
</div>
) : (
t("common.empty")
)}
</td>
<td>
{run.has_output ? (
<a className="link-btn" href={collectionRunDownloadUrl(run.id)} target="_blank" rel="noreferrer">

View file

@ -186,6 +186,9 @@ export const pauseCollectionJob = (jobId: string) =>
export const restartCollectionJob = (jobId: string) =>
apiPost<CollectionJobItem>(`/v1/ne-collections/${jobId}/restart`, {});
export const retryFailedCollectionJob = (jobId: string) =>
apiPost<CollectionJobItem>(`/v1/ne-collections/${jobId}/retry-failed`, {});
export const deleteCollectionJob = (jobId: string) =>
apiDelete<{ ok: boolean }>(`/v1/ne-collections/${jobId}`);