feat: browser graph

This commit is contained in:
2026-04-04 00:13:38 +02:00
parent 285825fff7
commit e5c9c00d2f
9 changed files with 809 additions and 3 deletions

245
backend/browse.py Normal file
View File

@@ -0,0 +1,245 @@
"""Network browser — discovers NomadNet nodes and fetches pages via Reticulum.
Starts an RNS transport on FastAPI startup, listens for NomadNet node
announces, and exposes discovered nodes + remote page fetching via API.
"""
from __future__ import annotations
import asyncio
import logging
import os
import threading
import time
from pathlib import Path
from fastapi import APIRouter, Query
router = APIRouter()
log = logging.getLogger("browse")
# ---------------------------------------------------------------------------
# Module-level state
# ---------------------------------------------------------------------------
_nodes: dict[str, dict] = {} # hash_hex -> node info
_own_hash: str | None = None
_own_name: str = os.environ.get("NOMADNET_NODE_NAME", "Micronomicon")
_lock = threading.Lock()
_started = False
# ---------------------------------------------------------------------------
# RNS lifecycle
# ---------------------------------------------------------------------------
def start_browser() -> None:
"""Initialize RNS and begin listening for NomadNet node announces."""
global _started, _own_hash
if _started:
return
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)
# 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(
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."""
with _lock:
nodes = list(_nodes.values())
# Ensure the user's own node is always present
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,
})
return nodes
@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.
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"}
return {"content": content}
# ---------------------------------------------------------------------------
# Local page reader
# ---------------------------------------------------------------------------
def _read_local_page(path: str) -> dict:
pages_dir = os.environ.get("PAGES_DIR", str(Path.home() / ".nomadnetwork/storage/pages"))
filepath = Path(pages_dir) / path
try:
return {"content": filepath.read_text()}
except FileNotFoundError:
return {"content": None, "error": "Page not found"}
# ---------------------------------------------------------------------------
# Remote page fetcher (RNS link + request/response)
# ---------------------------------------------------------------------------
async def _request_remote_page(hash_hex: str, path: str) -> str | None:
"""Establish an RNS link to a remote NomadNet node and request a page."""
try:
import RNS
except ImportError:
return None
loop = asyncio.get_event_loop()
future: asyncio.Future[str | None] = loop.create_future()
def _do_request():
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
while time.time() < deadline:
if RNS.Transport.has_path(dest_hash):
break
time.sleep(0.1)
else:
loop.call_soon_threadsafe(future.set_result, None)
return
identity = RNS.Identity.recall(dest_hash)
if not identity:
loop.call_soon_threadsafe(future.set_result, None)
return
dest = RNS.Destination(
identity,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
"nomadnetwork",
"node",
)
link = RNS.Link(dest)
# Wait for link to become active
deadline = time.time() + 15
while time.time() < deadline:
if link.status == RNS.Link.ACTIVE:
break
time.sleep(0.1)
else:
link.teardown()
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
if resp is not None:
content = resp.decode("utf-8") if isinstance(resp, bytes) else str(resp)
loop.call_soon_threadsafe(future.set_result, content)
else:
loop.call_soon_threadsafe(future.set_result, None)
except Exception:
loop.call_soon_threadsafe(future.set_result, None)
finally:
link.teardown()
def on_failed(request_receipt):
if not future.done():
loop.call_soon_threadsafe(future.set_result, None)
link.teardown()
link.request(
"/page/" + path,
response_callback=on_response,
failed_callback=on_failed,
)
except Exception as exc:
log.warning("Remote page request failed: %s", exc)
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:
return await asyncio.wait_for(future, timeout=30.0)
except asyncio.TimeoutError:
return None

View File

@@ -7,17 +7,20 @@ from fastapi.staticfiles import StaticFiles
from pages import router as pages_router, ensure_default_pages
from docker_utils import router as docker_router
from converter import router as converter_router
from browse import router as browse_router, start_browser
app = FastAPI(title="µFrame Editor")
app.include_router(converter_router, prefix="/api")
app.include_router(pages_router, prefix="/api")
app.include_router(docker_router, prefix="/api")
app.include_router(browse_router, prefix="/api")
@app.on_event("startup")
async def startup():
ensure_default_pages()
start_browser()
@app.get("/api/health")

6
backend/package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "backend",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -4,3 +4,4 @@ docker>=7.0
Pillow>=10.0
python-multipart>=0.0.6
md2txt @ git+https://codeberg.org/randogoth/md2txt
rns>=0.9.3