diff --git a/requirements.txt b/requirements.txt index 77807cf5..c02c74e1 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/settings/settings.toml b/settings/settings.toml index aa987959..d67ab1a4 100644 --- a/settings/settings.toml +++ b/settings/settings.toml @@ -11,4 +11,4 @@ reboot_threads = 300 # tool or the privileged API will not work using admin as the password. # If you change the password, you can pass that password here. -whatsminer_pwd = "admin" \ No newline at end of file +whatsminer_pwd = "admin" diff --git a/tools/web_monitor/__init__.py b/tools/web_monitor/__init__.py new file mode 100644 index 00000000..7b3d3d9f --- /dev/null +++ b/tools/web_monitor/__init__.py @@ -0,0 +1,5 @@ +from tools.web_monitor.app import app +import uvicorn + +if __name__ == "__main__": + uvicorn.run("app:app", host="127.0.0.1", port=80) diff --git a/tools/web_monitor/app.py b/tools/web_monitor/app.py new file mode 100644 index 00000000..24a44aaf --- /dev/null +++ b/tools/web_monitor/app.py @@ -0,0 +1,325 @@ +import asyncio +import datetime +import ipaddress +import os + +import uvicorn +import websockets.exceptions +from fastapi import FastAPI, Request +from fastapi import WebSocket, WebSocketDisconnect +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from network import MinerNetwork +from tools.web_monitor.miner_factory import miner_factory +from tools.web_monitor.web_settings import get_current_settings, update_settings + +app = FastAPI() + +app.mount("/static", StaticFiles(directory="static"), name="static") +templates = Jinja2Templates(directory="templates") + + +@app.get("/") +def index(request: Request): + return RedirectResponse(request.url_for('dashboard')) + + +@app.get("/dashboard") +def dashboard(request: Request): + return templates.TemplateResponse("index.html", { + "request": request, + "cur_miners": get_current_miner_list() + }) + + +@app.websocket("/dashboard/ws") +async def dashboard_websocket(websocket: WebSocket): + await websocket.accept() + graph_sleep_time = get_current_settings()["graph_data_sleep_time"] + try: + while True: + miners = get_current_miner_list() + all_miner_data = [] + data_gen = asyncio.as_completed( + [get_miner_data_dashboard(miner_ip) for miner_ip in miners]) + for all_data in data_gen: + data_point = await all_data + all_miner_data.append(data_point) + all_miner_data.sort(key=lambda x: x["ip"]) + await websocket.send_json( + {"datetime": datetime.datetime.now().isoformat(), + "miners": all_miner_data}) + await asyncio.sleep(graph_sleep_time) + except WebSocketDisconnect: + print("Websocket disconnected.") + pass + except websockets.exceptions.ConnectionClosedOK: + pass + + +async def get_miner_data_dashboard(miner_ip): + try: + settings = get_current_settings() + miner_identify_timeout = settings["miner_identify_timeout"] + miner_data_timeout = settings["miner_data_timeout"] + + miner_ip = await asyncio.wait_for(miner_factory.get_miner(miner_ip), miner_identify_timeout) + + miner_summary = await asyncio.wait_for(miner_ip.api.summary(), miner_data_timeout) + if miner_summary: + if 'MHS av' in miner_summary['SUMMARY'][0].keys(): + hashrate = format( + round(miner_summary['SUMMARY'][0]['MHS av'] / 1000000, + 2), ".2f") + elif 'GHS av' in miner_summary['SUMMARY'][0].keys(): + hashrate = format( + round(miner_summary['SUMMARY'][0]['GHS av'] / 1000, 2), + ".2f") + else: + hashrate = 0 + else: + hashrate = 0 + + return {"ip": str(miner_ip.ip), "hashrate": hashrate} + + except asyncio.exceptions.TimeoutError: + return {"ip": miner_ip, "error": "The miner_ip is not responding."} + + except KeyError: + return {"ip": miner_ip, + "error": "The miner_ip returned unusable/unsupported data."} + + +@app.get("/scan") +def scan(request: Request): + return templates.TemplateResponse("scan.html", { + "request": request, + "cur_miners": get_current_miner_list() + }) + + +@app.get("/miner") +def miner(_request: Request, _miner_ip): + return get_miner + + +@app.websocket("/miner/{miner_ip}/ws") +async def miner_websocket(websocket: WebSocket, miner_ip): + await websocket.accept() + settings = get_current_settings() + miner_identify_timeout = settings["miner_identify_timeout"] + miner_data_timeout = settings["miner_data_timeout"] + + try: + while True: + try: + cur_miner = await asyncio.wait_for( + miner_factory.get_miner(str(miner_ip)), miner_identify_timeout) + + data = await asyncio.wait_for( + cur_miner.api.multicommand("summary", "fans", "stats"), miner_data_timeout) + + miner_model = await cur_miner.get_model() + + miner_summary = None + miner_fans = None + if "summary" in data.keys(): + miner_summary = data["summary"][0] + elif "SUMMARY" in data.keys(): + miner_summary = data + miner_fans = {"FANS": []} + for item in ["Fan Speed In", "Fan Speed Out"]: + if item in miner_summary["SUMMARY"][0].keys(): + miner_fans["FANS"].append( + {"RPM": miner_summary["SUMMARY"][0][item]}) + + if "fans" in data.keys(): + miner_fans = data["fans"][0] + + if "stats" in data.keys(): + miner_stats = data["stats"][0] + miner_fans = {"FANS": []} + for item in ["fan1", "fan2", "fan3", "fan4"]: + if item in miner_stats["STATS"][1].keys(): + miner_fans["FANS"].append( + {"RPM": miner_stats["STATS"][1][item]}) + + if miner_summary: + if 'MHS av' in miner_summary['SUMMARY'][0].keys(): + hashrate = format( + round( + miner_summary['SUMMARY'][0]['MHS av'] / 1000000, + 2), ".2f") + elif 'GHS av' in miner_summary['SUMMARY'][0].keys(): + hashrate = format( + round(miner_summary['SUMMARY'][0]['GHS av'] / 1000, + 2), + ".2f") + else: + hashrate = 0 + else: + hashrate = 0 + + fan_speeds = [] + + if miner_fans: + for fan in miner_fans["FANS"]: + fan_speeds.append(fan["RPM"]) + + while len(fan_speeds) < 5: + fan_speeds.append(0) + + data = {"hashrate": hashrate, + "fans": fan_speeds, + "datetime": datetime.datetime.now().isoformat(), + "model": miner_model} + await websocket.send_json(data) + await asyncio.sleep(settings["graph_sleep_time"]) + except asyncio.exceptions.TimeoutError: + data = {"error": "The miner is not responding."} + await websocket.send_json(data) + await asyncio.sleep(.5) + except KeyError as e: + print(e) + data = { + "error": "The miner returned unusable/unsupported data."} + await websocket.send_json(data) + await asyncio.sleep(.5) + except WebSocketDisconnect: + print("Websocket disconnected.") + except websockets.exceptions.ConnectionClosedOK: + pass + + +@app.get("/miner/{miner_ip}") +def get_miner(request: Request, miner_ip): + return templates.TemplateResponse("miner.html", { + "request": request, + "cur_miners": get_current_miner_list(), + "miner": miner_ip + }) + + +@app.get("/miner_ip/{miner_ip}/remove") +def get_miner(request: Request, miner_ip): + miners = get_current_miner_list() + miners.remove(miner_ip) + with open("miner_list.txt", "w") as file: + for miner_ip in miners: + file.write(miner_ip + "\n") + + return RedirectResponse(request.url_for('dashboard')) + + +def get_current_miner_list(): + cur_miners = [] + if os.path.exists(os.path.join(os.getcwd(), "miner_list.txt")): + with open(os.path.join(os.getcwd(), "miner_list.txt")) as file: + for line in file.readlines(): + cur_miners.append(line.strip()) + cur_miners = sorted(cur_miners, key=lambda x: ipaddress.ip_address(x)) + return cur_miners + + +@app.route("/settings", methods=["GET", "POST"]) +async def settings(request: Request): + return templates.TemplateResponse("settings.html", { + "request": request, + "cur_miners": get_current_miner_list(), + "settings": get_current_settings() + }) + + +@app.post("/settings/update") +async def update_settings_page(request: Request): + data = await request.form() + graph_data_sleep_time = data.get('graph_data_sleep_time') + miner_data_timeout = data.get('miner_data_timeout') + miner_identify_timeout = data.get('miner_identify_timeout') + new_settings = { + "graph_data_sleep_time": int(graph_data_sleep_time), + "miner_data_timeout": int(miner_data_timeout), + "miner_identify_timeout": int(miner_identify_timeout), + } + update_settings(new_settings) + return RedirectResponse(request.url_for("settings")) + + + + +@app.get("/remove_all_miners") +async def remove_all_miners(request: Request): + file = open("miner_list.txt", "w") + file.close() + return RedirectResponse(request.url_for("settings")) + + +@app.post("/scan/add_miners") +async def add_miners_scan(request: Request): + miners = await request.json() + with open("miner_list.txt", "a+") as file: + for miner_ip in miners["miners"]: + file.write(miner_ip + "\n") + return scan + + +@app.websocket("/scan/ws") +async def websocket_scan(websocket: WebSocket): + await websocket.accept() + cur_task = None + try: + while True: + ws_data = await websocket.receive_text() + if "-Cancel-" in ws_data: + if cur_task: + cur_task.cancel() + try: + await cur_task + except asyncio.CancelledError: + cur_task = None + await websocket.send_text("Cancelled") + else: + cur_task = asyncio.create_task( + do_websocket_scan(websocket, ws_data)) + if cur_task and cur_task.done(): + cur_task = None + except WebSocketDisconnect: + print("Websocket disconnected.") + except websockets.exceptions.ConnectionClosedOK: + pass + + +async def do_websocket_scan(websocket: WebSocket, network_ip: str): + cur_miners = get_current_miner_list() + try: + if "/" in network_ip: + network_ip, network_subnet = network_ip.split("/") + network = MinerNetwork(network_ip, mask=network_subnet) + else: + network = MinerNetwork(network_ip) + miner_generator = network.scan_network_generator() + miners = [] + async for miner_ip in miner_generator: + if miner_ip and str(miner_ip) not in cur_miners: + miners.append(miner_ip) + + get_miner_genenerator = miner_factory.get_miner_generator(miners) + all_miners = [] + async for found_miner in get_miner_genenerator: + all_miners.append( + {"ip": found_miner.ip, "model": await found_miner.get_model()}) + all_miners.sort(key=lambda x: x["ip"]) + send_miners = [] + for miner_ip in all_miners: + send_miners.append( + {"ip": str(miner_ip["ip"]), "model": miner_ip["model"]}) + await websocket.send_json(send_miners) + await websocket.send_text("Done") + except asyncio.CancelledError: + raise + + +if __name__ == "__main__": + uvicorn.run("app:app", host="127.0.0.1", port=80) diff --git a/tools/web_monitor/miner_factory.py b/tools/web_monitor/miner_factory.py new file mode 100644 index 00000000..157b743a --- /dev/null +++ b/tools/web_monitor/miner_factory.py @@ -0,0 +1,7 @@ +""" +This file stores the MinerFactory instance used by the WebMonitor for use in other files. +""" + +from miners.miner_factory import MinerFactory + +miner_factory = MinerFactory() diff --git a/tools/web_monitor/static/navbar.css b/tools/web_monitor/static/navbar.css new file mode 100644 index 00000000..1d582b38 --- /dev/null +++ b/tools/web_monitor/static/navbar.css @@ -0,0 +1,165 @@ +body { + min-height: 100vh; + min-height: -webkit-fill-available; +} + +html { + height: -webkit-fill-available; +} + +main { + display: flex; + flex-wrap: nowrap; + height: 100vh; + height: -webkit-fill-available; + max-height: 100vh; + overflow-x: auto; + overflow-y: hidden; +} + +.bi { + vertical-align: -.125em; + pointer-events: none; + fill: currentColor; +} + +.dropdown-toggle { outline: 0; } + +.nav-flush .nav-link { + border-radius: 0; +} + +.btn-toggle-nav a { + display: inline-flex; + padding: .1875rem .5rem; + margin-top: .125rem; + margin-left: 1.25rem; + text-decoration: none; +} + +.btn-toggle-nav a:hover, +.btn-toggle-nav a:focus { + background-color: #0d6efd; +} + +.scrollarea { + overflow-y: auto; +} + +.fw-semibold { font-weight: 600; } + +.sidebar { + position: fixed; + top: 0; + /* rtl:raw: + right: 0; + */ + bottom: 0; + /* rtl:remove */ + left: 0; + z-index: 100; /* Behind the navbar */ + padding: 0px 0 0; /* Height of navbar */ + box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); +} + +@media (max-width: 767.98px) { + .sidebar { + top: 48px; + } +} + +.sidebar-sticky { + position: relative; + top: 0; + height: calc(100vh - 48px); + padding-top: .5rem; + overflow-x: hidden; + overflow-y: auto; +} + +.sidebar .nav-link { + font-weight: 500; + color: #333; +} + +.sidebar .nav-link .feather { + margin-right: 4px; + color: #727272; +} + +.sidebar .nav-link.active { + color: #2470dc; +} + +.sidebar .nav-link:hover .feather, +.sidebar .nav-link.active .feather { + color: inherit; +} + +.sidebar-heading { + font-size: .75rem; + text-transform: uppercase; +} + +.navbar-brand { + padding-top: .75rem; + padding-bottom: .75rem; + font-size: 1rem; + background-color: rgba(0, 0, 0, .25); + box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25); +} + +.navbar .navbar-toggler { + top: .25rem; + right: 1rem; +} + +.navbar .form-control { + padding: .75rem 1rem; + border-width: 0; + border-radius: 0; +} + +.form-control-dark { + color: #fff; + background-color: rgba(255, 255, 255, .1); + border-color: rgba(255, 255, 255, .1); +} + +.form-control-dark:focus { + border-color: transparent; + box-shadow: 0 0 0 3px rgba(255, 255, 255, .25); +} + +.btn-toggle-nav{ + max-height: 300px; + -webkit-overflow-scrolling: touch; +} + +/* Scrollbar */ +.btn-toggle-nav::-webkit-scrollbar { + width: 5px; +} +.btn-toggle-nav::-webkit-scrollbar-track { + box-shadow: inset 0 0 5px grey; + border-radius: 10px; +} +.btn-toggle-nav::-webkit-scrollbar-thumb { + background-image: linear-gradient(180deg, #D0368A 0%, #708AD4 99%); + box-shadow: inset 2px 2px 5px 0 rgba(#fff, 0.5); + border-radius: 100px; +} + +.nav-pills .nav-link.active { + color: #212529; + background-image: linear-gradient(180deg, #D0368A 0%, #708AD4 99%); +} + +.nav-link:hover { + background-image: linear-gradient(180deg, #760A45 0%, #23449F 99%); +} + +.nav-link { + transition: unset; + color: unset; +} diff --git a/tools/web_monitor/templates/index.html b/tools/web_monitor/templates/index.html new file mode 100644 index 00000000..c2e399a1 --- /dev/null +++ b/tools/web_monitor/templates/index.html @@ -0,0 +1,127 @@ +{% extends 'navbar.html'%} +{% block content %} + + + +{% if cur_miners|length == 0 %}Click here to add miners.{% endif %} + + +
+ + +{% endblock content %} diff --git a/tools/web_monitor/templates/miner.html b/tools/web_monitor/templates/miner.html new file mode 100644 index 00000000..58b4308c --- /dev/null +++ b/tools/web_monitor/templates/miner.html @@ -0,0 +1,285 @@ +{% extends 'navbar.html'%} +{% block content %} + + +| + + | +IP | +Model | +0 Miners | +
|---|