Source: engine/universal/machine/information/machineInformation.js

/* eslint-disable no-param-reassign */
/* eslint-disable class-methods-use-this */
const { machine, timer, network } = require('@proceed/system');
const config = require('../configuration/configHandler');
const routes = require('./src/routes/machineInformationRoutes');
const logging = require('../logging/logging');

const DAY = 864000;
const HALFDAY = DAY / 2;
const HOUR = 3600;
const HALFHOUR = HOUR / 2;
const MINUTE = 60;
const TENMINUTES = MINUTE * 10;

/**
 * Property names and corresponding cache timeouts in s.
 */
const PROPERTIES = {
  hostname: Infinity,
  id: Infinity,
  online: 5,
  os: Infinity,
  cpu: 0,
  mem: 5,
  disk: 30,
  battery: 30,
  display: 60,
  network: 10,
  outputs: 30,
  inputs: 30,
};

/**
 * The property names of the machine values that are set/appended by the config.
 */
const CONFIG_PROPERTIES = [
  'port',
  'classes',
  'domains',
  'inputs',
  'outputs',
  'onlineCheckingAddresses',
  'currentlyConnectedEnvironments',
  'name',
  'description',
  'acceptUserTasks',
  'deactivateProcessExecution',
];

const loggingConfigObject = {
  moduleName: 'MACHINE INFORMATION',
};

let instance;

/**
 * @memberof module:@proceed/machine
 * @class
 *
 * Class for requesting information about the machine the engine is running on.
 */
class MachineInformation {
  /**
   * @hideconstructor
   */
  constructor() {
    this.RAMloads = [];
    this.CPUloads = [];
    this.machineInfoCache = new Map();
    this.done = undefined;
    this.logger = logging.getLogger(loggingConfigObject);
  }

  /**
   * Initializes the Machine-Manager component by reading the interval at which cpu and ram
   * information should be fetched
   */
  async init() {
    // Set done promise so the other methods wait for init to finish.
    let succeed;
    this.done = new Promise((resolve) => {
      succeed = resolve;
    });

    this.logger.debug('Initializing the Machine Information module');

    const loadInterval = await config.readConfig('engine.loadInterval');
    if (loadInterval <= 60 && loadInterval >= 0) {
      this.loadInterval = loadInterval;
    } else {
      this.loadInterval = 10; // standard 10
    }
    this.maxLoadsSize = DAY / this.loadInterval;
    this.totalMemMB = (await this.getMachineInformation(['mem'])).mem.total;
    timer.setInterval(this._getRecord.bind(this), this.loadInterval * 1000);

    routes(this);
    succeed();
    return this.done;
  }

  /**
   * Returns information about the device
   * @param {string[]} properties The property names that are to be read
   * @param {object[]} The properties for the provided names
   */
  async getMachineInformation(properties) {
    let _properties = properties;
    if (!properties || properties.length === 0) {
      // create array to request all properties
      _properties = Object.keys(PROPERTIES).concat(CONFIG_PROPERTIES);
    }
    this.logger.trace('Machine information were requested: ' + _properties);

    const machineInfos = {};

    // set 'name' and 'description' which only come from the config and not the machine module
    // if 'name' is empty, set the hostname as default
    if (_properties.includes('name')) {
      const _name = await config.readConfig('name');
      machineInfos.name = _name ? _name : (await machine.getMachineInfo(['hostname']))['hostname'];
    }
    if (_properties.includes('description')) {
      machineInfos['description'] = await config.readConfig('description');
    }

    // properties array to object with values filled from cache or native machine module
    for (const _prop of _properties) {
      const [hit, cached] = this._checkCache(_prop);
      //get value from cache
      if (hit) {
        machineInfos[_prop] = cached;
      } else if (_prop === 'online') {
        const value = await this._checkOnline();
        machineInfos[_prop] = value;
        this._setCache(_prop, value);
      } else {
        // Fetch values from native machine module
        const _tmpMachineValueFromNative = await machine.getMachineInfo([_prop]);

        // Exclude the (non-existent) machine properties that come from the config: object is empty
        if (Object.keys(_tmpMachineValueFromNative).length !== 0) {
          machineInfos[_prop] = _tmpMachineValueFromNative[_prop];
          this._setCache(_prop, _tmpMachineValueFromNative[_prop]);
        }
      }
    }

    if (_properties.includes('cpu')) {
      // Add load averages:
      machineInfos.cpu.loadLastMinute = this._getAverageLoad(MINUTE).CPU;
      machineInfos.cpu.loadLastTenMinutes = this._getAverageLoad(TENMINUTES).CPU;
      machineInfos.cpu.loadLastHalfHour = this._getAverageLoad(HALFHOUR).CPU;
      machineInfos.cpu.loadLastHour = this._getAverageLoad(HOUR).CPU;
      machineInfos.cpu.loadLastHalfDay = this._getAverageLoad(HALFDAY).CPU;
      machineInfos.cpu.loadLastDay = this._getAverageLoad(DAY).CPU;
    }

    // put other config values into machine, call by reference
    await this._mergeConfigWithMachineInfos(machineInfos, _properties);

    this.logger.trace('Returned Machine infomation: ' + JSON.stringify(machineInfos));
    return machineInfos;
  }

