import { cyan, magenta } from 'colors'; import { Response } from 'express'; import SocketIO from 'socket.io'; import { timeMap } from './ApiManagers/UserManager'; import { WebSocket } from './websocket'; const fs = require('fs'); /** * DashStats focuses on tracking user data for each session. * * This includes time connected, number of operations, and * the rate of their operations */ export namespace DashStats { export const SAMPLING_INTERVAL = 1000; // in milliseconds (ms) - Time interval to update the frontend. export const RATE_INTERVAL = 10; // in seconds (s) - Used to calculate rate const statsCSVFilename = './src/server/stats/userLoginStats.csv'; const columns = ['USERNAME', 'ACTION', 'TIME']; /** * UserStats holds the stats associated with a particular user. */ interface UserStats { socketId: string; username: string; time: string; operations: number; rate: number; } /** * UserLastOperations is the queue object for each user * storing their past operations. */ interface UserLastOperations { sampleOperations: number; // stores how many operations total are in this rate section (10 sec, for example) lastSampleOperations: number; // stores how many total operations were recorded at the last sample previousOperationsQueue: number[]; // stores the operations to calculate rate. } /** * StatsDataBundle represents an object that will be sent to the frontend view * on each websocket update. */ interface StatsDataBundle { connectedUsers: UserStats[]; } /** * CSVStore represents how objects will be stored in the CSV */ interface CSVStore { USERNAME: string; ACTION: string; TIME: string; } /** * ServerTraffic describes the current traffic going to the backend. */ enum ServerTraffic { NOT_BUSY, BUSY, VERY_BUSY } // These values can be changed after further testing how many // users correspond to each traffic level in Dash. const BUSY_SERVER_BOUND = 2; const VERY_BUSY_SERVER_BOUND = 3; const serverTrafficMessages = [ "Not Busy", "Busy", "Very Busy" ] // lastUserOperations maps each username to a UserLastOperations // structure export const lastUserOperations = new Map(); /** * handleStats is called when the /stats route is called, providing a JSON * object with relevant stats. In this case, we return the number of * current connections and * @param res Response object from Express */ export function handleStats(res: Response) { let current = getCurrentStats(); const results: CSVStore[] = []; res.json({ currentConnections: current.length, socketMap: current, }); } /** * getUpdatedStatesBundle() sends an updated copy of the current stats to the * frontend /statsview route via websockets. * * @returns a StatsDataBundle that is sent to the frontend view on each websocket update */ export function getUpdatedStatsBundle(): StatsDataBundle { let current = getCurrentStats(); return { connectedUsers: current, } } /** * handleStatsView() is called when the /statsview route is called. This * will use pug to render a frontend view of the current stats * * @param res */ export function handleStatsView(res: Response) { let current = getCurrentStats(); let connectedUsers = current.map((socketPair) => { return socketPair.time + " - " + socketPair.username + " Operations: " + socketPair.operations; }) let serverTraffic = ServerTraffic.NOT_BUSY; if(current.length < BUSY_SERVER_BOUND) { serverTraffic = ServerTraffic.NOT_BUSY; } else if(current.length >= BUSY_SERVER_BOUND && current.length < VERY_BUSY_SERVER_BOUND) { serverTraffic = ServerTraffic.BUSY; } else { serverTraffic = ServerTraffic.VERY_BUSY; } res.render("stats.pug", { title: "Dash Stats", numConnections: connectedUsers.length, serverTraffic: serverTraffic, serverTrafficMessage : serverTrafficMessages[serverTraffic], connectedUsers: connectedUsers }); } /** * logUserLogin() writes a login event to the CSV file. * * @param username the username in the format of "username@domain.com logged in" * @param socket the websocket associated with the current connection */ export function logUserLogin(username: string | undefined, socket: SocketIO.Socket) { if (!(username === undefined)) { let currentDate = new Date(); console.log(magenta(`User ${username.split(' ')[0]} logged in at: ${currentDate.toISOString()}`)); let toWrite: CSVStore = { USERNAME : username, ACTION : "loggedIn", TIME : currentDate.toISOString() } let statsFile = fs.createWriteStream(statsCSVFilename, { flags: "a"}); statsFile.write(convertToCSV(toWrite)); statsFile.end(); console.log(cyan(convertToCSV(toWrite))); } } /** * logUserLogout() writes a logout event to the CSV file. * * @param username the username in the format of "username@domain.com logged in" * @param socket the websocket associated with the current connection. */ export function logUserLogout(username: string | undefined, socket: SocketIO.Socket) { if (!(username === undefined)) { let currentDate = new Date(); let statsFile = fs.createWriteStream(statsCSVFilename, { flags: "a"}); let toWrite: CSVStore = { USERNAME : username, ACTION : "loggedOut", TIME : currentDate.toISOString() } statsFile.write(convertToCSV(toWrite)); statsFile.end(); } } /** * getLastOperationsOrDefault() is a helper method that will attempt * to query the lastUserOperations map for a specified username. If the * username is not in the map, an empty UserLastOperations object is returned. * @param username * @returns the user's UserLastOperations structure or an empty * UserLastOperations object (All values set to 0) if the username is not found. */ function getLastOperationsOrDefault(username: string): UserLastOperations { if(lastUserOperations.get(username) === undefined) { let initializeOperationsQueue = []; for(let i = 0; i < RATE_INTERVAL; i++) { initializeOperationsQueue.push(0); } return { sampleOperations: 0, lastSampleOperations: 0, previousOperationsQueue: initializeOperationsQueue } } return lastUserOperations.get(username)!; } /** * updateLastOperations updates a specific user's UserLastOperations information * for the current sampling cycle. The method removes old/outdated counts for * operations from the queue and adds new data for the current sampling * cycle to the queue, updating the total count as it goes. * @param lastOperationData the old UserLastOperations data that must be updated * @param currentOperations the total number of operations measured for this sampling cycle. * @returns the udpated UserLastOperations structure. */ function updateLastOperations(lastOperationData: UserLastOperations, currentOperations: number): UserLastOperations { // create a copy of the UserLastOperations to modify let newLastOperationData: UserLastOperations = { sampleOperations: lastOperationData.sampleOperations, lastSampleOperations: lastOperationData.lastSampleOperations, previousOperationsQueue: lastOperationData.previousOperationsQueue.slice() } let newSampleOperations = newLastOperationData.sampleOperations; newSampleOperations -= newLastOperationData.previousOperationsQueue.shift()!; // removes and returns the first element of the queue let operationsThisCycle = currentOperations - lastOperationData.lastSampleOperations; newSampleOperations += operationsThisCycle; // add the operations this cycle to find out what our count for the interval should be (e.g operations in the last 10 seconds) // update values for the copy object newLastOperationData.sampleOperations = newSampleOperations; newLastOperationData.previousOperationsQueue.push(operationsThisCycle); newLastOperationData.lastSampleOperations = currentOperations; return newLastOperationData; } /** * getUserOperationsOrDefault() is a helper method to get the user's total * operations for the CURRENT sampling interval. The method will return 0 * if the username is not in the userOperations map. * @param username the username to search the map for * @returns the total number of operations recorded up to this sampling cycle. */ function getUserOperationsOrDefault(username: string): number { return WebSocket.userOperations.get(username) === undefined ? 0 : WebSocket.userOperations.get(username)! } /** * getCurrentStats() calculates the total stats for this cycle. In this case, * getCurrentStats() returns an Array of UserStats[] objects describing * the stats for each user * @returns an array of UserStats storing data for each user at the current moment. */ function getCurrentStats(): UserStats[] { let socketPairs: UserStats[] = []; for (let [key, value] of WebSocket.socketMap) { let username = value.split(' ')[0]; let connectionTime = new Date(timeMap[username]); let connectionTimeString = connectionTime.toLocaleDateString() + " " + connectionTime.toLocaleTimeString(); if (!key.disconnected) { let lastRecordedOperations = getLastOperationsOrDefault(username); let currentUserOperationCount = getUserOperationsOrDefault(username); socketPairs.push({ socketId: key.id, username: username, time: connectionTimeString.includes("Invalid Date") ? "" : connectionTimeString, operations : WebSocket.userOperations.get(username) ? WebSocket.userOperations.get(username)! : 0, rate: lastRecordedOperations.sampleOperations }); lastUserOperations.set(username, updateLastOperations(lastRecordedOperations,currentUserOperationCount)); } } return socketPairs; } /** * convertToCSV() is a helper method that stringifies a CSVStore object * that can be written to the CSV file later. * @param dataObject the object to stringify * @returns the object as a string. */ function convertToCSV(dataObject: CSVStore): string { return `${dataObject.USERNAME},${dataObject.ACTION},${dataObject.TIME}\n`; } }