network: per-peer connection badge (direct/relay, RTT, loss, bitrate)

Answer "am I actually P2P right now?" per peer. A 1 Hz session task
snapshots the selected QUIC path of every live audio connection
(IrohTransport::connection_stats), core::connstats::derive turns
consecutive snapshots into RTT/loss/bitrate (path switches and counter
resets invalidate the rate window), and the peer card shows a
Direct/Relay badge with a hover tooltip for address, loss, and up/down
bitrate. No new dependencies, no wire change.

Loopback-integration-tested against real iroh endpoints; not yet
field-verified on a 2-machine call (FEATURES.md row marked 🧪).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 15:15:22 -04:00
co-authored by Claude Fable 5
parent 99a4a336ad
commit d2432740c1
8 changed files with 494 additions and 0 deletions
+121
View File
@@ -869,6 +869,10 @@ pub struct AppState {
game_override: GameOverrideChoice,
peers: HashMap<EndpointId, PeerState>,
audio_levels: HashMap<EndpointId, f32>,
/// Latest per-peer connection transparency info (direct/relay, RTT, window
/// loss/bitrate), replaced wholesale by each `UiEvent::ConnectionStats`
/// (~1/sec). A peer with no entry has no live audio link right now.
conn_stats: HashMap<EndpointId, crate::core::connstats::PeerConnInfo>,
/// Peers we've locally muted (their audio isn't mixed into our output).
locally_muted: HashSet<EndpointId>,
/// When we joined the current room, for the in-room call-duration timer.
@@ -1046,6 +1050,7 @@ impl AppState {
self.music_prefetch_inflight = None;
self.peers.clear();
self.audio_levels.clear();
self.conn_stats.clear();
self.locally_muted.clear();
self.chat_messages.clear();
self.chat_input.clear();
@@ -1209,6 +1214,7 @@ impl Default for AppState {
game_override: GameOverrideChoice::Auto,
peers: HashMap::new(),
audio_levels: HashMap::new(),
conn_stats: HashMap::new(),
locally_muted: HashSet::new(),
call_started: None,
recording: false,
@@ -1686,6 +1692,37 @@ fn pan_label(pan: f32) -> String {
}
}
/// Connection badge text on the peer card: path type + RTT ("Direct · 12 ms").
fn conn_badge_label(info: &crate::core::connstats::PeerConnInfo) -> String {
let kind = if info.relay { "Relay" } else { "Direct" };
format!("{kind} · {} ms", info.rtt_ms)
}
/// First tooltip line: path type + remote address ("Direct (1.2.3.4:5)" /
/// "Relay (https://relay.example./)").
fn conn_tooltip_path(info: &crate::core::connstats::PeerConnInfo) -> String {
let kind = if info.relay { "Relay" } else { "Direct" };
format!("{kind} ({})", info.remote_addr)
}
/// Loss line for the tooltip. `None` (first poll / idle window) reads as clean.
fn conn_loss_label(loss_pct: Option<f32>) -> String {
match loss_pct {
Some(pct) => format!("Loss {:.1}% (last second)", pct.clamp(0.0, 100.0)),
None => "Loss — (last second)".to_string(),
}
}
/// One direction of the bitrate line ("32 kbps", "1.5 Mbps", or "—" until a
/// full poll window has elapsed on the current path).
fn conn_rate_label(kbps: Option<f32>) -> String {
match kbps {
None => "".to_string(),
Some(k) if k >= 1000.0 => format!("{:.1} Mbps", k / 1000.0),
Some(k) => format!("{k:.0} kbps"),
}
}
fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
match message {
AppMessage::NicknameChanged(val) => {
@@ -1934,6 +1971,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.audio_levels.insert(id, val);
}
}
UiEvent::ConnectionStats(infos) => {
// Full replacement: a peer missing from this round has no
// live link, so its (stale) badge must go away too.
state.conn_stats = infos.into_iter().collect();
}
UiEvent::MicLevel(level) => {
state.mic_level = level;
}
@@ -6192,6 +6234,41 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
name_col = name_col
.push(text(format!("Playing {game}")).size(11).color(color_blue));
}
// Connection-transparency badge: path type + RTT, with
// the full story (address, loss, bitrate) on hover.
// Rendered only while the audio link is live — the
// Connecting/Reconnecting indicator covers the rest.
if let Some(info) = state.conn_stats.get(peer_id) {
let dot_color = if info.relay { color_yellow } else { color_green };
let badge = row![
text("").size(9).color(dot_color),
text(conn_badge_label(info)).size(11).color(color_subtext),
]
.spacing(4)
.align_y(iced::alignment::Vertical::Center);
let detail = column![
text(conn_tooltip_path(info)).size(11).color(color_text),
text(conn_loss_label(info.loss_pct)).size(11).color(color_subtext),
text(format!(
"↑ {} ↓ {}",
conn_rate_label(info.up_kbps),
conn_rate_label(info.down_kbps)
))
.size(11)
.color(color_subtext),
]
.spacing(2);
name_col = name_col.push(
tooltip(
badge,
container(detail)
.padding(8)
.style(c_style(color_crust, color_surface, 6.0)),
iced::widget::tooltip::Position::Bottom,
)
.gap(6),
);
}
name_col
},
add_friend_el,
@@ -9100,6 +9177,17 @@ mod tests {
state.invalid_audio.insert(attachment_id);
state.connecting.insert(peer);
state.ever_connected.insert(peer);
state.conn_stats.insert(
peer,
crate::core::connstats::PeerConnInfo {
relay: false,
remote_addr: "1.2.3.4:5".to_string(),
rtt_ms: 12,
loss_pct: None,
up_kbps: None,
down_kbps: None,
},
);
state.recording = true;
state.recording_started = Some(now);
state.call_started = Some(now);
@@ -9139,6 +9227,7 @@ mod tests {
assert!(state.invalid_audio.is_empty());
assert!(state.connecting.is_empty());
assert!(state.ever_connected.is_empty());
assert!(state.conn_stats.is_empty());
assert!(!state.recording);
assert!(state.recording_started.is_none());
assert!(state.call_started.is_none());
@@ -9786,6 +9875,38 @@ mod tests {
assert!(ch.is_finite() && ch >= CHAT_MIN_H);
}
#[test]
fn conn_badge_and_tooltip_labels() {
use super::{conn_badge_label, conn_loss_label, conn_rate_label, conn_tooltip_path};
let direct = crate::core::connstats::PeerConnInfo {
relay: false,
remote_addr: "192.168.1.7:53340".to_string(),
rtt_ms: 12,
loss_pct: Some(0.44),
up_kbps: Some(32.4),
down_kbps: None,
};
assert_eq!(conn_badge_label(&direct), "Direct · 12 ms");
assert_eq!(conn_tooltip_path(&direct), "Direct (192.168.1.7:53340)");
let relay = crate::core::connstats::PeerConnInfo {
relay: true,
remote_addr: "https://relay.example./".to_string(),
..direct.clone()
};
assert_eq!(conn_badge_label(&relay), "Relay · 12 ms");
assert_eq!(conn_tooltip_path(&relay), "Relay (https://relay.example./)");
assert_eq!(conn_loss_label(Some(0.44)), "Loss 0.4% (last second)");
// Out-of-range inputs clamp instead of reading nonsense.
assert_eq!(conn_loss_label(Some(250.0)), "Loss 100.0% (last second)");
assert_eq!(conn_loss_label(None), "Loss — (last second)");
assert_eq!(conn_rate_label(Some(32.4)), "32 kbps");
assert_eq!(conn_rate_label(Some(1500.0)), "1.5 Mbps");
assert_eq!(conn_rate_label(None), "");
}
#[test]
fn controls_and_drawer_width_clamps() {
use super::{CHAT_MIN_W, CONTROLS_MIN_W, clamp_chat_drawer_width, clamp_controls_width};