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

@ -12,7 +12,18 @@ _TERMINAL = frozenset({"success", "fail", "cancelled"})
def _sync_job_counts(job: NeCollectionJob, runs: list[NeCollectionRun]) -> None: def _sync_job_counts(job: NeCollectionJob, runs: list[NeCollectionRun]) -> None:
job.success_count = sum(1 for r in runs if str(r.status) == "success") job.success_count = sum(1 for r in runs if str(r.status) == "success")
job.fail_count = sum(1 for r in runs if str(r.status) in ("fail", "cancelled")) job.fail_count = sum(1 for r in runs if str(r.status) == "fail")
# cancelled rows are tracked separately via ne_count - success - fail - active
def sync_job_progress(db: Session, job_id: str) -> None:
"""Refresh success/fail counters while a job is still running."""
job = db.get(NeCollectionJob, job_id)
if not job or str(job.status or "") != "running":
return
runs = db.query(NeCollectionRun).filter(NeCollectionRun.job_id == job_id).all()
_sync_job_counts(job, runs)
db.commit()
def finalize_collection_job(db: Session, job_id: str) -> None: def finalize_collection_job(db: Session, job_id: str) -> None:
@ -56,6 +67,9 @@ def reconcile_stale_collection_job(db: Session, job_id: str) -> bool:
if st in _TERMINAL: if st in _TERMINAL:
continue continue
if st == "pending": if st == "pending":
# Pending while job is running means queued in the worker pool, not stuck.
if str(job.status or "") == "running":
continue
anchor = job.started_at or job.created_at anchor = job.started_at or job.created_at
limit = pending_stale_sec limit = pending_stale_sec
reason = "collection_pending_stale" reason = "collection_pending_stale"

View file

@ -15,6 +15,7 @@ from .collection_service import (
pause_collection_job, pause_collection_job,
resolve_run_output_path, resolve_run_output_path,
restart_collection_job, restart_collection_job,
retry_failed_collection_job,
) )
from .collection_schemas import CollectionJobCreate from .collection_schemas import CollectionJobCreate
from .db import get_db from .db import get_db
@ -95,6 +96,11 @@ def api_restart_collection(job_id: str, db: Session = Depends(get_db)):
return restart_collection_job(db, job_id).model_dump() return restart_collection_job(db, job_id).model_dump()
@router.post("/{job_id}/retry-failed")
def api_retry_failed_collection(job_id: str, db: Session = Depends(get_db)):
return retry_failed_collection_job(db, job_id).model_dump()
@router.delete("/{job_id}") @router.delete("/{job_id}")
def api_delete_collection(job_id: str, db: Session = Depends(get_db)): def api_delete_collection(job_id: str, db: Session = Depends(get_db)):
return delete_collection_job(db, job_id) return delete_collection_job(db, job_id)

View file

