Files
meshview/meshview/templates/node.html
T
Óscar García Amor a298347f14 feat(node): add power metrics chart (#190)
* fix(node): read telemetry fields from their block

The telemetry charts matched fields with unanchored regexes over the
whole payload, so "voltage:" also matched "ch1_voltage:" (power_metrics)
and environment_metrics voltage, plotting them as the device voltage.
The same applied to "temperature:" and other shared field names.

Parse each value only inside its own metrics block (device_metrics or
environment_metrics), match the exact field name and accept negative
values (e.g. temperatures below zero).

* feat(node): add power metrics chart

Plot power_metrics telemetry (ch1..ch8 voltage and current) on the node
page. Each channel that reported data gets a voltage series on the left
axis (V) and a dashed current series on the right axis (mA).

The legacy voltage/current fields of environment_metrics, reported by INA
sensors and to be deprecated in favor of PowerMetrics, are shown in the
same chart as "Environment" series.

The chart is hidden when the node sends no power data and supports the
usual expand and CSV export actions. Translations added for en, es, ru.
2026-11-09 13:54:22 -08:00

1779 lines
65 KiB
HTML

{% extends "base.html" %}
{% block theme %}light{% endblock %}
{% block body_class %}mesh-data-page mesh-node-page{% endblock %}
{% block css %}
{% include "data-page-styles.html" %}
<style>
.node-info { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; margin-bottom:20px; font-size:13px; }
.node-info-col { background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:9px; padding:20px; overflow-wrap:anywhere; }
.node-info-col > div { padding:5px 0; }
.node-info-col h2 { margin:0 0 10px; padding-bottom:10px; border-bottom:1px solid var(--mesh-border); color:var(--mesh-text); font-size:15px; font-weight:600; }
.node-info strong { color:var(--mesh-muted); font-weight:500; }
.node-actions .qr-unavailable { align-self:center; color:var(--mesh-muted); font-size:13px; }
.node-actions { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:20px; }
.chart-container { width:100%; background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:9px; margin-bottom:20px; overflow:hidden; }
.chart-header { display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:10px; padding:16px 20px; border-bottom:1px solid var(--mesh-border); font-size:14px; font-weight:600; }
.chart-actions { display:flex; gap:6px; }
.filter-container { display:flex; gap:10px; flex-wrap:wrap; padding:16px; background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:9px; margin-bottom:18px; }
#chartModal,#qrModal { display:none; position:fixed; inset:0; background:#17263c99; z-index:9999; align-items:center; justify-content:center; padding:16px; }
#chartModal > div { background:var(--mesh-surface); border-radius:10px; width:95%; height:85%; padding:14px; }
#qrModal > div { background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:12px; padding:24px; width:380px; max-width:100%; text-align:center; }
.qr-header { display:flex; justify-content:space-between; align-items:center; gap:10px; }
.qr-title { font-size:18px; margin:0; }
.qr-node-name { font-size:15px; margin:16px 0; overflow-wrap:anywhere; }
.qr-image { display:inline-block; padding:16px; background:var(--mesh-surface); margin-bottom:14px; }
.qr-image img,.qr-image canvas { max-width:100%; }
.qr-url-container { border:1px solid var(--mesh-border); border-radius:6px; background:var(--mesh-page); padding:12px; margin-bottom:16px; }
.qr-url { display:block; max-height:80px; overflow:auto; overflow-wrap:anywhere; font:11px/1.6 monospace; color:var(--mesh-muted); }
.qr-actions { display:flex; justify-content:center; }
.mesh-data-page .copied { background:#e8f5ee; color:#187346; }
@media(max-width:900px) { .node-info { grid-template-columns:minmax(0,1fr); gap:10px; } }
@media(max-width:700px) { .chart-header { padding:12px; } .chart-actions button { padding:6px 8px; } }
</style>
{% endblock %}
{% block body %}
<div class="container">
<header class="data-heading"><div><span class="mesh-eyebrow">{{ tr("DEVICE INSPECTOR") }}</span><h1><span id="nodeLabel">{{ tr("Node details") }}</span></h1><p>{{ tr("Device information, telemetry, and recent packet activity.") }}</p></div><a class="mesh-secondary-button" href="/nodelist">{{ tr("All nodes ↗") }}</a></header>
<!-- Node Actions -->
<div class="node-actions" id="nodeActions" style="display:none;">
<button onclick="copyImportUrl()" id="copyUrlBtn">
<span>📋</span> <span data-translate-lang="copy_import_url">Copy Import URL</span>
</button>
<button onclick="showQrCode()" id="showQrBtn">
<span>🔳</span> <span data-translate-lang="show_qr_code">Show QR Code</span>
</button>
<span class="qr-unavailable" id="qrUnavailable" style="display:none;" data-translate-lang="qr_no_public_key">
No public key heard from this node yet, so it cannot be shared as a contact.
</span>
<span class="qr-unavailable" id="qrKeyConflict" style="display:none;" data-translate-lang="qr_key_conflict">
This node has reported more than one public key, so it is not shared as a contact.
</span>
<button onclick="openReachReport()" id="reachReportBtn">
<span>📈</span> <span>{{ tr("Reliability") }}</span>
</button>
<button onclick="toggleCoverage()" id="toggleCoverageBtn" disabled title="{{ tr("Location required for coverage") }}">
<span>📡</span> <span data-translate-lang="toggle_coverage">Predicted Coverage</span>
</button>
<a class="inline-link" id="coverageHelpLink" href="/docs/COVERAGE.md" target="_blank" rel="noopener" data-translate-lang="coverage_help">
Coverage Help
</a>
</div>
<!-- Node Info -->
<div id="node-info" class="node-info">
<section class="node-info-col" aria-labelledby="node-info-heading-0">
<h2 id="node-info-heading-0">{{ tr("Identity") }}</h2>
<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="id">Hex ID</strong><strong>: </strong><span id="info-id">—</span></div>
<div><strong data-translate-lang="node_id">Node ID</strong><strong>: </strong><span id="info-node-id">—</span></div>
</section>
<section class="node-info-col" aria-labelledby="node-info-heading-1">
<h2 id="node-info-heading-1">{{ tr("Device & network") }}</h2>
<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="mqtt_gateway">MQTT Gateway</strong><strong>: </strong> <span id="info-mqtt-gateway">—</span></div>
</section>
<section class="node-info-col" aria-labelledby="node-info-heading-2">
<h2 id="node-info-heading-2">{{ tr("Location") }}</h2>
<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>{{ tr("Altitude (above sea level)") }}</strong><strong>: </strong> <span id="info-altitude">—</span></div>
</section>
<section class="node-info-col" aria-labelledby="node-info-heading-3">
<h2 id="node-info-heading-3">{{ tr("Activity") }}</h2>
<div><strong data-translate-lang="first_update">First Update</strong><strong>: </strong> <span id="info-first-update">—</span></div>
<div><strong data-translate-lang="last_update">Last Update</strong><strong>: </strong> <span id="info-last-update">—</span></div>
<div>
<strong data-translate-lang="statistics">Statistics</strong><strong>: </strong>
<span id="info-stats"
data-label-24h="24h"
data-label-sent="{{ tr("Packets sent") }}"
data-label-seen="{{ tr("Times seen") }}">—</span>
</div>
</section>
</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:380px;"></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:380px;"></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:380px;"></div>
</div>
<!-- Power Metrics -->
<div id="power_chart_container" class="chart-container" style="display:none;">
<div class="chart-header">
⚡ <span data-translate-lang="power">Power Metrics</span>
<div class="chart-actions">
<button onclick="expandChart('power')" data-translate-lang="expand">Expand</button>
<button onclick="exportCSV('power')" data-translate-lang="export_csv">Export CSV</button>
</div>
</div>
<div id="chart_power" style="height:380px;"></div>
</div>
<!-- Neighbor chart -->
<!-- Neighbor Time-Series Chart -->
<div id="neighbor_chart_container" class="chart-container">
<div class="chart-header">
📡 <span data-translate-lang="neighbors_chart">Neighbors (SNR Over Time)</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:380px;"></div>
</div>
<!-- Packet Histogram -->
<div id="packet_histogram_container" class="chart-container">
<div class="chart-header">
📊 <span data-translate-lang="packets_per_day">Packets per Day (Last 7 Days)</span>
<div class="chart-actions">
<button onclick="expandChart('packet_histogram')" data-translate-lang="expand">Expand</button>
<button onclick="exportCSV('packet_histogram')" data-translate-lang="export_csv">Export CSV</button>
</div>
</div>
<div id="chart_packet_histogram" style="height:380px;"></div>
</div>
<!-- Packet Filters -->
<div id="node-packets" class="filter-container" style="scroll-margin-top:20px; margin-bottom:10px; display:flex; gap:12px; flex-wrap:wrap;">
<select id="packet_since" aria-label="{{ tr("Packet time range") }}">
<option value="">{{ tr("All time") }}</option>
<option value="3600">{{ tr("Last hour") }}</option>
<option value="21600">{{ tr("Last 6 hours") }}</option>
<option value="86400">{{ tr("Last 24 hours") }}</option>
<option value="172800">{{ tr("Last 2 days") }}</option>
<option value="259200" selected>{{ tr("Last 3 days") }}</option>
<option value="432000">{{ tr("Last 5 days") }}</option>
<option value="604800">{{ tr("Last 7 days") }}</option>
</select>
<select id="packet_port" aria-label="{{ tr("Packet port") }}">
<option value="">{{ tr("All ports") }}</option>
</select>
<button onclick="reloadPackets()">{{ tr("Apply") }}</button>
<button onclick="exportPacketsCSV()">{{ tr("Export CSV") }}</button>
</div>
<!-- Packets -->
<div class="data-table-wrap" role="region" aria-label="{{ tr("Node packet table") }}" tabindex="0">
<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>
<th data-translate-lang="size">Size</th>
</tr>
</thead>
<tbody id="packet_list"></tbody>
</table>
</div>
</div>
<!-- Modal -->
<div id="chartModal">
<div>
<div style="text-align:right;">
<button onclick="closeModal()" style="background:none;border:none;color:var(--mesh-muted);">✖</button>
</div>
<div id="modalChart" style="width:100%; height:90%;"></div>
</div>
</div>
<!-- QR Code Modal -->
<div id="qrModal">
<div>
<div class="qr-header">
<h3 class="qr-title" data-translate-lang="share_contact_qr">Share Contact QR</h3>
<button class="qr-close" onclick="closeQrModal()">✕</button>
</div>
<div class="qr-node-name" id="qrNodeName">{{ tr("Loading...") }}</div>
<div class="qr-image">
<div id="qrCodeContainer"></div>
</div>
<div class="qr-url-container">
<span class="qr-url" id="qrUrl">{{ tr("Generating...") }}</span>
</div>
<div class="qr-actions">
<button class="qr-btn" onclick="copyQrUrl()" id="copyQrBtn">
<span>📋</span> <span data-translate-lang="copy_url">Copy URL</span>
</button>
</div>
</div>
</div>
<script src="/static/vendor/echarts-5.5.0/dist/echarts.min.js"></script>
<script src="/static/vendor/qrcodejs-1.0.0/qrcode.min.js"></script>
<script src="/static/vendor/leaflet.heat-0.2.0/dist/leaflet-heat.js"
integrity="sha384-mFKkGiGvT5vo1fEyGCD3hshDdKmW3wzXW/x+fWriYJArD0R3gawT6lMvLboM22c0"
crossorigin="anonymous"></script>
<script src="/static/portmaps.js"></script>
<script>
const PORT_COLOR_MAP = window.PORT_COLOR_MAP || {};
const PORT_LABEL_MAP = window.PORT_LABEL_MAP || {};
/* ======================================================
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 || MeshI18n.t("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>${escapeHtml(node.long_name || node.short_name || node.node_id)}</b>
</a>
${node.short_name ? ` (${escapeHtml(node.short_name)})` : ""}<br>
<b><span data-translate-lang="node_id">
${nodeTranslations.node_id || MeshI18n.t("Node ID")}:
</span></b> ${node.node_id}<br>
<b><span data-translate-lang="hw_model">
${nodeTranslations.hw_model || MeshI18n.t("HW Model")}:
</span></b> ${escapeHtml(node.hw_model ?? "—")}<br>
<b><span data-translate-lang="channel">
${nodeTranslations.channel || MeshI18n.t("Channel")}:
</span></b> ${escapeHtml(node.channel ?? "—")}<br>
<b><span data-translate-lang="role">
${nodeTranslations.role || MeshI18n.t("Role")}:
</span></b> ${escapeHtml(node.role ?? "—")}<br>
<b><span data-translate-lang="firmware">
${nodeTranslations.firmware || MeshI18n.t("Firmware")}:
</span></b> ${escapeHtml(node.firmware ?? "—")}<br>
<b><span data-translate-lang="last_update">
${nodeTranslations.last_update || MeshI18n.t("Last Update")}:
</span></b> ${formatLastSeen(node.last_seen_us)}
<br>
<b><span data-translate-lang="first_update">
${nodeTranslations.first_update || MeshI18n.t("First Update")}:
</span></b> ${formatLastSeen(node.first_seen_us)}
</div>
`;
}
function formatLastSeen(us) {
if (!us) return "—";
const d = new Date(us / 1000);
return d.toLocaleString(MeshI18n.language, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
function formatLocalTime(us){
return new Date(us / 1000).toLocaleString(MeshI18n.language, {
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 currentPacketRows = [];
let map, markers = {};
let coverageLayer = null;
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";
document.getElementById("nodeLabel").textContent = MeshI18n.t("Node unavailable");
return;
}
// Label in title
document.getElementById("nodeLabel").textContent =
nodeMap[fromNodeId] || fromNodeId;
// Info card
document.getElementById("info-id").textContent = node.id ?? "—";
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-mqtt-gateway").textContent =
node.is_mqtt_gateway ? (nodeTranslations.yes || MeshI18n.t("Yes")) : (nodeTranslations.no || MeshI18n.t("No"));
document.getElementById("info-channel").textContent = node.channel ?? "—";
document.getElementById("info-lat").textContent =
node.last_lat != null ? (node.last_lat / 1e7).toFixed(6) : "—";
document.getElementById("info-lon").textContent =
node.last_long != null ? (node.last_long / 1e7).toFixed(6) : "—";
document.getElementById("info-altitude").textContent =
node.last_altitude != null ? `${node.last_altitude} m` : "—";
const coverageBtn = document.getElementById("toggleCoverageBtn");
const coverageHelp = document.getElementById("coverageHelpLink");
if (coverageBtn) {
const hasLocation = Boolean(node.last_lat && node.last_long);
coverageBtn.disabled = !hasLocation;
coverageBtn.title = hasLocation
? ""
: (nodeTranslations.location_required || MeshI18n.t("Location required for coverage"));
coverageBtn.style.display = hasLocation ? "" : "none";
}
if (coverageHelp) {
const hasLocation = Boolean(node.last_lat && node.last_long);
coverageHelp.style.display = hasLocation ? "" : "none";
}
let lastSeen = "—";
if (node.last_seen_us) {
lastSeen = formatLastSeen(node.last_seen_us);
}
let firstSeen = "—";
if (node.first_seen_us) {
firstSeen = formatLastSeen(node.first_seen_us);
}
document.getElementById("info-first-update").textContent = firstSeen;
document.getElementById("info-last-update").textContent = lastSeen;
loadNodeStats(node.node_id);
} 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 || MeshI18n.t("All")}
</span>`;
}
// Direct to MQTT
if (id === 1) {
return `<span class="to-mqtt" data-translate-lang="direct_to_mqtt">
${nodeTranslations.direct_to_mqtt || MeshI18n.t("Direct to MQTT")}
</span>`;
}
// Normal node
const label = labelOverride || nodeMap[id] || id;
return `<a href="/node/${id}" style="text-decoration:underline; color:inherit;">
${escapeHtml(label)}
</a>`;
}
function initPacketPortFilter() {
const sel = document.getElementById("packet_port");
if (!sel) return;
Object.keys(PORT_LABEL_MAP)
.map(Number)
.sort((a, b) => a - b)
.forEach(p => {
const opt = document.createElement("option");
opt.value = p;
opt.textContent = `${PORT_LABEL_MAP[p]} (${p})`;
sel.appendChild(opt);
});
const requestedPort = new URLSearchParams(window.location.search).get("portnum");
if ([...sel.options].some(option => option.value === requestedPort)) {
sel.value = requestedPort;
}
}
/* ======================================================
PORT LABELS
====================================================== */
function portLabel(p) {
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);
L.control.scale({
position: 'bottomleft',
metric: true,
imperial: true
}).addTo(map);
}
async function toggleCoverage() {
if (!map) initMap();
if (coverageLayer) {
map.removeLayer(coverageLayer);
coverageLayer = null;
return;
}
const nodeId = currentNode?.node_id || fromNodeId;
if (!nodeId) return;
try {
const res = await fetch(`/api/coverage/${encodeURIComponent(nodeId)}?mode=perimeter`);
if (!res.ok) {
console.error("Coverage request failed", res.status);
return;
}
const data = await res.json();
if (!data.perimeter || data.perimeter.length < 3) {
console.warn("Coverage perimeter missing or too small");
return;
}
coverageLayer = L.polygon(data.perimeter, {
color: "#6f42c1",
weight: 2,
opacity: 0.7,
fillColor: "#000000",
fillOpacity: 0.10
}).addTo(map);
map.fitBounds(coverageLayer.getBounds(), { padding: [20, 20] });
map.invalidateSize();
} catch (err) {
console.error("Coverage request failed", err);
}
}
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>${escapeHtml(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;
// Prefer the currently displayed source position (e.g. latest track point),
// then fall back to the node API location.
let srcLat;
let srcLon;
let srcNode = currentNode || nodeCache[src] || null;
if (nodePositions[src]) {
[srcLat, srcLon] = nodePositions[src];
} else {
srcNode = srcNode || await fetchNodeFromApi(src);
if (!srcNode || !srcNode.last_lat || !srcNode.last_long) return;
srcLat = srcNode.last_lat / 1e7;
srcLon = srcNode.last_long / 1e7;
nodePositions[src] = [srcLat, srcLon];
}
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;
nodePositions[nid] = [lat, lon];
// Marker
addMarker(nid, lat, lon, "blue", neighbor);
// Link line
L.polyline(
[[srcLat, srcLon], [lat, lon]],
{ color: "gray", weight: 1 }
).addTo(map);
}
ensureMapVisible();
}
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) : MeshI18n.t("Start"));
const endMarker = L.circleMarker([last.lat, last.lon], {
radius: 6,
color: 'red',
fillColor: 'red',
fillOpacity: 1
}).addTo(map).bindPopup(node ? makeNodePopup(node) : MeshI18n.t("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(filters = {}) {
const list = document.getElementById("packet_list");
list.innerHTML = "";
const url = new URL("/api/packets", window.location.origin);
url.searchParams.set("node_id", fromNodeId);
url.searchParams.set("limit", 1000);
if (filters.since) {
url.searchParams.set("since", filters.since);
}
if (filters.portnum) {
url.searchParams.set("portnum", filters.portnum);
}
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
const packets = data.packets || [];
currentPacketRows = packets;
for (const pkt of packets.reverse()) {
// ================================
// TABLE ROW
// ================================
const safePayload = escapeHtml(pkt.payload || "");
const localTime = formatLocalTime(pkt.import_time_us);
const fromCell = nodeLink(pkt.from_node_id, pkt.long_name);
const toCell = nodeLink(pkt.to_node_id, pkt.to_long_name);
let inlineLinks = "";
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>`;
}
}
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="/traceroute/${traceId}" target="_blank">⮕</a>`;
}
const sizeBytes = packetSizeBytes(pkt);
const packetIdCell = pkt.packet_id
? `<a href="/packet/${pkt.storage_id || pkt.id}"
style="text-decoration:underline; color:inherit;">
${pkt.packet_id}
</a>`
: pkt.portnum === 73
? `<span class="text-secondary" title="${MeshI18n.h("MQTT map report")}">${MeshI18n.h("Not a Packet")}</span>`
: `<span class="text-secondary" title="${MeshI18n.h("No Meshtastic packet ID")}">—</span>`;
list.insertAdjacentHTML("afterbegin", `
<tr class="packet-row">
<td>${localTime}</td>
<td><span class="toggle-btn">▶</span>
${packetIdCell}
</td>
<td>${fromCell}</td>
<td>${toCell}</td>
<td>${portLabel(pkt.portnum)}${inlineLinks}</td>
<td>${sizeBytes.toLocaleString(MeshI18n.language)} B</td>
</tr>
<tr class="payload-row">
<td colspan="6" 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: [],
// power_metrics channels (ch1..ch8) plus the legacy voltage/current
// that INA sensors report inside environment_metrics.
power: {}, powerSeries: []
};
const POWER_CHANNELS = [1, 2, 3, 4, 5, 6, 7, 8];
for (const ch of POWER_CHANNELS) {
chartData.power[`ch${ch}_voltage`] = [];
chartData.power[`ch${ch}_current`] = [];
}
chartData.power.env_voltage = [];
chartData.power.env_current = [];
// Return the body of a "name { ... }" block from the text-format payload.
// Several telemetry variants share field names (voltage, temperature...),
// so each value must be read only from its own block.
const metricsBlock = (pl, name) =>
pl.match(new RegExp(`(?:^|\\n)\\s*${name}\\s*\\{([^}]*)\\}`))?.[1] || "";
// Read a numeric field by its exact name (line-anchored, so "voltage"
// does not match "ch1_voltage").
const metricField = (block, name) => {
const m = block.match(new RegExp(`(?:^|\\n)\\s*${name}:\\s*(-?[\\d.]+(?:[eE][-+]?\\d+)?)`));
return m ? parseFloat(m[1]) : NaN;
};
for (const pkt of packets.reverse()) {
const pl = pkt.payload || "";
const t = new Date(pkt.import_time_us / 1000);
chartData.times.push(
t.toLocaleString(MeshI18n.language, { month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit" })
);
const dev = metricsBlock(pl, "device_metrics");
const env = metricsBlock(pl, "environment_metrics");
const pwr = metricsBlock(pl, "power_metrics");
for (const ch of POWER_CHANNELS) {
chartData.power[`ch${ch}_voltage`].push(metricField(pwr, `ch${ch}_voltage`));
chartData.power[`ch${ch}_current`].push(metricField(pwr, `ch${ch}_current`));
}
chartData.power.env_voltage.push(metricField(env, "voltage"));
chartData.power.env_current.push(metricField(env, "current"));
chartData.battery.push(metricField(dev, "battery_level"));
chartData.voltage.push(metricField(dev, "voltage"));
chartData.airUtil.push(metricField(dev, "air_util_tx"));
chartData.chanUtil.push(metricField(dev, "channel_utilization"));
chartData.temperature.push(metricField(env, "temperature"));
chartData.humidity.push(metricField(env, "relative_humidity"));
chartData.pressure.push(metricField(env, "barometric_pressure"));
}
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 = initMeshChart(document.getElementById('chart_battery_voltage'));
chart1.setOption({
tooltip: { trigger:'axis' },
legend: { data:[MeshI18n.t("Battery Level"),MeshI18n.t("Voltage")], textStyle:{ color:'#64748b' } },
xAxis: { type:'category', data:chartData.times, axisLabel:{ color:'#64748b' } },
yAxis: [
{ type:'value', name:'Battery (%)', axisLabel:{ color:'#64748b' } },
{ type:'value', name:'Voltage (V)', axisLabel:{ color:'#64748b' } }
],
series: [
makeLine(MeshI18n.t("Battery Level"), 'rgba(255,214,82,1)', chartData.battery),
makeLine(MeshI18n.t("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 = initMeshChart(document.getElementById('chart_air_channel'));
chart2.setOption({
tooltip: { trigger:'axis' },
legend: { data:[MeshI18n.t("Air Util Tx"),MeshI18n.t("Channel Utilization")], textStyle:{ color:'#64748b' } },
xAxis: { type:'category', data:chartData.times, axisLabel:{ color:'#64748b' } },
yAxis: { type:'value', name:'%', axisLabel:{ color:'#64748b' } },
series: [
makeLine(MeshI18n.t("Air Util Tx"), 'rgba(138,255,108,1)', chartData.airUtil),
makeLine(MeshI18n.t("Channel Utilization"), 'rgba(255,102,204,1)', chartData.chanUtil)
]
});
} else {
airContainer.style.display = "none";
}
// Environment chart
if (hasEnv) {
envContainer.style.display = "block";
chart3 = initMeshChart(document.getElementById('chart_environment'));
chart3.setOption({
tooltip: { trigger:'axis' },
legend: { data:[MeshI18n.t("Temperature (°C)"),MeshI18n.t("Humidity (%)"),MeshI18n.t("Pressure (hPa)")], textStyle:{ color:'#64748b' } },
xAxis: { type:'category', data:chartData.times, axisLabel:{ color:'#64748b' } },
yAxis: [
{ type:'value', name:'°C / %', axisLabel:{ color:'#64748b' } },
{ type:'value', name:'hPa', axisLabel:{ color:'#64748b' } }
],
series: [
makeLine(MeshI18n.t("Temperature (°C)"), 'rgba(255,138,82,1)', chartData.temperature),
makeLine(MeshI18n.t("Humidity (%)"), 'rgba(138,255,108,1)', chartData.humidity),
makeLine(MeshI18n.t("Pressure (hPa)"), 'rgba(79,155,255,1)', chartData.pressure, 1)
]
});
} else {
envContainer.style.display = "none";
}
// Power chart: one voltage (left axis) and one current (right axis) series
// per channel that reported data. Current is dashed and unfilled so up to
// 18 series stay readable.
const POWER_COLORS = [
'rgba(79,155,255,1)', 'rgba(255,214,82,1)', 'rgba(138,255,108,1)',
'rgba(255,102,204,1)', 'rgba(255,138,82,1)', 'rgba(0,212,212,1)',
'rgba(178,132,255,1)', 'rgba(200,200,200,1)'
];
const ENV_POWER_COLOR = 'rgba(255,80,80,1)';
const powerSources = [
...POWER_CHANNELS.map(ch => ({
key: `ch${ch}`,
color: POWER_COLORS[ch - 1],
voltageLabel: MeshI18n.t("Ch{n} voltage (V)", {n: ch}),
currentLabel: MeshI18n.t("Ch{n} current (mA)", {n: ch})
})),
{
key: "env",
color: ENV_POWER_COLOR,
voltageLabel: MeshI18n.t("Environment voltage (V)"),
currentLabel: MeshI18n.t("Environment current (mA)")
}
];
const hasData = values => values.some(v => !isNaN(v));
for (const src of powerSources) {
const voltage = chartData.power[`${src.key}_voltage`];
const current = chartData.power[`${src.key}_current`];
if (hasData(voltage))
chartData.powerSeries.push({ label: src.voltageLabel, color: src.color, data: voltage, axis: 0 });
if (hasData(current))
chartData.powerSeries.push({ label: src.currentLabel, color: src.color, data: current, axis: 1 });
}
const powerContainer = document.getElementById("power_chart_container");
let chart4 = null;
if (chartData.powerSeries.length) {
powerContainer.style.display = "block";
chart4 = initMeshChart(document.getElementById('chart_power'));
chart4.setOption({
tooltip: { trigger:'axis' },
legend: {
type: 'scroll',
data: chartData.powerSeries.map(s => s.label),
textStyle:{ color:'#64748b' }
},
xAxis: { type:'category', data:chartData.times, axisLabel:{ color:'#64748b' } },
yAxis: [
{ type:'value', name:'Voltage (V)', axisLabel:{ color:'#64748b' } },
{ type:'value', name:'Current (mA)', axisLabel:{ color:'#64748b' } }
],
series: chartData.powerSeries.map(s => {
const line = makeLine(s.label, s.color, s.data, s.axis);
if (s.axis === 1) {
delete line.areaStyle;
line.lineStyle = { ...line.lineStyle, type: 'dashed' };
line.symbol = 'triangle';
}
return line;
})
});
} else {
powerContainer.style.display = "none";
}
// Resize charts that exist
window.addEventListener("resize", () => {
[chart1, chart2, chart3, chart4].forEach(c => { if (c) c.resize(); });
});
}
async function loadLatestNeighborIds() {
const url = new URL("/api/packets", window.location.origin);
url.searchParams.set("from_node_id", fromNodeId);
url.searchParams.set("portnum", 71);
url.searchParams.set("limit", 1); // ✅ ONLY the latest packet
const res = await fetch(url);
if (!res.ok) return [];
const data = await res.json();
const pkt = data.packets?.[0];
if (!pkt || !pkt.payload) return [];
const ids = [];
const re = /neighbors\s*\{([^}]+)\}/g;
let m;
while ((m = re.exec(pkt.payload)) !== null) {
const id = m[1].match(/node_id:\s*(\d+)/);
if (id) ids.push(parseInt(id[1], 10));
}
return ids;
}
/* ======================================================
NEIGHBOR CHART (portnum=71)
The request is bounded to NEIGHBOR_WINDOW_DAYS through the
API's `since` parameter, and only the NEIGHBOR_MAX_SERIES
neighbors with the most data points in that window are
drawn. Without both limits a busy node produces hundreds of
legend entries covering weeks of history.
====================================================== */
const NEIGHBOR_WINDOW_DAYS = 7;
const NEIGHBOR_MAX_SERIES = 15;
async function loadNeighborTimeSeries() {
const container = document.getElementById("neighbor_chart_container");
const chartEl = document.getElementById("chart_neighbors");
// Start of the window, in microseconds (import_time_us is epoch-us).
const sinceUs = (Date.now() - NEIGHBOR_WINDOW_DAYS * 86400 * 1000) * 1000;
const url = `/api/packets?portnum=71&from_node_id=${fromNodeId}`
+ `&since=${sinceUs}&limit=500`;
const res = await fetch(url);
if (!res.ok) {
container.style.display = "none";
return;
}
const data = await res.json();
// Re-apply the window here too, so the chart cannot drift past
// it if the packet source ever returns something older.
const packets = (data.packets || [])
.filter(p => (p.import_time_us || 0) >= sinceUs);
if (!packets.length) {
container.style.display = "none";
return;
}
// Sort packets chronologically (microseconds)
packets.sort((a, b) => (a.import_time_us || 0) - (b.import_time_us || 0));
// --- Pass 1: collect the readings, without looking up names yet ---
const neighborHistory = {}; // node_id -> { times[], snr[] }
for (const pkt of packets) {
if (!pkt.import_time_us || !pkt.payload) continue;
const ts = pkt.import_time_us; // KEEP NUMERIC TIMESTAMP
const blockRe = /neighbors\s*\{([^}]+)\}/g;
let m;
while ((m = blockRe.exec(pkt.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);
if (!neighborHistory[nid]) {
neighborHistory[nid] = { times: [], snr: [] };
}
neighborHistory[nid].times.push(ts);
neighborHistory[nid].snr.push(parseFloat(snrMatch[1]));
}
}
// --- Keep the neighbors with the most data points in the window ---
const ranked = Object.entries(neighborHistory)
.sort((a, b) => b[1].times.length - a[1].times.length);
const kept = ranked.slice(0, NEIGHBOR_MAX_SERIES);
const dropped = ranked.length - kept.length;
if (!kept.length) {
container.style.display = "none";
return;
}
// --- Pass 2: names, only for the series that are drawn ---
await Promise.all(kept.map(async ([nid, entry]) => {
const neighbor = await fetchNodeFromApi(nid);
entry.name = neighbor?.short_name ||
neighbor?.long_name ||
`Node ${nid}`;
}));
// ECharts identifies a series by its name, so two neighbors sharing
// a short name would be drawn as one line. Keep them apart.
const usedNames = new Set();
for (const [nid, entry] of kept) {
if (usedNames.has(entry.name)) entry.name = `${entry.name} (${nid})`;
usedNames.add(entry.name);
}
// Timestamps across the series that are drawn
const xTimes = Array.from(
new Set(kept.flatMap(([, entry]) => entry.times))
).sort((a, b) => a - b);
const legend = [];
const series = [];
for (const [, entry] of kept) {
const byTime = new Map();
entry.times.forEach((t, i) => byTime.set(t, entry.snr[i]));
legend.push(entry.name);
series.push({
name: entry.name,
type: "line",
smooth: true,
connectNulls: true,
showSymbol: false,
data: xTimes.map(t => (byTime.has(t) ? byTime.get(t) : null))
});
}
// Values behind the "Export CSV" button: the most recent SNR
// in the window, per neighbor.
neighborData = {
ids: kept.map(([nid]) => nid),
names: kept.map(([, entry]) => entry.name),
snrs: kept.map(([, entry]) => entry.snr[entry.snr.length - 1])
};
// Say which neighbors are on screen, so a missing line is not a mystery.
const subtextTemplate = dropped > 0
? (nodeTranslations.neighbors_chart_subtitle_capped ||
MeshI18n.t("Last {days} days — the {n} neighbors with the most data points, of {total}"))
: (nodeTranslations.neighbors_chart_subtitle ||
MeshI18n.t("Last {days} days — {n} neighbors"));
const subtext = subtextTemplate
.replace("{days}", NEIGHBOR_WINDOW_DAYS)
.replace("{n}", kept.length)
.replace("{total}", ranked.length);
const chart = initMeshChart(chartEl);
chart.setOption({
title: {
subtext,
left: "center",
bottom: 0,
subtextStyle: { color: "#888", fontSize: 11 }
},
grid: { bottom: 60 },
tooltip: {
trigger: "axis",
axisPointer: { type: "line" },
formatter: params => {
if (!params.length) return "";
const timestamp = Number(params[0].axisValue);
const heading = new Date(timestamp / 1000).toLocaleString(MeshI18n.language);
return escapeHtml(heading) + "<br>" + params.map(point =>
`${point.marker}${escapeHtml(point.seriesName)}: ${escapeHtml(point.value ?? MeshI18n.t("N/A"))}`
).join("<br>");
}
},
legend: {
type: "scroll",
data: legend,
textStyle: { color: "#64748b" }
},
xAxis: {
type: "category",
data: xTimes,
axisLabel: {
color: "#64748b",
formatter: value =>
new Date(value / 1000).toLocaleString(MeshI18n.language, {
year: "2-digit",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
})
}
},
yAxis: {
type: "value",
name: "SNR",
axisLabel: { color: "#64748b" }
},
series
});
window.addEventListener("resize", () => chart.resize());
}
async function loadPacketHistogram() {
const DAYS = 7;
const now = new Date();
const dayKeyFromDate = (d) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const dayKeys = [];
const dayLabels = [];
for (let i = DAYS - 1; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
dayKeys.push(dayKeyFromDate(d));
dayLabels.push(
d.toLocaleDateString(MeshI18n.language, { month: "short", day: "numeric" })
);
}
const url = new URL("/api/packets", window.location.origin);
url.searchParams.set("node_id", fromNodeId);
// last 7 days only (microseconds)
const sinceUs = Date.now() * 1000 - (7 * 24 * 60 * 60 * 1_000_000);
url.searchParams.set("since", sinceUs);
// modest safety limit (still applies after server-side filter)
url.searchParams.set("limit", 2000);
const res = await fetch(url);
if (!res.ok) return;
const packets = (await res.json()).packets || [];
const counts = {}; // { port: { day: count } }
const ports = new Set();
for (const pkt of packets) {
if (!pkt.import_time_us) continue;
const day = dayKeyFromDate(new Date(pkt.import_time_us / 1000));
if (!dayKeys.includes(day)) continue;
const port = pkt.portnum ?? 0;
ports.add(port);
counts[port] ??= {};
counts[port][day] = (counts[port][day] || 0) + 1;
}
if (!ports.size) {
document.getElementById("packet_histogram_container").style.display = "none";
return;
}
const series = Array.from(ports)
.sort((a, b) => a - b)
.map(port => ({
name: PORT_LABEL_MAP[port] || `Port ${port}`,
type: "bar",
stack: "total",
barMaxWidth: 42,
itemStyle: {
color: PORT_COLOR_MAP[port] || "#888"
},
data: dayKeys.map(d => counts[port]?.[d] || 0)
}));
const chart = initMeshChart(
document.getElementById("chart_packet_histogram")
);
chart.setOption({
animation: false,
tooltip: { trigger: "axis" },
legend: { textStyle: { color: "#64748b" } },
xAxis: {
type: "category",
data: dayLabels,
axisLabel: { color: "#64748b" }
},
yAxis: {
type: "value",
axisLabel: { color: "#64748b" }
},
series
});
window.addEventListener("resize", () => chart.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 = initMeshChart(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 = [[MeshI18n.t("Time")]];
if (type === "battery_voltage") {
rows[0].push(MeshI18n.t("Battery Level"), MeshI18n.t("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(MeshI18n.t("Air Util Tx"), MeshI18n.t("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(MeshI18n.t("Temperature"), MeshI18n.t("Humidity"), MeshI18n.t("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 === "power") {
rows[0].push(...chartData.powerSeries.map(s => s.label));
for (let i = 0; i < chartData.times.length; i++)
rows.push([chartData.times[i], ...chartData.powerSeries.map(s => isNaN(s.data[i]) ? null : s.data[i])]);
}
else if (type === "neighbors") {
rows[0] = [MeshI18n.t("Neighbor Node ID"), MeshI18n.t("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 = csvFromRows(rows);
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();
requestAnimationFrame(async () => {
await loadNodeInfo();
await loadNodeQr();
// ✅ MAP MUST EXIST FIRST
if (!map) initMap();
// Load the track first so neighbor links anchor to the same
// visible current-node position shown on this page.
await loadTrack();
// ✅ DRAW LATEST NEIGHBORS ONCE
const neighborIds = await loadLatestNeighborIds();
if (neighborIds.length) {
await drawNeighbors(fromNodeId, neighborIds);
}
initPacketPortFilter();
await reloadPackets();
await loadTelemetryCharts();
await loadNeighborTimeSeries();
await loadPacketHistogram();
ensureMapVisible();
setTimeout(ensureMapVisible, 1000);
window.addEventListener("resize", ensureMapVisible);
window.addEventListener("focus", ensureMapVisible);
});
});
function packetSizeBytes(pkt) {
if (!pkt) return 0;
// Prefer raw payload length
if (pkt.payload) {
return new TextEncoder().encode(pkt.payload).length;
}
// Fallbacks (if you later add protobuf/base64)
if (pkt.raw_payload) {
return atob(pkt.raw_payload).length;
}
return 0;
}
async function loadNodeStats(nodeId) {
try {
const res = await fetch(
`/api/stats/count?from_node=${nodeId}&period_type=day&length=1`
);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
const packets = data?.total_packets ?? 0;
const seen = data?.total_seen ?? 0;
document.getElementById("info-stats").textContent =
MeshI18n.t("24h · Packets sent: {packets} · Times seen: {seen}", {packets: packets.toLocaleString(MeshI18n.language), seen: seen.toLocaleString(MeshI18n.language)});
} catch (err) {
console.error("Failed to load node stats:", err);
document.getElementById("info-stats").textContent = "—";
}
}
function reloadPackets() {
const sinceSel = document.getElementById("packet_since").value;
const portSel = document.getElementById("packet_port").value;
const filters = {};
if (sinceSel) {
const sinceUs = Date.now() * 1000 - (parseInt(sinceSel, 10) * 1_000_000);
filters.since = sinceUs;
}
if (portSel) {
filters.portnum = portSel;
}
return loadPackets(filters);
}
function exportPacketsCSV() {
if (!currentPacketRows.length) {
alert(MeshI18n.t("No packets to export."));
return;
}
const rows = [
[MeshI18n.t("Time"), MeshI18n.t("Packet ID"), MeshI18n.t("From Node"), MeshI18n.t("To Node"), MeshI18n.t("Port"), MeshI18n.t("Port Name"), MeshI18n.t("Payload")]
];
for (const pkt of currentPacketRows) {
const time = pkt.import_time_us
? new Date(pkt.import_time_us / 1000).toISOString()
: "";
const portName = PORT_LABEL_MAP[pkt.portnum] || MeshI18n.t("Port {port}", {port: pkt.portnum});
const displayPacketId = pkt.packet_id || (pkt.portnum === 73 ? MeshI18n.t("Not a Packet") : "");
// Keep each packet on one line; csvFromRows() does the quoting.
const payload = (pkt.payload || "").replace(/\r?\n/g, " ");
rows.push([
time,
displayPacketId,
pkt.from_node_id,
pkt.to_node_id,
pkt.portnum,
portName,
payload
]);
}
const csv = csvFromRows(rows);
const blob = new Blob([csv], { type: "text/csv" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `packets_${fromNodeId}_${Date.now()}.csv`;
link.click();
}
/* ======================================================
QR CODE & IMPORT URL
====================================================== */
let currentMeshtasticUrl = "";
async function loadNodeQr() {
const actionsDiv = document.getElementById("nodeActions");
try {
const qrRes = await fetch(`/api/node/${fromNodeId}/qr`);
const qrData = await qrRes.json();
if (qrRes.ok) {
// No URL means no usable public key: none is known, or the node reported several.
currentMeshtasticUrl = qrData.meshtastic_url || "";
const canShare = Boolean(currentMeshtasticUrl);
const keyConflict = Boolean(qrData.public_key_conflict);
document.getElementById("copyUrlBtn").style.display = canShare ? "" : "none";
document.getElementById("showQrBtn").style.display = canShare ? "" : "none";
document.getElementById("qrUnavailable").style.display =
canShare || keyConflict ? "none" : "";
document.getElementById("qrKeyConflict").style.display = keyConflict ? "" : "none";
actionsDiv.style.display = "flex";
} else {
actionsDiv.style.display = "none";
}
} catch (err) {
console.error("Failed to load QR data:", err);
actionsDiv.style.display = "none";
}
}
function copyImportUrl() {
if (!currentMeshtasticUrl) return;
navigator.clipboard.writeText(currentMeshtasticUrl).then(() => {
const btn = document.getElementById("copyUrlBtn");
const originalText = btn.innerHTML;
btn.innerHTML = `<span>✅</span> <span data-translate-lang="copied">${MeshI18n.h("Copied!")}</span>`;
btn.classList.add("copy-success");
setTimeout(() => {
btn.innerHTML = originalText;
btn.classList.remove("copy-success");
}, 2000);
}).catch(err => {
console.error("Failed to copy:", err);
alert(MeshI18n.t("Failed to copy URL to clipboard"));
});
}
function showQrCode() {
if (!currentMeshtasticUrl) return;
const node = currentNode;
document.getElementById("qrNodeName").textContent =
node && node.long_name ? node.long_name : `Node ${fromNodeId}`;
document.getElementById("qrUrl").textContent = currentMeshtasticUrl;
generateQrCode(currentMeshtasticUrl);
document.getElementById("qrModal").style.display = "flex";
}
function openReachReport() {
if (!fromNodeId) return;
window.location.href = `/reliability?node_id=${encodeURIComponent(fromNodeId)}`;
}
function closeQrModal() {
document.getElementById("qrModal").style.display = "none";
}
function copyQrUrl() {
navigator.clipboard.writeText(currentMeshtasticUrl).then(() => {
const btn = document.getElementById("copyQrBtn");
const originalHTML = btn.innerHTML;
btn.innerHTML = `<span>✅</span> <span data-translate-lang="copied">${MeshI18n.h("Copied!")}</span>`;
btn.classList.add("copied");
setTimeout(() => {
btn.innerHTML = originalHTML;
btn.classList.remove("copied");
}, 2000);
}).catch(err => {
console.error("Failed to copy:", err);
});
}
function generateQrCode(text) {
const container = document.getElementById("qrCodeContainer");
if (!container) return;
container.innerHTML = "";
try {
new QRCode(container, {
text: text,
width: 200,
height: 200,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M
});
} catch (e) {
console.error("QR Code generation error:", e);
container.innerHTML = `<div style="padding:20px;color:#f87171;">${MeshI18n.h("Failed to generate QR code")}</div>`;
}
}
/* ======================================================
END QR CODE & IMPORT URL
====================================================== */
</script>
{% endblock %}