diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 10944696..33e658f8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -324,11 +324,18 @@ The same backend publishes a socket snapshot every 5 seconds for the Host tab. T The tool automatically detects and lists available network interfaces using platform-specific methods: - **Linux**: Uses `netlink` or falls back to `/sys/class/net/` -- **macOS**: Uses `getifaddrs()` system call +- **macOS**: Uses `getifaddrs()` system call and `SIOCGIFMEDIA` for link state and speed - **Windows**: Uses IP Helper APIs (`GetAdaptersInfo()` for interface listing and `GetAdaptersAddresses()` for the parser's complete IPv4/IPv6 local-address set) - **All platforms**: Falls back to pcap's `pcap_findalldevs()` when native methods fail +The interface-statistics provider also reports optional directional link +capacity. Linux reads `/sys/class/net//speed`, macOS combines active +media information with the interface baud rate, FreeBSD uses `ifi_baudrate`, +and Windows reads `ReceiveLinkSpeed` and `TransmitLinkSpeed` from +`MIB_IF_ROW2`. Aggregate graphs use this capacity only when every interface +carrying traffic in that direction has a known value. + Packet endpoint orientation maintains a snapshot of the addresses currently assigned to the host. Packet-processing workers refresh it every 30 seconds and, when neither unicast endpoint is recognized as local, perform a rate-limited refresh and retry that packet once. diff --git a/ARCHITECTURE.zh-CN.md b/ARCHITECTURE.zh-CN.md index 9f3d9491..b5507d7c 100644 --- a/ARCHITECTURE.zh-CN.md +++ b/ARCHITECTURE.zh-CN.md @@ -318,11 +318,17 @@ RustNet 使用平台特定的 API 将网络连接与进程关联。每次归属 该工具使用平台特定的方法自动检测和列出可用网络接口: - **Linux**:使用 `netlink` 或回退到 `/sys/class/net/` -- **macOS**:使用 `getifaddrs()` 系统调用 +- **macOS**:使用 `getifaddrs()` 系统调用,并通过 `SIOCGIFMEDIA` 获取链路状态与速率 - **Windows**:使用 IP Helper API(`GetAdaptersInfo()` 用于列出接口, `GetAdaptersAddresses()` 用于获取解析器所需的完整 IPv4/IPv6 本地地址集合) - **所有平台**:当原生方法失败时回退到 pcap 的 `pcap_findalldevs()` +接口统计 provider 还会报告可选的方向链路容量。Linux 读取 +`/sys/class/net//speed`,macOS 将活跃媒体信息与接口 baud rate 结合, +FreeBSD 使用 `ifi_baudrate`,Windows 则读取 `MIB_IF_ROW2` 的 +`ReceiveLinkSpeed` 和 `TransmitLinkSpeed`。只有当该方向上所有正在传输流量的接口 +都具有已知容量时,汇总图表才会使用这一固定上限。 + 数据包端点方向判定会维护当前分配给主机的地址快照。数据包处理线程每 30 秒刷新一次; 当两个单播端点都无法识别为本地地址时,还会以限速方式立即刷新,并重新解析该数据包一次。 因此,DHCP 地址变化、VPN 连接、网络漫游以及 IPv6 隐私地址轮换后,流量方向仍能正确判定。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 731fd01e..6cb2c56e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the selected process group ### Changed +- **Capacity-Scaled Traffic Graphs**: aggregate RX and TX graphs use the local + interfaces' reported link capacity as a stable ceiling and show utilization + when every traffic-bearing interface has a known speed. Unsupported and + virtual interfaces retain observed-peak scaling. A fixed capacity-relative + curve keeps low-utilization traffic visible without moving the ceiling - **Responsive System Sidebar**: Traffic now appears before the static Security details, which collapse to the sandbox status when terminal height is limited - **Contextual Help Overlay**: Help now opens above the active tab and only diff --git a/README.ja.md b/README.ja.md index 45fc8ac4..bcf88748 100644 --- a/README.ja.md +++ b/README.ja.md @@ -21,6 +21,7 @@ RustNet は、各接続を所有するプロセス、通信量、状態、アプ - HTTP、TLS/SNI、DNS、SSH、QUIC などの深層パケット解析 - TCP、QUIC ハンドシェイク、DNS 応答、ICMP エコーの往復時間(RTT)と、TCP の再送・順序入れ替わりをリアルタイム表示 - Host タブに TCP LISTEN ソケット、UDP BOUND エンドポイント、TCP 状態集計、観測 RTT、所有プロセス、インターフェース統計を表示 +- リンク容量を取得できる場合、安定した容量基準の曲線で RX / TX と現在の利用率を表示 - `port:`、`process:`、`sni:`、`state:` などのフィルター - 注釈付き PCAPNG、PCAP と JSONL sidecar、JSON ログの出力 - ローカル GeoIP データベースによる国、ASN、都市情報 diff --git a/README.md b/README.md index 42d6aef0..35bab1e5 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for technical information. RustNet combines process-level traffic accounting with real-time network interface statistics: - **Overview Tab**: Shows active interfaces with current rates, errors, and drops +- **Traffic Graphs**: Use a stable capacity-relative curve and show link utilization when available - **Activity Tab** (press `3`): Ranks processes by Egress (TX) or Ingress (RX), including retained and rolling traffic, rates, shares, connections, and destinations - **Security Workflow**: Sort by Egress, identify an unexpected uploader, then inspect its top remote peer and retained traffic even after the connection closes - **Host Tab** (press `5`): Shows TCP LISTEN sockets, UDP BOUND endpoints, aggregated TCP states, observed RTT, and process ownership diff --git a/README.zh-CN.md b/README.zh-CN.md index 4189223e..f7a4a123 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -82,6 +82,7 @@ cargo build --release --no-default-features RustNet 将进程级流量计量与实时网络接口统计整合在一起: - **概览标签页**:展示当前活跃的接口,包含速率、错误数与丢包数 +- **流量图表**:链路容量可用时,以稳定的容量相对曲线显示 RX / TX 和利用率 - **活动标签页**(按 `3`):按出站 (TX) 或入站 (RX) 查看进程排名,包括保留流量与滚动流量、速率、占比、连接数和目的地 - **安全工作流**:按出站流量排序,找出异常上传进程,然后检查其流量最大的远端对端;即使连接关闭,仍可查看保留流量 - **主机标签页**(按 `5`):显示 TCP LISTEN 套接字、UDP BOUND 端点、TCP 状态汇总、观测 RTT 和所属进程 diff --git a/USAGE.md b/USAGE.md index b450be33..b3bd8c4b 100644 --- a/USAGE.md +++ b/USAGE.md @@ -1095,6 +1095,15 @@ In process grouping mode (`a`), group headers show the historic connection count **Graph tab:** The graph tab always shows only active connections, even when historic mode is on. +Its aggregate RX and TX waves use the operating system's reported link capacity +as a stable ceiling when all traffic-bearing interfaces have a known speed. The +header then shows current utilization and, where space permits, the link +capacity. A fixed capacity-relative curve keeps low utilization visible without +moving that ceiling. Tunnels, virtual adapters, and drivers without speed +information fall back automatically to the rolling observed-peak scale. A +`>100%` indicator means the measured interface counters exceeded the reported +capacity; the wave remains pinned to the capacity ceiling so the mismatch is +visible. **Resetting:** diff --git a/USAGE.zh-CN.md b/USAGE.zh-CN.md index ac73cfb1..92413570 100644 --- a/USAGE.zh-CN.md +++ b/USAGE.zh-CN.md @@ -1072,7 +1072,11 @@ RustNet 根据协议和检测到的应用调整连接超时: **图表标签页:** -图表标签页始终只显示活跃连接,即使历史模式开启。 +图表标签页始终只显示活跃连接,即使历史模式开启。当所有正在传输流量的接口都能提供链路速率时, +汇总 RX 和 TX 波形会使用操作系统报告的链路容量作为稳定上限。标题会显示当前利用率,并在空间足够时 +显示链路容量。固定的容量相对曲线会在不移动上限的情况下保持低利用率流量可见。隧道、虚拟适配器或 +无法提供速率信息的驱动会自动回退到滚动观测峰值缩放。 +`>100%` 表示测得的接口计数器速率超过了报告容量;波形仍固定在容量上限,以便明确显示这种不一致。 **重置:** diff --git a/crates/rustnet-core/src/network/interface_stats/bsd.rs b/crates/rustnet-core/src/network/interface_stats/bsd.rs index 6fd84b8f..74f2ccd3 100644 --- a/crates/rustnet-core/src/network/interface_stats/bsd.rs +++ b/crates/rustnet-core/src/network/interface_stats/bsd.rs @@ -5,7 +5,7 @@ //! struct layout differs (Apple's counters are `u32`, FreeBSD's are `u64`), //! so each platform supplies its own extractor to the shared walker. -use super::{InterfaceStats, InterfaceStatsProvider}; +use super::{InterfaceStats, InterfaceStatsProvider, LinkCapacity}; use std::ffi::CStr; use std::io; use std::ptr; @@ -51,6 +51,138 @@ fn collect_af_link_stats( #[cfg(target_os = "macos")] pub struct MacOSStatsProvider; +#[cfg(target_os = "macos")] +const IFM_ETHER: i32 = 0x20; +#[cfg(target_os = "macos")] +const IFM_IEEE80211: i32 = 0x80; +#[cfg(target_os = "macos")] +const IFM_NMASK: i32 = 0xe0; +#[cfg(target_os = "macos")] +const IFM_TMASK_COMPAT: i32 = 0x1f; +#[cfg(target_os = "macos")] +const IFM_TMASK_EXT: i32 = 0x000f_0000; +#[cfg(target_os = "macos")] +const IFM_TMASK_EXT_SHIFT: u32 = 11; +#[cfg(target_os = "macos")] +const IFM_AVALID: i32 = 0x1; +#[cfg(target_os = "macos")] +const IFM_ACTIVE: i32 = 0x2; + +/// Apple's public `ifmediareq` is packed to four-byte alignment, including +/// its pointer field. The libc crate exposes the ioctl constants but not this +/// userspace request structure on macOS. +#[cfg(target_os = "macos")] +#[repr(C, packed(4))] +struct MacIfMediaReq { + ifm_name: [libc::c_char; libc::IFNAMSIZ], + ifm_current: libc::c_int, + ifm_mask: libc::c_int, + ifm_status: libc::c_int, + ifm_active: libc::c_int, + ifm_count: libc::c_int, + ifm_ulist: *mut libc::c_int, +} + +#[cfg(target_os = "macos")] +fn media_subtype(media: i32) -> i32 { + (media & IFM_TMASK_COMPAT) | ((media & IFM_TMASK_EXT) >> IFM_TMASK_EXT_SHIFT) +} + +#[cfg(target_os = "macos")] +fn ethernet_media_speed_bps(subtype: i32) -> Option { + let mbps = match subtype { + 3..=5 | 12 | 13 => 10, + 6..=10 | 52 | 62 => 100, + 11 | 14..=16 | 24 | 25 | 41 => 1_000, + 22 | 32 | 36 | 63 => 2_500, + 23 | 64 | 69 | 70 => 5_000, + 18..=21 | 26..=29 | 33..=35 | 42 | 59 => 10_000, + 30 => 20_000, + 40 | 53..=55 | 58 | 60 | 61 | 65..=68 | 71 | 86 => 25_000, + 37 | 38 | 43..=45 | 72..=74 => 40_000, + 39 | 56 | 57 | 75..=85 | 87 | 88 => 50_000, + 51 => 56_000, + 47..=50 | 89..=102 => 100_000, + 103..=105 => 200_000, + _ => return None, + }; + Some(mbps * 1_000_000) +} + +#[cfg(target_os = "macos")] +fn wireless_media_speed_bps(subtype: i32) -> Option { + let bits_per_second = match subtype { + 3 | 8 => 1_000_000, + 4 | 5 => 2_000_000, + 6 => 5_500_000, + 7 => 11_000_000, + 9 => 22_000_000, + _ => return None, + }; + Some(bits_per_second) +} + +#[cfg(target_os = "macos")] +fn media_speed_bps(media: i32) -> Option { + let subtype = media_subtype(media); + match media & IFM_NMASK { + IFM_ETHER => ethernet_media_speed_bps(subtype), + IFM_IEEE80211 => wireless_media_speed_bps(subtype), + _ => None, + } +} + +/// Return whether the link is active and the current media word. Extended +/// media is attempted first so links above 20 Gb/s are not collapsed to +/// `IFM_OTHER` by the compatibility ioctl. +#[cfg(target_os = "macos")] +fn query_media(interface: &str) -> Option<(bool, i32)> { + let name = interface.as_bytes(); + if name.len() >= libc::IFNAMSIZ { + return None; + } + + let socket = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0) }; + if socket < 0 { + return None; + } + + let mut request: MacIfMediaReq = unsafe { std::mem::zeroed() }; + for (target, source) in request.ifm_name.iter_mut().zip(name) { + *target = *source as libc::c_char; + } + + let extended = unsafe { libc::ioctl(socket, libc::SIOCGIFXMEDIA, &mut request) }; + let result = if extended == 0 { + extended + } else { + unsafe { libc::ioctl(socket, libc::SIOCGIFMEDIA, &mut request) } + }; + unsafe { libc::close(socket) }; + if result != 0 { + return None; + } + + let status = unsafe { std::ptr::addr_of!(request.ifm_status).read_unaligned() }; + let active_media = unsafe { std::ptr::addr_of!(request.ifm_active).read_unaligned() }; + let active = status & IFM_AVALID == 0 || status & IFM_ACTIVE != 0; + Some((active, active_media)) +} + +#[cfg(target_os = "macos")] +fn macos_link_capacity(interface: &str, baudrate: u32) -> LinkCapacity { + let media = match query_media(interface) { + Some((false, _)) => return LinkCapacity::default(), + Some((true, media)) => media_speed_bps(media), + None => None, + }; + let baudrate = (baudrate > 0 && baudrate != u32::MAX).then_some(u64::from(baudrate)); + media + .or(baudrate) + .map(LinkCapacity::symmetric) + .unwrap_or_default() +} + /// Sanitize counter values that may be uninitialized or invalid on virtual interfaces. /// On macOS, some virtual interfaces (like vmenet0) report garbage values for certain /// statistics fields, particularly ifi_iqdrops. We detect these by checking if: @@ -83,6 +215,7 @@ impl InterfaceStatsProvider for MacOSStatsProvider { let total_tx_packets = if_data.ifi_opackets; InterfaceStats { + link_capacity: macos_link_capacity(&name, if_data.ifi_baudrate), interface_name: name, rx_bytes: if_data.ifi_ibytes as u64, tx_bytes: if_data.ifi_obytes as u64, @@ -112,6 +245,9 @@ impl InterfaceStatsProvider for FreeBSDStatsProvider { // FreeBSD's if_data counters are already u64. collect_af_link_stats(|if_data, name| InterfaceStats { interface_name: name, + link_capacity: (if_data.ifi_baudrate > 0) + .then(|| LinkCapacity::symmetric(if_data.ifi_baudrate)) + .unwrap_or_default(), rx_bytes: if_data.ifi_ibytes, tx_bytes: if_data.ifi_obytes, rx_packets: if_data.ifi_ipackets, @@ -146,6 +282,23 @@ mod tests { } } + #[cfg(target_os = "macos")] + #[test] + fn macos_media_request_matches_system_abi() { + assert_eq!(std::mem::size_of::(), 44); + } + + #[cfg(target_os = "macos")] + #[test] + fn maps_common_macos_media_speeds() { + assert_eq!(ethernet_media_speed_bps(3), Some(10_000_000)); + assert_eq!(ethernet_media_speed_bps(16), Some(1_000_000_000)); + assert_eq!(ethernet_media_speed_bps(22), Some(2_500_000_000)); + assert_eq!(ethernet_media_speed_bps(42), Some(10_000_000_000)); + assert_eq!(ethernet_media_speed_bps(97), Some(100_000_000_000)); + assert_eq!(ethernet_media_speed_bps(0), None); + } + #[cfg(target_os = "freebsd")] #[test] fn test_freebsd_list_interfaces() { diff --git a/crates/rustnet-core/src/network/interface_stats/linux.rs b/crates/rustnet-core/src/network/interface_stats/linux.rs index 7263ad6e..debe629a 100644 --- a/crates/rustnet-core/src/network/interface_stats/linux.rs +++ b/crates/rustnet-core/src/network/interface_stats/linux.rs @@ -1,6 +1,6 @@ // interface_stats/linux.rs - Linux sysfs-based interface stats -use super::{InterfaceStats, InterfaceStatsProvider}; +use super::{InterfaceStats, InterfaceStatsProvider, LinkCapacity}; use std::fs; use std::io; use std::time::SystemTime; @@ -22,6 +22,7 @@ impl LinuxStatsProvider { Ok(InterfaceStats { interface_name: interface.to_string(), + link_capacity: read_link_capacity(interface), rx_bytes: read_stat(&base_path, "rx_bytes")?, tx_bytes: read_stat(&base_path, "tx_bytes")?, rx_packets: read_stat(&base_path, "rx_packets")?, @@ -36,6 +37,27 @@ impl LinuxStatsProvider { } } +/// Read the latest negotiated speed exposed by ethtool-backed drivers. +/// Missing files and sentinel values are normal for loopback, tunnels, and +/// virtual interfaces, so capacity discovery must not fail stat collection. +fn read_link_capacity(interface: &str) -> LinkCapacity { + let path = format!("/sys/class/net/{interface}/speed"); + fs::read_to_string(path) + .ok() + .and_then(|value| parse_speed_mbps(&value)) + .map(LinkCapacity::symmetric) + .unwrap_or_default() +} + +fn parse_speed_mbps(value: &str) -> Option { + value + .trim() + .parse::() + .ok() + .filter(|speed| *speed > 0) + .and_then(|speed| speed.checked_mul(1_000_000)) +} + impl InterfaceStatsProvider for LinuxStatsProvider { fn get_all_stats(&self) -> Result, io::Error> { let mut stats = Vec::new(); @@ -147,4 +169,13 @@ mod tests { assert!(result.is_err()); assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound); } + + #[test] + fn parses_optional_link_speed() { + assert_eq!(parse_speed_mbps("1000\n"), Some(1_000_000_000)); + assert_eq!(parse_speed_mbps("-1\n"), None); + assert_eq!(parse_speed_mbps("0"), None); + assert_eq!(parse_speed_mbps("not available"), None); + assert_eq!(parse_speed_mbps(&u64::MAX.to_string()), None); + } } diff --git a/crates/rustnet-core/src/network/interface_stats/mod.rs b/crates/rustnet-core/src/network/interface_stats/mod.rs index 48d76d7a..5b756384 100644 --- a/crates/rustnet-core/src/network/interface_stats/mod.rs +++ b/crates/rustnet-core/src/network/interface_stats/mod.rs @@ -17,10 +17,29 @@ use linux::LinuxStatsProvider; #[cfg(target_os = "windows")] use windows::WindowsStatsProvider; +/// Nominal receive and transmit capacity reported by the operating system. +/// Values are bits per second. Either direction may be unavailable for +/// interfaces such as loopback devices, tunnels, and virtual adapters. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LinkCapacity { + pub rx_bps: Option, + pub tx_bps: Option, +} + +impl LinkCapacity { + pub const fn symmetric(bits_per_second: u64) -> Self { + Self { + rx_bps: Some(bits_per_second), + tx_bps: Some(bits_per_second), + } + } +} + /// Statistics for a network interface #[derive(Debug, Clone)] pub struct InterfaceStats { pub interface_name: String, + pub link_capacity: LinkCapacity, pub rx_bytes: u64, pub tx_bytes: u64, pub rx_packets: u64, @@ -43,7 +62,10 @@ impl InterfaceStats { .as_secs_f64(); if duration == 0.0 { - return InterfaceRates::default(); + return InterfaceRates { + link_capacity: self.link_capacity, + ..InterfaceRates::default() + }; } InterfaceRates { @@ -51,6 +73,7 @@ impl InterfaceStats { as u64, tx_bytes_per_sec: ((self.tx_bytes.saturating_sub(previous.tx_bytes)) as f64 / duration) as u64, + link_capacity: self.link_capacity, } } @@ -77,6 +100,7 @@ impl InterfaceStats { pub struct InterfaceRates { pub rx_bytes_per_sec: u64, pub tx_bytes_per_sec: u64, + pub link_capacity: LinkCapacity, } /// Traffic transferred over a rolling interface-counter window. @@ -136,6 +160,7 @@ mod tests { ) -> InterfaceStats { InterfaceStats { interface_name: "test".to_string(), + link_capacity: LinkCapacity::default(), rx_bytes, tx_bytes, rx_packets, diff --git a/crates/rustnet-core/src/network/interface_stats/windows.rs b/crates/rustnet-core/src/network/interface_stats/windows.rs index b676ca6c..8a98fc8c 100644 --- a/crates/rustnet-core/src/network/interface_stats/windows.rs +++ b/crates/rustnet-core/src/network/interface_stats/windows.rs @@ -1,16 +1,24 @@ // interface_stats/windows.rs - Windows IP Helper API interface stats -use super::{InterfaceStats, InterfaceStatsProvider}; +use super::{InterfaceStats, InterfaceStatsProvider, LinkCapacity}; use std::collections::HashMap; use std::io; use std::time::SystemTime; -use windows::Win32::NetworkManagement::IpHelper::{FreeMibTable, GetIfTable2, MIB_IF_TABLE2}; +use windows::Win32::NetworkManagement::IpHelper::{ + FreeMibTable, GetIfTable2, IF_TYPE_SOFTWARE_LOOPBACK, MIB_IF_TABLE2, +}; use windows::Win32::NetworkManagement::Ndis::IfOperStatusUp; /// Windows-specific implementation using IP Helper API pub struct WindowsStatsProvider; +/// NDIS reports `NDIS_LINK_SPEED_UNKNOWN` (`u64::MAX`) for adapters without a +/// negotiated speed, such as disconnected Wi-Fi. +fn reported_link_speed(bits_per_second: u64) -> Option { + (bits_per_second > 0 && bits_per_second != u64::MAX).then_some(bits_per_second) +} + impl InterfaceStatsProvider for WindowsStatsProvider { fn get_all_stats(&self) -> Result, io::Error> { unsafe { @@ -75,8 +83,20 @@ impl InterfaceStatsProvider for WindowsStatsProvider { continue; } + // The loopback pseudo-interface reports a nominal speed that + // would inflate the summed aggregate capacity. + let link_capacity = if row.Type == IF_TYPE_SOFTWARE_LOOPBACK { + LinkCapacity::default() + } else { + LinkCapacity { + rx_bps: reported_link_speed(row.ReceiveLinkSpeed), + tx_bps: reported_link_speed(row.TransmitLinkSpeed), + } + }; + let stat = InterfaceStats { interface_name: name.clone(), + link_capacity, rx_bytes: row.InOctets, tx_bytes: row.OutOctets, rx_packets: row.InUcastPkts + row.InNUcastPkts, @@ -120,6 +140,13 @@ impl InterfaceStatsProvider for WindowsStatsProvider { mod tests { use super::*; + #[test] + fn ignores_unknown_link_speed_sentinel() { + assert_eq!(reported_link_speed(0), None); + assert_eq!(reported_link_speed(u64::MAX), None); + assert_eq!(reported_link_speed(1_000_000_000), Some(1_000_000_000)); + } + #[test] fn test_windows_list_interfaces() { let provider = WindowsStatsProvider; diff --git a/crates/rustnet-core/src/network/types/graph.rs b/crates/rustnet-core/src/network/types/graph.rs index 5ee7af09..618f4b96 100644 --- a/crates/rustnet-core/src/network/types/graph.rs +++ b/crates/rustnet-core/src/network/types/graph.rs @@ -116,6 +116,49 @@ impl Default for GraphScale { } } +const LINK_CAPACITY_CONFIRMATIONS: u8 = 2; + +/// Debounces link negotiation changes and transient provider failures. A new +/// value, including `None`, must be observed twice before it changes the graph +/// ceiling. +#[derive(Debug, Clone, Default)] +struct StableLinkCapacity { + current_bps: Option, + candidate_bps: Option, + confirmations: u8, +} + +impl StableLinkCapacity { + fn observe(&mut self, observed_bps: Option) { + let observed_bps = observed_bps.filter(|capacity| *capacity > 0); + if observed_bps == self.current_bps { + self.candidate_bps = observed_bps; + self.confirmations = 0; + return; + } + + if observed_bps == self.candidate_bps { + self.confirmations = self.confirmations.saturating_add(1); + } else { + self.candidate_bps = observed_bps; + self.confirmations = 1; + } + + if self.confirmations >= LINK_CAPACITY_CONFIRMATIONS { + self.current_bps = observed_bps; + self.confirmations = 0; + } + } + + fn bits_per_second(&self) -> Option { + self.current_bps + } + + fn bytes_per_second(&self) -> Option { + self.current_bps.map(|capacity| capacity as f64 / 8.0) + } +} + /// Ring buffer for aggregate traffic history (used for graphs) #[derive(Debug, Clone)] pub struct TrafficHistory { @@ -123,6 +166,8 @@ pub struct TrafficHistory { max_samples: usize, rx_scale: GraphScale, tx_scale: GraphScale, + rx_link_capacity: StableLinkCapacity, + tx_link_capacity: StableLinkCapacity, opened_scale: GraphScale, closed_scale: GraphScale, } @@ -142,6 +187,8 @@ impl TrafficHistory { max_samples, rx_scale: GraphScale::default(), tx_scale: GraphScale::default(), + rx_link_capacity: StableLinkCapacity::default(), + tx_link_capacity: StableLinkCapacity::default(), opened_scale: GraphScale::new(10), closed_scale: GraphScale::new(10), } @@ -245,12 +292,39 @@ impl TrafficHistory { self.max_samples } + /// Observe the latest trustworthy aggregate capacity for the interface + /// rates represented by this history. Values are bits per second. + pub fn observe_link_capacity(&mut self, rx_bps: Option, tx_bps: Option) { + self.rx_link_capacity.observe(rx_bps); + self.tx_link_capacity.observe(tx_bps); + } + + pub fn rx_link_capacity_bps(&self) -> Option { + self.rx_link_capacity.bits_per_second() + } + + pub fn tx_link_capacity_bps(&self) -> Option { + self.tx_link_capacity.bits_per_second() + } + + pub fn rx_link_capacity_bytes_per_sec(&self) -> Option { + self.rx_link_capacity.bytes_per_second() + } + + pub fn tx_link_capacity_bytes_per_sec(&self) -> Option { + self.tx_link_capacity.bytes_per_second() + } + pub fn rx_graph_ceiling(&self) -> f64 { - self.rx_scale.ceiling() + self.rx_link_capacity + .bytes_per_second() + .unwrap_or_else(|| self.rx_scale.ceiling()) } pub fn tx_graph_ceiling(&self) -> f64 { - self.tx_scale.ceiling() + self.tx_link_capacity + .bytes_per_second() + .unwrap_or_else(|| self.tx_scale.ceiling()) } pub fn opened_graph_ceiling(&self) -> f64 { @@ -575,6 +649,35 @@ mod tests { ); } + #[test] + fn link_capacity_replaces_the_observed_graph_ceiling_after_confirmation() { + let mut history = TrafficHistory::new(3); + add_sample(&mut history, 1_000_000, 2_000_000, 1, 0, 0, None); + + history.observe_link_capacity(Some(1_000_000_000), Some(2_000_000_000)); + assert_eq!(history.rx_link_capacity_bps(), None); + assert_eq!(history.tx_link_capacity_bps(), None); + + history.observe_link_capacity(Some(1_000_000_000), Some(2_000_000_000)); + assert_eq!(history.rx_link_capacity_bps(), Some(1_000_000_000)); + assert_eq!(history.tx_link_capacity_bps(), Some(2_000_000_000)); + assert_eq!(history.rx_graph_ceiling(), 125_000_000.0); + assert_eq!(history.tx_graph_ceiling(), 250_000_000.0); + } + + #[test] + fn transient_missing_capacity_does_not_move_the_ceiling() { + let mut history = TrafficHistory::new(3); + for _ in 0..2 { + history.observe_link_capacity(Some(1_000_000_000), Some(1_000_000_000)); + } + + history.observe_link_capacity(None, None); + assert_eq!(history.rx_link_capacity_bps(), Some(1_000_000_000)); + history.observe_link_capacity(None, None); + assert_eq!(history.rx_link_capacity_bps(), None); + } + #[test] fn traffic_scale_forgets_peaks_after_they_leave_the_window() { let mut history = TrafficHistory::new(3); diff --git a/src/app/sampling.rs b/src/app/sampling.rs index 901a6d67..055f54d9 100644 --- a/src/app/sampling.rs +++ b/src/app/sampling.rs @@ -11,7 +11,7 @@ use std::thread; use std::time::{Duration, Instant, SystemTime}; use crate::network::{ - interface_stats::InterfaceStats, + interface_stats::{InterfaceRates, InterfaceStats, LinkCapacity}, services::ServiceLookup, tracker::ConnectionTracker, types::{Connection, ConnectionLifecycleSample}, @@ -21,6 +21,55 @@ use super::logging::{log_connection_event, log_pcap_connection}; use super::state::App; use super::{LIVE_RATE_INTERVAL, MIN_RATE_SAMPLE_SECONDS, TRAFFIC_HISTORY_CAPACITY}; +/// One coherent aggregate of the interface rates used by the host traffic +/// graphs. A direction has a trustworthy capacity only when at least one +/// contributing interface reports it and no unknown-capacity interface is +/// currently carrying traffic in that direction. +#[derive(Debug, Default, PartialEq, Eq)] +struct AggregateInterfaceRates { + rx_bytes_per_sec: u64, + tx_bytes_per_sec: u64, + rx_capacity_bps: u64, + tx_capacity_bps: u64, + has_rx_capacity: bool, + has_tx_capacity: bool, + unknown_rx_active: bool, + unknown_tx_active: bool, +} + +impl AggregateInterfaceRates { + fn include(&mut self, rates: &InterfaceRates) { + self.rx_bytes_per_sec = self.rx_bytes_per_sec.saturating_add(rates.rx_bytes_per_sec); + self.tx_bytes_per_sec = self.tx_bytes_per_sec.saturating_add(rates.tx_bytes_per_sec); + + match rates.link_capacity.rx_bps { + Some(capacity) if capacity > 0 => { + self.has_rx_capacity = true; + self.rx_capacity_bps = self.rx_capacity_bps.saturating_add(capacity); + } + _ if rates.rx_bytes_per_sec > 0 => self.unknown_rx_active = true, + _ => {} + } + match rates.link_capacity.tx_bps { + Some(capacity) if capacity > 0 => { + self.has_tx_capacity = true; + self.tx_capacity_bps = self.tx_capacity_bps.saturating_add(capacity); + } + _ if rates.tx_bytes_per_sec > 0 => self.unknown_tx_active = true, + _ => {} + } + } + + fn link_capacity(&self) -> LinkCapacity { + LinkCapacity { + rx_bps: (self.has_rx_capacity && !self.unknown_rx_active) + .then_some(self.rx_capacity_bps), + tx_bps: (self.has_tx_capacity && !self.unknown_tx_active) + .then_some(self.tx_capacity_bps), + } + } +} + /// Spawn a named worker thread that runs `body` every `interval` until /// `should_stop` is set. /// @@ -337,15 +386,13 @@ impl App { Arc::clone(&self.should_stop), move || { // Aggregate rates from all interfaces - let (total_rx, total_tx) = - interface_rates - .iter() - .fold((0u64, 0u64), |(rx, tx), entry| { - ( - rx + entry.value().rx_bytes_per_sec, - tx + entry.value().tx_bytes_per_sec, - ) - }); + let aggregate = interface_rates.iter().fold( + AggregateInterfaceRates::default(), + |mut aggregate, entry| { + aggregate.include(entry.value()); + aggregate + }, + ); // Get active connection count from snapshot (excludes // historic) and record per-connection rate samples on @@ -408,9 +455,11 @@ impl App { // Add sample to traffic history if let Ok(mut history) = traffic_history.write() { + let link_capacity = aggregate.link_capacity(); + history.observe_link_capacity(link_capacity.rx_bps, link_capacity.tx_bps); history.add_sample_with_lifecycle( - total_rx, - total_tx, + aggregate.rx_bytes_per_sec, + aggregate.tx_bytes_per_sec, ConnectionLifecycleSample { active: connection_count, retained: tracker.historic_len(), @@ -532,4 +581,57 @@ mod live_rate_sampling_tests { assert_eq!(per_second_rate(1, 0.5, 10.0), 20); assert_eq!(per_second_rate(0, 0.5, 10.0), 0); } + + #[test] + fn aggregates_directional_rates_and_capacities() { + let mut aggregate = AggregateInterfaceRates::default(); + aggregate.include(&InterfaceRates { + rx_bytes_per_sec: 10, + tx_bytes_per_sec: 20, + link_capacity: LinkCapacity { + rx_bps: Some(1_000), + tx_bps: Some(2_000), + }, + }); + aggregate.include(&InterfaceRates { + rx_bytes_per_sec: 30, + tx_bytes_per_sec: 40, + link_capacity: LinkCapacity { + rx_bps: Some(3_000), + tx_bps: Some(4_000), + }, + }); + + assert_eq!(aggregate.rx_bytes_per_sec, 40); + assert_eq!(aggregate.tx_bytes_per_sec, 60); + assert_eq!( + aggregate.link_capacity(), + LinkCapacity { + rx_bps: Some(4_000), + tx_bps: Some(6_000), + } + ); + } + + #[test] + fn active_unknown_interfaces_disable_only_their_direction() { + let mut aggregate = AggregateInterfaceRates::default(); + aggregate.include(&InterfaceRates { + link_capacity: LinkCapacity::symmetric(1_000), + ..InterfaceRates::default() + }); + aggregate.include(&InterfaceRates { + rx_bytes_per_sec: 10, + tx_bytes_per_sec: 0, + ..InterfaceRates::default() + }); + + assert_eq!( + aggregate.link_capacity(), + LinkCapacity { + rx_bps: None, + tx_bps: Some(1_000), + } + ); + } } diff --git a/src/app/state.rs b/src/app/state.rs index 849e55e8..1ea7c0fd 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -896,6 +896,7 @@ mod activity_reset_tests { fn interface_sample(timestamp: SystemTime, rx_bytes: u64, tx_bytes: u64) -> InterfaceStats { InterfaceStats { interface_name: "eth0".to_string(), + link_capacity: Default::default(), rx_bytes, tx_bytes, rx_packets: 0, diff --git a/src/ui/format.rs b/src/ui/format.rs index a9423a0c..af1b5926 100644 --- a/src/ui/format.rs +++ b/src/ui/format.rs @@ -23,6 +23,28 @@ pub(super) fn format_rate(bytes_per_second: f64) -> String { } } +/// Format an operating-system link capacity, which is reported in decimal +/// bits per second rather than the binary byte units used for traffic rates. +pub(super) fn format_link_speed(bits_per_second: u64) -> String { + const KILOBITS: f64 = 1_000.0; + const MEGABITS: f64 = KILOBITS * 1_000.0; + const GIGABITS: f64 = MEGABITS * 1_000.0; + const TERABITS: f64 = GIGABITS * 1_000.0; + + let bits_per_second = bits_per_second as f64; + if bits_per_second >= TERABITS { + format!("{:.1} Tb/s", bits_per_second / TERABITS) + } else if bits_per_second >= GIGABITS { + format!("{:.1} Gb/s", bits_per_second / GIGABITS) + } else if bits_per_second >= MEGABITS { + format!("{:.1} Mb/s", bits_per_second / MEGABITS) + } else if bits_per_second >= KILOBITS { + format!("{:.1} Kb/s", bits_per_second / KILOBITS) + } else { + format!("{bits_per_second:.0} b/s") + } +} + /// Format rate to compact form for tight spaces. `zero` is what a /// zero/absent rate renders as: the "-" placeholder in tables, "0B" /// in the activity bars. @@ -111,7 +133,14 @@ pub(super) fn ellipsize_left(s: &str, max: usize) -> String { #[cfg(test)] mod tests { - use super::ellipsize_left; + use super::{ellipsize_left, format_link_speed}; + + #[test] + fn link_speeds_use_decimal_bit_units() { + assert_eq!(format_link_speed(1_000_000_000), "1.0 Gb/s"); + assert_eq!(format_link_speed(2_500_000_000), "2.5 Gb/s"); + assert_eq!(format_link_speed(100_000_000), "100.0 Mb/s"); + } #[test] fn fitting_strings_are_returned_unchanged() { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 60533b4e..bf4d67b6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1031,7 +1031,9 @@ mod snapshot_tests { use crate::app::{App, Config}; use crate::network::geoip::GeoIpInfo; use crate::network::interface_stats::{InterfaceRates, InterfaceStats, InterfaceTrafficWindow}; - use crate::network::types::{Connection, Protocol, ProtocolState, TcpState, TrafficHistory}; + use crate::network::types::{ + Connection, ConnectionLifecycleSample, Protocol, ProtocolState, TcpState, TrafficHistory, + }; use rustnet_host::{HostSocket, HostSocketState, HostTcpState, SocketOwner, SocketSnapshot}; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; @@ -2297,6 +2299,7 @@ mod snapshot_tests { "eth0", InterfaceStats { interface_name: "eth0".to_string(), + link_capacity: Default::default(), rx_bytes: 1_500_000_000, tx_bytes: 250_000_000, rx_packets: 1_200_000, @@ -2314,6 +2317,7 @@ mod snapshot_tests { InterfaceRates { rx_bytes_per_sec: 524_288, tx_bytes_per_sec: 131_072, + ..Default::default() }, ); @@ -2350,6 +2354,7 @@ mod snapshot_tests { InterfaceRates { rx_bytes_per_sec: 2_097_152, tx_bytes_per_sec: 4_194_304, + ..Default::default() }, ); app.set_interface_traffic_window_for_test( @@ -2404,6 +2409,41 @@ mod snapshot_tests { }); } + #[test] + fn graph_tab_shows_link_capacity_and_utilization() { + let app = test_app(); + app.set_connections_snapshot_for_test(sample_connections()); + let mut history = TrafficHistory::new(60); + for _ in 0..2 { + history.observe_link_capacity(Some(1_000_000_000), Some(1_000_000_000)); + } + for _ in 0..2 { + history.add_sample_with_lifecycle( + 62_500_000, + 31_250_000, + ConnectionLifecycleSample::default(), + 0, + 0, + None, + ); + } + app.set_traffic_history_for_test(history); + + let ui_state = UIState { + selected_tab: 3, + ..Default::default() + }; + let connections = app.get_connections(); + let output = render_app(&app, &ui_state, &connections, None, 180, 40); + + assert!(output.contains("50.0%"), "missing RX utilization: {output}"); + assert!(output.contains("25.0%"), "missing TX utilization: {output}"); + assert!( + output.matches("cap 1.0 Gb/s").count() >= 2, + "missing capacity labels: {output}" + ); + } + /// A stock 80x24 terminal cannot hold all three Graph sections; the /// fixed-height rows drop from the bottom up so the waves and the /// health row render at full height instead of all three squeezed. diff --git a/src/ui/tabs/graph.rs b/src/ui/tabs/graph.rs index 34254b83..88bf381b 100644 --- a/src/ui/tabs/graph.rs +++ b/src/ui/tabs/graph.rs @@ -218,7 +218,9 @@ fn draw_traffic_chart(f: &mut Frame, history: &TrafficHistory, area: Rect) { halves[0], &rx, "↓ RX", - braille_graph::WavePanelOptions::new(frac, window).with_max_val(history.rx_graph_ceiling()), + braille_graph::WavePanelOptions::new(frac, window) + .with_max_val(history.rx_graph_ceiling()) + .with_capacity_bps(history.rx_link_capacity_bps()), theme::rx_wave, ); braille_graph::wave_panel( @@ -226,7 +228,9 @@ fn draw_traffic_chart(f: &mut Frame, history: &TrafficHistory, area: Rect) { halves[2], &tx, "↑ TX", - braille_graph::WavePanelOptions::new(frac, window).with_max_val(history.tx_graph_ceiling()), + braille_graph::WavePanelOptions::new(frac, window) + .with_max_val(history.tx_graph_ceiling()) + .with_capacity_bps(history.tx_link_capacity_bps()), theme::tx_wave, ); } diff --git a/src/ui/tabs/overview.rs b/src/ui/tabs/overview.rs index a338d5b6..33c0872b 100644 --- a/src/ui/tabs/overview.rs +++ b/src/ui/tabs/overview.rs @@ -1275,22 +1275,41 @@ fn mini_wave( width: u16, frac: f64, window: usize, + capacity_bytes_per_sec: Option, wave: fn(f64) -> Color, ) -> Vec> { // A braille cell has two horizontal dots. Keep one traffic sample per dot // in this compact graph so advancing the ring translates existing crests // instead of resampling them against shifting fractional boundaries. let visible_window = mini_wave_window(width, window); - let ceiling = mini_wave_ceiling(samples, visible_window); - braille_graph::render( - samples, - width as usize, - 1, - ceiling, - frac, - visible_window, - |intensity| wave(MINI_WAVE_INTENSITY * intensity), - ) + if let Some(capacity) = capacity_bytes_per_sec.filter(|capacity| *capacity > 0.0) { + braille_graph::render_capacity( + samples, + width as usize, + 1, + capacity, + frac, + visible_window, + |intensity| wave(MINI_WAVE_INTENSITY * intensity), + ) + } else { + braille_graph::render( + samples, + width as usize, + 1, + mini_wave_ceiling(samples, visible_window), + frac, + visible_window, + |intensity| wave(MINI_WAVE_INTENSITY * intensity), + ) + } +} + +struct MiniWaveOptions { + frac: f64, + window: usize, + capacity_bytes_per_sec: Option, + wave: fn(f64) -> Color, } /// One sidebar sparkline row: the colored RX/TX label, then the @@ -1301,21 +1320,26 @@ fn draw_mini_wave_row( area: Rect, label: &'static str, rates: &[u64], - frac: f64, - window: usize, - wave: fn(f64) -> Color, + options: MiniWaveOptions, ) { let cols = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Length(3), Constraint::Min(0)]) .split(area); - let label = Paragraph::new(label).style(theme::fg(wave(MINI_WAVE_INTENSITY))); + let label = Paragraph::new(label).style(theme::fg((options.wave)(MINI_WAVE_INTENSITY))); f.render_widget(label, cols[0]); let data = smooth_mini_wave(rates); f.render_widget( - Paragraph::new(mini_wave(&data, cols[1].width, frac, window, wave)), + Paragraph::new(mini_wave( + &data, + cols[1].width, + options.frac, + options.window, + options.capacity_bytes_per_sec, + options.wave, + )), cols[1], ); } @@ -1362,9 +1386,12 @@ fn draw_interface_stats_with_graph(f: &mut Frame, app: &App, area: Rect) -> Resu sparkline_rows[0], "RX", &rx_rates, - frac, - window, - theme::rx_wave, + MiniWaveOptions { + frac, + window, + capacity_bytes_per_sec: traffic_history.rx_link_capacity_bytes_per_sec(), + wave: theme::rx_wave, + }, ); let tx_rates = traffic_history.get_tx_sparkline_data(usize::MAX); @@ -1373,9 +1400,12 @@ fn draw_interface_stats_with_graph(f: &mut Frame, app: &App, area: Rect) -> Resu sparkline_rows[1], "TX", &tx_rates, - frac, - window, - theme::tx_wave, + MiniWaveOptions { + frac, + window, + capacity_bytes_per_sec: traffic_history.tx_link_capacity_bytes_per_sec(), + wave: theme::tx_wave, + }, ); // Current rates row @@ -1565,14 +1595,29 @@ mod tests { #[test] fn mini_wave_color_does_not_follow_latest_rate() { - let quiet = mini_wave(&[64, 128], 12, 0.0, 120, theme::rx_wave); - let busy = mini_wave(&[64, 1024], 12, 0.0, 120, theme::rx_wave); + let quiet = mini_wave(&[64, 128], 12, 0.0, 120, None, theme::rx_wave); + let busy = mini_wave(&[64, 1024], 12, 0.0, 120, None, theme::rx_wave); let expected = Some(theme::rx_wave(MINI_WAVE_INTENSITY)); assert_eq!(quiet[0].spans[0].style.fg, expected); assert_eq!(busy[0].spans[0].style.fg, expected); } + #[test] + fn mini_wave_uses_link_capacity_when_available() { + let auto = mini_wave(&[1_000_000; 8], 12, 0.0, 120, None, theme::rx_wave); + let capacity_scaled = mini_wave( + &[1_000_000; 8], + 12, + 0.0, + 120, + Some(125_000_000.0), + theme::rx_wave, + ); + + assert_ne!(auto, capacity_scaled); + } + #[test] fn mini_wave_uses_a_longer_trailing_average() { assert_eq!( diff --git a/src/ui/widgets/braille_graph.rs b/src/ui/widgets/braille_graph.rs index aa17c697..1792973f 100644 --- a/src/ui/widgets/braille_graph.rs +++ b/src/ui/widgets/braille_graph.rs @@ -14,7 +14,10 @@ use ratatui::{ widgets::Paragraph, }; -use crate::ui::{format::format_rate, theme}; +use crate::ui::{ + format::{format_link_speed, format_rate}, + theme, +}; /// Width of rate values in wave-panel headers. This fits values through /// `999.99 GB/s` and keeps both the trend glyph and `peak` label anchored as @@ -26,6 +29,7 @@ pub(in crate::ui) struct WavePanelOptions { frac: f64, window: usize, max_val: Option, + capacity_bps: Option, } impl WavePanelOptions { @@ -35,6 +39,7 @@ impl WavePanelOptions { frac, window, max_val: None, + capacity_bps: None, } } @@ -47,6 +52,11 @@ impl WavePanelOptions { self.max_val = Some(max_val); self } + + pub(in crate::ui) fn with_capacity_bps(mut self, capacity_bps: Option) -> Self { + self.capacity_bps = capacity_bps.filter(|capacity| *capacity > 0); + self + } } /// Unicode braille bit for a dot at (dx, dy) inside one cell. @@ -72,6 +82,34 @@ fn ease_out_quad(t: f64) -> f64 { t * (2.0 - t) } +/// Vertical normalization for a wave. Observed peaks use the historical +/// easing curve. Link capacity uses a stronger, fixed fourth-root curve so +/// low utilization stays visible without changing the ceiling. +#[derive(Debug, Clone, Copy)] +enum WaveScale { + ObservedPeak(f64), + LinkCapacity(f64), +} + +impl WaveScale { + fn height_ratio(self, value: f64) -> f64 { + let (maximum, capacity_scaled) = match self { + Self::ObservedPeak(maximum) => (maximum, false), + Self::LinkCapacity(maximum) => (maximum, true), + }; + if maximum <= 0.0 { + return 0.0; + } + + let ratio = (value / maximum).clamp(0.0, 1.0); + if capacity_scaled { + ratio.sqrt().sqrt() + } else { + ease_out_quad(ratio) + } + } +} + /// Value at fractional position `pos` (in sample units), linearly /// interpolated between neighbors. fn sample_at(samples: &[u64], pos: f64) -> f64 { @@ -107,7 +145,7 @@ fn smooth_columns(cols: &[f64]) -> Vec { } /// Render `samples` (oldest→newest) as a filled braille wave of -/// `width`×`height` cells, normalized against `max_val`. +/// `width`×`height` cells, normalized against an observed peak. /// /// `window` is the number of samples the panel spans horizontally /// (the history buffer's capacity, not the current sample count). @@ -131,6 +169,48 @@ pub(in crate::ui) fn render( frac: f64, window: usize, row_color: impl Fn(f64) -> ratatui::style::Color, +) -> Vec> { + render_with_scale( + samples, + width, + height, + WaveScale::ObservedPeak(max_val), + frac, + window, + row_color, + ) +} + +/// Render against a fixed link capacity using a curve that preserves detail +/// at low utilization. +pub(in crate::ui) fn render_capacity( + samples: &[u64], + width: usize, + height: usize, + capacity: f64, + frac: f64, + window: usize, + row_color: impl Fn(f64) -> ratatui::style::Color, +) -> Vec> { + render_with_scale( + samples, + width, + height, + WaveScale::LinkCapacity(capacity), + frac, + window, + row_color, + ) +} + +fn render_with_scale( + samples: &[u64], + width: usize, + height: usize, + scale: WaveScale, + frac: f64, + window: usize, + row_color: impl Fn(f64) -> ratatui::style::Color, ) -> Vec> { if width == 0 || height == 0 || samples.is_empty() { return Vec::new(); @@ -168,11 +248,7 @@ pub(in crate::ui) fn render( // Fill each dot column bottom-up to its eased height. let mut grid = vec![vec![0u8; width]; height]; for (x, col) in cols.iter().enumerate() { - let ratio = if max_val > 0.0 { - ease_out_quad((col / max_val).clamp(0.0, 1.0)) - } else { - 0.0 - }; + let ratio = scale.height_ratio(*col); let h_dots = ratio * dots_y as f64; for y_dot in 0..dots_y { if (y_dot as f64) < h_dots { @@ -224,11 +300,24 @@ fn format_peak_rate(rate: f64) -> String { format!("peak {rate:>HEADER_RATE_WIDTH$}") } +fn format_utilization(bytes_per_second: f64, capacity_bps: u64) -> String { + let percent = bytes_per_second * 8.0 * 100.0 / capacity_bps as f64; + if percent > 100.0 { + ">100%".to_string() + } else if percent > 0.0 && percent < 0.01 { + "<0.01%".to_string() + } else if percent < 1.0 { + format!("{percent:.2}%") + } else { + format!("{percent:.1}%") + } +} + /// One rate direction as a complete panel: a header line (label, the /// current rate, a trend arrow, and the window peak), an optional summary -/// line, then a gradient braille wave. The wave is normalized to the window -/// peak, while row colors stay anchored to vertical height so rate changes do -/// not recolor the entire history. +/// line, then a gradient braille wave. The wave uses the observed peak or a +/// fixed capacity-relative curve, while row colors stay anchored to vertical +/// height so rate changes do not recolor the entire history. pub(in crate::ui) fn wave_panel( f: &mut Frame, area: Rect, @@ -247,7 +336,7 @@ pub(in crate::ui) fn wave_panel( let speed_ratio = (current / max_val).clamp(0.0, 1.0); let value_color = wave(0.35 + 0.65 * speed_ratio); - let left = vec![ + let mut left = vec![ Span::styled(format!("{label} "), theme::bold_fg(wave(0.4))), Span::styled(format_header_rate(current), theme::bold_fg(value_color)), Span::styled( @@ -255,7 +344,31 @@ pub(in crate::ui) fn wave_panel( theme::fg(theme::muted()), ), ]; - let right = Span::styled(format_peak_rate(peak), theme::fg(theme::muted())); + if let Some(capacity_bps) = options.capacity_bps { + let saturated = current * 8.0 > capacity_bps as f64; + left.push(Span::styled( + format!(" {}", format_utilization(current, capacity_bps)), + theme::fg(if saturated { + theme::warn() + } else { + theme::muted() + }), + )); + } + + let peak_text = format_peak_rate(peak); + let right_text = options + .capacity_bps + .map(|capacity| format!("{peak_text} cap {}", format_link_speed(capacity))) + .filter(|text| { + let left_width = left + .iter() + .map(|span| span.content.chars().count()) + .sum::(); + left_width + 1 + text.chars().count() <= area.width as usize + }) + .unwrap_or(peak_text); + let right = Span::styled(right_text, theme::fg(theme::muted())); f.render_widget( Paragraph::new(spread_line(left, right, area.width)), Rect::new(area.x, area.y, area.width, 1), @@ -275,15 +388,27 @@ pub(in crate::ui) fn wave_panel( area.width, area.height.saturating_sub(1 + summary_height), ); - let lines = render( - samples, - graph_area.width as usize, - graph_area.height as usize, - max_val, - options.frac, - options.window, - wave, - ); + let lines = if let Some(capacity) = options.capacity_bps { + render_capacity( + samples, + graph_area.width as usize, + graph_area.height as usize, + capacity as f64 / 8.0, + options.frac, + options.window, + wave, + ) + } else { + render( + samples, + graph_area.width as usize, + graph_area.height as usize, + max_val, + options.frac, + options.window, + wave, + ) + }; f.render_widget(Paragraph::new(lines), graph_area); } @@ -424,4 +549,41 @@ mod tests { ); } } + + #[test] + fn utilization_marks_saturated_samples() { + assert_eq!(format_utilization(62_500_000.0, 1_000_000_000), "50.0%"); + assert_eq!(format_utilization(125_000_001.0, 1_000_000_000), ">100%"); + assert_eq!(format_utilization(1_000.0, 1_000_000_000), "<0.01%"); + assert_eq!(format_utilization(625_000.0, 1_000_000_000), "0.50%"); + } + + #[test] + fn capacity_scale_keeps_low_utilization_visible() { + let observed = WaveScale::ObservedPeak(100.0).height_ratio(0.4); + let capacity = WaveScale::LinkCapacity(100.0).height_ratio(0.4); + + assert!(observed < 0.01); + assert!((0.24..0.27).contains(&capacity)); + assert_eq!(WaveScale::LinkCapacity(100.0).height_ratio(100.0), 1.0); + } + + #[test] + fn capacity_scaled_wave_renders_sub_percent_traffic() { + let samples = vec![4; 30]; + let lines = render_capacity(&samples, 10, 10, 1_000.0, 0.0, 30, |_| { + ratatui::style::Color::Reset + }); + let occupied_rows = lines + .iter() + .filter(|line| { + line.spans + .iter() + .flat_map(|span| span.content.chars()) + .any(|cell| cell != '\u{2800}') + }) + .count(); + + assert!(occupied_rows >= 3, "got {occupied_rows} occupied rows"); + } }