feat(trace-charts): serve persisted traces as a web chart (iter 2)
Iteration 2 (serve half) of the visual face — spec 0056 / plan 0056:
read a persisted run's traces and serve them as a self-contained uPlot
HTML chart. Completes the goal "tap data from a graph, serve it as a
chart in the browser".
- chart-viewer.js (first-party): a pure buildCharts(traces, mode) that
maps the injected window.AURA_TRACES to uPlot configs (no DOM) — overlay
= one plot, every series on its own y-scale over the shared xs; panels =
one plot per series, all on the same timestamp x. A browser-only mount()
instantiates the vendored global uPlot. Guarded by a node .mjs DOM test
(+ a sparse/gaps variant) shelling out exactly like viewer_dot.rs.
- render_chart_html + ChartMode/ChartData/Series (aura-cli/render.rs):
mirrors render_html — self-contained page, uPlot (1.6.32, vendored
d534f10) + chart-viewer.js inlined via include_str!, data injected as
window.AURA_TRACES.
- emit_chart + build_chart_data (aura-cli/main.rs): the spec-§6 3-step
union-spine alignment — xs = sorted-dedup union of every tap's ts; a
synthetic empty-payload spine + ALL taps as symmetric join_on_ts sides
(no tap privileged, no point dropped); flatten each (tap,column) to a
Series of Option<f64>. New arms `aura chart <name> [--panels]`; a
missing run is stderr + exit 2 (TraceStoreError::NotFound), never a
panic. USAGE advertises the verb in lockstep.
Verified by the orchestrator: `cargo test --workspace` green (incl. the
node guards, render_chart_html, and the chart e2e); `cargo clippy
--workspace --all-targets -D warnings` exit 0; and a real end-to-end —
`aura run --trace demo && aura chart demo` emits a 57KB self-contained
page (doctype + inlined uPlot + AURA_TRACES carrying the equity/exposure
series), `--panels` reflects the panels mode, `aura chart nope` exits 2.
Implementer deviation folded in (compiler-prescribed): serde added to
aura-cli/Cargo.toml for the Series derive (Cargo.lock updated).
refs #101
This commit is contained in:
@@ -8,16 +8,18 @@
|
||||
|
||||
use aura_engine::Composite;
|
||||
|
||||
/// The prototype's `<head>` shell + body skeleton (header, stage, tooltip, error
|
||||
/// pane), verbatim from `docs/design/prototypes/graph-render/index.html` lines
|
||||
/// 1-38 — minus the two `<script src="./…">` tags, since `render_html` inlines
|
||||
/// the assets instead.
|
||||
/// The shared `<head>` (charset, viewport, `<style>`) + the opening `<body>` tag,
|
||||
/// up to the per-page `<title>` slot. Derived from the prototype
|
||||
/// (`docs/design/prototypes/graph-render/index.html` lines 1-38), minus the two
|
||||
/// `<script src="./…">` tags (the assets are inlined). The `{title}` placeholder
|
||||
/// and the per-page `<header>` let one shell serve both the graph and the chart
|
||||
/// page without one borrowing the other's label.
|
||||
const SHELL_HEAD: &str = r#"<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>aura graph</title>
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #16161a; color: #cdd6f4;
|
||||
font-family: ui-monospace, monospace; }
|
||||
@@ -42,7 +44,11 @@ const SHELL_HEAD: &str = r#"<!doctype html>
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
"#;
|
||||
|
||||
/// The graph page's `<title>` text and `<header>` line (hover/expand/drill hint),
|
||||
/// graph-specific — split out of the shared shell.
|
||||
const GRAPH_HEAD: &str = r#"<header>
|
||||
<b>aura graph</b>
|
||||
<span id="crumb"></span>
|
||||
<span class="sub">hover · [+] expand · body drill</span>
|
||||
@@ -52,10 +58,24 @@ const SHELL_HEAD: &str = r#"<!doctype html>
|
||||
<pre id="err"></pre>
|
||||
"#;
|
||||
|
||||
/// The chart page's `<title>` text and `<header>` line — describes what the chart
|
||||
/// view actually renders (timestamp-aligned trace series), not the graph viewer.
|
||||
const CHART_HEAD: &str = r#"<header>
|
||||
<b>aura chart</b>
|
||||
<span class="sub">timestamp-aligned trace series</span>
|
||||
</header>
|
||||
<div id="stage"></div>
|
||||
<pre id="err"></pre>
|
||||
"#;
|
||||
|
||||
const VIZ_JS: &str = include_str!("../assets/viz-standalone.js");
|
||||
const PANZOOM_JS: &str = include_str!("../assets/svg-pan-zoom.min.js");
|
||||
const VIEWER_JS: &str = include_str!("../assets/graph-viewer.js");
|
||||
|
||||
const UPLOT_JS: &str = include_str!("../assets/uPlot.iife.min.js");
|
||||
const UPLOT_CSS: &str = include_str!("../assets/uPlot.min.css");
|
||||
const CHART_VIEWER_JS: &str = include_str!("../assets/chart-viewer.js");
|
||||
|
||||
/// Assemble the self-contained interactive graph viewer page for `root`.
|
||||
///
|
||||
/// Order is load-bearing: the Graphviz-WASM and pan-zoom globals (`Viz`,
|
||||
@@ -64,9 +84,10 @@ const VIEWER_JS: &str = include_str!("../assets/graph-viewer.js");
|
||||
pub fn render_html(root: &Composite) -> String {
|
||||
let model = aura_engine::model_to_json(root);
|
||||
let mut html = String::with_capacity(
|
||||
SHELL_HEAD.len() + VIZ_JS.len() + PANZOOM_JS.len() + VIEWER_JS.len() + model.len() + 256,
|
||||
SHELL_HEAD.len() + GRAPH_HEAD.len() + VIZ_JS.len() + PANZOOM_JS.len() + VIEWER_JS.len() + model.len() + 256,
|
||||
);
|
||||
html.push_str(SHELL_HEAD);
|
||||
html.push_str(&SHELL_HEAD.replace("{title}", "aura graph"));
|
||||
html.push_str(GRAPH_HEAD);
|
||||
html.push_str("<script>");
|
||||
html.push_str(VIZ_JS);
|
||||
html.push_str("</script>\n<script>");
|
||||
@@ -79,6 +100,68 @@ pub fn render_html(root: &Composite) -> String {
|
||||
html
|
||||
}
|
||||
|
||||
/// Which way the chart draws the (timestamp-aligned) series.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum ChartMode {
|
||||
/// One plot, all series superimposed, each on its own y-scale.
|
||||
Overlay,
|
||||
/// One stacked plot per series, all sharing the timestamp x-axis.
|
||||
Panels,
|
||||
}
|
||||
|
||||
/// One chart series: a name, its own y-scale id (so disparate magnitudes stay
|
||||
/// legible), and one `Option<f64>` per shared-x slot (`null` = a gap where the tap
|
||||
/// did not fire at that timestamp).
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct Series {
|
||||
pub name: String,
|
||||
pub y_scale_id: String,
|
||||
pub points: Vec<Option<f64>>,
|
||||
}
|
||||
|
||||
/// The serve-ready chart payload: the shared timestamp axis and the per-(tap,column)
|
||||
/// series. These are exactly the fields `chart-viewer.js` reads; injected verbatim
|
||||
/// as `window.AURA_TRACES` (plus the `mode`). The run's manifest stays on disk in the
|
||||
/// trace store's `index.json`; it is not carried into the served page, which renders
|
||||
/// only the series, so nothing here is dead injected payload.
|
||||
pub struct ChartData {
|
||||
pub xs: Vec<i64>,
|
||||
pub series: Vec<Series>,
|
||||
}
|
||||
|
||||
/// Assemble the self-contained uPlot chart page for one run's aligned traces.
|
||||
///
|
||||
/// Order is load-bearing (mirrors `render_html`): the uPlot global and CSS, then the
|
||||
/// injected `window.AURA_TRACES`, must be defined before `chart-viewer.js` runs.
|
||||
pub fn render_chart_html(data: &ChartData, mode: ChartMode) -> String {
|
||||
let mode_str = match mode {
|
||||
ChartMode::Overlay => "overlay",
|
||||
ChartMode::Panels => "panels",
|
||||
};
|
||||
let traces = serde_json::json!({
|
||||
"mode": mode_str,
|
||||
"xs": data.xs,
|
||||
"series": data.series,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let mut html = String::with_capacity(
|
||||
SHELL_HEAD.len() + CHART_HEAD.len() + UPLOT_JS.len() + UPLOT_CSS.len() + CHART_VIEWER_JS.len() + traces.len() + 256,
|
||||
);
|
||||
html.push_str(&SHELL_HEAD.replace("{title}", "aura chart"));
|
||||
html.push_str(CHART_HEAD);
|
||||
html.push_str("<style>");
|
||||
html.push_str(UPLOT_CSS);
|
||||
html.push_str("</style>\n<script>");
|
||||
html.push_str(UPLOT_JS);
|
||||
html.push_str("</script>\n<script>window.AURA_TRACES = ");
|
||||
html.push_str(&traces);
|
||||
html.push_str(";</script>\n<script>");
|
||||
html.push_str(CHART_VIEWER_JS);
|
||||
html.push_str("</script>\n</body>\n</html>\n");
|
||||
html
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -95,6 +178,9 @@ mod tests {
|
||||
// a complete HTML document
|
||||
assert!(html.starts_with("<!doctype html>"), "not an HTML doc");
|
||||
assert!(html.trim_end().ends_with("</html>"), "HTML doc not closed");
|
||||
// the graph page wears its own label (not the chart's)
|
||||
assert!(html.contains("<title>aura graph</title>"), "graph page title missing");
|
||||
assert!(html.contains("<b>aura graph</b>"), "graph header label missing");
|
||||
// self-contained: every asset is inlined, no remote <script src>
|
||||
assert!(!html.contains("<script src"), "assets must be inlined, found a remote script tag");
|
||||
// the deterministic model is injected as the viewer's data source
|
||||
@@ -109,4 +195,57 @@ mod tests {
|
||||
"C4 four-colour palette missing"
|
||||
);
|
||||
}
|
||||
|
||||
fn sample_chart_data() -> ChartData {
|
||||
ChartData {
|
||||
xs: vec![1, 2, 3],
|
||||
series: vec![
|
||||
Series { name: "equity".into(), y_scale_id: "y_0".into(), points: vec![Some(0.0), Some(0.001), Some(0.004)] },
|
||||
Series { name: "exposure".into(), y_scale_id: "y_1".into(), points: vec![Some(1.0), Some(1.0), Some(-1.0)] },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_chart_html_is_self_contained_and_injects_traces() {
|
||||
let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay);
|
||||
assert!(html.starts_with("<!doctype html>"), "not an HTML doc");
|
||||
assert!(html.trim_end().ends_with("</html>"), "HTML doc not closed");
|
||||
// self-contained: no remote asset
|
||||
assert!(!html.contains("<script src"), "assets must be inlined");
|
||||
// data injected as the viewer's source, with the mode reflected
|
||||
assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected");
|
||||
assert!(html.contains("\"mode\":\"overlay\""), "overlay mode not reflected");
|
||||
assert!(html.contains("\"equity\""), "series name missing from injected data");
|
||||
// the vendored uPlot bundle + first-party viewer are inlined
|
||||
assert!(html.contains("uPlot=function"), "vendored uPlot bundle missing");
|
||||
assert!(html.contains("buildCharts"), "chart-viewer.js not inlined");
|
||||
}
|
||||
|
||||
/// The chart page labels itself as a chart, never borrowing the graph viewer's
|
||||
/// `<title>`/header/breadcrumb — a served chart must not claim to be a graph.
|
||||
#[test]
|
||||
fn render_chart_html_wears_its_own_chart_label() {
|
||||
let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay);
|
||||
assert!(html.contains("<title>aura chart</title>"), "chart page title not chart-specific");
|
||||
assert!(html.contains("<b>aura chart</b>"), "chart header label not chart-specific");
|
||||
// none of the graph viewer's chrome leaked in
|
||||
assert!(!html.contains("aura graph"), "graph label leaked onto the chart page");
|
||||
assert!(!html.contains("hover · [+] expand · body drill"), "graph breadcrumb leaked onto the chart page");
|
||||
}
|
||||
|
||||
/// `window.AURA_TRACES` carries only the fields `chart-viewer.js` reads
|
||||
/// (`mode`, `xs`, `series`) — no `manifest`/`taps` injected-but-unread payload.
|
||||
#[test]
|
||||
fn render_chart_html_injects_only_what_the_viewer_reads() {
|
||||
let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay);
|
||||
assert!(!html.contains("\"manifest\""), "unread manifest carried into the served chart");
|
||||
assert!(!html.contains("\"taps\""), "unread taps list carried into the served chart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_chart_html_reflects_panels_mode() {
|
||||
let html = render_chart_html(&sample_chart_data(), ChartMode::Panels);
|
||||
assert!(html.contains("\"mode\":\"panels\""), "panels mode not reflected");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user