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:
parent
f0771a1ce4
commit
9dbe34a508
File diff suppressed because it is too large
Load Diff
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import Link from "next/link";
|
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 NetworkStatus from "@/components/mesh/NetworkStatus";
|
||||||
import MeshCoreStatus from "@/components/mesh/MeshCoreStatus";
|
import MeshCoreStatus from "@/components/mesh/MeshCoreStatus";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: "/messages", icon: MessageSquare, label: "Messages", description: "LXMF (Reticulum) and MeshCore messages in one view" },
|
{ href: "/messages", icon: MessageSquare, label: "Messages", description: "LXMF + MeshCore messages with images, files, and voice" },
|
||||||
{ href: "/topology", icon: Network, label: "Topology", description: "Visualize mesh network paths and connections" },
|
{ 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: "/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() {
|
export default function HomePage() {
|
||||||
|
|
@ -17,43 +19,38 @@ export default function HomePage() {
|
||||||
<Radio className="h-6 w-6 text-primary" />
|
<Radio className="h-6 w-6 text-primary" />
|
||||||
<h1 className="text-xl font-bold">rMesh</h1>
|
<h1 className="text-xl font-bold">rMesh</h1>
|
||||||
<span className="text-sm text-muted-foreground ml-2">Mesh Networking for rSpace</span>
|
<span className="text-sm text-muted-foreground ml-2">Mesh Networking for rSpace</span>
|
||||||
<a
|
<a href="https://rspace.online" className="ml-auto text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||||
href="https://rspace.online"
|
|
||||||
className="ml-auto text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
rSpace
|
rSpace
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-5xl mx-auto px-4 py-8 space-y-8">
|
<main className="max-w-5xl mx-auto px-4 py-8 space-y-8">
|
||||||
{/* Hero */}
|
<div className="text-center py-6">
|
||||||
<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">
|
<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" />
|
<Radio className="h-4 w-4" />
|
||||||
Part of the rSpace Ecosystem
|
Part of the rSpace Ecosystem
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-bold mb-3">Resilient Mesh Communications</h2>
|
<h2 className="text-3xl font-bold mb-3">Resilient Mesh Communications</h2>
|
||||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||||
Dual-stack mesh networking: MeshCore for LoRa mesh with intelligent routing,
|
MeshCore for LoRa mesh, Reticulum for encrypted Internet backbone.
|
||||||
Reticulum for encrypted Internet backbone. Your community stays connected
|
Messaging, voice calls, file sharing, and NomadNet browsing — all over mesh.
|
||||||
even when traditional infrastructure fails.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dual status cards */}
|
{/* Dual status */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<MeshCoreStatus />
|
<MeshCoreStatus />
|
||||||
<NetworkStatus />
|
<NetworkStatus />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation cards */}
|
{/* Navigation */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{NAV_ITEMS.map((item) => (
|
{NAV_ITEMS.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={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">
|
<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" />
|
<item.icon className="h-5 w-5 text-primary" />
|
||||||
|
|
@ -64,29 +61,34 @@ export default function HomePage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Architecture explanation */}
|
{/* Architecture */}
|
||||||
<div className="rounded-xl border bg-card p-6 shadow-sm">
|
<div className="rounded-xl border bg-card p-6 shadow-sm">
|
||||||
<h3 className="font-semibold mb-3">Dual-Stack Architecture</h3>
|
<h3 className="font-semibold mb-3">Features</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||||
<div>
|
<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">
|
<p className="text-muted-foreground">
|
||||||
Intelligent structured routing over LoRa radio. Path learning instead of
|
Images, files, and Codec2 voice messages over LXMF. Auto-resend
|
||||||
flooding — scales to 64+ hops. Companion nodes, repeaters, and room servers.
|
failed messages when peers come online. Propagation node relay.
|
||||||
MIT licensed, runs on $20 hardware.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p className="text-muted-foreground">
|
||||||
Transport-agnostic encrypted networking. Bridges separate MeshCore LoRa
|
Encrypted audio calls over Reticulum Links using Codec2 encoding.
|
||||||
islands over the Internet. LXMF for delay-tolerant messaging between
|
Works at 700 bps — viable even over LoRa radio.
|
||||||
sites. All traffic encrypted end-to-end by default.
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 p-3 rounded-lg bg-muted/50 font-mono text-xs text-muted-foreground">
|
<div className="mt-4 p-3 rounded-lg bg-muted/50 font-mono text-xs text-muted-foreground">
|
||||||
[MeshCore Nodes] <--868MHz LoRa--> [Companion] <--TCP--> [rMesh Server] <--Reticulum TCP--> [Remote Sites]
|
[MeshCore LoRa] <-> [Companion TCP] <-> [rMesh Bridge] <-> [Reticulum TCP] <-> [Global Network + NomadNet + Calls]
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -10,6 +17,10 @@ interface Message {
|
||||||
recipient_hash: string;
|
recipient_hash: string;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
fields?: MessageFields;
|
||||||
|
rssi?: number;
|
||||||
|
snr?: number;
|
||||||
|
quality?: number;
|
||||||
status: string;
|
status: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
@ -18,6 +29,12 @@ function formatTime(ts: number): string {
|
||||||
return new Date(ts * 1000).toLocaleString();
|
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() {
|
export default function MessageList() {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
|
|
@ -29,9 +46,7 @@ export default function MessageList() {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setMessages(data.messages || []);
|
setMessages(data.messages || []);
|
||||||
setTotal(data.total || 0);
|
setTotal(data.total || 0);
|
||||||
} catch {
|
} catch { /* ignore */ }
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchMessages();
|
fetchMessages();
|
||||||
|
|
@ -59,16 +74,26 @@ export default function MessageList() {
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<div key={msg.id} className="p-3 rounded-lg bg-muted/50">
|
<div key={msg.id} className="p-3 rounded-lg bg-muted/50">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{msg.direction === "outbound" ? (
|
{msg.direction === "outbound" ? (
|
||||||
<ArrowUpRight className="h-4 w-4 text-primary" />
|
<ArrowUpRight className="h-4 w-4 text-primary" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowDownLeft className="h-4 w-4 text-accent" />
|
<ArrowDownLeft className="h-4 w-4 text-accent" />
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-medium uppercase text-muted-foreground">
|
<span className="text-xs font-medium uppercase text-muted-foreground">{msg.direction}</span>
|
||||||
{msg.direction}
|
{/* Attachment indicators */}
|
||||||
</span>
|
{msg.fields?.image && <Image className="h-3.5 w-3.5 text-blue-400" />}
|
||||||
<span className={`ml-auto text-xs px-2 py-0.5 rounded-full ${
|
{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 === "delivered" ? "bg-green-500/10 text-green-500" :
|
||||||
msg.status === "failed" ? "bg-destructive/10 text-destructive" :
|
msg.status === "failed" ? "bg-destructive/10 text-destructive" :
|
||||||
"bg-yellow-500/10 text-yellow-500"
|
"bg-yellow-500/10 text-yellow-500"
|
||||||
|
|
@ -76,10 +101,36 @@ export default function MessageList() {
|
||||||
{msg.status}
|
{msg.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
<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">
|
<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>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>
|
<span>To: <code className="font-mono">{msg.recipient_hash.slice(0, 12)}...</code></span>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,37 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 {
|
interface MeshStatus {
|
||||||
online: boolean;
|
reticulum: {
|
||||||
transport_enabled: boolean;
|
online: boolean;
|
||||||
identity_hash: string;
|
transport_enabled: boolean;
|
||||||
uptime_seconds: number;
|
identity_hash: string;
|
||||||
announced_count: number;
|
uptime_seconds: number;
|
||||||
path_count: 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 {
|
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);
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (d > 0) return `${d}d ${h}h`;
|
||||||
if (h > 0) return `${h}h ${m}m`;
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
return `${m}m`;
|
return `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
@ -29,7 +46,7 @@ export default function NetworkStatus() {
|
||||||
const res = await fetch("/rmesh/api/mesh/status");
|
const res = await fetch("/rmesh/api/mesh/status");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setStatus(data);
|
setStatus(data);
|
||||||
setError(!data.online);
|
setError(!data.reticulum?.online);
|
||||||
} catch {
|
} catch {
|
||||||
setError(true);
|
setError(true);
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +57,9 @@ export default function NetworkStatus() {
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const ret = status?.reticulum;
|
||||||
|
const prop = status?.propagation;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-6 shadow-sm">
|
<div className="rounded-xl border bg-card p-6 shadow-sm">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<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" />}
|
{error ? <WifiOff className="h-5 w-5 text-destructive" /> : <Wifi className="h-5 w-5 text-primary" />}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">Reticulum Transport</h3>
|
<h3 className="font-semibold">Reticulum Backbone</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{error ? "Offline" : status?.transport_enabled ? "Active" : "Connecting..."}
|
{error ? "Offline" : ret?.transport_enabled ? "Active" : "Connecting..."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={`ml-auto h-3 w-3 rounded-full ${error ? "bg-destructive" : "bg-green-500"} animate-pulse`} />
|
<div className={`ml-auto h-3 w-3 rounded-full ${error ? "bg-destructive" : "bg-green-500"} animate-pulse`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status && !error && (
|
{ret && !error && (
|
||||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
<div className="grid grid-cols-2 gap-3 mt-4 text-sm">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2">
|
||||||
<Hash className="h-4 w-4 text-muted-foreground" />
|
<Hash className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-muted-foreground">Identity:</span>
|
<span className="text-muted-foreground">ID:</span>
|
||||||
<code className="text-xs font-mono truncate">{status.identity_hash.slice(0, 16)}...</code>
|
<code className="text-xs font-mono truncate">{ret.identity_hash.slice(0, 12)}...</code>
|
||||||
</div>
|
</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" />
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-muted-foreground">Uptime:</span>
|
<span className="text-muted-foreground">Up:</span>
|
||||||
<span>{formatUptime(status.uptime_seconds)}</span>
|
<span>{formatUptime(ret.uptime_seconds)}</span>
|
||||||
</div>
|
</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" />
|
<Radio className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-muted-foreground">Announces:</span>
|
<span className="text-muted-foreground">Announces:</span>
|
||||||
<span>{status.announced_count}</span>
|
<span>{ret.announced_count}</span>
|
||||||
</div>
|
</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" />
|
<Network className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-muted-foreground">Paths:</span>
|
<span className="text-muted-foreground">Paths:</span>
|
||||||
<span>{status.path_count}</span>
|
<span>{ret.path_count}</span>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Antenna, Clock } from "lucide-react";
|
import { Antenna, Clock, Signal } from "lucide-react";
|
||||||
|
|
||||||
interface NodeInfo {
|
interface NodeInfo {
|
||||||
destination_hash: string;
|
destination_hash: string;
|
||||||
app_name?: string;
|
identity_hash?: string;
|
||||||
aspects?: string;
|
app_data?: string;
|
||||||
last_heard?: number;
|
last_heard?: number;
|
||||||
hops?: number;
|
rssi?: number;
|
||||||
|
snr?: number;
|
||||||
|
quality?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeAgo(timestamp: number): string {
|
function timeAgo(timestamp: number): string {
|
||||||
|
|
@ -19,6 +21,12 @@ function timeAgo(timestamp: number): string {
|
||||||
return `${Math.floor(seconds / 86400)}d ago`;
|
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() {
|
export default function NodeList() {
|
||||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
|
|
@ -30,9 +38,7 @@ export default function NodeList() {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setNodes(data.nodes || []);
|
setNodes(data.nodes || []);
|
||||||
setTotal(data.total || 0);
|
setTotal(data.total || 0);
|
||||||
} catch {
|
} catch { /* ignore */ }
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchNodes();
|
fetchNodes();
|
||||||
|
|
@ -54,27 +60,35 @@ export default function NodeList() {
|
||||||
|
|
||||||
{nodes.length === 0 ? (
|
{nodes.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
<div
|
<div key={node.destination_hash} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||||
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="flex items-center gap-3 min-w-0">
|
||||||
<div className="h-2 w-2 rounded-full bg-green-500 shrink-0" />
|
<div className="h-2 w-2 rounded-full bg-green-500 shrink-0" />
|
||||||
<code className="text-xs font-mono truncate">
|
<div className="min-w-0">
|
||||||
{node.destination_hash.slice(0, 20)}...
|
<code className="text-xs font-mono truncate block">{node.destination_hash.slice(0, 20)}...</code>
|
||||||
</code>
|
{node.app_data && (
|
||||||
</div>
|
<span className="text-xs text-muted-foreground">{node.app_data}</span>
|
||||||
{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>
|
||||||
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue