Skip to main content

qlog_dancer/reports/
html.rs

1// Copyright (C) 2025, Cloudflare, Inc.
2// All rights reserved.
3//
4// Redistribution and use in source and binary forms, with or without
5// modification, are permitted provided that the following conditions are
6// met:
7//
8//     * Redistributions of source code must retain the above copyright notice,
9//       this list of conditions and the following disclaimer.
10//
11//     * Redistributions in binary form must reproduce the above copyright
12//       notice, this list of conditions and the following disclaimer in the
13//       documentation and/or other materials provided with the distribution.
14//
15// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
16// IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
17// THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
18// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
19// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
20// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
21// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
22// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
23// LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
24// NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
25// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
27//! Reporting (tables etc.)
28
29use 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        // This is a bit weird, we need to get our actual Table and then convert
365        // it back to a builder to pass to HtmlTable.
366        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        // colorize the table
371        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                            // Aborted
492                            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                            // Aborted
544                            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 the status code is unknown, we probably got no
608                        // response
609
610                        if let Ok(val) = val.parse::<u16>() {
611                            if val >= 400 {
612                                mark_red = true;
613                            }
614                        } else {
615                            // if the status code is unknown or mangled, we
616                            // probably got no response
617                            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 there is no content-length, we can't verify
632                        // it was receivd ok.
633                        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}