mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-06-23 19:41:18 +02:00
a93f8d7212
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.
290 lines
8.9 KiB
HTML
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 %}
|