Kaynağa Gözat

report viewer: old pty-captured bench logs render like clean ones — collapse rich spinner redraw runs to the last frame per phase (wordish-token progress key, bar-tip glyphs vary), drop tty caret-notation (^D/^H) and backspace artifacts, dedup consecutive lines; copy button in the viewer header (top right, one click, clipboard API + fallback)

Alexander Korolev 1 ay önce
ebeveyn
işleme
b34eb65752
3 değiştirilmiş dosya ile 172 ekleme ve 0 silme
  1. 97 0
      src/report/serve.rs
  2. 54 0
      src/report/ui_tests.rs
  3. 21 0
      src/report/view.html

+ 97 - 0
src/report/serve.rs

@@ -152,6 +152,96 @@ pub(crate) fn load_report(config: &Config, recipe: &str, file: &str) -> Option<S
         })
 }
 
+/// Braille spinner frame or progress bar — llama-benchy's rich in-place
+/// progress redraws.
+fn is_spinner_frame(line: &str) -> bool {
+    let t = line.trim_start();
+    t.starts_with(['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']) || t.contains('━')
+}
+
+/// Progress-redraw identity: the spinner frames differ only by the glyph,
+/// the elapsed clock and the run/progress counters — same key = same phase
+/// redrawn in place.
+fn progress_key(line: &str) -> String {
+    line.split_whitespace()
+        .filter(|t| {
+            // keep only wordish tokens (the test name): bars — the tip glyph
+            // varies as the bar advances (━╸╺…) — spinner glyphs, @ signs and
+            // other graphics carry no alphanumerics; counters (0/18), elapsed
+            // clocks (0:01:48) and the "run N" pair are progress churn.
+            t.chars().any(|c| c.is_alphanumeric())
+                && !t.chars().all(|c| c.is_ascii_digit() || c == '/')
+                && !t.contains(':')
+                && *t != "run"
+        })
+        .collect::<Vec<_>>()
+        .join(" ")
+}
+
+/// Make old pty-captured bench logs read like the new ones: drop pty
+/// control-char artifacts (`^D`/backspace echoes), collapse each spinner
+/// phase's redraw run down to its last frame, drop consecutive duplicate
+/// lines. Idempotent on logs that are already clean.
+pub(crate) fn clean_log(content: &str) -> String {
+    let lines: Vec<String> = content
+        .lines()
+        .map(|l| {
+            let l: String = l
+                .chars()
+                .filter(|c| !matches!(c, '\u{4}' | '\u{8}'))
+                .collect();
+            // the pty echoes control chars as caret notation (^D, ^H) —
+            // printable '^' + @.._ pairs at the line start
+            let mut l = l.as_str();
+            while l.starts_with('^')
+                && l[1..]
+                    .chars()
+                    .next()
+                    .is_some_and(|c| ('@'..='_').contains(&c))
+            {
+                l = &l[2..];
+            }
+            l.to_string()
+        })
+        .collect();
+    let mut keep = vec![true; lines.len()];
+    let mut i = 0;
+    while i < lines.len() {
+        let prog = is_spinner_frame(&lines[i]);
+        let key = if prog {
+            progress_key(&lines[i])
+        } else {
+            String::new()
+        };
+        let mut j = i + 1;
+        while j < lines.len() {
+            let same = if prog {
+                is_spinner_frame(&lines[j]) && progress_key(&lines[j]) == key
+            } else {
+                !lines[i].is_empty() && lines[j] == lines[i]
+            };
+            if !same {
+                break;
+            }
+            keep[j] = false;
+            j += 1;
+        }
+        if j > i + 1 && prog {
+            // a redraw run keeps only its final frame (exact dups keep the first)
+            keep[i] = false;
+            keep[j - 1] = true;
+        }
+        i = j;
+    }
+    lines
+        .iter()
+        .enumerate()
+        .filter(|(k, _)| keep[*k])
+        .map(|(_, l)| l.as_str())
+        .collect::<Vec<_>>()
+        .join("\n")
+}
+
 /// `GET /r/<recipe>/<file>` — file viewer page: rendered markdown (with a
 /// raw toggle), syntax-highlighted TOML/JSON, plain logs.
 const VIEW_TEMPLATE: &str = include_str!("view.html");
