Selaa lähdekoodia

web UI: no blink on refresh, preserve controls; add UI tests

Root cause of both: onState wiped + rebuilt the whole DOM on every
SSE/heartbeat tick, so (1) the win-in animation replayed (blink) and
(2) the active cluster's recipe select + dry-run checkbox reset.

- sameSnapshot(): compare everything except ts; onState returns early
  when nothing changed → no DOM churn, no blink (the anti-blink rule)
- restoreControls(): recipe pick + dry-run tick survive a rebuild that
  does happen (e.g. a node flip); chip switch passes force=true
- win-in animations now only on truly new elements (.cluster.anim on
  first paint/switch, .job.new on first appearance)
- tests: ui_js_syntax_and_snapshot_logic runs the real ui.html through
  node (syntax gate + behavior probe of sameSnapshot via SNAP-LT
  markers); negative-verified it fails on a broken (missing =>) file.
  Skipped when node is absent.
Alexander Korolev 2 kuukautta sitten
vanhempi
sitoutus
8c0b20a6dc
2 muutettua tiedostoa jossa 119 lisäystä ja 19 poistoa
  1. 67 0
      src/web/tests.rs
  2. 52 19
      src/web/ui.html

+ 67 - 0
src/web/tests.rs

@@ -222,3 +222,70 @@ async fn serve_endpoint_registers_job_and_rejects_concurrent() {
     );
     let _ = id;
 }
+
+// --- UI (ui.html) -------------------------------------------------------------
+// The embedded page is vanilla JS with no build step; these tests run the
+// real file through node: (1) syntax gate, (2) behavior probe of the pure
+// snapshot-compare logic (the anti-blink rule), extracted between the
+// SNAP-LT markers in ui.html. Skipped when node is unavailable.
+
+fn extract_script(html: &str) -> String {
+    let start = html.find("<script>").unwrap() + "<script>".len();
+    let end = html.rfind("</script>").unwrap();
+    html[start..end].to_string()
+}
+
+#[test]
+fn ui_js_syntax_and_snapshot_logic() {
+    let html = include_str!("ui.html");
+    if std::process::Command::new("node")
+        .arg("--version")
+        .output()
+        .is_err()
+    {
+        eprintln!("SKIP ui_js_syntax_and_snapshot_logic: node not available");
+        return;
+    }
+    let script = extract_script(html);
+    let pid = std::process::id();
+    let js = std::env::temp_dir().join(format!("sm121-ui-{pid}.js"));
+    std::fs::write(&js, &script).unwrap();
+    let out = std::process::Command::new("node")
+        .arg("--check")
+        .arg(&js)
+        .output()
+        .unwrap();
+    assert!(
+        out.status.success(),
+        "ui.html JS must parse: {}",
+        String::from_utf8_lossy(&out.stderr)
+    );
+
+    // sameSnapshot: ts must be ignored, real changes must not be equal.
+    let s = script.find("/*SNAP-LT:START*/").unwrap() + "/*SNAP-LT:START*/".len();
+    let e = script.find("/*SNAP-LT:END*/").unwrap();
+    let probe = format!(
+        "{fn}
+const a = {{ ts: 1, clusters: [{{ name: 'c', up: true, free: 1, nodes: [] }}], jobs: [] }};
+const b = {{ ts: 99, clusters: [{{ name: 'c', up: true, free: 1, nodes: [] }}], jobs: [] }};
+const d = {{ ts: 99, clusters: [{{ name: 'c', up: true, free: 0, nodes: [] }}], jobs: [] }};
+if (sameSnapshot(a, b) !== true) throw new Error('ts must be ignored');
+if (sameSnapshot(a, d) !== false) throw new Error('changed fleet must differ');
+if (sameSnapshot(a, a) !== true) throw new Error('identity');
+console.log('ok');",
+        fn = &script[s..e]
+    );
+    let js2 = std::env::temp_dir().join(format!("sm121-ui-probe-{pid}.js"));
+    std::fs::write(&js2, probe).unwrap();
+    let out = std::process::Command::new("node")
+        .arg(&js2)
+        .output()
+        .unwrap();
+    assert!(
+        out.status.success(),
+        "sameSnapshot probe failed: {}",
+        String::from_utf8_lossy(&out.stderr)
+    );
+    let _ = std::fs::remove_file(&js);
+    let _ = std::fs::remove_file(&js2);
+}

