Skip to content

Commit bbe2d1e

Browse files
authored
chore: polish mcp logs UX (#2533)
1 parent ff9a5dc commit bbe2d1e

File tree

6 files changed

+126
-14
lines changed

6 files changed

+126
-14
lines changed

platform/backend/src/mcp-server-runtime/k8s-deployment.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,9 +1415,11 @@ export default class K8sDeployment {
14151415
"CrashLoopBackOff",
14161416
"ImagePullBackOff",
14171417
"ErrImagePull",
1418+
"ErrImageNeverPull",
14181419
"CreateContainerConfigError",
14191420
"CreateContainerError",
14201421
"RunContainerError",
1422+
"InvalidImageName",
14211423
];
14221424
if (failureStates.includes(waitingReason)) {
14231425
const message =
@@ -1522,10 +1524,14 @@ export default class K8sDeployment {
15221524
/**
15231525
* Stream logs from the pod with follow enabled.
15241526
* If no running pod is found, falls back to showing K8s events.
1527+
* @param responseStream - The stream to write logs to
1528+
* @param lines - Number of initial lines to fetch
1529+
* @param abortSignal - Optional abort signal to cancel the stream
15251530
*/
15261531
async streamLogs(
15271532
responseStream: NodeJS.WritableStream,
15281533
lines: number = 100,
1534+
abortSignal?: AbortSignal,
15291535
): Promise<void> {
15301536
try {
15311537
const pod = await this.findPodForDeployment();
@@ -1579,12 +1585,6 @@ export default class K8sDeployment {
15791585
}
15801586
});
15811587

1582-
responseStream.on("close", () => {
1583-
if (logStream.destroy) {
1584-
logStream.destroy();
1585-
}
1586-
});
1587-
15881588
// Use the Log client to stream logs with follow=true
15891589
const req = await this.k8sLog.log(
15901590
this.namespace,
@@ -1599,11 +1599,45 @@ export default class K8sDeployment {
15991599
},
16001600
);
16011601

1602+
// Track abort handler for cleanup
1603+
let abortHandler: (() => void) | null = null;
1604+
1605+
// Handle abort signal
1606+
if (abortSignal) {
1607+
abortHandler = () => {
1608+
if (req) {
1609+
req.abort();
1610+
}
1611+
logStream.destroy();
1612+
if (!("destroyed" in responseStream) || !responseStream.destroyed) {
1613+
responseStream.end();
1614+
}
1615+
};
1616+
1617+
if (abortSignal.aborted) {
1618+
abortHandler();
1619+
return;
1620+
}
1621+
1622+
abortSignal.addEventListener("abort", abortHandler, { once: true });
1623+
}
1624+
1625+
// Cleanup function to remove abort listener
1626+
const cleanupAbortListener = () => {
1627+
if (abortSignal && abortHandler) {
1628+
abortSignal.removeEventListener("abort", abortHandler);
1629+
}
1630+
};
1631+
16021632
// Handle cleanup when response stream closes
16031633
responseStream.on("close", () => {
16041634
if (req) {
16051635
req.abort();
16061636
}
1637+
if (logStream.destroy) {
1638+
logStream.destroy();
1639+
}
1640+
cleanupAbortListener();
16071641
});
16081642
} catch (error: unknown) {
16091643
logger.error(

platform/backend/src/mcp-server-runtime/manager.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,18 +549,23 @@ export class McpServerRuntimeManager {
549549

550550
/**
551551
* Stream logs from an MCP server deployment with follow enabled
552+
* @param mcpServerId - The MCP server ID
553+
* @param responseStream - The stream to write logs to
554+
* @param lines - Number of initial lines to fetch
555+
* @param abortSignal - Optional abort signal to cancel the stream
552556
*/
553557
async streamMcpServerLogs(
554558
mcpServerId: string,
555559
responseStream: NodeJS.WritableStream,
556560
lines: number = 100,
561+
abortSignal?: AbortSignal,
557562
): Promise<void> {
558563
const k8sDeployment = this.mcpServerIdToDeploymentMap.get(mcpServerId);
559564
if (!k8sDeployment) {
560565
throw new Error(`Deployment not found for MCP server ${mcpServerId}`);
561566
}
562567

563-
await k8sDeployment.streamLogs(responseStream, lines);
568+
await k8sDeployment.streamLogs(responseStream, lines, abortSignal);
564569
}
565570

566571
/**

platform/backend/src/websocket.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type ClientWebSocketMessage,
66
ClientWebSocketMessageSchema,
77
type ClientWebSocketMessageType,
8+
MCP_DEFAULT_LOG_LINES,
89
type ServerWebSocketMessage,
910
} from "@shared";
1011
import type { WebSocket, WebSocketServer } from "ws";
@@ -136,7 +137,7 @@ class WebSocketService {
136137
return this.handleSubscribeMcpLogs(
137138
ws,
138139
message.payload.serverId,
139-
message.payload.lines ?? 500,
140+
message.payload.lines ?? MCP_DEFAULT_LOG_LINES,
140141
clientContext,
141142
);
142143
},
@@ -739,6 +740,7 @@ class WebSocketService {
739740
serverId,
740741
stream,
741742
lines,
743+
abortController.signal,
742744
);
743745
} catch (error) {
744746
logger.error({ error, serverId }, "Failed to start MCP logs stream");

platform/frontend/src/app/mcp-catalog/_parts/installation-progress.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,40 @@ interface InstallationProgressProps {
2222
const PHASES = {
2323
pending: {
2424
progress: 33,
25-
description: "Starting container...",
25+
description: "Starting container",
2626
},
2727
"discovering-tools": {
2828
progress: 66,
29-
description: "Discovering tools...",
29+
description: "Discovering tools",
3030
},
3131
success: {
3232
progress: 100,
3333
description: "Installation complete",
3434
},
3535
} as const;
3636

37+
/**
38+
* Hook that returns animated dots that cycle through ".", "..", "..."
39+
*/
40+
function useAnimatedDots(isActive: boolean) {
41+
const [dotCount, setDotCount] = useState(1);
42+
43+
useEffect(() => {
44+
if (!isActive) {
45+
setDotCount(1);
46+
return;
47+
}
48+
49+
const interval = setInterval(() => {
50+
setDotCount((prev) => (prev % 3) + 1);
51+
}, 400);
52+
53+
return () => clearInterval(interval);
54+
}, [isActive]);
55+
56+
return ".".repeat(dotCount);
57+
}
58+
3759
/**
3860
* Hook that returns animated progress value that smoothly increments
3961
*/
@@ -83,6 +105,16 @@ export function InstallationProgress({
83105
const isActive = status === "pending" || status === "discovering-tools";
84106
const targetProgress = phaseInfo?.progress ?? 0;
85107
const animatedProgress = useAnimatedProgress(targetProgress, isActive);
108+
const animatedDots = useAnimatedDots(isActive);
109+
110+
// Build description with animated dots for active phases
111+
const description = useMemo(() => {
112+
if (!phaseInfo) return "";
113+
if (isActive) {
114+
return `${phaseInfo.description}${animatedDots}`;
115+
}
116+
return phaseInfo.description;
117+
}, [phaseInfo, isActive, animatedDots]);
86118

87119
if (!status || status === "idle" || status === "success") {
88120
return null;
@@ -96,7 +128,7 @@ export function InstallationProgress({
96128
<div className="w-full space-y-2">
97129
<Progress value={animatedProgress} />
98130
<div className="flex items-center justify-between text-xs">
99-
<span className="text-muted-foreground">{phaseInfo?.description}</span>
131+
<span className="text-muted-foreground">{description}</span>
100132
{serverId && (
101133
<Button
102134
variant="link"

platform/frontend/src/app/mcp-catalog/_parts/mcp-logs-dialog.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
"use client";
22

3-
import type { McpLogsErrorMessage, McpLogsMessage } from "@shared";
3+
import {
4+
MCP_DEFAULT_LOG_LINES,
5+
type McpLogsErrorMessage,
6+
type McpLogsMessage,
7+
} from "@shared";
48
import { ArrowDown, Copy, Terminal } from "lucide-react";
59
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
610
import { toast } from "sonner";
@@ -76,6 +80,9 @@ export function McpLogsDialog({
7680
const [isStreaming, setIsStreaming] = useState(false);
7781
const unsubscribeLogsRef = useRef<(() => void) | null>(null);
7882
const unsubscribeErrorRef = useRef<(() => void) | null>(null);
83+
const connectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
84+
null,
85+
);
7986
const scrollAreaRef = useRef<HTMLDivElement>(null);
8087
const currentServerIdRef = useRef<string | null>(null);
8188

@@ -94,6 +101,12 @@ export function McpLogsDialog({
94101
const streamingText = useStreamingAnimation(isWaitingForLogs);
95102

96103
const stopStreaming = useCallback(() => {
104+
// Clear connection timeout
105+
if (connectionTimeoutRef.current) {
106+
clearTimeout(connectionTimeoutRef.current);
107+
connectionTimeoutRef.current = null;
108+
}
109+
97110
// Unsubscribe from WebSocket messages
98111
if (unsubscribeLogsRef.current) {
99112
unsubscribeLogsRef.current();
@@ -130,12 +143,27 @@ export function McpLogsDialog({
130143
// Connect to WebSocket if not already connected
131144
websocketService.connect();
132145

146+
// Set up connection timeout - if no logs received within 10 seconds, show error
147+
connectionTimeoutRef.current = setTimeout(() => {
148+
// Only trigger timeout if we're still streaming and haven't received any logs
149+
if (currentServerIdRef.current === targetServerId) {
150+
setStreamError("Connection timeout - unable to connect to server");
151+
setIsStreaming(false);
152+
}
153+
}, 10000);
154+
133155
// Subscribe to log messages for this server
134156
unsubscribeLogsRef.current = websocketService.subscribe(
135157
"mcp_logs",
136158
(message: McpLogsMessage) => {
137159
if (message.payload.serverId !== targetServerId) return;
138160

161+
// Clear connection timeout on first message
162+
if (connectionTimeoutRef.current) {
163+
clearTimeout(connectionTimeoutRef.current);
164+
connectionTimeoutRef.current = null;
165+
}
166+
139167
// Capture the command from the first message
140168
if (message.payload.command) {
141169
setCommand(message.payload.command);
@@ -169,6 +197,12 @@ export function McpLogsDialog({
169197
(message: McpLogsErrorMessage) => {
170198
if (message.payload.serverId !== targetServerId) return;
171199

200+
// Clear connection timeout on error
201+
if (connectionTimeoutRef.current) {
202+
clearTimeout(connectionTimeoutRef.current);
203+
connectionTimeoutRef.current = null;
204+
}
205+
172206
setStreamError(message.payload.error);
173207
toast.error(`Streaming failed: ${message.payload.error}`);
174208
setIsStreaming(false);
@@ -178,7 +212,7 @@ export function McpLogsDialog({
178212
// Send subscribe message to server
179213
websocketService.send({
180214
type: "subscribe_mcp_logs",
181-
payload: { serverId: targetServerId, lines: 500 },
215+
payload: { serverId: targetServerId, lines: MCP_DEFAULT_LOG_LINES },
182216
});
183217
},
184218
[autoScroll, stopStreaming],

platform/shared/websocket.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { z } from "zod";
22

3+
/**
4+
* MCP Logs defaults
5+
*/
6+
export const MCP_DEFAULT_LOG_LINES = 500;
7+
38
/**
49
* WebSocket Message Payload Schemas (Client -> Server)
510
*/
@@ -55,7 +60,7 @@ const BrowserSetZoomPayloadSchema = z.object({
5560
// MCP Server Logs payloads
5661
const SubscribeMcpLogsPayloadSchema = z.object({
5762
serverId: z.string().uuid(),
58-
lines: z.number().int().min(1).max(10000).default(500), // Number of initial lines to fetch
63+
lines: z.number().int().min(1).max(10000).default(MCP_DEFAULT_LOG_LINES), // Number of initial lines to fetch
5964
});
6065

6166
const UnsubscribeMcpLogsPayloadSchema = z.object({

0 commit comments

Comments
 (0)