mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/mihomo-party-org/clash-party.git
synced 2026-09-20 08:03:39 +08:00
feat: add per-log-file size limit with truncation
This commit is contained in:
@@ -3,6 +3,7 @@ import { appConfigPath } from '../utils/dirs'
|
||||
import { parse, stringify } from '../utils/yaml'
|
||||
import { deepMerge } from '../utils/merge'
|
||||
import { defaultConfig } from '../utils/template'
|
||||
import { normalizeMaxLogFileSizeMB, setGlobalMaxLogFileSizeMB } from '../utils/logFile'
|
||||
|
||||
let appConfig: IAppConfig // config.yaml
|
||||
let appConfigWriteQueue: Promise<void> = Promise.resolve()
|
||||
@@ -13,9 +14,11 @@ export async function getAppConfig(force = false): Promise<IAppConfig> {
|
||||
const data = await readFile(appConfigPath(), 'utf-8')
|
||||
const parsedConfig = parse(data)
|
||||
const mergedConfig = deepMerge({ ...defaultConfig }, parsedConfig || {})
|
||||
mergedConfig.maxLogFileSize = normalizeMaxLogFileSizeMB(mergedConfig.maxLogFileSize)
|
||||
if (JSON.stringify(mergedConfig) !== JSON.stringify(parsedConfig)) {
|
||||
await writeFile(appConfigPath(), stringify(mergedConfig))
|
||||
}
|
||||
setGlobalMaxLogFileSizeMB(mergedConfig.maxLogFileSize)
|
||||
appConfig = mergedConfig
|
||||
})
|
||||
await appConfigWriteQueue
|
||||
@@ -30,6 +33,8 @@ export async function patchAppConfig(patch: Partial<IAppConfig>): Promise<void>
|
||||
appConfig.nameserverPolicy = patch.nameserverPolicy
|
||||
}
|
||||
appConfig = deepMerge(appConfig, patch)
|
||||
appConfig.maxLogFileSize = normalizeMaxLogFileSizeMB(appConfig.maxLogFileSize)
|
||||
setGlobalMaxLogFileSizeMB(appConfig.maxLogFileSize)
|
||||
await writeFile(appConfigPath(), stringify(appConfig))
|
||||
})
|
||||
await appConfigWriteQueue
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFile, rm, writeFile } from 'fs/promises'
|
||||
import { promisify } from 'util'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { createWriteStream, existsSync } from 'fs'
|
||||
import { existsSync } from 'fs'
|
||||
import chokidar, { FSWatcher } from 'chokidar'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { mainWindow } from '../window'
|
||||
@@ -28,6 +28,7 @@ import { startMonitor } from '../resolve/trafficMonitor'
|
||||
import { safeShowErrorBox } from '../utils/init'
|
||||
import i18next from '../../shared/i18n'
|
||||
import { managerLogger } from '../utils/logger'
|
||||
import { createCappedLogWritableStream } from '../utils/logFile'
|
||||
import {
|
||||
startMihomoTraffic,
|
||||
startMihomoConnections,
|
||||
@@ -210,9 +211,6 @@ async function prepareCore(detached: boolean, skipStop = false): Promise<CoreCon
|
||||
function spawnCoreProcess(config: CoreConfig): ChildProcess {
|
||||
const { corePath, workDir, ipcPath, cpuPriority, detached } = config
|
||||
|
||||
const stdout = createWriteStream(coreLogPath(), { flags: 'a' })
|
||||
const stderr = createWriteStream(coreLogPath(), { flags: 'a' })
|
||||
|
||||
const proc = spawn(corePath, ['-d', workDir, ctlParam, ipcPath], {
|
||||
detached,
|
||||
stdio: detached ? 'ignore' : undefined
|
||||
@@ -226,6 +224,8 @@ function spawnCoreProcess(config: CoreConfig): ChildProcess {
|
||||
}
|
||||
|
||||
if (!detached) {
|
||||
const stdout = createCappedLogWritableStream(coreLogPath())
|
||||
const stderr = createCappedLogWritableStream(coreLogPath())
|
||||
proc.stdout?.pipe(stdout)
|
||||
proc.stderr?.pipe(stderr)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Worker } from 'worker_threads'
|
||||
import { createWriteStream, existsSync, mkdirSync } from 'fs'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { writeFile, rm, cp } from 'fs/promises'
|
||||
import http from 'http'
|
||||
import net from 'net'
|
||||
@@ -12,6 +12,7 @@ import subStoreIcon from '../../../resources/subStoreIcon.png?asset'
|
||||
import { dataDir, mihomoWorkDir, subStoreDir, substoreLogPath } from '../utils/dirs'
|
||||
import { getAppConfig, getControledMihomoConfig } from '../config'
|
||||
import { systemLogger } from '../utils/logger'
|
||||
import { createCappedLogWritableStream } from '../utils/logFile'
|
||||
|
||||
export let pacPort: number
|
||||
export let subStorePort: number
|
||||
@@ -109,8 +110,8 @@ export async function startSubStoreBackendServer(): Promise<void> {
|
||||
subStorePort = await findAvailablePort(38324)
|
||||
const icon = nativeImage.createFromPath(subStoreIcon)
|
||||
icon.toDataURL()
|
||||
const stdout = createWriteStream(substoreLogPath(), { flags: 'a' })
|
||||
const stderr = createWriteStream(substoreLogPath(), { flags: 'a' })
|
||||
const stdout = createCappedLogWritableStream(substoreLogPath())
|
||||
const stderr = createCappedLogWritableStream(substoreLogPath())
|
||||
const env = {
|
||||
SUB_STORE_BACKEND_API_PORT: subStorePort.toString(),
|
||||
SUB_STORE_BACKEND_API_HOST: subStoreHost,
|
||||
|
||||
165
src/main/utils/logFile.ts
Normal file
165
src/main/utils/logFile.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { appendFile, open, stat, writeFile } from 'fs/promises'
|
||||
import { Writable } from 'stream'
|
||||
|
||||
const MB = 1024 * 1024
|
||||
const DEFAULT_MAX_LOG_FILE_SIZE_MB = 10
|
||||
const MIN_MAX_LOG_FILE_SIZE_MB = 1
|
||||
const TRUNCATE_MARKER = Buffer.from('\n[LOG] File truncated because size limit reached.\n')
|
||||
|
||||
interface LogFileState {
|
||||
queue: Promise<void>
|
||||
size: number | null
|
||||
}
|
||||
|
||||
const logFileStates = new Map<string, LogFileState>()
|
||||
|
||||
let globalMaxLogFileSizeBytes = DEFAULT_MAX_LOG_FILE_SIZE_MB * MB
|
||||
|
||||
function getLogFileState(filePath: string): LogFileState {
|
||||
const existing = logFileStates.get(filePath)
|
||||
if (existing) return existing
|
||||
|
||||
const created: LogFileState = {
|
||||
queue: Promise.resolve(),
|
||||
size: null
|
||||
}
|
||||
logFileStates.set(filePath, created)
|
||||
return created
|
||||
}
|
||||
|
||||
export function normalizeMaxLogFileSizeMB(value: unknown): number {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return DEFAULT_MAX_LOG_FILE_SIZE_MB
|
||||
return Math.max(MIN_MAX_LOG_FILE_SIZE_MB, Math.floor(num))
|
||||
}
|
||||
|
||||
export function setGlobalMaxLogFileSizeMB(value: unknown): void {
|
||||
const normalized = normalizeMaxLogFileSizeMB(value)
|
||||
globalMaxLogFileSizeBytes = normalized * MB
|
||||
}
|
||||
|
||||
export function getGlobalMaxLogFileSizeBytes(): number {
|
||||
return globalMaxLogFileSizeBytes
|
||||
}
|
||||
|
||||
async function readTail(filePath: string, bytes: number): Promise<Buffer> {
|
||||
if (bytes <= 0) return Buffer.alloc(0)
|
||||
|
||||
try {
|
||||
const file = await open(filePath, 'r')
|
||||
try {
|
||||
const fileStat = await file.stat()
|
||||
const readSize = Math.min(bytes, fileStat.size)
|
||||
if (readSize <= 0) return Buffer.alloc(0)
|
||||
|
||||
const buffer = Buffer.alloc(readSize)
|
||||
await file.read(buffer, 0, readSize, fileStat.size - readSize)
|
||||
return buffer
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
} catch {
|
||||
return Buffer.alloc(0)
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentSize(filePath: string, state: LogFileState): Promise<number> {
|
||||
if (state.size !== null) return state.size
|
||||
|
||||
try {
|
||||
const fileStat = await stat(filePath)
|
||||
state.size = fileStat.size
|
||||
} catch {
|
||||
state.size = 0
|
||||
}
|
||||
|
||||
return state.size
|
||||
}
|
||||
|
||||
async function appendToFileWithLimitInternal(
|
||||
filePath: string,
|
||||
data: Buffer,
|
||||
state: LogFileState,
|
||||
maxBytes: number
|
||||
): Promise<void> {
|
||||
if (data.length === 0) return
|
||||
|
||||
if (maxBytes <= 0) {
|
||||
await appendFile(filePath, data)
|
||||
const size = await getCurrentSize(filePath, state)
|
||||
state.size = size + data.length
|
||||
return
|
||||
}
|
||||
|
||||
if (data.length >= maxBytes) {
|
||||
const sliced = data.subarray(data.length - maxBytes)
|
||||
await writeFile(filePath, sliced)
|
||||
state.size = sliced.length
|
||||
return
|
||||
}
|
||||
|
||||
const size = await getCurrentSize(filePath, state)
|
||||
if (size + data.length <= maxBytes) {
|
||||
await appendFile(filePath, data)
|
||||
state.size = size + data.length
|
||||
return
|
||||
}
|
||||
|
||||
const keepBytes = Math.max(0, maxBytes - data.length - TRUNCATE_MARKER.length)
|
||||
const tail = await readTail(filePath, keepBytes)
|
||||
let rewritten = Buffer.concat([tail, TRUNCATE_MARKER, data])
|
||||
|
||||
if (rewritten.length > maxBytes) {
|
||||
rewritten = rewritten.subarray(rewritten.length - maxBytes)
|
||||
}
|
||||
|
||||
await writeFile(filePath, rewritten)
|
||||
state.size = rewritten.length
|
||||
}
|
||||
|
||||
export async function appendToFileWithLimit(
|
||||
filePath: string,
|
||||
data: string | Buffer,
|
||||
maxBytes = getGlobalMaxLogFileSizeBytes()
|
||||
): Promise<void> {
|
||||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
||||
const state = getLogFileState(filePath)
|
||||
|
||||
state.queue = state.queue
|
||||
.catch(() => {
|
||||
// Keep queue alive after previous failures.
|
||||
})
|
||||
.then(() => appendToFileWithLimitInternal(filePath, buffer, state, maxBytes))
|
||||
|
||||
await state.queue
|
||||
}
|
||||
|
||||
class CappedLogWritable extends Writable {
|
||||
private readonly filePath: string
|
||||
private readonly maxBytes: number
|
||||
|
||||
constructor(filePath: string, maxBytes: number) {
|
||||
super()
|
||||
this.filePath = filePath
|
||||
this.maxBytes = maxBytes
|
||||
}
|
||||
|
||||
_write(
|
||||
chunk: Buffer | string,
|
||||
encoding: BufferEncoding,
|
||||
callback: (error?: Error | null) => void
|
||||
): void {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding)
|
||||
appendToFileWithLimit(this.filePath, buffer, this.maxBytes).then(
|
||||
() => callback(),
|
||||
(error) => callback(error as Error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function createCappedLogWritableStream(
|
||||
filePath: string,
|
||||
maxBytes = getGlobalMaxLogFileSizeBytes()
|
||||
): Writable {
|
||||
return new CappedLogWritable(filePath, maxBytes)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { logPath } from './dirs'
|
||||
import { appendToFileWithLimit } from './logFile'
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
@@ -24,7 +24,7 @@ class Logger {
|
||||
try {
|
||||
const appLogPath = logPath()
|
||||
const logMessage = this.formatLogMessage(level, message, error)
|
||||
await writeFile(appLogPath, logMessage, { flag: 'a' })
|
||||
await appendToFileWithLimit(appLogPath, logMessage)
|
||||
} catch (logError) {
|
||||
// 如果写入日志文件失败,仍然输出到控制台
|
||||
console.error(`[Logger] Failed to write to log file:`, logError)
|
||||
|
||||
@@ -13,6 +13,7 @@ export const defaultConfig: IAppConfig = {
|
||||
trayProxyGroupStyle: 'default',
|
||||
disableTrayIconColor: false,
|
||||
maxLogDays: 7,
|
||||
maxLogFileSize: 10,
|
||||
proxyCols: 'auto',
|
||||
connectionDirection: 'asc',
|
||||
connectionOrderBy: 'time',
|
||||
|
||||
@@ -222,6 +222,7 @@
|
||||
"mihomo.disableEmbedCA": "Disable Embed CA",
|
||||
"mihomo.disableSystemCA": "Disable System CA",
|
||||
"mihomo.logRetentionDays": "Log Retention Days",
|
||||
"mihomo.logFileSizeLimit": "Single Log File Limit (MB)",
|
||||
"mihomo.logLevel": "Log Level",
|
||||
"mihomo.selectLogLevel": "Select Log Level",
|
||||
"mihomo.silent": "Silent",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"mihomo.disableEmbedCA": "عدم استفاده از گواهی CA داخلی",
|
||||
"mihomo.disableSystemCA": "عدم استفاده از گواهی CA سیستم",
|
||||
"mihomo.logRetentionDays": "روزهای نگهداری لاگ",
|
||||
"mihomo.logFileSizeLimit": "حداکثر حجم هر فایل لاگ (MB)",
|
||||
"mihomo.logLevel": "سطح لاگ",
|
||||
"mihomo.selectLogLevel": "انتخاب سطح لاگ",
|
||||
"mihomo.silent": "بیصدا",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"mihomo.disableEmbedCA": "Отключить встроенный CA",
|
||||
"mihomo.disableSystemCA": "Отключить системный CA",
|
||||
"mihomo.logRetentionDays": "Дни хранения логов",
|
||||
"mihomo.logFileSizeLimit": "Лимит одного лог-файла (MB)",
|
||||
"mihomo.logLevel": "Уровень логирования",
|
||||
"mihomo.selectLogLevel": "Выберите уровень логирования",
|
||||
"mihomo.silent": "Тихий",
|
||||
|
||||
@@ -222,6 +222,7 @@
|
||||
"mihomo.disableEmbedCA": "不使用内置 CA 证书",
|
||||
"mihomo.disableSystemCA": "不使用系统 CA 证书",
|
||||
"mihomo.logRetentionDays": "日志保留天数",
|
||||
"mihomo.logFileSizeLimit": "日志上限(MB)",
|
||||
"mihomo.logLevel": "日志等级",
|
||||
"mihomo.selectLogLevel": "选择日志等级",
|
||||
"mihomo.silent": "静默",
|
||||
|
||||
@@ -222,6 +222,7 @@
|
||||
"mihomo.disableEmbedCA": "不使用內置 CA 證書",
|
||||
"mihomo.disableSystemCA": "不使用系統 CA 證書",
|
||||
"mihomo.logRetentionDays": "日誌保留天數",
|
||||
"mihomo.logFileSizeLimit": "日誌上限(MB)",
|
||||
"mihomo.logLevel": "日誌等級",
|
||||
"mihomo.selectLogLevel": "選擇日誌等級",
|
||||
"mihomo.silent": "靜默",
|
||||
|
||||
@@ -96,6 +96,7 @@ const Mihomo: React.FC = () => {
|
||||
smartCoreStrategy = 'sticky-sessions',
|
||||
smartCollectorSize = 100,
|
||||
maxLogDays = 7,
|
||||
maxLogFileSize = 10,
|
||||
sysProxy,
|
||||
showMixedPort,
|
||||
enableMixedPort = true,
|
||||
@@ -1431,6 +1432,26 @@ const Mihomo: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title={t('mihomo.logFileSizeLimit')} divider>
|
||||
<Input
|
||||
size="sm"
|
||||
type="number"
|
||||
className="w-[100px]"
|
||||
value={maxLogFileSize.toString()}
|
||||
onValueChange={(v) => {
|
||||
const num = parseInt(v)
|
||||
if (!isNaN(num)) {
|
||||
patchAppConfig({ maxLogFileSize: num })
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const num = parseInt(e.target.value)
|
||||
if (isNaN(num) || num < 1) {
|
||||
patchAppConfig({ maxLogFileSize: 1 })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title={t('mihomo.logLevel')} divider>
|
||||
<Select
|
||||
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
|
||||
|
||||
1
src/shared/types.d.ts
vendored
1
src/shared/types.d.ts
vendored
@@ -312,6 +312,7 @@ interface IAppConfig {
|
||||
autoCloseConnection: boolean
|
||||
sysProxy: ISysProxyConfig
|
||||
maxLogDays: number
|
||||
maxLogFileSize: number
|
||||
userAgent?: string
|
||||
delayTestConcurrency?: number
|
||||
delayTestUrl?: string
|
||||
|
||||
Reference in New Issue
Block a user