/* nodejs-poolController. An application to control pool equipment.
Copyright (C) 2016, 2017, 2018, 2019, 2020, 2021, 2022.
Russell Goldin, tagyoureit. russ.goldin@gmail.com
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
*/
import * as path from 'path';
import * as fs from 'fs';
import * as winston from 'winston';
import * as os from 'os';
import { utils } from "../controller/Constants";
import { Message } from '../controller/comms/messages/Messages';
import { config } from '../config/Config';
import { webApp } from '../web/Server';
import { sl } from '../controller/comms/ScreenLogic';
const extend = require("extend");
class Logger {
constructor() {
if (!fs.existsSync(path.join(process.cwd(), '/logs'))) fs.mkdirSync(path.join(process.cwd(), '/logs'));
this.pktPath = path.join(process.cwd(), '/logs', this.getPacketPath());
this.captureForReplayBaseDir = path.join(process.cwd(), '/logs/', this.getLogTimestamp());
this.captureForReplayPath = this.captureForReplayBaseDir;
this.pkts = [];
this.slMessages = [];
}
private cfg;
private pkts: Message[];
private slMessages: any[];
private pktPath: string;
private consoleToFilePath: string;
private transports: { console: winston.transports.ConsoleTransportInstance, file?: winston.transports.FileTransportInstance, consoleFile?: winston.transports.FileTransportInstance } = {
console: new winston.transports.Console({ level: 'silly' })
};
private captureForReplayBaseDir: string;
private captureForReplayPath: string;
private pktTimer: NodeJS.Timeout;
private currentTimestamp: string;
private _captureInProgress: boolean = false;
public get captureInProgress(): boolean { return this._captureInProgress; }
private getPacketPath(): string {
// changed this to remove spaces from the name
return 'packetLog(' + this.getLogTimestamp() + ').log';
}
private getConsoleToFilePath(): string {
return 'consoleLog(' + this.getLogTimestamp() + ').log';
}
public getLogTimestamp(bNew: boolean = false): string {
if (!bNew && typeof this.currentTimestamp !== 'undefined') { return this.currentTimestamp; }
var ts = new Date();
function pad(n) { return (n < 10 ? '0' : '') + n; }
this.currentTimestamp = ts.getFullYear() + '-' + pad(ts.getMonth() + 1) + '-' + pad(ts.getDate()) + '_' + pad(ts.getHours()) + '-' + pad(ts.getMinutes()) + '-' + pad(ts.getSeconds());
return this.currentTimestamp;
}
private myFormat = winston.format.printf(({ level, message, label }) => {
return `[${new Date().toLocaleString()}] ${level}: ${message}`;
});
private _logger: winston.Logger;
public init() {
this.cfg = config.getSection('log');
logger._logger = winston.createLogger({
format: winston.format.combine(winston.format.timestamp({format: 'MMMM DD YYYY'}), winston.format.colorize(), winston.format.splat(), this.myFormat),
transports: [this.transports.console]
});
this.transports.console.level = this.cfg.app.level;
// Only start capture if not already capturing (prevents duplicate transports when config watcher triggers init())
if (this.cfg.app.captureForReplay && !this._captureInProgress) {
this.startCaptureForReplay(false);
}
if (this.cfg.app.logToFile) {
this.transports.consoleFile = new winston.transports.File({
filename: path.join(process.cwd(), '/logs', this.getConsoleToFilePath()),
level: 'silly',
format: winston.format.combine(winston.format.splat(), winston.format.uncolorize(), this.myFormat)
});
this.transports.consoleFile.level = this.cfg.app.level;
this._logger.add(this.transports.consoleFile);
}
}
public async stopAsync() {
try {
this.info(`Stopping logger Process.`);
if (this.cfg.app.captureForReplay) {
return await this.stopCaptureForReplayAsync();
}
// Free up the file handles. This is yet another goofiness with winston. Not sure why they
// need to exclusively lock the file handles when the process always appends. Just stupid.
if (typeof this.transports.consoleFile !== 'undefined') {
this._logger.remove(this.transports.consoleFile);
this.transports.consoleFile.close();
this.transports.consoleFile = undefined;
}
console.log(`Logger Process Stopped`);
} catch (err) { console.log(`Error shutting down logger: ${err.message}`); }
}
public get options(): any { return this.cfg; }
public info(...args: any[]) { logger._logger.info.apply(logger._logger, arguments); }
public debug(...args: any[]) { logger._logger.debug.apply(logger._logger, arguments); }
public warn(...args: any[]) { logger._logger.warn.apply(logger._logger, arguments); }
public verbose(...args: any[]) { logger._logger.verbose.apply(logger._logger, arguments); }
public error(...args: any[]): Error { logger._logger.error.apply(logger._logger, arguments); return new Error(arguments[0]); }
public silly(...args: any[]) { logger._logger.silly.apply(logger._logger, arguments); }
public reject(sError: string): Promise {
logger.error(sError);
return Promise.reject(new Error(sError));
}
private isIncluded(byte: number, arr: number[]): boolean {
if (typeof (arr) === "undefined" || !arr || arr.length === 0) return true;
if (arr.indexOf(byte) !== -1) return true;
return false;
}
private isExcluded(byte: number, arr: number[]): boolean {
if (typeof (arr) === "undefined" || !arr) return false;
if (arr && arr.length === 0) return false;
if (arr.indexOf(byte) !== -1) return true;
return false;
}
public packet(msg: Message) {
// Emits to clients should not be gated by logging settings.
// Logging-to-file/console remains gated by packet config and filters.
webApp.emitToChannel('msgLogger', 'logMessage', msg);
// Filter out the messages we do not want to *log*.
let bLog: boolean = true;
if (logger.cfg.packet.enabled || logger.cfg.app.captureForReplay) {
// A random packet may actually find its way into the throws should the bytes get messed up
// in a fashion where the header byte is 255, 0, 255 but we have not identified the channel.
// Thus far we have seen 165 and 166.
const cfgPacket = logger.cfg.packet[msg.protocol] || logger.cfg.packet['unidentified'];
if (!logger.cfg.app.captureForReplay) {
// Log invalid messages no matter what if the user has selected invalid message logging.
if (bLog && !msg.isValid) {
if (!logger.cfg.packet.invalid) bLog = false;
}
else {
if (bLog && !cfgPacket.enabled) bLog = false;
if (bLog && !logger.isIncluded(msg.source, cfgPacket.includeSouce)) bLog = false;
if (bLog && !logger.isIncluded(msg.dest, cfgPacket.includeDest)) bLog = false;
if (bLog && !logger.isIncluded(msg.action, cfgPacket.includeActions)) bLog = false;
if (bLog && logger.isExcluded(msg.source, cfgPacket.excludeSource)) bLog = false;
if (bLog && logger.isExcluded(msg.dest, cfgPacket.excludeDest)) bLog = false;
if (bLog && logger.isExcluded(msg.action, cfgPacket.excludeActions)) bLog = false;
}
}
if (bLog && logger.cfg.packet.logToFile) {
logger.pkts.push(msg);
if (logger.pkts.length > 5)
logger.flushLogs();
else {
// Attempt to ease up on the writes if we are logging a bunch of packets.
if (logger.pktTimer) clearTimeout(logger.pktTimer);
logger.pktTimer = setTimeout(logger.flushLogs, 1000);
}
}
}
else {
bLog = false;
}
if (logger.cfg.packet.logToConsole && bLog) {
if (msg.isValid) logger._logger.info(msg.toLog());
else logger._logger.warn(msg.toLog());
}
}
public screenlogic(data: any){
if (logger.cfg.screenlogic.enabled || logger.cfg.app.captureForReplay){
if (logger.cfg.screenlogic.logToFile) {
logger.slMessages.push(data);
if (logger.slMessages.length > 5)
logger.flushSLLogs();
else {
// Attempt to ease up on the writes if we are logging a bunch of packets.
if (logger.pktTimer) clearTimeout(logger.pktTimer);
logger.pktTimer = setTimeout(logger.flushSLLogs, 1000);
}
}
webApp.emitToChannel('msgLogger', 'logMessage', data);
}
if (logger.cfg.screenlogic.logToConsole){
logger._logger.info(sl.toLog(data));
}
}
public logAPI(apiReq:string){
if (logger.cfg.app.captureForReplay){
// TODO: buffer this
fs.appendFile(logger.pktPath, apiReq, function(err) {
if (err) logger.error('Error writing packet to %s: %s', logger.pktPath, err);
});
}
}
public clearMessages() {
if (fs.existsSync(logger.pktPath)) {
logger.info(`Clearing message log: ${ logger.pktPath }`);
fs.truncateSync(logger.pktPath, 0);
}
}
public flushLogs() {
var p: Message[] = logger.pkts.splice(0, logger.pkts.length);
var buf: string = '';
if (logger.cfg.packet.enabled) {
for (let i = 0; i < p.length; i++) {
buf += (p[i].toLog() + os.EOL);
}
fs.appendFile(logger.pktPath, buf, function(err) {
if (err) logger.error('Error writing packet to %s: %s', logger.pktPath, err);
});
}
buf = '';
}
public flushSLLogs() {
var p: any[] = logger.slMessages.splice(0, logger.slMessages.length);
var buf: string = '';
for (let i = 0; i < p.length; i++) {
buf += (p[i] + os.EOL);
}
fs.appendFile(logger.pktPath, buf, function(err) {
if (err) logger.error(`Error writing screenlogic message to ${logger.pktPath}: ${err.message}`);
});
buf = '';
}
public setOptions(opts, c?: any) {
c = typeof c === 'undefined' ? this.cfg : c;
for (let prop in opts) {
let o = opts[prop];
if (o instanceof Array) {
//console.log({ o: o, c: c, prop: prop });
c[prop] = o; // Stop here we are replacing the array.
}
else if (typeof o === 'object') {
if (typeof c[prop] === 'undefined') c[prop] = {};
this.setOptions(o, c[prop]); // Use recursion here. Harder to follow but much less code.
}
else
c[prop] = opts[prop];
}
config.setSection('log', this.cfg);
if (utils.makeBool(this.cfg.app.logToFile)) {
if (typeof this.transports.consoleFile === 'undefined') {
this.transports.consoleFile = new winston.transports.File({
filename: path.join(process.cwd(), '/logs', this.getConsoleToFilePath()),
level: 'silly',
format: winston.format.combine(winston.format.splat(), winston.format.uncolorize(), this.myFormat)
});
this._logger.add(this.transports.consoleFile);
}
}
else {
if (typeof this.transports.consoleFile !== 'undefined') {
this._logger.remove(this.transports.consoleFile);
this.transports.consoleFile.close();
this.transports.consoleFile = undefined;
}
}
for (let [key, transport] of Object.entries(this.transports)) {
if(typeof transport !== 'undefined') transport.level = this.cfg.app.level;
}
}
public startCaptureForReplay(bResetLogs:boolean) {
this._captureInProgress = true;
logger.info(`Starting Replay Capture.`);
// start new replay directory
if (!fs.existsSync(this.captureForReplayBaseDir)) fs.mkdirSync(this.captureForReplayBaseDir, { recursive: true });
// Create logs subdirectory for additional log files
let logsSubDir = path.join(this.captureForReplayBaseDir, 'logs');
if (!fs.existsSync(logsSubDir)) fs.mkdirSync(logsSubDir, { recursive: true });
if (bResetLogs){
if (fs.existsSync(path.join(process.cwd(), 'data/poolConfig.json'))) {
fs.copyFileSync(path.join(process.cwd(), 'data/poolConfig.json'), path.join(process.cwd(),'data/', `poolConfig-${this.getLogTimestamp()}.json`));
fs.unlinkSync((path.join(process.cwd(), 'data/poolConfig.json')));
}
if (fs.existsSync(path.join(process.cwd(), 'data/poolState.json'))) {
fs.copyFileSync(path.join(process.cwd(), 'data/poolState.json'), path.join(process.cwd(),'data/', `poolState-${this.getLogTimestamp()}.json`));
fs.unlinkSync((path.join(process.cwd(), 'data/poolState.json')));
}
this.clearMessages();
}
logger.cfg = extend(true, {}, logger.cfg, {
"packet": {
"enabled": true,
"logToConsole": true,
"logToFile": true,
"invalid": true,
"broadcast": {
"enabled": true,
"includeActions": [],
"includeSource": [],
"includeDest": [],
"excludeActions": [],
"excludeSource": [],
"excludeDest": []
},
"pump": {
"enabled": true,
"includeActions": [],
"includeSource": [],
"includeDest": [],
"excludeActions": [],
"excludeSource": [],
"excludeDest": []
},
"chlorinator": {
"enabled": true,
"includeSource": [],
"includeDest": [],
"excludeSource": [],
"excludeDest": []
},
"intellichem": {
"enabled": true,
"includeActions": [],
"exclueActions": [],
"includeSource": [],
"includeDest": [],
"excludeSource": [],
"excludeDest": []
},
"intellivalve": {
"enabled": true,
"includeActions": [],
"exclueActions": [],
"includeSource": [],
"includeDest": [],
"excludeSource": [],
"excludeDest": []
},
"unidentified": {
"enabled": true,
"includeSource": [],
"includeDest": [],
"excludeSource": [],
"excludeDest": []
},
"unknown": {
"enabled": true,
"includeSource": [],
"includeDest": [],
"excludeSource": [],
"excludeDest": []
}
},
"app": {
"enabled": true,
"level": "silly",
"captureForReplay": true
}
});
this.consoleToFilePath = path.join(this.captureForReplayBaseDir, this.getConsoleToFilePath());
this.transports.file = new winston.transports.File({
filename: this.consoleToFilePath,
level: 'silly',
format: winston.format.combine(winston.format.splat(), winston.format.uncolorize(), this.myFormat)
});
logger._logger.add(this.transports.file);
this.transports.console.level = 'silly';
}
public async stopCaptureForReplayAsync(remLogs?: any[]):Promise {
try {
if (!this._captureInProgress) {
logger.warn(`stopCaptureForReplayAsync called with no active capture session; creating backup without capture logs`);
}
// Get REM server configurations from config
let configData = config.getSection();
let remServers = [];
if (configData.web && configData.web.interfaces) {
for (let interfaceName in configData.web.interfaces) {
let interfaceConfig = configData.web.interfaces[interfaceName];
if (interfaceConfig.type === 'rem' && interfaceConfig.enabled) {
remServers.push({
name: interfaceConfig.name || interfaceName,
uuid: interfaceConfig.uuid,
host: interfaceConfig.options?.host || '',
backup: true
});
}
}
}
// Use the existing backup logic to create the base backup.
const ts = this.currentTimestamp || this.getLogTimestamp();
let backupOptions = {
njsPC: true,
servers: remServers,
name: `Packet Capture ${ts}`,
automatic: false
};
let backupFile = await webApp.backupServer(backupOptions);
// Add packet capture logs to the existing backup zip
let jszip = require("jszip");
let zip = await jszip.loadAsync(fs.readFileSync(backupFile.filePath));
// Add packet capture logs to the njsPC/logs directory if present.
if (typeof logger.pktPath === 'string' && logger.pktPath.length > 0 && fs.existsSync(logger.pktPath)) {
zip.file(`njsPC/logs/${path.basename(logger.pktPath)}`, fs.readFileSync(logger.pktPath));
} else {
logger.warn(`Packet capture log file unavailable during stopCaptureForReplayAsync; skipping packet log attachment`);
}
if (typeof this.consoleToFilePath === 'string' && this.consoleToFilePath.length > 0 && fs.existsSync(this.consoleToFilePath)) {
zip.file(`njsPC/logs/${path.basename(this.consoleToFilePath)}`, fs.readFileSync(this.consoleToFilePath));
} else {
logger.warn(`Console capture log file unavailable during stopCaptureForReplayAsync; skipping console log attachment`);
}
// Add REM server logs if provided.
if (remLogs && remLogs.length > 0) {
logger.info(`Adding ${remLogs.length} REM logs to backup`);
for (let remLog of remLogs) {
// Create logs directory for the REM server using the hardcoded name.
let logPath = `Relay Equipment Manager/logs/${remLog.logFileName}`;
logger.info(`Adding REM log to backup: ${logPath} (size: ${remLog.logData.length} characters)`);
zip.file(logPath, remLog.logData);
}
} else {
logger.info(`No REM logs provided to add to backup`);
}
// Generate the updated zip.
await zip.generateAsync({ type: 'nodebuffer' }).then(content => {
fs.writeFileSync(backupFile.filePath, content);
});
// Restore original logging configuration.
this.cfg = config.getSection('log');
if (typeof this.transports.file !== 'undefined') {
logger._logger.remove(this.transports.file);
this.transports.file.close();
this.transports.file = undefined;
}
this.transports.console.level = this.cfg.app.level;
this._captureInProgress = false;
return backupFile.filePath;
}
catch (err) {
this._captureInProgress = false;
return Promise.reject(err instanceof Error ? err.message : `${err}`);
}
}
}
export var logger = new Logger();