From 5ebd6156517f77774d02090f5b4df603307097b9 Mon Sep 17 00:00:00 2001 From: menzelj Date: Mon, 8 Jun 2026 11:14:58 +0000 Subject: [PATCH] Remote stack logs: surface proxy/agent errors instead of silent disconnect (0.13.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote-stack log streaming showed only "disconnected, 0 lines" whenever the agent log proxy failed, because the LogViewer ignored type:"error" messages and the proxy swallowed connection errors. - ws.py: the agent-logs proxy now reports a clear, logged reason on failure — distinguishes "cannot reach agent " from a handshake rejection (HTTP 404 hints the agent is outdated and lacks live-log support) and forwards abnormal upstream close codes (e.g. 4401 bad agent token). - LogViewer: renders type:"error" messages (red) and surfaces a 4401 close as an authorization error, instead of silently showing "Waiting for log output…". Co-Authored-By: Claude Opus 4.8 --- backend/agent_app.py | 2 +- backend/main.py | 2 +- backend/routers/ws.py | 46 +++++++++++++++++--- frontend/package.json | 2 +- frontend/src/components/stacks/LogViewer.tsx | 21 ++++++++- 5 files changed, 61 insertions(+), 12 deletions(-) diff --git a/backend/agent_app.py b/backend/agent_app.py index c721278..14f99d6 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -40,7 +40,7 @@ from services import backup_service, compose_service logger = logging.getLogger("stackpilot.agent") -AGENT_VERSION = "0.13.0" +AGENT_VERSION = "0.13.1" # --------------------------------------------------------------------------- # diff --git a/backend/main.py b/backend/main.py index 173ea89..11b87af 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.13.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.13.1", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/backend/routers/ws.py b/backend/routers/ws.py index d661b61..f1c9aef 100644 --- a/backend/routers/ws.py +++ b/backend/routers/ws.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import json +import logging import contextlib @@ -16,6 +17,8 @@ from database import engine from models.agent import Agent from services import compose_service +logger = logging.getLogger("stackpilot.ws") + router = APIRouter(tags=["ws"]) @@ -113,18 +116,47 @@ async def ws_agent_logs( else "ws://" + base) ws_url += f"/agent/ws/logs/{stack_id}?token={agent.token}" + async def _err(detail: str) -> None: + with contextlib.suppress(Exception): + await websocket.send_text(json.dumps({"type": "error", "detail": detail})) + + # Connect to the agent. Surface connection problems (agent down, wrong URL, + # an outdated agent that lacks /agent/ws/logs, TLS issues) instead of + # silently dropping the socket. try: - async with websockets.connect(ws_url, open_timeout=10, ping_interval=20) as upstream: - async for message in upstream: - await websocket.send_text( - message if isinstance(message, str) else message.decode("utf-8", "replace") - ) + 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 live-log support; update it." if code == 404 else "" + logger.warning("Agent log proxy: handshake to %s failed (%s)", agent.name, code) + await _err(f"Agent '{agent.name}' rejected the log stream (HTTP {code}){hint}") + with contextlib.suppress(Exception): + await websocket.close() + return + except Exception as exc: # noqa: BLE001 + logger.warning("Agent log 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 + + try: + async for message in upstream: + await websocket.send_text( + message if isinstance(message, str) else message.decode("utf-8", "replace") + ) except WebSocketDisconnect: pass + except websockets.ConnectionClosed as exc: + # Abnormal upstream close (e.g. 4401 bad token, or agent-side error). + if exc.code not in (1000, 1001): + await _err(f"Agent log stream closed unexpectedly (code {exc.code}).") except Exception as exc: # noqa: BLE001 - with contextlib.suppress(Exception): - await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)})) + logger.warning("Agent log 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() diff --git a/frontend/package.json b/frontend/package.json index 2966efb..eee0039 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.13.0", + "version": "0.13.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/stacks/LogViewer.tsx b/frontend/src/components/stacks/LogViewer.tsx index 8f225da..99e7de4 100644 --- a/frontend/src/components/stacks/LogViewer.tsx +++ b/frontend/src/components/stacks/LogViewer.tsx @@ -25,11 +25,14 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num const [lines, setLines] = useState<{ service: string | null; line: string }[]>([]); const [autoScroll, setAutoScroll] = useState(true); const [connected, setConnected] = useState(false); + const [error, setError] = useState(null); const containerRef = useRef(null); const token = useAuthStore((s) => s.accessToken); useEffect(() => { if (!token) return; + setError(null); + let gotError = false; const proto = window.location.protocol === "https:" ? "wss" : "ws"; const path = agentId != null @@ -38,7 +41,13 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num const url = `${proto}://${window.location.host}${path}?token=${token}`; const ws = new WebSocket(url); ws.onopen = () => setConnected(true); - ws.onclose = () => setConnected(false); + ws.onclose = (ev) => { + setConnected(false); + // Auth rejection from the proxy/agent (JWT or agent token) closes 4401. + if (!gotError && ev.code === 4401) { + setError("Not authorized to stream logs (session or agent token)."); + } + }; ws.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); @@ -47,6 +56,9 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num const next = [...prev, { service: msg.service, line: msg.line }]; return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next; }); + } else if (msg.type === "error") { + gotError = true; + setError(msg.detail || "Log stream error"); } } catch { /* ignore */ @@ -96,7 +108,12 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num ref={containerRef} className="flex-1 overflow-auto rounded-lg bg-slate-950 p-3 font-mono text-xs leading-relaxed" > - {lines.length === 0 && ( + {error && ( +
+ ⚠ {error} +
+ )} + {lines.length === 0 && !error && (
Waiting for log output…
)} {lines.map((l, i) => (