1.40
This commit is contained in:
parent
c1ac2201c1
commit
f5154b3c66
225
frontend/src/components/LogViewer/HttpPollingLogSource.ts
Normal file
225
frontend/src/components/LogViewer/HttpPollingLogSource.ts
Normal file
@ -0,0 +1,225 @@
|
||||
/**
|
||||
* HTTP轮询日志数据源策略实现
|
||||
*/
|
||||
import { LogDataSource, LogStreamStatus, HttpPollingLogConfig } from './types';
|
||||
|
||||
export class HttpPollingLogSource implements LogDataSource {
|
||||
private config: HttpPollingLogConfig;
|
||||
private status: LogStreamStatus = LogStreamStatus.DISCONNECTED;
|
||||
private error: string | null = null;
|
||||
private pollingTimer: NodeJS.Timeout | null = null;
|
||||
private isPolling = false;
|
||||
|
||||
private logCallback: ((log: string) => void) | null = null;
|
||||
private statusCallback: ((status: LogStreamStatus) => void) | null = null;
|
||||
private errorCallback: ((error: string) => void) | null = null;
|
||||
|
||||
private referenceForNext: string | null = null;
|
||||
private referenceForPrevious: string | null = null;
|
||||
private lastDisplayedTimestamp: string | null = null;
|
||||
private firstDisplayedTimestamp: string | null = null;
|
||||
|
||||
constructor(config: HttpPollingLogConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.isPolling) return;
|
||||
|
||||
this.updateStatus(LogStreamStatus.CONNECTING);
|
||||
this.error = null;
|
||||
this.isPolling = true;
|
||||
|
||||
// 初始加载
|
||||
this.fetchInitialLogs();
|
||||
|
||||
// 启动自动刷新
|
||||
if (this.config.autoRefresh) {
|
||||
this.startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.stopPolling();
|
||||
this.isPolling = false;
|
||||
this.updateStatus(LogStreamStatus.DISCONNECTED);
|
||||
}
|
||||
|
||||
send(message: string): void {
|
||||
// HTTP轮询策略不需要发送消息
|
||||
console.warn('[HttpPollingLogSource] send() is not supported for HTTP polling strategy');
|
||||
}
|
||||
|
||||
onLog(callback: (log: string) => void): void {
|
||||
this.logCallback = callback;
|
||||
}
|
||||
|
||||
onStatusChange(callback: (status: LogStreamStatus) => void): void {
|
||||
this.statusCallback = callback;
|
||||
}
|
||||
|
||||
onError(callback: (error: string) => void): void {
|
||||
this.errorCallback = callback;
|
||||
}
|
||||
|
||||
getStatus(): LogStreamStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getError(): string | null {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.disconnect();
|
||||
this.logCallback = null;
|
||||
this.statusCallback = null;
|
||||
this.errorCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始加载日志
|
||||
*/
|
||||
private async fetchInitialLogs(): Promise<void> {
|
||||
try {
|
||||
const response = await this.config.fetchLogs(this.config.initialParams);
|
||||
|
||||
if (response.logs.length > 0) {
|
||||
const formattedLogs = response.logs.map(log => this.formatLog(log));
|
||||
formattedLogs.forEach(log => this.logCallback?.(log));
|
||||
|
||||
this.firstDisplayedTimestamp = response.logs[0].timestamp;
|
||||
this.lastDisplayedTimestamp = response.logs[response.logs.length - 1].timestamp;
|
||||
}
|
||||
|
||||
this.referenceForPrevious = response.referenceForPrevious?.referenceTimestamp || null;
|
||||
this.referenceForNext = response.referenceForNext?.referenceTimestamp || null;
|
||||
|
||||
this.updateStatus(LogStreamStatus.CONNECTED);
|
||||
} catch (err) {
|
||||
const errorMsg = `获取日志失败: ${err instanceof Error ? err.message : '未知错误'}`;
|
||||
this.updateError(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询新日志
|
||||
*/
|
||||
private async pollNewLogs(): Promise<void> {
|
||||
if (!this.referenceForNext) return;
|
||||
|
||||
try {
|
||||
const params = {
|
||||
...this.config.initialParams,
|
||||
referenceTimestamp: this.referenceForNext,
|
||||
direction: 'next',
|
||||
};
|
||||
|
||||
const response = await this.config.fetchLogs(params);
|
||||
|
||||
// 去重:只保留比当前最新日志更新的日志
|
||||
const uniqueLogs = response.logs.filter(log => {
|
||||
if (!this.lastDisplayedTimestamp) return true;
|
||||
return log.timestamp > this.lastDisplayedTimestamp;
|
||||
});
|
||||
|
||||
if (uniqueLogs.length > 0) {
|
||||
const formattedLogs = uniqueLogs.map(log => this.formatLog(log));
|
||||
formattedLogs.forEach(log => this.logCallback?.(log));
|
||||
|
||||
this.lastDisplayedTimestamp = uniqueLogs[uniqueLogs.length - 1].timestamp;
|
||||
}
|
||||
|
||||
this.referenceForNext = response.referenceForNext?.referenceTimestamp || null;
|
||||
} catch (err) {
|
||||
console.error('[HttpPollingLogSource] Poll error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动轮询
|
||||
*/
|
||||
private startPolling(): void {
|
||||
this.stopPolling();
|
||||
|
||||
const interval = (this.config.refreshInterval ?? 5) * 1000;
|
||||
this.pollingTimer = setInterval(() => {
|
||||
this.pollNewLogs();
|
||||
}, interval);
|
||||
|
||||
this.updateStatus(LogStreamStatus.STREAMING);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止轮询
|
||||
*/
|
||||
private stopPolling(): void {
|
||||
if (this.pollingTimer) {
|
||||
clearInterval(this.pollingTimer);
|
||||
this.pollingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日志
|
||||
*/
|
||||
private formatLog(log: { timestamp: string; content: string }): string {
|
||||
if (this.config.formatLog) {
|
||||
return this.config.formatLog(log);
|
||||
}
|
||||
return `${log.timestamp} ${log.content}`;
|
||||
}
|
||||
|
||||
private updateStatus(newStatus: LogStreamStatus): void {
|
||||
this.status = newStatus;
|
||||
this.statusCallback?.(newStatus);
|
||||
}
|
||||
|
||||
private updateError(errorMsg: string): void {
|
||||
this.error = errorMsg;
|
||||
this.errorCallback?.(errorMsg);
|
||||
this.updateStatus(LogStreamStatus.ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共方法:加载历史日志
|
||||
*/
|
||||
public async loadPreviousLogs(): Promise<void> {
|
||||
if (!this.referenceForPrevious) return;
|
||||
|
||||
try {
|
||||
const params = {
|
||||
...this.config.initialParams,
|
||||
referenceTimestamp: this.referenceForPrevious,
|
||||
direction: 'prev',
|
||||
};
|
||||
|
||||
const response = await this.config.fetchLogs(params);
|
||||
|
||||
// 去重:只保留比当前最早日志更早的日志
|
||||
const uniqueLogs = response.logs.filter(log => {
|
||||
if (!this.firstDisplayedTimestamp) return true;
|
||||
return log.timestamp < this.firstDisplayedTimestamp;
|
||||
});
|
||||
|
||||
if (uniqueLogs.length > 0) {
|
||||
// 注意:历史日志需要插入到前面,但这里通过回调追加
|
||||
// 调用者需要处理插入顺序
|
||||
const formattedLogs = uniqueLogs.map(log => this.formatLog(log));
|
||||
formattedLogs.forEach(log => this.logCallback?.(log));
|
||||
|
||||
this.firstDisplayedTimestamp = response.logs[0].timestamp;
|
||||
this.referenceForPrevious = response.logs[0].timestamp;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[HttpPollingLogSource] Load previous logs error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共方法:加载更多日志
|
||||
*/
|
||||
public async loadNextLogs(): Promise<void> {
|
||||
await this.pollNewLogs();
|
||||
}
|
||||
}
|
||||
108
frontend/src/components/LogViewer/LogStreamViewer.tsx
Normal file
108
frontend/src/components/LogViewer/LogStreamViewer.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 通用日志流查看器组件 - 策略模式
|
||||
*
|
||||
* 特性:
|
||||
* - 支持多种日志数据源策略(WebSocket、HTTP轮询等)
|
||||
* - 可自定义控制栏
|
||||
* - 暴露完整的控制API
|
||||
* - 业务逻辑完全解耦
|
||||
*/
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { MonacoLogViewer } from './MonacoLogViewer';
|
||||
import type { LogStreamViewerProps, LogViewerAPI, LogStreamControlAPI } from './types';
|
||||
import { LogStreamStatus } from './types';
|
||||
|
||||
export const LogStreamViewer: React.FC<LogStreamViewerProps> = ({
|
||||
dataSource,
|
||||
customToolbar,
|
||||
monacoConfig = {},
|
||||
onReady,
|
||||
className = '',
|
||||
}) => {
|
||||
const logViewerApiRef = useRef<LogViewerAPI | null>(null);
|
||||
const [currentStatus, setCurrentStatus] = React.useState(dataSource.getStatus());
|
||||
|
||||
// 注册日志接收回调
|
||||
useEffect(() => {
|
||||
console.log('[LogStreamViewer] useEffect triggered, connecting...');
|
||||
|
||||
dataSource.onLog((logText) => {
|
||||
if (logViewerApiRef.current) {
|
||||
logViewerApiRef.current.appendLog(logText);
|
||||
}
|
||||
});
|
||||
|
||||
// 注册状态变化回调
|
||||
dataSource.onStatusChange((newStatus) => {
|
||||
setCurrentStatus(newStatus);
|
||||
});
|
||||
|
||||
// 自动连接
|
||||
dataSource.connect();
|
||||
|
||||
return () => {
|
||||
console.log('[LogStreamViewer] useEffect cleanup, disconnecting...');
|
||||
dataSource.cleanup();
|
||||
};
|
||||
}, [dataSource]);
|
||||
|
||||
// 构建控制API
|
||||
const controlApi: LogStreamControlAPI = {
|
||||
connect: () => dataSource.connect(),
|
||||
disconnect: () => dataSource.disconnect(),
|
||||
send: (message: string) => dataSource.send?.(message),
|
||||
getStatus: () => dataSource.getStatus(),
|
||||
getError: () => dataSource.getError(),
|
||||
};
|
||||
|
||||
// 当LogViewer API就绪时,通知外部
|
||||
const handleLogViewerReady = (api: LogViewerAPI) => {
|
||||
logViewerApiRef.current = api;
|
||||
if (onReady) {
|
||||
onReady(controlApi, api);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态和错误信息
|
||||
const error = dataSource.getError();
|
||||
const isConnecting = currentStatus === LogStreamStatus.CONNECTING;
|
||||
|
||||
// CONNECTING状态下不显示错误(可能是临时的连接问题)
|
||||
const shouldShowError = error && !isConnecting;
|
||||
|
||||
console.log('[LogStreamViewer] Render - status:', currentStatus, 'error:', error, 'shouldShowError:', shouldShowError, 'isConnecting:', isConnecting);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
{/* 自定义控制栏 */}
|
||||
{customToolbar && (
|
||||
<div className="flex-shrink-0">
|
||||
{customToolbar}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 日志显示区域 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{shouldShowError ? (
|
||||
<div className="h-full flex items-center justify-center p-4 bg-gray-950">
|
||||
<div className="text-center max-w-md">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isConnecting ? (
|
||||
<div className="h-full flex items-center justify-center p-4 bg-gray-950">
|
||||
<div className="text-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent mx-auto mb-2" />
|
||||
<span className="text-sm text-muted-foreground">正在连接服务器...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MonacoLogViewer
|
||||
{...monacoConfig}
|
||||
onReady={handleLogViewerReady}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
159
frontend/src/components/LogViewer/MonacoLogViewer.tsx
Normal file
159
frontend/src/components/LogViewer/MonacoLogViewer.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Monaco日志查看器组件
|
||||
* 基于Monaco Editor实现的高性能只读日志查看器
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Editor, { Monaco } from '@monaco-editor/react';
|
||||
import type { editor } from 'monaco-editor';
|
||||
import type { MonacoLogViewerProps, LogViewerAPI } from './types';
|
||||
|
||||
export const MonacoLogViewer: React.FC<MonacoLogViewerProps> = ({
|
||||
autoScroll = true,
|
||||
fontSize = 12,
|
||||
theme = 'vs-dark',
|
||||
height = '100%',
|
||||
onReady,
|
||||
className = '',
|
||||
style = {},
|
||||
}) => {
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||
const monacoRef = useRef<Monaco | null>(null);
|
||||
const autoScrollRef = useRef(autoScroll);
|
||||
const [content, setContent] = useState('');
|
||||
const lineCountRef = useRef(0);
|
||||
|
||||
// 更新自动滚动状态
|
||||
useEffect(() => {
|
||||
autoScrollRef.current = autoScroll;
|
||||
}, [autoScroll]);
|
||||
|
||||
// Monaco Editor挂载完成
|
||||
const handleEditorDidMount = (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
|
||||
editorRef.current = editor;
|
||||
monacoRef.current = monaco;
|
||||
|
||||
// 配置编辑器选项
|
||||
editor.updateOptions({
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'off',
|
||||
lineNumbers: 'on',
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
lineDecorationsWidth: 10,
|
||||
lineNumbersMinChars: 6,
|
||||
renderLineHighlight: 'none',
|
||||
automaticLayout: true,
|
||||
scrollbar: {
|
||||
vertical: 'visible',
|
||||
horizontal: 'visible',
|
||||
verticalScrollbarSize: 10,
|
||||
horizontalScrollbarSize: 10,
|
||||
},
|
||||
});
|
||||
|
||||
// 检查是否在底部附近
|
||||
const isNearBottom = () => {
|
||||
if (!editorRef.current) return true;
|
||||
const scrollTop = editorRef.current.getScrollTop();
|
||||
const scrollHeight = editorRef.current.getScrollHeight();
|
||||
const clientHeight = editorRef.current.getLayoutInfo().height;
|
||||
return scrollHeight - scrollTop - clientHeight < 50;
|
||||
};
|
||||
|
||||
// 暴露API
|
||||
if (onReady) {
|
||||
const api: LogViewerAPI = {
|
||||
appendLog: (log: string) => {
|
||||
lineCountRef.current++;
|
||||
const shouldScroll = autoScrollRef.current && isNearBottom();
|
||||
setContent((prev) => {
|
||||
const newContent = prev ? `${prev}\n${log}` : log;
|
||||
// 只有在底部时才自动滚动
|
||||
if (shouldScroll) {
|
||||
setTimeout(() => {
|
||||
const lineCount = editorRef.current?.getModel()?.getLineCount() || 0;
|
||||
editorRef.current?.revealLine(lineCount);
|
||||
}, 0);
|
||||
}
|
||||
return newContent;
|
||||
});
|
||||
},
|
||||
appendLogs: (logs: string[]) => {
|
||||
lineCountRef.current += logs.length;
|
||||
const shouldScroll = autoScrollRef.current && isNearBottom();
|
||||
setContent((prev) => {
|
||||
const newContent = prev ? `${prev}\n${logs.join('\n')}` : logs.join('\n');
|
||||
// 只有在底部时才自动滚动
|
||||
if (shouldScroll) {
|
||||
setTimeout(() => {
|
||||
const lineCount = editorRef.current?.getModel()?.getLineCount() || 0;
|
||||
editorRef.current?.revealLine(lineCount);
|
||||
}, 0);
|
||||
}
|
||||
return newContent;
|
||||
});
|
||||
},
|
||||
clear: () => {
|
||||
setContent('');
|
||||
lineCountRef.current = 0;
|
||||
},
|
||||
scrollToBottom: () => {
|
||||
const lineCount = editorRef.current?.getModel()?.getLineCount() || 0;
|
||||
editorRef.current?.revealLine(lineCount);
|
||||
},
|
||||
search: (text: string) => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.trigger('', 'actions.find', { searchString: text });
|
||||
}
|
||||
},
|
||||
findNext: () => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.trigger('', 'editor.action.nextMatchFindAction', null);
|
||||
}
|
||||
},
|
||||
findPrevious: () => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.trigger('', 'editor.action.previousMatchFindAction', null);
|
||||
}
|
||||
},
|
||||
getLineCount: () => lineCountRef.current,
|
||||
};
|
||||
onReady(api);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full ${className}`} style={{ height, ...style }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="plaintext"
|
||||
value={content}
|
||||
theme={theme}
|
||||
options={{
|
||||
fontSize,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'off',
|
||||
lineNumbers: 'on',
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
lineDecorationsWidth: 10,
|
||||
lineNumbersMinChars: 6,
|
||||
renderLineHighlight: 'none',
|
||||
automaticLayout: true,
|
||||
scrollbar: {
|
||||
vertical: 'visible',
|
||||
horizontal: 'visible',
|
||||
verticalScrollbarSize: 10,
|
||||
horizontalScrollbarSize: 10,
|
||||
},
|
||||
}}
|
||||
onMount={handleEditorDidMount}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,114 +1,318 @@
|
||||
# LogViewer 组件
|
||||
# LogViewer 日志查看器组件
|
||||
|
||||
专业的日志查看器组件,基于 Monaco Editor 实现,支持高性能虚拟滚动和语法高亮。
|
||||
基于Monaco Editor实现的高性能只读日志查看器组件,支持WebSocket实时日志流和HTTP轮询两种数据源。
|
||||
|
||||
## 特性
|
||||
|
||||
- ✅ **高性能虚拟滚动**:基于 Monaco Editor,支持百万行日志
|
||||
- ✅ **语法高亮**:自动识别 INFO/WARN/ERROR 日志级别并高亮显示
|
||||
- ✅ **双模式支持**:结构化日志(带时间戳/级别)和纯文本日志
|
||||
- ✅ **自动滚动**:可选的自动滚动到底部功能
|
||||
- ✅ **工具栏**:内置刷新和下载按钮
|
||||
- ✅ **主题支持**:支持亮色和暗色主题
|
||||
- ✅ **高性能虚拟滚动** - 支持百万行日志流畅显示
|
||||
- ✅ **内置搜索功能** - 快速查找日志内容
|
||||
- ✅ **只读模式** - 禁用用户输入,专注日志查看
|
||||
- ✅ **自动滚动** - 新日志自动滚动到底部
|
||||
- ✅ **批量追加优化** - 支持批量追加日志,减少渲染次数
|
||||
- ✅ **主题切换** - 支持深色/浅色/高对比度主题
|
||||
- ✅ **响应式布局** - 自动适应容器大小
|
||||
- ✅ **行号显示** - 清晰的行号显示
|
||||
- ✅ **策略模式** - 支持WebSocket和HTTP轮询数据源
|
||||
|
||||
## 使用示例
|
||||
## 架构设计
|
||||
|
||||
### 结构化日志模式(Dashboard 部署日志)
|
||||
|
||||
```tsx
|
||||
import LogViewer from '@/components/LogViewer';
|
||||
import type { StructuredLog } from '@/components/LogViewer/types';
|
||||
|
||||
const logs: StructuredLog[] = [
|
||||
{
|
||||
sequenceId: 1,
|
||||
timestamp: '2025-12-13T10:30:00.123Z',
|
||||
level: 'INFO',
|
||||
message: '开始部署应用...',
|
||||
},
|
||||
{
|
||||
sequenceId: 2,
|
||||
timestamp: '2025-12-13T10:30:05.456Z',
|
||||
level: 'WARN',
|
||||
message: '检测到配置文件缺失,使用默认配置',
|
||||
},
|
||||
{
|
||||
sequenceId: 3,
|
||||
timestamp: '2025-12-13T10:30:10.789Z',
|
||||
level: 'ERROR',
|
||||
message: '部署失败:连接超时',
|
||||
},
|
||||
];
|
||||
|
||||
<LogViewer
|
||||
logs={logs}
|
||||
loading={false}
|
||||
onRefresh={handleRefresh}
|
||||
onDownload={handleDownload}
|
||||
autoScroll={true}
|
||||
showToolbar={true}
|
||||
/>
|
||||
```
|
||||
LogStreamViewer (通用组件)
|
||||
├── LogDataSource (策略接口)
|
||||
│ ├── WebSocketLogSource (WebSocket策略实现)
|
||||
│ └── HttpPollingLogSource (HTTP轮询策略实现)
|
||||
└── MonacoLogViewer (显示层)
|
||||
```
|
||||
|
||||
### 纯文本模式(K8s Pod 日志)
|
||||
## 安装依赖
|
||||
|
||||
组件依赖以下包(项目已安装):
|
||||
- `@monaco-editor/react`
|
||||
- `monaco-editor`
|
||||
|
||||
## 基础用法
|
||||
|
||||
### 方式1:使用WebSocket日志流(推荐)
|
||||
|
||||
```tsx
|
||||
import LogViewer from '@/components/LogViewer';
|
||||
import { LogStreamViewer, WebSocketLogSource } from '@/components/LogViewer';
|
||||
|
||||
const logContent = `
|
||||
2025-12-13 10:30:00 Starting application...
|
||||
2025-12-13 10:30:01 Loading configuration...
|
||||
2025-12-13 10:30:02 Server started on port 8080
|
||||
`;
|
||||
function DashboardLogs() {
|
||||
const dataSource = new WebSocketLogSource({
|
||||
url: 'ws://localhost:8080/logs',
|
||||
messageParser: (message) => {
|
||||
const data = JSON.parse(message);
|
||||
return data.type === 'LOG' ? data.content : null;
|
||||
},
|
||||
autoConnect: true,
|
||||
});
|
||||
|
||||
<LogViewer
|
||||
content={logContent}
|
||||
loading={false}
|
||||
onRefresh={handleRefresh}
|
||||
onDownload={handleDownload}
|
||||
theme="vs-dark"
|
||||
/>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `logs` | `StructuredLog[]` | - | 结构化日志数据(与 content 二选一) |
|
||||
| `content` | `string` | - | 纯文本日志内容(与 logs 二选一) |
|
||||
| `loading` | `boolean` | `false` | 加载状态 |
|
||||
| `onRefresh` | `() => void` | - | 刷新回调函数 |
|
||||
| `onDownload` | `() => void` | - | 下载回调函数 |
|
||||
| `height` | `string \| number` | `'100%'` | 组件高度 |
|
||||
| `theme` | `'light' \| 'vs-dark'` | `'light'` | 主题 |
|
||||
| `showToolbar` | `boolean` | `true` | 是否显示工具栏 |
|
||||
| `autoScroll` | `boolean` | `true` | 是否自动滚动到底部 |
|
||||
| `className` | `string` | - | 自定义类名 |
|
||||
|
||||
## 类型定义
|
||||
|
||||
```typescript
|
||||
export type LogLevel = 'INFO' | 'WARN' | 'ERROR';
|
||||
|
||||
export interface StructuredLog {
|
||||
sequenceId: number;
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
return (
|
||||
<div style={{ height: '600px' }}>
|
||||
<LogStreamViewer dataSource={dataSource} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 方式2:使用HTTP轮询
|
||||
|
||||
```tsx
|
||||
import { LogStreamViewer, HttpPollingLogSource } from '@/components/LogViewer';
|
||||
import { getPodLogs } from '@/services/api';
|
||||
|
||||
function PodLogs({ namespace, podName }: { namespace: string; podName: string }) {
|
||||
const dataSource = new HttpPollingLogSource({
|
||||
fetchLogs: (params) => getPodLogs(namespace, podName, params),
|
||||
initialParams: { lines: 100 },
|
||||
autoRefresh: true,
|
||||
refreshInterval: 5,
|
||||
formatLog: (log) => `[${log.timestamp}] ${log.content}`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ height: '600px' }}>
|
||||
<LogStreamViewer dataSource={dataSource} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 方式3:直接使用MonacoLogViewer
|
||||
|
||||
```tsx
|
||||
import { MonacoLogViewer, type LogViewerAPI } from '@/components/LogViewer';
|
||||
import { useState } from 'react';
|
||||
|
||||
function SimpleLogViewer() {
|
||||
const [api, setApi] = useState<LogViewerAPI | null>(null);
|
||||
|
||||
const handleReady = (logApi: LogViewerAPI) => {
|
||||
setApi(logApi);
|
||||
logApi.appendLog('Component ready!');
|
||||
};
|
||||
|
||||
const handleAddLog = () => {
|
||||
api?.appendLog(`[${new Date().toISOString()}] New log message`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={handleAddLog}>添加日志</button>
|
||||
<div style={{ height: '500px' }}>
|
||||
<MonacoLogViewer
|
||||
onReady={handleReady}
|
||||
autoScroll={true}
|
||||
theme="vs-dark"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## MonacoLogViewer Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| autoScroll | `boolean` | `true` | 是否自动滚动到底部 |
|
||||
| fontSize | `number` | `12` | 字体大小 |
|
||||
| theme | `'vs-dark' \| 'vs-light' \| 'hc-black'` | `'vs-dark'` | 主题 |
|
||||
| height | `string \| number` | `'100%'` | 高度 |
|
||||
| onReady | `(api: LogViewerAPI) => void` | - | 组件就绪回调,暴露API |
|
||||
| className | `string` | `''` | 自定义类名 |
|
||||
| style | `React.CSSProperties` | `{}` | 自定义样式 |
|
||||
|
||||
## LogStreamViewer Props
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| dataSource | `LogDataSource` | 日志数据源策略(必需) |
|
||||
| customToolbar | `React.ReactNode` | 自定义控制栏 |
|
||||
| monacoConfig | `Partial<MonacoLogViewerProps>` | Monaco配置 |
|
||||
| onReady | `(controlApi, logApi) => void` | 就绪回调 |
|
||||
| className | `string` | 自定义类名 |
|
||||
|
||||
## API 方法
|
||||
|
||||
通过 `onReady` 回调获取的 API 对象提供以下方法:
|
||||
|
||||
### LogViewerAPI
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| appendLog | `(log: string) => void` | 追加单条日志 |
|
||||
| appendLogs | `(logs: string[]) => void` | 批量追加日志(性能优化) |
|
||||
| clear | `() => void` | 清空所有日志 |
|
||||
| scrollToBottom | `() => void` | 滚动到底部 |
|
||||
| search | `(text: string) => void` | 搜索文本 |
|
||||
| findNext | `() => void` | 查找下一个 |
|
||||
| findPrevious | `() => void` | 查找上一个 |
|
||||
| getLineCount | `() => number` | 获取当前日志行数 |
|
||||
|
||||
### LogStreamControlAPI
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| connect | 连接数据源 |
|
||||
| disconnect | 断开数据源 |
|
||||
| send | 发送消息(WebSocket) |
|
||||
| getStatus | 获取连接状态 |
|
||||
| getError | 获取错误信息 |
|
||||
|
||||
## 完整示例:带控制栏的日志流
|
||||
|
||||
```tsx
|
||||
import {
|
||||
LogStreamViewer,
|
||||
WebSocketLogSource,
|
||||
LogStreamStatus,
|
||||
type LogStreamControlAPI,
|
||||
type LogViewerAPI
|
||||
} from '@/components/LogViewer';
|
||||
import { useState } from 'react';
|
||||
|
||||
function AdvancedLogViewer() {
|
||||
const [controlApi, setControlApi] = useState<LogStreamControlAPI | null>(null);
|
||||
const [logApi, setLogApi] = useState<LogViewerAPI | null>(null);
|
||||
const [status, setStatus] = useState<LogStreamStatus>(LogStreamStatus.DISCONNECTED);
|
||||
|
||||
const dataSource = new WebSocketLogSource({
|
||||
url: 'ws://localhost:8080/logs',
|
||||
messageParser: (msg) => JSON.parse(msg).content,
|
||||
autoConnect: true,
|
||||
});
|
||||
|
||||
dataSource.onStatusChange(setStatus);
|
||||
|
||||
const handleReady = (ctrl: LogStreamControlAPI, log: LogViewerAPI) => {
|
||||
setControlApi(ctrl);
|
||||
setLogApi(log);
|
||||
};
|
||||
|
||||
const customToolbar = (
|
||||
<div className="flex gap-2 p-2 border-b bg-gray-900">
|
||||
<button onClick={() => controlApi?.connect()}>连接</button>
|
||||
<button onClick={() => controlApi?.disconnect()}>断开</button>
|
||||
<button onClick={() => logApi?.clear()}>清空</button>
|
||||
<span className="ml-auto text-sm text-gray-400">
|
||||
状态: {status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '600px' }}>
|
||||
<LogStreamViewer
|
||||
dataSource={dataSource}
|
||||
customToolbar={customToolbar}
|
||||
onReady={handleReady}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 创建自定义数据源
|
||||
|
||||
实现 `LogDataSource` 接口:
|
||||
|
||||
```tsx
|
||||
import { LogDataSource, LogStreamStatus } from '@/components/LogViewer';
|
||||
|
||||
class CustomLogSource implements LogDataSource {
|
||||
private status = LogStreamStatus.DISCONNECTED;
|
||||
private error: string | null = null;
|
||||
private logCallback?: (log: string) => void;
|
||||
private statusCallback?: (status: LogStreamStatus) => void;
|
||||
private errorCallback?: (error: string) => void;
|
||||
|
||||
connect() {
|
||||
this.status = LogStreamStatus.CONNECTING;
|
||||
this.statusCallback?.(this.status);
|
||||
|
||||
// 实现连接逻辑
|
||||
// ...
|
||||
|
||||
this.status = LogStreamStatus.CONNECTED;
|
||||
this.statusCallback?.(this.status);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
// 实现断开逻辑
|
||||
this.status = LogStreamStatus.DISCONNECTED;
|
||||
this.statusCallback?.(this.status);
|
||||
}
|
||||
|
||||
onLog(callback: (log: string) => void) {
|
||||
this.logCallback = callback;
|
||||
}
|
||||
|
||||
onStatusChange(callback: (status: LogStreamStatus) => void) {
|
||||
this.statusCallback = callback;
|
||||
}
|
||||
|
||||
onError(callback: (error: string) => void) {
|
||||
this.errorCallback = callback;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getError() {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 性能优化建议
|
||||
|
||||
1. **批量追加日志**:使用 `appendLogs` 而不是多次调用 `appendLog`
|
||||
```tsx
|
||||
// ❌ 不推荐
|
||||
logs.forEach(log => api.appendLog(log));
|
||||
|
||||
// ✅ 推荐
|
||||
api.appendLogs(logs);
|
||||
```
|
||||
|
||||
2. **限制日志行数**:定期清理旧日志
|
||||
```tsx
|
||||
if (api.getLineCount() > 10000) {
|
||||
api.clear();
|
||||
}
|
||||
```
|
||||
|
||||
3. **暂停自动滚动**:大量日志时可暂时关闭自动滚动
|
||||
```tsx
|
||||
<MonacoLogViewer autoScroll={false} />
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **性能优化**:Monaco Editor 内置虚拟滚动,可高效处理大量日志
|
||||
2. **模式选择**:`logs` 和 `content` 二选一,优先使用 `logs`
|
||||
3. **自动滚动**:首次加载和内容更新时会自动滚动到底部(如果 `autoScroll=true`)
|
||||
4. **下载功能**:下载的文件名格式为 `log-{timestamp}.log`
|
||||
5. **语法高亮**:仅在结构化日志模式下生效
|
||||
1. 组件必须有明确的高度(通过父容器或 style 设置)
|
||||
2. 组件是只读的,不支持用户输入
|
||||
3. Monaco Editor会自动处理虚拟滚动,性能优秀
|
||||
4. 搜索功能会打开Monaco的查找面板
|
||||
|
||||
## 技术实现
|
||||
## 故障排查
|
||||
|
||||
- **Monaco Editor**:使用 `@monaco-editor/react` 封装
|
||||
- **自定义语言**:定义 `deploylog` 语言,支持日志级别语法高亮
|
||||
- **自定义主题**:`deploylog-theme` (亮色) 和 `deploylog-theme-dark` (暗色)
|
||||
- **格式化工具**:`formatters.ts` 提供日志格式化函数
|
||||
### 日志不显示
|
||||
- 检查容器是否有高度
|
||||
- 检查数据源是否正确连接
|
||||
- 查看浏览器控制台是否有错误
|
||||
|
||||
### 性能问题
|
||||
- 使用 `appendLogs` 批量追加
|
||||
- 限制最大日志行数
|
||||
- 考虑关闭自动滚动
|
||||
|
||||
### WebSocket连接失败
|
||||
- 检查URL是否正确
|
||||
- 检查messageParser是否正确解析消息
|
||||
- 查看网络面板的WebSocket连接状态
|
||||
|
||||
75
frontend/src/components/LogViewer/StaticLogViewer.tsx
Normal file
75
frontend/src/components/LogViewer/StaticLogViewer.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 静态日志查看器组件
|
||||
* 用于显示静态日志内容(非实时流)
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { MonacoLogViewer } from './MonacoLogViewer';
|
||||
import type { LogViewerAPI, MonacoLogViewerProps } from './types';
|
||||
|
||||
interface StaticLogViewerProps extends Omit<MonacoLogViewerProps, 'onReady'> {
|
||||
/** 日志内容(字符串或字符串数组) */
|
||||
content?: string | string[];
|
||||
/** 加载状态 */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const StaticLogViewer: React.FC<StaticLogViewerProps> = ({
|
||||
content,
|
||||
loading,
|
||||
...monacoProps
|
||||
}) => {
|
||||
const apiRef = useRef<LogViewerAPI | null>(null);
|
||||
const contentRef = useRef<string | string[] | undefined>(undefined);
|
||||
|
||||
// 当内容变化时更新显示
|
||||
useEffect(() => {
|
||||
if (!apiRef.current || !content) return;
|
||||
|
||||
// 如果内容相同,不重复设置
|
||||
if (contentRef.current === content) return;
|
||||
contentRef.current = content;
|
||||
|
||||
// 清空并设置新内容
|
||||
apiRef.current.clear();
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
apiRef.current.appendLogs(content);
|
||||
} else {
|
||||
const lines = content.split('\n');
|
||||
apiRef.current.appendLogs(lines);
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const handleReady = (api: LogViewerAPI) => {
|
||||
apiRef.current = api;
|
||||
|
||||
// 初始化时如果有内容,立即显示
|
||||
if (content) {
|
||||
if (Array.isArray(content)) {
|
||||
api.appendLogs(content);
|
||||
} else {
|
||||
const lines = content.split('\n');
|
||||
api.appendLogs(lines);
|
||||
}
|
||||
contentRef.current = content;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-gray-950">
|
||||
<div className="text-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent mx-auto mb-2" />
|
||||
<span className="text-sm text-muted-foreground">加载日志中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MonacoLogViewer
|
||||
{...monacoProps}
|
||||
onReady={handleReady}
|
||||
/>
|
||||
);
|
||||
};
|
||||
197
frontend/src/components/LogViewer/WebSocketLogSource.ts
Normal file
197
frontend/src/components/LogViewer/WebSocketLogSource.ts
Normal file
@ -0,0 +1,197 @@
|
||||
/**
|
||||
* WebSocket日志数据源策略实现
|
||||
*/
|
||||
import { LogDataSource, LogStreamStatus, WebSocketLogConfig } from './types';
|
||||
|
||||
export class WebSocketLogSource implements LogDataSource {
|
||||
private ws: WebSocket | null = null;
|
||||
private config: WebSocketLogConfig;
|
||||
private status: LogStreamStatus = LogStreamStatus.DISCONNECTED;
|
||||
private error: string | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private hasConnected = false;
|
||||
private isManualDisconnect = false;
|
||||
|
||||
private logCallback: ((log: string) => void) | null = null;
|
||||
private statusCallback: ((status: LogStreamStatus) => void) | null = null;
|
||||
private errorCallback: ((error: string) => void) | null = null;
|
||||
|
||||
constructor(config: WebSocketLogConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
console.log('[WebSocketLogSource] connect() called, current readyState:', this.ws?.readyState);
|
||||
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
console.log('[WebSocketLogSource] Already connected, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearReconnectTimer();
|
||||
this.updateStatus(LogStreamStatus.CONNECTING);
|
||||
this.error = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.hasConnected = false;
|
||||
this.isManualDisconnect = false;
|
||||
|
||||
try {
|
||||
console.log('[WebSocketLogSource] Creating new WebSocket:', this.config.url);
|
||||
this.ws = new WebSocket(this.config.url);
|
||||
console.log('[WebSocketLogSource] WebSocket created, readyState:', this.ws.readyState);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('[WebSocketLogSource] onopen triggered! readyState:', this.ws?.readyState);
|
||||
this.reconnectAttempts = 0;
|
||||
this.hasConnected = true;
|
||||
this.updateStatus(LogStreamStatus.CONNECTED);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
||||
|
||||
// 处理 STATUS 消息
|
||||
if (message.type === 'STATUS' && message.data?.response?.status) {
|
||||
const newStatus = message.data.response.status as LogStreamStatus;
|
||||
console.log('[WebSocketLogSource] Received STATUS:', newStatus);
|
||||
this.updateStatus(newStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理 ERROR 消息
|
||||
if (message.type === 'ERROR' && message.data?.response?.error) {
|
||||
const errorMsg = message.data.response.error;
|
||||
console.error('[WebSocketLogSource] Received ERROR:', errorMsg);
|
||||
this.updateError(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理 LOG 消息
|
||||
if (message.type === 'LOG') {
|
||||
const logText = this.config.messageParser(event.data);
|
||||
if (logText && this.logCallback) {
|
||||
this.logCallback(logText);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WebSocketLogSource] Message parse error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (event) => {
|
||||
console.error('[WebSocketLogSource] WebSocket error:', event);
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log('[WebSocketLogSource] onclose triggered, code:', event.code, 'reason:', event.reason);
|
||||
|
||||
if (event.code === 1006) {
|
||||
this.updateError('无法连接到服务器,请检查网络连接');
|
||||
} else if (event.code === 1008) {
|
||||
this.updateError('认证失败,请重新登录');
|
||||
} else if (event.code !== 1000 && event.code !== 1001) {
|
||||
this.updateError(`连接关闭: ${event.reason || '未知原因'} (代码: ${event.code})`);
|
||||
}
|
||||
|
||||
if (this.hasConnected && !this.isManualDisconnect && event.code !== 1008) {
|
||||
this.handleReconnect();
|
||||
} else {
|
||||
this.updateStatus(LogStreamStatus.DISCONNECTED);
|
||||
this.hasConnected = false;
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
const errorMsg = `创建WebSocket连接失败: ${err instanceof Error ? err.message : '未知错误'}`;
|
||||
console.error('[WebSocketLogSource]', errorMsg);
|
||||
this.updateError(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
console.log('[WebSocketLogSource] disconnect() called');
|
||||
this.isManualDisconnect = true;
|
||||
this.clearReconnectTimer();
|
||||
|
||||
if (this.ws) {
|
||||
console.log('[WebSocketLogSource] Closing WebSocket, readyState:', this.ws.readyState);
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.reconnectAttempts = 0;
|
||||
this.hasConnected = false;
|
||||
this.updateStatus(LogStreamStatus.DISCONNECTED);
|
||||
}
|
||||
|
||||
send(message: string): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(message);
|
||||
} else {
|
||||
console.warn('[WebSocketLogSource] Cannot send message: WebSocket is not connected');
|
||||
}
|
||||
}
|
||||
|
||||
onLog(callback: (log: string) => void): void {
|
||||
this.logCallback = callback;
|
||||
}
|
||||
|
||||
onStatusChange(callback: (status: LogStreamStatus) => void): void {
|
||||
this.statusCallback = callback;
|
||||
}
|
||||
|
||||
onError(callback: (error: string) => void): void {
|
||||
this.errorCallback = callback;
|
||||
}
|
||||
|
||||
getStatus(): LogStreamStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getError(): string | null {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
console.log('[WebSocketLogSource] cleanup() called');
|
||||
this.disconnect();
|
||||
this.logCallback = null;
|
||||
this.statusCallback = null;
|
||||
this.errorCallback = null;
|
||||
}
|
||||
|
||||
private updateStatus(newStatus: LogStreamStatus): void {
|
||||
this.status = newStatus;
|
||||
this.statusCallback?.(newStatus);
|
||||
}
|
||||
|
||||
private updateError(errorMsg: string): void {
|
||||
this.error = errorMsg;
|
||||
this.errorCallback?.(errorMsg);
|
||||
this.updateStatus(LogStreamStatus.ERROR);
|
||||
}
|
||||
|
||||
private handleReconnect(): void {
|
||||
const maxAttempts = this.config.maxReconnectAttempts ?? 5;
|
||||
if (this.reconnectAttempts >= maxAttempts) {
|
||||
this.updateError(`连接断开,已达到最大重连次数(${maxAttempts})`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
this.updateStatus(LogStreamStatus.CONNECTING);
|
||||
|
||||
const interval = this.config.reconnectInterval ?? 3000;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.connect();
|
||||
}, interval);
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
import dayjs from 'dayjs';
|
||||
import type { StructuredLog } from './types';
|
||||
|
||||
/**
|
||||
* 计算行号宽度(字符数)
|
||||
*/
|
||||
export const calculateLineNumberWidth = (totalLines: number): number => {
|
||||
return Math.max(4, String(totalLines).length + 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除文本中的ANSI转义序列(颜色控制字符)
|
||||
*
|
||||
* ANSI转义序列包括:
|
||||
* - CSI序列:\u001b[<参数>m(如 \u001b[39m 重置前景色)
|
||||
* - OSC序列:\u001b]<参数>;<文本>BEL/ST
|
||||
* - 其他控制序列:光标移动、字符集选择等
|
||||
*
|
||||
* 使用业界验证的通用正则表达式,匹配所有标准ANSI转义序列
|
||||
*
|
||||
* @param text - 包含ANSI转义码的原始文本
|
||||
* @returns 清理后的纯文本
|
||||
*/
|
||||
export const stripAnsiCodes = (text: string): string => {
|
||||
if (!text) return '';
|
||||
// 匹配所有ANSI转义序列的通用正则表达式
|
||||
// \u001b\u009b - ESC字符(两种编码)
|
||||
// [[()#;?]* - 可选的前缀字符
|
||||
// (?:[0-9]{1,4}(?:;[0-9]{0,4})*)? - 参数(数字和分号)
|
||||
// [0-9A-ORZcf-nqry=><] - 命令字符
|
||||
return text.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化结构化日志为Monaco显示文本
|
||||
* 格式:行号 | 时间戳 | 级别 | 消息
|
||||
*/
|
||||
export const formatStructuredLogs = (logs: StructuredLog[]): string => {
|
||||
if (!logs || logs.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lineNumWidth = calculateLineNumberWidth(logs.length);
|
||||
|
||||
return logs.map((log, index) => {
|
||||
const lineNum = String(index + 1).padStart(lineNumWidth, ' ');
|
||||
const timestamp = dayjs(log.timestamp).format('YYYY-MM-DD HH:mm:ss.SSS');
|
||||
const level = log.level.padEnd(5, ' '); // INFO/WARN/ERROR 统一5个字符
|
||||
|
||||
return `${lineNum} | ${timestamp} | ${level} | ${log.message}`;
|
||||
}).join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化纯文本日志
|
||||
* 自动清理ANSI转义码(终端颜色控制字符)
|
||||
*/
|
||||
export const formatPlainText = (content: string): string => {
|
||||
return stripAnsiCodes(content || '');
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化纯文本日志并添加行号
|
||||
*/
|
||||
export const formatPlainTextWithLineNumbers = (content: string): string => {
|
||||
if (!content) return '';
|
||||
|
||||
const lines = content.split('\n');
|
||||
const lineNumWidth = calculateLineNumberWidth(lines.length);
|
||||
|
||||
return lines.map((line, index) => {
|
||||
const lineNum = String(index + 1).padStart(lineNumWidth, ' ');
|
||||
return `${lineNum} | ${line}`;
|
||||
}).join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* 从格式化的日志文本生成下载内容
|
||||
*/
|
||||
export const generateDownloadContent = (logs?: StructuredLog[], content?: string): string => {
|
||||
if (logs && logs.length > 0) {
|
||||
return formatStructuredLogs(logs);
|
||||
}
|
||||
if (content) {
|
||||
return content;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
24
frontend/src/components/LogViewer/index.ts
Normal file
24
frontend/src/components/LogViewer/index.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* LogViewer组件导出
|
||||
*/
|
||||
export { MonacoLogViewer } from './MonacoLogViewer';
|
||||
export { StaticLogViewer } from './StaticLogViewer';
|
||||
export { LogStreamViewer } from './LogStreamViewer';
|
||||
export { WebSocketLogSource } from './WebSocketLogSource';
|
||||
export { HttpPollingLogSource } from './HttpPollingLogSource';
|
||||
|
||||
export { LogStreamStatus } from './types';
|
||||
|
||||
export type {
|
||||
MonacoLogViewerProps,
|
||||
LogViewerAPI,
|
||||
LogStreamViewerProps,
|
||||
LogStreamControlAPI,
|
||||
LogDataSource,
|
||||
WebSocketLogConfig,
|
||||
HttpPollingLogConfig,
|
||||
LogViewerTheme,
|
||||
} from './types';
|
||||
|
||||
// 默认导出
|
||||
export { LogStreamViewer as default } from './LogStreamViewer';
|
||||
@ -1,251 +0,0 @@
|
||||
import React, { useEffect, useRef, useMemo } from 'react';
|
||||
import Editor, { OnMount } from '@monaco-editor/react';
|
||||
import * as monaco from 'monaco-editor';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Download, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatStructuredLogs, formatPlainText, generateDownloadContent } from './formatters';
|
||||
import { registerLogLanguage } from './logLanguage';
|
||||
import type { LogViewerProps, MonacoLayoutConfig } from './types';
|
||||
|
||||
/**
|
||||
* Monaco Editor默认布局配置
|
||||
*/
|
||||
const DEFAULT_MONACO_LAYOUT: Required<MonacoLayoutConfig> = {
|
||||
lineNumbersMinChars: 3,
|
||||
lineDecorationsWidth: 20,
|
||||
glyphMargin: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* LogViewer - 专业的日志查看器组件
|
||||
*
|
||||
* 基于Monaco Editor实现,支持:
|
||||
* - 虚拟滚动(高性能,支持百万行)
|
||||
* - 语法高亮(INFO/WARN/ERROR)
|
||||
* - 结构化日志和纯文本两种模式
|
||||
* - 自动滚动到底部
|
||||
* - 刷新和下载功能
|
||||
*/
|
||||
const LogViewer: React.FC<LogViewerProps> = ({
|
||||
logs,
|
||||
content,
|
||||
loading = false,
|
||||
onRefresh,
|
||||
onDownload,
|
||||
height = '100%',
|
||||
theme = 'light',
|
||||
showToolbar = true,
|
||||
customToolbar,
|
||||
showLineNumbers = true,
|
||||
autoScroll = true,
|
||||
className,
|
||||
monacoLayout,
|
||||
fontSize = 12,
|
||||
onScrollToTop,
|
||||
onScrollToBottom,
|
||||
}) => {
|
||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
const isStructuredMode = !!logs;
|
||||
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
// 合并默认布局配置和自定义配置
|
||||
const layoutConfig = { ...DEFAULT_MONACO_LAYOUT, ...monacoLayout };
|
||||
|
||||
// 格式化日志内容
|
||||
const formattedContent = useMemo(() => {
|
||||
if (logs && logs.length > 0) {
|
||||
return formatStructuredLogs(logs);
|
||||
}
|
||||
if (content) {
|
||||
// 纯文本直接显示,Monaco会提供行号
|
||||
return formatPlainText(content);
|
||||
}
|
||||
return '';
|
||||
}, [logs, content]);
|
||||
|
||||
// 注册自定义语言(只执行一次)
|
||||
useEffect(() => {
|
||||
registerLogLanguage();
|
||||
}, []);
|
||||
|
||||
// Editor挂载回调
|
||||
const handleEditorDidMount: OnMount = (editor) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
// 监听滚动事件
|
||||
if (onScrollToTop || onScrollToBottom) {
|
||||
editor.onDidScrollChange(() => {
|
||||
// 清除之前的定时器
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 防抖:500ms后检查滚动位置
|
||||
scrollTimeoutRef.current = setTimeout(() => {
|
||||
if (isLoadingRef.current) return;
|
||||
|
||||
const scrollTop = editor.getScrollTop();
|
||||
const scrollHeight = editor.getScrollHeight();
|
||||
const viewHeight = editor.getLayoutInfo().height;
|
||||
|
||||
// 滚动到顶部(距离顶部小于50px)
|
||||
if (scrollTop < 50 && onScrollToTop) {
|
||||
isLoadingRef.current = true;
|
||||
onScrollToTop();
|
||||
// 500ms后重置加载状态
|
||||
setTimeout(() => {
|
||||
isLoadingRef.current = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// 滚动到底部(距离底部小于50px)
|
||||
if (scrollTop + viewHeight >= scrollHeight - 50 && onScrollToBottom) {
|
||||
isLoadingRef.current = true;
|
||||
onScrollToBottom();
|
||||
// 500ms后重置加载状态
|
||||
setTimeout(() => {
|
||||
isLoadingRef.current = false;
|
||||
}, 500);
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (autoScroll && editorRef.current && formattedContent) {
|
||||
// 延迟执行,确保内容已渲染
|
||||
setTimeout(() => {
|
||||
const editor = editorRef.current;
|
||||
if (editor) {
|
||||
const lineCount = editor.getModel()?.getLineCount() || 0;
|
||||
editor.revealLine(lineCount);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [formattedContent, autoScroll]);
|
||||
|
||||
// 处理下载
|
||||
const handleDownload = () => {
|
||||
const downloadContent = generateDownloadContent(logs, content);
|
||||
if (!downloadContent) return;
|
||||
|
||||
const blob = new Blob([downloadContent], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `log-${new Date().getTime()}.log`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
onDownload?.();
|
||||
};
|
||||
|
||||
// Monaco Editor配置
|
||||
const editorOptions: monaco.editor.IStandaloneEditorConstructionOptions = {
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
fontSize,
|
||||
fontFamily: "'Menlo', 'Monaco', 'Courier New', monospace",
|
||||
lineNumbers: showLineNumbers ? 'on' : 'off',
|
||||
glyphMargin: layoutConfig.glyphMargin,
|
||||
folding: false,
|
||||
lineDecorationsWidth: showLineNumbers ? layoutConfig.lineDecorationsWidth : 0,
|
||||
lineNumbersMinChars: showLineNumbers ? layoutConfig.lineNumbersMinChars : 0,
|
||||
renderLineHighlight: 'none',
|
||||
scrollbar: {
|
||||
vertical: 'visible',
|
||||
horizontal: 'visible',
|
||||
useShadows: false,
|
||||
verticalScrollbarSize: 10,
|
||||
horizontalScrollbarSize: 10,
|
||||
},
|
||||
overviewRulerLanes: 0,
|
||||
hideCursorInOverviewRuler: true,
|
||||
overviewRulerBorder: false,
|
||||
};
|
||||
|
||||
// 确定使用的语言和主题
|
||||
const editorLanguage = isStructuredMode ? 'deploylog' : 'plaintext';
|
||||
const editorTheme = isStructuredMode
|
||||
? (theme === 'vs-dark' ? 'deploylog-theme-dark' : 'deploylog-theme')
|
||||
: theme;
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full', className)}>
|
||||
{/* 工具栏 */}
|
||||
{showToolbar && (
|
||||
customToolbar ? (
|
||||
<div className="mb-3 flex-shrink-0">{customToolbar}</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between mb-3 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{logs && logs.length > 0 && (
|
||||
<span>共 {logs.length} 条日志</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={!formattedContent || loading}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载
|
||||
</Button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 日志内容区域 */}
|
||||
<div className="flex-1 border rounded-md overflow-hidden" style={{ height }}>
|
||||
{loading && !formattedContent ? (
|
||||
<div className="flex items-center justify-center h-full bg-muted/10">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<span className="text-sm text-muted-foreground">加载日志中...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : formattedContent ? (
|
||||
<Editor
|
||||
height="100%"
|
||||
language={editorLanguage}
|
||||
theme={editorTheme}
|
||||
value={formattedContent}
|
||||
options={editorOptions}
|
||||
onMount={handleEditorDidMount}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full bg-muted/10">
|
||||
<span className="text-sm text-muted-foreground">暂无日志</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogViewer;
|
||||
@ -1,72 +0,0 @@
|
||||
import * as monaco from 'monaco-editor';
|
||||
|
||||
/**
|
||||
* 注册自定义日志语言和主题
|
||||
*/
|
||||
export const registerLogLanguage = () => {
|
||||
// 检查是否已注册
|
||||
const languages = monaco.languages.getLanguages();
|
||||
if (languages.some(lang => lang.id === 'deploylog')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 注册语言
|
||||
monaco.languages.register({ id: 'deploylog' });
|
||||
|
||||
// 定义语法高亮规则
|
||||
monaco.languages.setMonarchTokensProvider('deploylog', {
|
||||
tokenizer: {
|
||||
root: [
|
||||
// 行号(数字 + |)
|
||||
[/^\s*\d+\s*\|/, 'log.linenumber'],
|
||||
|
||||
// 时间戳(YYYY-MM-DD HH:mm:ss.SSS)
|
||||
[/\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}/, 'log.timestamp'],
|
||||
|
||||
// 日志级别
|
||||
[/\bINFO\b/, 'log.info'],
|
||||
[/\bWARN\b/, 'log.warn'],
|
||||
[/\bERROR\b/, 'log.error'],
|
||||
|
||||
// 分隔符
|
||||
[/\|/, 'log.separator'],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// 定义自定义主题
|
||||
monaco.editor.defineTheme('deploylog-theme', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'log.linenumber', foreground: '999999' },
|
||||
{ token: 'log.timestamp', foreground: '666666' },
|
||||
{ token: 'log.separator', foreground: 'cccccc' },
|
||||
{ token: 'log.info', foreground: '2563eb', fontStyle: 'bold' },
|
||||
{ token: 'log.warn', foreground: 'ca8a04', fontStyle: 'bold' },
|
||||
{ token: 'log.error', foreground: 'dc2626', fontStyle: 'bold' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#fafafa',
|
||||
'editor.lineHighlightBackground': '#f5f5f5',
|
||||
},
|
||||
});
|
||||
|
||||
// 定义暗色主题
|
||||
monaco.editor.defineTheme('deploylog-theme-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'log.linenumber', foreground: '666666' },
|
||||
{ token: 'log.timestamp', foreground: '999999' },
|
||||
{ token: 'log.separator', foreground: '555555' },
|
||||
{ token: 'log.info', foreground: '60a5fa', fontStyle: 'bold' },
|
||||
{ token: 'log.warn', foreground: 'fbbf24', fontStyle: 'bold' },
|
||||
{ token: 'log.error', foreground: 'f87171', fontStyle: 'bold' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#1e1e1e',
|
||||
'editor.lineHighlightBackground': '#2a2a2a',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,79 +1,190 @@
|
||||
/**
|
||||
* 日志级别
|
||||
* 日志查看器通用类型定义 - 策略模式
|
||||
*/
|
||||
export type LogLevel = 'INFO' | 'WARN' | 'ERROR';
|
||||
|
||||
export type LogViewerTheme = 'vs-dark' | 'vs-light' | 'hc-black';
|
||||
|
||||
/**
|
||||
* Monaco Editor布局配置
|
||||
* 日志流状态
|
||||
*/
|
||||
export interface MonacoLayoutConfig {
|
||||
/** 行号区域最小字符数 */
|
||||
lineNumbersMinChars?: number;
|
||||
/** 装饰区域宽度(行号和内容之间的间距) */
|
||||
lineDecorationsWidth?: number;
|
||||
/** 是否显示字形边距 */
|
||||
glyphMargin?: boolean;
|
||||
export enum LogStreamStatus {
|
||||
DISCONNECTED = 'DISCONNECTED',
|
||||
CONNECTING = 'CONNECTING',
|
||||
CONNECTED = 'CONNECTED',
|
||||
STREAMING = 'STREAMING',
|
||||
PAUSED = 'PAUSED',
|
||||
STOPPED = 'STOPPED',
|
||||
ERROR = 'ERROR',
|
||||
}
|
||||
|
||||
/**
|
||||
* 结构化日志条目
|
||||
* 日志数据源策略接口
|
||||
* 定义所有日志数据源必须实现的方法
|
||||
*/
|
||||
export interface StructuredLog {
|
||||
sequenceId: number;
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
export interface LogDataSource {
|
||||
/** 连接/启动数据源 */
|
||||
connect(): void;
|
||||
|
||||
/** 断开/停止数据源 */
|
||||
disconnect(): void;
|
||||
|
||||
/** 发送控制消息(可选,某些策略可能不需要) */
|
||||
send?(message: string): void;
|
||||
|
||||
/** 注册日志接收回调 */
|
||||
onLog(callback: (log: string) => void): void;
|
||||
|
||||
/** 注册状态变化回调 */
|
||||
onStatusChange(callback: (status: LogStreamStatus) => void): void;
|
||||
|
||||
/** 注册错误回调 */
|
||||
onError(callback: (error: string) => void): void;
|
||||
|
||||
/** 获取当前状态 */
|
||||
getStatus(): LogStreamStatus;
|
||||
|
||||
/** 获取当前错误 */
|
||||
getError(): string | null;
|
||||
|
||||
/** 清理资源 */
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* LogViewer组件Props
|
||||
* WebSocket日志源配置
|
||||
*/
|
||||
export interface LogViewerProps {
|
||||
/** 结构化日志数据(与content二选一) */
|
||||
logs?: StructuredLog[];
|
||||
|
||||
/** 纯文本日志内容(与logs二选一) */
|
||||
content?: string;
|
||||
|
||||
/** 加载状态 */
|
||||
loading?: boolean;
|
||||
|
||||
/** 刷新回调 */
|
||||
onRefresh?: () => void;
|
||||
|
||||
/** 下载回调 */
|
||||
onDownload?: () => void;
|
||||
|
||||
/** 高度 */
|
||||
height?: string | number;
|
||||
|
||||
/** 主题 */
|
||||
theme?: 'light' | 'vs-dark';
|
||||
|
||||
/** 是否显示工具栏 */
|
||||
showToolbar?: boolean;
|
||||
|
||||
/** 自定义工具栏(如果提供,将替换默认工具栏) */
|
||||
customToolbar?: React.ReactNode;
|
||||
|
||||
/** 是否显示行号(仅对纯文本有效,结构化日志始终显示) */
|
||||
showLineNumbers?: boolean;
|
||||
|
||||
/** 是否自动滚动到底部 */
|
||||
autoScroll?: boolean;
|
||||
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
|
||||
/** Monaco Editor布局配置 */
|
||||
monacoLayout?: MonacoLayoutConfig;
|
||||
|
||||
/** 字体大小(默认12) */
|
||||
fontSize?: number;
|
||||
|
||||
/** 滚动到顶部回调(用于加载更早的日志) */
|
||||
onScrollToTop?: () => void;
|
||||
|
||||
/** 滚动到底部回调(用于加载更新的日志) */
|
||||
onScrollToBottom?: () => void;
|
||||
export interface WebSocketLogConfig {
|
||||
/** WebSocket URL */
|
||||
url: string;
|
||||
|
||||
/** 消息解析器 - 将WebSocket消息转换为日志文本 */
|
||||
messageParser: (message: any) => string | null;
|
||||
|
||||
/** 自动连接 */
|
||||
autoConnect?: boolean;
|
||||
|
||||
/** 最大重连次数 */
|
||||
maxReconnectAttempts?: number;
|
||||
|
||||
/** 重连间隔(ms) */
|
||||
reconnectInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP轮询日志源配置
|
||||
*/
|
||||
export interface HttpPollingLogConfig {
|
||||
/** 获取日志的API函数 */
|
||||
fetchLogs: (params: any) => Promise<{
|
||||
logs: Array<{ timestamp: string; content: string }>;
|
||||
referenceForPrevious?: { referenceTimestamp: string } | null;
|
||||
referenceForNext?: { referenceTimestamp: string } | null;
|
||||
}>;
|
||||
|
||||
/** 初始请求参数 */
|
||||
initialParams: any;
|
||||
|
||||
/** 自动刷新 */
|
||||
autoRefresh?: boolean;
|
||||
|
||||
/** 刷新间隔(秒) */
|
||||
refreshInterval?: number;
|
||||
|
||||
/** 日志格式化函数 */
|
||||
formatLog?: (log: { timestamp: string; content: string }) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志流控制API
|
||||
*/
|
||||
export interface LogStreamControlAPI {
|
||||
/** 连接 */
|
||||
connect: () => void;
|
||||
|
||||
/** 断开连接 */
|
||||
disconnect: () => void;
|
||||
|
||||
/** 发送消息 */
|
||||
send: (message: string) => void;
|
||||
|
||||
/** 获取连接状态 */
|
||||
getStatus: () => LogStreamStatus;
|
||||
|
||||
/** 获取错误信息 */
|
||||
getError: () => string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* XTerm日志查看器API
|
||||
*/
|
||||
export interface LogViewerAPI {
|
||||
/** 追加单条日志 */
|
||||
appendLog: (log: string) => void;
|
||||
|
||||
/** 批量追加日志 */
|
||||
appendLogs: (logs: string[]) => void;
|
||||
|
||||
/** 清空日志 */
|
||||
clear: () => void;
|
||||
|
||||
/** 滚动到底部 */
|
||||
scrollToBottom: () => void;
|
||||
|
||||
/** 搜索 */
|
||||
search: (text: string) => void;
|
||||
|
||||
/** 查找下一个 */
|
||||
findNext: () => void;
|
||||
|
||||
/** 查找上一个 */
|
||||
findPrevious: () => void;
|
||||
|
||||
/** 获取行数 */
|
||||
getLineCount: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monaco日志查看器Props
|
||||
*/
|
||||
export interface MonacoLogViewerProps {
|
||||
/** 自动滚动 */
|
||||
autoScroll?: boolean;
|
||||
|
||||
/** 字体大小 */
|
||||
fontSize?: number;
|
||||
|
||||
/** 主题 */
|
||||
theme?: LogViewerTheme;
|
||||
|
||||
/** 高度 */
|
||||
height?: string | number;
|
||||
|
||||
/** 就绪回调 */
|
||||
onReady?: (api: LogViewerAPI) => void;
|
||||
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
|
||||
/** 自定义样式 */
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志流查看器Props
|
||||
*/
|
||||
export interface LogStreamViewerProps {
|
||||
/** 日志数据源策略 */
|
||||
dataSource: LogDataSource;
|
||||
|
||||
/** 自定义控制栏 */
|
||||
customToolbar?: React.ReactNode;
|
||||
|
||||
/** Monaco配置 */
|
||||
monacoConfig?: Partial<MonacoLogViewerProps>;
|
||||
|
||||
/** 就绪回调 - 暴露控制API */
|
||||
onReady?: (controlApi: LogStreamControlAPI, logApi: LogViewerAPI) => void;
|
||||
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* 支持 SSH、K8s Pod 等多种终端场景
|
||||
*/
|
||||
|
||||
export type TerminalType = 'ssh' | 'k8s-pod' | 'docker-container';
|
||||
export type TerminalType = 'ssh' | 'k8s-pod' | 'docker-container' | 'log';
|
||||
|
||||
export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error';
|
||||
|
||||
|
||||
@ -222,11 +222,12 @@ export const DraggableWindow: React.FC<DraggableWindowProps> = ({
|
||||
{/* 调整大小手柄 */}
|
||||
{!isMaximized && (
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize"
|
||||
className="absolute bottom-0 right-0 w-6 h-6 cursor-nwse-resize z-50"
|
||||
onMouseDown={handleResizeStart}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, transparent 50%, #999 50%)',
|
||||
background: 'linear-gradient(135deg, transparent 50%, rgba(100, 116, 139, 0.5) 50%)',
|
||||
}}
|
||||
title="拖动调整窗口大小"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -10,7 +10,6 @@ import { getLanguageIcon } from '../utils/languageIcons';
|
||||
import type { ApplicationConfig, DeployEnvironment, DeployRecord } from '../types';
|
||||
import { DeployFlowGraphModal } from './DeployFlowGraphModal';
|
||||
import DeploymentFormModal from './DeploymentFormModal';
|
||||
import { LogViewerDialog } from './LogViewerDialog';
|
||||
import { DeployTabContent } from './DeployTabContent';
|
||||
import { RuntimeTabContent } from './RuntimeTabContent';
|
||||
|
||||
@ -32,7 +31,6 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
|
||||
const [selectedDeployRecordId, setSelectedDeployRecordId] = useState<number | null>(null);
|
||||
const [flowModalOpen, setFlowModalOpen] = useState(false);
|
||||
const [deployDialogOpen, setDeployDialogOpen] = useState(false);
|
||||
const [logDialogOpen, setLogDialogOpen] = useState(false);
|
||||
|
||||
const languageConfig = getLanguageIcon(app.language);
|
||||
|
||||
@ -149,14 +147,6 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
|
||||
deployRecordId={selectedDeployRecordId}
|
||||
onOpenChange={setFlowModalOpen}
|
||||
/>
|
||||
|
||||
{/* 日志查看对话框 */}
|
||||
<LogViewerDialog
|
||||
open={logDialogOpen}
|
||||
onOpenChange={setLogDialogOpen}
|
||||
app={app}
|
||||
environment={environment}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -11,8 +11,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { AlertCircle, Clock, FileText } from 'lucide-react';
|
||||
import { getDeployNodeLogs } from '../service';
|
||||
import type { DeployNodeLogDTO } from '../types';
|
||||
import LogViewer from '@/components/LogViewer';
|
||||
import type { StructuredLog } from '@/components/LogViewer/types';
|
||||
import { StaticLogViewer } from '@/components/LogViewer';
|
||||
|
||||
interface DeployNodeLogDialogProps {
|
||||
open: boolean;
|
||||
@ -109,13 +108,14 @@ const DeployNodeLogDialog: React.FC<DeployNodeLogDialogProps> = ({
|
||||
<p className="text-sm mt-2">日志通常保留 7 天,请检查部署时间</p>
|
||||
</div>
|
||||
) : logData?.logs && logData.logs.length > 0 ? (
|
||||
<LogViewer
|
||||
logs={logData.logs as StructuredLog[]}
|
||||
<StaticLogViewer
|
||||
content={logData.logs.map((log: any) =>
|
||||
typeof log === 'string' ? log : `[${log.timestamp || ''}] ${log.content || log.message || ''}`
|
||||
)}
|
||||
loading={loading}
|
||||
onRefresh={fetchLogs}
|
||||
theme="light"
|
||||
theme="vs-light"
|
||||
autoScroll={true}
|
||||
showToolbar={true}
|
||||
fontSize={12}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
|
||||
@ -1,111 +0,0 @@
|
||||
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 w-full overflow-x-auto overflow-y-auto font-mono text-xs bg-gray-950 text-gray-100 p-3 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar-track]:bg-gray-900 [&::-webkit-scrollbar-thumb]:bg-gray-700 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:hover:bg-gray-600"
|
||||
>
|
||||
{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,349 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
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;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
app: ApplicationConfig;
|
||||
environment: DeployEnvironment;
|
||||
}
|
||||
|
||||
export const LogViewerDialog: React.FC<LogViewerDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
app,
|
||||
environment,
|
||||
}) => {
|
||||
const [lines, setLines] = useState(100);
|
||||
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 = () => {
|
||||
switch (app.runtimeType) {
|
||||
case 'K8S':
|
||||
return { icon: Box, color: 'text-purple-600', bg: 'bg-purple-100', label: 'Kubernetes' };
|
||||
case 'DOCKER':
|
||||
return { icon: Container, color: 'text-orange-600', bg: 'bg-orange-100', label: 'Docker' };
|
||||
case 'SERVER':
|
||||
return { icon: Server, color: 'text-gray-600', bg: 'bg-gray-100', label: '服务器' };
|
||||
default:
|
||||
return { icon: Server, color: 'text-gray-600', bg: 'bg-gray-100', label: '未知' };
|
||||
}
|
||||
};
|
||||
|
||||
const runtimeConfig = getRuntimeIcon();
|
||||
const RuntimeIcon = runtimeConfig.icon;
|
||||
|
||||
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 { color: 'text-gray-500', label: '未连接' };
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-6xl h-[85vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="pl-6 pr-16 pt-6 pb-4 border-b flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* 左侧:标题信息 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<ScrollText className="h-5 w-5" />
|
||||
<span className="text-lg font-semibold">{app.applicationName} - 日志查看</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<Badge variant="outline" className={runtimeConfig.bg}>
|
||||
<RuntimeIcon className={`h-3 w-3 mr-1 ${runtimeConfig.color}`} />
|
||||
{runtimeConfig.label}
|
||||
</Badge>
|
||||
<span className="text-sm font-normal text-muted-foreground">{environment.environmentName}</span>
|
||||
<Circle className={`h-2 w-2 ${statusIndicator.color} fill-current`} />
|
||||
</div>
|
||||
|
||||
{/* 右侧:控制元素 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={lines}
|
||||
onChange={(e) => setLines(Number(e.target.value))}
|
||||
min={10}
|
||||
max={1000}
|
||||
placeholder="行数"
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
|
||||
{app.runtimeType === 'K8S' && (
|
||||
<>
|
||||
{loadingPods ? (
|
||||
<div className="h-8 w-48 flex items-center justify-center border rounded-md">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : podNames.length > 0 ? (
|
||||
<Select value={podName} onValueChange={setPodName}>
|
||||
<SelectTrigger className="h-8 w-48">
|
||||
<SelectValue placeholder="选择Pod" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{podNames.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={podName}
|
||||
onChange={(e) => setPodName(e.target.value)}
|
||||
placeholder="无可用Pod"
|
||||
className="h-8 w-48"
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<TooltipProvider>
|
||||
<div className="flex gap-1">
|
||||
{/* 启动/恢复/重试按钮 */}
|
||||
{(status === LogStreamStatus.DISCONNECTED ||
|
||||
status === LogStreamStatus.CONNECTED ||
|
||||
status === LogStreamStatus.ERROR) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{status === LogStreamStatus.ERROR ? '重试' : '启动'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 连接中按钮 */}
|
||||
{status === LogStreamStatus.CONNECTING && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" disabled className="h-8 w-8 p-0">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>连接中</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 恢复按钮(暂停状态) */}
|
||||
{status === LogStreamStatus.PAUSED && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" onClick={resume} className="h-8 w-8 p-0">
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>恢复</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 暂停按钮(流式传输中) */}
|
||||
{status === LogStreamStatus.STREAMING && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" onClick={pause} className="h-8 w-8 p-0">
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>暂停</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 停止按钮 */}
|
||||
{(status === LogStreamStatus.CONNECTING ||
|
||||
status === LogStreamStatus.STREAMING ||
|
||||
status === LogStreamStatus.PAUSED) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" onClick={stop} className="h-8 w-8 p-0">
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>停止</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 清空按钮 */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={clearLogs}
|
||||
disabled={status === LogStreamStatus.CONNECTING}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>清空</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
|
||||
{/* 日志显示区域 */}
|
||||
<div className="flex-1 overflow-hidden px-6 py-4">
|
||||
<LogStreamViewer
|
||||
logs={logs}
|
||||
status={status}
|
||||
error={error}
|
||||
autoScroll={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,465 +0,0 @@
|
||||
/**
|
||||
* 日志查看窗口组件
|
||||
* 用于在可拖动窗口中显示日志流
|
||||
*/
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Server,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
Trash2,
|
||||
Loader2,
|
||||
ScrollText,
|
||||
Circle,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} 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 { ConnectionStatus } from '@/components/Terminal';
|
||||
import request from '@/utils/request';
|
||||
|
||||
interface LogViewerWindowProps {
|
||||
windowId: string;
|
||||
app: ApplicationConfig;
|
||||
environment: DeployEnvironment;
|
||||
onCloseReady: () => void;
|
||||
onStatusChange: (status: ConnectionStatus) => void;
|
||||
updateWindowTitle: (titleNode: React.ReactNode) => void;
|
||||
}
|
||||
|
||||
export const LogViewerWindow: React.FC<LogViewerWindowProps> = ({
|
||||
windowId,
|
||||
app,
|
||||
environment,
|
||||
onCloseReady,
|
||||
onStatusChange,
|
||||
updateWindowTitle,
|
||||
}) => {
|
||||
const [lines, setLines] = useState(500);
|
||||
const [podName, setPodName] = useState('');
|
||||
const [podNames, setPodNames] = useState<string[]>([]);
|
||||
const [loadingPods, setLoadingPods] = useState(false);
|
||||
const [isControlBarCollapsed, setIsControlBarCollapsed] = useState(false);
|
||||
|
||||
const closeAllRef = useRef<(() => void) | null>(null);
|
||||
const onCloseReadyRef = useRef(onCloseReady);
|
||||
const onStatusChangeRef = useRef(onStatusChange);
|
||||
const updateWindowTitleRef = useRef(updateWindowTitle);
|
||||
|
||||
// 保持最新的回调引用
|
||||
useEffect(() => {
|
||||
onCloseReadyRef.current = onCloseReady;
|
||||
}, [onCloseReady]);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChangeRef.current = onStatusChange;
|
||||
}, [onStatusChange]);
|
||||
|
||||
useEffect(() => {
|
||||
updateWindowTitleRef.current = updateWindowTitle;
|
||||
}, [updateWindowTitle]);
|
||||
|
||||
// 使用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 (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);
|
||||
});
|
||||
}
|
||||
}, [app.runtimeType, app.teamApplicationId]);
|
||||
|
||||
// 窗口打开时连接
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 连接成功后自动启动日志流
|
||||
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]);
|
||||
|
||||
// 同步状态到窗口管理器
|
||||
useEffect(() => {
|
||||
const connectionStatus: ConnectionStatus =
|
||||
status === LogStreamStatus.CONNECTED || status === LogStreamStatus.STREAMING ? 'connected' :
|
||||
status === LogStreamStatus.CONNECTING ? 'connecting' :
|
||||
status === LogStreamStatus.ERROR ? 'error' :
|
||||
'disconnected';
|
||||
|
||||
onStatusChangeRef.current(connectionStatus);
|
||||
}, [status]);
|
||||
|
||||
// 注册优雅关闭方法
|
||||
useEffect(() => {
|
||||
const closeMethodName = `__closeSSH_${windowId}`;
|
||||
(window as any)[closeMethodName] = () => {
|
||||
console.log(`[LogViewerWindow] 调用优雅关闭方法: ${windowId}`);
|
||||
// 先断开连接
|
||||
if (closeAllRef.current) {
|
||||
closeAllRef.current();
|
||||
}
|
||||
disconnect();
|
||||
// 再通知窗口可以关闭
|
||||
onCloseReadyRef.current();
|
||||
};
|
||||
console.log(`[LogViewerWindow] 注册优雅关闭方法: ${closeMethodName}`);
|
||||
|
||||
// 保存关闭方法
|
||||
closeAllRef.current = () => {
|
||||
disconnect();
|
||||
};
|
||||
|
||||
return () => {
|
||||
delete (window as any)[closeMethodName];
|
||||
console.log(`[LogViewerWindow] 注销优雅关闭方法: ${closeMethodName}`);
|
||||
};
|
||||
}, [windowId, disconnect]);
|
||||
|
||||
const getRuntimeIcon = () => {
|
||||
switch (app.runtimeType) {
|
||||
case 'K8S':
|
||||
return { icon: Box, color: 'text-purple-600', bg: 'bg-purple-100', label: 'Kubernetes' };
|
||||
case 'DOCKER':
|
||||
return { icon: Container, color: 'text-orange-600', bg: 'bg-orange-100', label: 'Docker' };
|
||||
case 'SERVER':
|
||||
return { icon: Server, color: 'text-gray-600', bg: 'bg-gray-100', label: '服务器' };
|
||||
default:
|
||||
return { icon: Server, color: 'text-gray-600', bg: 'bg-gray-100', label: '未知' };
|
||||
}
|
||||
};
|
||||
|
||||
// 构建动态标题
|
||||
const buildTitle = useCallback(() => {
|
||||
const runtimeConfig = getRuntimeIcon();
|
||||
const RuntimeIcon = runtimeConfig.icon;
|
||||
|
||||
let statusColor = 'text-gray-500';
|
||||
switch (status) {
|
||||
case LogStreamStatus.CONNECTING:
|
||||
statusColor = 'text-yellow-500';
|
||||
break;
|
||||
case LogStreamStatus.CONNECTED:
|
||||
statusColor = 'text-blue-500';
|
||||
break;
|
||||
case LogStreamStatus.STREAMING:
|
||||
statusColor = 'text-green-500';
|
||||
break;
|
||||
case LogStreamStatus.PAUSED:
|
||||
statusColor = 'text-orange-500';
|
||||
break;
|
||||
case LogStreamStatus.STOPPED:
|
||||
statusColor = 'text-gray-500';
|
||||
break;
|
||||
case LogStreamStatus.ERROR:
|
||||
statusColor = 'text-red-500';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 状态指示点 */}
|
||||
<Circle className={`h-2.5 w-2.5 fill-current ${statusColor}`} />
|
||||
|
||||
{/* 运行时图标 */}
|
||||
<RuntimeIcon className={`h-4 w-4 ${runtimeConfig.color}`} />
|
||||
|
||||
{/* 应用名称 */}
|
||||
<span>{app.applicationName}</span>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<span className="text-muted-foreground">|</span>
|
||||
|
||||
{/* 日志查看标识 */}
|
||||
<ScrollText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm font-normal">日志查看</span>
|
||||
</div>
|
||||
);
|
||||
}, [app.applicationName, app.runtimeType, status]);
|
||||
|
||||
// 状态变化时更新标题
|
||||
useEffect(() => {
|
||||
console.log('[LogViewerWindow] 更新标题, status:', status, 'updateWindowTitleRef:', !!updateWindowTitleRef.current);
|
||||
if (updateWindowTitleRef.current) {
|
||||
updateWindowTitleRef.current(buildTitle());
|
||||
}
|
||||
}, [buildTitle, status]);
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col h-full w-full">
|
||||
{/* 紧凑控制栏 - 收缩时完全隐藏 */}
|
||||
<div className={isControlBarCollapsed ? 'hidden' : 'flex-shrink-0 border-b bg-muted/30'}>
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<TooltipProvider>
|
||||
{/* 折叠/展开按钮 */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsControlBarCollapsed(!isControlBarCollapsed)}
|
||||
className={`p-0 transition-all ${isControlBarCollapsed ? 'h-5 w-5' : 'h-7 w-7'}`}
|
||||
>
|
||||
{isControlBarCollapsed ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isControlBarCollapsed ? '展开控制栏' : '折叠控制栏'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* 控制按钮区域 - 折叠时隐藏 */}
|
||||
{!isControlBarCollapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={lines}
|
||||
onChange={(e) => setLines(Number(e.target.value))}
|
||||
min={10}
|
||||
max={1000}
|
||||
placeholder="行数"
|
||||
className="h-7 w-16 text-xs"
|
||||
/>
|
||||
|
||||
{app.runtimeType === 'K8S' && (
|
||||
<>
|
||||
{loadingPods ? (
|
||||
<div className="h-7 w-40 flex items-center justify-center border rounded-md bg-background">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : podNames.length > 0 ? (
|
||||
<Select value={podName} onValueChange={setPodName}>
|
||||
<SelectTrigger className="h-7 w-40 text-xs">
|
||||
<SelectValue placeholder="选择Pod" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
{podNames.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={podName}
|
||||
onChange={(e) => setPodName(e.target.value)}
|
||||
placeholder="无可用Pod"
|
||||
className="h-7 w-40 text-xs"
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-1">
|
||||
{/* 启动/恢复/重试按钮 */}
|
||||
{(status === LogStreamStatus.DISCONNECTED ||
|
||||
status === LogStreamStatus.CONNECTED ||
|
||||
status === LogStreamStatus.ERROR) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{status === LogStreamStatus.ERROR ? '重试' : '启动'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 连接中按钮 */}
|
||||
{status === LogStreamStatus.CONNECTING && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" disabled className="h-7 w-7 p-0">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>连接中</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 恢复按钮(暂停状态) */}
|
||||
{status === LogStreamStatus.PAUSED && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" onClick={resume} className="h-7 w-7 p-0">
|
||||
<Play className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>恢复</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 暂停按钮(流式传输中) */}
|
||||
{status === LogStreamStatus.STREAMING && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" onClick={pause} className="h-7 w-7 p-0">
|
||||
<Pause className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>暂停</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 停止按钮 */}
|
||||
{(status === LogStreamStatus.CONNECTING ||
|
||||
status === LogStreamStatus.STREAMING ||
|
||||
status === LogStreamStatus.PAUSED) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" onClick={stop} className="h-7 w-7 p-0">
|
||||
<Square className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>停止</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 清空按钮 */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={clearLogs}
|
||||
disabled={status === LogStreamStatus.CONNECTING}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>清空</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日志显示区域 - 无padding直接填充 */}
|
||||
<div className="flex-1 w-full overflow-hidden relative">
|
||||
{/* 悬浮展开按钮 - 收缩时显示 */}
|
||||
{isControlBarCollapsed && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsControlBarCollapsed(false)}
|
||||
className="absolute top-2 right-2 z-10 h-6 w-6 p-0 bg-background/80 backdrop-blur-sm hover:bg-background"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>展开控制栏</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<LogStreamViewer
|
||||
logs={logs}
|
||||
status={status}
|
||||
error={error}
|
||||
autoScroll={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,13 +1,45 @@
|
||||
/**
|
||||
* 日志窗口管理器
|
||||
* 使用 TerminalWindowManager 支持多窗口、拖拽、调整大小
|
||||
* 直接集成LogStreamViewer,无需中间层
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { TerminalWindowManager } from '@/components/Terminal';
|
||||
import { Box, Container, Server, ScrollText } from 'lucide-react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Server,
|
||||
ScrollText,
|
||||
Circle,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
Trash2,
|
||||
Loader2,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
LogStreamViewer,
|
||||
WebSocketLogSource,
|
||||
type LogStreamControlAPI,
|
||||
type LogViewerAPI,
|
||||
LogStreamStatus,
|
||||
} from '@/components/LogViewer';
|
||||
import type { ApplicationConfig, DeployEnvironment } from '../types';
|
||||
import { LogViewerWindow } from './LogViewerWindow';
|
||||
import type { ConnectionStatus } from '@/components/Terminal';
|
||||
import request from '@/utils/request';
|
||||
|
||||
/**
|
||||
* 日志窗口资源类型
|
||||
@ -21,6 +53,514 @@ interface LogWindowManagerProps {
|
||||
onOpenWindow?: (windowId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志查看器内容组件
|
||||
* 包含所有业务逻辑和UI
|
||||
*/
|
||||
const LogViewerContent: React.FC<{
|
||||
windowId: string;
|
||||
app: ApplicationConfig;
|
||||
onCloseReady: () => void;
|
||||
onStatusChange: (status: ConnectionStatus) => void;
|
||||
updateWindowTitle: (titleNode: React.ReactNode) => void;
|
||||
}> = ({
|
||||
windowId,
|
||||
app,
|
||||
onCloseReady,
|
||||
onStatusChange,
|
||||
updateWindowTitle,
|
||||
}) => {
|
||||
const [lines, setLines] = useState(500);
|
||||
const [podName, setPodName] = useState('');
|
||||
const [podNames, setPodNames] = useState<string[]>([]);
|
||||
const [loadingPods, setLoadingPods] = useState(app.runtimeType === 'K8S');
|
||||
const [isControlBarCollapsed, setIsControlBarCollapsed] = useState(false);
|
||||
const [status, setStatus] = useState<LogStreamStatus>(LogStreamStatus.DISCONNECTED);
|
||||
|
||||
const controlApiRef = useRef<LogStreamControlAPI | null>(null);
|
||||
const logApiRef = useRef<LogViewerAPI | null>(null);
|
||||
const handleStartRef = useRef<(() => void) | null>(null);
|
||||
const loadingPodsRef = useRef(loadingPods);
|
||||
const onCloseReadyRef = useRef(onCloseReady);
|
||||
const onStatusChangeRef = useRef(onStatusChange);
|
||||
const updateWindowTitleRef = useRef(updateWindowTitle);
|
||||
|
||||
// 保持最新的回调引用和状态
|
||||
useEffect(() => {
|
||||
loadingPodsRef.current = loadingPods;
|
||||
}, [loadingPods]);
|
||||
|
||||
useEffect(() => {
|
||||
onCloseReadyRef.current = onCloseReady;
|
||||
}, [onCloseReady]);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChangeRef.current = onStatusChange;
|
||||
}, [onStatusChange]);
|
||||
|
||||
useEffect(() => {
|
||||
updateWindowTitleRef.current = updateWindowTitle;
|
||||
}, [updateWindowTitle]);
|
||||
|
||||
// 获取K8S Pod列表
|
||||
useEffect(() => {
|
||||
if (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);
|
||||
});
|
||||
}
|
||||
}, [app.runtimeType, app.teamApplicationId]);
|
||||
|
||||
// 同步状态到窗口管理器
|
||||
useEffect(() => {
|
||||
const connectionStatus: ConnectionStatus =
|
||||
status === LogStreamStatus.CONNECTED || status === LogStreamStatus.STREAMING ? 'connected' :
|
||||
status === LogStreamStatus.CONNECTING ? 'connecting' :
|
||||
status === LogStreamStatus.ERROR ? 'error' :
|
||||
'disconnected';
|
||||
|
||||
onStatusChangeRef.current(connectionStatus);
|
||||
}, [status]);
|
||||
|
||||
// 注册优雅关闭方法
|
||||
useEffect(() => {
|
||||
const closeMethodName = `__closeSSH_${windowId}`;
|
||||
(window as any)[closeMethodName] = () => {
|
||||
controlApiRef.current?.disconnect();
|
||||
onCloseReadyRef.current();
|
||||
};
|
||||
|
||||
return () => {
|
||||
delete (window as any)[closeMethodName];
|
||||
};
|
||||
}, [windowId]);
|
||||
|
||||
// 格式化时间戳
|
||||
const formatTimestamp = (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;
|
||||
}
|
||||
};
|
||||
|
||||
// 控制按钮处理函数
|
||||
const handleStart = useCallback(() => {
|
||||
console.log('[LogWindowManager] handleStart called, controlApiRef:', !!controlApiRef.current);
|
||||
if (!controlApiRef.current) {
|
||||
console.log('[LogWindowManager] controlApiRef is null, cannot send START');
|
||||
return;
|
||||
}
|
||||
|
||||
logApiRef.current?.clear();
|
||||
|
||||
const startMessage = {
|
||||
type: 'START',
|
||||
data: {
|
||||
request: {
|
||||
lines,
|
||||
...(app.runtimeType === 'K8S' && podName ? { name: podName } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[LogWindowManager] Sending START message:', startMessage);
|
||||
controlApiRef.current.send(JSON.stringify(startMessage));
|
||||
setStatus(LogStreamStatus.STREAMING);
|
||||
}, [lines, app.runtimeType, podName]);
|
||||
|
||||
// 保持最新的handleStart引用
|
||||
useEffect(() => {
|
||||
handleStartRef.current = handleStart;
|
||||
}, [handleStart]);
|
||||
|
||||
// 创建WebSocket数据源
|
||||
const dataSource = useMemo(() => {
|
||||
console.log('[LogWindowManager] Creating new dataSource for app:', app.teamApplicationId, app.runtimeType);
|
||||
|
||||
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/${app.teamApplicationId}/logs/stream`;
|
||||
const url = token ? `${baseUrl}?token=${encodeURIComponent(token)}` : baseUrl;
|
||||
|
||||
const source = new WebSocketLogSource({
|
||||
url,
|
||||
messageParser: (data: any) => {
|
||||
try {
|
||||
const message = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
|
||||
// 只处理LOG类型消息
|
||||
if (message.type === 'LOG' && message.data?.response) {
|
||||
const { timestamp, content } = message.data.response;
|
||||
const formattedTime = formatTimestamp(timestamp);
|
||||
return `[${formattedTime}] ${content}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('[LogViewer] Parse error:', err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
autoConnect: false,
|
||||
});
|
||||
|
||||
// 注册状态变化回调
|
||||
source.onStatusChange((newStatus) => {
|
||||
console.log('[LogWindowManager] Status changed to:', newStatus);
|
||||
setStatus(newStatus);
|
||||
|
||||
// 收到CONNECTED状态后自动发送START消息
|
||||
if (newStatus === LogStreamStatus.CONNECTED) {
|
||||
// K8S应用需要等待Pod列表加载完成
|
||||
if (app.runtimeType === 'K8S' && loadingPodsRef.current) {
|
||||
console.log('[LogWindowManager] K8S waiting for pods, skip START');
|
||||
return;
|
||||
}
|
||||
|
||||
// 延迟发送START消息,确保controlApiRef已设置
|
||||
console.log('[LogWindowManager] CONNECTED received, sending START in 100ms...');
|
||||
setTimeout(() => {
|
||||
handleStartRef.current?.();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
return source;
|
||||
}, [app.teamApplicationId, app.runtimeType]);
|
||||
|
||||
// 获取运行时图标配置
|
||||
const getRuntimeIcon = () => {
|
||||
switch (app.runtimeType) {
|
||||
case 'K8S':
|
||||
return { icon: Box, color: 'text-purple-600', label: 'Kubernetes' };
|
||||
case 'DOCKER':
|
||||
return { icon: Container, color: 'text-orange-600', label: 'Docker' };
|
||||
case 'SERVER':
|
||||
return { icon: Server, color: 'text-gray-600', label: '服务器' };
|
||||
default:
|
||||
return { icon: Server, color: 'text-gray-600', label: '未知' };
|
||||
}
|
||||
};
|
||||
|
||||
// 构建动态标题
|
||||
const buildTitle = useCallback(() => {
|
||||
const runtimeConfig = getRuntimeIcon();
|
||||
const RuntimeIcon = runtimeConfig.icon;
|
||||
|
||||
let statusColor = 'text-gray-500';
|
||||
switch (status) {
|
||||
case LogStreamStatus.CONNECTING:
|
||||
statusColor = 'text-yellow-500';
|
||||
break;
|
||||
case LogStreamStatus.CONNECTED:
|
||||
case LogStreamStatus.STREAMING:
|
||||
statusColor = 'text-green-500';
|
||||
break;
|
||||
case LogStreamStatus.PAUSED:
|
||||
statusColor = 'text-orange-500';
|
||||
break;
|
||||
case LogStreamStatus.ERROR:
|
||||
statusColor = 'text-red-500';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Circle className={`h-2.5 w-2.5 fill-current ${statusColor}`} />
|
||||
<RuntimeIcon className={`h-4 w-4 ${runtimeConfig.color}`} />
|
||||
<span>{app.applicationName}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<ScrollText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm font-normal">日志查看</span>
|
||||
</div>
|
||||
);
|
||||
}, [app.applicationName, app.runtimeType, status]);
|
||||
|
||||
// 状态变化时更新标题
|
||||
useEffect(() => {
|
||||
if (updateWindowTitleRef.current) {
|
||||
updateWindowTitleRef.current(buildTitle());
|
||||
}
|
||||
}, [buildTitle, status]);
|
||||
|
||||
const handlePause = () => {
|
||||
if (!controlApiRef.current) return;
|
||||
|
||||
const pauseMessage = {
|
||||
type: 'CONTROL',
|
||||
data: { request: { action: 'PAUSE' } },
|
||||
};
|
||||
|
||||
controlApiRef.current.send(JSON.stringify(pauseMessage));
|
||||
setStatus(LogStreamStatus.PAUSED);
|
||||
};
|
||||
|
||||
const handleResume = () => {
|
||||
if (!controlApiRef.current) return;
|
||||
|
||||
const resumeMessage = {
|
||||
type: 'CONTROL',
|
||||
data: { request: { action: 'RESUME' } },
|
||||
};
|
||||
|
||||
controlApiRef.current.send(JSON.stringify(resumeMessage));
|
||||
setStatus(LogStreamStatus.STREAMING);
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (!controlApiRef.current) return;
|
||||
|
||||
const stopMessage = {
|
||||
type: 'CONTROL',
|
||||
data: { request: { action: 'STOP' } },
|
||||
};
|
||||
|
||||
controlApiRef.current.send(JSON.stringify(stopMessage));
|
||||
|
||||
setTimeout(() => {
|
||||
controlApiRef.current?.disconnect();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
logApiRef.current?.clear();
|
||||
};
|
||||
|
||||
// 自定义控制栏
|
||||
const customToolbar = (
|
||||
<div className={isControlBarCollapsed ? 'hidden' : 'border-b bg-muted/30'}>
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsControlBarCollapsed(!isControlBarCollapsed)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>折叠控制栏</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={lines}
|
||||
onChange={(e) => setLines(Number(e.target.value))}
|
||||
min={10}
|
||||
max={1000}
|
||||
placeholder="行数"
|
||||
className="h-7 w-16 text-xs"
|
||||
/>
|
||||
|
||||
{app.runtimeType === 'K8S' && (
|
||||
<>
|
||||
{loadingPods ? (
|
||||
<div className="h-7 w-40 flex items-center justify-center border rounded-md bg-background">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : podNames.length > 0 ? (
|
||||
<Select value={podName} onValueChange={setPodName}>
|
||||
<SelectTrigger className="h-7 w-40 text-xs">
|
||||
<SelectValue placeholder="选择Pod" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
{podNames.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={podName}
|
||||
onChange={(e) => setPodName(e.target.value)}
|
||||
placeholder="无可用Pod"
|
||||
className="h-7 w-40 text-xs"
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-1">
|
||||
{(status === LogStreamStatus.DISCONNECTED ||
|
||||
status === LogStreamStatus.CONNECTED ||
|
||||
status === LogStreamStatus.ERROR) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" onClick={handleStart} className="h-7 w-7 p-0">
|
||||
<Play className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{status === LogStreamStatus.ERROR ? '重试' : '启动'}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{status === LogStreamStatus.CONNECTING && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" disabled className="h-7 w-7 p-0">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>连接中</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{status === LogStreamStatus.PAUSED && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" onClick={handleResume} className="h-7 w-7 p-0">
|
||||
<Play className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>恢复</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{status === LogStreamStatus.STREAMING && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" onClick={handlePause} className="h-7 w-7 p-0">
|
||||
<Pause className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>暂停</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{(status === LogStreamStatus.CONNECTING ||
|
||||
status === LogStreamStatus.STREAMING ||
|
||||
status === LogStreamStatus.PAUSED) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" onClick={handleStop} className="h-7 w-7 p-0">
|
||||
<Square className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>停止</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleClear}
|
||||
disabled={status === LogStreamStatus.CONNECTING}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>清空</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// K8S应用等待Pod列表加载完成
|
||||
if (app.runtimeType === 'K8S' && loadingPods) {
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full items-center justify-center bg-gray-950">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<span className="text-sm text-muted-foreground">正在加载Pod列表...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full relative">
|
||||
{/* 悬浮展开按钮 */}
|
||||
{isControlBarCollapsed && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsControlBarCollapsed(false)}
|
||||
className="absolute top-2 right-2 z-10 h-6 w-6 p-0 bg-background/80 backdrop-blur-sm hover:bg-background"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>展开控制栏</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* 通用日志流组件 */}
|
||||
<LogStreamViewer
|
||||
dataSource={dataSource}
|
||||
customToolbar={customToolbar}
|
||||
monacoConfig={{
|
||||
autoScroll: true,
|
||||
theme: 'vs-dark',
|
||||
fontSize: 13,
|
||||
height: '100%',
|
||||
}}
|
||||
onReady={(controlApi, logApi) => {
|
||||
controlApiRef.current = controlApi;
|
||||
logApiRef.current = logApi;
|
||||
|
||||
// API就绪后,检查连接状态并自动启动
|
||||
const currentStatus = controlApi.getStatus();
|
||||
console.log('[LogWindowManager] onReady triggered, status:', currentStatus, 'runtimeType:', app.runtimeType);
|
||||
|
||||
if (currentStatus === LogStreamStatus.CONNECTED) {
|
||||
// K8S应用需要等待Pod列表加载完成
|
||||
if (app.runtimeType === 'K8S' && loadingPodsRef.current) {
|
||||
console.log('[LogWindowManager] K8S waiting for pods, skip START');
|
||||
return;
|
||||
}
|
||||
// 自动发送START消息
|
||||
console.log('[LogWindowManager] Sending START message in 100ms...');
|
||||
setTimeout(() => {
|
||||
console.log('[LogWindowManager] Calling handleStart');
|
||||
handleStartRef.current?.();
|
||||
}, 100);
|
||||
} else {
|
||||
console.log('[LogWindowManager] Status is not CONNECTED, current:', currentStatus);
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LogWindowManager: React.FC<LogWindowManagerProps> = ({ onOpenWindow }) => {
|
||||
// 获取运行时图标和配置
|
||||
const getRuntimeConfig = (runtimeType: string | null) => {
|
||||
@ -60,10 +600,9 @@ export const LogWindowManager: React.FC<LogWindowManagerProps> = ({ onOpenWindow
|
||||
getResourceId={(resource) => `${resource.app.teamApplicationId}-${resource.environment.environmentId}`}
|
||||
renderTerminal={(windowId, resource, { onCloseReady, onStatusChange, updateWindowTitle }) => {
|
||||
return (
|
||||
<LogViewerWindow
|
||||
<LogViewerContent
|
||||
windowId={windowId}
|
||||
app={resource.app}
|
||||
environment={resource.environment}
|
||||
onCloseReady={onCloseReady}
|
||||
onStatusChange={onStatusChange}
|
||||
updateWindowTitle={updateWindowTitle}
|
||||
|
||||
@ -1,282 +0,0 @@
|
||||
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,6 +372,3 @@ export interface DeployNodeLogDTO {
|
||||
expired: boolean;
|
||||
logs: LogEntry[];
|
||||
}
|
||||
|
||||
// 导出日志流相关类型
|
||||
export * from './types/logStream';
|
||||
|
||||
@ -1,94 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,7 @@ import { FileText, RefreshCw, Download } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getK8sPodLogs, getK8sPodsByDeployment } from '../service';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import LogViewer from '@/components/LogViewer';
|
||||
import { StaticLogViewer } from '@/components/LogViewer';
|
||||
import type { K8sPodResponse } from '../types';
|
||||
import { LOG_QUERY_CONSTANTS as LOG_CONSTANTS, LOG_COUNT_OPTIONS } from '../types';
|
||||
|
||||
@ -333,19 +333,9 @@ const PodLogDialog: React.FC<PodLogDialogProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<LogViewer
|
||||
content={logContent}
|
||||
loading={loading && !logContent}
|
||||
onRefresh={() => fetchLogs()}
|
||||
onDownload={handleDownload}
|
||||
theme="vs-dark"
|
||||
autoScroll={autoScrollEnabled}
|
||||
showToolbar={true}
|
||||
showLineNumbers={true}
|
||||
fontSize={10}
|
||||
onScrollToTop={loadPreviousPage}
|
||||
onScrollToBottom={loadNextPage}
|
||||
customToolbar={
|
||||
<>
|
||||
{/* 控制栏 */}
|
||||
<div className="border-b bg-muted/30 px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{containers.length > 1 && (
|
||||
@ -445,8 +435,19 @@ const PodLogDialog: React.FC<PodLogDialogProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 日志查看器 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<StaticLogViewer
|
||||
content={logContent}
|
||||
loading={loading && !logContent}
|
||||
theme="vs-dark"
|
||||
autoScroll={autoScrollEnabled}
|
||||
fontSize={10}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user