Files
potato-mesh/web/views/index.erb
T
2025-09-20 20:59:21 +02:00

1223 lines
53 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
<link rel="icon" type="image/svg+xml" href="/potatomesh-logo.svg" />
<% refresh_interval_seconds = 60 %>
<% tile_filter_light = "grayscale(1) saturate(0) brightness(0.92) contrast(1.05)" %>
<% tile_filter_dark = "grayscale(1) invert(1) brightness(0.9) contrast(1.08)" %>
<!-- 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;
--map-tile-filter-light: <%= tile_filter_light %>;
--map-tile-filter-dark: <%= tile_filter_dark %>;
}
body {
font-family: system-ui, Segoe UI, Roboto, Ubuntu, Arial, sans-serif;
margin: var(--pad);
padding-bottom: 32px;
--map-tiles-filter: var(--map-tile-filter-light);
}
h1 { margin: 0 0 8px }
.site-title { display: inline-flex; align-items: center; gap: 12px; }
.site-title img { width: 52px; height: 52px; display: block; border-radius: 12px; }
.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; }
.short-name[data-node-info] { cursor: pointer; }
.short-info-overlay { position: absolute; background: #fff; color: #111; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); padding: 8px 10px 10px; font-size: 11px; line-height: 1.4; min-width: 200px; max-width: 240px; z-index: 2000; }
.short-info-overlay[hidden] { display: none; }
.short-info-overlay .short-info-close { position: absolute; top: 4px; right: 4px; border: none; background: transparent; font-size: 14px; line-height: 1; padding: 2px; border-radius: 4px; cursor: pointer; color: inherit; }
.short-info-overlay .short-info-close:hover { background: rgba(0, 0, 0, 0.08); }
.short-info-content { margin: 0; }
.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; }
.auto-refresh-toggle { display: inline-flex; align-items: center; gap: 6px; }
.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; }
.sort-button { padding: 0; border: none; background: none; color: inherit; font: inherit; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; }
.sort-button:hover { background: none; }
.sort-button:focus-visible { outline: 2px solid #4a90e2; outline-offset: 2px; }
.sort-indicator { font-size: 0.75em; opacity: 0.6; }
th[aria-sort] .sort-indicator { opacity: 1; }
label { font-size: 14px; color: #333; }
input[type="text"] { padding: 6px 10px; border: 1px solid #ccc; border-radius: 6px; }
.legend { position: relative; background: #fff; padding: 8px 10px 10px; border: 1px solid #ccc; border-radius: 8px; font-size: 12px; line-height: 18px; min-width: 160px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); }
.legend-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; font-weight: 600; }
.legend-title { font-size: 13px; }
.legend-close { border: none; background: transparent; cursor: pointer; padding: 2px; line-height: 1; font-size: 16px; border-radius: 4px; color: inherit; }
.legend-close:hover { background: rgba(0, 0, 0, 0.08); }
.legend-items { display: flex; flex-direction: column; gap: 4px; }
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-swatch { display: inline-block; width: 12px; height: 12px; border-radius: 2px; }
.legend-hidden { display: none !important; }
.legend-toggle { margin-top: 8px; }
.legend-toggle-button { font-size: 12px; }
#map .leaflet-tile-pane,
#map .leaflet-layer,
#map .leaflet-tile.map-tiles {
opacity: 0.75;
filter: var(--map-tiles-filter, var(--map-tile-filter-light));
-webkit-filter: var(--map-tiles-filter, var(--map-tile-filter-light));
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: #fff;
color: #333;
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.4);
}
#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; }
.info-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; padding: var(--pad); z-index: 1000; }
.info-overlay[hidden] { display: none; }
.info-dialog { background: #fff; color: #111; max-width: 420px; width: min(100%, 420px); border-radius: 12px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2); position: relative; padding: 20px 24px; outline: none; }
.info-dialog:focus { outline: 2px solid #4a90e2; outline-offset: 4px; }
.info-close { position: absolute; top: 10px; right: 10px; padding: 4px; border: none; background: transparent; font-size: 20px; line-height: 1; border-radius: 999px; }
.info-close:hover { background: rgba(0, 0, 0, 0.06); }
.info-title { margin: 0 0 8px; font-size: 20px; }
.info-intro { margin: 0 0 12px; font-size: 14px; color: #444; }
.info-details { margin: 0; font-size: 14px; line-height: 1.6; }
.info-details dt { font-weight: 600; margin-top: 12px; color: #222; }
.info-details dd { margin: 4px 0 0; }
.info-details dd a { color: inherit; word-break: break-word; }
@media (max-width: 1280px) {
#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;
}
}
@media (max-width: 768px) {
.row { flex-direction: column; align-items: stretch; gap: var(--pad); }
.site-title img { width: 44px; height: 44px; }
.map-row { flex-direction: column; }
.controls { order: 2; display: grid; grid-template-columns: auto minmax(0, 1fr) auto 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;
}
.legend { max-width: min(240px, 80vw); }
}
/* Dark mode overrides */
body.dark {
background: #111;
color: #eee;
--map-tiles-filter: var(--map-tile-filter-dark);
}
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 .sort-button { background: none; border: none; color: inherit; }
body.dark .sort-button:hover { background: none; }
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; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); }
body.dark .legend-close:hover { background: rgba(255, 255, 255, 0.1); }
body.dark .legend-toggle-button { background: #333; border-color: #444; color: #eee; }
body.dark .legend-toggle-button:hover { background: #444; }
body.dark .leaflet-popup-content-wrapper,
body.dark .leaflet-popup-tip {
background: #333;
color: #eee;
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.8);
}
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 }
body.dark .info-overlay { background: rgba(0, 0, 0, 0.7); }
body.dark .info-dialog { background: #1c1c1c; color: #eee; border: 1px solid #444; }
body.dark .info-intro { color: #bbb; }
body.dark .info-details dt { color: #ddd; }
body.dark .info-close:hover { background: rgba(255, 255, 255, 0.1); }
body.dark .short-info-overlay { background: #1c1c1c; border-color: #444; color: #eee; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.55); }
body.dark .short-info-overlay .short-info-close:hover { background: rgba(255, 255, 255, 0.1); }
</style>
<style id="map-tiles-light">
body:not(.dark) {
--map-tiles-filter: <%= tile_filter_light %>;
}
body:not(.dark) #map .leaflet-tile-pane,
body:not(.dark) #map .leaflet-layer,
body:not(.dark) #map .leaflet-tile.map-tiles {
filter: <%= tile_filter_light %>;
-webkit-filter: <%= tile_filter_light %>;
}
</style>
<style id="map-tiles-dark">
body.dark {
--map-tiles-filter: <%= tile_filter_dark %>;
}
body.dark #map .leaflet-tile-pane,
body.dark #map .leaflet-layer,
body.dark #map .leaflet-tile.map-tiles {
filter: <%= tile_filter_dark %>;
-webkit-filter: <%= tile_filter_dark %>;
}
</style>
</head>
<body>
<h1 class="site-title">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
</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: …</p>
<div class="refresh-actions">
<label class="auto-refresh-toggle"><input type="checkbox" id="autoRefresh" checked /> Auto-refresh every <%= refresh_interval_seconds %> seconds</label>
<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>
<button id="infoBtn" type="button" aria-haspopup="dialog" aria-controls="infoOverlay" aria-label="Show site information">️ Info</button>
</div>
</div>
<div id="infoOverlay" class="info-overlay" role="dialog" aria-modal="true" aria-labelledby="infoTitle" hidden>
<div class="info-dialog" tabindex="-1">
<button type="button" class="info-close" id="infoClose" aria-label="Close site information">×</button>
<h2 id="infoTitle" class="info-title">About <%= site_name %></h2>
<p class="info-intro">Quick facts about this PotatoMesh instance.</p>
<dl class="info-details">
<dt>Default channel</dt>
<dd><%= default_channel %></dd>
<dt>Frequency</dt>
<dd><%= default_frequency %></dd>
<dt>Map center</dt>
<dd><%= format("%.5f, %.5f", map_center_lat, map_center_lon) %></dd>
<dt>Visible range</dt>
<dd>Nodes within roughly <%= max_node_distance_km %> km of the center are shown.</dd>
<dt>Auto-refresh</dt>
<dd>Updates every <%= refresh_interval_seconds %> seconds.</dd>
<% if matrix_room && !matrix_room.empty? %>
<dt>Matrix room</dt>
<dd><a href="https://matrix.to/#/<%= matrix_room %>" target="_blank" rel="noreferrer noopener"><%= matrix_room %></a></dd>
<% end %>
</dl>
</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><button type="button" class="sort-button" data-sort-key="node_id" data-sort-label="Node ID">Node ID <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="short_name" data-sort-label="Short Name">Short <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="long_name" data-sort-label="Long Name">Long Name <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="last_heard" data-sort-label="Last Seen">Last Seen <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="role" data-sort-label="Role">Role <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="hw_model" data-sort-label="Hardware Model">HW Model <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="battery_level" data-sort-label="Battery Level">Battery <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="voltage" data-sort-label="Voltage">Voltage <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="uptime_seconds" data-sort-label="Uptime">Uptime <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="channel_utilization" data-sort-label="Channel Utilization">Channel Util <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="air_util_tx" data-sort-label="Air Utilization (Tx)">Air Util Tx <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="latitude" data-sort-label="Latitude">Latitude <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="longitude" data-sort-label="Longitude">Longitude <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="altitude" data-sort-label="Altitude">Altitude <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="position_time" data-sort-label="Last Position">Last Position <span class="sort-indicator" aria-hidden="true"></span></button></th>
</tr>
</thead>
<tbody></tbody>
</table>
<div id="shortInfoOverlay" class="short-info-overlay" role="dialog" hidden>
<button type="button" class="short-info-close" aria-label="Close node details">×</button>
<div class="short-info-content"></div>
</div>
<footer>
PotatoMesh
<% if version && !version.empty? %>
<span class="mono"><%= version %></span> —
<% end %>
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 autoRefreshEl = document.getElementById('autoRefresh');
const refreshBtn = document.getElementById('refreshBtn');
const filterInput = document.getElementById('filterInput');
const themeToggle = document.getElementById('themeToggle');
const infoBtn = document.getElementById('infoBtn');
const infoOverlay = document.getElementById('infoOverlay');
const infoClose = document.getElementById('infoClose');
const infoDialog = infoOverlay ? infoOverlay.querySelector('.info-dialog') : null;
const shortInfoOverlay = document.getElementById('shortInfoOverlay');
const shortInfoClose = shortInfoOverlay ? shortInfoOverlay.querySelector('.short-info-close') : null;
const shortInfoContent = shortInfoOverlay ? shortInfoOverlay.querySelector('.short-info-content') : null;
const titleEl = document.querySelector('title');
const headerEl = document.querySelector('h1');
const headerTitleTextEl = headerEl ? headerEl.querySelector('.site-title-text') : null;
const chatEl = document.getElementById('chat');
const refreshInfo = document.getElementById('refreshInfo');
const baseTitle = document.title;
const nodesTable = document.getElementById('nodes');
const sortButtons = nodesTable ? Array.from(nodesTable.querySelectorAll('thead .sort-button[data-sort-key]')) : [];
const tableSorters = {
node_id: { getValue: n => n.node_id, compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
short_name: { getValue: n => n.short_name, compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
long_name: { getValue: n => n.long_name, compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
last_heard: { getValue: n => n.last_heard, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
role: { getValue: n => n.role, compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
hw_model: { getValue: n => n.hw_model, compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
battery_level: { getValue: n => n.battery_level, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
voltage: { getValue: n => n.voltage, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
uptime_seconds: { getValue: n => n.uptime_seconds, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
channel_utilization: { getValue: n => n.channel_utilization, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
air_util_tx: { getValue: n => n.air_util_tx, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
latitude: { getValue: n => n.latitude, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' },
longitude: { getValue: n => n.longitude, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' },
altitude: { getValue: n => n.altitude, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
position_time: { getValue: n => n.position_time, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' }
};
let sortState = {
key: 'last_heard',
direction: tableSorters.last_heard ? tableSorters.last_heard.defaultDirection : 'desc'
};
let allNodes = [];
let shortInfoAnchor = null;
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: …`;
let refreshTimer = null;
function hasStringValue(value) {
if (value == null) return false;
return String(value).trim().length > 0;
}
function hasNumberValue(value) {
if (value == null || value === '') return false;
const num = typeof value === 'number' ? value : Number(value);
return Number.isFinite(num);
}
function compareString(a, b) {
const strA = (a == null ? '' : String(a)).trim();
const strB = (b == null ? '' : String(b)).trim();
const hasA = strA.length > 0;
const hasB = strB.length > 0;
if (!hasA && !hasB) return 0;
if (!hasA) return 1;
if (!hasB) return -1;
return strA.localeCompare(strB, undefined, { numeric: true, sensitivity: 'base' });
}
function compareNumber(a, b) {
const numA = typeof a === 'number' ? a : Number(a);
const numB = typeof b === 'number' ? b : Number(b);
const validA = Number.isFinite(numA);
const validB = Number.isFinite(numB);
if (validA && validB) {
if (numA === numB) return 0;
return numA < numB ? -1 : 1;
}
if (validA) return -1;
if (validB) return 1;
return 0;
}
function sortNodes(nodes) {
if (!Array.isArray(nodes)) return [];
const config = tableSorters[sortState.key];
if (!config) return nodes.slice();
const dir = sortState.direction === 'asc' ? 1 : -1;
const getter = config.getValue;
const hasValue = config.hasValue;
const compare = config.compare;
const arr = nodes.slice();
arr.sort((a, b) => {
const valueA = getter(a);
const valueB = getter(b);
const presentA = hasValue ? hasValue(valueA) : valueA != null && valueA !== '';
const presentB = hasValue ? hasValue(valueB) : valueB != null && valueB !== '';
if (!presentA && !presentB) return 0;
if (!presentA) return 1;
if (!presentB) return -1;
const result = compare(valueA, valueB);
return result * dir;
});
return arr;
}
function updateSortIndicators() {
if (!nodesTable || !sortButtons.length) return;
nodesTable.querySelectorAll('thead th').forEach(th => th.removeAttribute('aria-sort'));
sortButtons.forEach(button => {
const indicator = button.querySelector('.sort-indicator');
if (indicator) indicator.textContent = '';
button.removeAttribute('data-sort-active');
button.setAttribute('aria-pressed', 'false');
const label = button.dataset.sortLabel || button.textContent.trim();
button.setAttribute('aria-label', `Sort by ${label}`);
});
const activeButton = sortButtons.find(button => button.dataset.sortKey === sortState.key);
if (!activeButton) return;
const indicator = activeButton.querySelector('.sort-indicator');
if (indicator) indicator.textContent = sortState.direction === 'asc' ? '▲' : '▼';
const th = activeButton.closest('th');
if (th) {
th.setAttribute('aria-sort', sortState.direction === 'asc' ? 'ascending' : 'descending');
}
activeButton.setAttribute('data-sort-active', 'true');
activeButton.setAttribute('aria-pressed', 'true');
const label = activeButton.dataset.sortLabel || activeButton.textContent.trim();
const directionLabel = sortState.direction === 'asc' ? 'ascending' : 'descending';
const nextDirection = sortState.direction === 'asc' ? 'descending' : 'ascending';
activeButton.setAttribute('aria-label', `${label}, sorted ${directionLabel}. Activate to sort ${nextDirection}.`);
}
if (sortButtons.length) {
sortButtons.forEach(button => {
button.addEventListener('click', () => {
const key = button.dataset.sortKey;
if (!key) return;
if (sortState.key === key) {
sortState = { key, direction: sortState.direction === 'asc' ? 'desc' : 'asc' };
} else {
const config = tableSorters[key];
const dir = config && config.defaultDirection ? config.defaultDirection : 'asc';
sortState = { key, direction: dir };
}
applyFilter();
});
});
}
updateSortIndicators();
function restartAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
if (autoRefreshEl && autoRefreshEl.checked) {
refreshTimer = setInterval(refresh, REFRESH_MS);
}
}
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 TILE_LAYER_URL = 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png';
const TILE_ATTRIBUTION =
'&copy; OpenStreetMap contributors, tiles style by Humanitarian OpenStreetMap Team, hosted by OpenStreetMap France';
const TILE_FILTER_LIGHT = '<%= tile_filter_light %>';
const TILE_FILTER_DARK = '<%= tile_filter_dark %>';
function resolveTileFilter() {
return document.body.classList.contains('dark') ? TILE_FILTER_DARK : TILE_FILTER_LIGHT;
}
function applyFilterToTileElement(tile, filterValue) {
if (!tile) return;
if (tile.classList && !tile.classList.contains('map-tiles')) {
tile.classList.add('map-tiles');
}
const value = filterValue || resolveTileFilter();
if (tile.style) {
tile.style.filter = value;
tile.style.webkitFilter = value;
}
}
function applyFilterToTileContainers(filterValue) {
const value = filterValue || resolveTileFilter();
const tileContainer = tiles && typeof tiles.getContainer === 'function' ? tiles.getContainer() : null;
if (tileContainer && tileContainer.style) {
tileContainer.style.filter = value;
tileContainer.style.webkitFilter = value;
}
const tilePane = map && typeof map.getPane === 'function' ? map.getPane('tilePane') : null;
if (tilePane && tilePane.style) {
tilePane.style.filter = value;
tilePane.style.webkitFilter = value;
}
}
function ensureTileHasCurrentFilter(tile) {
if (!tile) return;
const filterValue = resolveTileFilter();
applyFilterToTileElement(tile, filterValue);
}
function applyFiltersToAllTiles() {
const filterValue = resolveTileFilter();
document.body.style.setProperty('--map-tiles-filter', filterValue);
const tileEls = document.querySelectorAll('#map .leaflet-tile');
tileEls.forEach(tile => applyFilterToTileElement(tile, filterValue));
applyFilterToTileContainers(filterValue);
}
const tiles = L.tileLayer(TILE_LAYER_URL, {
maxZoom: 19,
attribution: TILE_ATTRIBUTION,
className: 'map-tiles',
crossOrigin: 'anonymous'
});
let tileDomObserver = null;
function observeTileContainer() {
if (typeof MutationObserver !== 'function') return;
const container = tiles && typeof tiles.getContainer === 'function' ? tiles.getContainer() : null;
const tilePane = map && typeof map.getPane === 'function' ? map.getPane('tilePane') : null;
const targets = [];
if (container) targets.push(container);
if (tilePane && !targets.includes(tilePane)) targets.push(tilePane);
if (!targets.length) return;
if (tileDomObserver) {
tileDomObserver.disconnect();
}
const handleNode = (node, filterValue) => {
if (!node || node.nodeType !== 1) return;
if (node.classList && node.classList.contains('leaflet-tile')) {
applyFilterToTileElement(node, filterValue);
}
if (typeof node.querySelectorAll === 'function') {
const nestedTiles = node.querySelectorAll('.leaflet-tile');
nestedTiles.forEach(tile => applyFilterToTileElement(tile, filterValue));
}
};
tileDomObserver = new MutationObserver(mutations => {
const filterValue = resolveTileFilter();
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => handleNode(node, filterValue));
});
applyFilterToTileContainers(filterValue);
});
targets.forEach(target => tileDomObserver.observe(target, { childList: true, subtree: true }));
}
tiles.on('tileloadstart', event => {
if (!event || !event.tile) return;
ensureTileHasCurrentFilter(event.tile);
applyFilterToTileContainers();
});
tiles.on('tileload', event => {
if (!event || !event.tile) return;
ensureTileHasCurrentFilter(event.tile);
applyFilterToTileContainers();
});
tiles.on('load', () => {
applyFiltersToAllTiles();
observeTileContainer();
});
tiles.addTo(map);
observeTileContainer();
// Default view until first data arrives
map.setView(MAP_CENTER, 10);
applyFiltersToAllTiles();
map.on('moveend', applyFiltersToAllTiles);
map.on('zoomend', applyFiltersToAllTiles);
const markersLayer = L.layerGroup().addTo(map);
let legendContainer = null;
let legendToggleButton = null;
let legendVisible = true;
function setLegendVisibility(visible) {
legendVisible = visible;
if (legendContainer) {
legendContainer.classList.toggle('legend-hidden', !visible);
legendContainer.setAttribute('aria-hidden', visible ? 'false' : 'true');
}
if (legendToggleButton) {
legendToggleButton.setAttribute('aria-pressed', visible ? 'true' : 'false');
legendToggleButton.setAttribute('aria-label', visible ? 'Hide map legend' : 'Show map legend');
legendToggleButton.textContent = visible ? 'Hide legend' : 'Show legend';
}
}
const legend = L.control({ position: 'bottomright' });
legend.onAdd = function () {
const div = L.DomUtil.create('div', 'legend');
div.id = 'mapLegend';
div.setAttribute('role', 'region');
div.setAttribute('aria-label', 'Map legend');
legendContainer = div;
const header = L.DomUtil.create('div', 'legend-header', div);
const title = L.DomUtil.create('span', 'legend-title', header);
title.textContent = 'Legend';
const closeButton = L.DomUtil.create('button', 'legend-close', header);
closeButton.type = 'button';
closeButton.setAttribute('aria-label', 'Hide legend');
closeButton.textContent = '×';
closeButton.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
setLegendVisibility(false);
if (legendToggleButton) {
legendToggleButton.focus();
}
});
const itemsContainer = L.DomUtil.create('div', 'legend-items', div);
for (const [role, color] of Object.entries(roleColors)) {
const item = L.DomUtil.create('div', 'legend-item', itemsContainer);
const swatch = L.DomUtil.create('span', 'legend-swatch', item);
swatch.style.background = color;
swatch.setAttribute('aria-hidden', 'true');
const label = L.DomUtil.create('span', 'legend-label', item);
label.textContent = role;
}
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(div);
return div;
};
legend.addTo(map);
legendContainer = legend.getContainer();
const legendToggleControl = L.control({ position: 'bottomright' });
legendToggleControl.onAdd = function () {
const container = L.DomUtil.create('div', 'leaflet-control legend-toggle');
const button = L.DomUtil.create('button', 'legend-toggle-button', container);
button.type = 'button';
button.textContent = 'Hide legend';
button.setAttribute('aria-pressed', 'true');
button.setAttribute('aria-label', 'Hide map legend');
button.setAttribute('aria-controls', 'mapLegend');
button.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
setLegendVisibility(!legendVisible);
});
legendToggleButton = button;
L.DomEvent.disableClickPropagation(container);
L.DomEvent.disableScrollPropagation(container);
return container;
};
legendToggleControl.addTo(map);
const legendMediaQuery = window.matchMedia('(max-width: 768px)');
setLegendVisibility(!legendMediaQuery.matches);
legendMediaQuery.addEventListener('change', event => {
setLegendVisibility(!event.matches);
});
themeToggle.addEventListener('click', () => {
const dark = document.body.classList.toggle('dark');
themeToggle.textContent = dark ? '☀️' : '🌙';
applyFiltersToAllTiles();
});
let lastFocusBeforeInfo = null;
function openInfoOverlay() {
if (!infoOverlay || !infoDialog) return;
lastFocusBeforeInfo = document.activeElement;
infoOverlay.hidden = false;
document.body.style.setProperty('overflow', 'hidden');
infoDialog.focus();
}
function closeInfoOverlay() {
if (!infoOverlay || !infoDialog) return;
infoOverlay.hidden = true;
document.body.style.removeProperty('overflow');
const target = lastFocusBeforeInfo && typeof lastFocusBeforeInfo.focus === 'function' ? lastFocusBeforeInfo : infoBtn;
if (target && typeof target.focus === 'function') {
target.focus();
}
lastFocusBeforeInfo = null;
}
if (infoBtn && infoOverlay && infoClose) {
infoBtn.addEventListener('click', openInfoOverlay);
infoClose.addEventListener('click', closeInfoOverlay);
infoOverlay.addEventListener('click', event => {
if (event.target === infoOverlay) {
closeInfoOverlay();
}
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !infoOverlay.hidden) {
closeInfoOverlay();
}
});
}
if (shortInfoClose) {
shortInfoClose.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
closeShortInfoOverlay();
});
}
document.addEventListener('click', event => {
const shortTarget = event.target.closest('.short-name');
if (shortTarget && shortTarget.dataset && shortTarget.dataset.nodeInfo) {
event.preventDefault();
event.stopPropagation();
let info = null;
try {
info = JSON.parse(shortTarget.dataset.nodeInfo);
} catch (err) {
console.warn('Failed to parse node info payload', err);
}
if (!info) return;
if (!info.shortName && shortTarget.textContent) {
info.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
}
if (!info.role) {
info.role = 'CLIENT';
}
if (shortInfoOverlay && !shortInfoOverlay.hidden && shortInfoAnchor === shortTarget) {
closeShortInfoOverlay();
} else {
openShortInfoOverlay(shortTarget, info);
}
return;
}
if (shortInfoOverlay && !shortInfoOverlay.hidden && !shortInfoOverlay.contains(event.target)) {
closeShortInfoOverlay();
}
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && shortInfoOverlay && !shortInfoOverlay.hidden) {
closeShortInfoOverlay();
}
});
window.addEventListener('resize', () => {
if (shortInfoOverlay && !shortInfoOverlay.hidden) {
requestAnimationFrame(positionShortInfoOverlay);
}
});
// --- Helpers ---
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderShortHtml(short, role, longName, nodeData = null){
const safeTitle = longName ? escapeHtml(String(longName)) : '';
const titleAttr = safeTitle ? ` title="${safeTitle}"` : '';
const resolvedRole = role || (nodeData && nodeData.role) || 'CLIENT';
let infoAttr = '';
if (nodeData && typeof nodeData === 'object') {
const info = {
nodeId: nodeData.node_id ?? nodeData.nodeId ?? '',
shortName: short != null ? String(short) : (nodeData.short_name ?? ''),
longName: nodeData.long_name ?? longName ?? '',
role: resolvedRole,
hwModel: nodeData.hw_model ?? nodeData.hwModel ?? '',
battery: nodeData.battery_level ?? nodeData.battery ?? null,
voltage: nodeData.voltage ?? null,
uptime: nodeData.uptime_seconds ?? nodeData.uptime ?? null,
channel: nodeData.channel_utilization ?? nodeData.channel ?? null,
airUtil: nodeData.air_util_tx ?? nodeData.airUtil ?? null,
};
infoAttr = ` data-node-info="${escapeHtml(JSON.stringify(info))}"`;
}
if (!short) {
return `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>?&nbsp;&nbsp;&nbsp;</span>`;
}
const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, '&nbsp;');
const color = roleColors[resolvedRole] || roleColors.CLIENT;
return `<span class="short-name" style="background:${color}"${titleAttr}${infoAttr}>${padded}</span>`;
}
function formatShortInfoUptime(value) {
if (value == null || value === '') return '';
const num = Number(value);
if (!Number.isFinite(num)) return '';
return num === 0 ? '0s' : timeHum(num);
}
function shortInfoValueOrDash(value) {
return value != null && value !== '' ? String(value) : '—';
}
function closeShortInfoOverlay() {
if (!shortInfoOverlay) return;
shortInfoOverlay.hidden = true;
shortInfoOverlay.style.visibility = 'visible';
shortInfoAnchor = null;
}
function positionShortInfoOverlay() {
if (!shortInfoOverlay || shortInfoOverlay.hidden || !shortInfoAnchor) return;
if (!document.body.contains(shortInfoAnchor)) {
closeShortInfoOverlay();
return;
}
const rect = shortInfoAnchor.getBoundingClientRect();
const overlayRect = shortInfoOverlay.getBoundingClientRect();
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
let left = rect.left + window.scrollX;
let top = rect.top + window.scrollY;
const maxLeft = window.scrollX + viewportWidth - overlayRect.width - 8;
const maxTop = window.scrollY + viewportHeight - overlayRect.height - 8;
left = Math.max(window.scrollX + 8, Math.min(left, maxLeft));
top = Math.max(window.scrollY + 8, Math.min(top, maxTop));
shortInfoOverlay.style.left = `${left}px`;
shortInfoOverlay.style.top = `${top}px`;
shortInfoOverlay.style.visibility = 'visible';
}
function openShortInfoOverlay(target, info) {
if (!shortInfoOverlay || !shortInfoContent || !info) return;
const lines = [];
const longNameValue = shortInfoValueOrDash(info.longName ?? '');
lines.push(`<strong>${escapeHtml(longNameValue)}</strong>`);
const shortParts = [];
shortParts.push(renderShortHtml(info.shortName, info.role, info.longName));
const nodeIdValue = shortInfoValueOrDash(info.nodeId ?? '');
if (nodeIdValue !== '—') {
shortParts.push(`<span class="mono">${escapeHtml(nodeIdValue)}</span>`);
}
if (shortParts.length) {
lines.push(shortParts.join(' '));
}
lines.push(`Role: ${escapeHtml(shortInfoValueOrDash(info.role || 'CLIENT'))}`);
lines.push(`Model: ${escapeHtml(shortInfoValueOrDash(fmtHw(info.hwModel)))}`);
lines.push(`Battery: ${escapeHtml(shortInfoValueOrDash(fmtAlt(info.battery, '%')))}`);
lines.push(`Voltage: ${escapeHtml(shortInfoValueOrDash(fmtAlt(info.voltage, 'V')))}`);
lines.push(`Uptime: ${escapeHtml(shortInfoValueOrDash(formatShortInfoUptime(info.uptime)))}`);
lines.push(`Channel Util: ${escapeHtml(shortInfoValueOrDash(fmtTx(info.channel)))}`);
lines.push(`Air Util Tx: ${escapeHtml(shortInfoValueOrDash(fmtTx(info.airUtil)))}`);
shortInfoContent.innerHTML = lines.join('<br/>');
shortInfoAnchor = target;
shortInfoOverlay.hidden = false;
shortInfoOverlay.style.visibility = 'hidden';
requestAnimationFrame(positionShortInfoOverlay);
}
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, n.long_name, n);
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, m.node?.long_name, m.node);
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, n.long_name, n)}</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);
if (shortInfoOverlay && shortInfoAnchor && !document.body.contains(shortInfoAnchor)) {
closeShortInfoOverlay();
}
}
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, n.long_name, n)} <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 rawQuery = filterInput ? filterInput.value : '';
const q = rawQuery.trim().toLowerCase();
const filteredNodes = !q ? allNodes.slice() : allNodes.filter(n => {
return [n.node_id, n.short_name, n.long_name]
.filter(value => value != null && value !== '')
.some(value => String(value).toLowerCase().includes(q));
});
const sortedNodes = sortNodes(filteredNodes);
const nowSec = Date.now()/1000;
renderTable(sortedNodes, nowSec);
renderMap(sortedNodes, nowSec);
updateCount(sortedNodes, nowSec);
updateRefreshInfo(sortedNodes, nowSec);
updateSortIndicators();
}
if (filterInput) {
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();
restartAutoRefresh();
refreshBtn.addEventListener('click', refresh);
if (autoRefreshEl) {
autoRefreshEl.addEventListener('change', () => {
restartAutoRefresh();
if (autoRefreshEl.checked) {
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;
if (headerTitleTextEl) {
headerTitleTextEl.textContent = text;
} else if (headerEl) {
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}.`;
}
</script>
</body>
</html>