ソースを参照

web logs: ANSI SGR rendering in log windows — themed palette (8 colors/bold/dim/italic/underline, fg replaces fg), strip OSC + non-SGR CSI, carriage-return progress frames collapse to the last frame, debounced batched re-render, raw text kept for copy; node probe + playwright timing fix

Alexander Korolev 2 週間 前
親
コミット
9cf3deb380
3 ファイル変更、135 行追加、9 行削除
  1. 81 6
      src/web/ui.html
  2. 6 3
      src/web/ui.playwright.mjs
  3. 48 0
      src/web/ui_js_tests.rs

+ 81 - 6
src/web/ui.html

@@ -13,6 +13,7 @@
     --led-on: #3fb950; --led-off: #f85149; --led-busy: #d29922; --led-free: #30363d;
     --glow-on: 0 0 8px 2px rgba(63,185,80,.55); --glow-busy: 0 0 8px 2px rgba(210,153,34,.55);
     --btn: #21262d; --btn-hover: #30363d;
+    --a-red: #f85149; --a-green: #3fb950; --a-yellow: #d29922; --a-blue: #58a6ff; --a-magenta: #bc8cff; --a-cyan: #39c5cf; --a-gray: #8b949e;
   }
   :root[data-theme="light"] {
     --bg: #f6f8fa; --panel: #ffffff; --fg: #1f2328; --muted: #656d76;
@@ -21,6 +22,7 @@
     --led-on: #1a7f37; --led-off: #cf222e; --led-busy: #9a6700; --led-free: #8c959f;
     --glow-on: 0 0 8px 2px rgba(26,127,55,.35); --glow-busy: 0 0 8px 2px rgba(154,103,0,.35);
     --btn: #f6f8fa; --btn-hover: #eaeef2;
+    --a-red: #cf222e; --a-green: #1a7f37; --a-yellow: #9a6700; --a-blue: #0969da; --a-magenta: #8250df; --a-cyan: #1b7c83; --a-gray: #656d76;
   }
   * { box-sizing: border-box; }
   html, body { height: 100%; }
@@ -200,6 +202,19 @@
     font: 11.5px/1.5 var(--mono); white-space: pre-wrap;
     word-break: break-word; color: var(--fg);
   }
+  /* ANSI SGR rendering: theme-aware palette for the container logs */
+  .logpre .a31, .logpre .a91 { color: var(--a-red); }
+  .logpre .a32, .logpre .a92 { color: var(--a-green); }
+  .logpre .a33, .logpre .a93 { color: var(--a-yellow); }
+  .logpre .a34, .logpre .a94 { color: var(--a-blue); }
+  .logpre .a35, .logpre .a95 { color: var(--a-magenta); }
+  .logpre .a36, .logpre .a96 { color: var(--a-cyan); }
+  .logpre .a30, .logpre .a90 { color: var(--a-gray); }
+  .logpre .a37, .logpre .a97 { color: var(--fg); }
+  .logpre .b { font-weight: 600; }
+  .logpre .u { text-decoration: underline; }
+  .logpre .it { font-style: italic; }
+  .logpre .dim2 { color: var(--muted); }
 
   /* --- raw content windows --------------------------------------------------- */
   .rawwin .win-body { display: flex; flex-direction: column; padding: 0; }
@@ -1637,13 +1652,73 @@ function trimPre(pre) {
     pre.textContent = t.split("\n").slice(-MAX_PRE_LINES).join("\n");
   }
 }
