Compare commits

..

No commits in common. "main" and "dev" have entirely different histories.
main ... dev

10 changed files with 83 additions and 9534 deletions

8969
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,41 +0,0 @@
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 });
}
}

View File

@ -1,138 +0,0 @@
"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>
);
}

View File

@ -1,125 +0,0 @@
"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,14 +1,12 @@
import Link from "next/link";
import { Radio, MessageSquare, Network, Antenna, Phone, Globe, Database } from "lucide-react";
import { Radio, MessageSquare, Network, Antenna, Users } 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 + MeshCore messages with images, files, and voice" },
{ href: "/topology", icon: Network, label: "Topology", description: "Network visualization with signal quality metrics" },
{ 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: "/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() {
@ -19,38 +17,43 @@ 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">
<div className="text-center py-6">
{/* Hero */}
<div className="text-center py-8">
<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">
MeshCore for LoRa mesh, Reticulum for encrypted Internet backbone.
Messaging, voice calls, file sharing, and NomadNet browsing all over mesh.
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.
</p>
</div>
{/* Dual status */}
{/* Dual status cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<MeshCoreStatus />
<NetworkStatus />
</div>
{/* Navigation */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Navigation cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className="group rounded-xl border bg-card p-5 shadow-sm hover:shadow-md hover:border-primary/30 transition-all"
className="group rounded-xl border bg-card p-6 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" />
@ -61,34 +64,29 @@ export default function HomePage() {
))}
</div>
{/* Architecture */}
{/* Architecture explanation */}
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h3 className="font-semibold mb-3">Features</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-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">
<div>
<p className="font-medium text-accent mb-1">Rich Messaging</p>
<p className="font-medium text-accent mb-1">MeshCore (LoRa Mesh)</p>
<p className="text-muted-foreground">
Images, files, and Codec2 voice messages over LXMF. Auto-resend
failed messages when peers come online. Propagation node relay.
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.
</p>
</div>
<div>
<p className="font-medium text-accent mb-1">Voice Calls</p>
<p className="font-medium text-primary mb-1">Reticulum (Internet Backbone)</p>
<p className="text-muted-foreground">
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.
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.
</p>
</div>
</div>
<div className="mt-4 p-3 rounded-lg bg-muted/50 font-mono text-xs text-muted-foreground">
[MeshCore LoRa] &lt;-&gt; [Companion TCP] &lt;-&gt; [rMesh Bridge] &lt;-&gt; [Reticulum TCP] &lt;-&gt; [Global Network + NomadNet + Calls]
[MeshCore Nodes] &lt;--868MHz LoRa--&gt; [Companion] &lt;--TCP--&gt; [rMesh Server] &lt;--Reticulum TCP--&gt; [Remote Sites]
</div>
</div>
</main>

View File

@ -1,14 +1,7 @@
"use client";
import { useEffect, useState } from "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 };
}
import { Mail, ArrowUpRight, ArrowDownLeft } from "lucide-react";
interface Message {
id: string;
@ -17,10 +10,6 @@ interface Message {
recipient_hash: string;
title: string;
content: string;
fields?: MessageFields;
rssi?: number;
snr?: number;
quality?: number;
status: string;
timestamp: number;
}
@ -29,12 +18,6 @@ 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);
@ -46,7 +29,9 @@ export default function MessageList() {
const data = await res.json();
setMessages(data.messages || []);
setTotal(data.total || 0);
} catch { /* ignore */ }
} catch {
// ignore
}
};
fetchMessages();
@ -74,26 +59,16 @@ 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>
{/* 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 ${
<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 ${
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"
@ -101,36 +76,10 @@ export default function MessageList() {
{msg.status}
</span>
</div>
{/* Content */}
{msg.title && <p className="text-sm font-medium">{msg.title}</p>}
{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,37 +1,20 @@
"use client";
import { useEffect, useState } from "react";
import { Radio, Wifi, WifiOff, Clock, Hash, Network, Phone, Globe } from "lucide-react";
import { Radio, Wifi, WifiOff, Clock, Hash, Network } from "lucide-react";
interface MeshStatus {
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;
online: boolean;
transport_enabled: boolean;
identity_hash: string;
uptime_seconds: number;
announced_count: number;
path_count: number;
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const h = Math.floor(seconds / 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`;
}
@ -46,7 +29,7 @@ export default function NetworkStatus() {
const res = await fetch("/rmesh/api/mesh/status");
const data = await res.json();
setStatus(data);
setError(!data.reticulum?.online);
setError(!data.online);
} catch {
setError(true);
}
@ -57,9 +40,6 @@ 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">
@ -67,58 +47,36 @@ 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 Backbone</h3>
<h3 className="font-semibold">Reticulum Transport</h3>
<p className="text-sm text-muted-foreground">
{error ? "Offline" : ret?.transport_enabled ? "Active" : "Connecting..."}
{error ? "Offline" : status?.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>
{ret && !error && (
<div className="grid grid-cols-2 gap-3 mt-4 text-sm">
<div className="flex items-center gap-2">
{status && !error && (
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="flex items-center gap-2 text-sm">
<Hash className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">ID:</span>
<code className="text-xs font-mono truncate">{ret.identity_hash.slice(0, 12)}...</code>
<span className="text-muted-foreground">Identity:</span>
<code className="text-xs font-mono truncate">{status.identity_hash.slice(0, 16)}...</code>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Up:</span>
<span>{formatUptime(ret.uptime_seconds)}</span>
<span className="text-muted-foreground">Uptime:</span>
<span>{formatUptime(status.uptime_seconds)}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-sm">
<Radio className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Announces:</span>
<span>{ret.announced_count}</span>
<span>{status.announced_count}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-sm">
<Network className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Paths:</span>
<span>{ret.path_count}</span>
<span>{status.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,16 +1,14 @@
"use client";
import { useEffect, useState } from "react";
import { Antenna, Clock, Signal } from "lucide-react";
import { Antenna, Clock } from "lucide-react";
interface NodeInfo {
destination_hash: string;
identity_hash?: string;
app_data?: string;
app_name?: string;
aspects?: string;
last_heard?: number;
rssi?: number;
snr?: number;
quality?: number;
hops?: number;
}
function timeAgo(timestamp: number): string {
@ -21,12 +19,6 @@ 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);
@ -38,7 +30,9 @@ export default function NodeList() {
const data = await res.json();
setNodes(data.nodes || []);
setTotal(data.total || 0);
} catch { /* ignore */ }
} catch {
// ignore
}
};
fetchNodes();
@ -60,35 +54,27 @@ export default function NodeList() {
{nodes.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No nodes discovered yet. Nodes appear as they announce on the network.
No nodes discovered yet. Nodes will 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" />
<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>
)}
<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>
</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>