Remote stack logs: surface proxy/agent errors instead of silent disconnect (0.13.1)
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 <url>" 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
25bba1cf2c
commit
5ebd615651
@@ -40,7 +40,7 @@ from services import backup_service, compose_service
|
|||||||
|
|
||||||
logger = logging.getLogger("stackpilot.agent")
|
logger = logging.getLogger("stackpilot.agent")
|
||||||
|
|
||||||
AGENT_VERSION = "0.13.0"
|
AGENT_VERSION = "0.13.1"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_task.cancel()
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
+35
-3
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ from database import engine
|
|||||||
from models.agent import Agent
|
from models.agent import Agent
|
||||||
from services import compose_service
|
from services import compose_service
|
||||||
|
|
||||||
|
logger = logging.getLogger("stackpilot.ws")
|
||||||
|
|
||||||
router = APIRouter(tags=["ws"])
|
router = APIRouter(tags=["ws"])
|
||||||
|
|
||||||
|
|
||||||
@@ -113,18 +116,47 @@ async def ws_agent_logs(
|
|||||||
else "ws://" + base)
|
else "ws://" + base)
|
||||||
ws_url += f"/agent/ws/logs/{stack_id}?token={agent.token}"
|
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:
|
||||||
|
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:
|
try:
|
||||||
async with websockets.connect(ws_url, open_timeout=10, ping_interval=20) as upstream:
|
|
||||||
async for message in upstream:
|
async for message in upstream:
|
||||||
await websocket.send_text(
|
await websocket.send_text(
|
||||||
message if isinstance(message, str) else message.decode("utf-8", "replace")
|
message if isinstance(message, str) else message.decode("utf-8", "replace")
|
||||||
)
|
)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
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
|
except Exception as exc: # noqa: BLE001
|
||||||
with contextlib.suppress(Exception):
|
logger.warning("Agent log proxy: stream error from %s: %s", agent.name, exc)
|
||||||
await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)}))
|
await _err(str(exc))
|
||||||
finally:
|
finally:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await upstream.close()
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await websocket.close()
|
await websocket.close()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.13.0",
|
"version": "0.13.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -25,11 +25,14 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num
|
|||||||
const [lines, setLines] = useState<{ service: string | null; line: string }[]>([]);
|
const [lines, setLines] = useState<{ service: string | null; line: string }[]>([]);
|
||||||
const [autoScroll, setAutoScroll] = useState(true);
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const token = useAuthStore((s) => s.accessToken);
|
const token = useAuthStore((s) => s.accessToken);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
setError(null);
|
||||||
|
let gotError = false;
|
||||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
const path =
|
const path =
|
||||||
agentId != null
|
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 url = `${proto}://${window.location.host}${path}?token=${token}`;
|
||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
ws.onopen = () => setConnected(true);
|
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) => {
|
ws.onmessage = (ev) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(ev.data);
|
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 }];
|
const next = [...prev, { service: msg.service, line: msg.line }];
|
||||||
return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next;
|
return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next;
|
||||||
});
|
});
|
||||||
|
} else if (msg.type === "error") {
|
||||||
|
gotError = true;
|
||||||
|
setError(msg.detail || "Log stream error");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -96,7 +108,12 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num
|
|||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="flex-1 overflow-auto rounded-lg bg-slate-950 p-3 font-mono text-xs leading-relaxed"
|
className="flex-1 overflow-auto rounded-lg bg-slate-950 p-3 font-mono text-xs leading-relaxed"
|
||||||
>
|
>
|
||||||
{lines.length === 0 && (
|
{error && (
|
||||||
|
<div className="mb-1 whitespace-pre-wrap break-words text-rose-400">
|
||||||
|
⚠ {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lines.length === 0 && !error && (
|
||||||
<div className="text-slate-500">Waiting for log output…</div>
|
<div className="text-slate-500">Waiting for log output…</div>
|
||||||
)}
|
)}
|
||||||
{lines.map((l, i) => (
|
{lines.map((l, i) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user