refactor(cli): unify scalar presentation formatting #91
No files matched your search
@@ -10,6 +10,7 @@ use irtt_client::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::args::{HeaderMode, OutputFormat};
|
use super::args::{HeaderMode, OutputFormat};
|
||||||
|
use crate::cmd::format::{format_duration, format_signed_duration, ABSENT};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) struct OutputConfig {
|
pub(super) struct OutputConfig {
|
||||||
@@ -817,7 +818,7 @@ fn render_table_row(row: &OutputRow, columns: &[Column], context: RenderContext<
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn format_table_cell(column: &Column, value: Option<String>) -> String {
|
fn format_table_cell(column: &Column, value: Option<String>) -> String {
|
||||||
let value = value.unwrap_or_else(|| "-".to_owned());
|
let value = value.unwrap_or_else(|| ABSENT.to_owned());
|
||||||
let width = column.table_width();
|
let width = column.table_width();
|
||||||
if column.align_right() {
|
if column.align_right() {
|
||||||
format!("{value:>width$}")
|
format!("{value:>width$}")
|
||||||
@@ -1161,34 +1162,6 @@ fn wall_time_ns(wall: SystemTime) -> Option<u128> {
|
|||||||
.map(|duration| duration.as_nanos())
|
.map(|duration| duration.as_nanos())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_duration(duration: Duration) -> String {
|
|
||||||
format_ns(duration.as_nanos() as f64)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_signed_duration(duration: SignedDuration) -> String {
|
|
||||||
format_signed_ns(duration.as_nanos() as f64)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_signed_ns(ns: f64) -> String {
|
|
||||||
if ns < 0.0 {
|
|
||||||
format!("-{}", format_ns(-ns))
|
|
||||||
} else {
|
|
||||||
format_ns(ns)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_ns(ns: f64) -> String {
|
|
||||||
if ns < 1_000.0 {
|
|
||||||
format!("{ns:.0}ns")
|
|
||||||
} else if ns < 1_000_000.0 {
|
|
||||||
format!("{:.1}µs", ns / 1_000.0)
|
|
||||||
} else if ns < 1_000_000_000.0 {
|
|
||||||
format!("{:.1}ms", ns / 1_000_000.0)
|
|
||||||
} else {
|
|
||||||
format!("{:.3}s", ns / 1_000_000_000.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn warning_kind(kind: WarningKind) -> &'static str {
|
fn warning_kind(kind: WarningKind) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
WarningKind::MalformedOrUnrelatedPacket => "malformed_or_unrelated_packet",
|
WarningKind::MalformedOrUnrelatedPacket => "malformed_or_unrelated_packet",
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ use std::fmt::Write as _;
|
|||||||
|
|
||||||
use irtt_stats::{Snapshot, TimeStats};
|
use irtt_stats::{Snapshot, TimeStats};
|
||||||
|
|
||||||
|
use crate::cmd::format::{
|
||||||
|
format_ns_f64, format_optional_ns_f64 as format_ns_f64_opt,
|
||||||
|
format_optional_ns_i128 as format_ns_i128, format_percent,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn format_summary(summary: &Snapshot) -> String {
|
pub fn format_summary(summary: &Snapshot) -> String {
|
||||||
format_summary_with_options(summary, SummaryFormatOptions::default())
|
format_summary_with_options(summary, SummaryFormatOptions::default())
|
||||||
}
|
}
|
||||||
@@ -117,34 +122,6 @@ fn write_time_row(out: &mut String, label: &str, value: &TimeStats) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_percent(value: f64) -> String {
|
|
||||||
format!("{value:.2}%")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_ns_i128(value: Option<i128>) -> String {
|
|
||||||
value
|
|
||||||
.map(|value| format_ns_f64(value as f64))
|
|
||||||
.unwrap_or_else(|| "-".to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_ns_f64_opt(value: Option<f64>) -> String {
|
|
||||||
value.map(format_ns_f64).unwrap_or_else(|| "-".to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_ns_f64(value: f64) -> String {
|
|
||||||
let sign = if value.is_sign_negative() { "-" } else { "" };
|
|
||||||
let value = value.abs();
|
|
||||||
if value < 1_000.0 {
|
|
||||||
format!("{sign}{value:.0}ns")
|
|
||||||
} else if value < 1_000_000.0 {
|
|
||||||
format!("{sign}{:.1}µs", value / 1_000.0)
|
|
||||||
} else if value < 1_000_000_000.0 {
|
|
||||||
format!("{sign}{:.1}ms", value / 1_000_000.0)
|
|
||||||
} else {
|
|
||||||
format!("{sign}{:.3}s", value / 1_000_000_000.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
//! Scalar presentation vocabulary shared by the CLI frontends.
|
||||||
|
//!
|
||||||
|
//! Stream output, the run summary, and the TUI display different metrics in
|
||||||
|
//! different layouts, but they render the same *scalars*: nanosecond-scale
|
||||||
|
//! durations, percentages, and counts. This module owns those spellings so the
|
||||||
|
//! frontends cannot drift apart on units, rounding, or how an absent value is
|
||||||
|
//! rendered.
|
||||||
|
//!
|
||||||
|
//! It is presentation only. Nothing here belongs in `irtt-stats`, and it is
|
||||||
|
//! deliberately not a general formatting framework: it holds exactly the
|
||||||
|
//! vocabulary more than one frontend needs.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use irtt_client::SignedDuration;
|
||||||
|
|
||||||
|
/// Rendering used wherever a scalar is unavailable.
|
||||||
|
pub(crate) const ABSENT: &str = "-";
|
||||||
|
|
||||||
|
/// Format a nanosecond scalar.
|
||||||
|
///
|
||||||
|
/// Unit selection and rounding are one policy for every frontend:
|
||||||
|
///
|
||||||
|
/// | magnitude | unit | precision |
|
||||||
|
/// |-----------|------|-----------|
|
||||||
|
/// | `< 1µs` | `ns` | whole |
|
||||||
|
/// | `< 1ms` | `µs` | 1 decimal |
|
||||||
|
/// | `< 1s` | `ms` | 1 decimal |
|
||||||
|
/// | `>= 1s` | `s` | 3 decimals |
|
||||||
|
///
|
||||||
|
/// Negative values are meaningful (clock offsets, one-way deltas, IPDV), so
|
||||||
|
/// the unit is selected from the magnitude and the sign is preserved. Values
|
||||||
|
/// that are not finite render as [`ABSENT`].
|
||||||
|
pub(crate) fn format_ns_f64(value: f64) -> String {
|
||||||
|
if !value.is_finite() {
|
||||||
|
return ABSENT.to_owned();
|
||||||
|
}
|
||||||
|
let sign = if value.is_sign_negative() { "-" } else { "" };
|
||||||
|
let magnitude = value.abs();
|
||||||
|
if magnitude < 1_000.0 {
|
||||||
|
format!("{sign}{magnitude:.0}ns")
|
||||||
|
} else if magnitude < 1_000_000.0 {
|
||||||
|
format!("{sign}{:.1}µs", magnitude / 1_000.0)
|
||||||
|
} else if magnitude < 1_000_000_000.0 {
|
||||||
|
format!("{sign}{:.1}ms", magnitude / 1_000_000.0)
|
||||||
|
} else {
|
||||||
|
format!("{sign}{:.3}s", magnitude / 1_000_000_000.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format an exact nanosecond scalar with [`format_ns_f64`]'s policy.
|
||||||
|
pub(crate) fn format_ns_i128(value: i128) -> String {
|
||||||
|
let sign = if value < 0 { "-" } else { "" };
|
||||||
|
let magnitude = value.saturating_abs() as f64;
|
||||||
|
format!("{sign}{}", format_ns_f64(magnitude))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
|
||||||
|
pub(crate) fn format_optional_ns_i128(value: Option<i128>) -> String {
|
||||||
|
value.map(format_ns_i128).unwrap_or_else(absent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
|
||||||
|
pub(crate) fn format_optional_ns_f64(value: Option<f64>) -> String {
|
||||||
|
value.map(format_ns_f64).unwrap_or_else(absent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a duration as a nanosecond scalar.
|
||||||
|
pub(crate) fn format_duration(value: Duration) -> String {
|
||||||
|
format_ns_i128(i128::try_from(value.as_nanos()).unwrap_or(i128::MAX))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a signed duration as a nanosecond scalar, preserving its sign.
|
||||||
|
pub(crate) fn format_signed_duration(value: SignedDuration) -> String {
|
||||||
|
format_ns_i128(value.as_nanos())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a percentage with two decimals; non-finite values render as
|
||||||
|
/// [`ABSENT`].
|
||||||
|
pub(crate) fn format_percent(value: f64) -> String {
|
||||||
|
if value.is_finite() {
|
||||||
|
format!("{value:.2}%")
|
||||||
|
} else {
|
||||||
|
ABSENT.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format `value` as a percentage of `total`, capped at 100%.
|
||||||
|
///
|
||||||
|
/// A zero total has no percentage to report and renders as [`ABSENT`].
|
||||||
|
pub(crate) fn format_percent_ratio(value: u64, total: u64) -> String {
|
||||||
|
if total == 0 {
|
||||||
|
ABSENT.to_owned()
|
||||||
|
} else {
|
||||||
|
format_percent((value as f64 / total as f64 * 100.0).min(100.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a scalar count.
|
||||||
|
pub(crate) fn format_count(value: u64) -> String {
|
||||||
|
value.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format an optional scalar count, rendering `None` as [`ABSENT`].
|
||||||
|
pub(crate) fn format_optional_count(value: Option<u64>) -> String {
|
||||||
|
value.map(format_count).unwrap_or_else(absent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn absent() -> String {
|
||||||
|
ABSENT.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nanosecond_scalars_select_units_at_the_defined_boundaries() {
|
||||||
|
assert_eq!(format_ns_f64(0.0), "0ns");
|
||||||
|
assert_eq!(format_ns_f64(999.0), "999ns");
|
||||||
|
assert_eq!(format_ns_f64(1_000.0), "1.0µs");
|
||||||
|
assert_eq!(format_ns_f64(999_999.0), "1000.0µs");
|
||||||
|
assert_eq!(format_ns_f64(1_000_000.0), "1.0ms");
|
||||||
|
assert_eq!(format_ns_f64(999_999_999.0), "1000.0ms");
|
||||||
|
assert_eq!(format_ns_f64(1_000_000_000.0), "1.000s");
|
||||||
|
assert_eq!(format_ns_f64(1_500_000_000.0), "1.500s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_nanosecond_scalars_keep_their_sign() {
|
||||||
|
assert_eq!(format_ns_f64(-500.0), "-500ns");
|
||||||
|
assert_eq!(format_ns_f64(-1_500.0), "-1.5µs");
|
||||||
|
assert_eq!(format_ns_f64(-2_500_000.0), "-2.5ms");
|
||||||
|
assert_eq!(format_ns_f64(-1_000_000_000.0), "-1.000s");
|
||||||
|
assert_eq!(format_ns_i128(-1_500), "-1.5µs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_and_floating_nanosecond_scalars_agree() {
|
||||||
|
for value in [0_i128, 1, 999, 1_000, 1_234_567, -1_234_567, 5_000_000_000] {
|
||||||
|
assert_eq!(format_ns_i128(value), format_ns_f64(value as f64));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn microseconds_use_the_micro_sign() {
|
||||||
|
assert!(format_ns_f64(1_500.0).ends_with("µs"));
|
||||||
|
assert!(!format_ns_f64(1_500.0).contains("us"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn durations_render_as_nanosecond_scalars() {
|
||||||
|
assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms");
|
||||||
|
assert_eq!(format_duration(Duration::from_secs(1)), "1.000s");
|
||||||
|
assert_eq!(
|
||||||
|
format_signed_duration(SignedDuration::from_nanos(-1_500)),
|
||||||
|
"-1.5µs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_and_non_finite_scalars_render_as_a_placeholder() {
|
||||||
|
assert_eq!(format_optional_ns_i128(None), ABSENT);
|
||||||
|
assert_eq!(format_optional_ns_f64(None), ABSENT);
|
||||||
|
assert_eq!(format_optional_count(None), ABSENT);
|
||||||
|
assert_eq!(format_ns_f64(f64::NAN), ABSENT);
|
||||||
|
assert_eq!(format_ns_f64(f64::INFINITY), ABSENT);
|
||||||
|
assert_eq!(format_percent(f64::NAN), ABSENT);
|
||||||
|
assert_eq!(format_percent_ratio(1, 0), ABSENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percentages_and_counts_have_one_spelling() {
|
||||||
|
assert_eq!(format_percent(0.0), "0.00%");
|
||||||
|
assert_eq!(format_percent(12.345), "12.35%");
|
||||||
|
assert_eq!(format_percent_ratio(1, 4), "25.00%");
|
||||||
|
assert_eq!(format_percent_ratio(5, 4), "100.00%");
|
||||||
|
assert_eq!(format_count(42), "42");
|
||||||
|
assert_eq!(format_optional_count(Some(0)), "0");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
#[cfg(feature = "client")]
|
#[cfg(feature = "client")]
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
#[cfg(any(feature = "client", feature = "tui"))]
|
||||||
|
mod format;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod server;
|
pub mod server;
|
||||||
#[cfg(feature = "tui")]
|
#[cfg(feature = "tui")]
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ use ratatui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
cmd::tui::args::TuiArgs,
|
cmd::{
|
||||||
|
format::{
|
||||||
|
format_count, format_duration, format_ns_f64, format_optional_count,
|
||||||
|
format_optional_ns_i128, format_percent, format_percent_ratio, ABSENT,
|
||||||
|
},
|
||||||
|
tui::args::TuiArgs,
|
||||||
|
},
|
||||||
shared::client::{expected_probe_count, GroupPacingArg},
|
shared::client::{expected_probe_count, GroupPacingArg},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -330,7 +336,7 @@ impl TuiState {
|
|||||||
recent = Some(format!(
|
recent = Some(format!(
|
||||||
"reply seq={} effective={}",
|
"reply seq={} effective={}",
|
||||||
format_seq(*seq),
|
format_seq(*seq),
|
||||||
format_ns_i128(Some(rtt.effective.as_nanos()))
|
format_optional_ns_i128(Some(rtt.effective.as_nanos()))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
ClientEvent::EchoLoss { seq, .. } => {
|
ClientEvent::EchoLoss { seq, .. } => {
|
||||||
@@ -349,7 +355,7 @@ impl TuiState {
|
|||||||
.map(|sample| {
|
.map(|sample| {
|
||||||
format!(
|
format!(
|
||||||
" effective={}",
|
" effective={}",
|
||||||
format_ns_i128(Some(sample.effective.as_nanos()))
|
format_optional_ns_i128(Some(sample.effective.as_nanos()))
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
@@ -1149,8 +1155,8 @@ fn header(state: &TuiState, density: HeaderDensity) -> Paragraph<'_> {
|
|||||||
let session = selected
|
let session = selected
|
||||||
.and_then(|target| target.session.as_deref())
|
.and_then(|target| target.session.as_deref())
|
||||||
.unwrap_or("-");
|
.unwrap_or("-");
|
||||||
let elapsed = format_duration(state.started_at.elapsed());
|
let elapsed = format_span(state.started_at.elapsed());
|
||||||
let duration = format_optional_duration(state.config.duration);
|
let duration = format_optional_span(state.config.duration);
|
||||||
let mode = if state.config.duration.is_some() {
|
let mode = if state.config.duration.is_some() {
|
||||||
"finite"
|
"finite"
|
||||||
} else {
|
} else {
|
||||||
@@ -1186,8 +1192,8 @@ fn header(state: &TuiState, density: HeaderDensity) -> Paragraph<'_> {
|
|||||||
)),
|
)),
|
||||||
Line::from(format!(
|
Line::from(format!(
|
||||||
"interval: {} timeout: {}",
|
"interval: {} timeout: {}",
|
||||||
format_duration(state.config.interval),
|
format_span(state.config.interval),
|
||||||
format_duration(state.config.timeout)
|
format_span(state.config.timeout)
|
||||||
)),
|
)),
|
||||||
Line::from(negotiated),
|
Line::from(negotiated),
|
||||||
]);
|
]);
|
||||||
@@ -1197,7 +1203,7 @@ fn header(state: &TuiState, density: HeaderDensity) -> Paragraph<'_> {
|
|||||||
Line::from(format!("session: {session}")),
|
Line::from(format!("session: {session}")),
|
||||||
Line::from(format!(
|
Line::from(format!(
|
||||||
"{mode} elapsed: {elapsed} interval: {}",
|
"{mode} elapsed: {elapsed} interval: {}",
|
||||||
format_duration(state.config.interval)
|
format_span(state.config.interval)
|
||||||
)),
|
)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -1216,12 +1222,12 @@ fn graph_summary_panel(state: &TuiState, snapshot: &Snapshot) -> Paragraph<'stat
|
|||||||
let remote = selected
|
let remote = selected
|
||||||
.and_then(|target| target.remote.as_deref())
|
.and_then(|target| target.remote.as_deref())
|
||||||
.unwrap_or("-");
|
.unwrap_or("-");
|
||||||
let elapsed = format_duration(state.started_at.elapsed());
|
let elapsed = format_span(state.started_at.elapsed());
|
||||||
let packets = snapshot.packets;
|
let packets = snapshot.packets;
|
||||||
let last = state
|
let last = state
|
||||||
.selected_target()
|
.selected_target()
|
||||||
.and_then(|target| target.last_sample)
|
.and_then(|target| target.last_sample)
|
||||||
.map(|sample| format_ns_i128(Some(sample.effective_ns)))
|
.map(|sample| format_optional_ns_i128(Some(sample.effective_ns)))
|
||||||
.unwrap_or_else(|| "-".to_owned());
|
.unwrap_or_else(|| "-".to_owned());
|
||||||
|
|
||||||
let target_context = if state.is_multi_target() {
|
let target_context = if state.is_multi_target() {
|
||||||
@@ -1295,7 +1301,7 @@ fn packet_panel(state: &TuiState, snapshot: &Snapshot, panel_height: u16) -> Par
|
|||||||
)),
|
)),
|
||||||
Line::from(format!(
|
Line::from(format!(
|
||||||
"server received {} window {}",
|
"server received {} window {}",
|
||||||
format_optional_u64(packets.server_packets_received),
|
format_optional_count(packets.server_packets_received),
|
||||||
format_optional_hex(packets.server_received_window)
|
format_optional_hex(packets.server_received_window)
|
||||||
)),
|
)),
|
||||||
])
|
])
|
||||||
@@ -1314,7 +1320,7 @@ fn target_table_panel(state: &TuiState, panel_height: u16) -> Paragraph<'static>
|
|||||||
let snapshot = target.stats.snapshot();
|
let snapshot = target.stats.snapshot();
|
||||||
let last = target
|
let last = target
|
||||||
.last_sample
|
.last_sample
|
||||||
.map(|sample| format_ns_i128(Some(sample.effective_ns)))
|
.map(|sample| format_optional_ns_i128(Some(sample.effective_ns)))
|
||||||
.unwrap_or_else(|| "-".to_owned());
|
.unwrap_or_else(|| "-".to_owned());
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
target_label_span(&target.label, idx, 16),
|
target_label_span(&target.label, idx, 16),
|
||||||
@@ -1695,7 +1701,7 @@ fn viewport_x_bounds(viewport: GraphViewportRange) -> [f64; 2] {
|
|||||||
|
|
||||||
fn viewport_x_axis_labels(viewport: GraphViewportRange) -> Vec<Span<'static>> {
|
fn viewport_x_axis_labels(viewport: GraphViewportRange) -> Vec<Span<'static>> {
|
||||||
vec![
|
vec![
|
||||||
Span::raw(format!("-{}", format_duration(viewport.window))),
|
Span::raw(format!("-{}", format_span(viewport.window))),
|
||||||
Span::raw(if viewport.is_live { "live" } else { "end" }),
|
Span::raw(if viewport.is_live { "live" } else { "end" }),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1704,7 +1710,7 @@ fn graph_context(state: &TuiState, viewport: GraphViewportRange) -> String {
|
|||||||
format!(
|
format!(
|
||||||
"{} | window {}",
|
"{} | window {}",
|
||||||
graph_viewport_status(state),
|
graph_viewport_status(state),
|
||||||
format_duration(viewport.window)
|
format_span(viewport.window)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1744,9 +1750,9 @@ fn format_axis_time_ms(value_ms: f64) -> String {
|
|||||||
if abs_ms < 1.0 {
|
if abs_ms < 1.0 {
|
||||||
let us = abs_ms * 1_000.0;
|
let us = abs_ms * 1_000.0;
|
||||||
if us < 10.0 {
|
if us < 10.0 {
|
||||||
format!("{sign}{us:.1}us")
|
format!("{sign}{us:.1}µs")
|
||||||
} else {
|
} else {
|
||||||
format!("{sign}{us:.0}us")
|
format!("{sign}{us:.0}µs")
|
||||||
}
|
}
|
||||||
} else if abs_ms < 1_000.0 {
|
} else if abs_ms < 1_000.0 {
|
||||||
if abs_ms < 10.0 {
|
if abs_ms < 10.0 {
|
||||||
@@ -1804,23 +1810,23 @@ fn sample_panel(state: &TuiState, snapshot: &Snapshot) -> Paragraph<'static> {
|
|||||||
)),
|
)),
|
||||||
Line::from(format!(
|
Line::from(format!(
|
||||||
"raw / adjusted / effective: {} / {} / {}",
|
"raw / adjusted / effective: {} / {} / {}",
|
||||||
last.map(|sample| format_ns_i128(Some(sample.raw_ns)))
|
last.map(|sample| format_optional_ns_i128(Some(sample.raw_ns)))
|
||||||
.unwrap_or_else(|| "-".to_owned()),
|
.unwrap_or_else(|| "-".to_owned()),
|
||||||
last.map(|sample| format_ns_i128(sample.adjusted_ns))
|
last.map(|sample| format_optional_ns_i128(sample.adjusted_ns))
|
||||||
.unwrap_or_else(|| "-".to_owned()),
|
.unwrap_or_else(|| "-".to_owned()),
|
||||||
last.map(|sample| format_ns_i128(Some(sample.effective_ns)))
|
last.map(|sample| format_optional_ns_i128(Some(sample.effective_ns)))
|
||||||
.unwrap_or_else(|| "-".to_owned())
|
.unwrap_or_else(|| "-".to_owned())
|
||||||
)),
|
)),
|
||||||
Line::from(format!(
|
Line::from(format!(
|
||||||
"one-way c2s / s2c: {} / {}",
|
"one-way c2s / s2c: {} / {}",
|
||||||
last.map(|sample| format_ns_i128(sample.client_to_server_ns))
|
last.map(|sample| format_optional_ns_i128(sample.client_to_server_ns))
|
||||||
.unwrap_or_else(|| "-".to_owned()),
|
.unwrap_or_else(|| "-".to_owned()),
|
||||||
last.map(|sample| format_ns_i128(sample.server_to_client_ns))
|
last.map(|sample| format_optional_ns_i128(sample.server_to_client_ns))
|
||||||
.unwrap_or_else(|| "-".to_owned())
|
.unwrap_or_else(|| "-".to_owned())
|
||||||
)),
|
)),
|
||||||
Line::from(format!(
|
Line::from(format!(
|
||||||
"server processing: {}",
|
"server processing: {}",
|
||||||
last.map(|sample| format_ns_i128(sample.server_processing_ns))
|
last.map(|sample| format_optional_ns_i128(sample.server_processing_ns))
|
||||||
.unwrap_or_else(|| "-".to_owned())
|
.unwrap_or_else(|| "-".to_owned())
|
||||||
)),
|
)),
|
||||||
Line::from(format!(
|
Line::from(format!(
|
||||||
@@ -1888,7 +1894,7 @@ fn graph_viewport_status(state: &TuiState) -> String {
|
|||||||
let live_end = state.newest_graph_sample_time().unwrap_or(now).max(now);
|
let live_end = state.newest_graph_sample_time().unwrap_or(now).max(now);
|
||||||
format!(
|
format!(
|
||||||
"history -{}",
|
"history -{}",
|
||||||
format_duration(live_end.saturating_duration_since(end))
|
format_span(live_end.saturating_duration_since(end))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1906,9 +1912,9 @@ fn push_time_line(lines: &mut Vec<Line<'_>>, label: &str, stats: &TimeStats) {
|
|||||||
lines.push(Line::from(format!(
|
lines.push(Line::from(format!(
|
||||||
"{label:<18} {:>5} {:>9} {:>9} {:>9} {:>9}",
|
"{label:<18} {:>5} {:>9} {:>9} {:>9} {:>9}",
|
||||||
format_count(stats.count),
|
format_count(stats.count),
|
||||||
format_ns_i128(stats.min_ns),
|
format_optional_ns_i128(stats.min_ns),
|
||||||
format_ns_f64(stats.mean_ns),
|
format_ns_f64(stats.mean_ns),
|
||||||
format_ns_i128(stats.max_ns),
|
format_optional_ns_i128(stats.max_ns),
|
||||||
format_ns_f64(stats.stddev_ns())
|
format_ns_f64(stats.stddev_ns())
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -1918,7 +1924,7 @@ fn format_negotiated(negotiated: &NegotiatedParams) -> String {
|
|||||||
let duration = if params.duration_ns == 0 {
|
let duration = if params.duration_ns == 0 {
|
||||||
"-".to_owned()
|
"-".to_owned()
|
||||||
} else {
|
} else {
|
||||||
format_ns_i128(Some(i128::from(params.duration_ns)))
|
format_optional_ns_i128(Some(i128::from(params.duration_ns)))
|
||||||
};
|
};
|
||||||
let restrictions = if negotiated.restrictions.is_empty() {
|
let restrictions = if negotiated.restrictions.is_empty() {
|
||||||
"none".to_owned()
|
"none".to_owned()
|
||||||
@@ -1928,7 +1934,7 @@ fn format_negotiated(negotiated: &NegotiatedParams) -> String {
|
|||||||
format!(
|
format!(
|
||||||
"duration={} interval={} length={} clock={:?} timestamps={:?} stats={:?} restrictions={}",
|
"duration={} interval={} length={} clock={:?} timestamps={:?} stats={:?} restrictions={}",
|
||||||
duration,
|
duration,
|
||||||
format_ns_i128(Some(i128::from(params.interval_ns))),
|
format_optional_ns_i128(Some(i128::from(params.interval_ns))),
|
||||||
params.length,
|
params.length,
|
||||||
params.clock,
|
params.clock,
|
||||||
params.stamp_at,
|
params.stamp_at,
|
||||||
@@ -1937,80 +1943,43 @@ fn format_negotiated(negotiated: &NegotiatedParams) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_optional_duration(value: Option<Duration>) -> String {
|
fn format_optional_span(value: Option<Duration>) -> String {
|
||||||
value.map(format_duration).unwrap_or_else(|| "-".to_owned())
|
value.map(format_span).unwrap_or_else(|| ABSENT.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_duration(value: Duration) -> String {
|
/// Format a wall-clock span shown in the TUI's headers, config lines, and
|
||||||
|
/// graph window labels.
|
||||||
|
///
|
||||||
|
/// Spans are read at a glance rather than compared numerically, so they are
|
||||||
|
/// deliberately coarser than the shared scalar policy: a zero span is `0s`,
|
||||||
|
/// whole seconds carry one decimal, and a span of a minute or more is written
|
||||||
|
/// as minutes and seconds. Below one second there is nothing to gain from a
|
||||||
|
/// second spelling, so the shared scalar vocabulary is used directly.
|
||||||
|
fn format_span(value: Duration) -> String {
|
||||||
if value.is_zero() {
|
if value.is_zero() {
|
||||||
return "0s".to_owned();
|
return "0s".to_owned();
|
||||||
}
|
}
|
||||||
let nanos = value.as_nanos();
|
let nanos = value.as_nanos();
|
||||||
if nanos < 1_000 {
|
if nanos < 1_000_000_000 {
|
||||||
format!("{nanos}ns")
|
return format_duration(value);
|
||||||
} else if nanos < 1_000_000 {
|
}
|
||||||
format!("{:.1}us", nanos as f64 / 1_000.0)
|
let secs = value.as_secs();
|
||||||
} else if nanos < 1_000_000_000 {
|
if nanos < 60_000_000_000 {
|
||||||
format!("{:.1}ms", nanos as f64 / 1_000_000.0)
|
|
||||||
} else if nanos < 60_000_000_000 {
|
|
||||||
format!("{:.1}s", nanos as f64 / 1_000_000_000.0)
|
format!("{:.1}s", nanos as f64 / 1_000_000_000.0)
|
||||||
} else {
|
} else {
|
||||||
let secs = value.as_secs();
|
|
||||||
format!("{}m{:02}s", secs / 60, secs % 60)
|
format!("{}m{:02}s", secs / 60, secs % 60)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_ns_i128(value: Option<i128>) -> String {
|
|
||||||
value.map(format_ns_value).unwrap_or_else(|| "-".to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_ns_f64(value: f64) -> String {
|
|
||||||
format_ns_value(value.round() as i128)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_ns_value(value: i128) -> String {
|
|
||||||
let sign = if value < 0 { "-" } else { "" };
|
|
||||||
let value = value.saturating_abs() as f64;
|
|
||||||
if value < 1_000.0 {
|
|
||||||
format!("{sign}{value:.0}ns")
|
|
||||||
} else if value < 1_000_000.0 {
|
|
||||||
format!("{sign}{:.1}us", value / 1_000.0)
|
|
||||||
} else if value < 1_000_000_000.0 {
|
|
||||||
format!("{sign}{:.1}ms", value / 1_000_000.0)
|
|
||||||
} else {
|
|
||||||
format!("{sign}{:.3}s", value / 1_000_000_000.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_percent(value: f64) -> String {
|
|
||||||
if value.is_finite() {
|
|
||||||
format!("{value:.2}%")
|
|
||||||
} else {
|
|
||||||
"-".to_owned()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_percent_ratio(value: u64, total: u64) -> String {
|
|
||||||
if total == 0 {
|
|
||||||
"-".to_owned()
|
|
||||||
} else {
|
|
||||||
format_percent((value as f64 / total as f64 * 100.0).min(100.0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_rate(count: u64, elapsed: Duration) -> String {
|
fn format_rate(count: u64, elapsed: Duration) -> String {
|
||||||
let secs = elapsed.as_secs_f64();
|
let secs = elapsed.as_secs_f64();
|
||||||
if secs <= f64::EPSILON {
|
if secs <= f64::EPSILON {
|
||||||
"-".to_owned()
|
ABSENT.to_owned()
|
||||||
} else {
|
} else {
|
||||||
format!("{:.2}/s", count as f64 / secs)
|
format!("{:.2}/s", count as f64 / secs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_count(value: u64) -> String {
|
|
||||||
value.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate(value: &str, max_chars: usize) -> String {
|
fn truncate(value: &str, max_chars: usize) -> String {
|
||||||
if value.chars().count() <= max_chars {
|
if value.chars().count() <= max_chars {
|
||||||
return value.to_owned();
|
return value.to_owned();
|
||||||
@@ -2026,14 +1995,10 @@ fn format_seq(value: u32) -> String {
|
|||||||
value.to_string()
|
value.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_optional_u64(value: Option<u64>) -> String {
|
|
||||||
value.map(format_count).unwrap_or_else(|| "-".to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_optional_hex(value: Option<u64>) -> String {
|
fn format_optional_hex(value: Option<u64>) -> String {
|
||||||
value
|
value
|
||||||
.map(|value| format!("0x{value:x}"))
|
.map(|value| format!("0x{value:x}"))
|
||||||
.unwrap_or_else(|| "-".to_owned())
|
.unwrap_or_else(|| ABSENT.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn duration_ns(value: Duration) -> i128 {
|
fn duration_ns(value: Duration) -> i128 {
|
||||||
@@ -2177,12 +2142,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spans_stay_coarse_above_a_second_and_share_scalar_units_below_it() {
|
||||||
|
assert_eq!(format_span(Duration::ZERO), "0s");
|
||||||
|
assert_eq!(format_span(Duration::from_nanos(750)), "750ns");
|
||||||
|
assert_eq!(format_span(Duration::from_micros(500)), "500.0µs");
|
||||||
|
assert_eq!(format_span(Duration::from_millis(25)), "25.0ms");
|
||||||
|
assert_eq!(format_span(Duration::from_millis(1_500)), "1.5s");
|
||||||
|
assert_eq!(format_span(Duration::from_secs(90)), "1m30s");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn formats_signed_durations_and_missing_values() {
|
fn formats_signed_durations_and_missing_values() {
|
||||||
assert_eq!(format_ns_i128(Some(-1_500_000)), "-1.5ms");
|
assert_eq!(format_optional_ns_i128(Some(-1_500_000)), "-1.5ms");
|
||||||
assert_eq!(format_ns_i128(Some(750)), "750ns");
|
assert_eq!(format_optional_ns_i128(Some(750)), "750ns");
|
||||||
assert_eq!(format_ns_i128(None), "-");
|
assert_eq!(format_optional_ns_i128(None), "-");
|
||||||
assert_eq!(format_optional_duration(None), "-");
|
assert_eq!(format_optional_ns_i128(Some(1_500)), "1.5µs");
|
||||||
|
assert_eq!(format_optional_span(None), "-");
|
||||||
assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms");
|
assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms");
|
||||||
assert_eq!(format_percent_ratio(1, 4), "25.00%");
|
assert_eq!(format_percent_ratio(1, 4), "25.00%");
|
||||||
assert_eq!(format_optional_hex(Some(0x1f)), "0x1f");
|
assert_eq!(format_optional_hex(Some(0x1f)), "0x1f");
|
||||||
@@ -2748,7 +2724,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rendered,
|
rendered,
|
||||||
vec!["-1.00ms", "-500us", "0.0us", "500us", "1.00ms"]
|
vec!["-1.00ms", "-500µs", "0.0µs", "500µs", "1.00ms"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user