mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-08-04 16:03:36 +02:00
684 lines
23 KiB
HTML
684 lines
23 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block css %}
|
|
.reach-page {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 0 16px;
|
|
}
|
|
|
|
.reach-header {
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.reach-header h1 {
|
|
font-size: 1.35rem;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.reach-panel {
|
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
background: rgba(255, 255, 255, 0.04);
|
|
}
|
|
|
|
.reach-controls {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.reach-search {
|
|
min-width: 0;
|
|
flex: 0 1 420px;
|
|
}
|
|
|
|
.reach-search-label,
|
|
.reach-limit-label {
|
|
align-self: center;
|
|
white-space: nowrap;
|
|
color: rgba(255, 255, 255, 0.72);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.reach-limit-label {
|
|
margin-left: auto;
|
|
}
|
|
|
|
.reach-node-link {
|
|
align-self: center;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.reach-controls {
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
|
|
.reach-summary {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.reach-metric {
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
border-radius: 8px;
|
|
padding: 8px 10px;
|
|
background: rgba(0, 0, 0, 0.18);
|
|
}
|
|
|
|
.reach-metric-value {
|
|
font-size: 1.2rem;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.reach-table-wrap {
|
|
overflow-x: auto;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.reach-table {
|
|
width: 100%;
|
|
min-width: 760px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.reach-gateway-panels {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 10px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.reach-gateway-panel {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.reach-gateway-table {
|
|
min-width: 0;
|
|
}
|
|
|
|
.reach-table th,
|
|
.reach-table td {
|
|
vertical-align: top;
|
|
padding: 0.35rem 0.5rem;
|
|
}
|
|
|
|
.reach-count-bar-cell {
|
|
min-width: 180px;
|
|
}
|
|
|
|
.reach-count-bar-track {
|
|
position: relative;
|
|
height: 20px;
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.12);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.reach-count-bar-fill {
|
|
height: 100%;
|
|
min-width: 24px;
|
|
border-radius: inherit;
|
|
background: var(--reach-bar-color, #4ade80);
|
|
}
|
|
|
|
.reach-count-bar-value {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
padding-left: 8px;
|
|
font-family: monospace;
|
|
font-size: 0.85rem;
|
|
color: #fff;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.reach-muted {
|
|
color: rgba(255, 255, 255, 0.62);
|
|
}
|
|
|
|
.reach-heard-link {
|
|
padding: 0;
|
|
border: 0;
|
|
background: transparent;
|
|
color: #8ab4f8;
|
|
text-decoration: underline;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.port-tag {
|
|
display: inline-block;
|
|
padding: 1px 6px;
|
|
border-radius: 6px;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
color: #fff;
|
|
}
|
|
|
|
.reliability-modal {
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 5000;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 16px;
|
|
background: rgba(0, 0, 0, 0.72);
|
|
}
|
|
|
|
.reliability-modal.open {
|
|
display: flex;
|
|
}
|
|
|
|
.reliability-modal-panel {
|
|
width: min(760px, 100%);
|
|
max-height: min(720px, 92vh);
|
|
overflow: auto;
|
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
border-radius: 8px;
|
|
background: #111827;
|
|
padding: 12px;
|
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.reliability-modal-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.reliability-modal-title {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.reliability-status-heard {
|
|
color: #4ade80 !important;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.reliability-status-missed {
|
|
color: #f87171 !important;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.reliability-status-skipped {
|
|
color: #cbd5e1 !important;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.reliability-row-skipped td {
|
|
text-decoration: line-through;
|
|
text-decoration-thickness: 1px;
|
|
color: rgba(255, 255, 255, 0.58) !important;
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.reach-gateway-panels {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
{% endblock %}
|
|
|
|
{% block body %}
|
|
<main class="reach-page">
|
|
<div class="reach-header">
|
|
<h1>Reliability</h1>
|
|
<p class="text-secondary mb-0">
|
|
Review how often each gateway hears packets from a selected node. The report uses the
|
|
latest selected packet sample and calculates reliability as heard packets divided by packets
|
|
reviewed. The range table groups gateways by reliability bands, while the gateway list
|
|
shows each gateway's heard count and percentage for the same sample. Reliability is
|
|
calculated only from Text, Position, Node Info, and Telemetry packets; other packet types
|
|
are shown in packet details but skipped from the calculation.
|
|
</p>
|
|
</div>
|
|
|
|
<section class="reach-panel">
|
|
<form class="reach-controls" id="reach-search-form">
|
|
<label class="reach-search-label" for="reach-node-search">Search</label>
|
|
<input
|
|
class="form-control reach-search"
|
|
id="reach-node-search"
|
|
list="reach-node-options"
|
|
placeholder="Search node name or enter node ID"
|
|
autocomplete="off"
|
|
>
|
|
<datalist id="reach-node-options"></datalist>
|
|
<label class="reach-limit-label" for="reliability-packet-type">Packet type</label>
|
|
<select class="form-select" id="reliability-packet-type" style="max-width:150px;">
|
|
<option value="" selected>All</option>
|
|
<option value="1">Text</option>
|
|
<option value="3">Position</option>
|
|
<option value="4">Node Info</option>
|
|
<option value="67">Telemetry</option>
|
|
</select>
|
|
<label class="reach-limit-label" for="reach-packet-limit">Packets to review</label>
|
|
<select class="form-select" id="reach-packet-limit" style="max-width:110px;">
|
|
<option value="10">10</option>
|
|
<option value="20" selected>20</option>
|
|
<option value="30">30</option>
|
|
<option value="40">40</option>
|
|
<option value="50">50</option>
|
|
<option value="60">60</option>
|
|
<option value="70">70</option>
|
|
<option value="80">80</option>
|
|
<option value="90">90</option>
|
|
<option value="100">100</option>
|
|
</select>
|
|
<button class="btn btn-primary" type="submit">Load</button>
|
|
<a class="btn btn-outline-secondary reach-node-link" id="reach-node-link" href="/node/3530776522">
|
|
Back to Node
|
|
</a>
|
|
</form>
|
|
|
|
<div class="reach-table-wrap">
|
|
<table class="table table-dark table-sm table-striped reach-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Reliability Bands</th>
|
|
<th>Gateway Count</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="reach-bucket-body">
|
|
<tr>
|
|
<td colspan="2" class="reach-muted">Loading statistics...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="reach-summary">
|
|
<div class="reach-metric">
|
|
<div class="text-secondary">Latest Packets</div>
|
|
<div class="reach-metric-value" id="reach-packet-count">...</div>
|
|
</div>
|
|
<div class="reach-metric">
|
|
<div class="text-secondary">Unique Gateways</div>
|
|
<div class="reach-metric-value" id="reach-gateway-count">...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="reach-gateway-panels">
|
|
<div class="reach-gateway-panel">
|
|
<table class="table table-dark table-sm table-striped reach-table reach-gateway-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Gateway</th>
|
|
<th>Heard</th>
|
|
<th>Reliability</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="reach-packet-body-left">
|
|
<tr>
|
|
<td colspan="3" class="reach-muted">Loading packets...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="reach-gateway-panel">
|
|
<table class="table table-dark table-sm table-striped reach-table reach-gateway-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Gateway</th>
|
|
<th>Heard</th>
|
|
<th>Reliability</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="reach-packet-body-right">
|
|
<tr>
|
|
<td colspan="3" class="reach-muted">Loading packets...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<div class="reliability-modal" id="gateway-packet-modal" role="dialog" aria-modal="true" aria-labelledby="gateway-packet-title">
|
|
<div class="reliability-modal-panel">
|
|
<div class="reliability-modal-header">
|
|
<div class="reliability-modal-title" id="gateway-packet-title">Packet Details</div>
|
|
<button class="btn btn-sm btn-outline-light" type="button" onclick="closeGatewayPacketModal()">Close</button>
|
|
</div>
|
|
<div class="reach-table-wrap">
|
|
<table class="table table-dark table-sm table-striped reach-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Packet</th>
|
|
<th>Time</th>
|
|
<th>Type</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="gateway-packet-body">
|
|
<tr>
|
|
<td colspan="4" class="reach-muted">No packet selected.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/static/portmaps.js"></script>
|
|
<script>
|
|
const defaultReachNodeId = 3530776522;
|
|
const defaultReachPacketLimit = 20;
|
|
const initialReachNodeId = Number(new URLSearchParams(window.location.search).get("node_id")) || defaultReachNodeId;
|
|
let reachNodeId = initialReachNodeId;
|
|
let reachNodes = [];
|
|
let reliabilityGatewayDetails = new Map();
|
|
const countedReliabilityPorts = new Set([1, 3, 4, 67]);
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function gatewayLabel(nodeId, nodesById) {
|
|
const node = nodesById.get(Number(nodeId));
|
|
const name = node?.long_name || node?.short_name;
|
|
return name ? `${name} (${nodeId})` : String(nodeId);
|
|
}
|
|
|
|
function nodeLink(nodeId, nodesById) {
|
|
return `<a href="/node/${encodeURIComponent(nodeId)}">${
|
|
escapeHtml(gatewayLabel(nodeId, nodesById))
|
|
}</a>`;
|
|
}
|
|
|
|
function nodeSearchLabel(node) {
|
|
const name = node.long_name || node.short_name || "Unnamed";
|
|
return `${name} (${node.node_id})`;
|
|
}
|
|
|
|
function formatPacketTime(us) {
|
|
if (!us) return "N/A";
|
|
return new Date(us / 1000).toLocaleString();
|
|
}
|
|
|
|
function packetTypeTag(portnum) {
|
|
const name = window.PORT_MAP?.[portnum] || "Unknown";
|
|
const color = window.PORT_COLORS?.[portnum] || "#6c757d";
|
|
return `
|
|
<span class="port-tag" style="background-color:${color}">
|
|
${escapeHtml(name)}
|
|
</span>
|
|
<span class="text-secondary">(${escapeHtml(portnum ?? "?")})</span>
|
|
`;
|
|
}
|
|
|
|
function isSkippedReliabilityPacket(packet) {
|
|
return !countedReliabilityPorts.has(Number(packet.portnum));
|
|
}
|
|
|
|
function openGatewayPacketModal(nodeId) {
|
|
const details = reliabilityGatewayDetails.get(Number(nodeId));
|
|
const titleEl = document.getElementById("gateway-packet-title");
|
|
const bodyEl = document.getElementById("gateway-packet-body");
|
|
const modalEl = document.getElementById("gateway-packet-modal");
|
|
|
|
if (!details) return;
|
|
|
|
titleEl.textContent = `${details.label}: ${details.heardCount} (${details.total}) packets heard`;
|
|
bodyEl.innerHTML = details.packets.map(packet => {
|
|
const statusClass = packet.skipped
|
|
? "reliability-status-skipped"
|
|
: packet.heard ? "reliability-status-heard" : "reliability-status-missed";
|
|
const statusText = packet.heard ? "Heard" : "Missed";
|
|
|
|
return `
|
|
<tr class="${packet.skipped ? "reliability-row-skipped" : ""}">
|
|
<td><a href="/packet/${packet.id}">${packet.id}</a></td>
|
|
<td>${escapeHtml(formatPacketTime(packet.import_time_us))}</td>
|
|
<td>${packetTypeTag(packet.portnum)}</td>
|
|
<td class="${statusClass}">${statusText}</td>
|
|
</tr>
|
|
`;
|
|
}).join("");
|
|
modalEl.classList.add("open");
|
|
}
|
|
|
|
function closeGatewayPacketModal() {
|
|
document.getElementById("gateway-packet-modal").classList.remove("open");
|
|
}
|
|
|
|
function parseNodeSearchValue(value) {
|
|
const trimmed = value.trim();
|
|
const parenthesizedId = trimmed.match(/\((\d+)\)\s*$/);
|
|
if (parenthesizedId) return Number(parenthesizedId[1]);
|
|
|
|
const rawId = trimmed.match(/^\d+$/);
|
|
if (rawId) return Number(trimmed);
|
|
|
|
const lower = trimmed.toLowerCase();
|
|
const node = reachNodes.find(n =>
|
|
(n.long_name || "").toLowerCase() === lower ||
|
|
(n.short_name || "").toLowerCase() === lower
|
|
);
|
|
return node ? Number(node.node_id) : null;
|
|
}
|
|
|
|
async function fetchJson(url) {
|
|
const res = await fetch(url);
|
|
if (!res.ok) {
|
|
throw new Error(`${url} returned ${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
async function loadReachNodes() {
|
|
const data = await fetchJson("/api/nodes?days_active=3");
|
|
reachNodes = data.nodes || [];
|
|
|
|
const optionsEl = document.getElementById("reach-node-options");
|
|
optionsEl.innerHTML = reachNodes
|
|
.filter(node => node.node_id)
|
|
.map(node => `<option value="${escapeHtml(nodeSearchLabel(node))}"></option>`)
|
|
.join("");
|
|
|
|
return reachNodes;
|
|
}
|
|
|
|
async function loadReachReport(nodeId = reachNodeId) {
|
|
reachNodeId = Number(nodeId);
|
|
const packetLimit = Number(document.getElementById("reach-packet-limit").value) || defaultReachPacketLimit;
|
|
const packetType = document.getElementById("reliability-packet-type").value;
|
|
const packetCountEl = document.getElementById("reach-packet-count");
|
|
const gatewayCountEl = document.getElementById("reach-gateway-count");
|
|
const nodeLinkEl = document.getElementById("reach-node-link");
|
|
const bodyEls = [
|
|
document.getElementById("reach-packet-body-left"),
|
|
document.getElementById("reach-packet-body-right"),
|
|
];
|
|
const bucketBodyEl = document.getElementById("reach-bucket-body");
|
|
const setGatewayTables = html => {
|
|
bodyEls.forEach(el => {
|
|
el.innerHTML = html;
|
|
});
|
|
};
|
|
|
|
packetCountEl.textContent = "...";
|
|
gatewayCountEl.textContent = "...";
|
|
reliabilityGatewayDetails = new Map();
|
|
nodeLinkEl.href = `/node/${encodeURIComponent(reachNodeId)}`;
|
|
bucketBodyEl.innerHTML = '<tr><td colspan="2" class="reach-muted">Loading statistics...</td></tr>';
|
|
setGatewayTables('<tr><td colspan="3" class="reach-muted">Loading packets...</td></tr>');
|
|
|
|
try {
|
|
const packetUrl = new URL("/api/packets", window.location.origin);
|
|
packetUrl.searchParams.set("from_node_id", reachNodeId);
|
|
packetUrl.searchParams.set("limit", packetLimit);
|
|
if (packetType) packetUrl.searchParams.set("portnum", packetType);
|
|
|
|
const [packetData] = await Promise.all([
|
|
fetchJson(packetUrl),
|
|
]);
|
|
|
|
const packets = packetData.packets || [];
|
|
const nodesById = new Map(reachNodes.map(node => [Number(node.node_id), node]));
|
|
|
|
packetCountEl.textContent = packets.length;
|
|
|
|
if (packets.length === 0) {
|
|
gatewayCountEl.textContent = "0";
|
|
bucketBodyEl.innerHTML = '<tr><td colspan="2" class="reach-muted">No packets found.</td></tr>';
|
|
setGatewayTables('<tr><td colspan="3" class="reach-muted">No packets found.</td></tr>');
|
|
return;
|
|
}
|
|
|
|
const seenResults = await Promise.all(
|
|
packets.map(packet =>
|
|
fetchJson(`/api/packets_seen/${packet.id}`)
|
|
.then(data => ({ packet, seen: data.seen || [] }))
|
|
.catch(err => {
|
|
console.error("Failed to load packet seen rows", packet.id, err);
|
|
return { packet, seen: [], error: true };
|
|
})
|
|
)
|
|
);
|
|
|
|
const gatewayStats = new Map();
|
|
const reportResults = seenResults.filter(result => !isSkippedReliabilityPacket(result.packet));
|
|
reportResults.forEach(result => {
|
|
const gatewaysForPacket = new Set(result.seen.map(row => Number(row.node_id)));
|
|
gatewaysForPacket.delete(reachNodeId);
|
|
gatewaysForPacket.forEach(nodeId => {
|
|
gatewayStats.set(nodeId, (gatewayStats.get(nodeId) || 0) + 1);
|
|
});
|
|
});
|
|
|
|
const gatewayRows = [...gatewayStats.entries()]
|
|
.map(([nodeId, heardCount]) => ({
|
|
nodeId,
|
|
heardCount,
|
|
percent: reportResults.length ? (heardCount / reportResults.length) * 100 : 0,
|
|
}))
|
|
.sort((a, b) => b.percent - a.percent || b.heardCount - a.heardCount || a.nodeId - b.nodeId);
|
|
|
|
gatewayCountEl.textContent = gatewayRows.length;
|
|
const buckets = [
|
|
{ label: "100%", color: "#22c55e", matches: row => row.percent === 100 },
|
|
{ label: "90-99%", color: "#84cc16", matches: row => row.percent >= 90 && row.percent < 100 },
|
|
{ label: "80-89%", color: "#eab308", matches: row => row.percent >= 80 && row.percent < 90 },
|
|
{ label: "70-79%", color: "#f97316", matches: row => row.percent >= 70 && row.percent < 80 },
|
|
{ label: "60-69%", color: "#ef4444", matches: row => row.percent >= 60 && row.percent < 70 },
|
|
{ label: "50-59%", color: "#991b1b", matches: row => row.percent >= 50 && row.percent < 60 },
|
|
];
|
|
const bucketRows = buckets.map(bucket => ({
|
|
label: bucket.label,
|
|
color: bucket.color,
|
|
count: gatewayRows.filter(bucket.matches).length,
|
|
}));
|
|
const maxBucketCount = Math.max(...bucketRows.map(row => row.count), 1);
|
|
bucketBodyEl.innerHTML = bucketRows.map(row => {
|
|
const width = row.count > 0 ? Math.max((row.count / maxBucketCount) * 100, 8) : 0;
|
|
return `
|
|
<tr>
|
|
<td>${row.label}</td>
|
|
<td class="reach-count-bar-cell">
|
|
<div class="reach-count-bar-track">
|
|
<div class="reach-count-bar-fill" style="width:${width}%; --reach-bar-color:${row.color};"></div>
|
|
<div class="reach-count-bar-value">${row.count}</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join("");
|
|
|
|
const renderGatewayRows = rows => rows.length
|
|
? rows.map(row => `
|
|
<tr>
|
|
<td>${nodeLink(row.nodeId, nodesById)}</td>
|
|
<td>
|
|
<button class="reach-heard-link" type="button" onclick="openGatewayPacketModal(${row.nodeId})">
|
|
${row.heardCount}
|
|
</button>
|
|
<span class="text-secondary">(${reportResults.length})</span>
|
|
</td>
|
|
<td>${row.percent.toFixed(1)}%</td>
|
|
</tr>
|
|
`).join("")
|
|
: '<tr><td colspan="3" class="reach-muted">No gateways heard these packets.</td></tr>';
|
|
reliabilityGatewayDetails = new Map(gatewayRows.map(row => [
|
|
Number(row.nodeId),
|
|
{
|
|
label: gatewayLabel(row.nodeId, nodesById),
|
|
heardCount: row.heardCount,
|
|
total: reportResults.length,
|
|
packets: seenResults.map(result => ({
|
|
id: result.packet.id,
|
|
import_time_us: result.packet.import_time_us,
|
|
portnum: result.packet.portnum,
|
|
skipped: isSkippedReliabilityPacket(result.packet),
|
|
heard: new Set(result.seen.map(seen => Number(seen.node_id))).has(Number(row.nodeId)),
|
|
})),
|
|
}
|
|
]));
|
|
const splitIndex = Math.ceil(gatewayRows.length / 2);
|
|
bodyEls[0].innerHTML = renderGatewayRows(gatewayRows.slice(0, splitIndex));
|
|
bodyEls[1].innerHTML = gatewayRows.length > 1
|
|
? renderGatewayRows(gatewayRows.slice(splitIndex))
|
|
: '<tr><td colspan="3" class="reach-muted">No additional gateways.</td></tr>';
|
|
} catch (err) {
|
|
console.error("Failed to load reach report:", err);
|
|
packetCountEl.textContent = "!";
|
|
gatewayCountEl.textContent = "!";
|
|
bucketBodyEl.innerHTML = '<tr><td colspan="2" class="text-danger">Failed to load statistics.</td></tr>';
|
|
setGatewayTables('<tr><td colspan="3" class="text-danger">Failed to load report.</td></tr>');
|
|
}
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", async () => {
|
|
document.getElementById("gateway-packet-modal").addEventListener("click", event => {
|
|
if (event.target.id === "gateway-packet-modal") closeGatewayPacketModal();
|
|
});
|
|
|
|
document.getElementById("reach-search-form").addEventListener("submit", event => {
|
|
event.preventDefault();
|
|
|
|
const nodeId = parseNodeSearchValue(document.getElementById("reach-node-search").value);
|
|
if (!nodeId) {
|
|
document.getElementById("reach-bucket-body").innerHTML =
|
|
'<tr><td colspan="2" class="text-warning">Enter a valid node ID or select a node.</td></tr>';
|
|
document.getElementById("reach-packet-body-left").innerHTML =
|
|
'<tr><td colspan="3" class="text-warning">Enter a valid node ID or select a node.</td></tr>';
|
|
document.getElementById("reach-packet-body-right").innerHTML =
|
|
'<tr><td colspan="3" class="text-warning">Enter a valid node ID or select a node.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
loadReachReport(nodeId);
|
|
});
|
|
|
|
try {
|
|
await loadReachNodes();
|
|
} catch (err) {
|
|
console.error("Failed to load node list:", err);
|
|
}
|
|
|
|
const searchEl = document.getElementById("reach-node-search");
|
|
searchEl.value = nodeSearchLabel(
|
|
reachNodes.find(node => Number(node.node_id) === initialReachNodeId) ||
|
|
{ node_id: initialReachNodeId, long_name: "Node" }
|
|
);
|
|
loadReachReport(initialReachNodeId);
|
|
});
|
|
</script>
|
|
{% endblock %}
|