+/*ANSI-LT:START*/
+// ANSI SGR → themed spans. Everything else (cursor movement, OSC titles)
+// is stripped; HTML in the log stays escaped because the text is escaped
+// first and only our own spans are added.
+function ansiToHtml(text) {
+  text = String(text)
+    .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|\x1b\x07)/g, "") // OSC (titles etc.)
+    .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, (m) => (m.endsWith("m") ? m : "")) // non-SGR CSI
+    .replace(/\x1b\[[0-9;?]*$/, ""); // unterminated sequence at the tail
+  const drop = (cls, rm) => cls.filter((c) => c !== rm);
+  let out = "", cls = [], last = 0;
+  const open = () => (cls.length ? `<span class="${cls.join(" ")}">` : "");
+  const re = /\x1b\[([0-9;]*)m/g;
+  let m;
+  while ((m = re.exec(text))) {
+    out += open() + esc(text.slice(last, m.index)) + (cls.length ? "</span>" : "");
+    for (const part of (m[1] || "0").split(";")) {
+      const n = +part || 0;
+      if (n === 0) cls = [];
+      else if (n === 1) cls.push("b");
+      else if (n === 2) cls.push("dim2");
+      else if (n === 3) cls.push("it");
+      else if (n === 4) cls.push("u");
+      else if (n === 22) cls = drop(drop(cls, "b"), "dim2");
+      else if (n === 23) cls = drop(cls, "it");
+      else if (n === 24) cls = drop(cls, "u");
+      else if (n === 39) cls = cls.filter((c) => !/^a(3[0-7]|9[0-7])$/.test(c));
+      else if ((n >= 30 && n <= 37) || (n >= 90 && n <= 97))
+        cls = cls.filter((c) => !/^a(3[0-7]|9[0-7])$/.test(c)).concat("a" + n); // fg replaces fg
+      // 40-47/100-107 bg and 38/48 extended colors: rare in logs, dropped
+    }
+    last = m.index + m[0].length;
+  }
+  out += open() + esc(text.slice(last));
+  if (cls.length) out += "</span>";
+  return out;
+}
+/*ANSI-LT:END*/
+
+// carriage returns carry progress-bar frames — the last frame wins, so the
+// log keeps the final bar state instead of a wall of rewrites
+const lastCRFrame = (line) => line.split("\r").pop();
+
 function appendLogLine(w, line) {
-  if (w.paused) { w.buf += line + "\n"; return; }
-  preSet(w, w.pre.textContent + line + "\n");
+  if (w.paused) { w.buf += lastCRFrame(line) + "\n"; return; }
+  w.raw += lastCRFrame(line) + "\n";
+  trimRaw(w);
+  scheduleRender(w);
 }
 function preSet(w, text) {
-  w.pre.textContent = text;
-  trimPre(w.pre);
+  w.raw = text.split("\n").map(lastCRFrame).join("\n");
+  trimRaw(w);
+  renderLog(w);
+}
+// the raw text is authoritative (the copy button uses the rendered text);
+// cap it like the old DOM trim did
+function trimRaw(w) {
+  const lines = w.raw.split("\n");
+  if (lines.length > MAX_PRE_LINES + 1) w.raw = lines.slice(-MAX_PRE_LINES).join("\n");
+}
+let renderTimer = null;
+function scheduleRender(w) {
+  if (w.renderTimer) return;
+  w.renderTimer = setTimeout(() => { w.renderTimer = null; renderLog(w); }, 120);
+}
+function renderLog(w) {
+  w.pre.innerHTML = ansiToHtml(w.raw);
   if (!w.paused) w.pre.scrollTop = w.pre.scrollHeight;
 }
 function onLog(ev) {
@@ -1686,7 +1761,7 @@ async function openLog(cluster, node, recipe) {
       if (w) detachLog(w[0]);
     },
   });
-  const w = { cluster, node, recipe, rank: n.rank, win: win.win, pre: win.body.querySelector(".logpre"), buf: "", paused: false };
+  const w = { cluster, node, recipe, rank: n.rank, win: win.win, pre: win.body.querySelector(".logpre"), raw: "", buf: "", paused: false, renderTimer: null };
   win.body.querySelector(".logcopy").onclick = async () => {
     try { await navigator.clipboard.writeText(w.pre.textContent); } catch {}
   };
