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
This commit is contained in:
syoul
2026-04-25 23:02:32 +02:00
parent c1a81a9065
commit 95e7cb4bd3
9 changed files with 382 additions and 34 deletions

View File

@@ -1,5 +1,6 @@
pub mod admin; pub mod admin;
pub mod peers; pub mod peers;
pub mod routes;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use reqwest::{Client, Response}; use reqwest::{Client, Response};

View File

@@ -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<Route>,
pub fallback: Vec<Route>,
pub queried: Vec<QueriedSubnet>,
}
impl MyceliumClient {
pub async fn routes_selected(&self) -> AppResult<Vec<Route>> {
let r = self.http().get(self.url("/admin/routes/selected")).send().await?;
Self::parse(r).await
}
pub async fn routes_fallback(&self) -> AppResult<Vec<Route>> {
let r = self.http().get(self.url("/admin/routes/fallback")).send().await?;
Self::parse(r).await
}
pub async fn routes_queried(&self) -> AppResult<Vec<QueriedSubnet>> {
let r = self.http().get(self.url("/admin/routes/queried")).send().await?;
Self::parse(r).await
}
pub async fn routes_snapshot(&self) -> AppResult<RoutesSnapshot> {
// 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,
})
}
}

View File

@@ -1,5 +1,6 @@
use crate::api::admin::NodeInfo; use crate::api::admin::NodeInfo;
use crate::api::peers::{AggregatedStats, PeerInfo}; use crate::api::peers::{AggregatedStats, PeerInfo};
use crate::api::routes::RoutesSnapshot;
use crate::api::MyceliumClient; use crate::api::MyceliumClient;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::sidecar::SidecarConfig; use crate::sidecar::SidecarConfig;
@@ -91,3 +92,10 @@ pub async fn peers_stats(state: State<'_, AppState>) -> AppResult<AggregatedStat
let peers = require_client(&state)?.list_peers().await?; let peers = require_client(&state)?.list_peers().await?;
Ok(crate::api::peers::aggregate(&peers)) Ok(crate::api::peers::aggregate(&peers))
} }
// ─── Routes ──────────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn routes_snapshot(state: State<'_, AppState>) -> AppResult<RoutesSnapshot> {
require_client(&state)?.routes_snapshot().await
}

View File

