Archived
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:
7 files changed
+373
-175
No files matched your search
Generated
+51
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user