Files
simplex-orchestrate/supervisor/server.py
Jon d6041c1048 Add web management front end (served by the supervisor)
Single-page UI styled after simplex-manager/web/index.html (palette, header,
section-tabs, cards). Talks to the supervisor over REST (control) + WebSocket
(/events live stream):
- Profiles tab: create cli/directory/broadcast, list with status, stop
- Console tab: send raw chat commands to a cli profile, see the response
- Events tab: live event feed from all profiles

server.py serves webui/index.html at /. Validated end to end with curl:
GET / , GET /profiles, start-cli, cmd /user (activeUser), stop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:45:49 +01:00

99 lines
2.9 KiB
Python

"""Front-facing API the website talks to.
The browser:
- opens a WebSocket to /events to receive live chat events from every profile
(each event is tagged with the originating profile name);
- uses REST to spawn/stop profiles and to send commands to cli profiles.
This process sits between the website and the SimpleX binaries; it is the only
thing that touches the binaries. Run: uvicorn supervisor.server:app
"""
import contextlib
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from .supervisor import Supervisor
app = FastAPI(title="SimpleX Orchestrate")
WEBUI = Path(__file__).resolve().parent.parent / "webui" / "index.html"
@app.get("/")
async def index() -> FileResponse:
return FileResponse(WEBUI)
_browser_clients: set[WebSocket] = set()
async def _broadcast(name: str, event: dict) -> None:
dead = []
for ws in _browser_clients:
try:
await ws.send_json({"profile": name, "event": event})
except Exception:
dead.append(ws)
for ws in dead:
_browser_clients.discard(ws)
sup = Supervisor(on_event=_broadcast)
@app.on_event("shutdown")
async def _shutdown() -> None:
await sup.stop_all()
# ── Control (REST) ──────────────────────────────────────────────────────────────
@app.get("/profiles")
async def profiles() -> dict:
return {"profiles": sup.list()}
@app.post("/profiles/{name}/start-cli")
async def start_cli(name: str) -> dict:
await sup.start_cli(name)
return {"ok": True}
@app.post("/profiles/{name}/start-directory")
async def start_directory(name: str, body: dict) -> dict:
await sup.start_directory(name, body["super_users"], body["web_folder"])
return {"ok": True}
@app.post("/profiles/{name}/start-broadcast")
async def start_broadcast(name: str, body: dict) -> dict:
await sup.start_broadcast(name, body["display_name"], body["publishers"])
return {"ok": True}
@app.post("/profiles/{name}/cmd")
async def cmd(name: str, body: dict) -> dict:
"""Send a raw chat command string to a cli profile (e.g. '/_send @1 text hi')."""
return {"resp": await sup.send(name, body["cmd"])}
@app.post("/profiles/{name}/stop")
async def stop(name: str) -> dict:
await sup.stop(name)
return {"ok": True}
# ── Live events (WebSocket) ──────────────────────────────────────────────────────
@app.websocket("/events")
async def events(ws: WebSocket) -> None:
await ws.accept()
_browser_clients.add(ws)
try:
while True:
await ws.receive_text() # keep the socket open; ignore inbound
except WebSocketDisconnect:
pass
finally:
_browser_clients.discard(ws)