|
1 | 1 | // SPDX-License-Identifier: Apache-2.0 |
2 | 2 | // SPDX-FileCopyrightText: Copyright the Vortex contributors |
3 | 3 |
|
4 | | -//! HTML routes. |
| 4 | +//! HTML routes for the bench.vortex.dev v3 alpha web UI. |
5 | 5 | //! |
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. |
10 | 20 |
|
11 | 21 | 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; |
12 | 28 | use axum::routing::get; |
13 | 29 | use maud::DOCTYPE; |
14 | 30 | use maud::Markup; |
| 31 | +use maud::PreEscaped; |
15 | 32 | use maud::html; |
16 | 33 |
|
| 34 | +use crate::api; |
| 35 | +use crate::api::ChartResponse; |
| 36 | +use crate::api::Group; |
17 | 37 | use crate::app::AppState; |
| 38 | +use crate::db; |
| 39 | +use crate::slug::ChartKey; |
18 | 40 |
|
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 `/`. |
20 | 46 | 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 | + } |
22 | 162 | } |
23 | 163 |
|
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(); |
25 | 167 | 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! { |
26 | 198 | (DOCTYPE) |
27 | 199 | html lang="en" { |
28 | 200 | head { |
29 | 201 | 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"; |
31 | 204 | } |
32 | 205 | 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("")) } |
37 | 209 | } |
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) } |
43 | 212 | } |
44 | 213 | } |
45 | 214 | } |
| 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); |
46 | 259 | } |
47 | 260 | } |
0 commit comments