Files
Scoreboard/console/main/log-bus.js
T

87 lines
2.5 KiB
JavaScript

'use strict'
const fs = require('fs')
const path = require('path')
// 日志总线:内存环形缓冲供管理窗口实时查看,同时落一份文件供现场排障。
// 现场没有外网也没有终端,文件日志是事后定位问题的唯一线索。
class LogBus {
constructor(options = {}) {
const { maxLines = 2000, file = null, maxFileBytes = 4 * 1024 * 1024 } = options
this.maxLines = maxLines
this.maxFileBytes = maxFileBytes
this.lines = []
this.listeners = new Set()
// 子进程的输出是流式分片的,可能把一行拆到两次 data 事件里
this.pending = new Map()
this.stream = null
if (file) this.openFile(file)
}
openFile(file) {
try {
fs.mkdirSync(path.dirname(file), { recursive: true })
if (fs.existsSync(file) && fs.statSync(file).size > this.maxFileBytes) {
// Windows 上 rename 到已存在的目标会失败,先删掉上一份轮转文件
const rotated = `${file}.1`
try {
fs.rmSync(rotated, { force: true })
} catch {
// 删不掉就让 rename 失败,落到下面的「仅内存日志」兜底
}
fs.renameSync(file, rotated)
}
this.stream = fs.createWriteStream(file, { flags: 'a' })
} catch (err) {
this.stream = null
this.push('console', `日志文件不可写,仅保留内存日志:${err.message}`)
}
}
append(source, chunk) {
const text = `${this.pending.get(source) || ''}${String(chunk)}`
const parts = text.split(/\r?\n/)
// 最后一段是没有换行的残行,留到下一次拼接
this.pending.set(source, parts.pop() || '')
for (const part of parts) this.push(source, part)
}
push(source, message) {
const line = `[${new Date().toISOString()}] [${source}] ${message}`
this.lines.push(line)
if (this.lines.length > this.maxLines) {
this.lines.splice(0, this.lines.length - this.maxLines)
}
if (this.stream) this.stream.write(`${line}\n`)
for (const listener of this.listeners) {
try {
listener(line)
} catch {
// 单个订阅者出错不能拖垮日志链路
}
}
}
tail() {
return this.lines.slice()
}
clear() {
this.lines = []
}
onLine(listener) {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
close() {
if (this.stream) {
this.stream.end()
this.stream = null
}
}
}
module.exports = { LogBus }