wizard
This commit is contained in:
267
init/web.py
Normal file
267
init/web.py
Normal file
@@ -0,0 +1,267 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user