Skip to content

Commit 3f1727b

Browse files
connortsui20claude
andcommitted
[claude] benchmarks-website-v3: web UI (landing + chart pages) (#7641)
## Summary Implements the alpha web UI for `bench.vortex.dev` v3 per [`benchmarks-website/planning/components/web-ui.md`](../tree/claude/vortex-benchmarks-ui-v3-QxRCK/benchmarks-website/planning/components/web-ui.md). Replaces the placeholder `html.rs` router introduced in #7637 with two real pages backed by Maud templates and a vendored Chart.js bundle. - `GET /` — landing page that lists every group + chart link from `/api/groups`, rendered via `maud`. - `GET /chart/{slug}` — single Chart.js line chart. Payload is fetched server-side via the same `api::collect_chart` helper used by `/api/chart/:slug`, then embedded inline as a JSON `<script id="chart-data">` block. No client-side round-trip after page load. - `GET /static/...` — vendored `chart.umd.js` (Chart.js 4.4.4, MIT), `chart-init.js`, and `style.css`. All bundled into the binary via `include_bytes!`. Slugs are treated as opaque per [`02-contracts.md`](../tree/claude/vortex-benchmarks-ui-v3-QxRCK/benchmarks-website/planning/02-contracts.md): the chart handler echoes whatever `/api/groups` returned straight into `ChartKey::from_slug` without parsing or constructing them itself. `api::collect_groups` and `api::collect_chart` are now `pub(crate)` so the HTML handlers reuse the same row collectors that back the JSON read routes — no second SQL implementation. The chart-init script and the embedded JSON payload between them satisfy the "no network round-trip after page load" criterion. Inside the JSON `<script>` block, `</`, `<!--`, and `<script` are escaped via JSON-safe string escapes so that benign payload contents can never break out of the script element. ## Tests `tests/web_ui.rs` (new, 6 tests): - `landing_page_snapshot` — `insta` snapshot of `GET /` after seeding three envelopes with distinct `commit.sha` / `commit.timestamp` values. - `chart_page_snapshot` — `insta` snapshot of the rendered tpch-Q1 chart page; exercises multi-series rendering (`datafusion:vortex-file-compressed` + `duckdb:parquet`) and verifies both the inline `<script id="chart-data">` block and the `/static/chart.umd.js` reference. - `chart_page_round_trips_every_slug` — every slug returned by `/api/groups` resolves to a 200 chart page with inline data. - `unknown_slug_renders_404` — bogus slug → 404 HTML page. - `empty_landing_page_renders` — empty DB → "No data ingested yet." - `static_assets_are_served` — content-type checks for the three `/static/*` files. Pre-existing `tests/ingest.rs` still passes (10 tests). ## Stack inheritance Inherits the version pins set by #7637 in `benchmarks-website/server/Cargo.toml`. The only Cargo change is `insta = { workspace = true }` under `[dev-dependencies]`. ## Verified locally - `cargo build -p vortex-bench-server` - `cargo test -p vortex-bench-server` — 10 ingest + 6 web-ui tests pass. - `cargo +nightly fmt -p vortex-bench-server -- --check` — clean. - `cargo clippy -p vortex-bench-server --all-targets` — clean. - End-to-end smoke test against a running server: `INGEST_BEARER_TOKEN=test` + `cargo run`, POST two envelopes with different commit shas, verified `/`, `/chart/{slug}`, the three `/static/*` routes, and the invalid-slug 404 path with `curl`. ## Test plan - [ ] Reviewer runs `cargo test -p vortex-bench-server` locally. - [ ] Reviewer starts the server (`INGEST_BEARER_TOKEN=test cargo run -p vortex-bench-server`), POSTs `benchmarks-website/server/fixtures/envelope.json`, and visits `http://127.0.0.1:3000/` in a real browser to confirm the chart hydrates (this PR was developed in a headless sandbox so visual verification was not possible here). - [ ] CI green. ## Out of scope (deferred per `web-ui.md` + `deferred.md`) Per-commit page, filter UI, full-screen modal, deep links, LTTB downsampling, lookup-table-driven engine names / colours, chartjs-plugin-zoom, ratio rendering on compression-size charts, and geomean summary cards are explicitly deferred and not touched here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01UjgnLq5MCmcpyv6PXC5oLv)_ --------- Signed-off-by: Claude <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Connor Tsui <connor.tsui20@gmail.com>
1 parent e849742 commit 3f1727b

12 files changed

Lines changed: 885 additions & 21 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

_typos.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ extend-ignore-re = [
88
]
99

1010
[files]
11-
extend-exclude = ["/vortex-bench/**", "/docs/references.bib", "benchmarks/**", "vortex-sqllogictest/slt/**", "encodings/fsst/src/dfa/tests.rs", "encodings/fsst/src/dfa/flat_contains.rs"]
11+
extend-exclude = ["/vortex-bench/**", "/docs/references.bib", "benchmarks/**", "vortex-sqllogictest/slt/**", "encodings/fsst/src/dfa/tests.rs", "encodings/fsst/src/dfa/flat_contains.rs", "benchmarks-website/server/static/**"]
1212

1313
[type.py]
1414
extend-ignore-identifiers-re = [

benchmarks-website/server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
4040
twox-hash = "2.1"
4141

4242
[dev-dependencies]
43+
insta = { workspace = true }
4344
reqwest = { workspace = true, features = ["json"] }
4445
tempfile = { workspace = true }
4546
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net"] }

benchmarks-website/server/src/api.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,9 @@ fn collect_health(conn: &Connection, db_path: String) -> Result<HealthResponse>
131131
})
132132
}
133133

