zwave-js-ui
Version:
Z-Wave Control Panel and MQTT Gateway
282 lines (281 loc) • 9.82 kB
JavaScript
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;