Files
meshview/meshview/templates/node.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}&section=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:'&copy; 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 === "<" ? "&lt;" : "&gt;");
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 %}