feat: graph browser
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
14
deploy.sh
Executable 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"
|
||||||
204
frontend/package-lock.json
generated
204
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
21
reticulum-client.conf
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user