mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
1971 lines
61 KiB
HTML
1971 lines
61 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: 380px;
|
|
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; }
|
|
|
|
/* --- QR Code & Import --- */
|
|
.node-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 14px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.node-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.node-actions button {
|
|
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
|
|
border: 1px solid #4a5568;
|
|
border-radius: 8px;
|
|
color: #e4e9ee;
|
|
padding: 8px 16px;
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-weight: 500;
|
|
transition: all 0.2s;
|
|
}
|
|
.node-actions button:hover {
|
|
background: linear-gradient(135deg, #3d4758 0%, #2a303c 100%);
|
|
border-color: #6a7788;
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
}
|
|
.node-actions button.copied {
|
|
background: linear-gradient(135deg, #276749 0%, #22543d 100%);
|
|
border-color: #48bb78;
|
|
color: #fff;
|
|
}
|
|
.copy-success {
|
|
color: #4ade80 !important;
|
|
transition: opacity 0.3s;
|
|
}
|
|
|
|
/* --- QR Modal --- */
|
|
#qrModal {
|
|
display:none;
|
|
position:fixed;
|
|
top:0; left:0; width:100%; height:100%;
|
|
background:rgba(0,0,0,0.95);
|
|
z-index:10000;
|
|
align-items:center;
|
|
justify-content:center;
|
|
backdrop-filter:blur(4px);
|
|
}
|
|
#qrModal > div {
|
|
background:linear-gradient(145deg, #1e2228, #16191d);
|
|
border:1px solid #3a4450;
|
|
border-radius:16px;
|
|
padding:28px;
|
|
max-width:380px;
|
|
text-align:center;
|
|
color:#e4e9ee;
|
|
box-shadow:0 25px 80px rgba(0,0,0,0.6);
|
|
}
|
|
#qrModal .qr-header {
|
|
display:flex;
|
|
justify-content:space-between;
|
|
align-items:center;
|
|
margin-bottom:16px;
|
|
}
|
|
#qrModal .qr-title {
|
|
font-size:1.3rem;
|
|
font-weight:600;
|
|
margin:0;
|
|
color:#9fd4ff;
|
|
}
|
|
#qrModal .qr-close {
|
|
background:rgba(255,255,255,0.05);
|
|
border:1px solid #4a5568;
|
|
color:#9ca3af;
|
|
width:32px;
|
|
height:32px;
|
|
border-radius:8px;
|
|
cursor:pointer;
|
|
font-size:1.2rem;
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:center;
|
|
transition:all 0.2s;
|
|
}
|
|
#qrModal .qr-close:hover {
|
|
background:rgba(255,255,255,0.1);
|
|
color:#fff;
|
|
border-color:#6a7788;
|
|
}
|
|
#qrModal .qr-node-name {
|
|
font-size:1.15rem;
|
|
color:#fff;
|
|
margin:12px 0 20px;
|
|
font-weight:500;
|
|
}
|
|
#qrModal .qr-image {
|
|
background:#fff;
|
|
padding:16px;
|
|
border-radius:12px;
|
|
display:inline-block;
|
|
margin-bottom:16px;
|
|
box-shadow:0 8px 30px rgba(0,0,0,0.4);
|
|
}
|
|
#qrModal .qr-image img {
|
|
display:block;
|
|
border-radius:4px;
|
|
}
|
|
#qrModal .qr-url-container {
|
|
background:rgba(0,0,0,0.4);
|
|
border-radius:8px;
|
|
padding:12px;
|
|
margin-bottom:18px;
|
|
}
|
|
#qrModal .qr-url {
|
|
font-size:0.65rem;
|
|
color:#9ca3af;
|
|
word-break:break-all;
|
|
font-family:'Monaco', 'Menlo', monospace;
|
|
line-height:1.4;
|
|
max-height:48px;
|
|
overflow-y:auto;
|
|
display:block;
|
|
}
|
|
#qrModal .qr-actions {
|
|
display:flex;
|
|
gap:12px;
|
|
justify-content:center;
|
|
}
|
|
#qrModal .qr-btn {
|
|
background:linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
|
|
border:1px solid #4a5568;
|
|
color:#e4e9ee;
|
|
padding:12px 24px;
|
|
border-radius:10px;
|
|
cursor:pointer;
|
|
font-size:0.9rem;
|
|
font-weight:500;
|
|
transition:all 0.2s;
|
|
display:flex;
|
|
align-items:center;
|
|
gap:8px;
|
|
min-width:140px;
|
|
justify-content:center;
|
|
}
|
|
#qrModal .qr-btn:hover {
|
|
background:linear-gradient(135deg, #3d4758 0%, #2a303c 100%);
|
|
border-color:#6a7788;
|
|
transform:translateY(-2px);
|
|
box-shadow:0 4px 12px rgba(0,0,0,0.3);
|
|
}
|
|
#qrModal .qr-btn.copied {
|
|
background:linear-gradient(135deg, #276749 0%, #22543d 100%);
|
|
border-color:#48bb78;
|
|
color:#fff;
|
|
}
|
|
|
|
/* --- Impersonation Warning --- */
|
|
.impersonation-warning {
|
|
background: rgba(239, 68, 68, 0.15);
|
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
|
border-radius: 8px;
|
|
padding: 12px 16px;
|
|
margin-bottom: 14px;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
}
|
|
.impersonation-warning .warning-icon {
|
|
font-size: 1.2rem;
|
|
}
|
|
.impersonation-warning .warning-content {
|
|
flex: 1;
|
|
}
|
|
.impersonation-warning .warning-title {
|
|
color: #f87171;
|
|
font-weight: bold;
|
|
margin-bottom: 4px;
|
|
}
|
|
.impersonation-warning .warning-text {
|
|
font-size: 0.85rem;
|
|
color: #ccc;
|
|
}
|
|
{% 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 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>
|
|
<button onclick="toggleCoverage()" id="toggleCoverageBtn" disabled title="Location required for coverage">
|
|
<span>📡</span> <span data-translate-lang="toggle_coverage">Predicted Coverage</span>
|
|
</button>
|
|
<button onclick="toggleObservedCoverage()" id="toggleObservedCoverageBtn" disabled title="Location required for coverage">
|
|
<span>🛰</span> <span data-translate-lang="toggle_observed_coverage">Observed 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>
|
|
<div id="observedCoverageControls" class="node-actions" style="display:none;">
|
|
<span data-translate-lang="observed_settings">Observed Settings</span>
|
|
<label>
|
|
<span data-translate-lang="max_hops">Max Hops</span>
|
|
<input id="observedMaxHops" type="number" min="0" max="10" value="1" style="width:60px;">
|
|
</label>
|
|
<label>
|
|
<span data-translate-lang="bearing_step">Bearing Step</span>
|
|
<input id="observedBearingStep" type="number" min="1" max="90" value="5" style="width:60px;">
|
|
</label>
|
|
<label>
|
|
<span data-translate-lang="packets_limit">Packets</span>
|
|
<input id="observedPacketsLimit" type="number" min="1" max="1000" value="50" style="width:80px;">
|
|
</label>
|
|
<button onclick="refreshObservedCoverage()" id="refreshObservedCoverageBtn">
|
|
<span data-translate-lang="refresh">Refresh</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Impersonation Warning -->
|
|
<div id="impersonationWarning" class="impersonation-warning" style="display:none;">
|
|
<span class="warning-icon">⚠️</span>
|
|
<div class="warning-content">
|
|
<div class="warning-title" data-translate-lang="potential_impersonation">Potential Impersonation Detected</div>
|
|
<div class="warning-text" id="impersonationText"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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="id">Hex ID</strong><strong>: </strong><span id="info-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="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="Packets sent"
|
|
data-label-seen="Times seen">—</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: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>
|
|
|
|
<!-- 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 class="filter-container" style="margin-bottom:10px; display:flex; gap:12px; flex-wrap:wrap;">
|
|
<select id="packet_since">
|
|
<option value="">All time</option>
|
|
<option value="3600">Last hour</option>
|
|
<option value="21600">Last 6 hours</option>
|
|
<option value="86400">Last 24 hours</option>
|
|
<option value="172800">Last 2 days</option>
|
|
<option value="259200">Last 3 days</option>
|
|
<option value="432000">Last 5 days</option>
|
|
<option value="604800">Last 7 days</option>
|
|
</select>
|
|
|
|
<select id="packet_port">
|
|
<option value="">All ports</option>
|
|
</select>
|
|
|
|
<button onclick="reloadPackets()">Apply</button>
|
|
<button onclick="exportPacketsCSV()">Export CSV</button>
|
|
</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>
|
|
<th data-translate-lang="size">Size</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>
|
|
|
|
<!-- 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">Loading...</div>
|
|
<div class="qr-image">
|
|
<div id="qrCodeContainer"></div>
|
|
</div>
|
|
<div class="qr-url-container">
|
|
<span class="qr-url" id="qrUrl">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="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
|
<script src="https://unpkg.com/leaflet.heat/dist/leaflet-heat.js"></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}§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)}
|
|
<br>
|
|
<b><span data-translate-lang="first_update">
|
|
${nodeTranslations.first_update || "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([], {
|
|
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 currentPacketRows = [];
|
|
|
|
let map, markers = {};
|
|
let coverageLayer = null;
|
|
let observedCoverageLayer = null;
|
|
let observedControlsVisible = false;
|
|
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-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-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) : "—";
|
|
const coverageBtn = document.getElementById("toggleCoverageBtn");
|
|
const coverageHelp = document.getElementById("coverageHelpLink");
|
|
const observedCoverageBtn = document.getElementById("toggleObservedCoverageBtn");
|
|
if (coverageBtn) {
|
|
const hasLocation = Boolean(node.last_lat && node.last_long);
|
|
coverageBtn.disabled = !hasLocation;
|
|
coverageBtn.title = hasLocation
|
|
? ""
|
|
: (nodeTranslations.location_required || "Location required for coverage");
|
|
coverageBtn.style.display = hasLocation ? "" : "none";
|
|
}
|
|
if (observedCoverageBtn) {
|
|
const hasLocation = Boolean(node.last_lat && node.last_long);
|
|
observedCoverageBtn.disabled = !hasLocation;
|
|
observedCoverageBtn.title = hasLocation
|
|
? ""
|
|
: (nodeTranslations.location_required || "Location required for coverage");
|
|
observedCoverageBtn.style.display = hasLocation ? "" : "none";
|
|
}
|
|
if (coverageHelp) {
|
|
const hasLocation = Boolean(node.last_lat && node.last_long);
|
|
coverageHelp.style.display = hasLocation ? "" : "none";
|
|
}
|
|
if (!(node.last_lat && node.last_long)) {
|
|
setObservedControlsVisible(false);
|
|
}
|
|
|
|
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 || "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>`;
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
|
|
/* ======================================================
|
|
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:'© OpenStreetMap'
|
|
}).addTo(map);
|
|
}
|
|
|
|
async function toggleCoverage() {
|
|
if (!map) initMap();
|
|
|
|
if (coverageLayer) {
|
|
map.removeLayer(coverageLayer);
|
|
coverageLayer = null;
|
|
return;
|
|
}
|
|
if (observedCoverageLayer) {
|
|
map.removeLayer(observedCoverageLayer);
|
|
observedCoverageLayer = null;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
async function toggleObservedCoverage() {
|
|
if (!map) initMap();
|
|
|
|
if (observedCoverageLayer) {
|
|
map.removeLayer(observedCoverageLayer);
|
|
observedCoverageLayer = null;
|
|
setObservedControlsVisible(false);
|
|
return;
|
|
}
|
|
if (coverageLayer) {
|
|
map.removeLayer(coverageLayer);
|
|
coverageLayer = null;
|
|
}
|
|
|
|
const nodeId = currentNode?.node_id || fromNodeId;
|
|
if (!nodeId) return;
|
|
|
|
try {
|
|
setObservedControlsVisible(true);
|
|
const maxHops = getObservedNumber("observedMaxHops", 1, 0, 10);
|
|
const bearingStep = getObservedNumber("observedBearingStep", 5, 1, 90);
|
|
const packetsLimit = getObservedNumber("observedPacketsLimit", 50, 1, 1000);
|
|
const res = await fetch(
|
|
`/api/coverage_observed/${encodeURIComponent(nodeId)}?max_hops=${maxHops}&bearing_step=${bearingStep}&packets_limit=${packetsLimit}`
|
|
);
|
|
if (!res.ok) {
|
|
console.error("Observed coverage request failed", res.status);
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
if (!data.perimeter || data.perimeter.length < 3) {
|
|
console.warn("Observed coverage perimeter missing or too small");
|
|
return;
|
|
}
|
|
observedCoverageLayer = L.polygon(data.perimeter, {
|
|
color: "#17a2b8",
|
|
weight: 3,
|
|
opacity: 1.0,
|
|
fillColor: "#000000",
|
|
fillOpacity: 0.1
|
|
}).addTo(map);
|
|
map.fitBounds(observedCoverageLayer.getBounds(), { padding: [20, 20] });
|
|
map.invalidateSize();
|
|
} catch (err) {
|
|
console.error("Observed coverage request failed", err);
|
|
}
|
|
}
|
|
|
|
function setObservedControlsVisible(show) {
|
|
const controls = document.getElementById("observedCoverageControls");
|
|
if (!controls) return;
|
|
observedControlsVisible = show;
|
|
controls.style.display = show ? "" : "none";
|
|
}
|
|
|
|
function getObservedNumber(id, fallback, min, max) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return fallback;
|
|
const num = Number(el.value);
|
|
if (Number.isNaN(num)) return fallback;
|
|
return Math.max(min, Math.min(max, num));
|
|
}
|
|
|
|
async function refreshObservedCoverage() {
|
|
if (!observedCoverageLayer) {
|
|
await toggleObservedCoverage();
|
|
return;
|
|
}
|
|
await toggleObservedCoverage();
|
|
await toggleObservedCoverage();
|
|
}
|
|
|
|
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;
|
|
|
|
// Ensure source node position exists
|
|
const srcNode = await fetchNodeFromApi(src);
|
|
if (!srcNode || !srcNode.last_lat || !srcNode.last_long) return;
|
|
|
|
const srcLat = srcNode.last_lat / 1e7;
|
|
const 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) : "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(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 = (pkt.payload || "")
|
|
.replace(/[<>]/g, m => (m === "<" ? "<" : ">"));
|
|
|
|
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="/graph/traceroute/${traceId}" target="_blank">⮕</a>`;
|
|
}
|
|
|
|
const sizeBytes = packetSizeBytes(pkt);
|
|
|
|
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>
|
|
<td>${sizeBytes.toLocaleString()} 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: []
|
|
};
|
|
|
|
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(); });
|
|
});
|
|
}
|
|
|
|
|
|
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)
|
|
====================================================== */
|
|
|
|
async function loadNeighborTimeSeries() {
|
|
const container = document.getElementById("neighbor_chart_container");
|
|
const chartEl = document.getElementById("chart_neighbors");
|
|
|
|
const url = `/api/packets?portnum=71&from_node_id=${fromNodeId}&limit=500`;
|
|
const res = await fetch(url);
|
|
|
|
if (!res.ok) {
|
|
container.style.display = "none";
|
|
return;
|
|
}
|
|
|
|
const data = await res.json();
|
|
const packets = data.packets || [];
|
|
|
|
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));
|
|
|
|
const neighborHistory = {}; // node_id -> { name, 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);
|
|
const snr = parseFloat(snrMatch[1]);
|
|
|
|
// Fetch neighbor metadata once
|
|
const neighbor = await fetchNodeFromApi(nid);
|
|
|
|
if (!neighborHistory[nid]) {
|
|
neighborHistory[nid] = {
|
|
name: neighbor?.short_name ||
|
|
neighbor?.long_name ||
|
|
`Node ${nid}`,
|
|
times: [],
|
|
snr: []
|
|
};
|
|
}
|
|
|
|
neighborHistory[nid].times.push(ts);
|
|
neighborHistory[nid].snr.push(snr);
|
|
}
|
|
}
|
|
|
|
// Collect ALL timestamps across neighbors
|
|
const allTimes = new Set();
|
|
Object.values(neighborHistory).forEach(entry => {
|
|
entry.times.forEach(t => allTimes.add(t));
|
|
});
|
|
|
|
// Sort timestamps numerically
|
|
const xTimes = Array.from(allTimes).sort((a, b) => a - b);
|
|
|
|
const legend = [];
|
|
const series = [];
|
|
|
|
for (const entry of Object.values(neighborHistory)) {
|
|
legend.push(entry.name);
|
|
|
|
series.push({
|
|
name: entry.name,
|
|
type: "line",
|
|
smooth: true,
|
|
connectNulls: true,
|
|
showSymbol: false,
|
|
data: xTimes.map(t => {
|
|
const idx = entry.times.indexOf(t);
|
|
return idx >= 0 ? entry.snr[idx] : null;
|
|
})
|
|
});
|
|
}
|
|
|
|
const chart = echarts.init(chartEl);
|
|
|
|
chart.setOption({
|
|
tooltip: {
|
|
trigger: "axis",
|
|
axisPointer: { type: "line" }
|
|
},
|
|
legend: {
|
|
data: legend,
|
|
textStyle: { color: "#ccc" }
|
|
},
|
|
xAxis: {
|
|
type: "category",
|
|
data: xTimes,
|
|
axisLabel: {
|
|
color: "#ccc",
|
|
formatter: value =>
|
|
new Date(value / 1000).toLocaleString([], {
|
|
year: "2-digit",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit"
|
|
})
|
|
}
|
|
},
|
|
yAxis: {
|
|
type: "value",
|
|
name: "SNR",
|
|
axisLabel: { color: "#ccc" }
|
|
},
|
|
series
|
|
});
|
|
|
|
window.addEventListener("resize", () => chart.resize());
|
|
}
|
|
|
|
|
|
|
|
async function loadPacketHistogram() {
|
|
const DAYS = 7;
|
|
const now = new Date();
|
|
|
|
const dayKeys = [];
|
|
const dayLabels = [];
|
|
|
|
for (let i = DAYS - 1; i >= 0; i--) {
|
|
const d = new Date(now);
|
|
d.setDate(d.getDate() - i);
|
|
dayKeys.push(d.toISOString().slice(0, 10));
|
|
dayLabels.push(
|
|
d.toLocaleDateString([], { 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 = new Date(pkt.import_time_us / 1000)
|
|
.toISOString()
|
|
.slice(0, 10);
|
|
|
|
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 = echarts.init(
|
|
document.getElementById("chart_packet_histogram")
|
|
);
|
|
|
|
chart.setOption({
|
|
animation: false,
|
|
tooltip: { trigger: "axis" },
|
|
legend: { textStyle: { color: "#ccc" } },
|
|
xAxis: {
|
|
type: "category",
|
|
data: dayLabels,
|
|
axisLabel: { color: "#ccc" }
|
|
},
|
|
yAxis: {
|
|
type: "value",
|
|
axisLabel: { color: "#ccc" }
|
|
},
|
|
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 = 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();
|
|
|
|
requestAnimationFrame(async () => {
|
|
await loadNodeInfo();
|
|
|
|
// Load QR code URL and impersonation check
|
|
await loadNodeQrAndImpersonation();
|
|
|
|
// ✅ MAP MUST EXIST FIRST
|
|
if (!map) initMap();
|
|
|
|
// ✅ DRAW LATEST NEIGHBORS ONCE
|
|
const neighborIds = await loadLatestNeighborIds();
|
|
if (neighborIds.length) {
|
|
await drawNeighbors(fromNodeId, neighborIds);
|
|
}
|
|
|
|
// ⚠️ Track may add to map, but must not hide it
|
|
await loadTrack();
|
|
|
|
await loadPackets();
|
|
initPacketPortFilter();
|
|
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 =
|
|
`24h · Packets sent: ${packets.toLocaleString()} · Times seen: ${seen.toLocaleString()} `;
|
|
} 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;
|
|
}
|
|
|
|
loadPackets(filters);
|
|
}
|
|
|
|
function exportPacketsCSV() {
|
|
if (!currentPacketRows.length) {
|
|
alert("No packets to export.");
|
|
return;
|
|
}
|
|
|
|
const rows = [
|
|
["Time", "Packet ID", "From Node", "To Node", "Port", "Port Name", "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] || `Port ${pkt.portnum}`;
|
|
|
|
// Escape quotes + line breaks for CSV safety
|
|
const payload = (pkt.payload || "")
|
|
.replace(/"/g, '""')
|
|
.replace(/\r?\n/g, " ");
|
|
|
|
rows.push([
|
|
time,
|
|
pkt.id,
|
|
pkt.from_node_id,
|
|
pkt.to_node_id,
|
|
pkt.portnum,
|
|
portName,
|
|
`"${payload}"`
|
|
]);
|
|
}
|
|
|
|
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 = `packets_${fromNodeId}_${Date.now()}.csv`;
|
|
link.click();
|
|
}
|
|
|
|
/* ======================================================
|
|
QR CODE & IMPORT URL
|
|
====================================================== */
|
|
|
|
let currentMeshtasticUrl = "";
|
|
|
|
async function loadNodeQrAndImpersonation() {
|
|
const actionsDiv = document.getElementById("nodeActions");
|
|
const warningDiv = document.getElementById("impersonationWarning");
|
|
|
|
try {
|
|
const [qrRes, impRes] = await Promise.all([
|
|
fetch(`/api/node/${fromNodeId}/qr`),
|
|
fetch(`/api/node/${fromNodeId}/impersonation-check`)
|
|
]);
|
|
|
|
const qrData = await qrRes.json();
|
|
if (qrRes.ok && qrData.meshtastic_url) {
|
|
currentMeshtasticUrl = qrData.meshtastic_url;
|
|
actionsDiv.style.display = "flex";
|
|
} else {
|
|
actionsDiv.style.display = "none";
|
|
}
|
|
|
|
const impData = await impRes.json();
|
|
if (impRes.ok && impData.potential_impersonation) {
|
|
warningDiv.style.display = "flex";
|
|
document.getElementById("impersonationText").textContent =
|
|
impData.warning || `This node has sent ${impData.unique_public_key_count} different public keys. This could indicate impersonation.`;
|
|
} else {
|
|
warningDiv.style.display = "none";
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to load QR/impersonation data:", err);
|
|
actionsDiv.style.display = "none";
|
|
warningDiv.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">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("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 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">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;">Failed to generate QR code</div>';
|
|
}
|
|
}
|
|
|
|
/* ======================================================
|
|
END QR CODE & IMPORT URL
|
|
====================================================== */
|
|
|
|
|
|
|
|
</script>
|
|
{% endblock %}
|