# 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
- `NodeConfig` pydantic model: `id, display_name, port, created_at`
- `NODES_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 existing `nomadnet.conf` with node-specific `node_name`
- `_next_port()` — allocate TCP ports starting from 4242
- Docker container management via Docker SDK (already a dependency):
  - `start_node_container(node)` — create container from `ghcr.io/markqvist/nomadnet:latest`, mount node's `pages/` dir, per-node identity volume, shared `reticulum.conf`, generated `nomadnet.conf`
  - `stop_node_container(id)`, `restart_node_container(id)`, `get_node_status(id)`
- `ensure_default_node()` — migration: if `NODES_DIR/default` doesn't exist, create it and move contents from old `PAGES_DIR`/`SOURCES_DIR`
- Startup reconciliation: check existing containers, start any that should be running
- API router:
  - `GET /api/nodes` — list all nodes with status
  - `POST /api/nodes` — create `{id, display_name}`
  - `GET /api/nodes/{node_id}` — node details
  - `DELETE /api/nodes/{node_id}` — stop container, remove dir
  - `POST /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_DIR` with 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_id` parameter
- 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)` — create `index.mu` per node

### `backend/graph.py`
- Same refactor: accept `node_id`, add `/api/nodes/{node_id}/graph`
- Keep `/api/graph` as alias for default

### `backend/docker_utils.py`
- Deprecate in favor of `nodes.py` restart endpoint
- Keep `/api/restart` as alias -> restart default node

### `backend/main.py`
- Import and include `nodes` router
- Startup: call `ensure_default_node()`, then start all node containers
- Remove direct `ensure_default_pages()` call (handled by node creation)

### `compose.yml`
- Remove static `nomadnet` service (backend manages containers dynamically)
- Replace `pages`/`sources`/`nomadnet-config` volumes with single `nodes` volume
- Add `NODES_DIR=/data/nodes`, `NOMADNET_IMAGE`, `RETICULUM_CONF` env vars
- Add `reticulum` bridge network for container communication
- Keep `reticulum.conf` bind-mount for backend to pass to spawned containers

### `frontend/src/stores/pagesStore.ts`
- Read `activeNodeId` from `nodesStore`
- 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 `/nodes` route -> `NodesView`

### `frontend/src/components/shared/AppShell.tsx` (or equivalent layout)
- Add `NodeSelector` to header/nav area

## Backward Compatibility

- Existing `/api/pages/...`, `/api/restart`, `/api/graph` remain as aliases for `node_id="default"`
- `ensure_default_node()` migrates old flat `PAGES_DIR`/`SOURCES_DIR` into `NODES_DIR/default/`
- Frontend defaults `activeNodeId` to `"default"` — single-node users see no change
- `NodeSelector` only shows when >1 node exists (or shows as subtle indicator for single node)

## Implementation Order

1. `backend/nodes.py` — core model, storage, Docker container management, API
2. `backend/pages.py` — refactor to accept `node_id`, add node-scoped endpoints
3. `backend/graph.py` — refactor to accept `node_id`
4. `backend/main.py` — wire in nodes router, startup logic
5. `compose.yml` — restructure (remove static nomadnet, add nodes volume + network)
6. `frontend/src/stores/nodesStore.ts` — new store
7. `frontend/src/stores/pagesStore.ts` — parameterize by active node
8. `frontend/src/components/shared/NodeSelector.tsx` — node switcher
9. `frontend/src/routes/NodesView.tsx` — management UI
10. `frontend/src/App.tsx` + layout — add routes, add selector to shell
11. `frontend/src/routes/DashboardView.tsx` — node-aware
12. `frontend/src/routes/EditorView.tsx` — node-aware save/publish/load
13. `frontend/src/routes/GraphView.tsx` — node-aware

## Verification

1. `docker compose up --build -d` — starts backend only (no static nomadnet)
2. Hit `GET /api/nodes` — should return `[{id: "default", display_name: "Micronomicon", status: "running", ...}]`
3. Open web IDE — pages dashboard shows default node's pages, NodeSelector shows "Micronomicon"
4. Create a page, publish — appears in default node's container at `/root/.nomadnetwork/storage/pages/`
5. `POST /api/nodes` with `{id: "relay", display_name: "Relay Alpha"}` — new container starts, `GET /api/nodes` shows 2 nodes
6. Switch to "Relay Alpha" in NodeSelector — empty page list, create and publish `index.mu`
7. From remote NomadNet client, both nodes visible on network with separate identities and pages
8. Delete "Relay Alpha" — container stops, files removed, back to single node
9. Run backend tests: `python -m pytest uframe/tests/ -v` — all 91 pass (DSL unchanged)
