// ENiGMA½ const { MenuModule } = require('./menu_module'); const stringFormat = require('./string_format'); const Events = require('./events'); const { getActiveConnectionList, AllConnections, getConnectionByNodeId, removeClient, } = require('./client_connections'); const StatLog = require('./stat_log'); const SysProps = require('./system_property'); const UserProps = require('./user_property'); const Log = require('./logger'); const Config = require('./config.js').get; const { Errors } = require('./enig_error'); const { pipeToAnsi } = require('./color_codes'); const MultiLineEditTextView = require('./multi_line_edit_text_view').MultiLineEditTextView; // deps const async = require('async'); const _ = require('lodash'); const moment = require('moment'); const bunyan = require('bunyan'); exports.moduleInfo = { name: 'WFC', desc: 'Semi-Traditional Waiting For Caller', author: 'NuSkooler', }; const FormIds = { main: 0, help: 1, fullLog: 2, confirmKickPrompt: 3, }; const MciViewIds = { main: { nodeStatus: 1, quickLogView: 2, selectedNodeStatusInfo: 3, confirmXy: 4, customRangeStart: 10, }, }; // Secure + 2FA + root user + 'wfc' group. const DefaultACS = 'SCAF2ID1GM[wfc]'; const MainStatRefreshTimeMs = 5000; // 5s const MailCountTTLSeconds = 10; exports.getModule = class WaitingForCallerModule extends MenuModule { constructor(options) { super(options); this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs: options.extraArgs, }); // // Enforce that we have at least a secure connection in our ACS check // if (!this.config.acs) { this.config.acs = DefaultACS; } else if (!this.config.acs.includes('SC')) { this.config.acs = 'SC' + this.config.acs; // secure connection at the very least } // ensure the menu instance has this setting if (!_.has(options, 'menuConfig.config.acs')) { _.set(options, 'menuConfig.config.acs', this.config.acs); } this.selectedNodeStatusIndex = -1; // no selection this.refreshing = false; this.menuMethods = { toggleAvailable: (formData, extraArgs, cb) => { const avail = this.client.user.isAvailable(); this.client.user.setAvailability(!avail); return this._refreshAll(cb); }, toggleVisible: (formData, extraArgs, cb) => { const visible = this.client.user.isVisible(); this.client.user.setVisibility(!visible); this.visibilityToggled = true; // we won't restore it in this case return this._refreshAll(cb); }, displayHelp: (formData, extraArgs, cb) => { return this._displayHelpPage(cb); }, setNodeStatusSelection: (formData, extraArgs, cb) => { const nodeStatusView = this.getView('main', MciViewIds.main.nodeStatus); if (!nodeStatusView) { return cb(null); } const nodeId = parseInt(formData.ch); // 1-based if (isNaN(nodeId)) { return cb(null); } const index = this._getNodeStatusIndexByNodeId(nodeStatusView, nodeId); if (index > -1) { this.selectedNodeStatusIndex = index; this._selectNodeByIndex(nodeStatusView, this.selectedNodeStatusIndex); const nodeStatusSelectionView = this.getView( 'main', MciViewIds.main.selectedNodeStatusInfo ); if (nodeStatusSelectionView) { const item = nodeStatusView.getItems()[index]; this._updateNodeStatusSelection(nodeStatusSelectionView, item); } } return cb(null); }, kickSelectedNode: (formData, extraArgs, cb) => { return this._confirmKickSelectedNode(cb); }, kickNodeYes: (formData, extraArgs, cb) => { return this._kickSelectedNode(cb); }, kickNodeNo: (formData, extraArgs, cb) => { //this._startRefreshing(); return cb(null); }, }; } initSequence() { async.series( [ callback => { return this.beforeArt(callback); }, callback => { return this._displayMainPage(false, callback); }, ], () => { this.finishedLoading(); } ); } _displayMainPage(clearScreen, cb) { async.series( [ callback => { return this.displayArtAndPrepViewController( 'main', FormIds.main, { clearScreen }, callback ); }, callback => { const quickLogView = this.getView( 'main', MciViewIds.main.quickLogView ); if (!quickLogView) { return callback(null); } if (!this.logRingBuffer) { const logLevel = this.config.quickLogLevel || // WFC specific _.get(Config(), 'logging.rotatingFile.level') || // ...or system setting 'info'; // ...or default to info this.logRingBuffer = new bunyan.RingBuffer({ limit: quickLogView.dimens.height || 24, }); Log.log.addStream({ name: 'wfc-ringbuffer', type: 'raw', level: logLevel, stream: this.logRingBuffer, }); } const nodeStatusView = this.getView( 'main', MciViewIds.main.nodeStatus ); const nodeStatusSelectionView = this.getView( 'main', MciViewIds.main.selectedNodeStatusInfo ); if (nodeStatusView && nodeStatusSelectionView) { nodeStatusView.on('index update', index => { const item = nodeStatusView.getItems()[index]; this._updateNodeStatusSelection( nodeStatusSelectionView, item ); }); } return callback(null); }, callback => { return this._refreshAll(callback); }, ], err => { if (!err) { this._startRefreshing(); } return cb(err); } ); } enter() { this.client.stopIdleMonitor(); this._applyOpVisibility(); Events.on( Events.getSystemEvents().ClientDisconnected, this._clientDisconnected.bind(this) ); super.enter(); } leave() { _.remove(Log.log.streams, stream => { return stream.name === 'wfc-ringbuffer'; }); Events.removeListener( Events.getSystemEvents().ClientDisconnected, this._clientDisconnected ); this._restoreOpVisibility(); this._stopRefreshing(); this.client.startIdleMonitor(); super.leave(); } _updateNodeStatusSelection(nodeStatusSelectionView, item) { if (item) { const nodeStatusSelectionFormat = this.config.nodeStatusSelectionFormat || '{text}'; const s = stringFormat(nodeStatusSelectionFormat, item); if (nodeStatusSelectionView instanceof MultiLineEditTextView) { nodeStatusSelectionView.setAnsi(pipeToAnsi(s, this.client)); } else { nodeStatusSelectionView.setText(s); } } } _displayHelpPage(cb) { this._stopRefreshing(); this.displayAsset(this.menuConfig.config.art.help, { clearScreen: true }, () => { this.client.waitForKeyPress(() => { return this._displayMainPage(true, cb); }); }); } _getSelectedNodeItem() { const nodeStatusView = this.getView('main', MciViewIds.main.nodeStatus); if (!nodeStatusView) { return null; } return nodeStatusView.getItem(nodeStatusView.getFocusItemIndex()); } _confirmKickSelectedNode(cb) { const nodeItem = this._getSelectedNodeItem(); if (!nodeItem) { return cb(null); } const confirmView = this.getView('main', MciViewIds.main.confirmXy); if (!confirmView) { return cb( Errors.MissingMci(`Missing prompt XY${MciViewIds.main.confirmXy} MCI`) ); } // disallow kicking self if (this.client.node === parseInt(nodeItem.node)) { return cb(null); } const promptOptions = { clearAtSubmit: true, submitNotify: () => { this._startRefreshing(); }, }; if (confirmView.dimens.width) { promptOptions.clearWidth = confirmView.dimens.width; } this._stopRefreshing(); return this.promptForInput( { formName: 'confirmKickPrompt', formId: FormIds.confirmKickPrompt, promptName: this.config.confirmKickNodePrompt || 'confirmKickNodePrompt', prevFormName: 'main', position: confirmView.position, }, promptOptions, err => { return cb(err); } ); } _kickSelectedNode(cb) { const nodeItem = this._getSelectedNodeItem(); if (!nodeItem) { return cb(Errors.UnexpectedState('Expecting a selected node')); } const client = getConnectionByNodeId(parseInt(nodeItem.node)); if (!client) { return cb( Errors.UnexpectedState(`Expecting a client for node ID ${nodeItem.node}`) ); } // :TODO: optional kick art removeClient(client); return cb(null); } _applyOpVisibility() { this.restoreUserIsVisible = this.client.user.isVisible(); const vis = this.config.opVisibility || 'current'; switch (vis) { case 'hidden': this.client.user.setVisibility(false); break; case 'visible': this.client.user.setVisibility(true); break; default: break; } } _restoreOpVisibility() { if (!this.visibilityToggled) { this.client.user.setVisibility(this.restoreUserIsVisible); } } _startRefreshing() { if (this.mainRefreshTimer) { this._stopRefreshing(); } this.mainRefreshTimer = setInterval(() => { this._refreshAll(); }, MainStatRefreshTimeMs); } _stopRefreshing() { if (this.mainRefreshTimer) { clearInterval(this.mainRefreshTimer); delete this.mainRefreshTimer; } } _clientDisconnected() { const nodeStatusSelectionView = this.getView( 'main', MciViewIds.main.selectedNodeStatusInfo ); if (nodeStatusSelectionView) { nodeStatusSelectionView.setText(''); } this.selectedNodeStatusIndex = 0; // will select during refresh this._refreshAll(); // have to update the selection view here if (nodeStatusSelectionView) { const nodeStatusView = this.getView('main', MciViewIds.main.nodeStatus); if (nodeStatusView) { const item = nodeStatusView.getItems()[this.selectedNodeStatusIndex]; this._updateNodeStatusSelection(nodeStatusSelectionView, item); } } } _refreshAll(cb) { if (this.refreshing) { if (cb) { return cb(null); } return; } this.refreshing = true; async.series( [ callback => { return this._refreshStats(callback); }, callback => { return this._refreshNodeStatus(callback); }, callback => { return this._refreshQuickLog(callback); }, callback => { this.updateCustomViewTextsWithFilter( 'main', MciViewIds.main.customRangeStart, this.stats ); return callback(null); }, ], err => { this.refreshing = false; if (cb) { return cb(err); } } ); } _getStatusStrings(isAvailable, isVisible) { const availIndicators = Array.isArray(this.config.statusAvailableIndicators) ? this.config.statusAvailableIndicators : this.client.currentTheme.helpers.getStatusAvailIndicators(); const visIndicators = Array.isArray(this.config.statusVisibleIndicators) ? this.config.statusVisibleIndicators : this.client.currentTheme.helpers.getStatusVisibleIndicators(); return [ isAvailable ? availIndicators[1] || 'Y' : availIndicators[0] || 'N', isVisible ? visIndicators[1] || 'Y' : visIndicators[0] || 'N', ]; } _refreshStats(cb) { const fileAreaStats = StatLog.getSystemStat(SysProps.FileBaseAreaStats) || {}; const sysMemStats = StatLog.getSystemStat(SysProps.SystemMemoryStats) || {}; const sysLoadStats = StatLog.getSystemStat(SysProps.SystemLoadStats) || {}; const lastLoginStats = StatLog.getSystemStat(SysProps.LastLogin); const processTrafficStats = StatLog.getSystemStat(SysProps.ProcessTrafficStats) || {}; const now = moment(); const [availIndicator, visIndicator] = this._getStatusStrings( this.client.user.isAvailable(), this.client.user.isVisible() ); this.stats = { // Date/Time nowDate: now.format(this.getDateFormat()), nowTime: now.format(this.getTimeFormat()), now: now.format(this._dateTimeFormat('now')), // Current process (our Node.js service) processUptimeSeconds: process.uptime(), // Totals totalCalls: StatLog.getSystemStatNum(SysProps.LoginCount), totalPosts: StatLog.getSystemStatNum(SysProps.MessageTotalCount), totalUsers: StatLog.getSystemStatNum(SysProps.TotalUserCount), totalFiles: fileAreaStats.totalFiles || 0, totalFileBytes: fileAreaStats.totalBytes || 0, // Today's Stats callsToday: StatLog.getSystemStatNum(SysProps.LoginsToday), postsToday: StatLog.getSystemStatNum(SysProps.MessagesToday), uploadsToday: StatLog.getSystemStatNum(SysProps.FileUlTodayCount), uploadBytesToday: StatLog.getSystemStatNum(SysProps.FileUlTodayBytes), downloadsToday: StatLog.getSystemStatNum(SysProps.FileDlTodayCount), downloadBytesToday: StatLog.getSystemStatNum(SysProps.FileDlTodayBytes), newUsersToday: StatLog.getSystemStatNum(SysProps.NewUsersTodayCount), // Current currentUserName: this.client.user.username, currentUserRealName: this.client.user.getProperty(UserProps.RealName) || this.client.user.username, availIndicator: availIndicator, visIndicator: visIndicator, lastLoginUserName: lastLoginStats.userName, lastLoginRealName: lastLoginStats.realName, lastLoginDate: moment(lastLoginStats.timestamp).format(this.getDateFormat()), lastLoginTime: moment(lastLoginStats.timestamp).format(this.getTimeFormat()), lastLogin: moment(lastLoginStats.timestamp).format( this._dateTimeFormat('lastLogin') ), totalMemoryBytes: sysMemStats.totalBytes || 0, freeMemoryBytes: sysMemStats.freeBytes || 0, systemAvgLoad: sysLoadStats.average || 0, systemCurrentLoad: sysLoadStats.current || 0, newPrivateMail: StatLog.getUserStatNumByClient( this.client, UserProps.NewPrivateMailCount, MailCountTTLSeconds ), newMessagesAddrTo: StatLog.getUserStatNumByClient( this.client, UserProps.NewAddressedToMessageCount, MailCountTTLSeconds ), processBytesIngress: processTrafficStats.ingress || 0, processBytesEgress: processTrafficStats.egress || 0, }; return cb(null); } _getNodeStatusIndexByNodeId(nodeStatusView, nodeId) { return nodeStatusView.getItems().findIndex(entry => entry.node == nodeId); } _selectNodeByIndex(nodeStatusView, index) { if (index >= 0 && nodeStatusView.getFocusItemIndex() !== index) { nodeStatusView.setFocusItemIndex(index); } else { nodeStatusView.redraw(); } } _refreshNodeStatus(cb) { const nodeStatusView = this.getView('main', MciViewIds.main.nodeStatus); if (!nodeStatusView) { return cb(null); } const nodeStatusItems = getActiveConnectionList(AllConnections) .slice(0, nodeStatusView.dimens.height) .map(ac => { // Handle pre-authenticated if (!ac.authenticated) { ac.text = ac.userName = '*Pre Auth*'; ac.action = 'Logging In'; } const [availIndicator, visIndicator] = this._getStatusStrings( ac.isAvailable, ac.isVisible ); const timeOn = ac.timeOn || moment.duration(0); return Object.assign(ac, { availIndicator, visIndicator, timeOnMinutes: timeOn.asMinutes(), timeOn: _.upperFirst(timeOn.humanize()), // make friendly affils: ac.affils || 'N/A', realName: ac.realName || 'N/A', }); }); // If this is our first pass, we'll also update the selection const firstStatusRefresh = nodeStatusView.getCount() === 0; // :TODO: Currently this always redraws due to setItems(). We really need painters alg.; The alternative now is to compare items... yuk. nodeStatusView.setItems(nodeStatusItems); this._selectNodeByIndex(nodeStatusView, this.selectedNodeStatusIndex); // redraws if (firstStatusRefresh) { const nodeStatusSelectionView = this.getView( 'main', MciViewIds.main.selectedNodeStatusInfo ); if (nodeStatusSelectionView) { const item = nodeStatusView.getItems()[0]; this._updateNodeStatusSelection(nodeStatusSelectionView, item); } } return cb(null); } _refreshQuickLog(cb) { const quickLogView = this.viewControllers.main.getView( MciViewIds.main.quickLogView ); if (!quickLogView) { return cb(null); } const records = this.logRingBuffer.records; if (records.length === 0) { return cb(null); } const hasChanged = this.lastLogTime !== records[records.length - 1].time; this.lastLogTime = records[records.length - 1].time; if (!hasChanged) { return cb(null); } const quickLogTimestampFormat = this.config.quickLogTimestampFormat || this.getDateTimeFormat('short'); const levelIndicators = this.config.quickLogLevelIndicators || { trace: 'T', debug: 'D', info: 'I', warn: 'W', error: 'E', fatal: 'F', }; const makeLevelIndicator = level => { return levelIndicators[level] || '?'; }; const quickLogLevelMessagePrefixes = this.config.quickLogLevelMessagePrefixes || {}; const prefixMssage = (message, level) => { const prefix = quickLogLevelMessagePrefixes[level] || ''; return `${prefix}${message}`; }; const logItems = records.map(rec => { const level = bunyan.nameFromLevel[rec.level]; return { timestamp: moment(rec.time).format(quickLogTimestampFormat), level: rec.level, levelIndicator: makeLevelIndicator(level), nodeId: rec.nodeId || '*', sessionId: rec.sessionId || '', message: prefixMssage(rec.msg, level), }; }); quickLogView.setItems(logItems); quickLogView.redraw(); return cb(null); } _dateTimeFormat(element) { const format = this.config[`${element}DateTimeFormat`]; return format || this.getDateFormat(); } };