From c4be1e98419bf94c9f7079aaa622d2e5e66930fd Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 26 May 2026 15:02:03 +0200 Subject: [PATCH] schedules fire-now: textContent + DOM nodes instead of innerHTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit argus 🟡 note on #471 — values rendered into the button flash are all server-side ints/bool today, but textContent + element children is the safer pattern if a stringy field ever lands in FireNowReport. Captures original children on enter so error paths can restore faithfully (the previous innerHTML round-trip would have already lost any nested element structure). --- frontend/packages/dashboard/src/app.js | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index a5375ace..7056579c 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -2578,8 +2578,23 @@ window.marked = marked; + 'this is RECURRING — sends an extra pulse out-of-band. ' + 'the regular cadence keeps firing on schedule.'; if (!confirm(prompt)) return; - const originalLabel = btn ? btn.innerHTML : ''; - if (btn) { btn.disabled = true; btn.innerHTML = ' firing…'; } + // Capture child nodes so we can restore on error, then replace + // with DOM-built content (textContent + element children rather + // than innerHTML — per argus's review note on #471, the format + // string only carries server-side ints/bool today but textContent + // is the safer pattern if a stringy field ever lands). + const originalChildren = btn ? Array.from(btn.childNodes) : []; + const restoreBtn = () => { + if (!btn) return; + btn.disabled = false; + while (btn.firstChild) btn.removeChild(btn.firstChild); + for (const n of originalChildren) btn.appendChild(n); + }; + if (btn) { + btn.disabled = true; + while (btn.firstChild) btn.removeChild(btn.firstChild); + btn.append(el('span', { class: 'spinner' }, '◐'), ' firing…'); + } try { const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/fire-now', { method: 'POST', @@ -2588,7 +2603,7 @@ window.marked = marked; if (!resp.ok) { const text = await resp.text().catch(() => ''); alert('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : '')); - if (btn) { btn.disabled = false; btn.innerHTML = originalLabel; } + restoreBtn(); return; } // Backend returns FireNowReport { ok, failed, missing, one_shot_consumed }. @@ -2603,7 +2618,8 @@ window.marked = marked; if (report.failed) bits.push(report.failed + ' failed'); if (report.missing) bits.push(report.missing + ' missing'); const suffix = report.one_shot_consumed ? ' — consumed' : ''; - btn.innerHTML = '↯ fired: ' + (bits.join(', ') || 'no targets') + suffix; + while (btn.firstChild) btn.removeChild(btn.firstChild); + btn.textContent = '↯ fired: ' + (bits.join(', ') || 'no targets') + suffix; btn.classList.add('btn-fire-now-flashed'); } // Hold the flash briefly so the operator can read it before the @@ -2611,7 +2627,7 @@ window.marked = marked; setTimeout(() => { refreshSchedules(); }, 1500); } catch (err) { alert('fire-now failed: ' + err); - if (btn) { btn.disabled = false; btn.innerHTML = originalLabel; } + restoreBtn(); } } async function cancelScheduleAll(id) {