  /**
   * Adds some config value to the machine values.
   * Note: Some properties can be set by both, the native machine and
   * the native config module. E.g. 'output' and 'inputs' are determined
   * from the machine but extended by the config values
   *
   * @param {object} machineObj object containing the information from the native machine module; call-by-reference
   * @param {array} relevantPropertiesToReturn array containing the keys that needs to be returned
   */
  async _mergeConfigWithMachineInfos(machineInfos, relevantPropertiesToReturn) {
    let configData = await config.readConfig('machine');
    let relevantConfigData = _filterRelevantConfigKeys(configData, relevantPropertiesToReturn);
    _mergeConfigAndMachine(relevantConfigData, machineInfos);

    configData = await config.readConfig('processes');
    relevantConfigData = _filterRelevantConfigKeys(configData, relevantPropertiesToReturn);
    _mergeConfigAndMachine(relevantConfigData, machineInfos);

    // Filter only the needed config keys to be requested
    function _filterRelevantConfigKeys(configKeys, relevantKeys) {
      const rkObject = {};
      Object.keys(configKeys)
        .filter((key) => relevantKeys.includes(key))
        .forEach((key) => {
          rkObject[key] = configKeys[key];
        });
      return rkObject;
    }

    // merge Keys if object and array,
    // only take config key if not already has a value in machineObj
    // call by reference
    function _mergeConfigAndMachine(configObj, machineObj) {
      Object.keys(configObj).forEach((key) => {
        if (typeof machineObj[key] === 'undefined') {
          machineObj[key] = configObj[key];
        } else if (Array.isArray(machineObj[key])) {
          const newVals = configObj[key].filter((val) => !machineObj[key].includes(val));
          machineObj[key] = machineObj[key].concat(newVals);
        } else if (typeof machineObj[key] === 'object') {
          _mergeConfigAndMachine(configObj[key], machineObj[key]);
        }
      });
    }
  }

  /**
   * Returns an array `[hit, result]` with `hit` being a boolean indicating if
   * the cache was hit or missed and `result` carrying the value, if any. This
   * is needed because all nullish values could also be valid cache hits and
   * thus aren't suitable for cache miss indication.
   * @private
   */
  _checkCache(property) {
    if (this.machineInfoCache.has(property)) {
      // Found entry in cache, but we still need to validate its freshness
      const [entry, timestamp] = this.machineInfoCache.get(property);
      const timeout = PROPERTIES[property];

      if (new Date().getTime() - timestamp > timeout * 1000) {
        // Invalidate cache if older than timeout
        this.machineInfoCache.delete(property);
      } else {
        return [true, entry];
      }
    }
    return [false, null];
  }

  _setCache(property, value) {
    // Store the value and set timestamp
    this.machineInfoCache.set(property, [value, new Date().getTime()]);
  }

  /**
   * Clears the cache. Only necessary for testing.
   */
  _clearCache() {
    this.machineInfoCache.clear();
  }

  /**
   * Calculates the average CPU and RAM load for a given interval
   * @param {number} time One of the supported time frames in seconds
   * @returns {object} An object including the average for the given time
   */
  _getAverageLoad(time) {
    // const tempRAMLoads = [...this.RAMloads].map((s) => parseFloat(s)); // clone
    const tempCPULoads = [...this.CPUloads];
    // if there are not enough snapshots, use all available
    // const RAMlowerBound = tempRAMLoads.length >= time / this.loadInterval
    //  ? tempRAMLoads.length - (time / this.loadInterval) : 0;
    const CPUlowerBound =
      tempCPULoads.length >= time / this.loadInterval
        ? tempCPULoads.length - time / this.loadInterval
        : 0;

    /*
    let RAMsum = 0;
    let RAMcount = 0;
    for (let i = tempRAMLoads.length - 1; i >= RAMlowerBound; i -= 1) {
      RAMsum += tempRAMLoads[i] / this.totalMemMB;
      RAMcount += 1;
    }

    let ramResult = 0;
    if (RAMsum !== 0) {
      ramResult = (1 - (RAMsum / RAMcount)).toFixed(4);
    } else {
      ramResult = 0;
    }
    */

    let CPUsum = 0;
    let CPUcount = 0;
    for (let i = tempCPULoads.length - 1; i >= CPUlowerBound; i -= 1) {
      CPUsum += tempCPULoads[i];
      CPUcount += 1;
    }

    let cpuResult = 0;
    if (CPUsum !== 0) {
      cpuResult = CPUsum / CPUcount;
    } else {
      cpuResult = 0;
    }

    return { RAM: 0, CPU: cpuResult };
  }

  /**
   * Fetches RAM utilization and saves it in the instance's array.
   */
  async _getRecord() {
    const { free } = (await this.getMachineInformation(['mem'])).mem;
    await this.done;
    if (this.RAMloads.length >= this.maxLoadsSize) {
      this.RAMloads.shift();
    }
    this.RAMloads.push(free);

    this._getCPULoadRecord();
  }

  /**
   * Calculates the current CPU utilization and adds it to the instance's CPU array
   */
  async _getCPULoadRecord() {
    const load = (await this.getMachineInformation(['cpu'])).cpu.currentLoad;

    if (this.CPUloads.length >= this.maxLoadsSize) {
      this.CPUloads.shift();
    }
    this.CPUloads.push(load);
  }

  async _checkOnline() {
    const addresses = await config.readConfig('machine.onlineCheckingAddresses');
    const online = (
      await Promise.all(
        addresses.map(
          (address) =>
            new Promise((resolve, reject) => {
              timer.setTimeout(() => {
                resolve(false);
              }, 10000);
              network
                .sendRequest(address, undefined, '/', undefined, false)
                .then(() => resolve(true))
                .catch((err) => resolve(err === 'Status Code was: 301'));
            })
        )
      )
    ).includes(true);
    return online;
  }
}

/*
 * Returns the Machine-Manager's instance and creates it if necessary.
 * returns {module:@proceed/machine}
 */
function getInstance() {
  if (!instance) {
    instance = new MachineInformation();
  }
  return instance;
}

module.exports = getInstance();