diff --git a/CHANGELOG.md b/CHANGELOG.md index d428aad9..67bd0b8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Inline Connection Health**: connection rows now show compact TCP + retransmit/out-of-order, QUIC Retry/version, and transactional UDP + retry/timeout badges, with a severity-first Health sort. The Details + Transport Health card marks the counters behind the badge with their + letters, e.g. `TCP Retransmits (R)` and `Out-of-Order (O)` (#583) - **VPN Traffic Detection**: identify WireGuard and OpenVPN connections through - packet signatures, including OpenVPN over UDP and TCP + packet signatures, including OpenVPN over UDP and TCP (#581) - **Host Socket Inventory**: the new Host tab shows TCP LISTEN sockets, UDP BOUND endpoints, TCP state totals, observed RTT, process owners, and the detailed interface table on Linux, macOS, FreeBSD, and Windows diff --git a/README.ja.md b/README.ja.md index 8c4724d5..f1bef813 100644 --- a/README.ja.md +++ b/README.ja.md @@ -20,7 +20,7 @@ RustNet は、各接続を所有するプロセス、通信量、状態、アプ - TCP、UDP、QUIC 接続とプロセスの対応付け。詳細には PID、実行ファイル、ユーザー/グループ名、照合の信頼度、全プラットフォーム共通の親プロセスチェーン(上限あり)を表示 - Linux 5.11 以降では、起動時の BPF task-file イテレーターにより、ファイル capabilities で実行した場合でも root や他ユーザーが所有する既存 socket を識別 - HTTP、TLS/SNI、DNS、SSH、QUIC、WireGuard、OpenVPN などの深層パケット解析 -- TCP、QUIC ハンドシェイク、DNS 応答、ICMP エコーの往復時間(RTT)と、TCP の再送・順序入れ替わりをリアルタイム表示 +- TCP、QUIC ハンドシェイク、DNS 応答、ICMP エコーの往復時間(RTT)と、TCP の再送・順序入れ替わりをリアルタイム表示。Overview テーブルではプロトコル別のヘルスバッジにより、TCP の問題、明示的な QUIC Retry/バージョンネゴシエーション、トランザクション型 UDP の再試行/タイムアウトを表示し、重大度順に並べ替え可能 - Host タブに TCP LISTEN ソケット、UDP BOUND エンドポイント、TCP 状態集計、観測 RTT、所有プロセス、インターフェース統計を表示 - `port:`、`process:`、`sni:`、`state:` などのフィルター - 注釈付き PCAPNG、PCAP と JSONL sidecar、JSON ログの出力 diff --git a/README.md b/README.md index b1e6ffea..e882cac5 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ - **Deep packet inspection**: Identify HTTP, HTTPS/TLS with SNI, DNS, SSH, FTP, QUIC, MQTT, BitTorrent, WireGuard, OpenVPN, STUN, NTP, mDNS, LLMNR, DHCP, SNMP, SSDP, and NetBIOS, without external dissectors. - **Annotated PCAPNG export**: `--pcapng-export` writes a Wireshark-ready capture with process, PID, direction, DPI/SNI, and GeoIP embedded as per-packet comments. Open it in Wireshark and every packet already names its owning process, with no post-processing. Classic `--pcap-export` with a JSONL sidecar for offline correlation is also available. - **Security sandboxing**: Landlock (Linux 5.13+), Seatbelt (macOS), token privilege drop + job-object child-process block (Windows). Drops privileges immediately after libpcap initializes. See [SECURITY.md](SECURITY.md). -- **Network analytics**: Real-time round-trip times for TCP, QUIC handshakes, DNS responses, and ICMP echo, plus TCP retransmission, out-of-order, and fast-retransmit detection. +- **Network analytics**: Real-time round-trip times for TCP, QUIC handshakes, DNS responses, and ICMP echo, plus TCP retransmission, out-of-order, and fast-retransmit detection. Protocol-aware health badges surface TCP issues, explicit QUIC Retry/version events, and retries/timeouts for transaction-based UDP, with severity-first sorting in the Overview table. - **Smart connection lifecycle**: Protocol-aware timeouts with white → yellow → red staleness indicators. Toggle `t` to keep historic (closed) connections visible for forensics. - **Vim/fzf-style filtering**: `port:`, `src:`, `dst:`, `sni:`, `process:`, `state:`, `proto:`, plus regex via `/(?i)pattern/`. - **GeoIP enrichment**: Country lookups via local MaxMind GeoLite2. No network calls. diff --git a/README.zh-CN.md b/README.zh-CN.md index c18fc479..93e973eb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -32,7 +32,7 @@ - **深度包检测**:无需外部解析器即可识别 HTTP、带 SNI 的 HTTPS/TLS、DNS、SSH、FTP、QUIC、MQTT、BitTorrent、WireGuard、OpenVPN、STUN、NTP、mDNS、LLMNR、DHCP、SNMP、SSDP 及 NetBIOS。 - **带注释的 PCAPNG 导出**:`--pcapng-export` 可写出能直接用 Wireshark 打开的捕获文件,并将进程、PID、方向、DPI/SNI 和 GeoIP 作为逐包注释嵌入。每个数据包都会直接标明所属进程,无需后处理。也可使用经典的 `--pcap-export` 配合 JSONL sidecar 进行离线关联。 - **安全沙箱**:Linux 5.13+ 使用 Landlock,macOS 使用 Seatbelt,Windows 通过 token 降权 + job-object 阻止子进程创建。libpcap 初始化完成后立即丢弃特权。详见 [SECURITY.zh-CN.md](SECURITY.zh-CN.md)。 -- **网络分析**:实时统计 TCP、QUIC 握手、DNS 响应及 ICMP 回显的往返时延,并检测 TCP 重传、乱序包和快重传。 +- **网络分析**:实时统计 TCP、QUIC 握手、DNS 响应及 ICMP 回显的往返时延,并检测 TCP 重传、乱序包和快重传。概览表格通过按协议显示的健康徽标,呈现 TCP 问题、明确可见的 QUIC Retry/版本协商事件,以及事务型 UDP 的重试/超时,并按严重程度排序。 - **智能连接生命周期**:按协议设置超时,以白 → 黄 → 红的颜色指示过期程度。按 `t` 可保留历史(已关闭)连接以便事后追溯。 - **Vim / fzf 风格过滤**:支持 `port:`、`src:`、`dst:`、`sni:`、`process:`、`state:`、`proto:`,以及 `/(?i)pattern/` 形式的正则。 - **GeoIP 增强**:基于本地 MaxMind GeoLite2 数据库查询国家信息,不发起任何网络请求。 diff --git a/USAGE.md b/USAGE.md index b450be33..9af3dfdd 100644 --- a/USAGE.md +++ b/USAGE.md @@ -567,6 +567,7 @@ Press `s` to cycle through columns in left-to-right order: | **Application** | ↑ Ascending | Sort by detected application protocol (HTTP, DNS, etc.), with TCP/UDP as tie-break | | **State** | ↑ Ascending | Sort by connection state (ESTABLISHED, etc.) | | **RTT** | ↓ Descending | Sort by round-trip time (slowest connections first by default) | +| **Health** | ↓ Descending | Sort protocol-aware health signals by severity, then event count | | **Bandwidth (Rx/Tx)** | ↓ Descending | Sort by **combined up+down** bandwidth (highest first by default) | Columns hidden at narrow terminal widths stay in the cycle — the active sort is always named in the table's section title. @@ -594,7 +595,7 @@ Section title shows current sort: **Press `s` (lowercase) - Cycle Columns:** - Moves to the next column in left-to-right visual order - **Resets to default direction** for that column -- Bandwidth column defaults to descending (↓) to show highest values first +- Bandwidth, RTT, and Health default to descending (↓) to show the most significant values first - Text columns default to ascending (↑) for alphabetical order **Press `S` (Shift+s) - Toggle Direction:** @@ -768,6 +769,26 @@ Active TCP Flows: 18 ### Per-Connection Statistics +The Overview table shows observable connection quality in the **Health** +column. The badge adapts to the protocol: + +- `R3/O1` for TCP means three retransmits and one out-of-order packet. +- `R1/V0` for QUIC means one explicit Retry and no Version Negotiation packet. +- `R2/T1` for outgoing DNS, LLMNR, NetBIOS, STUN, or NTP transactions means + two repeated request IDs and one request that expired unanswered. NTP polls + carry a fresh transmit timestamp each time, so NTP surfaces timeouts rather + than retries. + +Clean, gradable connections show `ok`. Generic UDP, unsupported protocols, and +transaction rows where no outgoing request was observed show `-`. Double-digit +counts are displayed as `+`, while Details retains the exact counters. The +Details Transport Health card marks the two counters behind the badge with +their letters (`TCP Retransmits (R)`, `Out-of-Order (O)`), so the compact badge +maps back to exact numbers. Health +sorting is severity-first: TCP retransmits and request timeouts rank above +warning-only out-of-order, retry, and version events, then higher counts rank +first. + When viewing connection details (press `Enter` on a connection), TCP analytics are shown for that specific connection: ``` diff --git a/USAGE.zh-CN.md b/USAGE.zh-CN.md index ac73cfb1..1a270dda 100644 --- a/USAGE.zh-CN.md +++ b/USAGE.zh-CN.md @@ -557,6 +557,7 @@ RustNet 提供强大的表格排序功能来帮助你分析网络连接。按 `s | **Application** | ↑ 升序 | 按检测到的应用协议排序(HTTP、DNS 等),以 TCP/UDP 作为同序比较 | | **State** | ↑ 升序 | 按连接状态排序(ESTABLISHED 等) | | **RTT** | ↓ 降序 | 按往返时延排序(默认最慢的连接优先) | +| **Health** | ↓ 降序 | 按协议相关健康信号的严重程度排序,再按事件数排序 | | **Bandwidth (Rx/Tx)** | ↓ 降序 | 按**上下行合计**带宽排序(默认最高优先) | 在窄终端下被隐藏的列仍留在循环中 —— 当前排序列始终显示在表格的区段标题中。 @@ -584,7 +585,7 @@ Process │ Remote ↑ │ Local │ Service │ App │ ... **按 `s`(小写)—— 循环列:** - 移动到从左到右视觉顺序的下一列 - **重置为该列的默认方向** -- 带宽列默认降序(↓)以优先显示最高值 +- 带宽、RTT 和 Health 默认降序(↓),优先显示最显著的值 - 文本列默认升序(↑)以按字母顺序排列 **按 `S`(Shift+s)—— 切换方向:** @@ -757,6 +758,21 @@ Active TCP Flows: 18 ### 逐连接统计 +概览表格的 **Health** 列会显示可观测的连接质量,徽标会随协议变化: + +- TCP 的 `R3/O1` 表示三次重传和一个乱序包。 +- QUIC 的 `R1/V0` 表示一个明确的 Retry 包,没有版本协商包。 +- 对于出站 DNS、LLMNR、NetBIOS、STUN 或 NTP 事务,`R2/T1` 表示 + 两次使用相同请求 ID 的重试和一个未收到应答而过期的请求。NTP 每次 + 轮询都携带新的发送时间戳,因此 NTP 主要报告超时而非重试。 + +可评估且无异常的连接显示 `ok`。普通 UDP、不支持的协议,以及未观察到 +出站请求的事务连接显示 `-`。两位数及以上的计数显示为 `+`,详情页保留 +精确计数。详情页的 Transport Health 卡片会在徽标对应的两个计数标签后 +标注字母(`TCP Retransmits (R)`、`Out-of-Order (O)`),便于把紧凑徽标 +对应回精确数字。Health 排序优先按严重程度:TCP 重传和请求超时高于仅告警的 +乱序、重试及版本事件,同级再按事件数降序排列。 + 查看连接详情时(在连接上按 `Enter`),显示该特定连接的 TCP 分析: ``` diff --git a/crates/rustnet-core/src/network/tracker.rs b/crates/rustnet-core/src/network/tracker.rs index e8c318b6..58de1f81 100644 --- a/crates/rustnet-core/src/network/tracker.rs +++ b/crates/rustnet-core/src/network/tracker.rs @@ -48,7 +48,7 @@ use crate::network::neighbors::{NeighborCache, NeighborEntry}; use crate::network::parser::ParsedPacket; use crate::network::types::{ ApplicationProtocol, AttributionSource, Connection, ConnectionKey, Protocol, ProtocolState, - QuicPacketType, RttTracker, + QuicPacketType, RequestHealthEvents, RttTracker, }; use dashmap::DashMap; use rustc_hash::FxBuildHasher; @@ -282,6 +282,49 @@ fn apply_timings(conn: &mut Connection, timings: &PacketTimings) { } } +/// Count explicit QUIC health signals and mark outgoing timed requests. +fn apply_observed_protocol_health(conn: &mut Connection, parsed: &ParsedPacket) { + let Some(dpi) = parsed.dpi_result.as_ref() else { + return; + }; + match &dpi.application { + ApplicationProtocol::Quic(quic) => match quic.packet_type { + QuicPacketType::Retry => { + conn.protocol_health.quic_retry_count = + conn.protocol_health.quic_retry_count.saturating_add(1); + } + QuicPacketType::VersionNegotiation => { + conn.protocol_health.quic_version_negotiation_count = conn + .protocol_health + .quic_version_negotiation_count + .saturating_add(1); + } + _ => {} + }, + ApplicationProtocol::Dns(info) if parsed.is_outgoing && !info.is_response => { + conn.protocol_health.request_observed = true; + } + ApplicationProtocol::Llmnr(info) if parsed.is_outgoing && !info.is_response => { + conn.protocol_health.request_observed = true; + } + ApplicationProtocol::NetBios(info) if parsed.is_outgoing && info.is_request() => { + conn.protocol_health.request_observed = true; + } + ApplicationProtocol::Stun(info) + if parsed.is_outgoing + && info.message_class == crate::network::types::StunMessageClass::Request => + { + conn.protocol_health.request_observed = true; + } + ApplicationProtocol::Ntp(info) + if parsed.is_outgoing && info.mode == crate::network::types::NtpMode::Client => + { + conn.protocol_health.request_observed = true; + } + _ => {} + } +} + /// A live, lifecycle-managed table of network connections built from parsed /// packets. See the [module docs](self) for the intended usage. pub struct ConnectionTracker { @@ -439,6 +482,7 @@ impl ConnectionTracker { let mut netbios_response_time: Option = None; let mut stun_rtt: Option = None; let mut ntp_rtt: Option = None; + let mut request_health_events = RequestHealthEvents::default(); let base_key = parsed.connection_key(); if parsed.protocol == Protocol::Tcp && let Some(tcp_header) = &parsed.tcp_header @@ -523,8 +567,11 @@ impl ConnectionTracker { } _ => {} } + request_health_events = tracker.take_request_health_events(); } + self.apply_request_health_events(request_health_events); + // ICMP echo requests reuse one identifier for the life of a ping // process, so sequence number is part of the key. That allows several // subsecond requests to be pending at once and replies to arrive out of @@ -586,6 +633,21 @@ impl ConnectionTracker { } } + fn apply_request_health_events(&self, events: RequestHealthEvents) { + for key in events.retries { + if let Some(mut conn) = self.connections.get_mut(&key) { + conn.protocol_health.request_retry_count = + conn.protocol_health.request_retry_count.saturating_add(1); + } + } + for key in events.timeouts { + if let Some(mut conn) = self.connections.get_mut(&key) { + conn.protocol_health.request_timeout_count = + conn.protocol_health.request_timeout_count.saturating_add(1); + } + } + } + fn ingest_into_active( &self, parsed: &ParsedPacket, @@ -615,11 +677,13 @@ impl ConnectionTracker { .and_modify(|conn| { deltas = merge_packet_into_connection(conn, parsed, now); apply_timings(conn, &timings); + apply_observed_protocol_health(conn, parsed); }) .or_insert_with(|| { created = true; let mut conn = create_connection_from_packet(parsed, now); apply_timings(&mut conn, &timings); + apply_observed_protocol_health(&mut conn, parsed); // Attribute a hostname (or enroll for a later DNS response) // at creation. The cache only touches its own maps, so this // is safe under the entry's shard lock. @@ -885,6 +949,12 @@ impl ConnectionTracker { .lifecycle .write() .unwrap_or_else(std::sync::PoisonError::into_inner); + let request_health_events = self + .rtt + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .expire_requests(now); + self.apply_request_health_events(request_health_events); let mut removed: Vec = Vec::new(); let mut removed_keys: Vec = Vec::new(); let mut to_archive: Vec<(HistoricKey, Connection)> = Vec::new(); @@ -1460,6 +1530,20 @@ mod tests { ); } + #[test] + fn quic_retry_and_version_negotiation_packets_are_counted() { + let tracker = ConnectionTracker::new(); + let retry = tracker.ingest_at(&quic_packet(QuicPacketType::Retry, false), capture_time(0)); + tracker.ingest_at( + &quic_packet(QuicPacketType::VersionNegotiation, false), + capture_time(1), + ); + + let conn = tracker.connections().get(&retry.key).unwrap().clone(); + assert_eq!(conn.protocol_health.quic_retry_count, 1); + assert_eq!(conn.protocol_health.quic_version_negotiation_count, 1); + } + /// rustnet watches from an endpoint, so an arriving packet followed by this /// host's own answer spans no network at all — it times the local stack's /// turnaround. Only an outbound packet may start the clock. @@ -1602,10 +1686,28 @@ mod tests { #[test] fn retransmitted_dns_query_measures_from_the_last_send() { let tracker = ConnectionTracker::new(); - tracker.ingest_at(&dns_packet(0x1234, true, false, 0), capture_time(0)); + let query = tracker.ingest_at(&dns_packet(0x1234, true, false, 0), capture_time(0)); tracker.ingest_at(&dns_packet(0x1234, true, false, 0), capture_time(1_000)); let response = tracker.ingest_at(&dns_packet(0x1234, false, true, 0), capture_time(1_018)); assert_eq!(response.dns_response_time, Some(Duration::from_millis(18))); + + let conn = tracker.connections().get(&query.key).unwrap().clone(); + assert!(conn.protocol_health.request_observed); + assert_eq!(conn.protocol_health.request_retry_count, 1); + assert_eq!(conn.protocol_health.request_timeout_count, 0); + } + + #[test] + fn unanswered_dns_query_is_counted_as_a_timeout() { + let tracker = ConnectionTracker::new(); + let query = tracker.ingest_at(&dns_packet(0x1234, true, false, 0), capture_time(0)); + + tracker.cleanup(capture_time(10_001)); + + let conn = tracker.connections().get(&query.key).unwrap().clone(); + assert!(conn.protocol_health.request_observed); + assert_eq!(conn.protocol_health.request_retry_count, 0); + assert_eq!(conn.protocol_health.request_timeout_count, 1); } /// SERVFAIL is still an answer: the round trip completed, so it is a valid diff --git a/crates/rustnet-core/src/network/types/connection.rs b/crates/rustnet-core/src/network/types/connection.rs index 40966c32..47cf8a56 100644 --- a/crates/rustnet-core/src/network/types/connection.rs +++ b/crates/rustnet-core/src/network/types/connection.rs @@ -11,7 +11,9 @@ use super::identity::{ use super::protocol_info::{ ApplicationProtocol, DpiInfo, QuicConnectionState, QuicInfo, QuicPacketType, }; -use super::rates::{RateTracker, TERMINAL_ARCHIVE_GRACE, TcpAnalytics, smooth_rate}; +use super::rates::{ + ProtocolHealth, RateTracker, TERMINAL_ARCHIVE_GRACE, TcpAnalytics, smooth_rate, +}; /// Distribution of connections by application protocol (from DPI) #[derive(Debug, Clone, Default)] @@ -191,6 +193,9 @@ pub struct Connection { // TCP analytics (only for TCP connections) pub tcp_analytics: Option, + // Health signals observable without TCP sequence analysis. + pub protocol_health: ProtocolHealth, + // Initial RTT measurement: TCP SYN/SYN-ACK timing, or the QUIC long-header // handshake exchange. Set once, from the first round trip observed. pub initial_rtt: Option, @@ -299,6 +304,7 @@ impl Connection { current_incoming_rate_bps: 0.0, current_outgoing_rate_bps: 0.0, tcp_analytics, + protocol_health: ProtocolHealth::default(), initial_rtt: None, dns_response_time: None, llmnr_response_time: None, diff --git a/crates/rustnet-core/src/network/types/rates.rs b/crates/rustnet-core/src/network/types/rates.rs index 5b38b9ff..b1f4e2d6 100644 --- a/crates/rustnet-core/src/network/types/rates.rs +++ b/crates/rustnet-core/src/network/types/rates.rs @@ -351,6 +351,22 @@ impl Default for TcpAnalytics { } } +/// Observable health signals for protocols that do not expose TCP sequence +/// and acknowledgement analysis. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ProtocolHealth { + /// Explicit QUIC Retry packets seen on this connection. + pub quic_retry_count: u64, + /// Explicit QUIC Version Negotiation packets seen on this connection. + pub quic_version_negotiation_count: u64, + /// Repeated transaction IDs observed before a matching response. + pub request_retry_count: u64, + /// Tracked requests that expired without a matching response. + pub request_timeout_count: u64, + /// At least one outgoing request was observed for this connection. + pub request_observed: bool, +} + // Rate-smoothing constants — tune these to control how quickly displayed // rates react to traffic changes. /// Multiplier when traffic stops entirely: prev * DECAY_FAST each refresh. diff --git a/crates/rustnet-core/src/network/types/rtt.rs b/crates/rustnet-core/src/network/types/rtt.rs index 1240ba3f..fae98abf 100644 --- a/crates/rustnet-core/src/network/types/rtt.rs +++ b/crates/rustnet-core/src/network/types/rtt.rs @@ -41,7 +41,7 @@ struct PendingTable { max_age: Duration, } -impl PendingTable { +impl PendingTable { fn new(cap: usize, max_age: Duration) -> Self { Self { map: HashMap::new(), @@ -51,10 +51,12 @@ impl PendingTable { } /// Record a request, dropping new keys at the cap. - fn start(&mut self, key: K, value: V) { - if self.map.len() < self.cap || self.map.contains_key(&key) { + fn start(&mut self, key: K, value: V) -> bool { + let was_pending = self.map.contains_key(&key); + if self.map.len() < self.cap || was_pending { self.map.insert(key, value); } + was_pending } /// Take the pending entry a reply matches, if its request was tracked. @@ -64,11 +66,19 @@ impl PendingTable { /// Drop entries older than `max_age` relative to the capture time of the /// packet being processed. - fn prune(&mut self, now: SystemTime) { + fn prune(&mut self, now: SystemTime) -> Vec { let Some(cutoff) = now.checked_sub(self.max_age) else { - return; + return Vec::new(); }; - self.map.retain(|_, v| v.stamp() > cutoff); + let mut expired = Vec::new(); + self.map.retain(|_, v| { + let keep = v.stamp() > cutoff; + if !keep { + expired.push(*v); + } + keep + }); + expired } fn clear(&mut self) { @@ -89,20 +99,31 @@ impl PendingTable { is_request: bool, is_response: bool, at: SystemTime, - ) -> Option<(Duration, ConnectionKey)> { - self.prune(at); + ) -> (Option<(Duration, ConnectionKey)>, bool) { if is_outgoing && is_request { - self.start(pending_key, (at, connection_key)); - return None; + let retried = self.start(pending_key, (at, connection_key)); + return (None, retried); } if !is_outgoing && is_response { - let (sent_at, request_key) = self.complete(&pending_key)?; - return Some((at.duration_since(sent_at).unwrap_or_default(), request_key)); + let Some((sent_at, request_key)) = self.complete(&pending_key) else { + return (None, false); + }; + return ( + Some((at.duration_since(sent_at).unwrap_or_default(), request_key)), + false, + ); } - None + (None, false) } } +/// Per-connection request health events collected while correlating packets. +#[derive(Debug, Default)] +pub(crate) struct RequestHealthEvents { + pub(crate) retries: Vec, + pub(crate) timeouts: Vec, +} + /// Correlation scope for protocols that use the DNS transaction ID. /// /// Unicast DNS can pair on the full connection. LLMNR queries are multicast @@ -146,10 +167,12 @@ pub struct RttTracker { pending_icmp_echoes: PendingTable<(ConnectionKey, u16, u16)>, /// Outbound STUN requests awaiting their response, keyed by connection /// and the 96-bit transaction ID. - pending_stun: PendingTable<(ConnectionKey, [u8; 12])>, + pending_stun: PendingTable<(ConnectionKey, [u8; 12]), (SystemTime, ConnectionKey)>, /// Outbound NTP client requests awaiting a server response, keyed by /// connection and the transmit timestamp the response echoes back. - pending_ntp: PendingTable<(ConnectionKey, u64)>, + pending_ntp: PendingTable<(ConnectionKey, u64), (SystemTime, ConnectionKey)>, + /// Health events waiting for the connection table to consume them. + request_health_events: RequestHealthEvents, /// Recent RTT measurements for aggregation: (timestamp, rtt_duration) recent_rtts: VecDeque<(Instant, Duration)>, /// Maximum number of recent RTTs to keep @@ -176,6 +199,7 @@ impl RttTracker { pending_icmp_echoes: PendingTable::new(MAX_PENDING, max_request_age), pending_stun: PendingTable::new(MAX_PENDING, max_request_age), pending_ntp: PendingTable::new(MAX_PENDING, max_request_age), + request_health_events: RequestHealthEvents::default(), recent_rtts: VecDeque::new(), max_recent_rtts: 100, } @@ -249,16 +273,20 @@ impl RttTracker { is_response: bool, at: SystemTime, ) -> Option { - self.pending_dns - .record_exchange( - DnsTransactionKey::Unicast(key, txid), - key, - is_outgoing, - !is_response, - is_response, - at, - ) - .map(|(rtt, _)| rtt) + let expired = self.pending_dns.prune(at); + self.note_request_timeouts(expired); + let (completed, retried) = self.pending_dns.record_exchange( + DnsTransactionKey::Unicast(key, txid), + key, + is_outgoing, + !is_response, + is_response, + at, + ); + if retried { + self.request_health_events.retries.push(key); + } + completed.map(|(rtt, _)| rtt) } /// Record an LLMNR packet and return the first response time together with @@ -276,14 +304,20 @@ impl RttTracker { is_outgoing: bool, at: SystemTime, ) -> Option<(Duration, ConnectionKey)> { - self.pending_dns.record_exchange( + let expired = self.pending_dns.prune(at); + self.note_request_timeouts(expired); + let (completed, retried) = self.pending_dns.record_exchange( DnsTransactionKey::Llmnr(key.local_addr, info.txid), key, is_outgoing, !info.is_response, info.is_response, at, - ) + ); + if retried { + self.request_health_events.retries.push(key); + } + completed } /// Record a NetBIOS packet and, when an incoming final response matches an @@ -305,14 +339,20 @@ impl RttTracker { at: SystemTime, ) -> Option<(Duration, ConnectionKey)> { let pending_key = (key.local_addr, info.service, info.transaction_id); - self.pending_netbios.record_exchange( + let expired = self.pending_netbios.prune(at); + self.note_request_timeouts(expired); + let (completed, retried) = self.pending_netbios.record_exchange( pending_key, key, is_outgoing, info.is_request(), info.is_response, at, - ) + ); + if retried { + self.request_health_events.retries.push(key); + } + completed } /// Record an ICMP echo packet, returning the RTT when an incoming reply @@ -365,15 +405,18 @@ impl RttTracker { class: StunMessageClass, at: SystemTime, ) -> Option { - self.pending_stun.prune(at); + let expired = self.pending_stun.prune(at); + self.note_request_timeouts(expired); let pending_key = (key, transaction_id); match (is_outgoing, class) { (true, StunMessageClass::Request) => { - self.pending_stun.start(pending_key, at); + if self.pending_stun.start(pending_key, (at, key)) { + self.request_health_events.retries.push(key); + } None } (false, StunMessageClass::SuccessResponse | StunMessageClass::ErrorResponse) => { - let sent_at = self.pending_stun.complete(&pending_key)?; + let (sent_at, _) = self.pending_stun.complete(&pending_key)?; Some(at.duration_since(sent_at).unwrap_or_default()) } _ => None, @@ -394,20 +437,52 @@ impl RttTracker { is_outgoing: bool, at: SystemTime, ) -> Option { - self.pending_ntp.prune(at); + let expired = self.pending_ntp.prune(at); + self.note_request_timeouts(expired); match (is_outgoing, info.mode) { (true, NtpMode::Client) => { - self.pending_ntp.start((key, info.transmit_timestamp), at); + if self + .pending_ntp + .start((key, info.transmit_timestamp), (at, key)) + { + self.request_health_events.retries.push(key); + } None } (false, NtpMode::Server) => { - let sent_at = self.pending_ntp.complete(&(key, info.origin_timestamp))?; + let (sent_at, _) = self.pending_ntp.complete(&(key, info.origin_timestamp))?; Some(at.duration_since(sent_at).unwrap_or_default()) } _ => None, } } + fn note_request_timeouts( + &mut self, + expired: impl IntoIterator, + ) { + self.request_health_events + .timeouts + .extend(expired.into_iter().map(|(_, key)| key)); + } + + /// Expire all tracked request protocols and return their health events. + pub(crate) fn expire_requests(&mut self, now: SystemTime) -> RequestHealthEvents { + let dns = self.pending_dns.prune(now); + let netbios = self.pending_netbios.prune(now); + let stun = self.pending_stun.prune(now); + let ntp = self.pending_ntp.prune(now); + self.note_request_timeouts(dns); + self.note_request_timeouts(netbios); + self.note_request_timeouts(stun); + self.note_request_timeouts(ntp); + self.take_request_health_events() + } + + pub(crate) fn take_request_health_events(&mut self) -> RequestHealthEvents { + std::mem::take(&mut self.request_health_events) + } + /// Record a completed data round trip (segment to covering ACK) measured /// by the per-connection estimator, so the aggregate RTT view reflects /// established connections rather than only fresh handshakes. @@ -451,6 +526,7 @@ impl RttTracker { self.pending_icmp_echoes.clear(); self.pending_stun.clear(); self.pending_ntp.clear(); + self.request_health_events = RequestHealthEvents::default(); self.recent_rtts.clear(); } } diff --git a/src/ui/connection_table.rs b/src/ui/connection_table.rs index 56c78c08..5d3565cf 100644 --- a/src/ui/connection_table.rs +++ b/src/ui/connection_table.rs @@ -27,7 +27,7 @@ use ratatui::widgets::{Cell, Row, Table}; use std::net::SocketAddr; use crate::network::dns::DnsResolver; -use crate::network::types::{AddrKind, Connection, Protocol}; +use crate::network::types::{AddrKind, ApplicationProtocol, Connection, Protocol}; use crate::ui::{ ClickAction, ClickableRegions, NONE_PLACEHOLDER, SortColumn, UIState, dpi_color, format::{format_rate_compact, format_rtt_compact, truncate_with_ellipsis}, @@ -44,8 +44,9 @@ const LOCATION_WIDTH: u16 = 4; const SERVICE_WIDTH: u16 = 10; // most IANA service names ("netbios-ns") fit const APP_WIDTH_FULL: u16 = 24; const APP_WIDTH_COMPACT: u16 = 14; -const STATE_WIDTH: u16 = 12; // longest TCP state: "ESTABLISHED" (11) +const STATE_WIDTH: u16 = 11; // longest TCP state: "ESTABLISHED" (11) const RTT_WIDTH: u16 = 7; // "234ms", "1.2s"; header "RTT ↓" when sorted +const HEALTH_WIDTH: u16 = 5; // "R3/O1", "R1/V0", or "R2/T1" const BANDWIDTH_WIDTH: u16 = 11; /// Floor for the Remote column; bare "ip:port" for IPv4 fits in 21. const REMOTE_MIN_WIDTH: u16 = 21; @@ -73,6 +74,8 @@ pub(in crate::ui) enum ColumnId { State, /// Best available TCP, QUIC handshake, or ICMP echo RTT. Rtt, + /// Protocol-aware observable health badge. + Health, Bandwidth, } @@ -96,6 +99,7 @@ impl Column { ColumnId::Application => Some(SortColumn::Application), ColumnId::State => Some(SortColumn::State), ColumnId::Rtt => Some(SortColumn::Rtt), + ColumnId::Health => Some(SortColumn::Health), ColumnId::Bandwidth => Some(SortColumn::BandwidthTotal), }; Self { id, width, sort } @@ -117,8 +121,8 @@ fn table_chrome(column_count: usize) -> u16 { /// never affects the layout, so columns stay put while scrolling. /// /// Too narrow: whole columns are hidden in a fixed degradation order -/// (Location → Service → Local → RTT → Application shrinks to compact → -/// State) rather than truncating cells. The floor is Process · +/// (Location → Service → Local → RTT → Health → Application shrinks to +/// compact → State) rather than truncating cells. The floor is Process · /// Remote · App · Bandwidth; below that ratatui clips columns from the /// right. /// @@ -141,6 +145,7 @@ pub(in crate::ui) fn select_columns(available_width: u16, has_location: bool) -> Column::new(ColumnId::Application, APP_WIDTH_FULL), Column::new(ColumnId::State, STATE_WIDTH), Column::new(ColumnId::Rtt, RTT_WIDTH), + Column::new(ColumnId::Health, HEALTH_WIDTH), Column::new(ColumnId::Bandwidth, BANDWIDTH_WIDTH), ]); @@ -154,6 +159,7 @@ pub(in crate::ui) fn select_columns(available_width: u16, has_location: bool) -> ColumnId::Service, ColumnId::Local, ColumnId::Rtt, + ColumnId::Health, ] { if !fits(&columns) { columns.retain(|c| c.id != id); @@ -352,6 +358,7 @@ fn header_label(id: ColumnId, ui_state: &UIState) -> &'static str { ColumnId::Application => "App", ColumnId::State => "State", ColumnId::Rtt => "RTT", + ColumnId::Health => "Hlth", ColumnId::Bandwidth => "", // built as spans in build_header } } @@ -391,7 +398,9 @@ pub(in crate::ui) fn build_header<'a>(columns: &[Column], ui_state: &UIState) -> } let label = header_label(col.id, ui_state); - let text = if active { + let text = if active && col.id == ColumnId::Health { + format!("H {sort_arrow}") + } else if active { format!("{label} {sort_arrow}") } else { label.to_string() @@ -514,6 +523,7 @@ pub(in crate::ui) fn connection_row<'a>( } } ColumnId::Rtt => rtt_cell(conn, color_cells), + ColumnId::Health => health_cell(conn, color_cells), ColumnId::Bandwidth => { if conn.is_historic { Cell::from(Line::from("n/a").right_aligned()) @@ -614,6 +624,130 @@ fn rtt_cell<'a>(conn: &Connection, color_cells: bool) -> Cell<'a> { Cell::from(line.right_aligned()) } +/// Compact protocol-aware health badge. TCP shows retransmits/out-of-order, +/// QUIC shows explicit Retry/Version Negotiation packets, and transaction-based +/// UDP shows repeated requests/timeouts. Other protocols remain ungraded. +fn health_cell<'a>(conn: &Connection, color_cells: bool) -> Cell<'a> { + let Some((kind, first_count, second_count)) = health_counts(conn) else { + let style = if color_cells { + theme::fg(theme::muted()) + } else { + Style::default() + }; + return Cell::from(NONE_PLACEHOLDER).style(style); + }; + let (first, second) = match kind { + HealthKind::Tcp => ( + ('R', first_count, theme::err()), + ('O', second_count, theme::warn()), + ), + HealthKind::Quic => ( + ('R', first_count, theme::warn()), + ('V', second_count, theme::warn()), + ), + HealthKind::Transaction => ( + ('R', first_count, theme::warn()), + ('T', second_count, theme::err()), + ), + }; + health_pair_cell(first, second, color_cells) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::ui) enum HealthKind { + Tcp, + Quic, + Transaction, +} + +/// Classify a connection's gradable health signals: the badge kind plus its +/// two counters in display order. Shared with the Health sort so the badge +/// and the ordering can never disagree on which connections are graded. +pub(in crate::ui) fn health_counts(conn: &Connection) -> Option<(HealthKind, u64, u64)> { + if let Some(analytics) = conn.tcp_analytics.as_ref() { + return Some(( + HealthKind::Tcp, + analytics.retransmit_count, + analytics.out_of_order_count, + )); + } + + let application = conn.dpi_info.as_ref().map(|dpi| &dpi.application); + if matches!(application, Some(ApplicationProtocol::Quic(_))) { + return Some(( + HealthKind::Quic, + conn.protocol_health.quic_retry_count, + conn.protocol_health.quic_version_negotiation_count, + )); + } + + let transactional_udp = conn.protocol == Protocol::Udp + && matches!( + application, + Some( + ApplicationProtocol::Dns(_) + | ApplicationProtocol::Llmnr(_) + | ApplicationProtocol::NetBios(_) + | ApplicationProtocol::Stun(_) + | ApplicationProtocol::Ntp(_) + ) + ); + (transactional_udp && conn.protocol_health.request_observed).then_some(( + HealthKind::Transaction, + conn.protocol_health.request_retry_count, + conn.protocol_health.request_timeout_count, + )) +} + +fn health_pair_cell<'a>( + first: (char, u64, Color), + second: (char, u64, Color), + color_cells: bool, +) -> Cell<'a> { + let (first_label, first_count, first_color) = first; + let (second_label, second_count, second_color) = second; + if first_count.saturating_add(second_count) == 0 { + let style = if color_cells { + theme::fg(theme::ok()) + } else { + Style::default() + }; + return Cell::from("ok").style(style); + } + + let first_count_text = health_count_text(first_count); + let second_count_text = health_count_text(second_count); + if !color_cells { + return Cell::from(format!( + "{first_label}{first_count_text}/{second_label}{second_count_text}" + )); + } + + let first_style = if first_count > 0 { + theme::bold_fg(first_color) + } else { + theme::fg(theme::muted()) + }; + let second_style = if second_count > 0 { + theme::bold_fg(second_color) + } else { + theme::fg(theme::muted()) + }; + Cell::from(Line::from(vec![ + Span::styled(format!("{first_label}{first_count_text}"), first_style), + Span::styled("/", theme::fg(theme::muted())), + Span::styled(format!("{second_label}{second_count_text}"), second_style), + ])) +} + +fn health_count_text(count: u64) -> String { + if count > 9 { + "+".to_string() + } else { + count.to_string() + } +} + /// Bandwidth cell: "{rx}/{tx}" right-aligned, rx/tx halves colored when /// there's live traffic, whole cell muted when idle (muted preset). The /// ↓/↑ arrows live in the column header, not on every row. Takes raw @@ -723,7 +857,10 @@ pub(in crate::ui) fn render_row_table( #[cfg(test)] mod tests { use super::*; - use crate::network::types::{Connection, Protocol, ProtocolState, TcpState}; + use crate::network::types::{ + ApplicationProtocol, Connection, DnsInfo, DnsQueryType, DpiInfo, Protocol, ProtocolState, + QuicInfo, TcpState, + }; use std::net::{IpAddr, Ipv6Addr, SocketAddr}; fn ids(columns: &[Column]) -> Vec { @@ -755,8 +892,8 @@ mod tests { } // Width math for the full set with Location at floor widths: - // 22+21+18+4+10+24+12+7+11 = 129 content + chrome(9 cols) = 9 -> 138. - const FULL_WIDTH: u16 = 138; + // 22+21+18+4+10+24+11+7+5+11 = 133 content + chrome(10 cols) = 10 -> 143. + const FULL_WIDTH: u16 = 143; #[test] fn select_columns_shows_everything_when_wide() { @@ -772,6 +909,7 @@ mod tests { ColumnId::Application, ColumnId::State, ColumnId::Rtt, + ColumnId::Health, ColumnId::Bandwidth, ] ); @@ -785,31 +923,37 @@ mod tests { assert!(!ids(&cols).contains(&ColumnId::Location)); assert!(ids(&cols).contains(&ColumnId::Service)); - // 22+21+18+10+24+12+7+11 = 125 + chrome(8) = 133 -> below that Service goes. - let cols = select_columns(132, true); + // 22+21+18+10+24+11+7+5+11 = 129 + chrome(9) = 138 -> below that Service goes. + let cols = select_columns(137, true); assert!(!ids(&cols).contains(&ColumnId::Service)); assert!(ids(&cols).contains(&ColumnId::Local)); - // 22+21+18+24+12+7+11 = 115 + chrome(7) = 122 -> below that Local goes. - let cols = select_columns(122, true); + // 22+21+18+24+11+7+5+11 = 119 + chrome(8) = 127 -> below that Local goes. + let cols = select_columns(127, true); assert!(ids(&cols).contains(&ColumnId::Local)); assert_eq!(width_of(&cols, ColumnId::Application), APP_WIDTH_FULL); - let cols = select_columns(121, true); + let cols = select_columns(126, true); assert!(!ids(&cols).contains(&ColumnId::Local)); - // 22+21+24+12+7+11 = 97 + chrome(6) = 103 -> below that RTT goes. - let cols = select_columns(103, true); + // 22+21+24+11+7+5+11 = 101 + chrome(7) = 108 -> below that RTT goes. + let cols = select_columns(108, true); assert!(ids(&cols).contains(&ColumnId::Rtt)); - let cols = select_columns(102, true); + let cols = select_columns(107, true); assert!(!ids(&cols).contains(&ColumnId::Rtt)); - // 22+21+24+12+11 = 90 + chrome(5) = 95 -> below that App compacts. - let cols = select_columns(94, true); + // 22+21+24+11+5+11 = 94 + chrome(6) = 100 -> below that Health goes. + let cols = select_columns(100, true); + assert!(ids(&cols).contains(&ColumnId::Health)); + let cols = select_columns(99, true); + assert!(!ids(&cols).contains(&ColumnId::Health)); + + // 22+21+24+11+11 = 89 + chrome(5) = 94 -> below that App compacts. + let cols = select_columns(93, true); assert_eq!(width_of(&cols, ColumnId::Application), APP_WIDTH_COMPACT); assert!(ids(&cols).contains(&ColumnId::State)); - // 22+21+14+12+11 = 80 + chrome(5) = 85 -> below that State goes. - let cols = select_columns(84, true); + // 22+21+14+11+11 = 79 + chrome(5) = 84 -> below that State goes. + let cols = select_columns(83, true); assert_eq!( ids(&cols), vec![ @@ -843,6 +987,7 @@ mod tests { // Fixed columns never grow. assert_eq!(width_of(&cols, ColumnId::State), STATE_WIDTH); assert_eq!(width_of(&cols, ColumnId::Rtt), RTT_WIDTH); + assert_eq!(width_of(&cols, ColumnId::Health), HEALTH_WIDTH); assert_eq!(width_of(&cols, ColumnId::Bandwidth), BANDWIDTH_WIDTH); // The grid spans the full width exactly, so the Bandwidth // column sits flush against the right edge. @@ -1089,4 +1234,53 @@ mod tests { conn.process_name = None; assert_eq!(process_style(&conn, true), base); } + + #[test] + fn health_badge_keeps_both_issue_counts_compact() { + assert_eq!(health_count_text(3), "3"); + assert_eq!(health_count_text(10), "+"); + assert_eq!( + format!("R{}/O{}", health_count_text(3), health_count_text(1)), + "R3/O1" + ); + } + + #[test] + fn health_counts_adapt_to_quic_and_transactional_udp() { + let mut quic = Connection::new( + Protocol::Udp, + "192.168.1.10:50000".parse().unwrap(), + "203.0.113.7:443".parse().unwrap(), + ProtocolState::Udp, + ); + quic.dpi_info = Some(DpiInfo { + application: ApplicationProtocol::Quic(Box::new(QuicInfo::new(1))), + }); + quic.protocol_health.quic_retry_count = 2; + quic.protocol_health.quic_version_negotiation_count = 1; + assert_eq!(health_counts(&quic), Some((HealthKind::Quic, 2, 1))); + + let mut dns = Connection::new( + Protocol::Udp, + "192.168.1.10:50001".parse().unwrap(), + "1.1.1.1:53".parse().unwrap(), + ProtocolState::Udp, + ); + dns.dpi_info = Some(DpiInfo { + application: ApplicationProtocol::Dns(DnsInfo { + query_name: None, + query_type: Some(DnsQueryType::A), + response_ips: Vec::new(), + is_response: false, + txid: 1, + rcode: None, + nodata: None, + }), + }); + assert_eq!(health_counts(&dns), None); + dns.protocol_health.request_observed = true; + dns.protocol_health.request_retry_count = 3; + dns.protocol_health.request_timeout_count = 1; + assert_eq!(health_counts(&dns), Some((HealthKind::Transaction, 3, 1))); + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 60f25dfc..942c2252 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -394,7 +394,8 @@ mod tests { assert_eq!(Service.next(false), Application); assert_eq!(Application.next(false), State); assert_eq!(State.next(false), Rtt); - assert_eq!(Rtt.next(false), BandwidthTotal); + assert_eq!(Rtt.next(false), Health); + assert_eq!(Health.next(false), BandwidthTotal); assert_eq!(BandwidthTotal.next(false), CreatedAt); // Cycles back } @@ -414,9 +415,10 @@ mod tests { fn test_sort_column_default_directions() { use SortColumn::*; - // Bandwidth and RTT should default to descending (false) + // Bandwidth, RTT, and Health should default to descending (false) assert!(!BandwidthTotal.default_direction()); assert!(!Rtt.default_direction()); + assert!(!Health.default_direction()); // Everything else should default to ascending (true) assert!(Process.default_direction()); @@ -458,11 +460,14 @@ mod tests { assert_eq!(ui_state.sort_column, SortColumn::Application); assert!(ui_state.sort_ascending); - // Cycle to State, then Rtt, then BandwidthTotal + // Cycle to State, Rtt, Health, then BandwidthTotal ui_state.cycle_sort_column(); // State ui_state.cycle_sort_column(); // Rtt assert_eq!(ui_state.sort_column, SortColumn::Rtt); assert!(!ui_state.sort_ascending); // RTT defaults to descending (slowest first) + ui_state.cycle_sort_column(); // Health + assert_eq!(ui_state.sort_column, SortColumn::Health); + assert!(!ui_state.sort_ascending); // Health defaults to descending (most events first) ui_state.cycle_sort_column(); // BandwidthTotal assert_eq!(ui_state.sort_column, SortColumn::BandwidthTotal); assert!(!ui_state.sort_ascending); // Bandwidth defaults to descending @@ -499,6 +504,7 @@ mod tests { assert_eq!(Service.display_name(), "Service"); assert_eq!(State.display_name(), "State"); assert_eq!(Rtt.display_name(), "RTT"); + assert_eq!(Health.display_name(), "Health"); } #[test] @@ -511,8 +517,8 @@ mod tests { // Cycle through columns to reach BandwidthTotal // CreatedAt -> Process -> RemoteAddress -> LocalAddress -> Service -> - // Application -> State -> Rtt -> BandwidthTotal - for _ in 0..8 { + // Application -> State -> Rtt -> Health -> BandwidthTotal + for _ in 0..9 { ui_state.cycle_sort_column(); } @@ -1253,6 +1259,8 @@ mod snapshot_tests { // both flat and process-aggregate modes below. fn overview_connections() -> Vec { + use crate::network::types::{ApplicationProtocol, DnsInfo, DnsQueryType, DpiInfo}; + let mut connections = sample_connections(); connections[0].current_incoming_rate_bps = 3_200_000.0; connections[0].current_outgoing_rate_bps = 950_000.0; @@ -1264,7 +1272,23 @@ mod snapshot_tests { // and connections with nothing measured (placeholder). if let Some(analytics) = connections[0].tcp_analytics.as_mut() { analytics.smoothed_rtt = Some(Duration::from_micros(23_400)); + analytics.retransmit_count = 3; + analytics.out_of_order_count = 1; } + connections[1].dpi_info = Some(DpiInfo { + application: ApplicationProtocol::Dns(DnsInfo { + query_name: Some("example.com".to_string()), + query_type: Some(DnsQueryType::A), + response_ips: Vec::new(), + is_response: false, + txid: 0x1234, + rcode: None, + nodata: None, + }), + }); + connections[1].protocol_health.request_observed = true; + connections[1].protocol_health.request_retry_count = 2; + connections[1].protocol_health.request_timeout_count = 1; connections[2].initial_rtt = Some(Duration::from_millis(184)); connections } @@ -1513,6 +1537,8 @@ mod snapshot_tests { quic_conn.service_name = Some("https".to_string()); quic_conn.process_name = Some("firefox".to_string()); quic_conn.initial_rtt = Some(Duration::from_micros(23_400)); + quic_conn.protocol_health.quic_retry_count = 2; + quic_conn.protocol_health.quic_version_negotiation_count = 1; quic_conn.dpi_info = Some(DpiInfo { application: ApplicationProtocol::Quic(Box::new(quic)), }); @@ -1522,7 +1548,7 @@ mod snapshot_tests { for tcp_only in [ "TCP Retransmits", - "Out-of-Order Packets", + "Out-of-Order (O)", "Duplicate ACKs", "Fast Retransmits", "Window Size", @@ -1538,8 +1564,10 @@ mod snapshot_tests { ); assert!(output.contains("Idle Timeout") && output.contains("30s")); assert!(output.contains("Connection Close") && output.contains("application 0x100")); + assert!(output.contains("Retry Packets (R)") && output.contains('2')); + assert!(output.contains("Ver. Negotiations (V)") && output.contains('1')); assert!( - output.contains("Loss counters are encrypted in QUIC"), + output.contains("1-RTT loss counters are encrypted in QUIC"), "the card should say why the loss counters are absent" ); @@ -1566,6 +1594,9 @@ mod snapshot_tests { let dns_conn = &mut connections[1]; dns_conn.dns_response_time = Some(Duration::from_micros(12_300)); + dns_conn.protocol_health.request_observed = true; + dns_conn.protocol_health.request_retry_count = 2; + dns_conn.protocol_health.request_timeout_count = 1; dns_conn.dpi_info = Some(DpiInfo { application: ApplicationProtocol::Dns(DnsInfo { query_name: Some("example.com".to_string()), @@ -1586,6 +1617,8 @@ mod snapshot_tests { "the paired query/response time should fill the card" ); assert!(output.contains("Last Response Code") && output.contains("NOERROR")); + assert!(output.contains("Request Retries") && output.contains('2')); + assert!(output.contains("Request Timeouts") && output.contains('1')); assert!( output.contains("DNS Query") && output.contains("example.com"), "the queried name should show alongside the response details" diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_arp_application_card.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_arp_application_card.snap index 2052eab8..2dafde85 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_arp_application_card.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_arp_application_card.snap @@ -4,9 +4,9 @@ expression: output --- rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 ─────────────────────────━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────────────────── - Process Remote Local Service App State RTT Rx↓/Tx↑ + Process Remote Local Service App State RTT Hlth Rx↓/Tx↑ -▌- 192.168.1.1:0 192.168.1.10:0 - ARP ARP_WHO_HAS… - -/- +▌- 192.168.1.1:0 192.168.1.10:0 - ARP ARP_WHO_HA… - - -/- diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_dns_response_application_card.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_dns_response_application_card.snap index bbe5bc80..99383b8c 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_dns_response_application_card.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_dns_response_application_card.snap @@ -4,9 +4,9 @@ expression: output --- rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 ─────────────────────────━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────────────────── - Process Remote Local Service App State RTT Rx↓/Tx↑ + Process Remote Local Service App State RTT Hlth Rx↓/Tx↑ -▌proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - UDP·DNS (example.com) DNS_RESPONSE - -/- +▌proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - UDP·DNS (example.com) DNS_RESPON… - - -/- @@ -25,10 +25,10 @@ Service - Network Context Transport Health ▐ Local Hostname - DNS Response Time - ▐ Local MAC - Last Response Code NOERROR ▐ -Remote Hostname - ▐ -Attributed Name - Timed by pairing query and response IDs ▐ +Remote Hostname - Request Retries (R) - ▐ +Attributed Name - Request Timeouts (T) - ▐ Attributed Via - ▐ -Remote MAC - +Remote MAC - Timed by pairing query and response IDs Country - City - ASN - diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_http_application_card.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_http_application_card.snap index 378d5b52..221cedf4 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_http_application_card.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_http_application_card.snap @@ -4,9 +4,9 @@ expression: output --- rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 ─────────────────────────━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────────────────── - Process Remote Local Service App State RTT Rx↓/Tx↑ + Process Remote Local Service App State RTT Hlth Rx↓/Tx↑ -▌proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - TCP·HTTP (example.com) ESTABLISHED - -/- +▌proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - TCP·HTTP (example.com) ESTABLISHED - ok -/- @@ -25,8 +25,8 @@ Service - Network Context Transport Health ▐ Local Hostname - Initial RTT - ▐ Local MAC - Live RTT - ▐ -Remote Hostname - TCP Retransmits 0 ▐ -Attributed Name - Out-of-Order Packets 0 ▐ +Remote Hostname - TCP Retransmits (R) 0 ▐ +Attributed Name - Out-of-Order (O) 0 ▐ Attributed Via - Duplicate ACKs 0 ▐ Remote MAC - Fast Retransmits 0 Country - Window Size 0 diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_https_without_tls_info.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_https_without_tls_info.snap index b30ba0bc..8d632ee4 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_https_without_tls_info.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_https_without_tls_info.snap @@ -4,9 +4,9 @@ expression: output --- rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 ─────────────────────────━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────────────────── - Process Remote Local Service App State RTT Rx↓/Tx↑ + Process Remote Local Service App State RTT Hlth Rx↓/Tx↑ -▌proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - TCP·HTTPS ESTABLISHED - -/- +▌proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - TCP·HTTPS ESTABLISHED - ok -/- @@ -25,8 +25,8 @@ Service - Network Context Transport Health ▐ Local Hostname - Initial RTT - ▐ Local MAC - Live RTT - ▐ -Remote Hostname - TCP Retransmits 0 ▐ -Attributed Name - Out-of-Order Packets 0 ▐ +Remote Hostname - TCP Retransmits (R) 0 ▐ +Attributed Name - Out-of-Order (O) 0 ▐ Attributed Via - Duplicate ACKs 0 ▐ Remote MAC - Fast Retransmits 0 Country - Window Size 0 diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_icmp_echo_application_card.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_icmp_echo_application_card.snap index 092ade10..3fce2292 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_icmp_echo_application_card.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_icmp_echo_application_card.snap @@ -4,9 +4,9 @@ expression: output --- rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 ─────────────────────────━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────────────────── - Process Remote Local Service App State RTT Rx↓/Tx↑ + Process Remote Local Service App State RTT Hlth Rx↓/Tx↑ -▌ping 8.8.8.8:0 192.168.1.10:0 - ICMP ECHO_REQ(46… 8.7ms -/- +▌ping 8.8.8.8:0 192.168.1.10:0 - ICMP ECHO_REQ(4… 8.7ms - -/- diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_icmpv6_ndp_application_card.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_icmpv6_ndp_application_card.snap index e61987c0..9f4b79e6 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_icmpv6_ndp_application_card.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_icmpv6_ndp_application_card.snap @@ -4,9 +4,9 @@ expression: output --- rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 ─────────────────────────━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────────────────── - Process Remote Local Service App State RTT Rx↓/Tx↑ + Process Remote Local Service App State RTT Hlth Rx↓/Tx↑ -▌- [fe80::2]:0 [fe80::1]:0 - ICMP ICMP_OTHER - -/- +▌- [fe80::2]:0 [fe80::1]:0 - ICMP ICMP_OTHER - - -/- diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_igmp_application_card.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_igmp_application_card.snap index 63225dfe..df76f37e 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_igmp_application_card.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_igmp_application_card.snap @@ -4,9 +4,9 @@ expression: output --- rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 ─────────────────────────━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────────────────── - Process Remote Local Service App State RTT Rx↓/Tx↑ + Process Remote Local Service App State RTT Hlth Rx↓/Tx↑ -▌- 224.0.0.251:0 192.168.1.10:0 - IGMP REPORT_V2(2… - -/- +▌- 224.0.0.251:0 192.168.1.10:0 - IGMP REPORT_V2(… - - -/- diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_ntp_application_card.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_ntp_application_card.snap index e0119706..505c2767 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_ntp_application_card.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_ntp_application_card.snap @@ -4,9 +4,9 @@ expression: output --- rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 ─────────────────────────━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────────────────── - Process Remote Local Service App State RTT Rx↓/Tx↑ + Process Remote Local Service App State RTT Hlth Rx↓/Tx↑ -▌proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - UDP·NTP (v4 Server) NTP - -/- +▌proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - UDP·NTP (v4 Server) NTP - - -/- @@ -24,10 +24,10 @@ Service - ▐ Network Context Transport Health ▐ Local Hostname - NTP RTT - ▐ -Local MAC - ▐ -Remote Hostname - Paired by originate timestamp echo ▐ +Local MAC - Request Retries (R) - ▐ +Remote Hostname - Request Timeouts (T) - ▐ Attributed Name - ▐ -Attributed Via - ▐ +Attributed Via - Paired by originate timestamp echo ▐ Remote MAC - Country - City - diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_ssh_application_card.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_ssh_application_card.snap index 8af1eb59..443981a0 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_ssh_application_card.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_ssh_application_card.snap @@ -4,9 +4,9 @@ expression: output --- rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 ─────────────────────────━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────────────────── - Process Remote Local Service App State RTT Rx↓/Tx↑ + Process Remote Local Service App State RTT Hlth Rx↓/Tx↑ -▌proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - TCP·SSH (OpenSSH) ESTABLISHED - -/- +▌proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - TCP·SSH (OpenSSH) ESTABLISHED - ok -/- @@ -25,8 +25,8 @@ Service - Network Context Transport Health ▐ Local Hostname - Initial RTT - ▐ Local MAC - Live RTT - ▐ -Remote Hostname - TCP Retransmits 0 ▐ -Attributed Name - Out-of-Order Packets 0 ▐ +Remote Hostname - TCP Retransmits (R) 0 ▐ +Attributed Name - Out-of-Order (O) 0 ▐ Attributed Via - Duplicate ACKs 0 ▐ Remote MAC - Fast Retransmits 0 Country - Window Size 0 diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_tcp_https.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_tcp_https.snap index 7727c48b..8104c528 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_tcp_https.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__details_tab_tcp_https.snap @@ -4,11 +4,11 @@ expression: output --- rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 ─────────────────────────━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────────────────── - Process Remote Local Service App State RTT Rx↓/Tx↑ + Process Remote Local Service App State RTT Hlth Rx↓/Tx↑ -▌firefox (2001) 140.82.121.4:443 192.168.1.10:51234 https TCP ESTABLISHED - -/- - systemd-resolved (820) 1.1.1.1:53 192.168.1.10:53 dns UDP UDP_ACTIVE - -/- - sshd (1500) 10.0.0.5:51022 192.168.1.10:22 ssh TCP ESTABLISHED - -/- +▌firefox (2001) 140.82.121.4:443 192.168.1.10:51234 https TCP ESTABLISHED - ok -/- + systemd-resolved (820) 1.1.1.1:53 192.168.1.10:53 dns UDP UDP_ACTIVE - - -/- + sshd (1500) 10.0.0.5:51022 192.168.1.10:22 ssh TCP ESTABLISHED - ok -/- ▎ firefox → 140.82.121.4:443 [ESTABLISHED] · click a field to copy Connection Application ▐ @@ -25,8 +25,8 @@ Service https Network Context Transport Health ▐ Local Hostname - Initial RTT - ▐ Local MAC 68:5e:dd:09:15:5e (Apple, Inc.) Live RTT - ▐ -Remote Hostname - TCP Retransmits 0 ▐ -Attributed Name - Out-of-Order Packets 0 ▐ +Remote Hostname - TCP Retransmits (R) 0 ▐ +Attributed Name - Out-of-Order (O) 0 ▐ Attributed Via - Duplicate ACKs 0 ▐ Remote MAC - Fast Retransmits 0 Country - Window Size 0 diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__help_overlay_details.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__help_overlay_details.snap index f2bd79fa..c319ce9a 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__help_overlay_details.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__help_overlay_details.snap @@ -1,32 +1,34 @@ --- source: src/ui/mod.rs +assertion_line: 718 expression: output --- ╭ Help · Details ──────────────────────────────────────────────────────────────────────────╮ - │ Inspect the currently selected connection. │ - │ │ - │ ▎ Details Actions │ - │ ↑/k, ↓/j Show the previous or next connection │ - │ g, G Show the first or last connection │ - │ Page Up/Down Move through connections by one page │ - │ Ctrl+B/F Move through connections by one page │ - │ Ctrl+D/U Scroll the connection information panes │ - │ c Copy the remote address │ - │ Esc Return to Overview │ + │ Inspect the currently selected connection. ▐ │ + │ ▐ │ + │ ▎ Details Actions ▐ │ + │ ↑/k, ↓/j Show the previous or next connection ▐ │ + │ g, G Show the first or last connection ▐ │ + │ Page Up/Down Move through connections by one page ▐ │ + │ Ctrl+B/F Move through connections by one page ▐ │ + │ Ctrl+D/U Scroll the connection information panes ▐ │ + │ c Copy the remote address ▐ │ + │ Esc Return to Overview ▐ │ + │ ▐ │ + │ ▎ Connection Display ▐ │ + │ White Active connection ▐ │ + │ Yellow to red Connection approaching its timeout ▐ │ + │ Gray Historic closed connection ▐ │ + │ ~name Hostname inferred from an observed DNS response ▐ │ + │ App column Application protocol, SNI, or HTTP Host ▐ │ + │ R3/O1 Health for TCP: retransmits and out-of-order packets ▐ │ + │ R1/V0 Health for QUIC: Retry and Version Negotiation packets │ + │ R2/T1 Health for UDP requests: retries and timeouts │ + │ ok, - Healthy connection, or an ungraded protocol │ + │ R+ Counts of 10 or more; Details has the exact numbers │ │ │ │ ▎ Mouse │ │ Click field Copy that field's value │ │ Click connection Select a connection in the continuity strip │ - │ Scroll wheel Scroll the connection information panes │ - │ │ - │ ▎ Global │ - │ Tab, ] Next tab │ - │ Shift+Tab, [ Previous tab │ - │ 1-5 Jump to Overview, Details, Activity, Graph, or Host │ - │ x Clear all connections (press twice) │ - │ h, Esc Close this help overlay │ - │ q Quit application (press twice to confirm) │ - │ Ctrl+C Quit immediately │ - │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__help_overlay_overview.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__help_overlay_overview.snap index 550803f5..2f9ef41a 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__help_overlay_overview.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__help_overlay_overview.snap @@ -1,5 +1,6 @@ --- source: src/ui/mod.rs +assertion_line: 703 expression: output --- @@ -22,7 +23,7 @@ expression: output │ c Copy the selected remote address ▐ │ │ p Toggle service names and port numbers ▐ │ │ d Toggle hostnames and IP addresses ▐ │ - │ s, S Change sort column or direction ▐ │ + │ s, S Change sort column or direction │ │ a Toggle process grouping │ │ Space Expand or collapse the selected group │ │ ←/→, l Collapse or expand the selected group │ @@ -36,5 +37,5 @@ expression: output │ Gray Historic closed connection │ │ ~name Hostname inferred from an observed DNS response │ │ App column Application protocol, SNI, or HTTP Host │ - │ │ + │ R3/O1 Health for TCP: retransmits and out-of-order packets │ ╰──────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__overview_live_connections.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__overview_live_connections.snap index 13944c83..83f1cd00 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__overview_live_connections.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__overview_live_connections.snap @@ -5,12 +5,12 @@ expression: render_overview(false) rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 ────────────━━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ▎ Live Connections - Process Remote Local Service App State RTT Rx↓/Tx↑ + Process Remote Local Service App State RTT Hlth Rx↓/Tx↑ -▌firefox (2001) 140.82.121.4:443 192.168.1.10:51234 https TCP ESTABLISHED 23ms 3.1M/928K - systemd-resolved (820) 1.1.1.1:53 192.168.1.10:53 dns UDP UDP_ACTIVE - 47K/82K - sshd (1500) 10.0.0.5:51022 192.168.1.10:22 ssh TCP ESTABLISHED 184ms 410K/1.7M - curl (9876) 151.101.1.195:443 192.168.1.10:60123 https TCP TIME_WAIT - -/- +▌firefox (2001) 140.82.121.4:443 192.168.1.10:51234 https TCP ESTABLISHED 23ms R3/O1 3.1M/928K + systemd-resolved (820) 1.1.1.1:53 192.168.1.10:53 dns UDP·DNS (example.com) DNS_QUERY - R2/T1 47K/82K + sshd (1500) 10.0.0.5:51022 192.168.1.10:22 ssh TCP ESTABLISHED 184ms ok 410K/1.7M + curl (9876) 151.101.1.195:443 192.168.1.10:60123 https TCP TIME_WAIT - ok -/- diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__overview_process_aggregate.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__overview_process_aggregate.snap index 317dc67a..4ce3fed4 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__overview_process_aggregate.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__overview_process_aggregate.snap @@ -5,12 +5,12 @@ expression: render_overview(true) rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 ────────────━━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ▎ Process Aggregate - Process Remote Local Service App State RTT Rx↓/Tx↑ + Process Remote Local Service App State RTT Hlth Rx↓/Tx↑ -▌▸ curl (1) TCP:1 UDP:0 -/- - ▸ firefox (1) TCP:1 UDP:0 3.1M/928K - ▸ sshd (1) TCP:1 UDP:0 410K/1.7M - ▸ systemd-resolved (1) TCP:0 UDP:1 47K/82K +▌▸ curl (1) TCP:1 UDP:0 -/- + ▸ firefox (1) TCP:1 UDP:0 3.1M/928K + ▸ sshd (1) TCP:1 UDP:0 410K/1.7M + ▸ systemd-resolved (1) TCP:0 UDP:1 47K/82K diff --git a/src/ui/sorting.rs b/src/ui/sorting.rs index 8e80c8a5..d06bb9cb 100644 --- a/src/ui/sorting.rs +++ b/src/ui/sorting.rs @@ -4,6 +4,7 @@ use crate::network::types::Connection; use crate::ui::SortColumn; +use crate::ui::connection_table::{HealthKind, health_counts}; /// Sort `connections` in place by the chosen column. `ascending` /// flips the comparator's ordering after the column-specific cmp. @@ -81,6 +82,8 @@ pub fn sort_connections(connections: &mut [Connection], sort_column: SortColumn, a_rtt.cmp(&b_rtt) } + SortColumn::Health => health_sort_key(a).cmp(&health_sort_key(b)), + SortColumn::Location => { let a_loc = a .geoip_info @@ -104,10 +107,34 @@ pub fn sort_connections(connections: &mut [Connection], sort_column: SortColumn, }); } +/// Rank observed health by severity first, then by total and severe events. +/// A timeout or TCP retransmit stays above any number of warning-only signals. +fn health_sort_key(connection: &Connection) -> (u8, u64, u64) { + let (severe, warning) = match health_counts(connection) { + Some((HealthKind::Tcp, retransmits, out_of_order)) => (retransmits, out_of_order), + Some((HealthKind::Quic, retries, version_negotiations)) => { + (0, retries.saturating_add(version_negotiations)) + } + Some((HealthKind::Transaction, retries, timeouts)) => (timeouts, retries), + None => (0, 0), + }; + let severity = if severe > 0 { + 2 + } else if warning > 0 { + 1 + } else { + 0 + }; + (severity, severe.saturating_add(warning), severe) +} + #[cfg(test)] mod tests { use super::*; - use crate::network::types::{Protocol, ProtocolState}; + use crate::network::types::{ + ApplicationProtocol, DnsInfo, DnsQueryType, DpiInfo, Protocol, ProtocolState, QuicInfo, + TcpState, + }; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; fn connection(port: u16) -> Connection { @@ -119,6 +146,40 @@ mod tests { ) } + fn tcp_connection(port: u16) -> Connection { + Connection::new( + Protocol::Tcp, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), 443), + ProtocolState::Tcp(TcpState::Established), + ) + } + + fn dns_connection(port: u16) -> Connection { + let mut conn = connection(port); + conn.dpi_info = Some(DpiInfo { + application: ApplicationProtocol::Dns(DnsInfo { + query_name: Some("example.com".to_string()), + query_type: Some(DnsQueryType::A), + response_ips: Vec::new(), + is_response: false, + txid: 1, + rcode: None, + nodata: None, + }), + }); + conn.protocol_health.request_observed = true; + conn + } + + fn quic_connection(port: u16) -> Connection { + let mut conn = connection(port); + conn.dpi_info = Some(DpiInfo { + application: ApplicationProtocol::Quic(Box::new(QuicInfo::new(1))), + }); + conn + } + #[test] fn historic_cached_rate_does_not_affect_bandwidth_sort() { let mut live = connection(40_000); @@ -153,4 +214,52 @@ mod tests { .collect(); assert_eq!(names, ["curl", "Safari", "WindowServer"]); } + + #[test] + fn health_sort_puts_most_affected_connections_first() { + let healthy = tcp_connection(40_000); + let mut out_of_order = tcp_connection(40_001); + out_of_order + .tcp_analytics + .as_mut() + .unwrap() + .out_of_order_count = 2; + let mut retransmits = tcp_connection(40_002); + retransmits.tcp_analytics.as_mut().unwrap().retransmit_count = 3; + + let mut connections = vec![healthy, out_of_order, retransmits]; + sort_connections(&mut connections, SortColumn::Health, false); + + let event_counts: Vec<_> = connections + .iter() + .map(|connection| { + connection.tcp_analytics.as_ref().map_or(0, |analytics| { + analytics.retransmit_count + analytics.out_of_order_count + }) + }) + .collect(); + assert_eq!(event_counts, [3, 2, 0]); + } + + #[test] + fn health_sort_uses_protocol_aware_severity_tiers() { + let healthy = connection(40_000); + let mut retries = dns_connection(40_001); + retries.protocol_health.request_retry_count = 4; + let mut quic = quic_connection(40_002); + quic.protocol_health.quic_retry_count = 6; + let mut timeout = dns_connection(40_003); + timeout.protocol_health.request_timeout_count = 1; + + let mut connections = vec![healthy, retries, quic, timeout]; + sort_connections(&mut connections, SortColumn::Health, false); + + let ports: Vec<_> = connections + .iter() + .map(|connection| connection.local_addr.port()) + .collect(); + assert_eq!(ports, [40_003, 40_002, 40_001, 40_000]); + assert_eq!(health_sort_key(&connections[0]), (2, 1, 1)); + assert_eq!(health_sort_key(&connections[1]), (1, 6, 0)); + } } diff --git a/src/ui/state.rs b/src/ui/state.rs index 4f1e06d4..8bc79d2b 100644 --- a/src/ui/state.rs +++ b/src/ui/state.rs @@ -179,7 +179,8 @@ pub enum SortColumn { Application, Service, State, - Rtt, // Best available TCP, QUIC handshake, or ICMP echo RTT + Rtt, // Best available TCP, QUIC handshake, or ICMP echo RTT + Health, // Protocol-aware observable health signals } impl SortColumn { @@ -206,7 +207,8 @@ impl SortColumn { Self::Service => Self::Application, // Column 6: App (proto·application) Self::Application => Self::State, // Column 7: State Self::State => Self::Rtt, // Column 8: RTT - Self::Rtt => Self::BandwidthTotal, // Column 9: ↓Rx/Tx↑ (combined total) + Self::Rtt => Self::Health, // Column 9: Health + Self::Health => Self::BandwidthTotal, // Column 10: ↓Rx/Tx↑ (combined total) Self::BandwidthTotal => Self::CreatedAt, // Back to default } } @@ -218,6 +220,8 @@ impl SortColumn { Self::BandwidthTotal => false, // Slowest connections first - latency problems surface on top Self::Rtt => false, + // Most severe observable health signals first + Self::Health => false, // Ascending by default - alphabetical or chronological Self::Process => true, @@ -244,6 +248,7 @@ impl SortColumn { Self::Service => "Service", Self::State => "State", Self::Rtt => "RTT", + Self::Health => "Health", } } } diff --git a/src/ui/tabs/details.rs b/src/ui/tabs/details.rs index 5a321e0d..10d34332 100644 --- a/src/ui/tabs/details.rs +++ b/src/ui/tabs/details.rs @@ -43,8 +43,8 @@ use crate::ui::{ }; /// Padded width for detail labels so values line up vertically. -/// Sized for the longest expected label ("Out-of-Order Packets" = 20 chars) -/// plus 2 chars of breathing room before the value column. +/// Sized for the longest expected label ("Ver. Negotiations (V)" = 21 chars) +/// plus breathing room before the value column. pub(in crate::ui) const DETAIL_LABEL_WIDTH: usize = 22; /// Below this terminal width the Details info panes collapse back to a @@ -254,6 +254,24 @@ impl<'a> DetailsBuilder<'a> { } } +fn request_health_fields(details: &mut DetailsBuilder<'_>, conn: &Connection) { + let display = |count: u64| { + if conn.protocol_health.request_observed { + count.to_string() + } else { + NONE_PLACEHOLDER.to_string() + } + }; + details.field( + "Request Retries (R)", + display(conn.protocol_health.request_retry_count), + ); + details.field( + "Request Timeouts (T)", + display(conn.protocol_health.request_timeout_count), + ); +} + #[cfg(unix)] static USER_NAMES: OnceLock>>> = OnceLock::new(); #[cfg(unix)] @@ -1674,11 +1692,13 @@ pub(in crate::ui) fn draw_connection_details( } else { details.field("Last Response Code", NONE_PLACEHOLDER.to_string()); } + request_health_fields(&mut details, conn); // Footnote style matching the QUIC branch below. details.plain_line(Line::from("")); details.note("Timed by pairing query and response IDs"); } else if llmnr_info.is_some() { details.rtt_field("LLMNR Response Time", conn.llmnr_response_time); + request_health_fields(&mut details, conn); details.plain_line(Line::from("")); details.note("First response paired by transaction ID"); } else if let Some(netbios) = netbios_info { @@ -1697,18 +1717,21 @@ pub(in crate::ui) fn draw_connection_details( } else { details.field("Last Response Status", NONE_PLACEHOLDER.to_string()); } + request_health_fields(&mut details, conn); details.plain_line(Line::from("")); details.note("Timed by pairing request and response IDs"); } else if stun_info.is_some() { // Method and class live in the Application card; this card keeps // only the measured outcome. details.rtt_field("STUN RTT", conn.stun_rtt); + request_health_fields(&mut details, conn); details.plain_line(Line::from("")); details.note("Paired by 96-bit transaction ID"); } else if ntp_info.is_some() { // Stratum lives in the Application card; this card keeps only the // measured outcome. details.rtt_field("NTP RTT", conn.ntp_rtt); + request_health_fields(&mut details, conn); details.plain_line(Line::from("")); details.note("Paired by originate timestamp echo"); } else if let Some(sequence) = icmp_echo_sequence { @@ -1733,6 +1756,16 @@ pub(in crate::ui) fn draw_connection_details( details.rtt_field("Initial RTT", conn.initial_rtt); } if let Some(quic) = quic_info { + details.field( + "Retry Packets (R)", + conn.protocol_health.quic_retry_count.to_string(), + ); + details.field( + "Ver. Negotiations (V)", + conn.protocol_health + .quic_version_negotiation_count + .to_string(), + ); details.field( "Idle Timeout", quic.idle_timeout @@ -1749,18 +1782,15 @@ pub(in crate::ui) fn draw_connection_details( // Separated from the fields above so it reads as a footnote on the // card rather than another value that failed to resolve. details.plain_line(Line::from("")); - details.note("Loss counters are encrypted in QUIC"); + details.note("1-RTT loss counters are encrypted in QUIC"); } else if conn.protocol == Protocol::Tcp { let counters = conn.tcp_analytics.as_ref(); // Live RTT: EWMA over data-segment round trips, updated for the whole // life of the connection (unlike the one-shot handshake RTT above). details.rtt_field("Live RTT", counters.and_then(|a| a.smoothed_rtt)); for (label, value) in [ - ("TCP Retransmits", counters.map(|a| a.retransmit_count)), - ( - "Out-of-Order Packets", - counters.map(|a| a.out_of_order_count), - ), + ("TCP Retransmits (R)", counters.map(|a| a.retransmit_count)), + ("Out-of-Order (O)", counters.map(|a| a.out_of_order_count)), ("Duplicate ACKs", counters.map(|a| a.duplicate_ack_count)), ( "Fast Retransmits", diff --git a/src/ui/tabs/help.rs b/src/ui/tabs/help.rs index 0f3a210b..c4fc2502 100644 --- a/src/ui/tabs/help.rs +++ b/src/ui/tabs/help.rs @@ -194,12 +194,25 @@ const OVERVIEW_MOUSE: &[HelpRow] = &[ ("Scroll wheel", "Navigate the connection list"), ]; -const OVERVIEW_DISPLAY: &[HelpRow] = &[ +// Shown for both Overview and Details: the Details tab keeps the +// connection table (and its Health column) above the cards. +const CONNECTION_DISPLAY: &[HelpRow] = &[ ("White", "Active connection"), ("Yellow to red", "Connection approaching its timeout"), ("Gray", "Historic closed connection"), ("~name", "Hostname inferred from an observed DNS response"), ("App column", "Application protocol, SNI, or HTTP Host"), + ( + "R3/O1", + "Health for TCP: retransmits and out-of-order packets", + ), + ( + "R1/V0", + "Health for QUIC: Retry and Version Negotiation packets", + ), + ("R2/T1", "Health for UDP requests: retries and timeouts"), + ("ok, -", "Healthy connection, or an ungraded protocol"), + ("R+", "Counts of 10 or more; Details has the exact numbers"), ]; const DETAILS_KEYS: &[HelpRow] = &[ @@ -337,12 +350,13 @@ fn help_lines(context: HelpContext) -> Vec> { HelpContext::Overview => { push_section(&mut lines, "Connection Navigation", CONNECTION_NAV_KEYS); push_section(&mut lines, "Overview Actions", OVERVIEW_KEYS); - push_section(&mut lines, "Connection Display", OVERVIEW_DISPLAY); + push_section(&mut lines, "Connection Display", CONNECTION_DISPLAY); push_section(&mut lines, "Filter Examples", FILTER_EXAMPLES); push_section(&mut lines, "Mouse", OVERVIEW_MOUSE); } HelpContext::Details => { push_section(&mut lines, "Details Actions", DETAILS_KEYS); + push_section(&mut lines, "Connection Display", CONNECTION_DISPLAY); push_section(&mut lines, "Mouse", DETAILS_MOUSE); } HelpContext::Activity => { @@ -494,8 +508,10 @@ mod tests { }; let text = plain_text(&state); assert!(text.contains("Details Actions")); + // The connection table (and its Health column) stays visible on + // Details, so its display legend is shared with Overview. + assert!(text.contains("Connection Display")); assert!(!text.contains("Filter Examples")); - assert!(!text.contains("Connection Display")); assert!(!text.contains("process grouping")); }