Files
meshview/meshview/templates/nodegraph.html
T
Alec Perkins a93f8d7212 feat: Add colors and weighting to graph, condense labels
The colors indicate the role, and the edges are weighted by how many traceroutes contributed to that edge. This helps to communicate the quality of the connection. The colors and node size based on role make it easier to see the composition of the graph. This also moves the look-and-feel pieces into the template only, and attempts to centralize the color definitions.
2025-09-10 18:04:12 -04:00

290 lines
8.9 KiB
HTML

{% extends "base.html" %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>
{% endblock %}
{% block css %}
#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-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 {
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 {
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;
code {
color: inherit;
}
}
.legend-box {
display: inline-block;
width: 12px;
height: 12px;
margin-right: 5px;
border-radius: 3px;
&.circle {
border-radius: 6px;
}
}
{% endblock %}
{% block body %}
<div id="mynetwork"></div>
<div class="search-container">
<label for="channel-select" 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>
<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>
<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: #ffbf00"></span> Other</div>
<div><span class="legend-box circle" style="background-color: #6c757d"></span> Unknown</div>
</div>
</div>
<script>
const chart = echarts.init(document.getElementById('mynetwork'));
const colors = {
edge: {
traceroute: '#ff5733',
neighbor: '#049acd',
},
role: {
ROUTER: '#ff5733',
ROUTER_LATE: '#b65224',
CLIENT: '#007bff',
CLIENT_MUTE: '#00c3ff',
other: '#ffbf00',
unknown: '#6c757d',
},
selection: '#ff8c00',
};
function getRoleColor(role) {
if (!role) return colors.role.unknown;
return colors.role[role] || colors.role.other;
}
function getSymbolSize (role) {
switch (role) {
case 'ROUTER': return 30;
case 'ROUTER_LATE': return 30;
case 'CLIENT': return 15;
case 'CLIENT_MUTE': return 7;
default: return 15; // Unknown or other roles
}
}
function getLabel (role, short_name, long_name) {
if (role === 'ROUTER') return long_name;
if (role === 'ROUTER_LATE') return long_name;
if (role === 'CLIENT') return short_name;
if (role === 'CLIENT_MUTE') return short_name;
return short_name || '';
}
// --- Nodes ---
const nodes = [
{% for node in nodes %}
{
name: "{{ node.node_id }}", // node_id as string
value: getLabel({{node.role | tojson}}, {{node.short_name | tojson }}, {{node.long_name | tojson}}), // display label
symbol: 'circle',
symbolSize: getSymbolSize({{node.role | tojson}}),
itemStyle: { color: getRoleColor({{node.role | tojson}}), opacity:1 },
label: { show:true, position:'right', color:'#333', fontSize:12, formatter: (p)=>p.data.value },
long_name: {{ node.long_name | tojson }},
short_name: {{ node.short_name | tojson }},
role: {{ node.role | tojson }},
hw_model: {{ node.hw_model | tojson }},
channel: {{ node.channel | tojson }}
},
{% endfor %}
];
// --- Edges ---
const edges = [
{% for edge in edges %}
{
source: "{{ edge.from }}", // edge source as string
target: "{{ edge.to }}", // edge target as string
originalColor: colors.edge[{{edge.type | tojson}}],
lineStyle: {
color: colors.edge[{{edge.type | tojson}}],
width: {{edge.weight | tojson}},
},
},
{% endfor %}
];
let filteredNodes = [];
let filteredEdges = [];
let selectedChannel = 'LongFast';
let lastSelectedNode = null;
function populateChannelDropdown() {
const sel = document.getElementById('channel-select');
const unique = [...new Set(nodes.map(n=>n.channel).filter(Boolean))].sort();
unique.forEach(ch=>{
const opt = document.createElement('option');
opt.value=ch; opt.text=ch;
if(ch==='LongFast') opt.selected=true;
sel.appendChild(opt);
});
selectedChannel = sel.value;
filterByChannel();
}
function filterByChannel() {
selectedChannel = document.getElementById('channel-select').value;
filteredNodes = nodes.filter(n=>n.channel===selectedChannel);
const nodeSet = new Set(filteredNodes.map(n=>n.name));
filteredEdges = edges.filter(e=>nodeSet.has(e.source) && nodeSet.has(e.target));
lastSelectedNode=null;
updateChart();
}
function updateChart() {
const updatedNodes = filteredNodes.map(node=>{
let opacity=1, color=getRoleColor(node.role), borderColor='transparent', borderWidth=6;
if(lastSelectedNode){
const connected = filteredEdges.some(e=>
(e.source===node.name && e.target===lastSelectedNode) ||
(e.target===node.name && e.source===lastSelectedNode)
);
if(node.name === lastSelectedNode) {
opacity=1;
borderColor=colors.selection;
}
else if(connected) opacity=1;
else opacity=0.4;
}
return {...node, itemStyle:{...node.itemStyle, color,opacity, borderColor, borderWidth}};
});
const updatedEdges = filteredEdges.map(edge=>{
let opacity=0.1, width=edge.lineStyle.width;
if(lastSelectedNode){
const connected = edge.source===lastSelectedNode || edge.target===lastSelectedNode;
opacity=connected?1:0.05; width=edge.lineStyle.width;
}
return {...edge, lineStyle:{color:edge.originalColor||'#d3d3d3', width, opacity}};
});
chart.setOption({series:[{type:'graph', layout:'force', data:updatedNodes, links:updatedEdges, roam:true, force:{repulsion:200, edgeLength:[80,120]}}]});
}
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(selNode){
lastSelectedNode=selNode; updateChart();
const n = filteredNodes.find(x=>x.name===selNode);
if(n){
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;
}
}
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!");
}
populateChannelDropdown();
window.addEventListener('resize', ()=>chart.resize());
</script>
{% endblock %}