P2: peers CRUD and aggregated stats

Backend
- api/peers.rs: list/add/remove + aggregate() that derives totals,
  per-state counts, and tx/rx sums in one pass over the peer list
- poller.rs spawns a 3s tokio loop that emits peers://updated and
  stats://updated; cancelled via abort() on stop_daemon
- DELETE peer URL-encodes the endpoint (the path includes ://) with
  a small inline percent-encoder to avoid a url crate dep
- Tauri commands: peers_list, peer_add (with empty-string guard),
  peer_remove, peers_stats

Frontend
- peers store subscribes to the two events and refreshes after
  add/remove for immediate UI feedback
- Peers view renders endpoint, type, color-coded state badge, and
  formatBytes-formatted rx/tx; the four stat cards re-use a
  reusable Stat component
- AddPeerDialog uses radix-vue's Dialog primitive with regex
  validation for tcp:// and quic:// schemes
This commit is contained in:
syoul
2026-04-25 22:56:50 +02:00
parent d737231123
commit c1a81a9065
11 changed files with 608 additions and 8 deletions

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import {
DialogRoot,
DialogTrigger,
DialogPortal,
DialogOverlay,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose,
} from "radix-vue";
import { Plus, X, Loader2 } from "lucide-vue-next";
import { usePeersStore } from "@/stores/peers";
const peersStore = usePeersStore();
const open = ref(false);
const value = ref("");
const submitting = ref(false);
const error = ref<string | null>(null);
const ENDPOINT_RE = /^(tcp|quic):\/\/.+:\d+$/i;
watch(open, (v) => {
if (!v) {
value.value = "";
error.value = null;
submitting.value = false;
}
});
async function submit() {
const ep = value.value.trim();
if (!ENDPOINT_RE.test(ep)) {
error.value = "Format: tcp://host:port or quic://host:port";
return;
}
submitting.value = true;
error.value = null;
try {
await peersStore.add(ep);
open.value = false;
} catch (e) {
error.value = String(e);
} finally {
submitting.value = false;
}
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogTrigger
class="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
>
<Plus class="h-4 w-4" />
Add peer
</DialogTrigger>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out"
/>
<DialogContent
class="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-card p-6 shadow-lg"
>
<div class="flex items-start justify-between gap-4">
<div>
<DialogTitle class="text-lg font-semibold">
Add a peer
</DialogTitle>
<DialogDescription class="mt-1 text-sm text-muted-foreground">
Enter a TCP or QUIC endpoint. The daemon will attempt to connect
and propagate routes from this peer.
</DialogDescription>
</div>
<DialogClose class="text-muted-foreground hover:text-foreground">
<X class="h-4 w-4" />
</DialogClose>
</div>
<form class="mt-4 space-y-3" @submit.prevent="submit">
<input
v-model="value"
type="text"
spellcheck="false"
autocomplete="off"
placeholder="tcp://188.40.132.242:9651"
class="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
:disabled="submitting"
/>
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
<div class="flex justify-end gap-2 pt-2">
<DialogClose
class="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-secondary"
:disabled="submitting"
>
Cancel
</DialogClose>
<button
type="submit"
class="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-60"
:disabled="submitting"
>
<Loader2 v-if="submitting" class="h-3 w-3 animate-spin" />
Add
</button>
</div>
</form>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

32
src/components/Stat.vue Normal file
View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { PropType } from "vue";
defineProps({
label: { type: String, required: true },
value: { type: String, required: true },
tone: {
type: String as PropType<"alive" | "connecting" | "dead" | undefined>,
default: undefined,
},
});
const toneClass: Record<string, string> = {
alive: "text-emerald-500",
connecting: "text-yellow-500",
dead: "text-destructive",
};
</script>
<template>
<div class="rounded-md border border-border bg-card px-3 py-2 min-w-[5rem]">
<div class="text-xs uppercase tracking-wide text-muted-foreground">
{{ label }}
</div>
<div
class="mt-0.5 text-lg font-semibold"
:class="tone ? toneClass[tone] : ''"
>
{{ value }}
</div>
</div>
</template>

View File

@@ -25,6 +25,31 @@ export interface SidecarConfig {
const cmd = <T>(name: string, args?: Record<string, unknown>) =>
invoke<T>(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<DaemonStatus>("daemon_status"),
startDaemon: (config?: SidecarConfig) =>
@@ -32,4 +57,16 @@ export const api = {
stopDaemon: () => cmd<DaemonStatus>("stop_daemon"),
nodeInfo: () => cmd<NodeInfo>("node_info"),
sidecarLogs: () => cmd<string[]>("sidecar_logs"),
peersList: () => cmd<PeerInfo[]>("peers_list"),
peerAdd: (endpoint: string) => cmd<void>("peer_add", { endpoint }),
peerRemove: (endpoint: string) => cmd<void>("peer_remove", { endpoint }),
peersStats: () => cmd<AggregatedStats>("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}`;
}

66
src/stores/peers.ts Normal file
View File

@@ -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<PeerInfo[]>([]);
const stats = ref<AggregatedStats>({
peersTotal: 0,
peersAlive: 0,
peersConnecting: 0,
peersDead: 0,
txBytes: 0,
rxBytes: 0,
});
const loading = ref(false);
const error = ref<string | null>(null);
let peersUnlisten: (() => void) | null = null;
let statsUnlisten: (() => void) | null = null;
async function bootstrap() {
if (!peersUnlisten) {
peersUnlisten = await on<PeerInfo[]>(Events.PeersUpdated, (e) => {
peers.value = e.payload;
});
}
if (!statsUnlisten) {
statsUnlisten = await on<AggregatedStats>(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 };
});

View File

@@ -1,5 +1,141 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, computed } from "vue";
import { storeToRefs } from "pinia";
import { Trash2, ArrowDown, ArrowUp } from "lucide-vue-next";
import AddPeerDialog from "@/components/AddPeerDialog.vue";
import Stat from "@/components/Stat.vue";
import { usePeersStore } from "@/stores/peers";
import { useNodeStore } from "@/stores/node";
import { endpointString, type ConnectionState } from "@/lib/api";
import { formatBytes } from "@/lib/utils";
const peersStore = usePeersStore();
const node = useNodeStore();
const { peers, stats, error } = storeToRefs(peersStore);
const { phase } = storeToRefs(node);
const isReady = computed(() => phase.value === "ready");
onMounted(async () => {
await peersStore.bootstrap();
if (isReady.value) {
await peersStore.refresh();
}
});
onBeforeUnmount(() => {
peersStore.dispose();
});
function badgeFor(state: ConnectionState): string {
switch (state) {
case "alive":
return "bg-emerald-500/15 text-emerald-500 border-emerald-500/30";
case "connecting":
return "bg-yellow-500/15 text-yellow-500 border-yellow-500/30";
case "dead":
return "bg-destructive/15 text-destructive border-destructive/30";
default:
return "bg-muted text-muted-foreground border-border";
}
}
async function remove(endpoint: string) {
if (!confirm(`Remove ${endpoint}?`)) return;
try {
await peersStore.remove(endpoint);
} catch (e) {
alert(`Could not remove peer: ${e}`);
}
}
</script>
<template>
<p class="text-sm text-muted-foreground">Peers view wired in P2.</p>
<div class="space-y-4">
<div class="flex flex-wrap items-center gap-3">
<Stat label="Peers" :value="String(stats.peersTotal)" />
<Stat label="Alive" :value="String(stats.peersAlive)" tone="alive" />
<Stat
label="Connecting"
:value="String(stats.peersConnecting)"
tone="connecting"
/>
<Stat label="Dead" :value="String(stats.peersDead)" tone="dead" />
<div class="ml-auto">
<AddPeerDialog />
</div>
</div>
<div class="flex gap-3 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1">
<ArrowDown class="h-3 w-3" /> {{ formatBytes(stats.rxBytes) }}
</span>
<span class="inline-flex items-center gap-1">
<ArrowUp class="h-3 w-3" /> {{ formatBytes(stats.txBytes) }}
</span>
</div>
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
<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">Endpoint</th>
<th class="px-4 py-2 text-left font-medium">Type</th>
<th class="px-4 py-2 text-left font-medium">State</th>
<th class="px-4 py-2 text-right font-medium">RX</th>
<th class="px-4 py-2 text-right font-medium">TX</th>
<th class="px-4 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr v-if="!peers.length">
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground">
{{
isReady
? "No peers yet — add one to get started."
: "Daemon offline."
}}
</td>
</tr>
<tr
v-for="peer in peers"
:key="endpointString(peer.endpoint)"
class="hover:bg-muted/30"
>
<td class="px-4 py-2 font-mono text-xs break-all">
{{ endpointString(peer.endpoint) }}
</td>
<td class="px-4 py-2">
<span class="text-xs text-muted-foreground">{{ peer.type }}</span>
</td>
<td class="px-4 py-2">
<span
class="inline-block rounded-full border px-2 py-0.5 text-xs"
:class="badgeFor(peer.connectionState)"
>
{{ peer.connectionState }}
</span>
</td>
<td class="px-4 py-2 text-right font-mono text-xs">
{{ formatBytes(peer.rxBytes) }}
</td>
<td class="px-4 py-2 text-right font-mono text-xs">
{{ formatBytes(peer.txBytes) }}
</td>
<td class="px-4 py-2 text-right">
<button
class="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
title="Remove peer"
@click="remove(endpointString(peer.endpoint))"
>
<Trash2 class="h-4 w-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>