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:
commit
f0771a1ce4
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
NEXTAUTH_SECRET=change-me
|
||||
DB_PASSWORD=change-me
|
||||
BRIDGE_API_KEY=change-me
|
||||
INFISICAL_CLIENT_ID=
|
||||
INFISICAL_CLIENT_SECRET=
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
.next/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
encryptid-sdk/
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
basePath: "/rmesh",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { handlers } from "@/lib/auth";
|
||||
export const { GET, POST } = handlers;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: "ok" });
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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] <--868MHz LoRa--> [Companion] <--TCP--> [rMesh Server] <--Reticulum TCP--> [Remote Sites]
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(); }
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue