Improve waterfall.

This commit is contained in:
jcamiel 2023-06-08 10:03:16 +02:00 committed by hurl-bot
parent f98b49d903
commit b1e1ccd084
No known key found for this signature in database
GPG Key ID: 1283A2B4A0DCAF8D
27 changed files with 1778 additions and 911 deletions

View File

@ -17,11 +17,12 @@
*/
//! HTML report
mod file;
mod nav;
mod report;
mod run;
mod source;
mod testcase;
mod waterfall;
mod timeline;
pub use report::write_report;
pub use testcase::Testcase;

View File

@ -0,0 +1,87 @@
/*
* Hurl (https://hurl.dev)
* Copyright (C) 2023 Orange
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
use crate::report::html::Testcase;
use crate::util::logger;
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum Tab {
Timeline,
Run,
Source,
}
impl Testcase {
/// Returns the HTML navigation component for a `tab`.
/// This common component is used to get source information and errors.
pub fn get_nav_html(&self, content: &str, tab: Tab) -> String {
let status = get_status_html(self.success);
let errors = self.get_errors_html(content);
let errors_count = if !self.errors.is_empty() {
self.errors.len().to_string()
} else {
"-".to_string()
};
format!(
include_str!("resources/nav.html"),
duration = self.time_in_ms,
errors = errors,
errors_count = errors_count,
filename = self.filename,
href_run = self.run_filename(),
href_source = self.source_filename(),
href_timeline = self.timeline_filename(),
run_selected = tab == Tab::Run,
source_selected = tab == Tab::Source,
status = status,
timeline_selected = tab == Tab::Timeline,
)
}
/// Formats a list of Hurl errors to HTML snippet.
fn get_errors_html(&self, content: &str) -> String {
self.errors
.iter()
.map(|e| {
let line = e.source_info.start.line;
let column = e.source_info.start.column;
let filename = &self.filename;
let message = logger::error_string(filename, content, e, false);
// We override the first part of the error string to add an anchor to
// the error context.
let old = format!("{filename}:{line}:{column}");
let href = self.source_filename();
let new = format!("<a href=\"{href}#l{line}\">{filename}:{line}:{column}</a>");
let message = message.replace(&old, &new);
format!(
"<div class=\"error\">\
<div class=\"error-desc\"><pre><code>{message}</code></pre></div>\
</div>"
)
})
.collect::<Vec<_>>()
.join("")
}
}
fn get_status_html(success: bool) -> &'static str {
if success {
"<span class=\"success\">Success</span>"
} else {
"<span class=\"failure\">Failure</span>"
}
}

View File

@ -143,7 +143,7 @@ fn create_html_table_row(result: &HTMLResult) -> String {
format!(
r#"<tr class="{status}" data-duration="{duration_in_ms}" data-status="{status}" data-filename="{filename}" data-id="{id}">
<td><a href="store/{id}.html">{displayed_filename}</a></td>
<td><a href="store/{id}-timeline.html">{displayed_filename}</a></td>
<td>{status}</td>
<td>{duration_in_s}</td>
</tr>

View File

@ -1,105 +0,0 @@
body {
font-family: "Helvetica Neue", Arial, sans-serif;
font-size: 1.125rem;
line-height: 1.4;
}
.container {
max-width: 2000px;
width: 100%;
margin-left: auto;
margin-right: auto;
}
.report-nav {
margin-top: 20px;
margin-bottom: 20px;
}
.report-nav-links {
display: flex;
margin-bottom: 20px;
font-weight: bold;
}
.report-nav a {
color: royalblue;
margin-right: 20px;
}
.report-nav a[aria-selected="true"] {
color: #ff0288;
}
.report-nav-summary > div {
display: flex;
}
.report-nav-summary .item-name {
min-width: 100px;
font-weight: bold;
}
.error {
margin-top: 10px;
margin-bottom: 10px;
border-left: red 4px solid;
}
.error-desc {
background: #f5f5f5;
}
.error-desc pre {
font-size: 0.8rem;
padding: 0.8rem;
overflow-x: auto;
}
.line-error {
border-bottom: red 2px dashed;
}
.line-error::after {
content: " ⛔️"
}
.file-container {
max-width: 1200px;
width: 100%;
margin-left: auto;
margin-right: auto;
display: flex;
padding: 0;
border: solid 1px #dcdcde;
}
.line-numbers {
text-align: right;
padding: 8px 10px;
border-right: solid 1px #dcdcde;
background: #fbfafd;
}
.line-numbers a {
color: #89888d;
text-decoration: none;
}
.line-numbers a:hover {
text-decoration: underline;
}
.file {
padding: 8px 10px;
overflow: auto;
overflow-y: hidden;
}
.success, .success a {
color: green;
}
.failure, .failure a {
color: red;
}

View File

@ -1,35 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{filename}</title>
<style>
{file_css}
{hurl_css}
</style>
<body>
<div>
<div class="container">
<div class="report-nav">
<div class="report-nav-links">
<div><a href="../index.html">Report</a></div>
<div><a aria-selected="true" href="{href_file}">File</a></div>
<div><a href="{href_waterfall}">Waterfall</a></div>
</div>
<div class="report-nav-summary">
<div><div class="item-name">File:</div><div><a href="{href_file}">{filename}</a></div></div>
<div><div class="item-name">Status:</div><div>{status}</div></div>
<div><div class="item-name">Duration:</div><div>{duration} ms</div></div>
<div><div class="item-name">Errors:</div><div>{errors_count}</div></div>
<div></div>
</div>
<div class="errors">{errors}</div>
</div>
</div>
<div class="file-container">
<div class="line-numbers">{lines_div}</div>
<div class="file">{file_div}</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,60 @@
.report-nav {
margin-top: 20px;
margin-bottom: 20px;
}
.report-nav-links {
display: flex;
margin-bottom: 20px;
font-weight: bold;
}
.report-nav a {
color: royalblue;
margin-right: 20px;
}
.report-nav a[aria-selected="true"] {
color: #ff0288;
}
.report-nav-summary > div {
display: flex;
}
.report-nav-summary .item-name {
min-width: 100px;
font-weight: bold;
}
.error {
margin-top: 10px;
margin-bottom: 10px;
border-left: red 4px solid;
}
.error-desc {
background: #f5f5f5;
}
.error-desc pre {
font-size: 0.8rem;
line-height: 1.2;
margin: 0.75rem;
padding: 0.8rem;
overflow-x: auto;
}
.error-desc pre code {
font-size: 0.8rem;
line-height: 1.2;
}
.success, .success a {
color: green;
}
.failure, .failure a {
color: red;
}

View File

@ -0,0 +1,16 @@
<div class="report-nav">
<div class="report-nav-links">
<div><a href="../index.html">Report</a></div>
<div><a aria-selected="{timeline_selected}" href="{href_timeline}">Timeline</a></div>
<div><a aria-selected="{run_selected}" href="{href_run}">Run</a></div>
<div><a aria-selected="{source_selected}" href="{href_source}">Source</a></div>
</div>
<div class="report-nav-summary">
<div><div class="item-name">File:</div><div><a href="{href_source}">{filename}</a></div></div>
<div><div class="item-name">Status:</div><div>{status}</div></div>
<div><div class="item-name">Duration:</div><div>{duration} ms</div></div>
<div><div class="item-name">Errors:</div><div>{errors_count}</div></div>
<div></div>
</div>
<div class="errors">{errors}</div>
</div>

View File

@ -0,0 +1,65 @@
body {
font-family: "Helvetica Neue", Arial, sans-serif;
font-size: 1.125rem;
line-height: 1.4;
}
.container {
max-width: 1200px;
width: 100%;
margin-left: auto;
margin-right: auto;
}
h4:target {
color: #ff0288;
}
table {
display: block;
font-size: 15px;
width: 100%;
max-width: 100%;
overflow: auto;
border-collapse: collapse;
margin-top: 16px;
margin-bottom: 16px;
}
th, td {
border-width: 1px;
border-style: solid;
border-color: #ddd;
}
th {
padding: 6px 8px;
text-align: left;
background: #f5f5f5;
}
td {
padding: 6px 8px;
vertical-align: text-top;
}
.name {
width: 120px;
font-weight: bold;
background: #fbfafd;
}
.value {
width: 800px;
word-break: break-all
}
details {
margin-bottom: 20px;
}
summary {
font-size: 1.3rem;
line-height: 1.4;
font-weight: bold;
}

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{filename}</title>
<style>
{run_css}
{nav_css}
</style>
<body>
<div>
<div class="container">
{nav}
{run}
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,48 @@
body {
font-family: "Helvetica Neue", Arial, sans-serif;
font-size: 1.125rem;
line-height: 1.4;
}
.line-error {
border-bottom: red 2px dashed;
}
.line-error::after {
content: " ⛔️"
}
.container {
max-width: 1200px;
width: 100%;
margin-left: auto;
margin-right: auto;
}
.source-container {
display: flex;
padding: 0;
border: solid 1px #dcdcde;
}
.line-numbers {
text-align: right;
padding: 8px 10px;
border-right: solid 1px #dcdcde;
background: #fbfafd;
}
.line-numbers a {
color: #89888d;
text-decoration: none;
}
.line-numbers a:hover {
text-decoration: underline;
}
.source {
padding: 8px 10px;
overflow: auto;
overflow-y: hidden;
}

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{filename}</title>
<style>
{hurl_css}
{nav_css}
{source_css}
</style>
<body>
<div>
<div class="container">
{nav}
<div class="source-container">
<div class="line-numbers">{lines_div}</div>
<div class="source">{source_div}</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,31 @@
body {
font-family: "Helvetica Neue", Arial, sans-serif;
font-size: 1.125rem;
line-height: 1.4;
}
.container {
max-width: 1200px;
width: 100%;
margin-left: auto;
margin-right: auto;
}
.timeline-container {
border: solid 1px #dcdcde;
display: flex;
}
.calls {
position: sticky;
left: 0;
right: 0;
width: 260px;
flex-shrink: 0;
}
.waterfall {
overflow: auto;
overflow-y: hidden;
}

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{filename}</title>
<style>
{timeline_css}
{nav_css}
</style>
<body>
<div>
<div class="container">
{nav}
<div class="timeline-container">
<div class="calls">{calls}</div>
<div class="waterfall">{waterfall}</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,45 +1,11 @@
body {
font-family: "Helvetica Neue", Arial, sans-serif;
font-size: 1.125rem;
line-height: 1.4;
.call-detail {
display: none;
}
.container {
max-width: 2000px;
width: 100%;
margin-left: auto;
margin-right: auto;
.call-summary:hover + .call-detail, .call-detail:hover {
display: block;
}
.report-nav {
margin-top: 20px;
margin-bottom: 20px;
}
.report-nav-links {
display: flex;
margin-bottom: 20px;
font-weight: bold;
}
.report-nav a {
color: royalblue;
margin-right: 20px;
}
.report-nav a[aria-selected="true"] {
color: #ff0288;
}
.waterfall-container {
max-width: 2000px;
width: 100%;
margin-left: auto;
margin-right: auto;
border: solid 1px #dcdcde;
}
.waterfall {
overflow: auto;
overflow-y: hidden;
.call-cell {
pointer-events: none;
}

View File

@ -1,25 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{filename}</title>
<style>
{css}
</style>
<body>
<div>
<div class="container">
<div class="report-nav">
<div class="report-nav-links">
<div><a href="../index.html">Report</a></div>
<div><a href="{href_file}">File</a></div>
<div><a aria-selected="true" href="{href_waterfall}">Waterfall</a></div>
</div>
</div>
</div>
<div class="waterfall-container">
<div class="waterfall">{svg}</div>
</div>
</div>
</body>
</html>

View File

@ -1,30 +0,0 @@
.grid-labels {
font: 15px sans-serif;
}
.call-detail {
display: none;
font: 17px sans-serif;
}
.call-summary:hover + .call-detail, .call-detail:hover {
display: block;
}
.call-detail-total {
font-weight: bold;
}
a:link, a:visited {
cursor: pointer;
}
a text, text a {
fill: blue;
text-decoration: underline;
}
a:hover, a:active {
outline: dotted 1px blue;
}

View File

@ -0,0 +1,172 @@
/*
* Hurl (https://hurl.dev)
* Copyright (C) 2023 Orange
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
use crate::http::Call;
use crate::report::html::nav::Tab;
use crate::report::html::Testcase;
use crate::runner::EntryResult;
use hurl_core::ast::HurlFile;
impl Testcase {
/// Creates an HTML view of a run (HTTP status code, response header etc...)
pub fn get_run_html(
&self,
hurl_file: &HurlFile,
content: &str,
entries: &[EntryResult],
) -> String {
let nav = self.get_nav_html(content, Tab::Run);
let nav_css = include_str!("resources/nav.css");
let run_css = include_str!("resources/run.css");
let mut run = String::new();
for (entry_index, e) in entries.iter().enumerate() {
let entry_node = hurl_file.entries.get(entry_index).unwrap();
let line = entry_node.request.space0.source_info.start.line;
let source = self.source_filename();
run.push_str("<details open>");
let info = get_entry_html(e, entry_index + 1);
run.push_str(&info);
for (call_index, c) in e.calls.iter().enumerate() {
let info = get_call_html(
c,
entry_index + 1,
call_index + 1,
&self.filename,
&source,
line,
);
run.push_str(&info);
}
run.push_str("</details>");
}
format!(
include_str!("resources/run.html"),
filename = self.filename,
nav = nav,
nav_css = nav_css,
run = run,
run_css = run_css,
)
}
}
/// Returns an HTML view of an `entry` information as HTML (title, `entry_index` and captures).
fn get_entry_html(entry: &EntryResult, entry_index: usize) -> String {
let mut text = String::new();
text.push_str(&format!("<summary>Entry {entry_index}</summary>"));
if !entry.captures.is_empty() {
let mut values = entry
.captures
.iter()
.map(|c| (&c.name, c.value.to_string()))
.collect::<Vec<(&String, String)>>();
values.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
let table = new_table("Captures", &values);
text.push_str(&table);
}
text
}
/// Returns an HTML view of a `call` (source file, request and response headers, certificate etc...)
fn get_call_html(
call: &Call,
entry_index: usize,
call_index: usize,
filename: &str,
source: &str,
line: usize,
) -> String {
let mut text = String::new();
let id = format!("e{entry_index}:c{call_index}");
text.push_str(&format!("<h4 id=\"{id}\">Call {call_index}</h3>"));
// General
let status = call.response.status.to_string();
let version = call.response.version.to_string();
let url = &call.request.url;
let url = format!("<a href=\"{url}\">{url}</a>");
let source = format!("<a href=\"{source}#l{line}\">{filename}:{line}</a>");
let values = vec![
("Request URL", url.as_str()),
("Request Method", call.request.method.as_str()),
("Version", version.as_str()),
("Status code", status.as_str()),
("Source", source.as_str()),
];
let table = new_table("General", &values);
text.push_str(&table);
// Certificate
if let Some(certificate) = &call.response.certificate {
let start_date = certificate.start_date.to_string();
let end_date = certificate.expire_date.to_string();
let values = vec![
("Subject", certificate.subject.as_str()),
("Issuer", certificate.issuer.as_str()),
("Start Date", start_date.as_str()),
("Expire Date", end_date.as_str()),
("Serial Number", certificate.serial_number.as_str()),
];
let table = new_table("Certificate", &values);
text.push_str(&table);
}
let mut values = call
.request
.headers
.iter()
.map(|h| (h.name.as_str(), h.value.as_str()))
.collect::<Vec<(&str, &str)>>();
values.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
let table = new_table("Request Headers", &values);
text.push_str(&table);
let mut values = call
.response
.headers
.iter()
.map(|h| (h.name.as_str(), h.value.as_str()))
.collect::<Vec<(&str, &str)>>();
values.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
let table = new_table("Response Headers", &values);
text.push_str(&table);
text
}
fn new_table<T: AsRef<str>, U: AsRef<str>>(title: &str, data: &[(T, U)]) -> String {
let mut text = String::new();
text.push_str(&format!(
"<table><thead><tr><th colspan=\"2\">{title}</tr></th></thead><tbody>"
));
data.iter().for_each(|(name, value)| {
text.push_str(&format!(
"<tr><td class=\"name\">{}</td><td class=\"value\">{}</td></tr>",
name.as_ref(),
value.as_ref()
));
});
text.push_str("</tbody></table>");
text
}

View File

@ -15,88 +15,32 @@
* limitations under the License.
*
*/
use crate::report::html::nav::Tab;
use hurl_core::ast::HurlFile;
use regex::{Captures, Regex};
use std::fs::File;
use std::io::Write;
use std::path::Path;
use hurl_core::parser;
use crate::report::html::Testcase;
use crate::report::Error;
use crate::runner::{EntryResult, Error as RunnerError};
use crate::util::logger;
use crate::runner::Error as RunnerError;
impl Testcase {
/// Exports a [`Testcase`] to HTML.
///
/// It will create two HTML files:
/// - an HTML for the Hurl source file (with potential errors and syntax colored),
/// - an HTML for the entries waterfall
pub fn write_html(
&self,
content: &str,
entries: &[EntryResult],
dir_path: &Path,
) -> Result<(), Error> {
// We create the HTML Hurl source file.
let output_file = dir_path.join("store").join(format!("{}.html", self.id));
let mut file = File::create(output_file)?;
// We parse the content as we'll reuse the AST to construct the HTML source file, and
// the waterfall.
// TODO: for the moment, we can only have parseable file.
let hurl_file = parser::parse_hurl_file(content).unwrap();
let html = self.get_file_html(&hurl_file, content);
file.write_all(html.as_bytes())?;
// Then we create the HTML entries waterfall.
let output_file = dir_path
.join("store")
.join(format!("{}-waterfall.html", self.id));
let mut file = File::create(output_file)?;
let html = self.get_waterfall_html(&hurl_file, entries);
file.write_all(html.as_bytes())?;
Ok(())
}
/// Returns the HTML string of the Hurl source file (syntax colored and errors).
fn get_file_html(&self, hurl_file: &HurlFile, content: &str) -> String {
let file_div = hurl_core::format::format_html(hurl_file, false);
let file_div = underline_errors(&file_div, &self.errors);
pub fn get_source_html(&self, hurl_file: &HurlFile, content: &str) -> String {
let nav = self.get_nav_html(content, Tab::Source);
let nav_css = include_str!("resources/nav.css");
let source_div = hurl_core::format::format_html(hurl_file, false);
let source_div = underline_errors(&source_div, &self.errors);
let lines_div = get_numbered_lines(content);
let file_css = include_str!("resources/file.css");
let status = if self.success {
"<span class=\"success\">Success</span>"
} else {
"<span class=\"failure\">Failure</span>"
};
let errors = get_html_errors(&self.filename, content, &self.errors);
let errors_count = if !errors.is_empty() {
errors.len().to_string()
} else {
"-".to_string()
};
let source_css = include_str!("resources/source.css");
let hurl_css = hurl_core::format::hurl_css();
let href_file = format!("{}.html", self.id);
let href_waterfall = format!("{}-waterfall.html", self.id);
format!(
include_str!("resources/file.html"),
file_css = file_css,
include_str!("resources/source.html"),
filename = self.filename,
hurl_css = hurl_css,
lines_div = lines_div,
file_div = file_div,
errors_count = errors_count,
errors = errors,
filename = self.filename,
status = status,
href_file = href_file,
href_waterfall = href_waterfall,
duration = self.time_in_ms
nav = nav,
nav_css = nav_css,
source_div = source_div,
source_css = source_css,
)
}
}
@ -115,29 +59,6 @@ fn get_numbered_lines(content: &str) -> String {
lines
}
/// Formats a list of Hurl errors to HTML snippet.
fn get_html_errors(filename: &str, content: &str, errors: &[RunnerError]) -> String {
errors
.iter()
.map(|e| {
let line = e.source_info.start.line;
let column = e.source_info.start.column;
let message = logger::error_string(filename, content, e, false);
// We override the first part of the error string to add an anchor to
// the error context.
let old = format!("{filename}:{line}:{column}");
let new = format!("<a href=\"#l{line}\">{filename}:{line}:{column}</a>");
let message = message.replace(&old, &new);
format!(
"<div class=\"error\">\
<div class=\"error-desc\"><pre><code>{message}</code></pre></div>\
</div>"
)
})
.collect::<Vec<_>>()
.join("")
}
/// Adds error class to `content` lines that triggers `errors`.
fn underline_errors(content: &str, errors: &[RunnerError]) -> String {
// In nutshell, we're replacing line `<span class="line">...</span>`

View File

@ -15,9 +15,13 @@
* limitations under the License.
*
*/
use hurl_core::parser;
use std::fs::File;
use std::io::Write;
use std::path::Path;
use uuid::Uuid;
use crate::runner::{Error, HurlResult};
use crate::runner::{EntryResult, Error, HurlResult};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Testcase {
@ -44,4 +48,54 @@ impl Testcase {
.collect(),
}
}
/// Exports a [`Testcase`] to HTML.
///
/// It will create three HTML files:
/// - an HTML view of the Hurl source file (with potential errors and syntax colored),
/// - an HTML timeline view of the executed entries (with potential errors, waterfall)
/// - an HTML view of the executed run (headers, cookies, etc...)
pub fn write_html(
&self,
content: &str,
entries: &[EntryResult],
dir_path: &Path,
) -> Result<(), crate::report::Error> {
// We parse the content as we'll reuse the AST to construct the HTML source file, and
// the waterfall.
// TODO: for the moment, we can only have parseable file.
let hurl_file = parser::parse_hurl_file(content).unwrap();
// We create the timeline view.
let output_file = dir_path.join("store").join(self.timeline_filename());
let mut file = File::create(output_file)?;
let html = self.get_timeline_html(&hurl_file, content, entries);
file.write_all(html.as_bytes())?;
// Then create the run view.
let output_file = dir_path.join("store").join(self.run_filename());
let mut file = File::create(output_file)?;
let html = self.get_run_html(&hurl_file, content, entries);
file.write_all(html.as_bytes())?;
// And create the source view.
let output_file = dir_path.join("store").join(self.source_filename());
let mut file = File::create(output_file)?;
let html = self.get_source_html(&hurl_file, content);
file.write_all(html.as_bytes())?;
Ok(())
}
pub fn source_filename(&self) -> String {
format!("{}-source.html", self.id)
}
pub fn timeline_filename(&self) -> String {
format!("{}-timeline.html", self.id)
}
pub fn run_filename(&self) -> String {
format!("{}-run.html", self.id)
}
}

View File

@ -0,0 +1,164 @@
/*
* Hurl (https://hurl.dev)
* Copyright (C) 2023 Orange
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
use crate::http::Call;
use crate::report::html::timeline::svg::Attribute::{
Fill, FontFamily, FontSize, Height, Href, TextDecoration, ViewBox, Width, X, Y,
};
use crate::report::html::timeline::svg::{Element, ElementKind};
use crate::report::html::timeline::unit::{Pixel, Px};
use crate::report::html::timeline::util::{new_failure_icon, new_success_icon, trunc_str};
use crate::report::html::timeline::{svg, CallContext, CALL_HEIGHT};
use crate::report::html::Testcase;
use std::iter::zip;
impl Testcase {
/// Returns a SVG view of `calls` list using contexts `call_ctxs`.
pub fn get_calls_svg(&self, calls: &[&Call], call_ctxs: &[CallContext]) -> String {
let margin_top = 50.px();
let margin_bottom = 250.px();
let call_height = 24.px();
let width = 260.px();
let height = call_height * calls.len() + margin_top + margin_bottom;
let height = Pixel::max(100.px(), height);
let mut root = svg::new_svg();
root.add_attr(ViewBox(0.0, 0.0, width.0, height.0));
root.add_attr(Width(width.0.to_string()));
root.add_attr(Height(height.0.to_string()));
// Add symbols fo success and failure icons:
let symbol = new_success_icon("success");
root.add_child(symbol);
let symbol = new_failure_icon("failure");
root.add_child(symbol);
// Add a flat background.
let mut elt = Element::new(ElementKind::Rect);
elt.add_attr(X(0.0));
elt.add_attr(Y(0.0));
elt.add_attr(Width("100%".to_string()));
elt.add_attr(Height("100%".to_string()));
elt.add_attr(Fill("#fbfafd".to_string()));
root.add_child(elt);
// Add horizontal lines
let x = 0.px();
let y = margin_top;
let elt = new_grid(calls, y, width, height);
root.add_child(elt);
// Add calls info
let elt = new_calls(calls, call_ctxs, x, y);
root.add_child(elt);
root.to_string()
}
}
/// Returns an SVG view of a list of `call`.
/// For instance:
///
/// `✅ GET www.google.fr 303 <run>`
fn new_calls(
calls: &[&Call],
call_ctxs: &[CallContext],
offset_x: Pixel,
offset_y: Pixel,
) -> Element {
let mut group = svg::new_group();
group.add_attr(FontSize("13px".to_string()));
group.add_attr(FontFamily("sans-serif".to_string()));
group.add_attr(Fill("#777".to_string()));
let margin_left = 13.px();
zip(calls, call_ctxs)
.enumerate()
.for_each(|(index, (call, call_ctx))| {
let mut x = offset_x + margin_left;
let y = offset_y + (CALL_HEIGHT * index) + CALL_HEIGHT - 7.px();
// Icon success / failure
let mut elt = svg::new_use();
let icon = if call_ctx.success {
"#success"
} else {
"#failure"
};
elt.add_attr(Href(icon.to_string()));
elt.add_attr(X(x.0 - 6.0));
elt.add_attr(Y(y.0 - 11.0));
elt.add_attr(Width("13".to_string()));
elt.add_attr(Height("13".to_string()));
group.add_child(elt);
x += 12.px();
// URL
let url = &call.request.url;
let url = url.strip_prefix("http://").unwrap_or(url);
let url = url.strip_prefix("https://").unwrap_or(url);
let text = format!("{} {url}", call.request.method);
let text = trunc_str(&text, 24);
let mut elt = svg::new_text(x.0, y.0, &text);
if !call_ctx.success {
elt.add_attr(Fill("red".to_string()));
}
group.add_child(elt);
// Status code
x += 180.px();
let text = format!("{}", call.response.status);
let mut elt = svg::new_text(x.0, y.0, &text);
if !call_ctx.success {
elt.add_attr(Fill("red".to_string()));
}
group.add_child(elt);
// Source
x += 28.px();
let href = format!(
"{}#e{}:c{}",
call_ctx.run_filename, call_ctx.entry_index, call_ctx.call_entry_index
);
let mut a = svg::new_a(&href);
let mut text = svg::new_text(x.0, y.0, "run");
text.add_attr(Fill("royalblue".to_string()));
text.add_attr(TextDecoration("underline".to_string()));
a.add_child(text);
group.add_child(a);
});
group
}
/// Returns a SVG view of the grid calls.
fn new_grid(calls: &[&Call], offset_y: Pixel, width: Pixel, height: Pixel) -> Element {
let mut group = svg::new_group();
let nb_lines = 2 * (calls.len() / 2) + 2;
(0..nb_lines).for_each(|index| {
let y = CALL_HEIGHT * index + offset_y - (index % 2).px();
let elt = svg::new_rect(0.0, y.0, width.0, 1.0, "#ddd");
group.add_child(elt);
});
// Right borders:
let elt = svg::new_rect(width.0 - 1.0, 0.0, 1.0, height.0, "#ddd");
group.add_child(elt);
group
}

View File

@ -0,0 +1,101 @@
/*
* Hurl (https://hurl.dev)
* Copyright (C) 2023 Orange
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
use crate::http::Call;
use crate::report::html::nav::Tab;
use crate::report::html::timeline::unit::Pixel;
use crate::report::html::Testcase;
use crate::runner::EntryResult;
use hurl_core::ast::HurlFile;
mod calls;
mod nice;
mod svg;
mod unit;
mod util;
mod waterfall;
/// Some common constants used to construct our SVG timeline.
const CALL_HEIGHT: Pixel = Pixel(24.0);
const CALL_INSET: Pixel = Pixel(3.0);
/// A structure that holds information to construct a SVG view
/// of a [`Call`]
pub struct CallContext {
pub success: bool, // If the parent entry is successful or not
pub line: usize, // Line number of the source entry (1-based)
pub entry_index: usize, // Index of the runtime EntryResult
pub call_entry_index: usize, // Index of the runtime Call in the current entry
pub call_index: usize, // Index of the runtime Call in the whole run
pub source_filename: String,
pub run_filename: String,
}
impl Testcase {
/// Returns the HTML timeline of these `entries`.
/// The AST `hurl_file` is used to construct URL with line numbers to the corresponding
/// entry in the colored HTML source file.
pub fn get_timeline_html(
&self,
hurl_file: &HurlFile,
content: &str,
entries: &[EntryResult],
) -> String {
let calls = entries
.iter()
.flat_map(|e| &e.calls)
.collect::<Vec<&Call>>();
let call_ctxs = self.get_call_contexts(hurl_file, entries);
let timeline_css = include_str!("../resources/timeline.css");
let nav = self.get_nav_html(content, Tab::Timeline);
let nav_css = include_str!("../resources/nav.css");
let calls_svg = self.get_calls_svg(&calls, &call_ctxs);
let waterfall_svg = self.get_waterfall_svg(&calls, &call_ctxs);
format!(
include_str!("../resources/timeline.html"),
calls = calls_svg,
filename = self.filename,
nav = nav,
nav_css = nav_css,
timeline_css = timeline_css,
waterfall = waterfall_svg,
)
}
/// Constructs a list of call contexts to record source line code, runtime entry and call indices.
fn get_call_contexts(&self, hurl_file: &HurlFile, entries: &[EntryResult]) -> Vec<CallContext> {
let mut calls_ctx = vec![];
for (entry_index, e) in entries.iter().enumerate() {
for (call_entry_index, _) in e.calls.iter().enumerate() {
let entry_src = hurl_file.entries.get(entry_index).unwrap();
let line = entry_src.request.space0.source_info.start.line;
let ctx = CallContext {
success: e.errors.is_empty(),
line,
entry_index: entry_index + 1,
call_entry_index: call_entry_index + 1,
call_index: calls_ctx.len() + 1,
source_filename: self.source_filename(),
run_filename: self.run_filename(),
};
calls_ctx.push(ctx);
}
}
calls_ctx
}
}

View File

@ -85,7 +85,7 @@ fn nice_number(range: f64, round: bool) -> f64 {
#[cfg(test)]
mod tests {
use crate::report::html::waterfall::NiceScale;
use crate::report::html::timeline::nice::NiceScale;
#[test]
fn test_nice_scale() {

View File

@ -29,10 +29,13 @@ pub enum ElementKind {
Filter, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/filter
Group, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g
Line, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/line
Path, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/path
Rect, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/rect
Style, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/style
Svg, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/svg
Symbol, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/symbol
Text, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text
Use, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/use
}
impl ElementKind {
@ -45,10 +48,13 @@ impl ElementKind {
ElementKind::FeDropShadow => "feDropShadow",
ElementKind::Group => "g",
ElementKind::Line => "line",
ElementKind::Path => "path",
ElementKind::Rect => "rect",
ElementKind::Style => "style",
ElementKind::Svg => "svg",
ElementKind::Symbol => "symbol",
ElementKind::Text => "text",
ElementKind::Use => "use",
}
}
}
@ -77,11 +83,11 @@ impl Element {
/// Adds an attribute `attr` to this element.
pub fn add_attr(&mut self, attr: Attribute) {
self.attrs.push(attr)
self.attrs.push(attr);
}
/// Returns an iterator over these element's attributes.
pub fn get_attrs(&self) -> Iter<'_, Attribute> {
pub fn attrs(&self) -> Iter<'_, Attribute> {
self.attrs.iter()
}
@ -91,7 +97,7 @@ impl Element {
}
/// Returns an iterator over these element's children.
pub fn get_children(&self) -> Iter<'_, Element> {
pub fn children(&self) -> Iter<'_, Element> {
self.children.iter()
}
@ -101,7 +107,7 @@ impl Element {
}
/// Returns this element's kind.
pub fn get_kind(&self) -> ElementKind {
pub fn kind(&self) -> ElementKind {
self.kind
}
@ -116,7 +122,7 @@ impl Element {
}
/// Returns the content if this element or an empty string if this element has no content.
pub fn get_content(&self) -> &str {
pub fn content(&self) -> &str {
match &self.content {
None => "",
Some(e) => e,
@ -125,27 +131,27 @@ impl Element {
/// Serializes this element to a SVG string.
fn to_svg(&self) -> String {
let name = self.get_kind().name();
let name = self.kind().name();
let mut s = String::from("<");
s.push_str(name);
if self.get_kind() == ElementKind::Svg {
if self.kind() == ElementKind::Svg {
// Attributes specific to svg
push_attr(&mut s, "xmlns", "http://www.w3.org/2000/svg");
}
for att in self.get_attrs() {
for att in self.attrs() {
s.push(' ');
s.push_str(&att.to_string());
}
if self.has_children() || self.has_content() {
s.push('>');
for child in self.get_children() {
for child in self.children() {
s.push_str(&child.to_svg());
}
s.push_str(self.get_content());
s.push_str(self.content());
s.push_str("</");
s.push_str(name);
s.push('>');
@ -170,22 +176,30 @@ fn push_attr(f: &mut String, key: &str, value: &str) {
/// SVG elements can be modified using attributes.
/// This list of attributes is __partial__ and only includes attributes necessary for Hurl waterfall
/// export. See <https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute>
// TODO: fond a better way to represent unit. For the moment X attribute
// take a float but X could be "10", "10px", "10%".
#[derive(Clone, Debug, PartialEq)]
pub enum Attribute {
Class(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/class
DX(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/dx
DY(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/dy
Fill(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/fill
Filter(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/filter
FloodOpacity(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/flood-opacity
Height(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/height
Href(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/href
Id(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/id
StdDeviation(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stdDeviation
Stroke(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke
StrokeWidth(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-width
Class(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/class
D(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d
DX(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/dx
DY(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/dy
Fill(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/fill
Filter(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/filter
FloodOpacity(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/flood-opacity
FontFamily(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/font-family
FontSize(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/font-size
FontWeight(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/font-weight
Height(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/height
Href(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/href
Id(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/id
Opacity(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/opacity
StdDeviation(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stdDeviation
Stroke(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke
StrokeWidth(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-width
TextDecoration(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/text-decoration
ViewBox(f64, f64, f64, f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox
Width(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/width
Width(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/width
X(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/x
X1(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/x1
X2(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/x2
@ -198,17 +212,23 @@ impl Attribute {
fn name(&self) -> &'static str {
match self {
Attribute::Class(_) => "class",
Attribute::D(_) => "d",
Attribute::DX(_) => "dx",
Attribute::DY(_) => "dy",
Attribute::Fill(_) => "fill",
Attribute::Filter(_) => "filter",
Attribute::FloodOpacity(_) => "flood-opacity",
Attribute::FontFamily(_) => "font-family",
Attribute::FontSize(_) => "font-size",
Attribute::FontWeight(_) => "font-weight",
Attribute::Height(_) => "height",
Attribute::Href(_) => "href",
Attribute::Id(_) => "id",
Attribute::Opacity(_) => "opacity",
Attribute::StdDeviation(_) => "stdDeviation",
Attribute::Stroke(_) => "stroke",
Attribute::StrokeWidth(_) => "stroke-width",
Attribute::TextDecoration(_) => "text-decoration",
Attribute::ViewBox(_, _, _, _) => "viewBox",
Attribute::Width(_) => "width",
Attribute::X(_) => "x",
@ -225,17 +245,23 @@ impl fmt::Display for Attribute {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let value = match self {
Attribute::Class(value) => value.clone(),
Attribute::D(value) => value.clone(),
Attribute::DX(value) => value.to_string(),
Attribute::DY(value) => value.to_string(),
Attribute::Fill(value) => value.clone(),
Attribute::Filter(value) => value.clone(),
Attribute::FloodOpacity(value) => value.to_string(),
Attribute::FontFamily(value) => value.clone(),
Attribute::FontSize(value) => value.clone(),
Attribute::FontWeight(value) => value.clone(),
Attribute::Height(value) => value.to_string(),
Attribute::Href(value) => value.to_string(),
Attribute::Id(value) => value.clone(),
Attribute::Opacity(value) => value.to_string(),
Attribute::StdDeviation(value) => value.to_string(),
Attribute::Stroke(value) => value.to_string(),
Attribute::StrokeWidth(value) => value.to_string(),
Attribute::TextDecoration(value) => value.clone(),
Attribute::ViewBox(min_x, min_y, width, height) => {
format!("{min_x} {min_y} {width} {height}")
}
@ -252,31 +278,31 @@ impl fmt::Display for Attribute {
}
/// Returns a new `<a>` element.
pub fn a(href: &str) -> Element {
pub fn new_a(href: &str) -> Element {
let mut elt = Element::new(ElementKind::A);
elt.add_attr(Attribute::Href(href.to_string()));
elt
}
/// Returns a new `<svg>` element.
pub fn svg() -> Element {
pub fn new_svg() -> Element {
Element::new(ElementKind::Svg)
}
/// Returns a new `<g>` element.
pub fn group() -> Element {
pub fn new_group() -> Element {
Element::new(ElementKind::Group)
}
/// Returns a new `<style>` element.
pub fn style(content: &str) -> Element {
pub fn new_style(content: &str) -> Element {
let mut elt = Element::new(ElementKind::Style);
elt.set_content(content);
elt
}
/// Returns a new `<text>` element.
pub fn text(x: f64, y: f64, content: &str) -> Element {
pub fn new_text(x: f64, y: f64, content: &str) -> Element {
let mut elt = Element::new(ElementKind::Text);
elt.add_attr(Attribute::X(x));
elt.add_attr(Attribute::Y(y));
@ -285,7 +311,7 @@ pub fn text(x: f64, y: f64, content: &str) -> Element {
}
/// Returns a new `<line>` element.
pub fn line(x1: f64, y1: f64, x2: f64, y2: f64) -> Element {
pub fn new_line(x1: f64, y1: f64, x2: f64, y2: f64) -> Element {
let mut elt = Element::new(ElementKind::Line);
elt.add_attr(Attribute::X1(x1));
elt.add_attr(Attribute::Y1(y1));
@ -295,31 +321,48 @@ pub fn line(x1: f64, y1: f64, x2: f64, y2: f64) -> Element {
}
/// Returns a new `<rect>` element.
pub fn rect(x: f64, y: f64, width: f64, height: f64, fill: &str) -> Element {
pub fn new_rect(x: f64, y: f64, width: f64, height: f64, fill: &str) -> Element {
let mut elt = Element::new(ElementKind::Rect);
elt.add_attr(Attribute::X(x));
elt.add_attr(Attribute::Y(y));
elt.add_attr(Attribute::Width(width));
elt.add_attr(Attribute::Height(height));
elt.add_attr(Attribute::Width(width.to_string()));
elt.add_attr(Attribute::Height(height.to_string()));
elt.add_attr(Attribute::Fill(fill.to_string()));
elt
}
/// Returns a new `<defs>` element.
pub fn defs() -> Element {
pub fn new_defs() -> Element {
Element::new(ElementKind::Defs)
}
/// Returns a new `<filter>` element.
pub fn filter() -> Element {
pub fn new_filter() -> Element {
Element::new(ElementKind::Filter)
}
/// Returns a new `<feDropShadow>` element.
pub fn fe_drop_shadow() -> Element {
pub fn new_fe_drop_shadow() -> Element {
Element::new(ElementKind::FeDropShadow)
}
/// Returns a new `<symbol>` element.
pub fn new_symbol() -> Element {
Element::new(ElementKind::Symbol)
}
/// Returns a new `<path>` element.
pub fn new_path(d: &str) -> Element {
let mut elt = Element::new(ElementKind::Path);
elt.add_attr(Attribute::D(d.to_string()));
elt
}
/// Returns a new `<use>` element.
pub fn new_use() -> Element {
Element::new(ElementKind::Use)
}
#[cfg(test)]
mod tests {
use super::Attribute::*;
@ -327,7 +370,7 @@ mod tests {
#[test]
fn simple_line_svg() {
let mut elt = line(0.0, 80.0, 100.0, 20.0);
let mut elt = new_line(0.0, 80.0, 100.0, 20.0);
elt.add_attr(Stroke("black".to_string()));
assert_eq!(
elt.to_string(),
@ -337,17 +380,17 @@ mod tests {
#[test]
fn group_svg() {
let mut root = svg();
let mut root = new_svg();
root.add_attr(ViewBox(0.0, 0.0, 100.0, 100.0));
let mut group = group();
let mut group = new_group();
group.add_attr(Fill("white".to_string()));
group.add_attr(Stroke("green".to_string()));
group.add_attr(StrokeWidth(5.0));
let elt = rect(0.0, 0.0, 40.0, 60.0, "#fff");
let elt = new_rect(0.0, 0.0, 40.0, 60.0, "#fff");
group.add_child(elt);
let elt = rect(20.0, 10.0, 3.5, 15.0, "red");
let elt = new_rect(20.0, 10.0, 3.5, 15.0, "red");
group.add_child(elt);
root.add_child(group);

View File

@ -16,10 +16,11 @@
*
*/
use std::cmp::Ordering;
use std::ops::{Add, Sub};
use std::ops::{Add, AddAssign, Mul, Sub, SubAssign};
use chrono::{DateTime, Utc};
/// Represents a second, millisecond or microsecond.
#[derive(Copy, Clone, PartialEq)]
pub enum TimeUnit {
Second(Second),
@ -73,15 +74,18 @@ impl PartialOrd for TimeUnit {
}
}
/// Represents a second.
#[derive(Copy, Clone, PartialEq)]
pub struct Second(pub f64);
/// Represents a microsecond.
impl From<Microsecond> for Second {
fn from(value: Microsecond) -> Self {
Second(value.0 / 1_000_000.0)
}
}
/// Represents a millisecond.
#[derive(Copy, Clone, PartialEq)]
pub struct Millisecond(pub f64);
@ -124,9 +128,30 @@ impl From<TimeUnit> for Microsecond {
}
}
/// Represents a byte.
#[derive(Copy, Clone, PartialEq)]
pub struct Byte(pub f64);
/// Represents a logic pixel.
#[derive(Copy, Clone, PartialEq)]
pub struct Pixel(pub f64);
pub trait Px {
fn px(self) -> Pixel;
}
impl Px for f64 {
fn px(self) -> Pixel {
Pixel(self)
}
}
impl Px for usize {
fn px(self) -> Pixel {
Pixel(self as f64)
}
}
impl Sub for Pixel {
type Output = Pixel;
fn sub(self, rhs: Self) -> Self {
@ -141,6 +166,32 @@ impl Add for Pixel {
}
}
impl Mul<f64> for Pixel {
type Output = Pixel;
fn mul(self, rhs: f64) -> Pixel {
Pixel(self.0 * rhs)
}
}
impl Mul<usize> for Pixel {
type Output = Pixel;
fn mul(self, rhs: usize) -> Pixel {
Pixel(self.0 * rhs as f64)
}
}
impl AddAssign for Pixel {
fn add_assign(&mut self, rhs: Pixel) {
*self = *self + rhs
}
}
impl SubAssign for Pixel {
fn sub_assign(&mut self, rhs: Pixel) {
*self = *self - rhs
}
}
impl From<Pixel> for f64 {
fn from(value: Pixel) -> Self {
value.0
@ -159,6 +210,15 @@ impl PartialOrd for Pixel {
}
}
impl Pixel {
pub fn max(v1: Pixel, v2: Pixel) -> Pixel {
Pixel(f64::max(v1.0, v2.0))
}
pub fn min(v1: Pixel, v2: Pixel) -> Pixel {
Pixel(f64::min(v1.0, v2.0))
}
}
#[derive(Copy, Clone)]
pub struct Interval<Idx: Copy> {
pub start: Idx,
@ -171,6 +231,8 @@ impl<Idx: Copy> Interval<Idx> {
}
}
/// Structure that hold a time interval and a pixel interval.
/// This can be used to easily convert a time to a pixel value.
#[derive(Copy, Clone)]
pub struct Scale {
times: Interval<Microsecond>,
@ -178,6 +240,7 @@ pub struct Scale {
}
impl Scale {
/// Returns a new scale from `times` to `pixels`.
pub fn new(times: Interval<DateTime<Utc>>, pixels: Interval<Pixel>) -> Self {
let duration = times.end - times.start;
let start = Microsecond(0.0);
@ -186,6 +249,7 @@ impl Scale {
Scale { times, pixels }
}
/// Returns a pixel value of `time`.
pub fn to_pixel(self, time: Microsecond) -> Pixel {
let pixel = (time.0 - self.times.start.0) * (self.pixels.end.0 - self.pixels.start.0)
/ (self.times.end.0 - self.times.start.0);

View File

@ -0,0 +1,114 @@
/*
* Hurl (https://hurl.dev)
* Copyright (C) 2023 Orange
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
use crate::report::html::timeline::svg;
use crate::report::html::timeline::svg::Attribute::{Fill, Id, ViewBox};
use crate::report::html::timeline::svg::Element;
use crate::report::html::timeline::unit::{Interval, Pixel, Px};
/// Truncates a `text` if there are more than `max_len` chars (with ellipsis).
pub fn trunc_str(text: &str, max_len: usize) -> String {
if text.len() > max_len {
format!("{}...", &text[0..max_len])
} else {
text.to_string()
}
}
/// Returns the stripe background SVG (1 call over 2)
pub fn new_stripes(
nb_stripes: usize,
stripe_height: Pixel,
pixels_x: Interval<Pixel>,
pixels_y: Interval<Pixel>,
color: &str,
) -> Element {
let mut group = svg::new_group();
let x = pixels_x.start;
let width = pixels_x.end - pixels_x.start;
// We want to have an odd number of stripes to have a filled strip at the bottom.
let nb_calls = 2 * (nb_stripes / 2) + 1;
(0..nb_calls)
.step_by(2)
.map(|index| {
svg::new_rect(
x.0,
(index as f64) * stripe_height.0 + pixels_y.start.0,
width.0,
stripe_height.0,
color,
)
})
.for_each(|r| group.add_child(r));
group
}
/// Returns the SVG success icon identified by `id`.
pub fn new_success_icon(id: &str) -> Element {
new_icon(id, 512.px(), 512.px(), "M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z", "#10bb00")
}
/// Returns the SVG failure icon identified by `id`.
pub fn new_failure_icon(id: &str) -> Element {
new_icon(id, 512.px(), 512.px(), "M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24V264c0 13.3-10.7 24-24 24s-24-10.7-24-24V152c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z", "red")
}
/// Returns a SVG icon identified by `id`, with a `width` pixel by `height` pixel size, `path` and `color`.
fn new_icon(id: &str, width: Pixel, height: Pixel, path: &str, color: &str) -> Element {
let mut symbol = svg::new_symbol();
symbol.add_attr(Id(id.to_string()));
symbol.add_attr(ViewBox(0.0, 0.0, width.0, height.0));
let mut path = svg::new_path(path);
path.add_attr(Fill(color.to_string()));
symbol.add_child(path);
symbol
}
#[cfg(test)]
mod tests {
use crate::report::html::timeline::unit::{Interval, Px};
use crate::report::html::timeline::util::{new_stripes, trunc_str};
#[test]
fn truncates() {
assert_eq!(trunc_str("foo", 32), "foo");
assert_eq!(trunc_str("abcdefgh", 3), "abc...");
}
#[test]
fn create_stripes() {
let elt = new_stripes(
10,
1.px(),
Interval::new(0.px(), 10.px()),
Interval::new(0.px(), 10.px()),
"green",
);
assert_eq!(
elt.to_string(),
"<g>\
<rect x=\"0\" y=\"0\" width=\"10\" height=\"1\" fill=\"green\" />\
<rect x=\"0\" y=\"2\" width=\"10\" height=\"1\" fill=\"green\" />\
<rect x=\"0\" y=\"4\" width=\"10\" height=\"1\" fill=\"green\" />\
<rect x=\"0\" y=\"6\" width=\"10\" height=\"1\" fill=\"green\" />\
<rect x=\"0\" y=\"8\" width=\"10\" height=\"1\" fill=\"green\" />\
<rect x=\"0\" y=\"10\" width=\"10\" height=\"1\" fill=\"green\" />\
</g>"
);
}
}

View File

@ -0,0 +1,630 @@
/*
* Hurl (https://hurl.dev)
* Copyright (C) 2023 Orange
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
use chrono::{DateTime, Utc};
use std::iter::zip;
use std::time::Duration;
use crate::http::Call;
use crate::report::html::timeline::nice::NiceScale;
use crate::report::html::timeline::svg::Attribute::{
Class, Fill, Filter, FloodOpacity, FontFamily, FontSize, FontWeight, Height, Href, Id, Opacity,
StdDeviation, Stroke, StrokeWidth, TextDecoration, ViewBox, Width, DX, DY, X, Y,
};
use crate::report::html::timeline::svg::{new_a, Element};
use crate::report::html::timeline::unit::{
Byte, Interval, Microsecond, Millisecond, Pixel, Px, Scale, Second, TimeUnit,
};
use crate::report::html::timeline::util::{
new_failure_icon, new_stripes, new_success_icon, trunc_str,
};
use crate::report::html::timeline::{svg, CallContext, CALL_HEIGHT, CALL_INSET};
use crate::report::html::Testcase;
/// Returns the start and end date for these entries.
fn get_times_interval(calls: &[&Call]) -> Option<Interval<DateTime<Utc>>> {
let begin = calls.first();
let end = calls.last();
match (begin, end) {
(Some(start), Some(end)) => {
let start = start.timings.begin_call;
let end = end.timings.end_call;
Some(Interval { start, end })
}
_ => None,
}
}
impl Testcase {
/// Returns the SVG string of this list of `calls`.
pub fn get_waterfall_svg(&self, calls: &[&Call], call_ctxs: &[CallContext]) -> String {
// Compute our scale (transform 0 based microsecond to 0 based pixels):
let times = get_times_interval(calls);
let times = match times {
Some(t) => t,
None => return "".to_string(),
};
let margin_top = 50.px();
let margin_bottom = 250.px();
let width = 938.px();
let height = (CALL_HEIGHT * calls.len()) + margin_top + margin_bottom;
let height = Pixel::max(100.px(), height);
let mut root = svg::new_svg();
root.add_attr(ViewBox(0.0, 0.0, width.0, height.0));
root.add_attr(Width(width.0.to_string()));
root.add_attr(Height(height.0.to_string()));
// Add styles, filters, symbols fo success and failure icons:
let elt = svg::new_style(include_str!("../resources/waterfall.css"));
root.add_child(elt);
let elt = new_filters();
root.add_child(elt);
let elt = new_success_icon("success");
root.add_child(elt);
let elt = new_failure_icon("failure");
root.add_child(elt);
// We add some space for the right last grid labels.
let pixels_x = Interval::new(0.px(), width);
let pixels_y = Interval::new(margin_top, height);
let scale_x = Scale::new(times, pixels_x);
let ticks_number = 10;
let grid = new_grid(
calls,
times,
ticks_number,
scale_x,
pixels_x,
pixels_y,
CALL_HEIGHT,
);
root.add_child(grid);
let elts = zip(calls, call_ctxs)
.map(|(call, call_ctx)| new_call(call, call_ctx, times, scale_x, pixels_x, pixels_y));
// We construct SVG calls from last to first so the detail of any call is not overridden
// by the next call.
elts.rev().for_each(|e| root.add_child(e));
root.to_string()
}
}
/// Returns the grid SVG with tick on "nice" times and stripes background.
fn new_grid(
calls: &[&Call],
times: Interval<DateTime<Utc>>,
ticks_number: usize,
scale_x: Scale,
pixels_x: Interval<Pixel>,
pixels_y: Interval<Pixel>,
call_height: Pixel,
) -> Element {
let mut grid = svg::new_group();
let elt = new_stripes(calls.len(), call_height, pixels_x, pixels_y, "#f5f5f5");
grid.add_child(elt);
let elt = new_vert_lines(times, ticks_number, scale_x, pixels_y);
grid.add_child(elt);
grid
}
/// Returns the verticals lines with labels for the time ticks.
fn new_vert_lines(
times: Interval<DateTime<Utc>>,
ticks_number: usize,
scale_x: Scale,
pixels_y: Interval<Pixel>,
) -> Element {
let mut group = svg::new_group();
// We compute in which unit we're going to draw the grid
let duration = times.end - times.start;
let duration = duration.num_microseconds().unwrap() as f64;
let duration = Microsecond(duration);
let delta = Microsecond(duration.0 / ticks_number as f64);
let (start, end) = match delta.0 {
d if d < 1_000.0 => (TimeUnit::zero_mc(), TimeUnit::Microsecond(duration)),
d if d < 1_000_000.0 => {
let end = Millisecond::from(duration);
(TimeUnit::zero_ms(), TimeUnit::Millisecond(end))
}
_ => {
let end = Second::from(duration);
(TimeUnit::zero_s(), TimeUnit::Second(end))
}
};
let nice_scale = NiceScale::new(start.as_f64(), end.as_f64(), ticks_number);
let mut t = start;
let mut values = vec![];
while t < end {
let x = scale_x.to_pixel(Microsecond::from(t));
// We want a integer pixel value:
let x = x.0.round();
values.push((x, t));
t = t.add_raw(nice_scale.get_tick_spacing());
}
// Draw the vertical lines:
let mut lines = svg::new_group();
lines.add_attr(Stroke("#ccc".to_string()));
values.iter().for_each(|(x, _)| {
if *x <= 0.0 {
return;
}
let elt = svg::new_line(*x, 0.0, *x, pixels_y.end.0);
lines.add_child(elt)
});
group.add_child(lines);
// Finally, draw labels
let mut labels = svg::new_group();
labels.add_attr(FontSize("15px".to_string()));
labels.add_attr(FontFamily("sans-serif".to_string()));
labels.add_attr(Fill("#777".to_string()));
values
.iter()
.map(|(x, t)| svg::new_text(*x + 5.0, 20.0, &format!("{} {}", t.as_f64(), t.unit())))
.for_each(|l| labels.add_child(l));
group.add_child(labels);
group
}
/// Returns the SVG of this `call`.
/// `times` is the time interval of the complete run, `scale_x` allows to convert
/// between times and pixel for the X-axis.
fn new_call(
call: &Call,
call_ctx: &CallContext,
times: Interval<DateTime<Utc>>,
scale_x: Scale,
pixels_x: Interval<Pixel>,
pixels_y: Interval<Pixel>,
) -> Element {
let mut call_elt = svg::new_group();
let summary = new_call_timings(call, call_ctx, times, scale_x, pixels_y);
call_elt.add_child(summary);
let detail = new_call_tooltip(call, call_ctx, times, scale_x, pixels_x, pixels_y);
call_elt.add_child(detail);
call_elt
}
/// Returns the SVG timings block of this `call`.
fn new_call_timings(
call: &Call,
call_ctx: &CallContext,
times: Interval<DateTime<Utc>>,
scale_x: Scale,
pixels_y: Interval<Pixel>,
) -> Element {
let mut group = svg::new_group();
group.add_attr(Class("call-summary".to_string()));
let offset_y = CALL_HEIGHT * (call_ctx.call_index - 1) + pixels_y.start;
let y = offset_y + CALL_INSET;
let height = CALL_HEIGHT - CALL_INSET * 2;
// DNS
let dns_x = (call.timings.begin_call - times.start).to_std().unwrap();
let dns_x = to_pixel(dns_x, scale_x);
let dns_width = to_pixel(call.timings.name_lookup, scale_x);
if dns_width.0 > 0.0 {
let elt = svg::new_rect(dns_x.0, y.0, dns_width.0, height.0, "#1d9688");
group.add_child(elt);
}
// TCP Handshake
let tcp_x = to_pixel(call.timings.name_lookup, scale_x) + dns_x;
let tcp_width = to_pixel(call.timings.connect - call.timings.name_lookup, scale_x);
if tcp_width.0 > 0.0 {
let elt = svg::new_rect(tcp_x.0, y.0, tcp_width.0, height.0, "#fa7f03");
group.add_child(elt);
}
// SSL
let ssl_x = to_pixel(call.timings.connect, scale_x) + dns_x;
let ssl_width = to_pixel(call.timings.app_connect - call.timings.connect, scale_x);
if ssl_width.0 > 0.0 {
let elt = svg::new_rect(ssl_x.0, y.0, ssl_width.0, height.0, "#9933ff");
group.add_child(elt);
}
// Wait
let wait_x = to_pixel(call.timings.pre_transfer, scale_x) + dns_x;
let wait_width = to_pixel(
call.timings.start_transfer - call.timings.pre_transfer,
scale_x,
);
if wait_width.0 > 0.0 {
let elt = svg::new_rect(wait_x.0, y.0, wait_width.0, height.0, "#18c852");
group.add_child(elt);
}
// Data transfer
let data_transfer_x = to_pixel(call.timings.start_transfer, scale_x) + dns_x;
let data_transfer_width = to_pixel(call.timings.total - call.timings.start_transfer, scale_x);
if data_transfer_width.0 > 0.0 {
let elt = svg::new_rect(
data_transfer_x.0,
y.0,
data_transfer_width.0,
height.0,
"#36a9f4",
);
group.add_child(elt);
}
group
}
/// Returns the SVG detail of this `call`.
/// Each call timings value is displayed under the timings (see [`crate::http::Timings`]).
fn new_call_tooltip(
call: &Call,
call_ctx: &CallContext,
times: Interval<DateTime<Utc>>,
scale_x: Scale,
pixels_x: Interval<Pixel>,
pixels_y: Interval<Pixel>,
) -> Element {
let mut group = svg::new_group();
group.add_attr(Class("call-detail".to_string()));
group.add_attr(FontFamily("sans-serif".to_string()));
group.add_attr(FontSize("17px".to_string()));
let width = 600.px();
let height = 235.px();
let offset_x = (call.timings.begin_call - times.start).to_std().unwrap();
let offset_x = to_pixel(offset_x, scale_x);
let offset_y = CALL_HEIGHT * (call_ctx.call_index - 1) + pixels_y.start;
let offset_y = offset_y + CALL_HEIGHT - CALL_INSET;
let max_width = pixels_x.end - pixels_x.start;
// We bound the tooltip background to the overall bouding box.
let offset_x = Pixel::max(offset_x, 6.px());
let offset_x = Pixel::min(offset_x, max_width - width - 6.px());
let selection = new_call_sel(call, call_ctx, times, scale_x, pixels_y);
group.add_child(selection);
let x = offset_x;
let y = offset_y;
let mut elt = svg::new_rect(x.0, y.0, width.0, height.0, "white");
elt.add_attr(Filter("url(#shadow)".to_string()));
elt.add_attr(Stroke("#ccc".to_string()));
elt.add_attr(StrokeWidth(1.0));
group.add_child(elt);
let x = x + 14.px();
let y = y + 14.px();
let delta_y = 30.px();
// Icon + URL + method
let mut elt = svg::new_use();
let icon = if call_ctx.success {
"#success"
} else {
"#failure"
};
elt.add_attr(Href(icon.to_string()));
elt.add_attr(X(x.0));
elt.add_attr(Y(y.0));
elt.add_attr(Width("20".to_string()));
elt.add_attr(Height("20".to_string()));
group.add_child(elt);
let text = format!("{} {}", call.request.method, call.request.url);
let text = trunc_str(&text, 54);
let text = format!("{text} {}", call.response.status);
let mut elt = svg::new_text(x.0 + 30.0, y.0 + 16.0, &text);
let color = if call_ctx.success { "#555" } else { "red" };
elt.add_attr(Fill(color.to_string()));
elt.add_attr(FontWeight("bold".to_string()));
group.add_child(elt);
let x = x + 12.px();
let y = y + 2.px();
// DNS
let y = y + delta_y;
let duration = call.timings.name_lookup.as_micros();
let duration = Microsecond(duration as f64);
let elt = new_legend(x, y, "DNS lookup", Some("#1d9688"), duration);
group.add_child(elt);
// TCP handshake
let y = y + delta_y;
let duration = (call.timings.connect - call.timings.name_lookup).as_micros();
let duration = Microsecond(duration as f64);
let elt = new_legend(x, y, "TCP handshake", Some("#fa7f03"), duration);
group.add_child(elt);
// SSL handshake
let y = y + delta_y;
let duration = (call.timings.app_connect - call.timings.connect).as_micros();
let duration = Microsecond(duration as f64);
let elt = new_legend(x, y, "SSL handshake", Some("#9933ff"), duration);
group.add_child(elt);
// Wait
let y = y + delta_y;
let duration = (call.timings.start_transfer - call.timings.pre_transfer).as_micros();
let duration = Microsecond(duration as f64);
let elt = new_legend(x, y, "Wait", Some("#18c852"), duration);
group.add_child(elt);
// Data transfer
let y = y + delta_y;
let duration = (call.timings.total - call.timings.start_transfer).as_micros();
let duration = Microsecond(duration as f64);
let elt = new_legend(x, y, "Data transfer", Some("#36a9f4"), duration);
group.add_child(elt);
// Total
let y = y + delta_y;
let duration = call.timings.total.as_micros();
let duration = Microsecond(duration as f64);
let mut elt = new_legend(x, y, "Total", None, duration);
elt.add_attr(FontWeight("bold".to_string()));
group.add_child(elt);
let x = offset_x;
// Start and stop timestamps
let start = (call.timings.begin_call - times.start).to_std().unwrap();
let end = (call.timings.end_call - times.start).to_std().unwrap();
let x = x + 380.px();
let y = offset_y + 64.px();
let value = Microsecond(start.as_micros() as f64);
let value = value.to_human_string();
let elt = new_value("Start:", &value, x, y);
group.add_child(elt);
let y = y + delta_y;
let value = Microsecond(end.as_micros() as f64);
let value = value.to_human_string();
let elt = new_value("Stop:", &value, x, y);
group.add_child(elt);
let y = y + delta_y;
let value = Byte(call.response.body.len() as f64);
let value = value.to_human_string();
let elt = new_value("Transferred:", &value, x, y);
group.add_child(elt);
// Run URL
let y = y + 56.px();
let run = format!(
"{}#e{}:c{}",
call_ctx.run_filename, call_ctx.entry_index, call_ctx.call_entry_index
);
let mut elt = svg::new_text(x.0, y.0, "(view run)");
elt.add_attr(Fill("royalblue".to_string()));
elt.add_attr(TextDecoration("underline".to_string()));
let mut a = new_a(&run);
a.add_child(elt);
group.add_child(a);
// Source URL
let y = y + delta_y;
let run = format!("{}#l{}", call_ctx.source_filename, call_ctx.line);
let mut elt = svg::new_text(x.0, y.0, "(view source)");
elt.add_attr(Fill("royalblue".to_string()));
elt.add_attr(TextDecoration("underline".to_string()));
let mut a = new_a(&run);
a.add_child(elt);
group.add_child(a);
group
}
/// Returns the highlighted span time of a call.
fn new_call_sel(
call: &Call,
call_ctx: &CallContext,
times: Interval<DateTime<Utc>>,
scale_x: Scale,
pixels_y: Interval<Pixel>,
) -> Element {
let offset_x_start = (call.timings.begin_call - times.start).to_std().unwrap();
let offset_x_start = to_pixel(offset_x_start, scale_x);
let offset_x_end = (call.timings.end_call - times.start).to_std().unwrap();
let offset_x_end = to_pixel(offset_x_end, scale_x);
let color = if call_ctx.success { "green" } else { "red" };
let mut elt = svg::new_rect(
offset_x_start.0,
0.0,
(offset_x_end - offset_x_start).0,
pixels_y.end.0,
color,
);
elt.add_attr(Opacity(0.05));
elt.add_attr(Class("call-cell".to_string()));
elt
}
fn new_legend(
x: Pixel,
y: Pixel,
text: &str,
color: Option<&str>,
duration: Microsecond,
) -> Element {
let dx_label = 36.px();
let dy_label = 17.px();
let dx_duration = 180.px();
let mut group = svg::new_group();
if let Some(color) = color {
let color_elt = svg::new_rect(x.0, y.0, 20.0, 20.0, color);
group.add_child(color_elt);
}
let mut text_elt = svg::new_text((x + dx_label).0, (y + dy_label).0, text);
text_elt.add_attr(Fill("#555".to_string()));
group.add_child(text_elt);
let duration = duration.to_human_string();
let mut duration_elt = svg::new_text((x + dx_duration).0, (y + dy_label).0, &duration);
duration_elt.add_attr(Fill("#333".to_string()));
group.add_child(duration_elt);
group
}
fn new_value(label: &str, value: &str, x: Pixel, y: Pixel) -> Element {
let mut group = svg::new_group();
let mut elt = svg::new_text(x.0, y.0, label);
elt.add_attr(Fill("#555".to_string()));
group.add_child(elt);
let x = x + 100.px();
let mut elt = svg::new_text(x.0, y.0, value);
elt.add_attr(Fill("#333".to_string()));
group.add_child(elt);
group
}
/// Converts a `duration` to pixel, using `scale_x`.
fn to_pixel(duration: Duration, scale_x: Scale) -> Pixel {
let value = duration.as_micros();
let value = Microsecond(value as f64);
scale_x.to_pixel(value)
}
/// Creates SVG filters for the waterfall (used by drop shadow of call tooltip).
fn new_filters() -> Element {
let mut defs = svg::new_defs();
let mut filter = svg::new_filter();
filter.add_attr(Id("shadow".to_string()));
let mut shadow = svg::new_fe_drop_shadow();
shadow.add_attr(DX(0.0));
shadow.add_attr(DY(4.0));
shadow.add_attr(StdDeviation(4.0));
shadow.add_attr(FloodOpacity(0.25));
filter.add_child(shadow);
defs.add_child(filter);
defs
}
impl Microsecond {
/// Returns a human readable sting of a microsecond.
fn to_human_string(self) -> String {
match self.0 {
d if d < 0.0 => "_".to_string(),
d if d < 1_000.0 => format!("{d:.1} µs"),
d if d < 1_000_000.0 => format!("{:.1} ms", d / 1_000.0),
d => format!("{:.1} s", d / 1_000_000.0),
}
}
}
impl Byte {
/// Returns a human readable sting of a byte.
fn to_human_string(self) -> String {
match self.0 {
d if d < 0.0 => "_".to_string(),
d if d < 1_000.0 => format!("{d:.1} B"),
d if d < 1_000_000.0 => format!("{:.1} kB", d / 1_000.0),
d => format!("{:.1} MB", d / 1_000_000.0),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::html::timeline::unit::Microsecond;
use crate::report::html::timeline::unit::{Interval, Scale};
use chrono::{Duration, TimeZone, Utc};
#[test]
fn legend_svg() {
let x = 20.px();
let y = 30.px();
let text = "Hellow world";
let color = "red";
let duration = Microsecond(2000.0);
let elt = new_legend(x, y, text, Some(color), duration);
assert_eq!(
elt.to_string(),
"<g>\
<rect x=\"20\" y=\"30\" width=\"20\" height=\"20\" fill=\"red\" />\
<text x=\"56\" y=\"47\" fill=\"#555\">Hellow world</text>\
<text x=\"200\" y=\"47\" fill=\"#333\">2.0 ms</text>\
</g>"
);
}
#[test]
fn grid_vert_lines_svg() {
let start = Utc.with_ymd_and_hms(2022, 1, 1, 8, 0, 0).unwrap();
let end = start + Duration::seconds(1);
let times = Interval { start, end };
let start = 0.px();
let end = 1000.px();
let pixels_x = Interval { start, end };
let start = 0.px();
let end = 100.px();
let pixels_y = Interval { start, end };
let scale_x = Scale::new(times, pixels_x);
let ticks_number = 10;
let elt = new_vert_lines(times, ticks_number, scale_x, pixels_y);
assert_eq!(
elt.to_string(),
"<g>\
<g stroke=\"#ccc\">\
<line x1=\"100\" y1=\"0\" x2=\"100\" y2=\"100\" />\
<line x1=\"200\" y1=\"0\" x2=\"200\" y2=\"100\" />\
<line x1=\"300\" y1=\"0\" x2=\"300\" y2=\"100\" />\
<line x1=\"400\" y1=\"0\" x2=\"400\" y2=\"100\" />\
<line x1=\"500\" y1=\"0\" x2=\"500\" y2=\"100\" />\
<line x1=\"600\" y1=\"0\" x2=\"600\" y2=\"100\" />\
<line x1=\"700\" y1=\"0\" x2=\"700\" y2=\"100\" />\
<line x1=\"800\" y1=\"0\" x2=\"800\" y2=\"100\" />\
<line x1=\"900\" y1=\"0\" x2=\"900\" y2=\"100\" />\
</g>\
<g font-size=\"15px\" font-family=\"sans-serif\" fill=\"#777\">\
<text x=\"5\" y=\"20\">0 ms</text>\
<text x=\"105\" y=\"20\">100 ms</text>\
<text x=\"205\" y=\"20\">200 ms</text>\
<text x=\"305\" y=\"20\">300 ms</text>\
<text x=\"405\" y=\"20\">400 ms</text>\
<text x=\"505\" y=\"20\">500 ms</text>\
<text x=\"605\" y=\"20\">600 ms</text>\
<text x=\"705\" y=\"20\">700 ms</text>\
<text x=\"805\" y=\"20\">800 ms</text>\
<text x=\"905\" y=\"20\">900 ms</text>\
</g>\
</g>"
);
}
}

View File

@ -1,536 +0,0 @@
/*
* Hurl (https://hurl.dev)
* Copyright (C) 2023 Orange
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
use chrono::{DateTime, Utc};
use hurl_core::ast::{Entry, HurlFile};
use std::time::Duration;
use crate::http::Call;
use crate::report::html::waterfall::nice::NiceScale;
use crate::report::html::waterfall::svg::Attribute::{
Class, Fill, Filter, FloodOpacity, Height, Id, StdDeviation, Stroke, StrokeWidth, ViewBox,
Width, DX, DY,
};
use crate::report::html::waterfall::svg::Element;
use crate::report::html::waterfall::unit::{
Interval, Microsecond, Millisecond, Pixel, Scale, Second, TimeUnit,
};
use crate::report::html::Testcase;
use crate::runner::EntryResult;
mod nice;
mod svg;
mod unit;
impl Testcase {
/// Returns the HTML waterfall of these `entries`.
/// `hurl_file` AST is used to construct URL with line numbers to the correponding
/// entry in the colored HTML source file.
pub fn get_waterfall_html(&self, hurl_file: &HurlFile, entries: &[EntryResult]) -> String {
let href_file = format!("{}.html", self.id);
let href_waterfall = format!("{}-waterfall.html", self.id);
let css = include_str!("../resources/waterfall.css");
let svg = get_waterfall_svg(hurl_file, &self.id, entries);
format!(
include_str!("../resources/waterfall.html"),
filename = self.filename,
css = css,
svg = svg,
href_file = href_file,
href_waterfall = href_waterfall
)
}
}
/// Returns the start and end date for these entries.
fn get_times_interval(entries: &[EntryResult]) -> Option<Interval<DateTime<Utc>>> {
let calls = entries
.iter()
.flat_map(|entry| &entry.calls)
.collect::<Vec<&Call>>();
let begin = calls.first();
let end = calls.last();
match (begin, end) {
(Some(start), Some(end)) => {
let start = start.timings.begin_call;
let end = end.timings.end_call;
Some(Interval { start, end })
}
_ => None,
}
}
/// Returns the SVG string of this list of `entries`.
/// `hurl_file` AST is used to construct URL with line numbers to the correponding
/// entry in the colored HTML source file.
fn get_waterfall_svg(hurl_file: &HurlFile, id: &str, entries: &[EntryResult]) -> String {
let margin_top = Pixel(50.0);
let margin_bottom = Pixel(250.0);
let margin_right = Pixel(20.0);
let entry_height = Pixel(20.0);
let y = Pixel(0.0);
let width = Pixel(2000.0);
let height = f64::max(
100.0,
(entry_height.0 * entries.len() as f64) + margin_top.0 + margin_bottom.0,
);
let height = Pixel(height);
let mut root = svg::svg();
root.add_attr(ViewBox(0.0, 0.0, width.0, height.0));
root.add_attr(Width(width.0));
root.add_attr(Height(height.0));
// Compute our scale (transform 0 based microsecond to 0 based pixels):
let times = get_times_interval(entries);
let times = match times {
Some(t) => t,
None => return "".to_string(),
};
// We add some space for the right last grid labels.
let pixels = Interval::new(Pixel(0.0), width - margin_right);
let scale_x = Scale::new(times, pixels);
let style = svg::style(include_str!("../resources/waterfall_svg.css"));
root.add_child(style);
let filters = filters();
root.add_child(filters);
let grid = grid(times, 20, scale_x, y, height);
root.add_child(grid);
// We construct entry from last to first so the detail SVG of any entry is not overridden
// by the next entry SGV.
entries
.iter()
.enumerate()
.rev()
.map(|(index, e)| {
let entry_node = hurl_file.entries.get(index).unwrap();
let entry_url = entry_url(id, entry_node);
let offset_y = Pixel((index as f64) * entry_height.0 + margin_top.0);
entry(e, times, scale_x, offset_y, width, &entry_url)
})
.for_each(|e| root.add_child(e));
root.to_string()
}
/// Returns the grid SVG with tick on "nice" times.
fn grid(
times: Interval<DateTime<Utc>>,
ticks_number: usize,
scale_x: Scale,
y: Pixel,
height: Pixel,
) -> Element {
let mut grid = svg::group();
grid.add_attr(Class("grid".to_string()));
// We compute in which unit we're going to draw the grid
let duration = times.end - times.start;
let duration = duration.num_microseconds().unwrap() as f64;
let duration = Microsecond(duration);
let delta = Microsecond(duration.0 / ticks_number as f64);
let (start, end) = match delta.0 {
d if d < 1_000.0 => (TimeUnit::zero_mc(), TimeUnit::Microsecond(duration)),
d if d < 1_000_000.0 => {
let end = Millisecond::from(duration);
(TimeUnit::zero_ms(), TimeUnit::Millisecond(end))
}
_ => {
let end = Second::from(duration);
(TimeUnit::zero_s(), TimeUnit::Second(end))
}
};
let nice_scale = NiceScale::new(start.as_f64(), end.as_f64(), ticks_number);
let mut t = start;
let mut values = vec![];
while t < end {
let x = scale_x.to_pixel(Microsecond::from(t));
// We want a integer pixel value:
values.push((x.0.round(), t));
t = t.add_raw(nice_scale.get_tick_spacing());
}
// First, draw the vertical lines:
let mut lines = svg::group();
lines.add_attr(Class("grid-line".to_string()));
lines.add_attr(Stroke("#ccc".to_string()));
values
.iter()
.map(|(x, _)| svg::line(*x, y.0, *x, height.0))
.for_each(|l| lines.add_child(l));
grid.add_child(lines);
// Then, draw labels
let mut labels = svg::group();
labels.add_attr(Class("grid-labels".to_string()));
labels.add_attr(Fill("#777".to_string()));
values
.iter()
.map(|(x, t)| svg::text(*x + 5.0, 20.0, &format!("{} {}", t.as_f64(), t.unit())))
.for_each(|l| labels.add_child(l));
grid.add_child(labels);
grid
}
/// Returns the SVG of this `entry`.
/// `times` is the time interval of the complete run, `scale_x` allows to convert
/// between times and pixel for the X-axis.
/// The entry is offset on the Y-Axis by `offset_y` pixels, and right boxed to a
/// maximum of `max_width` pixel.
fn entry(
entry: &EntryResult,
times: Interval<DateTime<Utc>>,
scale_x: Scale,
offset_y: Pixel,
max_width: Pixel,
url: &str,
) -> Element {
let mut group = svg::group();
group.add_attr(Class("entry".to_string()));
entry
.calls
.iter()
.map(|c| call(c, times, scale_x, offset_y, max_width, url))
.for_each(|c| group.add_child(c));
group
}
/// Returns the SVG of this `call`.
/// `times` is the time interval of the complete run, `scale_x` allows to convert
/// between times and pixel for the X-axis.
/// The entry is offset on the Y-Axis by `offset_y` pixels, and right boxed to a
/// maximum of `max_width` pixel.
fn call(
call: &Call,
times: Interval<DateTime<Utc>>,
scale_x: Scale,
offset_y: Pixel,
max_width: Pixel,
entry_url: &str,
) -> Element {
let mut call_elt = svg::group();
call_elt.add_attr(Class("call".to_string()));
let summary = call_summary(call, times, scale_x, offset_y);
call_elt.add_child(summary);
let detail = call_detail(call, times, scale_x, offset_y, max_width, entry_url);
call_elt.add_child(detail);
call_elt
}
/// Returns the SVG summary of this `call`.
/// The summary is a suit of boxes for each call timings (see [`crate::http::Timings`]).
fn call_summary(
call: &Call,
times: Interval<DateTime<Utc>>,
scale_x: Scale,
offset_y: Pixel,
) -> Element {
let mut group = svg::group();
group.add_attr(Class("call-summary".to_string()));
let y = offset_y;
let height = Pixel(20.0);
// DNS
let dns_x = (call.timings.begin_call - times.start).to_std().unwrap();
let dns_x = duration_to_pixel(dns_x, scale_x);
let dns_width = duration_to_pixel(call.timings.name_lookup, scale_x);
if dns_width.0 > 0.0 {
let elt = svg::rect(dns_x.0, y.0, dns_width.0, height.0, "#1d9688");
group.add_child(elt);
}
// TCP Handshake
let tcp_x = duration_to_pixel(call.timings.name_lookup, scale_x) + dns_x;
let tcp_width = duration_to_pixel(call.timings.connect - call.timings.name_lookup, scale_x);
if tcp_width.0 > 0.0 {
let elt = svg::rect(tcp_x.0, y.0, tcp_width.0, height.0, "#fa7f03");
group.add_child(elt);
}
// SSL
let ssl_x = duration_to_pixel(call.timings.connect, scale_x) + dns_x;
let ssl_width = duration_to_pixel(call.timings.app_connect - call.timings.connect, scale_x);
if ssl_width.0 > 0.0 {
let elt = svg::rect(ssl_x.0, y.0, ssl_width.0, height.0, "#9933ff");
group.add_child(elt);
}
// Wait
let wait_x = duration_to_pixel(call.timings.pre_transfer, scale_x) + dns_x;
let wait_width = duration_to_pixel(
call.timings.start_transfer - call.timings.pre_transfer,
scale_x,
);
if wait_width.0 > 0.0 {
let elt = svg::rect(wait_x.0, y.0, wait_width.0, height.0, "#18c852");
group.add_child(elt);
}
// Data transfer
let data_transfer_x = duration_to_pixel(call.timings.start_transfer, scale_x) + dns_x;
let data_transfer_width =
duration_to_pixel(call.timings.total - call.timings.start_transfer, scale_x);
if data_transfer_width.0 > 0.0 {
let elt = svg::rect(
data_transfer_x.0,
y.0,
data_transfer_width.0,
height.0,
"#36a9f4",
);
group.add_child(elt);
}
group
}
/// Returns the SVG detail of this `call`.
/// Each call timings value is displayed under the summary (see [`crate::http::Timings`]).
fn call_detail(
call: &Call,
times: Interval<DateTime<Utc>>,
scale_x: Scale,
offset_y: Pixel,
max_width: Pixel,
entry_url: &str,
) -> Element {
let mut group = svg::group();
group.add_attr(Class("call-detail".to_string()));
let x = (call.timings.begin_call - times.start).to_std().unwrap();
let x = duration_to_pixel(x, scale_x) + Pixel(6.0);
let y = offset_y + Pixel(20.0);
let width = Pixel(600.0);
let height = Pixel(235.0);
let x = if x + width > max_width {
max_width - width - Pixel(10.0)
} else {
x
};
// Background detail:
let mut elt = svg::rect(x.0, y.0, width.0, height.0, "white");
elt.add_attr(Filter("url(#shadow)".to_string()));
elt.add_attr(Stroke("#cc".to_string()));
elt.add_attr(StrokeWidth(1.0));
group.add_child(elt);
let x = x + Pixel(16.0);
let y = y + Pixel(20.0);
let delta_y = Pixel(30.0);
// URL + method
let method = &call.request.method;
let url = &call.request.url;
let url = if url.len() > 64 {
format!("{}...", &url[0..64])
} else {
url.to_string()
};
let elt = svg::text(x.0, y.0 + 14.0, &format!("{method} {url}"));
let mut a = svg::a(entry_url);
a.add_child(elt);
group.add_child(a);
// DNS
let y = y + delta_y;
let duration = call.timings.name_lookup.as_micros();
let duration = Microsecond(duration as f64);
let elt = legend(x, y, "DNS lookup", Some("#1d9688"), duration);
group.add_child(elt);
// TCP handshake
let y = y + delta_y;
let duration = (call.timings.connect - call.timings.name_lookup).as_micros();
let duration = Microsecond(duration as f64);
let elt = legend(x, y, "TCP handshake", Some("#fa7f03"), duration);
group.add_child(elt);
// SSL handshake
let y = y + delta_y;
let duration = (call.timings.app_connect - call.timings.connect).as_micros();
let duration = Microsecond(duration as f64);
let elt = legend(x, y, "SSL handshake", Some("#9933ff"), duration);
group.add_child(elt);
// Wait
let y = y + delta_y;
let duration = (call.timings.start_transfer - call.timings.pre_transfer).as_micros();
let duration = Microsecond(duration as f64);
let elt = legend(x, y, "Wait", Some("#18c852"), duration);
group.add_child(elt);
// Data transfer
let y = y + delta_y;
let duration = (call.timings.total - call.timings.start_transfer).as_micros();
let duration = Microsecond(duration as f64);
let elt = legend(x, y, "Data transfer", Some("#36a9f4"), duration);
group.add_child(elt);
// Total
let y = y + delta_y;
let duration = call.timings.total.as_micros();
let duration = Microsecond(duration as f64);
let mut elt = legend(x, y, "Total", None, duration);
elt.add_attr(Class("call-detail-total".to_string()));
group.add_child(elt);
group
}
fn legend(x: Pixel, y: Pixel, text: &str, color: Option<&str>, duration: Microsecond) -> Element {
let dx_label = Pixel(36.0);
let dy_label = Pixel(17.0);
let dx_duration = Pixel(180.0);
let mut group = svg::group();
if let Some(color) = color {
let color_elt = svg::rect(x.0, y.0, 20.0, 20.0, color);
group.add_child(color_elt);
}
let mut text_elt = svg::text((x + dx_label).0, (y + dy_label).0, text);
text_elt.add_attr(Fill("#555".to_string()));
group.add_child(text_elt);
let duration = duration.human_string();
let mut duration_elt = svg::text((x + dx_duration).0, (y + dy_label).0, &duration);
duration_elt.add_attr(Fill("#333".to_string()));
group.add_child(duration_elt);
group
}
fn duration_to_pixel(duration: Duration, scale_x: Scale) -> Pixel {
let value = duration.as_micros();
let value = Microsecond(value as f64);
scale_x.to_pixel(value)
}
fn filters() -> Element {
let mut defs = svg::defs();
let mut filter = svg::filter();
filter.add_attr(Id("shadow".to_string()));
let mut shadow = svg::fe_drop_shadow();
shadow.add_attr(DX(0.0));
shadow.add_attr(DY(4.0));
shadow.add_attr(StdDeviation(4.0));
shadow.add_attr(FloodOpacity(0.25));
filter.add_child(shadow);
defs.add_child(filter);
defs
}
fn entry_url(id: &str, entry: &Entry) -> String {
let line = entry.request.space0.source_info.start.line;
format!("{id}.html#l{line}")
}
impl Microsecond {
fn human_string(&self) -> String {
match self.0 {
d if d < 0.0 => "_".to_string(),
d if d < 1_000.0 => format!("{d:.1} µs"),
d if d < 1_000_000.0 => format!("{:.1} ms", d / 1_000.0),
d => format!("{:.1} s", d / 1_000_000.0),
}
}
}
#[cfg(test)]
mod tests {
use crate::report::html::waterfall::unit::Microsecond;
use crate::report::html::waterfall::unit::{Interval, Pixel, Scale};
use crate::report::html::waterfall::{grid, legend};
use chrono::{Duration, TimeZone, Utc};
#[test]
fn legend_svg() {
let x = Pixel(20.0);
let y = Pixel(30.0);
let text = "Hellow world";
let color = "red";
let duration = Microsecond(2000.0);
let elt = legend(x, y, text, Some(color), duration);
assert_eq!(
elt.to_string(),
"<g>\
<rect x=\"20\" y=\"30\" width=\"20\" height=\"20\" fill=\"red\" />\
<text x=\"56\" y=\"47\" fill=\"#555\">Hellow world</text>\
<text x=\"200\" y=\"47\" fill=\"#333\">2.0 ms</text>\
</g>"
);
}
#[test]
fn grid_svg() {
let start = Utc.with_ymd_and_hms(2022, 1, 1, 8, 0, 0).unwrap();
let end = start + Duration::seconds(1);
let times = Interval { start, end };
let start = Pixel(0.0);
let end = Pixel(1000.0);
let pixels = Interval { start, end };
let scale_x = Scale::new(times, pixels);
let ticks_number = 10;
let y = Pixel(0.0);
let height = Pixel(100.0);
let elt = grid(times, ticks_number, scale_x, y, height);
assert_eq!(
elt.to_string(),
"<g class=\"grid\">\
<g class=\"grid-line\" stroke=\"#ccc\">\
<line x1=\"0\" y1=\"0\" x2=\"0\" y2=\"100\" />\
<line x1=\"100\" y1=\"0\" x2=\"100\" y2=\"100\" />\
<line x1=\"200\" y1=\"0\" x2=\"200\" y2=\"100\" />\
<line x1=\"300\" y1=\"0\" x2=\"300\" y2=\"100\" />\
<line x1=\"400\" y1=\"0\" x2=\"400\" y2=\"100\" />\
<line x1=\"500\" y1=\"0\" x2=\"500\" y2=\"100\" />\
<line x1=\"600\" y1=\"0\" x2=\"600\" y2=\"100\" />\
<line x1=\"700\" y1=\"0\" x2=\"700\" y2=\"100\" />\
<line x1=\"800\" y1=\"0\" x2=\"800\" y2=\"100\" />\
<line x1=\"900\" y1=\"0\" x2=\"900\" y2=\"100\" />\
</g>\
<g class=\"grid-labels\" fill=\"#777\">\
<text x=\"5\" y=\"20\">0 ms</text>\
<text x=\"105\" y=\"20\">100 ms</text>\
<text x=\"205\" y=\"20\">200 ms</text>\
<text x=\"305\" y=\"20\">300 ms</text>\
<text x=\"405\" y=\"20\">400 ms</text>\
<text x=\"505\" y=\"20\">500 ms</text>\
<text x=\"605\" y=\"20\">600 ms</text>\
<text x=\"705\" y=\"20\">700 ms</text>\
<text x=\"805\" y=\"20\">800 ms</text>\
<text x=\"905\" y=\"20\">900 ms</text>\
</g>\
</g>"
);
}
}