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:
syoul
2026-04-25 22:56:50 +02:00
parent d737231123
commit c1a81a9065
11 changed files with 608 additions and 8 deletions

61
src-tauri/src/poller.rs Normal file
View File

@@ -0,0 +1,61 @@
use crate::api::peers;
use crate::sidecar::SidecarHandle;
use parking_lot::Mutex;
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use tokio::task::JoinHandle;
use tracing::warn;
const PEERS_INTERVAL: Duration = Duration::from_secs(3);
pub struct Poller {
handle: Mutex<Option<JoinHandle<()>>>,
}
impl Poller {
pub fn new() -> Arc<Self> {
Arc::new(Self {
handle: Mutex::new(None),
})
}
/// Spawn a background task that pulls /admin/peers every few seconds and
/// fans the result out as `peers://updated` and an aggregated
/// `stats://updated` event. Cancels any previously-running task.
pub fn start(self: &Arc<Self>, app: AppHandle, sidecar: Arc<SidecarHandle>) {
self.stop();
let h = tokio::spawn(async move {
// First tick is immediate so the UI doesn't wait a full interval
// for the first peer list right after the daemon comes up.
let mut first = true;
loop {
if !first {
tokio::time::sleep(PEERS_INTERVAL).await;
}
first = false;
let Some(client) = sidecar.client() else {
break;
};
match client.list_peers().await {
Ok(list) => {
let stats = peers::aggregate(&list);
let _ = app.emit("peers://updated", &list);
let _ = app.emit("stats://updated", &stats);
}
Err(e) => {
warn!(error = %e, "poller: list_peers failed");
}
}
}
});
*self.handle.lock() = Some(h);
}
pub fn stop(&self) {
if let Some(h) = self.handle.lock().take() {
h.abort();
}
}
}