1use crate::inspector::Inspector;
7use crate::task::{TaskInfo, TaskState};
8use std::fmt::Write as FmtWrite;
9
10pub struct HtmlReporter {
12 inspector: Inspector,
13}
14
15impl HtmlReporter {
16 #[must_use]
18 pub fn new(inspector: Inspector) -> Self {
19 Self { inspector }
20 }
21
22 #[must_use]
24 pub fn global() -> Self {
25 Self::new(Inspector::global().clone())
26 }
27
28 #[must_use]
30 pub fn generate_html(&self) -> String {
31 let mut html = String::new();
32
33 writeln!(html, "<!DOCTYPE html>").unwrap();
35 writeln!(html, "<html lang=\"en\">").unwrap();
36 writeln!(html, "<head>").unwrap();
37 writeln!(html, " <meta charset=\"UTF-8\">").unwrap();
38 writeln!(
39 html,
40 " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
41 )
42 .unwrap();
43 writeln!(html, " <title>async-inspect Report</title>").unwrap();
44
45 html.push_str(&self.generate_css());
47
48 writeln!(html, "</head>").unwrap();
49 writeln!(html, "<body>").unwrap();
50
51 html.push_str(&self.generate_header());
53
54 writeln!(html, " <div class=\"container\">").unwrap();
56
57 html.push_str(&self.generate_stats_panel());
59
60 html.push_str(&self.generate_timeline_viz());
62
63 html.push_str(&self.generate_state_machine_graph());
65
66 html.push_str(&self.generate_task_list());
68
69 writeln!(html, " </div>").unwrap();
70
71 html.push_str(&self.generate_javascript());
73
74 writeln!(html, "</body>").unwrap();
75 writeln!(html, "</html>").unwrap();
76
77 html
78 }
79
80 fn generate_css(&self) -> String {
82 r"
83 <style>
84 * {
85 margin: 0;
86 padding: 0;
87 box-sizing: border-box;
88 }
89
90 body {
91 font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
92 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
93 min-height: 100vh;
94 padding: 20px;
95 }
96
97 .container {
98 max-width: 1400px;
99 margin: 0 auto;
100 background: white;
101 border-radius: 12px;
102 box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
103 overflow: hidden;
104 }
105
106 header {
107 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
108 color: white;
109 padding: 30px;
110 text-align: center;
111 }
112
113 header h1 {
114 font-size: 2.5em;
115 margin-bottom: 10px;
116 }
117
118 header p {
119 font-size: 1.2em;
120 opacity: 0.9;
121 }
122
123 .stats-panel {
124 display: grid;
125 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
126 gap: 20px;
127 padding: 30px;
128 background: #f8f9fa;
129 border-bottom: 1px solid #e0e0e0;
130 }
131
132 .stat-card {
133 background: white;
134 padding: 20px;
135 border-radius: 8px;
136 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
137 transition: transform 0.2s;
138 }
139
140 .stat-card:hover {
141 transform: translateY(-5px);
142 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
143 }
144
145 .stat-card .label {
146 color: #666;
147 font-size: 0.9em;
148 text-transform: uppercase;
149 letter-spacing: 1px;
150 margin-bottom: 5px;
151 }
152
153 .stat-card .value {
154 font-size: 2em;
155 font-weight: bold;
156 color: #667eea;
157 }
158
159 .timeline-viz {
160 padding: 30px;
161 border-bottom: 1px solid #e0e0e0;
162 }
163
164 .timeline-viz h2 {
165 margin-bottom: 20px;
166 color: #333;
167 }
168
169 .timeline-container {
170 position: relative;
171 height: 400px;
172 background: #f8f9fa;
173 border-radius: 8px;
174 overflow-x: auto;
175 overflow-y: auto;
176 border: 1px solid #e0e0e0;
177 }
178
179 .timeline-svg {
180 width: 100%;
181 min-width: 800px;
182 height: 100%;
183 }
184
185 .task-row {
186 cursor: pointer;
187 transition: opacity 0.2s;
188 }
189
190 .task-row:hover {
191 opacity: 0.8;
192 }
193
194 .task-bar {
195 stroke-width: 2;
196 stroke: white;
197 }
198
199 .task-bar.completed {
200 fill: #4caf50;
201 }
202
203 .task-bar.running {
204 fill: #2196f3;
205 }
206
207 .task-bar.blocked {
208 fill: #ff9800;
209 }
210
211 .task-bar.failed {
212 fill: #f44336;
213 }
214
215 .task-bar.pending {
216 fill: #9e9e9e;
217 }
218
219 .task-list {
220 padding: 30px;
221 }
222
223 .task-list h2 {
224 margin-bottom: 20px;
225 color: #333;
226 }
227
228 .task-item {
229 background: #f8f9fa;
230 border-radius: 8px;
231 padding: 20px;
232 margin-bottom: 15px;
233 cursor: pointer;
234 transition: all 0.2s;
235 border-left: 4px solid #667eea;
236 }
237
238 .task-item:hover {
239 background: #e9ecef;
240 transform: translateX(5px);
241 }
242
243 .task-item.expanded {
244 background: white;
245 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
246 }
247
248 .task-header {
249 display: flex;
250 justify-content: space-between;
251 align-items: center;
252 }
253
254 .task-name {
255 font-weight: bold;
256 font-size: 1.1em;
257 color: #333;
258 }
259
260 .task-state {
261 padding: 5px 15px;
262 border-radius: 20px;
263 font-size: 0.85em;
264 font-weight: bold;
265 text-transform: uppercase;
266 }
267
268 .state-completed {
269 background: #4caf50;
270 color: white;
271 }
272
273 .state-running {
274 background: #2196f3;
275 color: white;
276 }
277
278 .state-blocked {
279 background: #ff9800;
280 color: white;
281 }
282
283 .state-failed {
284 background: #f44336;
285 color: white;
286 }
287
288 .state-pending {
289 background: #9e9e9e;
290 color: white;
291 }
292
293 .task-details {
294 margin-top: 15px;
295 padding-top: 15px;
296 border-top: 1px solid #e0e0e0;
297 display: none;
298 }
299
300 .task-item.expanded .task-details {
301 display: block;
302 }
303
304 .task-meta {
305 display: grid;
306 grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
307 gap: 15px;
308 margin-bottom: 15px;
309 }
310
311 .meta-item {
312 font-size: 0.9em;
313 }
314
315 .meta-label {
316 color: #666;
317 font-weight: bold;
318 margin-bottom: 3px;
319 }
320
321 .meta-value {
322 color: #333;
323 }
324
325 .events-section {
326 margin-top: 15px;
327 }
328
329 .events-section h4 {
330 margin-bottom: 10px;
331 color: #667eea;
332 }
333
334 .event-item {
335 background: white;
336 padding: 10px;
337 margin-bottom: 8px;
338 border-radius: 4px;
339 border-left: 3px solid #667eea;
340 font-size: 0.9em;
341 }
342
343 .event-time {
344 color: #666;
345 font-family: 'Courier New', monospace;
346 }
347
348 .legend {
349 display: flex;
350 gap: 20px;
351 margin-top: 15px;
352 padding: 15px;
353 background: white;
354 border-radius: 8px;
355 }
356
357 .legend-item {
358 display: flex;
359 align-items: center;
360 gap: 8px;
361 font-size: 0.9em;
362 }
363
364 .legend-color {
365 width: 20px;
366 height: 20px;
367 border-radius: 4px;
368 }
369
370 @keyframes pulse {
371 0%, 100% { opacity: 1; }
372 50% { opacity: 0.5; }
373 }
374
375 .running-indicator {
376 animation: pulse 1.5s infinite;
377 }
378
379 /* State Machine Graph */
380 .state-machine-graph {
381 padding: 30px;
382 border-bottom: 1px solid #e0e0e0;
383 }
384
385 .state-machine-graph h2 {
386 margin-bottom: 20px;
387 color: #333;
388 }
389
390 .graph-container {
391 background: #f8f9fa;
392 border-radius: 8px;
393 padding: 20px;
394 min-height: 400px;
395 border: 1px solid #e0e0e0;
396 position: relative;
397 }
398
399 .state-node {
400 cursor: pointer;
401 transition: all 0.2s;
402 }
403
404 .state-node:hover {
405 transform: scale(1.05);
406 }
407
408 .state-node circle {
409 stroke-width: 2;
410 transition: all 0.2s;
411 }
412
413 .state-node:hover rect,
414 .state-node:hover circle {
415 stroke-width: 3;
416 }
417
418 .state-node.pending rect,
419 .state-node.pending circle {
420 fill: #9e9e9e;
421 stroke: #757575;
422 }
423
424 .state-node.running rect,
425 .state-node.running circle {
426 fill: #2196f3;
427 stroke: #1976d2;
428 }
429
430 .state-node.blocked rect,
431 .state-node.blocked circle {
432 fill: #ff9800;
433 stroke: #f57c00;
434 }
435
436 .state-node.completed rect,
437 .state-node.completed circle {
438 fill: #4caf50;
439 stroke: #388e3c;
440 }
441
442 .state-node.failed rect,
443 .state-node.failed circle {
444 fill: #f44336;
445 stroke: #d32f2f;
446 }
447
448 .state-node text {
449 fill: white;
450 font-size: 12px;
451 font-weight: bold;
452 text-anchor: middle;
453 pointer-events: none;
454 }
455
456 .state-transition {
457 fill: none;
458 stroke: #999;
459 stroke-width: 2;
460 marker-end: url(#arrowhead);
461 }
462
463 .state-transition.animated {
464 stroke-dasharray: 5, 5;
465 animation: dash 1s linear infinite;
466 }
467
468 @keyframes dash {
469 to {
470 stroke-dashoffset: -10;
471 }
472 }
473
474 .transition-label {
475 font-size: 10px;
476 fill: #666;
477 text-anchor: middle;
478 }
479
480 .graph-legend {
481 margin-top: 15px;
482 padding: 15px;
483 background: white;
484 border-radius: 8px;
485 display: flex;
486 gap: 20px;
487 flex-wrap: wrap;
488 }
489 </style>
490"
491 .to_string()
492 }
493
494 fn generate_header(&self) -> String {
496 let stats = self.inspector.stats();
497 format!(
498 r" <header>
499 <h1>🔍 async-inspect</h1>
500 <p>X-ray vision for async Rust - {} tasks analyzed</p>
501 </header>
502",
503 stats.total_tasks
504 )
505 }
506
507 fn generate_stats_panel(&self) -> String {
509 let stats = self.inspector.stats();
510 let mut html = String::new();
511
512 writeln!(html, " <div class=\"stats-panel\">").unwrap();
513
514 self.add_stat_card(&mut html, "Total Tasks", &stats.total_tasks.to_string());
515 self.add_stat_card(&mut html, "Running", &stats.running_tasks.to_string());
516 self.add_stat_card(&mut html, "Blocked", &stats.blocked_tasks.to_string());
517 self.add_stat_card(&mut html, "Completed", &stats.completed_tasks.to_string());
518 self.add_stat_card(&mut html, "Failed", &stats.failed_tasks.to_string());
519 self.add_stat_card(&mut html, "Total Events", &stats.total_events.to_string());
520 self.add_stat_card(
521 &mut html,
522 "Duration",
523 &format!("{:.2}s", stats.timeline_duration.as_secs_f64()),
524 );
525
526 writeln!(html, " </div>").unwrap();
527
528 html
529 }
530
531 fn add_stat_card(&self, html: &mut String, label: &str, value: &str) {
533 writeln!(html, " <div class=\"stat-card\">").unwrap();
534 writeln!(html, " <div class=\"label\">{label}</div>").unwrap();
535 writeln!(html, " <div class=\"value\">{value}</div>").unwrap();
536 writeln!(html, " </div>").unwrap();
537 }
538
539 fn generate_timeline_viz(&self) -> String {
541 let tasks = self.inspector.get_all_tasks();
542
543 if tasks.is_empty() {
544 return String::from(
545 " <div class=\"timeline-viz\"><p>No tasks to visualize</p></div>",
546 );
547 }
548
549 let mut html = String::new();
550 writeln!(html, " <div class=\"timeline-viz\">").unwrap();
551 writeln!(html, " <h2>Concurrency Timeline</h2>").unwrap();
552 writeln!(html, " <div class=\"timeline-container\">").unwrap();
553
554 html.push_str(&self.generate_svg_timeline(&tasks));
556
557 writeln!(html, " </div>").unwrap();
558
559 writeln!(html, " <div class=\"legend\">").unwrap();
561 writeln!(html, " <div class=\"legend-item\">").unwrap();
562 writeln!(
563 html,
564 " <div class=\"legend-color\" style=\"background: #4caf50;\"></div>"
565 )
566 .unwrap();
567 writeln!(html, " <span>Completed</span>").unwrap();
568 writeln!(html, " </div>").unwrap();
569 writeln!(html, " <div class=\"legend-item\">").unwrap();
570 writeln!(
571 html,
572 " <div class=\"legend-color\" style=\"background: #2196f3;\"></div>"
573 )
574 .unwrap();
575 writeln!(html, " <span>Running</span>").unwrap();
576 writeln!(html, " </div>").unwrap();
577 writeln!(html, " <div class=\"legend-item\">").unwrap();
578 writeln!(
579 html,
580 " <div class=\"legend-color\" style=\"background: #ff9800;\"></div>"
581 )
582 .unwrap();
583 writeln!(html, " <span>Blocked</span>").unwrap();
584 writeln!(html, " </div>").unwrap();
585 writeln!(html, " <div class=\"legend-item\">").unwrap();
586 writeln!(
587 html,
588 " <div class=\"legend-color\" style=\"background: #f44336;\"></div>"
589 )
590 .unwrap();
591 writeln!(html, " <span>Failed</span>").unwrap();
592 writeln!(html, " </div>").unwrap();
593 writeln!(html, " <div class=\"legend-item\">").unwrap();
594 writeln!(
595 html,
596 " <div class=\"legend-color\" style=\"background: #9e9e9e;\"></div>"
597 )
598 .unwrap();
599 writeln!(html, " <span>Pending</span>").unwrap();
600 writeln!(html, " </div>").unwrap();
601 writeln!(html, " </div>").unwrap();
602
603 writeln!(html, " </div>").unwrap();
604
605 html
606 }
607
608 fn generate_svg_timeline(&self, tasks: &[TaskInfo]) -> String {
610 let mut svg = String::new();
611
612 let start_time = tasks
614 .iter()
615 .map(|t| t.created_at)
616 .min()
617 .unwrap_or_else(std::time::Instant::now);
618
619 let end_time = tasks
620 .iter()
621 .map(|t| t.created_at + t.age())
622 .max()
623 .unwrap_or_else(std::time::Instant::now);
624
625 let total_duration = end_time.duration_since(start_time);
626 let total_ms = total_duration.as_millis() as f64;
627
628 let width = 1200.0;
630 let row_height = 40.0;
631 let margin_left = 200.0;
632 let timeline_width = width - margin_left - 50.0;
633 let height = (tasks.len() as f64 * row_height) + 60.0;
634
635 writeln!(svg, "<svg class=\"timeline-svg\" viewBox=\"0 0 {width} {height}\" xmlns=\"http://www.w3.org/2000/svg\">").unwrap();
636
637 self.add_time_axis(&mut svg, margin_left, timeline_width, total_ms);
639
640 for (i, task) in tasks.iter().enumerate() {
642 let y = 50.0 + (i as f64 * row_height);
643 self.add_task_row(
644 &mut svg,
645 task,
646 y,
647 margin_left,
648 timeline_width,
649 start_time,
650 total_ms,
651 );
652 }
653
654 writeln!(svg, "</svg>").unwrap();
655
656 svg
657 }
658
659 fn add_time_axis(&self, svg: &mut String, margin_left: f64, width: f64, total_ms: f64) {
661 let num_markers = 10;
663 for i in 0..=num_markers {
664 let x = margin_left + (f64::from(i) / f64::from(num_markers)) * width;
665 let time_ms = (f64::from(i) / f64::from(num_markers)) * total_ms;
666
667 writeln!(svg, " <line x1=\"{x}\" y1=\"30\" x2=\"{x}\" y2=\"35\" stroke=\"#999\" stroke-width=\"1\" />").unwrap();
668 writeln!(svg, " <text x=\"{}\" y=\"25\" text-anchor=\"middle\" font-size=\"10\" fill=\"#666\">{}ms</text>", x, time_ms as u64).unwrap();
669 }
670
671 writeln!(
673 svg,
674 " <line x1=\"{}\" y1=\"35\" x2=\"{}\" y2=\"35\" stroke=\"#333\" stroke-width=\"2\" />",
675 margin_left,
676 margin_left + width
677 )
678 .unwrap();
679 }
680
681 fn add_task_row(
683 &self,
684 svg: &mut String,
685 task: &TaskInfo,
686 y: f64,
687 margin_left: f64,
688 timeline_width: f64,
689 start_time: std::time::Instant,
690 total_ms: f64,
691 ) {
692 writeln!(svg, " <text x=\"10\" y=\"{}\" font-size=\"12\" font-weight=\"bold\" fill=\"#333\">{}</text>", y + 5.0, task.name).unwrap();
694
695 let task_start = task.created_at.duration_since(start_time).as_millis() as f64;
697 let task_duration = task.age().as_millis() as f64;
698
699 let x = margin_left + (task_start / total_ms) * timeline_width;
700 let bar_width = ((task_duration / total_ms) * timeline_width).max(2.0);
701
702 let state_class = match task.state {
703 TaskState::Completed => "completed",
704 TaskState::Running => "running",
705 TaskState::Blocked { .. } => "blocked",
706 TaskState::Failed => "failed",
707 TaskState::Pending => "pending",
708 };
709
710 writeln!(svg, " <g class=\"task-row\" data-task-id=\"{}\">", task.id).unwrap();
711 writeln!(svg, " <rect class=\"task-bar {}\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"25\" rx=\"3\" />",
712 state_class, x, y - 12.0, bar_width).unwrap();
713 writeln!(
714 svg,
715 " <title>{}: {:.2}ms</title>",
716 task.name, task_duration
717 )
718 .unwrap();
719 writeln!(svg, " </g>").unwrap();
720 }
721
722 fn generate_state_machine_graph(&self) -> String {
724 let mut html = String::new();
725 writeln!(html, " <div class=\"state-machine-graph\">").unwrap();
726 writeln!(html, " <h2>Task Relationship Graph</h2>").unwrap();
727 writeln!(html, " <p style=\"color: #666; margin-bottom: 15px;\">Hierarchical view of task dependencies and interactions</p>").unwrap();
728 writeln!(
729 html,
730 " <div class=\"graph-container\" id=\"state-graph\">"
731 )
732 .unwrap();
733
734 html.push_str(&self.generate_state_machine_svg());
736
737 writeln!(html, " </div>").unwrap();
738
739 writeln!(html, " <div class=\"graph-legend\">").unwrap();
741 writeln!(html, " <div class=\"legend-item\">").unwrap();
742 writeln!(
743 html,
744 " <div class=\"legend-color\" style=\"background: #9e9e9e;\"></div>"
745 )
746 .unwrap();
747 writeln!(html, " <span>Pending</span>").unwrap();
748 writeln!(html, " </div>").unwrap();
749 writeln!(html, " <div class=\"legend-item\">").unwrap();
750 writeln!(
751 html,
752 " <div class=\"legend-color\" style=\"background: #2196f3;\"></div>"
753 )
754 .unwrap();
755 writeln!(html, " <span>Running</span>").unwrap();
756 writeln!(html, " </div>").unwrap();
757 writeln!(html, " <div class=\"legend-item\">").unwrap();
758 writeln!(
759 html,
760 " <div class=\"legend-color\" style=\"background: #ff9800;\"></div>"
761 )
762 .unwrap();
763 writeln!(html, " <span>Blocked</span>").unwrap();
764 writeln!(html, " </div>").unwrap();
765 writeln!(html, " <div class=\"legend-item\">").unwrap();
766 writeln!(
767 html,
768 " <div class=\"legend-color\" style=\"background: #4caf50;\"></div>"
769 )
770 .unwrap();
771 writeln!(html, " <span>Completed</span>").unwrap();
772 writeln!(html, " </div>").unwrap();
773 writeln!(html, " <div class=\"legend-item\">").unwrap();
774 writeln!(
775 html,
776 " <div class=\"legend-color\" style=\"background: #f44336;\"></div>"
777 )
778 .unwrap();
779 writeln!(html, " <span>Failed</span>").unwrap();
780 writeln!(html, " </div>").unwrap();
781 writeln!(html, " </div>").unwrap();
782
783 writeln!(html, " </div>").unwrap();
784
785 html
786 }
787
788 fn generate_state_machine_svg(&self) -> String {
790 use std::collections::{HashMap, HashSet};
791
792 let mut svg = String::new();
793 let tasks = self.inspector.get_all_tasks();
794
795 if tasks.is_empty() {
796 writeln!(svg, "<svg width=\"800\" height=\"400\"><text x=\"400\" y=\"200\" text-anchor=\"middle\" fill=\"#666\">No tasks to visualize</text></svg>").unwrap();
797 return svg;
798 }
799
800 let width = 1000.0;
801 let height = 600.0;
802
803 writeln!(
804 svg,
805 "<svg width=\"{width}\" height=\"{height}\" xmlns=\"http://www.w3.org/2000/svg\">"
806 )
807 .unwrap();
808
809 writeln!(svg, " <defs>").unwrap();
811 writeln!(svg, " <marker id=\"arrowhead\" markerWidth=\"10\" markerHeight=\"10\" refX=\"9\" refY=\"3\" orient=\"auto\">").unwrap();
812 writeln!(
813 svg,
814 " <polygon points=\"0 0, 10 3, 0 6\" fill=\"#999\" />"
815 )
816 .unwrap();
817 writeln!(svg, " </marker>").unwrap();
818 writeln!(svg, " <marker id=\"arrowhead-parent\" markerWidth=\"10\" markerHeight=\"10\" refX=\"9\" refY=\"3\" orient=\"auto\">").unwrap();
819 writeln!(
820 svg,
821 " <polygon points=\"0 0, 10 3, 0 6\" fill=\"#667eea\" />"
822 )
823 .unwrap();
824 writeln!(svg, " </marker>").unwrap();
825 writeln!(svg, " <marker id=\"arrowhead-blocked\" markerWidth=\"10\" markerHeight=\"10\" refX=\"9\" refY=\"3\" orient=\"auto\">").unwrap();
826 writeln!(
827 svg,
828 " <polygon points=\"0 0, 10 3, 0 6\" fill=\"#ff9800\" />"
829 )
830 .unwrap();
831 writeln!(svg, " </marker>").unwrap();
832 writeln!(svg, " </defs>").unwrap();
833
834 let mut parent_child: Vec<(crate::task::TaskId, crate::task::TaskId)> = Vec::new();
836 let mut root_tasks: Vec<&TaskInfo> = Vec::new();
837
838 for task in &tasks {
839 if let Some(parent_id) = task.parent {
840 parent_child.push((parent_id, task.id));
841 } else {
842 root_tasks.push(task);
843 }
844 }
845
846 let mut task_positions: HashMap<crate::task::TaskId, (f64, f64)> = HashMap::new();
848 let mut layers: Vec<Vec<crate::task::TaskId>> = Vec::new();
849
850 if root_tasks.is_empty() {
852 layers.push(tasks.iter().map(|t| t.id).collect());
854 } else {
855 layers.push(root_tasks.iter().map(|t| t.id).collect());
856 }
857
858 let mut processed: HashSet<crate::task::TaskId> = layers[0].iter().copied().collect();
860 loop {
861 let last_layer = layers.last().unwrap();
862 let mut next_layer = Vec::new();
863
864 for &parent_id in last_layer {
865 for &(pid, cid) in &parent_child {
866 if pid == parent_id && !processed.contains(&cid) {
867 next_layer.push(cid);
868 processed.insert(cid);
869 }
870 }
871 }
872
873 if next_layer.is_empty() {
874 break;
875 }
876 layers.push(next_layer);
877 }
878
879 let layer_height = 120.0;
881 let base_y = 80.0;
882
883 for (layer_idx, layer) in layers.iter().enumerate() {
884 let y = base_y + (layer_idx as f64 * layer_height);
885 let layer_width = width - 100.0;
886 let spacing = if layer.len() > 1 {
887 layer_width / (layer.len() - 1) as f64
888 } else {
889 0.0
890 };
891
892 for (i, &task_id) in layer.iter().enumerate() {
893 let x = if layer.len() == 1 {
894 width / 2.0
895 } else {
896 50.0 + (i as f64 * spacing)
897 };
898 task_positions.insert(task_id, (x, y));
899 }
900 }
901
902 for &(parent_id, child_id) in &parent_child {
904 if let (Some(&(x1, y1)), Some(&(x2, y2))) = (
905 task_positions.get(&parent_id),
906 task_positions.get(&child_id),
907 ) {
908 writeln!(svg, " <line class=\"state-transition\" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#667eea\" stroke-width=\"2\" marker-end=\"url(#arrowhead-parent)\" stroke-dasharray=\"5,5\" />",
909 x1, y1 + 35.0, x2, y2 - 35.0).unwrap();
910
911 let mid_x = (x1 + x2) / 2.0;
913 let mid_y = (y1 + y2) / 2.0;
914 writeln!(svg, " <text x=\"{}\" y=\"{}\" class=\"transition-label\" fill=\"#667eea\">spawns</text>",
915 mid_x + 10.0, mid_y).unwrap();
916 }
917 }
918
919 for task in &tasks {
922 if let TaskState::Blocked { ref await_point } = task.state {
923 for other_task in &tasks {
925 if other_task.id != task.id && await_point.contains(&other_task.name) {
926 if let (Some(&(x1, y1)), Some(&(x2, y2))) = (
927 task_positions.get(&task.id),
928 task_positions.get(&other_task.id),
929 ) {
930 writeln!(svg, " <path class=\"state-transition\" d=\"M {} {} Q {} {} {} {}\" stroke=\"#ff9800\" stroke-width=\"2\" marker-end=\"url(#arrowhead-blocked)\" />",
931 x1 + 30.0, y1, x1 + 50.0, (y1 + y2) / 2.0, x2 - 30.0, y2).unwrap();
932
933 writeln!(svg, " <text x=\"{}\" y=\"{}\" class=\"transition-label\" fill=\"#ff9800\">waits for</text>",
934 x1 + 60.0, (y1 + y2) / 2.0).unwrap();
935 }
936 }
937 }
938 }
939 }
940
941 for task in &tasks {
943 if let Some(&(x, y)) = task_positions.get(&task.id) {
944 let state_class = match task.state {
945 TaskState::Pending => "pending",
946 TaskState::Running => "running",
947 TaskState::Blocked { .. } => "blocked",
948 TaskState::Completed => "completed",
949 TaskState::Failed => "failed",
950 };
951
952 writeln!(
954 svg,
955 " <g class=\"state-node {}\" data-task-id=\"{}\">",
956 state_class, task.id
957 )
958 .unwrap();
959 writeln!(svg, " <rect x=\"{}\" y=\"{}\" width=\"120\" height=\"70\" rx=\"10\" ry=\"10\" />", x - 60.0, y - 35.0).unwrap();
960
961 let display_name = if task.name.len() > 12 {
963 format!("{}...", &task.name[..9])
964 } else {
965 task.name.clone()
966 };
967 writeln!(
968 svg,
969 " <text x=\"{}\" y=\"{}\" font-size=\"13\">{}</text>",
970 x,
971 y - 5.0,
972 display_name
973 )
974 .unwrap();
975
976 writeln!(svg, " <text x=\"{}\" y=\"{}\" font-size=\"10\" fill=\"white\" opacity=\"0.8\">#{}</text>",
978 x, y + 12.0, task.id.as_u64()).unwrap();
979
980 let state_text = match task.state {
982 TaskState::Pending => "⏸ Pending",
983 TaskState::Running => "▶ Running",
984 TaskState::Blocked { .. } => "⏳ Blocked",
985 TaskState::Completed => "✓ Done",
986 TaskState::Failed => "✗ Failed",
987 };
988 writeln!(svg, " <text x=\"{}\" y=\"{}\" font-size=\"9\" fill=\"white\" opacity=\"0.9\">{}</text>",
989 x, y + 25.0, state_text).unwrap();
990
991 writeln!(
993 svg,
994 " <title>{}\nState: {:?}\nPoll count: {}\nRuntime: {:.2}ms</title>",
995 task.name,
996 task.state,
997 task.poll_count,
998 task.total_run_time.as_millis()
999 )
1000 .unwrap();
1001 writeln!(svg, " </g>").unwrap();
1002 }
1003 }
1004
1005 let legend_y = height - 80.0;
1007 writeln!(svg, " <text x=\"20\" y=\"{legend_y}\" font-size=\"14\" font-weight=\"bold\" fill=\"#333\">Relationships:</text>").unwrap();
1008 writeln!(svg, " <line x1=\"20\" y1=\"{}\" x2=\"80\" y2=\"{}\" stroke=\"#667eea\" stroke-width=\"2\" stroke-dasharray=\"5,5\" marker-end=\"url(#arrowhead-parent)\" />",
1009 legend_y + 15.0, legend_y + 15.0).unwrap();
1010 writeln!(
1011 svg,
1012 " <text x=\"90\" y=\"{}\" font-size=\"12\" fill=\"#666\">Parent spawns child</text>",
1013 legend_y + 20.0
1014 )
1015 .unwrap();
1016
1017 writeln!(svg, " <line x1=\"20\" y1=\"{}\" x2=\"80\" y2=\"{}\" stroke=\"#ff9800\" stroke-width=\"2\" marker-end=\"url(#arrowhead-blocked)\" />",
1018 legend_y + 35.0, legend_y + 35.0).unwrap();
1019 writeln!(
1020 svg,
1021 " <text x=\"90\" y=\"{}\" font-size=\"12\" fill=\"#666\">Task waits for</text>",
1022 legend_y + 40.0
1023 )
1024 .unwrap();
1025
1026 writeln!(svg, "</svg>").unwrap();
1027
1028 svg
1029 }
1030
1031 fn generate_task_list(&self) -> String {
1033 let tasks = self.inspector.get_all_tasks();
1034 let mut html = String::new();
1035
1036 writeln!(html, " <div class=\"task-list\">").unwrap();
1037 writeln!(html, " <h2>Task Details</h2>").unwrap();
1038
1039 for task in &tasks {
1040 html.push_str(&self.generate_task_item(task));
1041 }
1042
1043 writeln!(html, " </div>").unwrap();
1044
1045 html
1046 }
1047
1048 fn generate_task_item(&self, task: &TaskInfo) -> String {
1050 let mut html = String::new();
1051
1052 let (state_class, state_text) = match task.state {
1053 TaskState::Completed => ("completed", "Completed"),
1054 TaskState::Running => ("running", "Running"),
1055 TaskState::Blocked { .. } => ("blocked", "Blocked"),
1056 TaskState::Failed => ("failed", "Failed"),
1057 TaskState::Pending => ("pending", "Pending"),
1058 };
1059
1060 writeln!(
1061 html,
1062 " <div class=\"task-item\" data-task-id=\"{}\">",
1063 task.id
1064 )
1065 .unwrap();
1066 writeln!(html, " <div class=\"task-header\">").unwrap();
1067 writeln!(
1068 html,
1069 " <div class=\"task-name\">{}</div>",
1070 task.name
1071 )
1072 .unwrap();
1073 writeln!(
1074 html,
1075 " <div class=\"task-state state-{state_class}\">{state_text}</div>"
1076 )
1077 .unwrap();
1078 writeln!(html, " </div>").unwrap();
1079
1080 writeln!(html, " <div class=\"task-details\">").unwrap();
1082 writeln!(html, " <div class=\"task-meta\">").unwrap();
1083 writeln!(html, " <div class=\"meta-item\">").unwrap();
1084 writeln!(
1085 html,
1086 " <div class=\"meta-label\">Task ID</div>"
1087 )
1088 .unwrap();
1089 writeln!(
1090 html,
1091 " <div class=\"meta-value\">{}</div>",
1092 task.id
1093 )
1094 .unwrap();
1095 writeln!(html, " </div>").unwrap();
1096 writeln!(html, " <div class=\"meta-item\">").unwrap();
1097 writeln!(
1098 html,
1099 " <div class=\"meta-label\">Age</div>"
1100 )
1101 .unwrap();
1102 writeln!(
1103 html,
1104 " <div class=\"meta-value\">{:.2}ms</div>",
1105 task.age().as_millis()
1106 )
1107 .unwrap();
1108 writeln!(html, " </div>").unwrap();
1109 writeln!(html, " <div class=\"meta-item\">").unwrap();
1110 writeln!(
1111 html,
1112 " <div class=\"meta-label\">Poll Count</div>"
1113 )
1114 .unwrap();
1115 writeln!(
1116 html,
1117 " <div class=\"meta-value\">{}</div>",
1118 task.poll_count
1119 )
1120 .unwrap();
1121 writeln!(html, " </div>").unwrap();
1122 writeln!(html, " <div class=\"meta-item\">").unwrap();
1123 writeln!(
1124 html,
1125 " <div class=\"meta-label\">Total Runtime</div>"
1126 )
1127 .unwrap();
1128 writeln!(
1129 html,
1130 " <div class=\"meta-value\">{:.2}ms</div>",
1131 task.total_run_time.as_millis()
1132 )
1133 .unwrap();
1134 writeln!(html, " </div>").unwrap();
1135 writeln!(html, " </div>").unwrap();
1136
1137 let events = self.inspector.get_task_events(task.id);
1139 if !events.is_empty() {
1140 writeln!(html, " <div class=\"events-section\">").unwrap();
1141 writeln!(
1142 html,
1143 " <h4>Events ({} total)</h4>",
1144 events.len()
1145 )
1146 .unwrap();
1147 for event in events.iter().take(10) {
1148 writeln!(html, " <div class=\"event-item\">").unwrap();
1149 writeln!(
1150 html,
1151 " <span class=\"event-time\">[{:.3}ms]</span> {}",
1152 event.age().as_millis(),
1153 event.kind
1154 )
1155 .unwrap();
1156 writeln!(html, " </div>").unwrap();
1157 }
1158 if events.len() > 10 {
1159 writeln!(html, " <div style=\"margin-top: 10px; color: #666; font-size: 0.85em;\">... and {} more events</div>", events.len() - 10).unwrap();
1160 }
1161 writeln!(html, " </div>").unwrap();
1162 }
1163
1164 writeln!(html, " </div>").unwrap();
1165 writeln!(html, " </div>").unwrap();
1166
1167 html
1168 }
1169
1170 fn generate_javascript(&self) -> String {
1172 String::from(
1173 r##"
1174 <script>
1175 // Task item click to expand/collapse
1176 document.querySelectorAll('.task-item').forEach(item => {
1177 item.addEventListener('click', (e) => {
1178 // Don't toggle if clicking on a link or button
1179 if (e.target.tagName === 'A' || e.target.tagName === 'BUTTON') {
1180 return;
1181 }
1182 item.classList.toggle('expanded');
1183 });
1184 });
1185
1186 // SVG task row click to highlight corresponding task item
1187 document.querySelectorAll('.task-row').forEach(row => {
1188 row.addEventListener('click', (e) => {
1189 const taskId = row.getAttribute('data-task-id');
1190 const taskItem = document.querySelector(`.task-item[data-task-id="${taskId}"]`);
1191
1192 if (taskItem) {
1193 // Scroll into view
1194 taskItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
1195
1196 // Expand
1197 taskItem.classList.add('expanded');
1198
1199 // Flash highlight
1200 taskItem.style.background = '#fff3cd';
1201 setTimeout(() => {
1202 taskItem.style.background = '';
1203 }, 1000);
1204 }
1205 });
1206 });
1207
1208 // Add smooth scrolling
1209 document.querySelectorAll('a[href^="#"]').forEach(anchor => {
1210 anchor.addEventListener('click', function (e) {
1211 e.preventDefault();
1212 const target = document.querySelector(this.getAttribute('href'));
1213 if (target) {
1214 target.scrollIntoView({ behavior: 'smooth' });
1215 }
1216 });
1217 });
1218 </script>
1219"##,
1220 )
1221 }
1222
1223 pub fn save_to_file(&self, path: &str) -> std::io::Result<()> {
1225 let html = self.generate_html();
1226 std::fs::write(path, html)?;
1227 Ok(())
1228 }
1229}
1230
1231#[cfg(test)]
1232mod tests {
1233 use super::*;
1234
1235 #[test]
1236 fn test_html_generation() {
1237 let inspector = Inspector::new();
1238 inspector.register_task("test_task".to_string());
1239
1240 let reporter = HtmlReporter::new(inspector);
1241 let html = reporter.generate_html();
1242
1243 assert!(html.contains("<!DOCTYPE html>"));
1244 assert!(html.contains("async-inspect"));
1245 assert!(html.contains("test_task"));
1246 }
1247
1248 #[test]
1249 fn test_save_to_file() {
1250 let inspector = Inspector::new();
1251 let reporter = HtmlReporter::new(inspector);
1252
1253 let temp_dir = std::env::temp_dir();
1255 let temp_file = temp_dir.join("async_inspect_test.html");
1256 reporter.save_to_file(temp_file.to_str().unwrap()).unwrap();
1257
1258 let content = std::fs::read_to_string(&temp_file).unwrap();
1259 assert!(content.contains("<!DOCTYPE html>"));
1260
1261 std::fs::remove_file(temp_file).ok();
1263 }
1264}