Initial commit: rMesh frontend for rSpace

Next.js 16 rApp with dual-stack mesh networking dashboard:
- MeshCore status, contacts, and LoRa messages
- Reticulum status, topology, LXMF messaging
- Node registration for hardware tracking
- EncryptID auth, Prisma/Postgres, Traefik routing
- Serves at rspace.online/rmesh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-06 16:42:22 +00:00
commit f0771a1ce4
46 changed files with 2078 additions and 0 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
.next
.git
.env
.env.*
!.env.example

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
NEXTAUTH_SECRET=change-me
DB_PASSWORD=change-me
BRIDGE_API_KEY=change-me
INFISICAL_CLIENT_ID=
INFISICAL_CLIENT_SECRET=

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
.next/
.env
.env.*
!.env.example
encryptid-sdk/

67
Dockerfile Normal file
View File

@ -0,0 +1,67 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files first for layer caching
COPY package*.json ./
COPY prisma ./prisma/
# Copy the encryptid-sdk (symlinked or copied into build context)
COPY encryptid-sdk/ /encryptid-sdk/
# Install dependencies
RUN npm ci || npm install
# Ensure SDK is properly linked in node_modules
RUN rm -rf node_modules/@encryptid/sdk && \
mkdir -p node_modules/@encryptid && \
cp -r /encryptid-sdk node_modules/@encryptid/sdk
# Copy source files
COPY src ./src
COPY public ./public
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
# Generate Prisma client
RUN npx prisma generate
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files from builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
# Infisical entrypoint for secret injection
COPY --chown=nextjs:nodejs entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Set ownership
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "server.js"]

23
components.json Normal file
View File

@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

82
docker-compose.yml Normal file
View File

@ -0,0 +1,82 @@
services:
rmesh-online:
container_name: rmesh-online
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.http.routers.rmesh.rule=(Host(`rspace.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rspace.online`))
&& PathPrefix(`/rmesh`)
- traefik.http.routers.rmesh-online.rule=Host(`rmesh.online`)
- traefik.http.routers.rmesh-online.entrypoints=web
- traefik.http.routers.rmesh-online.priority=150
- traefik.http.routers.rmesh-online.service=rmesh
- traefik.http.routers.rmesh-online.middlewares=rmesh-rootredirect
- traefik.http.middlewares.rmesh-rootredirect.redirectregex.regex=^https?://rmesh\.online/?$
- traefik.http.middlewares.rmesh-rootredirect.redirectregex.replacement=https://rmesh.online/rmesh
- traefik.http.middlewares.rmesh-rootredirect.redirectregex.permanent=false
- traefik.http.routers.rmesh.entrypoints=web
- traefik.http.routers.rmesh.priority=140
- traefik.http.services.rmesh.loadbalancer.server.port=3000
- traefik.docker.network=traefik-public
environment:
- NEXTAUTH_URL=https://rspace.online/rmesh
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- ENCRYPTID_SERVER_URL=https://auth.ridentity.online
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=https://auth.ridentity.online
- ROOT_DOMAIN=rspace.online
- NEXT_PUBLIC_ROOT_DOMAIN=rspace.online
- DATABASE_URL=postgresql://rmesh:${DB_PASSWORD}@rmesh-postgres:5432/rmesh
- INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID}
- INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET}
- INFISICAL_PROJECT_SLUG=rmesh
- RETICULUM_BRIDGE_URL=http://rmesh-reticulum:8000
- BRIDGE_API_KEY=${BRIDGE_API_KEY}
networks:
- traefik-public
- rmesh-internal
depends_on:
rmesh-postgres:
condition: service_healthy
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
image: localhost:3000/jeffemmett/rmesh-online:${IMAGE_TAG:-latest}
rmesh-postgres:
image: postgres:16-alpine
container_name: rmesh-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=rmesh
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=rmesh
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- rmesh-internal
healthcheck:
test:
- CMD-SHELL
- pg_isready -U rmesh -d rmesh
interval: 5s
timeout: 5s
retries: 5
cap_drop:
- ALL
cap_add:
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
security_opt:
- no-new-privileges:true
networks:
traefik-public:
external: true
rmesh-internal:
internal: true
volumes:
postgres_data:

83
entrypoint.sh Executable file
View File

@ -0,0 +1,83 @@
#!/bin/sh
# Infisical secret injection entrypoint (Node.js)
# Fetches secrets from Infisical API and injects them as env vars before starting the app.
# Required env vars: INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET
# Optional: INFISICAL_PROJECT_SLUG, INFISICAL_ENV (default: prod),
# INFISICAL_URL (default: http://infisical:8080)
set -e
export INFISICAL_URL="${INFISICAL_URL:-http://infisical:8080}"
export INFISICAL_ENV="${INFISICAL_ENV:-prod}"
# IMPORTANT: Set INFISICAL_PROJECT_SLUG in your docker-compose.yml
export INFISICAL_PROJECT_SLUG="${INFISICAL_PROJECT_SLUG:?INFISICAL_PROJECT_SLUG must be set}"
if [ -z "$INFISICAL_CLIENT_ID" ] || [ -z "$INFISICAL_CLIENT_SECRET" ]; then
echo "[infisical] No credentials set, starting without secret injection"
exec "$@"
fi
echo "[infisical] Fetching secrets from ${INFISICAL_PROJECT_SLUG}/${INFISICAL_ENV}..."
# Use Node.js (already in the image) for reliable JSON parsing and HTTP calls
EXPORTS=$(node -e "
const http = require('http');
const https = require('https');
const url = new URL(process.env.INFISICAL_URL);
const client = url.protocol === 'https:' ? https : http;
const post = (path, body) => new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const req = client.request({ hostname: url.hostname, port: url.port, path, method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': data.length }
}, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(JSON.parse(d))); });
req.on('error', reject);
req.end(data);
});
const get = (path, token) => new Promise((resolve, reject) => {
const req = client.request({ hostname: url.hostname, port: url.port, path, method: 'GET',
headers: { 'Authorization': 'Bearer ' + token }
}, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(JSON.parse(d))); });
req.on('error', reject);
req.end();
});
(async () => {
try {
const auth = await post('/api/v1/auth/universal-auth/login', {
clientId: process.env.INFISICAL_CLIENT_ID,
clientSecret: process.env.INFISICAL_CLIENT_SECRET
});
if (!auth.accessToken) { console.error('[infisical] Auth failed'); process.exit(1); }
const slug = process.env.INFISICAL_PROJECT_SLUG;
const env = process.env.INFISICAL_ENV;
const secrets = await get('/api/v3/secrets/raw?workspaceSlug=' + slug + '&environment=' + env + '&secretPath=/&recursive=true', auth.accessToken);
if (!secrets.secrets) { console.error('[infisical] No secrets returned'); process.exit(1); }
// Output as shell-safe export statements
for (const s of secrets.secrets) {
// Single-quote the value to prevent shell expansion, escape existing single quotes
const escaped = s.secretValue.replace(/'/g, \"'\\\\''\" );
console.log('export ' + s.secretKey + \"='\" + escaped + \"'\");
}
} catch (e) { console.error('[infisical] Error:', e.message); process.exit(1); }
})();
" 2>&1) || {
echo "[infisical] WARNING: Failed to fetch secrets, starting with existing env vars"
exec "$@"
}
# Check if we got export statements or error messages
if echo "$EXPORTS" | grep -q "^export "; then
COUNT=$(echo "$EXPORTS" | grep -c "^export ")
eval "$EXPORTS"
echo "[infisical] Injected ${COUNT} secrets"
else
echo "[infisical] WARNING: $EXPORTS"
echo "[infisical] Starting with existing env vars"
fi
exec "$@"

14
eslint.config.mjs Normal file
View File

@ -0,0 +1,14 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [...compat.extends("next/core-web-vitals")];
export default eslintConfig;

8
next.config.ts Normal file
View File

@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
basePath: "/rmesh",
};
export default nextConfig;

40
package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "rmesh-online",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"postinstall": "prisma generate"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@encryptid/sdk": "file:../encryptid-sdk",
"@prisma/client": "^6.19.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"prisma": "^6.19.2",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

131
prisma/schema.prisma Normal file
View File

@ -0,0 +1,131 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Auth (NextAuth v5 + EncryptID) ─────────────────────────────
model User {
id String @id @default(cuid())
email String @unique
name String?
did String? @unique // EncryptID DID (passkey identity)
emailVerified DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
nodes MeshNode[]
messages LxmfMessage[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// ─── Mesh Models ─────────────────────────────────────────────────
model MeshNode {
id String @id @default(cuid())
name String
reticulumHash String? @unique @map("reticulum_hash")
nodeType String @default("software") @map("node_type")
hardwareType String @default("") @map("hardware_type")
firmwareVersion String @default("") @map("firmware_version")
location String @default("")
latitude Float?
longitude Float?
isOnline Boolean @default(false) @map("is_online")
lastSeenAt DateTime? @map("last_seen_at")
metadata Json @default("{}")
registeredById String @map("registered_by_id")
registeredBy User @relation(fields: [registeredById], references: [id])
spaceSlug String? @map("space_slug")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([spaceSlug])
@@index([registeredById])
@@index([nodeType])
@@map("mesh_nodes")
}
model LxmfMessage {
id String @id @default(cuid())
lxmfHash String? @unique @map("lxmf_hash")
direction String @default("inbound")
senderHash String @default("") @map("sender_hash")
recipientHash String @default("") @map("recipient_hash")
title String @default("")
content String
contentType String @default("text/plain") @map("content_type")
status String @default("pending")
deliveredAt DateTime? @map("delivered_at")
userId String? @map("user_id")
user User? @relation(fields: [userId], references: [id])
spaceSlug String? @map("space_slug")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([spaceSlug])
@@index([userId])
@@index([direction])
@@index([senderHash])
@@map("lxmf_messages")
}
model TopologySnapshot {
id String @id @default(cuid())
nodesJson Json @map("nodes_json")
linksJson Json @map("links_json")
nodeCount Int @default(0) @map("node_count")
linkCount Int @default(0) @map("link_count")
spaceSlug String? @map("space_slug")
capturedAt DateTime @default(now()) @map("captured_at")
@@index([capturedAt])
@@index([spaceSlug])
@@map("topology_snapshots")
}

View File

@ -0,0 +1,2 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View File

@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ status: "ok" });
}

View File

