Files
meshview/meshview/templates/reliability.html
T
pablorevilla-meshtastic 82323a8783 changes to calculations
2026-05-14 07:40:04 -07:00

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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 %}