File size: 8,253 Bytes
ddce7e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
/**
 * WebSocket 日志服务模块
 * 提供实时日志推送和日志文件管理
 */
import { WebSocketServer } from 'ws';
import fs from 'fs';
import path from 'path';
import { getDataDir } from './paths.js';

// 默认配置
const DEFAULT_LOG_MAX_SIZE_MB = 10;   // 单个日志文件最大 10MB
const DEFAULT_LOG_MAX_FILES = 5;      // 保留 5 个历史文件
const DEFAULT_LOG_MAX_MEMORY = 500;   // 内存中保留 500 条日志

// 日志目录
const dataDir = getDataDir();
const LOG_DIR = path.join(dataDir, 'logs');
const LOG_FILE = path.join(LOG_DIR, 'app.log');

// 确保日志目录存在
if (!fs.existsSync(LOG_DIR)) {
    fs.mkdirSync(LOG_DIR, { recursive: true });
}

class LogWebSocketServer {
    constructor() {
        this.wss = null;
        this.clients = new Set();
        this.logStore = [];
        this.currentLogSize = 0;

        // 配置(可在运行时更新)
        this.maxSizeMB = DEFAULT_LOG_MAX_SIZE_MB;
        this.maxFiles = DEFAULT_LOG_MAX_FILES;
        this.maxMemory = DEFAULT_LOG_MAX_MEMORY;

        // 初始化日志文件大小
        this._initLogFileSize();

        // 写入缓冲(避免频繁写入)
        this.writeBuffer = [];
        this.flushTimer = null;
        this.FLUSH_INTERVAL = 1000; // 1秒刷新一次
    }

    /**
     * 初始化获取当前日志文件大小
     */
    _initLogFileSize() {
        try {
            if (fs.existsSync(LOG_FILE)) {
                const stats = fs.statSync(LOG_FILE);
                this.currentLogSize = stats.size;
            }
        } catch (error) {
            this.currentLogSize = 0;
        }
    }

    /**
     * 更新配置
     */
    updateConfig(config) {
        if (config.logMaxSizeMB !== undefined) {
            this.maxSizeMB = config.logMaxSizeMB;
        }
        if (config.logMaxFiles !== undefined) {
            this.maxFiles = config.logMaxFiles;
        }
        if (config.logMaxMemory !== undefined) {
            this.maxMemory = config.logMaxMemory;
        }
    }

    /**
     * 初始化 WebSocket 服务器
     * @param {http.Server} server - HTTP 服务器实例
     */
    initialize(server) {
        this.wss = new WebSocketServer({ server, path: '/ws/logs' });

        this.wss.on('connection', (ws, req) => {
            this.clients.add(ws);

            // 发送最近的日志历史
            const recentLogs = this.logStore.slice(-50);
            if (recentLogs.length > 0) {
                ws.send(JSON.stringify({
                    type: 'history',
                    logs: recentLogs
                }));
            }

            ws.on('close', () => {
                this.clients.delete(ws);
            });

            ws.on('error', () => {
                this.clients.delete(ws);
            });
        });
    }

    /**
     * 广播日志到所有客户端
     */
    broadcast(entry) {
        const message = JSON.stringify({
            type: 'log',
            log: entry
        });

        for (const client of this.clients) {
            if (client.readyState === 1) { // OPEN
                try {
                    client.send(message);
                } catch (e) {
                    this.clients.delete(client);
                }
            }
        }
    }

    /**
     * 存储日志条目
     */
    storeLog(level, message) {
        const entry = {
            id: Date.now() + '-' + Math.random().toString(36).substr(2, 9),
            timestamp: new Date().toISOString(),
            level,
            message
        };

        // 存储到内存
        this.logStore.push(entry);
        while (this.logStore.length > this.maxMemory) {
            this.logStore.shift();
        }

        // 广播到 WebSocket 客户端
        this.broadcast(entry);

        // 添加到写入缓冲
        this._bufferWrite(entry);

        return entry;
    }

