mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
1086 lines
35 KiB
HTML
1086 lines
35 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block css %}
|
|
{{ super() }}
|
|
|
|
/* --- Map --- */
|
|
#map {
|
|
width: 100%;
|
|
height: 400px;
|
|
margin-bottom: 20px;
|
|
border-radius: 8px;
|
|
display: block;
|
|
}
|
|
.leaflet-container {
|
|
background: #1a1a1a;
|
|
z-index: 1;
|
|
}
|
|
|
|
/* --- Node Info --- */
|
|
.node-info {
|
|
background-color: #1f2226;
|
|
border: 1px solid #3a3f44;
|
|
color: #ddd;
|
|
font-size: 0.88rem;
|
|
padding: 12px 14px;
|
|
margin-bottom: 14px;
|
|
border-radius: 8px;
|
|
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
|
grid-column-gap: 14px;
|
|
grid-row-gap: 6px;
|
|
}
|
|
|
|
.node-info div { padding: 2px 0; }
|
|
.node-info strong {
|
|
color: #9fd4ff;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* --- Charts --- */
|
|
.chart-container {
|
|
width: 100%;
|
|
height: 320px;
|
|
margin-bottom: 25px;
|
|
border: 1px solid #3a3f44;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
background-color: #16191d;
|
|
}
|
|
.chart-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
background: #1f2226;
|
|
padding: 6px 12px;
|
|
font-weight: bold;
|
|
border-bottom: 1px solid #333;
|
|
font-size: 1rem;
|
|
}
|
|
.chart-actions button {
|
|
background: rgba(255,255,255,0.05);
|
|
border: 1px solid #555;
|
|
border-radius: 4px;
|
|
color: #ccc;
|
|
font-size: 0.8rem;
|
|
padding: 2px 6px;
|
|
cursor: pointer;
|
|
}
|
|
.chart-actions button:hover {
|
|
color: #fff;
|
|
background: rgba(255,255,255,0.15);
|
|
border-color: #888;
|
|
}
|
|
|
|
/* --- Packet Table --- */
|
|
.packet-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.85rem;
|
|
color: #e4e9ee;
|
|
}
|
|
.packet-table th, .packet-table td {
|
|
border: 1px solid #3a3f44;
|
|
padding: 6px 10px;
|
|
text-align: left;
|
|
}
|
|
.packet-table th {
|
|
background-color: #1f2226;
|
|
font-weight: bold;
|
|
}
|
|
.packet-table tr:nth-of-type(odd) { background-color: #272b2f; }
|
|
.packet-table tr:nth-of-type(even) { background-color: #212529; }
|
|
|
|
.port-tag {
|
|
padding: 2px 6px;
|
|
border-radius: 6px;
|
|
font-size: 0.75rem;
|
|
color: #fff;
|
|
}
|
|
|
|
.to-mqtt { font-style: italic; color: #aaa; }
|
|
|
|
.payload-row { display: none; background-color: #1b1e22; }
|
|
.payload-cell {
|
|
padding: 8px 12px;
|
|
font-family: monospace;
|
|
white-space: pre-wrap;
|
|
color: #b0bec5;
|
|
}
|
|
.packet-table tr.expanded + .payload-row { display: table-row; }
|
|
.toggle-btn { cursor: pointer; color: #aaa; margin-right: 6px; }
|
|
.toggle-btn:hover { color: #fff; }
|
|
|
|
/* --- Chart Modal --- */
|
|
#chartModal {
|
|
display:none; position:fixed; top:0; left:0; width:100%; height:100%;
|
|
background:rgba(0,0,0,0.9); z-index:9999;
|
|
align-items:center; justify-content:center;
|
|
}
|
|
#chartModal > div {
|
|
background:#1b1e22; border-radius:8px;
|
|
width:90%; height:85%; padding:10px;
|
|
}
|
|
|
|
/* Inline link */
|
|
.inline-link {
|
|
margin-left: 6px;
|
|
font-weight: bold;
|
|
text-decoration: none;
|
|
color: #9fd4ff;
|
|
}
|
|
.inline-link:hover { color: #c7e6ff; }
|
|
{% endblock %}
|
|
|
|
{% block body %}
|
|
<div class="container">
|
|
|
|
<h5 class="mb-3">
|
|
📡 <span data-translate-lang="specifications">Specifications:</span><strong>:</strong>
|
|
<span id="nodeLabel"></span>
|
|
</h5>
|
|
|
|
<!-- Node Info -->
|
|
<div id="node-info" class="node-info">
|
|
<div><strong data-translate-lang="node_id">Node ID</strong><strong>:</strong><span id="info-node-id">—</span></div>
|
|
<div><strong data-translate-lang="long_name">Long Name</strong><strong>:</strong> <span id="info-long-name">—</span></div>
|
|
<div><strong data-translate-lang="short_name">Short Name</strong><strong>:</strong> <span id="info-short-name">—</span></div>
|
|
|
|
<div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>:</strong> <span id="info-hw-model">—</span></div>
|
|
<div><strong data-translate-lang="firmware">Firmware</strong><strong>:</strong> <span id="info-firmware">—</span></div>
|
|
<div><strong data-translate-lang="role">Role</strong><strong>:</strong> <span id="info-role">—</span></div>
|
|
|
|
<div><strong data-translate-lang="channel">Channel</strong><strong>:</strong> <span id="info-channel">—</span></div>
|
|
<div><strong data-translate-lang="latitude">Latitude</strong><strong>:</strong> <span id="info-lat">—</span></div>
|
|
<div><strong data-translate-lang="longitude">Longitude</strong><strong>:</strong> <span id="info-lon">—</span></div>
|
|
|
|
<div><strong data-translate-lang="last_update">Last Update</strong><strong>:</strong> <span id="info-last-update">—</span></div>
|
|
</div>
|
|
|
|
<!-- Map -->
|
|
<div id="map"></div>
|
|
|
|
<!-- Battery Chart -->
|
|
<div id="battery_voltage_container" class="chart-container">
|
|
<div class="chart-header">
|
|
🔋 <span data-translate-lang="battery_voltage">Battery & Voltage</span>
|
|
<div class="chart-actions">
|
|
<button onclick="expandChart('battery_voltage')" data-translate-lang="expand">Expand</button>
|
|
<button onclick="exportCSV('battery_voltage')" data-translate-lang="export_csv">Export CSV</button>
|
|
</div>
|
|
</div>
|
|
<div id="chart_battery_voltage" style="height:260px;"></div>
|
|
</div>
|
|
|
|
<!-- Air/Channel -->
|
|
<div id="air_channel_container" class="chart-container">
|
|
<div class="chart-header">
|
|
📶 <span data-translate-lang="air_channel">Air & Channel Utilization</span>
|
|
<div class="chart-actions">
|
|
<button onclick="expandChart('air_channel')" data-translate-lang="expand">Expand</button>
|
|
<button onclick="exportCSV('air_channel')" data-translate-lang="export_csv">Export CSV</button>
|
|
</div>
|
|
</div>
|
|
<div id="chart_air_channel" style="height:260px;"></div>
|
|
</div>
|
|
|
|
<!-- Env Metrics -->
|
|
<div id="env_chart_container" class="chart-container" style="display:none;">
|
|
<div class="chart-header">
|
|
🌡️ <span data-translate-lang="environment">Environment Metrics</span>
|
|
<div class="chart-actions">
|
|
<button onclick="expandChart('environment')" data-translate-lang="expand">Expand</button>
|
|
<button onclick="exportCSV('environment')" data-translate-lang="export_csv">Export CSV</button>
|
|
</div>
|
|
</div>
|
|
<div id="chart_environment" style="height:260px;"></div>
|
|
</div>
|
|
|
|
<!-- Neighbor chart -->
|
|
<div id="neighbor_chart_container" class="chart-container" style="display:none;">
|
|
<div class="chart-header">
|
|
📡 <span data-translate-lang="neighbors_chart">Neighbors (Signal-to-Noise)</span>
|
|
<div class="chart-actions">
|
|
<button onclick="expandChart('neighbors')" data-translate-lang="expand">Expand</button>
|
|
<button onclick="exportCSV('neighbors')" data-translate-lang="export_csv">Export CSV</button>
|
|
</div>
|
|
</div>
|
|
<div id="chart_neighbors" style="height:260px;"></div>
|
|
</div>
|
|
|
|
<!-- Packets -->
|
|
<table class="packet-table">
|
|
<thead>
|
|
<tr>
|
|
<th data-translate-lang="time">Time</th>
|
|
<th data-translate-lang="packet_id">Packet ID</th>
|
|
<th data-translate-lang="from">From</th>
|
|
<th data-translate-lang="to">To</th>
|
|
<th data-translate-lang="port">Port</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="packet_list"></tbody>
|
|
</table>
|
|
|
|
</div>
|
|
|
|
<!-- Modal -->
|
|
<div id="chartModal">
|
|
<div>
|
|
<div style="text-align:right;">
|
|
<button onclick="closeModal()" style="background:none;border:none;color:#ccc;">✖</button>
|
|
</div>
|
|
<div id="modalChart" style="width:100%; height:90%;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
|
|
|
|
<script>
|
|
/* ======================================================
|
|
NODE PAGE TRANSLATION (isolated from base)
|
|
====================================================== */
|
|
|
|
let nodeTranslations = {};
|
|
|
|
async function loadTranslationsNode() {
|
|
try {
|
|
const cfg = await window._siteConfigPromise;
|
|
const lang = cfg?.site?.language || "en";
|
|
|
|
const res = await fetch(`/api/lang?lang=${lang}§ion=node`);
|
|
nodeTranslations = await res.json();
|
|
|
|
applyTranslationsNode(nodeTranslations);
|
|
|
|
// Broadcast label can be set here since translations are now loaded
|
|
nodeMap[4294967295] = nodeTranslations.all_broadcast || "All";
|
|
} catch (err) {
|
|
console.error("Node translation load failed", err);
|
|
}
|
|
}
|
|
|
|
function applyTranslationsNode(dict, root=document) {
|
|
root.querySelectorAll("[data-translate-lang]").forEach(el => {
|
|
const key = el.dataset.translateLang;
|
|
|
|
if (dict[key]) {
|
|
if (el.tagName === "INPUT" && el.placeholder !== undefined) {
|
|
el.placeholder = dict[key];
|
|
} else {
|
|
el.textContent = dict[key];
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ======================================================
|
|
POPUP + TIME HELPERS
|
|
====================================================== */
|
|
|
|
function makeNodePopup(node) {
|
|
return `
|
|
<div style="font-size:0.9em">
|
|
<a href="/node/${node.node_id}" style="color:inherit; text-decoration:underline;">
|
|
<b>${node.long_name || node.short_name || node.node_id}</b>
|
|
</a>
|
|
${node.short_name ? ` (${node.short_name})` : ""}<br>
|
|
|
|
<b><span data-translate-lang="node_id">
|
|
${nodeTranslations.node_id || "Node ID"}:
|
|
</span></b> ${node.node_id}<br>
|
|
|
|
<b><span data-translate-lang="hw_model">
|
|
${nodeTranslations.hw_model || "HW Model"}:
|
|
</span></b> ${node.hw_model ?? "—"}<br>
|
|
|
|
<b><span data-translate-lang="channel">
|
|
${nodeTranslations.channel || "Channel"}:
|
|
</span></b> ${node.channel ?? "—"}<br>
|
|
|
|
<b><span data-translate-lang="role">
|
|
${nodeTranslations.role || "Role"}:
|
|
</span></b> ${node.role ?? "—"}<br>
|
|
|
|
<b><span data-translate-lang="firmware">
|
|
${nodeTranslations.firmware || "Firmware"}:
|
|
</span></b> ${node.firmware ?? "—"}<br>
|
|
|
|
<b><span data-translate-lang="last_update">
|
|
${nodeTranslations.last_update || "Last Update"}:
|
|
</span></b> ${formatLastSeen(node.last_seen_us)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function formatLastSeen(us) {
|
|
if (!us) return "—";
|
|
const d = new Date(us / 1000);
|
|
return d.toLocaleString([], {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit"
|
|
});
|
|
}
|
|
|
|
function formatLocalTime(us){
|
|
return new Date(us / 1000).toLocaleString([], {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit"
|
|
});
|
|
}
|
|
|
|
/* ======================================================
|
|
GLOBALS
|
|
====================================================== */
|
|
|
|
let nodeMap = {}; // node_id -> label
|
|
let nodePositions = {}; // node_id -> [lat, lon]
|
|
let nodeCache = {}; // node_id -> full node object
|
|
let currentNode = null;
|
|
|
|
let map, markers = {};
|
|
let chartData = {}, neighborData = { ids:[], names:[], snrs:[] };
|
|
|
|
let fromNodeId = new URLSearchParams(window.location.search).get("from_node_id");
|
|
if (!fromNodeId) {
|
|
const parts = window.location.pathname.split("/");
|
|
fromNodeId = parts[parts.length - 1];
|
|
}
|
|
|
|
/* ======================================================
|
|
API HELPERS (USE /api/nodes?node_id=...)
|
|
====================================================== */
|
|
|
|
async function fetchNodeFromApi(nodeId) {
|
|
if (nodeCache[nodeId]) return nodeCache[nodeId];
|
|
|
|
try {
|
|
const res = await fetch(`/api/nodes?node_id=${encodeURIComponent(nodeId)}`);
|
|
if (!res.ok) {
|
|
console.error("Failed /api/nodes?node_id=", nodeId, res.status);
|
|
return null;
|
|
}
|
|
const data = await res.json();
|
|
const node = (data.nodes || [])[0];
|
|
if (!node) return null;
|
|
|
|
nodeCache[nodeId] = node;
|
|
|
|
const label = node.long_name || node.short_name || node.id || node.node_id;
|
|
nodeMap[node.node_id] = label;
|
|
|
|
if (node.last_lat && node.last_long) {
|
|
nodePositions[node.node_id] = [node.last_lat / 1e7, node.last_long / 1e7];
|
|
}
|
|
|
|
return node;
|
|
} catch (err) {
|
|
console.error("Error fetching node", nodeId, err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/* ======================================================
|
|
LOAD NODE INFO (SINGLE NODE)
|
|
====================================================== */
|
|
|
|
async function loadNodeInfo(){
|
|
try {
|
|
const node = await fetchNodeFromApi(fromNodeId);
|
|
currentNode = node;
|
|
|
|
if (!node) {
|
|
document.getElementById("node-info").style.display = "none";
|
|
return;
|
|
}
|
|
|
|
// Label in title
|
|
document.getElementById("nodeLabel").textContent =
|
|
nodeMap[fromNodeId] || fromNodeId;
|
|
|
|
// Info card
|
|
document.getElementById("info-node-id").textContent = node.node_id ?? "—";
|
|
document.getElementById("info-long-name").textContent = node.long_name ?? "—";
|
|
document.getElementById("info-short-name").textContent = node.short_name ?? "—";
|
|
document.getElementById("info-hw-model").textContent = node.hw_model ?? "—";
|
|
document.getElementById("info-firmware").textContent = node.firmware ?? "—";
|
|
document.getElementById("info-role").textContent = node.role ?? "—";
|
|
document.getElementById("info-channel").textContent = node.channel ?? "—";
|
|
|
|
document.getElementById("info-lat").textContent =
|
|
node.last_lat ? (node.last_lat / 1e7).toFixed(6) : "—";
|
|
document.getElementById("info-lon").textContent =
|
|
node.last_long ? (node.last_long / 1e7).toFixed(6) : "—";
|
|
|
|
let lastSeen = "—";
|
|
if (node.last_seen_us) {
|
|
lastSeen = formatLastSeen(node.last_seen_us);
|
|
}
|
|
document.getElementById("info-last-update").textContent = lastSeen;
|
|
} catch (err) {
|
|
console.error("Failed to load node info:", err);
|
|
document.getElementById("node-info").style.display = "none";
|
|
}
|
|
}
|
|
|
|
/* ======================================================
|
|
NODE LINK RENDERING
|
|
====================================================== */
|
|
|
|
function nodeLink(id, labelOverride = null) {
|
|
// Broadcast
|
|
if (id === 4294967295) {
|
|
return `<span class="to-mqtt" data-translate-lang="all_broadcast">
|
|
${nodeTranslations.all_broadcast || "All"}
|
|
</span>`;
|
|
}
|
|
|
|
// Direct to MQTT
|
|
if (id === 1) {
|
|
return `<span class="to-mqtt" data-translate-lang="direct_to_mqtt">
|
|
${nodeTranslations.direct_to_mqtt || "Direct to MQTT"}
|
|
</span>`;
|
|
}
|
|
|
|
// Normal node
|
|
const label = labelOverride || nodeMap[id] || id;
|
|
|
|
return `<a href="/node/${id}" style="text-decoration:underline; color:inherit;">
|
|
${label}
|
|
</a>`;
|
|
}
|
|
|
|
|
|
/* ======================================================
|
|
PORT LABELS
|
|
====================================================== */
|
|
|
|
function portLabel(p) {
|
|
const PORT_COLOR_MAP = {
|
|
0: "#6c757d",
|
|
1: "#007bff",
|
|
3: "#28a745",
|
|
4: "#ffc107",
|
|
5: "#dc3545",
|
|
6: "#20c997",
|
|
65: "#6610f2",
|
|
67: "#17a2b8",
|
|
68: "#fd7e14",
|
|
69: "#6f42c1",
|
|
70: "#ff4444",
|
|
71: "#ff66cc",
|
|
72: "#00cc99",
|
|
73: "#9999ff",
|
|
74: "#cc00cc",
|
|
75: "#ffbb33",
|
|
76: "#00bcd4",
|
|
77: "#8bc34a",
|
|
78: "#795548"
|
|
};
|
|
|
|
const PORT_LABEL_MAP = {
|
|
0: "UNKNOWN",
|
|
1: "Text",
|
|
3: "Position",
|
|
4: "Node Info",
|
|
5: "Routing",
|
|
6: "Admin",
|
|
65: "Store & Forward",
|
|
67: "Telemetry",
|
|
70: "Traceroute",
|
|
71: "Neighbor"
|
|
};
|
|
|
|
const color = PORT_COLOR_MAP[p] || "#6c757d";
|
|
const label = PORT_LABEL_MAP[p] || `Port ${p}`;
|
|
|
|
return `
|
|
<span class="port-tag"
|
|
style="background-color:${color}"
|
|
data-no-translate>
|
|
${label}
|
|
</span>
|
|
<span class="text-secondary">(${p})</span>
|
|
`;
|
|
}
|
|
|
|
/* ======================================================
|
|
MAP SETUP
|
|
====================================================== */
|
|
|
|
function initMap(){
|
|
map = L.map('map', { preferCanvas:true }).setView([37.7749, -122.4194], 8);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution:'© OpenStreetMap'
|
|
}).addTo(map);
|
|
}
|
|
|
|
function hideMap(){
|
|
const mapDiv = document.getElementById("map");
|
|
if (mapDiv) {
|
|
mapDiv.style.display = "none";
|
|
}
|
|
}
|
|
|
|
function addMarker(id, lat, lon, color = "red", node = null) {
|
|
if (!map) return;
|
|
if (isNaN(lat) || isNaN(lon)) return;
|
|
|
|
nodePositions[id] = [lat, lon];
|
|
|
|
if (!node) {
|
|
node = nodeCache[id] || null;
|
|
}
|
|
|
|
const popupHtml = node ? makeNodePopup(node) : `<b>${id}</b>`;
|
|
|
|
const m = L.circleMarker([lat, lon], {
|
|
radius: 6,
|
|
color,
|
|
fillColor: color,
|
|
fillOpacity: 1
|
|
}).addTo(map).bindPopup(popupHtml);
|
|
|
|
markers[id] = m;
|
|
m.bringToFront();
|
|
}
|
|
|
|
async function drawNeighbors(src, nids){
|
|
if (!map) return;
|
|
const srcPos = nodePositions[src];
|
|
if (!srcPos) return;
|
|
|
|
for (const nid of nids) {
|
|
const neighbor = await fetchNodeFromApi(nid);
|
|
if (!neighbor || !neighbor.last_lat || !neighbor.last_long) continue;
|
|
|
|
const lat = neighbor.last_lat / 1e7;
|
|
const lon = neighbor.last_long / 1e7;
|
|
|
|
addMarker(nid, lat, lon, "blue", neighbor);
|
|
|
|
const dstPos = [lat, lon];
|
|
L.polyline([srcPos, dstPos], { color:'gray', weight:1 }).addTo(map);
|
|
}
|
|
}
|
|
|
|
function ensureMapVisible(){
|
|
if (!map) return;
|
|
requestAnimationFrame(() => {
|
|
map.invalidateSize();
|
|
const group = L.featureGroup(Object.values(markers));
|
|
if (group.getLayers().length > 0) {
|
|
map.fitBounds(group.getBounds(), {
|
|
padding: [20, 20],
|
|
maxZoom: 11
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ======================================================
|
|
POSITION TRACK (portnum=3)
|
|
====================================================== */
|
|
|
|
async function loadTrack(){
|
|
try {
|
|
const url = new URL("/api/packets", window.location.origin);
|
|
url.searchParams.set("portnum", 3);
|
|
url.searchParams.set("from_node_id", fromNodeId);
|
|
url.searchParams.set("limit", 50);
|
|
|
|
const res = await fetch(url);
|
|
if (!res.ok) {
|
|
hideMap();
|
|
return;
|
|
}
|
|
|
|
const data = await res.json();
|
|
const packets = data.packets || [];
|
|
const points = [];
|
|
|
|
for (const pkt of packets) {
|
|
if (!pkt.payload) continue;
|
|
const latMatch = pkt.payload.match(/latitude_i:\s*(-?\d+)/);
|
|
const lonMatch = pkt.payload.match(/longitude_i:\s*(-?\d+)/);
|
|
if (!latMatch || !lonMatch) continue;
|
|
|
|
const lat = parseInt(latMatch[1], 10) / 1e7;
|
|
const lon = parseInt(lonMatch[1], 10) / 1e7;
|
|
if (isNaN(lat) || isNaN(lon)) continue;
|
|
|
|
points.push({
|
|
lat,
|
|
lon,
|
|
time: pkt.import_time_us
|
|
});
|
|
}
|
|
|
|
if (!points.length) {
|
|
hideMap();
|
|
return;
|
|
}
|
|
|
|
// Sort chronologically (oldest -> newest)
|
|
points.sort((a, b) => a.time - b.time);
|
|
|
|
// Track node's last known position
|
|
const latest = points[points.length - 1];
|
|
nodePositions[fromNodeId] = [latest.lat, latest.lon];
|
|
|
|
if (!map) {
|
|
initMap();
|
|
}
|
|
|
|
const latlngs = points.map(p => [p.lat, p.lon]);
|
|
const trackLine = L.polyline(latlngs, {
|
|
color: '#052152',
|
|
weight: 2
|
|
}).addTo(map);
|
|
|
|
const first = points[0];
|
|
const last = points[points.length - 1];
|
|
|
|
const node = currentNode || nodeCache[fromNodeId] || null;
|
|
|
|
const startMarker = L.circleMarker([first.lat, first.lon], {
|
|
radius: 6,
|
|
color: 'green',
|
|
fillColor: 'green',
|
|
fillOpacity: 1
|
|
}).addTo(map).bindPopup(node ? makeNodePopup(node) : "Start");
|
|
|
|
const endMarker = L.circleMarker([last.lat, last.lon], {
|
|
radius: 6,
|
|
color: 'red',
|
|
fillColor: 'red',
|
|
fillOpacity: 1
|
|
}).addTo(map).bindPopup(node ? makeNodePopup(node) : "Latest");
|
|
|
|
markers["__track_start"] = startMarker;
|
|
markers["__track_end"] = endMarker;
|
|
|
|
map.fitBounds(trackLine.getBounds(), { padding:[20,20] });
|
|
|
|
} catch (err) {
|
|
console.error("Failed to load track:", err);
|
|
hideMap();
|
|
}
|
|
}
|
|
|
|
/* ======================================================
|
|
PACKETS TABLE + NEIGHBOR OVERLAY
|
|
====================================================== */
|
|
|
|
async function loadPackets(){
|
|
const url = new URL("/api/packets", window.location.origin);
|
|
url.searchParams.set("from_node_id", fromNodeId);
|
|
url.searchParams.set("limit", 200);
|
|
|
|
const res = await fetch(url);
|
|
if (!res.ok) return;
|
|
|
|
const data = await res.json();
|
|
const list = document.getElementById("packet_list");
|
|
|
|
for (const pkt of (data.packets || []).reverse()) {
|
|
const safePayload = (pkt.payload || "").replace(/[<>]/g, m => m === "<" ? "<" : ">");
|
|
const localTime = formatLocalTime(pkt.import_time_us);
|
|
const fromCell = nodeLink(pkt.from_node_id);
|
|
const toCell = nodeLink(pkt.to_node_id, pkt.to_long_name);
|
|
|
|
// Neighbor packets (port 71) → draw neighbors on map
|
|
if (pkt.portnum === 71 && pkt.payload) {
|
|
const nids = [];
|
|
const re = /neighbors\s*\{\s*node_id:\s*(\d+)/g;
|
|
let m;
|
|
while ((m = re.exec(pkt.payload)) !== null) {
|
|
nids.push(parseInt(m[1]));
|
|
}
|
|
if (nids.length && map) {
|
|
await drawNeighbors(pkt.from_node_id, nids);
|
|
}
|
|
}
|
|
|
|
let inlineLinks = "";
|
|
|
|
// Position link (Google Maps)
|
|
if (pkt.portnum === 3 && pkt.payload) {
|
|
const latMatch = pkt.payload.match(/latitude_i:\s*(-?\d+)/);
|
|
const lonMatch = pkt.payload.match(/longitude_i:\s*(-?\d+)/);
|
|
|
|
if (latMatch && lonMatch) {
|
|
const lat = parseFloat(latMatch[1]) / 1e7;
|
|
const lon = parseFloat(lonMatch[1]) / 1e7;
|
|
inlineLinks += ` <a class="inline-link" href="https://www.google.com/maps?q=${lat},${lon}" target="_blank">📍</a>`;
|
|
}
|
|
}
|
|
|
|
// Traceroute link
|
|
if (pkt.portnum === 70) {
|
|
let traceId = pkt.id;
|
|
const match = pkt.payload?.match(/ID:\s*(\d+)/i);
|
|
if (match) traceId = match[1];
|
|
inlineLinks += ` <a class="inline-link" href="/graph/traceroute/${traceId}" target="_blank">⮕</a>`;
|
|
}
|
|
|
|
list.insertAdjacentHTML("afterbegin", `
|
|
<tr class="packet-row">
|
|
<td>${localTime}</td>
|
|
<td><span class="toggle-btn">▶</span> <a href="/packet/${pkt.id}" style="text-decoration:underline; color:inherit;">${pkt.id}</a></td>
|
|
<td>${fromCell}</td>
|
|
<td>${toCell}</td>
|
|
<td>${portLabel(pkt.portnum)}${inlineLinks}</td>
|
|
</tr>
|
|
<tr class="payload-row">
|
|
<td colspan="5" class="payload-cell">${safePayload}</td>
|
|
</tr>`);
|
|
}
|
|
}
|
|
|
|
/* ======================================================
|
|
TELEMETRY CHARTS (portnum=67)
|
|
====================================================== */
|
|
|
|
async function loadTelemetryCharts(){
|
|
const url = `/api/packets?portnum=67&from_node_id=${fromNodeId}`;
|
|
const res = await fetch(url);
|
|
if (!res.ok) return;
|
|
|
|
const data = await res.json();
|
|
const packets = data.packets || [];
|
|
chartData = {
|
|
times: [],
|
|
battery: [], voltage: [],
|
|
airUtil: [], chanUtil: [],
|
|
temperature: [], humidity: [], pressure: []
|
|
};
|
|
|
|
for (const pkt of packets.reverse()) {
|
|
const pl = pkt.payload || "";
|
|
const t = new Date(pkt.import_time_us / 1000);
|
|
chartData.times.push(
|
|
t.toLocaleString([], { month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit" })
|
|
);
|
|
|
|
// Matches your payload exactly (inside device_metrics {})
|
|
chartData.battery.push(
|
|
parseFloat(pl.match(/battery_level:\s*([\d.]+)/)?.[1] || NaN)
|
|
);
|
|
chartData.voltage.push(
|
|
parseFloat(pl.match(/voltage:\s*([\d.]+)/)?.[1] || NaN)
|
|
);
|
|
chartData.airUtil.push(
|
|
parseFloat(pl.match(/air_util_tx:\s*([\d.]+)/)?.[1] || NaN)
|
|
);
|
|
chartData.chanUtil.push(
|
|
parseFloat(pl.match(/channel_utilization:\s*([\d.]+)/)?.[1] || NaN)
|
|
);
|
|
chartData.temperature.push(
|
|
parseFloat(pl.match(/temperature:\s*([\d.]+)/)?.[1] || NaN)
|
|
);
|
|
chartData.humidity.push(
|
|
parseFloat(pl.match(/relative_humidity:\s*([\d.]+)/)?.[1] || NaN)
|
|
);
|
|
chartData.pressure.push(
|
|
parseFloat(pl.match(/barometric_pressure:\s*([\d.]+)/)?.[1] || NaN)
|
|
);
|
|
}
|
|
|
|
const hasBattery = chartData.battery.some(v => !isNaN(v));
|
|
const hasVoltage = chartData.voltage.some(v => !isNaN(v));
|
|
const hasAir = chartData.airUtil.some(v => !isNaN(v));
|
|
const hasChan = chartData.chanUtil.some(v => !isNaN(v));
|
|
const hasEnv =
|
|
chartData.temperature.some(v => !isNaN(v)) ||
|
|
chartData.humidity.some(v => !isNaN(v)) ||
|
|
chartData.pressure.some(v => !isNaN(v));
|
|
|
|
const batteryContainer = document.getElementById("battery_voltage_container");
|
|
const airContainer = document.getElementById("air_channel_container");
|
|
const envContainer = document.getElementById("env_chart_container");
|
|
|
|
const makeLine = (name, color, data, yAxisIndex = 0) => ({
|
|
name,
|
|
type: 'line',
|
|
smooth: true,
|
|
connectNulls: true,
|
|
yAxisIndex,
|
|
showSymbol: true,
|
|
symbol: 'circle',
|
|
symbolSize: 8,
|
|
lineStyle: {
|
|
width: 2,
|
|
color,
|
|
shadowColor: color.replace('1)', '0.4)'),
|
|
shadowBlur: 8,
|
|
shadowOffsetY: 3
|
|
},
|
|
itemStyle: {
|
|
color,
|
|
borderColor: '#000',
|
|
borderWidth: 1
|
|
},
|
|
areaStyle: {
|
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
{ offset: 0, color: color.replace('1)', '0.65)') },
|
|
{ offset: 0.5, color: color.replace('1)', '0.35)') },
|
|
{ offset: 1, color: 'rgba(0,0,0,0)' }
|
|
])
|
|
},
|
|
data: data.map(v => isNaN(v) ? null : v)
|
|
});
|
|
|
|
let chart1 = null, chart2 = null, chart3 = null;
|
|
|
|
// Battery / Voltage chart
|
|
if (hasBattery || hasVoltage) {
|
|
batteryContainer.style.display = "block";
|
|
chart1 = echarts.init(document.getElementById('chart_battery_voltage'));
|
|
chart1.setOption({
|
|
tooltip: { trigger:'axis' },
|
|
legend: { data:['Battery Level','Voltage'], textStyle:{ color:'#ccc' } },
|
|
xAxis: { type:'category', data:chartData.times, axisLabel:{ color:'#ccc' } },
|
|
yAxis: [
|
|
{ type:'value', name:'Battery (%)', axisLabel:{ color:'#ccc' } },
|
|
{ type:'value', name:'Voltage (V)', axisLabel:{ color:'#ccc' } }
|
|
],
|
|
series: [
|
|
makeLine('Battery Level', 'rgba(255,214,82,1)', chartData.battery),
|
|
makeLine('Voltage', 'rgba(79,155,255,1)', chartData.voltage, 1)
|
|
]
|
|
});
|
|
} else {
|
|
batteryContainer.style.display = "none";
|
|
}
|
|
|
|
// Air / Channel chart
|
|
if (hasAir || hasChan) {
|
|
airContainer.style.display = "block";
|
|
chart2 = echarts.init(document.getElementById('chart_air_channel'));
|
|
chart2.setOption({
|
|
tooltip: { trigger:'axis' },
|
|
legend: { data:['Air Util Tx','Channel Utilization'], textStyle:{ color:'#ccc' } },
|
|
xAxis: { type:'category', data:chartData.times, axisLabel:{ color:'#ccc' } },
|
|
yAxis: { type:'value', name:'%', axisLabel:{ color:'#ccc' } },
|
|
series: [
|
|
makeLine('Air Util Tx', 'rgba(138,255,108,1)', chartData.airUtil),
|
|
makeLine('Channel Utilization', 'rgba(255,102,204,1)', chartData.chanUtil)
|
|
]
|
|
});
|
|
} else {
|
|
airContainer.style.display = "none";
|
|
}
|
|
|
|
// Environment chart
|
|
if (hasEnv) {
|
|
envContainer.style.display = "block";
|
|
chart3 = echarts.init(document.getElementById('chart_environment'));
|
|
chart3.setOption({
|
|
tooltip: { trigger:'axis' },
|
|
legend: { data:['Temperature (°C)','Humidity (%)','Pressure (hPa)'], textStyle:{ color:'#ccc' } },
|
|
xAxis: { type:'category', data:chartData.times, axisLabel:{ color:'#ccc' } },
|
|
yAxis: [
|
|
{ type:'value', name:'°C / %', axisLabel:{ color:'#ccc' } },
|
|
{ type:'value', name:'hPa', axisLabel:{ color:'#ccc' } }
|
|
],
|
|
series: [
|
|
makeLine('Temperature (°C)', 'rgba(255,138,82,1)', chartData.temperature),
|
|
makeLine('Humidity (%)', 'rgba(138,255,108,1)', chartData.humidity),
|
|
makeLine('Pressure (hPa)', 'rgba(79,155,255,1)', chartData.pressure, 1)
|
|
]
|
|
});
|
|
} else {
|
|
envContainer.style.display = "none";
|
|
}
|
|
|
|
// Resize charts that exist
|
|
window.addEventListener("resize", () => {
|
|
[chart1, chart2, chart3].forEach(c => { if (c) c.resize(); });
|
|
});
|
|
}
|
|
|
|
/* ======================================================
|
|
NEIGHBOR CHART (portnum=71, latest)
|
|
====================================================== */
|
|
|
|
async function loadNeighborChart(){
|
|
const url = `/api/packets?portnum=71&from_node_id=${fromNodeId}&limit=1`;
|
|
const res = await fetch(url);
|
|
if (!res.ok) return;
|
|
|
|
const data = await res.json();
|
|
const packets = data.packets || [];
|
|
if (!packets.length) {
|
|
document.getElementById("neighbor_chart_container").style.display = "none";
|
|
return;
|
|
}
|
|
|
|
const pkt = packets[0];
|
|
const payload = pkt.payload || "";
|
|
|
|
const re = /neighbors\s*\{\s*([^}]+)\}/g;
|
|
let m;
|
|
const ids = [], names = [], snrs = [];
|
|
|
|
while ((m = re.exec(payload)) !== null) {
|
|
const block = m[1];
|
|
const idMatch = block.match(/node_id:\s*(\d+)/);
|
|
const snrMatch = block.match(/snr:\s*(-?\d+(?:\.\d+)?)/);
|
|
if (!idMatch || !snrMatch) continue;
|
|
|
|
const nid = parseInt(idMatch[1], 10);
|
|
const snr = parseFloat(snrMatch[1]);
|
|
|
|
// Try to enrich with node name from API (lazy)
|
|
if (!nodeMap[nid]) {
|
|
await fetchNodeFromApi(nid);
|
|
}
|
|
|
|
ids.push(nid);
|
|
names.push(nodeMap[nid] || nid);
|
|
snrs.push(snr);
|
|
}
|
|
|
|
if (!ids.length) {
|
|
document.getElementById("neighbor_chart_container").style.display = "none";
|
|
return;
|
|
}
|
|
|
|
neighborData = { ids, names, snrs };
|
|
|
|
const container = document.getElementById("neighbor_chart_container");
|
|
container.style.display = "block";
|
|
|
|
const chartEl = document.getElementById("chart_neighbors");
|
|
const neighborChart = echarts.init(chartEl);
|
|
|
|
neighborChart.setOption({
|
|
tooltip: { trigger:'axis' },
|
|
legend: { data:['SNR (dB)'], textStyle:{ color:'#ccc' } },
|
|
xAxis: {
|
|
type:'category',
|
|
data:names,
|
|
axisLabel:{ color:'#ccc', rotate: names.length > 8 ? 45 : 0 }
|
|
},
|
|
yAxis: {
|
|
type:'value',
|
|
name:'SNR (dB)',
|
|
axisLabel:{ color:'#ccc' }
|
|
},
|
|
series:[{
|
|
name:'SNR (dB)',
|
|
type:'bar',
|
|
data:snrs,
|
|
itemStyle:{ color:'rgba(138,255,108,1)' }
|
|
}]
|
|
});
|
|
|
|
window.addEventListener("resize", () => {
|
|
neighborChart.resize();
|
|
});
|
|
}
|
|
|
|
/* ======================================================
|
|
EXPAND / EXPORT BUTTONS
|
|
====================================================== */
|
|
|
|
function expandChart(type){
|
|
const srcEl = document.getElementById(`chart_${type}`);
|
|
if (!srcEl) return;
|
|
const sourceChart = echarts.getInstanceByDom(srcEl);
|
|
if (!sourceChart) return;
|
|
|
|
const modal = document.getElementById('chartModal');
|
|
const modalChart = echarts.init(document.getElementById('modalChart'));
|
|
modal.style.display = "flex";
|
|
modalChart.setOption(sourceChart.getOption());
|
|
modalChart.resize();
|
|
}
|
|
function closeModal(){
|
|
document.getElementById('chartModal').style.display = "none";
|
|
}
|
|
|
|
function exportCSV(type){
|
|
const rows = [["Time"]];
|
|
|
|
if (type === "battery_voltage") {
|
|
rows[0].push("Battery Level", "Voltage");
|
|
for (let i = 0; i < chartData.times.length; i++)
|
|
rows.push([chartData.times[i], chartData.battery[i], chartData.voltage[i]]);
|
|
}
|
|
else if (type === "air_channel") {
|
|
rows[0].push("Air Util Tx", "Channel Utilization");
|
|
for (let i = 0; i < chartData.times.length; i++)
|
|
rows.push([chartData.times[i], chartData.airUtil[i], chartData.chanUtil[i]]);
|
|
}
|
|
else if (type === "environment") {
|
|
rows[0].push("Temperature", "Humidity", "Pressure");
|
|
for (let i = 0; i < chartData.times.length; i++)
|
|
rows.push([
|
|
chartData.times[i],
|
|
chartData.temperature[i],
|
|
chartData.humidity[i],
|
|
chartData.pressure[i]
|
|
]);
|
|
}
|
|
else if (type === "neighbors") {
|
|
rows[0] = ["Neighbor Node ID", "Neighbor Name", "SNR (dB)"];
|
|
for (let i = 0; i < neighborData.ids.length; i++) {
|
|
rows.push([
|
|
neighborData.ids[i],
|
|
neighborData.names[i],
|
|
neighborData.snrs[i]
|
|
]);
|
|
}
|
|
}
|
|
|
|
const csv = rows.map(r => r.join(",")).join("\n");
|
|
const blob = new Blob([csv], { type:"text/csv" });
|
|
const link = document.createElement("a");
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = `${type}_${fromNodeId}.csv`;
|
|
link.click();
|
|
}
|
|
|
|
/* ======================================================
|
|
EXPAND PAYLOAD ROWS
|
|
====================================================== */
|
|
|
|
document.addEventListener("click", e => {
|
|
const btn = e.target.closest(".toggle-btn");
|
|
if (!btn) return;
|
|
const row = btn.closest(".packet-row");
|
|
row.classList.toggle("expanded");
|
|
btn.textContent = row.classList.contains("expanded") ? "▼" : "▶";
|
|
});
|
|
|
|
/* ======================================================
|
|
INIT
|
|
====================================================== */
|
|
|
|
document.addEventListener("DOMContentLoaded", async () => {
|
|
await loadTranslationsNode(); // translations first
|
|
|
|
requestAnimationFrame(async () => {
|
|
await loadNodeInfo(); // single-node fetch
|
|
if (!map) initMap(); // init map early so neighbors can draw
|
|
await loadTrack();
|
|
await loadPackets();
|
|
await loadTelemetryCharts();
|
|
await loadNeighborChart();
|
|
ensureMapVisible();
|
|
setTimeout(ensureMapVisible, 1000);
|
|
window.addEventListener("resize", ensureMapVisible);
|
|
window.addEventListener("focus", ensureMapVisible);
|
|
});
|
|
});
|
|
|
|
</script>
|
|
{% endblock %}
|