Stream update progress into a bar on the stack's row (0.42.0)
CI / build-and-push (push) Successful in 1m55s
CI / build-and-push (push) Successful in 1m55s
Update ran as a blocking POST with nothing to show but a spinner, so the
status added in 86c67df could only sit above the table as a banner.
Adds /ws/update/{stack_id}, streaming `compose pull` then `up -d` with
--progress json, and feeds it through the existing DeployTracker — the
same weighting the deploy console uses. The result renders as a progress
bar inside the stack's own row: percentage, phase label, and layer/byte
detail. Non-streaming actions (start/stop/restart/pull/down) reuse the
bar in its indeterminate form, so every row action looks consistent.
compose_service gains _stream_phase, shared by stream_up and the new
stream_update; a failed pull short-circuits before `up`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5
This commit is contained in:
+68
-2
@@ -16,8 +16,14 @@ from sqlmodel import Session
|
||||
from auth import decode_token
|
||||
from database import engine
|
||||
from models.agent import Agent
|
||||
from models.setting import EVENT_STACK_ERROR, EVENT_STACK_START
|
||||
from services import audit_service, compose_service, exec_service, notify_service
|
||||
from models.setting import EVENT_PULL_FAILED, EVENT_STACK_ERROR, EVENT_STACK_START
|
||||
from services import (
|
||||
audit_service,
|
||||
compose_service,
|
||||
exec_service,
|
||||
notify_service,
|
||||
update_service,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("stackpilot.ws")
|
||||
|
||||
@@ -241,6 +247,66 @@ async def ws_agent_logs(
|
||||
await websocket.close()
|
||||
|
||||
|
||||
@router.websocket("/ws/update/{stack_id}")
|
||||
async def ws_update(
|
||||
websocket: WebSocket,
|
||||
stack_id: str,
|
||||
token: str | None = Query(default=None),
|
||||
):
|
||||
"""Run `docker compose pull && up -d` and stream its output, so the stacks
|
||||
list can render real update progress. Same audit/notify contract as the
|
||||
REST `/update` endpoint, which stays for non-interactive callers."""
|
||||
await websocket.accept()
|
||||
if not await _authorize_admin(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_update(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; compose keeps running so the update finishes.
|
||||
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.update", target=stack_id,
|
||||
detail=f"rc={rc} (update stream)", ip="ws",
|
||||
)
|
||||
if ok:
|
||||
await notify_service.notify(
|
||||
EVENT_STACK_START, f"Stack '{stack_id}' updated",
|
||||
"compose pull + up completed successfully.", session,
|
||||
)
|
||||
else:
|
||||
await notify_service.notify(
|
||||
EVENT_PULL_FAILED, f"Stack '{stack_id}' update failed",
|
||||
"compose pull/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()
|
||||
|
||||
if ok:
|
||||
update_service.refresh_stack_local(stack_id)
|
||||
|
||||
|
||||
@router.websocket("/ws/agent-deploy/{agent_id}/{stack_id}")
|
||||
async def ws_agent_deploy(
|
||||
websocket: WebSocket,
|
||||
|
||||
@@ -387,17 +387,16 @@ async def supports_json_progress() -> bool:
|
||||
return _json_progress
|
||||
|
||||
|
||||
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)``.
|
||||
"""
|
||||
async def _stream_phase(
|
||||
stack_id: str, args: list[str], override: Optional[str], json_progress: bool
|
||||
):
|
||||
"""One compose subcommand, streamed. Yields ``("log", line)`` per output
|
||||
line, then ``("rc", returncode)`` exactly once."""
|
||||
cmd = _compose_base_cmd(stack_id, override)
|
||||
if await supports_json_progress():
|
||||
if json_progress:
|
||||
# Global flag, must precede the subcommand.
|
||||
cmd += ["--progress", "json"]
|
||||
cmd += ["up", "-d", "--remove-orphans"]
|
||||
cmd += args
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
@@ -407,7 +406,41 @@ async def stream_up(stack_id: str, override: Optional[str] = None):
|
||||
async for raw in proc.stdout:
|
||||
yield ("log", raw.decode("utf-8", "replace").rstrip("\n"))
|
||||
await proc.wait()
|
||||
yield ("done", proc.returncode)
|
||||
yield ("rc", proc.returncode)
|
||||
|
||||
|
||||
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)``.
|
||||
"""
|
||||
json_progress = await supports_json_progress()
|
||||
async for kind, payload in _stream_phase(
|
||||
stack_id, ["up", "-d", "--remove-orphans"], override, json_progress
|
||||
):
|
||||
yield ("done", payload) if kind == "rc" else ("log", payload)
|
||||
|
||||
|
||||
async def stream_update(stack_id: str, override: Optional[str] = None):
|
||||
"""Run `compose pull` then `compose up -d`, streaming both phases, so the
|
||||
stacks list can show real update progress instead of a spinner.
|
||||
|
||||
Yields ``("log", line)`` for each output line of either phase, then
|
||||
``("done", returncode)`` once. A failed pull short-circuits: recreating
|
||||
containers on images that never came down would only make things worse.
|
||||
"""
|
||||
json_progress = await supports_json_progress()
|
||||
rc = 0
|
||||
for args in (["pull"], ["up", "-d", "--remove-orphans"]):
|
||||
async for kind, payload in _stream_phase(stack_id, args, override, json_progress):
|
||||
if kind == "log":
|
||||
yield ("log", payload)
|
||||
else:
|
||||
rc = payload
|
||||
if rc != 0:
|
||||
break
|
||||
yield ("done", rc)
|
||||
|
||||
|
||||
# Convenience lifecycle wrappers ------------------------------------------------
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the StackPilot release version."""
|
||||
|
||||
APP_VERSION = "0.41.0"
|
||||
APP_VERSION = "0.42.0"
|
||||
|
||||
Reference in New Issue
Block a user