+ 52 - 19
src/web/ui.html

@@ -48,7 +48,8 @@
     padding: 14px; min-height: 120px;
   }
   .card h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); margin: 0 0 10px; }
-  .cluster { margin-bottom: 14px; animation: winIn .35s ease both; }
+  .cluster.anim { animation: winIn .35s ease both; }
+  .cluster { margin-bottom: 14px; }
   .cluster-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
   .cluster-head b { font-size: 13px; }
   .cluster-head .free { color: var(--muted); font-size: 12px; }
@@ -108,7 +109,8 @@
   .abtn:disabled { opacity: .45; cursor: default; }
   .abtn.danger { color: var(--led-off); }
   .jobs { font-family: ui-monospace, monospace; font-size: 12px; }
-  .job { display: flex; gap: 8px; align-items: baseline; padding: 4px 0; border-bottom: 1px solid var(--border); animation: winIn .3s ease both; }
+  .job { display: flex; gap: 8px; align-items: baseline; padding: 4px 0; border-bottom: 1px solid var(--border); }
+  .job.new { animation: winIn .3s ease both; }
   .job:last-child { border-bottom: none; }
   .jdot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex-shrink: 0; align-self: center; }
   .job.ok .jdot { background: var(--led-on); }
@@ -172,7 +174,15 @@
 "use strict";
 const $ = (s) => document.querySelector(s);
 const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
-const state = { snap: null, jobs: new Map(), recipes: [], active: localStorage.getItem("sm121-active") || "" };
+const state = { snap: null, jobs: new Map(), recipes: [], active: localStorage.getItem("sm121-active") || "", panel: null, shown: new Set() };
+
+/*SNAP-LT:START*/
+// Pure: same fleet + jobs? (`ts` changes on every probe, so it is
+// excluded). Used to skip DOM rebuilds when nothing changed — no blink.
+function sameSnapshot(a, b) {
+  return JSON.stringify({ c: a.clusters, j: a.jobs }) === JSON.stringify({ c: b.clusters, j: b.jobs });
+}
+/*SNAP-LT:END*/
 
 // --- theme ---------------------------------------------------------------
 const root = document.documentElement;
@@ -215,9 +225,9 @@ function clusterReason(c) {
   if (c.soft_off && c.soft_off.length) r = (r ? r + " · " : "") + "⚙ " + c.soft_off.join(", ") + " off";
   return r;
 }
