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:
co-authored by
Claude Opus 4.8
parent
5b59f5e8f9
commit
7592085ce9
@@ -23,6 +23,9 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
|||||||
- **Live status** — running / partial / stopped / error / updating, computed from
|
- **Live status** — running / partial / stopped / error / updating, computed from
|
||||||
Docker container labels.
|
Docker container labels.
|
||||||
- **Real-time logs** — streamed over WebSocket, color-coded per service.
|
- **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` →
|
- **Monaco editor** — YAML editing with an `.env` tab and a **`docker run` →
|
||||||
compose** converter.
|
compose** converter.
|
||||||
- **Dashboard** — system resource bar, stack grid with quick actions, and a
|
- **Dashboard** — system resource bar, stack grid with quick actions, and a
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ def _map_docker(exc: DockerError):
|
|||||||
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
||||||
raise exc # falls through to the global 502 DockerError handler
|
raise exc # falls through to the global 502 DockerError handler
|
||||||
|
|
||||||
AGENT_VERSION = "0.21.6"
|
AGENT_VERSION = "0.22.0"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_task.cancel()
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
+60
-1
@@ -16,7 +16,8 @@ from sqlmodel import Session
|
|||||||
from auth import decode_token
|
from auth import decode_token
|
||||||
from database import engine
|
from database import engine
|
||||||
from models.agent import Agent
|
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")
|
logger = logging.getLogger("stackpilot.ws")
|
||||||
|
|
||||||
@@ -92,6 +93,64 @@ async def ws_service_logs(
|
|||||||
pass
|
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}")
|
@router.websocket("/ws/agent-logs/{agent_id}/{stack_id}")
|
||||||
async def ws_agent_logs(
|
async def ws_agent_logs(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
|
|||||||
@@ -329,6 +329,25 @@ async def stream_compose(
|
|||||||
await proc.wait()
|
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 ------------------------------------------------
|
# Convenience lifecycle wrappers ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.21.6",
|
"version": "0.22.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Button, Card, Input } from "@/components/ui";
|
|||||||
import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel";
|
import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel";
|
||||||
import { EnvEditor } from "@/components/env/EnvEditor";
|
import { EnvEditor } from "@/components/env/EnvEditor";
|
||||||
import { PortConflictDialog } from "@/components/stacks/PortConflictDialog";
|
import { PortConflictDialog } from "@/components/stacks/PortConflictDialog";
|
||||||
|
import { DeployConsole } from "@/components/stacks/DeployConsole";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
import { agentsApi } from "@/api/agents";
|
import { agentsApi } from "@/api/agents";
|
||||||
import { portsApi, type PortConflict } from "@/api/ports";
|
import { portsApi, type PortConflict } from "@/api/ports";
|
||||||
@@ -40,6 +41,7 @@ export function StackEditor() {
|
|||||||
const [conflicts, setConflicts] = useState<PortConflict[] | null>(null);
|
const [conflicts, setConflicts] = useState<PortConflict[] | null>(null);
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
const [host, setHost] = useState("local");
|
const [host, setHost] = useState("local");
|
||||||
|
const [deployId, setDeployId] = useState<string | null>(null);
|
||||||
|
|
||||||
const existing = useQuery({
|
const existing = useQuery({
|
||||||
queryKey: ["stack", id],
|
queryKey: ["stack", id],
|
||||||
@@ -97,9 +99,10 @@ export function StackEditor() {
|
|||||||
qc.invalidateQueries({ queryKey: ["stack", stackId] });
|
qc.invalidateQueries({ queryKey: ["stack", stackId] });
|
||||||
toast.success("Saved");
|
toast.success("Saved");
|
||||||
if (deploy && stackId) {
|
if (deploy && stackId) {
|
||||||
const t = toast.loading("Deploying…");
|
// Stream the deploy live in the console modal instead of a blind
|
||||||
await stacksApi.start(stackId);
|
// spinner; navigation happens when the user closes it.
|
||||||
toast.success("Deployed ✓", { id: t });
|
setDeployId(stackId);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
navigate(`/stacks/${stackId}`);
|
navigate(`/stacks/${stackId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -244,6 +247,19 @@ export function StackEditor() {
|
|||||||
onCancel={() => setConflicts(null)}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user