feat(tui): display wall-clock timestamps on graph x-axis
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m13s
Rust CI / cargo doc (push) Successful in 26s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Supply chain / cargo-deny advisories (push) Successful in 7s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m4s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m13s
Rust CI / cargo doc (push) Successful in 26s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Supply chain / cargo-deny advisories (push) Successful in 7s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m4s
This commit is contained in:
3 files changed
+308
-23
No files matched your search
Generated
+126
@@ -17,6 +17,15 @@ version = "0.2.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_system_properties"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -199,6 +208,16 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -211,6 +230,17 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
|
dependencies = [
|
||||||
|
"iana-time-zone",
|
||||||
|
"num-traits",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -292,6 +322,12 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -553,6 +589,12 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "finl_unicode"
|
name = "finl_unicode"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -686,6 +728,30 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.65"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ident_case"
|
name = "ident_case"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -751,6 +817,7 @@ dependencies = [
|
|||||||
name = "irtt-rs"
|
name = "irtt-rs"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
@@ -1604,6 +1671,12 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook"
|
name = "signal-hook"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
@@ -2153,12 +2226,65 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.59.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
|
|||||||
@@ -44,9 +44,10 @@ doc = false
|
|||||||
default = ["client", "server", "tui"]
|
default = ["client", "server", "tui"]
|
||||||
client = ["dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"]
|
client = ["dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"]
|
||||||
server = ["dep:irtt-server", "dep:tokio"]
|
server = ["dep:irtt-server", "dep:tokio"]
|
||||||
tui =["dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"]
|
tui =["dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm", "dep:chrono"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true }
|
||||||
clap = { version = "4.6.7", features = ["derive", "env"] }
|
clap = { version = "4.6.7", features = ["derive", "env"] }
|
||||||
crossterm = { version = "0.29.0", features = ["event-stream"], optional = true }
|
crossterm = { version = "0.29.0", features = ["event-stream"], optional = true }
|
||||||
futures-core = { version = "0.3.34", default-features = false, optional = true }
|
futures-core = { version = "0.3.34", default-features = false, optional = true }
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, VecDeque},
|
collections::{BTreeMap, VecDeque},
|
||||||
io::{self, Stdout},
|
io::{self, Stdout},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant, SystemTime},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Local, TimeZone, Utc};
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
cursor::Show,
|
cursor::Show,
|
||||||
event::KeyCode,
|
event::KeyCode,
|
||||||
@@ -11,7 +12,9 @@ use crossterm::{
|
|||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
};
|
};
|
||||||
use irtt_client::managed::{ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance};
|
use irtt_client::managed::{ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance};
|
||||||
use irtt_client::{ClientEvent, NegotiationResult, OneWayDelaySample, RttSample, SignedDuration};
|
use irtt_client::{
|
||||||
|
ClientEvent, ClientTimestamp, NegotiationResult, OneWayDelaySample, RttSample, SignedDuration,
|
||||||
|
};
|
||||||
use irtt_stats::{StatsCollector, TimeStats};
|
use irtt_stats::{StatsCollector, TimeStats};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
backend::CrosstermBackend,
|
backend::CrosstermBackend,
|
||||||
@@ -40,7 +43,26 @@ const MIN_WIDTH: u16 = 56;
|
|||||||
const MIN_HEIGHT: u16 = 18;
|
const MIN_HEIGHT: u16 = 18;
|
||||||
const DEFAULT_GRAPH_WINDOW: Duration = Duration::from_secs(60);
|
const DEFAULT_GRAPH_WINDOW: Duration = Duration::from_secs(60);
|
||||||
const MIN_GRAPH_WINDOW: Duration = Duration::from_secs(5);
|
const MIN_GRAPH_WINDOW: Duration = Duration::from_secs(5);
|
||||||
const MAX_GRAPH_WINDOW: Duration = Duration::from_secs(60 * 60);
|
const MAX_GRAPH_WINDOW: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
|
|
||||||
|
const GRAPH_WINDOWS: &[Duration] = &[
|
||||||
|
Duration::from_secs(5),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
Duration::from_secs(15),
|
||||||
|
Duration::from_secs(30),
|
||||||
|
Duration::from_secs(60),
|
||||||
|
Duration::from_secs(2 * 60),
|
||||||
|
Duration::from_secs(5 * 60),
|
||||||
|
Duration::from_secs(10 * 60),
|
||||||
|
Duration::from_secs(15 * 60),
|
||||||
|
Duration::from_secs(30 * 60),
|
||||||
|
Duration::from_secs(60 * 60),
|
||||||
|
Duration::from_secs(2 * 60 * 60),
|
||||||
|
Duration::from_secs(4 * 60 * 60),
|
||||||
|
Duration::from_secs(8 * 60 * 60),
|
||||||
|
Duration::from_secs(12 * 60 * 60),
|
||||||
|
Duration::from_secs(24 * 60 * 60),
|
||||||
|
];
|
||||||
|
|
||||||
pub(super) struct TuiTerminal {
|
pub(super) struct TuiTerminal {
|
||||||
terminal: Terminal<CrosstermBackend<Stdout>>,
|
terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||||
@@ -297,7 +319,7 @@ impl TuiState {
|
|||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
target.push_graph_sample(GraphSample {
|
target.push_graph_sample(GraphSample {
|
||||||
timestamp: received_at.mono,
|
timestamp: *received_at,
|
||||||
seq: *seq,
|
seq: *seq,
|
||||||
rtt: *rtt,
|
rtt: *rtt,
|
||||||
one_way: *one_way,
|
one_way: *one_way,
|
||||||
@@ -472,14 +494,24 @@ impl TuiState {
|
|||||||
fn oldest_graph_sample_time(&self) -> Option<Instant> {
|
fn oldest_graph_sample_time(&self) -> Option<Instant> {
|
||||||
self.targets
|
self.targets
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|target| target.graph_history.front().map(|sample| sample.timestamp))
|
.filter_map(|target| {
|
||||||
|
target
|
||||||
|
.graph_history
|
||||||
|
.front()
|
||||||
|
.map(|sample| sample.timestamp.mono)
|
||||||
|
})
|
||||||
.min()
|
.min()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn newest_graph_sample_time(&self) -> Option<Instant> {
|
fn newest_graph_sample_time(&self) -> Option<Instant> {
|
||||||
self.targets
|
self.targets
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|target| target.graph_history.back().map(|sample| sample.timestamp))
|
.filter_map(|target| {
|
||||||
|
target
|
||||||
|
.graph_history
|
||||||
|
.back()
|
||||||
|
.map(|sample| sample.timestamp.mono)
|
||||||
|
})
|
||||||
.max()
|
.max()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -660,7 +692,6 @@ impl GraphViewport {
|
|||||||
start: end.checked_sub(self.window).unwrap_or(end),
|
start: end.checked_sub(self.window).unwrap_or(end),
|
||||||
end,
|
end,
|
||||||
window: self.window,
|
window: self.window,
|
||||||
is_live: matches!(self.mode, GraphViewportMode::Follow),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,11 +726,11 @@ impl GraphViewport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn zoom_in(&mut self, newest: Option<Instant>) {
|
fn zoom_in(&mut self, newest: Option<Instant>) {
|
||||||
self.set_window(self.window * 2 / 3, newest);
|
self.set_window(graph_zoom_window(self.window, false), newest);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn zoom_out(&mut self, newest: Option<Instant>) {
|
fn zoom_out(&mut self, newest: Option<Instant>) {
|
||||||
self.set_window(self.window * 3 / 2, newest);
|
self.set_window(graph_zoom_window(self.window, true), newest);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reset_window(&mut self, newest: Option<Instant>) {
|
fn reset_window(&mut self, newest: Option<Instant>) {
|
||||||
@@ -742,6 +773,23 @@ impl GraphViewport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn graph_zoom_window(current: Duration, larger: bool) -> Duration {
|
||||||
|
if larger {
|
||||||
|
GRAPH_WINDOWS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|&window| window > current)
|
||||||
|
.unwrap_or(MAX_GRAPH_WINDOW)
|
||||||
|
} else {
|
||||||
|
GRAPH_WINDOWS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.rev()
|
||||||
|
.find(|&window| window < current)
|
||||||
|
.unwrap_or(MIN_GRAPH_WINDOW)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum GraphViewportMode {
|
enum GraphViewportMode {
|
||||||
Follow,
|
Follow,
|
||||||
@@ -753,7 +801,6 @@ struct GraphViewportRange {
|
|||||||
start: Instant,
|
start: Instant,
|
||||||
end: Instant,
|
end: Instant,
|
||||||
window: Duration,
|
window: Duration,
|
||||||
is_live: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -839,7 +886,7 @@ impl GraphMetric {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
struct GraphSample {
|
struct GraphSample {
|
||||||
timestamp: Instant,
|
timestamp: ClientTimestamp,
|
||||||
seq: u32,
|
seq: u32,
|
||||||
rtt: RttSample,
|
rtt: RttSample,
|
||||||
one_way: Option<OneWayDelaySample>,
|
one_way: Option<OneWayDelaySample>,
|
||||||
@@ -985,7 +1032,7 @@ fn target_table(state: &TuiState, area: Rect) -> Table<'static> {
|
|||||||
},
|
},
|
||||||
target
|
target
|
||||||
.last_sample
|
.last_sample
|
||||||
.map(|sample| format_span(sample.timestamp.elapsed()))
|
.map(|sample| format_span(sample.timestamp.mono.elapsed()))
|
||||||
.unwrap_or_else(|| ABSENT.to_owned()),
|
.unwrap_or_else(|| ABSENT.to_owned()),
|
||||||
])
|
])
|
||||||
.style(target_style(idx))
|
.style(target_style(idx))
|
||||||
@@ -1194,7 +1241,7 @@ fn render_graph_area(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
|
|||||||
.x_axis(
|
.x_axis(
|
||||||
Axis::default()
|
Axis::default()
|
||||||
.bounds(viewport_x_bounds(viewport))
|
.bounds(viewport_x_bounds(viewport))
|
||||||
.labels(viewport_x_axis_labels(viewport))
|
.labels(viewport_x_axis_labels(state, viewport, area.width))
|
||||||
.style(Style::default().fg(Color::Gray)),
|
.style(Style::default().fg(Color::Gray)),
|
||||||
)
|
)
|
||||||
.y_axis(
|
.y_axis(
|
||||||
@@ -1229,7 +1276,9 @@ fn target_metric_series(
|
|||||||
let data = target
|
let data = target
|
||||||
.graph_history
|
.graph_history
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|sample| sample.timestamp >= viewport.start && sample.timestamp <= viewport.end)
|
.filter(|sample| {
|
||||||
|
sample.timestamp.mono >= viewport.start && sample.timestamp.mono <= viewport.end
|
||||||
|
})
|
||||||
.filter_map(|sample| {
|
.filter_map(|sample| {
|
||||||
metric
|
metric
|
||||||
.value_ns(sample)
|
.value_ns(sample)
|
||||||
@@ -1246,6 +1295,7 @@ fn target_metric_series(
|
|||||||
fn sample_x(sample: &GraphSample, viewport: GraphViewportRange) -> f64 {
|
fn sample_x(sample: &GraphSample, viewport: GraphViewportRange) -> f64 {
|
||||||
sample
|
sample
|
||||||
.timestamp
|
.timestamp
|
||||||
|
.mono
|
||||||
.saturating_duration_since(viewport.start)
|
.saturating_duration_since(viewport.start)
|
||||||
.as_secs_f64()
|
.as_secs_f64()
|
||||||
}
|
}
|
||||||
@@ -1348,11 +1398,95 @@ fn viewport_x_bounds(viewport: GraphViewportRange) -> [f64; 2] {
|
|||||||
[0.0, viewport.window.as_secs_f64().max(1.0)]
|
[0.0, viewport.window.as_secs_f64().max(1.0)]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn viewport_x_axis_labels(viewport: GraphViewportRange) -> Vec<Span<'static>> {
|
// Use a sample near the viewport end, including when viewing retained history.
|
||||||
vec![
|
// Wall-clock jumps affect labels only; the viewport and series remain monotonic.
|
||||||
Span::raw(format!("-{}", format_span(viewport.window))),
|
fn graph_timestamp_anchor(state: &TuiState, end: Instant) -> Option<ClientTimestamp> {
|
||||||
Span::raw(if viewport.is_live { "live" } else { "end" }),
|
state
|
||||||
]
|
.targets
|
||||||
|
.iter()
|
||||||
|
.filter_map(|target| {
|
||||||
|
target
|
||||||
|
.graph_history
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|sample| sample.timestamp.mono <= end)
|
||||||
|
.or_else(|| target.graph_history.front())
|
||||||
|
})
|
||||||
|
.map(|sample| sample.timestamp)
|
||||||
|
.min_by_key(|timestamp| {
|
||||||
|
if timestamp.mono <= end {
|
||||||
|
end.duration_since(timestamp.mono)
|
||||||
|
} else {
|
||||||
|
timestamp.mono.duration_since(end)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph_wall_time(anchor: ClientTimestamp, tick: Instant) -> Option<SystemTime> {
|
||||||
|
if tick >= anchor.mono {
|
||||||
|
anchor.wall.checked_add(tick.duration_since(anchor.mono))
|
||||||
|
} else {
|
||||||
|
anchor.wall.checked_sub(anchor.mono.duration_since(tick))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph_local_time(wall: SystemTime) -> Option<DateTime<chrono::FixedOffset>> {
|
||||||
|
let nanos = match wall.duration_since(SystemTime::UNIX_EPOCH) {
|
||||||
|
Ok(duration) => duration.as_nanos() as i128,
|
||||||
|
Err(error) => -(error.duration().as_nanos() as i128),
|
||||||
|
};
|
||||||
|
let seconds = i64::try_from(nanos.div_euclid(1_000_000_000)).ok()?;
|
||||||
|
let subsecond = nanos.rem_euclid(1_000_000_000) as u32;
|
||||||
|
let utc = DateTime::<Utc>::from_timestamp(seconds, subsecond)?;
|
||||||
|
// Chrono also falls back to UTC when the system timezone cannot be found.
|
||||||
|
Some(
|
||||||
|
Local
|
||||||
|
.timestamp_opt(seconds, subsecond)
|
||||||
|
.single()
|
||||||
|
.map(|local| local.fixed_offset())
|
||||||
|
.unwrap_or_else(|| utc.fixed_offset()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn viewport_x_axis_labels(
|
||||||
|
state: &TuiState,
|
||||||
|
viewport: GraphViewportRange,
|
||||||
|
width: u16,
|
||||||
|
) -> Vec<Span<'static>> {
|
||||||
|
let Some(anchor) = graph_timestamp_anchor(state, viewport.end) else {
|
||||||
|
return vec![Span::raw(ABSENT), Span::raw(ABSENT)];
|
||||||
|
};
|
||||||
|
let local_at = |tick| graph_wall_time(anchor, tick).and_then(graph_local_time);
|
||||||
|
let date_boundary = local_at(viewport.start)
|
||||||
|
.zip(local_at(viewport.end))
|
||||||
|
.is_some_and(|(start, end)| start.date_naive() != end.date_naive());
|
||||||
|
// Reserve space for the borders/y axis and a gap between labels. Use the
|
||||||
|
// longest possible label for this window so narrow charts remain readable.
|
||||||
|
let fractional = viewport.window < Duration::from_secs(8);
|
||||||
|
let label_width = 8 + if fractional { 2 } else { 0 } + if date_boundary { 6 } else { 0 };
|
||||||
|
let count = (usize::from(width.saturating_sub(14)) / (label_width + 3)).clamp(2, 5);
|
||||||
|
let step = viewport.window / (count - 1) as u32;
|
||||||
|
let fractional = step < Duration::from_secs(2);
|
||||||
|
(0..count)
|
||||||
|
.map(|idx| {
|
||||||
|
let tick = if idx == count - 1 {
|
||||||
|
Some(viewport.end)
|
||||||
|
} else {
|
||||||
|
viewport.start.checked_add(step * idx as u32)
|
||||||
|
};
|
||||||
|
let label = tick.and_then(local_at).map(|local| {
|
||||||
|
let mut label = local.format("%H:%M:%S").to_string();
|
||||||
|
if fractional {
|
||||||
|
label.push_str(&format!(".{}", local.timestamp_subsec_millis() / 100));
|
||||||
|
}
|
||||||
|
if date_boundary && (idx == 0 || idx == count - 1) {
|
||||||
|
label = format!("{} {label}", local.format("%m-%d"));
|
||||||
|
}
|
||||||
|
label
|
||||||
|
});
|
||||||
|
Span::raw(label.unwrap_or_else(|| ABSENT.to_owned()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn y_axis_label_count(height: u16) -> usize {
|
fn y_axis_label_count(height: u16) -> usize {
|
||||||
@@ -1479,15 +1613,39 @@ 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_000_000 {
|
if nanos < 1_000_000_000 {
|
||||||
return format_duration(value);
|
return format_duration(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
let secs = value.as_secs();
|
let secs = value.as_secs();
|
||||||
if nanos < 60_000_000_000 {
|
|
||||||
format!("{:.1}s", nanos as f64 / 1_000_000_000.0)
|
if secs < 60 {
|
||||||
|
return format!("{:.1}s", nanos as f64 / 1_000_000_000.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let seconds = secs % 60;
|
||||||
|
let minutes = (secs / 60) % 60;
|
||||||
|
let hours = (secs / 3600) % 24;
|
||||||
|
let days = secs / 86_400;
|
||||||
|
|
||||||
|
if days != 0 {
|
||||||
|
if hours == 0 {
|
||||||
|
format!("{days}d")
|
||||||
} else {
|
} else {
|
||||||
format!("{}m{:02}s", secs / 60, secs % 60)
|
format!("{days}d{hours:02}h")
|
||||||
|
}
|
||||||
|
} else if hours != 0 {
|
||||||
|
if minutes == 0 {
|
||||||
|
format!("{hours}h")
|
||||||
|
} else {
|
||||||
|
format!("{hours}h{minutes:02}m")
|
||||||
|
}
|
||||||
|
} else if seconds == 0 {
|
||||||
|
format!("{minutes}m")
|
||||||
|
} else {
|
||||||
|
format!("{minutes}m{seconds:02}s")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user