Project homepage Mailing List  Warmcat.com  API Docs  Github Mirror 
    npro  
 Modern all-safe Rust Network Protocol library supporting h1, h2, h3, ws, wt sans-IO and with socket IO + tls
git clone https://npro.rs/repo/npro
 
root / assets / linux-android.svg
Author[]Andy Green <andy@warmcat.com> 2026-09-12 15:48 UTC
Committer[]Andy Green <andy@warmcat.com> 2026-09-12 15:48 UTC
Treed4d07dffaf346142b2cc3adc5d9f9e1cff8a8a9c   Raw Patch
 
assets: tasks pane: stop rebuilding on every overview, coalesce table sorts
assets: tasks pane: stop rebuilding on every overview, coalesce table sorts

sai-web queues an overview push on every change, and the tasks pane
rebuilt its whole markup each time: hundreds of platform icon <img>s
recreated, then a refresh_state() pass over every task which, for the
new table, re-sorted all rows with DOM moves per task -- quadratic in
the task count and taking seconds on a busy event.

- Skip the rebuild when a signature of everything the pane shows (event
  header state + per-task uuid/run/state/started/duration/step/platform)
  is unchanged since the last render; live per-task changes are already
  applied in place by refresh_state().
- Coalesce table re-sorts into one deferred pass per burst, read the sort
  keys once per row, and only touch the DOM (a single fragment append)
  when the order actually changed.
- Row refresh is a no-op when the row already reflects the task.
- Activity flashing uses direct id lookups instead of document-wide
  attribute scans every 500ms.

