diff --git a/src-tauri/src/api/mod.rs b/src-tauri/src/api/mod.rs index cd381d0..b863094 100644 --- a/src-tauri/src/api/mod.rs +++ b/src-tauri/src/api/mod.rs @@ -1,4 +1,5 @@ pub mod admin; +pub mod peers; use crate::error::{AppError, AppResult}; use reqwest::{Client, Response}; @@ -48,7 +49,6 @@ impl MyceliumClient { } } - #[allow(dead_code)] // wired in P2 (peers add/remove) pub(crate) async fn check_status(resp: Response) -> AppResult<()> { let status = resp.status(); if status.is_success() { diff --git a/src-tauri/src/api/peers.rs b/src-tauri/src/api/peers.rs new file mode 100644 index 0000000..a5f7b56 --- /dev/null +++ b/src-tauri/src/api/peers.rs @@ -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> { + 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": "://:"}`. + 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 +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7e99149..06232d0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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, } -#[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 { + 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 { 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 { + 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 { - 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 { pub fn sidecar_logs(state: State<'_, AppState>) -> Vec { state.sidecar.logs_snapshot() } + +// ─── Peers ─────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn peers_list(state: State<'_, AppState>) -> AppResult> { + 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 { + let peers = require_client(&state)?.list_peers().await?; + Ok(crate::api::peers::aggregate(&peers)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 73157a9..dab2a03 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod commands; pub mod elevation; pub mod error; +pub mod poller; pub mod sidecar; pub mod state; @@ -34,6 +35,10 @@ pub fn run() { commands::stop_daemon, commands::node_info, commands::sidecar_logs, + commands::peers_list, + commands::peer_add, + commands::peer_remove, + commands::peers_stats, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/poller.rs b/src-tauri/src/poller.rs new file mode 100644 index 0000000..002fd03 --- /dev/null +++ b/src-tauri/src/poller.rs @@ -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>>, +} + +impl Poller { + pub fn new() -> Arc { + 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, app: AppHandle, sidecar: Arc) { + 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(); + } + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 1a5a770..d51567f 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,14 +1,17 @@ +use crate::poller::Poller; use crate::sidecar::SidecarHandle; use std::sync::Arc; pub struct AppState { pub sidecar: Arc, + pub poller: Arc, } impl AppState { pub fn new() -> Self { Self { sidecar: SidecarHandle::new(), + poller: Poller::new(), } } } diff --git a/src/components/AddPeerDialog.vue b/src/components/AddPeerDialog.vue new file mode 100644 index 0000000..6bbe894 --- /dev/null +++ b/src/components/AddPeerDialog.vue @@ -0,0 +1,113 @@ + + + diff --git a/src/components/Stat.vue b/src/components/Stat.vue new file mode 100644 index 0000000..005b53c --- /dev/null +++ b/src/components/Stat.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/lib/api.ts b/src/lib/api.ts index 6b14d64..30f62d9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -25,6 +25,31 @@ export interface SidecarConfig { const cmd = (name: string, args?: Record) => invoke(name, args); +export interface PeerEndpoint { + proto: string; + socketAddr: string; +} + +export type PeerKind = "static" | "inbound" | "linkLocalDiscovery" | string; +export type ConnectionState = "alive" | "connecting" | "dead" | string; + +export interface PeerInfo { + endpoint: PeerEndpoint; + type: PeerKind; + connectionState: ConnectionState; + txBytes: number; + rxBytes: number; +} + +export interface AggregatedStats { + peersTotal: number; + peersAlive: number; + peersConnecting: number; + peersDead: number; + txBytes: number; + rxBytes: number; +} + export const api = { daemonStatus: () => cmd("daemon_status"), startDaemon: (config?: SidecarConfig) => @@ -32,4 +57,16 @@ export const api = { stopDaemon: () => cmd("stop_daemon"), nodeInfo: () => cmd("node_info"), sidecarLogs: () => cmd("sidecar_logs"), + + peersList: () => cmd("peers_list"), + peerAdd: (endpoint: string) => cmd("peer_add", { endpoint }), + peerRemove: (endpoint: string) => cmd("peer_remove", { endpoint }), + peersStats: () => cmd("peers_stats"), }; + +/** Format the canonical peer endpoint string the API expects. */ +export function endpointString(e: PeerEndpoint): string { + // socketAddr already contains host:port (with brackets for v6); proto is + // the URL scheme. + return `${e.proto}://${e.socketAddr}`; +} diff --git a/src/stores/peers.ts b/src/stores/peers.ts new file mode 100644 index 0000000..89bce4a --- /dev/null +++ b/src/stores/peers.ts @@ -0,0 +1,66 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { api, type AggregatedStats, type PeerInfo } from "@/lib/api"; +import { Events, on } from "@/lib/events"; + +export const usePeersStore = defineStore("peers", () => { + const peers = ref([]); + const stats = ref({ + peersTotal: 0, + peersAlive: 0, + peersConnecting: 0, + peersDead: 0, + txBytes: 0, + rxBytes: 0, + }); + const loading = ref(false); + const error = ref(null); + + let peersUnlisten: (() => void) | null = null; + let statsUnlisten: (() => void) | null = null; + + async function bootstrap() { + if (!peersUnlisten) { + peersUnlisten = await on(Events.PeersUpdated, (e) => { + peers.value = e.payload; + }); + } + if (!statsUnlisten) { + statsUnlisten = await on(Events.StatsUpdated, (e) => { + stats.value = e.payload; + }); + } + } + + async function refresh() { + loading.value = true; + error.value = null; + try { + peers.value = await api.peersList(); + stats.value = await api.peersStats(); + } catch (e) { + error.value = String(e); + } finally { + loading.value = false; + } + } + + async function add(endpoint: string) { + await api.peerAdd(endpoint); + await refresh(); + } + + async function remove(endpoint: string) { + await api.peerRemove(endpoint); + await refresh(); + } + + function dispose() { + peersUnlisten?.(); + statsUnlisten?.(); + peersUnlisten = null; + statsUnlisten = null; + } + + return { peers, stats, loading, error, bootstrap, refresh, add, remove, dispose }; +}); diff --git a/src/views/Peers.vue b/src/views/Peers.vue index 7239f1b..aea4910 100644 --- a/src/views/Peers.vue +++ b/src/views/Peers.vue @@ -1,5 +1,141 @@ - +