From d46a6c35763fe1dbd378b0a85b1dbddde9575327 Mon Sep 17 00:00:00 2001 From: menzelj Date: Mon, 8 Jun 2026 19:44:31 +0000 Subject: [PATCH] Remote deploy console: stream agent `compose up` to the browser (0.23.0) Extends the live deploy console to remote/agent stacks. New agent WS endpoint `/agent/ws/deploy/{stack_id}` runs `compose up -d` and streams its output; the central app proxies it through `/ws/agent-deploy/{agent_id}/{stack_id}` (same pattern + token URL-encoding as the agent-logs proxy) and records an `agent.stack.start` audit entry. The editor's remote Deploy path now opens the DeployConsole (agentId) instead of the blocking `agentsApi.action(start)`. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 + backend/agent_app.py | 34 +++++++- backend/main.py | 2 +- backend/routers/ws.py | 79 +++++++++++++++++++ frontend/package.json | 2 +- .../src/components/stacks/DeployConsole.tsx | 10 ++- frontend/src/pages/StackEditor.tsx | 22 ++++-- 7 files changed, 140 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2e8c201..a69b764 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. - **Live deploy console** — deploying from the editor streams `compose up` output (image pulls, container creation) over a WebSocket in real time instead of a blind spinner; the deploy keeps running server-side if the modal is closed. + Works for **remote** stacks too — the central app proxies the agent's deploy + stream through to the browser. - **Monaco editor** — YAML editing with an `.env` tab and a **`docker run` → compose** converter. - **Dashboard** — system resource bar, stack grid with quick actions, and a diff --git a/backend/agent_app.py b/backend/agent_app.py index 5bf3bc6..62c04d2 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -62,7 +62,7 @@ def _map_docker(exc: DockerError): raise HTTPException(status_code=code, detail=exc.detail or exc.error) raise exc # falls through to the global 502 DockerError handler -AGENT_VERSION = "0.22.0" +AGENT_VERSION = "0.23.0" # --------------------------------------------------------------------------- # @@ -640,6 +640,38 @@ async def ws_logs(websocket: WebSocket, stack_id: str, token: str | None = Query pass +@app.websocket("/agent/ws/deploy/{stack_id}") +async def ws_deploy(websocket: WebSocket, stack_id: str, token: str | None = Query(default=None)): + """Run `docker compose up -d` and stream its output to the central app so the + browser sees deploy progress live (token via query param).""" + await websocket.accept() + expected = settings.AGENT_TOKEN + if not expected or token != expected: + await websocket.close(code=4401) + return + if not os.path.isdir(compose_service.stack_dir(stack_id)): + await websocket.send_text(json.dumps({"type": "error", "detail": "stack not found"})) + await websocket.close() + return + compose_service.mark_busy(stack_id) + try: + async for kind, payload in compose_service.stream_up(stack_id): + if kind == "log": + await websocket.send_text(json.dumps({"type": "log", "line": payload})) + else: + await websocket.send_text(json.dumps({"type": "done", "returncode": payload})) + except WebSocketDisconnect: + # Browser navigated away; the compose subprocess keeps running. + pass + except Exception as exc: # noqa: BLE001 + try: + await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)})) + except Exception: # noqa: BLE001 + pass + finally: + compose_service.clear_busy(stack_id) + + @app.get("/agent/health") def health() -> dict: return {"status": "ok"} diff --git a/backend/main.py b/backend/main.py index 6e266da..b99adef 100644 --- a/backend/main.py +++ b/backend/main.py @@ -55,7 +55,7 @@ async def lifespan(app: FastAPI): schedule_task.cancel() -app = FastAPI(title="StackPilot", version="0.22.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.23.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/backend/routers/ws.py b/backend/routers/ws.py index 8e2e89e..ac8c1c0 100644 --- a/backend/routers/ws.py +++ b/backend/routers/ws.py @@ -223,6 +223,85 @@ async def ws_agent_logs( await websocket.close() +@router.websocket("/ws/agent-deploy/{agent_id}/{stack_id}") +async def ws_agent_deploy( + websocket: WebSocket, + agent_id: int, + stack_id: str, + token: str | None = Query(default=None), +): + """Proxy a remote agent's `compose up` deploy stream through to the browser, + then record the same audit entry as the REST agent lifecycle endpoint.""" + await websocket.accept() + if not await _authorize(websocket, token): + return + username = decode_token(token, "access").get("sub", "unknown") if token else "unknown" + + with Session(engine) as session: + agent = session.get(Agent, agent_id) + if not agent: + await websocket.send_text(json.dumps({"type": "error", "detail": "agent not found"})) + await websocket.close() + return + + base = agent.url.rstrip("/") + ws_url = ("wss://" + base[8:] if base.startswith("https://") + else "ws://" + base[7:] if base.startswith("http://") + else "ws://" + base) + ws_url += f"/agent/ws/deploy/{stack_id}?token={urllib.parse.quote(agent.token, safe='')}" + + async def _err(detail: str) -> None: + with contextlib.suppress(Exception): + await websocket.send_text(json.dumps({"type": "error", "detail": detail})) + + try: + upstream = await websockets.connect(ws_url, open_timeout=10, ping_interval=20) + except websockets.InvalidStatus as exc: + code = getattr(getattr(exc, "response", None), "status_code", None) + hint = " — the agent may be running an old version without deploy-console support; update it." if code == 404 else "" + logger.warning("Agent deploy proxy: handshake to %s failed (%s)", agent.name, code) + await _err(f"Agent '{agent.name}' rejected the deploy stream (HTTP {code}){hint}") + with contextlib.suppress(Exception): + await websocket.close() + return + except Exception as exc: # noqa: BLE001 + logger.warning("Agent deploy proxy: cannot reach %s at %s: %s", agent.name, agent.url, exc) + await _err(f"Could not connect to agent '{agent.name}' at {agent.url}: {exc}") + with contextlib.suppress(Exception): + await websocket.close() + return + + rc: int | None = None + try: + async for message in upstream: + text = message if isinstance(message, str) else message.decode("utf-8", "replace") + with contextlib.suppress(Exception): + msg = json.loads(text) + if msg.get("type") == "done": + rc = msg.get("returncode") + await websocket.send_text(text) + except WebSocketDisconnect: + pass + except websockets.ConnectionClosed as exc: + if exc.code not in (1000, 1001): + await _err(f"Agent deploy stream closed unexpectedly (code {exc.code}).") + except Exception as exc: # noqa: BLE001 + logger.warning("Agent deploy proxy: stream error from %s: %s", agent.name, exc) + await _err(str(exc)) + finally: + with contextlib.suppress(Exception): + await upstream.close() + with contextlib.suppress(Exception): + await websocket.close() + + with contextlib.suppress(Exception): + with Session(engine) as session: + audit_service.record( + session, user=username, action="agent.stack.start", + target=f"{agent.name}/{stack_id}", detail=f"rc={rc} (deploy console)", ip="ws", + ) + + @router.websocket("/ws/events") async def ws_events( websocket: WebSocket, diff --git a/frontend/package.json b/frontend/package.json index 094b0ff..39533be 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.22.0", + "version": "0.23.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/stacks/DeployConsole.tsx b/frontend/src/components/stacks/DeployConsole.tsx index 7b03619..4346b57 100644 --- a/frontend/src/components/stacks/DeployConsole.tsx +++ b/frontend/src/components/stacks/DeployConsole.tsx @@ -15,9 +15,11 @@ type Phase = "running" | "success" | "failed" | "error"; */ export function DeployConsole({ stackId, + agentId, onClose, }: { stackId: string; + agentId?: number; onClose: () => void; }) { const [lines, setLines] = useState([]); @@ -29,7 +31,11 @@ export function DeployConsole({ useEffect(() => { if (!token) return; const proto = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${proto}://${window.location.host}/ws/deploy/${stackId}?token=${token}`; + const path = + agentId != null + ? `/ws/agent-deploy/${agentId}/${stackId}` + : `/ws/deploy/${stackId}`; + const url = `${proto}://${window.location.host}${path}?token=${token}`; const ws = new WebSocket(url); ws.onmessage = (ev) => { try { @@ -54,7 +60,7 @@ export function DeployConsole({ setPhase((p) => (p === "running" ? "error" : p)); }; return () => ws.close(); - }, [stackId, token]); + }, [stackId, agentId, token]); useEffect(() => { if (boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight; diff --git a/frontend/src/pages/StackEditor.tsx b/frontend/src/pages/StackEditor.tsx index 42a0d4f..360c755 100644 --- a/frontend/src/pages/StackEditor.tsx +++ b/frontend/src/pages/StackEditor.tsx @@ -42,6 +42,7 @@ export function StackEditor() { const [checking, setChecking] = useState(false); const [host, setHost] = useState("local"); const [deployId, setDeployId] = useState(null); + const [deployAgentId, setDeployAgentId] = useState(undefined); const existing = useQuery({ queryKey: ["stack", id], @@ -80,9 +81,10 @@ export function StackEditor() { qc.invalidateQueries({ queryKey: ["agent-stacks", aid] }); toast.success("Saved"); if (deploy) { - const t = toast.loading("Deploying…"); - await agentsApi.action(aid, created.id, "start"); - toast.success("Deployed ✓", { id: t }); + // Stream the remote deploy live through the agent-deploy WS proxy. + setDeployAgentId(aid); + setDeployId(created.id); + return; } navigate(`/hosts/${aid}/stacks/${created.id}`); return; @@ -251,12 +253,20 @@ export function StackEditor() { {deployId && ( { const sid = deployId; + const aid = deployAgentId; setDeployId(null); - qc.invalidateQueries({ queryKey: ["stacks"] }); - qc.invalidateQueries({ queryKey: ["stack", sid] }); - navigate(`/stacks/${sid}`); + setDeployAgentId(undefined); + if (aid != null) { + qc.invalidateQueries({ queryKey: ["agent-stacks", aid] }); + navigate(`/hosts/${aid}/stacks/${sid}`); + } else { + qc.invalidateQueries({ queryKey: ["stacks"] }); + qc.invalidateQueries({ queryKey: ["stack", sid] }); + navigate(`/stacks/${sid}`); + } }} /> )}