@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
/**
* Internal provision endpoint -- called by rSpace Registry when activating
* rmesh for a space. No auth required (only reachable from Docker network).
*/
export async function POST(request: Request) {
const body = await request.json();
const space: string = body.space?.trim();
if (!space) {
return NextResponse.json({ error: "Missing space name" }, { status: 400 });
}
const ownerDid: string = body.owner_did || "";
return NextResponse.json({
status: "ok",
space,
owner_did: ownerDid,
message: "rmesh space acknowledged",
});
}

17
src/app/api/me/route.ts Normal file
View File

@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
export async function GET() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ authenticated: false, user: null });
}
return NextResponse.json({
authenticated: true,
user: {
id: session.user.id,
name: session.user.name,
email: session.user.email,
},
});
}

View File

@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { getMeshMessages, sendMeshMessage } from "@/lib/mesh-client";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get("limit") || "100");
const offset = parseInt(searchParams.get("offset") || "0");
try {
const data = await getMeshMessages(limit, offset);
return NextResponse.json(data);
} catch {
return NextResponse.json({ messages: [], total: 0 }, { status: 503 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { destination_hash, content, title } = body;
if (!destination_hash || !content) {
return NextResponse.json(
{ error: "destination_hash and content are required" },
{ status: 400 },
);
}
const data = await sendMeshMessage(destination_hash, content, title || "");
return NextResponse.json(data);
} catch {
return NextResponse.json({ error: "Failed to send message" }, { status: 503 });
}
}

View File

@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { getMeshNodes } from "@/lib/mesh-client";
export async function GET() {
try {
const data = await getMeshNodes();
return NextResponse.json(data);
} catch {
return NextResponse.json({ nodes: [], total: 0 }, { status: 503 });
}
}

View File

@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { getCombinedStatus } from "@/lib/mesh-client";
export async function GET() {
try {
const data = await getCombinedStatus();
return NextResponse.json(data);
} catch {
return NextResponse.json({
reticulum: { online: false, transport_enabled: false, identity_hash: "", uptime_seconds: 0, announced_count: 0, path_count: 0 },
meshcore: { connected: false, device_info: {}, contact_count: 0, message_count: 0 },
}, { status: 503 });
}
}

View File

@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { getMeshTopology } from "@/lib/mesh-client";
export async function GET() {
try {
const data = await getMeshTopology();
return NextResponse.json(data);
} catch {
return NextResponse.json({ nodes: [], links: [], node_count: 0, link_count: 0 }, { status: 503 });
}
}

View File

@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { getMeshCoreContacts, refreshMeshCoreContacts } from "@/lib/mesh-client";
export async function GET() {
try {
return NextResponse.json(await getMeshCoreContacts());
} catch {
return NextResponse.json({ contacts: [], total: 0 }, { status: 503 });
}
}
export async function POST() {
try {
return NextResponse.json(await refreshMeshCoreContacts());
} catch {
return NextResponse.json({ contacts: [], total: 0 }, { status: 503 });
}
}

View File

@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { getMeshCoreMessages, sendMeshCoreMessage } from "@/lib/mesh-client";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get("limit") || "100");
const offset = parseInt(searchParams.get("offset") || "0");
try {
return NextResponse.json(await getMeshCoreMessages(limit, offset));
} catch {
return NextResponse.json({ messages: [], total: 0 }, { status: 503 });
}
}
export async function POST(request: Request) {
try {
const { contact_name, content } = await request.json();
if (!contact_name || !content) {
return NextResponse.json({ error: "contact_name and content required" }, { status: 400 });
}
return NextResponse.json(await sendMeshCoreMessage(contact_name, content));
} catch {
return NextResponse.json({ error: "Failed to send" }, { status: 503 });
}
}

View File

@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { getMeshCoreStats } from "@/lib/mesh-client";
export async function GET() {
try {
return NextResponse.json(await getMeshCoreStats());
} catch {
return NextResponse.json({}, { status: 503 });
}
}

View File

@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { getMeshCoreStatus } from "@/lib/mesh-client";
export async function GET() {
try {
return NextResponse.json(await getMeshCoreStatus());
} catch {
return NextResponse.json({ connected: false, device_info: {}, contact_count: 0, message_count: 0 }, { status: 503 });
}
}

View File

@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
const REGISTRY_URL = process.env.REGISTRY_URL || "http://rspace-registry:8000";
export async function GET(request: Request) {
try {
const res = await fetch(`${REGISTRY_URL}/spaces`, {
headers: {
Authorization: request.headers.get("Authorization") || "",
},
cache: "no-store",
});
const data = await res.json();
return NextResponse.json(data);
} catch {
return NextResponse.json({ spaces: [] }, { status: 503 });
}
}

84
src/app/globals.css Normal file
View File

