Files
meshview/meshview/templates/nodegraph.html
T
2026-10-14 07:36:27 -07:00

386 lines
11 KiB
HTML

{% extends "base.html" %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>
{% endblock %}
{% block css %}
<style>
#mynetwork {
width: 100%;
height: 100vh;
border: 1px solid #ddd;
background-color: #f8f9fa;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
/* Search UI */
.search-container {
position: absolute;
bottom: 100px;
left: 10px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 5px;
}
.search-container input,
.search-container select,
.search-container button {
padding: 8px;
border-radius: 8px;
border: 1px solid #ccc;
}
.search-container button {
background-color: #007bff;
color: white;
cursor: pointer;
}
.search-container button:hover {
background-color: #0056b3;
}
/* Node info box */
#node-info {
position: absolute;
bottom: 10px;
right: 10px;
background-color: rgba(255,255,255,0.95);
padding: 12px;
border-radius: 8px;
border: 1px solid #ccc;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
font-size: 14px;
color: #333;
width: 260px;
max-height: 250px;
overflow-y: auto;
}
/* Legend */
#legend {
position: absolute;
bottom: 10px;
left: 10px;
background: rgba(255,255,255,0.9);
padding: 10px;
border-radius: 5px;
border: 1px solid #ccc;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
font-size: 14px;
display: flex;
color: #333;
}
.legend-category {
margin-right: 10px;
}
.legend-box {
display: inline-block;
width: 12px;
height: 12px;
margin-right: 5px;
border-radius: 3px;
}
.circle { border-radius: 6px; }
</style>
{% endblock %}
{% block body %}
<div id="mynetwork"></div>
<!-- SEARCH + FILTER -->
<div class="search-container">
<label style="color:#333;">Channel:</label>
<select id="channel-select" onchange="filterByChannel()"></select>
<input type="text" id="node-search" placeholder="Search node...">
<button onclick="searchNode()">Search</button>
</div>
<!-- INFO BOX -->
<div id="node-info">
<b>Long Name:</b> <span id="node-long-name"></span><br>
<b>Short Name:</b> <span id="node-short-name"></span><br>
<b>Role:</b> <span id="node-role"></span><br>
<b>Hardware Model:</b> <span id="node-hw-model"></span>
</div>
<!-- LEGEND -->
<div id="legend">
<div class="legend-category">
<div><span class="legend-box" style="background-color: #ff5733"></span>Traceroute</div>
<div><span class="legend-box" style="background-color: #049acd"></span>Neighbor</div>
</div>
<div class="legend-category">
<div><span class="legend-box circle" style="background-color: #ff5733"></span><code>ROUTER</code></div>
<div><span class="legend-box circle" style="background-color: #b65224"></span><code>ROUTER_LATE</code></div>
</div>
<div class="legend-category">
<div><span class="legend-box circle" style="background-color: #007bff"></span><code>CLIENT</code></div>
<div><span class="legend-box circle" style="background-color: #00c3ff"></span><code>CLIENT_MUTE</code></div>
</div>
<div class="legend-category">
<div><span class="legend-box circle" style="background-color: #049acd"></span><code>CLIENT_BASE</code></div>
<div><span class="legend-box circle" style="background-color: #ffbf00"></span>Other</div>
</div>
<div class="legend-category">
<div><span class="legend-box circle" style="background-color: #6c757d"></span>Unknown</div>
</div>
</div>
<script>
// Initialize ECharts
const chart = echarts.init(document.getElementById("mynetwork"));
// -----------------------------------
// COLOR + ROLE HELPERS
// -----------------------------------
const colors = {
edge: { traceroute:"#ff5733", neighbor:"#049acd" },
role: {
ROUTER:"#ff5733",
ROUTER_LATE:"#b65224",
CLIENT:"#007bff",
CLIENT_MUTE:"#00c3ff",
CLIENT_BASE:"#049acd",
other:"#ffbf00",
unknown:"#6c757d"
},
selection:"#ff8c00"
};
function getRoleColor(role) { return colors.role[role] || colors.role.other; }
function getSymbolSize(role) {
switch(role){
case "ROUTER":
case "ROUTER_LATE": return 30;
case "CLIENT_BASE": return 18;
case "CLIENT": return 15;
case "CLIENT_MUTE": return 7;
default: return 15;
}
}
function getLabel(role, shortName, longName) {
if (role === "ROUTER" || role === "ROUTER_LATE") return longName;
return shortName || "";
}
// -----------------------------------
// STATE
// -----------------------------------
let nodes = [];
let edges = [];
let filteredNodes = [];
let filteredEdges = [];
let lastSelectedNode = null;
let selectedChannel = null;
// -----------------------------------
// LOAD NODES + EDGES FROM API
// -----------------------------------
async function loadData() {
// 1. Load nodes
const n = await fetch("/api/nodes").then(r => r.json());
nodes = n.nodes.map(x => ({
name: String(x.node_id),
node_id: x.node_id,
long_name: x.long_name,
short_name: x.short_name,
hw_model: x.hw_model,
role: x.role,
channel: x.channel,
labelValue: getLabel(x.role, x.short_name, x.long_name),
symbolSize: getSymbolSize(x.role),
itemStyle: {
color: getRoleColor(x.role),
opacity: 1
},
label: {
show: true,
position: "right",
color: "#333",
fontSize: 12,
formatter: p => p.data.labelValue
}
}));
const allNodeIDs = new Set(nodes.map(n => n.name));
// 2. Load edges
const e = await fetch("/api/edges").then(r => r.json());
// Only keep edges that reference valid nodes
edges = e.edges
.filter(ed =>
allNodeIDs.has(String(ed.from)) &&
allNodeIDs.has(String(ed.to))
)
.map(ed => ({
source: String(ed.from),
target: String(ed.to),
edgeType: ed.type,
originalColor: colors.edge[ed.type] || "#ccc",
lineStyle: {
width: 2,
color: colors.edge[ed.type] || "#ccc",
opacity: 1
}
}));
// 3. Determine which nodes are actually used in edges
const usedNodeIDs = new Set();
edges.forEach(e => {
usedNodeIDs.add(e.source);
usedNodeIDs.add(e.target);
});
// 4. Remove unused (no-edge) nodes
nodes = nodes.filter(n => usedNodeIDs.has(n.name));
// 5. Double safety: remove any edges referencing removed nodes
edges = edges.filter(e =>
usedNodeIDs.has(e.source) && usedNodeIDs.has(e.target)
);
// 6. Now ready to build dropdown & render
populateChannelDropdown();
}
// -----------------------------------
// CHANNEL FILTER
// -----------------------------------
function populateChannelDropdown() {
const sel = document.getElementById("channel-select");
const chans = [...new Set(nodes.map(n => n.channel))].sort();
chans.forEach(ch => {
const opt = document.createElement("option");
opt.value = ch;
opt.text = ch;
sel.appendChild(opt);
});
selectedChannel = chans[0];
sel.value = selectedChannel;
filterByChannel();
}
function filterByChannel() {
selectedChannel = document.getElementById("channel-select").value;
filteredNodes = nodes.filter(n => n.channel === selectedChannel);
const allowed = new Set(filteredNodes.map(n => n.name));
filteredEdges = edges.filter(e => allowed.has(e.source) && allowed.has(e.target));
lastSelectedNode = null;
updateChart();
}
// -----------------------------------
// FORCE GRAPH UPDATE
// -----------------------------------
function updateChart() {
const updatedNodes = filteredNodes.map(n => {
let opacity = 1;
let borderColor = "transparent";
if (lastSelectedNode) {
const connected = filteredEdges.some(
e => (e.source === n.name && e.target === lastSelectedNode) ||
(e.target === n.name && e.source === lastSelectedNode)
);
if (n.name === lastSelectedNode) {
borderColor = colors.selection;
} else if (!connected) {
opacity = 0.3;
}
}
return {
...n,
itemStyle: { ...n.itemStyle, opacity, borderColor, borderWidth: 6 }
};
});
const updatedEdges = filteredEdges.map(e => {
const connected =
lastSelectedNode &&
(e.source === lastSelectedNode || e.target === lastSelectedNode);
return {
...e,
lineStyle: { ...e.lineStyle, opacity: connected ? 1 : 0.1 }
};
});
chart.setOption({
animation: false,
series: [{
type: "graph",
layout: "force",
roam: true,
data: updatedNodes,
links: updatedEdges,
force: { repulsion: 200, edgeLength: [80, 120] }
}]
});
}
// -----------------------------------
// CLICK EVENTS
// -----------------------------------
chart.on("click", function(params){
if (params.dataType === "node") {
updateSelectedNode(params.data.name);
} else {
lastSelectedNode = null;
updateChart();
document.getElementById("node-long-name").innerText = "";
document.getElementById("node-short-name").innerText = "";
document.getElementById("node-role").innerText = "";
document.getElementById("node-hw-model").innerText = "";
}
});
function updateSelectedNode(id) {
lastSelectedNode = id;
updateChart();
const n = filteredNodes.find(n => n.name === id);
if (!n) return;
document.getElementById("node-long-name").innerText = n.long_name;
document.getElementById("node-short-name").innerText = n.short_name;
document.getElementById("node-role").innerText = n.role;
document.getElementById("node-hw-model").innerText = n.hw_model;
}
// -----------------------------------
// SEARCH
// -----------------------------------
function searchNode() {
const q = document.getElementById("node-search").value.toLowerCase().trim();
if (!q) return;
const found = filteredNodes.find(n =>
n.name.toLowerCase().includes(q) ||
(n.long_name || "").toLowerCase().includes(q) ||
(n.short_name || "").toLowerCase().includes(q)
);
if (found) updateSelectedNode(found.name);
else alert("Node not found in current channel!");
}
// -----------------------------------
loadData();
window.addEventListener("resize", () => chart.resize());
</script>
{% endblock %}