mirror of
https://github.com/Orange-OpenSource/hurl.git
synced 2024-12-25 12:05:32 +03:00
Improve waterfall.
This commit is contained in:
parent
f98b49d903
commit
b1e1ccd084
@ -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;
|
||||
|
87
packages/hurl/src/report/html/nav.rs
Normal file
87
packages/hurl/src/report/html/nav.rs
Normal 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>"
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
60
packages/hurl/src/report/html/resources/nav.css
Normal file
60
packages/hurl/src/report/html/resources/nav.css
Normal 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;
|
||||
}
|
16
packages/hurl/src/report/html/resources/nav.html
Normal file
16
packages/hurl/src/report/html/resources/nav.html
Normal 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>
|
65
packages/hurl/src/report/html/resources/run.css
Normal file
65
packages/hurl/src/report/html/resources/run.css
Normal 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;
|
||||
}
|
18
packages/hurl/src/report/html/resources/run.html
Normal file
18
packages/hurl/src/report/html/resources/run.html
Normal 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>
|
48
packages/hurl/src/report/html/resources/source.css
Normal file
48
packages/hurl/src/report/html/resources/source.css
Normal 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;
|
||||
}
|
22
packages/hurl/src/report/html/resources/source.html
Normal file
22
packages/hurl/src/report/html/resources/source.html
Normal 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>
|
31
packages/hurl/src/report/html/resources/timeline.css
Normal file
31
packages/hurl/src/report/html/resources/timeline.css
Normal 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;
|
||||
}
|
||||
|
21
packages/hurl/src/report/html/resources/timeline.html
Normal file
21
packages/hurl/src/report/html/resources/timeline.html
Normal 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>
|
@ -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;
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
172
packages/hurl/src/report/html/run.rs
Normal file
172
packages/hurl/src/report/html/run.rs
Normal 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
|
||||
}
|
@ -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>`
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
164
packages/hurl/src/report/html/timeline/calls.rs
Normal file
164
packages/hurl/src/report/html/timeline/calls.rs
Normal 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
|
||||
}
|
101
packages/hurl/src/report/html/timeline/mod.rs
Normal file
101
packages/hurl/src/report/html/timeline/mod.rs
Normal 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
|
||||
}
|
||||
}
|
@ -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() {
|
@ -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);
|
||||
|
@ -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);
|
114
packages/hurl/src/report/html/timeline/util.rs
Normal file
114
packages/hurl/src/report/html/timeline/util.rs
Normal 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>"
|
||||
);
|
||||
}
|
||||
}
|
630
packages/hurl/src/report/html/timeline/waterfall.rs
Normal file
630
packages/hurl/src/report/html/timeline/waterfall.rs
Normal 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>"
|
||||
);
|
||||
}
|
||||
}
|
@ -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>"
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user