rustlavel_debugbar/
render.rs1use crate::collector::Collected;
9use std::time::Duration;
10
11pub fn bar(collected: &Collected, route: &str, status: u16) -> String {
13 let elapsed = collected.started.map(|started| started.elapsed()).unwrap_or_default();
14 let queries = collected.of("db.query");
15 let repeated = collected.repeated_queries();
16
17 let mut html = String::with_capacity(4096);
18 html.push_str(r#"<div id="rl-bar" data-rl-bar><style>"#);
19 html.push_str(STYLE);
20 html.push_str("</style>");
21
22 html.push_str(r#"<div class="rl-summary" onclick="rlToggle()">"#);
23 push_chip(&mut html, "route", &format!("{status} {route}"), false);
24 push_chip(&mut html, "time", &millis(elapsed), false);
25 push_chip(
26 &mut html,
27 "queries",
28 &format!("{} · {}", queries.len(), millis(collected.total("db.query"))),
29 !repeated.is_empty(),
32 );
33
34 let hits = collected.of("cache.hit").len();
35 let misses = collected.of("cache.miss").len();
36 if hits + misses > 0 {
37 push_chip(&mut html, "cache", &format!("{hits} hit · {misses} miss"), false);
38 }
39 html.push_str(r#"<span class="rl-grow"></span><span class="rl-caret">▲</span></div>"#);
40
41 html.push_str(r#"<div class="rl-panel" hidden>"#);
42
43 if !repeated.is_empty() {
44 html.push_str(r#"<div class="rl-warn"><strong>Repeated queries.</strong> "#);
45 html.push_str("The same statement ran more than once, which is usually a relation \
46 loaded inside a loop rather than alongside the set.</div>");
47 html.push_str("<table>");
48 for (sql, count) in repeated.iter().take(10) {
49 html.push_str(&format!(
50 "<tr><td class=\"rl-count\">×{count}</td><td><code>{}</code></td></tr>",
51 escape(sql)
52 ));
53 }
54 html.push_str("</table>");
55 }
56
57 for (kind, heading) in SECTIONS {
58 let entries = collected.of(kind);
59 if entries.is_empty() {
60 continue;
61 }
62 html.push_str(&format!("<h4>{heading} <span>{}</span></h4><table>", entries.len()));
63 for entry in entries.iter().take(100) {
64 html.push_str("<tr><td class=\"rl-dur\">");
65 html.push_str(&entry.duration.map(millis).unwrap_or_else(|| "—".into()));
66 html.push_str("</td><td><code>");
67 html.push_str(&escape(&entry.label));
68 html.push_str("</code></td></tr>");
69 }
70 html.push_str("</table>");
71 }
72
73 html.push_str(
74 r#"<p class="rl-note">Work moved to another task with <code>tokio::spawn</code> is
75 not counted here — it is no longer on the request's task, which is what the bar
76 follows.</p>"#,
77 );
78 html.push_str("</div>");
79 html.push_str(r#"<script>function rlToggle(){var p=document.querySelector('#rl-bar .rl-panel');p.hidden=!p.hidden;document.querySelector('#rl-bar .rl-caret').textContent=p.hidden?'▲':'▼';}</script>"#);
80 html.push_str("</div>");
81 html
82}
83
84const SECTIONS: &[(&str, &str)] = &[
85 ("db.query", "Queries"),
86 ("cache.hit", "Cache hits"),
87 ("cache.miss", "Cache misses"),
88 ("http.client", "Outbound HTTP"),
89 ("ai.call", "AI"),
90 ("mcp.call", "MCP"),
91 ("mail.sent", "Mail"),
92 ("queue.pushed", "Jobs queued"),
93];
94
95fn push_chip(html: &mut String, label: &str, value: &str, alarming: bool) {
96 let class = if alarming { "rl-chip rl-alarm" } else { "rl-chip" };
97 html.push_str(&format!(
98 r#"<span class="{class}"><b>{}</b>{}</span>"#,
99 escape(label),
100 escape(value)
101 ));
102}
103
104fn millis(duration: Duration) -> String {
105 let ms = duration.as_secs_f64() * 1000.0;
106 if ms < 1.0 { format!("{:.2} ms", ms) } else { format!("{:.1} ms", ms) }
107}
108
109fn escape(text: &str) -> String {
115 let mut out = String::with_capacity(text.len());
116 for character in text.chars() {
117 match character {
118 '&' => out.push_str("&"),
119 '<' => out.push_str("<"),
120 '>' => out.push_str(">"),
121 '"' => out.push_str("""),
122 '\'' => out.push_str("'"),
123 other => out.push(other),
124 }
125 }
126 out
127}
128
129const STYLE: &str = "#rl-bar{position:fixed;left:0;right:0;bottom:0;z-index:2147483000;\
130font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#e6e8ea;\
131background:#14181d;border-top:1px solid #2a313a;box-shadow:0 -2px 12px rgba(0,0,0,.3)}\
132#rl-bar .rl-summary{display:flex;align-items:center;gap:14px;padding:7px 12px;cursor:pointer}\
133#rl-bar .rl-chip{white-space:nowrap}#rl-bar .rl-chip b{color:#7b8794;font-weight:400;margin-right:6px}\
134#rl-bar .rl-alarm{color:#ff8a6a}#rl-bar .rl-grow{flex:1}#rl-bar .rl-caret{color:#7b8794}\
135#rl-bar .rl-panel{max-height:45vh;overflow:auto;border-top:1px solid #2a313a;padding:10px 12px}\
136#rl-bar h4{margin:14px 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#7b8794}\
137#rl-bar h4 span{color:#4a5561}#rl-bar table{width:100%;border-collapse:collapse}\
138#rl-bar td{padding:3px 8px 3px 0;vertical-align:top;border-bottom:1px solid #1b2027}\
139#rl-bar .rl-dur,#rl-bar .rl-count{white-space:nowrap;color:#7b8794;width:1%}\
140#rl-bar code{color:#cfd6dd;word-break:break-all}\
141#rl-bar .rl-warn{background:#2a1d18;border-left:3px solid #ff8a6a;padding:8px 10px;margin:4px 0 8px}\
142#rl-bar .rl-note{color:#4a5561;margin:14px 0 0}";
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use crate::collector::Timing;
148
149 fn query(sql: &str) -> Timing {
150 Timing {
151 kind: "db.query".into(),
152 label: sql.into(),
153 duration: Some(Duration::from_millis(2)),
154 fields: Vec::new(),
155 }
156 }
157
158 #[test]
159 fn shows_the_route_status_and_query_count() {
160 let collected =
161 Collected { timings: vec![query("select 1"), query("select 2")], started: None };
162 let html = bar(&collected, "/users/{id}", 200);
163
164 assert!(html.contains("200 /users/{id}"));
165 assert!(html.contains("2 · "), "the query count belongs on the summary");
166 }
167
168 #[test]
169 fn a_repeated_query_is_called_out_rather_than_just_counted() {
170 let mut timings = vec![];
171 for _ in 0..12 {
172 timings.push(query("select * from users where id = ?"));
173 }
174 let html = bar(&Collected { timings, started: None }, "/posts", 200);
175
176 assert!(html.contains("Repeated queries"));
177 assert!(html.contains("×12"));
178 assert!(html.contains("rl-alarm"), "the chip turns red so it is noticed");
179 }
180
181 #[test]
182 fn sql_containing_html_cannot_close_the_page_or_run_a_script() {
183 let hostile = "select * from t where name = '</script><img src=x onerror=alert(1)>'";
186 let html = bar(&Collected { timings: vec![query(hostile)], started: None }, "/", 200);
187
188 assert!(!html.contains("<img src=x"), "raw markup reached the page");
189 assert!(!html.contains("</script><img"), "the script tag was not closed early");
190 assert!(html.contains("<img src=x"));
191 }
192
193 #[test]
194 fn a_hostile_route_is_escaped_too() {
195 let html = bar(&Collected::default(), "/<script>alert(1)</script>", 404);
196 assert!(!html.contains("<script>alert(1)"));
197 assert!(html.contains("<script>"));
198 }
199
200 #[test]
201 fn an_empty_request_still_renders_a_usable_bar() {
202 let html = bar(&Collected::default(), "/health", 204);
203
204 assert!(html.contains("204 /health"));
205 assert!(html.contains("rl-bar"));
206 assert!(!html.contains("Repeated queries"));
207 }
208
209 #[test]
210 fn the_limit_of_the_measurement_is_printed_rather_than_hidden() {
211 let html = bar(&Collected::default(), "/", 200);
215 assert!(html.contains("tokio::spawn"));
216 }
217}