mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
546 lines
21 KiB
Plaintext
546 lines
21 KiB
Plaintext
<!doctype html>
|
|
|
|
<!--
|
|
Copyright (C) 2025 l5yth
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
-->
|
|
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
<title><%= site_name %></title>
|
|
<% refresh_interval_seconds = 60 %>
|
|
|
|
<!-- Leaflet CSS/JS (CDN) -->
|
|
<link
|
|
rel="stylesheet"
|
|
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
crossorigin=""
|
|
/>
|
|
<script
|
|
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
crossorigin=""
|
|
></script>
|
|
|
|
<style>
|
|
:root { --pad: 16px; }
|
|
body { font-family: system-ui, Segoe UI, Roboto, Ubuntu, Arial, sans-serif; margin: var(--pad); padding-bottom: 32px; }
|
|
h1 { margin: 0 0 8px }
|
|
.meta { color:#555; margin-bottom:12px }
|
|
.pill{ display:inline-block; padding:2px 8px; border-radius:999px; background:#eee; font-size:12px }
|
|
#map { flex: 1; height: 60vh; border: 1px solid #ddd; border-radius: 8px; }
|
|
table { border-collapse: collapse; width: 100%; margin: 0; }
|
|
th, td { padding: 4px 6px; text-align: left; }
|
|
th { position: sticky; top: 0; background: #fafafa; }
|
|
.mono { font-family: ui-monospace, Menlo, Consolas, monospace; }
|
|
.row { display: flex; gap: var(--pad); align-items: center; justify-content: space-between; }
|
|
.map-row { display: flex; gap: var(--pad); align-items: stretch; }
|
|
#chat { flex: 0 0 33%; max-width: 33%; height: 60vh; border: 1px solid #ddd; border-radius: 8px; overflow-y: auto; padding: 6px; font-size: 12px; }
|
|
.chat-entry-node { font-family: ui-monospace, Menlo, Consolas, monospace; color: #555 }
|
|
.chat-entry-msg { font-family: ui-monospace, Menlo, Consolas, monospace; }
|
|
.chat-entry-date { font-family: ui-monospace, Menlo, Consolas, monospace; font-weight: bold; }
|
|
.short-name { display:inline-block; border-radius:4px; padding:0 2px; }
|
|
.meta-info { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
|
|
.refresh-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: start; width: 100%; }
|
|
.refresh-info { margin: 0; color: #555; }
|
|
.refresh-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-self: end; }
|
|
.controls { display: flex; gap: 8px; align-items: center; }
|
|
.controls label { display: inline-flex; align-items: center; gap: 6px; }
|
|
button { padding: 6px 10px; border: 1px solid #ccc; background: #fff; border-radius: 6px; cursor: pointer; }
|
|
button:hover { background: #f6f6f6; }
|
|
label { font-size: 14px; color: #333; }
|
|
input[type="text"] { padding: 6px 10px; border: 1px solid #ccc; border-radius: 6px; }
|
|
.legend { background: #fff; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 12px; line-height: 18px; }
|
|
.legend span { display: inline-block; width: 12px; height: 12px; margin-right: 6px; vertical-align: middle; }
|
|
#map .leaflet-tile { filter: opacity(70%); }
|
|
#nodes { font-size: 12px; }
|
|
footer { position: fixed; bottom: 0; left: var(--pad); width: calc(100% - 2 * var(--pad)); background: #fafafa; border-top: 1px solid #ddd; text-align: center; font-size: 12px; padding: 4px 0; }
|
|
@media (max-width: 768px) {
|
|
.row { flex-direction: column; align-items: stretch; gap: var(--pad); }
|
|
.map-row { flex-direction: column; }
|
|
.controls { order: 2; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; width: 100%; gap: 12px; }
|
|
.controls input[type="text"] { width: 100%; }
|
|
.controls button { justify-self: end; }
|
|
.meta-info { order: 1; width: 100%; }
|
|
.refresh-row { grid-template-columns: 1fr; row-gap: 8px; }
|
|
.refresh-actions { flex-direction: row; align-items: center; gap: 8px; justify-self: start; flex-wrap: nowrap; }
|
|
#map { order: 1; flex: none; max-width: 100%; height: 50vh; }
|
|
#chat { order: 2; flex: none; max-width: 100%; height: 30vh; }
|
|
#nodes th:nth-child(1),
|
|
#nodes td:nth-child(1),
|
|
#nodes th:nth-child(5),
|
|
#nodes td:nth-child(5),
|
|
#nodes th:nth-child(6),
|
|
#nodes td:nth-child(6),
|
|
#nodes th:nth-child(9),
|
|
#nodes td:nth-child(9),
|
|
#nodes th:nth-child(12),
|
|
#nodes td:nth-child(12),
|
|
#nodes th:nth-child(13),
|
|
#nodes td:nth-child(13),
|
|
#nodes th:nth-child(14),
|
|
#nodes td:nth-child(14),
|
|
#nodes th:nth-child(15),
|
|
#nodes td:nth-child(15) {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
/* Dark mode overrides */
|
|
body.dark { background: #111; color: #eee; }
|
|
body.dark .meta { color: #bbb; }
|
|
body.dark .refresh-info { color: #bbb; }
|
|
body.dark .pill { background: #444; }
|
|
body.dark #map { border-color: #444; }
|
|
body.dark #chat { border-color: #444; background: #222; color: #eee; }
|
|
body.dark th { background: #222; }
|
|
body.dark button { background: #333; border-color: #444; color: #eee; }
|
|
body.dark button:hover { background: #444; }
|
|
body.dark label { color: #ddd; }
|
|
body.dark input[type="text"] { background: #222; color: #eee; border-color: #444; }
|
|
body.dark .legend { background: #333; border-color: #444; color: #eee; }
|
|
body.dark footer { background: #222; border-top-color: #444; color: #eee; }
|
|
body.dark a { color: #9bd; }
|
|
body.dark .chat-entry-node { color: #777 }
|
|
body.dark .chat-entry-msg { color: #bbb }
|
|
body.dark .short-name { color: #555 }
|
|
body.dark .chat-entry-date { color: #bbb }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1><%= site_name %></h1>
|
|
<div class="row meta">
|
|
<div class="meta-info">
|
|
<div class="refresh-row">
|
|
<p id="refreshInfo" class="refresh-info" aria-live="polite"><%= default_channel %> (<%= default_frequency %>) — active nodes: … — auto-refresh every <%= refresh_interval_seconds %> seconds.</p>
|
|
<div class="refresh-actions">
|
|
<button id="refreshBtn" type="button">Refresh now</button>
|
|
<span id="status" class="pill">loading…</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="controls">
|
|
<label><input type="checkbox" id="fitBounds" checked /> Auto-fit map</label>
|
|
<input type="text" id="filterInput" placeholder="Filter nodes" />
|
|
<button id="themeToggle" type="button" aria-label="Toggle dark mode">🌙</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="map-row">
|
|
<div id="chat" aria-label="Chat log"></div>
|
|
<div id="map" role="region" aria-label="Nodes map"></div>
|
|
</div>
|
|
|
|
<table id="nodes">
|
|
<thead>
|
|
<tr>
|
|
<th>Node ID</th>
|
|
<th>Short</th>
|
|
<th>Long Name</th>
|
|
<th>Last Seen</th>
|
|
<th>Role</th>
|
|
<th>HW Model</th>
|
|
<th>Battery</th>
|
|
<th>Voltage</th>
|
|
<th>Uptime</th>
|
|
<th>Channel Util</th>
|
|
<th>Air Util Tx</th>
|
|
<th>Latitude</th>
|
|
<th>Longitude</th>
|
|
<th>Altitude</th>
|
|
<th>Last Position</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
|
|
<footer>
|
|
PotatoMesh GitHub: <a href="https://github.com/l5yth/potato-mesh" target="_blank">l5yth/potato-mesh</a>
|
|
<% if matrix_room && !matrix_room.empty? %>
|
|
— <%= site_name %> Matrix:
|
|
<a href="https://matrix.to/#/<%= matrix_room %>" target="_blank"><%= matrix_room %></a>
|
|
<% end %>
|
|
</footer>
|
|
|
|
<script>
|
|
const statusEl = document.getElementById('status');
|
|
const fitBoundsEl = document.getElementById('fitBounds');
|
|
const refreshBtn = document.getElementById('refreshBtn');
|
|
const filterInput = document.getElementById('filterInput');
|
|
const themeToggle = document.getElementById('themeToggle');
|
|
const titleEl = document.querySelector('title');
|
|
const headerEl = document.querySelector('h1');
|
|
const chatEl = document.getElementById('chat');
|
|
const refreshInfo = document.getElementById('refreshInfo');
|
|
const baseTitle = document.title;
|
|
let allNodes = [];
|
|
const seenNodeIds = new Set();
|
|
const seenMessageIds = new Set();
|
|
let lastChatDate;
|
|
const NODE_LIMIT = 1000;
|
|
const CHAT_LIMIT = 1000;
|
|
const REFRESH_MS = <%= refresh_interval_seconds * 1000 %>;
|
|
refreshInfo.textContent = `<%= default_channel %> (<%= default_frequency %>) — active nodes: … — auto-refresh every ${REFRESH_MS / 1000} seconds.`;
|
|
|
|
const MAP_CENTER = L.latLng(<%= map_center_lat %>, <%= map_center_lon %>);
|
|
const MAX_NODE_DISTANCE_KM = <%= max_node_distance_km %>;
|
|
|
|
const roleColors = Object.freeze({
|
|
CLIENT: '#A8D5BA',
|
|
CLIENT_HIDDEN: '#B8DCA9',
|
|
CLIENT_MUTE: '#D2E3A2',
|
|
TRACKER: '#E8E6A1',
|
|
SENSOR: '#F4E3A3',
|
|
LOST_AND_FOUND: '#F9D4A6',
|
|
REPEATER: '#F7B7A3',
|
|
ROUTER_LATE: '#F29AA3',
|
|
ROUTER: '#E88B94'
|
|
});
|
|
|
|
// --- Map setup ---
|
|
const map = L.map('map', { worldCopyJump: true });
|
|
const lightTiles = L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png', {
|
|
maxZoom: 18,
|
|
attribution: '© OpenStreetMap contributors & Stadia Maps'
|
|
});
|
|
const darkTiles = L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}.png', {
|
|
maxZoom: 18,
|
|
attribution: '© OpenStreetMap contributors & Stadia Maps'
|
|
});
|
|
let tiles = lightTiles.addTo(map);
|
|
// Default view until first data arrives
|
|
map.setView(MAP_CENTER, 10);
|
|
|
|
const markersLayer = L.layerGroup().addTo(map);
|
|
|
|
const legend = L.control({ position: 'bottomright' });
|
|
legend.onAdd = function () {
|
|
const div = L.DomUtil.create('div', 'legend');
|
|
for (const [role, color] of Object.entries(roleColors)) {
|
|
div.innerHTML += `<div><span style="background:${color}"></span>${role}</div>`;
|
|
}
|
|
return div;
|
|
};
|
|
legend.addTo(map);
|
|
|
|
themeToggle.addEventListener('click', () => {
|
|
const dark = document.body.classList.toggle('dark');
|
|
themeToggle.textContent = dark ? '☀️' : '🌙';
|
|
map.removeLayer(tiles);
|
|
tiles = dark ? darkTiles : lightTiles;
|
|
tiles.addTo(map);
|
|
});
|
|
|
|
// --- Helpers ---
|
|
function escapeHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function renderShortHtml(short, role){
|
|
if (!short) {
|
|
return `<span class="short-name" style="background:#ccc">? </span>`;
|
|
}
|
|
const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, ' ');
|
|
const color = roleColors[role] || roleColors.CLIENT;
|
|
return `<span class="short-name" style="background:${color}">${padded}</span>`;
|
|
}
|
|
|
|
function appendChatEntry(div) {
|
|
chatEl.appendChild(div);
|
|
while (chatEl.childElementCount > CHAT_LIMIT) {
|
|
chatEl.removeChild(chatEl.firstChild);
|
|
}
|
|
chatEl.scrollTop = chatEl.scrollHeight;
|
|
}
|
|
|
|
function maybeAddDateDivider(ts) {
|
|
if (!ts) return;
|
|
const d = new Date(ts * 1000);
|
|
const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
if (lastChatDate !== key) {
|
|
lastChatDate = key;
|
|
const midnight = new Date(d);
|
|
midnight.setHours(0, 0, 0, 0);
|
|
const div = document.createElement('div');
|
|
div.className = 'chat-entry-date';
|
|
div.textContent = `-- ${formatDate(midnight)} --`;
|
|
appendChatEntry(div);
|
|
}
|
|
}
|
|
|
|
function addNewNodeChatEntry(n) {
|
|
maybeAddDateDivider(n.first_heard);
|
|
const div = document.createElement('div');
|
|
const ts = formatTime(new Date(n.first_heard * 1000));
|
|
div.className = 'chat-entry-node';
|
|
const short = renderShortHtml(n.short_name, n.role);
|
|
const longName = escapeHtml(n.long_name || '');
|
|
div.innerHTML = `[${ts}] ${short} <em>New node: ${longName}</em>`;
|
|
appendChatEntry(div);
|
|
}
|
|
|
|
function addNewMessageChatEntry(m) {
|
|
maybeAddDateDivider(m.rx_time);
|
|
const div = document.createElement('div');
|
|
const ts = formatTime(new Date(m.rx_time * 1000));
|
|
const short = renderShortHtml(m.node?.short_name, m.node?.role);
|
|
const text = escapeHtml(m.text || '');
|
|
div.className = 'chat-entry-msg';
|
|
div.innerHTML = `[${ts}] ${short} ${text}`;
|
|
appendChatEntry(div);
|
|
}
|
|
|
|
function pad(n) { return String(n).padStart(2, "0"); }
|
|
|
|
function formatTime(d) {
|
|
return pad(d.getHours()) + ":" +
|
|
pad(d.getMinutes()) + ":" +
|
|
pad(d.getSeconds());
|
|
}
|
|
|
|
function formatDate(d) {
|
|
return d.getFullYear() + "-" +
|
|
pad(d.getMonth() + 1) + "-" +
|
|
pad(d.getDate());
|
|
}
|
|
|
|
function fmtHw(v) {
|
|
return v && v !== "UNSET" ? String(v) : "";
|
|
}
|
|
|
|
function fmtCoords(v, d = 5) {
|
|
if (v == null || v === '') return "";
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n.toFixed(d) : "";
|
|
}
|
|
|
|
function fmtAlt(v, s) {
|
|
return (v == null || v === '') ? "" : `${v}${s}`;
|
|
}
|
|
|
|
function fmtTx(v, d = 3) {
|
|
if (v == null || v === '') return "";
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? `${n.toFixed(d)}%` : "";
|
|
}
|
|
|
|
function timeHum(unixSec) {
|
|
if (!unixSec) return "";
|
|
if (unixSec < 0) return "0s";
|
|
if (unixSec < 60) return `${unixSec}s`;
|
|
if (unixSec < 3600) return `${Math.floor(unixSec/60)}m ${Math.floor((unixSec%60))}s`;
|
|
if (unixSec < 86400) return `${Math.floor(unixSec/3600)}h ${Math.floor((unixSec%3600)/60)}m`;
|
|
return `${Math.floor(unixSec/86400)}d ${Math.floor((unixSec%86400)/3600)}h`;
|
|
}
|
|
|
|
function timeAgo(unixSec, nowSec = Date.now()/1000) {
|
|
if (!unixSec) return "";
|
|
const diff = Math.floor(nowSec - Number(unixSec));
|
|
if (diff < 0) return "0s";
|
|
if (diff < 60) return `${diff}s`;
|
|
if (diff < 3600) return `${Math.floor(diff/60)}m ${Math.floor((diff%60))}s`;
|
|
if (diff < 86400) return `${Math.floor(diff/3600)}h ${Math.floor((diff%3600)/60)}m`;
|
|
return `${Math.floor(diff/86400)}d ${Math.floor((diff%86400)/3600)}h`;
|
|
}
|
|
|
|
async function fetchNodes(limit = NODE_LIMIT) {
|
|
const r = await fetch(`/api/nodes?limit=${limit}`, { cache: 'no-store' });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return r.json();
|
|
}
|
|
|
|
async function fetchMessages(limit = NODE_LIMIT) {
|
|
const r = await fetch(`/api/messages?limit=${limit}`, { cache: 'no-store' });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return r.json();
|
|
}
|
|
|
|
function computeDistances(nodes) {
|
|
for (const n of nodes) {
|
|
const latRaw = n.latitude;
|
|
const lonRaw = n.longitude;
|
|
if (latRaw == null || latRaw === '' || lonRaw == null || lonRaw === '') {
|
|
n.distance_km = null;
|
|
continue;
|
|
}
|
|
const lat = Number(latRaw);
|
|
const lon = Number(lonRaw);
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
|
n.distance_km = null;
|
|
continue;
|
|
}
|
|
n.distance_km = L.latLng(lat, lon).distanceTo(MAP_CENTER) / 1000;
|
|
}
|
|
}
|
|
|
|
function renderTable(nodes, nowSec) {
|
|
const tb = document.querySelector('#nodes tbody');
|
|
const frag = document.createDocumentFragment();
|
|
for (const n of nodes) {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `
|
|
<td class="mono">${n.node_id || ""}</td>
|
|
<td>${renderShortHtml(n.short_name, n.role)}</td>
|
|
<td>${n.long_name || ""}</td>
|
|
<td>${timeAgo(n.last_heard, nowSec)}</td>
|
|
<td>${n.role || "CLIENT"}</td>
|
|
<td>${fmtHw(n.hw_model)}</td>
|
|
<td>${fmtAlt(n.battery_level, "%")}</td>
|
|
<td>${fmtAlt(n.voltage, "V")}</td>
|
|
<td>${timeHum(n.uptime_seconds)}</td>
|
|
<td>${fmtTx(n.channel_utilization)}</td>
|
|
<td>${fmtTx(n.air_util_tx)}</td>
|
|
<td>${fmtCoords(n.latitude)}</td>
|
|
<td>${fmtCoords(n.longitude)}</td>
|
|
<td>${fmtAlt(n.altitude, "m")}</td>
|
|
<td class="mono">${n.pos_time_iso ? `${timeAgo(n.position_time, nowSec)}` : ""}</td>`;
|
|
frag.appendChild(tr);
|
|
}
|
|
tb.replaceChildren(frag);
|
|
}
|
|
|
|
function renderMap(nodes, nowSec) {
|
|
markersLayer.clearLayers();
|
|
const pts = [];
|
|
for (const n of nodes) {
|
|
const latRaw = n.latitude, lonRaw = n.longitude;
|
|
if (latRaw == null || latRaw === '' || lonRaw == null || lonRaw === '') continue;
|
|
const lat = Number(latRaw), lon = Number(lonRaw);
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
|
if (n.distance_km != null && n.distance_km > MAX_NODE_DISTANCE_KM) continue;
|
|
|
|
const color = roleColors[n.role] || '#3388ff';
|
|
const marker = L.circleMarker([lat, lon], {
|
|
radius: 9,
|
|
color: '#000',
|
|
weight: 1,
|
|
fillColor: color,
|
|
fillOpacity: 0.7,
|
|
opacity: 0.7
|
|
});
|
|
const lines = [
|
|
`<b>${n.long_name || ''}</b>`,
|
|
`${renderShortHtml(n.short_name, n.role)} <span class="mono">${n.node_id || ''}</span>`,
|
|
n.hw_model ? `Model: ${fmtHw(n.hw_model)}` : null,
|
|
`Role: ${n.role || 'CLIENT'}`,
|
|
(n.battery_level != null ? `Battery: ${fmtAlt(n.battery_level, "%")}, ${fmtAlt(n.voltage, "V")}` : null),
|
|
(n.last_heard ? `Last seen: ${timeAgo(n.last_heard, nowSec)}` : null),
|
|
(n.pos_time_iso ? `Last Position: ${timeAgo(n.position_time, nowSec)}` : null),
|
|
(n.uptime_seconds ? `Uptime: ${timeHum(n.uptime_seconds)}` : null),
|
|
].filter(Boolean);
|
|
marker.bindPopup(lines.join('<br/>'));
|
|
marker.addTo(markersLayer);
|
|
pts.push([lat, lon]);
|
|
}
|
|
if (pts.length && fitBoundsEl.checked) {
|
|
const b = L.latLngBounds(pts);
|
|
map.fitBounds(b.pad(0.2), { animate: false });
|
|
}
|
|
}
|
|
|
|
function applyFilter() {
|
|
const q = filterInput.value.trim().toLowerCase();
|
|
const nodes = !q ? allNodes : allNodes.filter(n => {
|
|
return [n.node_id, n.short_name, n.long_name]
|
|
.filter(Boolean)
|
|
.some(v => v.toLowerCase().includes(q));
|
|
});
|
|
const nowSec = Date.now()/1000;
|
|
renderTable(nodes, nowSec);
|
|
renderMap(nodes, nowSec);
|
|
updateCount(nodes, nowSec);
|
|
updateRefreshInfo(nodes, nowSec);
|
|
}
|
|
|
|
filterInput.addEventListener('input', applyFilter);
|
|
|
|
async function refresh() {
|
|
try {
|
|
statusEl.textContent = 'refreshing…';
|
|
const nodes = await fetchNodes();
|
|
computeDistances(nodes);
|
|
const newNodes = [];
|
|
for (const n of nodes) {
|
|
if (n.node_id && !seenNodeIds.has(n.node_id)) {
|
|
newNodes.push(n);
|
|
}
|
|
}
|
|
const messages = await fetchMessages();
|
|
const newMessages = [];
|
|
for (const m of messages) {
|
|
if (m.id && !seenMessageIds.has(m.id)) {
|
|
newMessages.push(m);
|
|
}
|
|
}
|
|
const entries = [];
|
|
for (const n of newNodes) entries.push({ type: 'node', ts: n.first_heard ?? 0, item: n });
|
|
for (const m of newMessages) entries.push({ type: 'msg', ts: m.rx_time ?? 0, item: m });
|
|
entries.sort((a, b) => {
|
|
if (a.ts !== b.ts) return a.ts - b.ts;
|
|
return a.type === 'node' && b.type === 'msg' ? -1 : a.type === 'msg' && b.type === 'node' ? 1 : 0;
|
|
});
|
|
for (const e of entries) {
|
|
if (e.type === 'node') {
|
|
addNewNodeChatEntry(e.item);
|
|
if (e.item.node_id) seenNodeIds.add(e.item.node_id);
|
|
} else {
|
|
addNewMessageChatEntry(e.item);
|
|
if (e.item.id) seenMessageIds.add(e.item.id);
|
|
}
|
|
}
|
|
allNodes = nodes;
|
|
applyFilter();
|
|
statusEl.textContent = 'updated ' + new Date().toLocaleTimeString();
|
|
} catch (e) {
|
|
statusEl.textContent = 'error: ' + e.message;
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
refresh();
|
|
setInterval(refresh, REFRESH_MS);
|
|
refreshBtn.addEventListener('click', refresh);
|
|
|
|
function updateCount(nodes, nowSec) {
|
|
const dayAgoSec = nowSec - 86400;
|
|
const count = nodes.filter(n => n.last_heard && Number(n.last_heard) >= dayAgoSec).length;
|
|
const text = `${baseTitle} (${count})`;
|
|
titleEl.textContent = text;
|
|
headerEl.textContent = text;
|
|
}
|
|
|
|
function updateRefreshInfo(nodes, nowSec) {
|
|
const windows = [
|
|
{ label: 'hour', secs: 3600 },
|
|
{ label: 'day', secs: 86400 },
|
|
{ label: 'week', secs: 7 * 86400 },
|
|
];
|
|
const counts = windows.map(w => {
|
|
const c = nodes.filter(n => n.last_heard && nowSec - Number(n.last_heard) <= w.secs).length;
|
|
return `${c}/${w.label}`;
|
|
}).join(', ');
|
|
refreshInfo.textContent = `<%= default_channel %> (<%= default_frequency %>) — active nodes: ${counts} — auto-refresh every ${REFRESH_MS / 1000} seconds.`;
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|