update kiosk page

This commit is contained in:
pablorevilla-meshtastic committed 2026-11-05 17:08:18 -08:00
1 parent ff3bd61242
commit 2810278db5
3 files changed
+37 -319

No files matched your search

+7
View File
@@ -704,3 +704,10 @@ Check the log file to see it the script run at the specific time.
## Technical Documentation
For more detailed technical documentation including database migrations, architecture details, and advanced topics, see the [docs/](docs/) directory.
### Fullscreen kiosk map
Open `/static/kiosk.html` or `/map?kiosk=1` for a fullscreen live map without the
site header or footer. Kiosk mode shares the live map's layers, channel filters,
packet activity, and compact node inspector. Map position parameters (`lat`, `lng`,
and `zoom`) also work with kiosk mode.
+13 -319
View File
@@ -1,324 +1,18 @@
<!DOCTYPE html>
<html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Mesh Nodes Live Map</title>
<link rel="stylesheet" href="/static/vendor/leaflet-1.9.4/dist/leaflet.css" crossorigin=""/>
<script src="/static/theme.js"></script>
<style>
body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
#map { height: 100vh; width: 100%; }
#legend {
position: absolute;
bottom: 10px;
right: 10px;
background: white; /* changed from rgba(0,0,0,0.8) to white */
color: black; /* text color black */
padding: 10px;
border-radius: 5px;
z-index: 1000;
font-size: 13px;
line-height: 1.5;
border: 1px solid #ccc; /* optional: subtle border for white bg */
}
#filter-container { margin-bottom: 6px; text-align: left; }
.filter-checkbox { margin-right: 4px; }
.blinking-tooltip {
background: white;
color: black;
border: 1px solid #000;
border-radius: 4px;
padding: 2px 5px;
}
</style>
<!-- mesh-theme:start -->
<style>
:root {
color-scheme:light;
--mesh-primary:#245ea8;
--mesh-primary-dark:#19467f;
--mesh-primary-rgb:36,94,168;
--mesh-brand:#245ea8;
--mesh-brand-deep:#19467f;
--mesh-surface:#ffffff;
--mesh-text:#243247;
--mesh-soft:#eaf1fb;
--mesh-hover:#dce8f8;
--mesh-border:#dce4ef;
--mesh-page:#f5f8fc;
--mesh-stripe:#f7f9fc;
--mesh-muted:#64748b;
--mesh-chat-stripe:#eef3fb;
--mesh-pulse-soft:#edf4ff;
--mesh-pulse-peak:#c9defa;
--mesh-pulse-mid:#deebfc;
}
[data-bs-theme="dark"] {
color-scheme:dark;
--mesh-primary:#8bbcff;
--mesh-primary-dark:#b6d5ff;
--mesh-primary-rgb:139,188,255;
--mesh-brand:#193d69;
--mesh-brand-deep:#112b4b;
--mesh-surface:#182335;
--mesh-text:#e2eaf6;
--mesh-soft:#203653;
--mesh-hover:#29486c;
--mesh-border:#34465f;
--mesh-page:#101925;
--mesh-stripe:#1c2a3d;
--mesh-muted:#a0aec2;
--mesh-chat-stripe:#20314a;
--mesh-pulse-soft:#203653;
--mesh-pulse-peak:#355d8e;
--mesh-pulse-mid:#29486c;
--bs-link-color:var(--mesh-primary);
--bs-link-color-rgb:var(--mesh-primary-rgb);
--bs-link-hover-color:var(--mesh-primary-dark);
--bs-link-hover-color-rgb:182,213,255;
}
body { background:var(--mesh-page); color:var(--mesh-text); }
.mesh-theme-toggle { flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border:1px solid #ffffff70; border-radius:6px; background:transparent; color:white; font:22px/1 system-ui,sans-serif; cursor:pointer; }
.mesh-header .mesh-theme-toggle { margin-left:auto; }
.mesh-theme-toggle:hover { background:#ffffff20; }
.mesh-theme-toggle .theme-icon { width:22px; height:22px; flex-shrink:0; }
.mesh-theme-toggle .theme-icon--sun { display:none; }
[data-bs-theme="dark"] .mesh-theme-toggle .theme-icon--sun { display:block; }
[data-bs-theme="dark"] .mesh-theme-toggle .theme-icon--moon { display:none; }
.mesh-theme-toggle:focus-visible { outline:2px solid white; outline-offset:3px; }
@media(max-width:1000px) { .mesh-header .mesh-menu-toggle { margin-left:auto; } .mesh-header .mesh-theme-toggle { margin-left:12px; } .mesh-header #site-menu { order:4; } }
[data-bs-theme="dark"] :is(.chat-status,.chat-details,.chat-channel,.chat-status[data-state="paused"],.chat-status[data-state="loading"],.good-x) { background:var(--mesh-soft); color:var(--mesh-primary); border-color:var(--mesh-border); }
[data-bs-theme="dark"] :is(.chat-status[data-state="error"],.bad-x) { background:#472a24; color:#ffb4a6; }
[data-bs-theme="dark"] .ok-x { background:#453820; color:#f1d38a; }
[data-bs-theme="dark"] .chat-details:hover { background:var(--mesh-hover); color:var(--mesh-primary-dark); }
[data-bs-theme="dark"] .chat-nick { filter:brightness(1.4) saturate(.8); }
[data-bs-theme="dark"] :is(.summary-card:nth-child(2) .summary-count,.summary-card:nth-child(3) .summary-count,.summary-card:nth-child(4) .summary-count,.mesh-metric:nth-child(2) strong,.mesh-metric:nth-child(3) strong,.reach-metric:nth-child(2) .reach-metric-value) { color:var(--mesh-primary); }
[data-bs-theme="dark"] :is(.reliability-status-heard) { color:#8fd7aa!important; }
[data-bs-theme="dark"] .reliability-status-missed { color:#ffb4a6!important; }
[data-bs-theme="dark"] .reach-count-bar-value span { color:#17263c; }
[data-bs-theme="dark"] .leaflet-tile-pane { filter:invert(1) hue-rotate(180deg) brightness(.85) saturate(.75); }
[data-bs-theme="dark"] :is(.leaflet-container,.leaflet-control-layers,.leaflet-bar a,.leaflet-popup-content-wrapper,.leaflet-popup-tip,.leaflet-tooltip,.leaflet-control-attribution,.leaflet-control-scale-line,#unmapped-packets,#legend,#node-info,.legend) { background:var(--mesh-surface); color:var(--mesh-text); border-color:var(--mesh-border); }
[data-bs-theme="dark"] :is(.leaflet-control-layers label,.leaflet-popup-close-button,#unmapped-packets h3,.unmapped-node,.blinking-tooltip,.legend) { color:var(--mesh-text); }
[data-bs-theme="dark"] :is(.leaflet-bar a:hover,.leaflet-control-layers-expanded) { background:var(--mesh-stripe); }
[data-bs-theme="dark"] :is(.unmapped-empty,.reach-muted) { color:var(--mesh-muted); }
[data-bs-theme="dark"] :is(.source-marker,.reach-map-marker) { color:white; }
[data-bs-theme="dark"] .qr-image { background:white; }
[data-bs-theme="dark"] :is(#traceroute-error,#map-load-status,.text-danger) { color:#ffb4a6; }
[data-bs-theme="dark"] .reach-count-bar-track { background:var(--mesh-soft); }
</style>
<!-- mesh-theme:end -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Meshview Kiosk</title>
<script>
const target = new URL('/map', location.origin);
target.search = location.search;
target.searchParams.set('kiosk', '1');
target.hash = location.hash;
location.replace(target.href);
</script>
</head>
<body>
<button class="mesh-theme-toggle" style="position:fixed;top:12px;right:12px;z-index:2000;background:#245ea8" type="button" data-theme-toggle aria-label="Switch to night mode" aria-pressed="false"><svg class="theme-icon theme-icon--sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"/></svg><svg class="theme-icon theme-icon--moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.9 13A9 9 0 0 1 11 3.1 9 9 0 1 0 20.9 13Z"/></svg></button>
<div id="map"></div>
<div id="legend">
<div id="filter-container"></div>
</div>
<script src="/static/vendor/leaflet-1.9.4/dist/leaflet.js" crossorigin></script>
<script src="/static/vendor/leaflet-polylinedecorator-1.6.0/dist/leaflet.polylineDecorator.js" crossorigin></script>
<script src="/static/portmaps.js"></script>
<script src="/static/html-escape.js"></script>
<script>
(async function(){
// --- Load config ---
let config = {};
try {
const res = await fetch('/api/config');
config = await res.json();
} catch(err){ console.error('Failed to load config', err); }
const mapInterval = Number(config.site?.map_interval) || 3;
const bayAreaBounds = [
[Number(config.site?.map_top_left_lat), Number(config.site?.map_top_left_lon)],
[Number(config.site?.map_bottom_right_lat), Number(config.site?.map_bottom_right_lon)]
];
// --- Initialize map ---
const map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
map.fitBounds(bayAreaBounds);
// --- Utilities ---
const palette = ["#e6194b","#4363d8","#f58231","#911eb4","#46f0f0","#f032e6","#bcf60c","#fabebe","#008080","#e6beff","#9a6324","#fffac8","#800000","#aaffc3","#808000","#ffd8b1","#000075","#808080"];
const colorMap = new Map(); let nextColorIndex=0;
function hashToColor(str){
if(colorMap.has(str)) return colorMap.get(str);
const color = palette[nextColorIndex % palette.length];
colorMap.set(str, color); nextColorIndex++;
return color;
}
function timeAgoFromUs(us){
const diff = Date.now() - (us / 1000);
const s=Math.floor(diff/1000), m=Math.floor(s/60), h=Math.floor(m/60), d=Math.floor(h/24);
if(d>0) return d+'d'; if(h>0) return h+'h'; if(m>0) return m+'m'; return s+'s';
}
function isInvalidCoord(node){ return !node || !node.last_lat || !node.last_long; }
// --- Load nodes ---
let nodes = [];
try {
const res = await fetch('/api/nodes');
const data = await res.json();
nodes = data.nodes || [];
} catch(err){ console.error('Failed to load nodes', err); }
const markers = new Map(); // channel -> markers; Map, because channel names come from the mesh
const markerById = {}; // Keyed by numeric node_id for packets
const nodeMap = new Map(); // Keyed by numeric node_id
const channels = new Set();
const activeBlinks = new Map();
const portMap = window.PORT_LABEL_MAP;
nodes.forEach(node=>{
if(isInvalidCoord(node)) return;
const lat = node.last_lat/1e7;
const lng = node.last_long/1e7;
channels.add(node.channel);
nodeMap.set(node.node_id,node);
const color = hashToColor(node.channel);
const marker = L.circleMarker([lat,lng],{radius:6,color:"white",fillColor:color,fillOpacity:1,weight:0.7}).addTo(map);
marker.nodeId = node.node_id;
marker.originalColor = color;
markerById[node.node_id]=marker;
let popupContent=`<b>${escapeHtml(node.long_name)} (${escapeHtml(node.short_name)})</b><br>
<b>Channel:</b> ${escapeHtml(node.channel)}<br>
<b>Model:</b> ${escapeHtml(node.hw_model)}<br>
<b>Role:</b> ${escapeHtml(node.role)}<br>`;
if(node.last_seen_us) popupContent+=`<b>Last seen:</b> ${timeAgoFromUs(node.last_seen_us)}<br>`;
if(node.firmware) popupContent+=`<b>Firmware:</b> ${escapeHtml(node.firmware)}<br>`;
marker.on('click', e=>{
e.originalEvent.stopPropagation();
marker.bindPopup(popupContent).openPopup();
setTimeout(()=>marker.closePopup(),3000);
});
if(!markers.has(node.channel)) markers.set(node.channel, []);
markers.get(node.channel).push({marker,isRouter});
});
// --- Filters ---
const filterContainer=document.getElementById('filter-container');
channels.forEach(channel=>{
const id=`filter-${channel.replace(/\s+/g,'-').toLowerCase()}`;
const color=hashToColor(channel);
const label=document.createElement('label');
label.style.color=color;
label.innerHTML=`<input type="checkbox" class="filter-checkbox" id="${escapeHtml(id)}" checked> ${escapeHtml(channel)}`;
filterContainer.appendChild(label);
});
function updateMarkers(){
nodes.forEach(node=>{
const id=`filter-${node.channel.replace(/\s+/g,'-').toLowerCase()}`;
const checkbox=document.getElementById(id);
const marker=markerById[node.node_id];
if(marker) marker.setStyle({fillOpacity: checkbox.checked?1:0});
});
localStorage.setItem('meshview_map_filters', JSON.stringify({
channels: Array.from(channels).reduce((obj,c)=>{ obj[c]=document.getElementById(`filter-${c.replace(/\s+/g,'-').toLowerCase()}`).checked; return obj; },{})
}));
}
document.querySelectorAll(".filter-checkbox").forEach(input=>input.addEventListener("change",updateMarkers));
// Load saved filters
const savedFilters=JSON.parse(localStorage.getItem('meshview_map_filters')||'{}');
if(savedFilters.channels){
Object.keys(savedFilters.channels).forEach(c=>{
const checkbox=document.getElementById(`filter-${c.replace(/\s+/g,'-').toLowerCase()}`);
if(checkbox) checkbox.checked=savedFilters.channels[c];
});
}
updateMarkers();
// --- Packet blinking ---
function blinkNode(marker,longName,portnum){
if(!map.hasLayer(marker)) return;
if(activeBlinks.has(marker)){
clearInterval(activeBlinks.get(marker));
marker.setStyle({fillColor: marker.originalColor});
if(marker.tooltip) map.removeLayer(marker.tooltip);
}
let count=0;
const portName=portMap[portnum]||`Port ${portnum}`;
const tooltip=L.tooltip({permanent:true,direction:'top',offset:[0,-marker.options.radius-5],className:'blinking-tooltip'})
.setContent(`${escapeHtml(longName)} (${escapeHtml(portName)})`).setLatLng(marker.getLatLng());
tooltip.addTo(map); marker.tooltip=tooltip;
const interval=setInterval(()=>{
if(map.hasLayer(marker)){
marker.setStyle({fillColor:count%2===0?'yellow':marker.originalColor});
marker.bringToFront();
}
count++;
if(count>7){ clearInterval(interval); marker.setStyle({fillColor:marker.originalColor}); map.removeLayer(tooltip); activeBlinks.delete(marker); }
},500);
activeBlinks.set(marker,interval);
}
let lastImportTimeUs = null;
async function fetchLatestPacket(){
try{
const res = await fetch(`/api/packets?limit=1`);
const data = await res.json();
lastImportTimeUs = data.packets?.[0]?.import_time_us || 0;
}catch(err){
console.error(err);
}
}
async function fetchNewPackets(){
if (!lastImportTimeUs) return;
try{
const res = await fetch(`/api/packets?since=${lastImportTimeUs}`);
const data = await res.json();
if(!data.packets || !data.packets.length) return;
let latest = lastImportTimeUs;
data.packets.forEach(packet => {
// Track newest microsecond timestamp
if (packet.import_time_us && packet.import_time_us > latest) {
latest = packet.import_time_us;
}
// Look up marker and blink it
const marker = markerById[packet.from_node_id];
const nodeData = nodeMap.get(packet.from_node_id);
if (marker && nodeData) {
blinkNode(marker, nodeData.long_name, packet.portnum);
}
});
lastImportTimeUs = latest;
}catch(err){
console.error(err);
}
}
if(mapInterval>0){ fetchLatestPacket(); setInterval(fetchNewPackets,mapInterval*1000); }
})();
</script>
<a href="/map?kiosk=1">Open the fullscreen live map</a>
</body>
</html>
+17
View File
@@ -3,6 +3,11 @@
{% block body_class %}{{ "mesh-dashboard" if dashboard else "mesh-map-only" }}{% endblock %}
{% block css %}
<script>
if (new URLSearchParams(location.search).get('kiosk') === '1') {
document.documentElement.classList.add('mesh-kiosk');
}
</script>
<style>
.legend { background:var(--mesh-surface);padding:8px;line-height:1.5;border-radius:5px;box-shadow:0 0 10px rgba(0,0,0,0.3);font-size:14px;color:var(--mesh-text); }
.legend i { width:12px;height:12px;display:inline-block;margin-right:6px;border-radius:50%; }
@@ -136,6 +141,18 @@
.mesh-explorer .mesh-node-list { flex:1; min-height:0; max-height:none; }
.mesh-explorer .mesh-node-inspector { max-height:100%; }
}
/* Kiosk shares the live map and fills the viewport without dashboard chrome. */
html.mesh-kiosk, .mesh-kiosk body { width:100%; height:100%; margin:0; overflow:hidden; }
.mesh-kiosk .mesh-header,.mesh-kiosk .mesh-announcement,.mesh-kiosk .mesh-footer,
.mesh-kiosk .mesh-page-heading,.mesh-kiosk .mesh-map-panel > .mesh-panel-title,
.mesh-kiosk .mesh-map-panel > div:has(> #share-button) { display:none; }
.mesh-kiosk .mesh-map-only .mesh-content { width:100%; height:100%; padding:0; max-width:none; }
.mesh-kiosk .mesh-explorer,.mesh-kiosk .mesh-map-panel { width:100%; height:100%; border:0; border-radius:0; }
.mesh-kiosk .mesh-map-only .mesh-map-panel #map-wrapper { height:100dvh; min-height:0; }
.mesh-kiosk .mesh-map-panel #filter-container { position:absolute; bottom:24px; left:10px; z-index:700; max-width:calc(100% - 240px); max-height:25dvh; overflow:auto; padding:8px; background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:6px; }
.mesh-kiosk #map-load-status:not(:empty) { position:absolute; top:10px; left:50px; z-index:800; background:var(--mesh-surface); padding:8px; border-radius:5px; }
.mesh-kiosk .mesh-map-panel #map-legend { bottom:110px!important; }
@media(max-width:700px) { .mesh-kiosk .mesh-map-panel #filter-container { max-width:calc(100% - 20px); bottom:24px; } .mesh-kiosk #unmapped-packets { display:none; } }
/* Compact live-map node card. */
.mesh-map-only .mesh-content { max-width:none; }
.mesh-map-only .mesh-explorer { display:block; }