@@ -1694,7 +1769,7 @@ async function openLog(cluster, node, recipe) {
   const pauseCb = win.body.querySelector(".logpause");
   pauseCb.onchange = () => {
     w.paused = pauseCb.checked;
-    if (!w.paused && w.buf) { preSet(w, w.pre.textContent + w.buf); w.buf = ""; }
+    if (!w.paused && w.buf) { w.raw += w.buf; w.buf = ""; trimRaw(w); renderLog(w); }
   };
   const sel = win.body.querySelector("select");
   for (const x of n.containers) {

+ 6 - 3
src/web/ui.playwright.mjs

@@ -197,10 +197,13 @@ await page.evaluate(() => openLog("fake", "n0", "deepseek-v4-flash"));
 await page.waitForSelector(".logwin", { timeout: 10000 });
 ok("log window spawned (attach l1)", (await page.locator(".logwin").count()) === 1);
 await page.evaluate(() => onLog({ type: "log", attach: "l1", line: "hello-from-ui-test" }));
-ok(
-  "SSE log line lands in the window",
-  (await page.locator(".logwin .logpre").textContent()).includes("hello-from-ui-test"),
+// the log render is debounced (batched) — wait for the flush
+await page.waitForFunction(
+  () => document.querySelector(".logwin .logpre").textContent.includes("hello-from-ui-test"),
+  null,
+  { timeout: 5000 },
 );
+ok("SSE log line lands in the window", true);
 await page.click(".logwin .logpause");
 await page.evaluate(() => onLog({ type: "log", attach: "l1", line: "while-paused" }));
 ok(

+ 48 - 0
src/web/ui_js_tests.rs

@@ -190,6 +190,54 @@ console.log("ok");
     let _ = std::fs::remove_file(&js);
 }
 
+/// ansiToHtml (ANSI-LT block): SGR codes become themed spans, everything
+/// else is stripped, HTML stays escaped.
+#[test]
+fn ui_js_ansi_log_rendering() {
+    let html = include_str!("ui.html");
+    if std::process::Command::new("node")
+        .arg("--version")
+        .output()
+        .is_err()
+    {
+        eprintln!("SKIP ui_js_ansi_log_rendering: node not available");
+        return;
+    }
+    let script = extract_script(html);
+    let s = script.find("/*ANSI-LT:START*/").unwrap() + "/*ANSI-LT:START*/".len();
+    let e = script.find("/*ANSI-LT:END*/").unwrap();
+    let body = r#"
+const esc = (t) => String(t).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
+const eq = (got, want, what) => { if (got !== want) throw new Error(what + ": " + JSON.stringify(got) + " != " + JSON.stringify(want)); };
+const E = "\u001b";
+eq(ansiToHtml(`${E}[32mok${E}[0m`), '<span class="a32">ok</span>', "green");
+eq(ansiToHtml(`${E}[1;32mboth${E}[0m`), '<span class="b a32">both</span>', "bold+color");
+eq(ansiToHtml(`${E}[32mred${E}[0m plain`), '<span class="a32">red</span> plain', "reset");
+eq(ansiToHtml(`${E}[32ma${E}[33mb`), '<span class="a32">a</span><span class="a33">b</span>', "two colors");
+eq(ansiToHtml("<script>alert(1)</script>"), "&lt;script&gt;alert(1)&lt;/script&gt;", "html escaped");
+eq(ansiToHtml(`${E}]0;title${E}\x07text`), "text", "OSC stripped");
+eq(ansiToHtml(`${E}[2Kerase${E}[1Aup`), "eraseup", "non-SGR CSI stripped");
+eq(ansiToHtml(`${E}[32m`), '<span class="a32"></span>', "unterminated ok");
+eq(ansiToHtml("plain"), "plain", "no ansi");
+eq(ansiToHtml(`${E}[2mdim${E}[22m${E}[4mun${E}[24mx`), '<span class="dim2">dim</span><span class="u">un</span>x', "on/off attrs");
+console.log("ok");
+"#;
+    let probe = format!("{}\n{}", &script[s..e], body);
+    let pid = std::process::id();
+    let js = std::env::temp_dir().join(format!("sm121-ui-ansi-{pid}.js"));
+    std::fs::write(&js, probe).unwrap();
+    let out = std::process::Command::new("node")
+        .arg(&js)
+        .output()
+        .unwrap();
+    assert!(
+        out.status.success(),
+        "ansiToHtml probe failed: {}",
+        String::from_utf8_lossy(&out.stderr)
+    );
+    let _ = std::fs::remove_file(&js);
+}
+
 /// serveBody (FLAGS-LT block): the serve flags → API body mapping is the
 /// trickiest pure logic in the UI — probe it in node like sameSnapshot.
 #[test]