commit 74f6c8255ab04b8aa21bb882151a3eabb0528067
parent 6b7e2a49349d55e4b19f686f781afbf148024f0e
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Mon, 27 Jul 2026 20:12:10 +0200
Track peer activity freshness
Diffstat:
4 files changed, 115 insertions(+), 10 deletions(-)
diff --git a/assets/iuna-ui.js b/assets/iuna-ui.js
@@ -1066,6 +1066,7 @@ window.iunaApp = function iunaApp() {
if (this.networkHealth.ok) return "healthy";
if (this.networkHealth.state === "syncing") return "syncing";
if (this.networkHealth.state === "isolated") return "isolated";
+ if (this.networkHealth.state === "stale") return "stale";
return "error";
},
@@ -1092,8 +1093,15 @@ window.iunaApp = function iunaApp() {
return this.peers.filter((peer) => peer.last_error);
},
+ stalePeer(peer) {
+ const lastSuccess = peer.last_success_ms;
+ if (typeof lastSuccess !== "number") return false;
+ return Date.now() - lastSuccess > 20 * 60 * 1000;
+ },
+
peerStatus(peer) {
if (peer.last_error) return "error";
+ if (this.stalePeer(peer)) return "stale";
if (typeof peer.last_known_height === "number") return "synced";
if ((peer.messages_sent ?? 0) > 0 || (peer.messages_received ?? 0) > 0) return "active";
return "pending";
@@ -1102,12 +1110,29 @@ window.iunaApp = function iunaApp() {
peerStatusLabel(peer) {
return {
error: "Error",
+ stale: "Stale",
synced: "Synced",
active: "Active",
pending: "Pending",
}[this.peerStatus(peer)];
},
+ relativeTimeLabel(timestampMs) {
+ if (typeof timestampMs !== "number") return "-";
+ const ageSeconds = Math.max(0, Math.round((Date.now() - timestampMs) / 1000));
+ if (ageSeconds < 5) return "now";
+ if (ageSeconds < 60) return `${ageSeconds}s ago`;
+ const ageMinutes = Math.round(ageSeconds / 60);
+ if (ageMinutes < 60) return `${ageMinutes}m ago`;
+ const ageHours = Math.round(ageMinutes / 60);
+ if (ageHours < 48) return `${ageHours}h ago`;
+ return `${Math.round(ageHours / 24)}d ago`;
+ },
+
+ peerLastContactLabel(peer) {
+ return this.relativeTimeLabel(peer.last_contact_ms);
+ },
+
peerHeightDelta(peer) {
const local = this.status.chain?.height;
const remote = peer.last_known_height;
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -40,6 +40,7 @@ const AUTH_COOKIE_NAME: &str = "iuna_session";
const AUTH_SESSION_TTL_MS: u64 = 12 * 60 * 60 * 1_000;
const PASSWORD_KDF_ALGORITHM: &str = "pbkdf2-sha256";
const PASSWORD_KDF_ITERATIONS: u32 = 120_000;
+const PEER_STALE_AFTER_MS: u64 = 20 * 60 * 1_000;
#[derive(Clone)]
struct HttpState {
@@ -89,6 +90,7 @@ struct NetworkHealthResponse {
inbound_peers: usize,
healthy_peers: usize,
failed_peers: usize,
+ stale_peers: usize,
pending_transactions: usize,
last_error: Option<String>,
}
@@ -755,6 +757,14 @@ fn validate_peer_address(peer: String) -> Result<String> {
}
fn network_health(status: &NodeStatus, peers: &[PeerInfo]) -> NetworkHealthResponse {
+ network_health_at(status, peers, now_ms())
+}
+
+fn network_health_at(
+ status: &NodeStatus,
+ peers: &[PeerInfo],
+ now_ms: u64,
+) -> NetworkHealthResponse {
let local_height = status.chain.height;
let remote_best_height = peers.iter().filter_map(|peer| peer.last_known_height).max();
let best_known_height = remote_best_height.unwrap_or(local_height).max(local_height);
@@ -785,6 +795,14 @@ fn network_health(status: &NodeStatus, peers: &[PeerInfo]) -> NetworkHealthRespo
.iter()
.filter(|peer| peer.last_error.is_some())
.count();
+ let stale_peers = peers
+ .iter()
+ .filter(|peer| {
+ peer.last_success_ms.is_some_and(|last_success| {
+ now_ms.saturating_sub(last_success) > PEER_STALE_AFTER_MS
+ })
+ })
+ .count();
let lag_blocks = best_known_height.saturating_sub(local_height);
let last_error = peers.iter().rev().find_map(|peer| {
peer.last_error
@@ -798,6 +816,8 @@ fn network_health(status: &NodeStatus, peers: &[PeerInfo]) -> NetworkHealthRespo
"syncing"
} else if failed_peers > 0 && healthy_peers == 0 {
"peer errors"
+ } else if stale_peers > 0 && healthy_peers == stale_peers {
+ "stale"
} else if remote_best_height.is_some_and(|height| local_height > height) {
"ahead of peers"
} else {
@@ -806,7 +826,7 @@ fn network_health(status: &NodeStatus, peers: &[PeerInfo]) -> NetworkHealthRespo
.to_string();
NetworkHealthResponse {
- ok: !peers.is_empty() && lag_blocks == 0 && healthy_peers > 0,
+ ok: !peers.is_empty() && lag_blocks == 0 && healthy_peers > stale_peers,
state,
local_height,
best_known_height,
@@ -816,6 +836,7 @@ fn network_health(status: &NodeStatus, peers: &[PeerInfo]) -> NetworkHealthRespo
inbound_peers,
healthy_peers,
failed_peers,
+ stale_peers,
pending_transactions: status.chain.pending_transactions,
last_error,
}
@@ -1764,6 +1785,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.peer-form input { width: 100%; }
.peer-status { display: inline-flex; align-items: center; border: 1px solid #3a4248; border-radius: 999px; padding: 3px 8px; color: #a8b2b8; font-size: 11px; font-weight: 850; }
.peer-status.synced, .peer-status.active { border-color: #566d25; color: #d5f55f; background: #1c2516; }
+ .peer-status.stale { border-color: #5f5125; color: #ffe08a; background: #211d12; }
.peer-status.error { border-color: #713434; color: #ffb1a8; background: #2a1717; }
.peer-actions { display: flex; gap: 6px; align-items: center; }
.peer-remove { padding: 4px 7px; border-color: #4f3737; background: #221717; color: #ffb1a8; font-size: 12px; }
@@ -1771,7 +1793,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.network-health { display: grid; grid-template-columns: minmax(180px, .8fr) minmax(0, 1.2fr); gap: 12px; align-items: stretch; margin-bottom: 12px; }
.network-health-state { display: grid; align-content: center; gap: 5px; border: 1px solid #3a4248; border-radius: 8px; padding: 12px; background: #111316; }
.network-health-state.healthy { border-color: #566d25; background: #182112; }
- .network-health-state.syncing { border-color: #5f5125; background: #211d12; }
+ .network-health-state.syncing, .network-health-state.stale { border-color: #5f5125; background: #211d12; }
.network-health-state.isolated, .network-health-state.error { border-color: #713434; background: #241716; }
.network-health-label { color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; }
.network-health-value { color: #e8edf0; font-size: 20px; font-weight: 900; text-transform: capitalize; }
@@ -1845,7 +1867,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.block-card { flex-basis: 108px; }
}
</style>
- <script defer src="/assets/iuna-ui.js?v=54"></script>
+ <script defer src="/assets/iuna-ui.js?v=55"></script>
<script defer src="/assets/alpine.min.js"></script>
</head>
<body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak>
@@ -2093,6 +2115,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="peer-summary-item"><div class="peer-summary-label">Local Height</div><div class="peer-summary-value" x-text="networkHealth.local_height ?? '-'"></div></div>
<div class="peer-summary-item"><div class="peer-summary-label">Best Known</div><div class="peer-summary-value" x-text="networkHealth.best_known_height ?? '-'"></div></div>
<div class="peer-summary-item"><div class="peer-summary-label">Lag</div><div class="peer-summary-value" x-text="networkLagLabel()"></div></div>
+ <div class="peer-summary-item"><div class="peer-summary-label">Stale</div><div class="peer-summary-value" x-text="networkHealth.stale_peers ?? '-'"></div></div>
<div class="peer-summary-item"><div class="peer-summary-label">Mempool</div><div class="peer-summary-value" x-text="networkHealth.pending_transactions ?? '-'"></div></div>
</div>
</div>
@@ -2105,13 +2128,14 @@ const INDEX_HTML: &str = r#"<!doctype html>
</div>
<div class="table-wrap">
<table>
- <thead><tr><th>Status</th><th>Address</th><th>Direction</th><th>Height</th><th>Delta</th><th>Tip</th><th>Sent</th><th>Received</th><th>Last Error</th><th>Actions</th></tr></thead>
+ <thead><tr><th>Status</th><th>Address</th><th>Direction</th><th>Last Contact</th><th>Height</th><th>Delta</th><th>Tip</th><th>Sent</th><th>Received</th><th>Last Error</th><th>Actions</th></tr></thead>
<tbody>
<template x-for="peer in peers" :key="peer.address">
<tr>
<td><span class="peer-status" :class="peerStatus(peer)" x-text="peerStatusLabel(peer)"></span></td>
<td><code x-text="peer.address"></code></td>
<td x-text="peer.direction"></td>
+ <td x-text="peerLastContactLabel(peer)"></td>
<td x-text="peer.last_known_height ?? '-'"></td>
<td x-text="peerHeightDelta(peer)"></td>
<td><code x-text="short(peer.last_known_tip_hash)"></code></td>
@@ -2121,7 +2145,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<td><div class="peer-actions"><button class="peer-remove" type="button" x-show="canRemovePeer(peer)" @click="removePeer(peer)">Remove</button><span class="muted" x-show="!canRemovePeer(peer)">Observed</span></div></td>
</tr>
</template>
- <tr x-show="peers.length === 0"><td colspan="10">No peers</td></tr>
+ <tr x-show="peers.length === 0"><td colspan="11">No peers</td></tr>
</tbody>
</table>
</div>
@@ -2489,11 +2513,11 @@ mod tests {
};
use super::{
- AUTH_COOKIE_NAME, HttpState, TransferForm, api_auth_login_form, api_auth_setup_form,
- api_auth_status, dev_seed_verify_bypass_allowed, hash_password, hex_encode, pbkdf2_sha256,
- persist_burn_settings_config, persist_pow_mining_config, require_auth_middleware,
- required_fee_per_byte_burn, required_fee_per_byte_mine, validate_password,
- validate_transfer_form, verify_password, wallet_transaction_rows,
+ AUTH_COOKIE_NAME, HttpState, PEER_STALE_AFTER_MS, TransferForm, api_auth_login_form,
+ api_auth_setup_form, api_auth_status, dev_seed_verify_bypass_allowed, hash_password,
+ hex_encode, pbkdf2_sha256, persist_burn_settings_config, persist_pow_mining_config,
+ require_auth_middleware, required_fee_per_byte_burn, required_fee_per_byte_mine,
+ validate_password, validate_transfer_form, verify_password, wallet_transaction_rows,
};
#[test]
@@ -2701,6 +2725,9 @@ mod tests {
last_known_height: Some(3),
last_known_tip_hash: Some("remote-tip".to_string()),
last_error: None,
+ last_contact_ms: Some(10_000),
+ last_success_ms: Some(10_000),
+ last_error_ms: None,
}],
);
assert!(!syncing.ok);
@@ -2718,6 +2745,9 @@ mod tests {
last_known_height: None,
last_known_tip_hash: None,
last_error: Some("connection refused".to_string()),
+ last_contact_ms: Some(10_000),
+ last_success_ms: None,
+ last_error_ms: Some(10_000),
}],
);
assert!(!peer_errors.ok);
@@ -2726,6 +2756,26 @@ mod tests {
peer_errors.last_error.as_deref(),
Some("127.0.0.1:9446: connection refused")
);
+
+ let stale = super::network_health_at(
+ &status,
+ &[PeerInfo {
+ address: "127.0.0.1:9447".to_string(),
+ direction: PeerDirection::Outbound,
+ messages_sent: 1,
+ messages_received: 1,
+ last_known_height: Some(0),
+ last_known_tip_hash: Some("tip".to_string()),
+ last_error: None,
+ last_contact_ms: Some(1),
+ last_success_ms: Some(1),
+ last_error_ms: None,
+ }],
+ PEER_STALE_AFTER_MS + 2,
+ );
+ assert!(!stale.ok);
+ assert_eq!(stale.state, "stale");
+ assert_eq!(stale.stale_peers, 1);
}
#[test]
diff --git a/src/app.rs b/src/app.rs
@@ -1116,30 +1116,47 @@ impl PeerBook {
}
pub fn record_sent(&mut self, address: &str, count: u64) {
+ let now = now_ms();
let peer = self.ensure(address, PeerDirection::Outbound);
peer.messages_sent += count;
+ peer.last_contact_ms = Some(now);
+ peer.last_success_ms = Some(now);
peer.last_error = None;
}
pub fn record_status(&mut self, address: &str, height: u64, tip_hash: String) {
+ let now = now_ms();
let peer = self.ensure(address, PeerDirection::Outbound);
peer.last_known_height = Some(height);
peer.last_known_tip_hash = Some(tip_hash);
+ peer.last_contact_ms = Some(now);
+ peer.last_success_ms = Some(now);
+ peer.last_error = None;
}
pub fn record_error(&mut self, address: &str, error: impl Into<String>) {
+ let now = now_ms();
let peer = self.ensure(address, PeerDirection::Outbound);
+ peer.last_contact_ms = Some(now);
+ peer.last_error_ms = Some(now);
peer.last_error = Some(error.into());
}
pub fn record_inbound_error(&mut self, address: &str, error: impl Into<String>) {
+ let now = now_ms();
let peer = self.ensure(address, PeerDirection::Inbound);
+ peer.last_contact_ms = Some(now);
+ peer.last_error_ms = Some(now);
peer.last_error = Some(error.into());
}
pub fn record_received(&mut self, address: &str, count: u64) {
+ let now = now_ms();
let peer = self.ensure(address, PeerDirection::Inbound);
peer.messages_received += count;
+ peer.last_contact_ms = Some(now);
+ peer.last_success_ms = Some(now);
+ peer.last_error = None;
}
fn ensure(&mut self, address: &str, direction: PeerDirection) -> &mut PeerInfo {
@@ -1158,6 +1175,9 @@ pub struct PeerInfo {
pub last_known_height: Option<u64>,
pub last_known_tip_hash: Option<String>,
pub last_error: Option<String>,
+ pub last_contact_ms: Option<u64>,
+ pub last_success_ms: Option<u64>,
+ pub last_error_ms: Option<u64>,
}
impl PeerInfo {
@@ -1170,6 +1190,9 @@ impl PeerInfo {
last_known_height: None,
last_known_tip_hash: None,
last_error: None,
+ last_contact_ms: None,
+ last_success_ms: None,
+ last_error_ms: None,
}
}
}
diff --git a/tests/iuna.rs b/tests/iuna.rs
@@ -1832,6 +1832,9 @@ fn peer_book_tracks_multiple_peers_without_networking() {
assert_eq!(sent_peer.last_known_height, Some(12));
assert_eq!(sent_peer.last_known_tip_hash.as_deref(), Some("tip-hash"));
assert_eq!(sent_peer.last_error, None);
+ assert!(sent_peer.last_contact_ms.is_some());
+ assert!(sent_peer.last_success_ms.is_some());
+ assert_eq!(sent_peer.last_error_ms, None);
let failed_peer = list
.iter()
@@ -1841,6 +1844,8 @@ fn peer_book_tracks_multiple_peers_without_networking() {
failed_peer.last_error.as_deref(),
Some("connection refused")
);
+ assert!(failed_peer.last_contact_ms.is_some());
+ assert!(failed_peer.last_error_ms.is_some());
let inbound_peer = list
.iter()
@@ -1848,6 +1853,8 @@ fn peer_book_tracks_multiple_peers_without_networking() {
.unwrap();
assert_eq!(inbound_peer.direction, PeerDirection::Inbound);
assert_eq!(inbound_peer.messages_received, 1);
+ assert!(inbound_peer.last_contact_ms.is_some());
+ assert!(inbound_peer.last_success_ms.is_some());
let inbound_error = list
.iter()