-function renderCluster(c, prev) {
+function renderCluster(c, prev, animate) {
   const el = document.createElement("div");
-  el.className = "cluster";
+  el.className = "cluster" + (animate ? " anim" : "");
   const head = document.createElement("div");
   head.className = "cluster-head";
   if (c.up) {
@@ -291,19 +301,27 @@ function abtn(id, label, fn, cls) {
   b.onclick = fn;
   return b;
 }
-function onState(snap) {
+function onState(snap, force) {
   const prev = state.snap;
+  if (!force && prev && sameSnapshot(prev, snap)) return; // nothing changed — no DOM churn
   state.snap = snap;
   state.jobs = new Map(snap.jobs.map((j) => [j.id, j]));
   if (!snap.clusters.find((c) => c.name === state.active)) {
     state.active = snap.clusters[0] ? snap.clusters[0].name : "";
   }
+  // control state must survive the rebuild (recipe pick, dry-run tick)
+  const activeName = state.active;
+  const keepRecipe = recipeOf(activeName);
+  const keepDry = dryOf(activeName);
+  const animate = state.panel !== activeName; // first paint or cluster switch
+  state.panel = activeName;
   const box = $("#clusters");
   box.innerHTML = "";
-  const active = snap.clusters.find((c) => c.name === state.active);
+  const active = snap.clusters.find((c) => c.name === activeName);
   if (active) {
-    box.appendChild(renderCluster(active, prev ? prev.clusters.find((p) => p.name === active.name) : null));
+    box.appendChild(renderCluster(active, prev ? prev.clusters.find((p) => p.name === activeName) : null, animate));
   }
+  restoreControls(activeName, keepRecipe, keepDry);
   // compact fleet strip: one chip per cluster, click to make it active
   const strip = document.createElement("div");
   strip.className = "strip";
@@ -314,12 +332,21 @@ function onState(snap) {
     const m = c.up ? c.free + " free" : "⏻ OFF";
     chip.title = clusterReason(c);
     chip.innerHTML = `<span class="led ${dot}"></span>${esc(c.name)}<span class="m">${m}</span>`;
-    chip.onclick = () => { state.active = c.name; localStorage.setItem("sm121-active", c.name); onState(state.snap); };
+    chip.onclick = () => { state.active = c.name; localStorage.setItem("sm121-active", c.name); onState(state.snap, true); };
     strip.appendChild(chip);
   }
   box.appendChild(strip);
   renderJobs();
 }
+function restoreControls(name, recipe, dry) {
+  if (!name) return;
+  const d = document.getElementById("dry-" + name);
+  if (d && dry) d.checked = true;
+  const sel = document.getElementById("recipe-" + name);
+  if (sel && recipe) {
+    for (const o of sel.options) if (o.value === recipe) { sel.value = recipe; break; }
+  }
+}
 
 // --- jobs ---------------------------------------------------------------------
 // "Running" entries come from the docker labels (sm121.recipe / sm121.cluster)
@@ -338,17 +365,25 @@ function runningFromLabels() {
 function renderJobs() {
   const box = $("#jobs");
   box.innerHTML = "";
+  const prevShown = state.shown;
+  state.shown = new Set();
+  const row = (key, cls, html) => {
+    const el = document.createElement("div");
+    el.className = "job " + cls + (prevShown.has(key) ? "" : " new");
+    el.innerHTML = html;
+    box.appendChild(el);
+    state.shown.add(key);
+    return el;
+  };
   const run = runningFromLabels();
   if (run.length) {
     const h = document.createElement("div");
     h.className = "jobs-empty"; h.textContent = "running (docker labels)";
     box.appendChild(h);
     for (const r of run) {
-      const el = document.createElement("div");
-      el.className = "job jjob";
-      el.innerHTML = `<span class="jdot"></span><span class="jkind">serve</span>
-        <span class="jmsg">${esc(r.cluster)}:${esc(r.recipe)} — ${esc(r.name)} on ${esc(r.node)} · ${esc(r.status)}</span>`;
-      box.appendChild(el);
+      row("c:" + r.cluster + ":" + r.recipe + ":" + r.name, "jjob",
+        `<span class="jdot"></span><span class="jkind">serve</span>
+        <span class="jmsg">${esc(r.cluster)}:${esc(r.recipe)} — ${esc(r.name)} on ${esc(r.node)} · ${esc(r.status)}</span>`);
     }
   }
   const js = [...state.jobs.values()].sort((a, b) => b.started - a.started);
@@ -359,12 +394,10 @@ function renderJobs() {
     box.appendChild(sep);
   }
   for (const j of js) {
-    const el = document.createElement("div");
-    el.className = "job " + (j.status === "ok" ? "ok" : j.status === "failed" ? "failed" : "jjob");
+    const cls = j.status === "ok" ? "ok" : j.status === "failed" ? "failed" : "jjob";
     const when = new Date(j.started * 1000).toLocaleTimeString();
-    el.innerHTML = `<span class="jdot"></span><span class="jkind">${esc(j.kind)}</span>
-      <span class="jmsg">${esc(j.cluster)}${j.recipe ? ":" + esc(j.recipe) : ""} — ${esc(j.message)} <span>(${when})</span></span>`;
-    box.appendChild(el);
+    row("j:" + j.id, cls, `<span class="jdot"></span><span class="jkind">${esc(j.kind)}</span>
+      <span class="jmsg">${esc(j.cluster)}${j.recipe ? ":" + esc(j.recipe) : ""} — ${esc(j.message)} <span>(${when})</span></span>`);
     setBtnBusy(j, j.status === "running");
   }
 }