|
@@ -1,7 +1,10 @@
|
|
|
//! `sm121 report` — one table of all tested recipes and their benchmark
|
|
//! `sm121 report` — one table of all tested recipes and their benchmark
|
|
|
//! history. Default renders a Markdown table to stdout; `--serve` (implied
|
|
//! history. Default renders a Markdown table to stdout; `--serve` (implied
|
|
|
//! by `--open`) serves an interactive HTML version (tree grouping, column
|
|
//! by `--open`) serves an interactive HTML version (tree grouping, column
|
|
|
-//! visibility, sorting, search, pagination) on a local port.
|
|
|
|
|
|
|
+//! visibility, sorting, search, pagination) on a local port, with live
|
|
|
|
|
+//! updates: a background refresher re-scans the `reports/` dirs every 5 s
|
|
|
|
|
+//! and an SSE endpoint (`/events`) pushes the rows JSON on change, so a
|
|
|
|
|
+//! finishing bench appears in the page without a reload.
|
|
|
//!
|
|
//!
|
|
|
//! Data sources are read-only and local: the recipe dirs from
|
|
//! Data sources are read-only and local: the recipe dirs from
|
|
|
//! [`crate::config::recipe_dirs`] (each `<name>/recipe.toml`) plus each
|
|
//! [`crate::config::recipe_dirs`] (each `<name>/recipe.toml`) plus each
|
|
@@ -15,6 +18,7 @@ use std::collections::HashSet;
|
|
|
use std::path::Path;
|
|
use std::path::Path;
|
|
|
|
|
|
|
|
pub(crate) mod parse;
|
|
pub(crate) mod parse;
|
|
|
|
|
+pub(crate) mod serve;
|
|
|
pub(crate) use parse::{PerfCell, parse_bench_json, perf_from};
|
|
pub(crate) use parse::{PerfCell, parse_bench_json, perf_from};
|
|
|
|
|
|
|
|
const PAGE_TEMPLATE: &str = include_str!("page.html");
|
|
const PAGE_TEMPLATE: &str = include_str!("page.html");
|
|
@@ -393,56 +397,6 @@ fn t(key: &str) -> String {
|
|
|
fluent_i18n::t!(key)
|
|
fluent_i18n::t!(key)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/// Load a report file for the `/r/<recipe>/<file>` route. Both path parts
|
|
|
|
|
-/// must be plain names (no traversal) — the route is loopback-only, but the
|
|
|
|
|
-/// resolve walks every recipe dir, so stay strict.
|
|
|
|
|
-fn load_report(config: &Config, recipe: &str, file: &str) -> Option<String> {
|
|
|
|
|
- let plain = |s: &str| {
|
|
|
|
|
- !s.contains("..")
|
|
|
|
|
- && s.chars()
|
|
|
|
|
- .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|
|
|
|
|
- };
|
|
|
|
|
- if !plain(recipe) || !plain(file) {
|
|
|
|
|
- return None;
|
|
|
|
|
- }
|
|
|
|
|
- crate::config::recipe_dirs(config)
|
|
|
|
|
- .into_iter()
|
|
|
|
|
- .find_map(|dir| {
|
|
|
|
|
- let p = dir.join(recipe).join("reports").join(file);
|
|
|
|
|
- p.is_file()
|
|
|
|
|
- .then(|| std::fs::read_to_string(p).ok())
|
|
|
|
|
- .flatten()
|
|
|
|
|
- })
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/// `GET /r/<recipe>/<file>` — full report text in a minimal pre page.
|
|
|
|
|
-async fn report_file(
|
|
|
|
|
- axum::extract::State(state): axum::extract::State<std::sync::Arc<Config>>,
|
|
|
|
|
- axum::extract::Path((recipe, file)): axum::extract::Path<(String, String)>,
|
|
|
|
|
-) -> axum::response::Response {
|
|
|
|
|
- use axum::http::StatusCode;
|
|
|
|
|
- use axum::response::IntoResponse;
|
|
|
|
|
- match load_report(&state, &recipe, &file) {
|
|
|
|
|
- Some(text) => axum::response::Html(format!(
|
|
|
|
|
- "<!doctype html><meta charset=\"utf-8\"><title>{recipe} — {file}</title>\
|
|
|
|
|
- <pre style=\"font:12px/1.45 ui-monospace,monospace;white-space:pre-wrap;\
|
|
|
|
|
- word-break:break-word;margin:16px;\">{}</pre>",
|
|
|
|
|
- text.replace('&', "&")
|
|
|
|
|
- .replace('<', "<")
|
|
|
|
|
- .replace('>', ">")
|
|
|
|
|
- ))
|
|
|
|
|
- .into_response(),
|
|
|
|
|
- None => (
|
|
|
|
|
- StatusCode::NOT_FOUND,
|
|
|
|
|
- axum::response::Html(
|
|
|
|
|
- "<!doctype html><meta charset=\"utf-8\"><title>404</title>report not found"
|
|
|
|
|
- .to_string(),
|
|
|
|
|
- ),
|
|
|
|
|
- )
|
|
|
|
|
- .into_response(),
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
/// The interactive page: template with `{{DATA}}` replaced by the JSON
|
|
/// The interactive page: template with `{{DATA}}` replaced by the JSON
|
|
|
/// rows (`<` escaped so `</script>` in data can't break out).
|
|
/// rows (`<` escaped so `</script>` in data can't break out).
|
|
|
fn html_page(rows: &[ReportRow]) -> String {
|
|
fn html_page(rows: &[ReportRow]) -> String {
|
|
@@ -452,6 +406,8 @@ fn html_page(rows: &[ReportRow]) -> String {
|
|
|
|
|
|
|
|
/// Entry point: Markdown to stdout, or the interactive page on
|
|
/// Entry point: Markdown to stdout, or the interactive page on
|
|
|
/// `http://127.0.0.1:<port>/` (`--open` also spawns the browser).
|
|
/// `http://127.0.0.1:<port>/` (`--open` also spawns the browser).
|
|
|
|
|
+/// The page is live: `/events` (SSE) pushes the rows JSON whenever the
|
|
|
|
|
+/// refresher detects a change in the `reports/` dirs.
|
|
|
pub async fn run(config: &Config, serve: bool, port: u16, open: bool) -> Result<()> {
|
|
pub async fn run(config: &Config, serve: bool, port: u16, open: bool) -> Result<()> {
|
|
|
let rows = collect(config);
|
|
let rows = collect(config);
|
|
|
if !serve {
|
|
if !serve {
|
|
@@ -459,18 +415,15 @@ pub async fn run(config: &Config, serve: bool, port: u16, open: bool) -> Result<
|
|
|
return Ok(());
|
|
return Ok(());
|
|
|
}
|
|
}
|
|
|
let page = html_page(&rows);
|
|
let page = html_page(&rows);
|
|
|
|
|
+ let initial = serde_json::to_string(&rows).unwrap_or_default();
|
|
|
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
|
|
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
|
|
|
let addr = listener.local_addr()?;
|
|
let addr = listener.local_addr()?;
|
|
|
- let app = axum::Router::new()
|
|
|
|
|
- .route(
|
|
|
|
|
- "/",
|
|
|
|
|
- axum::routing::get(move || {
|
|
|
|
|
- let page = page.clone();
|
|
|
|
|
- async move { axum::response::Html(page) }
|
|
|
|
|
- }),
|
|
|
|
|
- )
|
|
|
|
|
- .route("/r/{recipe}/{file}", axum::routing::get(report_file))
|
|
|
|
|
- .with_state(std::sync::Arc::new(config.clone()));
|
|
|
|
|
|
|
+ let app = serve::router(
|
|
|
|
|
+ std::sync::Arc::new(config.clone()),
|
|
|
|
|
+ page,
|
|
|
|
|
+ initial,
|
|
|
|
|
+ serve::REFRESH_TICK,
|
|
|
|
|
+ );
|
|
|
if open {
|
|
if open {
|
|
|
let url = format!("http://{addr}/");
|
|
let url = format!("http://{addr}/");
|
|
|
tokio::spawn(async move {
|
|
tokio::spawn(async move {
|