diff --git a/.env b/.env new file mode 100644 index 0000000..5cc1924 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +PORT=8000 +HOST=127.0.0.1 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b824bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +**/__pycache__ +/logs/*.log \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..227a618 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Module", + "type": "python", + "request": "launch", + "module": "server_stats", + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..df7e682 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "python.formatting.provider": "black", + "terminal.integrated.env.linux": { + "PYTHONPATH": "${workspaceRoot}/src" + }, + "terminal.integrated.env.windows": { + "PYTHONPATH": "${workspaceRoot}/src" + }, + "files.exclude": { + "**/__pycache__": true, + "**/.pytest_cache": true + }, + "python.testing.pytestArgs": [ + "src" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/README.md b/README.md index 4bb1c7e..22e6558 100644 --- a/README.md +++ b/README.md @@ -18,19 +18,12 @@ Three smaller, more detailed sections to view more in depth statistics on hardwa ## Tech - - - [FastAPI](https://fastapi.tiangolo.com/) - - HTML/CSS/JS - [ChartJS](https://www.chartjs.org/) - [Poetry](https://python-poetry.org/docs/basic-usage/) - [Pytest](https://docs.pytest.org/en/7.1.x/) -## Usage -- Run main.py on the computer you wish to monitor, then connect to it via LAN (http://LANIP:8080/stats) - - - ## Installation [Python 3.7+](https://www.python.org/) @@ -38,12 +31,12 @@ Three smaller, more detailed sections to view more in depth statistics on hardwa To run: ``` poetry install -poetry run python main.py +poetry run python -m server_stats ``` -Connect to your ip, at the endpoint '/stats' on port 8080 +Connect to your ip, at the main endpoint on port 8000 (by default) ``` -http://YOUR_IP:8080/stats (if accessing from a different client machine than the host) -http://localhost:8080/stats (if youre accessing from the host machine) +http://YOUR_IP:8000/(if accessing from a different client machine than the host) +http://localhost:8080/ (if youre accessing from the host machine) ``` To run tests (development only): ``` @@ -51,7 +44,16 @@ poetry run pytest ``` ## Development -Want to contribute? Great! +### Contributors + +... + +### TODO + +- [ ] Temperature monitoring for Windows +- [ ] Visual display of temperature +- [ ] Js module for charts +- [ ] App integration tests strategy ## License diff --git a/tests/__init__.py b/logs/.gitkeep similarity index 100% rename from tests/__init__.py rename to logs/.gitkeep diff --git a/main.py b/main.py deleted file mode 100644 index a8e17cb..0000000 --- a/main.py +++ /dev/null @@ -1,88 +0,0 @@ -from fastapi import ( - FastAPI, - Request, - HTTPException, - WebSocket, - WebSocketDisconnect, -) -from fastapi.templating import Jinja2Templates -from fastapi.staticfiles import StaticFiles -from fastapi.responses import HTMLResponse -from utilities.connection_manager import Manager -from utilities.stats import Computer -import logging -import typing -import uvicorn -import uuid - - -app = FastAPI() -connection_manager = Manager() -templates = Jinja2Templates(directory="templates") -app.mount("/static", StaticFiles(directory=r"C:\Users\rpski\Desktop\Example Code\ServStats\static"), name="static") - -logging.basicConfig(filename="./logs/logs.log", filemode="w", level=logging.DEBUG) - - -def generate_id() -> str: - """ - Generates a UUID string for unique client IDs - :return: String representation of UUID object - """ - return str(uuid.uuid4()) - - -@app.get("/favicon.ico") -async def favicon() -> typing.NoReturn: - # No current FavIcon - fix later - raise HTTPException(status_code=403, detail="No favicon") - - -@app.get("/stats", response_class=HTMLResponse) -def stats_endpoint(request: Request) -> templates.TemplateResponse: - """ - HTTP endpoint to serve the Server Statistics Dashboard - :param request: HTTP Request from Client - :return: Returns the associated HTML files to the requesting client - """ - client_id = generate_id() - return templates.TemplateResponse( - "index.html", {"request": request, "id": client_id} - ) - - -@app.websocket("/ws/stats") -async def stats_websocket(client_websocket: WebSocket): - """ - Web Socket endpoint for communicating the "Server Statistics" in JSON to the client. Communication with the - data visualization client is done here. - :param client_websocket: Incoming Web Socket request. - :return: No explicit return, just continuous requests for information from client - """ - await connection_manager.connect(client_websocket) - try: - # Initial connection - await client_websocket.send_json({"event": "CONNECT"}) - while True: - try: - # Client sending data.... - data = await client_websocket.receive_json() - # DATAREQUEST is the asking protocol from the client requesting for the Hardware stats - if data["event"] == "DATAREQUEST": - await client_websocket.send_json( - {"event": "DATAREQUEST", "data": Computer.get_stats_dict()} - ) - else: - # Log if for some reason we get unexpected communication protocol from the client - logging.debug(data) - except WebSocketDisconnect: - await connection_manager.disconnect_websocket(client_websocket) - break - except Exception as e: - logging.debug(e) - except Exception as e: - logging.debug(e) - - -if __name__ == "__main__": - uvicorn.run(app, port=8080, host="0.0.0.0") diff --git a/poetry.lock b/poetry.lock index edab126..916f06b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -67,6 +67,7 @@ mypy-extensions = ">=0.4.3" pathspec = ">=0.9.0" platformdirs = ">=2" tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} [package.extras] colorama = ["colorama (>=0.4.3)"] @@ -463,6 +464,7 @@ python-versions = ">=3.6" [package.dependencies] anyio = ">=3.4.0,<5" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} [package.extras] full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"] @@ -569,8 +571,8 @@ python-versions = ">=3.7" [metadata] lock-version = "1.1" -python-versions = "^3.10" -content-hash = "a0c1809b6413370a92e35602b687639faa77b5d7d947d48b0e2be5ba1e581912" +python-versions = ">=3.8.10,<=3.10" +content-hash = "6afc0b1cb070e93a62dd213a5888f1f25648e96723cc6b8d899f7420c1fdb689" [metadata.files] anyio = [ diff --git a/pyproject.toml b/pyproject.toml index c629b8a..29c30b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,25 +1,26 @@ [tool.poetry] -name = "pythonproject" +name = "server-stats" version = "0.1.0" description = "" authors = [] [tool.poetry.dependencies] -python = "^3.10" +python = ">=3.8.10,<=3.10" fastapi = "^0.79.0" psutil = "^5.9.1" uvicorn = {extras = ["standard"], version = "^0.18.2"} Jinja2 = "^3.1.2" websockets = "^10.3" +python-dotenv = "^0.20.0" [tool.poetry.dev-dependencies] black = "^22.6.0" pytest = "^7.1.2" +pytest-asyncio = "^0.19.0" httpx = "^0.23.0" requests = "^2.28.1" trio = "^0.21.0" asyncio = "^3.4.3" -pytest-asyncio = "^0.19.0" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/88c629a5893c04a876ff51f0a740dcf1.gif b/resources/88c629a5893c04a876ff51f0a740dcf1.gif similarity index 100% rename from 88c629a5893c04a876ff51f0a740dcf1.gif rename to resources/88c629a5893c04a876ff51f0a740dcf1.gif diff --git a/src/server_stats/__init__.py b/src/server_stats/__init__.py new file mode 100644 index 0000000..9685b26 --- /dev/null +++ b/src/server_stats/__init__.py @@ -0,0 +1,8 @@ +# REVIEW: in order to be recognized as a module must have a __init__.py file +# https://docs.python.org/3.8/library/__main__.html. Module can now be run with python -m "module name" + +import logging + +from .constants import LOGGING_DIR + +logging.basicConfig(filename=LOGGING_DIR, filemode="w", level=logging.DEBUG) diff --git a/src/server_stats/__main__.py b/src/server_stats/__main__.py new file mode 100644 index 0000000..80e2b39 --- /dev/null +++ b/src/server_stats/__main__.py @@ -0,0 +1,95 @@ +# REVIEW: imports should be sorted in groups: top is python standard library, middle is third party, bottom is local +import logging + +import uvicorn +from fastapi import FastAPI, HTTPException, Request, WebSocket +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +# REVIEW: all internal imports should be relative +from .connection_manager import ConnectionManager +from .constants import ( + HOST, + PORT, + ROOT, + STATIC_DIR, + STATIC_ROUTE, + TEMPLATES_DIR, + WEBSOCKET_ROUTE, +) +from .computer import Computer +from .helpers import generate_id + +# REVIEW: best practice for logger is to initialize logger settings in __ini__.py +# then get the logger when being used in a script. __name__ is going to return main which was +# the name of the logger +logger = logging.getLogger(__name__) + +# REVIEW: the __main__.py (called a dunder main) is the top-level script that is executed when the program is run +# https://docs.python.org/3.8/library/__main__.html + +# REVIEW: good to have a separate function for main and +# call it in the if statement below +app = FastAPI() +connection_manager = ConnectionManager() +templates = Jinja2Templates(directory=TEMPLATES_DIR) + +# REVIEW: Didn't do a ton of research on this but this may not be the recommended mounting pattern for static files +# https://fastapi.tiangolo.com/tutorial/static-files/ +app.mount( + STATIC_ROUTE, + # REVIEW: file paths should be constants in most cases + StaticFiles(directory=STATIC_DIR), + name="static", +) + + +# REVIEW: not really needed to type NoReturn, can just have no type hint +# or type hint None if you really feel like it +@app.get("/favicon.ico") +async def favicon() -> None: + # No current FavIcon - fix later + raise HTTPException(status_code=403, detail="No favicon") + + +# REVIEW: Since there is only a single endpoint it should be kept as root +@app.get(ROOT, response_class=HTMLResponse) +def root(request: Request): + """ + HTTP endpoint to serve the Server Statistics Dashboard + :param request: HTTP Request from Client + :return: Returns the associated HTML files to the requesting client + """ + client_id = generate_id() + return templates.TemplateResponse( + "index.html", {"request": request, "id": client_id} + ) + + +# REVIEW: Since there is only a single websocket it should be kept as root. As you add +# more websockets you can change this if necessary +@app.websocket(WEBSOCKET_ROUTE) +async def root_websocket(client_websocket: WebSocket): + """ + Web Socket endpoint for communicating the "Server Statistics" in JSON to the client. Communication with the + data visualization client is done here. + :param client_websocket: Incoming Web Socket request. + :return: No explicit return, just continuous requests for information from client + """ + # REVIEW: don't need to resend the connection since the .connect method already does this + await connection_manager.connect(client_websocket) + # Initial connection + while True: + # Client sending data.... + data = await client_websocket.receive_json() + + # DATAREQUEST is the asking protocol from the client requesting for the Hardware stats + if data["event"] == "DATAREQUEST": + await client_websocket.send_json( + {"event": "DATAREQUEST", "stats": Computer().get_stats_dict()} + ) + + +if __name__ == "__main__": + uvicorn.run(app, port=PORT, host=HOST) diff --git a/src/server_stats/computer.py b/src/server_stats/computer.py new file mode 100644 index 0000000..ad083b3 --- /dev/null +++ b/src/server_stats/computer.py @@ -0,0 +1,111 @@ +from typing import Dict + +import psutil + +from .constants import BYTES_IN_GB + +""" +These helper functions are not intended to be called in any other place other than the provided class. +The functions themselves arent even really necessary, but I thought it looked cleaner to do the data conversion +in some functions rather than inline in the class itself, and just decided to write functions for each +class variable. +""" + +# REVIEW: These methods were purely in service of the Computer class so they belong within the class. You hitting this ambiguity is understandable +# as other languages use private vs public methods/properties to distinguish between functionality that is exposed externally versus +# functionality that is not. Since python does not use this people can be tempted to keep functions outside of the class in some cases but +# usually they should be kept within the class. +class Computer: + """ + This class is used to easily evaluate the status of the computer. It returns a dictionary with the results + of querying the status os the available computer components via psutils + """ + + def get_stats_dict(self) -> dict: + # REVIEW: Ideally all dict keys that are set in code should be constants + stats_dict = { + "cpu_count": self.cpu_count, + "cpu_usage": self.cpu_percent, + "cpu_frequency": self.cpu_frequency, + "core_temperatures": self.temperatures, + "ram_total": self.total_ram, + "ram_available": self.available_ram, + "ram_percentage": self.percentage_used_ram, + "disk_total": self.total_disk_space, + "disk_free": self.total_disk_free, + "disk_used": self.total_disk_used, + "disk_percentage": self.disk_percentage_used, + } + return stats_dict + + # REVIEW: since you only need a "getter" method it makes more sense to use a property instead of a method + @property + def cpu_count(self) -> int: + return psutil.cpu_count(logical=False) + + @property + def cpu_percent(self) -> float: + return psutil.cpu_percent(interval=1, percpu=False) + + @property + def cpu_frequency(self) -> Dict[str, float]: + current = psutil.cpu_freq()[0] + max_frequency = float(psutil.cpu_freq()[2]) + cpu_freq_dict = { + "current_frequency": current / 1000, + "max_frequency": max_frequency, + } + return cpu_freq_dict + + @property + def total_ram(self) -> float: + mem = psutil.virtual_memory() + # REVIEW: conversions should be preserved as a constant + return round((mem.total / BYTES_IN_GB), 2) + + @property + def available_ram(self) -> float: + mem = psutil.virtual_memory() + return round((mem.available / BYTES_IN_GB), 2) + + @property + def percentage_used_ram(self) -> float: + mem = psutil.virtual_memory() + return round(mem.percent, 2) + + @property + def total_disk_space(self) -> float: + disk = psutil.disk_usage("/") + return round((disk.total / BYTES_IN_GB), 2) + + @property + def total_disk_free(self) -> float: + disk = psutil.disk_usage("/") + return round((disk.free / BYTES_IN_GB), 2) + + @property + def total_disk_used(self) -> float: + disk = psutil.disk_usage("/") + return round((disk.total - disk.free) / BYTES_IN_GB, 2) + + @property + def disk_percentage_used(self) -> float: + disk = psutil.disk_usage("/") + return disk.percent + + @property + def temperatures(self) -> dict: + # Core temperature only available on linux machine + """ + Get core temperature(s) + :return: Dict of core temperatures if running on Linux. If other OS, returns and empty dictionary. + """ + try: + core_temp_list = psutil.sensors_temperatures(fahrenheit=True)["coretemp"] + core_temp_dict = {} + for core in core_temp_list: + core_temp_dict[core.label] = core.current + return core_temp_dict + except AttributeError: + core_temp_dict = {} + return core_temp_dict diff --git a/src/server_stats/connection_manager.py b/src/server_stats/connection_manager.py new file mode 100644 index 0000000..43a996c --- /dev/null +++ b/src/server_stats/connection_manager.py @@ -0,0 +1,16 @@ +from typing import List + +from fastapi import WebSocket + + +# REVIEW: classes should be capitalized and named the same as the file +class ConnectionManager: + def __init__(self): + self.connections: List[WebSocket] = [] + + async def connect(self, web_socket: WebSocket) -> None: + await web_socket.accept() + self.connections.append(web_socket) + + async def disconnect_websocket(self, web_socket: WebSocket) -> None: + self.connections.remove(web_socket) diff --git a/src/server_stats/constants.py b/src/server_stats/constants.py new file mode 100644 index 0000000..a261b21 --- /dev/null +++ b/src/server_stats/constants.py @@ -0,0 +1,21 @@ +import os + +from dotenv import load_dotenv + +load_dotenv() + +# REVIEW: things like port and local host should be saved as ENV variables +PORT = int(os.environ.get("PORT")) +HOST = os.environ.get("HOST") + +## ROUTES +STATIC_ROUTE = "/static" +ROOT = "/" +WEBSOCKET_ROUTE = "/ws" + +# REVIEW: all constants should be in all caps. +# REVIEW: filepaths should be relative unless set a the environment level +STATIC_DIR = "static" +LOGGING_DIR = "./logs/logs.log" +TEMPLATES_DIR = "templates" +BYTES_IN_GB = 1073741824 diff --git a/src/server_stats/helpers.py b/src/server_stats/helpers.py new file mode 100644 index 0000000..305a29b --- /dev/null +++ b/src/server_stats/helpers.py @@ -0,0 +1,10 @@ +import uuid + + +# REVIEW: Methods that are abstract of context should be collected into a helpers script +def generate_id() -> str: + """ + Generates a UUID string for unique client IDs + :return: String representation of UUID object + """ + return str(uuid.uuid4()) diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/conftest.py b/src/tests/conftest.py new file mode 100644 index 0000000..2cba44f --- /dev/null +++ b/src/tests/conftest.py @@ -0,0 +1,22 @@ +import pytest + +# REVIEW: Use the conftest.py file to build the fixtures needed for running tests. +# This prevents the issue you noticed where you needed a test client and had to +# intialize in the test script itself. + +# It would be a good idea to read up on fixtures as they are core to the usage of pytest. +# https://docs.pytest.org/en/6.2.x/fixture.html. Long story short these fixtures load +# into a particular scope when called by a test method. + + +@pytest.fixture(scope="module") +def test_client(): + pass + + +@pytest.fixture(scope="module") +def test_computer(): + # REVIEW: Any imports in tests should be done only in the context they are needed + from server_stats.computer import Computer + + return Computer() diff --git a/src/tests/test_computer.py b/src/tests/test_computer.py new file mode 100644 index 0000000..35072a9 --- /dev/null +++ b/src/tests/test_computer.py @@ -0,0 +1,100 @@ +import pytest + + +def test_get_cpu_count(test_computer): + """ + Assertion 1: Function returns an int + Assertion 2: Function returns a valid number of cores. + """ + # REVIEW: is instance and boolean expressions already return a boolean, no + # need to check "is True" + assert isinstance(test_computer.cpu_count, int) + assert test_computer.cpu_count > 0 + + +def test_get_cpu_usage(test_computer): + """ + Assertion 1: Function returns a float + """ + assert isinstance(test_computer.cpu_percent, float) + + +def test_get_cpu_frequency(test_computer): + """ + Loop Assertions: Checks the key is a string and the value is a float + Assertion 2: Function returns a dict + """ + cpu_frequency_dict = test_computer.cpu_frequency + for item in cpu_frequency_dict.keys(): + assert isinstance(item, str) + assert isinstance(cpu_frequency_dict[item], float) + + +# REVIEW: marking this as fail for now since I'm running windows +@pytest.mark.xfail +def test_get_core_temperatures(test_computer): + import os + + """ + Assertion 1: Function returns a dict + """ + assert os.name != "nt", "Temperature sensor with psutil only works with linux" + assert isinstance(test_computer.temperatures, dict) + + +def test_get_ram_totals(test_computer): + """ + Assertion 1: Function returns a float + """ + assert isinstance(test_computer.total_ram, float) + + +def test_get_ram_available(test_computer): + """ + Assertion 1: Function returns a float + """ + assert isinstance(test_computer.available_ram, float) + + +def test_get_ram_percent(test_computer): + """ + Assertion 1: Function returns a float + """ + assert isinstance(test_computer.percentage_used_ram, float) + + +def test_get_disk_total(test_computer): + """ + Assertion 1: Function returns a flaat + """ + assert isinstance(test_computer.total_disk_space, float) + + +def test_get_disk_free(test_computer): + """ + Assertion 1: Function returns a float + """ + assert isinstance(test_computer.total_disk_free, float) + + +def test_get_disk_used(test_computer): + """ + Assertion 1: Function returns a float + """ + assert isinstance(test_computer.total_disk_used, float) + + +def test_get_disk_percentage_used(test_computer): + """ + Assertion 1: Function returns a float + """ + assert isinstance(test_computer.disk_percentage_used, float) + + +def test_get_stats_dict(test_computer): + """ + No need to test the actual data inside the returned dictionary, all of that data has been validated through + the function tests preceding this test. + Assertion 1: Class method returns a dictionary + """ + assert isinstance(test_computer.get_stats_dict(), dict) diff --git a/src/tests/test_helpers.py b/src/tests/test_helpers.py new file mode 100644 index 0000000..a32c4ec --- /dev/null +++ b/src/tests/test_helpers.py @@ -0,0 +1,7 @@ +def test_generate_id(): + from server_stats.helpers import generate_id + + """ + Assertion 1: Test to ensure generate_id() actually returns unique ID's + """ + assert generate_id() != generate_id() diff --git a/static/scripts.js b/static/scripts.js index c548c31..c3f1362 100644 --- a/static/scripts.js +++ b/static/scripts.js @@ -1,17 +1,34 @@ // Open Websocket connection -var socket = new WebSocket("ws://localhost:8080/ws/stats"); +let socketRoute = "/ws" +let dataRequest = JSON.stringify({ event: "DATAREQUEST" }) + +var socket = new WebSocket("ws://localhost:8000" + socketRoute); // On open function -socket.onopen = function(event) { - socket.send(JSON.stringify({event: "DATAREQUEST"})); +socket.onopen = function (event) { + console.log("Connected to socket: " + socketRoute) + console.log("Sending initial request " + dataRequest) + socket.send(dataRequest); }; +socket.onerror = function (error) { + console.log(error) +} + +// Main Websocket Communication +socket.onmessage = function (event) { + let data = JSON.parse(event.data); + if (data.event == "CONNECT") { + console.log("Received connection from client") + } else if (data.event == "DATAREQUEST") { + updateData(data.stats) + } +}; // Setting up HTML Elements let cpu_count = document.getElementById("cpu_count") let cpu_usage = document.getElementById("cpu_usage") let cpu_frequency = document.getElementById("cpu_frequency") -let core_temperature = document.getElementById("core_temperature") let ram_total = document.getElementById("ram_total") let ram_availble = document.getElementById("ram_available") let ram_percentage = document.getElementById("ram_percentage") @@ -20,30 +37,12 @@ let disk_free = document.getElementById("disk_free") let disk_used = document.getElementById("disk_used") let disk_percentage = document.getElementById("disk_percentage") - -// Main Websocket Communication -socket.onmessage = function(event) { - let data = JSON.parse(event.data); - if (data.data){ - updateData(data.data) - } -}; - // Interval Timer to request Stats -setInterval(requestTimer, 1000); function requestTimer() { - socket.send(JSON.stringify({event: "DATAREQUEST"})); + socket.send(JSON.stringify({ event: "DATAREQUEST" })); } -// Chart configs -let updateInterval = 1000 //in ms -let numberElements = 120; - -//Globals -let updateCount = 0; - - // Chart Objects let cpuUsageChart = document.getElementById("cpuUsage"); let ramUsageChart = document.getElementById("ramUsage"); @@ -52,165 +51,160 @@ let diskUsageChart = document.getElementById("diskUsage"); // Common Chart Options (Line) let commonOptions = { - responsive: true, - maintainAspectRatio: false, - backgroundColor: "rgba(33,31,51,0.5)", - borderColor: "rgba(33,31,51,1)", - fill: true, - scales: { - x: { - color: "black", - grid: { - display: false - }, - ticks: { - color: "black" - }, + responsive: true, + maintainAspectRatio: false, + backgroundColor: "rgba(33,31,51,0.5)", + borderColor: "rgba(33,31,51,1)", + fill: true, + scales: { + x: { + color: "black", + grid: { + display: false + }, + ticks: { + color: "black" }, - y: { - beginAtZero: true, - max: 100, - grid: { - color: "rgba(33,31,51,0.2)" - }, - ticks: { - color: "black" - }, - } }, - legend: {display: false}, - tooltips:{ - enabled: false + y: { + beginAtZero: true, + max: 100, + grid: { + color: "rgba(33,31,51,0.2)" + }, + ticks: { + color: "black" + }, } + }, + legend: { display: false }, + tooltips: { + enabled: false + } }; // cpuUsageChart Instance var cpuUsageChartInstance = new Chart(cpuUsageChart, { - type: 'line', - data: { - datasets: [{ - label: "CPU Usage", - data: 0, - borderWidth: 1 - }] - }, - options: Object.assign({}, commonOptions, { + type: 'line', + data: { + datasets: [{ + label: "CPU Usage", + data: 0, + borderWidth: 1 + }] + }, + options: Object.assign({}, commonOptions, { responsive: true, - title:{ - display: true, - text: "CPU Usage", - fontSize: 18 - } - }) + title: { + display: true, + text: "CPU Usage", + fontSize: 18 + } + }) }); // ramUsageChart Instance var ramUsageChartInstance = new Chart(ramUsageChart, { - type: 'line', - data: { - datasets: [{ - label: "RAM Usage", - data: 0, - borderWidth: 1 - }] - }, - options: Object.assign({}, commonOptions, { - title:{ - display: true, - text: "RAM Usage", - fontSize: 18 - } - }) + type: 'line', + data: { + datasets: [{ + label: "RAM Usage", + data: 0, + borderWidth: 1 + }] + }, + options: Object.assign({}, commonOptions, { + title: { + display: true, + text: "RAM Usage", + fontSize: 18 + } + }) }); // diskUsageChart Instance var diskUsageChartInstance = new Chart(diskUsageChart, { - type: 'doughnut', - responsive: false, - maintainAspectRatio: false, - labels: [ - 'free', - 'Used' - ], - data: { - datasets: [{ - label: "Disk Usage", - data: [1, 1], - backgroundColor: [ - 'rgba(189, 27, 15, .8)', - 'rgba(33, 31, 81, .9)', - ], - hoverOffset: 4 - }] - }, - options: Object.assign({}, { - title:{ - display: true, - text: "Disk Usage", - fontSize: 18 - } - }) + type: 'doughnut', + responsive: false, + maintainAspectRatio: false, + labels: [ + 'free', + 'Used' + ], + data: { + datasets: [{ + label: "Disk Usage", + data: [1, 1], + backgroundColor: [ + 'rgba(189, 27, 15, .8)', + 'rgba(33, 31, 81, .9)', + ], + hoverOffset: 4 + }] + }, + options: Object.assign({}, { + title: { + display: true, + text: "Disk Usage", + fontSize: 18 + } + }) }); function shift(arr) { return arr.map((_, i, a) => a[(i + a.length - 1) % a.length]); } + +let updateCount = 0; +let numberElements = 30; // Function to push data to chart object instances function addData(data) { - if(data){ - let today = new Date(); - let time - if (today.getMinutes < 10){ - time = today.getHours() + ":0" + today.getMinutes(); - }else{ - time = today.getHours() + ":" + today.getMinutes(); - } - // CPU Usage - cpuUsageChartInstance.data.labels.push(time); - cpuUsageChartInstance.data.datasets.forEach((dataset) =>{dataset.data.push(data.cpu_usage)}); - // RAM Usage - ramUsageChartInstance.data.labels.push(time); - ramUsageChartInstance.data.datasets.forEach((dataset) =>{dataset.data.push(data.ram_percentage)}); - // Disk Usage - diskUsageChartInstance.data.datasets[0].data[0] = data.disk_used; - diskUsageChartInstance.data.datasets[0].data[1] = data.disk_free; - - - if(updateCount > numberElements){ - // For shifting the x axis markers - // CPU Usage - cpuUsageChartInstance.data.labels.shift(); - cpuUsageChartInstance.data.datasets[0].data.shift(); - // RAM Usage - ramUsageChartInstance.data.labels.shift(); - ramUsageChartInstance.data.datasets[0].data.shift(); - location.reload(); - } - else updateCount++; - console.log(cpuUsageChartInstance.data.datasets[0].data) - cpuUsageChartInstance.update(); - ramUsageChartInstance.update(); - diskUsageChartInstance.update(); - - + if (data) { + let time = new Date().toLocaleTimeString(); + // CPU Usage + cpuUsageChartInstance.data.labels.push(time); + cpuUsageChartInstance.data.datasets.forEach((dataset) => { dataset.data.push(data.cpu_usage) }); + // RAM Usage + ramUsageChartInstance.data.labels.push(time); + ramUsageChartInstance.data.datasets.forEach((dataset) => { dataset.data.push(data.ram_percentage) }); + // Disk Usage + diskUsageChartInstance.data.datasets[0].data[0] = data.disk_used; + diskUsageChartInstance.data.datasets[0].data[1] = data.disk_free; + + + if (updateCount > numberElements) { + // For shifting the x axis markers + // CPU Usage + cpuUsageChartInstance.data.labels.shift(); + cpuUsageChartInstance.data.datasets[0].data.shift(); + // RAM Usage + ramUsageChartInstance.data.labels.shift(); + ramUsageChartInstance.data.datasets[0].data.shift(); } - }; + else updateCount++; - // Update HTML elements -function updateData(data) { - addData(data) - console.log(data) - cpu_count.innerHTML = "Core count: " + data.cpu_count.toString() - cpu_usage.innerHTML = "CPU usage: " + data.cpu_usage.toString() + "%" - cpu_frequency.innerHTML = "CPU Frequency: " + data.cpu_frequency.current_frequency.toString() + " GHz" - ram_total.innerHTML = "RAM total: " + data.ram_total.toString() + " GB" - ram_available.innerHTML = "RAM Available: " + data.ram_available.toString() + " GB" - ram_percentage.innerHTML = "Percentage of RAM used: " + data.ram_percentage.toString() + "%" - disk_total.innerHTML = "Disk space total: " + data.disk_total.toString() + " GB" - disk_free.innerHTML = "Disk Space Free: " + data.disk_free.toString() + " GB" - disk_used.innerHTML = "Disk Space used: " + data.disk_used.toString() + " GB" - disk_percentage = "Disk Space Used: "+ data.disk_percentage.toString() + "%" + cpuUsageChartInstance.update(); + ramUsageChartInstance.update(); + diskUsageChartInstance.update(); + } +}; + +// Update HTML elements +function updateData(data) { + addData(data) + cpu_count.innerHTML = "Core count: " + data.cpu_count.toString() + cpu_usage.innerHTML = "CPU usage: " + data.cpu_usage.toString() + "%" + cpu_frequency.innerHTML = "CPU Frequency: " + data.cpu_frequency.current_frequency.toString() + " GHz" + ram_total.innerHTML = "RAM total: " + data.ram_total.toString() + " GB" + ram_available.innerHTML = "RAM Available: " + data.ram_available.toString() + " GB" + ram_percentage.innerHTML = "Percentage of RAM used: " + data.ram_percentage.toString() + "%" + disk_total.innerHTML = "Disk space total: " + data.disk_total.toString() + " GB" + disk_free.innerHTML = "Disk Space Free: " + data.disk_free.toString() + " GB" + disk_used.innerHTML = "Disk Space used: " + data.disk_used.toString() + " GB" + disk_percentage = "Disk Space Used: " + data.disk_percentage.toString() + "%" } -updateData() + +let updateInterval = 5000 +setInterval(requestTimer, updateInterval); diff --git a/tests/__pycache__/__init__.cpython-310.pyc b/tests/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 92a9c3f..0000000 Binary files a/tests/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/tests/__pycache__/test_ConnectionManager.cpython-310-pytest-7.1.1.pyc b/tests/__pycache__/test_ConnectionManager.cpython-310-pytest-7.1.1.pyc deleted file mode 100644 index f935b54..0000000 Binary files a/tests/__pycache__/test_ConnectionManager.cpython-310-pytest-7.1.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_ConnectionManager.cpython-310-pytest-7.1.2.pyc b/tests/__pycache__/test_ConnectionManager.cpython-310-pytest-7.1.2.pyc deleted file mode 100644 index 3ee2729..0000000 Binary files a/tests/__pycache__/test_ConnectionManager.cpython-310-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_main.cpython-310-pytest-7.1.1.pyc b/tests/__pycache__/test_main.cpython-310-pytest-7.1.1.pyc deleted file mode 100644 index cfd0028..0000000 Binary files a/tests/__pycache__/test_main.cpython-310-pytest-7.1.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_main.cpython-310-pytest-7.1.2.pyc b/tests/__pycache__/test_main.cpython-310-pytest-7.1.2.pyc deleted file mode 100644 index 0e1e9d9..0000000 Binary files a/tests/__pycache__/test_main.cpython-310-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_stats.cpython-310-pytest-7.1.1.pyc b/tests/__pycache__/test_stats.cpython-310-pytest-7.1.1.pyc deleted file mode 100644 index 8b703d1..0000000 Binary files a/tests/__pycache__/test_stats.cpython-310-pytest-7.1.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_stats.cpython-310-pytest-7.1.2.pyc b/tests/__pycache__/test_stats.cpython-310-pytest-7.1.2.pyc deleted file mode 100644 index f6c1716..0000000 Binary files a/tests/__pycache__/test_stats.cpython-310-pytest-7.1.2.pyc and /dev/null differ diff --git a/tests/test_connection_manager.py b/tests/test_connection_manager.py deleted file mode 100644 index a4ce12d..0000000 --- a/tests/test_connection_manager.py +++ /dev/null @@ -1,39 +0,0 @@ -from utilities import connection_manager -from fastapi.testclient import TestClient -from main import app -from fastapi import WebSocket, WebSocketDisconnect -import pytest - - -""" -This feels like a hacky-way to test this context manager, but I cant think of any other way to do it. The test will -fail if for any reason an uncaught exception is raised in the TestClient endpoint in this test(stats_websocket) -""" - - -client = TestClient(app, backend_options={"use_uvloop": True}) -connection_manager = connection_manager.Manager() - - -@app.websocket("/ws/test/stats") -async def stats_websocket(client_websocket: WebSocket): - try: - await connection_manager.connect(client_websocket) - await client_websocket.send_json({"event": "connected"}) - data = await client_websocket.receive_json() - if data["event"] == "disconnect": - await client_websocket.send_json({"event": "disconnected"}) - await connection_manager.disconnect_websocket(client_websocket) - except WebSocketDisconnect: - print("Disconnected") - pass - - -@pytest.mark.asyncio -async def test_websocket(): - with client.websocket_connect("/ws/test/stats") as websocket: - connection = websocket.receive_json() - assert connection["event"] == "connected" - websocket.send_json({"event": "disconnect"}) - disconnection = websocket.receive_json() - assert disconnection["event"] == "disconnected" diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index 0fa6ac3..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,43 +0,0 @@ -from fastapi.testclient import TestClient -from main import app, generate_id -from httpx import AsyncClient -import pytest - - -client = TestClient(app, backend_options={"use_uvloop": True}) - - -@pytest.mark.anyio -async def test_html_client(): - """ - Assertion 1: Tests the successful delivery of the /stats endpoint - """ - async with AsyncClient(app=app, base_url="http://test") as ac: - response = await ac.get("/stats") - assert response.status_code == 200 - - -def test_generate_id(): - """ - Assertion 1: Test to ensure generate_id() actually returns unique ID's - """ - assert generate_id() != generate_id() - - -def test_websocket(): - """ - There is no need to test for stats data validity here, data is validated in the test_stats. - Assertion 1: Ensures initial websocket connection event - Assertion 2: Ensures the delivery of the expected datatype from DATAREQUEST - """ - ws_client = TestClient(app) - with ws_client.websocket_connect("/ws/stats") as websocket: - # Initial WS connection - data = websocket.receive_json() - print(f"client {data}") - assert data == {"event": "CONNECT"} - # Stats request - websocket.send_json({"event": "DATAREQUEST"}) - data = websocket.receive_json() - print(f"client {data}") - assert isinstance(data, dict) diff --git a/tests/test_stats.py b/tests/test_stats.py deleted file mode 100644 index 91d1369..0000000 --- a/tests/test_stats.py +++ /dev/null @@ -1,95 +0,0 @@ -from utilities import stats - - -def test_get_cpu_count(): - """ - Assertion 1: Function returns an int - Assertion 2: Function returns a valid number of cores. - """ - assert isinstance(stats.get_cpu_count(), int) is True - assert stats.get_cpu_count() > 0 - - -def test_get_cpu_usage(): - """ - Assertion 1: Function returns a float - """ - assert isinstance(stats.get_cpu_usage(), float) is True - - -def test_get_cpu_frequency(): - """ - Loop Assertions: Checks the key is a string and the value is a float - Assertion 2: Function returns a dict - """ - cpu_frequency_dict = stats.get_cpu_frequency() - for item in cpu_frequency_dict.keys(): - assert isinstance(item, str) is True - assert isinstance(cpu_frequency_dict[item], float) is True - assert isinstance(stats.get_cpu_frequency(), dict) is True - - -def test_get_core_temperatures(): - """ - Assertion 1: Function returns a dict - """ - assert isinstance(stats.get_temperatures(), dict) is True - - -def test_get_ram_totals(): - """ - Assertion 1: Function returns a float - """ - assert isinstance(stats.get_total_ram(), float) is True - - -def test_get_ram_available(): - """ - Assertion 1: Function returns a float - """ - assert isinstance(stats.get_total_ram(), float) is True - - -def test_get_ram_percent(): - """ - Assertion 1: Function returns a float - """ - assert isinstance(stats.get_percentage_used_ram(), float) is True - - -def test_get_disk_total(): - """ - Assertion 1: Function returns a flaat - """ - assert isinstance(stats.get_total_disk_space(), float) is True - - -def test_get_disk_free(): - """ - Assertion 1: Function returns a float - """ - assert isinstance(stats.get_total_disk_free(), float) is True - - -def test_get_disk_used(): - """ - Assertion 1: Function returns a float - """ - assert isinstance(stats.get_total_disk_used(), float) is True - - -def test_get_disk_percentage_used(): - """ - Assertion 1: Function returns a float - """ - assert isinstance(stats.get_disk_percentage_used(), float) is True - - -def test_computer_class(): - """ - No need to test the actual data inside the returned dictionary, all of that data has been validated through - the function tests preceding this test. - Assertion 1: Class method returns a dictionary - """ - computer = stats.Computer - assert isinstance(computer.get_stats_dict(), dict) is True diff --git a/utilities/__pycache__/connection_manager.cpython-310.pyc b/utilities/__pycache__/connection_manager.cpython-310.pyc deleted file mode 100644 index 867223b..0000000 Binary files a/utilities/__pycache__/connection_manager.cpython-310.pyc and /dev/null differ diff --git a/utilities/__pycache__/stats.cpython-310.pyc b/utilities/__pycache__/stats.cpython-310.pyc deleted file mode 100644 index 7cbfcb2..0000000 Binary files a/utilities/__pycache__/stats.cpython-310.pyc and /dev/null differ diff --git a/utilities/connection_manager.py b/utilities/connection_manager.py deleted file mode 100644 index 4d8cbec..0000000 --- a/utilities/connection_manager.py +++ /dev/null @@ -1,14 +0,0 @@ -from fastapi import WebSocket -from typing import List, NoReturn - - -class Manager: - def __init__(self): - self.connections: List[WebSocket] = [] - - async def connect(self, web_socket: WebSocket) -> NoReturn: - await web_socket.accept() - self.connections.append(web_socket) - - async def disconnect_websocket(self, web_socket: WebSocket) -> NoReturn: - self.connections.remove(web_socket) diff --git a/utilities/stats.py b/utilities/stats.py deleted file mode 100644 index dd57258..0000000 --- a/utilities/stats.py +++ /dev/null @@ -1,103 +0,0 @@ -import psutil -from typing import Dict - -""" -These helper functions are not intended to be called in any other place other than the provided class. -The functions themselves arent even really necessary, but I thought it looked cleaner to do the data conversion -in some functions rather than inline in the class itself, and just decided to write functions for each -class variable. -""" - - -def get_cpu_count() -> int: - return psutil.cpu_count(logical=False) - - -def get_cpu_usage() -> float: - return psutil.cpu_percent(interval=1, percpu=False) - - -def get_cpu_frequency() -> Dict[str, float]: - current = psutil.cpu_freq()[0] - max_frequency = float(psutil.cpu_freq()[2]) - cpu_freq_dict = { - "current_frequency": current / 1000, - "max_frequency": max_frequency, - } - return cpu_freq_dict - - -def get_total_ram() -> float: - mem = psutil.virtual_memory() - return round((mem.total / 1073741824), 2) - - -def get_available_ram() -> float: - mem = psutil.virtual_memory() - return round((mem.available / 1073741824), 2) - - -def get_percentage_used_ram() -> float: - mem = psutil.virtual_memory() - return round(mem.percent, 2) - - -def get_total_disk_space() -> float: - disk = psutil.disk_usage("/") - return round((disk.total / 1073741824), 2) - - -def get_total_disk_free() -> float: - disk = psutil.disk_usage("/") - return round((disk.free / 1073741824), 2) - - -def get_total_disk_used() -> float: - disk = psutil.disk_usage("/") - return round((disk.total - disk.free) / 1073741824, 2) - - -def get_disk_percentage_used() -> float: - disk = psutil.disk_usage("/") - return disk.percent - - -def get_temperatures() -> dict: - # Core temperature only available on linux machine - """ - Get core temperature(s) - :return: Dict of core temperatures if running on Linux. If other OS, returns and empty dictionary. - """ - try: - core_temp_list = psutil.sensors_temperatures(fahrenheit=True)["coretemp"] - core_temp_dict = {} - for core in core_temp_list: - core_temp_dict[core.label] = core.current - return core_temp_dict - except AttributeError: - core_temp_dict = {} - return core_temp_dict - - -class Computer: - """ - This class is used to easily evaluate the status of the computer. It returns a dictionary with the results - of querying the status os the available computer components via psutils - """ - - @staticmethod - def get_stats_dict() -> dict: - stats_dict = { - "cpu_count": get_cpu_count(), - "cpu_usage": get_cpu_usage(), - "cpu_frequency": get_cpu_frequency(), - "core_temperatures": get_temperatures(), - "ram_total": get_total_ram(), - "ram_available": get_available_ram(), - "ram_percentage": get_percentage_used_ram(), - "disk_total": get_total_disk_space(), - "disk_free": get_total_disk_free(), - "disk_used": get_total_disk_used(), - "disk_percentage": get_disk_percentage_used(), - } - return stats_dict