    /**
     * 缓冲写入(减少磁盘 I/O)
     */
    _bufferWrite(entry) {
        const line = `${entry.timestamp} [${entry.level}] ${entry.message}\n`;
        this.writeBuffer.push(line);

        // 设置定时刷新
        if (!this.flushTimer) {
            this.flushTimer = setTimeout(() => {
                this._flushBuffer();
            }, this.FLUSH_INTERVAL);
        }
    }

    /**
     * 刷新缓冲到文件
     */
    _flushBuffer() {
        if (this.writeBuffer.length === 0) {
            this.flushTimer = null;
            return;
        }

        const content = this.writeBuffer.join('');
        this.writeBuffer = [];
        this.flushTimer = null;

        const contentSize = Buffer.byteLength(content, 'utf8');

        // 检查是否需要轮转
        if (this.currentLogSize + contentSize > this.maxSizeMB * 1024 * 1024) {
            this._rotateLog();
        }

        // 追加写入
        try {
            fs.appendFileSync(LOG_FILE, content, 'utf8');
            this.currentLogSize += contentSize;
        } catch (error) {
            console.error('写入日志文件失败:', error.message);
        }
    }

    /**
     * 日志轮转
     */
    _rotateLog() {
        try {
            // 删除最旧的文件
            for (let i = this.maxFiles - 1; i >= 1; i--) {
                const oldFile = `${LOG_FILE}.${i}`;
                const newFile = `${LOG_FILE}.${i + 1}`;
                if (fs.existsSync(oldFile)) {
                    if (i === this.maxFiles - 1) {
                        fs.unlinkSync(oldFile);
                    } else {
                        fs.renameSync(oldFile, newFile);
                    }
                }
            }

            // 重命名当前文件
            if (fs.existsSync(LOG_FILE)) {
                fs.renameSync(LOG_FILE, `${LOG_FILE}.1`);
            }

            this.currentLogSize = 0;
        } catch (error) {
            console.error('日志轮转失败:', error.message);
        }
    }

    /**
     * 获取日志(API 查询)
     */
    getLogs(options = {}) {
        const { level, search, limit = 100, offset = 0 } = options;

        let filtered = [...this.logStore];

        // 过滤分隔符
        filtered = filtered.filter(log => !this._isSeparator(log.message));

        if (level && level !== 'all') {
            filtered = filtered.filter(log => log.level === level);
        }

        if (search) {
            const searchLower = search.toLowerCase();
            filtered = filtered.filter(log =>
                log.message.toLowerCase().includes(searchLower)
            );
        }

        filtered.reverse();

        return {
            logs: filtered.slice(offset, offset + limit),
            total: filtered.length
        };
    }

    /**
     * 判断是否为分隔符
     */
    _isSeparator(message) {
        if (!message || typeof message !== 'string') return false;
        const trimmed = message.trim();
        if (trimmed.length < 3) return false;
        return /^[═─=\-*_~]+$/.test(trimmed);
    }

    /**
     * 清空日志
     */
    clearLogs() {
        this.logStore.length = 0;
        // 广播清空事件
        for (const client of this.clients) {
            if (client.readyState === 1) {
                try {
                    client.send(JSON.stringify({ type: 'clear' }));
                } catch (e) { }
            }
        }
    }

    /**
     * 获取统计
     */
    getLogStats() {
        const stats = { total: 0, info: 0, warn: 0, error: 0, request: 0, debug: 0 };

        for (const log of this.logStore) {
            if (this._isSeparator(log.message)) continue;
            stats.total++;
            if (stats[log.level] !== undefined) {
                stats[log.level]++;
            }
        }

        return stats;
    }

    /**
     * 关闭服务
     */
    close() {
        // 刷新剩余缓冲
        if (this.flushTimer) {
            clearTimeout(this.flushTimer);
            this._flushBuffer();
        }

        // 关闭 WebSocket
        if (this.wss) {
            for (const client of this.clients) {
                client.close();
            }
            this.wss.close();
        }
    }
}

// 单例
export const logWsServer = new LogWebSocketServer();
export default logWsServer;