feat: overhaul radio reconnection, TUI layout, DHT strategy, and signal handling

Radio reconnection:
- Add connect_mux_auto() to donglora-client — mux-only connect that never
  silently falls back to USB serial (matches Python client's sticky mux)
- Fix codec.rs read_frame() to handle WouldBlock alongside TimedOut
- Add liveness ping (2s) to detect dead mux connections
- Treat transmit errors as fatal (trigger reconnect)
- Faster backoff: 250ms initial, 5s max, resets after stable connection

TUI improvements:
- Redesign packet log: RF+Net columns show full packet path (RX→TX),
  hash column with dedup ellipsis, fixed-width columns throughout
- Move activity charts to right column (fills remaining width)
- 1-second sparkline buckets instead of 10-second
- Radio panel shows "(disconnected)" with red border when mux is down
- Header: status pills left, clock with TZ right-justified, Ctrl-C quits
- Local time via jiff crate with proper timezone abbreviation

DHT strategy:
- Alone: read every 3s, merge-publish every 30s (preserves existing peers)
- Connected: publish authoritative [self + neighbors], purging stale peers
- Publish immediately on first neighbor join

Signal handling:
- SIGINT and SIGTERM both trigger graceful shutdown via cancellation token
- All paths go through swarm.shutdown() to update DHT before exit
This commit is contained in:
swaits committed 2026-04-05 21:43:36 -06:00
1 parent 4508f317be
commit b4026ace34
7 files changed
+373 -175

No files matched your search

Generated
+51
View File
@@ -862,6 +862,7 @@ dependencies = [
"hex", "hex",
"iroh", "iroh",
"iroh-gossip", "iroh-gossip",
"jiff",
"mainline", "mainline",
"rand", "rand",
"ratatui", "ratatui",
@@ -1984,6 +1985,47 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
dependencies = [
"jiff-static",
"jiff-tzdb-platform",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
"windows-sys 0.61.2",
]
[[package]]
name = "jiff-static"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "jiff-tzdb"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076"
[[package]]
name = "jiff-tzdb-platform"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
dependencies = [
"jiff-tzdb",
]
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.94" version = "0.3.94"
@@ -2748,6 +2790,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "portable-atomic-util"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "portmapper" name = "portmapper"
version = "0.15.0" version = "0.15.0"
+1
View File
@@ -29,6 +29,7 @@ rand = "0.9"
hex = "0.4.3" hex = "0.4.3"
sha2 = "0.10" sha2 = "0.10"
tokio-util = { version = "0.7.18", features = ["rt"] } tokio-util = { version = "0.7.18", features = ["rt"] }
jiff = "0.2.23"
[lints.clippy] [lints.clippy]
unwrap_used = "deny" unwrap_used = "deny"
+91 -44
View File
@@ -361,12 +361,22 @@ fn spawn_broadcaster(
}); });
} }
/// Fast DHT interval while we have no neighbors. /// DHT poll interval while alone (read frequently, publish infrequently).
const DHT_FAST_INTERVAL_SECS: u64 = 5; const DHT_ALONE_READ_SECS: u64 = 3;
/// Periodically publish our peer list to the DHT and discover new peers. /// How often a lonely node publishes (merge-only, never destructive).
/// Slower than reads — publishing is heavier and a lonely node only adds itself.
const DHT_ALONE_PUBLISH_SECS: u64 = 30;
/// Periodically interact with the DHT to discover and share peers.
/// ///
/// Interval is adaptive: fast (5s) while alone, slow (5 min) once connected. /// Strategy:
/// - **Alone:** Read every 3s (cheap). Publish every 30s via merge — reads
/// existing data first, appends ourselves, writes back. Two fresh nodes
/// both doing this will eventually see each other. Never blindly overwrites.
/// - **Connected:** Read + publish on relaxed 5-min schedule. We have
/// valuable neighbor info to share.
/// - **Transition to connected:** Publish immediately.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn spawn_dht_heartbeat( fn spawn_dht_heartbeat(
my_id: PublicKey, my_id: PublicKey,
@@ -388,49 +398,82 @@ fn spawn_dht_heartbeat(
}; };
let pub_key_bytes = signing_key.verifying_key().to_bytes(); let pub_key_bytes = signing_key.verifying_key().to_bytes();
let mut was_alone = true;
let mut last_publish = Instant::now();
// Initial publish immediately. // Initial: read to discover existing peers, then merge-publish ourselves.
dht_read_and_join(&dht, &my_id, &pub_key_bytes, &salt, &gossip_sender, &state).await;
dht_publish(&dht, &my_id, &signing_key, &salt, &pub_key_bytes, &neighbors, &state).await; dht_publish(&dht, &my_id, &signing_key, &salt, &pub_key_bytes, &neighbors, &state).await;
loop { loop {
// Fast while alone, slow once we have neighbors.
let alone = neighbor_count.load(Ordering::Relaxed) == 0; let alone = neighbor_count.load(Ordering::Relaxed) == 0;
let base_secs = if alone {
DHT_FAST_INTERVAL_SECS // Transition: alone → connected. Publish immediately.
if was_alone && !alone {
info!("first neighbor joined — publishing to DHT");
dht_publish(&dht, &my_id, &signing_key, &salt, &pub_key_bytes, &neighbors, &state).await;
last_publish = Instant::now();
}
was_alone = alone;
// Sleep: fast reads while alone, relaxed when connected.
let sleep_dur = if alone {
Duration::from_secs(DHT_ALONE_READ_SECS)
} else { } else {
DHT_HEARTBEAT_SECS let jitter = rand::random::<u64>() % DHT_HEARTBEAT_JITTER_SECS.max(1);
Duration::from_secs(DHT_HEARTBEAT_SECS + jitter)
}; };
let max_jitter = if alone { 2 } else { DHT_HEARTBEAT_JITTER_SECS };
let jitter = rand::random::<u64>() % max_jitter.max(1);
let sleep_dur = Duration::from_secs(base_secs + jitter);
tokio::select! { tokio::select! {
() = tokio::time::sleep(sleep_dur) => {} () = tokio::time::sleep(sleep_dur) => {}
() = cancel.cancelled() => break, () = cancel.cancelled() => break,
} }
// GET: discover new peers and feed to gossip. // Always read — cheap, discovers peers to join.
if let Some(record) = dht.get_mutable_most_recent(&pub_key_bytes, Some(&salt)).await { dht_read_and_join(&dht, &my_id, &pub_key_bytes, &salt, &gossip_sender, &state).await;
let peers = decode_peer_list(record.value());
let new_peers: Vec<_> = peers
.into_iter()
.filter(|id| *id != my_id)
.collect();
if !new_peers.is_empty() {
info!("DHT discovered {} peer(s), feeding to gossip", new_peers.len());
if let Err(e) = gossip_sender.join_peers(new_peers).await {
debug!("join_peers error: {e:#}");
}
}
}
// PUT: merge-publish our info + neighbors + existing DHT peers. // Publish cadence depends on state.
dht_publish(&dht, &my_id, &signing_key, &salt, &pub_key_bytes, &neighbors, &state).await; let publish_interval = if alone {
Duration::from_secs(DHT_ALONE_PUBLISH_SECS)
} else {
Duration::from_secs(DHT_HEARTBEAT_SECS)
};
if last_publish.elapsed() >= publish_interval {
dht_publish(&dht, &my_id, &signing_key, &salt, &pub_key_bytes, &neighbors, &state).await;
last_publish = Instant::now();
}
} }
debug!("DHT heartbeat stopped"); debug!("DHT heartbeat stopped");
}); });
} }
/// Read the DHT and feed any discovered peers into the gossip swarm.
async fn dht_read_and_join(
dht: &AsyncDht,
my_id: &PublicKey,
pub_key_bytes: &[u8; 32],
salt: &[u8; 20],
gossip_sender: &iroh_gossip::api::GossipSender,
state: &Arc<Mutex<SwarmState>>,
) {
if let Some(record) = dht.get_mutable_most_recent(pub_key_bytes, Some(salt)).await {
let peers = decode_peer_list(record.value());
let new_peers: Vec<_> = peers
.into_iter()
.filter(|id| *id != *my_id)
.collect();
if !new_peers.is_empty() {
info!("DHT discovered {} peer(s), feeding to gossip", new_peers.len());
if let Err(e) = gossip_sender.join_peers(new_peers).await {
debug!("join_peers error: {e:#}");
}
}
if let Ok(mut s) = state.lock() {
s.dht_status = DhtStatus::Ready;
}
}
}
async fn dht_publish( async fn dht_publish(
dht: &AsyncDht, dht: &AsyncDht,
my_id: &PublicKey, my_id: &PublicKey,
@@ -440,30 +483,34 @@ async fn dht_publish(
neighbors: &Arc<Mutex<HashSet<PublicKey>>>, neighbors: &Arc<Mutex<HashSet<PublicKey>>>,
state: &Arc<Mutex<SwarmState>>, state: &Arc<Mutex<SwarmState>>,
) { ) {
// Merge: ourselves + live neighbors + existing DHT peers (preserve others' data). let has_neighbors = neighbors.lock().map(|n| !n.is_empty()).unwrap_or(false);
let mut seen = HashSet::new(); let mut seen = HashSet::new();
let mut peer_list: Vec<[u8; 32]> = Vec::with_capacity(MAX_DHT_PEERS); let mut peer_list: Vec<[u8; 32]> = Vec::with_capacity(MAX_DHT_PEERS);
// 1. Always include ourselves first. // Always include ourselves.
peer_list.push(*my_id.as_bytes()); peer_list.push(*my_id.as_bytes());
seen.insert(*my_id.as_bytes()); seen.insert(*my_id.as_bytes());
// 2. Add our live neighbors. if has_neighbors {
if let Ok(n) = neighbors.lock() { // Connected: publish authoritative data — self + live neighbors only.
for id in n.iter() { // This purges dead/stale peers from the DHT. We know who's alive
if seen.insert(*id.as_bytes()) && peer_list.len() < MAX_DHT_PEERS { // because they're our active gossip neighbors.
peer_list.push(*id.as_bytes()); if let Ok(n) = neighbors.lock() {
for id in n.iter() {
if seen.insert(*id.as_bytes()) && peer_list.len() < MAX_DHT_PEERS {
peer_list.push(*id.as_bytes());
}
} }
} }
} } else {
// Alone: merge-publish — read existing DHT data first, add ourselves,
// 3. Preserve existing DHT peers we don't already know about. // preserve other peers. We don't know who's alive so we can't purge.
if peer_list.len() < MAX_DHT_PEERS if let Some(record) = dht.get_mutable_most_recent(pub_key_bytes, Some(salt)).await {
&& let Some(record) = dht.get_mutable_most_recent(pub_key_bytes, Some(salt)).await for existing in decode_peer_list(record.value()) {
{ if seen.insert(*existing.as_bytes()) && peer_list.len() < MAX_DHT_PEERS {
for existing in decode_peer_list(record.value()) { peer_list.push(*existing.as_bytes());
if seen.insert(*existing.as_bytes()) && peer_list.len() < MAX_DHT_PEERS { }
peer_list.push(*existing.as_bytes());
} }
} }
} }
+17 -10
View File
@@ -142,6 +142,7 @@ async fn main() -> Result<()> {
requested: radio_config, requested: radio_config,
source: radio::ConfigSource::Ours, source: radio::ConfigSource::Ours,
device: String::new(), device: String::new(),
connected: false,
}, },
); );
@@ -175,18 +176,25 @@ async fn main() -> Result<()> {
router_cancel.cancel(); router_cancel.cancel();
}); });
// Spawn a task that cancels on ctrl-c or SIGTERM.
{
let cancel = cancel.clone();
tokio::spawn(async move {
let mut sigterm = tokio::signal::unix::signal(
tokio::signal::unix::SignalKind::terminate(),
).expect("failed to register SIGTERM handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => info!("received SIGINT"),
_ = sigterm.recv() => info!("received SIGTERM"),
}
cancel.cancel();
});
}
// Run TUI or headless. // Run TUI or headless.
if cli.log_only { if cli.log_only {
info!("running in headless mode"); info!("running in headless mode");
tokio::select! { cancel.cancelled().await;
() = cancel.cancelled() => {},
result = tokio::signal::ctrl_c() => {
if let Err(e) = result {
tracing::error!("signal handler error: {e}");
}
info!("received ctrl-c, shutting down");
}
}
} else { } else {
let terminal = tui::run( let terminal = tui::run(
config_watch_rx, config_watch_rx,
@@ -210,7 +218,6 @@ async fn main() -> Result<()> {
} }
// Shutdown (headless path). // Shutdown (headless path).
cancel.cancel();
swarm.shutdown().await; swarm.shutdown().await;
info!("donglora-bridge stopped"); info!("donglora-bridge stopped");
+40 -4
View File
@@ -50,10 +50,12 @@ fn radio_loop(
event_tx: mpsc::Sender<RadioEvent>, event_tx: mpsc::Sender<RadioEvent>,
mut tx_recv: mpsc::Receiver<Vec<u8>>, mut tx_recv: mpsc::Receiver<Vec<u8>>,
) { ) {
let mut backoff = Duration::from_secs(1); let initial_backoff = Duration::from_millis(250);
let max_backoff = Duration::from_secs(30); let max_backoff = Duration::from_secs(5);
let mut backoff = initial_backoff;
loop { loop {
let started = std::time::Instant::now();
match connect_and_run(&config, &port, &event_tx, &mut tx_recv) { match connect_and_run(&config, &port, &event_tx, &mut tx_recv) {
Ok(()) => { Ok(()) => {
info!("radio thread exiting"); info!("radio thread exiting");
@@ -62,6 +64,13 @@ fn radio_loop(
Err(e) => { Err(e) => {
error!("radio error: {e:#}"); error!("radio error: {e:#}");
let _ = event_tx.blocking_send(RadioEvent::Disconnected); let _ = event_tx.blocking_send(RadioEvent::Disconnected);
// If we were connected for a while, reset backoff so
// the next reconnect attempt is fast.
if started.elapsed() > Duration::from_secs(5) {
backoff = initial_backoff;
}
info!("reconnecting in {backoff:?}"); info!("reconnecting in {backoff:?}");
std::thread::sleep(backoff); std::thread::sleep(backoff);
backoff = (backoff * 2).min(max_backoff); backoff = (backoff * 2).min(max_backoff);
@@ -85,7 +94,7 @@ fn connect_and_run(
let c = donglora_client::connect(Some(port), timeout)?; let c = donglora_client::connect(Some(port), timeout)?;
(c, shorten_path(port)) (c, shorten_path(port))
} else { } else {
let c = donglora_client::connect_default()?; let c = donglora_client::connect_mux_auto(timeout)?;
(c, "mux".to_string()) (c, "mux".to_string())
}; };
info!("[radio] transport connected: {device}"); info!("[radio] transport connected: {device}");
@@ -128,18 +137,26 @@ fn connect_and_run(
.set_timeout(Duration::from_millis(100))?; .set_timeout(Duration::from_millis(100))?;
info!("[radio] entering main loop"); info!("[radio] entering main loop");
// Liveness: ping the mux periodically when idle to detect a dead connection.
// Without this, a killed mux causes recv() to silently return Ok(None) forever.
let liveness_interval = Duration::from_secs(2);
let mut last_activity = std::time::Instant::now();
loop { loop {
// Check for TX requests (non-blocking). // Check for TX requests (non-blocking).
while let Ok(payload) = tx_recv.try_recv() { while let Ok(payload) = tx_recv.try_recv() {
debug!("TX {} bytes", payload.len()); debug!("TX {} bytes", payload.len());
if let Err(e) = client.transmit(&payload, None) { if let Err(e) = client.transmit(&payload, None) {
warn!("transmit error: {e:#}"); warn!("transmit error: {e:#}");
return Err(e);
} }
last_activity = std::time::Instant::now();
} }
// Check for RX packets. // Check for RX packets.
match client.recv() { match client.recv() {
Ok(Some(Response::RxPacket { rssi, snr, payload })) => { Ok(Some(Response::RxPacket { rssi, snr, payload })) => {
last_activity = std::time::Instant::now();
let grade = snr_grade(snr, active_config.sf); let grade = snr_grade(snr, active_config.sf);
if grade.should_forward() { if grade.should_forward() {
debug!( debug!(
@@ -157,12 +174,31 @@ fn connect_and_run(
); );
} }
} }
Ok(Some(_)) => {} Ok(Some(_)) => {
last_activity = std::time::Instant::now();
}
Ok(None) => {} Ok(None) => {}
Err(e) if is_timeout_error(&e) => {} Err(e) if is_timeout_error(&e) => {}
Err(e) => return Err(e), Err(e) => return Err(e),
} }
// Liveness check: if no real activity for a while, ping the mux.
if last_activity.elapsed() >= liveness_interval {
// Temporarily extend the timeout for the ping command.
let _ = client.transport_mut().set_timeout(Duration::from_secs(2));
match client.ping() {
Ok(()) => {
last_activity = std::time::Instant::now();
}
Err(e) => {
warn!("liveness ping failed: {e:#}");
return Err(e);
}
}
// Restore the short timeout.
let _ = client.transport_mut().set_timeout(Duration::from_millis(100));
}
if event_tx.is_closed() { if event_tx.is_closed() {
return Ok(()); return Ok(());
} }
+30 -36
View File
@@ -18,12 +18,14 @@ pub struct RadioConfigInfo {
pub requested: donglora_client::RadioConfig, pub requested: donglora_client::RadioConfig,
pub source: ConfigSource, pub source: ConfigSource,
pub device: String, pub device: String,
pub connected: bool,
} }
/// Packet event for the TUI log. /// Packet event for the TUI log.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PacketLogEntry { pub struct PacketLogEntry {
pub timestamp: Instant, pub timestamp: Instant,
pub hash: [u8; 32],
pub direction: PacketDirection, pub direction: PacketDirection,
pub size: usize, pub size: usize,
pub snr: Option<i16>, pub snr: Option<i16>,
@@ -31,37 +33,32 @@ pub struct PacketLogEntry {
pub action: PacketAction, pub action: PacketAction,
} }
/// Which side the packet arrived from.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum PacketDirection { pub enum PacketDirection {
RadioRx, /// Heard on local radio → forwarded to gossip swarm.
RadioTx, RadioIn,
GossipRx, /// Received from gossip swarm → queued for radio TX.
GossipIn,
} }
/// What happened to the packet.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum PacketAction { pub enum PacketAction {
Forwarded, /// Successfully bridged (radio→gossip or gossip→radio).
Transmitted, Bridged,
/// Dropped: duplicate payload.
DroppedDedup, DroppedDedup,
/// Dropped: TX queue full.
DroppedQueueFull, DroppedQueueFull,
/// Dropped: rate limiter.
DroppedRateLimit, DroppedRateLimit,
} }
impl std::fmt::Display for PacketDirection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RadioRx => write!(f, "RX "),
Self::RadioTx => write!(f, "TX "),
Self::GossipRx => write!(f, "GSP"),
}
}
}
impl std::fmt::Display for PacketAction { impl std::fmt::Display for PacketAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::Forwarded => write!(f, "FWD"), Self::Bridged => write!(f, ""),
Self::Transmitted => write!(f, "TX"),
Self::DroppedDedup => write!(f, "DUP"), Self::DroppedDedup => write!(f, "DUP"),
Self::DroppedQueueFull => write!(f, "FULL"), Self::DroppedQueueFull => write!(f, "FULL"),
Self::DroppedRateLimit => write!(f, "RATE"), Self::DroppedRateLimit => write!(f, "RATE"),
@@ -188,7 +185,8 @@ pub async fn run(
debug!("radio RX dedup suppressed ({} bytes)", pkt.payload.len()); debug!("radio RX dedup suppressed ({} bytes)", pkt.payload.len());
let _ = log_tx.send(PacketLogEntry { let _ = log_tx.send(PacketLogEntry {
timestamp: Instant::now(), timestamp: Instant::now(),
direction: PacketDirection::RadioRx, hash,
direction: PacketDirection::RadioIn,
size: pkt.payload.len(), size: pkt.payload.len(),
snr: Some(pkt.snr), snr: Some(pkt.snr),
rssi: Some(pkt.rssi), rssi: Some(pkt.rssi),
@@ -204,11 +202,12 @@ pub async fn run(
let _ = log_tx.send(PacketLogEntry { let _ = log_tx.send(PacketLogEntry {
timestamp: Instant::now(), timestamp: Instant::now(),
direction: PacketDirection::RadioRx, hash,
direction: PacketDirection::RadioIn,
size: pkt.payload.len(), size: pkt.payload.len(),
snr: Some(pkt.snr), snr: Some(pkt.snr),
rssi: Some(pkt.rssi), rssi: Some(pkt.rssi),
action: PacketAction::Forwarded, action: PacketAction::Bridged,
}).await; }).await;
} }
RadioEvent::Connected(active_config, source, device) => { RadioEvent::Connected(active_config, source, device) => {
@@ -218,10 +217,12 @@ pub async fn run(
requested: requested_radio_config, requested: requested_radio_config,
source, source,
device, device,
connected: true,
}); });
} }
RadioEvent::Disconnected => { RadioEvent::Disconnected => {
stats.radio_connected.store(0, Ordering::Relaxed); stats.radio_connected.store(0, Ordering::Relaxed);
config_tx.send_modify(|info| info.connected = false);
} }
} }
} }
@@ -239,7 +240,8 @@ pub async fn run(
debug!("gossip RX dedup suppressed ({} bytes)", frame.payload.len()); debug!("gossip RX dedup suppressed ({} bytes)", frame.payload.len());
let _ = log_tx.send(PacketLogEntry { let _ = log_tx.send(PacketLogEntry {
timestamp: Instant::now(), timestamp: Instant::now(),
direction: PacketDirection::GossipRx, hash,
direction: PacketDirection::GossipIn,
size: frame.payload.len(), size: frame.payload.len(),
snr: Some(frame.snr), snr: Some(frame.snr),
rssi: Some(frame.rssi), rssi: Some(frame.rssi),
@@ -254,7 +256,8 @@ pub async fn run(
debug!("rate limited ({} bytes)", frame.payload.len()); debug!("rate limited ({} bytes)", frame.payload.len());
let _ = log_tx.send(PacketLogEntry { let _ = log_tx.send(PacketLogEntry {
timestamp: Instant::now(), timestamp: Instant::now(),
direction: PacketDirection::GossipRx, hash,
direction: PacketDirection::GossipIn,
size: frame.payload.len(), size: frame.payload.len(),
snr: Some(frame.snr), snr: Some(frame.snr),
rssi: Some(frame.rssi), rssi: Some(frame.rssi),
@@ -269,7 +272,8 @@ pub async fn run(
warn!("TX queue full, dropping packet ({} bytes)", frame.payload.len()); warn!("TX queue full, dropping packet ({} bytes)", frame.payload.len());
let _ = log_tx.send(PacketLogEntry { let _ = log_tx.send(PacketLogEntry {
timestamp: Instant::now(), timestamp: Instant::now(),
direction: PacketDirection::GossipRx, hash,
direction: PacketDirection::GossipIn,
size: frame.payload.len(), size: frame.payload.len(),
snr: Some(frame.snr), snr: Some(frame.snr),
rssi: Some(frame.rssi), rssi: Some(frame.rssi),
@@ -281,11 +285,12 @@ pub async fn run(
tx_queue.push_back(frame.payload.clone()); tx_queue.push_back(frame.payload.clone());
let _ = log_tx.send(PacketLogEntry { let _ = log_tx.send(PacketLogEntry {
timestamp: Instant::now(), timestamp: Instant::now(),
direction: PacketDirection::GossipRx, hash,
direction: PacketDirection::GossipIn,
size: frame.payload.len(), size: frame.payload.len(),
snr: Some(frame.snr), snr: Some(frame.snr),
rssi: Some(frame.rssi), rssi: Some(frame.rssi),
action: PacketAction::Transmitted, action: PacketAction::Bridged,
}).await; }).await;
} }
GossipEvent::NeighborChanged(count) => { GossipEvent::NeighborChanged(count) => {
@@ -297,18 +302,7 @@ pub async fn run(
// Drain TX queue to radio. // Drain TX queue to radio.
while let Some(payload) = tx_queue.pop_front() { while let Some(payload) = tx_queue.pop_front() {
let size = payload.len();
stats.radio_tx.fetch_add(1, Ordering::Relaxed); stats.radio_tx.fetch_add(1, Ordering::Relaxed);
let _ = log_tx
.send(PacketLogEntry {
timestamp: Instant::now(),
direction: PacketDirection::RadioTx,
size,
snr: None,
rssi: None,
action: PacketAction::Transmitted,
})
.await;
if radio_tx.send(payload).await.is_err() { if radio_tx.send(payload).await.is_err() {
break; break;
} }
+143 -81
View File
@@ -23,8 +23,8 @@ use crate::router::{PacketAction, PacketDirection, PacketLogEntry, RadioConfigIn
const TICK_RATE: Duration = Duration::from_millis(250); const TICK_RATE: Duration = Duration::from_millis(250);
const MAX_LOG_ENTRIES: usize = 200; const MAX_LOG_ENTRIES: usize = 200;
const BUCKET_SECS: u64 = 10; const BUCKET_SECS: u64 = 1;
const MAX_BUCKETS: usize = 60; const MAX_BUCKETS: usize = 300;
// ── Color palette (consistent across panels) ───────────────────── // ── Color palette (consistent across panels) ─────────────────────
@@ -202,7 +202,11 @@ async fn tui_loop(
if event::poll(timeout)? if event::poll(timeout)?
&& let Event::Key(key) = event::read()? && let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press && key.kind == KeyEventKind::Press
&& key.code == KeyCode::Char('q') && matches!(
(key.code, key.modifiers),
(KeyCode::Char('q'), _)
| (KeyCode::Char('c'), crossterm::event::KeyModifiers::CONTROL)
)
{ {
cancel.cancel(); cancel.cancel();
return Ok(()); return Ok(());
@@ -226,18 +230,22 @@ fn draw_ui(
start_time: Instant, start_time: Instant,
series: &TimeSeries, series: &TimeSeries,
) { ) {
let outer = Layout::default() // Header row + body.
let rows = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Percentage(65), Constraint::Percentage(35)]) .constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area); .split(area);
draw_header(frame, outer[0], stats); draw_header(frame, rows[0], stats);
// Body: 3 columns — left panels | packet log (fixed) | activity charts (fill).
// Packet log columns: Age=5 Hash=8 RF=4 Net=4 Size=6 RSSI=6 SNR=5 Act=5 = 43 + 2 border + 1 pad = 46
let cols = Layout::default() let cols = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Length(30), Constraint::Min(40)]) .constraints([Constraint::Length(30), Constraint::Length(46), Constraint::Min(10)])
.split(outer[1]); .split(rows[1]);
// Left column: Network, Radio, Stats stacked.
let left = Layout::default() let left = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
@@ -251,23 +259,35 @@ fn draw_ui(
draw_radio(frame, left[1], config_info); draw_radio(frame, left[1], config_info);
draw_stats(frame, left[2], stats, start_time); draw_stats(frame, left[2], stats, start_time);
draw_log(frame, cols[1], log_entries); draw_log(frame, cols[1], log_entries);
draw_activity(frame, outer[2], series); draw_activity(frame, cols[2], series);
} }
// ── Header ─────────────────────────────────────────────────────── // ── Header ───────────────────────────────────────────────────────
fn draw_header(frame: &mut ratatui::Frame, area: Rect, stats: &StatsSnapshot) { fn draw_header(frame: &mut ratatui::Frame, area: Rect, stats: &StatsSnapshot) {
let now = chrono_time(); let cols = Layout::default()
let line = Line::from(vec![ .direction(Direction::Horizontal)
Span::styled(" donglora-bridge ", Style::default().fg(C_TITLE).add_modifier(Modifier::BOLD)), .constraints([
Constraint::Length(14), // pills
Constraint::Min(0), // fill
])
.split(area);
// Left: status pills.
let pills = Line::from(vec![
Span::raw(" "),
status_pill("RADIO", stats.radio_connected), status_pill("RADIO", stats.radio_connected),
Span::raw(" "), Span::raw(" "),
status_pill("NET", stats.neighbor_count > 0), status_pill("NET", stats.neighbor_count > 0),
Span::raw(" "),
Span::styled(now, Style::default().fg(C_LABEL)),
Span::styled(" q=quit", Style::default().fg(C_LABEL)),
]); ]);
frame.render_widget(Paragraph::new(line), area); frame.render_widget(Paragraph::new(pills), cols[0]);
// Right: clock with timezone.
let clock = Line::from(Span::styled(
format!("{} ", chrono_time()),
Style::default().fg(C_LABEL),
));
frame.render_widget(Paragraph::new(clock).alignment(Alignment::Right), cols[1]);
} }
fn status_pill(label: &str, ok: bool) -> Span<'static> { fn status_pill(label: &str, ok: bool) -> Span<'static> {
@@ -307,11 +327,28 @@ fn draw_network(frame: &mut ratatui::Frame, area: Rect, state: &crate::gossip::S
// ── Radio panel ────────────────────────────────────────────────── // ── Radio panel ──────────────────────────────────────────────────
fn draw_radio(frame: &mut ratatui::Frame, area: Rect, config_info: &RadioConfigInfo) { fn draw_radio(frame: &mut ratatui::Frame, area: Rect, config_info: &RadioConfigInfo) {
let connected = config_info.connected;
let is_mux = matches!(config_info.source, ConfigSource::Mux); let is_mux = matches!(config_info.source, ConfigSource::Mux);
let border_color = if is_mux { C_RATE_DROP } else { C_BORDER };
let border_color = if !connected {
C_QUEUE_DROP
} else if is_mux {
C_RATE_DROP
} else {
C_BORDER
};
let title = if !connected {
" Radio (disconnected) "
} else if is_mux {
" Radio (mux) "
} else {
" Radio "
};
let block = Block::default() let block = Block::default()
.title(Span::styled( .title(Span::styled(
if is_mux { " Radio (mux) " } else { " Radio " }, title,
Style::default().fg(C_TITLE).add_modifier(Modifier::BOLD), Style::default().fg(C_TITLE).add_modifier(Modifier::BOLD),
)) ))
.borders(Borders::ALL) .borders(Borders::ALL)
@@ -330,15 +367,23 @@ fn draw_radio(frame: &mut ratatui::Frame, area: Rect, config_info: &RadioConfigI
let preamble = if a.preamble_len == 0 { 16 } else { a.preamble_len }; let preamble = if a.preamble_len == 0 { 16 } else { a.preamble_len };
let cad = if a.cad != 0 { "on" } else { "off" }; let cad = if a.cad != 0 { "on" } else { "off" };
// Dim values when disconnected; highlight mux mismatches when connected.
let val_color = if connected { C_ACCENT } else { C_LABEL };
let cf = |label: &str, value: String, matches: bool| -> Line<'static> { let cf = |label: &str, value: String, matches: bool| -> Line<'static> {
let color = if !is_mux || matches { C_ACCENT } else { C_RATE_DROP }; let color = if !connected {
let mark = if is_mux && !matches { " !" } else { "" }; C_LABEL
} else if !is_mux || matches {
C_ACCENT
} else {
C_RATE_DROP
};
let mark = if connected && is_mux && !matches { " !" } else { "" };
kv_line(label, &format!("{value}{mark}"), color) kv_line(label, &format!("{value}{mark}"), color)
}; };
let dev = if config_info.device.is_empty() { "-" } else { &config_info.device }; let dev = if config_info.device.is_empty() { "-" } else { &config_info.device };
let lines = vec![ let lines = vec![
kv_line("Device", dev, C_ACCENT), kv_line("Device", dev, val_color),
cf("Freq", format!("{freq_mhz:.3} MHz"), a.freq_hz == r.freq_hz), cf("Freq", format!("{freq_mhz:.3} MHz"), a.freq_hz == r.freq_hz),
cf("BW", bw_str.to_string(), a.bw == r.bw), cf("BW", bw_str.to_string(), a.bw == r.bw),
cf("SF", format!("{}", a.sf), a.sf == r.sf), cf("SF", format!("{}", a.sf), a.sf == r.sf),
@@ -392,41 +437,59 @@ fn draw_log(frame: &mut ratatui::Frame, area: Rect, entries: &[PacketLogEntry])
let start = entries.len().saturating_sub(data_height); let start = entries.len().saturating_sub(data_height);
let visible = &entries[start..]; let visible = &entries[start..];
// Column header with underline effect via bold. // Fixed column widths: Age=5 Hash=8 RF=4 Net=4 Size=6 RSSI=6 SNR=5 Act=5
// Every span (header and data) uses the same width per column.
let hdr = Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD);
let header = Line::from(vec![ let header = Line::from(vec![
Span::styled(" Age", Style::default().fg(C_LABEL).add_modifier(Modifier::BOLD)), Span::styled(format!("{:>5}", "Age"), hdr),
Span::styled(" RF", Style::default().fg(C_LABEL).add_modifier(Modifier::BOLD)), Span::styled(format!("{:>8}", "Hash"), hdr),
Span::styled(" Net", Style::default().fg(C_LABEL).add_modifier(Modifier::BOLD)), Span::styled(format!("{:>4}", "RF"), hdr),
Span::styled(" Size", Style::default().fg(C_LABEL).add_modifier(Modifier::BOLD)), Span::styled(format!("{:>4}", "Net"), hdr),
Span::styled(" RSSI", Style::default().fg(C_LABEL).add_modifier(Modifier::BOLD)), Span::styled(format!("{:>6}", "Size"), hdr),
Span::styled(" SNR", Style::default().fg(C_LABEL).add_modifier(Modifier::BOLD)), Span::styled(format!("{:>6}", "RSSI"), hdr),
Span::styled(" Act", Style::default().fg(C_LABEL).add_modifier(Modifier::BOLD)), Span::styled(format!("{:>5}", "SNR"), hdr),
Span::styled(format!("{:>5}", "Act"), hdr),
]); ]);
let mut lines = vec![header]; let mut lines = vec![header];
let mut prev_hash: Option<[u8; 32]> = None;
for e in visible { for e in visible {
let age = format_short_duration(e.timestamp.elapsed()); let age = format_short_duration(e.timestamp.elapsed());
let bridged = matches!(e.action, PacketAction::Bridged);
let dash = " ───"; // Show short hash, or ellipsis if same as previous line.
let hash_span = if prev_hash == Some(e.hash) {
Span::styled(format!("{:>8}", "··"), Style::default().fg(C_BORDER))
} else {
Span::styled(format!("{:>8}", hex::encode(&e.hash[..3])), Style::default().fg(C_LABEL))
};
prev_hash = Some(e.hash);
// Each row shows both columns. Bridged packets light up both sides.
// Dropped packets only show the input side.
let dash = Span::styled(format!("{:>4}", "──"), Style::default().fg(C_BORDER));
let (rf_col, net_col) = match e.direction { let (rf_col, net_col) = match e.direction {
PacketDirection::RadioRx => ( PacketDirection::RadioIn => (
Span::styled(" RX", Style::default().fg(C_RADIO_RX)), Span::styled(format!("{:>4}", "RX"), Style::default().fg(C_RADIO_RX)),
Span::styled(dash, Style::default().fg(C_BORDER)), if bridged {
Span::styled(format!("{:>4}", "TX"), Style::default().fg(C_NET_TX))
} else {
dash
},
), ),
PacketDirection::RadioTx => ( PacketDirection::GossipIn => (
Span::styled(" TX", Style::default().fg(C_RADIO_TX)), if bridged {
Span::styled(dash, Style::default().fg(C_BORDER)), Span::styled(format!("{:>4}", "TX"), Style::default().fg(C_RADIO_TX))
), } else {
PacketDirection::GossipRx => ( dash
Span::styled(dash, Style::default().fg(C_BORDER)), },
Span::styled(" RX", Style::default().fg(C_NET_RX)), Span::styled(format!("{:>4}", "RX"), Style::default().fg(C_NET_RX)),
), ),
}; };
let action_color = match e.action { let action_color = match e.action {
PacketAction::Forwarded => C_NET_TX, PacketAction::Bridged => C_LABEL,
PacketAction::Transmitted => C_RADIO_TX,
PacketAction::DroppedDedup => C_DEDUP, PacketAction::DroppedDedup => C_DEDUP,
PacketAction::DroppedQueueFull => C_QUEUE_DROP, PacketAction::DroppedQueueFull => C_QUEUE_DROP,
PacketAction::DroppedRateLimit => C_RATE_DROP, PacketAction::DroppedRateLimit => C_RATE_DROP,
@@ -434,21 +497,31 @@ fn draw_log(frame: &mut ratatui::Frame, area: Rect, entries: &[PacketLogEntry])
let rssi_span = match e.rssi { let rssi_span = match e.rssi {
Some(r) => Span::styled(format!("{r:>6}"), Style::default().fg(rssi_color(r))), Some(r) => Span::styled(format!("{r:>6}"), Style::default().fg(rssi_color(r))),
None => Span::styled(" -", Style::default().fg(C_LABEL)), None => Span::styled(format!("{:>6}", "-"), Style::default().fg(C_LABEL)),
}; };
let snr_span = match e.snr { let snr_span = match e.snr {
Some(s) => Span::styled(format!("{s:>5}"), Style::default().fg(snr_color(s))), Some(s) => Span::styled(format!("{s:>5}"), Style::default().fg(snr_color(s))),
None => Span::styled(" -", Style::default().fg(C_LABEL)), None => Span::styled(format!("{:>5}", "-"), Style::default().fg(C_LABEL)),
}; };
let action_str = match e.action {
PacketAction::Bridged => "",
PacketAction::DroppedDedup => "DUP",
PacketAction::DroppedQueueFull => "FULL",
PacketAction::DroppedRateLimit => "RATE",
};
let action_span = Span::styled(format!(" {:>4}", action_str), Style::default().fg(action_color));
let size_str = format!("{}B", e.size);
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(format!(" {age:>3} "), Style::default().fg(C_LABEL)), Span::styled(format!("{:>5}", age), Style::default().fg(C_LABEL)),
hash_span,
rf_col, rf_col,
net_col, net_col,
Span::styled(format!("{:>5}B", e.size), Style::default().fg(C_VALUE)), Span::styled(format!("{:>6}", size_str), Style::default().fg(C_VALUE)),
rssi_span, rssi_span,
snr_span, snr_span,
Span::styled(format!(" {}", e.action), Style::default().fg(action_color)), action_span,
])); ]));
} }
@@ -458,11 +531,11 @@ fn draw_log(frame: &mut ratatui::Frame, area: Rect, entries: &[PacketLogEntry])
// ── Activity chart ─────────────────────────────────────────────── // ── Activity chart ───────────────────────────────────────────────
fn draw_activity(frame: &mut ratatui::Frame, area: Rect, series: &TimeSeries) { fn draw_activity(frame: &mut ratatui::Frame, area: Rect, series: &TimeSeries) {
let block = panel_block(" Activity (10s buckets) "); let block = panel_block(" Activity (1s) ");
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
if inner.height < 3 || inner.width < 20 { if inner.height < 6 || inner.width < 6 {
return; return;
} }
@@ -475,61 +548,53 @@ fn draw_activity(frame: &mut ratatui::Frame, area: Rect, series: &TimeSeries) {
]) ])
.split(inner); .split(inner);
let rx_data = series.radio_rx(); let spark_w = inner.width as usize;
let tx_data = series.radio_tx();
let snr_data = series.avg_snr();
let label_w = 12u16; let rx_data = pad_right_align(&series.radio_rx(), spark_w);
let spark_w = inner.width.saturating_sub(label_w) as usize; let tx_data = pad_right_align(&series.radio_tx(), spark_w);
let snr_data = pad_right_align(&series.avg_snr(), spark_w);
let peak_rx = series.peak_rx(); let peak_rx = series.peak_rx();
let peak_tx = series.peak_tx(); let peak_tx = series.peak_tx();
let snr_label = series let snr_label = series
.latest_avg_snr() .latest_avg_snr()
.map(|s| format!("avg:{s}dB")) .map(|s| format!("{s}dB"))
.unwrap_or_else(|| "-".into()); .unwrap_or_else(|| "-".into());
let rx_padded = pad_right_align(&rx_data, spark_w); draw_sparkline_section(frame, rows[0], "RX", &format!("pk:{peak_rx}"), &rx_data, C_RADIO_RX);
let tx_padded = pad_right_align(&tx_data, spark_w); draw_sparkline_section(frame, rows[1], "TX", &format!("pk:{peak_tx}"), &tx_data, C_RADIO_TX);
let snr_padded = pad_right_align(&snr_data, spark_w); draw_sparkline_section(frame, rows[2], "SNR", &snr_label, &snr_data, C_SNR);
draw_sparkline_row(frame, rows[0], "RF RX", &format!("pk:{peak_rx}"), &rx_padded, C_RADIO_RX, label_w);
draw_sparkline_row(frame, rows[1], "RF TX", &format!("pk:{peak_tx}"), &tx_padded, C_RADIO_TX, label_w);
draw_sparkline_row(frame, rows[2], "SNR", &snr_label, &snr_padded, C_SNR, label_w);
} }
fn draw_sparkline_row( /// Draw a label line on top, sparkline filling the rest below.
fn draw_sparkline_section(
frame: &mut ratatui::Frame, frame: &mut ratatui::Frame,
area: Rect, area: Rect,
label: &str, label: &str,
detail: &str, detail: &str,
data: &[u64], data: &[u64],
color: Color, color: Color,
label_width: u16,
) { ) {
if area.width < label_width + 5 || area.height < 1 { if area.height < 2 || area.width < 4 {
return; return;
} }
let cols = Layout::default() let rows = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Vertical)
.constraints([Constraint::Length(label_width), Constraint::Min(1)]) .constraints([Constraint::Length(1), Constraint::Min(1)])
.split(area); .split(area);
// Label + detail stacked if room, otherwise just label. let label_line = Line::from(vec![
let mut label_lines = vec![ Span::styled(format!(" {label} "), Style::default().fg(color).add_modifier(Modifier::BOLD)),
Line::from(Span::styled(format!(" {label}"), Style::default().fg(color).add_modifier(Modifier::BOLD))), Span::styled(detail.to_string(), Style::default().fg(C_LABEL)),
]; ]);
if cols[0].height > 1 { frame.render_widget(Paragraph::new(label_line), rows[0]);
label_lines.push(Line::from(Span::styled(format!(" {detail}"), Style::default().fg(C_LABEL))));
}
frame.render_widget(Paragraph::new(label_lines), cols[0]);
let spark = Sparkline::default() let spark = Sparkline::default()
.data(data) .data(data)
.bar_set(symbols::bar::NINE_LEVELS) .bar_set(symbols::bar::NINE_LEVELS)
.style(Style::default().fg(color)); .style(Style::default().fg(color));
frame.render_widget(spark, cols[1]); frame.render_widget(spark, rows[1]);
} }
/// Pad data with leading zeros so values are right-aligned in `width` columns. /// Pad data with leading zeros so values are right-aligned in `width` columns.
@@ -609,8 +674,5 @@ fn format_short_duration(d: Duration) -> String {
} }
fn chrono_time() -> String { fn chrono_time() -> String {
let now = std::time::SystemTime::now(); jiff::Zoned::now().strftime("%H:%M:%S %Z").to_string()
let since_epoch = now.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since_epoch.as_secs();
format!("{:02}:{:02}:{:02}", (secs / 3600) % 24, (secs % 3600) / 60, secs % 60)
} }