@ -0,0 +1,84 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
}
:root {
--radius: 0.625rem;
--background: oklch(0.98 0.005 140);
--foreground: oklch(0.15 0.02 140);
--card: oklch(1 0 0);
--card-foreground: oklch(0.15 0.02 140);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.15 0.02 140);
--primary: oklch(0.55 0.2 140);
--primary-foreground: oklch(0.98 0 0);
--secondary: oklch(0.85 0.06 140);
--secondary-foreground: oklch(0.2 0.02 140);
--muted: oklch(0.95 0.01 140);
--muted-foreground: oklch(0.45 0.03 140);
--accent: oklch(0.65 0.18 80);
--accent-foreground: oklch(0.15 0.02 80);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.88 0.02 140);
--input: oklch(0.92 0.01 140);
--ring: oklch(0.55 0.2 140);
}
.dark {
--background: oklch(0.12 0.02 140);
--foreground: oklch(0.95 0.01 140);
--card: oklch(0.18 0.02 140);
--card-foreground: oklch(0.95 0.01 140);
--popover: oklch(0.18 0.02 140);
--popover-foreground: oklch(0.95 0.01 140);
--primary: oklch(0.7 0.2 140);
--primary-foreground: oklch(0.12 0.02 140);
--secondary: oklch(0.35 0.06 140);
--secondary-foreground: oklch(0.95 0.01 140);
--muted: oklch(0.25 0.02 140);
--muted-foreground: oklch(0.65 0.03 140);
--accent: oklch(0.6 0.15 80);
--accent-foreground: oklch(0.95 0.01 80);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(0.3 0.02 140);
--input: oklch(0.25 0.02 140);
--ring: oklch(0.7 0.2 140);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

24
src/app/layout.tsx Normal file
View File

@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { ThemeProvider } from "next-themes";
import "./globals.css";
export const metadata: Metadata = {
title: "rMesh - Mesh Networking for rSpace",
description: "Resilient mesh networking and offline-capable messaging for rSpace communities",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className="min-h-screen antialiased">
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
);
}

41
src/app/messages/page.tsx Normal file
View File

@ -0,0 +1,41 @@
import Link from "next/link";
import { Radio, ArrowLeft } from "lucide-react";
import MessageComposer from "@/components/mesh/MessageComposer";
import MessageList from "@/components/mesh/MessageList";
import MeshCoreMessages from "@/components/mesh/MeshCoreMessages";
export default function MessagesPage() {
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">Messages</h1>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 py-8 space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-accent" />
MeshCore (LoRa)
</h2>
<MeshCoreMessages />
</div>
<div className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-primary" />
Reticulum (LXMF)
</h2>
<MessageComposer />
<MessageList />
</div>
</div>
</main>
</div>
);
}

25
src/app/nodes/page.tsx Normal file
View File

@ -0,0 +1,25 @@
import Link from "next/link";
import { Radio, ArrowLeft } from "lucide-react";
import NodeList from "@/components/mesh/NodeList";
import NodeRegistration from "@/components/mesh/NodeRegistration";
export default function NodesPage() {
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">Mesh Nodes</h1>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 py-8 space-y-6">
<NodeRegistration />
<NodeList />
</main>
</div>
);
}

95
src/app/page.tsx Normal file
View File

@ -0,0 +1,95 @@
import Link from "next/link";
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 (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" },
];
export default function HomePage() {
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">
<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"
>
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="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.
</p>
</div>
{/* Dual status cards */}
<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">
{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"
>
<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" />
</div>
<h3 className="font-semibold mb-1">{item.label}</h3>
<p className="text-sm text-muted-foreground">{item.description}</p>
</Link>
))}
</div>
{/* Architecture explanation */}
<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">
<div>
<p className="font-medium text-accent mb-1">MeshCore (LoRa Mesh)</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.
</p>
</div>
<div>
<p className="font-medium text-primary mb-1">Reticulum (Internet Backbone)</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.
</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]
</div>
</div>
</main>
</div>
);
}

25
src/app/topology/page.tsx Normal file
View File

@ -0,0 +1,25 @@
import Link from "next/link";
import { Radio, ArrowLeft } from "lucide-react";
import TopologyMap from "@/components/mesh/TopologyMap";
import NetworkStatus from "@/components/mesh/NetworkStatus";
export default function TopologyPage() {
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">Network Topology</h1>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 py-8 space-y-6">
<NetworkStatus />
<TopologyMap />
</main>
</div>
);
}

View File

