7.2 KiB
7.2 KiB
Multi-Node Support for Micronomicon
Context
Currently Micronomicon runs a single NomadNet node — one identity, one set of pages, one index.mu. The user wants to host multiple independent "sites" on the Reticulum network, each with its own node identity, name, and pages. This requires changes across Docker infrastructure, backend API, and frontend UI.
Approach: Dynamic Node Containers via Docker API
The FastAPI backend already has Docker socket access. Instead of statically defining NomadNet services in compose.yml, the backend will create/manage NomadNet containers dynamically — one per node. Each node gets its own directory with pages, sources, config, and identity.
Storage Layout
/data/nodes/
default/
node.json # {"id", "display_name", "port", "created_at"}
nomadnet.conf # generated — unique node_name
pages/ # compiled .mu files (mounted into container)
sources/ # .uf source files
my-relay/
node.json
nomadnet.conf
pages/
sources/
Files to Create
backend/nodes.py — Node lifecycle management
NodeConfigpydantic model:id, display_name, port, created_atNODES_DIR = Path(os.environ.get("NODES_DIR", "/data/nodes"))- CRUD:
list_nodes(),get_node(id),create_node(id, display_name),delete_node(id) _generate_nomadnet_conf(display_name)— template from existingnomadnet.confwith node-specificnode_name_next_port()— allocate TCP ports starting from 4242- Docker container management via Docker SDK (already a dependency):
start_node_container(node)— create container fromghcr.io/markqvist/nomadnet:latest, mount node'spages/dir, per-node identity volume, sharedreticulum.conf, generatednomadnet.confstop_node_container(id),restart_node_container(id),get_node_status(id)
ensure_default_node()— migration: ifNODES_DIR/defaultdoesn't exist, create it and move contents from oldPAGES_DIR/SOURCES_DIR- Startup reconciliation: check existing containers, start any that should be running
- API router:
GET /api/nodes— list all nodes with statusPOST /api/nodes— create{id, display_name}GET /api/nodes/{node_id}— node detailsDELETE /api/nodes/{node_id}— stop container, remove dirPOST /api/nodes/{node_id}/restart
frontend/src/stores/nodesStore.ts — Node state
nodes: NodeMeta[],activeNodeId: string(persisted to localStorage, defaults to"default")fetchNodes(),setActiveNode(id),createNode(id, displayName),deleteNode(id),restartNode(id)
frontend/src/components/shared/NodeSelector.tsx — Header dropdown
- Dropdown listing nodes with status indicator (green/red dot)
- Switching active node refetches pages
- "Manage Nodes" link to
/nodes
frontend/src/routes/NodesView.tsx — Node management page
- Table: Name, Status, Port, Created, Actions (restart/delete)
- "Create Node" button with dialog (ID + display name)
Files to Modify
backend/pages.py
- Replace global
PAGES_DIR/SOURCES_DIRwith helpers:_node_pages_dir(node_id)->NODES_DIR/node_id/pages,_node_sources_dir(node_id)->NODES_DIR/node_id/sources - Refactor all functions to accept
node_idparameter - Add node-scoped endpoints:
GET/POST/DELETE /api/nodes/{node_id}/pages/{name} - Keep existing
/api/pages/{name}as aliases ->node_id="default" ensure_default_pages(node_id)— createindex.muper node
backend/graph.py
- Same refactor: accept
node_id, add/api/nodes/{node_id}/graph - Keep
/api/graphas alias for default
backend/docker_utils.py
- Deprecate in favor of
nodes.pyrestart endpoint - Keep
/api/restartas alias -> restart default node
backend/main.py
- Import and include
nodesrouter - Startup: call
ensure_default_node(), then start all node containers - Remove direct
ensure_default_pages()call (handled by node creation)
compose.yml
- Remove static
nomadnetservice (backend manages containers dynamically) - Replace
pages/sources/nomadnet-configvolumes with singlenodesvolume - Add
NODES_DIR=/data/nodes,NOMADNET_IMAGE,RETICULUM_CONFenv vars - Add
reticulumbridge network for container communication - Keep
reticulum.confbind-mount for backend to pass to spawned containers
frontend/src/stores/pagesStore.ts
- Read
activeNodeIdfromnodesStore - All fetch calls go to
/api/nodes/{activeNodeId}/pages/...
frontend/src/routes/DashboardView.tsx
- Show active node name in header
- Restart button restarts active node
- Page list scoped to active node via store
frontend/src/routes/EditorView.tsx
- Save/publish calls use
/api/nodes/{activeNodeId}/pages/{slug} - Load page from
/api/nodes/{activeNodeId}/pages/{name} - Show active node name in toolbar
frontend/src/routes/GraphView.tsx
- Fetch from
/api/nodes/{activeNodeId}/graph
frontend/src/App.tsx
- Add
/nodesroute ->NodesView
frontend/src/components/shared/AppShell.tsx (or equivalent layout)
- Add
NodeSelectorto header/nav area
Backward Compatibility
- Existing
/api/pages/...,/api/restart,/api/graphremain as aliases fornode_id="default" ensure_default_node()migrates old flatPAGES_DIR/SOURCES_DIRintoNODES_DIR/default/- Frontend defaults
activeNodeIdto"default"— single-node users see no change NodeSelectoronly shows when >1 node exists (or shows as subtle indicator for single node)
Implementation Order
backend/nodes.py— core model, storage, Docker container management, APIbackend/pages.py— refactor to acceptnode_id, add node-scoped endpointsbackend/graph.py— refactor to acceptnode_idbackend/main.py— wire in nodes router, startup logiccompose.yml— restructure (remove static nomadnet, add nodes volume + network)frontend/src/stores/nodesStore.ts— new storefrontend/src/stores/pagesStore.ts— parameterize by active nodefrontend/src/components/shared/NodeSelector.tsx— node switcherfrontend/src/routes/NodesView.tsx— management UIfrontend/src/App.tsx+ layout — add routes, add selector to shellfrontend/src/routes/DashboardView.tsx— node-awarefrontend/src/routes/EditorView.tsx— node-aware save/publish/loadfrontend/src/routes/GraphView.tsx— node-aware
Verification
docker compose up --build -d— starts backend only (no static nomadnet)- Hit
GET /api/nodes— should return[{id: "default", display_name: "Micronomicon", status: "running", ...}] - Open web IDE — pages dashboard shows default node's pages, NodeSelector shows "Micronomicon"
- Create a page, publish — appears in default node's container at
/root/.nomadnetwork/storage/pages/ POST /api/nodeswith{id: "relay", display_name: "Relay Alpha"}— new container starts,GET /api/nodesshows 2 nodes- Switch to "Relay Alpha" in NodeSelector — empty page list, create and publish
index.mu - From remote NomadNet client, both nodes visible on network with separate identities and pages
- Delete "Relay Alpha" — container stops, files removed, back to single node
- Run backend tests:
python -m pytest uframe/tests/ -v— all 91 pass (DSL unchanged)