1use table_to_html::html::Attribute;
30use table_to_html::html::HtmlElement;
31use table_to_html::html::HtmlValue;
32use table_to_html::html::HtmlVisitorMut;
33use table_to_html::HtmlTable;
34use tabled::Table;
35
36use crate::create_file_recursive;
37use crate::reports::events::sqlog_event_list;
38use crate::reports::text::request_timing_table;
39use crate::AppConfig;
40use crate::LogFileParseResult;
41use std::io::Write;
42
43const HTML_INCLUDES: &str = r#"
44<script src="https://code.jquery.com/jquery-3.7.0.js"></script>
45<script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
46<script src="https://cdn.datatables.net/1.13.7/js/dataTables.bootstrap5.min.js"></script>
47<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/css/bootstrap.min.css">
48<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.13.7/css/dataTables.bootstrap5.min.css">
49<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.13.7/css/jquery.dataTables.min.css">
50"#;
51
52const TABLE_INIT_SCRIPT: &str = r#"
53<script type="text/javascript">
54 window.addEventListener("load", (event) => {
55
56 let prefers = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
57 let html = document.querySelector('html');
58
59 html.classList.add(prefers);
60 html.setAttribute('data-bs-theme', prefers);
61
62 new DataTable('table.log-dancer-table',
63 {
64 paging: false,
65 dom: '<"center" flpti >'
66 });
67
68 let loading = document.getElementById("loading");
69 loading.style.visibility = 'hidden';
70
71 let tables = document.getElementById("tables");
72 tables.style.visibility = 'visible';
73 });
74</script>
75"#;
76
77const REQUEST_TABLE_INIT_SCRIPT: &str = r#"
78<script type="text/javascript">
79 window.addEventListener("load", (event) => {
80
81 let prefers = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
82 let html = document.querySelector('html');
83
84 html.classList.add(prefers);
85 html.setAttribute('data-bs-theme', prefers);
86
87 new DataTable('table.log-dancer-table',
88 {
89 paging: false,
90 dom: '<"center" flpti >',
91 columnDefs: [
92 {targets: [0,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19], type: 'html-num'}
93 ]
94 });
95
96 let loading = document.getElementById("loading");
97 loading.style.visibility = 'hidden';
98
99 let tables = document.getElementById("tables");
100 tables.style.visibility = 'visible';
101 });
102</script>
103"#;
104
105const SESSIONS_STYLES: &str = r#"
106<style>
107 .center {
108 margin: auto;
109 width: 90%;
110 padding: 10px;
111}
112
113.yellow {
114 color: yellow
115}
116
117.red {
118 color: red
119}
120
121.green {
122 color: limegreen
123}
124</style>
125"#;
126
127fn inject_table_id_class(
128 input: &HtmlTable, id: Option<String>, class: Option<String>,
129) -> String {
130 let id = if let Some(i) = id {
131 format!("id='{i}'")
132 } else {
133 "".to_string()
134 };
135 let class = if let Some(c) = class {
136 format!("class='{c}'")
137 } else {
138 "".to_string()
139 };
140 let replaced = format!("<table {id} {class}>");
141
142 input.to_string().replace("<table>", &replaced)
143}
144
145pub fn overview(log_file: &LogFileParseResult, config: &AppConfig) {
146 let mut h2 = vec![];
147 let mut quic = vec![];
148
149 for data in &log_file.data {
150 if let Some(h2_close) = &data.datastore.h2_session_close {
151 h2.push(h2_close);
152 }
153
154 if let Some(quic_close) = &data.datastore.quic_session_close {
155 quic.push(quic_close);
156 }
157 }
158
159 let filename = format!("{}-reports/overview.html", config.filename);
160 let mut file = create_file_recursive(&filename).unwrap();
161
162 file.write_all(HTML_INCLUDES.as_bytes()).unwrap();
163 file.write_all(TABLE_INIT_SCRIPT.as_bytes()).unwrap();
164 file.write_all(SESSIONS_STYLES.as_bytes()).unwrap();
165 file.write_all(r#"<html>
166 <head><head>
167 <body>
168 <div>
169 <h1 class="center">Session Overview</h1>
170 <p class="center">This page lists all the sessions (aka connections)
171 that were present in a log file. It is possible to filter only specific
172 SNIs for analysis using the qlog-dancer `--netlog-filter` option.</p>
173 <p class ="center">Analysed session detailed information is presented in
174 <a href="closures.html">session terminations</a>
175 and <a href="requests.html">requests breakdown</a>.</p>
176 "#.as_bytes()).unwrap();
177
178 let all_table = HtmlTable::with_header(Vec::<Vec<String>>::from(
179 Table::builder(log_file.details.sessions.values()),
180 ));
181 file.write_all(
182 inject_table_id_class(
183 &all_table,
184 Some("all_sessions".to_string()),
185 Some(
186 "log-dancer-table cell-border hover compact order-column"
187 .to_string(),
188 ),
189 )
190 .as_bytes(),
191 )
192 .unwrap();
193
194 file.write_all(
195 r#"
196 </div>
197 </body>
198 <html>"#
199 .as_bytes(),
200 )
201 .unwrap();
202}
203
204pub fn closures(log_file: &LogFileParseResult, config: &AppConfig) {
205 let mut h2 = vec![];
206 let mut quic = vec![];
207
208 for data in &log_file.data {
209 if let Some(h2_close) = &data.datastore.h2_session_close {
210 h2.push(h2_close);
211 }
212
213 if let Some(quic_close) = &data.datastore.quic_session_close {
214 quic.push(quic_close);
215 }
216 }
217
218 let filename = format!("{}-reports/closures.html", config.filename);
219 let mut file = create_file_recursive(&filename).unwrap();
220
221 file.write_all(HTML_INCLUDES.as_bytes()).unwrap();
222 file.write_all(TABLE_INIT_SCRIPT.as_bytes()).unwrap();
223 file.write_all(SESSIONS_STYLES.as_bytes()).unwrap();
224 file.write_all(r#"<html>
225 <head><head>
226 <body>
227 <div>
228 <h1 class="center">Session Overview</h1>
229 <p class="center">This page lists all the sessions (aka connections)
230 that were present in a log file and filtered into analysis using the
231 `--netlog-filter` option. Connections are split by HTTP version. A single SNI might
232 have multiple sessions, and it might use multiple HTTP versions. The reason that
233 each session is closed is also captured in the Error column (and subsequent columns).
234 A log that is closed before a session is terminated will not show any value in the
235 columns.</p>
236 <h2 class="center">HTTP/2 Connections</h2>"#.as_bytes()).unwrap();
237
238 let mut h2_html_table =
239 HtmlTable::with_header(Vec::<Vec<String>>::from(Table::builder(h2)));
240 h2_html_table.visit_mut(H2ClosureTableDecorator { i: 0 });
241
242 file.write_all(
243 inject_table_id_class(
244 &h2_html_table,
245 Some("h2_close".to_string()),
246 Some(
247 "log-dancer-table cell-border hover compact order-column"
248 .to_string(),
249 ),
250 )
251 .as_bytes(),
252 )
253 .unwrap();
254
255 file.write_all(
256 r#"
257 <h2 class="center">HTTP/3 & QUIC Connections</h2>"#
258 .as_bytes(),
259 )
260 .unwrap();
261
262 let mut quic_html_table =
263 HtmlTable::with_header(Vec::<Vec<String>>::from(Table::builder(quic)));
264 quic_html_table.visit_mut(QUICClosureTableDecorator { i: 0 });
265 file.write_all(
266 inject_table_id_class(
267 &quic_html_table,
268 Some("quic_close".to_string()),
269 Some(
270 "log-dancer-table cell-border hover compact order-column"
271 .to_string(),
272 ),
273 )
274 .as_bytes(),
275 )
276 .unwrap();
277
278 file.write_all(
279 r#"
280 </div>
281 </body>
282 <html>"#
283 .as_bytes(),
284 )
285 .unwrap();
286}
287
288pub fn requests(log_file: &LogFileParseResult, config: &AppConfig) {
289 let filename = format!("{}-reports/requests.html", config.filename);
290
291 let mut file = create_file_recursive(&filename).unwrap();
292
293 file.write_all(HTML_INCLUDES.as_bytes()).unwrap();
294 file.write_all(REQUEST_TABLE_INIT_SCRIPT.as_bytes())
295 .unwrap();
296 file.write_all(SESSIONS_STYLES.as_bytes()).unwrap();
297 file.write_all(
298 r#"<html>
299 <head><head>
300 <body>
301 <h1 class="center">Summary of All HTTP Requests</h1>
302 <div class="center" id="loading">
303 <strong>Loading data...</strong>
304 <div class="spinner-border" role="status">
305 </div>
306 </div>
307 <div class="center">
308 <p>This page provides information about the requests & responses in a connection.</p>
309 <p> Each individual table represents an HTTP session with a unique ID. Each session is bound
310 to an SNI and has a version. There can be multiple connections to the same SNI depending on the
311 client's behaviour.</p>
312 <p>In each table, a single row represents a request & response. The columns expressing different properties:</p>
313 <details>
314 <Summary>Click to expand</Summary>
315 <p>
316 <ul>
317 <li><strong>ID</strong> - the stream ID of the request & response</li>
318 <li><strong>Method</strong> - the request Method</li>
319 <li><strong>Host</strong> - the request Host (or authority). Due to connection coalescing, this value can be dfifferent from the session SNI</li>
320 <li><strong>Path</strong> - the request Path</li>
321 <li><strong>Status</strong> - the response Status</li>
322 <li><strong>Response Content-Length</strong> - for downloads; the value of the response Content-Length, if any. A response can omit this header. </li>
323 <li><strong>Response Transferred</strong> - for downloads; the actual number of bytes of response that were received. This can be less than Response Content-Length, indicating that the request or connection was terminated early.</li>
324 <li><strong>Download Duration (d2d) (ms)</strong> - the time duration between receiving the first and last DATA frame. This can be 0 for various reasons.</li>
325 <li><strong>Download Rate (d2d) (Mbps)</strong> - the download rate, in megabits/s, between first and last DATA frames. This number has caveats - can be very high if data size or durations are small.</li>
326 <li><strong>Download Duration (h2d) (ms)</strong> - the time duration between receiving the first HEADERS and last DATA frame. This can be 0 for various reasons.</li>
327 <li><strong>Dowload Rate (h2d) (Mbps)</strong> - the download rate, in megabits/s, between first HEADERS and last DATA frames. This number has caveats - can be very high if data size or durations are small.</li>
328 <li><strong>Client Tx Hdr, Rx First Data</strong> - the duration between the client sending a HEADERS frame, and the first DATA frame being received. This is analagous to TTFB.</li>
329 <li><strong>Client Tx Hdr, Rx Last Data</strong> - the duration between the client sending a HEADERS frame, and the last DATA frame being received. This is analagous to TTLB.</li>
330 <li><strong>Request Content-Length</strong> - for uploads; the value of the request Content-Length, if any. A request can omit this header. </li>
331 <li><strong>Request Transferred</strong> - for uploads; the actual number of bytes of request that were sent. This can be less than Request Content-Length, indicating that the request or connection was terminated early.</li>
332 <li><strong>Upload Duration (ms)</strong> - the time duration between sending the first and last DATA frame. This can be 0 for various reasons.</li>
333 <li><strong>Upload Rate (Mbps)</strong> - the upload rate, in megabits/s, between first and last DATA frames. This number has caveats - can be very high if data size or durations are small.</li>
334 <li><strong>Client Priority Header</strong> - the value of the RFC 9218 request Priority header, if any.</li>
335 <li><strong>Server Priority Header</strong> - the value of the RFC 9218 response Priority header, if any.</li>
336 <li><strong>Reset Stream Sent</strong> - the value of the error code in a Reset Stream, if sent.</li>
337 <li><strong>Reset Stream Received</strong> - the value of the error code in a Reset Stream, if received.</li>
338 <li><strong>Stop Sending Sent</strong> - the value of the error code in a Stop Sending, if sent.</li>
339 </ul>
340 </p>
341 </details>
342 </div>
343 <div id="tables" style="visibility: hidden;">
344 "#
345 .as_bytes(),
346 )
347 .unwrap();
348
349 for data in &log_file.data {
350 file.write_all(
351 format!(
352 "<h2 class=\"center\">Session ID: {:?}, {:?}, {:?}</h2>",
353 data.datastore.session_id.unwrap_or(-1),
354 data.datastore
355 .host
356 .clone()
357 .unwrap_or("ERROR UNKNOWN".to_string()),
358 data.datastore.application_proto
359 )
360 .as_bytes(),
361 )
362 .unwrap();
363
364 let table: tabled::builder::Builder =
367 request_timing_table(data, config).unwrap().into();
368 let mut reqs = HtmlTable::with_header(Vec::<Vec<String>>::from(table));
369
370 reqs.visit_mut(RequestTableDecorator { i: 0 });
372
373 file.write_all(
374 inject_table_id_class(
375 &reqs,
376 None,
377 Some(
378 "log-dancer-table cell-border hover compact order-column"
379 .to_string(),
380 ),
381 )
382 .as_bytes(),
383 )
384 .unwrap();
385 }
386
387 file.write_all(
388 r#"
389 </div>
390 </body>
391 <html>"#
392 .as_bytes(),
393 )
394 .unwrap();
395}
396
397pub fn event_list_html_from_sqlog(events: &[qlog::reader::Event]) -> String {
398 let table = sqlog_event_list(events);
399 let table = HtmlTable::with_header(Vec::<Vec<String>>::from(table));
400 inject_table_id_class(
401 &table,
402 None,
403 Some(
404 "log-dancer-table cell-border hover compact order-column".to_string(),
405 ),
406 )
407}
408
409pub fn event_list(log_file: &LogFileParseResult, config: &AppConfig) {
410 let filename = format!("{}-reports/event-list.html", config.filename);
411 let mut file = create_file_recursive(&filename).unwrap();
412
413 file.write_all(HTML_INCLUDES.as_bytes()).unwrap();
414 file.write_all(TABLE_INIT_SCRIPT.as_bytes()).unwrap();
415 file.write_all(SESSIONS_STYLES.as_bytes()).unwrap();
416 file.write_all(
417 r#"<html>
418 <head><head>
419 <body>
420 <div>
421 <h1 class="center">List of events</h1>
422 <p class="center">This page lists all the events
423 that were present in a log file.</p>
424 "#
425 .as_bytes(),
426 )
427 .unwrap();
428
429 for data in &log_file.data {
430 match &data.raw {
431 crate::RawLogEvents::QlogJson { events: _ } =>
432 println!("Support for event list of contained qlog is TODO"),
433 crate::RawLogEvents::QlogJsonSeq { events } => {
434 let table = event_list_html_from_sqlog(events);
435
436 file.write_all(table.as_bytes()).unwrap();
437 },
438 crate::RawLogEvents::Netlog =>
439 println!("Support for event list of netlog is TODO"),
440 }
441 }
442
443 file.write_all(
444 r#"
445 </div>
446 </body>
447 <html>"#
448 .as_bytes(),
449 )
450 .unwrap();
451}
452
453fn table_cell_value(cell: &HtmlElement) -> Option<String> {
454 if cell.tag() == "td" {
455 if let Some(HtmlValue::Elements(elems)) = cell.value() {
456 if let Some(val) = elems.first() {
457 if let Some(HtmlValue::Elements(p)) = val.value() {
458 if let Some(p_val) = p.first() {
459 if let Some(HtmlValue::Content(inner)) = p_val.value() {
460 return Some(inner.clone());
461 }
462 }
463 }
464 }
465 }
466 }
467
468 None
469}
470
471struct H2ClosureTableDecorator {
472 i: usize,
473}
474
475impl HtmlVisitorMut for H2ClosureTableDecorator {
476 fn visit_element_mut(&mut self, e: &mut HtmlElement) -> bool {
477 if e.tag() == "tr" {
478 if self.i == 0 {
479 self.i += 1;
480 return true;
481 }
482
483 let mut mark_red = false;
484 let mut mark_yellow = false;
485 let mut mark_green = false;
486
487 if let Some(HtmlValue::Elements(kids)) = e.value() {
488 if let Some(error) = kids.get(2) {
489 if let Some(err) = table_cell_value(error) {
490 if let Ok(val) = err.parse::<i32>() {
491 if val == -3 {
493 mark_yellow = true;
494 } else if val < 0 {
495 mark_red = true;
496 } else if val == 0 {
497 mark_green = true;
498 }
499 }
500 }
501 }
502 }
503
504 if mark_yellow {
505 let mut attrs = e.attrs().to_vec();
506 attrs.push(Attribute::new("class", "yellow".to_string()));
507 *e = HtmlElement::new("tr", attrs, e.value().cloned());
508 } else if mark_red {
509 let mut attrs = e.attrs().to_vec();
510 attrs.push(Attribute::new("class", "red".to_string()));
511 *e = HtmlElement::new("tr", attrs, e.value().cloned());
512 } else if mark_green {
513 let mut attrs = e.attrs().to_vec();
514 attrs.push(Attribute::new("class", "green".to_string()));
515 *e = HtmlElement::new("tr", attrs, e.value().cloned());
516 }
517 }
518
519 true
520 }
521}
522
523struct QUICClosureTableDecorator {
524 i: usize,
525}
526
527impl HtmlVisitorMut for QUICClosureTableDecorator {
528 fn visit_element_mut(&mut self, e: &mut HtmlElement) -> bool {
529 if e.tag() == "tr" {
530 if self.i == 0 {
531 self.i += 1;
532 return true;
533 }
534
535 let mut mark_red = false;
536 let mut mark_yellow = false;
537 let mut mark_green = false;
538
539 if let Some(HtmlValue::Elements(kids)) = e.value() {
540 if let Some(error) = kids.get(2) {
541 if let Some(err) = table_cell_value(error) {
542 if let Ok(val) = err.parse::<i32>() {
543 if val == 25 {
545 mark_green = true;
546 } else if val == 70 {
547 mark_yellow = true;
548 } else if val == 199 {
549 mark_red = true;
550 }
551 }
552 }
553 }
554 }
555
556 if mark_yellow {
557 let mut attrs = e.attrs().to_vec();
558 attrs.push(Attribute::new("class", "yellow".to_string()));
559 *e = HtmlElement::new("tr", attrs, e.value().cloned());
560 } else if mark_red {
561 let mut attrs = e.attrs().to_vec();
562 attrs.push(Attribute::new("class", "red".to_string()));
563 *e = HtmlElement::new("tr", attrs, e.value().cloned());
564 } else if mark_green {
565 let mut attrs = e.attrs().to_vec();
566 attrs.push(Attribute::new("class", "green".to_string()));
567 *e = HtmlElement::new("tr", attrs, e.value().cloned());
568 }
569 }
570
571 true
572 }
573}
574
575struct RequestTableDecorator {
576 i: usize,
577}
578
579impl HtmlVisitorMut for RequestTableDecorator {
580 fn visit_element_mut(&mut self, e: &mut HtmlElement) -> bool {
581 if e.tag() == "tr" {
582 if self.i == 0 {
583 self.i += 1;
584 return true;
585 }
586
587 let mut mark_red = false;
588 let mut mark_yellow = false;
589 let mut mark_green = false;
590
591 if let Some(HtmlValue::Elements(kids)) = e.value() {
592 if let Some(reset_sent) = kids.get(24) {
593 if table_cell_value(reset_sent) != Some("n/a".to_string()) {
594 mark_yellow = true;
595 }
596 }
597
598 if let Some(reset_received) = kids.get(25) {
599 if table_cell_value(reset_received) != Some("n/a".to_string())
600 {
601 mark_red = true;
602 }
603 }
604
605 if let Some(status_code) = kids.get(4) {
606 if let Some(val) = table_cell_value(status_code) {
607 if let Ok(val) = val.parse::<u16>() {
611 if val >= 400 {
612 mark_red = true;
613 }
614 } else {
615 mark_red = true;
618 }
619 }
620 }
621 let response_content_length = kids.get(5);
622 let response_content_transferred = kids.get(6);
623
624 if let (Some(cl), Some(tx)) =
625 (response_content_length, response_content_transferred)
626 {
627 let cl = table_cell_value(cl);
628 let tx = table_cell_value(tx);
629
630 if let (Some(length), Some(actual)) = (cl, tx) {
631 if length == "n/a" || length == actual {
634 mark_green = true;
635 } else {
636 mark_red = true;
637 }
638 }
639 }
640 }
641
642 if mark_yellow {
643 let mut attrs = e.attrs().to_vec();
644 attrs.push(Attribute::new("class", "yellow".to_string()));
645 *e = HtmlElement::new("tr", attrs, e.value().cloned());
646 } else if mark_red {
647 let mut attrs = e.attrs().to_vec();
648 attrs.push(Attribute::new("class", "red".to_string()));
649 *e = HtmlElement::new("tr", attrs, e.value().cloned());
650 } else if mark_green {
651 let mut attrs = e.attrs().to_vec();
652 attrs.push(Attribute::new("class", "green".to_string()));
653 *e = HtmlElement::new("tr", attrs, e.value().cloned());
654 }
655 }
656
657 true
658 }
659}