P2: peers CRUD and aggregated stats
Backend - api/peers.rs: list/add/remove + aggregate() that derives totals, per-state counts, and tx/rx sums in one pass over the peer list - poller.rs spawns a 3s tokio loop that emits peers://updated and stats://updated; cancelled via abort() on stop_daemon - DELETE peer URL-encodes the endpoint (the path includes ://) with a small inline percent-encoder to avoid a url crate dep - Tauri commands: peers_list, peer_add (with empty-string guard), peer_remove, peers_stats Frontend - peers store subscribes to the two events and refreshes after add/remove for immediate UI feedback - Peers view renders endpoint, type, color-coded state badge, and formatBytes-formatted rx/tx; the four stat cards re-use a reusable Stat component - AddPeerDialog uses radix-vue's Dialog primitive with regex validation for tcp:// and quic:// schemes
This commit is contained in:
107
src-tauri/src/api/peers.rs
Normal file
107
src-tauri/src/api/peers.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use crate::api::MyceliumClient;
|
||||
use crate::error::AppResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PeerEndpoint {
|
||||
pub proto: String,
|
||||
pub socket_addr: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PeerInfo {
|
||||
pub endpoint: PeerEndpoint,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String, // "static" | "inbound" | "linkLocalDiscovery"
|
||||
pub connection_state: String, // "alive" | "connecting" | "dead"
|
||||
#[serde(default)]
|
||||
pub tx_bytes: u64,
|
||||
#[serde(default)]
|
||||
pub rx_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AggregatedStats {
|
||||
pub peers_total: usize,
|
||||
pub peers_alive: usize,
|
||||
pub peers_connecting: usize,
|
||||
pub peers_dead: usize,
|
||||
pub tx_bytes: u64,
|
||||
pub rx_bytes: u64,
|
||||
}
|
||||
|
||||
pub fn aggregate(peers: &[PeerInfo]) -> AggregatedStats {
|
||||
let mut s = AggregatedStats {
|
||||
peers_total: peers.len(),
|
||||
..Default::default()
|
||||
};
|
||||
for p in peers {
|
||||
s.tx_bytes = s.tx_bytes.saturating_add(p.tx_bytes);
|
||||
s.rx_bytes = s.rx_bytes.saturating_add(p.rx_bytes);
|
||||
match p.connection_state.as_str() {
|
||||
"alive" => s.peers_alive += 1,
|
||||
"connecting" => s.peers_connecting += 1,
|
||||
"dead" => s.peers_dead += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AddPeerBody<'a> {
|
||||
endpoint: &'a str,
|
||||
}
|
||||
|
||||
impl MyceliumClient {
|
||||
pub async fn list_peers(&self) -> AppResult<Vec<PeerInfo>> {
|
||||
let resp = self.http().get(self.url("/admin/peers")).send().await?;
|
||||
Self::parse(resp).await
|
||||
}
|
||||
|
||||
/// The upstream OpenAPI doesn't pin the request body shape; the daemon
|
||||
/// accepts `{"endpoint": "<proto>://<addr>:<port>"}`.
|
||||
pub async fn add_peer(&self, endpoint: &str) -> AppResult<()> {
|
||||
let resp = self
|
||||
.http()
|
||||
.post(self.url("/admin/peers"))
|
||||
.json(&AddPeerBody { endpoint })
|
||||
.send()
|
||||
.await?;
|
||||
Self::check_status(resp).await
|
||||
}
|
||||
|
||||
pub async fn remove_peer(&self, endpoint: &str) -> AppResult<()> {
|
||||
// Endpoints look like `tcp://188.40.132.242:9651` — the slashes and
|
||||
// colons must be percent-encoded for the path segment.
|
||||
let encoded = url_encode_path_segment(endpoint);
|
||||
let resp = self
|
||||
.http()
|
||||
.delete(self.url(&format!("/admin/peers/{encoded}")))
|
||||
.send()
|
||||
.await?;
|
||||
Self::check_status(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
fn url_encode_path_segment(s: &str) -> String {
|
||||
// Minimal ASCII percent-encoder for the chars that would otherwise
|
||||
// break path parsing. Avoids pulling a full url-encoding crate.
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'a'..=b'z'
|
||||
| b'A'..=b'Z'
|
||||
| b'0'..=b'9'
|
||||
| b'-'
|
||||
| b'_'
|
||||
| b'.'
|
||||
| b'~' => out.push(b as char),
|
||||
_ => out.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user