From 3132d40391bd7a582d4bdf90751c68c728e84604 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 4 Apr 2026 16:18:21 +0200 Subject: [PATCH] feat: graph browser --- backend/browse.py | 218 ++++++--- backend/main.py | 13 +- compose.yml | 2 + deploy.sh | 14 + frontend/package-lock.json | 204 +++------ frontend/package.json | 5 +- frontend/src/api/client.ts | 20 + frontend/src/routes/BrowseView.tsx | 679 ++++++++++++++++++----------- reticulum-client.conf | 21 + reticulum.conf | 2 +- 10 files changed, 735 insertions(+), 443 deletions(-) create mode 100755 deploy.sh create mode 100644 reticulum-client.conf diff --git a/backend/browse.py b/backend/browse.py index f3f74ed..776576f 100644 --- a/backend/browse.py +++ b/backend/browse.py @@ -7,6 +7,7 @@ announces, and exposes discovered nodes + remote page fetching via API. from __future__ import annotations import asyncio +import json import logging import os import threading @@ -14,6 +15,7 @@ import time from pathlib import Path from fastapi import APIRouter, Query +from starlette.responses import StreamingResponse router = APIRouter() log = logging.getLogger("browse") @@ -27,19 +29,136 @@ _own_hash: str | None = None _own_name: str = os.environ.get("NOMADNET_NODE_NAME", "Micronomicon") _lock = threading.Lock() _started = False +_subscribers: list[asyncio.Queue] = [] +_sub_lock = threading.Lock() +_loop: asyncio.AbstractEventLoop | None = None + + +# --------------------------------------------------------------------------- +# RNS announce handler (must be an object with aspect_filter + method) +# --------------------------------------------------------------------------- + +def _push_node(node_data: dict) -> None: + """Push a node update to all SSE subscribers (thread-safe).""" + with _sub_lock: + for q in list(_subscribers): + if _loop and _loop.is_running(): + _loop.call_soon_threadsafe(q.put_nowait, node_data) + else: + try: + q.put_nowait(node_data) + except asyncio.QueueFull: + pass + + +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, + announced_identity, + app_data: bytes | None, + **kwargs, + ) -> None: + import RNS + + hash_hex = RNS.hexrep(destination_hash, delimit=False) + + name = hash_hex[:12] + if app_data: + try: + name = app_data.decode("utf-8") + except Exception: + pass + + 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: + _nodes[hash_hex] = { + "hash": hash_hex, + "name": name, + "last_seen": time.time(), + "is_self": is_self, + "type": "node", + "interface": iface_name, + } + if is_self: + global _own_hash + _own_hash = hash_hex + + 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, _own_hash + global _started, _loop, _reticulum if _started: return + try: + _loop = asyncio.get_event_loop() + except RuntimeError: + _loop = None + try: import RNS @@ -47,13 +166,9 @@ def start_browser() -> None: if configdir: Path(configdir).mkdir(parents=True, exist_ok=True) - reticulum = RNS.Reticulum(configdir=configdir) + _reticulum = RNS.Reticulum(configdir=configdir) - # Register handler for NomadNet page-serving node announces - RNS.Transport.register_announce_handler( - _on_announce, - aspect_filter="nomadnetwork.node", - ) + RNS.Transport.register_announce_handler(_AnnounceHandler()) _started = True log.info("RNS browser started (v%s)", RNS.__version__) @@ -62,61 +177,70 @@ def start_browser() -> None: log.warning("Failed to start RNS browser: %s", exc) -def _on_announce( - destination_hash: bytes, - announced_identity, - app_data: bytes | None, -) -> None: - """Handle an incoming NomadNet node announce.""" - import RNS - - hash_hex = RNS.hexrep(destination_hash, delimit=False) - - name = hash_hex[:12] - if app_data: - try: - name = app_data.decode("utf-8") - except Exception: - pass - - is_self = name == _own_name - - with _lock: - _nodes[hash_hex] = { - "hash": hash_hex, - "name": name, - "last_seen": time.time(), - "is_self": is_self, - } - if is_self: - global _own_hash - _own_hash = hash_hex - - log.info("Node announce: %s (%s)%s", name, hash_hex[:8], " [self]" if is_self else "") - - # --------------------------------------------------------------------------- # API endpoints # --------------------------------------------------------------------------- -@router.get("/browse/nodes") -async def list_nodes(): - """Return all discovered NomadNet page-serving nodes.""" +def _build_snapshot() -> list[dict]: + """Build a full snapshot: self node + interfaces + discovered nodes.""" with _lock: 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): nodes.insert(0, { "hash": _own_hash or "self", "name": _own_name, "last_seen": time.time(), "is_self": True, + "type": "node", }) + # Add interfaces + nodes.extend(_collect_interfaces()) 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}") async def get_remote_page(hash_hex: str, path: str = Query("index.mu")): """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 remote nodes establishes an RNS link and requests the page. """ - # Own node → read from disk with _lock: node = _nodes.get(hash_hex) if (node and node.get("is_self")) or hash_hex == "self": return _read_local_page(path) - # Remote node → RNS request content = await _request_remote_page(hash_hex, path) if content is None: 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: dest_hash = bytes.fromhex(hash_hex) - # Ensure path to destination is known if not RNS.Transport.has_path(dest_hash): RNS.Transport.request_path(dest_hash) deadline = time.time() + 10 @@ -195,7 +316,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None: link = RNS.Link(dest) - # Wait for link to become active deadline = time.time() + 15 while time.time() < deadline: 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) return - # Request the page via NomadNet's protocol def on_response(request_receipt): try: resp = request_receipt.response @@ -236,7 +355,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None: if not future.done(): loop.call_soon_threadsafe(future.set_result, None) - # Run blocking RNS operations in a thread threading.Thread(target=_do_request, daemon=True).start() try: diff --git a/backend/main.py b/backend/main.py index b7c30b7..e614923 100644 --- a/backend/main.py +++ b/backend/main.py @@ -28,7 +28,16 @@ async def health(): 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" 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") diff --git a/compose.yml b/compose.yml index 2961395..13f3b41 100644 --- a/compose.yml +++ b/compose.yml @@ -9,10 +9,12 @@ services: - PAGES_DIR=/data/pages - SOURCES_DIR=/data/sources - NOMADNET_CONTAINER=nomadnet + - LOG_LEVEL=DEBUG volumes: - pages:/data/pages - sources:/data/sources - /var/run/docker.sock:/var/run/docker.sock:ro + - ./reticulum-client.conf:/root/.reticulum/config:ro restart: unless-stopped depends_on: - nomadnet diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..8f515a8 --- /dev/null +++ b/deploy.sh @@ -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" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3377c1c..d004b70 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,15 +16,17 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", - "@cosmos.gl/graph": "^2.6.4", "@dagrejs/dagre": "^3.0.0", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@sigma/node-square": "^3.0.0", "@tailwindcss/vite": "^4.2.2", "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.3.3", + "graphology": "^0.26.0", + "graphology-layout-force": "^0.2.4", "lucide-react": "^1.7.0", "micron-parser": "^1.0.3", "next-themes": "^0.4.6", @@ -33,6 +35,7 @@ "react-resizable-panels": "^4.8.0", "react-router-dom": "^7.13.2", "shadcn": "^4.1.1", + "sigma": "^3.0.2", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", @@ -664,31 +667,6 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", @@ -1836,6 +1814,15 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", @@ -3316,18 +3303,6 @@ "devOptional": true, "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -3368,15 +3343,6 @@ "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": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -3389,22 +3355,6 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", @@ -3414,30 +3364,6 @@ "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": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", @@ -3979,6 +3905,15 @@ "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": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -4476,18 +4411,6 @@ "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": { "version": "6.0.2", "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==", "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": { "version": "16.13.2", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", @@ -4706,15 +4669,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "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": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -6177,18 +6131,6 @@ ], "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -6298,12 +6240,6 @@ "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": { "version": "2.1.1", "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==", "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": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6700,6 +6630,16 @@ "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": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index f0e32d5..3030f00 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,15 +18,17 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", - "@cosmos.gl/graph": "^2.6.4", "@dagrejs/dagre": "^3.0.0", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@sigma/node-square": "^3.0.0", "@tailwindcss/vite": "^4.2.2", "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.3.3", + "graphology": "^0.26.0", + "graphology-layout-force": "^0.2.4", "lucide-react": "^1.7.0", "micron-parser": "^1.0.3", "next-themes": "^0.4.6", @@ -35,6 +37,7 @@ "react-resizable-panels": "^4.8.0", "react-router-dom": "^7.13.2", "shadcn": "^4.1.1", + "sigma": "^3.0.2", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 370ae54..76f0c93 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -122,6 +122,14 @@ export interface NetworkNode { name: string; last_seen: number; 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 { @@ -130,6 +138,18 @@ export async function fetchBrowseNodes(): Promise { 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( hash: string, path: string = "index.mu", diff --git a/frontend/src/routes/BrowseView.tsx b/frontend/src/routes/BrowseView.tsx index e926b28..3362638 100644 --- a/frontend/src/routes/BrowseView.tsx +++ b/frontend/src/routes/BrowseView.tsx @@ -1,133 +1,170 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Graph } from "@cosmos.gl/graph"; -import { fetchBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import Graph from "graphology"; +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 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 { - primary: string; - muted: string; - border: string; +function cssVarToHex(varName: string): string { + const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); + if (!raw) return "#808080"; + const ctx = document.createElement("canvas").getContext("2d")!; + ctx.fillStyle = raw; + return ctx.fillStyle; } -const THEME_COLORS: Record = { - dark: { // .dark (terra) - primary: "#c47a32", - muted: "#8a7560", - border: "#6b5a42", - }, - azure: { // .theme-azure - primary: "#5aa0d4", - muted: "#6d8a9e", - border: "#4a6e88", - }, -}; - -function getThemeId(): string { - const cl = document.documentElement.classList; - if (cl.contains("theme-azure")) return "azure"; - return "dark"; +function getThemeColors() { + return { + primary: cssVarToHex("--primary"), + muted: cssVarToHex("--muted-foreground"), + border: cssVarToHex("--border"), + foreground: cssVarToHex("--foreground"), + bg: cssVarToHex("--background"), + }; } -function hexToRgba255(hex: string): [number, number, number, number] { - return [ - parseInt(hex.slice(1, 3), 16), - parseInt(hex.slice(3, 5), 16), - parseInt(hex.slice(5, 7), 16), - 255, - ]; +function lerpHex(a: string, b: string, t: number): string { + const parse = (h: string) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; + const ca = parse(a), cb = parse(b); + const r = Math.round(ca[0] + (cb[0] - ca[0]) * t); + const g = Math.round(ca[1] + (cb[1] - ca[1]) * t); + 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; + +// 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) { - const sorted = [...nodes].sort((a, b) => (a.is_self ? -1 : b.is_self ? 1 : 0)); +function nodeAttrs(entry: NetworkNode, colors: ThemeColors, ifaceColor: string) { + 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; - const positions = new Float32Array(n * 2); - const pointColors = new Float32Array(n * 4); - const sizes = new Float32Array(n); - const primaryRgba = hexToRgba255(colors.primary); - const mutedRgba = hexToRgba255(colors.muted); +function findParent( + entry: NetworkNode, + interfaces: NetworkNode[], + selfHash: string | undefined, + peerIndex: number, +): 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++) { - if (sorted[i].is_self) { - positions[i * 2] = 0; - positions[i * 2 + 1] = 0; +function syncGraph( + graph: Graph, + entries: NetworkNode[], + 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 { - const angle = ((i - 1) / Math.max(1, n - 1)) * Math.PI * 2; - positions[i * 2] = Math.cos(angle) * 100; - positions[i * 2 + 1] = Math.sin(angle) * 100; + graph.addNode(selfEntry.hash, { x: 0, y: 0, fixed: true, ...attrs }); } - - const rgba = sorted[i].is_self ? primaryRgba : mutedRgba; - pointColors[i * 4] = rgba[0]; - pointColors[i * 4 + 1] = rgba[1]; - pointColors[i * 4 + 2] = rgba[2]; - pointColors[i * 4 + 3] = rgba[3]; - - sizes[i] = sorted[i].is_self ? 14 : 7; } - const linkCount = Math.max(0, n - 1); - const links = new Float32Array(linkCount * 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, + // --- Add or update interfaces --- + interfaces.forEach((iface, ci) => { + const attrs = nodeAttrs(iface, colors, ifaceColor); + if (graph.hasNode(iface.hash)) { + graph.mergeNodeAttributes(iface.hash, attrs); + } else { + 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 + if (selfEntry && !graph.hasEdge(selfEntry.hash, iface.hash)) { + graph.addEdge(selfEntry.hash, iface.hash, { color: colors.border, size: 2 }); + } }); - const n = nodes.length; - if (n === 0) return; + // --- Add or update peers --- + peers.forEach((peer, pi) => { + const attrs = nodeAttrs(peer, colors, ifaceColor); + const parentHash = findParent(peer, interfaces, selfEntry?.hash, pi); - const pointColors = new Float32Array(n * 4); - const primaryRgba = hexToRgba255(colors.primary); - const mutedRgba = hexToRgba255(colors.muted); - for (let i = 0; i < n; i++) { - const rgba = nodes[i].is_self ? primaryRgba : mutedRgba; - pointColors[i * 4] = rgba[0]; - pointColors[i * 4 + 1] = rgba[1]; - pointColors[i * 4 + 2] = rgba[2]; - pointColors[i * 4 + 3] = rgba[3]; - } - graph.setPointColors(pointColors); - - 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; + if (graph.hasNode(peer.hash)) { + graph.mergeNodeAttributes(peer.hash, attrs); + } else { + // Position near parent + let px = 0, py = 0; + if (parentHash && graph.hasNode(parentHash)) { + const parent = graph.getNodeAttributes(parentHash); + const angle = Math.random() * Math.PI * 2; + const dist = 1.5 + Math.random() * 2; + px = (parent.x ?? 0) + Math.cos(angle) * dist; + py = (parent.y ?? 0) + Math.sin(angle) * dist; + } + graph.addNode(peer.hash, { x: px, y: py, ...attrs }); } - 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; + searchQuery: string; + selectedNode?: string; + suggestions?: Set; } // --------------------------------------------------------------------------- @@ -141,150 +178,257 @@ export default function BrowseView() { const [pageHtml, setPageHtml] = useState(null); const [pageLoading, setPageLoading] = useState(false); const [pageError, setPageError] = useState(null); - const [labelPositions, setLabelPositions] = useState<{ x: number; y: number }[]>([]); - const [themeId, setThemeId] = useState(getThemeId); + const [themeRev, setThemeRev] = useState(0); const containerRef = useRef(null); + const sigmaRef = useRef(null); const graphRef = useRef(null); - const nodesRef = useRef([]); - const pollRef = useRef | null>(null); - const rafRef = useRef(0); - - 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]); + const layoutRef = useRef(null); + const nodesMapRef = useRef>(new Map()); + const stateRef = useRef({ searchQuery: "" }); // ── Watch for theme changes ── useEffect(() => { - const observer = new MutationObserver(() => { - const id = getThemeId(); - setThemeId(id); - const graph = graphRef.current; - if (graph) { - const c = THEME_COLORS[id] ?? THEME_COLORS.dark; - applyThemeToGraph(graph, nodesRef.current, c); - } - }); + const observer = new MutationObserver(() => setThemeRev((r) => r + 1)); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); return () => observer.disconnect(); }, []); - // ── Initialize cosmos.gl graph ── + // ── Initialize Sigma with reducers ── useEffect(() => { if (!containerRef.current) return; - const graph = new Graph(containerRef.current, { - backgroundColor: [0, 0, 0, 0], - pointDefaultColor: colors.primary, - pointDefaultSize: 12, - linkDefaultColor: colors.border, - linkDefaultWidth: 1, - linkOpacity: 0.5, - enableSimulation: true, - enableDrag: true, - enableZoom: true, - fitViewOnInit: false, - spaceSize: 1024, - simulationGravity: 0.15, - simulationRepulsion: 0.6, - simulationLinkSpring: 0.3, - simulationLinkDistance: 60, - simulationFriction: 0.85, - simulationDecay: 8000, - renderHoveredPointRing: true, - hoveredPointRingColor: colors.primary, - hoveredPointCursor: "pointer", - onPointClick: (index: number) => { - const node = nodesRef.current[index]; - if (node) handleNodeClick(node); - }, - onClick: () => { - setSelectedNode(null); - setPageHtml(null); - setPageError(null); - }, - onSimulationTick: () => updateLabels(), - onZoom: () => updateLabels(), - }); - + const graph = new Graph(); graphRef.current = graph; + const colors = getThemeColors(); + const dim = dimColor(colors.bg); + + const renderer = new Sigma(graph, containerRef.current, { + allowInvalidContainer: true, + nodeProgramClasses: { square: NodeSquareProgram }, + defaultNodeColor: colors.primary, + defaultEdgeColor: colors.border, + labelColor: { color: colors.foreground }, + labelFont: "JetBrains Mono, monospace", + labelSize: 10, + labelRenderedSizeThreshold: 0, + renderEdgeLabels: false, + enableEdgeEvents: false, + // ── Node reducer: search highlighting + hover dimming ── + nodeReducer: (node, data) => { + const res: Partial = { ...data }; + const s = stateRef.current; + + // Hover: dim non-neighbors + 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; + }, + // ── Edge reducer: hide irrelevant edges ── + edgeReducer: (edge, data) => { + const res: Partial = { ...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); + setPageHtml(null); + setPageError(null); + }); + + // 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 () => { - cancelAnimationFrame(rafRef.current); - graph.destroy(); + layout.kill(); + renderer.kill(); + layoutRef.current = null; + sigmaRef.current = null; graphRef.current = null; }; }, []); - // ── Update labels from graph positions ── - 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 ── + // ── Search: update reducer state when filter changes ── useEffect(() => { + const renderer = sigmaRef.current; const graph = graphRef.current; - if (!graph) return; - if (filteredNodes.length === 0) { - nodesRef.current = []; - setLabelPositions([]); - graph.setPointPositions(new Float32Array(0)); - graph.setPointColors(new Float32Array(0)); - graph.setPointSizes(new Float32Array(0)); - graph.setLinks(new Float32Array(0)); - graph.setLinkColors(new Float32Array(0)); - graph.render(); - return; + if (!renderer || !graph) return; + + const s = stateRef.current; + const query = filter.trim(); + s.searchQuery = query; + + if (query) { + const lcQuery = query.toLowerCase(); + const matches = graph + .nodes() + .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; - const { sorted, positions, pointColors, sizes, links, linkColors } = buildBuffers(filteredNodes, c); - nodesRef.current = sorted; + renderer.refresh({ skipIndexation: true }); + }, [filter]); - graph.setPointPositions(positions); - 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 ── + // ── Incrementally sync graph when nodes arrive or theme changes ── useEffect(() => { - const load = () => { - fetchBrowseNodes().then(setNodes).catch(() => { }); + const renderer = sigmaRef.current; + 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(); + 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 = { ...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 = { ...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 | 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 () => { - if (pollRef.current) clearInterval(pollRef.current); + unsub(); + if (batchTimer) clearTimeout(batchTimer); + flush(); }; }, []); @@ -297,18 +441,54 @@ export default function BrowseView() { fetchRemotePage(node.hash) .then((res) => { - if (res.content) { - setPageHtml(renderMicron(res.content, true)); - } else { - setPageError(res.error ?? "No content"); - } + if (res.content) setPageHtml(renderMicron(res.content, true)); + else setPageError(res.error ?? "No content"); }) .catch((e) => setPageError(String(e))) .finally(() => setPageLoading(false)); }, []); + // ── Resizable split ── + const [graphHeight, setGraphHeight] = useState(500); + const draggingRef = useRef(false); + const wrapperRef = useRef(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 ( -
+
{/* Header */}

