UNPKG

zwave-js-ui

Version:

Z-Wave Control Panel and MQTT Gateway

282 lines (281 loc) 9.82 kB
import DailyRotateFile from 'winston-daily-rotate-file'; import winston from 'winston'; import { logsDir, storeDir } from "../config/app.js"; import { joinPath, ensureDirSync } from "./utils.js"; import * as path from 'node:path'; import { readdir, stat, unlink } from 'node:fs/promises'; import escapeStringRegexp from '@esm2cjs/escape-string-regexp'; import { PassThrough } from 'node:stream'; const { format, transports, addColors } = winston; const { combine, timestamp, printf, colorize, splat } = format; export const defaultLogFile = 'z-ui_%DATE%.log'; export const disableColors = process.env.NO_LOG_COLORS === 'true'; let transportsList = null; // ensure store and logs directories exist ensureDirSync(storeDir); ensureDirSync(logsDir); // custom colors for timestamp and module addColors({ time: 'grey', module: 'bold', }); const colorizer = colorize(); /** * Generate logger configuration starting from settings.gateway */ export function sanitizedConfig(module, config) { config = config || {}; const filePath = joinPath(logsDir, config.logFileName || defaultLogFile); return { module: module || '-', enabled: config.logEnabled !== undefined ? config.logEnabled : true, level: config.logLevel || 'info', logToFile: config.logToFile !== undefined ? config.logToFile : false, filePath: filePath, }; } /** * Return a custom logger format */ export function customFormat(noColor = false) { noColor = noColor || disableColors; const formats = [ splat(), // used for formats like: logger.log('info', Message %s', strinVal) timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), format((info) => { info.level = info.level.toUpperCase(); return info; })(), ]; if (!noColor) { formats.push(colorize({ level: true })); } // must be added at last formats.push(printf((info) => { if (!noColor) { info.timestamp = colorizer.colorize('time', info.timestamp); info.module = colorizer.colorize('module', info.module); } return `${info.timestamp} ${info.level} ${info.module}: ${info.message}${info.stack ? '\n' + info.stack : ''}`; })); return combine(...formats); } export const logStream = new PassThrough(); /** * Create the base transports based on settings provided */ export function customTransports(config) { // setup transports only once (see issue #2937) if (transportsList) { return transportsList; } transportsList = []; if (process.env.ZUI_NO_CONSOLE !== 'true') { transportsList.push(new transports.Console({ format: customFormat(), level: config.level, stderrLevels: ['error'], })); } const streamTransport = new transports.Stream({ format: customFormat(), level: config.level, stream: logStream, }); transportsList.push(streamTransport); if (config.logToFile) { let fileTransport; if (process.env.DISABLE_LOG_ROTATION === 'true') { fileTransport = new transports.File({ format: customFormat(true), filename: config.filePath, level: config.level, }); } else { const options = { filename: config.filePath, auditFile: joinPath(logsDir, 'zui-logs.audit.json'), datePattern: 'YYYY-MM-DD', createSymlink: true, symlinkName: path .basename(config.filePath) .replace(`_%DATE%`, '_current'), zippedArchive: true, maxFiles: process.env.ZUI_LOG_MAXFILES || '7d', maxSize: process.env.ZUI_LOG_MAXSIZE || '50m', level: config.level, format: customFormat(true), }; fileTransport = new DailyRotateFile(options); setupCleanJob(options); } transportsList.push(fileTransport); } // giving that we re-use transports, each module will subscribe to events // increeasing the default limit of 100 prevents warnings transportsList.forEach((t) => { t.setMaxListeners(100); if (t !== streamTransport) { t.silent = config.enabled === false; } }); return transportsList; } /** * Setup a logger */ export function setupLogger(container, module, config) { const sanitized = sanitizedConfig(module, config); // Winston automatically reuses an existing module logger const logger = container.add(module); const moduleName = module.toUpperCase() || '-'; logger.configure({ format: combine(format((info) => { info.module = moduleName; return info; })(), format.errors({ stack: true }), format.json()), // to correctly parse errors level: sanitized.level, transports: customTransports(sanitized), }); logger.module = module; logger.setup = (cfg) => setupLogger(container, module, cfg); return logger; } const logContainer = new winston.Container(); /** * Create a new logger for a specific module */ export function module(module) { return setupLogger(logContainer, module); } /** * Setup all loggers starting from config */ export function setupAll(config) { stopCleanJob(); transportsList.forEach((t) => { if (typeof t.close === 'function') { t.close(); } }); transportsList = null; logContainer.loggers.forEach((logger) => { logger.setup(config); }); } let cleanJob; export function setupCleanJob(settings) { if (cleanJob) { return; } let maxFilesMs; let maxFiles; let maxSizeBytes; const logger = module('LOGGER'); // convert maxFiles to milliseconds if (settings.maxFiles !== undefined) { const matches = settings.maxFiles.toString().match(/(\d+)([dhm])/); if (matches) { const value = parseInt(matches[1]); const unit = matches[2]; switch (unit) { case 'd': maxFilesMs = value * 24 * 60 * 60 * 1000; break; case 'h': maxFilesMs = value * 60 * 60 * 1000; break; case 'm': maxFilesMs = value * 60 * 1000; break; } } else { maxFiles = Number(settings.maxFiles); } } if (settings.maxSize !== undefined) { // convert maxSize to bytes const matches2 = settings.maxSize.toString().match(/(\d+)([kmg])/); if (matches2) { const value = parseInt(matches2[1]); const unit = matches2[2]; switch (unit) { case 'k': maxSizeBytes = value * 1024; break; case 'm': maxSizeBytes = value * 1024 * 1024; break; case 'g': maxSizeBytes = value * 1024 * 1024 * 1024; break; } } else { maxSizeBytes = Number(settings.maxSize); } } // clean up old log files based on maxFiles and maxSize const filePathRegExp = new RegExp(escapeStringRegexp(path.basename(settings.filename)).replace(/%DATE%/g, '(.*)')); const logsDir = path.dirname(settings.filename); const deleteFile = async (filePath) => { logger.info(`Deleting log file: ${filePath}`); return unlink(filePath).catch((e) => { if (e.code !== 'ENOENT') { logger.error(`Error deleting log file: ${filePath}`, e); } }); }; const clean = async () => { try { logger.info('Cleaning up log files...'); const files = await readdir(logsDir); const logFiles = files.filter((file) => file !== settings.symlinkName && filePathRegExp.test(file)); const fileStats = await Promise.allSettled(logFiles.map(async (file) => ({ file, stats: await stat(path.join(logsDir, file)), }))); const logFilesStats = []; for (const res of fileStats) { if (res.status === 'fulfilled') { logFilesStats.push(res.value); } else { logger.error('Error getting file stats:', res.reason); } } logFilesStats.sort((a, b) => a.stats.mtimeMs - b.stats.mtimeMs); // sort by mtime let totalSize = 0; let deletedFiles = 0; for (const { file, stats } of logFilesStats) { const filePath = path.join(logsDir, file); totalSize += stats.size; // last modified time in milliseconds const fileMs = stats.mtimeMs; const shouldDelete = (maxSizeBytes && totalSize > maxSizeBytes) || (maxFiles && logFiles.length - deletedFiles > maxFiles) || (maxFilesMs && fileMs && Date.now() - fileMs > maxFilesMs); if (shouldDelete) { await deleteFile(filePath); deletedFiles++; } } } catch (e) { logger.error('Error cleaning up log files:', e); } }; cleanJob = setInterval(clean, 60 * 60 * 1000); clean().catch(() => { }); } export function stopCleanJob() { if (cleanJob) { clearInterval(cleanJob); cleanJob = undefined; } } export { logContainer }; export default logContainer.loggers;