From 5ffdb179f77cf323c9bd7de443d5fd577f39a745 Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 15 Jun 2026 17:20:26 -0700 Subject: [PATCH] Download modal: diff-aware repaint + Retry-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Patch progress/speed in place on each tick instead of rebuilding the whole modal body, so open per-job logs no longer collapse and the bar doesn't flicker while downloading. Full rebuild only on a structural change (job added/removed, state or retryability changed, queue length changed). - "↻ Retry all failed" header button that re-issues every retryable failed job (re-snapshots between retries since indices shift). Co-Authored-By: Claude Opus 4.8 --- src/web_ui/index.html | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/web_ui/index.html b/src/web_ui/index.html index a92156d..ecb5fe7 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -2522,10 +2522,31 @@ function renderJobs(jobs,queued,maxConcurrent){ if(!body)return; if(!jobs.length&&!queued.length){ body.innerHTML=`
No active or recent downloads.
`; + jobsSig=''; return; } + // Diff-aware repaint: a full innerHTML rebuild on every progress tick + // (~0.6s while downloading) flickers and collapses any open per-job log. + // Only rebuild when the *structure* changes (jobs added/removed/state or + // retryability flipped, or the queue length changed); otherwise just patch + // the live bits (progress bar, speed/ETA) of the running rows in place. + const sig=JSON.stringify([jobs.map(j=>[j.state,j.can_retry]),queued.length]); + if(sig===jobsSig&&body.querySelector('.job-row')){ + jobs.forEach((j,i)=>{ + if(j.state!=='running')return; + const row=body.querySelector(`.job-row[data-job="${i}"]`); + if(!row)return; + const pr=row.querySelector('progress'); if(pr)pr.value=j.progress||0; + const mt=row.querySelector('.job-meta'); + if(mt)mt.textContent=`${jobMeta(j.last_line)} ${Math.round((j.progress||0)*100)}%`; + }); + return; + } + jobsSig=sig; const fin=jobs.some(j=>j.state!=='running'); - const hdr=fin?`
`:''; + const anyRetry=jobs.some(j=>j.state!=='running'&&j.can_retry); + const hdrBtns=`${anyRetry?``:''}`; + const hdr=fin?`
${hdrBtns}
`:''; // Queued jobs map 1:1 to the server's pending-queue index, so the cancel // button can DELETE /api/queued/. const queuedHtml=queued.map((q,i)=>`
@@ -2593,6 +2614,21 @@ async function cancelQueued(idx){ try{await api('/api/queued/'+idx,{method:'DELETE'});await pollProgress();} catch(e){setStatus('Error: '+e.message)} } +// Retry every retryable failed job. Each retry removes the old row and shifts +// indices, so we re-snapshot and retry the first match until none remain +// (capped so a server hiccup can't spin forever). +async function retryAllFailed(btn){ + if(btn)btn.disabled=true; + try{ + for(let guard=0;guard<200;guard++){ + const d=await(await api('/api/progress')).json(); + const idx=(d.jobs||[]).findIndex(j=>j.state!=='running'&&j.can_retry); + if(idx<0)break; + await api('/api/jobs/'+idx+'/retry',{method:'POST'}); + } + await pollProgress(); + }catch(e){setStatus('Error: '+e.message);if(btn)btn.disabled=false} +} // Expand/collapse the full server-side log for one job, fetched on demand. async function toggleJobLog(idx,btn){ const row=document.querySelector(`.job-row[data-job="${idx}"]`); @@ -2684,6 +2720,9 @@ async function removeJob(idx){try{await api('/api/jobs/'+idx,{method:'DELETE'}); let wasRunning=false; let progressWs=null; let pollTimer=null; +// Structural signature of the last full jobs repaint; lets renderJobs patch +// progress in place instead of rebuilding (and collapsing open logs) each tick. +let jobsSig=''; function applyProgressSnapshot(d){ if(!d)return; renderJobs(d.jobs,d.queued,d.max_concurrent);