Add calls, NomadNet, rich messages, signal quality, propagation UI

- Voice calls page: initiate, view active, hang up
- NomadNet browser: discover nodes, browse pages
- Messages: show images, files, voice attachments, RSSI/SNR indicators
- Nodes: display signal quality (RSSI color-coded, SNR)
- Network status: propagation node info, active calls, NomadNet count
- API routes: /api/propagation, /api/calls, /api/nomadnet
- Dashboard: 5 nav cards (Messages, Topology, Nodes, Calls, NomadNet)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-09 17:54:34 +00:00
parent f0771a1ce4
commit 9dbe34a508
10 changed files with 9534 additions and 83 deletions

8969
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
const BRIDGE_URL = process.env.RETICULUM_BRIDGE_URL || "http://rmesh-reticulum:8000";
const API_KEY = process.env.BRIDGE_API_KEY || "";
const headers = { "X-Bridge-API-Key": API_KEY, "Content-Type": "application/json" };
export async function GET() {
try {
const res = await fetch(`${BRIDGE_URL}/api/calls`, {
headers: { "X-Bridge-API-Key": API_KEY }, cache: "no-store",
});
return NextResponse.json(await res.json());
} catch {
return NextResponse.json({ calls: [] }, { status: 503 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { action, destination_hash, call_id } = body;
if (action === "call") {
const res = await fetch(`${BRIDGE_URL}/api/calls`, {
method: "POST", headers, body: JSON.stringify({ destination_hash }),
});
return NextResponse.json(await res.json());
}
if (action === "hangup") {
const res = await fetch(`${BRIDGE_URL}/api/calls/hangup`, {
method: "POST", headers, body: JSON.stringify({ call_id }),
});
return NextResponse.json(await res.json());
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch {
return NextResponse.json({ error: "Failed" }, { status: 503 });
}
}

View File

@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
const BRIDGE_URL = process.env.RETICULUM_BRIDGE_URL || "http://rmesh-reticulum:8000";
const API_KEY = process.env.BRIDGE_API_KEY || "";
const headers = { "X-Bridge-API-Key": API_KEY, "Content-Type": "application/json" };
export async function GET() {
try {
const res = await fetch(`${BRIDGE_URL}/api/nomadnet/nodes`, {
headers: { "X-Bridge-API-Key": API_KEY }, cache: "no-store",
});
return NextResponse.json(await res.json());
} catch {
return NextResponse.json({ nodes: [], total: 0 }, { status: 503 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const res = await fetch(`${BRIDGE_URL}/api/nomadnet/browse`, {
method: "POST", headers, body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
} catch {
return NextResponse.json({ error: "Failed" }, { status: 503 });
}
}

View File

@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
const BRIDGE_URL = process.env.RETICULUM_BRIDGE_URL || "http://rmesh-reticulum:8000";
const API_KEY = process.env.BRIDGE_API_KEY || "";
const headers = { "X-Bridge-API-Key": API_KEY };
export async function GET() {
try {
const [status, nodes] = await Promise.all([
fetch(`${BRIDGE_URL}/api/propagation/status`, { headers, cache: "no-store" }).then(r => r.json()),
fetch(`${BRIDGE_URL}/api/propagation/nodes`, { headers, cache: "no-store" }).then(r => r.json()),
]);
return NextResponse.json({ ...status, nodes: nodes.nodes || [] });
} catch {
return NextResponse.json({ local_enabled: false, outbound_node: null, known_nodes: 0, nodes: [] }, { status: 503 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { action, destination_hash } = body;
if (action === "set_preferred") {
const res = await fetch(`${BRIDGE_URL}/api/propagation/preferred`, {
method: "POST", headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ destination_hash }),
});
return NextResponse.json(await res.json());
}
if (action === "sync") {
const res = await fetch(`${BRIDGE_URL}/api/propagation/sync`, { method: "POST", headers });
return NextResponse.json(await res.json());
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch {
return NextResponse.json({ error: "Failed" }, { status: 503 });
}
}

138
src/app/calls/page.tsx Normal file
View File

@ -0,0 +1,138 @@
"use client";
import Link from "next/link";
import { Radio, ArrowLeft, Phone, PhoneOff } from "lucide-react";
import { useState, useEffect } from "react";
interface Call {
id: string;
direction: string;
started_at: number;
peer_hash: string;
duration: number;
}
export default function CallsPage() {
const [calls, setCalls] = useState<Call[]>([]);
const [destHash, setDestHash] = useState("");
const [calling, setCalling] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
useEffect(() => {
const fetchCalls = async () => {
try {
const res = await fetch("/rmesh/api/calls");
const data = await res.json();
setCalls(data.calls || []);
} catch { /* ignore */ }
};
fetchCalls();
const interval = setInterval(fetchCalls, 5000);
return () => clearInterval(interval);
}, []);
const initiateCall = async () => {
if (!destHash.trim()) return;
setCalling(true);
setFeedback(null);
try {
const res = await fetch("/rmesh/api/calls", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "call", destination_hash: destHash.trim() }),
});
const data = await res.json();
if (data.call_id) {
setFeedback(`Call initiated: ${data.call_id.slice(0, 12)}...`);
setDestHash("");
} else {
setFeedback(data.error || "Call failed");
}
} catch {
setFeedback("Server error");
}
setCalling(false);
};
const hangup = async (callId: string) => {
await fetch("/rmesh/api/calls", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "hangup", call_id: callId }),
});
};
return (
<div className="min-h-screen">
<header className="border-b bg-card/50 backdrop-blur-sm sticky top-0 z-10">
<div className="max-w-5xl mx-auto px-4 py-4 flex items-center gap-3">
<Link href="/" className="p-1 rounded-lg hover:bg-muted transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<Radio className="h-6 w-6 text-primary" />
<h1 className="text-xl font-bold">Voice Calls</h1>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 py-8 space-y-6">
{/* Initiate call */}
<div className="rounded-xl border bg-card p-6 shadow-sm space-y-4">
<h3 className="font-semibold">Start a Call</h3>
<p className="text-sm text-muted-foreground">
Encrypted voice calls over Reticulum Links using Codec2 encoding.
Works at 700 bps viable even over LoRa.
</p>
<div className="flex gap-3">
<input
type="text"
value={destHash}
onChange={(e) => setDestHash(e.target.value)}
placeholder="Destination hash (hex)"
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm font-mono"
/>
<button
onClick={initiateCall}
disabled={calling || !destHash.trim()}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
>
<Phone className="h-4 w-4" />
{calling ? "Calling..." : "Call"}
</button>
</div>
{feedback && <p className="text-sm text-muted-foreground">{feedback}</p>}
</div>
{/* Active calls */}
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h3 className="font-semibold mb-4">Active Calls</h3>
{calls.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No active calls</p>
) : (
<div className="space-y-3">
{calls.map((call) => (
<div key={call.id} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div>
<p className="text-sm font-medium">
{call.direction === "inbound" ? "Incoming" : "Outgoing"} call
</p>
<code className="text-xs text-muted-foreground font-mono">{call.peer_hash.slice(0, 20)}...</code>
<p className="text-xs text-muted-foreground mt-1">
Duration: {Math.floor(call.duration)}s
</p>
</div>
<button
onClick={() => hangup(call.id)}
className="flex items-center gap-1 rounded-lg bg-destructive px-3 py-1.5 text-sm text-white hover:bg-destructive/90"
>
<PhoneOff className="h-4 w-4" />
Hang up
</button>
</div>
))}
</div>
)}
</div>
</main>
</div>
);
}

125
src/app/nomadnet/page.tsx Normal file
View File

@ -0,0 +1,125 @@
"use client";
import Link from "next/link";
import { Radio, ArrowLeft, Globe, ExternalLink } from "lucide-react";
import { useState, useEffect } from "react";
interface NomadNode {
destination_hash: string;
name: string;
identity_hash: string;
last_heard: number;
}
export default function NomadNetPage() {
const [nodes, setNodes] = useState<NomadNode[]>([]);
const [browsing, setBrowsing] = useState<string | null>(null);
const [pageContent, setPageContent] = useState<string | null>(null);
const [browsingPath, setBrowsingPath] = useState("/");
useEffect(() => {
const fetchNodes = async () => {
try {
const res = await fetch("/rmesh/api/nomadnet");
const data = await res.json();
setNodes(data.nodes || []);
} catch { /* ignore */ }
};
fetchNodes();
const interval = setInterval(fetchNodes, 30000);
return () => clearInterval(interval);
}, []);
const browseNode = async (destHash: string, path: string = "/") => {
setBrowsing(destHash);
setBrowsingPath(path);
setPageContent(null);
try {
const res = await fetch("/rmesh/api/nomadnet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ destination_hash: destHash, path }),
});
const data = await res.json();
if (data.error) {
setPageContent(`Error: ${data.error}`);
} else {
setPageContent(data.status === "requesting" ? "Requesting page over mesh... (result will appear via WebSocket)" : JSON.stringify(data));
}
} catch {
setPageContent("Failed to connect");
}
};
return (
<div className="min-h-screen">
<header className="border-b bg-card/50 backdrop-blur-sm sticky top-0 z-10">
<div className="max-w-5xl mx-auto px-4 py-4 flex items-center gap-3">
<Link href="/" className="p-1 rounded-lg hover:bg-muted transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<Radio className="h-6 w-6 text-primary" />
<h1 className="text-xl font-bold">NomadNet Browser</h1>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 py-8 space-y-6">
{/* Node list */}
<div className="rounded-xl border bg-card p-6 shadow-sm">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-primary/10">
<Globe className="h-5 w-5 text-primary" />
</div>
<div>
<h3 className="font-semibold">NomadNet Nodes</h3>
<p className="text-sm text-muted-foreground">
{nodes.length} node{nodes.length !== 1 ? "s" : ""} discovered on the network
</p>
</div>
</div>
{nodes.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No NomadNet nodes discovered yet. Nodes announce themselves periodically.
</p>
) : (
<div className="space-y-2">
{nodes.map((node) => (
<div key={node.destination_hash} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="min-w-0">
<p className="text-sm font-medium">{node.name || "Unnamed Node"}</p>
<code className="text-xs text-muted-foreground font-mono">{node.destination_hash.slice(0, 20)}...</code>
</div>
<button
onClick={() => browseNode(node.destination_hash)}
className="flex items-center gap-1 rounded-lg bg-primary/10 px-3 py-1.5 text-sm text-primary hover:bg-primary/20 transition-colors"
>
<ExternalLink className="h-4 w-4" />
Browse
</button>
</div>
))}
</div>
)}
</div>
{/* Page viewer */}
{browsing && (
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h3 className="font-semibold mb-2">
Browsing: <code className="text-xs font-mono">{browsing.slice(0, 16)}...</code>
<span className="text-muted-foreground ml-2">{browsingPath}</span>
</h3>
{pageContent ? (
<pre className="mt-3 p-4 rounded-lg bg-muted/50 text-sm whitespace-pre-wrap overflow-auto max-h-96 font-mono">
{pageContent}
</pre>
) : (
<p className="text-sm text-muted-foreground animate-pulse">Loading page from mesh network...</p>
)}
</div>
)}
</main>
</div>
);
}

View File

@ -1,12 +1,14 @@
import Link from "next/link";
import { Radio, MessageSquare, Network, Antenna, Users } from "lucide-react";
import { Radio, MessageSquare, Network, Antenna, Phone, Globe, Database } from "lucide-react";
import NetworkStatus from "@/components/mesh/NetworkStatus";
import MeshCoreStatus from "@/components/mesh/MeshCoreStatus";
const NAV_ITEMS = [
{ href: "/messages", icon: MessageSquare, label: "Messages", description: "LXMF (Reticulum) and MeshCore messages in one view" },
{ href: "/topology", icon: Network, label: "Topology", description: "Visualize mesh network paths and connections" },
{ href: "/messages", icon: MessageSquare, label: "Messages", description: "LXMF + MeshCore messages with images, files, and voice" },
{ href: "/topology", icon: Network, label: "Topology", description: "Network visualization with signal quality metrics" },
{ href: "/nodes", icon: Antenna, label: "Nodes", description: "Register and manage mesh hardware nodes" },
{ href: "/calls", icon: Phone, label: "Calls", description: "Voice calls over Reticulum Links (Codec2)" },
{ href: "/nomadnet", icon: Globe, label: "NomadNet", description: "Browse decentralized pages and files on the mesh" },
];
export default function HomePage() {
@ -17,43 +19,38 @@ export default function HomePage() {
<Radio className="h-6 w-6 text-primary" />
<h1 className="text-xl font-bold">rMesh</h1>
<span className="text-sm text-muted-foreground ml-2">Mesh Networking for rSpace</span>
<a
href="https://rspace.online"
className="ml-auto text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<a href="https://rspace.online" className="ml-auto text-sm text-muted-foreground hover:text-foreground transition-colors">
rSpace
</a>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 py-8 space-y-8">
{/* Hero */}
<div className="text-center py-8">
<div className="text-center py-6">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium mb-4">
<Radio className="h-4 w-4" />
Part of the rSpace Ecosystem
</div>
<h2 className="text-3xl font-bold mb-3">Resilient Mesh Communications</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Dual-stack mesh networking: MeshCore for LoRa mesh with intelligent routing,
Reticulum for encrypted Internet backbone. Your community stays connected
even when traditional infrastructure fails.
MeshCore for LoRa mesh, Reticulum for encrypted Internet backbone.
Messaging, voice calls, file sharing, and NomadNet browsing all over mesh.
</p>
</div>
{/* Dual status cards */}
{/* Dual status */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<MeshCoreStatus />
<NetworkStatus />
</div>
{/* Navigation cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Navigation */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className="group rounded-xl border bg-card p-6 shadow-sm hover:shadow-md hover:border-primary/30 transition-all"
className="group rounded-xl border bg-card p-5 shadow-sm hover:shadow-md hover:border-primary/30 transition-all"
>
<div className="p-2 rounded-lg bg-primary/10 w-fit mb-3 group-hover:bg-primary/20 transition-colors">
<item.icon className="h-5 w-5 text-primary" />
@ -64,29 +61,34 @@ export default function HomePage() {
))}
</div>
{/* Architecture explanation */}
{/* Architecture */}
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h3 className="font-semibold mb-3">Dual-Stack Architecture</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm">
<h3 className="font-semibold mb-3">Features</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<p className="font-medium text-accent mb-1">MeshCore (LoRa Mesh)</p>
<p className="font-medium text-accent mb-1">Rich Messaging</p>
<p className="text-muted-foreground">
Intelligent structured routing over LoRa radio. Path learning instead of
flooding scales to 64+ hops. Companion nodes, repeaters, and room servers.
MIT licensed, runs on $20 hardware.
Images, files, and Codec2 voice messages over LXMF. Auto-resend
failed messages when peers come online. Propagation node relay.
</p>
</div>
<div>
<p className="font-medium text-primary mb-1">Reticulum (Internet Backbone)</p>
<p className="font-medium text-accent mb-1">Voice Calls</p>
<p className="text-muted-foreground">
Transport-agnostic encrypted networking. Bridges separate MeshCore LoRa
islands over the Internet. LXMF for delay-tolerant messaging between
sites. All traffic encrypted end-to-end by default.
Encrypted audio calls over Reticulum Links using Codec2 encoding.
Works at 700 bps viable even over LoRa radio.
</p>
</div>
<div>
<p className="font-medium text-accent mb-1">NomadNet Browser</p>
<p className="text-muted-foreground">
Browse decentralized pages and download files from NomadNet nodes
on the mesh. Like a web browser for the off-grid internet.
</p>
</div>
</div>
<div className="mt-4 p-3 rounded-lg bg-muted/50 font-mono text-xs text-muted-foreground">
[MeshCore Nodes] &lt;--868MHz LoRa--&gt; [Companion] &lt;--TCP--&gt; [rMesh Server] &lt;--Reticulum TCP--&gt; [Remote Sites]
[MeshCore LoRa] &lt;-&gt; [Companion TCP] &lt;-&gt; [rMesh Bridge] &lt;-&gt; [Reticulum TCP] &lt;-&gt; [Global Network + NomadNet + Calls]
</div>
</div>
</main>

View File

@ -1,7 +1,14 @@
"use client";
import { useEffect, useState } from "react";
import { Mail, ArrowUpRight, ArrowDownLeft } from "lucide-react";
import { Mail, ArrowUpRight, ArrowDownLeft, Image, FileText, Mic, Signal } from "lucide-react";
interface MessageFields {
image?: { type: string; filename: string; size: number };
file_attachments?: { name: string; stored_name: string; size: number }[];
audio?: { codec: string; mode: number; filename: string; size: number };
icon?: { name: string; foreground: string; background: string };
}
interface Message {
id: string;
@ -10,6 +17,10 @@ interface Message {
recipient_hash: string;
title: string;
content: string;
fields?: MessageFields;
rssi?: number;
snr?: number;
quality?: number;
status: string;
timestamp: number;
}
@ -18,6 +29,12 @@ function formatTime(ts: number): string {
return new Date(ts * 1000).toLocaleString();
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
}
export default function MessageList() {
const [messages, setMessages] = useState<Message[]>([]);
const [total, setTotal] = useState(0);
@ -29,9 +46,7 @@ export default function MessageList() {
const data = await res.json();
setMessages(data.messages || []);
setTotal(data.total || 0);
} catch {
// ignore
}
} catch { /* ignore */ }
};
fetchMessages();
@ -59,16 +74,26 @@ export default function MessageList() {
<div className="space-y-3">
{messages.map((msg) => (
<div key={msg.id} className="p-3 rounded-lg bg-muted/50">
{/* Header */}
<div className="flex items-center gap-2 mb-1">
{msg.direction === "outbound" ? (
<ArrowUpRight className="h-4 w-4 text-primary" />
) : (
<ArrowDownLeft className="h-4 w-4 text-accent" />
)}
<span className="text-xs font-medium uppercase text-muted-foreground">
{msg.direction}
</span>
<span className={`ml-auto text-xs px-2 py-0.5 rounded-full ${
<span className="text-xs font-medium uppercase text-muted-foreground">{msg.direction}</span>
{/* Attachment indicators */}
{msg.fields?.image && <Image className="h-3.5 w-3.5 text-blue-400" />}
{msg.fields?.file_attachments && <FileText className="h-3.5 w-3.5 text-orange-400" />}
{msg.fields?.audio && <Mic className="h-3.5 w-3.5 text-green-400" />}
{/* Signal quality */}
{msg.rssi != null && (
<span className="ml-auto flex items-center gap-1 text-xs text-muted-foreground" title={`RSSI: ${msg.rssi} dBm, SNR: ${msg.snr} dB`}>
<Signal className="h-3 w-3" />
{msg.rssi}dBm
</span>
)}
<span className={`${msg.rssi == null ? "ml-auto" : ""} text-xs px-2 py-0.5 rounded-full ${
msg.status === "delivered" ? "bg-green-500/10 text-green-500" :
msg.status === "failed" ? "bg-destructive/10 text-destructive" :
"bg-yellow-500/10 text-yellow-500"
@ -76,10 +101,36 @@ export default function MessageList() {
{msg.status}
</span>
</div>
{msg.title && (
<p className="text-sm font-medium">{msg.title}</p>
)}
{/* Content */}
{msg.title && <p className="text-sm font-medium">{msg.title}</p>}
<p className="text-sm mt-1">{msg.content}</p>
{/* Rich fields */}
{msg.fields?.image && (
<div className="mt-2 flex items-center gap-2 text-xs text-blue-400">
<Image className="h-4 w-4" />
<span>Image ({formatBytes(msg.fields.image.size)})</span>
</div>
)}
{msg.fields?.audio && (
<div className="mt-2 flex items-center gap-2 text-xs text-green-400">
<Mic className="h-4 w-4" />
<span>Voice message Codec2 mode {msg.fields.audio.mode} ({formatBytes(msg.fields.audio.size)})</span>
</div>
)}
{msg.fields?.file_attachments && (
<div className="mt-2 space-y-1">
{msg.fields.file_attachments.map((f, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-orange-400">
<FileText className="h-4 w-4" />
<span>{f.name} ({formatBytes(f.size)})</span>
</div>
))}
</div>
)}
{/* Metadata */}
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
<span>From: <code className="font-mono">{msg.sender_hash.slice(0, 12)}...</code></span>
<span>To: <code className="font-mono">{msg.recipient_hash.slice(0, 12)}...</code></span>

View File

@ -1,20 +1,37 @@
"use client";
import { useEffect, useState } from "react";
import { Radio, Wifi, WifiOff, Clock, Hash, Network } from "lucide-react";
import { Radio, Wifi, WifiOff, Clock, Hash, Network, Phone, Globe } from "lucide-react";
interface MeshStatus {
online: boolean;
transport_enabled: boolean;
identity_hash: string;
uptime_seconds: number;
announced_count: number;
path_count: number;
reticulum: {
online: boolean;
transport_enabled: boolean;
identity_hash: string;
uptime_seconds: number;
announced_count: number;
path_count: number;
active_calls?: number;
nomadnet_nodes?: number;
};
meshcore: {
connected: boolean;
contact_count: number;
message_count: number;
};
propagation?: {
local_enabled: boolean;
outbound_node: string | null;
known_nodes: number;
};
websocket_clients?: number;
}
function formatUptime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
@ -29,7 +46,7 @@ export default function NetworkStatus() {
const res = await fetch("/rmesh/api/mesh/status");
const data = await res.json();
setStatus(data);
setError(!data.online);
setError(!data.reticulum?.online);
} catch {
setError(true);
}
@ -40,6 +57,9 @@ export default function NetworkStatus() {
return () => clearInterval(interval);
}, []);
const ret = status?.reticulum;
const prop = status?.propagation;
return (
<div className="rounded-xl border bg-card p-6 shadow-sm">
<div className="flex items-center gap-3 mb-4">
@ -47,36 +67,58 @@ export default function NetworkStatus() {
{error ? <WifiOff className="h-5 w-5 text-destructive" /> : <Wifi className="h-5 w-5 text-primary" />}
</div>
<div>
<h3 className="font-semibold">Reticulum Transport</h3>
<h3 className="font-semibold">Reticulum Backbone</h3>
<p className="text-sm text-muted-foreground">
{error ? "Offline" : status?.transport_enabled ? "Active" : "Connecting..."}
{error ? "Offline" : ret?.transport_enabled ? "Active" : "Connecting..."}
</p>
</div>
<div className={`ml-auto h-3 w-3 rounded-full ${error ? "bg-destructive" : "bg-green-500"} animate-pulse`} />
</div>
{status && !error && (
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="flex items-center gap-2 text-sm">
{ret && !error && (
<div className="grid grid-cols-2 gap-3 mt-4 text-sm">
<div className="flex items-center gap-2">
<Hash className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Identity:</span>
<code className="text-xs font-mono truncate">{status.identity_hash.slice(0, 16)}...</code>
<span className="text-muted-foreground">ID:</span>
<code className="text-xs font-mono truncate">{ret.identity_hash.slice(0, 12)}...</code>
</div>
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Uptime:</span>
<span>{formatUptime(status.uptime_seconds)}</span>
<span className="text-muted-foreground">Up:</span>
<span>{formatUptime(ret.uptime_seconds)}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-2">
<Radio className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Announces:</span>
<span>{status.announced_count}</span>
<span>{ret.announced_count}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-2">
<Network className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Paths:</span>
<span>{status.path_count}</span>
<span>{ret.path_count}</span>
</div>
{(ret.active_calls ?? 0) > 0 && (
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-green-500" />
<span className="text-green-500 font-medium">{ret.active_calls} active call{ret.active_calls !== 1 ? "s" : ""}</span>
</div>
)}
{(ret.nomadnet_nodes ?? 0) > 0 && (
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">NomadNet:</span>
<span>{ret.nomadnet_nodes} nodes</span>
</div>
)}
{prop && (
<div className="col-span-2 flex items-center gap-2 mt-1 pt-2 border-t border-border/50">
<span className="text-xs text-muted-foreground">
Propagation: {prop.local_enabled ? "hosting" : "off"}
{prop.outbound_node && ` | relay: ${prop.outbound_node.slice(0, 8)}...`}
{prop.known_nodes > 0 && ` | ${prop.known_nodes} known`}
</span>
</div>
)}
</div>
)}
</div>

View File

@ -1,14 +1,16 @@
"use client";
import { useEffect, useState } from "react";
import { Antenna, Clock } from "lucide-react";
import { Antenna, Clock, Signal } from "lucide-react";
interface NodeInfo {
destination_hash: string;
app_name?: string;
aspects?: string;
identity_hash?: string;
app_data?: string;
last_heard?: number;
hops?: number;
rssi?: number;
snr?: number;
quality?: number;
}
function timeAgo(timestamp: number): string {
@ -19,6 +21,12 @@ function timeAgo(timestamp: number): string {
return `${Math.floor(seconds / 86400)}d ago`;
}
function signalColor(rssi: number): string {
if (rssi > -70) return "text-green-500";
if (rssi > -90) return "text-yellow-500";
return "text-red-500";
}
export default function NodeList() {
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [total, setTotal] = useState(0);
@ -30,9 +38,7 @@ export default function NodeList() {
const data = await res.json();
setNodes(data.nodes || []);
setTotal(data.total || 0);
} catch {
// ignore
}
} catch { /* ignore */ }
};
fetchNodes();
@ -54,27 +60,35 @@ export default function NodeList() {
{nodes.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No nodes discovered yet. Nodes will appear as they announce on the network.
No nodes discovered yet. Nodes appear as they announce on the network.
</p>
) : (
<div className="space-y-2">
{nodes.map((node) => (
<div
key={node.destination_hash}
className="flex items-center justify-between p-3 rounded-lg bg-muted/50"
>
<div key={node.destination_hash} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-3 min-w-0">
<div className="h-2 w-2 rounded-full bg-green-500 shrink-0" />
<code className="text-xs font-mono truncate">
{node.destination_hash.slice(0, 20)}...
</code>
</div>
{node.last_heard && (
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Clock className="h-3 w-3" />
{timeAgo(node.last_heard)}
<div className="min-w-0">
<code className="text-xs font-mono truncate block">{node.destination_hash.slice(0, 20)}...</code>
{node.app_data && (
<span className="text-xs text-muted-foreground">{node.app_data}</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
{node.rssi != null && (
<span className={`flex items-center gap-1 text-xs ${signalColor(node.rssi)}`} title={`SNR: ${node.snr ?? "?"}dB`}>
<Signal className="h-3.5 w-3.5" />
{node.rssi}dBm
</span>
)}
{node.last_heard && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{timeAgo(node.last_heard)}
</span>
)}
</div>
</div>
))}
</div>