mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-06-25 04:21:41 +02:00
1223 lines
53 KiB
Plaintext
1223 lines
53 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>
|
||
<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 =
|
||
'© 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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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}>? </span>`;
|
||
}
|
||
const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, ' ');
|
||
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>
|