|
|
@@ -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,
|