Finishing up all the pages for the 3.0 release.

Now all pages are functional.
This commit is contained in:
Pablo Revilla
2025-11-21 13:47:22 -08:00
parent 052a9460ca
commit fc01cb6a85
13 changed files with 251 additions and 810 deletions

View File

@@ -1,16 +0,0 @@
<div id="buttons" class="btn-group" role="group">
<a
role="button"
class="btn {{ 'btn-primary' if packet_event == 'packet' else 'btn-secondary'}}"
href="/packet_list/{{node_id}}?{{query_string}}"
>
TX/RX
</a>
<a
role="button"
class="btn {{ 'btn-primary' if packet_event == 'uplinked' else 'btn-secondary'}}"
href="/uplinked_list/{{node_id}}?{{query_string}}"
>
Uplinked
</a>
</div>

View File

@@ -1,7 +0,0 @@
<datalist
id="node_options"
>
{% for option in node_options %}
<option value="{{option.id}}">{{option.id}} -- {{option.long_name}} ({{option.short_name}})</option>
{% endfor %}
</datalist>

View File

@@ -48,10 +48,10 @@
display: none;
}
/* --- Source Marker (bigger, no animation) --- */
/* --- Source Marker: 2px bigger than gateway (26px vs 24px) --- */
.source-marker {
width: 36px;
height: 36px;
width: 26px;
height: 26px;
background: rgba(255,0,0,0.55);
border: 3px solid #ff0000;
border-radius: 50%;
@@ -268,17 +268,17 @@ document.addEventListener("DOMContentLoaded", async () => {
const allBounds = [];
/* -------------------------------------------------------
PACKET SOURCE MARKER: bigger red circle, no animation
-------------------------------------------------------- */
/* --------------------------------------------------------------------
SOURCE MARKER (26px vs 24px)
--------------------------------------------------------------------- */
if (lat && lon) {
allBounds.push([lat, lon]);
const sourceIcon = L.divIcon({
html: `<div class="source-marker"></div>`,
className: "",
iconSize: [36, 36],
iconAnchor: [18, 18]
iconSize: [26, 26],
iconAnchor: [13, 13]
});
const sourceMarker = L.marker([lat, lon], {
@@ -303,14 +303,25 @@ document.addEventListener("DOMContentLoaded", async () => {
/* -------------------------
Helpers
-------------------------- */
function hopColor(hop){
const c=[
"#ff3b30","#ff6b22","#ff9f0c",
"#ffd60a","#87d957","#57d9c4","#3db2ff"
/* 0 = warmest → 7 = coldest */
function hopColor(hopValue){
const colors = [
"#ff3b30", // 0 red
"#ff6b22",
"#ff9f0c",
"#ffd60a",
"#87d957",
"#57d9c4",
"#3db2ff",
"#1e63ff" // 7 deep cold blue
];
if(!hop||hop<1)return"#aaa";
if(hop>7)hop=7;
return c[hop-1];
let h = Number(hopValue);
if (isNaN(h)) return "#aaa";
if (h < 0) h = 0;
if (h > 7) h = 7;
return colors[h];
}
function haversine(lat1,lon1,lat2,lon2){
@@ -360,9 +371,9 @@ document.addEventListener("DOMContentLoaded", async () => {
const start = Number(s.hop_start ?? 0);
const limit = Number(s.hop_limit ?? 0);
const hopValue = limit - start;
const hopValue = start - limit;
const color = hopColor(Number(s.hop_limit));
const color = hopColor(hopValue);
const iconHtml = `
<div style="
@@ -404,8 +415,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<b>Signal</b><br>
RSSI: ${s.rx_rssi ?? "—"}<br>
SNR: ${s.rx_snr ?? "—"}<br><br>
<b>Hop Distance</b><br>
hop_limit (${limit}) hop_start (${start}) = <b>${hopValue}</b><br><br>
<b>Hops</b>: ${hopValue}<br>
<b>Distance</b><br>
${
distKm

View File

@@ -1,234 +0,0 @@
{% extends "base.html" %}
{% block css %}
/* Styles for the node info card */
#node_info {
height: 100%;
}
/* Styles for the map */
#map {
height: 100%;
min-height: 400px;
}
/* Styles for packet details section */
#packet_details {
height: 95vh;
overflow: scroll;
top: 3em;
}
/* Ensure inline display for details */
div.tab-pane > dl {
display: inline-block;
}
/* Set the maximum width of the page to 900px */
.container {
max-width: 900px;
margin: 0 auto; /* Center the content horizontally */
}
{% endblock %}
{% block body %}
<div id="node" class="container text-center">
<div class="container">
<div class="row">
<div class="col mb-3">
<!-- Node Information Card -->
<div class="card" id="node_info">
{% if node %}
<div class="card-header" id="node_color">
<strong style="margin-right: 1em ; margin-left: 1em; font-size: x-large;">{{node.short_name}}</strong>
<p style="margin-bottom: 0px; font-size: large; font-weight: bold;">{{node.long_name}}</p>
</div>
<div class="card-body">
<dl >
{% if trace %}
<dd id="map"></dd>
{% endif %}
<dt>NodeID</dt>
<dd>{{node.node_id|node_id_to_hex}}</dd>
<dt>Channel</dt>
<dd>{{node.channel}}</dd>
<dt>HW Model</dt>
<dd>{{node.hw_model}}</dd>
<dt>Role</dt>
<dd>{{node.role}}</dd>
{% if node.firmware %}
<dt>Firmware</dt>
<dd>{{node.firmware}}</dd>
{% endif %}
</dl>
<a href="/top?node_id={{node.node_id}}" >Get node traffic totals</a>
{% include "node_graphs.html" %}
</div>
{% else %}
<div class="card-body">
A NodeInfo has not been seen.
</div>
{% endif %}
</div>
</div>
</div>
<div class="row">
<div class="col">
<!-- Additional buttons can be included here -->
</div>
</div>
<div class="row">
<div class="col">
{% include 'packet_list.html' %}
</div>
<!-- <div class="col sticky-top" id="packet_details"></div> -->
</div>
</div>
</div>
<script>
var node_color = document.getElementById('node_color');
var node_id = '{{node.node_id | node_id_to_hex}}';
var color = node_id.slice(-6);
var bg_color = "#"+color;
node_color.style.background = bg_color;
var hex = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(bg_color);
var text_color = [
parseInt(hex[1], 16),
parseInt(hex[2], 16),
parseInt(hex[3], 16),
];
const brightness = Math.round(((parseInt(text_color[0]) * 299) +
(parseInt(text_color[1]) * 587) +
(parseInt(text_color[2]) * 114)) / 1000);
if (brightness > 125) {
var textColor = '#212529'
node_color.style.color = textColor
}
</script>
{% if trace %}
<script>
var trace = {{ trace | tojson }}; // Load trace data into JavaScript
var map = L.map('map').setView(trace[0], 13); // Initialize map centered at first trace point
var markers = L.featureGroup(); // Create a feature group for markers
markers.addTo(map);
// Add tile layer (OpenStreetMap)
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
// Draw a polyline along the trace path
L.polyline(trace, { color: 'blue', weight: 1}).addTo(map);
// Add a red circle marker for the starting node with a tooltip
var startMarker = L.circleMarker(trace[0], {
radius: 8,
color: 'red',
weight: 1,
fillColor: 'red',
fillOpacity: 0.5
}).addTo(markers) // Add to feature group
.bindPopup(`
<b>{{node.long_name}}</b><br/>
<b>Short:</b> {{node.short_name}}<br/>
<b>Channel:</b> {{node.channel}}<br/>
<b>Hardware:</b> {{node.hw_model}}<br/>
<b>Role:</b> {{node.role}}<br/>
<b>Firmware:</b> {{node.firmware}}<br/>
<b>Coordinates:</b> [{{node.last_lat}} , {{node.last_long}}]
`, {permanent: false, direction: 'top', opacity: 0.9});
// Function to calculate distance and convert to miles
function getDistanceInMiles(latlng1, latlng2) {
var meters = latlng1.distanceTo(latlng2); // Get distance in meters
return meters * 0.000621371; // Convert meters to miles
}
{% for n in neighbors %}
var neighborLatLng = L.latLng([{{n.location[0]}}, {{n.location[1]}}]);
var startLatLng = L.latLng(trace[0]);
// Calculate distance in miles with 1 decimal place
var distanceMiles = getDistanceInMiles(startLatLng, neighborLatLng).toFixed(1);
// Create a blue circle marker for each neighbor node
var m = L.circleMarker(neighborLatLng, {
radius: 6,
color: 'blue',
weight: 1,
fillColor: 'blue',
fillOpacity: 0.5
}).addTo(markers) // Add to feature group
.bindPopup(`
<b><a href="/packet_list/{{n.node_id}}">{{n.long_name}}</a></b><br>
<b>{{n.short_name}}</b> <br/>
<b>SNR:</b> {{n.snr}} <br/>
<b>Distance:</b> ${distanceMiles} miles <br/>
`, {permanent: false, direction: 'top', opacity: 0.9});
// Draw a polyline from the first trace point to each neighbor node
L.polyline([startLatLng, neighborLatLng], {
color: 'grey',
weight: 1
}).addTo(map);
{% endfor %}
// Add a legend to the map
var legend = L.control({ position: 'bottomleft' });
legend.onAdd = function(map) {
var div = L.DomUtil.create('div', 'info legend');
div.style.background = 'white';
div.style.padding = '8px';
div.style.border = '1px solid black';
div.style.borderRadius = '5px';
div.style.boxShadow = '0 0 5px rgba(0,0,0,0.3)';
div.style.color = 'black'; // Ensure text is black
div.style.textAlign = 'left'; /* Ensure left alignment */
div.innerHTML = `
<b>Legend</b><br>
<svg width="16" height="16">
<circle cx="8" cy="8" r="6" fill="blue" stroke="blue" stroke-width="1" fill-opacity="0.4"/>
</svg> Neighbor Node<br>
<svg width="20" height="20">
<circle cx="10" cy="10" r="8" fill="red" stroke="red" stroke-width="1" fill-opacity="0.4"/>
</svg> Home Node<br>
<svg width="20" height="4">
<line x1="0" y1="2" x2="20" y2="2" stroke="grey" stroke-width="2"/>
</svg> Connection to Neighbors<br>
<svg width="20" height="4">
<line x1="0" y1="2" x2="20" y2="2" stroke="blue" stroke-width="2"/>
</svg> Path taken by node
`;
return div;
};
legend.addTo(map);
// Ensure the map adjusts to fit all markers and trace points
setTimeout(() => {
if (markers.getLayers().length > 0 || trace.length > 0) {
var bounds = markers.getBounds(); // Get bounds from markers
// Ensure trace points are included in the bounds
trace.forEach(point => {
bounds.extend(point);
});
map.fitBounds(bounds.pad(0.1), { maxZoom: 10 });
}
}, 200); // Slightly longer delay to ensure all elements are fully loaded
</script>
{% endif %}
{% endblock %}

View File

@@ -1,109 +0,0 @@
{% extends "base.html" %}
{% block css %}
.table-title {
font-size: 2rem;
text-align: center;
margin-bottom: 20px;
}
.traffic-table {
width: 50%;
border-collapse: collapse;
margin: 0 auto;
font-family: Arial, sans-serif;
}
.traffic-table th,
.traffic-table td {
padding: 10px 15px;
text-align: left;
border: 1px solid #474b4e;
}
.traffic-table th {
background-color: #272b2f;
color: white;
}
.traffic:nth-of-type(odd) {
background-color: #272b2f; /* Lighter than #2a2a2a */
}
.traffic {
border: 1px solid #474b4e;
padding: 8px;
margin-bottom: 4px;
border-radius: 8px;
}
.traffic:nth-of-type(even) {
background-color: #212529; /* Slightly lighter than the previous #181818 */
}
.footer {
text-align: center;
margin-top: 20px;
}
{% endblock %}
{% block body %}
<section>
<h2 class="table-title">
{% if traffic %}
{{ traffic[0].long_name }} (last 24 hours)
{% else %}
No Traffic Data Available
{% endif %}
</h2>
<table class="traffic-table">
<thead>
<tr>
<th>Port Number</th>
<th>Packet Count</th>
</tr>
</thead>
<tbody>
{% for port in traffic %}
<tr class="traffic">
<td>
{% if port.portnum == 1 %}
TEXT_MESSAGE_APP
{% elif port.portnum == 3 %}
POSITION_APP
{% elif port.portnum == 4 %}
NODEINFO_APP
{% elif port.portnum == 5 %}
ROUTING_APP
{% elif port.portnum == 8 %}
WAYPOINT_APP
{% elif port.portnum == 67 %}
TELEMETRY_APP
{% elif port.portnum == 70 %}
TRACEROUTE_APP
{% elif port.portnum == 71 %}
NEIGHBORINFO_APP
{% elif port.portnum == 73 %}
MAP_REPORT_APP
{% elif port.portnum == 0 %}
UNKNOWN_APP
{% else %}
{{ port.portnum }}
{% endif %}
</td>
<td>{{ port.packet_count }}</td>
</tr>
{% else %}
<tr>
<td colspan="2">No traffic data available for this node.</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<footer class="footer">
<a href="/top">Back to Top Nodes</a>
</footer>
{% endblock %}

View File

@@ -1,132 +0,0 @@
<div id="details_map"></div>
{% for seen in packets_seen %}
<div class="card mt-2">
<div class="card-header">
{{seen.node.long_name}}(
<a hx-target="#node" href="/node_search?q={{seen.node_id|node_id_to_hex}}">
{{seen.node_id|node_id_to_hex}}
</a>
)
</div>
<div class="card-body">
<div class="card-text text-start">
<dl>
<dt>Import Time</dt>
<dd>{{seen.import_time.strftime('%-I:%M:%S %p - %m-%d-%Y')}}</dd>
<dt>rx_time</dt>
<dd>{{seen.rx_time|format_timestamp}}</dd>
<dt>hop_limit</dt>
<dd>{{seen.hop_limit}}</dd>
<dt>hop_start</dt>
<dd>{{seen.hop_start}}</dd>
<dt>channel</dt>
<dd>{{seen.channel}}</dd>
<dt>rx_snr</dt>
<dd>{{seen.rx_snr}}</dd>
<dt>rx_rssi</dt>
<dd>{{seen.rx_rssi}}</dd>
<dt>topic</dt>
<dd>{{seen.topic}}</dd>
</dl>
</div>
</div>
</div>
{% endfor %}
{% if map_center %}
<script>
var details_map = L.map('details_map').setView({{ map_center | tojson }}, 8);
var markers = L.featureGroup();
markers.addTo(details_map);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 15,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(details_map);
function getDistanceInMiles(latlng1, latlng2) {
var meters = latlng1.distanceTo(latlng2);
return meters * 0.000621371;
}
{% if from_node_cord %}
var fromNodeLatLng = L.latLng({{ from_node_cord | tojson }});
var fromNode = L.circleMarker(fromNodeLatLng, {
radius: 10,
color: 'red',
weight: 1,
fillColor: 'red',
fillOpacity: .4
}).addTo(markers);
fromNode.bindPopup(`
Sent by: <b>{{node.long_name}}</b><br/>
<b>Short:</b> {{node.short_name}}<br/>
<b>Channel:</b> {{node.channel}}<br/>
<b>Hardware:</b> {{node.hw_model}}<br/>
<b>Role:</b> {{node.role}}<br/>
<b>Firmware:</b> {{node.firmware}}<br/>
<b>Coordinates:</b> [{{node.last_lat}}, {{node.last_long}}]
`, { permanent: false, direction: 'top', opacity: 0.9 });
{% endif %}
{% for u in uplinked_nodes %}
var uplinkNodeLatLng = L.latLng([{{ u.lat }}, {{ u.long }}]);
{% if from_node_cord %}
var distanceMiles = getDistanceInMiles(fromNodeLatLng, uplinkNodeLatLng).toFixed(1);
{% endif %}
var node = L.marker(uplinkNodeLatLng, {
icon: L.divIcon({
className: 'text-icon',
html: `<div style="font-size: 12px; color: white; font-weight: bold; display: flex; justify-content: center; align-items: center; height: 16px; width: 16px; border-radius: 50%; background-color: blue; border: 1px solid blue;">{{u.hops}}</div>`,
iconSize: [16, 16],
iconAnchor: [8, 8]
})
}).addTo(markers);
node.setZIndexOffset({{u.hops}}*-1);
node.bindPopup(`
Heard by: <b>{{u.long_name}}</b><br>
<b>{{ u.short_name }}</b><br/>
<b>Hops:</b> {{ u.hops }}<br/>
<b>SNR:</b> {{ u.snr }}<br/>
<b>RSSI:</b> {{ u.rssi }}<br/>
{% if from_node_cord %}
<b>Distance:</b> ${distanceMiles} miles <br/>
{% endif %}
<b>Coordinates:</b> [{{u.lat}}, {{u.long}}]
`, { permanent: false, direction: 'top', opacity: 0.9 });
{% endfor %}
if (markers.getLayers().length > 0) {
details_map.fitBounds(markers.getBounds().pad(0.1), { animate: true });
}
var legend = L.control({ position: 'bottomleft' });
legend.onAdd = function(map) {
var div = L.DomUtil.create('div', 'info legend');
div.style.background = 'white';
div.style.padding = '8px';
div.style.border = '1px solid black';
div.style.borderRadius = '5px';
div.style.boxShadow = '0 0 5px rgba(0,0,0,0.3)';
div.style.color = 'black';
div.style.textAlign = 'left';
div.innerHTML = `
<b>Legend</b><br>
<svg width="20" height="20">
<circle cx="8" cy="8" r="6" fill="blue" stroke="blue" stroke-width="1" fill-opacity="0.9"/>
</svg> Receiving Node (Number is hop count)<br>
<svg width="20" height="20">
<circle cx="10" cy="10" r="8" fill="red" stroke="red" stroke-width="1" fill-opacity="0.4"/>
</svg> Sending Node<br>
`;
return div;
};
legend.addTo(details_map);
</script>
{% endif %}

View File

@@ -1,24 +0,0 @@
{% extends "base.html" %}
{% block css %}
/* Set the maximum width of the page to 900px */
.container {
max-width: 900px;
margin: 0 auto; /* Center the content horizontally */
}
{% endblock %}
{% block body %}
<div class="container">
<div class="row">
<div>
{% include 'packet.html' %}
</div>
<div
id="packet_details"
hx-get="/packet_details/{{packet.id}}"
hx-trigger="load"
>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,7 +0,0 @@
<div class="col" id="packet_list">
{% for packet in packets %}
{% include 'packet.html' %}
{% else %}
No packets found.
{% endfor %}
</div>

View File

@@ -1,21 +0,0 @@
{% extends "base.html" %}
{% block body %}
{% include "search_form.html" %}
<ul>
{% for node in nodes %}
<li>
<a href="/packet_list/{{node.node_id}}?{{query_string}}">
{{node.node_id | node_id_to_hex}}
{% if node.long_name %}
{{node.short_name}} &mdash; {{node.long_name}}
{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -1,44 +0,0 @@
<form
class="container p-2 sticky-top mx-auto"
id="search_form"
action="/node_search"
>
<div class="row">
<input
class="col m-2"
id="q"
type="text"
name="q"
placeholder="Node id"
autocomplete="off"
list="node_options"
value="{{raw_node_id}}"
hx-trigger="input delay:100ms"
hx-get="/node_match"
hx-target="#node_options"
/>
{% include "datalist.html" %}
{% set options = {
1: "Text Message",
3: "Position",
4: "Node Info",
67: "Telemetry",
70: "Traceroute",
71: "Neighbor Info",
}
%}
<select name="portnum" class="col-2 m-2">
<option
value = ""
{% if portnum not in options %}selected{% endif %}
>All</option>
{% for value, name in options.items() %}
<option
value="{{value}}"
{% if value == portnum %}selected{% endif %}
>{{ name }}</option>
{% endfor %}
</select>
<input type="submit" value="Go to Node" class="col-2 m-2" />
</div>
</form>

View File

@@ -93,26 +93,31 @@
{% block body %}
<div class="main-container">
<h2 class="main-header" data-translate-lang="mesh_stats_summary">Mesh Statistics - Summary (all available in Database)</h2>
<h2 class="main-header" data-translate-lang="mesh_stats_summary">
Mesh Statistics - Summary (all available in Database)
</h2>
<!-- Summary cards now fully driven by API + JS -->
<div class="summary-container" style="display:flex; justify-content:space-between; gap:10px; margin-bottom:20px;">
<div class="summary-card" style="flex:1;">
<p data-translate-lang="total_nodes">Total Nodes</p>
<div class="summary-count">{{ "{:,}".format(total_nodes) }}</div>
<div class="summary-count" id="summary_nodes">0</div>
</div>
<div class="summary-card" style="flex:1;">
<p data-translate-lang="total_packets">Total Packets</p>
<div class="summary-count">{{ "{:,}".format(total_packets) }}</div>
<div class="summary-count" id="summary_packets">0</div>
</div>
<div class="summary-card" style="flex:1;">
<p data-translate-lang="total_packets_seen">Total Packets Seen</p>
<div class="summary-count">{{ "{:,}".format(total_packets_seen) }}</div>
<div class="summary-count" id="summary_seen">0</div>
</div>
</div>
<!-- Daily Charts -->
<div class="card-section">
<p class="section-header" data-translate-lang="packets_per_day_all">Packets per Day - All Ports (Last 14 Days)</p>
<p class="section-header" data-translate-lang="packets_per_day_all">
Packets per Day - All Ports (Last 14 Days)
</p>
<div id="total_daily_all" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_daily_all" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_all" data-translate-lang="export_csv">Export CSV</button>
@@ -121,7 +126,9 @@
<!-- Packet Types Pie Chart with Channel Selector -->
<div class="card-section">
<p class="section-header" data-translate-lang="packet_types_last_24h">Packet Types - Last 24 Hours</p>
<p class="section-header" data-translate-lang="packet_types_last_24h">
Packet Types - Last 24 Hours
</p>
<select id="channelSelect">
<option value="" data-translate-lang="all_channels">All Channels</option>
</select>
@@ -131,7 +138,9 @@
</div>
<div class="card-section">
<p class="section-header" data-translate-lang="packets_per_day_text">Packets per Day - Text Messages (Port 1, Last 14 Days)</p>
<p class="section-header" data-translate-lang="packets_per_day_text">
Packets per Day - Text Messages (Port 1, Last 14 Days)
</p>
<div id="total_daily_portnum_1" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_daily_portnum_1" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_portnum_1" data-translate-lang="export_csv">Export CSV</button>
@@ -140,7 +149,9 @@
<!-- Hourly Charts -->
<div class="card-section">
<p class="section-header" data-translate-lang="packets_per_hour_all">Packets per Hour - All Ports</p>
<p class="section-header" data-translate-lang="packets_per_hour_all">
Packets per Hour - All Ports
</p>
<div id="total_hourly_all" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_hourly_all" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_hourly_all" data-translate-lang="export_csv">Export CSV</button>
@@ -148,7 +159,9 @@
</div>
<div class="card-section">
<p class="section-header" data-translate-lang="packets_per_hour_text">Packets per Hour - Text Messages (Port 1)</p>
<p class="section-header" data-translate-lang="packets_per_hour_text">
Packets per Hour - Text Messages (Port 1)
</p>
<div id="total_portnum_1" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_portnum_1" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_portnum_1" data-translate-lang="export_csv">Export CSV</button>
@@ -214,17 +227,123 @@ async function fetchStats(period_type,length,portnum=null,channel=null){
}catch{return [];}
}
async function fetchNodes(){ try{ const res=await fetch("/api/nodes"); const json=await res.json(); return json.nodes||[];}catch{return [];} }
async function fetchChannels(){ try{ const res = await fetch("/api/channels"); const json = await res.json(); return json.channels || [];}catch{return [];} }
async function fetchNodes(){
try{
const res=await fetch("/api/nodes");
const json=await res.json();
return json.nodes||[];
}catch{
return [];
}
}
function processCountField(nodes,field){ const counts={}; nodes.forEach(n=>{ const key=n[field]||"Unknown"; counts[key]=(counts[key]||0)+1; }); return Object.entries(counts).map(([name,value])=>({name,value})); }
function updateTotalCount(domId,data){ const el=document.getElementById(domId); if(!el||!data.length) return; const total=data.reduce((acc,d)=>acc+(d.count??d.packet_count??0),0); el.textContent=`Total: ${total.toLocaleString()}`; }
function prepareTopN(data,n=20){ data.sort((a,b)=>b.value-a.value); let top=data.slice(0,n); if(data.length>n){ const otherValue=data.slice(n).reduce((sum,item)=>sum+item.value,0); top.push({name:"Other", value:otherValue}); } return top; }
async function fetchChannels(){
try{
const res = await fetch("/api/channels");
const json = await res.json();
return json.channels || [];
}catch{
return [];
}
}
function processCountField(nodes,field){
const counts={};
nodes.forEach(n=>{
const key=n[field]||"Unknown";
counts[key]=(counts[key]||0)+1;
});
return Object.entries(counts).map(([name,value])=>({name,value}));
}
function updateTotalCount(domId,data){
const el=document.getElementById(domId);
if(!el||!data.length) return;
const total=data.reduce((acc,d)=>acc+(d.count??d.packet_count??0),0);
el.textContent=`Total: ${total.toLocaleString()}`;
}
function prepareTopN(data,n=20){
data.sort((a,b)=>b.value-a.value);
let top=data.slice(0,n);
if(data.length>n){
const otherValue=data.slice(n).reduce((sum,item)=>sum+item.value,0);
top.push({name:"Other", value:otherValue});
}
return top;
}
// --- Chart Rendering ---
function renderChart(domId,data,type,color){ const el=document.getElementById(domId); if(!el) return; const chart=echarts.init(el); const periods=data.map(d=>(d.period??d.period===0)?d.period.toString():''); const counts=data.map(d=>d.count??d.packet_count??0); chart.setOption({backgroundColor:'#272b2f', tooltip:{trigger:'axis'}, grid:{left:'6%', right:'6%', bottom:'18%'}, xAxis:{type:'category', data:periods, axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{rotate:45,color:'#ccc'}}, yAxis:{type:'value', axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{color:'#ccc'}}, series:[{data:counts,type:type,smooth:type==='line',itemStyle:{color:color}, areaStyle:type==='line'?{}:undefined}]}); return chart; }
function renderChart(domId,data,type,color){
const el=document.getElementById(domId);
if(!el) return;
const chart=echarts.init(el);
const periods=data.map(d=>(d.period??d.period===0)?d.period.toString():'');
const counts=data.map(d=>d.count??d.packet_count??0);
chart.setOption({
backgroundColor:'#272b2f',
tooltip:{trigger:'axis'},
grid:{left:'6%', right:'6%', bottom:'18%'},
xAxis:{
type:'category',
data:periods,
axisLine:{lineStyle:{color:'#aaa'}},
axisLabel:{rotate:45,color:'#ccc'}
},
yAxis:{
type:'value',
axisLine:{lineStyle:{color:'#aaa'}},
axisLabel:{color:'#ccc'}
},
series:[{
data:counts,
type:type,
smooth:type==='line',
itemStyle:{color:color},
areaStyle:type==='line'?{}:undefined
}]
});
return chart;
}
function renderPieChart(elId,data,name){ const el=document.getElementById(elId); if(!el) return; const chart=echarts.init(el); const top20=prepareTopN(data,20); chart.setOption({backgroundColor:"#272b2f", tooltip:{trigger:"item", formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`}, series:[{name:name, type:"pie", radius:["30%","70%"], center:["50%","50%"], avoidLabelOverlap:true, itemStyle:{borderRadius:6,borderColor:"#272b2f",borderWidth:2}, label:{show:true,formatter:"{b}\n{d}%", color:"#ccc", fontSize:10}, labelLine:{show:true,length:10,length2:6}, data:top20}]}); return chart; }
function renderPieChart(elId,data,name){
const el=document.getElementById(elId);
if(!el) return;
const chart=echarts.init(el);
const top20=prepareTopN(data,20);
chart.setOption({
backgroundColor:"#272b2f",
tooltip:{
trigger:"item",
formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`
},
series:[{
name:name,
type:"pie",
radius:["30%","70%"],
center:["50%","50%"],
avoidLabelOverlap:true,
itemStyle:{
borderRadius:6,
borderColor:"#272b2f",
borderWidth:2
},
label:{
show:true,
formatter:"{b}\n{d}%",
color:"#ccc",
fontSize:10
},
labelLine:{
show:true,
length:10,
length2:6
},
data:top20
}]
});
return chart;
}
// --- Packet Type Pie Chart ---
async function fetchPacketTypeBreakdown(channel=null) {
@@ -234,8 +353,10 @@ async function fetchPacketTypeBreakdown(channel=null) {
const total = (data || []).reduce((sum,d)=>sum+(d.count??d.packet_count??0),0);
return {portnum: pn, count: total};
});
const allData = await fetchStats('hour',24,null,channel);
const totalAll = allData.reduce((sum,d)=>sum+(d.count??d.packet_count??0),0);
const results = await Promise.all(requests);
const trackedTotal = results.reduce((sum,d)=>sum+d.count,0);
const other = Math.max(totalAll - trackedTotal,0);
@@ -250,40 +371,102 @@ let chartHwModel, chartRole, chartChannel;
let chartPacketTypes;
async function init(){
// Channel selector
const channels = await fetchChannels();
const select = document.getElementById("channelSelect");
channels.forEach(ch=>{ const opt = document.createElement("option"); opt.value = ch; opt.textContent = ch; select.appendChild(opt); });
channels.forEach(ch=>{
const opt = document.createElement("option");
opt.value = ch;
opt.textContent = ch;
select.appendChild(opt);
});
// Daily all ports
const dailyAllData=await fetchStats('day',14);
updateTotalCount('total_daily_all',dailyAllData);
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a');
// Daily port 1
const dailyPort1Data=await fetchStats('day',14,1);
updateTotalCount('total_daily_portnum_1',dailyPort1Data);
chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722');
// Hourly all ports
const hourlyAllData=await fetchStats('hour',24);
updateTotalCount('total_hourly_all',hourlyAllData);
chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6');
// Hourly per port
const portnums=[1,3,4,67,70,71];
const colors=['#ff5722','#2196f3','#9c27b0','#ffeb3b','#795548','#4caf50'];
const domIds=['chart_portnum_1','chart_portnum_3','chart_portnum_4','chart_portnum_67','chart_portnum_70','chart_portnum_71'];
const totalIds=['total_portnum_1','total_portnum_3','total_portnum_4','total_portnum_67','total_portnum_70','total_portnum_71'];
const allData=await Promise.all(portnums.map(pn=>fetchStats('hour',24,pn)));
for(let i=0;i<portnums.length;i++){ updateTotalCount(totalIds[i],allData[i]); window['chartPortnum'+portnums[i]]=renderChart(domIds[i],allData[i],'bar',colors[i]); }
const allData=await Promise.all(portnums.map(pn=>fetchStats('hour',24,pn)));
for(let i=0;i<portnums.length;i++){
updateTotalCount(totalIds[i],allData[i]);
window['chartPortnum'+portnums[i]]=renderChart(domIds[i],allData[i],'bar',colors[i]);
}
// Nodes for breakdown + summary node count
const nodes=await fetchNodes();
chartHwModel=renderPieChart("chart_hw_model",processCountField(nodes,"hw_model"),"Hardware");
chartRole=renderPieChart("chart_role",processCountField(nodes,"role"),"Role");
chartChannel=renderPieChart("chart_channel",processCountField(nodes,"channel"),"Channel");
const summaryNodesEl = document.getElementById("summary_nodes");
if (summaryNodesEl) {
summaryNodesEl.textContent = nodes.length.toLocaleString();
}
// Packet types pie
const packetTypesData = await fetchPacketTypeBreakdown();
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({ name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`), value: d.count }));
const formatted = packetTypesData
.filter(d=>d.count>0)
.map(d=>({
name: d.portnum==="other"
? "Other"
: (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`),
value: d.count
}));
chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
// Total packet + total seen from /api/stats/count
try {
const countsRes = await fetch("/api/stats/count");
if (countsRes.ok) {
const countsJson = await countsRes.json();
const elPackets = document.getElementById("summary_packets");
const elSeen = document.getElementById("summary_seen");
if (elPackets) {
elPackets.textContent = (countsJson.total_packets || 0).toLocaleString();
}
if (elSeen) {
elSeen.textContent = (countsJson.total_seen || 0).toLocaleString();
}
}
} catch (err) {
console.error("Failed to load /api/stats/count:", err);
}
}
window.addEventListener('resize',()=>{ [chartHourlyAll,chartPortnum1,chartPortnum3,chartPortnum4,chartPortnum67,chartPortnum70,chartPortnum71, chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel,chartPacketTypes].forEach(c=>c?.resize()); });
window.addEventListener('resize',()=>{
[
chartHourlyAll,
chartPortnum1,
chartPortnum3,
chartPortnum4,
chartPortnum67,
chartPortnum70,
chartPortnum71,
chartDailyAll,
chartDailyPortnum1,
chartHwModel,
chartRole,
chartChannel,
chartPacketTypes
].forEach(c=>c?.resize());
});
const modal=document.getElementById("chartModal");
const modalChartEl=document.getElementById("modalChart");
@@ -345,11 +528,19 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
document.getElementById("channelSelect").addEventListener("change", async (e)=>{
const channel = e.target.value;
const packetTypesData = await fetchPacketTypeBreakdown(channel);
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({ name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`), value: d.count }));
const formatted = packetTypesData
.filter(d=>d.count>0)
.map(d=>({
name: d.portnum==="other"
? "Other"
: (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`),
value: d.count
}));
chartPacketTypes?.dispose();
chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
});
// Kick everything off
init();
// --- Load config and translations ---
@@ -383,6 +574,5 @@ async function loadConfigAndTranslations() {
// Call after init
loadConfigAndTranslations();
</script>
{% endblock %}

View File

@@ -1,94 +0,0 @@
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>
{% endblock %}
{% block body %}
<div id="mynetwork" style="width: 100%; height: 800px;"></div>
<script type="text/javascript">
const chart = echarts.init(document.getElementById('mynetwork'));
const rawNodes = {{ chart_data['nodes'] | tojson }};
const rawEdges = {{ chart_data['edges'] | tojson }};
// Build DAG layout
const layers = {};
const nodeDepth = {};
// Organize nodes into layers by hop count
for (const edge of rawEdges) {
const { source, target } = edge;
if (!(source in nodeDepth)) nodeDepth[source] = 0;
const nextDepth = nodeDepth[source] + 1;
nodeDepth[target] = Math.max(nodeDepth[target] || 0, nextDepth);
}
for (const node of rawNodes) {
const depth = nodeDepth[node.name] || 0;
if (!(depth in layers)) layers[depth] = [];
layers[depth].push(node);
}
// Position nodes manually
const chartNodes = [];
const layerKeys = Object.keys(layers).sort((a, b) => +a - +b);
const verticalSpacing = 100;
const horizontalSpacing = 180;
layerKeys.forEach((depth, layerIndex) => {
const layer = layers[depth];
const y = layerIndex * verticalSpacing;
const xStart = -(layer.length - 1) * horizontalSpacing / 2;
layer.forEach((node, i) => {
chartNodes.push({
...node,
x: xStart + i * horizontalSpacing,
y: y,
itemStyle: {
color: '#dddddd',
borderColor: '#222',
borderWidth: 2,
},
label: {
show: true,
position: 'inside',
color: '#000',
fontSize: 12,
formatter: node.short_name || node.name,
},
});
});
});
const chartEdges = rawEdges.map(edge => ({
source: edge.source,
target: edge.target,
lineStyle: {
color: edge.originalColor || '#ccc',
width: 2,
type: 'solid',
},
}));
const option = {
backgroundColor: '#fff',
tooltip: {},
animation: false,
series: [{
type: 'graph',
layout: 'none',
coordinateSystem: null,
data: chartNodes,
links: chartEdges,
roam: true,
edgeSymbol: ['none', 'arrow'],
edgeSymbolSize: [0, 10],
lineStyle: {
curveness: 0,
},
}],
};
chart.setOption(option);
</script>
{% endblock %}

View File

@@ -269,6 +269,14 @@ async def top(request):
content_type="text/html",
)
@routes.get("/stats")
async def stats(request):
template = env.get_template("stats.html")
return web.Response(
text=template.render(),
content_type="text/html",
)
# Keep !!
@routes.get("/graph/traceroute/{packet_id}")
@@ -377,7 +385,7 @@ async def graph_traceroute(request):
content_type="image/svg+xml",
)
'''
@routes.get("/stats")
async def stats(request):
try:
@@ -399,86 +407,7 @@ async def stats(request):
status=500,
content_type="text/plain",
)
'''
@routes.get("/top")
async def top(request):
import time
try:
# Check if performance metrics should be displayed
show_perf = request.query.get("perf", "").lower() in ("true", "1", "yes")
# Start overall timing
start_time = time.perf_counter()
timing_data = None
node_id = request.query.get("node_id") # Get node_id from the URL query parameters
if node_id:
# If node_id is provided, fetch traffic data for the specific node
db_start = time.perf_counter()
node_traffic = await store.get_node_traffic(int(node_id))
db_time = time.perf_counter() - db_start
template = env.get_template("node_traffic.html")
html_content = template.render(
traffic=node_traffic, node_id=node_id, site_config=CONFIG
)
else:
# Otherwise, fetch top traffic nodes as usual
db_start = time.perf_counter()
top_nodes = await store.get_top_traffic_nodes()
db_time = time.perf_counter() - db_start
# Data processing timing
process_start = time.perf_counter()
# Count records processed
total_packets = sum(node.get('total_packets_sent', 0) for node in top_nodes)
total_seen = sum(node.get('total_times_seen', 0) for node in top_nodes)
process_time = time.perf_counter() - process_start
# Calculate total time
total_time = time.perf_counter() - start_time
# Only include timing_data if perf parameter is set
if show_perf:
timing_data = {
'db_query_ms': f"{db_time * 1000:.2f}",
'processing_ms': f"{process_time * 1000:.2f}",
'total_ms': f"{total_time * 1000:.2f}",
'node_count': len(top_nodes),
'total_packets': total_packets,
'total_seen': total_seen,
}
template = env.get_template("top.html")
html_content = template.render(
nodes=top_nodes,
timing_data=timing_data,
site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
)
return web.Response(
text=html_content,
content_type="text/html",
)
except Exception as e:
logger.error(f"Error in /top: {e}")
template = env.get_template("error.html")
rendered = template.render(
error_message="An error occurred in /top",
error_details=traceback.format_exc(),
site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
)
return web.Response(text=rendered, status=500, content_type="text/html")
'''
async def run_server():
# Wait for database migrations to complete before starting web server