@@ -172,6 +262,13 @@ async fn report_file(
             let clean = String::from_utf8_lossy(&clean)
                 .replace("\r\n", "\n")
                 .replace('\r', "\n");
+            // old pty-captured logs carry spinner-redraw soup and pty
+            // control-char artifacts — render them like the clean ones
+            let clean = if file.ends_with(".log") {
+                clean_log(&clean)
+            } else {
+                clean
+            };
             let meta = serde_json::json!({ "recipe": recipe, "file": file, "content": clean });
             let meta = serde_json::to_string(&meta)
                 .unwrap_or_default()

+ 54 - 0
src/report/ui_tests.rs

@@ -186,3 +186,57 @@ fn html_page_embeds_data_escaped() {
     let evil = vec![row("x", "a</script>b", "NVFP4", None)];
     assert!(!html_page(&evil).contains("</script>b"));
 }
+
+// ----- log rendering (serve.rs clean_log), moved at the 500-line cap -----
+
+#[test]
+fn clean_log_collapses_spinner_soup_and_dups() {
+    use super::serve::clean_log;
+    // one llama-benchy phase = many rich redraw frames (spinner glyph,
+    // elapsed clock and run counter differ, the test name does not); old
+    // pty-captured logs keep every frame as its own line, plus pty ^D/⌫
+    // artifacts and duplicated summary lines
+    let soup = "^D\u{8}\u{8}{\"event\": \"backend_detected\"}\n\n\
+\u{280b} Initializing\u{2026} \u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}  0/18 0:00:00\n\
+\u{2819} Initializing\u{2026} \u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}  0/18 0:00:00\n\
+\u{2839} pp2048 tg128 @ d0 c1 run 0 \u{2501}\u{2501}\u{2501}\u{2501}\u{257a}\u{2501}  1/18 0:00:02\n\
+\u{2838} pp2048 tg128 @ d0 c1 run 0 \u{2501}\u{2501}\u{2501}\u{2501}\u{257a}\u{2501}  1/18 0:00:02\n\
+\u{283c} pp2048 tg128 @ d0 c1 run 1 \u{2501}\u{2501}\u{2501}\u{2501}\u{257a}\u{2501}  2/18 0:00:05\n\
+  \u{2713} Complete \u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501} 18/18 0:01:48\n\
+  \u{2713} Complete \u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501} 18/18 0:01:48\n\
+\u{2502} pp2048 tg128 @ d0    \u{2502} c1 \u{2502}   1,230 \u{2502}    59.2 \u{2502}    2,630 \u{2502}    4,641 \u{2502} 2048+128\n";
+    let out = clean_log(soup);
+    let lines: Vec<&str> = out.lines().collect();
+    // pty artifact gone; each spinner phase keeps its LAST frame; one
+    // ✓ Complete; table row intact
+    assert_eq!(
+        lines.len(),
+        6,
+        "event, blank, init, run, check, table: {}",
+        out
+    );
+    assert!(lines[0].starts_with("{\"event\""), "no ^D: {}", lines[0]);
+    assert!(
+        lines[2].starts_with("\u{2819} Initializing"),
+        "init last frame: {}",
+        out
+    );
+    assert!(
+        lines[3].starts_with("\u{283c} pp2048"),
+        "run-0 frames dropped: {}",
+        out
+    );
+    assert!(lines[3].contains("run 1"), "keeps the LAST frame: {}", out);
+    assert!(
+        lines[4].trim().starts_with("\u{2713} Complete"),
+        "one check: {}",
+        out
+    );
+    assert!(
+        lines[5].starts_with("\u{2502} pp2048"),
+        "table row intact: {}",
+        out
+    );
+    // idempotent on already-clean logs
+    assert_eq!(clean_log(&out), out);
+}

+ 21 - 0
src/report/view.html

@@ -11,6 +11,9 @@
   header a { color: inherit; }
   #path { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
   #tabs { margin-left: auto; }
+  #copybtn { cursor: pointer; padding: 2px 10px; border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
+             background: canvas; color: inherit; border-radius: 4px; }
+  #copybtn:active { background: color-mix(in srgb, currentColor 15%, canvas); }
   #tabs button { padding: 2px 10px; cursor: pointer; border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
                  background: canvas; color: inherit; border-radius: 4px 0 0 4px; }
   #tabs button + button { border-radius: 0 4px 4px 0; border-left: 0; }
@@ -37,6 +40,7 @@
 <header>
   <span id="path"></span>
   <span id="tabs"></span>
+  <button id="copybtn" title="copy full text">⧉ copy</button>
 </header>
 <div id="out"></div>
 <script>
@@ -155,6 +159,23 @@ function hlJson(src) {
     .replace(/(: )(true|false|null)/g, '$1<span class="kw">$2</span>');
 }
 
+// ---------- copy the full text, one click (clipboard API + fallback) ----------
+function fallbackCopy(text) {
+  const ta = document.createElement("textarea");
+  ta.value = text; document.body.appendChild(ta); ta.select();
+  let ok = false; try { ok = document.execCommand("copy"); } catch {}
+  ta.remove(); return ok;
+}
+const cpBtn = document.getElementById("copybtn");
+cpBtn.onclick = () => {
+  const done = ok => {
+    cpBtn.textContent = ok ? "✓ copied" : "✗ failed";
+    setTimeout(() => (cpBtn.textContent = "⧉ copy"), 900);
+  };
+  if (navigator.clipboard) navigator.clipboard.writeText(META.content).then(() => done(true), () => done(fallbackCopy(META.content)));
+  else done(fallbackCopy(META.content));
+};
+
 // ---------- render ----------
 let mode = EXT === "md" ? "rendered" : "raw";
 const HAS_TABS = EXT === "md";