From 95e7cb4bd37b3eb216a49f60f7b86b3cfb1df9a1 Mon Sep 17 00:00:00 2001 From: syoul Date: Sat, 25 Apr 2026 23:02:32 +0200 Subject: [PATCH] P3: routes (selected, fallback, queried) Backend - api/routes.rs models the Babel-style route shape; metric uses an untagged enum to round-trip both numeric hop counts and the literal "infinite" string the daemon emits for poisoned routes - routes_snapshot() runs the three GETs concurrently with try_join so the snapshot is internally consistent - poller spawns a second 5s loop emitting routes://updated; both loops are owned by the Poller and aborted together on stop_daemon Frontend - routes store mirrors the snapshot shape; tabbed view (radix-vue) with selected, fallback and queried lists - RouteTable component shared by selected/fallback; metric column is colour-coded (0 green, low neutral, high yellow, infinite red) - Queried subnets show a live `expires in 12s` countdown driven by a 1Hz tick ref instead of mutating the store --- src-tauri/src/api/mod.rs | 1 + src-tauri/src/api/routes.rs | 68 +++++++++++++++++++ src-tauri/src/commands.rs | 8 +++ src-tauri/src/lib.rs | 1 + src-tauri/src/poller.rs | 92 +++++++++++++++++--------- src/components/RouteTable.vue | 58 +++++++++++++++++ src/lib/api.ts | 26 ++++++++ src/stores/routes.ts | 43 ++++++++++++ src/views/Routes.vue | 119 +++++++++++++++++++++++++++++++++- 9 files changed, 382 insertions(+), 34 deletions(-) create mode 100644 src-tauri/src/api/routes.rs create mode 100644 src/components/RouteTable.vue create mode 100644 src/stores/routes.ts diff --git a/src-tauri/src/api/mod.rs b/src-tauri/src/api/mod.rs index b863094..5be78c7 100644 --- a/src-tauri/src/api/mod.rs +++ b/src-tauri/src/api/mod.rs @@ -1,5 +1,6 @@ pub mod admin; pub mod peers; +pub mod routes; use crate::error::{AppError, AppResult}; use reqwest::{Client, Response}; diff --git a/src-tauri/src/api/routes.rs b/src-tauri/src/api/routes.rs new file mode 100644 index 0000000..98fa64b --- /dev/null +++ b/src-tauri/src/api/routes.rs @@ -0,0 +1,68 @@ +use crate::api::MyceliumClient; +use crate::error::AppResult; +use serde::{Deserialize, Serialize}; + +/// The daemon serializes the metric as either an unsigned integer for +/// reachable routes, or the literal string "infinite" for poisoned ones. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Metric { + Value(u64), + Infinite(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Route { + pub subnet: String, + pub next_hop: String, + pub metric: Metric, + pub seqno: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueriedSubnet { + pub subnet: String, + pub expiration: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct RoutesSnapshot { + pub selected: Vec, + pub fallback: Vec, + pub queried: Vec, +} + +impl MyceliumClient { + pub async fn routes_selected(&self) -> AppResult> { + let r = self.http().get(self.url("/admin/routes/selected")).send().await?; + Self::parse(r).await + } + + pub async fn routes_fallback(&self) -> AppResult> { + let r = self.http().get(self.url("/admin/routes/fallback")).send().await?; + Self::parse(r).await + } + + pub async fn routes_queried(&self) -> AppResult> { + let r = self.http().get(self.url("/admin/routes/queried")).send().await?; + Self::parse(r).await + } + + pub async fn routes_snapshot(&self) -> AppResult { + // Issue the three calls concurrently so the snapshot reflects a + // near-coincident view of the routing table. + let (sel, fb, q) = tokio::try_join!( + self.routes_selected(), + self.routes_fallback(), + self.routes_queried(), + )?; + Ok(RoutesSnapshot { + selected: sel, + fallback: fb, + queried: q, + }) + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 06232d0..6f9273a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,5 +1,6 @@ use crate::api::admin::NodeInfo; use crate::api::peers::{AggregatedStats, PeerInfo}; +use crate::api::routes::RoutesSnapshot; use crate::api::MyceliumClient; use crate::error::{AppError, AppResult}; use crate::sidecar::SidecarConfig; @@ -91,3 +92,10 @@ pub async fn peers_stats(state: State<'_, AppState>) -> AppResult) -> AppResult { + require_client(&state)?.routes_snapshot().await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dab2a03..2bd696b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -39,6 +39,7 @@ pub fn run() { commands::peer_add, commands::peer_remove, commands::peers_stats, + commands::routes_snapshot, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/poller.rs b/src-tauri/src/poller.rs index 002fd03..ad53830 100644 --- a/src-tauri/src/poller.rs +++ b/src-tauri/src/poller.rs @@ -8,54 +8,82 @@ use tokio::task::JoinHandle; use tracing::warn; const PEERS_INTERVAL: Duration = Duration::from_secs(3); +const ROUTES_INTERVAL: Duration = Duration::from_secs(5); pub struct Poller { - handle: Mutex>>, + peers_handle: Mutex>>, + routes_handle: Mutex>>, } impl Poller { pub fn new() -> Arc { Arc::new(Self { - handle: Mutex::new(None), + peers_handle: Mutex::new(None), + routes_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. + /// Spawn the two background loops. Cancels any previously-running tasks + /// so consecutive `start_daemon` calls don't leak handles. 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); + *self.peers_handle.lock() = Some(spawn_peers_loop(app.clone(), Arc::clone(&sidecar))); + *self.routes_handle.lock() = Some(spawn_routes_loop(app, sidecar)); } pub fn stop(&self) { - if let Some(h) = self.handle.lock().take() { + if let Some(h) = self.peers_handle.lock().take() { + h.abort(); + } + if let Some(h) = self.routes_handle.lock().take() { h.abort(); } } } + +fn spawn_peers_loop(app: AppHandle, sidecar: Arc) -> JoinHandle<()> { + tokio::spawn(async move { + // Tick once immediately so the UI doesn't wait the full interval. + 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"), + } + } + }) +} + +fn spawn_routes_loop(app: AppHandle, sidecar: Arc) -> JoinHandle<()> { + tokio::spawn(async move { + let mut first = true; + loop { + if !first { + tokio::time::sleep(ROUTES_INTERVAL).await; + } + first = false; + + let Some(client) = sidecar.client() else { + break; + }; + match client.routes_snapshot().await { + Ok(snap) => { + let _ = app.emit("routes://updated", &snap); + } + Err(e) => warn!(error = %e, "poller: routes_snapshot failed"), + } + } + }) +} diff --git a/src/components/RouteTable.vue b/src/components/RouteTable.vue new file mode 100644 index 0000000..9261632 --- /dev/null +++ b/src/components/RouteTable.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/lib/api.ts b/src/lib/api.ts index 30f62d9..1f9bd9e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -50,6 +50,30 @@ export interface AggregatedStats { rxBytes: number; } +// Routes +// +// `metric` is either a non-negative integer (number of hops) or the literal +// string "infinite" — preserved verbatim from the daemon JSON. +export type Metric = number | string; + +export interface Route { + subnet: string; + nextHop: string; + metric: Metric; + seqno: number; +} + +export interface QueriedSubnet { + subnet: string; + expiration: string; +} + +export interface RoutesSnapshot { + selected: Route[]; + fallback: Route[]; + queried: QueriedSubnet[]; +} + export const api = { daemonStatus: () => cmd("daemon_status"), startDaemon: (config?: SidecarConfig) => @@ -62,6 +86,8 @@ export const api = { peerAdd: (endpoint: string) => cmd("peer_add", { endpoint }), peerRemove: (endpoint: string) => cmd("peer_remove", { endpoint }), peersStats: () => cmd("peers_stats"), + + routesSnapshot: () => cmd("routes_snapshot"), }; /** Format the canonical peer endpoint string the API expects. */ diff --git a/src/stores/routes.ts b/src/stores/routes.ts new file mode 100644 index 0000000..df5c782 --- /dev/null +++ b/src/stores/routes.ts @@ -0,0 +1,43 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { api, type RoutesSnapshot } from "@/lib/api"; +import { Events, on } from "@/lib/events"; + +export const useRoutesStore = defineStore("routes", () => { + const snapshot = ref({ + selected: [], + fallback: [], + queried: [], + }); + const loading = ref(false); + const error = ref(null); + + let unlisten: (() => void) | null = null; + + async function bootstrap() { + if (!unlisten) { + unlisten = await on(Events.RoutesUpdated, (e) => { + snapshot.value = e.payload; + }); + } + } + + async function refresh() { + loading.value = true; + error.value = null; + try { + snapshot.value = await api.routesSnapshot(); + } catch (e) { + error.value = String(e); + } finally { + loading.value = false; + } + } + + function dispose() { + unlisten?.(); + unlisten = null; + } + + return { snapshot, loading, error, bootstrap, refresh, dispose }; +}); diff --git a/src/views/Routes.vue b/src/views/Routes.vue index 0335fee..05a7603 100644 --- a/src/views/Routes.vue +++ b/src/views/Routes.vue @@ -1,5 +1,120 @@ - +