Live deploy console: stream compose up output to the browser (0.22.0)

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 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-08 19:35:33 +00:00
co-authored by Claude Opus 4.8
parent 5b59f5e8f9
commit 7592085ce9
8 changed files with 212 additions and 7 deletions
+3
View File
@@ -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
+1 -1
View File
@@ -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"
# --------------------------------------------------------------------------- #
+1 -1
View File
@@ -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,
+60 -1
View File
@@ -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,
+19
View File
@@ -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 ------------------------------------------------
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.21.6",
"version": "0.22.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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<string[]>([]);
const [phase, setPhase] = useState<Phase>("running");
const [errorDetail, setErrorDetail] = useState<string | null>(null);
const boxRef = useRef<HTMLDivElement>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="flex max-h-[80vh] w-full max-w-3xl flex-col rounded-xl border border-slate-200 bg-card shadow-xl dark:border-slate-700 dark:bg-card-dark">
<div className="flex items-center gap-2 border-b border-slate-200 px-5 py-3 dark:border-slate-700">
{phase === "running" && (
<Loader2 className="h-5 w-5 animate-spin text-accent dark:text-accent-dark" />
)}
{phase === "success" && <CheckCircle2 className="h-5 w-5 text-green-500" />}
{(phase === "failed" || phase === "error") && (
<XCircle className="h-5 w-5 text-red-500" />
)}
<h2 className="text-lg font-semibold">
{phase === "running" && `Deploying ${stackId}`}
{phase === "success" && `Deployed ${stackId}`}
{phase === "failed" && `Deploy of ${stackId} failed`}
{phase === "error" && `Deploy of ${stackId} errored`}
</h2>
</div>
<div
ref={boxRef}
className="min-h-[200px] flex-1 overflow-auto bg-slate-950 p-3 font-mono text-xs leading-relaxed text-slate-200"
>
{lines.length === 0 && phase === "running" ? (
<span className="text-slate-500">Starting compose up</span>
) : (
lines.map((l, i) => (
<div key={i} className="whitespace-pre-wrap break-all">
{l}
</div>
))
)}
{errorDetail && <div className="mt-2 text-red-400">{errorDetail}</div>}
</div>
<div className="flex justify-end gap-2 border-t border-slate-200 px-5 py-3 dark:border-slate-700">
<Button variant={done ? "primary" : "outline"} onClick={onClose}>
{done ? "Close" : "Close (deploy continues)"}
</Button>
</div>
</div>
</div>
);
}
+19 -3
View File
@@ -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<PortConflict[] | null>(null);
const [checking, setChecking] = useState(false);
const [host, setHost] = useState("local");
const [deployId, setDeployId] = useState<string | null>(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 && (
<DeployConsole
stackId={deployId}
onClose={() => {
const sid = deployId;
setDeployId(null);
qc.invalidateQueries({ queryKey: ["stacks"] });
qc.invalidateQueries({ queryKey: ["stack", sid] });
navigate(`/stacks/${sid}`);
}}
/>
)}
</div>
);
}