Initial try at a tree view. Includes a few formatting options that will be useful elsewhere.

This commit is contained in:
Herbert Wolverson 2024-07-08 15:48:22 -05:00
parent d03a4f5b19
commit 0f759cbe5c
12 changed files with 448 additions and 4 deletions

View File

@ -1,6 +1,6 @@
#!/bin/bash
set -e
scripts=( index.js template.js login.js first-run.js shaped-devices.js )
scripts=( index.js template.js login.js first-run.js shaped-devices.js tree.js )
for script in "${scripts[@]}"
do
echo "Building {$script}"

View File

@ -52,4 +52,53 @@ export function lerpGreenToRedViaOrange(value, max) {
g = 255;
}
return `rgb(${r}, ${g}, ${b})`;
}
export function formatThroughput(throughput, limitInMbps) {
let limitBits = limitInMbps * 1000 * 1000;
let percent = 0;
if (limitBits > 0) {
percent = (throughput / limitBits) * 100;
}
let blob = "<span class='overlayThroughputWrapper'>";
blob += "<span class='overlayThroughputBar'>";
for (let i=0; i<100; i+=10) {
let color = lerpGreenToRedViaOrange(100-i, 100);
if (percent < i) {
blob += "░";
} else {
blob += "<span style='color: " + color + "'>█</span>";
}
}
blob += "</span>";
blob += "<span class='overlayThroughputNumber' style='color: white; font-weight: bold;'>" + scaleNumber(throughput * 8, 1) + "bps</span>";
blob += "</span>";
return blob;
}
export function formatRtt(rtt) {
if (rtt === undefined) {
return "-";
}
const limit = 200;
let percent = 0;
if (limit > 0) {
percent = (rtt / limit) * 100;
}
let blob = "<span class='overlayThroughputWrapper'>";
blob += "<span class='overlayThroughputBar'>";
for (let i=0; i<100; i+=10) {
let color = lerpGreenToRedViaOrange(100-i, 100);
if (percent < i) {
blob += "░";
} else {
blob += "<span style='color: " + color + "'>█</span>";
}
}
blob += "</span>";
blob += "<span class='overlayThroughputNumber' style='color: white; font-weight: bold;'>" + parseFloat(rtt).toFixed(0) + " ms</span>";
blob += "</span>";
return blob;
}

View File

