This commit is contained in:
dengqichen 2025-12-17 17:23:14 +08:00
parent c1ac2201c1
commit f5154b3c66
24 changed files with 1835 additions and 1993 deletions

View 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();
}
}

View File

@ -0,0 +1,108 @@
/**
* -
*
*
* - WebSocketHTTP轮询等
* -
* - 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>
);
};

View 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>
);
};

View File

@ -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连接状态

View 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}
/>
);
};

View 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;
}
}
}

View File

@ -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 '';
};

View 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';

View File

@ -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;

View File

@ -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',
},
});
};

View File

@ -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;
}

View File

@ -3,7 +3,7 @@
* SSHK8s 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';

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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">

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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}

View File

@ -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,
};
}

View File

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

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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>