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

@@ -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;
}
// 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
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>
<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>