Compare commits

...

2 Commits

Author SHA1 Message Date
f082a30b7f feat: docker and nomad config 2026-04-03 14:46:27 +02:00
576af715bd feat: fix buiild 2026-04-03 10:40:14 +02:00
11 changed files with 305 additions and 52 deletions

View File

@@ -61,11 +61,37 @@ python -m uframe deploy page.uf # compile + copy to NomadNet pages
### Docker
```bash
cd frontend && npm install && npm run build && cd ..
docker compose up --build
# 1. Build the frontend
cd frontend
npm install
npm run build
cd ..
# 2. Build and start all services
docker compose up --build -d
```
App at http://localhost:8080. Add Tailscale: `tailscale serve --bg https+insecure://localhost:8080`
This starts two containers:
- **micronomicon** — the web IDE + API on <http://localhost:8080>
- **nomadnet** — NomadNet node serving your published `.mu` pages
Pages published through the IDE are written to a shared volume that NomadNet reads from. The Docker socket is mounted read-only so the IDE can restart NomadNet when needed.
To follow logs: `docker compose logs -f`
To stop: `docker compose down`
To expose over Tailscale: `tailscale serve --bg https+insecure://localhost:8080`
#### Deploy a page via CLI (into the running stack)
```bash
cd backend && source .venv/bin/activate
python -m uframe deploy page.uf --dest "$(docker volume inspect micronomicon_pages -f '{{.Mountpoint}}')"
```
Or use the web IDE at <http://localhost:8080> and click **Publish**.
---
@@ -187,7 +213,7 @@ banner "My Node" "Mesh Network" # use library component
```bash
cd backend && source .venv/bin/activate
python -m pytest uframe/tests/ -v # 42 tests
python -m pytest uframe/tests/ -v # 91 tests
```
---

View File

@@ -4,7 +4,7 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pages import router as pages_router
from pages import router as pages_router, ensure_default_pages
from graph import router as graph_router
from docker_utils import router as docker_router
from converter import router as converter_router
@@ -17,6 +17,11 @@ app.include_router(graph_router, prefix="/api")
app.include_router(docker_router, prefix="/api")
@app.on_event("startup")
async def startup():
ensure_default_pages()
@app.get("/api/health")
async def health():
return {"status": "ok"}

View File

@@ -12,6 +12,48 @@ router = APIRouter()
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
DEFAULT_INDEX_SOURCE = '''\
page "Welcome" 60
bigtitle "uFrame" thin
box rounded "Micronomicon"
align center
text "Decentralized Page Server"
text "Powered by @bold{Reticulum} and @bold{NomadNet}"
spacer
heading 2 "Pages"
text "This node is serving pages built with the uFrame DSL."
text "Use the web IDE to create and publish new pages."
spacer
divider light
label "Node" "Micronomicon"
label "Engine" "uFrame v1"
status "Node" online
'''
def ensure_default_pages():
"""Create a default index page if none exists."""
PAGES_DIR.mkdir(parents=True, exist_ok=True)
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
index_mu = PAGES_DIR / "index.mu"
index_src = SOURCES_DIR / "index.uf"
if not index_mu.is_file():
# Compile and publish the default index
result = uframe.compile(DEFAULT_INDEX_SOURCE)
index_mu.write_text(result.micron, encoding="utf-8")
index_mu.chmod(0o644)
if not index_src.is_file():
index_src.write_text(DEFAULT_INDEX_SOURCE, encoding="utf-8")
class PageMeta(BaseModel):
name: str

View File

@@ -1,14 +1,34 @@
services:
micron-editor:
build: .
micronomicon:
build:
context: .
dockerfile: Dockerfile
ports:
- "127.0.0.1:8080:8080"
volumes:
- ~/.nomadnetwork/storage/pages:/data/pages
- ~/.micron-editor/sources:/data/sources
- /var/run/docker.sock:/var/run/docker.sock
environment:
- PAGES_DIR=/data/pages
- SOURCES_DIR=/data/sources
- NOMADNET_CONTAINER=nomadnet
restart: always
volumes:
- pages:/data/pages
- sources:/data/sources
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
depends_on:
- nomadnet
nomadnet:
image: ghcr.io/markqvist/nomadnet:latest
ports:
- "0.0.0.0:4242:4242"
volumes:
- pages:/root/.nomadnetwork/storage/pages
- nomadnet-config:/root/.nomadnetwork
- ./nomadnet.conf:/root/.nomadnetwork/config:ro
- ./reticulum.conf:/root/.reticulum/config:ro
restart: unless-stopped
volumes:
pages:
sources:
nomadnet-config:

View File

@@ -1,30 +0,0 @@
services:
micronomicon:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- PAGES_DIR=/data/pages
- SOURCES_DIR=/data/sources
- NOMADNET_CONTAINER=nomadnet
volumes:
- pages:/data/pages
- sources:/data/sources
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
depends_on:
- nomadnet
nomadnet:
image: ghcr.io/markqvist/nomadnet:latest
volumes:
- pages:/root/.nomadnetwork/storage/pages
- nomadnet-config:/root/.nomadnetwork
restart: unless-stopped
volumes:
pages:
sources:
nomadnet-config:

145
docs/multi-node-plan.md Normal file
View File

@@ -0,0 +1,145 @@
# 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)

View File

@@ -12,10 +12,10 @@ export default function BacklinkIndicator({ backlinks }: Props) {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground h-7 px-2">
{backlinks.length} backlink{backlinks.length !== 1 ? "s" : ""}
</Button>
<PopoverTrigger
render={<Button variant="ghost" size="sm" className="text-xs text-muted-foreground h-7 px-2" />}
>
{backlinks.length} backlink{backlinks.length !== 1 ? "s" : ""}
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="end">
<p className="text-xs font-semibold text-muted-foreground mb-2 px-1">

View File

@@ -189,11 +189,6 @@ function renderFormTag(inner: string): string {
return `<span style="color:#d4a8f8;border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px">${esc(placeholder || "...")}</span>`;
}
/** Render a form element line as a styled badge. */
function renderForm(line: string): string {
const inner = escapeHtml(line);
return `<span style="color:#d4a8f8;background:rgba(212,168,248,0.08);border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px;font-size:0.9em">${inner}</span>`;
}
export function renderMicron(source: string): string {
const lines = source.split("\n");

View File

@@ -52,7 +52,6 @@ export default function EditorView() {
override: [uframeCommandSource, uframeValueHintSource],
icons: false,
activateOnTyping: true,
maxOptions: 50,
}),
keywordHoverTooltip,
],

30
nomadnet.conf Normal file
View File

@@ -0,0 +1,30 @@
[logging]
loglevel = 4
destination = file
[client]
enable_client = yes
user_interface = text
announce_at_start = yes
[textui]
intro_time = 1
theme = dark
colormode = 256
glyphs = unicode
[node]
# Enable page-serving node
enable_node = yes
node_name = Micronomicon
# Announce on the network
announce_interval = 360
announce_at_start = yes
# Rescan pages directory every minute so newly
# published pages are picked up automatically
page_refresh_interval = 1
# Not running as a propagation node
disable_propagation = yes

21
reticulum.conf Normal file
View File

@@ -0,0 +1,21 @@
[reticulum]
enable_transport = True
share_instance = Yes
[logging]
loglevel = 4
[interfaces]
# Local AutoInterface for LAN discovery
[[Default Interface]]
type = AutoInterface
enabled = Yes
# TCP Server — allows other Reticulum peers to connect
# Expose this port to reach the node from outside Docker
[[TCP Server Interface]]
type = TCPServerInterface
enabled = Yes
listen_ip = 0.0.0.0
listen_port = 4242