1use reinhardt_core::security::escape_html_content;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6use tokio::sync::RwLock;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct DebugPanel {
11 pub title: String,
12 pub content: Vec<DebugEntry>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(tag = "type")]
17pub enum DebugEntry {
18 KeyValue {
19 key: String,
20 value: String,
21 },
22 Table {
23 headers: Vec<String>,
24 rows: Vec<Vec<String>>,
25 },
26 Code {
27 language: String,
28 code: String,
29 },
30 Text {
31 text: String,
32 },
33}
34
35#[derive(Debug, Clone, Serialize)]
37pub struct TimingInfo {
38 pub total_time: Duration,
39 pub sql_time: Duration,
40 pub sql_queries: usize,
41 pub cache_hits: usize,
42 pub cache_misses: usize,
43}
44
45#[derive(Debug, Clone, Serialize)]
47pub struct SqlQuery {
48 pub query: String,
49 pub duration: Duration,
50 pub stack_trace: Vec<String>,
51}
52
53pub struct DebugToolbar {
55 panels: Arc<RwLock<HashMap<String, DebugPanel>>>,
56 timing: Arc<RwLock<TimingInfo>>,
57 sql_queries: Arc<RwLock<Vec<SqlQuery>>>,
58 start_time: Instant,
59 enabled: bool,
60}
61
62impl DebugToolbar {
63 pub fn new() -> Self {
74 Self {
75 panels: Arc::new(RwLock::new(HashMap::new())),
76 timing: Arc::new(RwLock::new(TimingInfo {
77 total_time: Duration::from_secs(0),
78 sql_time: Duration::from_secs(0),
79 sql_queries: 0,
80 cache_hits: 0,
81 cache_misses: 0,
82 })),
83 sql_queries: Arc::new(RwLock::new(Vec::new())),
84 start_time: Instant::now(),
85 enabled: true,
86 }
87 }
88 pub fn set_enabled(&mut self, enabled: bool) {
100 self.enabled = enabled;
101 }
102 pub fn is_enabled(&self) -> bool {
113 self.enabled
114 }
115 pub async fn add_panel(&self, id: String, panel: DebugPanel) {
134 if !self.enabled {
135 return;
136 }
137 self.panels.write().await.insert(id, panel);
138 }
139 pub async fn record_sql_query(&self, query: String, duration: Duration) {
156 if !self.enabled {
157 return;
158 }
159
160 let sql_query = SqlQuery {
161 query,
162 duration,
163 stack_trace: vec![],
164 };
165
166 self.sql_queries.write().await.push(sql_query);
167
168 let mut timing = self.timing.write().await;
169 timing.sql_queries += 1;
170 timing.sql_time += duration;
171 }
172 pub async fn record_cache_hit(&self) {
187 if !self.enabled {
188 return;
189 }
190 self.timing.write().await.cache_hits += 1;
191 }
192 pub async fn record_cache_miss(&self) {
207 if !self.enabled {
208 return;
209 }
210 self.timing.write().await.cache_misses += 1;
211 }
212 pub async fn finalize(&self) {
230 if !self.enabled {
231 return;
232 }
233 self.timing.write().await.total_time = self.start_time.elapsed();
234 }
235 pub async fn get_panels(&self) -> HashMap<String, DebugPanel> {
254 self.panels.read().await.clone()
255 }
256 pub async fn get_timing(&self) -> TimingInfo {
271 self.timing.read().await.clone()
272 }
273 pub async fn get_sql_queries(&self) -> Vec<SqlQuery> {
290 self.sql_queries.read().await.clone()
291 }
292 pub async fn render_html(&self) -> String {
308 if !self.enabled {
309 return String::new();
310 }
311
312 let panels = self.get_panels().await;
313 let timing = self.get_timing().await;
314 let queries = self.get_sql_queries().await;
315
316 format!(
317 r#"
318<div class="debug-toolbar">
319 <div class="timing">
320 <h3>Timing</h3>
321 <p>Total: {:?}</p>
322 <p>SQL: {:?} ({} queries)</p>
323 <p>Cache: {} hits, {} misses</p>
324 </div>
325 <div class="sql-queries">
326 <h3>SQL Queries</h3>
327 <ul>
328 {}
329 </ul>
330 </div>
331 <div class="panels">
332 {}
333 </div>
334</div>
335"#,
336 timing.total_time,
337 timing.sql_time,
338 timing.sql_queries,
339 timing.cache_hits,
340 timing.cache_misses,
341 queries
342 .iter()
343 .map(|q| format!(
344 "<li>{} ({:?})</li>",
345 escape_html_content(&q.query),
346 q.duration
347 ))
348 .collect::<Vec<_>>()
349 .join("\n"),
350 panels
351 .iter()
352 .map(|(id, panel)| format!(
353 "<div class='panel' id='{}'><h3>{}</h3></div>",
354 escape_html_content(id),
355 escape_html_content(&panel.title)
356 ))
357 .collect::<Vec<_>>()
358 .join("\n")
359 )
360 }
361}
362
363impl Default for DebugToolbar {
364 fn default() -> Self {
365 Self::new()
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[tokio::test]
374 async fn test_debug_toolbar() {
375 let toolbar = DebugToolbar::new();
376
377 toolbar
378 .record_sql_query("SELECT * FROM users".to_string(), Duration::from_millis(10))
379 .await;
380 toolbar.record_cache_hit().await;
381 toolbar.finalize().await;
382
383 let timing = toolbar.get_timing().await;
384 assert_eq!(timing.sql_queries, 1);
385 assert_eq!(timing.cache_hits, 1);
386
387 let queries = toolbar.get_sql_queries().await;
388 assert_eq!(queries.len(), 1);
389 }
390
391 #[tokio::test]
392 async fn test_debug_panel() {
393 let toolbar = DebugToolbar::new();
394
395 let panel = DebugPanel {
396 title: "Test Panel".to_string(),
397 content: vec![DebugEntry::KeyValue {
398 key: "key".to_string(),
399 value: "value".to_string(),
400 }],
401 };
402
403 toolbar.add_panel("test".to_string(), panel).await;
404
405 let panels = toolbar.get_panels().await;
406 assert_eq!(panels.len(), 1);
407 assert!(panels.contains_key("test"));
408 }
409
410 #[test]
411 fn test_escape_html_content() {
412 assert_eq!(escape_html_content("hello"), "hello");
413 assert_eq!(
414 escape_html_content("<script>alert('xss')</script>"),
415 "<script>alert('xss')</script>"
416 );
417 assert_eq!(escape_html_content("a & b"), "a & b");
418 assert_eq!(
419 escape_html_content(r#"key="value""#),
420 "key="value""
421 );
422 }
423
424 #[tokio::test]
425 async fn test_render_html_escapes_sql_queries() {
426 let toolbar = DebugToolbar::new();
427 toolbar
428 .record_sql_query(
429 "SELECT * FROM users WHERE name = '<script>alert(1)</script>'".to_string(),
430 Duration::from_millis(1),
431 )
432 .await;
433 toolbar.finalize().await;
434
435 let html = toolbar.render_html().await;
436 assert!(!html.contains("<script>"));
437 assert!(html.contains("<script>"));
438 }
439
440 #[tokio::test]
441 async fn test_render_html_escapes_panel_content() {
442 let toolbar = DebugToolbar::new();
443 let panel = DebugPanel {
444 title: "<img src=x onerror=alert(1)>".to_string(),
445 content: vec![],
446 };
447 toolbar.add_panel("<script>".to_string(), panel).await;
448 toolbar.finalize().await;
449
450 let html = toolbar.render_html().await;
451 assert!(!html.contains("<script>"));
452 assert!(!html.contains("<img src=x"));
453 assert!(html.contains("<script>"));
454 assert!(html.contains("<img src=x onerror=alert(1)>"));
455 }
456
457 #[tokio::test]
458 async fn test_disabled_toolbar() {
459 let mut toolbar = DebugToolbar::new();
460 toolbar.set_enabled(false);
461
462 toolbar
463 .record_sql_query("SELECT 1".to_string(), Duration::from_millis(1))
464 .await;
465
466 let timing = toolbar.get_timing().await;
467 assert_eq!(timing.sql_queries, 0);
468 }
469}