@ -0,0 +1,94 @@
"use client";
import { useEffect, useState } from "react";
import { Users, RefreshCw } from "lucide-react";
interface Contact {
key_prefix: string;
name: string;
type: number;
last_seen?: number;
path_known: boolean;
public_key: string;
}
export default function MeshCoreContacts() {
const [contacts, setContacts] = useState<Contact[]>([]);
const [total, setTotal] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const fetchContacts = async () => {
try {
const res = await fetch("/rmesh/api/meshcore/contacts");
const data = await res.json();
setContacts(data.contacts || []);
setTotal(data.total || 0);
} catch {
// ignore
}
};
const handleRefresh = async () => {
setRefreshing(true);
try {
const res = await fetch("/rmesh/api/meshcore/contacts", { method: "POST" });
const data = await res.json();
setContacts(data.contacts || []);
setTotal(data.total || 0);
} catch {
// ignore
}
setRefreshing(false);
};
useEffect(() => {
fetchContacts();
const interval = setInterval(fetchContacts, 30000);
return () => clearInterval(interval);
}, []);
return (
<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-accent/10">
<Users className="h-5 w-5 text-accent" />
</div>
<div>
<h3 className="font-semibold">MeshCore Contacts</h3>
<p className="text-sm text-muted-foreground">{total} contact{total !== 1 ? "s" : ""}</p>
</div>
<button
onClick={handleRefresh}
disabled={refreshing}
className="ml-auto p-2 rounded-lg hover:bg-muted transition-colors disabled:opacity-50"
title="Refresh contacts"
>
<RefreshCw className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
</button>
</div>
{contacts.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No contacts yet. Contacts appear when MeshCore nodes advertise on the mesh.
</p>
) : (
<div className="space-y-2">
{contacts.map((c) => (
<div key={c.public_key || c.name} 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 shrink-0 ${c.path_known ? "bg-green-500" : "bg-yellow-500"}`} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{c.name || "Unknown"}</p>
<code className="text-xs text-muted-foreground font-mono">{c.key_prefix}...</code>
</div>
</div>
<span className="text-xs text-muted-foreground shrink-0">
{c.path_known ? "path known" : "no path"}
</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import { Radio, ArrowUpRight, ArrowDownLeft, Hash } from "lucide-react";
interface MCMessage {
id: string;
type: string;
direction: string;
sender: string;
content: string;
channel?: string;
recipient?: string;
timestamp: number;
status: string;
}
function formatTime(ts: number): string {
return new Date(ts * 1000).toLocaleString();
}
export default function MeshCoreMessages() {
const [messages, setMessages] = useState<MCMessage[]>([]);
const [total, setTotal] = useState(0);
useEffect(() => {
const fetchMessages = async () => {
try {
const res = await fetch("/rmesh/api/meshcore/messages");
const data = await res.json();
setMessages(data.messages || []);
setTotal(data.total || 0);
} catch {
// ignore
}
};
fetchMessages();
const interval = setInterval(fetchMessages, 10000);
return () => clearInterval(interval);
}, []);
return (
<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-accent/10">
<Radio className="h-5 w-5 text-accent" />
</div>
<div>
<h3 className="font-semibold">MeshCore Messages</h3>
<p className="text-sm text-muted-foreground">{total} message{total !== 1 ? "s" : ""}</p>
</div>
</div>
{messages.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No MeshCore messages yet. Messages appear when contacts send DMs or channel posts.
</p>
) : (
<div className="space-y-3">
{messages.map((msg) => (
<div key={msg.id} className="p-3 rounded-lg bg-muted/50">
<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 text-muted-foreground">{msg.sender}</span>
{msg.channel && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Hash className="h-3 w-3" />{msg.channel}
</span>
)}
<span className={`ml-auto text-xs px-2 py-0.5 rounded-full ${
msg.type === "channel" ? "bg-blue-500/10 text-blue-500" : "bg-accent/10 text-accent"
}`}>
{msg.type}
</span>
</div>
<p className="text-sm mt-1">{msg.content}</p>
<p className="text-xs text-muted-foreground mt-2">{formatTime(msg.timestamp)}</p>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import { Radio, WifiOff, Users, MessageSquare } from "lucide-react";
interface MCStatus {
connected: boolean;
device_info: {
name?: string;
firmware?: string;
freq?: number;
bw?: number;
sf?: number;
tx_power?: number;
};
contact_count: number;
message_count: number;
}
export default function MeshCoreStatus() {
const [status, setStatus] = useState<MCStatus | null>(null);
useEffect(() => {
const fetchStatus = async () => {
try {
const res = await fetch("/rmesh/api/meshcore/status");
setStatus(await res.json());
} catch {
setStatus(null);
}
};
fetchStatus();
const interval = setInterval(fetchStatus, 10000);
return () => clearInterval(interval);
}, []);
const connected = status?.connected ?? false;
const info = status?.device_info ?? {};
return (
<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 ${connected ? "bg-accent/10" : "bg-muted"}`}>
{connected ? (
<Radio className="h-5 w-5 text-accent" />
) : (
<WifiOff className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div>
<h3 className="font-semibold">MeshCore LoRa Mesh</h3>
<p className="text-sm text-muted-foreground">
{connected ? `Connected: ${info.name || "Companion"}` : "No companion device connected"}
</p>
</div>
<div className={`ml-auto h-3 w-3 rounded-full ${connected ? "bg-green-500 animate-pulse" : "bg-muted-foreground/30"}`} />
</div>
{connected && info.freq ? (
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="flex items-center gap-2 text-sm">
<Radio className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Freq:</span>
<span>{(info.freq / 1000000).toFixed(1)} MHz</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">SF{info.sf} / BW{info.bw ? (info.bw / 1000) + "kHz" : "?"}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Contacts:</span>
<span>{status?.contact_count ?? 0}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Messages:</span>
<span>{status?.message_count ?? 0}</span>
</div>
</div>
) : !connected ? (
<p className="text-xs text-muted-foreground mt-2">
Connect a MeshCore Companion node via WiFi (TCP) or USB serial to enable LoRa mesh.
Set MESHCORE_ENABLED=true and MESHCORE_HOST in the container config.
</p>
) : null}
</div>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { Send } from "lucide-react";
export default function MessageComposer({ onSent }: { onSent?: () => void }) {
const [destinationHash, setDestinationHash] = useState("");
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [sending, setSending] = useState(false);
const [feedback, setFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null);
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!destinationHash.trim() || !content.trim()) return;
setSending(true);
setFeedback(null);
try {
const res = await fetch("/rmesh/api/mesh/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
destination_hash: destinationHash.trim(),
content: content.trim(),
title: title.trim(),
}),
});
if (res.ok) {
setFeedback({ type: "success", text: "Message queued for delivery" });
setDestinationHash("");
setTitle("");
setContent("");
onSent?.();
} else {
const data = await res.json();
setFeedback({ type: "error", text: data.error || "Failed to send" });
}
} catch {
setFeedback({ type: "error", text: "Bridge unreachable" });
} finally {
setSending(false);
}
};
return (
<form onSubmit={handleSend} className="rounded-xl border bg-card p-6 shadow-sm space-y-4">
<h3 className="font-semibold">Send LXMF Message</h3>
<div>
<label className="block text-sm text-muted-foreground mb-1">Destination Hash</label>
<input
type="text"
value={destinationHash}
onChange={(e) => setDestinationHash(e.target.value)}
placeholder="Reticulum destination hash (hex)"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground/50"
required
/>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">Title (optional)</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Message title"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground/50"
/>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">Content</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Message content..."
rows={3}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground/50 resize-none"
required
/>
</div>
{feedback && (
<p className={`text-sm ${feedback.type === "success" ? "text-green-500" : "text-destructive"}`}>
{feedback.text}
</p>
)}
<button
type="submit"
disabled={sending || !destinationHash.trim() || !content.trim()}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send className="h-4 w-4" />
{sending ? "Sending..." : "Send"}
</button>
</form>
);
}

View File

@ -0,0 +1,94 @@
"use client";
import { useEffect, useState } from "react";
import { Mail, ArrowUpRight, ArrowDownLeft } from "lucide-react";
interface Message {
id: string;
direction: string;
sender_hash: string;
recipient_hash: string;
title: string;
content: string;
status: string;
timestamp: number;
}
function formatTime(ts: number): string {
return new Date(ts * 1000).toLocaleString();
}
export default function MessageList() {
const [messages, setMessages] = useState<Message[]>([]);
const [total, setTotal] = useState(0);
useEffect(() => {
const fetchMessages = async () => {
try {
const res = await fetch("/rmesh/api/mesh/messages");
const data = await res.json();
setMessages(data.messages || []);
setTotal(data.total || 0);
} catch {
// ignore
}
};
fetchMessages();
const interval = setInterval(fetchMessages, 10000);
return () => clearInterval(interval);
}, []);
return (
<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-accent/10">
<Mail className="h-5 w-5 text-accent" />
</div>
<div>
<h3 className="font-semibold">LXMF Messages</h3>
<p className="text-sm text-muted-foreground">{total} message{total !== 1 ? "s" : ""}</p>
</div>
</div>
{messages.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No messages yet. Send a message or wait for incoming LXMF messages.
</p>
) : (
<div className="space-y-3">
{messages.map((msg) => (
<div key={msg.id} className="p-3 rounded-lg bg-muted/50">
<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 ${
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"
}`}>
{msg.status}
</span>
</div>
{msg.title && (
<p className="text-sm font-medium">{msg.title}</p>
)}
<p className="text-sm mt-1">{msg.content}</p>
<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>
<span className="ml-auto">{formatTime(msg.timestamp)}</span>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,84 @@
"use client";
import { useEffect, useState } from "react";
import { Radio, Wifi, WifiOff, Clock, Hash, Network } from "lucide-react";
interface MeshStatus {
online: boolean;
transport_enabled: boolean;
identity_hash: string;
uptime_seconds: number;
announced_count: number;
path_count: number;
}
function formatUptime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export default function NetworkStatus() {
const [status, setStatus] = useState<MeshStatus | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
const fetchStatus = async () => {
try {
const res = await fetch("/rmesh/api/mesh/status");
const data = await res.json();
setStatus(data);
setError(!data.online);
} catch {
setError(true);
}
};
fetchStatus();
const interval = setInterval(fetchStatus, 10000);
return () => clearInterval(interval);
}, []);
return (
<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 ${error ? "bg-destructive/10" : "bg-primary/10"}`}>
{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>
<p className="text-sm text-muted-foreground">
{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>
{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">Identity:</span>
<code className="text-xs font-mono truncate">{status.identity_hash.slice(0, 16)}...</code>
</div>
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Uptime:</span>
<span>{formatUptime(status.uptime_seconds)}</span>
</div>
<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>{status.announced_count}</span>
</div>
<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>{status.path_count}</span>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,84 @@
"use client";
import { useEffect, useState } from "react";
import { Antenna, Clock } from "lucide-react";
interface NodeInfo {
destination_hash: string;
app_name?: string;
aspects?: string;
last_heard?: number;
hops?: number;
}
function timeAgo(timestamp: number): string {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
export default function NodeList() {
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [total, setTotal] = useState(0);
useEffect(() => {
const fetchNodes = async () => {
try {
const res = await fetch("/rmesh/api/mesh/nodes");
const data = await res.json();
setNodes(data.nodes || []);
setTotal(data.total || 0);
} catch {
// ignore
}
};
fetchNodes();
const interval = setInterval(fetchNodes, 15000);
return () => clearInterval(interval);
}, []);
return (
<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-accent/10">
<Antenna className="h-5 w-5 text-accent" />
</div>
<div>
<h3 className="font-semibold">Discovered Nodes</h3>
<p className="text-sm text-muted-foreground">{total} node{total !== 1 ? "s" : ""} on network</p>
</div>
</div>
{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.
</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="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>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,150 @@
"use client";
import { useState } from "react";
import { Plus } from "lucide-react";
const NODE_TYPES = [
{ value: "software", label: "Software Node" },
{ value: "companion", label: "Companion (BLE/USB)" },
{ value: "repeater", label: "Repeater" },
{ value: "room_server", label: "Room Server" },
{ value: "rnode", label: "RNode (LoRa)" },
];
const HARDWARE_TYPES = [
{ value: "", label: "Software only" },
{ value: "heltec_v3", label: "Heltec WiFi LoRa V3" },
{ value: "tbeam", label: "LILYGO T-Beam" },
{ value: "tdeck", label: "LILYGO T-Deck" },
{ value: "techo", label: "LILYGO T-Echo" },
{ value: "rak_wisblock", label: "RAK WisBlock" },
{ value: "rnode", label: "RNode" },
{ value: "seeed_xiao", label: "Seeed XIAO" },
{ value: "other", label: "Other" },
];
export default function NodeRegistration() {
const [name, setName] = useState("");
const [nodeType, setNodeType] = useState("software");
const [hardwareType, setHardwareType] = useState("");
const [reticulumHash, setReticulumHash] = useState("");
const [location, setLocation] = useState("");
const [saving, setSaving] = useState(false);
const [feedback, setFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setFeedback(null);
try {
const res = await fetch("/rmesh/api/mesh/nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name.trim(),
node_type: nodeType,
hardware_type: hardwareType,
reticulum_hash: reticulumHash.trim() || null,
location: location.trim(),
}),
});
if (res.ok) {
setFeedback({ type: "success", text: "Node registered" });
setName("");
setReticulumHash("");
setLocation("");
} else {
setFeedback({ type: "error", text: "Failed to register node" });
}
} catch {
setFeedback({ type: "error", text: "Server error" });
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handleSubmit} className="rounded-xl border bg-card p-6 shadow-sm space-y-4">
<h3 className="font-semibold">Register a Node</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-muted-foreground mb-1">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My rooftop repeater"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
required
/>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">Node Type</label>
<select
value={nodeType}
onChange={(e) => setNodeType(e.target.value)}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
>
{NODE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">Hardware</label>
<select
value={hardwareType}
onChange={(e) => setHardwareType(e.target.value)}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
>
{HARDWARE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">Reticulum Hash (optional)</label>
<input
type="text"
value={reticulumHash}
onChange={(e) => setReticulumHash(e.target.value)}
placeholder="Hex hash of destination"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm font-mono"
/>
</div>
<div className="sm:col-span-2">
<label className="block text-sm text-muted-foreground mb-1">Location</label>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Rooftop, Berlin Mitte"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
</div>
{feedback && (
<p className={`text-sm ${feedback.type === "success" ? "text-green-500" : "text-destructive"}`}>
{feedback.text}
</p>
)}
<button
type="submit"
disabled={saving || !name.trim()}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Plus className="h-4 w-4" />
{saving ? "Registering..." : "Register Node"}
</button>
</form>
);
}

View File

@ -0,0 +1,69 @@
"use client";
import { useEffect, useState } from "react";
import { GitBranch } from "lucide-react";
interface TopologyData {
nodes: { destination_hash: string; app_name?: string; last_heard?: number }[];
links: { source: string; target: string; hops: number; active: boolean }[];
node_count: number;
link_count: number;
}
export default function TopologyMap() {
const [topology, setTopology] = useState<TopologyData | null>(null);
useEffect(() => {
const fetchTopology = async () => {
try {
const res = await fetch("/rmesh/api/mesh/topology");
const data = await res.json();
setTopology(data);
} catch {
// ignore
}
};
fetchTopology();
const interval = setInterval(fetchTopology, 30000);
return () => clearInterval(interval);
}, []);
return (
<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">
<GitBranch className="h-5 w-5 text-primary" />
</div>
<div>
<h3 className="font-semibold">Network Topology</h3>
<p className="text-sm text-muted-foreground">
{topology ? `${topology.node_count} nodes, ${topology.link_count} links` : "Loading..."}
</p>
</div>
</div>
{topology && topology.links.length > 0 ? (
<div className="space-y-2">
{topology.links.map((link, i) => (
<div key={i} className="flex items-center gap-2 p-2 rounded-lg bg-muted/50 text-sm">
<code className="font-mono text-xs truncate">{link.source.slice(0, 12)}</code>
<span className="text-muted-foreground">
{"->".repeat(1)} {link.hops} hop{link.hops !== 1 ? "s" : ""} {"->".repeat(1)}
</span>
<code className="font-mono text-xs truncate">{link.target.slice(0, 12)}</code>
<span className={`ml-auto text-xs ${link.active ? "text-green-500" : "text-muted-foreground"}`}>
{link.active ? "active" : "stale"}
</span>
</div>
))}
</div>
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
<p>No network paths discovered yet.</p>
<p className="mt-1">Connect Reticulum peers to see the topology.</p>
</div>
)}
</div>
);
}

73
src/lib/auth.ts Normal file
View File

@ -0,0 +1,73 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "./prisma";
import { verifyEncryptIDToken } from "./encryptid";
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
trustHost: true,
session: {
strategy: "jwt",
},
pages: {
signIn: "/auth/signin",
error: "/auth/error",
},
providers: [
Credentials({
id: "encryptid",
name: "EncryptID Passkey",
credentials: {
token: { label: "Token", type: "text" },
},
async authorize(credentials) {
if (!credentials?.token) {
return null;
}
const claims = await verifyEncryptIDToken(credentials.token as string);
if (!claims) {
return null;
}
const did = claims.did || claims.sub;
let user = await prisma.user.findFirst({
where: { did },
});
if (!user) {
user = await prisma.user.create({
data: {
email: `${did}@encryptid.local`,
did,
name: claims.username || null,
emailVerified: new Date(),
},
});
}
return {
id: user.id,
email: user.email,
name: user.name,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
}
return session;
},
},
});

25
src/lib/encryptid.ts Normal file
View File

@ -0,0 +1,25 @@
import { verifyEncryptIDToken as sdkVerify } from '@encryptid/sdk/server';
export const ENCRYPTID_SERVER_URL =
process.env.ENCRYPTID_SERVER_URL || 'https://auth.ridentity.online';
export async function verifyEncryptIDToken(token: string): Promise<{
sub: string;
username?: string;
did?: string;
exp?: number;
} | null> {
try {
const claims = await sdkVerify(token, {
serverUrl: ENCRYPTID_SERVER_URL,
});
return {
sub: claims.sub,
username: claims.username,
did: claims.did,
exp: claims.exp,
};
} catch {
return null;
}
}

109
src/lib/mesh-client.ts Normal file
View File

@ -0,0 +1,109 @@
const BRIDGE_URL = process.env.RETICULUM_BRIDGE_URL || "http://rmesh-reticulum:8000";
const API_KEY = process.env.BRIDGE_API_KEY || "";
async function bridgeFetch(path: string, options: RequestInit = {}) {
const res = await fetch(`${BRIDGE_URL}${path}`, {
...options,
headers: {
"X-Bridge-API-Key": API_KEY,
"Content-Type": "application/json",
...options.headers,
},
cache: "no-store",
});
if (!res.ok) {
throw new Error(`Bridge request failed: ${res.status} ${res.statusText}`);
}
return res.json();
}
// Combined status
export async function getCombinedStatus() {
return bridgeFetch("/api/status");
}
// Reticulum (Internet backbone)
export async function getReticulumStatus() {
return bridgeFetch("/api/reticulum/status");
}
export async function getReticulumNodes() {
return bridgeFetch("/api/reticulum/nodes");
}
export async function getReticulumTopology() {
return bridgeFetch("/api/reticulum/topology");
}
export async function getReticulumMessages(limit = 100, offset = 0) {
return bridgeFetch(`/api/reticulum/messages?limit=${limit}&offset=${offset}`);
}
export async function sendReticulumMessage(destinationHash: string, content: string, title = "") {
return bridgeFetch("/api/reticulum/messages", {
method: "POST",
body: JSON.stringify({ destination_hash: destinationHash, content, title }),
});
}
export async function getReticulumIdentity() {
return bridgeFetch("/api/reticulum/identity");
}
export async function announceReticulum() {
return bridgeFetch("/api/reticulum/announce", { method: "POST" });
}
// MeshCore (LoRa mesh)
export async function getMeshCoreStatus() {
return bridgeFetch("/api/meshcore/status");
}
export async function getMeshCoreContacts() {
return bridgeFetch("/api/meshcore/contacts");
}
export async function refreshMeshCoreContacts() {
return bridgeFetch("/api/meshcore/contacts/refresh", { method: "POST" });
}
export async function getMeshCoreMessages(limit = 100, offset = 0) {
return bridgeFetch(`/api/meshcore/messages?limit=${limit}&offset=${offset}`);
}
export async function sendMeshCoreMessage(contactName: string, content: string) {
return bridgeFetch("/api/meshcore/messages", {
method: "POST",
body: JSON.stringify({ contact_name: contactName, content }),
});
}
export async function sendMeshCoreChannelMessage(channelIdx: number, content: string) {
return bridgeFetch("/api/meshcore/channels/messages", {
method: "POST",
body: JSON.stringify({ channel_idx: channelIdx, content }),
});
}
export async function advertiseMeshCore() {
return bridgeFetch("/api/meshcore/advert", { method: "POST" });
}
export async function getMeshCoreStats() {
return bridgeFetch("/api/meshcore/stats");
}
// Legacy compat
export async function getMeshStatus() { return getCombinedStatus(); }
export async function getMeshNodes() { return bridgeFetch("/api/nodes"); }
export async function getMeshTopology() { return bridgeFetch("/api/topology"); }
export async function getMeshMessages(limit = 100, offset = 0) {
return bridgeFetch(`/api/reticulum/messages?limit=${limit}&offset=${offset}`);
}
export async function sendMeshMessage(destinationHash: string, content: string, title = "") {
return sendReticulumMessage(destinationHash, content, title);
}
export async function getMeshIdentity() { return bridgeFetch("/api/identity"); }
export async function announceMesh() { return announceReticulum(); }

13
src/lib/prisma.ts Normal file
View File

@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

34
tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}