1.33 日志通用查询

This commit is contained in:
dengqichen 2025-12-16 16:36:04 +08:00
parent af53992713
commit 5a7970da36
7 changed files with 863 additions and 100 deletions

View File

@ -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<LogStreamViewerProps> = ({
logs,
status,
error,
autoScroll = true,
}) => {
const scrollRef = useRef<HTMLDivElement>(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 (
<div className="h-full flex flex-col gap-2 p-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-5/6" />
</div>
);
}
// 渲染错误状态
if (error) {
const isDevelopment = import.meta.env.DEV;
const isConnectionError = error.includes('无法连接') || error.includes('连接关闭');
return (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center max-w-md">
<p className="text-sm text-red-600 mb-2">{error}</p>
{isConnectionError && isDevelopment && (
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md text-left">
<p className="text-xs font-medium text-yellow-800 mb-2"></p>
<ul className="text-xs text-yellow-700 space-y-1 list-disc list-inside">
<li>WebSocket服务已启动</li>
<li>: /api/v1/team-applications/{'{teamAppId}'}/logs/stream</li>
<li>Token是否有效</li>
<li></li>
</ul>
</div>
)}
{!isDevelopment && (
<p className="text-xs text-muted-foreground mt-2"></p>
)}
</div>
</div>
);
}
// 渲染空状态
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 (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center">
<p className="text-sm text-muted-foreground">{message}</p>
{status === LogStreamStatus.CONNECTED && (
<p className="text-xs text-muted-foreground mt-2">"启动"</p>
)}
</div>
</div>
);
}
// 渲染日志内容
return (
<div
ref={scrollRef}
className="h-full overflow-x-auto overflow-y-auto font-mono text-xs bg-gray-950 text-gray-100 p-4 rounded-md"
>
{logs.map((log) => (
<div key={log.id} className="mb-1 whitespace-pre-wrap break-words">
<span className="text-gray-500">[{log.formattedTime}]</span>
<span className="ml-2">{log.content}</span>
</div>
))}
</div>
);
};

View File

@ -1,15 +1,38 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Box, Container, Server } from "lucide-react"; import { Input } from "@/components/ui/input";
import LogViewer from '@/components/LogViewer'; 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 type { ApplicationConfig, DeployEnvironment } from '../types';
import request from '@/utils/request';
interface LogViewerDialogProps { interface LogViewerDialogProps {
open: boolean; open: boolean;
@ -24,8 +47,80 @@ export const LogViewerDialog: React.FC<LogViewerDialogProps> = ({
app, app,
environment, environment,
}) => { }) => {
const [loading, setLoading] = useState(false); const [lines, setLines] = useState(100);
const [logContent, setLogContent] = useState<string>(''); const [podName, setPodName] = useState('');
const [podNames, setPodNames] = useState<string[]>([]);
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<string[]>(`/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 = () => { const getRuntimeIcon = () => {
switch (app.runtimeType) { switch (app.runtimeType) {
@ -43,106 +138,185 @@ export const LogViewerDialog: React.FC<LogViewerDialogProps> = ({
const runtimeConfig = getRuntimeIcon(); const runtimeConfig = getRuntimeIcon();
const RuntimeIcon = runtimeConfig.icon; const RuntimeIcon = runtimeConfig.icon;
const getRuntimeInfo = () => { const getStatusIndicator = () => {
switch (app.runtimeType) { switch (status) {
case 'K8S': case LogStreamStatus.CONNECTING:
return ( return { color: 'text-yellow-500', label: '连接中' };
<div className="space-y-1 text-sm"> case LogStreamStatus.CONNECTED:
<div className="flex items-center gap-2"> return { color: 'text-blue-500', label: '已连接' };
<span className="text-muted-foreground">:</span> case LogStreamStatus.STREAMING:
<span className="font-medium">{app.k8sSystemName || '-'}</span> return { color: 'text-green-500', label: '流式传输中' };
</div> case LogStreamStatus.PAUSED:
<div className="flex items-center gap-2"> return { color: 'text-orange-500', label: '已暂停' };
<span className="text-muted-foreground">:</span> case LogStreamStatus.STOPPED:
<span className="font-medium">{app.k8sNamespaceName || '-'}</span> return { color: 'text-gray-500', label: '已停止' };
</div> case LogStreamStatus.ERROR:
<div className="flex items-center gap-2"> return { color: 'text-red-500', label: '错误' };
<span className="text-muted-foreground">:</span>
<span className="font-medium">{app.k8sDeploymentName || '-'}</span>
</div>
</div>
);
case 'DOCKER':
return (
<div className="space-y-1 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{app.dockerServerName || '-'}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{app.dockerContainerName || '-'}</span>
</div>
</div>
);
case 'SERVER':
return (
<div className="space-y-1 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{app.serverName || '-'}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">:</span>
<code className="font-mono text-xs bg-muted px-2 py-1 rounded">
{app.logQueryCommand || '-'}
</code>
</div>
</div>
);
default: default:
return <div className="text-sm text-muted-foreground"></div>; return { color: 'text-gray-500', label: '未连接' };
} }
}; };
const handleRefreshLogs = async () => { const statusIndicator = getStatusIndicator();
setLoading(true);
// TODO: 调用实际的日志API const handleRestart = () => {
setTimeout(() => { clearLogs();
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' + if (status === LogStreamStatus.DISCONNECTED || status === LogStreamStatus.ERROR) {
'[2024-12-16 10:31:00] DEBUG Database connection established\n' + connect(); // 连接成功后会自动触发START通过useEffect
'[2024-12-16 10:31:15] INFO Processing request: GET /api/users\n' + } else {
'[2024-12-16 10:31:16] WARN Slow query detected: 1.2s\n' // 如果已连接直接发送START消息
); const startParams: any = { lines };
setLoading(false); if (app.runtimeType === 'K8S' && podName) {
}, 1000); startParams.name = podName;
}
start(startParams);
}
}; };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col"> <DialogContent className="max-w-6xl h-[85vh] flex flex-col p-0 overflow-hidden">
<DialogHeader> <DialogHeader className="px-6 pt-6 pb-4 border-b flex-shrink-0">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center justify-between">
<RuntimeIcon className={runtimeConfig.color} /> <div className="flex items-center gap-3">
{app.applicationName} - <ScrollText className="h-5 w-5" />
</DialogTitle> <span className="text-lg font-semibold">{app.applicationName} - </span>
<DialogDescription> </div>
: {environment.environmentName} <div className="flex items-center gap-3 text-sm font-normal">
</DialogDescription>
</DialogHeader>
<div className="space-y-4 flex-1 flex flex-col min-h-0">
<div className="p-4 rounded-lg border bg-muted/30">
<div className="flex items-center gap-2 mb-3">
<Badge variant="outline" className={runtimeConfig.bg}> <Badge variant="outline" className={runtimeConfig.bg}>
<RuntimeIcon className={`h-3 w-3 mr-1 ${runtimeConfig.color}`} /> <RuntimeIcon className={`h-3 w-3 mr-1 ${runtimeConfig.color}`} />
{runtimeConfig.label} {runtimeConfig.label}
</Badge> </Badge>
<Circle className={`h-2 w-2 ${statusIndicator.color} fill-current`} />
<span className="text-muted-foreground">{environment.environmentName}</span>
</div>
</DialogTitle>
</DialogHeader>
<div className="flex-1 flex flex-col overflow-hidden">
{/* 控制面板 */}
<div className="px-6 py-3 border-b flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-24">
<Label htmlFor="lines" className="text-xs"></Label>
<Input
id="lines"
type="number"
value={lines}
onChange={(e) => setLines(Number(e.target.value))}
min={10}
max={1000}
className="h-8 mt-1"
/>
</div>
{app.runtimeType === 'K8S' && (
<div className="w-64">
<Label htmlFor="podName" className="text-xs">Pod</Label>
{loadingPods ? (
<div className="h-8 flex items-center justify-center border rounded-md mt-1">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
</div>
) : podNames.length > 0 ? (
<Select value={podName} onValueChange={setPodName}>
<SelectTrigger className="h-8 mt-1">
<SelectValue placeholder="选择Pod" />
</SelectTrigger>
<SelectContent>
{podNames.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="podName"
value={podName}
onChange={(e) => setPodName(e.target.value)}
placeholder="无可用Pod"
className="h-8 mt-1"
disabled
/>
)}
</div>
)}
<div className="flex-1" />
<div className="flex gap-2 items-end">
{/* 启动/恢复/重试按钮 */}
{(status === LogStreamStatus.DISCONNECTED ||
status === LogStreamStatus.CONNECTED ||
status === LogStreamStatus.ERROR) && (
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={false}
>
<Play className="h-3 w-3 mr-1" />
{status === LogStreamStatus.ERROR ? '重试' : '启动'}
</Button>
)}
{/* 连接中按钮 */}
{status === LogStreamStatus.CONNECTING && (
<Button size="sm" variant="outline" disabled>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
</Button>
)}
{/* 恢复按钮(暂停状态) */}
{status === LogStreamStatus.PAUSED && (
<Button size="sm" variant="outline" onClick={resume}>
<Play className="h-3 w-3 mr-1" />
</Button>
)}
{/* 暂停按钮(流式传输中) */}
{status === LogStreamStatus.STREAMING && (
<Button size="sm" variant="outline" onClick={pause}>
<Pause className="h-3 w-3 mr-1" />
</Button>
)}
{/* 停止按钮 */}
{(status === LogStreamStatus.CONNECTING ||
status === LogStreamStatus.STREAMING ||
status === LogStreamStatus.PAUSED) && (
<Button size="sm" variant="outline" onClick={stop}>
<Square className="h-3 w-3 mr-1" />
</Button>
)}
{/* 清空按钮 */}
<Button
size="sm"
variant="outline"
onClick={clearLogs}
disabled={status === LogStreamStatus.CONNECTING}
>
<Trash2 className="h-3 w-3 mr-1" />
</Button>
</div>
</div> </div>
{getRuntimeInfo()}
</div> </div>
<div className="flex-1 min-h-0"> {/* 日志显示区域 */}
<LogViewer <div className="flex-1 overflow-hidden px-6 py-4">
content={logContent} <LogStreamViewer
loading={loading} logs={logs}
onRefresh={handleRefreshLogs} status={status}
onDownload={() => {}} error={error}
height="100%"
theme="vs-dark"
showLineNumbers={true}
autoScroll={true} autoScroll={true}
/> />
</div> </div>

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Button } from "@/components/ui/button"; 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { ApplicationConfig } from '../types'; import type { ApplicationConfig } from '../types';
import { K8sRuntimeStatus } from './K8sRuntimeStatus'; import { K8sRuntimeStatus } from './K8sRuntimeStatus';
@ -70,14 +70,36 @@ export const RuntimeTabContent: React.FC<RuntimeTabContentProps> = ({
{/* 操作区 - 固定在底部 */} {/* 操作区 - 固定在底部 */}
<div className="pt-3 border-t mt-3"> <div className="pt-3 border-t mt-3">
{hasRuntimeConfig ? ( {hasRuntimeConfig ? (
<Button app.isDeploying ? (
onClick={onLogClick} <TooltipProvider>
size="sm" <Tooltip>
className="w-full" <TooltipTrigger asChild>
> <div className="w-full">
<ScrollText className="h-4 w-4 mr-2" /> <Button
disabled
</Button> size="sm"
className="w-full"
>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
</Button>
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
onClick={onLogClick}
size="sm"
className="w-full"
>
<ScrollText className="h-4 w-4 mr-2" />
</Button>
)
) : ( ) : (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>

View File

@ -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>(LogStreamStatus.DISCONNECTED);
const [logs, setLogs] = useState<LogLine[]>([]);
const [error, setError] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const logIdCounterRef = useRef(0);
const reconnectAttemptsRef = useRef(0);
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(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,
};
}

View File

@ -372,3 +372,6 @@ export interface DeployNodeLogDTO {
expired: boolean; expired: boolean;
logs: LogEntry[]; logs: LogEntry[];
} }
// 导出日志流相关类型
export * from './types/logStream';

View File

@ -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<T = any> {
type: LogMessageType;
data: {
request?: T;
response?: T;
};
}
/**
* 使
*/
export interface LogLine {
id: string; // 唯一标识
timestamp: string;
content: string;
formattedTime?: string;
}

View File

@ -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<T>(
type: LogMessageType,
request?: T
): LogWebSocketMessage<T> {
return {
type,
data: request ? { request } : {},
};
}
/**
* WebSocket消息
*/
export function parseLogMessage<T>(data: string): LogWebSocketMessage<T> | 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;
}
}