(job)
| 554 | } |
| 555 | |
| 556 | function renderJob(job) { |
| 557 | if (!job) return; |
| 558 | const ui = ensureJobDom(job); |
| 559 | const now = Date.now(); |
| 560 | const state = String(job.state || '').toLowerCase(); |
| 561 | const cancelling = state === 'cancel_requested' || state === 'cancelling'; |
| 562 | const running = state === 'running' || state === 'finishing' || cancelling; |
| 563 | const queued = state === 'queued'; |
| 564 | const done = state === 'done'; |
| 565 | const finishing = state === 'finishing'; |
| 566 | const failed = ['error', 'failed', 'cancelled'].includes(state); |
| 567 | const elapsedMs = (done || failed) && job.endedAt ? (job.endedAt - job.startedAt) : (now - job.startedAt); |
| 568 | |
| 569 | const title = buildTitle(job); |
| 570 | const sub = buildSub(job); |
| 571 | if (ui.title) ui.title.textContent = title; |
| 572 | if (ui.sub) { |
| 573 | ui.sub.textContent = sub || ''; |
| 574 | ui.sub.style.display = sub ? '' : 'none'; |
| 575 | } |
| 576 | |
| 577 | if (ui.state) { |
| 578 | ui.state.className = `fr-transfer-item-state ${getJobStateClass(job)}`; |
| 579 | ui.state.textContent = getJobStateLabel(job); |
| 580 | } |
| 581 | |
| 582 | if (ui.cancelBtn) { |
| 583 | const cancellableState = queued || state === 'running' || cancelling; |
| 584 | const canCancel = !!job.cancelable && cancellableState && !done && !failed && !finishing; |
| 585 | const cancellingUi = !!job.cancelInFlight || !!job.cancelRequested || cancelling; |
| 586 | ui.cancelBtn.style.display = canCancel ? 'inline-flex' : 'none'; |
| 587 | ui.cancelBtn.disabled = !canCancel || cancellingUi; |
| 588 | ui.cancelBtn.textContent = cancellingUi ? 'Cancelling...' : 'Cancel'; |
| 589 | ui.cancelBtn.title = cancellingUi ? 'Cancellation requested' : 'Cancel transfer'; |
| 590 | } |
| 591 | |
| 592 | const hasActual = job.mode === 'actual'; |
| 593 | const totalBytes = Number.isFinite(job.bytesTotal) ? job.bytesTotal : Number(job.totalBytes || 0); |
| 594 | |
| 595 | if (queued) { |
| 596 | applyProgressBar(job, ui, 0, { indeterminate: true }); |
| 597 | if (ui.metrics) ui.metrics.textContent = 'Waiting for worker...'; |
| 598 | return; |
| 599 | } |
| 600 | |
| 601 | if (running && hasActual) { |
| 602 | const totalFiles = Number.isFinite(job.filesTotal) ? job.filesTotal : Number(job.itemCount || 0); |
| 603 | const doneFiles = Number.isFinite(job.filesDone) ? job.filesDone : null; |
| 604 | const doneBytes = Number.isFinite(job.bytesDone) ? job.bytesDone : null; |
| 605 | |
| 606 | if (finishing) { |
| 607 | const startAt = Number(job.finalizeStartAt || now); |
| 608 | const durationMs = Math.max(220, Number(job.finalizeDurationMs || 650)); |
| 609 | const fromPct = clamp(Number(job.finalizeFromPct || 0), 0, 99); |
| 610 | const ratio = clamp((now - startAt) / durationMs, 0, 1); |
| 611 | const displayPct = clamp(Math.round(fromPct + ((100 - fromPct) * ratio)), 0, 100); |
| 612 | |
| 613 | applyProgressBar(job, ui, displayPct, { indeterminate: false }); |
no test coverage detected