Files
soleprint/init/web.py
2026-04-12 12:34:25 -03:00

268 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""
spr init — Web wizard
Tiny standalone FastAPI server for room setup.
Run it, configure in browser, stop it.
Usage:
python -m init.web # http://localhost:9000
python -m init.web --port 9001
"""
import argparse
import json
import logging
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from init.core import (
CFG_DIR,
SPR_ROOT,
BACKEND_FRAMEWORKS,
FRONTEND_FRAMEWORKS,
DATA_FILES,
clone_room,
generate_layer0,
generate_layer1,
generate_layer2,
generate_layer3,
generate_layer4,
generate_layer5,
generate_layer6,
list_rooms,
next_free_port,
)
log = logging.getLogger("init")
app = FastAPI(title="Soleprint Room Setup")
WIZARD_HTML = Path(__file__).parent / "wizard.html"
# ---------------------------------------------------------------------------
# API models
# ---------------------------------------------------------------------------
class Layer2Data(BaseModel):
app_name: str = ""
backend_path: str = ""
backend_framework: str = "django"
frontend_path: str | None = None
frontend_framework: str | None = None
class Layer5Data(BaseModel):
test_url: str = "http://localhost:8000"
class GenerateRequest(BaseModel):
room: str
room_type: str = "standalone" # standalone | managed
port: int = 0
layers: list[int] = [0, 1] # which layers to generate
layer2: Layer2Data | None = None
layer5: Layer5Data | None = None
# data selections — which items to include in each data file
data_selections: dict[str, list[dict]] | None = None
class CloneRequest(BaseModel):
source: str
target: str
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
def index():
if WIZARD_HTML.exists():
return WIZARD_HTML.read_text()
raise HTTPException(404, "wizard.html not found")
@app.get("/api/rooms")
def get_rooms():
"""List existing rooms."""
return {"rooms": list_rooms()}
@app.get("/api/defaults")
def get_defaults():
"""Return available components from standalone room's data/*.json."""
standalone_data = CFG_DIR / "standalone" / "data"
defaults = {}
for name in DATA_FILES:
path = standalone_data / f"{name}.json"
if path.exists():
try:
defaults[name] = json.loads(path.read_text())
except json.JSONDecodeError:
defaults[name] = {"items": []}
else:
defaults[name] = {"items": []}
return {
"data": defaults,
"backend_frameworks": BACKEND_FRAMEWORKS,
"frontend_frameworks": FRONTEND_FRAMEWORKS,
"next_port": next_free_port(),
}
@app.post("/api/generate")
def generate(req: GenerateRequest):
"""Generate room files for selected layers."""
room = req.room.strip()
if not room:
raise HTTPException(400, "Room name is required")
room_dir = CFG_DIR / room
if room_dir.exists():
raise HTTPException(409, f"Room '{room}' already exists in cfg/")
port = req.port or next_free_port()
is_managed = req.room_type == "managed"
created = []
try:
# Layer 0: always
if 0 in req.layers:
config = generate_layer0(room_dir, room, port, is_managed)
# Apply data selections if provided
if req.data_selections:
data_dir = room_dir / "data"
for name, items in req.data_selections.items():
path = data_dir / f"{name}.json"
if path.exists():
path.write_text(json.dumps({"items": items}, indent=2) + "\n")
created.append("config + data")
else:
# Need config for later layers
config = generate_layer0(room_dir, room, port, is_managed)
created.append("config + data")
if 1 in req.layers:
generate_layer1(room_dir, room, port)
created.append("docker")
app_info = None
if 2 in req.layers and is_managed and req.layer2:
l2 = req.layer2
has_frontend = bool(l2.frontend_path)
app_info = generate_layer2(
room_dir, room, config,
l2.app_name or room, l2.backend_path, l2.backend_framework,
l2.frontend_path if has_frontend else None,
l2.frontend_framework if has_frontend else None,
)
created.append("managed app")
if 3 in req.layers and is_managed and app_info:
generate_layer3(room_dir, room, app_info["backend_framework"])
created.append("link")
app_name = app_info["app_name"] if app_info else None
if 4 in req.layers:
generate_layer4(room_dir, room, app_name)
created.append("scripts")
if 5 in req.layers:
url = req.layer5.test_url if req.layer5 else "http://localhost:8000"
generate_layer5(room_dir, room, url)
created.append("tester")
if 6 in req.layers and is_managed and app_info and app_info["has_frontend"]:
generate_layer6(room_dir, room, app_info["app_name"])
created.append("nginx")
except Exception as e:
raise HTTPException(500, f"Generation failed: {e}")
return {
"room": room,
"port": port,
"created": created,
"path": str(room_dir.relative_to(SPR_ROOT)),
"build_command": f"python build.py --cfg {room}",
}
@app.post("/api/clone")
def clone(req: CloneRequest):
"""Clone an existing room as a variant."""
if not req.source.strip() or not req.target.strip():
raise HTTPException(400, "Source and target room names required")
ok = clone_room(req.source.strip(), req.target.strip())
if not ok:
raise HTTPException(400, "Clone failed — check source exists and target doesn't")
return {
"source": req.source,
"target": req.target,
"path": f"cfg/{req.target}",
"build_command": f"python build.py --cfg {req.target}",
}
@app.get("/api/preview")
def preview(room: str = "myroom", room_type: str = "standalone"):
"""Return directory tree preview for a room configuration."""
is_managed = room_type == "managed"
tree = [f"cfg/{room}/"]
tree.append(" config.json")
tree.append(" data/")
for f in DATA_FILES:
tree.append(f" {f}.json")
tree.append(" soleprint/")
tree.append(" docker-compose.yml")
tree.append(" .env")
if is_managed:
tree.append(f" docker-compose.yml")
tree.append(f" Dockerfile.backend")
tree.append(f" .env")
tree.append(f" {room}/")
tree.append(f" dumps/")
tree.append(" link/")
tree.append(" main.py")
tree.append(" adapters/")
tree.append(" soleprint/")
tree.append(" nginx/")
tree.append(" local.conf")
tree.append(" docker-compose.nginx.yml")
tree.append(" ctrl/")
tree.append(" start.sh")
tree.append(" stop.sh")
tree.append(" status.sh")
tree.append(" logs.sh")
return {"tree": tree}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
import uvicorn
logging.basicConfig(level=logging.INFO, format="%(message)s")
parser = argparse.ArgumentParser(description="Soleprint room setup wizard (web)")
parser.add_argument("--port", type=int, default=9000)
parser.add_argument("--host", default="127.0.0.1")
args = parser.parse_args()
log.info("Room setup wizard: http://%s:%d", args.host, args.port)
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
if __name__ == "__main__":
main()