feat: graph browser

This commit is contained in:
2026-04-04 16:18:21 +02:00
parent 72ff02dbdf
commit 3132d40391
10 changed files with 735 additions and 443 deletions

View File

@@ -7,6 +7,7 @@ announces, and exposes discovered nodes + remote page fetching via API.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import os import os
import threading import threading
@@ -14,6 +15,7 @@ import time
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from starlette.responses import StreamingResponse
router = APIRouter() router = APIRouter()
log = logging.getLogger("browse") log = logging.getLogger("browse")
@@ -27,47 +29,45 @@ _own_hash: str | None = None
_own_name: str = os.environ.get("NOMADNET_NODE_NAME", "Micronomicon") _own_name: str = os.environ.get("NOMADNET_NODE_NAME", "Micronomicon")
_lock = threading.Lock() _lock = threading.Lock()
_started = False _started = False
_subscribers: list[asyncio.Queue] = []
_sub_lock = threading.Lock()
_loop: asyncio.AbstractEventLoop | None = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# RNS lifecycle # RNS announce handler (must be an object with aspect_filter + method)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def start_browser() -> None: def _push_node(node_data: dict) -> None:
"""Initialize RNS and begin listening for NomadNet node announces.""" """Push a node update to all SSE subscribers (thread-safe)."""
global _started, _own_hash with _sub_lock:
for q in list(_subscribers):
if _started: if _loop and _loop.is_running():
return _loop.call_soon_threadsafe(q.put_nowait, node_data)
else:
try: try:
import RNS q.put_nowait(node_data)
except asyncio.QueueFull:
configdir = os.environ.get("RNS_CONFIG_DIR", None) pass
if configdir:
Path(configdir).mkdir(parents=True, exist_ok=True)
reticulum = RNS.Reticulum(configdir=configdir)
# Register handler for NomadNet page-serving node announces
RNS.Transport.register_announce_handler(
_on_announce,
aspect_filter="nomadnetwork.node",
)
_started = True
log.info("RNS browser started (v%s)", RNS.__version__)
except Exception as exc:
log.warning("Failed to start RNS browser: %s", exc)
def _on_announce( class _AnnounceHandler:
"""RNS-compatible announce handler.
RNS.Transport.register_announce_handler() requires an object with:
- aspect_filter: str attribute
- received_announce(dest_hash, identity, app_data, ...): callable
"""
aspect_filter = "nomadnetwork.node"
def received_announce(
self,
destination_hash: bytes, destination_hash: bytes,
announced_identity, announced_identity,
app_data: bytes | None, app_data: bytes | None,
**kwargs,
) -> None: ) -> None:
"""Handle an incoming NomadNet node announce."""
import RNS import RNS
hash_hex = RNS.hexrep(destination_hash, delimit=False) hash_hex = RNS.hexrep(destination_hash, delimit=False)
@@ -81,42 +81,166 @@ def _on_announce(
is_self = name == _own_name is_self = name == _own_name
# Determine which interface this announce arrived on
iface_name = None
try:
path_entry = RNS.Transport.path_table.get(destination_hash)
if path_entry and path_entry[5]: # IDX_PT_RVCD_IF = 5
iface_name = getattr(path_entry[5], "name", None)
except Exception:
pass
with _lock: with _lock:
_nodes[hash_hex] = { _nodes[hash_hex] = {
"hash": hash_hex, "hash": hash_hex,
"name": name, "name": name,
"last_seen": time.time(), "last_seen": time.time(),
"is_self": is_self, "is_self": is_self,
"type": "node",
"interface": iface_name,
} }
if is_self: if is_self:
global _own_hash global _own_hash
_own_hash = hash_hex _own_hash = hash_hex
log.info("Node announce: %s (%s)%s", name, hash_hex[:8], " [self]" if is_self else "") log.info(
"Node announce: %s (%s) via %s%s",
name, hash_hex[:8], iface_name or "?",
" [self]" if is_self else "",
)
_push_node(_nodes[hash_hex])
# ---------------------------------------------------------------------------
# RNS lifecycle
# ---------------------------------------------------------------------------
_reticulum = None
def _collect_interfaces() -> list[dict]:
"""Read active RNS interfaces and return them as node-like dicts."""
try:
import RNS
except ImportError:
return []
ifaces = []
for iface in RNS.Transport.interfaces:
name = getattr(iface, "name", str(iface))
iface_id = f"iface_{name}"
target = getattr(iface, "target_ip", None) or getattr(iface, "target_host", None)
port = getattr(iface, "target_port", None) or getattr(iface, "bind_port", None)
ifaces.append({
"hash": iface_id,
"name": name,
"last_seen": time.time(),
"is_self": False,
"type": "interface",
"online": getattr(iface, "online", False),
"target": f"{target}:{port}" if target and port else None,
"txb": getattr(iface, "txb", 0),
"rxb": getattr(iface, "rxb", 0),
"bitrate": getattr(iface, "bitrate", 0),
"clients": len(getattr(iface, "clients", None) or []) if hasattr(iface, "clients") else None,
})
return ifaces
def start_browser() -> None:
"""Initialize RNS and begin listening for NomadNet node announces."""
global _started, _loop, _reticulum
if _started:
return
try:
_loop = asyncio.get_event_loop()
except RuntimeError:
_loop = None
try:
import RNS
configdir = os.environ.get("RNS_CONFIG_DIR", None)
if configdir:
Path(configdir).mkdir(parents=True, exist_ok=True)
_reticulum = RNS.Reticulum(configdir=configdir)
RNS.Transport.register_announce_handler(_AnnounceHandler())
_started = True
log.info("RNS browser started (v%s)", RNS.__version__)
except Exception as exc:
log.warning("Failed to start RNS browser: %s", exc)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# API endpoints # API endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/browse/nodes") def _build_snapshot() -> list[dict]:
async def list_nodes(): """Build a full snapshot: self node + interfaces + discovered nodes."""
"""Return all discovered NomadNet page-serving nodes."""
with _lock: with _lock:
nodes = list(_nodes.values()) nodes = list(_nodes.values())
# Ensure the user's own node is always present # Ensure type field on all nodes
for n in nodes:
n.setdefault("type", "node")
if not any(n["is_self"] for n in nodes): if not any(n["is_self"] for n in nodes):
nodes.insert(0, { nodes.insert(0, {
"hash": _own_hash or "self", "hash": _own_hash or "self",
"name": _own_name, "name": _own_name,
"last_seen": time.time(), "last_seen": time.time(),
"is_self": True, "is_self": True,
"type": "node",
}) })
# Add interfaces
nodes.extend(_collect_interfaces())
return nodes return nodes
@router.get("/browse/nodes")
async def list_nodes():
"""Return all discovered NomadNet nodes and interfaces."""
return _build_snapshot()
@router.get("/browse/nodes/stream")
async def stream_nodes():
"""SSE stream — pushes full snapshot then live node announces."""
queue: asyncio.Queue = asyncio.Queue(maxsize=64)
with _sub_lock:
_subscribers.append(queue)
async def event_generator():
try:
# Send full snapshot (self + interfaces + known nodes)
for entry in _build_snapshot():
yield f"data: {json.dumps(entry)}\n\n"
# Then stream new announces as they arrive
while True:
node = await queue.get()
yield f"data: {json.dumps(node)}\n\n"
except asyncio.CancelledError:
pass
finally:
with _sub_lock:
_subscribers.remove(queue)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/browse/page/{hash_hex}") @router.get("/browse/page/{hash_hex}")
async def get_remote_page(hash_hex: str, path: str = Query("index.mu")): async def get_remote_page(hash_hex: str, path: str = Query("index.mu")):
"""Fetch a Micron page from a node. """Fetch a Micron page from a node.
@@ -124,13 +248,11 @@ async def get_remote_page(hash_hex: str, path: str = Query("index.mu")):
For the user's own node reads from the local pages directory. For the user's own node reads from the local pages directory.
For remote nodes establishes an RNS link and requests the page. For remote nodes establishes an RNS link and requests the page.
""" """
# Own node → read from disk
with _lock: with _lock:
node = _nodes.get(hash_hex) node = _nodes.get(hash_hex)
if (node and node.get("is_self")) or hash_hex == "self": if (node and node.get("is_self")) or hash_hex == "self":
return _read_local_page(path) return _read_local_page(path)
# Remote node → RNS request
content = await _request_remote_page(hash_hex, path) content = await _request_remote_page(hash_hex, path)
if content is None: if content is None:
return {"content": None, "error": "Could not reach node"} return {"content": None, "error": "Could not reach node"}
@@ -168,7 +290,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
try: try:
dest_hash = bytes.fromhex(hash_hex) dest_hash = bytes.fromhex(hash_hex)
# Ensure path to destination is known
if not RNS.Transport.has_path(dest_hash): if not RNS.Transport.has_path(dest_hash):
RNS.Transport.request_path(dest_hash) RNS.Transport.request_path(dest_hash)
deadline = time.time() + 10 deadline = time.time() + 10
@@ -195,7 +316,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
link = RNS.Link(dest) link = RNS.Link(dest)
# Wait for link to become active
deadline = time.time() + 15 deadline = time.time() + 15
while time.time() < deadline: while time.time() < deadline:
if link.status == RNS.Link.ACTIVE: if link.status == RNS.Link.ACTIVE:
@@ -206,7 +326,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
loop.call_soon_threadsafe(future.set_result, None) loop.call_soon_threadsafe(future.set_result, None)
return return
# Request the page via NomadNet's protocol
def on_response(request_receipt): def on_response(request_receipt):
try: try:
resp = request_receipt.response resp = request_receipt.response
@@ -236,7 +355,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
if not future.done(): if not future.done():
loop.call_soon_threadsafe(future.set_result, None) loop.call_soon_threadsafe(future.set_result, None)
# Run blocking RNS operations in a thread
threading.Thread(target=_do_request, daemon=True).start() threading.Thread(target=_do_request, daemon=True).start()
try: try:

View File

@@ -28,7 +28,16 @@ async def health():
return {"status": "ok"} return {"status": "ok"}
# Serve built frontend as static files (SPA fallback) # Serve built frontend as static files with SPA fallback
static_dir = Path(__file__).parent / "static" static_dir = Path(__file__).parent / "static"
if static_dir.is_dir(): if static_dir.is_dir():
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") from fastapi.responses import FileResponse
app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets")
@app.get("/{path:path}")
async def spa_fallback(path: str):
file = static_dir / path
if file.is_file():
return FileResponse(file)
return FileResponse(static_dir / "index.html")

View File

@@ -9,10 +9,12 @@ services:
- PAGES_DIR=/data/pages - PAGES_DIR=/data/pages
- SOURCES_DIR=/data/sources - SOURCES_DIR=/data/sources
- NOMADNET_CONTAINER=nomadnet - NOMADNET_CONTAINER=nomadnet
- LOG_LEVEL=DEBUG
volumes: volumes:
- pages:/data/pages - pages:/data/pages
- sources:/data/sources - sources:/data/sources
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- ./reticulum-client.conf:/root/.reticulum/config:ro
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- nomadnet - nomadnet

14
deploy.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
echo "==> Building frontend..."
(cd frontend && npm run build)
echo "==> Deploying with docker compose..."
docker compose -f compose.yml up -d --build
echo "==> Done!"
echo " Web IDE: http://localhost:8080"
echo " Reticulum: tcp://0.0.0.0:4242"

View File

@@ -16,15 +16,17 @@
"@codemirror/search": "^6.6.0", "@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.40.0", "@codemirror/view": "^6.40.0",
"@cosmos.gl/graph": "^2.6.4",
"@dagrejs/dagre": "^3.0.0", "@dagrejs/dagre": "^3.0.0",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8",
"@sigma/node-square": "^3.0.0",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@xyflow/react": "^12.10.2", "@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"graphology": "^0.26.0",
"graphology-layout-force": "^0.2.4",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"micron-parser": "^1.0.3", "micron-parser": "^1.0.3",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -33,6 +35,7 @@
"react-resizable-panels": "^4.8.0", "react-resizable-panels": "^4.8.0",
"react-router-dom": "^7.13.2", "react-router-dom": "^7.13.2",
"shadcn": "^4.1.1", "shadcn": "^4.1.1",
"sigma": "^3.0.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
@@ -664,31 +667,6 @@
"w3c-keyname": "^2.2.4" "w3c-keyname": "^2.2.4"
} }
}, },
"node_modules/@cosmos.gl/graph": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/@cosmos.gl/graph/-/graph-2.6.4.tgz",
"integrity": "sha512-i+N9lSpAjGLTUPelo/bKNbQnKPDqt3k2UnRlfIWe2Lrambc4J3QFgOfpR8AalQ/1tgLRoeNtVBZ1GPpsNqae5w==",
"license": "MIT",
"dependencies": {
"d3-array": "^3.2.0",
"d3-color": "^3.1.0",
"d3-drag": "^3.0.0",
"d3-ease": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.1",
"d3-zoom": "^3.0.0",
"dompurify": "^3.2.6",
"gl-bench": "^1.0.42",
"gl-matrix": "^3.4.3",
"random": "^4.1.0",
"regl": "^2.1.0"
},
"engines": {
"node": ">=12.2.0",
"npm": ">=7.0.0"
}
},
"node_modules/@dagrejs/dagre": { "node_modules/@dagrejs/dagre": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz",
@@ -1836,6 +1814,15 @@
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sigma/node-square": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@sigma/node-square/-/node-square-3.0.0.tgz",
"integrity": "sha512-hPX2oWo7WeaSe6M3D56AXsrLyg3F+7N/YsodaJh4Sw3KTce0GAFVWWPZZklu9CITz0xi3kEmlCGulqAH0cVG2w==",
"license": "MIT",
"peerDependencies": {
"sigma": ">=3.0.0-beta.17"
}
},
"node_modules/@sindresorhus/merge-streams": { "node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@@ -3316,18 +3303,6 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": { "node_modules/d3-color": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
@@ -3368,15 +3343,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": { "node_modules/d3-interpolate": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
@@ -3389,22 +3355,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": { "node_modules/d3-selection": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
@@ -3414,30 +3364,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": { "node_modules/d3-timer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
@@ -3979,6 +3905,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/eventsource": { "node_modules/eventsource": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@@ -4476,18 +4411,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/gl-bench": {
"version": "1.0.42",
"resolved": "https://registry.npmjs.org/gl-bench/-/gl-bench-1.0.42.tgz",
"integrity": "sha512-zuMsA/NCPmI8dPy6q3zTUH8OUM5cqKg7uVWwqzrtXJPBqoypM0XeFWEc8iFOqbf/1qtXieWOrbmgFEByKTQt4Q==",
"license": "MIT"
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -4532,6 +4455,46 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/graphology": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz",
"integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==",
"license": "MIT",
"dependencies": {
"events": "^3.3.0"
},
"peerDependencies": {
"graphology-types": ">=0.24.0"
}
},
"node_modules/graphology-layout-force": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/graphology-layout-force/-/graphology-layout-force-0.2.4.tgz",
"integrity": "sha512-NYZz0YAnDkn5pkm30cvB0IScFoWGtbzJMrqaiH070dYlYJiag12Oc89dbVfaMaVR/w8DMIKxn/ix9Bqj+Umm9Q==",
"license": "MIT",
"dependencies": {
"graphology-utils": "^2.4.2"
},
"peerDependencies": {
"graphology-types": ">=0.19.0"
}
},
"node_modules/graphology-types": {
"version": "0.24.8",
"resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz",
"integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==",
"license": "MIT",
"peer": true
},
"node_modules/graphology-utils": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz",
"integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==",
"license": "MIT",
"peerDependencies": {
"graphology-types": ">=0.23.0"
}
},
"node_modules/graphql": { "node_modules/graphql": {
"version": "16.13.2", "version": "16.13.2",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz",
@@ -4706,15 +4669,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -6177,18 +6131,6 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/random": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/random/-/random-4.1.0.tgz",
"integrity": "sha512-6Ajb7XmMSE9EFAMGC3kg9mvE7fGlBip25mYYuSMzw/uUSrmGilvZo2qwX3RnTRjwXkwkS+4swse9otZ92VjAtQ==",
"license": "MIT",
"dependencies": {
"seedrandom": "^3.0.5"
},
"engines": {
"node": ">=14"
}
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -6298,12 +6240,6 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/regl": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/regl/-/regl-2.1.1.tgz",
"integrity": "sha512-+IOGrxl3FZ8ZM9ixCWQZzFRiRn7Rzn9bu3iFHwg/yz4tlOUQgbO4PHLgG+1ZT60zcIV8tief6Qrmyl8qcoJP0g==",
"license": "MIT"
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -6481,12 +6417,6 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/seedrandom": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
"license": "MIT"
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -6700,6 +6630,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sigma": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz",
"integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==",
"license": "MIT",
"dependencies": {
"events": "^3.3.0",
"graphology-utils": "^2.5.2"
}
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",

View File

@@ -18,15 +18,17 @@
"@codemirror/search": "^6.6.0", "@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.40.0", "@codemirror/view": "^6.40.0",
"@cosmos.gl/graph": "^2.6.4",
"@dagrejs/dagre": "^3.0.0", "@dagrejs/dagre": "^3.0.0",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8",
"@sigma/node-square": "^3.0.0",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@xyflow/react": "^12.10.2", "@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"graphology": "^0.26.0",
"graphology-layout-force": "^0.2.4",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"micron-parser": "^1.0.3", "micron-parser": "^1.0.3",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -35,6 +37,7 @@
"react-resizable-panels": "^4.8.0", "react-resizable-panels": "^4.8.0",
"react-router-dom": "^7.13.2", "react-router-dom": "^7.13.2",
"shadcn": "^4.1.1", "shadcn": "^4.1.1",
"sigma": "^3.0.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",

View File

@@ -122,6 +122,14 @@ export interface NetworkNode {
name: string; name: string;
last_seen: number; last_seen: number;
is_self: boolean; is_self: boolean;
type?: "node" | "interface";
interface?: string | null;
online?: boolean;
target?: string | null;
txb?: number;
rxb?: number;
bitrate?: number;
clients?: number | null;
} }
export async function fetchBrowseNodes(): Promise<NetworkNode[]> { export async function fetchBrowseNodes(): Promise<NetworkNode[]> {
@@ -130,6 +138,18 @@ export async function fetchBrowseNodes(): Promise<NetworkNode[]> {
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
} }
export function subscribeBrowseNodes(
onNode: (node: NetworkNode) => void,
): () => void {
const es = new EventSource("/api/browse/nodes/stream");
es.onmessage = (e) => {
try {
onNode(JSON.parse(e.data));
} catch { /* ignore parse errors */ }
};
return () => es.close();
}
export async function fetchRemotePage( export async function fetchRemotePage(
hash: string, hash: string,
path: string = "index.mu", path: string = "index.mu",

View File

@@ -1,133 +1,170 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Graph } from "@cosmos.gl/graph"; import Graph from "graphology";
import { fetchBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client"; import Sigma from "sigma";
import type { NodeDisplayData, EdgeDisplayData } from "sigma/types";
import { NodeSquareProgram } from "@sigma/node-square";
import ForceSupervisor from "graphology-layout-force/worker";
import { subscribeBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client";
import { renderMicron } from "@/components/editor/micronRenderer"; import { renderMicron } from "@/components/editor/micronRenderer";
import axisMundiUrl from "@/assets/axis-mundi.min.svg"; import axisMundiUrl from "@/assets/axis-mundi.min.svg";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Theme color maps — hex values matching index.css OKLCH definitions // Theme — read from CSS custom properties
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface ThemeColors { function cssVarToHex(varName: string): string {
primary: string; const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
muted: string; if (!raw) return "#808080";
border: string; const ctx = document.createElement("canvas").getContext("2d")!;
ctx.fillStyle = raw;
return ctx.fillStyle;
} }
const THEME_COLORS: Record<string, ThemeColors> = { function getThemeColors() {
dark: { // .dark (terra) return {
primary: "#c47a32", primary: cssVarToHex("--primary"),
muted: "#8a7560", muted: cssVarToHex("--muted-foreground"),
border: "#6b5a42", border: cssVarToHex("--border"),
}, foreground: cssVarToHex("--foreground"),
azure: { // .theme-azure bg: cssVarToHex("--background"),
primary: "#5aa0d4",
muted: "#6d8a9e",
border: "#4a6e88",
},
}; };
function getThemeId(): string {
const cl = document.documentElement.classList;
if (cl.contains("theme-azure")) return "azure";
return "dark";
} }
function hexToRgba255(hex: string): [number, number, number, number] { function lerpHex(a: string, b: string, t: number): string {
return [ const parse = (h: string) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
parseInt(hex.slice(1, 3), 16), const ca = parse(a), cb = parse(b);
parseInt(hex.slice(3, 5), 16), const r = Math.round(ca[0] + (cb[0] - ca[0]) * t);
parseInt(hex.slice(5, 7), 16), const g = Math.round(ca[1] + (cb[1] - ca[1]) * t);
255, const bl = Math.round(ca[2] + (cb[2] - ca[2]) * t);
]; return "#" + [r, g, bl].map(c => c.toString(16).padStart(2, "0")).join("");
}
type ThemeColors = ReturnType<typeof getThemeColors>;
// Dimmed color for non-matching nodes
function dimColor(bg: string): string {
return lerpHex(bg, "#808080", 0.15);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Data builders — convert NetworkNode[] to Float32Arrays for cosmos.gl // Incremental graph sync — adds/removes/updates nodes without clearing
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function buildBuffers(nodes: NetworkNode[], colors: ThemeColors) { function nodeAttrs(entry: NetworkNode, colors: ThemeColors, ifaceColor: string) {
const sorted = [...nodes].sort((a, b) => (a.is_self ? -1 : b.is_self ? 1 : 0)); if (entry.is_self) {
return { label: entry.name, size: 12, color: colors.primary, type: "circle" };
}
if (entry.type === "interface") {
return { label: `${entry.name}`, size: 8, color: ifaceColor, type: "square" };
}
return { label: entry.name, size: 5, color: colors.foreground, type: "circle" };
}
const n = sorted.length; function findParent(
const positions = new Float32Array(n * 2); entry: NetworkNode,
const pointColors = new Float32Array(n * 4); interfaces: NetworkNode[],
const sizes = new Float32Array(n); selfHash: string | undefined,
const primaryRgba = hexToRgba255(colors.primary); peerIndex: number,
const mutedRgba = hexToRgba255(colors.muted); ): string | undefined {
if (entry.interface) {
const iface = interfaces.find(i => i.name === entry.interface);
if (iface) return iface.hash;
}
if (interfaces.length > 0) return interfaces[peerIndex % interfaces.length].hash;
return selfHash;
}
for (let i = 0; i < n; i++) { function syncGraph(
if (sorted[i].is_self) { graph: Graph,
positions[i * 2] = 0; entries: NetworkNode[],
positions[i * 2 + 1] = 0; colors: ThemeColors,
) {
const ifaceColor = lerpHex(colors.primary, colors.muted, 0.4);
const selfEntry = entries.find(e => e.is_self);
const interfaces = entries.filter(e => e.type === "interface");
const peers = entries.filter(e => !e.is_self && e.type !== "interface");
const desiredNodes = new Set(entries.map(e => e.hash));
// --- Remove nodes no longer present ---
const toRemove = graph.nodes().filter(n => !desiredNodes.has(n));
for (const n of toRemove) graph.dropNode(n);
// --- Add or update self ---
if (selfEntry) {
const attrs = nodeAttrs(selfEntry, colors, ifaceColor);
if (graph.hasNode(selfEntry.hash)) {
graph.mergeNodeAttributes(selfEntry.hash, attrs);
} else { } else {
const angle = ((i - 1) / Math.max(1, n - 1)) * Math.PI * 2; graph.addNode(selfEntry.hash, { x: 0, y: 0, fixed: true, ...attrs });
positions[i * 2] = Math.cos(angle) * 100; }
positions[i * 2 + 1] = Math.sin(angle) * 100;
} }
const rgba = sorted[i].is_self ? primaryRgba : mutedRgba; // --- Add or update interfaces ---
pointColors[i * 4] = rgba[0]; interfaces.forEach((iface, ci) => {
pointColors[i * 4 + 1] = rgba[1]; const attrs = nodeAttrs(iface, colors, ifaceColor);
pointColors[i * 4 + 2] = rgba[2]; if (graph.hasNode(iface.hash)) {
pointColors[i * 4 + 3] = rgba[3]; graph.mergeNodeAttributes(iface.hash, attrs);
} else {
sizes[i] = sorted[i].is_self ? 14 : 7; const angle = (ci / Math.max(1, interfaces.length)) * Math.PI * 2 - Math.PI / 2;
const radius = 3;
graph.addNode(iface.hash, {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
fixed: true,
...attrs,
});
} }
// Ensure edge self → interface
const linkCount = Math.max(0, n - 1); if (selfEntry && !graph.hasEdge(selfEntry.hash, iface.hash)) {
const links = new Float32Array(linkCount * 2); graph.addEdge(selfEntry.hash, iface.hash, { color: colors.border, size: 2 });
const borderRgba = hexToRgba255(colors.border);
const linkColors = new Float32Array(linkCount * 4);
for (let i = 0; i < linkCount; i++) {
links[i * 2] = 0;
links[i * 2 + 1] = i + 1;
linkColors[i * 4] = borderRgba[0];
linkColors[i * 4 + 1] = borderRgba[1];
linkColors[i * 4 + 2] = borderRgba[2];
linkColors[i * 4 + 3] = 180;
} }
return { sorted, positions, pointColors, sizes, links, linkColors };
}
/** Re-apply theme colors to an existing graph instance */
function applyThemeToGraph(graph: Graph, nodes: NetworkNode[], colors: ThemeColors) {
graph.setConfig({
pointDefaultColor: colors.primary,
linkDefaultColor: colors.border,
hoveredPointRingColor: colors.primary,
}); });
const n = nodes.length; // --- Add or update peers ---
if (n === 0) return; peers.forEach((peer, pi) => {
const attrs = nodeAttrs(peer, colors, ifaceColor);
const parentHash = findParent(peer, interfaces, selfEntry?.hash, pi);
const pointColors = new Float32Array(n * 4); if (graph.hasNode(peer.hash)) {
const primaryRgba = hexToRgba255(colors.primary); graph.mergeNodeAttributes(peer.hash, attrs);
const mutedRgba = hexToRgba255(colors.muted); } else {
for (let i = 0; i < n; i++) { // Position near parent
const rgba = nodes[i].is_self ? primaryRgba : mutedRgba; let px = 0, py = 0;
pointColors[i * 4] = rgba[0]; if (parentHash && graph.hasNode(parentHash)) {
pointColors[i * 4 + 1] = rgba[1]; const parent = graph.getNodeAttributes(parentHash);
pointColors[i * 4 + 2] = rgba[2]; const angle = Math.random() * Math.PI * 2;
pointColors[i * 4 + 3] = rgba[3]; const dist = 1.5 + Math.random() * 2;
px = (parent.x ?? 0) + Math.cos(angle) * dist;
py = (parent.y ?? 0) + Math.sin(angle) * dist;
} }
graph.setPointColors(pointColors); graph.addNode(peer.hash, { x: px, y: py, ...attrs });
const linkCount = Math.max(0, n - 1);
if (linkCount > 0) {
const borderRgba = hexToRgba255(colors.border);
const linkColors = new Float32Array(linkCount * 4);
for (let i = 0; i < linkCount; i++) {
linkColors[i * 4] = borderRgba[0];
linkColors[i * 4 + 1] = borderRgba[1];
linkColors[i * 4 + 2] = borderRgba[2];
linkColors[i * 4 + 3] = 180;
}
graph.setLinkColors(linkColors);
} }
graph.render(); // Ensure edge parent → peer
if (parentHash && graph.hasNode(parentHash) && !graph.hasEdge(parentHash, peer.hash)) {
graph.addEdge(parentHash, peer.hash, { color: colors.border, size: 1 });
}
});
// --- Clean stale edges (both endpoints must still exist) ---
graph.forEachEdge((edge, _attrs, source, target) => {
if (!desiredNodes.has(source) || !desiredNodes.has(target)) {
graph.dropEdge(edge);
}
});
}
// ---------------------------------------------------------------------------
// Reducer state — mutable, drives nodeReducer / edgeReducer
// ---------------------------------------------------------------------------
interface ReducerState {
hoveredNode?: string;
hoveredNeighbors?: Set<string>;
searchQuery: string;
selectedNode?: string;
suggestions?: Set<string>;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -141,150 +178,257 @@ export default function BrowseView() {
const [pageHtml, setPageHtml] = useState<string | null>(null); const [pageHtml, setPageHtml] = useState<string | null>(null);
const [pageLoading, setPageLoading] = useState(false); const [pageLoading, setPageLoading] = useState(false);
const [pageError, setPageError] = useState<string | null>(null); const [pageError, setPageError] = useState<string | null>(null);
const [labelPositions, setLabelPositions] = useState<{ x: number; y: number }[]>([]); const [themeRev, setThemeRev] = useState(0);
const [themeId, setThemeId] = useState(getThemeId);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const sigmaRef = useRef<Sigma | null>(null);
const graphRef = useRef<Graph | null>(null); const graphRef = useRef<Graph | null>(null);
const nodesRef = useRef<NetworkNode[]>([]); const layoutRef = useRef<ForceSupervisor | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); const nodesMapRef = useRef<Map<string, NetworkNode>>(new Map());
const rafRef = useRef(0); const stateRef = useRef<ReducerState>({ searchQuery: "" });
const colors = THEME_COLORS[themeId] ?? THEME_COLORS.dark;
const filteredNodes = useMemo(() => {
if (!filter) return nodes;
const q = filter.toLowerCase();
return nodes.filter(
(n) => n.name.toLowerCase().includes(q) || n.hash.toLowerCase().includes(q),
);
}, [nodes, filter]);
// ── Watch for theme changes ── // ── Watch for theme changes ──
useEffect(() => { useEffect(() => {
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => setThemeRev((r) => r + 1));
const id = getThemeId();
setThemeId(id);
const graph = graphRef.current;
if (graph) {
const c = THEME_COLORS[id] ?? THEME_COLORS.dark;
applyThemeToGraph(graph, nodesRef.current, c);
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
// ── Initialize cosmos.gl graph ── // ── Initialize Sigma with reducers ──
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const graph = new Graph(containerRef.current, { const graph = new Graph();
backgroundColor: [0, 0, 0, 0], graphRef.current = graph;
pointDefaultColor: colors.primary,
pointDefaultSize: 12, const colors = getThemeColors();
linkDefaultColor: colors.border, const dim = dimColor(colors.bg);
linkDefaultWidth: 1,
linkOpacity: 0.5, const renderer = new Sigma(graph, containerRef.current, {
enableSimulation: true, allowInvalidContainer: true,
enableDrag: true, nodeProgramClasses: { square: NodeSquareProgram },
enableZoom: true, defaultNodeColor: colors.primary,
fitViewOnInit: false, defaultEdgeColor: colors.border,
spaceSize: 1024, labelColor: { color: colors.foreground },
simulationGravity: 0.15, labelFont: "JetBrains Mono, monospace",
simulationRepulsion: 0.6, labelSize: 10,
simulationLinkSpring: 0.3, labelRenderedSizeThreshold: 0,
simulationLinkDistance: 60, renderEdgeLabels: false,
simulationFriction: 0.85, enableEdgeEvents: false,
simulationDecay: 8000, // ── Node reducer: search highlighting + hover dimming ──
renderHoveredPointRing: true, nodeReducer: (node, data) => {
hoveredPointRingColor: colors.primary, const res: Partial<NodeDisplayData> = { ...data };
hoveredPointCursor: "pointer", const s = stateRef.current;
onPointClick: (index: number) => {
const node = nodesRef.current[index]; // Hover: dim non-neighbors
if (node) handleNodeClick(node); if (s.hoveredNeighbors && !s.hoveredNeighbors.has(node) && s.hoveredNode !== node) {
res.label = "";
res.color = dim;
}
// Search: highlight matches, dim the rest
if (s.selectedNode === node) {
res.highlighted = true;
} else if (s.suggestions) {
if (s.suggestions.has(node)) {
res.forceLabel = true;
} else {
res.label = "";
res.color = dim;
}
}
return res;
}, },
onClick: () => { // ── Edge reducer: hide irrelevant edges ──
edgeReducer: (edge, data) => {
const res: Partial<EdgeDisplayData> = { ...data };
const s = stateRef.current;
const g = graphRef.current!;
// Hover: hide edges not connected to hovered node
if (s.hoveredNode && !g.extremities(edge).includes(s.hoveredNode)) {
res.hidden = true;
}
// Search: hide edges not connecting two suggestions
if (s.suggestions &&
(!s.suggestions.has(g.source(edge)) || !s.suggestions.has(g.target(edge)))) {
res.hidden = true;
}
return res;
},
});
// Hover events
renderer.on("enterNode", ({ node }) => {
stateRef.current.hoveredNode = node;
stateRef.current.hoveredNeighbors = new Set(graph.neighbors(node));
renderer.refresh({ skipIndexation: true });
});
renderer.on("leaveNode", () => {
stateRef.current.hoveredNode = undefined;
stateRef.current.hoveredNeighbors = undefined;
renderer.refresh({ skipIndexation: true });
});
// Click events
renderer.on("clickNode", ({ node }) => {
const entry = nodesMapRef.current.get(node);
if (entry && entry.type !== "interface") {
handleNodeClick(entry);
}
});
renderer.on("clickStage", () => {
setSelectedNode(null); setSelectedNode(null);
setPageHtml(null); setPageHtml(null);
setPageError(null); setPageError(null);
},
onSimulationTick: () => updateLabels(),
onZoom: () => updateLabels(),
}); });
graphRef.current = graph; // Force layout — runs continuously, pins fixed nodes
const layout = new ForceSupervisor(graph, {
isNodeFixed: (_, attr) => attr.fixed,
settings: { gravity: 0.0005, repulsion: 0.5, attraction: 0.01, inertia: 0.6 },
});
layout.start();
layoutRef.current = layout;
sigmaRef.current = renderer;
return () => { return () => {
cancelAnimationFrame(rafRef.current); layout.kill();
graph.destroy(); renderer.kill();
layoutRef.current = null;
sigmaRef.current = null;
graphRef.current = null; graphRef.current = null;
}; };
}, []); }, []);
// ── Update labels from graph positions ── // ── Search: update reducer state when filter changes ──
const updateLabels = useCallback(() => {
const graph = graphRef.current;
if (!graph || nodesRef.current.length === 0) return;
const positions = graph.getPointPositions();
const next: { x: number; y: number }[] = [];
for (let i = 0; i < nodesRef.current.length; i++) {
const sx = positions[i * 2];
const sy = positions[i * 2 + 1];
if (sx === undefined) break;
const [px, py] = graph.spaceToScreenPosition([sx, sy]);
next.push({ x: px, y: py });
}
setLabelPositions(next);
}, []);
// ── Feed node data into graph when nodes change ──
useEffect(() => { useEffect(() => {
const renderer = sigmaRef.current;
const graph = graphRef.current; const graph = graphRef.current;
if (!graph) return; if (!renderer || !graph) return;
if (filteredNodes.length === 0) {
nodesRef.current = []; const s = stateRef.current;
setLabelPositions([]); const query = filter.trim();
graph.setPointPositions(new Float32Array(0)); s.searchQuery = query;
graph.setPointColors(new Float32Array(0));
graph.setPointSizes(new Float32Array(0)); if (query) {
graph.setLinks(new Float32Array(0)); const lcQuery = query.toLowerCase();
graph.setLinkColors(new Float32Array(0)); const matches = graph
graph.render(); .nodes()
return; .map((n) => ({ id: n, label: (graph.getNodeAttribute(n, "label") as string) || "" }))
.filter(({ label }) => label.toLowerCase().includes(lcQuery));
// Exact single match → select and zoom
if (matches.length === 1 && matches[0].label.toLowerCase() === lcQuery) {
s.selectedNode = matches[0].id;
s.suggestions = undefined;
const nodePosition = renderer.getNodeDisplayData(s.selectedNode);
if (nodePosition) {
renderer.getCamera().animate(nodePosition, { duration: 500 });
}
} else {
s.selectedNode = undefined;
s.suggestions = new Set(matches.map(({ id }) => id));
}
} else {
s.selectedNode = undefined;
s.suggestions = undefined;
} }
const c = THEME_COLORS[getThemeId()] ?? THEME_COLORS.dark; renderer.refresh({ skipIndexation: true });
const { sorted, positions, pointColors, sizes, links, linkColors } = buildBuffers(filteredNodes, c); }, [filter]);
nodesRef.current = sorted;
graph.setPointPositions(positions); // ── Incrementally sync graph when nodes arrive or theme changes ──
graph.setPointColors(pointColors);
graph.setPointSizes(sizes);
if (links.length > 0) {
graph.setLinks(links);
graph.setLinkColors(linkColors);
}
graph.setPinnedPoints([0]);
graph.render();
graph.start();
setTimeout(() => {
graph.fitView(400, 0.4);
updateLabels();
}, 200);
}, [filteredNodes, updateLabels]);
// ── Poll for nodes ──
useEffect(() => { useEffect(() => {
const load = () => { const renderer = sigmaRef.current;
fetchBrowseNodes().then(setNodes).catch(() => { }); const graph = graphRef.current;
if (!renderer || !graph) return;
const colors = getThemeColors();
const dim = dimColor(colors.bg);
// Build node map for click lookups
const map = new Map<string, NetworkNode>();
for (const n of nodes) map.set(n.hash, n);
nodesMapRef.current = map;
// Incremental add/remove/update — no graph.clear()
syncGraph(graph, nodes, colors);
// Update theme settings + dim color in reducers
renderer.setSetting("defaultNodeColor", colors.primary);
renderer.setSetting("defaultEdgeColor", colors.border);
renderer.setSetting("labelColor", { color: colors.foreground });
renderer.setSetting("nodeReducer", (node, data) => {
const res: Partial<NodeDisplayData> = { ...data };
const s = stateRef.current;
if (s.hoveredNeighbors && !s.hoveredNeighbors.has(node) && s.hoveredNode !== node) {
res.label = "";
res.color = dim;
}
if (s.selectedNode === node) {
res.highlighted = true;
} else if (s.suggestions) {
if (s.suggestions.has(node)) {
res.forceLabel = true;
} else {
res.label = "";
res.color = dim;
}
}
return res;
});
renderer.setSetting("edgeReducer", (edge, data) => {
const res: Partial<EdgeDisplayData> = { ...data };
const s = stateRef.current;
if (s.hoveredNode && !graph.extremities(edge).includes(s.hoveredNode)) {
res.hidden = true;
}
if (s.suggestions &&
(!s.suggestions.has(graph.source(edge)) || !s.suggestions.has(graph.target(edge)))) {
res.hidden = true;
}
return res;
});
renderer.refresh();
}, [nodes, themeRev]);
// ── Live SSE stream (batched) ──
useEffect(() => {
let pending: NetworkNode[] = [];
let batchTimer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
batchTimer = null;
if (pending.length === 0) return;
const batch = pending;
pending = [];
setNodes((prev) => {
const map = new Map(prev.map((n) => [n.hash, n]));
for (const node of batch) map.set(node.hash, node);
return Array.from(map.values());
});
}; };
load();
pollRef.current = setInterval(load, 30_000); const unsub = subscribeBrowseNodes((node) => {
pending.push(node);
if (!batchTimer) batchTimer = setTimeout(flush, 100);
});
return () => { return () => {
if (pollRef.current) clearInterval(pollRef.current); unsub();
if (batchTimer) clearTimeout(batchTimer);
flush();
}; };
}, []); }, []);
@@ -297,18 +441,54 @@ export default function BrowseView() {
fetchRemotePage(node.hash) fetchRemotePage(node.hash)
.then((res) => { .then((res) => {
if (res.content) { if (res.content) setPageHtml(renderMicron(res.content, true));
setPageHtml(renderMicron(res.content, true)); else setPageError(res.error ?? "No content");
} else {
setPageError(res.error ?? "No content");
}
}) })
.catch((e) => setPageError(String(e))) .catch((e) => setPageError(String(e)))
.finally(() => setPageLoading(false)); .finally(() => setPageLoading(false));
}, []); }, []);
// ── Resizable split ──
const [graphHeight, setGraphHeight] = useState(500);
const draggingRef = useRef(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const onResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
draggingRef.current = true;
const startY = e.clientY;
const startH = graphHeight;
const onMove = (ev: MouseEvent) => {
if (!draggingRef.current || !wrapperRef.current) return;
const wrapperH = wrapperRef.current.getBoundingClientRect().height;
const headerH = 41; // header bar height approx
const minGraph = 150;
const minPage = 100;
const maxGraph = wrapperH - headerH - minPage;
const newH = Math.min(maxGraph, Math.max(minGraph, startH + (ev.clientY - startY)));
setGraphHeight(newH);
};
const onUp = () => {
draggingRef.current = false;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
// Refresh sigma after resize
sigmaRef.current?.refresh();
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}, [graphHeight]);
const nodeCount = nodes.filter(n => n.type !== "interface").length;
const ifaceCount = nodes.filter(n => n.type === "interface").length;
const colors = getThemeColors();
return ( return (
<div className="flex flex-col" style={{ height: "100%" }}> <div ref={wrapperRef} className="flex flex-col" style={{ height: "100%" }}>
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 px-4 py-2 border-b-2 border-border shrink-0"> <div className="flex items-center gap-3 px-4 py-2 border-b-2 border-border shrink-0">
<h1 className="text-sm font-semibold">Browse</h1> <h1 className="text-sm font-semibold">Browse</h1>
@@ -316,53 +496,38 @@ export default function BrowseView() {
type="text" type="text"
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value)} onChange={(e) => setFilter(e.target.value)}
placeholder="Filter nodes..." placeholder="Search nodes..."
className="flex-1 h-8 px-2 text-xs bg-muted/50 border border-border rounded placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary" className="flex-1 h-8 px-2 text-xs bg-muted/50 border border-border rounded placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
/> />
<span className="text-[10px] text-muted-foreground uppercase tracking-wider whitespace-nowrap"> <span className="text-[10px] text-muted-foreground uppercase tracking-wider whitespace-nowrap">
{filteredNodes.length}/{nodes.length} node{nodes.length !== 1 && "s"} {nodeCount} node{nodeCount !== 1 && "s"}
{" · "}
{ifaceCount} iface{ifaceCount !== 1 && "s"}
</span> </span>
</div> </div>
{/* Graph + labels */} {/* Graph */}
<div <div
ref={containerRef}
className="relative shrink-0 bg-background overflow-hidden" className="relative shrink-0 bg-background overflow-hidden"
style={{ height: 500 }} style={{ height: graphHeight }}
> >
{nodesRef.current.map((node, i) => { {/* Sigma container — must have no React children */}
const lp = labelPositions[i]; <div ref={containerRef} className="absolute inset-0" />
if (!lp) return null;
return (
<span
key={node.hash}
className="absolute text-[10px] font-mono pointer-events-none select-none whitespace-nowrap"
style={{
left: lp.x,
top: lp.y - (node.is_self ? 24 : 12),
transform: "translate(-50%, -100%)",
color: node.is_self ? colors.primary : colors.muted,
}}
>
{node.name}
</span>
);
})}
{nodes.length === 0 && ( {nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm"> <div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm z-10 pointer-events-none">
Listening for nodes on the Reticulum network... Listening for nodes on the Reticulum network...
</div> </div>
)} )}
{nodes.length > 0 && filteredNodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm">
No nodes match "{filter}"
</div>
)}
</div> </div>
{/* Resize handle */}
<div
onMouseDown={onResizeStart}
className="shrink-0 h-1.5 cursor-row-resize border-y border-border bg-muted/30 hover:bg-primary/30 transition-colors"
/>
{/* Page viewer */} {/* Page viewer */}
<div className="flex-1 min-h-0 border-t-2 border-border flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">
<div className="flex items-center px-4 py-2 border-b border-border bg-background shrink-0"> <div className="flex items-center px-4 py-2 border-b border-border bg-background shrink-0">
<span className="text-xs font-semibold flex-1 truncate"> <span className="text-xs font-semibold flex-1 truncate">
{selectedNode ? ( {selectedNode ? (

21
reticulum-client.conf Normal file
View File

@@ -0,0 +1,21 @@
[reticulum]
enable_transport = False
share_instance = No
[logging]
loglevel = 4
[interfaces]
# Connect to NomadNet's TCP server for local traffic
[[NomadNet Link]]
type = TCPClientInterface
enabled = Yes
target_host = nomadnet
target_port = 4242
# Connect to Quad4 directly for external node announces
[[Quad4]]
type = TCPClientInterface
enabled = Yes
target_host = 62.151.179.77
target_port = 45657

View File

@@ -16,7 +16,7 @@
[[Quad4]] [[Quad4]]
type = TCPClientInterface type = TCPClientInterface
interface_enabled = false interface_enabled = true
target_host = 62.151.179.77 target_host = 62.151.179.77
target_port = 45657 target_port = 45657
mode = full mode = full