1.33 日志通用查询
This commit is contained in:
parent
af53992713
commit
5a7970da36
111
frontend/src/pages/Dashboard/components/LogStreamViewer.tsx
Normal file
111
frontend/src/pages/Dashboard/components/LogStreamViewer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
</div>
|
||||||
{getRuntimeInfo()}
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
{app.runtimeType === 'K8S' && (
|
||||||
<LogViewer
|
<div className="w-64">
|
||||||
content={logContent}
|
<Label htmlFor="podName" className="text-xs">Pod</Label>
|
||||||
loading={loading}
|
{loadingPods ? (
|
||||||
onRefresh={handleRefreshLogs}
|
<div className="h-8 flex items-center justify-center border rounded-md mt-1">
|
||||||
onDownload={() => {}}
|
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||||
height="100%"
|
</div>
|
||||||
theme="vs-dark"
|
) : podNames.length > 0 ? (
|
||||||
showLineNumbers={true}
|
<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>
|
||||||
|
|
||||||
|
{/* 日志显示区域 */}
|
||||||
|
<div className="flex-1 overflow-hidden px-6 py-4">
|
||||||
|
<LogStreamViewer
|
||||||
|
logs={logs}
|
||||||
|
status={status}
|
||||||
|
error={error}
|
||||||
autoScroll={true}
|
autoScroll={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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,6 +70,27 @@ export const RuntimeTabContent: React.FC<RuntimeTabContentProps> = ({
|
|||||||
{/* 操作区 - 固定在底部 */}
|
{/* 操作区 - 固定在底部 */}
|
||||||
<div className="pt-3 border-t mt-3">
|
<div className="pt-3 border-t mt-3">
|
||||||
{hasRuntimeConfig ? (
|
{hasRuntimeConfig ? (
|
||||||
|
app.isDeploying ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="w-full">
|
||||||
|
<Button
|
||||||
|
disabled
|
||||||
|
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
|
<Button
|
||||||
onClick={onLogClick}
|
onClick={onLogClick}
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -78,6 +99,7 @@ export const RuntimeTabContent: React.FC<RuntimeTabContentProps> = ({
|
|||||||
<ScrollText className="h-4 w-4 mr-2" />
|
<ScrollText className="h-4 w-4 mr-2" />
|
||||||
查看日志
|
查看日志
|
||||||
</Button>
|
</Button>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
282
frontend/src/pages/Dashboard/hooks/useLogStream.ts
Normal file
282
frontend/src/pages/Dashboard/hooks/useLogStream.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -372,3 +372,6 @@ export interface DeployNodeLogDTO {
|
|||||||
expired: boolean;
|
expired: boolean;
|
||||||
logs: LogEntry[];
|
logs: LogEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导出日志流相关类型
|
||||||
|
export * from './types/logStream';
|
||||||
|
|||||||
94
frontend/src/pages/Dashboard/types/logStream.ts
Normal file
94
frontend/src/pages/Dashboard/types/logStream.ts
Normal 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;
|
||||||
|
}
|
||||||
77
frontend/src/pages/Dashboard/utils/websocket.ts
Normal file
77
frontend/src/pages/Dashboard/utils/websocket.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user