Merge pull request #6 from UpstreamData/web_monitor

Web monitor
This commit is contained in:
UpstreamData
2022-03-07 12:40:11 -07:00
committed by GitHub
13 changed files with 1248 additions and 1 deletions

Binary file not shown.

View File

@@ -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"
whatsminer_pwd = "admin"

View File

@@ -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)

325
tools/web_monitor/app.py Normal file
View File

@@ -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)

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -0,0 +1,127 @@
{% extends 'navbar.html'%}
{% block content %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luxon@2.3.1/build/global/luxon.min.js"></script>
<canvas id="line-chart" class="grad-border mt-3 mb-4" width="600" height="360"></canvas>
{% if cur_miners|length == 0 %}<a role="button" href="{{url_for('scan')}}" id="noMiners" class="w-100 btn btn-info">Click here to add miners.</a>{% endif %}
<div id="errors"></div>
<script>
var ws = new WebSocket("ws://localhost:80/dashboard/ws");
let all_data = []
let all_labels = []
ws.onmessage = function(event) {
var new_data = JSON.parse(event.data)
if (!new_data["miners"].length == 0) {
total_hashrate = parseFloat(0)
errors = document.getElementById("errors")
for (i = 0; i< new_data["miners"].length; i++) {
if (new_data["miners"][i].hasOwnProperty("error")) {
if (!document.getElementById(new_data["miners"][i]["ip"] + "_error")) {
errors.innerHTML += "<div id='" + new_data["miners"][i]["ip"] + "_error" +
"' class='d-flex align-items-center p-1 mb-1 ms-4 alert alert-danger'><strong class='p-0 m-0'>" +
new_data["miners"][i]["ip"] + ": " +
new_data["miners"][i]["error"] +
"</strong><div class='spinner-border spinner-border-sm ms-auto'></div></div>"
}
} else {
if (document.getElementById(new_data["miners"][i]["ip"] + "_error")) {
document.getElementById(new_data["miners"][i]["ip"] + "_error").remove()
}
total_hashrate += parseFloat(new_data["miners"][i]["hashrate"])
}
};
var chart = document.getElementById("line-chart")
datetime = luxon.DateTime.fromISO(new_data["datetime"]).toLocal();
if (minerDataChart.data.labels.length > 50) minerDataChart.data.labels.shift();
if (minerDataChart.data.datasets[0].data.length > 50) minerDataChart.data.datasets[0].data.shift();
minerDataChart.data.labels.push(datetime.toLocaleString(luxon.DateTime.TIME_WITH_SECONDS));
minerDataChart.data.datasets[0].data.push(total_hashrate.toFixed(2));
minerDataChart.update();
}
};
var ctx = document.getElementById("line-chart").getContext("2d");
var width = document.getElementById("line-chart").width;
var chartGradient = ctx.createLinearGradient(0, 0, width, 0)
chartGradient.addColorStop(0, '#D0368A');
chartGradient.addColorStop(1, '#708AD4');
const chartAreaBorder = {
id: 'chartAreaBorder',
beforeDraw(chart, args, options) {
const {ctx, chartArea: {left, top, width, height}} = chart;
ctx.save();
ctx.strokeStyle = options.borderColor;
ctx.lineWidth = options.borderWidth;
ctx.strokeRect(left, top, width, height);
ctx.restore();
}
};
var minerDataChart = new Chart(document.getElementById("line-chart"), {
type: 'line',
data: {
labels: [
],
datasets: [{
label: "Hashrate",
borderColor: chartGradient,
pointBorderColor: chartGradient,
pointBackgroundColor: chartGradient,
pointHoverBackgroundColor: chartGradient,
pointHoverBorderColor: chartGradient,
data: [
],
}
]
},
plugins: [chartAreaBorder],
options: {
animation: {
easing: 'easeInSine',
duration: 0
},
plugins: {
chartAreaBorder: {
borderColor: chartGradient,
borderWidth: 1
},
legend: {
labels: {
color: chartGradient
}
},
tooltip: {
callbacks: {
label: function(data) {
return data.dataset.data[data.dataIndex] + " TH/s";
}
}
}
},
scales: {
y: {
min: 0, // minimum value
suggestedMax: 100,
stepSize: 10,
ticks: {
callback: function(value, index, ticks) {
return value + " TH/s";
}
}
},
x: {
ticks: {
maxTicksLimit: 6,
maxRotation: 0,
}
}
}
}
});
</script>
{% endblock content %}

View File

@@ -0,0 +1,285 @@
{% extends 'navbar.html'%}
{% block content %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luxon@2.3.1/build/global/luxon.min.js"></script>
<div class="row mt-2">
<div class="col">
<h2 class="ms-4">{{miner}}</h2>
</div>
<div class="col">
<div class="d-flex flex-row-reverse">
<button type="button" class="btn btn-outline-danger mx-1" data-bs-toggle="modal" data-bs-target="#removeModal">
Remove Miner
</button>
<!-- Modal -->
<div class="modal fade" id="removeModal" tabindex="-1" aria-labelledby="removeModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="removeModalLabel">Remove Miner</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Do you really want to remove this miner?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<a class="btn btn-danger" href="{{url_for('miner')}}/{{miner}}/remove" role="button">Remove</a>
</div>
</div>
</div>
</div>
<a class="btn btn-primary mx-1" target="_blank" href="http://{{miner}}" role="button">Web Interface</a>
</div>
</div>
</div>
<div class="row">
<div class="col-10">
<canvas id="line-chart" class="grad-border mt-3" width="600" height="360"></canvas>
</div>
<div class="col-2 mt-2">
<div class="d-flex justify-content-center" id="fan1">Fan 1</div>
<canvas class="mb-2" id="fan-chart-1" width="100" height="100"></canvas>
<div class="d-flex justify-content-center" id="fan2">Fan 2</div>
<canvas class="mb-2" id="fan-chart-2" width="100" height="100"></canvas>
<div class="d-flex justify-content-center" id="fan3">Fan 3</div>
<canvas class="mb-2" id="fan-chart-3" width="100" height="100"></canvas>
<div class="d-flex justify-content-center" id="fan4">Fan 4</div>
<canvas class="mb-2" id="fan-chart-4" width="100" height="100"></canvas>
</div>
</div>
<div class="d-flex align-items-center mt-4 ms-4 alert alert-secondary">
<div class="mx-auto">Model:</div>
<div class="mx-auto fw-bolder" id="minerModel">?</div>
<div class="mx-auto" style="border-left: 1px solid grey; height: 50px;"></div>
<div class="mx-auto">Hashrate:</div>
<div class="mx-auto fw-bolder" id="minerHashrate">?</div>
</div>
<div id="errorContainer" class="d-flex align-items-center mt-4 ms-4 alert alert-danger invisible">
<strong id="errorCode"></strong>
<div class="spinner-border ms-auto"></div>
</div>
<script>
var ws = new WebSocket("ws://localhost:80/miner/{{miner}}/ws");
let all_data = []
let all_labels = []
ws.onmessage = function(event) {
var new_data = JSON.parse(event.data)
if (new_data.hasOwnProperty("error")) {
var err_container = document.getElementById("errorContainer")
var err_code = document.getElementById("errorCode")
err_code.innerHTML = new_data['error']
err_container.classList.remove("invisible")
var miner_hr = document.getElementById("minerHashrate")
miner_hr.innerHTML = "?"
} else {
var chart = document.getElementById("line-chart")
var err_container = document.getElementById("errorContainer")
if (!err_container.classList.hasOwnProperty("invisible")) {
err_container.classList.add("invisible")
}
datetime = luxon.DateTime.fromISO(new_data["datetime"]).toLocal();
if (minerDataChart.data.labels.length > 50) minerDataChart.data.labels.shift();
if (minerDataChart.data.datasets[0].data.length > 50) minerDataChart.data.datasets[0].data.shift();
minerDataChart.data.labels.push(datetime.toLocaleString(luxon.DateTime.TIME_WITH_SECONDS));
minerDataChart.data.datasets[0].data.push(new_data["hashrate"].toFixed(2));
fan1Chart.data.datasets[0].data = [new_data["fans"][0], 6000-new_data["fans"][0]]
fan2Chart.data.datasets[0].data = [new_data["fans"][1], 6000-new_data["fans"][1]]
fan3Chart.data.datasets[0].data = [new_data["fans"][2], 6000-new_data["fans"][2]]
fan4Chart.data.datasets[0].data = [new_data["fans"][3], 6000-new_data["fans"][3]]
document.getElementById("fan1").innerHTML = "Fan 1: " + new_data["fans"][0]
document.getElementById("fan2").innerHTML = "Fan 2: " + new_data["fans"][1]
document.getElementById("fan3").innerHTML = "Fan 3: " + new_data["fans"][2]
document.getElementById("fan4").innerHTML = "Fan 4: " + new_data["fans"][3]
fan1Chart.update();
fan2Chart.update();
fan3Chart.update();
fan4Chart.update();
minerDataChart.update();
var miner_hr = document.getElementById("minerHashrate")
miner_hr.innerHTML = new_data["hashrate"].toFixed(2) + " TH/s"
var miner_model = document.getElementById("minerModel")
miner_model.innerHTML = new_data["model"]
};
};
var ctx = document.getElementById("line-chart").getContext("2d");
var width = document.getElementById("line-chart").width;
var chartGradient = ctx.createLinearGradient(0, 0, width, 0)
chartGradient.addColorStop(0, '#D0368A');
chartGradient.addColorStop(1, '#708AD4');
const chartAreaBorder = {
id: 'chartAreaBorder',
beforeDraw(chart, args, options) {
const {ctx, chartArea: {left, top, width, height}} = chart;
ctx.save();
ctx.strokeStyle = options.borderColor;
ctx.lineWidth = options.borderWidth;
ctx.strokeRect(left, top, width, height);
ctx.restore();
}
};
var minerDataChart = new Chart(document.getElementById("line-chart"), {
type: 'line',
data: {
labels: [
],
datasets: [{
label: "Hashrate",
borderColor: chartGradient,
pointBorderColor: chartGradient,
pointBackgroundColor: chartGradient,
pointHoverBackgroundColor: chartGradient,
pointHoverBorderColor: chartGradient,
data: [
],
}
]
},
plugins: [chartAreaBorder],
options: {
animation: {
easing: 'easeInSine',
duration: 0
},
plugins: {
chartAreaBorder: {
borderColor: chartGradient,
borderWidth: 1
},
legend: {
labels: {
color: chartGradient
}
},
tooltip: {
callbacks: {
label: function(data) {
return data.dataset.data[data.dataIndex] + " TH/s";
}
}
}
},
scales: {
y: {
min: 0, // minimum value
suggestedMax: 10,
stepSize: 1,
ticks: {
callback: function(value, index, ticks) {
return value + " TH/s";
}
}
},
x: {
ticks: {
maxTicksLimit: 6,
maxRotation: 0
}
}
}
}
});
var options_fans = {
animation: {
easing: 'easeInSine',
duration: 250,
},
aspectRatio: 1.5,
events: [],
responsive: true,
plugins: {
legend: {
display: false,
}
}
};
var fanCtx = document.getElementById("fan-chart-1").getContext("2d");
var fanWidth = document.getElementById("fan-chart-1").width;
var fanChartGradient = fanCtx.createLinearGradient(0, 0, fanWidth, -fanWidth)
fanChartGradient.addColorStop(0, '#D0368A');
fanChartGradient.addColorStop(1, '#708AD4');
var fan1Chart = new Chart(document.getElementById("fan-chart-1"), {
type: "doughnut",
data: {
labels: ["Fan 1"],
datasets: [
{
data: [0, 6000],
// add colors
backgroundColor: [
fanChartGradient,
"rgba(199, 199, 199, 1)"
]
},
]
},
options: options_fans
});
var fan2Chart = new Chart(document.getElementById("fan-chart-2"), {
type: "doughnut",
data: {
labels: ["Fan 2"],
datasets: [
{
data: [0, 6000],
// add colors
backgroundColor: [
fanChartGradient,
"rgba(199, 199, 199, 1)"
]
},
]
},
options: options_fans
});
var fan3Chart = new Chart(document.getElementById("fan-chart-3"), {
type: "doughnut",
data: {
labels: ["Fan 3"],
datasets: [
{
data: [0, 6000],
// add colors
backgroundColor: [
fanChartGradient,
"rgba(199, 199, 199, 1)"
]
},
]
},
options: options_fans
});
var fan4Chart = new Chart(document.getElementById("fan-chart-4"), {
type: "doughnut",
data: {
labels: ["Fan 4"],
datasets: [
{
data: [0, 6000],
// add colors
backgroundColor: [
fanChartGradient,
"rgba(199, 199, 199, 1)"
]
},
]
},
options: options_fans
});
</script>
{% endblock content %}

View File

@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<link href="{{ url_for('static', path='/navbar.css')}}" rel="stylesheet">
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<symbol id="dashboard" viewBox="0 0 16 16">
<path d="M8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4.5a.5.5 0 0 0 .5-.5v-4h2v4a.5.5 0 0 0 .5.5H14a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146zM2.5 14V7.707l5.5-5.5 5.5 5.5V14H10v-4a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v4H2.5z"></path>
</symbol>
<symbol id="miners" viewBox="0 0 16 16">
<path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4zM3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707zM2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10zm9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5zm.754-4.246a.389.389 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.389.389 0 0 0-.029-.518z"></path>
<path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A7.988 7.988 0 0 1 0 10zm8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3z"></path>
</symbol>
<symbol id="settings" viewBox="0 0 16 16">
<path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/>
<path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z"/>
</symbol>
<symbol id="scan" viewBox="0 0 16 16">
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h12zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
</symbol>
<symbol id="miner" viewBox="0 0 16 16">
<path d="M11.5 2a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 .5-.5Zm2 0a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 .5-.5Zm-10 8a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1h-6Zm0 2a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1h-6ZM5 3a1 1 0 0 0-1 1h-.5a.5.5 0 0 0 0 1H4v1h-.5a.5.5 0 0 0 0 1H4a1 1 0 0 0 1 1v.5a.5.5 0 0 0 1 0V8h1v.5a.5.5 0 0 0 1 0V8a1 1 0 0 0 1-1h.5a.5.5 0 0 0 0-1H9V5h.5a.5.5 0 0 0 0-1H9a1 1 0 0 0-1-1v-.5a.5.5 0 0 0-1 0V3H6v-.5a.5.5 0 0 0-1 0V3Zm0 1h3v3H5V4Zm6.5 7a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-2Z"/>
<path d="M1 2a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-2H.5a.5.5 0 0 1-.5-.5v-1A.5.5 0 0 1 .5 9H1V8H.5a.5.5 0 0 1-.5-.5v-1A.5.5 0 0 1 .5 6H1V5H.5a.5.5 0 0 1-.5-.5v-2A.5.5 0 0 1 .5 2H1Zm1 11a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v11Z"/>
</symbol>
</svg>
<header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
<a class="d-md-none col-md-3 col-lg-2 me-0 px-3" style="height: 50px;" href="#"></a>
<button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<ul class="navbar-nav px-3">
<li class="nav-item text-nowrap">
</li>
</ul>
</header>
<div class="container-fluid">
<div class="row">
<nav id="sidebarMenu" class="text-white bg-dark col-md-3 col-lg-2 d-md-block sidebar collapse">
<div class="position-sticky pt-3">
<ul class="nav nav-pills flex-column">
<li class="nav-item mb-1 mx-2">
<a href="{{url_for('dashboard')}}" class="nav-link {% if request.path == '/dashboard' %}active{% else %}text-white{% endif %}">
<svg class="bi me-2" width="16" height="16"><use xlink:href="#dashboard"></use></svg>
Dashboard
</a>
</li>
<li class="nav-item mb-1 mx-2">
<a href="" class="nav-link {% if request.path == '/scan' or request.path.split('/')[1] == 'miner' %}active{% else %}text-white{% endif %}" data-bs-toggle="collapse" data-bs-target="#miners-collapse" aria-expanded="false">
<svg class="bi me-2" width="16" height="16"><use xlink:href="#miners"></use></svg>
Miners
</a>
<div class="collapse mt-1" id="miners-collapse" style="">
<ul id="navMiners" class="btn-toggle-nav overflow-auto list-unstyled fw-normal pb-1 small">
<li>
<a href="{{url_for('scan')}}" class="nav-link {% if request.path == '/scan' %}active{% else %}text-white{% endif %}">
<svg class="bi me-2 mt-1" width="16" height="16"><use xlink:href="#scan"></use></svg>
Add Miners
</a>
</li>
{% for miner in cur_miners %}
<li>
<a href="{{url_for('miner')}}/{{miner}}" class="nav-link {% if request.path == '/miner/' + miner %}active{% else %}text-white{% endif %}">
<svg class="bi me-2 mt-1" width="16" height="16"><use xlink:href="#miner"></use></svg>
{{miner}}
</a>
</li>
{% endfor %}
</ul>
</div>
</li>
<li class="border-top my-3"></li>
<li class="nav-item mb-1 mx-2">
<a href="/settings" class="nav-link {% if request.path == '/settings' %}active{% else %}text-white{% endif %}">
<svg class="bi me-2" width="16" height="16"><use xlink:href="#settings"></use></svg>
Settings
</a>
</li>
</ul>
</div>
</nav>
<div class="col-md-9 ms-sm-auto col-lg-10 px-md-4 ps-4">
{% block content %}
{% endblock content %}
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,158 @@
{% extends 'navbar.html'%}
{% block content %}
<div class="row w-100 my-4">
<form action="" onsubmit="sendMessage(event)">
<div class="input-group mb-3">
<span class="input-group-text" id="scan-ip">Subnet</span>
<input type="text" class="form-control" id="messageText" placeholder="192.168.1.0/24" aria-describedby="scan-ip">
<button class="btn btn-danger" onclick="cancelScan()" style="display:none;" type="button" id="cancelButton">Cancel</button>
<button class="btn btn-primary" onclick="scanMiners()" type="button" id="scanButton">Scan</button>
</div>
</form>
</div>
<div class="row w-100">
<button class="btn btn-primary mb-4 mx-1" onclick="addMiners()" type="button" id="addButton">Add Selected Miners</button>
</div>
<div class="row w-100">
<table class="table table-striped table-responsive" style="max-height:300px;">
<thead>
<tr>
<th class="active col-1">
<input type="checkbox" class="select-all checkbox" name="select-all" id="selectAllCheckbox"/>
</th>
<th>IP</th>
<th>Model</th>
<th id="scanStatus" class="col-2">0 Miners</th>
</tr>
</thead>
<tbody id="minerTable">
</tbody>
</table>
</div>
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script>
$(function(){
//column checkbox select all or cancel
$("input.select-all").click(function () {
var checked = this.checked;
$("input.select-item").each(function (index,item) {
item.checked = checked;
});
});
//check selected items
$("input.select-item").click(function () {
var checked = this.checked;
var all = $("input.select-all")[0];
var total = $("input.select-item").length;
var len = $("input.select-item:checked:checked").length;
all.checked = len===total;
});
});
</script>
<script>
window.post = function(url, data) {
return fetch(url, {method: "POST", headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data)});
}
var ws = new WebSocket("ws://localhost:80/scan/ws");
ws.onmessage = function(event) {
if (event.data == "Done") {
document.getElementById("scanButton").innerHTML = "Scan"
document.getElementById("scanButton").disabled = false
document.getElementById("selectAllCheckbox").disabled = false
document.getElementById("scanStatus").innerHTML = document.getElementById('minerTable').rows.length + " Miners"
document.getElementById("cancelButton").style = "display:none;"
enableCheckboxes();
} else if (event.data == "Cancelled") {
document.getElementById("scanButton").innerHTML = "Scan"
document.getElementById("scanButton").disabled = false
document.getElementById("selectAllCheckbox").disabled = false
document.getElementById("scanStatus").innerHTML = document.getElementById('minerTable').rows.length + " Miners"
document.getElementById("cancelButton").style = "display:none;"
enableCheckboxes();
} else {
var miner_data = JSON.parse(event.data)
var miners = document.getElementById('minerTable')
miners.innerHTML = ""
miner_data.forEach(function(miner) {
var tr = document.createElement('tr')
tr.id = miner["ip"]
var checkbox_td = document.createElement('td')
checkbox_td.innerHTML = '<input type="checkbox" class="select-item checkbox" name="minerCheckboxes" value="' + miner["ip"] + '" />'
checkbox_td.className = "active"
var ip_td = document.createElement('td')
ip_td.innerHTML = miner["ip"]
var model_td = document.createElement('td')
model_td.innerHTML = miner["model"]
var empty_td = document.createElement('td')
tr.append(checkbox_td)
tr.append(ip_td)
tr.append(model_td)
tr.append(empty_td)
miners.append(tr)
});
disableCheckboxes();
};
};
function scanMiners(event) {
var input = document.getElementById("messageText")
var miners = document.getElementById('minerTable')
miners.innerHTML = ""
document.getElementById("scanStatus").innerHTML = "<span class='spinner-border spinner-border-sm'></span> Scanning"
document.getElementById("scanButton").innerHTML = "<span class='spinner-border spinner-border-sm'></span> Scanning"
document.getElementById("scanButton").disabled = true
document.getElementById("selectAllCheckbox").disabled = true
document.getElementById("cancelButton").style = ""
if (input.value != "") {
ws.send(input.value)
event.preventDefault()
} else {
ws.send("192.168.1.0/24")
};
};
function cancelScan(event) {
document.getElementById("scanStatus").innerHTML = "Canceling..."
document.getElementById("scanButton").innerHTML = "Canceling..."
document.getElementById("cancelButton").style = "display:none;"
ws.send("-Cancel-")
};
function addMiners(event) {
var checkedBoxes = document.querySelectorAll('input[name=minerCheckboxes]:checked');
if (checkedBoxes.length != 0) {
var minerList = [];
for (i = 0; i< checkedBoxes.length; i++) {
minerList.push(checkedBoxes[i].defaultValue);
}
post("{{url_for('add_miners_scan')}}", {miners: minerList})
for (i = 0; i< minerList.length; i++) {
var tr_to_remove = document.getElementById(minerList[i])
tr_to_remove.remove()
var navbar_miners = document.getElementById("navMiners")
navbar_miners.innerHTML += '<li><a href="/miner/' + minerList[i] + '" class="nav-link text-white"><svg class="bi me-2 mt-1" width="16" height="16"><use xlink:href="#miner"></use></svg>' + minerList[i] + '</a></li>'
}
document.getElementById("scanStatus").innerHTML = document.getElementById('minerTable').rows.length + " Miners"
};
};
function disableCheckboxes() {
var checkBoxes = document.querySelectorAll('input[name=minerCheckboxes]');
for (i = 0; i< checkBoxes.length; i++) {
checkBoxes[i].disabled = true
};
};
function enableCheckboxes() {
var checkBoxes = document.querySelectorAll('input[name=minerCheckboxes]');
for (i = 0; i< checkBoxes.length; i++) {
checkBoxes[i].disabled = false
};
};
</script>
{% endblock content %}

View File

@@ -0,0 +1,46 @@
{% extends 'navbar.html'%}
{% block content %}
<div class="row my-2">
<div class="col">
<div class="d-flex flex-row-reverse">
<button type="button" class="btn btn-outline-danger mx-1" data-bs-toggle="modal" data-bs-target="#removeModal">
Remove All Miners
</button>
<!-- Modal -->
<div class="modal fade" id="removeModal" tabindex="-1" aria-labelledby="removeModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="removeModalLabel">Remove Miner</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Do you really want to remove all miners?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<a class="btn btn-danger" href="{{url_for('remove_all_miners')}}" role="button">Remove</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<form method="post" action="/settings/update">
<div class="input-group mb-3">
<span class="input-group-text">Graph Data Sleep Time</span>
<input type="number" class="form-control" value="{{settings['graph_data_sleep_time']}}" name="graph_data_sleep_time" id="graph_data_sleep_time">
</div>
<div class="input-group mb-3">
<span class="input-group-text">Miner Data Timeout</span>
<input type="number" class="form-control" value="{{settings['miner_data_timeout']}}" name="miner_data_timeout" id="miner_data_timeout">
</div>
<div class="input-group mb-3">
<span class="input-group-text">Miner Identification Timeout</span>
<input type="number" class="form-control" value="{{settings['miner_identify_timeout']}}" name="miner_identify_timeout" id=" ">
</div>
<button type="submit" class="btn btn-primary w-100">Submit</button>
</form>
{% endblock content %}

View File

@@ -0,0 +1,20 @@
import toml
import os
def get_current_settings():
try:
with open(os.path.join(os.getcwd(), "web_settings.toml"), "r") as settings_file:
settings = toml.loads(settings_file.read())
except:
settings = {
"graph_data_sleep_time": 1,
"miner_data_timeout": 5,
"miner_identify_timeout": 5,
}
return settings
def update_settings(settings):
with open(os.path.join(os.getcwd(), "web_settings.toml"), "w") as settings_file:
settings_file.write(toml.dumps(settings))

View File

@@ -0,0 +1,3 @@
graph_data_sleep_time = 1
miner_data_timeout = 5
miner_identify_timeout = 5