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"));
}