134-
fn collect_groups(conn: &Connection) -> Result<Vec<Group>> {
134+
/// Collect every group + chart link derivable from the data. Used by both
135+
/// `GET /api/groups` and the HTML landing page.
136+
pub(crate) fn collect_groups(conn: &Connection) -> Result<Vec<Group>> {
135137
let mut groups: Vec<Group> = Vec::new();
136138

137139
let qm_groups = collect_query_groups(conn).context("collect_query_groups")?;
@@ -391,7 +393,9 @@ fn collect_vector_search_groups(conn: &Connection) -> Result<Vec<Group>> {
391393
Ok(groups)
392394
}
393395

394-
fn collect_chart(conn: &Connection, key: &ChartKey) -> Result<Option<ChartResponse>> {
396+
/// Collect the data for one chart by key. Used by both `GET /api/chart/:slug`
397+
/// and the HTML chart page.
398+
pub(crate) fn collect_chart(conn: &Connection, key: &ChartKey) -> Result<Option<ChartResponse>> {
395399
match key {
396400
ChartKey::QueryMeasurement {
397401
dataset,
Lines changed: 231 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,260 @@
11
// SPDX-License-Identifier: Apache-2.0
22
// SPDX-FileCopyrightText: Copyright the Vortex contributors
33

4-
//! HTML routes.
4+
//! HTML routes for the bench.vortex.dev v3 alpha web UI.
55
//!
6-
//! The web-ui component owns the actual landing-page and chart-page templates.
7-
//! At alpha this module exposes a single placeholder route so the server can
8-
//! be exercised end-to-end before web-ui lands; the web-ui PR replaces
9-
//! [`router`] with the real Maud templates.
6+
//! Two pages:
7+
//! - `GET /` — landing page listing every group + chart derived from the
8+
//! current data.
9+
//! - `GET /chart/{slug}` — single Chart.js line chart, payload fetched
10+
//! server-side and embedded inline as a JSON `<script>` block so there is
11+
//! no client-side round-trip after page load.
12+
//!
13+
//! Slugs are opaque strings the server received from `/api/groups`; the
14+
//! handler echoes them straight into [`crate::slug::ChartKey::from_slug`]
15+
//! without parsing.
16+
//!
17+
//! Static assets (Chart.js + CSS + the small hydration script) are served
18+
//! from `/static/...` via [`include_bytes!`] so the binary is fully
19+
//! self-contained.
1020
1121
use axum::Router;
22+
use axum::extract::Path;
23+
use axum::extract::State;
24+
use axum::http::StatusCode;
25+
use axum::http::header;
26+
use axum::response::IntoResponse;
27+
use axum::response::Response;
1228
use axum::routing::get;
1329
use maud::DOCTYPE;
1430
use maud::Markup;
31+
use maud::PreEscaped;
1532
use maud::html;
1633

34+
use crate::api;
35+
use crate::api::ChartResponse;
36+
use crate::api::Group;
1737
use crate::app::AppState;
38+
use crate::db;
39+
use crate::slug::ChartKey;
1840

19-
/// HTML routes mounted under `/`. Replaced by the web-ui component.
41+
const CHART_JS: &[u8] = include_bytes!("../static/chart.umd.js");
42+
const CHART_INIT_JS: &[u8] = include_bytes!("../static/chart-init.js");
43+
const STYLE_CSS: &[u8] = include_bytes!("../static/style.css");
44+
45+
/// HTML routes mounted under `/`.
2046
pub fn router() -> Router<AppState> {
21-
Router::new().route("/", get(placeholder))
47+
Router::new()
48+
.route("/", get(landing))
49+
.route("/chart/{slug}", get(chart_page))
50+
.route("/static/chart.umd.js", get(serve_chart_js))
51+
.route("/static/chart-init.js", get(serve_chart_init_js))
52+
.route("/static/style.css", get(serve_style_css))
53+
}
54+
55+
async fn landing(State(state): State<AppState>) -> Response {
56+
let groups = match db::run_blocking(&state.db, |conn| api::collect_groups(conn)).await {
57+
Ok(g) => g,
58+
Err(err) => {
59+
tracing::error!(error = ?err, "landing: collect_groups failed");
60+
return error_page(StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
61+
}
62+
};
63+
render_page(
64+
"bench.vortex.dev",
65+
"Vortex benchmarks (v3 alpha)",
66+
landing_body(&groups),
67+
PageScripts::None,
68+
)
69+
.into_response()
70+
}
71+
72+
async fn chart_page(State(state): State<AppState>, Path(slug): Path<String>) -> Response {
73+
let key = match ChartKey::from_slug(&slug) {
74+
Ok(key) => key,
75+
Err(err) => {
76+
tracing::warn!(error = ?err, slug, "chart_page: invalid slug");
77+
return error_page(StatusCode::NOT_FOUND, "chart not found").into_response();
78+
}
79+
};
80+
81+
let result = db::run_blocking(&state.db, move |conn| api::collect_chart(conn, &key)).await;
82+
let chart = match result {
83+
Ok(Some(c)) => c,
84+
Ok(None) => return error_page(StatusCode::NOT_FOUND, "chart not found").into_response(),
85+
Err(err) => {
86+
tracing::error!(error = ?err, "chart_page: collect_chart failed");
87+
return error_page(StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
88+
}
89+
};
90+
91+
let payload_json = match serde_json::to_string(&chart) {
92+
Ok(s) => s,
93+
Err(err) => {
94+
tracing::error!(error = ?err, "chart_page: serialize failed");
95+
return error_page(StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
96+
}
97+
};
98+
99+
let title = format!("{} — bench.vortex.dev", chart.display_name);
100+
render_page(
101+
&title,
102+
&chart.display_name,
103+
chart_body(&chart, &payload_json),
104+
PageScripts::Chart,
105+
)
106+
.into_response()
107+
}
108+
109+
/// Which scripts the page wants pulled in.
110+
enum PageScripts {
111+
None,
112+
Chart,
113+
}
114+
115+
fn render_page(title: &str, header_subtitle: &str, body: Markup, scripts: PageScripts) -> Markup {
116+
html! {
117+
(DOCTYPE)
118+
html lang="en" {
119+
head {
120+
meta charset="utf-8";
121+
meta name="viewport" content="width=device-width, initial-scale=1";
122+
title { (title) }
123+
link rel="stylesheet" href="/static/style.css";
124+
}
125+
body {
126+
header.page-header {
127+
h1 { a href="/" { "bench.vortex.dev" } }
128+
p.subtitle { (header_subtitle) }
129+
}
130+
main { (body) }
131+
@match scripts {
132+
PageScripts::None => {},
133+
PageScripts::Chart => {
134+
script src="/static/chart.umd.js" defer {}
135+
script src="/static/chart-init.js" defer {}
136+
},
137+
}
138+
}
139+
}
140+
}
141+
}
142+
143+
fn landing_body(groups: &[Group]) -> Markup {
144+
html! {
145+
@if groups.is_empty() {
146+
p.empty { "No data ingested yet." }
147+
} @else {
148+
@for group in groups {
149+
section.group {
150+
h2 { (group.name) }
151+
ul.charts {
152+
@for chart in &group.charts {
153+
li {
154+
a href={ "/chart/" (chart.slug) } { (chart.name) }
155+
}
156+
}
157+
}
158+
}
159+
}
160+
}
161+
}
22162
}
23163

24-
async fn placeholder() -> Markup {
164+
fn chart_body(chart: &ChartResponse, payload_json: &str) -> Markup {
165+
let series_count = chart.series.len();
166+
let commit_count = chart.commits.len();
25167
html! {
168+
p.chart-meta {
169+
"unit: " code { (chart.unit) }
170+
" · "
171+
(series_count) " series · "
172+
(commit_count) " commit" @if commit_count != 1 { "s" }
173+
}
174+
div.chart-wrap {
175+
canvas id="chart" {}
176+
}
177+
// Embedded JSON; rendered as text content so JSON `<` / `>` are HTML-escaped.
178+
script id="chart-data" type="application/json" { (PreEscaped(escape_json_for_script(payload_json))) }
179+
noscript {
180+
p.no-script { "JavaScript is required to render the chart." }
181+
}
182+
}
183+
}
184+
185+
/// Make a JSON string safe to embed inside a `<script>` element.
186+
///
187+
/// HTML parsers terminate `<script>` early on a literal `</`. Replacing the
188+
/// `/` with its escaped form keeps the JSON valid while neutering the
189+
/// terminator. `<!--` is similarly neutralised.
190+
fn escape_json_for_script(s: &str) -> String {
191+
s.replace("</", r"<\/")
192+
.replace("<!--", r"<\!--")
193+
.replace("<script", r"<\script")
194+
}
195+
196+
fn error_page(status: StatusCode, message: &str) -> Response {
197+
let body = html! {
26198
(DOCTYPE)
27199
html lang="en" {
28200
head {
29201
meta charset="utf-8";
30-
title { "bench.vortex.dev (v3 alpha)" }
202+
title { (status.as_u16()) " — bench.vortex.dev" }
203+
link rel="stylesheet" href="/static/style.css";
31204
}
32205
body {
33-
h1 { "bench.vortex.dev (v3 alpha)" }
34-
p {
35-
"Server is up. The landing page and chart page land with "
36-
"the web-ui PR."
206+
header.page-header {
207+
h1 { a href="/" { "bench.vortex.dev" } }
208+
p.subtitle { (status.as_u16()) " " (status.canonical_reason().unwrap_or("")) }
37209
}
38-
ul {
39-
li { code { "GET /api/groups" } }
40-
li { code { "GET /api/chart/{slug}" } }
41-
li { code { "GET /health" } }
42-
li { code { "POST /api/ingest" } " (bearer auth)" }
210+
main {
211+
p.empty { (message) }
43212
}
44213
}
45214
}
215+
};
216+
(status, body).into_response()
217+
}
218+
219+
async fn serve_chart_js() -> impl IntoResponse {
220+
static_response(CHART_JS, "application/javascript; charset=utf-8")
221+
}
222+
223+
async fn serve_chart_init_js() -> impl IntoResponse {
224+
static_response(CHART_INIT_JS, "application/javascript; charset=utf-8")
225+
}
226+
227+
async fn serve_style_css() -> impl IntoResponse {
228+
static_response(STYLE_CSS, "text/css; charset=utf-8")
229+
}
230+
231+
fn static_response(bytes: &'static [u8], content_type: &'static str) -> Response {
232+
(
233+
[
234+
(header::CONTENT_TYPE, content_type),
235+
(header::CACHE_CONTROL, "public, max-age=3600"),
236+
],
237+
bytes,
238+
)
239+
.into_response()
240+
}
241+
242+
#[cfg(test)]
243+
mod tests {
244+
use super::*;
245+
246+
#[test]
247+
fn escape_json_neutralises_script_terminators() {
248+
let input = r#"{"x":"</script><script>alert(1)</script>"}"#;
249+
let out = escape_json_for_script(input);
250+
assert!(!out.contains("</script"));
251+
assert!(!out.contains("<script"));
252+
assert!(out.contains(r"<\/script"));
253+
}
254+
255+
#[test]
256+
fn escape_json_passes_through_safe_strings() {
257+
let s = r#"{"a":1,"b":"hello"}"#;
258+
assert_eq!(escape_json_for_script(s), s);
46259
}
47260
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2014-2024 Chart.js Contributors
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6+
7+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8+
9+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

0 commit comments

Comments
 (0)