Browse

@@ -316,53 +496,38 @@ export default function BrowseView() { type="text" value={filter} 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" /> - {filteredNodes.length}/{nodes.length} node{nodes.length !== 1 && "s"} + {nodeCount} node{nodeCount !== 1 && "s"} + {" · "} + {ifaceCount} iface{ifaceCount !== 1 && "s"}
- {/* Graph + labels */} + {/* Graph */}
- {nodesRef.current.map((node, i) => { - const lp = labelPositions[i]; - if (!lp) return null; - return ( - - {node.name} - - ); - })} - + {/* Sigma container — must have no React children */} +
{nodes.length === 0 && ( -
+
Listening for nodes on the Reticulum network...
)} - {nodes.length > 0 && filteredNodes.length === 0 && ( -
- No nodes match "{filter}" -
- )}
+ {/* Resize handle */} +
+ {/* Page viewer */} -
+
{selectedNode ? ( diff --git a/reticulum-client.conf b/reticulum-client.conf new file mode 100644 index 0000000..e0f6854 --- /dev/null +++ b/reticulum-client.conf @@ -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 diff --git a/reticulum.conf b/reticulum.conf index acc9a4c..ffa46de 100644 --- a/reticulum.conf +++ b/reticulum.conf @@ -16,7 +16,7 @@ [[Quad4]] type = TCPClientInterface - interface_enabled = false + interface_enabled = true target_host = 62.151.179.77 target_port = 45657 mode = full