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

View File

@@ -1,4 +1,6 @@
use crate::api::admin::NodeInfo;
use crate::api::peers::{AggregatedStats, PeerInfo};
use crate::api::MyceliumClient;
use crate::error::{AppError, AppResult};
use crate::sidecar::SidecarConfig;
use crate::state::AppState;
@@ -13,8 +15,7 @@ pub struct DaemonStatus {
pub config_path: Option<String>,
}
#[tauri::command]
pub fn daemon_status(state: State<'_, AppState>) -> DaemonStatus {
fn snapshot(state: &AppState) -> DaemonStatus {
let sc = &state.sidecar;
DaemonStatus {
running: sc.is_running(),
@@ -24,6 +25,15 @@ pub fn daemon_status(state: State<'_, AppState>) -> DaemonStatus {
}
}
fn require_client(state: &AppState) -> AppResult<MyceliumClient> {
state.sidecar.client().ok_or(AppError::DaemonNotRunning)
}
#[tauri::command]
pub fn daemon_status(state: State<'_, AppState>) -> DaemonStatus {
snapshot(&state)
}
#[tauri::command]
pub async fn start_daemon(
app: AppHandle,
@@ -32,18 +42,22 @@ pub async fn start_daemon(
) -> AppResult<DaemonStatus> {
let cfg = config.unwrap_or_default();
state.sidecar.start(&app, &cfg).await?;
Ok(daemon_status(state))
state
.poller
.start(app.clone(), std::sync::Arc::clone(&state.sidecar));
Ok(snapshot(&state))
}
#[tauri::command]
pub async fn stop_daemon(state: State<'_, AppState>) -> AppResult<DaemonStatus> {
state.poller.stop();
state.sidecar.stop().await;
Ok(daemon_status(state))
Ok(snapshot(&state))
}
#[tauri::command]
pub async fn node_info(state: State<'_, AppState>) -> AppResult<NodeInfo> {
let client = state.sidecar.client().ok_or(AppError::DaemonNotRunning)?;
let client = require_client(&state)?;
client.node_info().await
}
@@ -51,3 +65,29 @@ pub async fn node_info(state: State<'_, AppState>) -> AppResult<NodeInfo> {
pub fn sidecar_logs(state: State<'_, AppState>) -> Vec<String> {
state.sidecar.logs_snapshot()
}
// ─── Peers ───────────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn peers_list(state: State<'_, AppState>) -> AppResult<Vec<PeerInfo>> {
require_client(&state)?.list_peers().await
}
#[tauri::command]
pub async fn peer_add(state: State<'_, AppState>, endpoint: String) -> AppResult<()> {
if endpoint.trim().is_empty() {
return Err(AppError::BadInput("endpoint must not be empty".into()));
}
require_client(&state)?.add_peer(endpoint.trim()).await
}
#[tauri::command]
pub async fn peer_remove(state: State<'_, AppState>, endpoint: String) -> AppResult<()> {
require_client(&state)?.remove_peer(&endpoint).await
}
#[tauri::command]
pub async fn peers_stats(state: State<'_, AppState>) -> AppResult<AggregatedStats> {
let peers = require_client(&state)?.list_peers().await?;
Ok(crate::api::peers::aggregate(&peers))
}