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:
@@ -1,5 +1,6 @@
|
||||
pub mod admin;
|
||||
pub mod peers;
|
||||
pub mod routes;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use reqwest::{Client, Response};
|
||||
|
||||
68
src-tauri/src/api/routes.rs
Normal file
68
src-tauri/src/api/routes.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<AggregatedStat
|
||||
let peers = require_client(&state)?.list_peers().await?;
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<Option<JoinHandle<()>>>,
|
||||
peers_handle: Mutex<Option<JoinHandle<()>>>,
|
||||
routes_handle: Mutex<Option<JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl Poller {
|
||||
pub fn new() -> Arc<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
|
||||
/// 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<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);
|
||||
*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<SidecarHandle>) -> 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<SidecarHandle>) -> 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"),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
58
src/components/RouteTable.vue
Normal file
58
src/components/RouteTable.vue
Normal 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>
|
||||
@@ -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<DaemonStatus>("daemon_status"),
|
||||
startDaemon: (config?: SidecarConfig) =>
|
||||
@@ -62,6 +86,8 @@ export const api = {
|
||||
peerAdd: (endpoint: string) => cmd<void>("peer_add", { endpoint }),
|
||||
peerRemove: (endpoint: string) => cmd<void>("peer_remove", { endpoint }),
|
||||
peersStats: () => cmd<AggregatedStats>("peers_stats"),
|
||||
|
||||
routesSnapshot: () => cmd<RoutesSnapshot>("routes_snapshot"),
|
||||
};
|
||||
|
||||
/** Format the canonical peer endpoint string the API expects. */
|
||||
|
||||
43
src/stores/routes.ts
Normal file
43
src/stores/routes.ts
Normal 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 };
|
||||
});
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user