diff --git a/frontend/src/pages/Dashboard/components/LogStreamViewer.tsx b/frontend/src/pages/Dashboard/components/LogStreamViewer.tsx new file mode 100644 index 00000000..1e4f3127 --- /dev/null +++ b/frontend/src/pages/Dashboard/components/LogStreamViewer.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useRef } from 'react'; +import { Skeleton } from "@/components/ui/skeleton"; +import { LogStreamStatus, type LogLine } from '../types/logStream'; + +interface LogStreamViewerProps { + logs: LogLine[]; + status: LogStreamStatus; + error: string | null; + autoScroll?: boolean; +} + +export const LogStreamViewer: React.FC = ({ + logs, + status, + error, + autoScroll = true, +}) => { + const scrollRef = useRef(null); + const isAutoScrollRef = useRef(autoScroll); + + // 更新自动滚动状态 + useEffect(() => { + isAutoScrollRef.current = autoScroll; + }, [autoScroll]); + + // 自动滚动到底部 + useEffect(() => { + if (isAutoScrollRef.current && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [logs]); + + // 渲染加载状态 + if (status === LogStreamStatus.CONNECTING) { + return ( +
+ + + +
+ ); + } + + // 渲染错误状态 + if (error) { + const isDevelopment = import.meta.env.DEV; + const isConnectionError = error.includes('无法连接') || error.includes('连接关闭'); + + return ( +
+
+

{error}

+ {isConnectionError && isDevelopment && ( +
+

开发提示:

+
    +
  • 确认后端WebSocket服务已启动
  • +
  • 检查端点: /api/v1/team-applications/{'{teamAppId}'}/logs/stream
  • +
  • 验证Token是否有效
  • +
  • 查看浏览器控制台获取详细错误信息
  • +
+
+ )} + {!isDevelopment && ( +

请检查网络连接或联系管理员

+ )} +
+
+ ); + } + + // 渲染空状态 + if (logs.length === 0) { + let message = '暂无日志'; + if (status === LogStreamStatus.CONNECTED) { + message = '已连接,等待启动日志流...'; + } else if (status === LogStreamStatus.STREAMING) { + message = '等待日志数据...'; + } else if (status === LogStreamStatus.PAUSED) { + message = '日志流已暂停'; + } else if (status === LogStreamStatus.STOPPED) { + message = '日志流已停止'; + } + + return ( +
+
+

{message}