@@ -39,6 +39,7 @@ pub fn run() {
commands::peer_add, commands::peer_add,
commands::peer_remove, commands::peer_remove,
commands::peers_stats, commands::peers_stats,
commands::routes_snapshot,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -8,26 +8,42 @@ use tokio::task::JoinHandle;
use tracing::warn; use tracing::warn;
const PEERS_INTERVAL: Duration = Duration::from_secs(3); const PEERS_INTERVAL: Duration = Duration::from_secs(3);
const ROUTES_INTERVAL: Duration = Duration::from_secs(5);
pub struct Poller { pub struct Poller {
handle: Mutex<Option<JoinHandle<()>>>, peers_handle: Mutex<Option<JoinHandle<()>>>,
routes_handle: Mutex<Option<JoinHandle<()>>>,
} }
impl Poller { impl Poller {
pub fn new() -> Arc<Self> { pub fn new() -> Arc<Self> {
Arc::new(Self { 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 /// Spawn the two background loops. Cancels any previously-running tasks
/// fans the result out as `peers://updated` and an aggregated /// so consecutive `start_daemon` calls don't leak handles.
/// `stats://updated` event. Cancels any previously-running task.
pub fn start(self: &Arc<Self>, app: AppHandle, sidecar: Arc<SidecarHandle>) { pub fn start(self: &Arc<Self>, app: AppHandle, sidecar: Arc<SidecarHandle>) {
self.stop(); self.stop();
let h = tokio::spawn(async move { *self.peers_handle.lock() = Some(spawn_peers_loop(app.clone(), Arc::clone(&sidecar)));
// First tick is immediate so the UI doesn't wait a full interval *self.routes_handle.lock() = Some(spawn_routes_loop(app, sidecar));
// for the first peer list right after the daemon comes up. }
pub fn stop(&self) {
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<SidecarHandle>) -> JoinHandle<()> {
tokio::spawn(async move {
// Tick once immediately so the UI doesn't wait the full interval.
let mut first = true; let mut first = true;
loop { loop {
if !first { if !first {
@@ -44,18 +60,30 @@ impl Poller {
let _ = app.emit("peers://updated", &list); let _ = app.emit("peers://updated", &list);
let _ = app.emit("stats://updated", &stats); let _ = app.emit("stats://updated", &stats);
} }
Err(e) => { Err(e) => warn!(error = %e, "poller: list_peers failed"),
warn!(error = %e, "poller: list_peers failed");
} }
} }
} })
});
*self.handle.lock() = Some(h);
} }
pub fn stop(&self) { fn spawn_routes_loop(app: AppHandle, sidecar: Arc<SidecarHandle>) -> JoinHandle<()> {
if let Some(h) = self.handle.lock().take() { tokio::spawn(async move {
h.abort(); 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"),
} }
} }
})
} }

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { Metric, Route } from "@/lib/api";
defineProps<{
rows: Route[];
ready: boolean;
}>();
function metricLabel(m: Metric): string {
return typeof m === "number" ? String(m) : m;
}
function metricClass(m: Metric): string {
if (typeof m === "number") {
if (m === 0) return "text-emerald-500";
if (m < 10) return "text-foreground";
return "text-yellow-500";
}
return "text-destructive";
}
function rowKey(r: Route): string {
return `${r.subnet}|${r.nextHop}|${r.seqno}`;
}
</script>
<template>
<div class="overflow-hidden rounded-lg border border-border">
<table class="w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase text-muted-foreground">
<tr>
<th class="px-4 py-2 text-left font-medium">Subnet</th>
<th class="px-4 py-2 text-left font-medium">Next hop</th>
<th class="px-4 py-2 text-right font-medium">Metric</th>
<th class="px-4 py-2 text-right font-medium">Seqno</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr v-if="!rows.length">
<td colspan="4" class="px-4 py-8 text-center text-muted-foreground">
{{ ready ? "No routes." : "Daemon offline." }}
</td>
</tr>
<tr v-for="r in rows" :key="rowKey(r)" class="hover:bg-muted/30">
<td class="px-4 py-2 font-mono text-xs break-all">{{ r.subnet }}</td>
<td class="px-4 py-2 font-mono text-xs break-all">{{ r.nextHop }}</td>
<td
class="px-4 py-2 text-right font-mono text-xs"
:class="metricClass(r.metric)"
>
{{ metricLabel(r.metric) }}
</td>
<td class="px-4 py-2 text-right font-mono text-xs">{{ r.seqno }}</td>
</tr>
</tbody>
</table>
</div>
</template>

View File

@@ -50,6 +50,30 @@ export interface AggregatedStats {
rxBytes: number; 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 = { export const api = {
daemonStatus: () => cmd<DaemonStatus>("daemon_status"), daemonStatus: () => cmd<DaemonStatus>("daemon_status"),
startDaemon: (config?: SidecarConfig) => startDaemon: (config?: SidecarConfig) =>
@@ -62,6 +86,8 @@ export const api = {
peerAdd: (endpoint: string) => cmd<void>("peer_add", { endpoint }), peerAdd: (endpoint: string) => cmd<void>("peer_add", { endpoint }),
peerRemove: (endpoint: string) => cmd<void>("peer_remove", { endpoint }), peerRemove: (endpoint: string) => cmd<void>("peer_remove", { endpoint }),
peersStats: () => cmd<AggregatedStats>("peers_stats"), peersStats: () => cmd<AggregatedStats>("peers_stats"),
routesSnapshot: () => cmd<RoutesSnapshot>("routes_snapshot"),
}; };
/** Format the canonical peer endpoint string the API expects. */ /** Format the canonical peer endpoint string the API expects. */

43
src/stores/routes.ts Normal file
View File

@@ -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<RoutesSnapshot>({
selected: [],
fallback: [],
queried: [],
});
const loading = ref(false);
const error = ref<string | null>(null);
let unlisten: (() => void) | null = null;
async function bootstrap() {
if (!unlisten) {
unlisten = await on<RoutesSnapshot>(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 };
});

View File

@@ -1,5 +1,120 @@
<script setup lang="ts"></script> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { storeToRefs } from "pinia";
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from "radix-vue";
import RouteTable from "@/components/RouteTable.vue";
import { useRoutesStore } from "@/stores/routes";
import { useNodeStore } from "@/stores/node";
import type { QueriedSubnet } from "@/lib/api";
const routesStore = useRoutesStore();
const node = useNodeStore();
const { snapshot, error } = storeToRefs(routesStore);
const { phase } = storeToRefs(node);
const isReady = computed(() => phase.value === "ready");
const tab = ref<"selected" | "fallback" | "queried">("selected");
// Drives the per-second refresh of the queried-subnet countdown without
// mutating the store; we just read tick.value inside expiresIn().
const tick = ref(0);
let timer: ReturnType<typeof setInterval> | null = null;
onMounted(async () => {
await routesStore.bootstrap();
if (isReady.value) await routesStore.refresh();
timer = setInterval(() => (tick.value += 1), 1000);
});
onBeforeUnmount(() => {
routesStore.dispose();
if (timer) clearInterval(timer);
});
function expiresIn(expiration: string): string {
void tick.value;
const ms = Date.parse(expiration);
if (!Number.isFinite(ms)) return expiration;
const delta = Math.round((ms - Date.now()) / 1000);
if (delta <= 0) return "expired";
if (delta < 60) return `${delta}s`;
if (delta < 3600) return `${Math.floor(delta / 60)}m ${delta % 60}s`;
return `${Math.floor(delta / 3600)}h ${Math.floor((delta % 3600) / 60)}m`;
}
function queryKey(q: QueriedSubnet): string {
return `${q.subnet}|${q.expiration}`;
}
</script>
<template> <template>
<p class="text-sm text-muted-foreground">Routes view wired in P3.</p> <TabsRoot v-model="tab" class="space-y-4">
<TabsList class="inline-flex rounded-md border border-border bg-card p-1">
<TabsTrigger
value="selected"
class="rounded px-3 py-1.5 text-sm data-[state=active]:bg-secondary data-[state=active]:text-secondary-foreground"
>
Selected
<span class="ml-2 text-xs text-muted-foreground">
{{ snapshot.selected.length }}
</span>
</TabsTrigger>
<TabsTrigger
value="fallback"
class="rounded px-3 py-1.5 text-sm data-[state=active]:bg-secondary data-[state=active]:text-secondary-foreground"
>
Fallback
<span class="ml-2 text-xs text-muted-foreground">
{{ snapshot.fallback.length }}
</span>
</TabsTrigger>
<TabsTrigger
value="queried"
class="rounded px-3 py-1.5 text-sm data-[state=active]:bg-secondary data-[state=active]:text-secondary-foreground"
>
Queried
<span class="ml-2 text-xs text-muted-foreground">
{{ snapshot.queried.length }}
</span>
</TabsTrigger>
</TabsList>
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
<TabsContent value="selected">
<RouteTable :rows="snapshot.selected" :ready="isReady" />
</TabsContent>
<TabsContent value="fallback">
<RouteTable :rows="snapshot.fallback" :ready="isReady" />
</TabsContent>
<TabsContent value="queried">
<div class="overflow-hidden rounded-lg border border-border">
<table class="w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase text-muted-foreground">
<tr>
<th class="px-4 py-2 text-left font-medium">Subnet</th>
<th class="px-4 py-2 text-left font-medium">Expires in</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr v-if="!snapshot.queried.length">
<td colspan="2" class="px-4 py-8 text-center text-muted-foreground">
{{ isReady ? "No queried subnets." : "Daemon offline." }}
</td>
</tr>
<tr
v-for="q in snapshot.queried"
:key="queryKey(q)"
class="hover:bg-muted/30"
>
<td class="px-4 py-2 font-mono text-xs break-all">{{ q.subnet }}</td>
<td class="px-4 py-2 font-mono text-xs">
{{ expiresIn(q.expiration) }}
</td>
</tr>
</tbody>
</table>
</div>
</TabsContent>
</TabsRoot>
</template> </template>