Sfoglia il codice sorgente

report viewer: follow-log (SSE) — live tail of bench .log files

The report viewer's .log files get a '▶ follow' button (in the title bar,
right of the path). It opens an EventSource on /r/<recipe>/<file>?follow=1 —
an SSE stream that polls the file every 2s and emits appended-tail deltas,
so a running bench's log tail live in the browser (auto-scroll). The static
viewer is unchanged (no ?follow=1 → the plain file).

- serve.rs: report_file_or_stream — ?follow=1 + .log → SSE (futures unfold,
  2s poll, delta of the grown tail + keepalive comments); report_file now
  takes AppState directly.
- view.html: #followbtn in the header (flex, right of #path; #path gets
  flex:1;min-width:0 so it truncates instead of pushing the button off).
  appendFollow() appends deltas to the baseline and re-renders escaped.

40 report tests pass.
Alexander Korolev 1 settimana fa
parent
commit
9c02032032
2 ha cambiato i file con 97 aggiunte e 3 eliminazioni
  1. 56 2
      src/report/serve.rs
  2. 41 1
      src/report/view.html

+ 56 - 2
src/report/serve.rs

@@ -279,12 +279,66 @@ pub(crate) fn clean_log(content: &str) -> String {
         .join("\n")
 }
 
+/// `GET /r/<recipe>/<file>` with `?follow=1` — SSE stream of a `.log`
+/// report file, updated as the file grows (the bench-log follow). The first
+/// message carries the full cleaned content (the client's baseline); each
+/// subsequent message carries only the appended tail (the delta since the
+/// last poll). The client appends deltas to its baseline. The stream polls
+/// the file every 2 s and ends when the client disconnects. Non-`.log`
+/// files and a missing `?follow=1` fall through to the static `report_file`.
+async fn report_file_or_stream(
+    axum::extract::State(state): axum::extract::State<AppState>,
+    axum::extract::Path((recipe, file)): Path<(String, String)>,
+    axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
+) -> axum::response::Response {
+    use axum::response::IntoResponse;
+    let follow = params.get("follow").is_some_and(|s| s == "1");
+    if !follow || !file.ends_with(".log") {
+        return report_file(state, Path((recipe, file))).await;
+    }
+    let config = state.config.clone();
+    type SseRes = Result<SseEvent, std::convert::Infallible>;
+    let stream = futures::stream::unfold(
+        (config, recipe, file, 0usize),
+        |mut st: (Arc<Config>, String, String, usize)| {
+            async move {
+                let (cfg, recipe, file, last_len) = &mut st;
+                if let Some(content) = load_report(cfg, recipe, file) {
+                    let clean = crate::ansi::strip_ansi(content.as_bytes());
+                    let clean = String::from_utf8_lossy(&clean)
+                        .replace("\r\n", "\n")
+                        .replace('\r', "\n");
+                    let clean = clean_log(&clean);
+                    if clean.len() > *last_len {
+                        let delta = clean.split_at(*last_len).1.to_string();
+                        *last_len = clean.len();
+                        return Some((
+                            Ok(SseEvent::default().data(delta)) as SseRes,
+                            (cfg.clone(), recipe.clone(), file.clone(), *last_len),
+                        ));
+                    }
+                }
+                tokio::time::sleep(Duration::from_secs(2)).await;
+                Some((
+                    Ok(SseEvent::default().comment("keepalive")) as SseRes,
+                    (cfg.clone(), recipe.clone(), file.clone(), *last_len),
+                ))
+            }
+        },
+    );
+    Sse::new(stream)
+        .keep_alive(
+            axum::response::sse::KeepAlive::new().interval(Duration::from_secs(15)),
+        )
+        .into_response()
+}
+
 /// `GET /r/<recipe>/<file>` — file viewer page: rendered markdown (with a
 /// raw toggle), syntax-highlighted TOML/JSON, plain logs.
 pub(crate) const VIEW_TEMPLATE: &str = include_str!("view.html");
 
 async fn report_file(
-    axum::extract::State(state): axum::extract::State<AppState>,
+    state: AppState,
     axum::extract::Path((recipe, file)): Path<(String, String)>,
 ) -> Response {
     use axum::http::StatusCode;
@@ -351,7 +405,7 @@ pub(crate) fn router(
             }),
         )
         .route("/events", get(events_stream))
-        .route("/r/{recipe}/{file}", get(report_file))
+        .route("/r/{recipe}/{file}", get(report_file_or_stream))
         .route("/repo/{*path}", get(super::repo::repo_route))
         .with_state(AppState {
             config,

+ 41 - 1
src/report/view.html

@@ -9,7 +9,7 @@
   header { display: flex; gap: 10px; align-items: center; padding: 8px 16px;
            border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent); position: sticky; top: 0; background: canvas; z-index: 2; }
   header a { color: inherit; }
-  #path { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  #path { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
   #tabs { margin-left: auto; }
   /* copy floats over the text field's top-right corner: half-transparent,
      opaque under the cursor */
@@ -18,6 +18,13 @@
              border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
              background: canvas; color: inherit; border-radius: 4px; }
   #copybtn:hover { opacity: 1; }
+  /* follow-log toggle: in the title bar (header), right of the path; only
+     shown for .log files; while following, the button turns active */
+  #followbtn { padding: 2px 10px; cursor: pointer; flex-shrink: 0;
+               border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
+               background: canvas; color: inherit; border-radius: 4px; opacity: .55; }
+  #followbtn:hover { opacity: 1; }
+  #followbtn.on { opacity: 1; background: color-mix(in srgb, currentColor 18%, canvas); font-weight: 600; }
   /* per-block copy buttons in rendered markdown: hidden until the block
      is hovered; while a block copy is visible, the full-text button hides
      so the two never compete for the cursor */
@@ -57,6 +64,7 @@
 <body>
 <header>
   <span id="path"></span>
+  <button id="followbtn" title="follow the log (live)" style="display:none">▶ follow</button>
   <span id="tabs"></span>
 </header>
 <button id="copybtn" title="copy full text">⧉ copy</button>
@@ -238,6 +246,38 @@ function paint() {
   }
 }
 paint();
+
+// ---------- follow-log (SSE stream of a .log file, live) ----------
+if (EXT === "log") {
+  const fbtn = document.getElementById("followbtn");
+  fbtn.style.display = "";
+  let es = null;
+  let buf = META.content;          // the running log text (baseline + deltas)
+  let following = false;
+  function appendFollow(delta) {
+    buf += delta;
+    // re-render the raw log (the .log viewer is always raw)
+    document.getElementById("out").innerHTML = "<pre>" + esc(buf.replace(/\r\n?/g, "\n")) + "</pre>";
+    // auto-scroll to the bottom while following
+    const w = window.innerWidth, h = window.innerHeight;
+    if (document.documentElement.scrollHeight > h) window.scrollTo(0, document.documentElement.scrollHeight);
+  }
+  fbtn.onclick = () => {
+    if (following) {
+      if (es) { es.close(); es = null; }
+      following = false;
+      fbtn.textContent = "▶ follow"; fbtn.classList.remove("on");
+      return;
+    }
+    // open the SSE stream (the ?follow=1 endpoint streams deltas)
+    const url = "/r/" + encodeURIComponent(META.recipe) + "/" + encodeURIComponent(META.file) + "?follow=1";
+    es = new EventSource(url);
+    es.onmessage = (e) => { if (e.data) appendFollow(e.data); };
+    es.onerror = () => { /* the stream ends when the server drops it; stay put */ };
+    following = true;
+    fbtn.textContent = "■ stop"; fbtn.classList.add("on");
+  };
+}
 </script>
 </body>
 </html>