Measured with 400 tasks: initial build ~100ms, an unchanged overview
refresh ~0.2ms, a burst of 400 state changes ~6ms plus one sort.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
diff --git a/assets/sai.js b/assets/sai.js index 27f63b0..4055df8 100644 --- a/assets/sai.js +++ b/assets/sai.js @@ -545,7 +545,10 @@ function renderPconHierarchy(container) { function update_task_activities() { for (const uuid in ongoing_task_activities) { const cat = ongoing_task_activities[uuid]; - document.querySelectorAll("[id='taskstate_" + uuid + "'], [data-task-uuid='" + uuid + "']").forEach(function(el) { + [ document.getElementById("taskstate_" + uuid), + document.getElementById("tt_" + uuid) ].forEach(function(el) { + if (!el) + return; el.classList.remove("activity-1", "activity-2", "activity-3"); if (cat > 0) { el.classList.add("activity-" + cat); @@ -2215,7 +2218,12 @@ function sai_tt_cmp_vals(a, b) /* * Reorder the rows of a task table in place: failed first, then the user's * primary key and direction (rows lacking a value for it go last either - * way), then task name and platform to keep the order stable + * way), then task name and platform to keep the order stable. + * + * Sort keys are read once per row, and the DOM is only touched if the + * order actually changed, in a single fragment append: this runs after + * every live state change and on the duration tick, so it must be cheap + * in the common no-op case. */ function sai_tt_resort(tab) { @@ -2225,36 +2233,67 @@ function sai_tt_resort(tab) var so = sai_tt_sort_get(); var now_ut = Math.round((new Date().getTime() / 1000)); - var rows = Array.prototype.slice.call(tbody.querySelectorAll("tr.tt-row")); - - rows.sort(function(ra, rb) { - var fa = ra.dataset.state === "4" ? 0 : 1, - fb = rb.dataset.state === "4" ? 0 : 1; - - if (fa !== fb) - return fa - fb; + var cur = Array.prototype.slice.call(tbody.rows); + var items = cur.map(function(tr) { + return { + tr: tr, + failed: tr.dataset.state === "4" ? 0 : 1, + key: sai_tt_row_key(tr, so.key, now_ut), + name: tr.dataset.name, + plat: tr.dataset.plat + }; + }); - var ka = sai_tt_row_key(ra, so.key, now_ut), - kb = sai_tt_row_key(rb, so.key, now_ut); + items.sort(function(a, b) { + if (a.failed !== b.failed) + return a.failed - b.failed; - if (ka === null && kb !== null) + if (a.key === null && b.key !== null) return 1; - if (kb === null && ka !== null) + if (b.key === null && a.key !== null) return -1; - if (ka !== null) { - var c = sai_tt_cmp_vals(ka, kb); + if (a.key !== null) { + var c = sai_tt_cmp_vals(a.key, b.key); if (c) return so.dir * c; } - var c2 = sai_tt_cmp_vals(ra.dataset.name, rb.dataset.name); + var c2 = sai_tt_cmp_vals(a.name, b.name); if (c2) return c2; - return sai_tt_cmp_vals(ra.dataset.plat, rb.dataset.plat); + return sai_tt_cmp_vals(a.plat, b.plat); }); - rows.forEach(function(r) { tbody.appendChild(r); }); + var moved = false; + for (var i = 0; i < items.length; i++) + if (items[i].tr !== cur[i]) { + moved = true; + break; + } + if (!moved) + return; + + var frag = document.createDocumentFragment(); + items.forEach(function(it) { frag.appendChild(it.tr); }); + tbody.appendChild(frag); +} + +/* + * Coalesce re-sorts: a burst of task state broadcasts (or the per-task + * refresh after a pane rebuild) asks many times, we sort once + */ +var sai_tt_resort_timer = null; + +function sai_tt_schedule_resort() +{ + if (sai_tt_resort_timer) + return; + + sai_tt_resort_timer = window.setTimeout(function() { + sai_tt_resort_timer = null; + document.querySelectorAll("table.tt").forEach(sai_tt_resort); + }, 0); } /* @@ -2272,6 +2311,19 @@ function sai_tt_refresh_row(t) if (run < parseInt(tr.dataset.run)) return; + /* + * Nothing to do if the row already reflects this task: the refresh + * pass after a pane rebuild hits every task, and the rows were just + * rendered from the same data + */ + var k = sai_tt_row_keys(t), changed = !tr.classList.contains("taskstate" + t.state) || + tr.dataset.rebuildable !== String(t.rebuildable); + for (var n in k) + if (tr.dataset[n] !== String(k[n])) + changed = true; + if (!changed) + return; + var now_ut = Math.round((new Date().getTime() / 1000)); var stale = []; @@ -2288,7 +2340,7 @@ function sai_tt_refresh_row(t) tr.cells[3].textContent = sai_tt_fmt_dur(sai_tt_dur_secs(t, now_ut)); tr.cells[4].innerHTML = sai_tt_step_html(t); - sai_tt_resort(tr.closest("table")); + sai_tt_schedule_resort(); } /* @@ -2318,7 +2370,7 @@ function sai_tt_tick() }); if (live && sai_tt_sort_get().key === "duration") - document.querySelectorAll("table.tt").forEach(sai_tt_resort); + sai_tt_schedule_resort(); } function sai_tt_set_selected(uuid) @@ -2382,6 +2434,32 @@ function sai_tt_split_begin(left, x0) document.addEventListener('touchend', finish); } +/* + * Everything the tasks pane's markup depends on, flattened to a string, so + * a rebuild can be skipped when an overview refresh brings nothing new. + * Live per-task changes are applied in place by refresh_state(), so most + * overview messages during a build change nothing here. + */ +var sai_tasks_pane_sig = null; + +function sai_tasks_pane_signature(o) +{ + var e = o.e; + var parts = [ e.uuid, e.state, e.adhoc ? 1 : 0, e.repo_name, e.ref, + e.hash, e.weburl || "", auth_state, gitohashi_integ ? 1 : 0 ]; + + if (o.t) + for (var q = 0; q < o.t.length; q++) { + var t = o.t[q]; + + parts.push(t.uuid, t.run, t.state, t.started, t.duration, + t.build_step, t.build_step_count, t.total_steps, + t.rebuildable, t.platform, t.taskname); + } + + return parts.join("\x01"); +} + function render_selected_event_tasks(o) { var now_ut = Math.round((new Date().getTime() / 1000)); var s = ""; @@ -2404,6 +2482,21 @@ function render_selected_event_tasks(o) { var c = document.getElementById("sai_event_tasks"); if (c) c.innerHTML = "<div class=\"sb-empty\">Select a project and branch</div>"; + sai_tasks_pane_sig = null; + return; + } + + /* + * Same event, same tasks, same header state as what's already in the + * pane (and nobody replaced the pane contents since): don't rebuild. + * Rebuilding recreates hundreds of platform icon <img>s and throws + * away the sub-panes' scroll positions for nothing. + */ + var sig = sai_tasks_pane_signature(o); + var container = document.getElementById("sai_event_tasks"); + if (container && sig === sai_tasks_pane_sig && + container.querySelector(".event-tasks-header[data-ev='" + san(e.uuid) + "']")) { + update_summary_and_progress(e.uuid); return; } @@ -2413,7 +2506,7 @@ function render_selected_event_tasks(o) { * available yet (sidebar-scoped overview events arrive with t:[] and * fetch their tasks on demand via selectEvent -> eventinfo). */ - s += "<div class=\"event-tasks-header"; + s += "<div data-ev=\"" + san(e.uuid) + "\" class=\"event-tasks-header"; if (e.state == 3) s += " comp_pass"; if (e.state == 4 || e.state == 6) s += " comp_fail"; if (e.adhoc) s += " adhoc"; @@ -2516,10 +2609,10 @@ function render_selected_event_tasks(o) { s += "<div class=\"no-tasks\">No tasks for this event</div>"; } - var container = document.getElementById("sai_event_tasks"); if (container) { + sai_tasks_pane_sig = sig; /* - * The pane is rebuilt on every overview refresh; carry the + * The pane is rebuilt when its content changed; carry the * sub-panes' scroll positions across so it doesn't jump */ var lp = document.getElementById("sai_tasks_left"),
Page fetched 0s ago, creation time: 4ms (vhost etag hits: 0%, cache hits: 0%)