@ -0,0 +1,292 @@
import {clearDiv, theading} from "./helpers/builders";
import {formatRtt, formatThroughput, lerpGreenToRedViaOrange, scaleNumber} from "./helpers/scaling";
import {subscribeWS} from "./pubsub/ws";
var tree = null;
var parent = 0;
// This runs first and builds the initial structure on the page
function getInitialTree() {
$.get("/local-api/networkTree/0", (data) => {
//console.log(data);
tree = data;
let treeTable = document.createElement("table");
treeTable.classList.add("table", "table-striped", "table-bordered");
let thead = document.createElement("thead");
thead.appendChild(theading("Name"));
thead.appendChild(theading("Limit"));
thead.appendChild(theading("⬇️"));
thead.appendChild(theading("⬆️"));
thead.appendChild(theading("RTT ⬇️"));
thead.appendChild(theading("RTT ⬆️"));
thead.appendChild(theading("Re-xmit ⬇️"));
thead.appendChild(theading("Re-xmit ⬆️"));
thead.appendChild(theading("ECN ⬇️"));
thead.appendChild(theading("ECN ⬆️"));
thead.appendChild(theading("Drops ⬇️"));
thead.appendChild(theading("Drops ⬆️"));
treeTable.appendChild(thead);
let tbody = document.createElement("tbody");
for (let i=0; i<tree.length; i++) {
let nodeId = tree[i][0];
let node = tree[i][1];
if (nodeId === parent) {
$("#nodeName").text(node.name);
let limit = "";
if (node.max_throughput[0] === 0) {
limit = "Unlimited";
} else {
limit = scaleNumber(node.max_throughput[0] * 1000 * 1000, 0);
}
limit += " / ";
if (node.max_throughput[1] === 0) {
limit += "Unlimited";
} else {
limit += scaleNumber(node.max_throughput[1] * 1000 * 1000, 0);
}
$("#parentLimits").text(limit);
$("#parentTpD").html(formatThroughput(node.current_throughput[0] * 8, node.max_throughput[0]));
$("#parentTpU").html(formatThroughput(node.current_throughput[1] * 8, node.max_throughput[1]));
console.log(node);
$("#parentRttD").html(formatRtt(node.rtts[0]));
$("#parentRttU").html(formatRtt(node.rtts[1]));
}
if (node.immediate_parent !== null && node.immediate_parent === parent) {
let row = buildRow(i);
tbody.appendChild(row);
iterateChildren(i, tbody, 1);
}
}
treeTable.appendChild(tbody);
// Clear and apply
let target = document.getElementById("tree");
clearDiv(target)
target.appendChild(treeTable);
subscribeWS(["NetworkTree"], onMessage);
});
}
function iterateChildren(idx, tBody, depth) {
for (let i=0; i<tree.length; i++) {
let node = tree[i][1];
if (node.immediate_parent !== null && node.immediate_parent === tree[idx][0]) {
let row = buildRow(i, depth);
tBody.appendChild(row);
iterateChildren(i, tBody, depth+1);
}
}
}
function buildRow(i, depth=0) {
let node = tree[i][1];
let nodeId = tree[i][0];
let row = document.createElement("tr");
row.classList.add("small");
let col = document.createElement("td");
let nodeName = "";
if (depth > 0) {
nodeName += "└";
}
for (let j=1; j<depth; j++) {
nodeName += "─";
}
if (depth > 0) nodeName += " ";
nodeName += "<a href='/tree.html?parent=" + nodeId + "'>";
nodeName += node.name;
nodeName += "</a>";
if (node.type !== null) {
nodeName += " (" + node.type + ")";
}
col.innerHTML = nodeName;
col.classList.add("small", "redactable");
row.appendChild(col);
col = document.createElement("td");
col.id = "limit-" + nodeId;
col.classList.add("small");
let limit = "";
if (node.max_throughput[0] === 0) {
limit = "Unlimited";
} else {
limit = scaleNumber(node.max_throughput[0] * 1000 * 1000, 0);
}
limit += " / ";
if (node.max_throughput[1] === 0) {
limit += "Unlimited";
} else {
limit += scaleNumber(node.max_throughput[0] * 1000 * 1000, 0);
}
col.textContent = limit;
row.appendChild(col);
col = document.createElement("td");
col.id = "down-" + nodeId;
col.classList.add("small");
col.innerHTML = formatThroughput(node.current_throughput[0] * 8, node.max_throughput[0]);
row.appendChild(col);
col = document.createElement("td");
col.id = "up-" + nodeId;
col.classList.add("small");
col.innerHTML = formatThroughput(node.current_throughput[1] * 8, node.max_throughput[1]);
row.appendChild(col);
col = document.createElement("td");
col.id = "rtt-down-" + nodeId;
col.innerHTML = formatRtt(node.rtts[0]);
row.appendChild(col);
col = document.createElement("td");
col.id = "rtt-up-" + nodeId;
col.innerHTML = formatRtt(node.rtts[1]);
row.appendChild(col);
col = document.createElement("td");
col.id = "re-xmit-down-" + nodeId;
if (node.current_retransmits[0] !== undefined) {
col.textContent = node.current_retransmits[0];
} else {
col.textContent = "-";
}
row.appendChild(col);
col = document.createElement("td");
col.id = "re-xmit-up-" + nodeId;
if (node.current_retransmits[1] !== undefined) {
col.textContent = node.current_retransmits[1];
} else {
col.textContent = "-";
}
row.appendChild(col);
col = document.createElement("td");
col.id = "ecn-down-" + nodeId;
if (node.current_marks[0] !== undefined) {
col.textContent = node.current_marks[0];
} else {
col.textContent = "-";
}
row.appendChild(col);
col = document.createElement("td");
col.id = "ecn-up-" + nodeId;
if (node.current_marks[1] !== undefined) {
col.textContent = node.current_marks[1];
} else {
col.textContent = "-";
}
row.appendChild(col);
col = document.createElement("td");
col.id = "drops-down-" + nodeId;
if (node.current_drops[0] !== undefined) {
col.textContent = node.current_drops[0];
} else {
col.textContent = "-";
}
row.appendChild(col);
col = document.createElement("td");
col.id = "drops-up-" + nodeId;
if (node.current_drops[1] !== undefined) {
col.textContent = node.current_drops[1];
} else {
col.textContent = "-";
}
row.appendChild(col);
return row;
}
function onMessage(msg) {
if (msg.event === "NetworkTree") {
//console.log(msg);
msg.data.forEach((n) => {
let nodeId = n[0];
let node = n[1];
let col = document.getElementById("down-" + nodeId);
if (col !== null) {
col.innerHTML = formatThroughput(node.current_throughput[0] * 8, node.max_throughput[0]);
}
col = document.getElementById("up-" + nodeId);
if (col !== null) {
col.innerHTML = formatThroughput(node.current_throughput[1] * 8, node.max_throughput[1]);
}
col = document.getElementById("rtt-down-" + nodeId);
if (col !== null) {
col.innerHTML = formatRtt(node.rtts[0]);
}
col = document.getElementById("rtt-up-" + nodeId);
if (col !== null) {
col.innerHTML = formatRtt(node.rtts[1]);
}
col = document.getElementById("re-xmit-down-" + nodeId);
if (col !== null) {
if (node.current_retransmits[0] !== undefined) {
col.textContent = node.current_retransmits[0];
} else {
col.textContent = "-";
}
}
col = document.getElementById("re-xmit-up-" + nodeId);
if (col !== null) {
if (node.current_retransmits[1] !== undefined) {
col.textContent = node.current_retransmits[1];
} else {
col.textContent = "-";
}
}
col = document.getElementById("ecn-down-" + nodeId);
if (col !== null) {
if (node.current_marks[0] !== undefined) {
col.textContent = node.current_marks[0];
} else {
col.textContent = "-";
}
}
col = document.getElementById("ecn-up-" + nodeId);
if (col !== null) {
if (node.current_marks[1] !== undefined) {
col.textContent = node.current_marks[1];
} else {
col.textContent = "-";
}
}
col = document.getElementById("drops-down-" + nodeId);
if (col !== null) {
if (node.current_drops[0] !== undefined) {
col.textContent = node.current_drops[0];
} else {
col.textContent = "-";
}
}
col = document.getElementById("drops-up-" + nodeId);
if (col !== null) {
if (node.current_drops[1] !== undefined) {
col.textContent = node.current_drops[1];
} else {
col.textContent = "-";
}
}
});
}
}
const params = new Proxy(new URLSearchParams(window.location.search), {
get: (searchParams, prop) => searchParams.get(prop),
});
if (params.parent !== null) {
parent = parseInt(params.parent);
} else {
parent = 0;
}
getInitialTree();