@ -14,7 +14,12 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import ManagedNE, NeCollectionJob, NeCollectionRun from .models import ManagedNE, NeCollectionJob, NeCollectionRun
from .collection_job_state import finalize_collection_job, reconcile_stale_collection_job, _sync_job_counts from .collection_job_state import (
finalize_collection_job,
reconcile_stale_collection_job,
sync_job_progress,
_sync_job_counts,
)
from .collection_schemas import CollectionJobCreate, CollectionJobOut, CollectionRunOut from .collection_schemas import CollectionJobCreate, CollectionJobOut, CollectionRunOut
from .ne_collect_runner import schedule_collection_runs from .ne_collect_runner import schedule_collection_runs
from .ne_collection_paths import clear_run_output_files, collection_data_root from .ne_collection_paths import clear_run_output_files, collection_data_root
@ -23,7 +28,7 @@ _log = logging.getLogger("netx.collection")
def _now() -> datetime: def _now() -> datetime:
return datetime.utcnow() return datetime.now()
def _parse_commands(text: str) -> list[str]: def _parse_commands(text: str) -> list[str]:
@ -93,7 +98,7 @@ def run_to_out(row: NeCollectionRun) -> CollectionRunOut:
ne_name=str(row.ne_name or ""), ne_name=str(row.ne_name or ""),
ne_ip=str(row.ne_ip or ""), ne_ip=str(row.ne_ip or ""),
status=str(row.status or "pending"), status=str(row.status or "pending"),
message=str(row.message or "")[:1000], message=str(row.message or ""),
output_rel_path=rel, output_rel_path=rel,
has_output=bool(rel), has_output=bool(rel),
started_at=row.started_at, started_at=row.started_at,
@ -188,9 +193,13 @@ def list_collection_jobs(db: Session, *, page: int = 1, page_size: int = 20) ->
total = int(stmt.count()) total = int(stmt.count())
rows = stmt.order_by(NeCollectionJob.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() rows = stmt.order_by(NeCollectionJob.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
for row in rows: for row in rows:
if str(row.status or "") not in ("done", "failed"): job_id = str(row.id)
reconcile_stale_collection_job(db, str(row.id)) if str(row.status or "") not in ("done", "failed", "paused"):
reconcile_stale_collection_job(db, job_id)
db.refresh(row) db.refresh(row)
if str(row.status or "") == "running":
sync_job_progress(db, job_id)
db.refresh(row)
job_ids = [str(x.id) for x in rows] job_ids = [str(x.id) for x in rows]
output_counts = _output_counts_for_jobs(db, job_ids) output_counts = _output_counts_for_jobs(db, job_ids)
return { return {
@ -205,9 +214,12 @@ def get_collection_job(db: Session, job_id: str) -> dict[str, Any]:
job = db.get(NeCollectionJob, job_id) job = db.get(NeCollectionJob, job_id)
if not job: if not job:
raise HTTPException(status_code=404, detail="collection_job_not_found") raise HTTPException(status_code=404, detail="collection_job_not_found")
if str(job.status or "") not in ("done", "failed"): if str(job.status or "") not in ("done", "failed", "paused"):
reconcile_stale_collection_job(db, job_id) reconcile_stale_collection_job(db, job_id)
db.refresh(job) db.refresh(job)
if str(job.status or "") == "running":
sync_job_progress(db, job_id)
db.refresh(job)
return { return {
"job": job_to_out(job, output_count=_output_count_for_job(db, job_id)).model_dump(), "job": job_to_out(job, output_count=_output_count_for_job(db, job_id)).model_dump(),
} }
@ -277,6 +289,57 @@ def pause_collection_job(db: Session, job_id: str) -> CollectionJobOut:
return job_to_out(job, output_count=_output_count_for_job(db, job_id)) return job_to_out(job, output_count=_output_count_for_job(db, job_id))
def _reset_runs_for_retry(
db: Session,
job_id: str,
runs: list[NeCollectionRun],
*,
only_statuses: frozenset[str],
) -> list[str]:
retry_ids: list[str] = []
for run in runs:
st = str(run.status or "")
if st in ("pending", "running") or st not in only_statuses:
continue
clear_run_output_files(job_id, str(run.id))
run.status = "pending"
run.message = ""
run.output_rel_path = ""
run.started_at = None
run.ended_at = None
retry_ids.append(str(run.id))
return retry_ids
def _start_job_retry(
db: Session,
job: NeCollectionJob,
job_id: str,
retry_ids: list[str],
commands: list[str],
*,
reset_all_counts: bool,
) -> CollectionJobOut:
if not retry_ids:
raise HTTPException(status_code=400, detail="collection_nothing_to_retry")
now = _now()
job.status = "running"
job.ended_at = None
job.error_message = ""
if reset_all_counts:
job.success_count = 0
job.fail_count = 0
else:
runs = db.query(NeCollectionRun).filter(NeCollectionRun.job_id == job_id).all()
_sync_job_counts(job, runs)
job.started_at = now
job.last_run_at = now
db.commit()
db.refresh(job)
schedule_collection_runs(job_id, retry_ids, commands)
return job_to_out(job, output_count=_output_count_for_job(db, job_id))
def restart_collection_job(db: Session, job_id: str) -> CollectionJobOut: def restart_collection_job(db: Session, job_id: str) -> CollectionJobOut:
job = db.get(NeCollectionJob, job_id) job = db.get(NeCollectionJob, job_id)
if not job: if not job:
@ -289,32 +352,29 @@ def restart_collection_job(db: Session, job_id: str) -> CollectionJobOut:
commands = _parse_commands(str(job.commands or "")) commands = _parse_commands(str(job.commands or ""))
if not commands: if not commands:
raise HTTPException(status_code=400, detail="commands_empty") raise HTTPException(status_code=400, detail="commands_empty")
retry_ids: list[str] = [] retry_ids = _reset_runs_for_retry(
for run in runs: db,
st = str(run.status or "") job_id,
if st in ("pending", "running"): runs,
continue only_statuses=frozenset({"success", "fail", "cancelled"}),
clear_run_output_files(job_id, str(run.id)) )
run.status = "pending" return _start_job_retry(db, job, job_id, retry_ids, commands, reset_all_counts=True)
run.message = ""
run.output_rel_path = ""
run.started_at = None def retry_failed_collection_job(db: Session, job_id: str) -> CollectionJobOut:
run.ended_at = None job = db.get(NeCollectionJob, job_id)
retry_ids.append(str(run.id)) if not job:
if not retry_ids: raise HTTPException(status_code=404, detail="collection_job_not_found")
raise HTTPException(status_code=400, detail="collection_nothing_to_retry") runs = db.query(NeCollectionRun).filter(NeCollectionRun.job_id == job_id).all()
now = _now() if not runs:
job.status = "running" raise HTTPException(status_code=400, detail="collection_no_runs")
job.ended_at = None if str(job.status or "") == "running" or _active_runs(runs):
job.error_message = "" raise HTTPException(status_code=400, detail="collection_job_running")
job.success_count = 0 commands = _parse_commands(str(job.commands or ""))
job.fail_count = 0 if not commands:
job.started_at = now raise HTTPException(status_code=400, detail="commands_empty")
job.last_run_at = now retry_ids = _reset_runs_for_retry(db, job_id, runs, only_statuses=frozenset({"fail"}))
db.commit() return _start_job_retry(db, job, job_id, retry_ids, commands, reset_all_counts=False)
db.refresh(job)
schedule_collection_runs(job_id, retry_ids, commands)
return job_to_out(job, output_count=0)
def delete_collection_job(db: Session, job_id: str) -> dict[str, bool]: def delete_collection_job(db: Session, job_id: str) -> dict[str, bool]:

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import logging import logging
import re import re
import traceback
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -9,7 +10,7 @@ from typing import Any
from netmiko import ConnectHandler from netmiko import ConnectHandler
from .collection_job_state import finalize_collection_job from .collection_job_state import finalize_collection_job, sync_job_progress
from .config import settings from .config import settings
from .db import SessionLocal from .db import SessionLocal
from .models import ManagedNE, NeCollectionJob, NeCollectionRun from .models import ManagedNE, NeCollectionJob, NeCollectionRun
@ -30,6 +31,13 @@ def _executor_pool() -> ThreadPoolExecutor:
return _executor return _executor
def _format_run_error(exc: BaseException) -> str:
head = f"{type(exc).__name__}: {exc}"
tb = traceback.format_exc().strip()
text = f"{head}\n{tb}" if tb else head
return text[:1020]
def _safe_filename_part(text: str) -> str: def _safe_filename_part(text: str) -> str:
s = re.sub(r'[<>:"/\\|?*]', "_", str(text or "").strip()) s = re.sub(r'[<>:"/\\|?*]', "_", str(text or "").strip())
return s[:80] or "device" return s[:80] or "device"
@ -152,14 +160,15 @@ def _run_single(job_id: str, run_id: str, commands: list[str]) -> None:
ended_at=finished_at, ended_at=finished_at,
) )
except CredentialCryptoError as exc: except CredentialCryptoError as exc:
_update_run(run_id, status="fail", message=str(exc)[:1000], ended_at=datetime.now()) _update_run(run_id, status="fail", message=str(exc)[:1020], ended_at=datetime.now())
except Exception as exc: except Exception as exc:
_log.exception("collection failed run=%s", run_id) _log.exception("collection failed run=%s", run_id)
_update_run(run_id, status="fail", message=str(exc).split("\n")[0][:1000], ended_at=datetime.now()) _update_run(run_id, status="fail", message=_format_run_error(exc), ended_at=datetime.now())
finally: finally:
db.close() db.close()
db2 = SessionLocal() db2 = SessionLocal()
try: try:
sync_job_progress(db2, job_id)
finalize_collection_job(db2, job_id) finalize_collection_job(db2, job_id)
finally: finally:
db2.close() db2.close()

View file

@ -26,7 +26,7 @@ class CollectionJobFinalizeTests(unittest.TestCase):
self.assertEqual(job.status, "paused") self.assertEqual(job.status, "paused")
self.assertEqual(job.success_count, 1) self.assertEqual(job.success_count, 1)
self.assertEqual(job.fail_count, 1) self.assertEqual(job.fail_count, 0)
self.assertIsNotNone(job.ended_at) self.assertIsNotNone(job.ended_at)
db.commit.assert_called_once() db.commit.assert_called_once()

View file

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

View file

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

View file

@ -957,6 +957,21 @@ pre {
background: #fff; 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) { @media (max-width: 1200px) {
.cards { .cards {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View file

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