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:
@@ -1,4 +1,5 @@
|
||||
pub mod admin;
|
||||
pub mod peers;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use reqwest::{Client, Response};
|
||||
@@ -48,7 +49,6 @@ impl MyceliumClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // wired in P2 (peers add/remove)
|
||||
pub(crate) async fn check_status(resp: Response) -> AppResult<()> {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
|
||||
107
src-tauri/src/api/peers.rs
Normal file
107
src-tauri/src/api/peers.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use crate::api::MyceliumClient;
|
||||
use crate::error::AppResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PeerEndpoint {
|
||||
pub proto: String,
|
||||
pub socket_addr: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PeerInfo {
|
||||
pub endpoint: PeerEndpoint,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String, // "static" | "inbound" | "linkLocalDiscovery"
|
||||
pub connection_state: String, // "alive" | "connecting" | "dead"
|
||||
#[serde(default)]
|
||||
pub tx_bytes: u64,
|
||||
#[serde(default)]
|
||||
pub rx_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AggregatedStats {
|
||||
pub peers_total: usize,
|
||||
pub peers_alive: usize,
|
||||
pub peers_connecting: usize,
|
||||
pub peers_dead: usize,
|
||||
pub tx_bytes: u64,
|
||||
pub rx_bytes: u64,
|
||||
}
|
||||
|
||||
pub fn aggregate(peers: &[PeerInfo]) -> AggregatedStats {
|
||||
let mut s = AggregatedStats {
|
||||
peers_total: peers.len(),
|
||||
..Default::default()
|
||||
};
|
||||
for p in peers {
|
||||
s.tx_bytes = s.tx_bytes.saturating_add(p.tx_bytes);
|
||||
s.rx_bytes = s.rx_bytes.saturating_add(p.rx_bytes);
|
||||
match p.connection_state.as_str() {
|
||||
"alive" => s.peers_alive += 1,
|
||||
"connecting" => s.peers_connecting += 1,
|
||||
"dead" => s.peers_dead += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AddPeerBody<'a> {
|
||||
endpoint: &'a str,
|
||||
}
|
||||
|
||||
impl MyceliumClient {
|
||||
pub async fn list_peers(&self) -> AppResult<Vec<PeerInfo>> {
|
||||
let resp = self.http().get(self.url("/admin/peers")).send().await?;
|
||||
Self::parse(resp).await
|
||||
}
|
||||
|
||||
/// The upstream OpenAPI doesn't pin the request body shape; the daemon
|
||||
/// accepts `{"endpoint": "<proto>://<addr>:<port>"}`.
|
||||
pub async fn add_peer(&self, endpoint: &str) -> AppResult<()> {
|
||||
let resp = self
|
||||
.http()
|
||||
.post(self.url("/admin/peers"))
|
||||
.json(&AddPeerBody { endpoint })
|
||||
.send()
|
||||
.await?;
|
||||
Self::check_status(resp).await
|
||||
}
|
||||
|
||||
pub async fn remove_peer(&self, endpoint: &str) -> AppResult<()> {
|
||||
// Endpoints look like `tcp://188.40.132.242:9651` — the slashes and
|
||||
// colons must be percent-encoded for the path segment.
|
||||
let encoded = url_encode_path_segment(endpoint);
|
||||
let resp = self
|
||||
.http()
|
||||
.delete(self.url(&format!("/admin/peers/{encoded}")))
|
||||
.send()
|
||||
.await?;
|
||||
Self::check_status(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
fn url_encode_path_segment(s: &str) -> String {
|
||||
// Minimal ASCII percent-encoder for the chars that would otherwise
|
||||
// break path parsing. Avoids pulling a full url-encoding crate.
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'a'..=b'z'
|
||||
| b'A'..=b'Z'
|
||||
| b'0'..=b'9'
|
||||
| b'-'
|
||||
| b'_'
|
||||
| b'.'
|
||||
| b'~' => out.push(b as char),
|
||||
_ => out.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::api::admin::NodeInfo;
|
||||
use crate::api::peers::{AggregatedStats, PeerInfo};
|
||||
use crate::api::MyceliumClient;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::sidecar::SidecarConfig;
|
||||
use crate::state::AppState;
|
||||
@@ -13,8 +15,7 @@ pub struct DaemonStatus {
|
||||
pub config_path: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn daemon_status(state: State<'_, AppState>) -> DaemonStatus {
|
||||
fn snapshot(state: &AppState) -> DaemonStatus {
|
||||
let sc = &state.sidecar;
|
||||
DaemonStatus {
|
||||
running: sc.is_running(),
|
||||
@@ -24,6 +25,15 @@ pub fn daemon_status(state: State<'_, AppState>) -> DaemonStatus {
|
||||
}
|
||||
}
|
||||
|
||||
fn require_client(state: &AppState) -> AppResult<MyceliumClient> {
|
||||
state.sidecar.client().ok_or(AppError::DaemonNotRunning)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn daemon_status(state: State<'_, AppState>) -> DaemonStatus {
|
||||
snapshot(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_daemon(
|
||||
app: AppHandle,
|
||||
@@ -32,18 +42,22 @@ pub async fn start_daemon(
|
||||
) -> AppResult<DaemonStatus> {
|
||||
let cfg = config.unwrap_or_default();
|
||||
state.sidecar.start(&app, &cfg).await?;
|
||||
Ok(daemon_status(state))
|
||||
state
|
||||
.poller
|
||||
.start(app.clone(), std::sync::Arc::clone(&state.sidecar));
|
||||
Ok(snapshot(&state))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_daemon(state: State<'_, AppState>) -> AppResult<DaemonStatus> {
|
||||
state.poller.stop();
|
||||
state.sidecar.stop().await;
|
||||
Ok(daemon_status(state))
|
||||
Ok(snapshot(&state))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn node_info(state: State<'_, AppState>) -> AppResult<NodeInfo> {
|
||||
let client = state.sidecar.client().ok_or(AppError::DaemonNotRunning)?;
|
||||
let client = require_client(&state)?;
|
||||
client.node_info().await
|
||||
}
|
||||
|
||||
@@ -51,3 +65,29 @@ pub async fn node_info(state: State<'_, AppState>) -> AppResult<NodeInfo> {
|
||||
pub fn sidecar_logs(state: State<'_, AppState>) -> Vec<String> {
|
||||
state.sidecar.logs_snapshot()
|
||||
}
|
||||
|
||||
// ─── Peers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn peers_list(state: State<'_, AppState>) -> AppResult<Vec<PeerInfo>> {
|
||||
require_client(&state)?.list_peers().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn peer_add(state: State<'_, AppState>, endpoint: String) -> AppResult<()> {
|
||||
if endpoint.trim().is_empty() {
|
||||
return Err(AppError::BadInput("endpoint must not be empty".into()));
|
||||
}
|
||||
require_client(&state)?.add_peer(endpoint.trim()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn peer_remove(state: State<'_, AppState>, endpoint: String) -> AppResult<()> {
|
||||
require_client(&state)?.remove_peer(&endpoint).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn peers_stats(state: State<'_, AppState>) -> AppResult<AggregatedStats> {
|
||||
let peers = require_client(&state)?.list_peers().await?;
|
||||
Ok(crate::api::peers::aggregate(&peers))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod api;
|
||||
pub mod commands;
|
||||
pub mod elevation;
|
||||
pub mod error;
|
||||
pub mod poller;
|
||||
pub mod sidecar;
|
||||
pub mod state;
|
||||
|
||||
@@ -34,6 +35,10 @@ pub fn run() {
|
||||
commands::stop_daemon,
|
||||
commands::node_info,
|
||||
commands::sidecar_logs,
|
||||
commands::peers_list,
|
||||
commands::peer_add,
|
||||
commands::peer_remove,
|
||||
commands::peers_stats,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
61
src-tauri/src/poller.rs
Normal file
61
src-tauri/src/poller.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use crate::api::peers;
|
||||
use crate::sidecar::SidecarHandle;
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::warn;
|
||||
|
||||
const PEERS_INTERVAL: Duration = Duration::from_secs(3);
|
||||
|
||||
pub struct Poller {
|
||||
handle: Mutex<Option<JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl Poller {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
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.
|
||||
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);
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
if let Some(h) = self.handle.lock().take() {
|
||||
h.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
use crate::poller::Poller;
|
||||
use crate::sidecar::SidecarHandle;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct AppState {
|
||||
pub sidecar: Arc<SidecarHandle>,
|
||||
pub poller: Arc<Poller>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sidecar: SidecarHandle::new(),
|
||||
poller: Poller::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
113
src/components/AddPeerDialog.vue
Normal file
113
src/components/AddPeerDialog.vue
Normal 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
32
src/components/Stat.vue
Normal 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>
|
||||
@@ -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
66
src/stores/peers.ts
Normal 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 };
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user