1use std::fmt::Write as _;
9
10use cu_profiler_core::model::{Report, ScenarioReport, Status};
11use cu_profiler_core::parser::CallNode;
12
13use crate::model::{scenario_budget, scenario_delta_pct, thousands};
14
15#[must_use]
17pub fn render(report: &Report) -> String {
18 let mut out = String::new();
19 out.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n");
20 out.push_str("<meta charset=\"utf-8\">\n");
21 out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
22 out.push_str("<title>cu-profiler report</title>\n");
23 out.push_str(STYLE);
24 out.push_str("</head>\n<body>\n");
25 out.push_str("<h1>cu-profiler report</h1>\n");
26
27 let s = &report.summary;
28 let _ = writeln!(
29 out,
30 "<p class=\"summary\">{} scenario(s): \
31 <span class=\"pass\">{} passed</span> · \
32 <span class=\"warn\">{} warned</span> · \
33 <span class=\"fail\">{} failed</span> — <strong>{} total CU</strong></p>",
34 s.total_scenarios,
35 s.passed,
36 s.warned,
37 s.failed,
38 thousands(s.total_cu),
39 );
40
41 push_overview_table(&mut out, report);
42 for scenario in &report.scenarios {
43 push_scenario(&mut out, scenario);
44 }
45
46 out.push_str("</body>\n</html>\n");
47 out
48}
49
50fn push_overview_table(out: &mut String, report: &Report) {
51 out.push_str("<table class=\"overview\">\n<thead><tr>");
52 for h in ["Scenario", "Actual CU", "Budget", "Delta", "Status"] {
53 let _ = write!(out, "<th>{h}</th>");
54 }
55 out.push_str("</tr></thead>\n<tbody>\n");
56 for sc in &report.scenarios {
57 let budget = scenario_budget(sc).map_or_else(|| "—".to_string(), thousands);
58 let delta = scenario_delta_pct(sc).map_or_else(|| "—".to_string(), |d| format!("{d:+.1}%"));
59 let _ = writeln!(
60 out,
61 "<tr><td>{}</td><td class=\"num\">{}</td><td class=\"num\">{}</td>\
62 <td class=\"num\">{}</td><td class=\"{}\">{}</td></tr>",
63 esc(&sc.name),
64 thousands(sc.measurement.total_cu),
65 budget,
66 delta,
67 status_class(sc.status),
68 sc.status.label(),
69 );
70 }
71 out.push_str("</tbody>\n</table>\n");
72}
73
74fn push_scenario(out: &mut String, sc: &ScenarioReport) {
75 let _ = writeln!(
76 out,
77 "<section class=\"scenario\">\n<h2>{} <span class=\"{}\">{}</span></h2>",
78 esc(&sc.name),
79 status_class(sc.status),
80 sc.status.label(),
81 );
82
83 let m = &sc.measurement;
84 let _ = writeln!(
85 out,
86 "<p class=\"meta\">{} CU · {} CPIs · depth {} · confidence {}</p>",
87 thousands(m.total_cu),
88 m.cpi_count,
89 m.cpi_depth,
90 esc(sc.confidence.level.label()),
91 );
92 if !sc.confidence.reasons.is_empty() {
93 out.push_str("<ul class=\"reasons\">\n");
94 for r in &sc.confidence.reasons {
95 let _ = writeln!(out, "<li>{}</li>", esc(r));
96 }
97 out.push_str("</ul>\n");
98 }
99
100 if let Some(tree) = &sc.call_tree {
101 out.push_str("<h3>Call tree</h3>\n");
102 push_tree(out, tree);
103 }
104
105 if !sc.scopes.is_empty() {
106 out.push_str("<h3>Scopes</h3>\n<ul class=\"scopes\">\n");
107 for scope in &sc.scopes {
108 let cu = match (scope.units_estimated, scope.percentage_of_total) {
109 (Some(u), Some(p)) => format!(" — {} CU ({p:.1}%)", thousands(u)),
110 _ => String::new(),
111 };
112 let _ = writeln!(out, "<li>{}{}</li>", esc(&scope.name), esc(&cu));
113 }
114 out.push_str("</ul>\n");
115 }
116
117 if !sc.diagnostics.is_empty() {
118 out.push_str("<h3>Diagnostics</h3>\n<ul class=\"diagnostics\">\n");
119 for d in &sc.diagnostics {
120 let _ = writeln!(
121 out,
122 "<li><strong>{}</strong> — {}<br><em>{}</em></li>",
123 esc(&d.title),
124 esc(&d.evidence),
125 esc(&d.recommendation),
126 );
127 }
128 out.push_str("</ul>\n");
129 }
130
131 out.push_str("</section>\n");
132}
133
134fn push_tree(out: &mut String, node: &CallNode) {
135 out.push_str("<ul class=\"tree\">\n");
136 push_node(out, node);
137 out.push_str("</ul>\n");
138}
139
140fn push_node(out: &mut String, node: &CallNode) {
141 let label = node.label.as_deref().unwrap_or(&node.program_id);
142 let units = node
143 .units_consumed
144 .map_or_else(String::new, |u| format!(" — {} CU", thousands(u)));
145 let _ = write!(out, "<li>{}{}", esc(label), esc(&units));
146 if !node.children.is_empty() {
147 out.push_str("\n<ul>\n");
148 for child in &node.children {
149 push_node(out, child);
150 }
151 out.push_str("</ul>\n");
152 }
153 out.push_str("</li>\n");
154}
155
156fn status_class(status: Status) -> &'static str {
157 match status {
158 Status::Pass => "pass",
159 Status::Warn => "warn",
160 Status::Fail => "fail",
161 Status::Unknown => "unknown",
162 }
163}
164
165fn esc(s: &str) -> String {
167 s.replace('&', "&")
168 .replace('<', "<")
169 .replace('>', ">")
170 .replace('"', """)
171 .replace('\'', "'")
172}
173
174const STYLE: &str = "<style>\n\
175body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;margin:2rem auto;max-width:60rem;color:#1b1f24;padding:0 1rem}\n\
176h1{font-size:1.5rem}h2{font-size:1.15rem;margin-top:2rem}h3{font-size:1rem;color:#444}\n\
177table{border-collapse:collapse;width:100%;margin:1rem 0}th,td{padding:.4rem .6rem;border-bottom:1px solid #e2e6ea;text-align:left}\n\
178td.num{text-align:right;font-variant-numeric:tabular-nums}\n\
179.pass{color:#1a7f37;font-weight:600}.warn{color:#9a6700;font-weight:600}.fail{color:#cf222e;font-weight:600}.unknown{color:#57606a;font-weight:600}\n\
180.summary{font-size:1.05rem}.meta{color:#57606a}\n\
181ul.tree,ul.tree ul{list-style:none;padding-left:1.1rem;border-left:1px solid #e2e6ea}\n\
182ul.scopes,ul.diagnostics,ul.reasons{padding-left:1.1rem}\n\
183.scenario{border-top:1px solid #e2e6ea;padding-top:.5rem}\n\
184</style>\n";
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use cu_profiler_core::Profiler;
190 use cu_profiler_core::backend::RecordedLogsBackend;
191 use cu_profiler_core::budget::BudgetPolicy;
192 use cu_profiler_core::metadata::RunMetadata;
193 use cu_profiler_core::scenario::Scenario;
194
195 fn report(name: &str) -> Report {
196 let mut backend = RecordedLogsBackend::new();
197 backend.insert_blob(
198 name,
199 "Program User111 invoke [1]\n\
200 Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]\n\
201 Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success\n\
202 Program User111 consumed 96000 of 100000 compute units\n\
203 Program User111 success",
204 true,
205 );
206 let mut scenario = Scenario::new(name);
207 scenario.budget = BudgetPolicy {
208 absolute_max_cu: Some(100_000),
209 warn_at_budget_pct: Some(90.0),
210 ..Default::default()
211 };
212 Profiler::new().run(&backend, &[scenario], None, RunMetadata::recorded("0.1.0"))
213 }
214
215 #[test]
216 fn renders_well_formed_document() {
217 let html = render(&report("swap"));
218 assert!(html.starts_with("<!doctype html>"));
219 assert!(html.contains("<title>cu-profiler report</title>"));
220 assert!(html.contains("cu-profiler report"));
221 assert!(html.contains("swap"));
222 assert!(html.contains("SPL Token")); assert!(html.trim_end().ends_with("</html>"));
224 }
225
226 #[test]
227 fn escapes_html_in_scenario_names() {
228 let html = render(&report("<script>evil</script>"));
229 assert!(!html.contains("<script>evil"));
230 assert!(html.contains("<script>evil</script>"));
231 }
232}