From 7592085ce914656ca959ccc7bae24c5dc8464d5c Mon Sep 17 00:00:00 2001 From: menzelj Date: Mon, 8 Jun 2026 19:35:33 +0000 Subject: [PATCH] Live deploy console: stream `compose up` output to the browser (0.22.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploying a local stack from the editor now opens a console modal that streams the `docker compose up -d` output (image pulls, container creation) live over a new `/ws/deploy/{stack_id}` WebSocket, replacing the blind "Deploying…" spinner. The compose subprocess keeps running server-side if the modal is closed early; the same audit entry + start/error notification as the REST start path is recorded. Co-Authored-By: Claude Opus 4.8 --- README.md | 3 + backend/agent_app.py | 2 +- backend/main.py | 2 +- backend/routers/ws.py | 61 +++++++++- backend/services/compose_service.py | 19 +++ frontend/package.json | 2 +- .../src/components/stacks/DeployConsole.tsx | 108 ++++++++++++++++++ frontend/src/pages/StackEditor.tsx | 22 +++- 8 files changed, 212 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/stacks/DeployConsole.tsx diff --git a/README.md b/README.md index e587fdc..2e8c201 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. - **Live status** — running / partial / stopped / error / updating, computed from Docker container labels. - **Real-time logs** — streamed over WebSocket, color-coded per service. +- **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. - **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 bf4154e..5bf3bc6 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.21.6" +AGENT_VERSION = "0.22.0" # --------------------------------------------------------------------------- # diff --git a/backend/main.py b/backend/main.py index f4c38ba..6e266da 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.21.6", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.22.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/backend/routers/ws.py b/backend/routers/ws.py index c0ca7e0..8e2e89e 100644 --- a/backend/routers/ws.py +++ b/backend/routers/ws.py @@ -16,7 +16,8 @@ from sqlmodel import Session from auth import decode_token from database import engine from models.agent import Agent -from services import compose_service +from models.setting import EVENT_STACK_ERROR, EVENT_STACK_START +from services import audit_service, compose_service, notify_service logger = logging.getLogger("stackpilot.ws") @@ -92,6 +93,64 @@ async def ws_service_logs( pass +@router.websocket("/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 (image pulls, container + creation) to the browser so the user sees deploy progress live. Records the + same audit entry and notification as the REST `/start` endpoint.""" + await websocket.accept() + if not await _authorize(websocket, token): + return + username = decode_token(token, "access").get("sub", "unknown") if token else "unknown" + + rc: int | None = None + disconnected = False + 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: + rc = payload + await websocket.send_text(json.dumps({"type": "done", "returncode": rc})) + except WebSocketDisconnect: + # Client navigated away; the compose subprocess keeps running so the + # deploy still completes in the background. + disconnected = True + except Exception as exc: # noqa: BLE001 + with contextlib.suppress(Exception): + await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)})) + finally: + compose_service.clear_busy(stack_id) + + ok = rc in (0, None) + try: + with Session(engine) as session: + audit_service.record( + session, user=username, action="stack.start", target=stack_id, + detail=f"rc={rc} (deploy console)", ip="ws", + ) + if ok: + await notify_service.notify( + EVENT_STACK_START, f"Stack '{stack_id}' started", + "compose up completed successfully.", session, + ) + else: + await notify_service.notify( + EVENT_STACK_ERROR, f"Stack '{stack_id}' start failed", + "compose up returned a non-zero exit code.", session, + ) + except Exception: # noqa: BLE001 - audit/notify are best-effort + pass + if not disconnected: + with contextlib.suppress(Exception): + await websocket.close() + + @router.websocket("/ws/agent-logs/{agent_id}/{stack_id}") async def ws_agent_logs( websocket: WebSocket, diff --git a/backend/services/compose_service.py b/backend/services/compose_service.py index 814db65..2aa1629 100644 --- a/backend/services/compose_service.py +++ b/backend/services/compose_service.py @@ -329,6 +329,25 @@ async def stream_compose( await proc.wait() +async def stream_up(stack_id: str, override: Optional[str] = None): + """Run `compose up -d` streaming combined output, so the deploy console can + show image-pull and container-create progress live. + + Yields ``("log", line)`` for each output line, then ``("done", returncode)``. + """ + cmd = _compose_base_cmd(stack_id, override) + ["up", "-d", "--remove-orphans"] + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + assert proc.stdout is not None + async for raw in proc.stdout: + yield ("log", raw.decode("utf-8", "replace").rstrip("\n")) + await proc.wait() + yield ("done", proc.returncode) + + # Convenience lifecycle wrappers ------------------------------------------------ diff --git a/frontend/package.json b/frontend/package.json index 651a571..094b0ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.21.6", + "version": "0.22.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/stacks/DeployConsole.tsx b/frontend/src/components/stacks/DeployConsole.tsx new file mode 100644 index 0000000..7b03619 --- /dev/null +++ b/frontend/src/components/stacks/DeployConsole.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef, useState } from "react"; +import { CheckCircle2, Loader2, XCircle } from "lucide-react"; +import { Button } from "@/components/ui"; +import { useAuthStore } from "@/store/auth"; + +const MAX_LINES = 2000; + +type Phase = "running" | "success" | "failed" | "error"; + +/** + * Modal that runs `compose up -d` over the `/ws/deploy/{id}` WebSocket and + * streams its output (image pulls, container creation) live, so the user sees + * deploy progress instead of a blind spinner. The compose subprocess keeps + * running on the server even if this modal is closed early. + */ +export function DeployConsole({ + stackId, + onClose, +}: { + stackId: string; + onClose: () => void; +}) { + const [lines, setLines] = useState([]); + const [phase, setPhase] = useState("running"); + const [errorDetail, setErrorDetail] = useState(null); + const boxRef = useRef(null); + const token = useAuthStore((s) => s.accessToken); + + useEffect(() => { + if (!token) return; + const proto = window.location.protocol === "https:" ? "wss" : "ws"; + const url = `${proto}://${window.location.host}/ws/deploy/${stackId}?token=${token}`; + const ws = new WebSocket(url); + ws.onmessage = (ev) => { + try { + const msg = JSON.parse(ev.data); + if (msg.type === "log") { + setLines((prev) => { + const next = [...prev, msg.line as string]; + return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next; + }); + } else if (msg.type === "done") { + setPhase(msg.returncode === 0 ? "success" : "failed"); + } else if (msg.type === "error") { + setPhase("error"); + setErrorDetail(msg.detail || "Deploy error"); + } + } catch { + /* ignore */ + } + }; + ws.onclose = () => { + // If the socket dropped before a done/error frame, surface it. + setPhase((p) => (p === "running" ? "error" : p)); + }; + return () => ws.close(); + }, [stackId, token]); + + useEffect(() => { + if (boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight; + }, [lines]); + + const done = phase !== "running"; + + return ( +
+
+
+ {phase === "running" && ( + + )} + {phase === "success" && } + {(phase === "failed" || phase === "error") && ( + + )} +

+ {phase === "running" && `Deploying ${stackId}…`} + {phase === "success" && `Deployed ${stackId} ✓`} + {phase === "failed" && `Deploy of ${stackId} failed`} + {phase === "error" && `Deploy of ${stackId} errored`} +

+
+ +
+ {lines.length === 0 && phase === "running" ? ( + Starting compose up… + ) : ( + lines.map((l, i) => ( +
+ {l} +
+ )) + )} + {errorDetail &&
{errorDetail}
} +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/pages/StackEditor.tsx b/frontend/src/pages/StackEditor.tsx index 8cbe753..42a0d4f 100644 --- a/frontend/src/pages/StackEditor.tsx +++ b/frontend/src/pages/StackEditor.tsx @@ -7,6 +7,7 @@ import { Button, Card, Input } from "@/components/ui"; import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel"; import { EnvEditor } from "@/components/env/EnvEditor"; import { PortConflictDialog } from "@/components/stacks/PortConflictDialog"; +import { DeployConsole } from "@/components/stacks/DeployConsole"; import { stacksApi } from "@/api/stacks"; import { agentsApi } from "@/api/agents"; import { portsApi, type PortConflict } from "@/api/ports"; @@ -40,6 +41,7 @@ export function StackEditor() { const [conflicts, setConflicts] = useState(null); const [checking, setChecking] = useState(false); const [host, setHost] = useState("local"); + const [deployId, setDeployId] = useState(null); const existing = useQuery({ queryKey: ["stack", id], @@ -97,9 +99,10 @@ export function StackEditor() { qc.invalidateQueries({ queryKey: ["stack", stackId] }); toast.success("Saved"); if (deploy && stackId) { - const t = toast.loading("Deploying…"); - await stacksApi.start(stackId); - toast.success("Deployed ✓", { id: t }); + // Stream the deploy live in the console modal instead of a blind + // spinner; navigation happens when the user closes it. + setDeployId(stackId); + return; } navigate(`/stacks/${stackId}`); } catch (err) { @@ -244,6 +247,19 @@ export function StackEditor() { onCancel={() => setConflicts(null)} /> )} + + {deployId && ( + { + const sid = deployId; + setDeployId(null); + qc.invalidateQueries({ queryKey: ["stacks"] }); + qc.invalidateQueries({ queryKey: ["stack", sid] }); + navigate(`/stacks/${sid}`); + }} + /> + )} ); }