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:
+121
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user