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,
13 pub content: Vec<DebugEntry>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(tag = "type")]
20pub enum DebugEntry {
21 KeyValue {
23 key: String,
25 value: String,
27 },
28 Table {
30 headers: Vec<String>,
32 rows: Vec<Vec<String>>,
34 },
35 Code {
37 language: String,
39 code: String,
41 },
42 Text {
44 text: String,
46 },
47}
48
49#[derive(Debug, Clone, Serialize)]
51pub struct TimingInfo {
52 pub total_time: Duration,
54 pub sql_time: Duration,
56 pub sql_queries: usize,
58 pub cache_hits: usize,
60 pub cache_misses: usize,
62}
63
64#[derive(Debug, Clone, Serialize)]
66pub struct SqlQuery {
67 pub query: String,
69 pub duration: Duration,
71 pub stack_trace: Vec<String>,
73}
74
75pub struct DebugToolbar {
77 panels: Arc<RwLock<HashMap<String, DebugPanel>>>,
78 timing: Arc<RwLock<TimingInfo>>,
79 sql_queries: Arc<RwLock<Vec<SqlQuery>>>,
80 start_time: Instant,
81 enabled: bool,
82}
83
84impl DebugToolbar {
85 pub fn new() -> Self {
96 Self {
97 panels: Arc::new(RwLock::new(HashMap::new())),
98 timing: Arc::new(RwLock::new(TimingInfo {
99 total_time: Duration::from_secs(0),
100 sql_time: Duration::from_secs(0),
101 sql_queries: 0,
102 cache_hits: 0,
103 cache_misses: 0,
104 })),
105 sql_queries: Arc::new(RwLock::new(Vec::new())),
106 start_time: Instant::now(),
107 enabled: true,
108 }
109 }
110 pub fn set_enabled(&mut self, enabled: bool) {
122 self.enabled = enabled;
123 }
124 pub fn is_enabled(&self) -> bool {
135 self.enabled
136 }
137 pub async fn add_panel(&self, id: String, panel: DebugPanel) {
156 if !self.enabled {
157 return;
158 }
159 self.panels.write().await.insert(id, panel);
160 }
161 pub async fn record_sql_query(&self, query: String, duration: Duration) {
178 if !self.enabled {
179 return;
180 }
181
182 let sql_query = SqlQuery {
183 query,
184 duration,
185 stack_trace: vec![],
186 };
187
188 self.sql_queries.write().await.push(sql_query);
189
190 let mut timing = self.timing.write().await;
191 timing.sql_queries += 1;
192 timing.sql_time += duration;
193 }
194 pub async fn record_cache_hit(&self) {
209 if !self.enabled {
210 return;
211 }
212 self.timing.write().await.cache_hits += 1;
213 }
214 pub async fn record_cache_miss(&self) {
229 if !self.enabled {
230 return;
231 }
232 self.timing.write().await.cache_misses += 1;
233 }
234 pub async fn finalize(&self) {
252 if !self.enabled {
253 return;
254 }
255 self.timing.write().await.total_time = self.start_time.elapsed();
256 }
257 pub async fn get_panels(&self) -> HashMap<String, DebugPanel> {
276 self.panels.read().await.clone()
277 }
278 pub async fn get_timing(&self) -> TimingInfo {
293 self.timing.read().await.clone()
294 }
295 pub async fn get_sql_queries(&self) -> Vec<SqlQuery> {
312 self.sql_queries.read().await.clone()
313 }
314 pub async fn render_html(&self) -> String {
330 if !self.enabled {
331 return String::new();
332 }
333
334 let panels = self.get_panels().await;
335 let timing = self.get_timing().await;
336 let queries = self.get_sql_queries().await;
337
338 format!(
339 r#"
340<div class="debug-toolbar">
341 <div class="timing">
342 <h3>Timing</h3>
343 <p>Total: {:?}</p>
344 <p>SQL: {:?} ({} queries)</p>
345 <p>Cache: {} hits, {} misses</p>
346 </div>
347 <div class="sql-queries">
348 <h3>SQL Queries</h3>
349 <ul>
350 {}
351 </ul>
352 </div>
353 <div class="panels">
354 {}
355 </div>
356</div>
357"#,
358 timing.total_time,
359 timing.sql_time,
360 timing.sql_queries,
361 timing.cache_hits,
362 timing.cache_misses,
363 queries
364 .iter()
365 .map(|q| format!(
366 "<li>{} ({:?})</li>",
367 escape_html_content(&q.query),
368 q.duration
369 ))
370 .collect::<Vec<_>>()
371 .join("\n"),
372 panels
373 .iter()
374 .map(|(id, panel)| format!(
375 "<div class='panel' id='{}'><h3>{}</h3></div>",
376 escape_html_content(id),
377 escape_html_content(&panel.title)
378 ))
379 .collect::<Vec<_>>()
380 .join("\n")
381 )
382 }
383}
384
385impl Default for DebugToolbar {
386 fn default() -> Self {
387 Self::new()
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[tokio::test]
396 async fn test_debug_toolbar() {
397 let toolbar = DebugToolbar::new();
398
399 toolbar
400 .record_sql_query("SELECT * FROM users".to_string(), Duration::from_millis(10))
401 .await;
402 toolbar.record_cache_hit().await;
403 toolbar.finalize().await;
404
405 let timing = toolbar.get_timing().await;
406 assert_eq!(timing.sql_queries, 1);
407 assert_eq!(timing.cache_hits, 1);
408
409 let queries = toolbar.get_sql_queries().await;
410 assert_eq!(queries.len(), 1);
411 }
412
413 #[tokio::test]
414 async fn test_debug_panel() {
415 let toolbar = DebugToolbar::new();
416
417 let panel = DebugPanel {
418 title: "Test Panel".to_string(),
419 content: vec![DebugEntry::KeyValue {
420 key: "key".to_string(),
421 value: "value".to_string(),
422 }],
423 };
424
425 toolbar.add_panel("test".to_string(), panel).await;
426
427 let panels = toolbar.get_panels().await;
428 assert_eq!(panels.len(), 1);
429 assert!(panels.contains_key("test"));
430 }
431
432 #[test]
433 fn test_escape_html_content() {
434 assert_eq!(escape_html_content("hello"), "hello");
435 assert_eq!(
436 escape_html_content("<script>alert('xss')</script>"),
437 "<script>alert('xss')</script>"
438 );
439 assert_eq!(escape_html_content("a & b"), "a & b");
440 assert_eq!(
441 escape_html_content(r#"key="value""#),
442 "key="value""
443 );
444 }
445
446 #[tokio::test]
447 async fn test_render_html_escapes_sql_queries() {
448 let toolbar = DebugToolbar::new();
449 toolbar
450 .record_sql_query(
451 "SELECT * FROM users WHERE name = '<script>alert(1)</script>'".to_string(),
452 Duration::from_millis(1),
453 )
454 .await;
455 toolbar.finalize().await;
456
457 let html = toolbar.render_html().await;
458 assert!(!html.contains("<script>"));
459 assert!(html.contains("<script>"));
460 }
461
462 #[tokio::test]
463 async fn test_render_html_escapes_panel_content() {
464 let toolbar = DebugToolbar::new();
465 let panel = DebugPanel {
466 title: "<img src=x onerror=alert(1)>".to_string(),
467 content: vec![],
468 };
469 toolbar.add_panel("<script>".to_string(), panel).await;
470 toolbar.finalize().await;
471
472 let html = toolbar.render_html().await;
473 assert!(!html.contains("<script>"));
474 assert!(!html.contains("<img src=x"));
475 assert!(html.contains("<script>"));
476 assert!(html.contains("<img src=x onerror=alert(1)>"));
477 }
478
479 #[tokio::test]
480 async fn test_disabled_toolbar() {
481 let mut toolbar = DebugToolbar::new();
482 toolbar.set_enabled(false);
483
484 toolbar
485 .record_sql_query("SELECT 1".to_string(), Duration::from_millis(1))
486 .await;
487
488 let timing = toolbar.get_timing().await;
489 assert_eq!(timing.sql_queries, 0);
490 }
491}