aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package-lock.json52
-rw-r--r--package.json2
-rw-r--r--src/server/ApiManagers/UserManager.ts1
-rw-r--r--src/server/DashStats.ts293
-rw-r--r--src/server/Message.ts2
-rw-r--r--src/server/index.ts13
-rw-r--r--src/server/stats/userLoginStats.csv7
-rw-r--r--src/server/websocket.ts36
-rw-r--r--views/resources/statsviewcontroller.js114
-rw-r--r--views/stats.pug30
-rw-r--r--views/stylesheets/statsview.css56
11 files changed, 564 insertions, 42 deletions
diff --git a/package-lock.json b/package-lock.json
index 4b83d07f8..7e664a22e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5552,6 +5552,19 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz",
"integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w=="
},
+ "csv-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.0.0.tgz",
+ "integrity": "sha512-s6OYSXAK3IdKqYO33y09jhypG/bSDHPuyCme/IdEHfWpLf/jKcpitVFyOC6UemgGk8v7Q5u2XE0vvwmanxhGlQ==",
+ "requires": {
+ "minimist": "^1.2.0"
+ }
+ },
+ "csv-stringify": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.3.0.tgz",
+ "integrity": "sha512-kTnnBkkLmAR1G409aUdShppWUClNbBQZXhrKrXzKYBGw4yfROspiFvVmjbKonCrdGfwnqwMXKLQG7ej7K/jwjg=="
+ },
"currently-unhandled": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz",
@@ -5571,16 +5584,6 @@
"integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=",
"dev": true
},
- "d": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz",
- "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==",
- "dev": true,
- "requires": {
- "es5-ext": "^0.10.50",
- "type": "^1.0.1"
- }
- },
"d3": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.8.4.tgz",
@@ -6990,28 +6993,6 @@
"is-symbol": "^1.0.2"
}
},
- "es5-ext": {
- "version": "0.10.61",
- "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.61.tgz",
- "integrity": "sha512-yFhIqQAzu2Ca2I4SE2Au3rxVfmohU9Y7wqGR+s7+H7krk26NXhIRAZDgqd6xqjCEFUomDEA3/Bo/7fKmIkW1kA==",
- "dev": true,
- "requires": {
- "es6-iterator": "^2.0.3",
- "es6-symbol": "^3.1.3",
- "next-tick": "^1.1.0"
- }
- },
- "es6-iterator": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
- "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
- "dev": true,
- "requires": {
- "d": "1",
- "es5-ext": "^0.10.35",
- "es6-symbol": "^3.1.1"
- }
- },
"es6-promise": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.2.1.tgz",
@@ -7023,7 +7004,6 @@
"integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==",
"dev": true,
"requires": {
- "d": "^1.0.1",
"ext": "^1.1.2"
}
},
@@ -22273,12 +22253,6 @@
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
},
- "type": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz",
- "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==",
- "dev": true
- },
"type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
diff --git a/package.json b/package.json
index 7bde4d194..0f9fc4895 100644
--- a/package.json
+++ b/package.json
@@ -185,6 +185,8 @@
"cookie-session": "^2.0.0",
"core-js": "^3.28.0",
"cors": "^2.8.5",
+ "csv-parser": "^3.0.0",
+ "csv-stringify": "^6.3.0",
"d3": "^7.6.1",
"D": "^1.0.0",
"depcheck": "^0.9.2",
diff --git a/src/server/ApiManagers/UserManager.ts b/src/server/ApiManagers/UserManager.ts
index 53e55c1c3..c3dadd821 100644
--- a/src/server/ApiManagers/UserManager.ts
+++ b/src/server/ApiManagers/UserManager.ts
@@ -5,6 +5,7 @@ import { msToTime } from '../ActionUtilities';
import * as bcrypt from 'bcrypt-nodejs';
import { Opt } from '../../fields/Doc';
import { WebSocket } from '../websocket';
+import { DashStats } from '../DashStats';
export const timeMap: { [id: string]: number } = {};
interface ActivityUnit {
diff --git a/src/server/DashStats.ts b/src/server/DashStats.ts
new file mode 100644
index 000000000..8d341db63
--- /dev/null
+++ b/src/server/DashStats.ts
@@ -0,0 +1,293 @@
+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<string, UserLastOperations>();
+
+ /**
+ * 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`;
+ }
+}
diff --git a/src/server/Message.ts b/src/server/Message.ts
index d87ae5027..8f0af08bc 100644
--- a/src/server/Message.ts
+++ b/src/server/Message.ts
@@ -94,4 +94,6 @@ export namespace MessageStore {
export const YoutubeApiQuery = new Message<YoutubeQueryInput>("Youtube Api Query");
export const DeleteField = new Message<string>("Delete field");
export const DeleteFields = new Message<string[]>("Delete fields");
+
+ export const UpdateStats = new Message<string>("updatestats");
}
diff --git a/src/server/index.ts b/src/server/index.ts
index 6562860fe..8b2e18847 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -19,6 +19,7 @@ import { GoogleCredentialsLoader, SSL } from './apis/google/CredentialsLoader';
import { GoogleApiServerUtils } from './apis/google/GoogleApiServerUtils';
import { DashSessionAgent } from './DashSession/DashSessionAgent';
import { AppliedSessionAgent } from './DashSession/Session/agents/applied_session_agent';
+import { DashStats } from './DashStats';
import { DashUploadUtils } from './DashUploadUtils';
import { Database } from './database';
import { Logger } from './ProcessFactory';
@@ -98,6 +99,18 @@ function routeSetter({ isRelease, addSupervisedRoute, logRegistrationOutcome }:
addSupervisedRoute({
method: Method.GET,
+ subscription: '/stats',
+ secureHandler: ({ res }) => DashStats.handleStats(res),
+ });
+
+ addSupervisedRoute({
+ method: Method.GET,
+ subscription: '/statsview',
+ secureHandler: ({ res }) => DashStats.handleStatsView(res),
+ });
+
+ addSupervisedRoute({
+ method: Method.GET,
subscription: '/resolvedPorts',
secureHandler: ({ res }) => res.send(resolvedPorts),
publicHandler: ({ res }) => res.send(resolvedPorts),
diff --git a/src/server/stats/userLoginStats.csv b/src/server/stats/userLoginStats.csv
new file mode 100644
index 000000000..23bcef885
--- /dev/null
+++ b/src/server/stats/userLoginStats.csv
@@ -0,0 +1,7 @@
+USER,ACTION,TIMEaaa@gmail.com,loggedIn,2023-04-08T20:08:17.533Z
+aaa@gmail.com,loggedIn,2023-04-08T20:14:32.460Z
+aaa@gmail.com,loggedIn,2023-04-08T20:14:44.884Z
+aaa@gmail.com,loggedIn,2023-04-08T20:14:56.854Z
+aaa@gmail.com,loggedIn,2023-04-08T20:16:59.747Z
+aaa@gmail.com,loggedIn,2023-04-08T20:56:50.759Z
+aaa@gmail.com,loggedIn,2023-04-08T20:56:58.175Z
diff --git a/src/server/websocket.ts b/src/server/websocket.ts
index 68b003496..a11d20cfa 100644
--- a/src/server/websocket.ts
+++ b/src/server/websocket.ts
@@ -12,6 +12,7 @@ import { GoogleCredentialsLoader, SSL } from './apis/google/CredentialsLoader';
import YoutubeApi from './apis/youtube/youtubeApiSample';
import { initializeGuest } from './authentication/DashUserModel';
import { Client } from './Client';
+import { DashStats } from './DashStats';
import { Database } from './database';
import { DocumentsCollection } from './IDatabase';
import { Diff, GestureContent, MessageStore, MobileDocumentUploadContent, MobileInkOverlayContent, Transferable, Types, UpdateMobileInkOverlayPositionContent, YoutubeQueryInput, YoutubeQueryTypes } from './Message';
@@ -20,8 +21,9 @@ import { resolvedPorts } from './server_Initialization';
export namespace WebSocket {
export let _socket: Socket;
- const clients: { [key: string]: Client } = {};
+ export const clients: { [key: string]: Client } = {};
export const socketMap = new Map<SocketIO.Socket, string>();
+ export const userOperations = new Map<string, number>();
export let disconnect: Function;
export async function initialize(isRelease: boolean, app: express.Express) {
@@ -49,6 +51,8 @@ export namespace WebSocket {
next();
});
+ socket.emit(MessageStore.UpdateStats.Message, DashStats.getUpdatedStatsBundle())
+
// convenience function to log server messages on the client
function log(message?: any, ...optionalParams: any[]) {
socket.emit('log', ['Message from server:', message, ...optionalParams]);
@@ -97,6 +101,17 @@ export namespace WebSocket {
console.log('received bye');
});
+ socket.on('disconnect', function () {
+ let currentUser = socketMap.get(socket);
+ if (!(currentUser === undefined)) {
+ let currentUsername = currentUser.split(' ')[0]
+ DashStats.logUserLogout(currentUsername, socket);
+ delete timeMap[currentUsername]
+ }
+ });
+
+
+
Utils.Emit(socket, MessageStore.Foo, 'handshooken');
Utils.AddServerHandler(socket, MessageStore.Bar, guid => barReceived(socket, guid));
@@ -130,6 +145,12 @@ export namespace WebSocket {
socket.disconnect(true);
};
});
+
+ setInterval(function() {
+ // Utils.Emit(socket, MessageStore.UpdateStats, DashStats.getUpdatedStatsBundle());
+
+ io.emit(MessageStore.UpdateStats.Message, DashStats.getUpdatedStatsBundle())
+ }, DashStats.SAMPLING_INTERVAL);
}
function processGesturePoints(socket: Socket, content: GestureContent) {
@@ -172,11 +193,16 @@ export namespace WebSocket {
}
function barReceived(socket: SocketIO.Socket, userEmail: string) {
- clients[userEmail] = new Client(userEmail.toString());
+ clients[userEmail] = new Client(userEmail.toString());
const currentdate = new Date();
const datetime = currentdate.getDate() + '/' + (currentdate.getMonth() + 1) + '/' + currentdate.getFullYear() + ' @ ' + currentdate.getHours() + ':' + currentdate.getMinutes() + ':' + currentdate.getSeconds();
console.log(blue(`user ${userEmail} has connected to the web socket at: ${datetime}`));
+ printActiveUsers();
+
+ timeMap[userEmail] = Date.now();
socketMap.set(socket, userEmail + ' at ' + datetime);
+ userOperations.set(userEmail, 0);
+ DashStats.logUserLogin(userEmail, socket);
}
function getField([id, callback]: [string, (result?: Transferable) => void]) {
@@ -300,7 +326,8 @@ export namespace WebSocket {
const remListItems = diff.diff.$set[updatefield].fields;
const curList = (curListItems as any)?.fields?.[updatefield.replace('fields.', '')]?.fields.filter((f: any) => f !== null) || [];
diff.diff.$set[updatefield].fields = curList?.filter(
- (curItem: any) => !remListItems.some((remItem: any) => (remItem.fieldId ? remItem.fieldId === curItem.fieldId : remItem.heading ? remItem.heading === curItem.heading : remItem === curItem))
+ (curItem: any) => !remListItems.some((remItem: any) => (remItem.fieldId ? remItem.fieldId === curItem.fieldId :
+ remItem.heading ? remItem.heading === curItem.heading : remItem === curItem))
);
const sendBack = diff.diff.length !== diff.diff.$set[updatefield].fields.length;
delete diff.diff.length;
@@ -346,6 +373,9 @@ export namespace WebSocket {
var CurUser: string | undefined = undefined;
function UpdateField(socket: Socket, diff: Diff) {
+ let currentUsername = socketMap.get(socket)!.split(' ')[0];
+ userOperations.set(currentUsername, userOperations.get(currentUsername) !== undefined ? userOperations.get(currentUsername)! + 1 : 0);
+
if (CurUser !== socketMap.get(socket)) {
CurUser = socketMap.get(socket);
console.log('Switch User: ' + CurUser);
diff --git a/views/resources/statsviewcontroller.js b/views/resources/statsviewcontroller.js
new file mode 100644
index 000000000..090e112e7
--- /dev/null
+++ b/views/resources/statsviewcontroller.js
@@ -0,0 +1,114 @@
+/**
+ * statsviewcontroller.js stores the JavaScript functions to update the stats page
+ * when the websocket updates.
+ */
+
+const BUSY_SERVER_BOUND = 2;
+const VERY_BUSY_SERVER_BOUND = 3;
+
+const MEDIUM_USE_BOUND = 100; //operations per 10 seconds
+const HIGH_USE_BOUND = 300;
+
+const serverTrafficMessages = {
+ 0 : "Not Busy",
+ 1 : "Busy",
+ 2: "Very Busy"
+};
+
+/**
+ * userDataComparator sorts the users based on the rate
+ *
+ * @param {*} user1 the first user to compare
+ * @param {*} user2 the second user to comapre
+ * @returns an integer indiciating which user should come first
+ */
+function userDataComparator(user1, user2) {
+ if(user1.rate < user2.rate) {
+ return 1;
+ } else if(user1.rate > user2.rate) {
+ return -1;
+ } else {
+ return 0;
+ }
+}
+
+/**
+ * calculateServerTraffic() returns an integer corresponding
+ * to the current traffic that can be used to get the message
+ * from "serverTrafficMessages"
+ *
+ * @param {*} data the incoming data from the backend
+ * @returns an integer where 0 is not busy, 1 is busy, and 2 is very busy.
+ */
+function calculateServerTraffic(data) {
+ let currentTraffic = data.connectedUsers.length;
+
+ let serverTraffic = 0;
+ if(currentTraffic < BUSY_SERVER_BOUND) {
+ serverTraffic = 0;
+ } else if(currentTraffic >= BUSY_SERVER_BOUND && currentTraffic < VERY_BUSY_SERVER_BOUND) {
+ serverTraffic = 1;
+ } else {
+ serverTraffic = 2;
+ }
+
+ return serverTraffic;
+}
+
+/**
+ * getUserRateColor determines what color the user's rate should
+ * be on the front end
+ * @param {*} rate the operations per time interval for a specific user
+ * @returns a string representing the color to make the user rate
+ */
+function getUserRateColor(rate) {
+ if(rate < MEDIUM_USE_BOUND) {
+ return "black";
+ } else if(rate >= MEDIUM_USE_BOUND && rate < HIGH_USE_BOUND) {
+ return "orange";
+ } else if(rate >= HIGH_USE_BOUND){
+ return "red";
+ } else {
+ return "black";
+ }
+}
+
+/**
+ * handleStatsUpdats() is called when new data is received from the backend
+ * from a websocket event. The method updates the HTML site to reflect the
+ * updated data
+ *
+ * @param {*} data the data coming from the backend.
+ */
+function handleStatsUpdate(data) {
+ let userListInnerHTML = "";
+ data.connectedUsers.sort(userDataComparator);
+ data.connectedUsers.map((userData, index) => {
+ let userRateColor = getUserRateColor(userData.rate);
+ let userEntry = `<p>${userData.time}</p>
+ <p>${userData.username}</p>
+ <p>Operations: ${userData.operations}</p>
+ <p style="color:${userRateColor}">Rate: ${userData.rate} operations per last 10 seconds</p>
+ `; // user data comes as last 10 seconds but it can be adjusted in DastStats.ts and websocket.ts
+ userListInnerHTML += "<li class=\"none\">" + userEntry + "</li>";
+ })
+
+ document.getElementById("connection-count").innerHTML = `Current Connections: ${data.connectedUsers.length}`
+ document.getElementById("connected-user-list").innerHTML = userListInnerHTML;
+
+ let serverTraffic = calculateServerTraffic(data);
+ let serverTrafficMessage = "Not Busy";
+ switch(serverTraffic) {
+ case 0:
+ serverTrafficMessage = "Not Busy";
+ break;
+ case 1:
+ serverTrafficMessage = "Busy";
+ break;
+ case 2:
+ serverTrafficMessage = "Very Busy";
+ break;
+ }
+ document.getElementById("stats-traffic-message").className="stats-server-status-item stats-server-status-" + serverTraffic;
+ document.getElementById("stats-traffic-message").innerHTML = `<p>${serverTrafficMessage}</p>`;
+} \ No newline at end of file
diff --git a/views/stats.pug b/views/stats.pug
new file mode 100644
index 000000000..16c28087e
--- /dev/null
+++ b/views/stats.pug
@@ -0,0 +1,30 @@
+extends ./layout
+
+//- stats.pug is the frontend for the stats page
+block content
+ style
+ include ./stylesheets/authentication.css
+ include ./stylesheets/statsview.css
+ script(src=`http://localhost:4321/socket.io/socket.io.js`)
+ script
+ include ./resources/statsviewcontroller.js
+ script.
+ var socket = io.connect("http://localhost:4321");
+ socket.on("connect", () => console.log("connected to socket"));
+
+ socket.on("a2cf757f-abd7-537b-953e-ef2f4f798f7e", (data) => handleStatsUpdate(data));
+ .outermost
+ .stats-container
+ h1 Dash Stats
+
+ p(class="stats-content" id="connection-count") Current Connections: #{numConnections}
+ div(class="stats-content stats-server-status-container")
+ p Server Status:
+ div(id="stats-traffic-message" class="stats-server-status-item stats-server-status-" + serverTraffic)
+ p #{serverTrafficMessage}
+ div(class="stats-content stats-connected-users")
+ p Connected Users:
+ ul(id="connected-user-list")
+
+
+ \ No newline at end of file
diff --git a/views/stylesheets/statsview.css b/views/stylesheets/statsview.css
new file mode 100644
index 000000000..c018bedfc
--- /dev/null
+++ b/views/stylesheets/statsview.css
@@ -0,0 +1,56 @@
+.outermost {
+ background-color: #251f1f;
+ display: flex;
+ flex-direction: row;
+ height: 98vh;
+ width: 99vw;
+ justify-content: center;
+ position: relative;
+}
+
+.stats-container {
+ background-color: white;
+
+ padding: 1rem;
+ width: 80vw;
+ border-radius: 8px;
+}
+
+.stats-content {
+ font-size: 1.25em;
+
+}
+
+.stats-server-status-container {
+ display: flex;
+ flex-direction: row;
+}
+
+.stats-server-status-item {
+ margin-left: 0.25rem;
+ padding: 0px 5px;
+
+ border-radius: 3px;
+ width: 8rem;
+ text-align: center;
+}
+
+.stats-server-status-0 {
+ /* not busy */
+ border: 3px green solid;
+}
+
+.stats-server-status-1 {
+ /* busy */
+ border: 3px #ffcc00 solid;
+}
+
+.stats-server-status-2 {
+ /* very busy */
+ border: 3px red solid;
+}
+
+.stats-connected-users {
+ max-height: 70vh;
+ overflow-y: auto;
+} \ No newline at end of file