278 lines
8.5 KiB
TypeScript
278 lines
8.5 KiB
TypeScript
import { ConfigStore } from './ConfigStore'
|
||
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
|
||
import { join, basename } from 'path'
|
||
|
||
export interface LogFile {
|
||
name: string
|
||
path: string
|
||
size: number
|
||
modifiedTime: Date
|
||
type: 'nginx' | 'nginx-error' | 'nginx-access' | 'php' | 'mysql' | 'mysql-error' | 'site-access' | 'site-error'
|
||
}
|
||
|
||
export interface LogContent {
|
||
content: string
|
||
totalLines: number
|
||
fileSize: number
|
||
}
|
||
|
||
export class LogManager {
|
||
private configStore: ConfigStore
|
||
|
||
constructor(configStore: ConfigStore) {
|
||
this.configStore = configStore
|
||
}
|
||
|
||
/**
|
||
* 获取所有可用的日志文件列表
|
||
*/
|
||
async getLogFiles(): Promise<{ nginx: LogFile[], php: LogFile[], mysql: LogFile[], sites: LogFile[] }> {
|
||
const result = {
|
||
nginx: [] as LogFile[],
|
||
php: [] as LogFile[],
|
||
mysql: [] as LogFile[],
|
||
sites: [] as LogFile[]
|
||
}
|
||
|
||
// Nginx 日志
|
||
const nginxPath = this.configStore.getNginxPath()
|
||
const nginxLogsDir = join(nginxPath, 'logs')
|
||
if (existsSync(nginxLogsDir)) {
|
||
const files = this.scanLogDir(nginxLogsDir)
|
||
for (const file of files) {
|
||
if (file.name.includes('error')) {
|
||
result.nginx.push({ ...file, type: 'nginx-error' })
|
||
} else if (file.name.includes('access')) {
|
||
result.nginx.push({ ...file, type: 'nginx-access' })
|
||
} else {
|
||
result.nginx.push({ ...file, type: 'nginx' })
|
||
}
|
||
}
|
||
}
|
||
|
||
// PHP 日志 - 检查每个 PHP 版本的日志
|
||
const phpVersions = this.configStore.get('phpVersions') || []
|
||
for (const version of phpVersions) {
|
||
const phpPath = this.configStore.getPhpPath(version)
|
||
const phpLogsDir = join(phpPath, 'logs')
|
||
if (existsSync(phpLogsDir)) {
|
||
const files = this.scanLogDir(phpLogsDir)
|
||
for (const file of files) {
|
||
result.php.push({ ...file, type: 'php', name: `[${version}] ${file.name}` })
|
||
}
|
||
}
|
||
// 也检查 php.ini 中配置的 error_log
|
||
const phpErrorLog = join(phpPath, 'php_errors.log')
|
||
if (existsSync(phpErrorLog)) {
|
||
const stat = statSync(phpErrorLog)
|
||
result.php.push({
|
||
name: `[${version}] php_errors.log`,
|
||
path: phpErrorLog,
|
||
size: stat.size,
|
||
modifiedTime: stat.mtime,
|
||
type: 'php'
|
||
})
|
||
}
|
||
}
|
||
|
||
// MySQL 日志
|
||
const mysqlVersions = this.configStore.get('mysqlVersions') || []
|
||
for (const version of mysqlVersions) {
|
||
const mysqlPath = this.configStore.getMysqlPath(version)
|
||
// MySQL 日志通常在 data 目录下
|
||
const mysqlDataDir = join(mysqlPath, 'data')
|
||
if (existsSync(mysqlDataDir)) {
|
||
const files = readdirSync(mysqlDataDir)
|
||
for (const file of files) {
|
||
if (file.endsWith('.err') || file.endsWith('.log')) {
|
||
const filePath = join(mysqlDataDir, file)
|
||
const stat = statSync(filePath)
|
||
const logType = file.includes('error') || file.endsWith('.err') ? 'mysql-error' : 'mysql'
|
||
result.mysql.push({
|
||
name: `[${version}] ${file}`,
|
||
path: filePath,
|
||
size: stat.size,
|
||
modifiedTime: stat.mtime,
|
||
type: logType
|
||
})
|
||
}
|
||
}
|
||
}
|
||
// 也检查 logs 目录
|
||
const mysqlLogsDir = join(mysqlPath, 'logs')
|
||
if (existsSync(mysqlLogsDir)) {
|
||
const files = this.scanLogDir(mysqlLogsDir)
|
||
for (const file of files) {
|
||
result.mysql.push({ ...file, type: 'mysql', name: `[${version}] ${file.name}` })
|
||
}
|
||
}
|
||
}
|
||
|
||
// 站点日志 - Nginx sites logs
|
||
const sites = this.configStore.get('sites') || []
|
||
for (const site of sites) {
|
||
// 站点日志通常在 nginx/logs 目录下,以域名命名
|
||
const siteAccessLog = join(nginxLogsDir, `${site.domain}.access.log`)
|
||
const siteErrorLog = join(nginxLogsDir, `${site.domain}.error.log`)
|
||
|
||
if (existsSync(siteAccessLog)) {
|
||
const stat = statSync(siteAccessLog)
|
||
result.sites.push({
|
||
name: `${site.domain} - 访问日志`,
|
||
path: siteAccessLog,
|
||
size: stat.size,
|
||
modifiedTime: stat.mtime,
|
||
type: 'site-access'
|
||
})
|
||
}
|
||
|
||
if (existsSync(siteErrorLog)) {
|
||
const stat = statSync(siteErrorLog)
|
||
result.sites.push({
|
||
name: `${site.domain} - 错误日志`,
|
||
path: siteErrorLog,
|
||
size: stat.size,
|
||
modifiedTime: stat.mtime,
|
||
type: 'site-error'
|
||
})
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 读取日志文件内容
|
||
* @param logPath 日志文件路径
|
||
* @param lines 读取的行数(从末尾开始),默认 500 行
|
||
*/
|
||
async readLog(logPath: string, lines: number = 500): Promise<LogContent> {
|
||
if (!existsSync(logPath)) {
|
||
return { content: '日志文件不存在', totalLines: 0, fileSize: 0 }
|
||
}
|
||
|
||
try {
|
||
const stat = statSync(logPath)
|
||
const fileSize = stat.size
|
||
|
||
// 如果文件小于 1MB,直接读取全部内容
|
||
if (fileSize < 1024 * 1024) {
|
||
const content = readFileSync(logPath, 'utf-8')
|
||
const allLines = content.split('\n')
|
||
const totalLines = allLines.length
|
||
|
||
// 取最后 N 行
|
||
const lastLines = allLines.slice(-lines).join('\n')
|
||
return { content: lastLines, totalLines, fileSize }
|
||
}
|
||
|
||
// 大文件:从末尾读取
|
||
const content = await this.readLastLines(logPath, lines)
|
||
return { content, totalLines: lines, fileSize }
|
||
} catch (error: any) {
|
||
return { content: `读取日志失败: ${error.message}`, totalLines: 0, fileSize: 0 }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从文件末尾读取指定行数
|
||
*/
|
||
private async readLastLines(filePath: string, lines: number): Promise<string> {
|
||
const fs = await import('fs/promises')
|
||
const stat = await fs.stat(filePath)
|
||
const fileSize = stat.size
|
||
|
||
// 估算需要读取的字节数(假设每行约 200 字节)
|
||
const bytesToRead = Math.min(fileSize, lines * 200)
|
||
const startPosition = Math.max(0, fileSize - bytesToRead)
|
||
|
||
const buffer = Buffer.alloc(bytesToRead)
|
||
const fd = await fs.open(filePath, 'r')
|
||
await fd.read(buffer, 0, bytesToRead, startPosition)
|
||
await fd.close()
|
||
|
||
const content = buffer.toString('utf-8')
|
||
const allLines = content.split('\n')
|
||
|
||
// 第一行可能不完整,跳过
|
||
const completeLines = startPosition === 0 ? allLines : allLines.slice(1)
|
||
|
||
return completeLines.slice(-lines).join('\n')
|
||
}
|
||
|
||
/**
|
||
* 清空日志文件
|
||
*/
|
||
async clearLog(logPath: string): Promise<{ success: boolean, message: string }> {
|
||
if (!existsSync(logPath)) {
|
||
return { success: false, message: '日志文件不存在' }
|
||
}
|
||
|
||
try {
|
||
const fs = await import('fs/promises')
|
||
await fs.writeFile(logPath, '')
|
||
return { success: true, message: '日志已清空' }
|
||
} catch (error: any) {
|
||
return { success: false, message: `清空日志失败: ${error.message}` }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 扫描目录中的日志文件
|
||
*/
|
||
private scanLogDir(dir: string): LogFile[] {
|
||
const files: LogFile[] = []
|
||
|
||
if (!existsSync(dir)) {
|
||
return files
|
||
}
|
||
|
||
try {
|
||
const items = readdirSync(dir)
|
||
for (const item of items) {
|
||
const filePath = join(dir, item)
|
||
const stat = statSync(filePath)
|
||
|
||
if (stat.isFile() && (item.endsWith('.log') || item.endsWith('.err'))) {
|
||
files.push({
|
||
name: item,
|
||
path: filePath,
|
||
size: stat.size,
|
||
modifiedTime: stat.mtime,
|
||
type: 'nginx' // 默认类型,调用方会覆盖
|
||
})
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('扫描日志目录失败:', error)
|
||
}
|
||
|
||
return files.sort((a, b) => b.modifiedTime.getTime() - a.modifiedTime.getTime())
|
||
}
|
||
|
||
/**
|
||
* 获取日志文件路径(用于在文件管理器中打开)
|
||
*/
|
||
getLogDirectory(type: 'nginx' | 'php' | 'mysql' | 'sites', version?: string): string {
|
||
switch (type) {
|
||
case 'nginx':
|
||
return join(this.configStore.getNginxPath(), 'logs')
|
||
case 'php':
|
||
if (version) {
|
||
return join(this.configStore.getPhpPath(version), 'logs')
|
||
}
|
||
return join(this.configStore.getBasePath(), 'php')
|
||
case 'mysql':
|
||
if (version) {
|
||
return join(this.configStore.getMysqlPath(version), 'data')
|
||
}
|
||
return join(this.configStore.getBasePath(), 'mysql')
|
||
case 'sites':
|
||
return join(this.configStore.getNginxPath(), 'logs')
|
||
default:
|
||
return this.configStore.getBasePath()
|
||
}
|
||
}
|
||
}
|
||
|