View File

@ -2,6 +2,7 @@ mod dashboard_themes;
mod version_check;
mod device_counts;
mod shaped_device_api;
mod network_tree;
use axum::Router;
use axum::routing::{get, post};
@ -15,4 +16,5 @@ pub fn local_api() -> Router {
.route("/versionCheck", get(version_check::version_check))
.route("/deviceCount", get(device_counts::count_users))
.route("/devicesAll", get(shaped_device_api::all_shaped_devices))
.route("/networkTree/:parent", get(network_tree::get_network_tree))
}

View File

@ -0,0 +1,20 @@
use axum::extract::Path;
use axum::Json;
use lqos_bus::BusResponse;
use lqos_config::NetworkJsonTransport;
use crate::shaped_devices_tracker;
use crate::shaped_devices_tracker::NETWORK_JSON;
pub async fn get_network_tree(
Path(parent): Path<usize>
) -> Json<Vec<(usize, NetworkJsonTransport)>> {
let net_json = NETWORK_JSON.read().unwrap();
let result: Vec<(usize, NetworkJsonTransport)> = net_json
.nodes
.iter()
.enumerate()
.map(|(i, n) | (i, n.clone_to_transit()))
.collect();
Json(result)
}

View File

@ -79,4 +79,22 @@ body.dark-mode {
min-height: 500px;
}
.dashEditButton { }
.redactable { }
.redactable { }
.small { font-size: 8pt; }
/* Funky tricks for the tree view */
.overlayThroughputWrapper {
position: relative;
width: 100%;
height: auto;
text-align: center;
}
.overlayThroughputNumber {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.2em;
color: white;
text-shadow: 2px 2px 4px #000000;
}