+ {status === LogStreamStatus.CONNECTED && ( +

点击"启动"按钮开始查看日志

+ )} +
+
+ ); + } + + // 渲染日志内容 + return ( +
+ {logs.map((log) => ( +
+ [{log.formattedTime}] + {log.content} +
+ ))} +
+ ); +}; diff --git a/frontend/src/pages/Dashboard/components/LogViewerDialog.tsx b/frontend/src/pages/Dashboard/components/LogViewerDialog.tsx index d697b56f..31f1933c 100644 --- a/frontend/src/pages/Dashboard/components/LogViewerDialog.tsx +++ b/frontend/src/pages/Dashboard/components/LogViewerDialog.tsx @@ -1,15 +1,38 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Dialog, DialogContent, - DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Box, Container, Server } from "lucide-react"; -import LogViewer from '@/components/LogViewer'; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Box, + Container, + Server, + Play, + Pause, + Square, + Trash2, + Circle, + Loader2, + ScrollText, +} from "lucide-react"; +import { LogStreamViewer } from './LogStreamViewer'; +import { useLogStream } from '../hooks/useLogStream'; +import { LogStreamStatus } from '../types/logStream'; import type { ApplicationConfig, DeployEnvironment } from '../types'; +import request from '@/utils/request'; interface LogViewerDialogProps { open: boolean; @@ -24,8 +47,80 @@ export const LogViewerDialog: React.FC = ({ app, environment, }) => { - const [loading, setLoading] = useState(false); - const [logContent, setLogContent] = useState(''); + const [lines, setLines] = useState(100); + const [podName, setPodName] = useState(''); + const [podNames, setPodNames] = useState([]); + const [loadingPods, setLoadingPods] = useState(false); + + // 使用WebSocket日志流Hook + const { + status, + logs, + error, + connect, + disconnect, + start, + pause, + resume, + stop, + clearLogs, + } = useLogStream({ + teamAppId: app.teamApplicationId, + autoConnect: false, + maxLines: 10000, + }); + + // 获取K8S Pod列表 + useEffect(() => { + if (open && app.runtimeType === 'K8S') { + setLoadingPods(true); + request.get(`/api/v1/team-applications/${app.teamApplicationId}/pod-names`) + .then((response) => { + if (response && response.length > 0) { + setPodNames(response); + setPodName(response[0]); + } + }) + .catch((error) => { + console.error('[LogViewer] Failed to fetch pod names:', error); + }) + .finally(() => { + setLoadingPods(false); + }); + } + }, [open, app.runtimeType, app.teamApplicationId]); + + // 对话框打开时连接 + useEffect(() => { + if (open) { + connect(); + } + + return () => { + disconnect(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // 连接成功后自动启动日志流 + useEffect(() => { + if (status === LogStreamStatus.CONNECTED) { + if (app.runtimeType === 'K8S' && loadingPods) { + return; + } + + const timer = setTimeout(() => { + const startParams: any = { lines }; + if (app.runtimeType === 'K8S' && podName) { + startParams.name = podName; + } + start(startParams); + }, 100); + + return () => clearTimeout(timer); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status, loadingPods, podName]); const getRuntimeIcon = () => { switch (app.runtimeType) { @@ -43,106 +138,185 @@ export const LogViewerDialog: React.FC = ({ const runtimeConfig = getRuntimeIcon(); const RuntimeIcon = runtimeConfig.icon; - const getRuntimeInfo = () => { - switch (app.runtimeType) { - case 'K8S': - return ( -
-
- 系统: - {app.k8sSystemName || '-'} -
-
- 命名空间: - {app.k8sNamespaceName || '-'} -
-
- 部署: - {app.k8sDeploymentName || '-'} -
-
- ); - case 'DOCKER': - return ( -
-
- 服务器: - {app.dockerServerName || '-'} -
-
- 容器: - {app.dockerContainerName || '-'} -
-
- ); - case 'SERVER': - return ( -
-
- 服务器: - {app.serverName || '-'} -
-
- 查询命令: - - {app.logQueryCommand || '-'} - -
-
- ); + const getStatusIndicator = () => { + switch (status) { + case LogStreamStatus.CONNECTING: + return { color: 'text-yellow-500', label: '连接中' }; + case LogStreamStatus.CONNECTED: + return { color: 'text-blue-500', label: '已连接' }; + case LogStreamStatus.STREAMING: + return { color: 'text-green-500', label: '流式传输中' }; + case LogStreamStatus.PAUSED: + return { color: 'text-orange-500', label: '已暂停' }; + case LogStreamStatus.STOPPED: + return { color: 'text-gray-500', label: '已停止' }; + case LogStreamStatus.ERROR: + return { color: 'text-red-500', label: '错误' }; default: - return
未配置运行时信息
; + return { color: 'text-gray-500', label: '未连接' }; } }; - const handleRefreshLogs = async () => { - setLoading(true); - // TODO: 调用实际的日志API - setTimeout(() => { - setLogContent( - '[2024-12-16 10:30:45] INFO Application started successfully\n' + - '[2024-12-16 10:30:46] INFO Server listening on port 8080\n' + - '[2024-12-16 10:31:00] DEBUG Database connection established\n' + - '[2024-12-16 10:31:15] INFO Processing request: GET /api/users\n' + - '[2024-12-16 10:31:16] WARN Slow query detected: 1.2s\n' - ); - setLoading(false); - }, 1000); + const statusIndicator = getStatusIndicator(); + + const handleRestart = () => { + clearLogs(); + + // 如果已断开连接,需要重新建立连接 + if (status === LogStreamStatus.DISCONNECTED || status === LogStreamStatus.ERROR) { + connect(); // 连接成功后会自动触发START(通过useEffect) + } else { + // 如果已连接,直接发送START消息 + const startParams: any = { lines }; + if (app.runtimeType === 'K8S' && podName) { + startParams.name = podName; + } + start(startParams); + } }; return ( - - - - - {app.applicationName} - 日志查看 - - - 环境: {environment.environmentName} - - - -
-
-
+ + + +
+ + {app.applicationName} - 日志查看 +
+
{runtimeConfig.label} + + {environment.environmentName} +
+
+
+ +
+ {/* 控制面板 */} +
+
+
+ + setLines(Number(e.target.value))} + min={10} + max={1000} + className="h-8 mt-1" + /> +
+ + {app.runtimeType === 'K8S' && ( +
+ + {loadingPods ? ( +
+ +
+ ) : podNames.length > 0 ? ( + + ) : ( + setPodName(e.target.value)} + placeholder="无可用Pod" + className="h-8 mt-1" + disabled + /> + )} +
+ )} + +
+ +
+ {/* 启动/恢复/重试按钮 */} + {(status === LogStreamStatus.DISCONNECTED || + status === LogStreamStatus.CONNECTED || + status === LogStreamStatus.ERROR) && ( + + )} + + {/* 连接中按钮 */} + {status === LogStreamStatus.CONNECTING && ( + + )} + + {/* 恢复按钮(暂停状态) */} + {status === LogStreamStatus.PAUSED && ( + + )} + + {/* 暂停按钮(流式传输中) */} + {status === LogStreamStatus.STREAMING && ( + + )} + + {/* 停止按钮 */} + {(status === LogStreamStatus.CONNECTING || + status === LogStreamStatus.STREAMING || + status === LogStreamStatus.PAUSED) && ( + + )} + + {/* 清空按钮 */} + +
- {getRuntimeInfo()}
-
- {}} - height="100%" - theme="vs-dark" - showLineNumbers={true} + {/* 日志显示区域 */} +
+
diff --git a/frontend/src/pages/Dashboard/components/RuntimeTabContent.tsx b/frontend/src/pages/Dashboard/components/RuntimeTabContent.tsx index e49ef2e9..8271e298 100644 --- a/frontend/src/pages/Dashboard/components/RuntimeTabContent.tsx +++ b/frontend/src/pages/Dashboard/components/RuntimeTabContent.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Button } from "@/components/ui/button"; -import { ScrollText, AlertCircle } from "lucide-react"; +import { ScrollText, AlertCircle, Loader2 } from "lucide-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import type { ApplicationConfig } from '../types'; import { K8sRuntimeStatus } from './K8sRuntimeStatus'; @@ -70,14 +70,36 @@ export const RuntimeTabContent: React.FC = ({ {/* 操作区 - 固定在底部 */}
{hasRuntimeConfig ? ( - + app.isDeploying ? ( + + + +
+ +
+
+ +

应用正在部署中,请等待部署完成后查看日志

+
+
+
+ ) : ( + + ) ) : ( diff --git a/frontend/src/pages/Dashboard/hooks/useLogStream.ts b/frontend/src/pages/Dashboard/hooks/useLogStream.ts new file mode 100644 index 00000000..1e9652db --- /dev/null +++ b/frontend/src/pages/Dashboard/hooks/useLogStream.ts @@ -0,0 +1,282 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { + LogMessageType, + LogStreamStatus, + LogControlAction, + type LogLine, + type LogLineData, + type LogStatusData, + type LogErrorData, + type LogStartRequest, +} from '../types/logStream'; +import { + buildLogStreamUrl, + parseLogMessage, + createStartMessage, + createControlMessage, + formatLogTimestamp, +} from '../utils/websocket'; + +interface UseLogStreamOptions { + teamAppId: number; + autoConnect?: boolean; + maxLines?: number; + maxReconnectAttempts?: number; + reconnectInterval?: number; +} + +interface UseLogStreamReturn { + status: LogStreamStatus; + logs: LogLine[]; + error: string | null; + connect: () => void; + disconnect: () => void; + start: (params?: LogStartRequest) => void; + pause: () => void; + resume: () => void; + stop: () => void; + clearLogs: () => void; +} + +export function useLogStream(options: UseLogStreamOptions): UseLogStreamReturn { + const { + teamAppId, + autoConnect = false, + maxLines = 10000, + maxReconnectAttempts = 5, + reconnectInterval = 3000, + } = options; + + const [status, setStatus] = useState(LogStreamStatus.DISCONNECTED); + const [logs, setLogs] = useState([]); + const [error, setError] = useState(null); + + const wsRef = useRef(null); + const logIdCounterRef = useRef(0); + const reconnectAttemptsRef = useRef(0); + const reconnectTimerRef = useRef(null); + const hasConnectedRef = useRef(false); // 跟踪是否曾经成功连接 + const isManualDisconnectRef = useRef(false); // 跟踪是否为用户主动断开 + + // 连接WebSocket + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + return; + } + + // 清除重连定时器 + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + + setStatus(LogStreamStatus.CONNECTING); + setError(null); + reconnectAttemptsRef.current = 0; + hasConnectedRef.current = false; // 重置连接标记 + isManualDisconnectRef.current = false; // 重置主动断开标记 + + const url = buildLogStreamUrl(teamAppId); + console.log('[LogStream] Connecting to:', url); + + try { + const ws = new WebSocket(url); + + ws.onopen = () => { + console.log('[LogStream] WebSocket connected successfully'); + setStatus(LogStreamStatus.CONNECTED); + reconnectAttemptsRef.current = 0; + hasConnectedRef.current = true; // 标记已成功连接 + }; + + ws.onmessage = (event) => { + console.log('[LogStream] Received message:', event.data); + const message = parseLogMessage(event.data); + if (!message) return; + + switch (message.type) { + case LogMessageType.STATUS: { + const statusData = message.data.response as LogStatusData; + setStatus(statusData.status); + break; + } + + case LogMessageType.LOG: { + const logData = message.data.response as LogLineData; + const logLine: LogLine = { + id: `log-${logIdCounterRef.current++}`, + timestamp: logData.timestamp, + content: logData.content, + formattedTime: formatLogTimestamp(logData.timestamp), + }; + + setLogs((prev) => { + const newLogs = [...prev, logLine]; + // 限制日志行数 + if (newLogs.length > maxLines) { + return newLogs.slice(newLogs.length - maxLines); + } + return newLogs; + }); + break; + } + + case LogMessageType.ERROR: { + const errorData = message.data.response as LogErrorData; + setError(errorData.error); + setStatus(LogStreamStatus.ERROR); + break; + } + } + }; + + ws.onerror = (event) => { + console.error('[LogStream] WebSocket error:', event); + console.error('[LogStream] WebSocket readyState:', ws.readyState); + console.error('[LogStream] WebSocket URL:', ws.url); + }; + + ws.onclose = (event) => { + console.log('[LogStream] WebSocket closed:', { + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + hadConnected: hasConnectedRef.current, + }); + + // 根据关闭码设置错误信息 + if (event.code === 1006) { + setError('无法连接到日志服务器,请检查后端服务是否启动'); + setStatus(LogStreamStatus.ERROR); + } else if (event.code === 1008) { + setError('认证失败,请重新登录'); + setStatus(LogStreamStatus.ERROR); + } else if (event.code !== 1000 && event.code !== 1001) { + setError(`连接关闭: ${event.reason || '未知原因'} (代码: ${event.code})`); + setStatus(LogStreamStatus.ERROR); + } + + // 只有在曾经成功连接过且非主动断开的情况下才尝试重连 + if (hasConnectedRef.current && !isManualDisconnectRef.current && event.code !== 1008) { + handleReconnect(); + } else { + setStatus(LogStreamStatus.DISCONNECTED); + hasConnectedRef.current = false; + } + }; + + wsRef.current = ws; + } catch (error) { + console.error('[LogStream] Failed to create WebSocket:', error); + setError(`创建WebSocket连接失败: ${error instanceof Error ? error.message : '未知错误'}`); + setStatus(LogStreamStatus.ERROR); + } + }, [teamAppId, maxLines]); // 移除status依赖,避免循环 + + // 处理重连 + const handleReconnect = useCallback(() => { + if (reconnectAttemptsRef.current >= maxReconnectAttempts) { + setError(`连接断开,已达到最大重连次数(${maxReconnectAttempts})`); + setStatus(LogStreamStatus.ERROR); + return; + } + + reconnectAttemptsRef.current++; + setStatus(LogStreamStatus.CONNECTING); + + reconnectTimerRef.current = setTimeout(() => { + console.log(`尝试重连 (${reconnectAttemptsRef.current}/${maxReconnectAttempts})`); + connect(); + }, reconnectInterval); + }, [maxReconnectAttempts, reconnectInterval, connect]); + + // 断开连接 + const disconnect = useCallback(() => { + // 标记为主动断开 + isManualDisconnectRef.current = true; + + // 清除重连定时器 + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + + reconnectAttemptsRef.current = 0; + hasConnectedRef.current = false; + setStatus(LogStreamStatus.DISCONNECTED); + }, []); + + // 发送消息 + const sendMessage = useCallback((message: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(message); + } else { + console.warn('WebSocket is not connected'); + } + }, []); + + // 启动日志流 + const start = useCallback((params?: LogStartRequest) => { + const message = createStartMessage(params || { lines: 100 }); + sendMessage(message); + }, [sendMessage]); + + // 暂停日志流 + const pause = useCallback(() => { + const message = createControlMessage({ action: LogControlAction.PAUSE }); + sendMessage(message); + }, [sendMessage]); + + // 恢复日志流 + const resume = useCallback(() => { + const message = createControlMessage({ action: LogControlAction.RESUME }); + sendMessage(message); + }, [sendMessage]); + + // 停止日志流 + const stop = useCallback(() => { + // 先发送STOP消息通知后端 + const message = createControlMessage({ action: LogControlAction.STOP }); + sendMessage(message); + + // 然后主动断开连接(不触发重连) + setTimeout(() => { + disconnect(); + }, 100); // 延迟一点确保消息发送成功 + }, [sendMessage, disconnect]); + + // 清空日志 + const clearLogs = useCallback(() => { + setLogs([]); + logIdCounterRef.current = 0; + }, []); + + // 自动连接 + useEffect(() => { + if (autoConnect) { + connect(); + } + + return () => { + disconnect(); + }; + }, [autoConnect, connect, disconnect]); + + return { + status, + logs, + error, + connect, + disconnect, + start, + pause, + resume, + stop, + clearLogs, + }; +} diff --git a/frontend/src/pages/Dashboard/types.ts b/frontend/src/pages/Dashboard/types.ts index 24ce9846..bb7d49e1 100644 --- a/frontend/src/pages/Dashboard/types.ts +++ b/frontend/src/pages/Dashboard/types.ts @@ -372,3 +372,6 @@ export interface DeployNodeLogDTO { expired: boolean; logs: LogEntry[]; } + +// 导出日志流相关类型 +export * from './types/logStream'; diff --git a/frontend/src/pages/Dashboard/types/logStream.ts b/frontend/src/pages/Dashboard/types/logStream.ts new file mode 100644 index 00000000..ce5f658a --- /dev/null +++ b/frontend/src/pages/Dashboard/types/logStream.ts @@ -0,0 +1,94 @@ +/** + * WebSocket日志流相关类型定义 + */ + +/** + * 消息类型枚举 + */ +export enum LogMessageType { + START = 'START', // 启动日志流 + CONTROL = 'CONTROL', // 控制日志流 + LOG = 'LOG', // 日志行数据 + STATUS = 'STATUS', // 状态变更 + ERROR = 'ERROR', // 错误信息 +} + +/** + * 控制动作枚举 + */ +export enum LogControlAction { + PAUSE = 'PAUSE', // 暂停 + RESUME = 'RESUME', // 恢复 + STOP = 'STOP', // 停止 +} + +/** + * 日志流状态枚举 + */ +export enum LogStreamStatus { + DISCONNECTED = 'DISCONNECTED', // 未连接 + CONNECTING = 'CONNECTING', // 连接中 + CONNECTED = 'CONNECTED', // 已连接 + STREAMING = 'STREAMING', // 流式传输中 + PAUSED = 'PAUSED', // 已暂停 + STOPPED = 'STOPPED', // 已停止 + ERROR = 'ERROR', // 错误状态 +} + +/** + * START消息请求参数 + */ +export interface LogStartRequest { + name?: string; // Pod名称或容器名称 + lines?: number; // 显示最近N行日志 +} + +/** + * CONTROL消息请求参数 + */ +export interface LogControlRequest { + action: LogControlAction; +} + +/** + * 日志行数据 + */ +export interface LogLineData { + timestamp: string; + content: string; +} + +/** + * 状态响应数据 + */ +export interface LogStatusData { + status: LogStreamStatus; +} + +/** + * 错误响应数据 + */ +export interface LogErrorData { + error: string; +} + +/** + * WebSocket消息基础结构 + */ +export interface LogWebSocketMessage { + type: LogMessageType; + data: { + request?: T; + response?: T; + }; +} + +/** + * 日志行(前端使用) + */ +export interface LogLine { + id: string; // 唯一标识 + timestamp: string; + content: string; + formattedTime?: string; +} diff --git a/frontend/src/pages/Dashboard/utils/websocket.ts b/frontend/src/pages/Dashboard/utils/websocket.ts new file mode 100644 index 00000000..a7188984 --- /dev/null +++ b/frontend/src/pages/Dashboard/utils/websocket.ts @@ -0,0 +1,77 @@ +import { + LogMessageType, + type LogWebSocketMessage, + type LogStartRequest, + type LogControlRequest, +} from '../types/logStream'; + +/** + * 构建WebSocket URL + */ +export function buildLogStreamUrl(teamAppId: number): string { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + const token = localStorage.getItem('token'); + const baseUrl = `${protocol}//${host}/api/v1/team-applications/${teamAppId}/logs/stream`; + + if (token) { + return `${baseUrl}?token=${encodeURIComponent(token)}`; + } + + return baseUrl; +} + +/** + * 创建WebSocket消息 + */ +export function createLogMessage( + type: LogMessageType, + request?: T +): LogWebSocketMessage { + return { + type, + data: request ? { request } : {}, + }; +} + +/** + * 解析WebSocket消息 + */ +export function parseLogMessage(data: string): LogWebSocketMessage | null { + try { + return JSON.parse(data); + } catch (error) { + console.error('Failed to parse WebSocket message:', error); + return null; + } +} + +/** + * 创建START消息 + */ +export function createStartMessage(params: LogStartRequest): string { + return JSON.stringify(createLogMessage(LogMessageType.START, params)); +} + +/** + * 创建CONTROL消息 + */ +export function createControlMessage(params: LogControlRequest): string { + return JSON.stringify(createLogMessage(LogMessageType.CONTROL, params)); +} + +/** + * 格式化时间戳 + */ +export function formatLogTimestamp(timestamp: string): string { + try { + const date = new Date(timestamp); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + const ms = String(date.getMilliseconds()).padStart(3, '0'); + return `${hours}:${minutes}:${seconds}.${ms}`; + } catch { + return timestamp; + } +}