commit 5185ae498b0f0138fcf6cbd3b4b71d59210908bb
parent 1681e5d6a6226759117ff4f9905944d4aa898f0f
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Mon, 27 Jul 2026 13:03:10 +0200
Improve peer management UI
Diffstat:
9 files changed, 239 insertions(+), 30 deletions(-)
diff --git a/README.md b/README.md
@@ -40,7 +40,7 @@ Open `http://127.0.0.1:18661`, set a local password, and back up the recovery ph
Ask for a seed node address and start iuna with `--join`:
```sh
-./iuna --data-dir .iuna --http 127.0.0.1:18661 --p2p 0.0.0.0:9445 --join seed.example:9444
+./iuna --http 127.0.0.1:18661 --p2p 0.0.0.0:9445 --join seed.example:9444
```
After the node syncs, you can receive IUNA, send transactions, burn for block finalization, or enable PoW mine actions from the Mining screen.
@@ -60,7 +60,7 @@ Genesis requires a fresh wallet and an empty chain database. It creates the star
iuna can expose a Stratum V1 endpoint for SHA-256 ASIC miners such as a Bitaxe:
```sh
-./iuna --data-dir .iuna --stratum 0.0.0.0:3333
+./iuna --stratum 0.0.0.0:3333
```
Use your iuna wallet address as the worker username. Accepted shares become PoW mine actions in the node mempool and are gossiped to peers.
diff --git a/assets/iuna-ui.js b/assets/iuna-ui.js
@@ -536,7 +536,7 @@ window.iunaApp = function iunaApp() {
}
},
- async postForm(path, fields, successMessage) {
+ async postForm(path, fields, successMessage, method = "POST") {
const body = new URLSearchParams();
for (const [key, value] of Object.entries(fields)) {
if (Array.isArray(value)) {
@@ -546,7 +546,7 @@ window.iunaApp = function iunaApp() {
}
}
const response = await fetch(path, {
- method: "POST",
+ method,
headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
body,
});
@@ -798,7 +798,7 @@ window.iunaApp = function iunaApp() {
async addPeer() {
try {
- const peer = this.peerAddress;
+ const peer = this.peerAddress.trim();
await this.postForm("/api/peers", { peer }, `Added peer ${peer}`);
this.peerAddress = "";
} catch (error) {
@@ -806,6 +806,14 @@ window.iunaApp = function iunaApp() {
}
},
+ async removePeer(peer) {
+ try {
+ await this.postForm("/api/peers", { peer: peer.address }, `Removed peer ${peer.address}`, "DELETE");
+ } catch (error) {
+ this.showFlash(error.message, "error");
+ }
+ },
+
async copyAddress() {
try {
await navigator.clipboard.writeText(this.setupAddress());
@@ -1051,6 +1059,51 @@ window.iunaApp = function iunaApp() {
return Math.min(local, ...peerHeights);
},
+ outboundPeers() {
+ return this.peers.filter((peer) => peer.direction !== "inbound");
+ },
+
+ inboundPeers() {
+ return this.peers.filter((peer) => peer.direction === "inbound");
+ },
+
+ healthyPeers() {
+ return this.peers.filter((peer) => !peer.last_error && typeof peer.last_known_height === "number");
+ },
+
+ failedPeers() {
+ return this.peers.filter((peer) => peer.last_error);
+ },
+
+ peerStatus(peer) {
+ if (peer.last_error) return "error";
+ if (typeof peer.last_known_height === "number") return "synced";
+ if ((peer.messages_sent ?? 0) > 0 || (peer.messages_received ?? 0) > 0) return "active";
+ return "pending";
+ },
+
+ peerStatusLabel(peer) {
+ return {
+ error: "Error",
+ synced: "Synced",
+ active: "Active",
+ pending: "Pending",
+ }[this.peerStatus(peer)];
+ },
+
+ peerHeightDelta(peer) {
+ const local = this.status.chain?.height;
+ const remote = peer.last_known_height;
+ if (typeof local !== "number" || typeof remote !== "number") return "-";
+ if (remote === local) return "even";
+ if (remote > local) return `+${remote - local}`;
+ return `-${local - remote}`;
+ },
+
+ canRemovePeer(peer) {
+ return peer.direction !== "inbound";
+ },
+
targetSecondsLabel() {
const ms = this.status.mining?.vdf_target_block_ms;
if (!ms) return "-";
diff --git a/devlogs/004-wallet-file.md b/devlogs/004-wallet-file.md
@@ -2,7 +2,7 @@
The node no longer has a baked-in dev wallet seed.
-On first real startup, `--start` or `--join`, iuna creates a wallet file and reuses it next time. The default is `.iuna/wallet.json`, or `<data-dir>/wallet.json` when a node uses its own data directory.
+On first real startup, `--start` or `--join`, iuna creates a wallet file and reuses it next time. The default is `~/.iuna/wallet.json`, or `<data-dir>/wallet.json` when a node uses its own data directory.
That matters for friend testing. You can restart your node and keep the same address, but friends do not need to pass a seed just to be someone else. They join your chain, get their own fresh local wallet, and start with 0 IUNA until you send them some.
diff --git a/docs/genesis.md b/docs/genesis.md
@@ -33,7 +33,7 @@ your-host.example:9444
They can join with:
```sh
-cargo run -- --data-dir .iuna --p2p 0.0.0.0:9445 --http 127.0.0.1:18661 --join your-host.example:9444
+cargo run -- --p2p 0.0.0.0:9445 --http 127.0.0.1:18661 --join your-host.example:9444
```
Keep the management UI bound to `127.0.0.1`.
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -253,7 +253,12 @@ pub async fn serve(
.route("/api/fee-estimate/burn", post(api_burn_fee_estimate_form))
.route("/api/fee-estimate/mine", post(api_mine_fee_estimate_form))
.route("/api/mempool", get(api_mempool))
- .route("/api/peers", get(api_peers).post(api_peer_form))
+ .route(
+ "/api/peers",
+ get(api_peers)
+ .post(api_peer_form)
+ .delete(api_peer_delete_form),
+ )
.route("/api/p2p/metrics", get(api_p2p_metrics))
.route(
"/api/settings/burn-per-block",
@@ -612,6 +617,14 @@ async fn api_peer_form(
action_json(result)
}
+async fn api_peer_delete_form(
+ State(state): State<HttpState>,
+ Form(form): Form<PeerForm>,
+) -> Json<ActionResponse> {
+ let result = remove_peer(&state, form.peer).await;
+ action_json(result)
+}
+
async fn peer_form(State(state): State<HttpState>, Form(form): Form<PeerForm>) -> Response {
match add_peer(&state, form.peer).await {
Ok(()) => Redirect::to("/").into_response(),
@@ -683,6 +696,7 @@ async fn persist_pow_mining_config(
}
async fn add_peer(state: &HttpState, peer: String) -> Result<()> {
+ let peer = validate_peer_address(peer)?;
let addresses = {
let mut peers = state.peers.lock().await;
peers.add_peer(peer);
@@ -693,6 +707,28 @@ async fn add_peer(state: &HttpState, peer: String) -> Result<()> {
config_store::save(&state.config_path, &config)
}
+async fn remove_peer(state: &HttpState, peer: String) -> Result<()> {
+ let peer = validate_peer_address(peer)?;
+ let addresses = {
+ let mut peers = state.peers.lock().await;
+ if !peers.remove_peer(&peer) {
+ bail!("peer is not configured as an outbound peer");
+ }
+ peers.addresses()
+ };
+ let mut config = state.ui_config.lock().await;
+ config.peers = addresses;
+ config_store::save(&state.config_path, &config)
+}
+
+fn validate_peer_address(peer: String) -> Result<String> {
+ let peer = peer.trim().to_string();
+ if peer.is_empty() {
+ bail!("peer address is required");
+ }
+ Ok(peer)
+}
+
async fn wallet_setup_response(
state: &HttpState,
headers: &HeaderMap,
@@ -1626,6 +1662,20 @@ const INDEX_HTML: &str = r#"<!doctype html>
.tx-value.number { color: #c7d0d5; font-variant-numeric: tabular-nums; }
.tx-value.text { color: #e8edf0; }
.metric-context { display: grid; gap: 5px; margin-top: 12px; }
+ .peer-toolbar { display: flex; justify-content: space-between; gap: 12px; align-items: start; flex-wrap: wrap; margin-bottom: 12px; }
+ .peer-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 8px; margin-bottom: 12px; }
+ .peer-summary-item { min-width: 0; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; }
+ .peer-summary-label { color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; }
+ .peer-summary-value { margin-top: 5px; color: #dce4e7; font-size: 15px; font-weight: 850; }
+ .peer-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; }
+ .peer-form label { min-width: min(320px, 100%); }
+ .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.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; }
+ .peer-remove:hover { border-color: #ffb1a8; color: #ffd4cf; }
.panel .grid + form { margin-top: 12px; }
.explorer-shell { width: 100%; display: grid; gap: 12px; }
.block-rail-wrap { background: #181b1f; border: 1px solid #2a3035; border-radius: 8px; padding: 12px; overflow: hidden; }
@@ -1922,6 +1972,47 @@ const INDEX_HTML: &str = r#"<!doctype html>
<section x-show="tab === 'p2p'">
<div class="panel">
+ <div class="peer-toolbar">
+ <div>
+ <h2>Peers</h2>
+ <div class="panel-description">Manage outbound peers and inspect which nodes are healthy, lagging, or failing. Inbound rows are observed sessions and are not persisted in config.</div>
+ </div>
+ <form class="peer-form" @submit.prevent="addPeer">
+ <label>Peer address<input x-model="peerAddress" placeholder="seed.example:9444"></label>
+ <button class="primary" type="submit">Add</button>
+ </form>
+ </div>
+ <div class="peer-summary">
+ <div class="peer-summary-item"><div class="peer-summary-label">Outbound</div><div class="peer-summary-value" x-text="outboundPeers().length"></div></div>
+ <div class="peer-summary-item"><div class="peer-summary-label">Inbound</div><div class="peer-summary-value" x-text="inboundPeers().length"></div></div>
+ <div class="peer-summary-item"><div class="peer-summary-label">Healthy</div><div class="peer-summary-value" x-text="healthyPeers().length"></div></div>
+ <div class="peer-summary-item"><div class="peer-summary-label">Errors</div><div class="peer-summary-value" x-text="failedPeers().length"></div></div>
+ <div class="peer-summary-item"><div class="peer-summary-label">Shared Height</div><div class="peer-summary-value" x-text="sharedHeightLabel()"></div></div>
+ </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>
+ <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="peer.last_known_height ?? '-'"></td>
+ <td x-text="peerHeightDelta(peer)"></td>
+ <td><code x-text="short(peer.last_known_tip_hash)"></code></td>
+ <td x-text="peer.messages_sent"></td>
+ <td x-text="peer.messages_received"></td>
+ <td x-text="peer.last_error || ''"></td>
+ <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>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ <div class="panel">
<h2>Metrics</h2>
<div class="grid">
<div class="metric"><div class="label">Inbound Sessions</div><div class="value" x-text="p2pMetrics.inbound_sessions_started ?? 0"></div></div>
@@ -1954,24 +2045,6 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="tx-field"><span class="tx-label">Last Parse</span><span class="tx-value text" x-text="p2pMetrics.last_parse_error || '-'"></span></div>
</div>
</div>
- <div class="panel">
- <h2>Add Peer</h2>
- <form @submit.prevent="addPeer">
- <label>Peer address<input x-model="peerAddress" placeholder="127.0.0.1:9445"></label>
- <button class="primary" type="submit">Add</button>
- </form>
- </div>
- <div class="panel table-wrap">
- <table>
- <thead><tr><th>Address</th><th>Direction</th><th>Height</th><th>Tip</th><th>Sent</th><th>Received</th><th>Last Error</th></tr></thead>
- <tbody>
- <template x-for="peer in peers" :key="peer.address">
- <tr><td><code x-text="peer.address"></code></td><td x-text="peer.direction"></td><td x-text="peer.last_known_height ?? '-'"></td><td><code x-text="short(peer.last_known_tip_hash)"></code></td><td x-text="peer.messages_sent"></td><td x-text="peer.messages_received"></td><td x-text="peer.last_error || ''"></td></tr>
- </template>
- <tr x-show="peers.length === 0"><td colspan="7">No peers</td></tr>
- </tbody>
- </table>
- </div>
</section>
<section x-show="tab === 'chain'">
@@ -2447,6 +2520,51 @@ mod tests {
assert_eq!(protected.body, "protected");
}
+ #[tokio::test]
+ async fn peer_management_updates_config_file() {
+ let dir = tempfile::tempdir().unwrap();
+ let config_path = dir.path().join("config.json");
+ let state = auth_test_state(
+ config_path.clone(),
+ UiConfig {
+ setup_complete: true,
+ ..UiConfig::default()
+ },
+ )
+ .await;
+
+ super::add_peer(&state, " 127.0.0.1:9445 ".to_string())
+ .await
+ .unwrap();
+ let config = config_store::load_or_create(&config_path).unwrap();
+ assert_eq!(config.peers, vec!["127.0.0.1:9445"]);
+ assert_eq!(state.peers.lock().await.addresses(), vec!["127.0.0.1:9445"]);
+
+ super::remove_peer(&state, "127.0.0.1:9445".to_string())
+ .await
+ .unwrap();
+ let config = config_store::load_or_create(&config_path).unwrap();
+ assert!(config.peers.is_empty());
+ assert!(state.peers.lock().await.addresses().is_empty());
+ }
+
+ #[tokio::test]
+ async fn peer_management_rejects_empty_and_inbound_removal() {
+ let dir = tempfile::tempdir().unwrap();
+ let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await;
+
+ assert!(super::add_peer(&state, " ".to_string()).await.is_err());
+ state
+ .peers
+ .lock()
+ .await
+ .record_received("127.0.0.1:9555", 1);
+
+ let result = super::remove_peer(&state, "127.0.0.1:9555".to_string()).await;
+ assert!(result.is_err());
+ assert_eq!(state.peers.lock().await.addresses(), Vec::<String>::new());
+ }
+
#[test]
fn wallet_transactions_include_old_confirmed_transfers_without_burns_or_explorer_pagination() {
let alice = Wallet::from_seed("wallet-history-alice");
diff --git a/src/app.rs b/src/app.rs
@@ -1083,6 +1083,19 @@ impl PeerBook {
}
}
+ pub fn remove_peer(&mut self, address: &str) -> bool {
+ if self
+ .peers
+ .get(address)
+ .is_some_and(|peer| peer.direction != PeerDirection::Inbound)
+ {
+ self.peers.remove(address);
+ true
+ } else {
+ false
+ }
+ }
+
pub fn addresses(&self) -> Vec<String> {
self.peers
.values()
diff --git a/src/main.rs b/src/main.rs
@@ -266,7 +266,7 @@ impl CliOptions {
peers: Vec::new(),
join_peers: Vec::new(),
chain_mode: ChainMode::Setup,
- data_dir: PathBuf::from(".iuna"),
+ data_dir: default_data_dir(),
};
let raw_args = args.into_iter().collect::<Vec<_>>();
@@ -287,7 +287,7 @@ impl CliOptions {
}
"--wallet-seed" => {
bail!(
- "--wallet-seed was removed; wallets are stored in --wallet <path> or .iuna/wallet.json"
+ "--wallet-seed was removed; wallets are stored in --wallet <path> or ~/.iuna/wallet.json"
)
}
"--http" => {
@@ -404,11 +404,20 @@ fn help_text() -> &'static str {
--p2p <addr:port> P2P TCP listener address (default 127.0.0.1:9444)\n\
--stratum <addr:port> Stratum V1 listener for SHA-256 ASIC miners\n\
--join <addr:port> Fetch chain snapshot from this peer before finalization\n\
- --data-dir <path> Local wallet directory\n\n\
+ --data-dir <path> Local wallet directory (default ~/.iuna)\n\n\
Environment:\n\
IUNA_DEV_SKIP_SEED_VERIFY=1 Show a setup button to skip seed verification\n"
}
+fn default_data_dir() -> PathBuf {
+ std::env::var_os("HOME")
+ .filter(|home| !home.is_empty())
+ .or_else(|| std::env::var_os("USERPROFILE").filter(|home| !home.is_empty()))
+ .map(PathBuf::from)
+ .map(|home| home.join(".iuna"))
+ .unwrap_or_else(|| PathBuf::from(".iuna"))
+}
+
fn snapshot_height(snapshot: &ChainSnapshot) -> u64 {
snapshot
.blocks
@@ -858,6 +867,13 @@ mod tests {
}
#[test]
+ fn data_dir_defaults_under_home() {
+ let opts = parse(&[]).unwrap().unwrap();
+ assert_eq!(opts.data_dir, super::default_data_dir());
+ assert!(opts.data_dir.ends_with(".iuna"));
+ }
+
+ #[test]
fn wallet_defaults_under_data_dir() {
let opts = parse(&["--genesis", "--data-dir", "tmp-node"])
.unwrap()
diff --git a/tests/iuna.rs b/tests/iuna.rs
@@ -1781,6 +1781,16 @@ fn peer_book_tracks_multiple_peers_without_networking() {
.unwrap();
assert_eq!(inbound_error.direction, PeerDirection::Inbound);
assert_eq!(inbound_error.last_error.as_deref(), Some("invalid nonce"));
+
+ assert!(peers.remove_peer("127.0.0.1:9445"));
+ assert!(!peers.addresses().contains(&"127.0.0.1:9445".to_string()));
+ assert!(!peers.remove_peer("127.0.0.1:9555"));
+ assert!(
+ peers
+ .list()
+ .iter()
+ .any(|peer| peer.address == "127.0.0.1:9555")
+ );
}
#[test]
diff --git a/www/index.html b/www/index.html
@@ -295,7 +295,6 @@
<div class="step">
<p>Or, if you already have a public seed node address, start iuna with that peer as your join target.</p>
<pre><code>./iuna \
- --data-dir .iuna \
--p2p 0.0.0.0:9445 \
--http 127.0.0.1:18661 \
--join seed.example:9444</code></pre>