View File

@ -49,7 +49,7 @@
</li>
<!-- Tree -->
<li class="nav-item">
<a class="nav-link">
<a class="nav-link" href="tree.html?parent=0">
<i class="fa fa-tree nav-icon"></i> Tree
</a>
</li>

View File

@ -0,0 +1,29 @@
<div class="row">
<div class="col-4">
<h5><i class="fa fa-tree"></i> Network Tree (<span id="nodeName"></span>)</h5>
<table class="table">
<tr>
<td style="font-weight: bold;">Limits:</td>
<td class="small"><span id="parentLimits"></span></td>
<td></td>
</tr>
<tr>
<td style="font-weight: bold;">Throughput:</td>
<td><span id="parentTpD"></span></td>
<td><span id="parentTpU"></span></td>
</tr>
<tr>
<td style="font-weight: bold;">RTT:</td>
<td><span id="parentRttD"></span></td>
<td><span id="parentRttU"></span></td>
</tr>
</table>
</div>
</div>
<div class="row">
<div class="col-12" id="tree">
<p><i class="fa fa-spin fa-spinner"></i> Loading, Please Wait</p>
</div>
</div>
<script src="tree.js"></script>

View File

@ -29,7 +29,7 @@ pub(super) fn static_routes() -> Result<Router> {
// Add HTML pages to serve directly to this list, otherwise
// they won't have template + authentication applied to them.
let html_pages = [
"index.html", "shaped_devices.html"
"index.html", "shaped_devices.html", "tree.html"
];
// Iterate through pages and construct the router

View File

@ -19,4 +19,5 @@ pub enum PublishedChannels {
Ram,
TreeSummary,
QueueStatsTotal,
NetworkTree,
}

View File

@ -9,6 +9,7 @@ mod flow_endpoints;
pub mod system_info;
mod tree_summary;
mod queue_stats_total;
mod network_tree;
use std::sync::Arc;
use crate::node_manager::ws::publish_subscribe::PubSub;
@ -36,6 +37,7 @@ pub(super) async fn channel_ticker(channels: Arc<PubSub>) {
system_info::ram_info(channels.clone()),
tree_summary::tree_summary(channels.clone()),
queue_stats_total::queue_stats_totals(channels.clone()),
network_tree::network_tree(channels.clone()),
);
}
}

View File

@ -0,0 +1,31 @@
use std::sync::Arc;
use serde_json::json;
use tokio::task::spawn_blocking;
use lqos_config::NetworkJsonTransport;
use crate::node_manager::ws::publish_subscribe::PubSub;
use crate::node_manager::ws::published_channels::PublishedChannels;
use crate::shaped_devices_tracker::NETWORK_JSON;
pub async fn network_tree(channels: Arc<PubSub>) {
if !channels.is_channel_alive(PublishedChannels::NetworkTree).await {
return;
}
let data: Vec<(usize, NetworkJsonTransport)> = spawn_blocking(|| {
let net_json = NETWORK_JSON.read().unwrap();
net_json
.nodes
.iter()
.enumerate()
.map(|(i, n) | (i, n.clone_to_transit()))
.collect()
}).await.unwrap();
let message = json!(
{
"event": PublishedChannels::NetworkTree.to_string(),
"data": data,
}
).to_string();
channels.send(PublishedChannels::NetworkTree, message).await;
}