2024-03-12 18:31:17 +13:00
< html >
< head >
< meta charset = "utf-8" >
2024-03-26 04:07:27 +13:00
< meta name = "viewport" content = "width=device-width, initial-scale=1, maximum-scale=1" >
2025-03-05 00:09:07 +01:00
< title > STHLM-MESH MAP< / title >
2024-03-13 17:29:16 +13:00
< meta name = "title" content = "Meshtastic Map" >
2024-03-12 18:31:17 +13:00
< meta name = "description" content = "An interactive map of all Meshtastic nodes." >
2024-05-16 20:30:14 +12:00
< link rel = "icon" type = "image/png" href = "/icon.png" / >
2024-03-12 18:31:17 +13:00
<!-- Open Graph / Facebook -->
< meta property = "og:type" content = "website" >
2025-03-05 00:09:07 +01:00
< meta property = "og:url" content = "https://map.sthlm-mesh.se" >
< meta property = "og:title" content = "STHLM-MESH MAP" >
2024-03-23 16:21:14 +13:00
< meta property = "og:description" content = "An interactive map of all Meshtastic nodes." >
2024-03-12 18:31:17 +13:00
<!-- tailwind css -->
2024-05-16 20:30:14 +12:00
< script src = "assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js" > < / script >
2024-03-12 18:31:17 +13:00
<!-- leaflet map -->
2024-05-16 20:30:14 +12:00
< link rel = "stylesheet" href = "assets/js/leaflet@1.9.3/dist/leaflet.css" / >
< script src = "assets/js/leaflet@1.9.3/dist/leaflet.js" > < / script >
2024-05-16 20:43:24 +12:00
<!-- leaflet plugins -->
< script src = "assets/js/leaflet-plugins/leaflet.polylineoffset.js" > < / script >
< script src = "assets/js/leaflet-plugins/leaflet.geometryutil.js" > < / script >
< script src = "assets/js/leaflet-plugins/leaflet-arrowheads.js" > < / script >
<!-- leaflet markercluster -->
< script src = "assets/js/leaflet-plugins/leaflet.markercluster/leaflet.markercluster.js" > < / script >
< link rel = "stylesheet" href = "assets/js/leaflet-plugins/leaflet.markercluster/MarkerCluster.css" / >
< link rel = "stylesheet" href = "assets/js/leaflet-plugins/leaflet.markercluster/MarkerCluster.Default.css" / >
2024-03-12 18:31:17 +13:00
2024-04-17 21:36:41 +12:00
<!-- leaflet groupedlayercontrol -->
2024-05-16 20:43:24 +12:00
< script src = "assets/js/leaflet-plugins/leaflet.groupedlayercontrol/leaflet.groupedlayercontrol.js" > < / script >
< link rel = "stylesheet" href = "assets/js/leaflet-plugins/leaflet.groupedlayercontrol/leaflet.groupedlayercontrol.css" / >
2024-04-17 21:36:41 +12:00
2024-03-12 18:31:17 +13:00
<!-- moment -->
2024-05-16 20:30:14 +12:00
< script src = "assets/js/moment@2.29.1/moment.min.js" > < / script >
2024-03-12 18:31:17 +13:00
2024-03-13 21:02:58 +13:00
<!-- vuejs -->
2024-05-16 20:30:14 +12:00
< script src = "assets/js/vue@3.4.26/dist/vue.global.js" > < / script >
<!-- axios -->
< script src = "assets/js/axios@1.6.8/dist/axios.min.js" > < / script >
2024-03-13 21:02:58 +13:00
2024-03-14 02:39:41 +13:00
<!-- chart js -->
2024-05-16 20:30:14 +12:00
< script src = "assets/js/chart.js@4.4.2/dist/chart.umd.js" > < / script >
2024-09-02 03:26:18 +12:00
< script src = "assets/js/chartjs-adapter-moment/chartjs-adapter-moment.js" > < / script >
2024-03-14 02:39:41 +13:00
2024-03-12 18:31:17 +13:00
< style >
2024-04-07 12:43:02 +12:00
/* used to prevent ui flicker before vuejs loads */
[v-cloak] {
display: none;
}
2025-08-15 20:39:10 +02:00
.icon-longfast {
2025-11-03 07:13:18 +01:00
background-color: #8c929e;
2025-08-15 20:39:10 +02:00
border-radius: 25px;
border: 1px solid white;
}
.icon-mediumfast {
2025-11-03 07:13:18 +01:00
background-color: #326be7;
2025-08-15 20:39:10 +02:00
border-radius: 25px;
border: 1px solid white;
}
2024-04-05 11:59:33 +13:00
.icon-mqtt-connected {
2025-04-14 17:21:33 +02:00
background-color: #2563eb; /* Change to use same color as disconnected // #16a34a; */
2024-03-12 18:31:17 +13:00
border-radius: 25px;
border: 1px solid white;
}
2024-04-05 11:59:33 +13:00
.icon-mqtt-disconnected {
2024-03-31 18:46:00 +13:00
background-color: #2563eb;
border-radius: 25px;
border: 1px solid white;
}
2024-04-05 11:59:33 +13:00
.icon-offline {
background-color: #dc2626;
border-radius: 25px;
border: 1px solid white;
}
2024-08-20 18:47:50 +12:00
.icon-position-history {
background-color: #a855f7;
border-radius: 25px;
border: 1px solid white;
}
2026-01-02 22:20:24 +01:00
.icon-traceroute-start {
background-color: #16a34a; /* green */
border-radius: 25px;
border: 1px solid white;
}
.icon-traceroute-end {
background-color: #dc2626; /* red */
border-radius: 25px;
border: 1px solid white;
}
2024-03-18 22:52:28 +13:00
.waypoint-label {
font-size: 26px;
background-color: transparent;
}
2024-03-12 18:31:17 +13:00
.link {
color: #2563eb;
}
.link:hover {
text-decoration: underline;
}
.tooltip {
position: relative;
display: inline-block;
}
.tooltip .tooltip-text {
visibility: hidden;
width: 80px;
background-color: black;
color: #fff;
text-align: center;
padding: 4px 0;
border-radius: 6px;
position: absolute;
z-index: 10000;
top: 100%;
left: 50%;
margin-top: 8px;
margin-left: -40px; /* Use half of the width (120/2 = 60), to center the tooltip */
}
.tooltip .tooltip-text::after {
content: " ";
position: absolute;
bottom: 100%; /* At the top of the tooltip */
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent transparent black transparent;
}
.tooltip:hover .tooltip-text {
visibility: visible;
}
2024-03-17 03:38:06 +13:00
.z-search {
2024-03-26 04:05:37 +13:00
z-index: 1001;
2024-03-12 18:31:17 +13:00
}
2024-03-17 03:38:06 +13:00
.z-sidebar {
2024-03-26 04:05:37 +13:00
z-index: 1002;
2024-03-17 03:38:06 +13:00
}
2024-03-12 18:31:17 +13:00
< / style >
< / head >
< body class = "h-full bg-gray-200" >
2024-04-07 12:43:02 +12:00
< div id = "app" v-cloak >
2024-03-12 18:31:17 +13:00
2024-03-13 21:02:58 +13:00
< div class = "flex flex-col h-full w-full overflow-hidden" >
< div class = "flex flex-col h-full" >
2025-09-20 07:12:08 +02:00
<!-- announcement -->
< div v-if = "isShowingAnnouncement" class = "flex bg-yellow-300 p-2 border-gray-300 border-b" >
<!-- info -->
< div class = "my-auto leading-tight" >
< div class = "font-bold" > Viktigt!< / div >
< div class = "text-sm" >
2025-11-19 20:03:24 +01:00
< span > I Stockholm används LoRa preset < b > Medium Range - Fast< / b > . För mer info < a href = "https://sthlm-mesh.se/blog/2025/övergång-till-mediumfast-den-27-september/" type = "button" class = "link" > klicka här< / a > .< / span >
2025-09-20 07:12:08 +02:00
< / div >
< / div >
<!-- action buttons -->
< div class = "flex my-auto ml-auto mr-0 sm:mr-2" >
< a @ click = "dismissAnnouncement" href = "javascript:void(0)" class = "rounded-full" >
< div class = "bg-white hover:bg-gray-100 p-2 rounded-full" >
< svg xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" class = "size-6" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "M6 18 18 6M6 6l12 12" / >
< / svg >
< / div >
< / a >
< / div >
< / div >
2024-03-13 21:02:58 +13:00
<!-- header -->
2025-03-05 00:09:07 +01:00
< div class = "flex p-3 h-16" style = "background-color: 30a552;" >
2024-03-26 04:05:37 +13:00
<!-- close mobile search button -->
< div v-if = "isShowingMobileSearch" class = "my-auto" >
< a @ click = "isShowingMobileSearch = false" href = "javascript:void(0)" class = "rounded-full" >
< div class = "bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg class = "w-6 h-6" xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" / >
< / svg >
< / div >
< / a >
< / div >
<!-- icon -->
2025-03-09 09:27:43 +01:00
< div v-if = "!isShowingMobileSearch" class = "hidden sm:block my-auto mr-2 ml-2" >
< img class = "w-8 h-8 rounded" src = "icon.png" / >
2024-03-12 18:31:17 +13:00
< / div >
2024-03-26 04:05:37 +13:00
<!-- app info -->
< div v-if = "!isShowingMobileSearch" class = "my-auto leading-tight" >
2025-03-09 09:27:43 +01:00
< a href = "https://sthlm-mesh.se" > < div class = "font-bold" style = "color: #ffffff; font-size: 1.25rem;" > STHLM-MESH< / div > < / a >
2024-03-13 21:02:58 +13:00
< / div >
2024-03-26 04:05:37 +13:00
<!-- search bar -->
< div class = "mx-3 flex-1 relative" :class = "{ 'hidden lg:block': !isShowingMobileSearch }" >
2024-03-17 03:38:06 +13:00
< input v-model = "searchText" type = "text" class = "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" :placeholder = "`Search ${nodes.length} nodes...`" >
< div v-if = "searchText !== ''" class = "absolute z-search bg-white w-full border border-gray-200 rounded-lg shadow-md mt-1 overflow-y-scroll max-h-80 divide-y divide-gray-200" >
< template v-if = "searchedNodes.length > 0" >
2024-09-04 12:32:17 +12:00
< div @ click = "onSearchResultNodeClick(node)" class = "flex space-x-2 p-2 hover:bg-gray-100 cursor-pointer" v-for = "node of searchedNodes" >
< div >
2024-09-07 21:57:23 +12:00
< div class = "flex rounded-full h-12 w-12 text-white shadow" :class = "[ `bg-[${getNodeColour(node.node_id)}]`, `text-[${getNodeTextColour(node.node_id)}]` ]" >
2024-09-04 12:32:17 +12:00
< div class = "mx-auto my-auto drop-shadow-sm" > {{ node.short_name }}< / div >
< / div >
< / div >
< div >
< div class = "text-gray-900" :class = "{ 'text-red-500': node.latitude == null || node.longitude == null }" > {{ node.long_name !== '' ? node.long_name : "-" }}< / div >
< div class = "flex space-x-1 text-sm text-gray-700" >
< div > {{ node.node_id_hex }} / {{ node.node_id }}< / div >
< / div >
2024-03-17 03:38:06 +13:00
< / div >
< / div >
2024-09-04 12:37:51 +12:00
< div v-if = "searchedNodes.length === 500" class = "text-gray-500 text-sm px-2 py-1" >
Only the first 500 results are shown.
< / div >
2024-03-17 03:38:06 +13:00
< / template >
< template v-else >
< div class = "p-2" >
No results found...
< / div >
< / template >
< / div >
< / div >
2024-03-26 04:05:37 +13:00
<!-- header action buttons -->
< div v-if = "!isShowingMobileSearch" class = "flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2" >
2024-08-24 15:10:50 +12:00
< a @ click = "isShowingInfoModal = true" href = "javascript:void(0)" class = "tooltip rounded-full" >
2024-03-23 12:59:17 +13:00
< div class = "bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
2024-03-29 23:16:09 +13:00
< svg class = "w-6 h-6" xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" / >
2024-03-23 12:59:17 +13:00
< / svg >
< / div >
< div class = "hidden sm:block" >
2024-03-29 23:16:09 +13:00
< span class = "tooltip-text" > About< / span >
2024-03-23 12:59:17 +13:00
< / div >
< / a >
2024-03-26 04:05:37 +13:00
< a @ click = "isShowingMobileSearch = true" href = "javascript:void(0)" class = "tooltip rounded-full block lg:hidden" >
2024-03-13 21:02:58 +13:00
< div id = "search-button" class = "bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg class = "w-6 h-6" xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" fill = "none" stroke-linecap = "round" stroke-linejoin = "round" >
< path stroke = "none" d = "M0 0h24v24H0z" fill = "none" > < / path >
< path d = "M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" > < / path >
< path d = "M21 21l-6 -6" > < / path >
< / svg >
< / div >
< div class = "hidden sm:block" >
< span class = "tooltip-text" > Search< / span >
< / div >
< / a >
2024-03-23 17:13:49 +13:00
< a @ click = "isShowingSettings = true" href = "javascript:void(0)" class = "tooltip rounded-full" >
< div class = "bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg class = "w-6 h-6" xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" / >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" / >
< / svg >
< / div >
< div class = "hidden sm:block" >
< span class = "tooltip-text" > Settings< / span >
< / div >
< / a >
2024-03-13 21:02:58 +13:00
< a href = "#" class = "tooltip rounded-full" onclick = "reload()" >
< div id = "reload-button" class = "bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg class = "w-6 h-6" xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" fill = "none" stroke-linecap = "round" stroke-linejoin = "round" >
< path stroke = "none" d = "M0 0h24v24H0z" fill = "none" > < / path >
< path d = "M19.933 13.041a8 8 0 1 1 -9.925 -8.788c3.899 -1 7.935 1.007 9.425 4.747" > < / path >
< path d = "M20 4v5h-5" > < / path >
< / svg >
< / div >
< div class = "hidden sm:block" >
< span class = "tooltip-text" > Reload< / span >
< / div >
< / a >
< / div >
2024-03-26 04:05:37 +13:00
2024-03-12 18:31:17 +13:00
< / div >
2024-03-13 21:02:58 +13:00
<!-- map -->
< div id = "map" style = "width:100%;height:100%;" > < / div >
2024-03-12 18:31:17 +13:00
2024-03-13 21:02:58 +13:00
< / div >
2024-03-12 18:31:17 +13:00
< / div >
2024-03-29 23:16:09 +13:00
<!-- info modal -->
< div class = "relative z-sidebar" >
<!-- overlay -->
< transition
enter-active-class="transition-opacity duration-300 ease-linear"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-linear"
leave-from-class="opacity-100"
leave-to-class="opacity-0">
2024-03-29 23:27:02 +13:00
< div v-show = "isShowingInfoModal" @ click = "dismissInfoModal" class = "fixed inset-0 bg-gray-900 bg-opacity-75" > < / div >
2024-03-29 23:16:09 +13:00
< / transition >
<!-- modal -->
< transition
enter-active-class="transition duration-300 ease-in-out transform"
enter-from-class="translate-y-full"
enter-to-class="translate-y-0"
leave-active-class="transition duration-300 ease-in-out transform"
leave-from-class="translate-y-0"
leave-to-class="translate-y-full">
2024-03-29 23:40:21 +13:00
< div @ click = "dismissInfoModal" v-show = "isShowingInfoModal" class = "fixed left-0 right-0 top-0 bottom-0 lg:pointer-events-none" >
< div class = "flex w-full h-full overflow-y-auto p-4" >
< div @ click . stop class = "mx-auto my-auto w-full max-w-2xl flex-col bg-white shadow-xl rounded-xl p-2 lg:pointer-events-auto" >
2024-03-29 23:16:09 +13:00
< div class = "relative flex" >
<!-- close button -->
< div class = "absolute top-0 right-0" >
< div class = "h-7" >
2024-03-29 23:27:02 +13:00
< a href = "javascript:void(0)" class = "rounded-full" @ click = "dismissInfoModal" >
2024-03-29 23:16:09 +13:00
< div class = "bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg class = "w-6 h-6" xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" fill = "none" stroke-linecap = "round" stroke-linejoin = "round" >
< path stroke = "none" d = "M0 0h24v24H0z" fill = "none" > < / path >
< path d = "M18 6l-12 12" > < / path >
< path d = "M6 6l12 12" > < / path >
< / svg >
< / div >
< / a >
< / div >
< / div >
<!-- content -->
< div class = "flex flex-col w-full py-2 space-y-2" >
<!-- app info -->
< div class = "w-full mx-auto text-center" >
< img src = "./icon.png" class = "mx-auto w-16 h-16 rounded mb-1" / >
< h1 class = "font-bold" > Meshtastic Map< / h1 >
< h2 > Created by < a class = "link" target = "_blank" href = "https://liamcottle.com" > Liam Cottle< / a > < / h2 >
2025-03-05 00:10:30 +01:00
< h2 > Forked by < a class = "link" target = "_blank" href = "http://github.com/Roslund/" > Roslund< / a > < / h2 >
2024-03-29 23:16:09 +13:00
< / div >
2025-03-05 00:10:30 +01:00
<!-- Beskrivning -->
2024-03-29 23:16:09 +13:00
< div >
2025-03-05 00:10:30 +01:00
< div class = "font-bold mb-2" > Beskrivning< / div >
< div class = "space-y-2" >
2025-03-05 00:14:39 +01:00
< div class = "bg-gray-100 rounded p-2 border border-gray-200" >
2025-11-19 20:03:24 +01:00
< div > Detta är en karta som enbart fokuserar på Stockholm.< / div >
< div > Den är baserad på Liam Cottle's open source projekt Meshtastic Map, men har flertalet ändringar och nya funktioner som gör att vi bättre kan analysera Meshen i Stockholm.< / div >
2025-03-05 00:14:39 +01:00
< / div >
2024-03-29 23:16:09 +13:00
< / div >
2025-11-19 20:03:24 +01:00
< br >
< div class = "font-bold mb-2" > Frågor och svar< / div >
2024-03-29 23:16:09 +13:00
< div class = "space-y-2" >
< div class = "bg-gray-100 rounded p-2 border border-gray-200" >
2025-11-19 20:03:24 +01:00
< div class = "font-semibold" > Hur får jag min nod att synas på kartan?< / div >
< div > Din nod behöver anting ha en GPS, ha en fast position inställd, eller att din telefon delar sin position.< / div >
< div > Utöver detta måste platsdelning vara påslåget under kanalinställningarna för MediumFast kanalen (vanligtvis kanal 0).< / div >
2024-08-24 14:58:39 +12:00
< / div >
< div class = "bg-gray-100 rounded p-2 border border-gray-200" >
2025-11-19 20:03:24 +01:00
< div class = "font-semibold" > Min nod är på fel plats på kartan< / div >
< div > Detta är troligtvis för att din nod inte delar < b > exakt position< / b > . Som standard är positionsprecisionen inställd på ± 3 km, vilket betyder att noden kan befinna sig inom en cirkel med radien 3 kilometer.< / div >
< div > Du kan ändra positions precisionen i kanalinställningarna. För mer info om positions precisionen, < a href = "https://sthlm-mesh.se/docs/position/#position-precision" > klicka här< / a > .< / div >
< / div >
< div class = "bg-gray-100 rounded p-2 border border-gray-200" >
< div class = "font-semibold" > Hur kan jag ansluta min nod till MQTT servern?< / div >
< div > Då vi enbart vill analysera Meshen i stockholm är MQTT servern inte öppen för alla. Endast ett fåtal noder är uppkopplade till MQTT för att kunna analysera trafiken som faktiskt går över LoRa.< / div >
< div > De noder som är kopplade mot MQTT servern bör:< / div >
2024-08-24 14:58:39 +12:00
< ul class = "list-disc list-inside" >
2025-11-19 20:03:24 +01:00
< li > Vara på en unik geografisk plats, då vi vill se hur trafiken fördelas< / li >
< li > Ha en stabil fast koppling till internet (via Ethernet eller WiFi)< / li >
< li > Ha hög tillgänglighet< / li >
< li > Ha direktkontakt med flertalet andra noder< / li >
2024-08-24 14:58:39 +12:00
< / ul >
2025-11-19 20:03:24 +01:00
< div > Tror du att din nod kan bidra, kontakta @Roslund på Discord.< / div >
2024-03-29 23:16:09 +13:00
< / div >
2025-03-05 00:14:39 +01:00
< / div >
< / div >
2024-03-29 23:16:09 +13:00
<!-- legal -->
< div >
< div class = "font-bold mb-2" > Legal< / div >
< div class = "bg-gray-100 rounded p-2 border border-gray-200" >
< div > This project is not affiliated with or endorsed by the < a class = "link" target = "_blank" href = "https://meshtastic.org" > Meshtastic< / a > project.< / div >
< div > The Meshtastic logo is the trademark of Meshtastic LLC.< / div >
< div > Map tiles provided by < a class = "link" target = "_blank" href = "https://www.openstreetmap.org/copyright" > OpenStreetMap< / a > < / div >
< / div >
< / div >
<!-- dismiss button -->
< div class = "mx-auto" >
2024-03-29 23:27:02 +13:00
< a href = "javascript:void(0)" @ click = "dismissInfoModal" >
2024-03-29 23:16:09 +13:00
< div class = "bg-gray-200 hover:bg-gray-300 px-6 py-2 rounded-md shadow" >
Dismiss
< / div >
< / a >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< / transition >
< / div >
2024-03-13 21:02:58 +13:00
<!-- hardware models sidebar -->
2024-03-13 23:06:51 +13:00
< div class = "relative z-sidebar" role = "dialog" aria-modal = "true" >
<!-- overlay -->
< transition
enter-active-class="transition-opacity duration-300 ease-linear"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-linear"
leave-from-class="opacity-100"
leave-to-class="opacity-0">
< div v-show = "isShowingHardwareModels" @ click = "isShowingHardwareModels = !isShowingHardwareModels" class = "fixed inset-0 bg-gray-900 bg-opacity-75" > < / div >
< / transition >
<!-- sidebar -->
< transition
enter-active-class="transition duration-300 ease-in-out transform"
enter-from-class="-translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition duration-300 ease-in-out transform"
leave-from-class="translate-x-0"
leave-to-class="-translate-x-full">
< div v-show = "isShowingHardwareModels" class = "fixed top-0 left-0 bottom-0" >
< div class = "w-screen max-w-md overflow-hidden" >
2024-03-14 19:52:06 +13:00
< div class = "flex h-full flex-col bg-white shadow-xl" >
2024-03-13 23:06:51 +13:00
<!-- slideover header -->
< div class = "p-2 border-b border-gray-200 shadow" >
< div class = "flex items-start justify-between" >
< div >
< h2 class = "font-bold" > Meshtastic Devices< / h2 >
< h3 class = "text-sm" > Ordered by most popular< / h3 >
< / div >
< div class = "my-auto ml-3 flex h-7 items-center" >
< a href = "javascript:void(0)" class = "rounded-full" @ click = "isShowingHardwareModels = !isShowingHardwareModels" >
< div class = "bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg class = "w-6 h-6" xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" fill = "none" stroke-linecap = "round" stroke-linejoin = "round" >
< path stroke = "none" d = "M0 0h24v24H0z" fill = "none" > < / path >
< path d = "M18 6l-12 12" > < / path >
< path d = "M6 6l12 12" > < / path >
< / svg >
< / div >
< / a >
2024-03-12 18:31:17 +13:00
< / div >
< / div >
2024-03-13 23:06:51 +13:00
< / div >
2024-03-12 18:31:17 +13:00
2024-03-13 23:06:51 +13:00
<!-- list of hardware models -->
< ul role = "list" class = "flex-1 divide-y divide-gray-200 overflow-y-auto" >
< li v-for = "hardwareModel of hardwareModelStats" >
< div class = "group relative flex items-center" >
< a href = "#" class = "block flex-1 px-4 py-2" >
< div class = "absolute inset-0 group-hover:bg-gray-100" aria-hidden = "true" > < / div >
< div class = "relative flex min-w-0 flex-1 items-center" >
< span class = "relative inline-block flex-shrink-0 mr-4" >
2024-05-16 20:30:14 +12:00
< img class = "h-20 w-20 rounded object-contain" :src = "`/images/devices/${hardwareModel.hardware_model_name}.png`" alt = "" onerror = "if(this.src != '/images/no_image.png') this.src = '/images/no_image.png';" >
2024-03-13 23:06:51 +13:00
< / span >
< div class = "truncate" >
< p class = "truncate text-sm font-medium text-gray-900" > {{ hardwareModel.hardware_model_name }}< / p >
< p class = "truncate text-sm text-gray-500" > {{ hardwareModel.count }} nodes on the map< / p >
2024-03-13 21:02:58 +13:00
< / div >
2024-03-13 23:06:51 +13:00
< / div >
< / a >
< / div >
< / li >
< / ul >
2024-03-13 21:02:58 +13:00
2024-03-12 18:31:17 +13:00
< / div >
< / div >
< / div >
2024-03-13 23:06:51 +13:00
< / transition >
2024-03-12 18:31:17 +13:00
< / div >
2024-03-13 21:02:58 +13:00
2024-03-14 00:12:42 +13:00
<!-- node info sidebar -->
< div class = "relative z-sidebar" role = "dialog" aria-modal = "true" >
<!-- overlay -->
< transition
enter-active-class="transition-opacity duration-300 ease-linear"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-linear"
leave-from-class="opacity-100"
leave-to-class="opacity-0">
< div v-show = "selectedNode != null" @ click = "selectedNode = null" class = "fixed inset-0 bg-gray-900 bg-opacity-75" > < / div >
< / transition >
<!-- sidebar -->
< transition
enter-active-class="transition duration-300 ease-in-out transform"
enter-from-class="-translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition duration-300 ease-in-out transform"
leave-from-class="translate-x-0"
leave-to-class="-translate-x-full">
< div v-show = "selectedNode != null" class = "fixed top-0 left-0 bottom-0" >
< div v-if = "selectedNode != null" class = "w-screen max-w-md overflow-hidden" >
2024-03-14 19:56:19 +13:00
< div class = "flex h-full flex-col bg-white shadow-xl" >
2024-03-14 00:12:42 +13:00
<!-- slideover header -->
< div class = "p-2 border-b border-gray-200 shadow" >
2024-08-28 01:42:44 +12:00
< div class = "flex" >
2024-08-28 02:17:05 +12:00
< div class = "my-auto mr-2" >
2024-09-07 21:57:23 +12:00
< div class = "flex rounded-full h-12 w-12 text-white shadow" :class = "[ `bg-[${getNodeColour(selectedNode.node_id)}]`, `text-[${getNodeTextColour(selectedNode.node_id)}]` ]" >
2024-09-04 12:32:17 +12:00
< div class = "mx-auto my-auto drop-shadow-sm" > {{ selectedNode.short_name }}< / div >
2024-08-28 01:42:44 +12:00
< / div >
< / div >
2024-08-28 02:17:05 +12:00
< div class = "my-auto mr-auto" >
2024-03-14 00:12:42 +13:00
< h2 class = "font-bold" > Node Info< / h2 >
< h3 class = "text-sm" > {{ selectedNode.long_name }}< / h3 >
< / div >
2024-08-28 02:02:28 +12:00
< div class = "my-auto ml-2 flex h-7 items-center space-x-2" >
< a href = "javascript:void(0)" class = "rounded-full" @ click = "copyShareLinkForNode(selectedNode.node_id)" >
< div class = "bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg xmlns = "http://www.w3.org/2000/svg" fill = "currentColor" viewBox = "0 0 256 256" class = "size-6" >
< path d = "M216,112v96a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V112A16,16,0,0,1,56,96H80a8,8,0,0,1,0,16H56v96H200V112H176a8,8,0,0,1,0-16h24A16,16,0,0,1,216,112ZM93.66,69.66,120,43.31V136a8,8,0,0,0,16,0V43.31l26.34,26.35a8,8,0,0,0,11.32-11.32l-40-40a8,8,0,0,0-11.32,0l-40,40A8,8,0,0,0,93.66,69.66Z" > < / path >
< / svg >
< / div >
< / a >
2024-03-14 00:12:42 +13:00
< a href = "javascript:void(0)" class = "rounded-full" @ click = "selectedNode = null" >
< div class = "bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg class = "w-6 h-6" xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" fill = "none" stroke-linecap = "round" stroke-linejoin = "round" >
< path stroke = "none" d = "M0 0h24v24H0z" fill = "none" > < / path >
< path d = "M18 6l-12 12" > < / path >
< path d = "M6 6l12 12" > < / path >
< / svg >
< / div >
< / a >
< / div >
< / div >
< / div >
2024-03-14 19:56:19 +13:00
< div class = "overflow-y-auto" >
2024-09-04 12:59:41 +12:00
<!-- no position banner -->
< div v-if = "findNodeMarkerById(selectedNode.node_id) == null" class = "flex bg-orange-500 text-white p-2" >
< div class = "my-auto mr-2" >
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" fill = "currentColor" class = "size-6" >
< path fill-rule = "evenodd" d = "m11.54 22.351.07.04.028.016a.76.76 0 0 0 .723 0l.028-.015.071-.041a16.975 16.975 0 0 0 1.144-.742 19.58 19.58 0 0 0 2.683-2.282c1.944-1.99 3.963-4.98 3.963-8.827a8.25 8.25 0 0 0-16.5 0c0 3.846 2.02 6.837 3.963 8.827a19.58 19.58 0 0 0 2.682 2.282 16.975 16.975 0 0 0 1.145.742ZM12 13.5a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" clip-rule = "evenodd" / >
< / svg >
< / div >
< div class = "my-auto" > This node has not reported a position.< / div >
< / div >
2024-03-14 19:56:19 +13:00
< div class = "flex flex-col my-2" >
< div class = "mx-auto" >
2024-05-16 20:30:14 +12:00
< img class = "h-48 w-48 rounded object-contain" :src = "`/images/devices/${selectedNode.hardware_model_name}.png`" alt = "" onerror = "if(this.src != '/images/no_image.png') this.src = '/images/no_image.png';" >
2024-03-14 19:56:19 +13:00
< / div >
2024-03-14 00:12:42 +13:00
< / div >
2024-07-06 01:21:29 +12:00
<!-- action buttons -->
< div class = "flex space-x-6 p-2 justify-center" >
<!-- sent messages -->
< a target = "_blank" :href = "`/api/v1/text-messages/embed?from=${selectedNode.node_id}`" class = "flex flex-col" title = "Messages sent from this Node" >
< div class = "flex mx-auto mb-1" >
< div class = "bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" class = "size-6" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "m15 11.25-3-3m0 0-3 3m3-3v7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" / >
< / svg >
< / div >
< / div >
< div class = "mx-auto text-sm font-medium text-gray-900" > Sent Msgs< / div >
< / a >
<!-- received messages -->
< a target = "_blank" :href = "`/api/v1/text-messages/embed?to=${selectedNode.node_id}`" class = "flex flex-col" title = "Messages sent to this Node" >
< div class = "flex mx-auto mb-1" >
< div class = "bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" class = "size-6" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "m9 12.75 3 3m0 0 3-3m-3 3v-7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" / >
< / svg >
< / div >
< / div >
< div class = "mx-auto text-sm font-medium text-gray-900" > Received Msgs< / div >
< / a >
<!-- gated messages -->
< a target = "_blank" :href = "`/api/v1/text-messages/embed?gateway_id=${selectedNode.node_id}`" class = "flex flex-col" title = "Messages gated to MQTT by this Node" >
< div class = "flex mx-auto mb-1" >
< div class = "bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" class = "size-6" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" / >
< / svg >
< / div >
< / div >
< div class = "mx-auto text-sm font-medium text-gray-900" > Gated Msgs< / div >
< / a >
< / div >
2024-03-14 19:56:19 +13:00
<!-- details -->
< div >
< div class = "bg-gray-200 p-2 font-semibold" > Details< / div >
< ul role = "list" class = "flex-1 divide-y divide-gray-200" >
2024-09-02 12:37:27 +12:00
<!-- id -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > ID< / div >
< div class = "ml-auto text-sm text-gray-700" > {{ selectedNode.node_id }}< / div >
< / li >
<!-- hex id -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Hex ID< / div >
< div class = "ml-auto text-sm text-gray-700" > {{ selectedNode.node_id_hex }}< / div >
< / li >
2024-09-02 12:18:29 +12:00
<!-- role -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Role< / div >
< div class = "ml-auto text-sm text-gray-700" > {{ selectedNode.role_name }}< / div >
2024-03-14 19:56:19 +13:00
< / li >
2024-09-02 12:18:29 +12:00
<!-- hardware -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Hardware< / div >
< div class = "ml-auto text-sm text-gray-700" > {{ selectedNode.hardware_model_name }}< / div >
2024-03-14 19:56:19 +13:00
< / li >
2024-03-14 00:12:42 +13:00
2024-09-02 12:18:29 +12:00
<!-- firmware version -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Firmware< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNode.firmware_version" > {{ selectedNode.firmware_version }}< / span >
< span v-else > ???< / span >
2024-04-02 11:23:18 +13:00
< / div >
< / li >
2024-03-14 19:56:19 +13:00
< / ul >
< / div >
2024-03-14 00:12:42 +13:00
2024-04-07 20:24:15 +12:00
<!-- position -->
< div >
2024-08-04 19:12:51 +02:00
< div @ click . stop class = "flex bg-gray-200 p-2 font-semibold" >
< div class = "my-auto" > Position< / div >
< div class = "ml-auto" >
2024-08-20 18:47:50 +12:00
< button @ click = "showNodePositionHistory(selectedNode.node_id)" type = "button" class = "rounded bg-white px-2 py-1 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" >
Show History
2024-08-04 19:12:51 +02:00
< / button >
< / div >
< / div >
2024-04-07 20:24:15 +12:00
< ul role = "list" class = "flex-1 divide-y divide-gray-200" >
2024-09-02 12:26:53 +12:00
<!-- position -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Lat/Long< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNode.latitude && selectedNode.longitude" > {{ selectedNode.latitude }}, {{ selectedNode.longitude }}< / span >
< span v-else > ???< / span >
2024-04-07 20:24:15 +12:00
< / div >
< / li >
2024-09-02 12:26:53 +12:00
<!-- altitude -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Altitude< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNode.altitude" > {{ selectedNode.altitude }}m< / span >
< span v-else > ???< / span >
2024-04-07 20:24:15 +12:00
< / div >
< / li >
< / ul >
< / div >
2024-06-07 00:33:45 +12:00
<!-- device metrics -->
2024-03-14 19:56:19 +13:00
< div >
2024-09-04 11:39:23 +12:00
< div class = "flex bg-gray-200 p-2 font-semibold" >
< div class = "my-auto" > Device Metrics< / div >
< div class = "my-auto ml-auto" >
2024-09-04 12:04:11 +12:00
< select v-model = "deviceMetricsTimeRange" class = "block w-full rounded-md border-0 py-0.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6" >
2024-09-04 11:39:23 +12:00
< option value = "1d" > 1 Day< / option >
< option value = "3d" > 3 Days< / option >
< option value = "7d" > 7 Days< / option >
2025-03-09 09:40:42 +01:00
< option value = "30d" > 30 Days< / option >
2024-09-04 11:39:23 +12:00
< / select >
< / div >
< / div >
2024-03-14 19:56:19 +13:00
< ul role = "list" class = "flex-1 divide-y divide-gray-200" >
2024-09-02 03:26:18 +12:00
<!-- device metrics chart -->
2024-03-14 19:56:19 +13:00
< li >
2024-09-02 03:26:18 +12:00
< div class = "px-4 py-2" >
< div class = "w-full" >
< canvas id = "deviceMetricsChart" style = "height:150px;" > < / canvas >
< div class = "flex" >
< div class = "mx-auto flex space-x-2" >
< div class = "flex mx-auto" >
< div class = "my-auto w-2 h-2 bg-blue-500 rounded-full" > < / div >
< div class = "my-auto ml-1 text-sm text-gray-500" > Battery Level< / div >
< / div >
< div class = "flex mx-auto" >
< div class = "my-auto w-2 h-2 bg-green-500 rounded-full" > < / div >
< div class = "my-auto ml-1 text-sm text-gray-500" > Channel Utilization< / div >
< / div >
< div class = "flex mx-auto" >
< div class = "my-auto w-2 h-2 bg-orange-500 rounded-full" > < / div >
< div class = "my-auto ml-1 text-sm text-gray-500" > Air Util TX< / div >
< / div >
2024-03-14 19:56:19 +13:00
< / div >
2024-03-14 02:39:41 +13:00
< / div >
2024-03-14 00:12:42 +13:00
< / div >
< / div >
2024-03-14 19:56:19 +13:00
< / li >
2024-09-02 03:26:18 +12:00
<!-- battery level -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Battery Level< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNode.battery_level" >
< span v-if = "selectedNode.battery_level > 100" > Plugged In< / span >
< span v-else > {{ selectedNode.battery_level }}%< / span >
< / span >
< span v-else > ???< / span >
2024-03-14 00:12:42 +13:00
< / div >
2024-03-14 19:56:19 +13:00
< / li >
2024-09-02 03:26:18 +12:00
<!-- voltage -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Voltage< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNode.voltage" > {{ Number(selectedNode.voltage).toFixed(2) }}V< / span >
< span v-else > ???< / span >
2024-03-14 00:12:42 +13:00
< / div >
2024-03-14 19:56:19 +13:00
< / li >
2024-09-02 03:26:18 +12:00
<!-- channel utilization -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Channel Utilization< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNode.channel_utilization" > {{ Number(selectedNode.channel_utilization).toFixed(2) }}%< / span >
< span v-else > ???< / span >
2024-03-14 00:12:42 +13:00
< / div >
2024-03-14 19:56:19 +13:00
< / li >
2024-03-14 00:12:42 +13:00
2024-09-02 03:26:18 +12:00
<!-- air util tx -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Air Util Tx< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNode.air_util_tx" > {{ Number(selectedNode.air_util_tx).toFixed(2) }}%< / span >
< span v-else > ???< / span >
< / div >
< / li >
<!-- air util tx -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Uptime< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNode.uptime_seconds" > {{ formatUptimeSeconds(selectedNode.uptime_seconds) }}< / span >
< span v-else > ???< / span >
2024-04-16 16:32:26 +12:00
< / div >
< / li >
2024-03-14 19:56:19 +13:00
< / ul >
< / div >
2024-03-14 00:12:42 +13:00
2024-06-07 00:51:33 +12:00
<!-- environment metrics -->
< div >
2024-09-08 22:30:22 +12:00
< div class = "flex bg-gray-200 p-2 font-semibold" >
< div class = "my-auto" > Environment Metrics< / div >
< div class = "my-auto ml-auto" >
< select v-model = "environmentMetricsTimeRange" class = "block w-full rounded-md border-0 py-0.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6" >
< option value = "1d" > 1 Day< / option >
< option value = "3d" > 3 Days< / option >
< option value = "7d" > 7 Days< / option >
2025-03-09 09:40:42 +01:00
< option value = "30d" > 30 Days< / option >
2024-09-08 22:30:22 +12:00
< / select >
< / div >
< / div >
2024-06-07 00:51:33 +12:00
< ul role = "list" class = "flex-1 divide-y divide-gray-200" >
2024-09-08 22:30:22 +12:00
<!-- environment metrics chart -->
2024-06-07 00:51:33 +12:00
< li >
2024-09-08 22:30:22 +12:00
< div class = "px-4 py-2" >
< div class = "w-full" >
< canvas id = "environmentMetricsChart" style = "height:150px;" > < / canvas >
< div class = "flex" >
< div class = "mx-auto flex space-x-2" >
< div class = "flex mx-auto" >
< div class = "my-auto w-2 h-2 bg-blue-500 rounded-full" > < / div >
< div class = "my-auto ml-1 text-sm text-gray-500" > Temperature< / div >
< / div >
< div class = "flex mx-auto" >
< div class = "my-auto w-2 h-2 bg-green-500 rounded-full" > < / div >
< div class = "my-auto ml-1 text-sm text-gray-500" > Humidity< / div >
< / div >
< div class = "flex mx-auto" >
< div class = "my-auto w-2 h-2 bg-orange-500 rounded-full" > < / div >
< div class = "my-auto ml-1 text-sm text-gray-500" > Pressure< / div >
< / div >
2025-06-26 22:33:37 +02:00
< div class = "flex mx-auto" >
< div class = "my-auto w-2 h-2 bg-pink-400 rounded-full" > < / div >
< div class = "my-auto ml-1 text-sm text-gray-500" > IAQ< / div >
< / div >
2024-06-07 00:51:33 +12:00
< / div >
< / div >
< / div >
< / div >
< / li >
2024-09-08 22:30:22 +12:00
<!-- temperature -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Temperature< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNode.temperature" > {{ formatTemperature(selectedNode.temperature) }}< / span >
< span v-else > ???< / span >
2024-06-07 00:51:33 +12:00
< / div >
< / li >
2024-09-08 22:30:22 +12:00
<!-- relative humidity -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Relative Humidity< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNode.relative_humidity" > {{ Number(selectedNode.relative_humidity).toFixed(0) }}%< / span >
< span v-else > ???< / span >
< / div >
< / li >
<!-- barometric pressure -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Barometric Pressure< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNode.barometric_pressure" > {{ Number(selectedNode.barometric_pressure).toFixed(1) }}hPa< / span >
< span v-else > ???< / span >
2024-06-07 00:51:33 +12:00
< / div >
< / li >
< / ul >
< / div >
2024-06-08 16:11:24 +12:00
<!-- power metrics -->
< div >
2024-09-04 12:04:11 +12:00
< div class = "flex bg-gray-200 p-2 font-semibold" >
< div class = "my-auto" > Power Metrics< / div >
< div class = "my-auto ml-auto" >
< select v-model = "powerMetricsTimeRange" class = "block w-full rounded-md border-0 py-0.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6" >
< option value = "1d" > 1 Day< / option >
< option value = "3d" > 3 Days< / option >
< option value = "7d" > 7 Days< / option >
2025-03-09 09:40:42 +01:00
< option value = "30d" > 30 Days< / option >
2024-09-04 12:04:11 +12:00
< / select >
< / div >
< / div >
2024-06-08 16:11:24 +12:00
< ul role = "list" class = "flex-1 divide-y divide-gray-200" >
2024-09-02 14:06:58 +12:00
<!-- power metrics chart -->
2024-06-08 16:11:24 +12:00
< li >
2024-09-02 14:06:58 +12:00
< div class = "px-4 py-2" >
< div class = "w-full" >
< canvas id = "powerMetricsChart" style = "height:150px;" > < / canvas >
< div class = "flex" >
< div class = "mx-auto flex space-x-2" >
< div class = "flex mx-auto" >
< div class = "my-auto w-2 h-2 bg-blue-500 rounded-full" > < / div >
< div class = "my-auto ml-1 text-sm text-gray-500" > Channel 1< / div >
< / div >
< div class = "flex mx-auto" >
< div class = "my-auto w-2 h-2 bg-green-500 rounded-full" > < / div >
< div class = "my-auto ml-1 text-sm text-gray-500" > Channel 2< / div >
< / div >
< div class = "flex mx-auto" >
< div class = "my-auto w-2 h-2 bg-orange-500 rounded-full" > < / div >
< div class = "my-auto ml-1 text-sm text-gray-500" > Channel 3< / div >
< / div >
2024-06-08 16:11:24 +12:00
< / div >
< / div >
< / div >
< / div >
< / li >
2024-09-02 14:06:58 +12:00
<!-- channel 1 -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Channel 1< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNodeLatestPowerMetric" >
< span v-if = "selectedNodeLatestPowerMetric?.ch1_voltage" > {{ Number(selectedNodeLatestPowerMetric.ch1_voltage).toFixed(2) }}V< / span >
< span v-else > ???< / span >
< span v-if = "selectedNodeLatestPowerMetric?.ch1_current" > / {{ Number(selectedNodeLatestPowerMetric.ch1_current).toFixed(2) }}mA< / span >
< / span >
< span v-else > ???< / span >
2024-06-08 16:11:24 +12:00
< / div >
< / li >
2024-09-02 14:06:58 +12:00
<!-- channel 2 -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Channel 2< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNodeLatestPowerMetric" >
< span v-if = "selectedNodeLatestPowerMetric?.ch2_voltage" > {{ Number(selectedNodeLatestPowerMetric.ch2_voltage).toFixed(2) }}V< / span >
< span v-else > ???< / span >
< span v-if = "selectedNodeLatestPowerMetric?.ch2_current" > / {{ Number(selectedNodeLatestPowerMetric.ch2_current).toFixed(2) }}mA< / span >
< / span >
< span v-else > ???< / span >
< / div >
< / li >
<!-- channel 3 -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Channel 3< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNodeLatestPowerMetric" >
< span v-if = "selectedNodeLatestPowerMetric?.ch3_voltage" > {{ Number(selectedNodeLatestPowerMetric.ch3_voltage).toFixed(2) }}V< / span >
< span v-else > ???< / span >
< span v-if = "selectedNodeLatestPowerMetric?.ch3_current" > / {{ Number(selectedNodeLatestPowerMetric.ch3_current).toFixed(2) }}mA< / span >
< / span >
< span v-else > ???< / span >
2024-06-08 16:11:24 +12:00
< / div >
< / li >
< / ul >
< / div >
2024-03-16 19:48:08 +13:00
<!-- mqtt -->
< div >
< div class = "bg-gray-200 p-2" >
< div class = "font-semibold" > MQTT< / div >
2024-03-19 02:33:11 +13:00
< div class = "text-sm text-gray-600" > Topics this node sent packets to< / div >
2024-03-16 19:48:08 +13:00
< / div >
< ul role = "list" class = "flex-1 divide-y divide-gray-200" >
< template v-if = "selectedNodeMqttMetrics.length > 0" >
< li v-for = "mqttMetric of selectedNodeMqttMetrics" >
< div class = "relative flex items-center" >
< div class = "block flex-1 px-4 py-2" >
< div class = "relative flex min-w-0 flex-1 items-center" >
< div class = "truncate" >
< p class = "truncate text-sm font-medium text-gray-900" > {{ mqttMetric.mqtt_topic }}< / p >
< div class = "text-sm text-gray-700" > Last packet {{ moment(new Date(mqttMetric.last_packet_at)).fromNow() }}< / div >
< / div >
< / div >
< / div >
< / div >
< / li >
< / template >
< template v-else >
< li >
< div class = "relative flex items-center" >
< div class = "block flex-1 px-4 py-2" >
< div class = "relative flex min-w-0 flex-1 items-center" >
< div class = "truncate" >
< div class = "text-sm text-gray-700" > No packets seen on MQTT< / div >
< / div >
< / div >
< / div >
< / div >
< / li >
< / template >
< / ul >
< / div >
2024-03-19 02:33:11 +13:00
<!-- traceroutes -->
< div >
< div class = "bg-gray-200 p-2" >
< div class = "font-semibold" > Trace Routes< / div >
< div class = "text-sm text-gray-600" > Only 5 most recent are shown< / div >
< / div >
< ul role = "list" class = "flex-1 divide-y divide-gray-200" >
< template v-if = "selectedNodeTraceroutes.length > 0" >
< li @ click = "showTraceRoute(traceroute)" v-for = "traceroute of selectedNodeTraceroutes" >
< div class = "relative flex items-center" >
< div class = "block flex-1 px-4 py-2" >
< div class = "relative flex min-w-0 flex-1 items-center" >
< div >
2024-04-16 14:15:25 +12:00
< p class = "text-sm text-gray-900" > < span class = "font-medium" > {{ findNodeById(traceroute.to)?.long_name || '???' }}< / span > to < span class = "font-medium" > {{ findNodeById(traceroute.from)?.long_name || '???' }}< / span > < / p >
< div class = "text-sm text-gray-700" > {{ moment(new Date(traceroute.updated_at)).fromNow() }} - {{ traceroute.route.length }} hops {{ traceroute.channel_id ? `on ${traceroute.channel_id}` : '' }}< / div >
2024-03-19 02:33:11 +13:00
< / div >
< / div >
< / div >
< / div >
< / li >
< / template >
< template v-else >
< li >
< div class = "relative flex items-center" >
< div class = "block flex-1 px-4 py-2" >
< div class = "relative flex min-w-0 flex-1 items-center" >
< div class = "truncate" >
< div class = "text-sm text-gray-700" > No traceroutes seen on MQTT< / div >
< / div >
< / div >
< / div >
< / div >
< / li >
< / template >
< / ul >
< / div >
2024-03-14 19:56:19 +13:00
<!-- other -->
< div >
< div class = "bg-gray-200 p-2 font-semibold" > Other< / div >
< ul role = "list" class = "flex-1 divide-y divide-gray-200" >
2024-09-02 04:14:26 +12:00
<!-- first seen -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > First Seen< / div >
< div class = "ml-auto text-sm text-gray-700" > {{ moment(new Date(selectedNode.created_at)).fromNow() }}< / div >
2024-03-14 19:56:19 +13:00
< / li >
2024-09-02 04:14:26 +12:00
<!-- last seen -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Last Seen< / div >
< div class = "ml-auto text-sm text-gray-700" > {{ moment(new Date(selectedNode.updated_at)).fromNow() }}< / div >
2024-03-14 19:56:19 +13:00
< / li >
2024-09-02 04:14:26 +12:00
<!-- neighbours updated -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Neighbours Updated< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNode.neighbours_updated_at" > {{ moment(new Date(selectedNode.neighbours_updated_at)).fromNow() }}< / span >
< span v-else > ???< / span >
2024-03-23 23:57:57 +13:00
< / div >
< / li >
2024-09-02 04:14:26 +12:00
<!-- position updated -->
< li class = "flex p-3" >
< div class = "text-sm font-medium text-gray-900" > Position Updated< / div >
< div class = "ml-auto text-sm text-gray-700" >
< span v-if = "selectedNode.position_updated_at" > {{ moment(new Date(selectedNode.position_updated_at)).fromNow() }}< / span >
< span v-else > ???< / span >
2024-03-23 23:57:57 +13:00
< / div >
< / li >
2024-03-14 19:56:19 +13:00
< / ul >
< / div >
2024-03-14 00:12:42 +13:00
2024-03-14 20:28:50 +13:00
<!-- share -->
< div >
2024-04-04 20:48:24 +13:00
< div class = "flex bg-gray-200 p-2 font-semibold" >
< div class = "my-auto" > Share Link< / div >
< div class = "ml-auto" >
< button @ click = "copyShareLinkForNode(selectedNode.node_id)" type = "button" class = "rounded bg-white px-2 py-1 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" >
Copy
< / button >
< / div >
< / div >
2024-03-14 20:28:50 +13:00
< ul role = "list" class = "flex-1 divide-y divide-gray-200" >
< li >
< div class = "relative flex items-center" >
< div class = "block flex-1 p-2" >
< div class = "flex space-x-2" >
2024-04-04 20:31:21 +13:00
< input type = "text" readonly class = "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" :value = "getShareLinkForNode(selectedNode.node_id)" >
2024-03-14 20:28:50 +13:00
< / div >
< / div >
< / div >
< / li >
< / ul >
< / div >
2024-03-14 00:12:42 +13:00
< / div >
< / div >
< / div >
< / div >
< / transition >
< / div >
2024-08-27 21:34:28 +12:00
<!-- traceroute info sidebar -->
2024-03-19 02:33:11 +13:00
< div class = "relative z-sidebar" role = "dialog" aria-modal = "true" >
<!-- overlay -->
< transition
enter-active-class="transition-opacity duration-300 ease-linear"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-linear"
leave-from-class="opacity-100"
leave-to-class="opacity-0">
< div v-show = "selectedTraceRoute != null" @ click = "selectedTraceRoute = null" class = "fixed inset-0 bg-gray-900 bg-opacity-75" > < / div >
< / transition >
<!-- sidebar -->
< transition
enter-active-class="transition duration-300 ease-in-out transform"
enter-from-class="-translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition duration-300 ease-in-out transform"
leave-from-class="translate-x-0"
leave-to-class="-translate-x-full">
< div v-show = "selectedTraceRoute != null" class = "fixed top-0 left-0 bottom-0" >
< div v-if = "selectedTraceRoute != null" class = "w-screen max-w-md overflow-hidden" >
< div class = "flex h-full flex-col bg-white shadow-xl" >
<!-- slideover header -->
< div class = "p-2 border-b border-gray-200 shadow" >
< div class = "flex items-start justify-between" >
< div >
< h2 class = "font-bold" > Traceroute #{{ selectedTraceRoute.id }}< / h2 >
2024-04-16 14:15:25 +12:00
< h3 class = "text-sm" > {{ moment(new Date(selectedTraceRoute.updated_at)).fromNow() }} - {{ selectedTraceRoute.route.length }} hops {{ selectedTraceRoute.channel_id ? `on ${selectedTraceRoute.channel_id}` : '' }}< / h3 >
2024-03-19 02:33:11 +13:00
< / div >
< div class = "my-auto ml-3 flex h-7 items-center" >
< a href = "javascript:void(0)" class = "rounded-full" @ click = "selectedTraceRoute = null" >
< div class = "bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg class = "w-6 h-6" xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" fill = "none" stroke-linecap = "round" stroke-linejoin = "round" >
< path stroke = "none" d = "M0 0h24v24H0z" fill = "none" > < / path >
< path d = "M18 6l-12 12" > < / path >
< path d = "M6 6l12 12" > < / path >
< / svg >
< / div >
< / a >
< / div >
< / div >
< / div >
< div class = "overflow-y-auto" >
<!-- details -->
< div class = "p-2" >
2024-09-08 13:34:06 +12:00
< ul role = "list" class = "space-y-3" >
2024-03-19 02:33:11 +13:00
<!-- node that initiated traceroute -->
2024-04-16 14:15:25 +12:00
< li :onclick = "`goToNode(${selectedTraceRoute.to})`" class = "relative flex gap-x-4" >
2024-09-08 13:34:06 +12:00
< div class = "absolute left-0 top-0 flex w-12 justify-center top-3 -bottom-3" >
2024-03-19 02:33:11 +13:00
< div class = "w-px bg-gray-200" > < / div >
< / div >
2024-09-08 13:34:06 +12:00
< div class = "my-auto relative flex flex-none items-center justify-center" >
< div >
< div class = "flex rounded-full h-12 w-12 text-white shadow" :class = "[ `bg-[${getNodeColour(selectedTraceRoute.to)}]`, `text-[${getNodeTextColour(selectedTraceRoute.to)}]` ]" >
< div class = "mx-auto my-auto drop-shadow-sm" > {{ findNodeById(selectedTraceRoute.to)?.short_name ?? "?" }}< / div >
< / div >
< / div >
2024-03-19 02:33:11 +13:00
< / div >
< div class = "flex-auto py-0.5 text-sm leading-5 text-gray-500" >
2024-04-16 14:15:25 +12:00
< div class = "font-medium text-gray-900" > {{ findNodeById(selectedTraceRoute.to)?.long_name || '???' }}< / div >
< div > Hex ID: !{{ Number(selectedTraceRoute.to).toString(16) }}< / div >
2024-03-19 02:33:11 +13:00
< div > Started the traceroute< / div >
< / div >
< / li >
<!-- middleman nodes -->
2024-04-16 14:15:25 +12:00
< li :onclick = "`goToNode(${route})`" v-for = "route of selectedTraceRoute.route" class = "relative flex gap-x-4" >
2024-09-08 13:34:06 +12:00
< div class = "absolute left-0 top-0 flex w-12 justify-center -bottom-3" >
2024-03-19 02:33:11 +13:00
< div class = "w-px bg-gray-200" > < / div >
< / div >
2024-09-08 13:34:06 +12:00
< div class = "my-auto relative flex flex-none items-center justify-center" >
< div >
< div class = "flex rounded-full h-12 w-12 text-white shadow" :class = "[ `bg-[${getNodeColour(route)}]`, `text-[${getNodeTextColour(route)}]` ]" >
< div class = "mx-auto my-auto drop-shadow-sm" > {{ findNodeById(route)?.short_name ?? "?" }}< / div >
< / div >
< / div >
2024-03-19 02:33:11 +13:00
< / div >
< div class = "flex-auto py-0.5 text-sm leading-5 text-gray-500" >
< div class = "font-medium text-gray-900" > {{ findNodeById(route)?.long_name || '???' }}< / div >
< div > Hex ID: !{{ Number(route).toString(16) }}< / div >
< div > Forwarded the packet< / div >
< / div >
< / li >
2024-04-16 14:15:25 +12:00
<!-- node that replied to traceroute -->
2024-04-16 14:34:09 +12:00
< li :onclick = "`goToNode(${selectedTraceRoute.from})`" v-if = "selectedTraceRoute.from" class = "relative flex gap-x-4" >
2024-09-08 13:34:06 +12:00
< div class = "absolute left-0 top-0 flex w-12 justify-center -bottom-3" >
2024-04-15 23:41:16 +01:00
< div class = "w-px bg-gray-200" > < / div >
< / div >
2024-09-08 13:34:06 +12:00
< div class = "my-auto relative flex flex-none items-center justify-center" >
< div >
< div class = "flex rounded-full h-12 w-12 text-white shadow" :class = "[ `bg-[${getNodeColour(selectedTraceRoute.from)}]`, `text-[${getNodeTextColour(selectedTraceRoute.from)}]` ]" >
< div class = "mx-auto my-auto drop-shadow-sm" > {{ findNodeById(selectedTraceRoute.from)?.short_name ?? "?" }}< / div >
< / div >
< / div >
2024-04-15 23:41:16 +01:00
< / div >
< div class = "flex-auto py-0.5 text-sm leading-5 text-gray-500" >
2024-04-16 14:15:25 +12:00
< div class = "font-medium text-gray-900" > {{ findNodeById(selectedTraceRoute.from)?.long_name || '???' }}< / div >
< div > Hex ID: !{{ Number(selectedTraceRoute.from).toString(16) }}< / div >
2024-04-15 23:41:16 +01:00
< div > Replied to traceroute< / div >
< / div >
< / li >
2024-04-16 14:15:25 +12:00
<!-- node that gated traceroute to mqtt -->
2024-04-16 14:34:09 +12:00
< li :onclick = "`goToNode(${selectedTraceRoute.gateway_id})`" v-if = "selectedTraceRoute.gateway_id" class = "relative flex gap-x-4" >
2024-09-08 13:34:06 +12:00
< div class = "absolute left-0 top-0 flex w-12 justify-center h-6" >
2024-03-19 02:33:11 +13:00
< div class = "w-px bg-gray-200" > < / div >
< / div >
2024-09-08 13:34:06 +12:00
< div class = "my-auto relative flex flex-none items-center justify-center" >
< div >
< div class = "flex rounded-full h-12 w-12 text-white shadow" :class = "[ `bg-[${getNodeColour(selectedTraceRoute.gateway_id)}]`, `text-[${getNodeTextColour(selectedTraceRoute.gateway_id)}]` ]" >
< div class = "mx-auto my-auto drop-shadow-sm" > {{ findNodeById(selectedTraceRoute.gateway_id)?.short_name ?? "?" }}< / div >
< / div >
< / div >
2024-03-19 02:33:11 +13:00
< / div >
< div class = "flex-auto py-0.5 text-sm leading-5 text-gray-500" >
< div class = "font-medium text-gray-900" > {{ findNodeById(selectedTraceRoute.gateway_id)?.long_name || '???' }}< / div >
< div > Hex ID: !{{ Number(selectedTraceRoute.gateway_id).toString(16) }}< / div >
< div > Gated the packet to MQTT< / div >
< / div >
< / li >
< / ul >
2024-04-02 21:14:55 +13:00
< / div >
< div >
< div class = "bg-gray-200 p-2 font-semibold" > Raw Data< / div >
< div class = "text-sm text-gray-700" >
< pre class = "bg-gray-100 rounded p-2 overflow-x-auto" > {{ JSON.stringify(selectedTraceRoute, null, 4) }}< / pre >
< / div >
2024-03-19 02:33:11 +13:00
< / div >
< / div >
< / div >
< / div >
< / div >
< / transition >
< / div >
2024-08-27 21:34:28 +12:00
<!-- settings sidebar -->
2024-03-23 17:13:49 +13:00
< div class = "relative z-sidebar" role = "dialog" aria-modal = "true" >
<!-- overlay -->
< transition
enter-active-class="transition-opacity duration-300 ease-linear"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-linear"
leave-from-class="opacity-100"
leave-to-class="opacity-0">
< div v-show = "isShowingSettings" @ click = "isShowingSettings = !isShowingSettings" class = "fixed inset-0 bg-gray-900 bg-opacity-75" > < / div >
< / transition >
<!-- sidebar -->
< transition
enter-active-class="transition duration-300 ease-in-out transform"
enter-from-class="-translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition duration-300 ease-in-out transform"
leave-from-class="translate-x-0"
leave-to-class="-translate-x-full">
< div v-show = "isShowingSettings" class = "fixed top-0 left-0 bottom-0" >
< div v-if = "isShowingSettings" class = "w-screen max-w-md overflow-hidden" >
< div class = "flex h-full flex-col bg-white shadow-xl" >
<!-- slideover header -->
< div class = "p-2 border-b border-gray-200 shadow" >
< div class = "flex items-start justify-between" >
< div >
< h2 class = "font-bold" > Settings< / h2 >
< h3 class = "text-sm" > Changes are only saved in this browser.< / h3 >
< / div >
< div class = "my-auto ml-3 flex h-7 items-center" >
< a href = "javascript:void(0)" class = "rounded-full" @ click = "isShowingSettings = false" >
< div class = "bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg class = "w-6 h-6" xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" fill = "none" stroke-linecap = "round" stroke-linejoin = "round" >
< path stroke = "none" d = "M0 0h24v24H0z" fill = "none" > < / path >
< path d = "M18 6l-12 12" > < / path >
< path d = "M6 6l12 12" > < / path >
< / svg >
< / div >
< / a >
< / div >
< / div >
< / div >
< div class = "overflow-y-auto divide-y divide-gray-200" >
2024-03-26 03:07:08 +13:00
<!-- configNodesMaxAgeInSeconds -->
< div class = "p-2" >
2024-03-26 03:11:44 +13:00
< label class = "block text-sm font-medium text-gray-900" > Nodes Max Age< / label >
< div class = "text-xs text-gray-600 mb-2" > Nodes not updated within this time are hidden. Reload to update map.< / div >
2024-03-26 03:07:08 +13:00
< select v-model = "configNodesMaxAgeInSeconds" class = "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" >
< option :value = "null" > Show All< / option >
< option value = "900" > 15 minutes< / option >
< option value = "1800" > 30 minutes< / option >
< option value = "3600" > 1 hour< / option >
< option value = "10800" > 3 hours< / option >
< option value = "21600" > 6 hours< / option >
< option value = "43200" > 12 hours< / option >
< option value = "86400" > 24 hours< / option >
< option value = "172800" > 2 days< / option >
< option value = "259200" > 3 days< / option >
< option value = "345600" > 4 days< / option >
< option value = "432000" > 5 days< / option >
< option value = "518400" > 6 days< / option >
< option value = "604800" > 7 days< / option >
< / select >
< / div >
2024-04-05 11:59:33 +13:00
<!-- configNodesOfflineAgeInSeconds -->
< div class = "p-2" >
< label class = "block text-sm font-medium text-gray-900" > Nodes Offline Age< / label >
< div class = "text-xs text-gray-600 mb-2" > Nodes not updated within this time will show as red icons. Reload to update map.< / div >
< select v-model = "configNodesOfflineAgeInSeconds" class = "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" >
< option :value = "null" > Don't show as offline< / option >
< option value = "900" > 15 minutes< / option >
< option value = "1800" > 30 minutes< / option >
< option value = "2700" > 45 minutes< / option >
< option value = "3600" > 1 hour< / option >
2024-04-05 12:48:49 +13:00
< option value = "7200" > 2 hours< / option >
2024-04-05 11:59:33 +13:00
< option value = "10800" > 3 hours< / option >
< option value = "21600" > 6 hours< / option >
< option value = "43200" > 12 hours< / option >
< option value = "86400" > 24 hours< / option >
< option value = "172800" > 2 days< / option >
< option value = "259200" > 3 days< / option >
< option value = "345600" > 4 days< / option >
< option value = "432000" > 5 days< / option >
< option value = "518400" > 6 days< / option >
< option value = "604800" > 7 days< / option >
< / select >
< / div >
2024-04-05 15:08:31 +13:00
<!-- configWaypointsMaxAgeInSeconds -->
< div class = "p-2" >
< label class = "block text-sm font-medium text-gray-900" > Waypoints Max Age< / label >
< div class = "text-xs text-gray-600 mb-2" > Waypoints not updated within this time are hidden. Reload to update map.< / div >
< select v-model = "configWaypointsMaxAgeInSeconds" class = "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" >
< option :value = "null" > Show All< / option >
< option value = "900" > 15 minutes< / option >
< option value = "1800" > 30 minutes< / option >
< option value = "3600" > 1 hour< / option >
< option value = "10800" > 3 hours< / option >
< option value = "21600" > 6 hours< / option >
< option value = "43200" > 12 hours< / option >
< option value = "86400" > 24 hours< / option >
< option value = "172800" > 2 days< / option >
< option value = "259200" > 3 days< / option >
< option value = "345600" > 4 days< / option >
< option value = "432000" > 5 days< / option >
< option value = "518400" > 6 days< / option >
< option value = "604800" > 7 days< / option >
< / select >
< / div >
2026-01-08 18:33:16 +01:00
<!-- configConnectionsTimePeriodInSeconds -->
2025-08-10 15:17:04 +02:00
< div class = "p-2" >
2026-01-08 18:33:16 +01:00
< label class = "block text-sm font-medium text-gray-900" > Connections Time Period< / label >
< div class = "text-xs text-gray-600 mb-2" > Edges within this time period are shown in the Connections layer. Reload to update map.< / div >
< select v-model = "configConnectionsTimePeriodInSeconds" class = "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" >
< option value = "300" > 5 minutes< / option >
2025-08-10 15:17:04 +02:00
< option value = "900" > 15 minutes< / option >
< option value = "3600" > 1 hour< / option >
< option value = "21600" > 6 hours< / option >
< option value = "43200" > 12 hours< / option >
< option value = "86400" > 24 hours< / option >
< option value = "172800" > 2 days< / option >
< option value = "259200" > 3 days< / option >
< option value = "604800" > 7 days< / option >
2026-01-08 18:33:16 +01:00
< option value = "1209600" > 14 days< / option >
< option value = "2592000" > 30 days< / option >
2025-08-10 15:17:04 +02:00
< / select >
< / div >
2026-01-08 18:33:16 +01:00
<!-- configConnectionsColoredLines -->
2024-03-23 17:13:49 +13:00
< div class = "p-2" >
2026-01-08 18:33:16 +01:00
< div class = "flex items-start" >
< div class = "flex items-center h-5" >
< input type = "checkbox" v-model = "configConnectionsColoredLines" class = "w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required >
< / div >
< label class = "ml-2 text-sm font-medium text-gray-900" > Colored Connection Lines< / label >
< / div >
< div class = "text-xs text-gray-600" > Colors the connection lines by the average SNR in the worst direction. Reload to update map.< / div >
< / div >
<!-- configConnectionsMaxDistanceInMeters -->
< div class = "p-2" >
< label class = "block text-sm font-medium text-gray-900" > Connections Max Distance (meters)< / label >
< div class = "text-xs text-gray-600 mb-2" > Connections further than this are hidden. Reload to update map.< / div >
< input type = "number" v-model = "configConnectionsMaxDistanceInMeters" class = "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" >
2024-03-23 17:13:49 +13:00
< / div >
2024-03-23 22:43:55 +13:00
<!-- configZoomLevelGoToNode -->
< div class = "p-2" >
2024-03-26 03:11:44 +13:00
< label class = "block text-sm font-medium text-gray-900" > Zoom Level (go to node)< / label >
< div class = "text-xs text-gray-600 mb-2" > How far to zoom map when navigating to a node.< / div >
2024-03-23 22:43:55 +13:00
< input type = "number" v-model = "configZoomLevelGoToNode" class = "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" >
< / div >
2024-06-24 17:14:46 +12:00
<!-- configTemperatureFormat -->
< div class = "p-2" >
< label class = "block text-sm font-medium text-gray-900" > Temperature Format< / label >
< div class = "text-xs text-gray-600 mb-2" > Metrics will be shown in the selected format.< / div >
< select v-model = "configTemperatureFormat" class = "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" >
2025-04-25 00:20:39 -04:00
< option value = "celsius" > Celsius (°C)< / option >
< option value = "fahrenheit" > Fahrenheit (°F)< / option >
2024-06-24 17:14:46 +12:00
< / select >
< / div >
2024-03-30 16:42:44 +13:00
<!-- configAutoUpdatePositionInUrl -->
< div class = "p-2" >
< div class = "flex items-start" >
< div class = "flex items-center h-5" >
< input type = "checkbox" v-model = "configAutoUpdatePositionInUrl" class = "w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required >
< / div >
< label class = "ml-2 text-sm font-medium text-gray-900" > Auto Update Position in URL< / label >
< / div >
< div class = "text-xs text-gray-600" > Sets lat/lng/zoom as query parameters.< / div >
< / div >
2024-04-04 20:55:41 +13:00
<!-- configEnableMapAnimations -->
< div class = "p-2" >
< div class = "flex items-start" >
< div class = "flex items-center h-5" >
< input type = "checkbox" v-model = "configEnableMapAnimations" class = "w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required >
< / div >
< label class = "ml-2 text-sm font-medium text-gray-900" > Enable Map Animations< / label >
< / div >
< div class = "text-xs text-gray-600" > Map will animate flying to nodes.< / div >
< / div >
2024-03-23 17:13:49 +13:00
< / div >
< / div >
< / div >
< / div >
< / transition >
< / div >
2026-01-08 18:33:16 +01:00
<!-- node connections modal -->
2024-04-07 12:30:27 +12:00
< div class = "relative z-sidebar" role = "dialog" aria-modal = "true" >
<!-- sidebar -->
< transition
enter-active-class="transition duration-300 ease-in-out transform"
enter-from-class="translate-y-full"
enter-to-class="translate-y-0"
leave-active-class="transition duration-300 ease-in-out transform"
leave-from-class="translate-y-0"
leave-to-class="translate-y-full">
2026-01-08 18:33:16 +01:00
< div v-show = "selectedNodeToShowConnections != null" class = "fixed left-0 right-0 bottom-0" >
< div v-if = "selectedNodeToShowConnections != null" class = "mx-auto w-screen max-w-md p-4" >
2024-04-07 12:41:28 +12:00
< div class = "flex h-full flex-col bg-white shadow-xl rounded-xl border" >
2024-04-07 12:30:27 +12:00
< div class = "p-2" >
< div class = "flex items-start justify-between" >
< div >
2026-01-08 18:33:16 +01:00
< h2 class = "font-bold" > {{ selectedNodeToShowConnections.short_name }} Connections< / h2 >
2024-04-07 12:30:27 +12:00
< / div >
< div class = "my-auto ml-3 flex h-7 items-center" >
2026-01-08 18:33:16 +01:00
< a href = "javascript:void(0)" class = "rounded-full" @ click = "dismissShowingNodeConnections" >
2024-04-07 12:30:27 +12:00
< div class = "bg-gray-100 hover:bg-gray-200 p-2 rounded-full" >
< svg class = "w-6 h-6" xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" fill = "none" stroke-linecap = "round" stroke-linejoin = "round" >
< path stroke = "none" d = "M0 0h24v24H0z" fill = "none" > < / path >
< path d = "M18 6l-12 12" > < / path >
< path d = "M6 6l12 12" > < / path >
< / svg >
< / div >
< / a >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< / transition >
< / div >
2024-08-04 19:12:51 +02:00
<!-- node position history modal -->
< div class = "relative z-sidebar" role = "dialog" aria-modal = "true" >
<!-- sidebar -->
< transition
enter-active-class="transition duration-300 ease-in-out transform"
enter-from-class="translate-y-full"
enter-to-class="translate-y-0"
leave-active-class="transition duration-300 ease-in-out transform"
leave-from-class="translate-y-0"
leave-to-class="translate-y-full">
< div v-show = "selectedNodeToShowPositionHistory != null" class = "fixed left-0 right-0 bottom-0" >
< div v-if = "selectedNodeToShowPositionHistory != null" class = "mx-auto w-screen max-w-md p-4" >
< div class = "flex h-full flex-col bg-white shadow-xl rounded-xl border" >
2024-08-20 18:47:50 +12:00
< div >
2024-09-04 18:01:54 +12:00
< div class = "flex p-2" >
< div class = "flex my-auto mr-3 space-x-2" >
< a href = "javascript:void(0)" @ click = "isPositionHistoryModalExpanded = !isPositionHistoryModalExpanded" class = "rounded-full" >
< div class = "bg-gray-100 hover:bg-gray-200 p-1 rounded-full" >
< svg v-if = "isPositionHistoryModalExpanded" xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" class = "size-6" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "m19.5 8.25-7.5 7.5-7.5-7.5" / >
< / svg >
< svg v-else xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" class = "size-6" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "m8.25 4.5 7.5 7.5-7.5 7.5" / >
< / svg >
< / div >
< / a >
< / div >
2024-08-20 18:47:50 +12:00
< div class = "my-auto mr-auto font-bold" > {{ selectedNodeToShowPositionHistory.short_name }} Position History< / div >
2024-09-04 18:01:54 +12:00
< div class = "flex my-auto ml-3 space-x-2" >
2024-08-04 19:12:51 +02:00
< a href = "javascript:void(0)" class = "rounded-full" @ click = "dismissShowingNodePositionHistory" >
2024-08-20 18:47:50 +12:00
< div class = "bg-gray-100 hover:bg-gray-200 p-1 rounded-full" >
2024-09-04 18:01:54 +12:00
< svg class = "size-6" xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" stroke-width = "1.5" stroke = "currentColor" fill = "none" stroke-linecap = "round" stroke-linejoin = "round" >
2024-08-04 19:12:51 +02:00
< path stroke = "none" d = "M0 0h24v24H0z" fill = "none" > < / path >
< path d = "M18 6l-12 12" > < / path >
< path d = "M6 6l12 12" > < / path >
< / svg >
< / div >
< / a >
< / div >
< / div >
2024-09-04 18:01:54 +12:00
< div v-if = "isPositionHistoryModalExpanded" class = "divide-y border-t" >
2024-08-20 18:47:50 +12:00
2024-08-21 23:08:59 +12:00
<!-- quick range -->
< div class = "flex p-2 space-x-2" >
< button @ click = "onPositionHistoryQuickRangeClick('1h')" type = "button" class = "w-full bg-gray-100 rounded border shadow px-2 py-1 text-sm hover:bg-gray-200 text-center" > 1 Hour< / button >
< button @ click = "onPositionHistoryQuickRangeClick('24h')" type = "button" class = "w-full bg-gray-100 rounded border shadow px-2 py-1 text-sm hover:bg-gray-200 text-center" > 24 Hours< / button >
< button @ click = "onPositionHistoryQuickRangeClick('7d')" type = "button" class = "w-full bg-gray-100 rounded border shadow px-2 py-1 text-sm hover:bg-gray-200 text-center" > 7 Days< / button >
2024-08-20 18:47:50 +12:00
< / div >
2024-08-21 23:08:59 +12:00
<!-- manual range -->
< div class = "p-2 space-y-1" >
<!-- from -->
< div class = "flex items-center" >
< label class = "text-sm pr-1 min-w-12 text-right" > From:< / label >
< input v-model = "positionHistoryDateTimeFrom" @ change = "loadNodePositionHistory(selectedNodeToShowPositionHistory.node_id)" type = "datetime-local" class = "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1" >
< / div >
<!-- to -->
< div class = "flex items-center" >
< label class = "text-sm pr-1 min-w-12 text-right" > To:< / label >
< input v-model = "positionHistoryDateTimeTo" @ change = "loadNodePositionHistory(selectedNodeToShowPositionHistory.node_id)" type = "datetime-local" class = "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1" >
< / div >
2024-08-20 18:47:50 +12:00
< / div >
< / div >
2024-08-04 19:12:51 +02:00
< / div >
< / div >
< / div >
< / div >
< / transition >
< / div >
2024-03-12 18:31:17 +13:00
< / div >
2024-03-23 17:13:49 +13:00
< script >
2024-03-29 23:27:02 +13:00
function getConfigHasSeenInfoModal() {
2024-04-04 20:55:41 +13:00
return localStorage.getItem("config_has_seen_info_modal") === "true";
2024-03-29 23:27:02 +13:00
}
function setConfigHasSeenInfoModal(value) {
return localStorage.setItem("config_has_seen_info_modal", value);
}
2024-03-30 16:42:44 +13:00
function getConfigAutoUpdatePositionInUrl() {
2024-08-24 15:02:45 +12:00
// use user preference, or enable by default
const value = localStorage.getItem("config_auto_update_position_in_url");
return value === "true" || value == null;
2024-03-30 16:42:44 +13:00
}
function setConfigAutoUpdatePositionInUrl(value) {
return localStorage.setItem("config_auto_update_position_in_url", value);
}
2024-04-04 20:55:41 +13:00
function getConfigEnableMapAnimations() {
const value = localStorage.getItem("config_enable_map_animations");
// enable animations by default
if(value === null){
return true;
}
return value === "true";
}
function setConfigEnableMapAnimations(value) {
return localStorage.setItem("config_enable_map_animations", value);
}
2024-06-24 17:14:46 +12:00
function getConfigTemperatureFormat() {
return localStorage.getItem("config_temperature_format") || "celsius";
}
function setConfigTemperatureFormat(format) {
return localStorage.setItem("config_temperature_format", format);
}
2024-04-17 22:38:32 +12:00
function getConfigMapSelectedTileLayer() {
return localStorage.getItem("config_map_selected_tile_layer") || "OpenStreetMap";
}
function setConfigMapSelectedTileLayer(layer) {
return localStorage.setItem("config_map_selected_tile_layer", layer);
}
2024-03-27 01:09:02 +13:00
function getConfigMapEnabledOverlayLayers() {
try {
const value = localStorage.getItem("config_map_enabled_overlay_layers");
if(value){
return JSON.parse(value);
}
} catch(e) {}
2024-03-31 18:46:00 +13:00
// overlays enabled by default
2026-01-02 22:20:24 +01:00
return ["Legend", "Position History", "Traceroutes"];
2024-03-27 01:09:02 +13:00
}
function setConfigMapEnabledOverlayLayers(layers) {
return localStorage.setItem("config_map_enabled_overlay_layers", JSON.stringify(layers));
}
2024-03-26 03:07:08 +13:00
function getConfigNodesMaxAgeInSeconds() {
const value = localStorage.getItem("config_nodes_max_age_in_seconds");
return value != null ? parseInt(value) : null;
}
function setConfigNodesMaxAgeInSeconds(value) {
if(value != null){
return localStorage.setItem("config_nodes_max_age_in_seconds", value);
} else {
return localStorage.removeItem("config_nodes_max_age_in_seconds");
}
}
2024-04-05 11:59:33 +13:00
function getConfigNodesOfflineAgeInSeconds() {
const value = localStorage.getItem("config_nodes_offline_age_in_seconds");
return value != null ? parseInt(value) : null;
}
function setConfigNodesOfflineAgeInSeconds(value) {
if(value != null){
return localStorage.setItem("config_nodes_offline_age_in_seconds", value);
} else {
return localStorage.removeItem("config_nodes_offline_age_in_seconds");
}
}
2024-04-05 15:08:31 +13:00
function getConfigWaypointsMaxAgeInSeconds() {
const value = localStorage.getItem("config_waypoints_max_age_in_seconds");
return value != null ? parseInt(value) : null;
}
function setConfigWaypointsMaxAgeInSeconds(value) {
if(value != null){
return localStorage.setItem("config_waypoints_max_age_in_seconds", value);
} else {
return localStorage.removeItem("config_waypoints_max_age_in_seconds");
}
}
2026-01-08 18:33:16 +01:00
function getConfigConnectionsMaxDistanceInMeters() {
const value = localStorage.getItem("config_connections_max_distance_in_meters");
// default to 70km (70,000 meters)
return value != null ? parseInt(value) : 70000;
2024-03-23 17:13:49 +13:00
}
2026-01-08 18:33:16 +01:00
function setConfigConnectionsMaxDistanceInMeters(value) {
return localStorage.setItem("config_connections_max_distance_in_meters", value);
2024-03-23 17:13:49 +13:00
}
2024-03-23 22:43:55 +13:00
function getConfigZoomLevelGoToNode() {
const value = localStorage.getItem("config_zoom_level_go_to_node");
const parsedValue = value != null ? parseInt(value) : null;
2024-03-30 15:14:54 +13:00
return parsedValue || 15;
2024-03-23 22:43:55 +13:00
}
function setConfigZoomLevelGoToNode(value) {
return localStorage.setItem("config_zoom_level_go_to_node", value);
}
2026-01-07 20:32:18 +01:00
function getConfigConnectionsTimePeriodInSeconds() {
const value = localStorage.getItem("config_connections_time_period_in_seconds");
// default to 24 hours if unset
return value != null ? parseInt(value) : 86400;
}
function setConfigConnectionsTimePeriodInSeconds(value) {
return localStorage.setItem("config_connections_time_period_in_seconds", value);
}
function getConfigConnectionsColoredLines() {
const value = localStorage.getItem("config_connections_colored_lines");
// disable colored lines by default
if(value === null){
return false;
}
return value === "true";
}
function setConfigConnectionsColoredLines(value) {
return localStorage.setItem("config_connections_colored_lines", value);
}
2024-03-29 23:55:37 +13:00
function isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
2024-03-23 17:13:49 +13:00
< / script >
2024-03-13 21:02:58 +13:00
< script >
Vue.createApp({
data() {
return {
2024-03-14 00:12:42 +13:00
2024-08-24 15:12:54 +12:00
isShowingAnnouncement: this.shouldShowAnnouncement(),
2024-03-26 03:07:08 +13:00
configNodesMaxAgeInSeconds: window.getConfigNodesMaxAgeInSeconds(),
2024-04-05 11:59:33 +13:00
configNodesOfflineAgeInSeconds: window.getConfigNodesOfflineAgeInSeconds(),
2024-04-05 15:08:31 +13:00
configWaypointsMaxAgeInSeconds: window.getConfigWaypointsMaxAgeInSeconds(),
2026-01-08 18:33:16 +01:00
configConnectionsMaxDistanceInMeters: window.getConfigConnectionsMaxDistanceInMeters(),
2024-03-23 22:43:55 +13:00
configZoomLevelGoToNode: window.getConfigZoomLevelGoToNode(),
2024-03-30 16:42:44 +13:00
configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(),
2024-04-04 20:55:41 +13:00
configEnableMapAnimations: window.getConfigEnableMapAnimations(),
2024-06-24 17:14:46 +12:00
configTemperatureFormat: window.getConfigTemperatureFormat(),
2026-01-07 20:32:18 +01:00
configConnectionsTimePeriodInSeconds: window.getConfigConnectionsTimePeriodInSeconds(),
configConnectionsColoredLines: window.getConfigConnectionsColoredLines(),
2024-03-23 17:13:49 +13:00
2024-03-13 21:02:58 +13:00
isShowingHardwareModels: false,
hardwareModelStats: null,
2024-03-14 00:12:42 +13:00
2024-03-29 23:57:37 +13:00
isShowingInfoModal: this.shouldShowInfoModal(),
2024-03-26 04:05:37 +13:00
isShowingMobileSearch: false,
2024-03-23 17:13:49 +13:00
isShowingSettings: false,
2024-03-17 03:38:06 +13:00
nodes: [],
searchText: "",
2024-03-14 00:12:42 +13:00
selectedNode: null,
2024-03-14 02:39:41 +13:00
selectedNodeDeviceMetrics: [],
2024-06-07 00:51:33 +12:00
selectedNodeEnvironmentMetrics: [],
2024-06-08 16:11:24 +12:00
selectedNodePowerMetrics: [],
2024-03-16 19:48:08 +13:00
selectedNodeMqttMetrics: [],
2024-03-19 02:33:11 +13:00
selectedNodeTraceroutes: [],
2025-03-09 09:40:42 +01:00
deviceMetricsTimeRange: "7d",
environmentMetricsTimeRange: "7d",
powerMetricsTimeRange: "7d",
2024-09-04 11:39:23 +12:00
2024-09-04 18:01:54 +12:00
isPositionHistoryModalExpanded: true,
2024-08-21 20:35:09 +12:00
positionHistoryDateTimeFrom: null,
positionHistoryDateTimeTo: null,
2024-08-04 19:12:51 +02:00
selectedNodePositionHistory: [],
selectedNodeToShowPositionHistory: null,
selectedNodePositionHistoryMarkers: [],
selectedNodePositionHistoryPolyLines: [],
2024-03-19 02:33:11 +13:00
selectedTraceRoute: null,
2025-08-10 15:17:04 +02:00
tracerouteEdges: [],
2024-03-14 00:12:42 +13:00
2026-01-08 18:33:16 +01:00
selectedNodeToShowConnections: null,
2024-04-07 12:30:27 +12:00
2024-03-14 00:12:42 +13:00
moment: window.moment,
2024-03-13 21:02:58 +13:00
};
},
mounted: function() {
// load data
this.loadHardwareModelStats();
2024-03-17 03:38:06 +13:00
// handle map click callback from outside of vue
window._onMapClick = () => {
this.searchText = "";
2024-03-26 04:05:37 +13:00
this.isShowingMobileSearch = false;
2024-03-17 03:38:06 +13:00
};
2024-03-14 00:12:42 +13:00
// handle node callback from outside of vue
window._onNodeClick = (node) => {
this.selectedNode = node;
2024-03-14 02:39:41 +13:00
this.loadNodeDeviceMetrics(node.node_id);
2024-06-07 00:51:33 +12:00
this.loadNodeEnvironmentMetrics(node.node_id);
2024-06-08 16:11:24 +12:00
this.loadNodePowerMetrics(node.node_id);
2024-03-16 19:48:08 +13:00
this.loadNodeMqttMetrics(node.node_id);
2024-03-19 02:33:11 +13:00
this.loadNodeTraceroutes(node.node_id);
2024-08-20 18:47:50 +12:00
//this.loadNodePositionHistory(node.node_id);
2024-03-14 00:12:42 +13:00
};
2024-04-07 12:30:27 +12:00
// handle node callback from outside of vue
2026-01-08 18:33:16 +01:00
window._onShowNodeConnectionsClick = (node) => {
this.selectedNodeToShowConnections = node;
2024-04-07 12:30:27 +12:00
};
2024-03-17 03:38:06 +13:00
// handle nodes updated callback from outside of vue
window._onNodesUpdated = (nodes) => {
this.nodes = nodes;
};
2024-03-13 21:02:58 +13:00
},
methods: {
2024-08-24 15:12:54 +12:00
getAnnouncementId: function() {
// change this when making a new announcement
return "1";
},
shouldShowAnnouncement: function() {
const lastSeenAnnouncementId = window.localStorage.getItem("last-seen-announcement-id");
return lastSeenAnnouncementId?.toString() !== this.getAnnouncementId();
},
dismissAnnouncement: function() {
window.localStorage.setItem("last-seen-announcement-id", this.getAnnouncementId());
this.isShowingAnnouncement = false;
},
2024-03-29 23:57:37 +13:00
shouldShowInfoModal: function() {
return !window.getConfigHasSeenInfoModal()
& & !window.isMobile();
},
2024-03-13 21:02:58 +13:00
loadHardwareModelStats: function() {
2024-03-13 21:04:27 +13:00
window.axios.get('/api/v1/stats/hardware-models').then((response) => {
2024-03-13 21:02:58 +13:00
this.hardwareModelStats = response.data.hardware_model_stats;
}).catch((error) => {
// do nothing
});
},
2024-03-14 02:39:41 +13:00
loadNodeDeviceMetrics: function(nodeId) {
2024-09-04 11:39:23 +12:00
// calculate unix timestamps in milliseconds for supported time ranges
const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
2024-09-02 03:26:18 +12:00
const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
2024-09-04 11:39:23 +12:00
const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
2025-03-09 09:40:42 +01:00
const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10);
2024-09-04 11:39:23 +12:00
// determine how long back to load device metrics from
var timeFrom = threeDaysAgoInMilliseconds;
switch(this.deviceMetricsTimeRange){
case "1d": {
timeFrom = oneDayAgoInMilliseconds;
break;
}
case "3d": {
timeFrom = threeDaysAgoInMilliseconds;
break;
}
case "7d": {
timeFrom = sevenDaysAgoInMilliseconds;
break;
}
2025-03-09 09:40:42 +01:00
case "30d": {
timeFrom = thirtyDaysAgoInMilliseconds;
break;
}
2024-09-04 11:39:23 +12:00
}
2024-03-14 02:39:41 +13:00
window.axios.get(`/api/v1/nodes/${nodeId}/device-metrics`, {
params: {
2024-09-04 11:39:23 +12:00
time_from: timeFrom,
2024-03-14 02:39:41 +13:00
},
}).then((response) => {
2024-03-14 03:59:09 +13:00
// reverse response, as it's newest to oldest, but we want oldest to newest
this.selectedNodeDeviceMetrics = response.data.device_metrics.reverse();
2024-03-14 02:39:41 +13:00
this.renderDeviceMetricCharts();
}).catch(() => {
this.selectedNodeDeviceMetrics = [];
this.renderDeviceMetricCharts();
});
},
2024-06-07 00:51:33 +12:00
loadNodeEnvironmentMetrics: function(nodeId) {
2024-09-08 22:30:22 +12:00
// calculate unix timestamps in milliseconds for supported time ranges
const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
2025-03-09 09:40:42 +01:00
const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10);
2024-09-08 22:30:22 +12:00
// determine how long back to load environment metrics from
var timeFrom = threeDaysAgoInMilliseconds;
switch(this.environmentMetricsTimeRange){
case "1d": {
timeFrom = oneDayAgoInMilliseconds;
break;
}
case "3d": {
timeFrom = threeDaysAgoInMilliseconds;
break;
}
case "7d": {
timeFrom = sevenDaysAgoInMilliseconds;
break;
}
2025-03-09 09:40:42 +01:00
case "30d": {
timeFrom = thirtyDaysAgoInMilliseconds;
break;
}
2024-09-08 22:30:22 +12:00
}
2024-06-07 00:51:33 +12:00
window.axios.get(`/api/v1/nodes/${nodeId}/environment-metrics`, {
params: {
2024-09-08 22:30:22 +12:00
time_from: timeFrom,
2024-06-07 00:51:33 +12:00
},
}).then((response) => {
// reverse response, as it's newest to oldest, but we want oldest to newest
this.selectedNodeEnvironmentMetrics = response.data.environment_metrics.reverse();
this.renderEnvironmentMetricCharts();
}).catch(() => {
this.selectedNodeEnvironmentMetrics = [];
this.renderEnvironmentMetricCharts();
});
},
2024-06-08 16:11:24 +12:00
loadNodePowerMetrics: function(nodeId) {
2024-09-04 12:04:11 +12:00
// calculate unix timestamps in milliseconds for supported time ranges
const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
2024-09-02 14:06:58 +12:00
const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
2024-09-04 12:04:11 +12:00
const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
2025-03-09 09:40:42 +01:00
const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10);
2024-09-04 12:04:11 +12:00
// determine how long back to load power metrics from
var timeFrom = threeDaysAgoInMilliseconds;
switch(this.powerMetricsTimeRange){
case "1d": {
timeFrom = oneDayAgoInMilliseconds;
break;
}
case "3d": {
timeFrom = threeDaysAgoInMilliseconds;
break;
}
case "7d": {
timeFrom = sevenDaysAgoInMilliseconds;
break;
}
2025-03-09 09:40:42 +01:00
case "30d": {
timeFrom = thirtyDaysAgoInMilliseconds;
break;
}
2024-09-04 12:04:11 +12:00
}
2024-06-08 16:11:24 +12:00
window.axios.get(`/api/v1/nodes/${nodeId}/power-metrics`, {
params: {
2024-09-04 12:04:11 +12:00
time_from: timeFrom,
2024-06-08 16:11:24 +12:00
},
}).then((response) => {
// reverse response, as it's newest to oldest, but we want oldest to newest
this.selectedNodePowerMetrics = response.data.power_metrics.reverse();
this.renderPowerMetricCharts();
}).catch(() => {
this.selectedNodePowerMetrics = [];
this.renderPowerMetricCharts();
});
},
2024-03-16 19:48:08 +13:00
loadNodeMqttMetrics: function(nodeId) {
this.selectedNodeMqttMetrics = [];
window.axios.get(`/api/v1/nodes/${nodeId}/mqtt-metrics`).then((response) => {
this.selectedNodeMqttMetrics = response.data.mqtt_metrics;
}).catch(() => {
// do nothing
});
},
2024-03-19 02:33:11 +13:00
loadNodeTraceroutes: function(nodeId) {
this.selectedNodeTraceroutes = [];
window.axios.get(`/api/v1/nodes/${nodeId}/traceroutes`, {
params: {
count: 5,
},
}).then((response) => {
this.selectedNodeTraceroutes = response.data.traceroutes;
}).catch(() => {
// do nothing
});
},
2024-08-04 19:12:51 +02:00
loadNodePositionHistory: function(nodeId) {
this.selectedNodePositionHistory = [];
2024-08-20 18:47:50 +12:00
window.axios.get(`/api/v1/nodes/${nodeId}/position-history`, {
params: {
// parse from datetime-local format, and send as unix timestamp in milliseconds
time_from: moment(this.positionHistoryDateTimeFrom, "YYYY-MM-DDTHH:mm").format("x"),
time_to: moment(this.positionHistoryDateTimeTo, "YYYY-MM-DDTHH:mm").format("x"),
},
}).then((response) => {
2024-08-04 19:12:51 +02:00
this.selectedNodePositionHistory = response.data.position_history;
2024-08-20 18:47:50 +12:00
if(this.selectedNodeToShowPositionHistory != null){
2024-08-04 19:12:51 +02:00
clearAllPositionHistory();
onPositionHistoryUpdated(response.data.position_history);
}
}).catch(() => {
// do nothing
});
},
2024-03-14 02:39:41 +13:00
renderDeviceMetricCharts: function() {
2024-09-02 03:26:18 +12:00
try {
this.updateDeviceMetricsChart();
} catch(e) {
console.log(e);
2024-03-14 02:39:41 +13:00
}
},
2024-09-02 03:26:18 +12:00
updateDeviceMetricsChart: function() {
2024-03-14 02:39:41 +13:00
2024-09-04 11:39:23 +12:00
// destroy existing chart
2024-09-04 11:51:57 +12:00
const chartElementId = "deviceMetricsChart";
const existingChart = window.Chart.getChart(chartElementId);
if(existingChart != null){
existingChart.destroy();
2024-09-04 11:39:23 +12:00
}
2024-09-04 11:51:57 +12:00
// get chart element
const chartElement = window.document.getElementById(chartElementId);
if(!chartElement){
2024-03-14 02:39:41 +13:00
return;
}
2024-09-04 11:51:57 +12:00
// create chart data
2024-09-02 03:26:18 +12:00
const labels = [];
const batteryMetrics = [];
const channelUtilizationMetrics = [];
const airUtilTxMetrics = [];
for(const deviceMetric of this.selectedNodeDeviceMetrics){
labels.push(moment(deviceMetric.created_at));
batteryMetrics.push(deviceMetric.battery_level);
channelUtilizationMetrics.push(deviceMetric.channel_utilization);
airUtilTxMetrics.push(deviceMetric.air_util_tx);
}
2024-03-14 02:46:57 +13:00
2024-09-04 11:51:57 +12:00
// create chart
new window.Chart(chartElement, {
2024-03-14 02:39:41 +13:00
type: 'line',
data: {
2024-09-02 03:26:18 +12:00
labels: labels,
datasets: [
{
label: 'Battery Level',
borderColor: '#3b82f6',
backgroundColor: '#3b82f6',
pointStyle: false, // no points
fill: false,
data: batteryMetrics,
2024-03-14 02:39:41 +13:00
},
2024-09-02 03:26:18 +12:00
{
label: 'Channel Util',
borderColor: '#22c55e',
backgroundColor: '#22c55e',
showLine: false, // no lines between points
fill: false,
data: channelUtilizationMetrics,
2024-03-14 02:39:41 +13:00
},
2024-09-02 03:26:18 +12:00
{
label: 'Air Util TX',
borderColor: '#f97316',
backgroundColor: '#f97316',
showLine: false, // no lines between points
fill: false,
data: airUtilTxMetrics,
2024-03-14 02:39:41 +13:00
2024-09-02 03:26:18 +12:00
},
],
2024-03-14 02:39:41 +13:00
},
options: {
responsive: true,
2024-09-02 03:26:18 +12:00
borderWidth: 2,
2024-03-14 02:39:41 +13:00
elements: {
point: {
2024-09-02 03:26:18 +12:00
radius: 2,
2024-03-14 02:39:41 +13:00
},
},
scales: {
x: {
2024-09-02 03:29:46 +12:00
position: 'top',
2024-09-02 03:26:18 +12:00
type: 'time',
time: {
unit: 'day',
displayFormats: {
day: 'MMM DD', // Jan 01
},
},
2024-03-14 02:39:41 +13:00
},
y: {
2024-09-02 03:26:18 +12:00
min: 0,
max: 101, // 101 is "Plugged In", need to include for tooltip to work
2024-09-02 13:01:43 +12:00
ticks: {
callback: (label) => `${label}%`,
},
2024-03-14 02:39:41 +13:00
},
},
plugins: {
legend: {
2024-09-02 03:26:18 +12:00
display: false,
2024-03-14 02:39:41 +13:00
},
tooltip: {
2024-09-02 14:00:02 +12:00
mode: "index",
2024-03-14 02:39:41 +13:00
intersect: false,
callbacks: {
2024-09-02 03:26:18 +12:00
label: (item) => {
return `${item.dataset.label}: ${item.formattedValue}%`;
},
2024-03-14 02:39:41 +13:00
},
},
},
}
});
2024-06-07 00:51:33 +12:00
},
renderEnvironmentMetricCharts: function() {
2024-09-08 22:30:22 +12:00
try {
this.updateEnvironmentMetricsChart();
} catch(e) {
console.log(e);
}
2024-06-07 00:51:33 +12:00
},
2024-09-08 22:30:22 +12:00
updateEnvironmentMetricsChart: function() {
2024-06-07 00:51:33 +12:00
2024-09-08 22:30:22 +12:00
// destroy existing chart
const chartElementId = "environmentMetricsChart";
const existingChart = window.Chart.getChart(chartElementId);
if(existingChart != null){
existingChart.destroy();
}
// get chart element
const chartElement = window.document.getElementById(chartElementId);
if(!chartElement){
2024-06-07 00:51:33 +12:00
return;
}
2024-09-08 22:30:22 +12:00
// create chart data
const labels = [];
const temperatureMetrics = [];
const relativeHumidityMetrics = [];
const barometricPressureMetrics = [];
2025-06-26 22:33:37 +02:00
const iaqMetrics = [];
2024-09-08 22:30:22 +12:00
for(const deviceMetric of this.selectedNodeEnvironmentMetrics){
labels.push(moment(deviceMetric.created_at));
temperatureMetrics.push(deviceMetric.temperature);
relativeHumidityMetrics.push(deviceMetric.relative_humidity);
barometricPressureMetrics.push(deviceMetric.barometric_pressure);
2025-06-26 22:33:37 +02:00
iaqMetrics.push(deviceMetric.iaq);
2024-09-08 22:30:22 +12:00
}
2024-06-07 00:51:33 +12:00
2024-09-08 22:30:22 +12:00
// create chart
new window.Chart(chartElement, {
2024-06-07 00:51:33 +12:00
type: 'line',
data: {
2024-09-08 22:30:22 +12:00
labels: labels,
datasets: [
{
label: 'Temperature',
2025-04-25 00:20:39 -04:00
suffix: '°C',
2024-09-08 22:30:22 +12:00
borderColor: '#3b82f6',
backgroundColor: '#3b82f6',
pointStyle: false, // no points
fill: false,
data: temperatureMetrics,
yAxisID: 'y',
2024-06-07 00:51:33 +12:00
},
2024-09-08 22:30:22 +12:00
{
label: 'Humidity',
suffix: '%',
borderColor: '#22c55e',
backgroundColor: '#22c55e',
pointStyle: false, // no points
fill: false,
data: relativeHumidityMetrics,
yAxisID: 'y',
2024-06-07 00:51:33 +12:00
},
2024-09-08 22:30:22 +12:00
{
label: 'Pressure',
suffix: 'hPa',
borderColor: '#f97316',
backgroundColor: '#f97316',
pointStyle: false, // no points
fill: false,
data: barometricPressureMetrics,
yAxisID: 'y1',
2024-06-07 00:51:33 +12:00
},
2025-06-26 22:33:37 +02:00
{
label: 'IAQ',
suffix: 'IAQ',
borderColor: '#f472b6',
backgroundColor: '#f472b6',
pointStyle: false, // no points
fill: false,
data: iaqMetrics,
yAxisID: 'yIAQ',
},
2024-09-08 22:30:22 +12:00
],
},
options: {
responsive: true,
borderWidth: 2,
2025-08-06 21:45:20 +02:00
spanGaps: 1000 * 60 * 60 * 24, // only show lines between metrics with a 24 hour or less gap
2024-06-07 00:51:33 +12:00
elements: {
point: {
2024-09-08 22:30:22 +12:00
radius: 2,
2024-06-07 00:51:33 +12:00
},
},
scales: {
x: {
2024-09-08 22:30:22 +12:00
position: 'top',
type: 'time',
time: {
unit: 'day',
displayFormats: {
day: 'MMM DD', // Jan 01
},
},
2024-06-07 00:51:33 +12:00
},
y: {
2024-12-16 00:23:10 +13:00
min: -20,
2024-06-07 00:51:33 +12:00
max: 100,
},
2024-09-08 22:30:22 +12:00
y1: {
2024-09-11 12:34:55 +12:00
min: 800,
max: 1100,
2024-09-08 22:30:22 +12:00
ticks: {
2024-09-11 12:34:55 +12:00
stepSize: 10,
2024-09-08 22:30:22 +12:00
callback: (label) => `${label} hPa`,
},
position: 'right',
grid: {
drawOnChartArea: false, // only want the grid lines for one axis to show up
},
},
2025-06-26 22:33:37 +02:00
yIAQ: {
type: 'linear',
display: false,
},
2024-06-07 00:51:33 +12:00
},
plugins: {
legend: {
2024-09-08 22:30:22 +12:00
display: false,
2024-06-07 00:51:33 +12:00
},
tooltip: {
2024-09-08 22:30:22 +12:00
mode: "index",
2024-06-07 00:51:33 +12:00
intersect: false,
callbacks: {
2024-09-08 22:30:22 +12:00
label: (item) => {
return `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}`;
},
2024-06-07 00:51:33 +12:00
},
},
},
}
});
2024-06-08 16:11:24 +12:00
},
renderPowerMetricCharts: function() {
2024-09-02 14:06:58 +12:00
try {
this.updatePowerMetricsChart();
} catch(e) {
console.log(e);
}
2024-06-08 16:11:24 +12:00
},
2024-09-02 14:06:58 +12:00
updatePowerMetricsChart: function() {
2024-06-08 16:11:24 +12:00
2024-09-04 12:04:11 +12:00
// destroy existing chart
const chartElementId = "powerMetricsChart";
const existingChart = window.Chart.getChart(chartElementId);
if(existingChart != null){
existingChart.destroy();
}
// get chart element
const chartElement = window.document.getElementById(chartElementId);
if(!chartElement){
2024-06-08 16:11:24 +12:00
return;
}
2024-09-04 12:04:11 +12:00
// create chart data
2024-09-02 14:06:58 +12:00
const labels = [];
const channel1VoltageReadings = [];
const channel2VoltageReadings = [];
const channel3VoltageReadings = [];
const channel1CurrentReadings = [];
const channel2CurrentReadings = [];
const channel3CurrentReadings = [];
for(const powerMetric of this.selectedNodePowerMetrics){
labels.push(moment(powerMetric.created_at));
channel1VoltageReadings.push(powerMetric.ch1_voltage);
channel2VoltageReadings.push(powerMetric.ch2_voltage);
channel3VoltageReadings.push(powerMetric.ch3_voltage);
channel1CurrentReadings.push(powerMetric.ch1_current);
channel2CurrentReadings.push(powerMetric.ch2_current);
channel3CurrentReadings.push(powerMetric.ch3_current);
}
2024-06-08 16:11:24 +12:00
2024-09-04 12:04:11 +12:00
// create chart
new window.Chart(chartElement, {
2024-06-08 16:11:24 +12:00
type: 'line',
data: {
2024-09-02 14:06:58 +12:00
labels: labels,
datasets: [
{
label: 'Ch1 Voltage',
suffix: "V",
borderColor: '#3b82f6',
backgroundColor: '#3b82f6',
pointStyle: false, // no points
fill: false,
data: channel1VoltageReadings,
yAxisID: 'y',
2024-06-08 16:11:24 +12:00
},
2024-09-02 14:06:58 +12:00
{
label: 'Ch2 Voltage',
suffix: "V",
borderColor: '#22c55e',
backgroundColor: '#22c55e',
pointStyle: false, // no points
fill: false,
data: channel2VoltageReadings,
yAxisID: 'y',
2024-06-08 16:11:24 +12:00
},
2024-09-02 14:06:58 +12:00
{
label: 'Ch3 Voltage',
suffix: "V",
borderColor: '#f97316',
backgroundColor: '#f97316',
pointStyle: false, // no points
fill: false,
data: channel3VoltageReadings,
yAxisID: 'y',
2024-06-08 16:11:24 +12:00
},
2024-09-02 14:06:58 +12:00
{
label: 'Ch1 Current',
suffix: "mA",
borderColor: '#93c5fd',
backgroundColor: '#93c5fd',
pointStyle: false, // no points
fill: false,
data: channel1CurrentReadings,
yAxisID: 'y1',
2024-06-08 16:11:24 +12:00
},
2024-09-02 14:06:58 +12:00
{
label: 'Ch2 Current',
suffix: "mA",
borderColor: '#86efac',
backgroundColor: '#86efac',
pointStyle: false, // no points
fill: false,
data: channel2CurrentReadings,
yAxisID: 'y1',
2024-06-08 16:11:24 +12:00
},
2024-09-02 14:06:58 +12:00
{
label: 'Ch3 Current',
suffix: "mA",
borderColor: '#fdba74',
backgroundColor: '#fdba74',
pointStyle: false, // no points
fill: false,
data: channel3CurrentReadings,
yAxisID: 'y1',
},
],
2024-06-08 16:11:24 +12:00
},
options: {
responsive: true,
2024-09-02 14:06:58 +12:00
borderWidth: 2,
spanGaps: 1000 * 60 * 60 * 3, // only show lines between metrics with a 3 hour or less gap
2024-06-08 16:11:24 +12:00
elements: {
point: {
2024-09-02 14:06:58 +12:00
radius: 2,
2024-06-08 16:11:24 +12:00
},
},
scales: {
x: {
2024-09-02 14:06:58 +12:00
position: 'top',
type: 'time',
time: {
unit: 'day',
displayFormats: {
day: 'MMM DD', // Jan 01
},
},
2024-06-08 16:11:24 +12:00
},
y: {
min: 0,
2025-02-26 13:06:57 +00:00
suggestedMax: 6,
2024-09-02 14:06:58 +12:00
ticks: {
callback: (label) => `${label}V`,
},
},
y1: {
2025-02-26 13:06:57 +00:00
suggestedMin: -50,
suggestedMax: 50,
2024-09-02 14:06:58 +12:00
ticks: {
stepSize: 50,
callback: (label) => `${label}mA`,
},
position: 'right',
grid: {
drawOnChartArea: false, // only want the grid lines for one axis to show up
},
2024-06-08 16:11:24 +12:00
},
},
plugins: {
legend: {
2024-09-02 14:06:58 +12:00
display: false,
2024-06-08 16:11:24 +12:00
},
tooltip: {
2024-09-02 14:06:58 +12:00
mode: "index",
2024-06-08 16:11:24 +12:00
intersect: false,
callbacks: {
2024-09-02 14:06:58 +12:00
label: (item) => {
return `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}`;
},
2024-06-08 16:11:24 +12:00
},
},
},
}
});
2024-03-14 02:39:41 +13:00
},
2024-03-19 02:33:11 +13:00
showTraceRoute: function(traceroute) {
this.selectedTraceRoute = traceroute;
},
findNodeById: function(id) {
return window.findNodeById(id);
},
2024-09-04 12:59:41 +12:00
findNodeMarkerById: function(id) {
return window.findNodeMarkerById(id);
},
2024-03-26 04:05:37 +13:00
onSearchResultNodeClick: function(node) {
// clear search
this.searchText = "";
// hide search
this.isShowingMobileSearch = false;
// go to node
2024-04-16 15:54:31 +12:00
if(window.goToNode(node.node_id)){
return;
}
// fallback to showing node details since we can't go to the node
window.showNodeDetails(node.node_id);
2024-03-26 04:05:37 +13:00
},
2024-03-29 23:27:02 +13:00
dismissInfoModal: function() {
this.isShowingInfoModal = false;
window.setConfigHasSeenInfoModal(true);
},
2024-04-02 11:31:46 +13:00
getRegionFrequencyRange: function(regionName) {
return window.getRegionFrequencyRange(regionName);
},
2024-08-20 18:47:50 +12:00
showNodePositionHistory: function(nodeId) {
// find node
const node = findNodeById(nodeId);
if(!node){
return;
}
// update ui
this.selectedNode = null;
this.selectedNodeToShowPositionHistory = node;
2024-09-04 18:01:54 +12:00
this.isPositionHistoryModalExpanded = true;
2024-08-20 18:47:50 +12:00
2024-08-21 23:18:21 +12:00
// close node info tooltip as position history shows under it
window.closeAllTooltips();
2024-08-21 20:35:09 +12:00
// reset default time range when opening position history ui
// YYYY-MM-DDTHH:mm is the format expected by the datetime-local input type
this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm');
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
2024-08-20 18:47:50 +12:00
// load position history
this.loadNodePositionHistory(nodeId);
2024-08-21 23:08:59 +12:00
},
onPositionHistoryQuickRangeClick: function(range) {
// update position history time range
switch(range){
case "1h": {
this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm');
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
break;
}
case "24h": {
this.positionHistoryDateTimeFrom = moment().subtract(24, "hours").format('YYYY-MM-DDTHH:mm');
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
break;
}
case "7d": {
this.positionHistoryDateTimeFrom = moment().subtract(7, "days").format('YYYY-MM-DDTHH:mm');
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
break;
}
}
// reload position history
const node = this.selectedNodeToShowPositionHistory;
if(node){
this.loadNodePositionHistory(node.node_id);
}
2024-08-04 19:12:51 +02:00
},
2024-04-04 20:31:21 +13:00
getShareLinkForNode: function(nodeId) {
return window.location.origin + `/?node_id=${nodeId}`;
2024-04-04 20:48:24 +13:00
},
copyShareLinkForNode: function(nodeId) {
// make sure copy to clipboard is supported
if(!navigator.clipboard || !navigator.clipboard.writeText){
alert("Clipboard not supported. Site must be served via https on iOS.");
return;
}
// copy share link to clipboard
const url = this.getShareLinkForNode(nodeId);
navigator.clipboard.writeText(url);
2024-08-28 02:02:28 +12:00
// tell user we copied it
alert("Link copied to clipboard!");
2024-04-04 20:48:24 +13:00
},
2026-01-08 18:33:16 +01:00
dismissShowingNodeConnections: function() {
window._onHideNodeConnectionsClick();
this.selectedNodeToShowConnections = null;
2024-04-07 12:30:27 +12:00
},
2024-08-04 19:12:51 +02:00
dismissShowingNodePositionHistory: function() {
2024-08-20 18:47:50 +12:00
this.selectedNodePositionHistory = [];
this.selectedNodeToShowPositionHistory = null;
this.selectedNodePositionHistoryMarkers = [];
this.selectedNodePositionHistoryPolyLines = [];
cleanUpPositionHistory();
2024-08-04 19:12:51 +02:00
},
2024-06-09 02:08:53 +12:00
formatUptimeSeconds: function(secondsToFormat) {
secondsToFormat = Number(secondsToFormat);
var days = Math.floor(secondsToFormat / (3600 * 24));
var hours = Math.floor((secondsToFormat % (3600 * 24)) / 3600);
var minutes = Math.floor((secondsToFormat % 3600) / 60);
var seconds = Math.floor(secondsToFormat % 60);
var daysPlural = days === 1 ? 'day' : 'days';
return `${days} ${daysPlural} ${hours}h ${minutes}m ${seconds}s`;
},
2024-06-24 17:14:46 +12:00
formatTemperature: function(celsius) {
switch(this.configTemperatureFormat){
case "celsius": {
2025-04-25 00:20:39 -04:00
return `${Number(celsius).toFixed(0)}°C`;
2024-06-24 17:14:46 +12:00
}
case "fahrenheit": {
const fahrenheit = this.celsiusToFahrenheit(celsius);
2025-04-25 00:20:39 -04:00
return `${fahrenheit.toFixed(0)}°F`;
2024-06-24 17:14:46 +12:00
}
}
},
convertTemperature: function(celsius) {
switch(this.configTemperatureFormat){
case "celsius": {
return celsius;
}
case "fahrenheit": {
return this.celsiusToFahrenheit(celsius);
}
}
},
getTemperatureUnit: function() {
switch(this.configTemperatureFormat){
2025-04-25 00:20:39 -04:00
case "celsius": return "°C";
case "fahrenheit": return "°F";
2024-06-24 17:14:46 +12:00
}
},
celsiusToFahrenheit: function(celsius) {
return (celsius * 9/5) + 32;
},
2024-08-28 01:42:44 +12:00
getNodeColour(nodeId) {
// convert node id to a hex colour
return "#" + (nodeId & 0x00FFFFFF).toString(16).padStart(6, '0');
},
2024-09-07 21:57:23 +12:00
getNodeTextColour(nodeId) {
// extract rgb components
const r = (nodeId & 0xFF0000) >> 16;
const g = (nodeId & 0x00FF00) >> 8;
const b = nodeId & 0x0000FF;
// calculate brightness
const brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255;
// determine text color based on brightness
return brightness > 0.5 ? "#000000" : "#FFFFFF";
},
2024-03-13 21:02:58 +13:00
},
2024-03-17 03:38:06 +13:00
computed: {
searchedNodes() {
// search nodes
const nodes = this.nodes.filter((node) => {
const matchesId = node.node_id?.toLowerCase()?.includes(this.searchText.toLowerCase());
const matchesHexId = node.node_id_hex?.toLowerCase()?.includes(this.searchText.toLowerCase());
const matchesLongName = node.long_name?.toLowerCase()?.includes(this.searchText.toLowerCase());
const matchesShortName = node.short_name?.toLowerCase()?.includes(this.searchText.toLowerCase());
return matchesId || matchesHexId || matchesLongName || matchesShortName;
});
// order alphabetically by long name
nodes.sort((nodeA, nodeB) => {
const nodeALongName = nodeA.long_name || "";
const nodeBLongName = nodeB.long_name || "";
return nodeALongName.localeCompare(nodeBLongName);
});
2024-09-04 12:37:51 +12:00
// only return the first 500 results to avoid ui lag...
return nodes.slice(0, 500);
2024-03-17 03:38:06 +13:00
},
2024-06-08 16:11:24 +12:00
selectedNodeLatestPowerMetric() {
const [ latestPowerMetric ] = this.selectedNodePowerMetrics.slice(-1);
return latestPowerMetric;
},
2024-03-17 03:38:06 +13:00
},
2024-03-23 17:13:49 +13:00
watch: {
2024-03-26 03:07:08 +13:00
configNodesMaxAgeInSeconds() {
window.setConfigNodesMaxAgeInSeconds(this.configNodesMaxAgeInSeconds);
},
2024-04-05 11:59:33 +13:00
configNodesOfflineAgeInSeconds() {
window.setConfigNodesOfflineAgeInSeconds(this.configNodesOfflineAgeInSeconds);
},
2024-04-05 15:08:31 +13:00
configWaypointsMaxAgeInSeconds() {
window.setConfigWaypointsMaxAgeInSeconds(this.configWaypointsMaxAgeInSeconds);
},
2026-01-08 18:33:16 +01:00
configConnectionsMaxDistanceInMeters() {
window.setConfigConnectionsMaxDistanceInMeters(this.configConnectionsMaxDistanceInMeters);
2024-03-23 17:13:49 +13:00
},
2024-03-23 22:43:55 +13:00
configZoomLevelGoToNode() {
window.setConfigZoomLevelGoToNode(this.configZoomLevelGoToNode);
},
2024-03-30 16:42:44 +13:00
configAutoUpdatePositionInUrl() {
window.setConfigAutoUpdatePositionInUrl(this.configAutoUpdatePositionInUrl);
},
2024-04-04 20:55:41 +13:00
configEnableMapAnimations() {
window.setConfigEnableMapAnimations(this.configEnableMapAnimations);
},
2024-06-24 17:14:46 +12:00
configTemperatureFormat() {
window.setConfigTemperatureFormat(this.configTemperatureFormat);
},
2026-01-07 20:32:18 +01:00
configConnectionsTimePeriodInSeconds() {
window.setConfigConnectionsTimePeriodInSeconds(this.configConnectionsTimePeriodInSeconds);
},
configConnectionsColoredLines() {
window.setConfigConnectionsColoredLines(this.configConnectionsColoredLines);
},
2024-09-04 11:39:23 +12:00
deviceMetricsTimeRange() {
this.loadNodeDeviceMetrics(this.selectedNode.node_id);
},
2024-09-08 22:30:22 +12:00
environmentMetricsTimeRange() {
this.loadNodeEnvironmentMetrics(this.selectedNode.node_id);
},
2024-09-04 12:04:11 +12:00
powerMetricsTimeRange() {
this.loadNodePowerMetrics(this.selectedNode.node_id);
},
2024-03-23 17:13:49 +13:00
},
2024-03-13 21:02:58 +13:00
}).mount('#app');
< / script >
2024-03-12 18:31:17 +13:00
< script >
// global state
var nodes = [];
var nodeMarkers = {};
var selectedNodeOutlineCircle = null;
2024-03-18 22:52:28 +13:00
var waypoints = [];
2024-03-12 18:31:17 +13:00
// set map bounds to be a little more than full size to prevent panning off screen
var bounds = [
[-100, 70], // top left
[100, 500], // bottom right
];
2025-03-05 00:10:56 +01:00
// create map positioned over Stockholm
2024-03-12 18:31:17 +13:00
var map = L.map('map', {
maxBounds: bounds,
}).setView([
2025-03-05 00:10:56 +01:00
59.3,
378.1,
], 10);
2024-03-12 18:31:17 +13:00
// remove leaflet link
map.attributionControl.setPrefix('');
2024-04-17 22:10:04 +12:00
var openStreetMapTileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
2024-03-23 16:18:39 +13:00
maxZoom: 22, // increase from 18 to 22
2024-04-17 21:36:41 +12:00
attribution: 'Tiles © < a href = "https://www.openstreetmap.org/copyright" > OpenStreetMap< / a > | Data from < a target = "_blank" href = "https://meshtastic.org/docs/software/integrations/mqtt/" > Meshtastic< / a > ',
2024-04-17 22:38:32 +12:00
});
2024-03-12 18:31:17 +13:00
2024-11-21 11:09:14 +13:00
var openTopoMapTileLayer = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
maxZoom: 17, // open topo map doesn't have tiles closer than this
attribution: 'Tiles © < a href = "https://www.openstreetmap.org/copyright" > OpenStreetMap< / a > ',
});
2024-04-17 22:10:04 +12:00
var esriWorldImageryTileLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
2024-04-17 21:36:41 +12:00
maxZoom: 21, // esri doesn't have tiles closer than this
attribution: 'Tiles © < a href = "https://developers.arcgis.com/documentation/mapping-apis-and-services/deployment/basemap-attribution/" > Esri< / a > | Data from < a target = "_blank" href = "https://meshtastic.org/docs/software/integrations/mqtt/" > Meshtastic< / a > '
});
2024-04-17 22:20:22 +12:00
var googleSatelliteTileLayer = L.tileLayer('https://{s}.google.com/vt/lyrs=s& x={x}& y={y}& z={z}', {
maxZoom: 21,
subdomains: ['mt0', 'mt1', 'mt2', 'mt3'],
attribution: 'Tiles © Google | Data from < a target = "_blank" href = "https://meshtastic.org/docs/software/integrations/mqtt/" > Meshtastic< / a > '
});
2024-04-17 22:18:28 +12:00
var googleHybridTileLayer = L.tileLayer('https://{s}.google.com/vt/lyrs=s,h& x={x}& y={y}& z={z}', {
maxZoom: 21,
subdomains: ['mt0', 'mt1', 'mt2', 'mt3'],
attribution: 'Tiles © Google | Data from < a target = "_blank" href = "https://meshtastic.org/docs/software/integrations/mqtt/" > Meshtastic< / a > '
});
2024-04-17 22:38:32 +12:00
var tileLayers = {
"OpenStreetMap": openStreetMapTileLayer,
2024-11-21 11:09:14 +13:00
"OpenTopoMap": openTopoMapTileLayer,
2024-04-17 22:38:32 +12:00
"Esri Satellite": esriWorldImageryTileLayer,
"Google Satellite": googleSatelliteTileLayer,
"Google Hybrid": googleHybridTileLayer,
};
// use tile layer based on config
const selectedTileLayerName = getConfigMapSelectedTileLayer();
const selectedTileLayer = tileLayers[selectedTileLayerName] || openStreetMapTileLayer;
selectedTileLayer.addTo(map);
2024-03-12 18:31:17 +13:00
// create layer groups
var nodesLayerGroup = new L.LayerGroup();
2026-01-08 18:33:16 +01:00
var backboneConnectionsLayerGroup = new L.LayerGroup();
var nodeConnectionsLayerGroup = new L.LayerGroup();
2024-03-15 15:16:47 +13:00
var nodesClusteredLayerGroup = L.markerClusterGroup({
showCoverageOnHover: false,
2024-05-19 11:50:52 +12:00
disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
2024-03-15 15:16:47 +13:00
});
2024-06-06 21:13:44 +12:00
var nodesRouterLayerGroup = L.markerClusterGroup({
showCoverageOnHover: false,
disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
});
2025-06-27 11:15:52 +02:00
var nodesBackboneLayerGroup = new L.LayerGroup();
2025-08-17 22:31:26 +02:00
var nodesMediumFastLayerGroup = new L.LayerGroup();
2025-09-28 20:26:19 +02:00
var nodesLongFastLayerGroup = new L.LayerGroup();
2024-03-18 22:52:28 +13:00
var waypointsLayerGroup = new L.LayerGroup();
2024-08-04 19:12:51 +02:00
var nodePositionHistoryLayerGroup = new L.LayerGroup();
2025-08-10 15:17:04 +02:00
var traceroutesLayerGroup = new L.LayerGroup();
2026-01-07 20:32:18 +01:00
var connectionsLayerGroup = new L.LayerGroup();
2024-03-12 18:31:17 +13:00
// create icons
2024-04-05 11:59:33 +13:00
var iconMqttConnected = L.divIcon({
className: 'icon-mqtt-connected',
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
2025-08-15 20:39:10 +02:00
var iconLongFast = L.divIcon({
className: 'icon-longfast',
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
var iconMediumFast = L.divIcon({
className: 'icon-mediumfast',
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
2024-04-05 11:59:33 +13:00
var iconMqttDisconnected = L.divIcon({
className: 'icon-mqtt-disconnected',
2024-03-16 18:02:48 +13:00
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
2024-03-12 18:31:17 +13:00
2024-03-31 18:46:00 +13:00
var iconOffline = L.divIcon({
className: 'icon-offline',
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
2024-08-20 18:47:50 +12:00
var iconPositionHistory = L.divIcon({
className: 'icon-position-history',
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
2026-01-02 22:20:24 +01:00
var iconTracerouteStart = L.divIcon({
className: 'icon-traceroute-start',
iconSize: [16, 16],
});
var iconTracerouteEnd = L.divIcon({
className: 'icon-traceroute-end',
iconSize: [16, 16],
});
2024-03-12 18:31:17 +13:00
// create legend
2024-03-31 18:46:00 +13:00
var legendLayerGroup = new L.LayerGroup();
2024-03-12 18:31:17 +13:00
var legend = L.control({position: 'bottomleft'});
legend.onAdd = function (map) {
2024-03-31 18:46:00 +13:00
var div = L.DomUtil.create('div', 'leaflet-control-layers');
2024-03-12 18:31:17 +13:00
div.style.backgroundColor = 'white';
div.style.padding = '12px';
2024-04-05 11:59:33 +13:00
div.innerHTML = `< div style = "margin-bottom:6px;" > < strong > Legend< / strong > < / div > `
2025-09-28 20:26:19 +02:00
+ `< div style = "display:flex" > < div class = "icon-mediumfast" style = "width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;" > < / div > MediumFast< / div > `
+ `< div style = "display:flex" > < div class = "icon-longfast" style = "width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;" > < / div > LongFast< / div > `
2026-01-02 22:20:24 +01:00
+ `< div style = "display:flex" > < div class = "icon-offline" style = "width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;" > < / div > Offline Too Long< / div > `
+ `< div style = "display:flex" > < svg width = "16" height = "3" style = "margin-right:4px;margin-top:auto;margin-bottom:auto;" > < line x1 = "0" y1 = "1.5" x2 = "16" y2 = "1.5" stroke = "#f97316" stroke-width = "4" / > < / svg > Traceroute< / div > `;
2024-03-12 18:31:17 +13:00
return div;
};
2024-04-17 22:38:32 +12:00
// handle baselayerchange to update tile layer preference
map.on('baselayerchange', function(event) {
setConfigMapSelectedTileLayer(event.name);
});
2024-03-12 18:31:17 +13:00
2024-03-31 18:46:00 +13:00
// handle adding/remove legend on map (can't use L.Control as an overlay, so we toggle an empty L.LayerGroup)
map.on('overlayadd overlayremove', function(event) {
if(event.name === "Legend"){
if(event.type === "overlayadd"){
map.addControl(legend);
} else if(event.type === "overlayremove"){
map.removeControl(legend);
}
}
});
2024-03-12 18:31:17 +13:00
// add layers to control ui
2024-04-17 21:36:41 +12:00
L.control.groupedLayers(tileLayers, {
"Nodes": {
"All": nodesLayerGroup,
2024-06-06 21:13:44 +12:00
"Routers": nodesRouterLayerGroup,
2025-06-27 11:15:52 +02:00
"Backbone": nodesBackboneLayerGroup,
2025-09-28 20:26:19 +02:00
"MediumFast": nodesMediumFastLayerGroup,
"LongFast": nodesLongFastLayerGroup,
2024-04-17 21:36:41 +12:00
"Clustered": nodesClusteredLayerGroup,
"None": new L.LayerGroup(),
},
"Overlays": {
"Legend": legendLayerGroup,
2026-01-08 18:33:16 +01:00
"Backbone Connection": backboneConnectionsLayerGroup,
2026-01-07 20:32:18 +01:00
"Connections": connectionsLayerGroup,
2024-04-17 21:36:41 +12:00
"Waypoints": waypointsLayerGroup,
2024-08-04 19:12:51 +02:00
"Position History": nodePositionHistoryLayerGroup,
2025-08-10 15:17:04 +02:00
"Traceroutes": traceroutesLayerGroup,
2024-04-17 21:36:41 +12:00
},
}, {
// make the "Nodes" group exclusive (use radio inputs instead of checkbox)
exclusiveGroups: ["Nodes"],
}).addTo(map);
2024-03-12 18:31:17 +13:00
2024-03-27 01:09:02 +13:00
// enable base layers
2025-03-09 09:28:14 +01:00
nodesLayerGroup.addTo(map);
2024-03-27 01:09:02 +13:00
// enable overlay layers based on config
const enabledOverlayLayers = getConfigMapEnabledOverlayLayers();
2024-03-31 18:46:00 +13:00
if(enabledOverlayLayers.includes("Legend")){
legendLayerGroup.addTo(map);
}
2025-06-27 11:15:52 +02:00
if(enabledOverlayLayers.includes("Backbone Connection")){
2026-01-08 18:33:16 +01:00
backboneConnectionsLayerGroup.addTo(map);
2025-06-27 11:15:52 +02:00
}
2026-01-07 20:32:18 +01:00
if(enabledOverlayLayers.includes("Connections")){
connectionsLayerGroup.addTo(map);
}
2024-03-27 01:09:02 +13:00
if(enabledOverlayLayers.includes("Waypoints")){
waypointsLayerGroup.addTo(map);
}
2024-08-04 19:12:51 +02:00
if(enabledOverlayLayers.includes("Position History")){
nodePositionHistoryLayerGroup.addTo(map);
}
2025-08-10 15:17:04 +02:00
if(enabledOverlayLayers.includes("Traceroutes")){
traceroutesLayerGroup.addTo(map);
}
2024-03-27 01:09:02 +13:00
map.on('overlayadd', function(event) {
2026-01-03 11:49:53 +01:00
// update config when map overlay is added
2024-03-27 01:09:02 +13:00
const layerName = event.name;
const enabledOverlayLayers = getConfigMapEnabledOverlayLayers();
if(!enabledOverlayLayers.includes(layerName)){
enabledOverlayLayers.push(layerName);
}
setConfigMapEnabledOverlayLayers(enabledOverlayLayers);
2026-01-03 11:49:53 +01:00
// clear traceroutes layer when traceroutes overlay is added
if (layerName === "Traceroutes") {
traceroutesLayerGroup.clearLayers();
}
2024-03-27 01:09:02 +13:00
});
// update config when map overlay is removed
map.on('overlayremove', function(event) {
const layerName = event.name;
const enabledOverlayLayers = getConfigMapEnabledOverlayLayers().filter(function(enabledOverlayLayer) {
return enabledOverlayLayer !== layerName;
});
setConfigMapEnabledOverlayLayers(enabledOverlayLayers);
});
2024-03-12 18:31:17 +13:00
2024-03-17 03:38:06 +13:00
// handle map clicks
2024-03-12 18:31:17 +13:00
map.on('click', function() {
2024-03-17 03:38:06 +13:00
// remove outline when map clicked
2024-03-12 18:31:17 +13:00
clearNodeOutline();
2024-03-17 03:38:06 +13:00
// send callback to vue
window._onMapClick();
2024-03-12 18:31:17 +13:00
});
2024-03-31 12:45:32 +13:00
// close all tooltips and popups when clicking map
map.on("click", function(event) {
// do nothing when clicking inside tooltip
const clickedElement = event.originalEvent.target;
if(elementOrAnyAncestorHasClass(clickedElement, "leaflet-tooltip")){
return;
}
closeAllTooltips();
closeAllPopups();
});
2024-03-12 18:31:17 +13:00
function isValidLatLng(lat, lng) {
if(isNaN(lat) || isNaN(lng)){
return false;
}
return true;
}
2024-03-13 14:03:00 +13:00
function findNodeById(id) {
// find node by id
var node = nodes.find((node) => node.node_id.toString() === id.toString());
if(node){
return node;
}
return null;
}
2024-03-12 18:31:17 +13:00
function findNodeMarkerById(id) {
// find node marker by id
var nodeMarker = nodeMarkers[id];
if(nodeMarker){
return nodeMarker;
}
return null;
}
2024-03-30 15:10:05 +13:00
function goToNode(id, animate, zoom){
2024-03-12 18:31:17 +13:00
2024-03-15 18:19:18 +13:00
// find node
var node = findNodeById(id);
if(!node){
alert("Could not find node: " + id);
2024-04-16 15:54:31 +12:00
return false;
2024-03-15 18:19:18 +13:00
}
2024-03-12 18:31:17 +13:00
// find node marker by id
var nodeMarker = findNodeMarkerById(id);
if(!nodeMarker){
2024-04-16 15:54:31 +12:00
return false;
2024-03-12 18:31:17 +13:00
}
// close all popups and tooltips
closeAllPopups();
closeAllTooltips();
// select node
showNodeOutline(id);
// fly to node marker
2024-03-30 15:10:05 +13:00
const shouldAnimate = animate != null ? animate : true;
map.flyTo(nodeMarker.getLatLng(), zoom || getConfigZoomLevelGoToNode(), {
2024-04-04 20:55:41 +13:00
animate: getConfigEnableMapAnimations() ? shouldAnimate : false,
2024-03-12 18:31:17 +13:00
});
2024-03-15 18:19:18 +13:00
// open tooltip for node
2024-03-31 12:48:22 +13:00
map.openTooltip(getTooltipContentForNode(node), nodeMarker.getLatLng(), {
interactive: true, // allow clicking buttons inside tooltip
permanent: true, // don't auto dismiss when clicking buttons inside tooltip
});
2024-03-12 18:31:17 +13:00
2024-04-16 15:54:31 +12:00
// successfully went to node
return true;
2024-03-12 18:31:17 +13:00
}
function goToRandomNode() {
if(nodes.length > 0){
const randomNode = nodes[Math.floor(Math.random() * nodes.length)];
if(randomNode){
2024-09-04 12:59:41 +12:00
// go to node
if(window.goToNode(randomNode.node_id)){
return;
}
// fallback to showing node details since we can't go to the node
window.showNodeDetails(randomNode.node_id);
2024-03-12 18:31:17 +13:00
}
}
}
function clearAllNodes() {
nodesLayerGroup.clearLayers();
2024-03-18 10:11:48 +13:00
nodesClusteredLayerGroup.clearLayers();
2024-06-06 22:42:34 +12:00
nodesRouterLayerGroup.clearLayers();
2025-06-27 11:15:52 +02:00
nodesBackboneLayerGroup.clearLayers();
2025-08-17 22:31:26 +02:00
nodesMediumFastLayerGroup.clearLayers();
2025-09-28 20:26:19 +02:00
nodesLongFastLayerGroup.clearLayers();
2024-03-12 18:31:17 +13:00
}
2026-01-08 18:33:16 +01:00
function clearAllBackboneConnections() {
backboneConnectionsLayerGroup.clearLayers();
2024-03-12 18:31:17 +13:00
}
2024-03-18 22:52:28 +13:00
function clearAllWaypoints() {
waypointsLayerGroup.clearLayers();
}
2025-08-10 15:17:04 +02:00
function clearAllTraceroutes() {
traceroutesLayerGroup.clearLayers();
}
2026-01-07 20:32:18 +01:00
function clearAllConnections() {
connectionsLayerGroup.clearLayers();
2026-01-08 18:33:16 +01:00
backboneConnectionsLayerGroup.clearLayers();
2026-01-07 20:32:18 +01:00
}
2024-03-12 18:31:17 +13:00
function closeAllPopups() {
map.eachLayer(function(layer) {
if(layer.options.pane === "popupPane"){
layer.removeFrom(map);
}
});
}
function closeAllTooltips() {
map.eachLayer(function(layer) {
if(layer.options.pane === "tooltipPane"){
layer.removeFrom(map);
}
});
}
2024-08-04 19:12:51 +02:00
function clearAllPositionHistory() {
nodePositionHistoryLayerGroup.clearLayers();
}
2024-03-12 18:31:17 +13:00
function clearNodeOutline() {
if(selectedNodeOutlineCircle){
selectedNodeOutlineCircle.removeFrom(map);
selectedNodeOutlineCircle = null;
}
}
function showNodeOutline(id) {
// remove any existing node circle
clearNodeOutline();
// find node marker by id
2024-08-26 18:26:54 +12:00
const nodeMarker = nodeMarkers[id];
2024-03-12 18:31:17 +13:00
if(!nodeMarker){
return;
}
2024-08-26 18:26:54 +12:00
// find node by id
const node = findNodeById(id);
if(!node){
return;
}
// add position precision circle around node
if(node.position_precision != null & & node.position_precision > 0 & & node.position_precision < 32 ) {
selectedNodeOutlineCircle = L.circle(nodeMarker.getLatLng(), {
radius: getPositionPrecisionInMeters(node.position_precision),
}).addTo(map);
}
2024-03-12 18:31:17 +13:00
}
2024-03-30 14:26:18 +13:00
function showNodeDetails(id) {
// find node
const node = findNodeById(id);
if(!node){
return;
}
// fire callback to vuejs handler
window._onNodeClick(node);
}
2024-04-07 12:30:27 +12:00
function getColourForSnr(snr) {
2026-01-07 20:32:18 +01:00
if(snr >= -4) return "#16a34a"; // good
if(snr > -8) return "#fff200"; // medium-good
if(snr > -12) return "#ff9f1c"; // medium
return "#dc2626"; // bad
}
function getSignalBarsIndicator(snrDb) {
if(snrDb == null) return '';
// Determine number of bars based on SNR
let bars = 0;
if(snrDb >= -4) bars = 4; // good
else if(snrDb > -8) bars = 3; // medium-good
else if(snrDb > -12) bars = 2; // medium
else bars = 1; // bad
const color = getColourForSnr(snrDb);
// Create 4 bars with increasing height
let indicator = '< span style = "display: inline-flex; align-items: flex-end; gap: 2px; height: 12px; vertical-align: middle; margin-left: 4px;" > ';
// Bar heights: 4px, 6px, 8px, 10px
const barHeights = [4, 6, 8, 10];
const barWidth = 2;
for (let i = 0; i < 4 ; i + + ) {
const height = barHeights[i];
const isActive = i < bars ;
const barColor = isActive ? color : '#d1d5db'; // gray for inactive bars
indicator += `< span style = "width: ${barWidth}px; height: ${height}px; background-color: ${barColor}; border-radius: 1px; display: inline-block;" > < / span > `;
}
indicator += '< / span > ';
return indicator;
2024-04-07 12:30:27 +12:00
}
2026-01-08 18:33:16 +01:00
function cleanUpNodeConnections() {
2024-04-05 18:21:43 +13:00
// close tooltips and popups
closeAllPopups();
closeAllTooltips();
2026-01-08 18:33:16 +01:00
// setup node connections layer
nodeConnectionsLayerGroup.clearLayers();
nodeConnectionsLayerGroup.removeFrom(map);
nodeConnectionsLayerGroup.addTo(map);
2024-04-05 18:21:43 +13:00
}
2024-11-24 16:51:34 +13:00
function getTerrainProfileImage(node1, node2) {
// line colour between nodes
const lineColour = "0000FF"; // blue
// node 1 (left side of image)
const node1MarkerColour = "0000FF"; // blue
const node1Latitude = node1.latitude;
const node1Longitude = node1.longitude;
2025-04-13 08:30:44 +02:00
const node1ElevationMSL = node1.altitude ?? "";
2024-11-24 16:51:34 +13:00
// node 2 (right side of image)
const node2MarkerColour = "0000FF"; // blue
const node2Latitude = node2.latitude;
const node2Longitude = node2.longitude;
2025-04-13 08:30:44 +02:00
const node2ElevationMSL = node2.altitude ?? "";
2024-11-24 16:51:34 +13:00
// generate terrain profile image url
return "https://heywhatsthat.com/bin/profile-0904.cgi?" + new URLSearchParams({
src: "meshtastic.liamcottle.net",
axes: 1, // include grid lines and a scale
metric: 1, // show metric units
2025-08-18 07:26:19 +02:00
curvature: 1,
2024-11-24 16:51:34 +13:00
width: 500,
height: 200,
pt0: `${node1Latitude},${node1Longitude},${lineColour},${node1ElevationMSL},${node1MarkerColour}`,
pt1: `${node2Latitude},${node2Longitude},${lineColour},${node2ElevationMSL},${node2MarkerColour}`,
}).toString();
}
2026-01-08 18:33:16 +01:00
async function showNodeConnections(id) {
2024-04-05 18:21:43 +13:00
2026-01-08 18:33:16 +01:00
cleanUpNodeConnections();
2024-04-05 18:21:43 +13:00
// find node
const node = findNodeById(id);
if(!node){
return;
}
// find node marker
const nodeMarker = findNodeMarkerById(node.node_id);
if(!nodeMarker){
return;
}
2026-01-08 18:33:16 +01:00
// show overlay for node connections
window._onShowNodeConnectionsClick(node);
2024-04-05 18:21:43 +13:00
2026-01-08 18:33:16 +01:00
// Fetch connections for this node
const connectionsTimePeriodSec = getConfigConnectionsTimePeriodInSeconds();
const connectionsTimeFrom = connectionsTimePeriodSec ? (Date.now() - connectionsTimePeriodSec * 1000) : undefined;
const connectionsParams = new URLSearchParams();
connectionsParams.set('node_id', id);
if (connectionsTimeFrom) connectionsParams.set('time_from', connectionsTimeFrom);
try {
const response = await window.axios.get(`/api/v1/connections?${connectionsParams.toString()}`);
const connections = response.data.connections ?? [];
2024-04-05 18:21:43 +13:00
2026-01-08 18:33:16 +01:00
for (const connection of connections) {
// Convert to numbers for comparison since API returns strings
const nodeA = parseInt(connection.node_a, 10);
const nodeB = parseInt(connection.node_b, 10);
2024-04-05 18:21:43 +13:00
2026-01-08 18:33:16 +01:00
const otherNodeId = nodeA === id ? nodeB : nodeA;
const otherNode = findNodeById(otherNodeId);
const otherNodeMarker = findNodeMarkerById(otherNodeId);
2025-08-10 15:17:04 +02:00
2026-01-08 18:33:16 +01:00
if (!otherNode || !otherNodeMarker) continue;
2025-08-10 15:17:04 +02:00
2026-01-08 18:33:16 +01:00
// Calculate distance
const distanceInMeters = nodeMarker.getLatLng().distanceTo(otherNodeMarker.getLatLng()).toFixed(2);
const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters();
if(configConnectionsMaxDistanceInMeters != null & & parseFloat(distanceInMeters) > configConnectionsMaxDistanceInMeters){
continue;
}
2025-08-10 15:17:04 +02:00
let distance = `${distanceInMeters} meters`;
if (distanceInMeters >= 1000) {
2026-01-08 18:33:16 +01:00
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
distance = `${distanceInKilometers} kilometers`;
2025-08-10 15:17:04 +02:00
}
2024-04-05 18:21:43 +13:00
2026-01-08 18:33:16 +01:00
// Determine line color
const configConnectionsColoredLines = getConfigConnectionsColoredLines();
const worstSnrDb = connection.worst_avg_snr_db;
const lineColor = configConnectionsColoredLines & & worstSnrDb != null ? getColourForSnr(worstSnrDb) : '#2563eb';
// Create bidirectional line
const line = L.polyline([
nodeMarker.getLatLng(),
otherNodeMarker.getLatLng(),
], {
color: lineColor,
opacity: 0.75,
weight: 3,
}).addTo(nodeConnectionsLayerGroup);
// Generate tooltip using standardized function
const tooltipNodeA = findNodeById(connection.node_a);
const tooltipNodeB = findNodeById(connection.node_b);
const tooltip = generateConnectionTooltip(connection, tooltipNodeA, tooltipNodeB, distance);
line.bindTooltip(tooltip, {
sticky: true,
opacity: 1,
interactive: true,
})
2024-04-05 18:21:43 +13:00
.bindPopup(tooltip)
.on('click', function(event) {
event.target.closeTooltip();
});
2026-01-08 18:33:16 +01:00
}
} catch (err) {
console.error('Error fetching connections:', err);
2024-04-05 18:21:43 +13:00
}
}
2024-03-12 18:31:17 +13:00
function clearMap() {
closeAllPopups();
closeAllTooltips();
clearAllNodes();
2026-01-08 18:33:16 +01:00
clearAllBackboneConnections();
2024-03-18 22:52:28 +13:00
clearAllWaypoints();
2025-08-10 15:17:04 +02:00
clearAllTraceroutes();
2026-01-07 20:32:18 +01:00
clearAllConnections();
2024-03-12 18:31:17 +13:00
clearNodeOutline();
2026-01-08 18:33:16 +01:00
cleanUpNodeConnections();
2024-03-12 18:31:17 +13:00
}
2024-03-30 14:26:18 +13:00
// returns true if the element or one of its parents has the class classname
function elementOrAnyAncestorHasClass(element, className) {
// check if element contains class
if(element.classList & & element.classList.contains(className)){
return true;
}
// check if parent node has the class
if(element.parentNode){
return elementOrAnyAncestorHasClass(element.parentNode, className);
}
// couldn't find the class
return false;
}
2024-04-05 14:51:35 +13:00
// escape strings for tooltips etc, to prevent html/script injection
// not used in vuejs, as that auto escapes
function escapeString(string) {
return string.replace(/< /g, "< ").replace(/>/g, "> ");
}
2024-08-28 22:47:32 +12:00
2024-03-12 18:31:17 +13:00
function onNodesUpdated(updatedNodes) {
// clear nodes cache
nodes = [];
2024-03-23 17:13:49 +13:00
// get config
2024-09-08 00:09:50 +12:00
const now = moment();
const configNodesMaxAgeInSeconds = getConfigNodesMaxAgeInSeconds();
const configNodesOfflineAgeInSeconds = getConfigNodesOfflineAgeInSeconds();
2026-01-08 18:33:16 +01:00
const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters();
2024-03-23 17:13:49 +13:00
2024-03-12 18:31:17 +13:00
// add nodes
2024-09-08 00:09:50 +12:00
for(const node of updatedNodes){
2024-03-12 18:31:17 +13:00
2024-03-26 03:07:08 +13:00
// skip nodes older than configured node max age
if(configNodesMaxAgeInSeconds){
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
if(lastUpdatedAgeInMillis > configNodesMaxAgeInSeconds * 1000){
continue;
}
}
2024-04-16 15:54:31 +12:00
// add to cache
nodes.push(node);
2024-03-13 03:46:50 +13:00
// skip nodes without position
if(!node.latitude || !node.longitude){
continue;
}
2024-03-12 18:31:17 +13:00
// fix lat long
node.latitude = node.latitude / 10000000;
node.longitude = node.longitude / 10000000;
2024-09-08 00:09:50 +12:00
// skip nodes with invalid position
if(!isValidLatLng(node.latitude, node.longitude)){
continue;
}
2024-03-12 18:31:17 +13:00
2024-09-08 00:09:50 +12:00
// wrap longitude for shortest path, everything to left of australia should be shown on the right
var longitude = parseFloat(node.longitude);
if(longitude < = 100){
longitude += 360;
}
2024-04-05 11:59:33 +13:00
2025-08-15 20:39:10 +02:00
// icon based on channel preset
var icon = iconLongFast;
if (node.channel_id == "MediumFast") {
icon = iconMediumFast;
}
2024-03-12 18:31:17 +13:00
2024-09-08 00:09:50 +12:00
// use offline icon for nodes older than configured node offline age
if(configNodesOfflineAgeInSeconds){
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
if(lastUpdatedAgeInMillis > configNodesOfflineAgeInSeconds * 1000){
icon = iconOffline;
2024-08-28 22:47:32 +12:00
}
2024-09-08 00:09:50 +12:00
}
2024-08-28 22:47:32 +12:00
2025-09-28 15:57:18 +02:00
// determine zIndexOffset: MediumFast (1000), LongFast (-1000), Offline (-2000)
var zIndexOffset = 1000;
if(icon == iconOffline){
zIndexOffset = -2000;
} else if(node.channel_id == 'LongFast'){
zIndexOffset = -1000;
}
2025-07-23 08:07:33 +02:00
// To not have overlapping nodes.
2025-11-02 20:50:21 +01:00
var latJitter = 0;
var lonJitter = 0;
// If position pression > 45m apply random jitter within a small circle to avoid diagonal-only displacement
if (node.position_precision < 19 ) {
const maxMeters = 40;
const r = maxMeters * Math.sqrt(Math.random());
const theta = 2 * Math.PI * Math.random();
const dy = r * Math.sin(theta);
const dx = r * Math.cos(theta);
const metersPerDegLat = 111320;
const metersPerDegLon = 111320 * Math.cos(node.latitude * Math.PI / 180);
latJitter = dy / metersPerDegLat;
lonJitter = metersPerDegLon ? (dx / metersPerDegLon) : 0;
}
2024-03-15 15:16:47 +13:00
2024-09-08 00:09:50 +12:00
// create node marker
2025-11-02 20:50:21 +01:00
const marker = L.marker([node.latitude + latJitter, longitude + lonJitter], {
2024-09-08 00:09:50 +12:00
icon: icon,
tagName: node.node_id,
2025-09-28 15:57:18 +02:00
// zIndex: offline (-2000) < has channel_id ( -1000 ) < others ( 1000 )
zIndexOffset: zIndexOffset,
2024-09-08 00:09:50 +12:00
}).on('click', function(event) {
// close tooltip on click to prevent tooltip and popup showing at same time
event.target.closeTooltip();
});
2024-06-06 21:13:44 +12:00
2024-09-08 00:09:50 +12:00
// add marker to node layer groups
marker.addTo(nodesLayerGroup);
nodesClusteredLayerGroup.addLayer(marker);
2024-03-12 18:31:17 +13:00
2024-09-08 00:09:50 +12:00
// add markers for routers and repeaters to routers layer group
if(node.role_name === "ROUTER"
|| node.role_name === "ROUTER_CLIENT"
2025-01-12 07:08:19 +13:00
|| node.role_name === "ROUTER_LATE"
2024-09-08 00:09:50 +12:00
|| node.role_name === "REPEATER"){
nodesRouterLayerGroup.addLayer(marker);
}
2024-03-15 15:31:50 +13:00
2025-06-27 11:15:52 +02:00
// add markers for backbone to layer group
if(node.is_backbone) {
nodesBackboneLayerGroup.addLayer(marker);
}
2025-08-17 22:31:26 +02:00
// add markers for MediumFast channel to layer group
if(node.channel_id == "MediumFast") {
nodesMediumFastLayerGroup.addLayer(marker);
}
2025-09-28 20:26:19 +02:00
// add markers for LongFast channel to layer group
if(node.channel_id == "LongFast") {
nodesLongFastLayerGroup.addLayer(marker);
}
2024-09-08 00:09:50 +12:00
// show tooltip on desktop only
if(!isMobile()){
marker.bindTooltip(getTooltipContentForNode(node), {
interactive: true,
});
}
2024-03-14 00:12:42 +13:00
2024-09-08 00:09:50 +12:00
// show node info tooltip when clicking node marker
marker.on("click", function(event) {
2024-03-30 14:26:18 +13:00
2024-09-08 00:09:50 +12:00
// close all other popups and tooltips
closeAllTooltips();
closeAllPopups();
2024-03-14 00:12:42 +13:00
2024-09-08 00:09:50 +12:00
// find node
const node = findNodeById(event.target.options.tagName);
if(!node){
return;
}
2024-08-26 18:26:54 +12:00
2024-09-08 00:09:50 +12:00
// show position precision outline
showNodeOutline(node.node_id);
2024-03-30 14:26:18 +13:00
2024-09-08 00:09:50 +12:00
// open tooltip for node
map.openTooltip(getTooltipContentForNode(node), event.target.getLatLng(), {
interactive: true, // allow clicking buttons inside tooltip
permanent: true, // don't auto dismiss when clicking buttons inside tooltip
2024-03-30 14:26:18 +13:00
});
2024-09-08 00:09:50 +12:00
});
2024-03-12 18:31:17 +13:00
2024-09-08 00:09:50 +12:00
// add to cache
nodeMarkers[node.node_id] = marker;
2024-03-12 18:31:17 +13:00
}
2024-03-17 03:38:06 +13:00
window._onNodesUpdated(nodes);
2024-03-12 18:31:17 +13:00
}
2024-03-18 22:52:28 +13:00
function onWaypointsUpdated(updatedWaypoints) {
// clear nodes cache
waypoints = [];
2024-09-08 00:09:50 +12:00
// get config
const now = moment();
const configWaypointsMaxAgeInSeconds = getConfigWaypointsMaxAgeInSeconds();
2024-03-18 22:52:28 +13:00
// add nodes
2024-09-08 00:09:50 +12:00
for(const waypoint of updatedWaypoints){
2024-03-18 22:52:28 +13:00
2024-04-05 15:08:31 +13:00
// skip waypoints older than configured waypoint max age
if(configWaypointsMaxAgeInSeconds){
const lastUpdatedAgeInMillis = now.diff(moment(waypoint.updated_at));
if(lastUpdatedAgeInMillis > configWaypointsMaxAgeInSeconds * 1000){
continue;
}
}
2024-03-18 22:52:28 +13:00
// skip expired waypoints
if(waypoint.expire < Date.now ( ) / 1000 ) {
continue;
}
2024-09-08 00:09:50 +12:00
// skip waypoints without position
2024-03-18 22:52:28 +13:00
if(!waypoint.latitude || !waypoint.longitude){
continue;
}
// fix lat long
waypoint.latitude = waypoint.latitude / 10000000;
waypoint.longitude = waypoint.longitude / 10000000;
2024-09-08 00:09:50 +12:00
// skip waypoints with invalid position
if(!isValidLatLng(waypoint.latitude, waypoint.longitude)){
continue;
}
2024-03-18 22:52:28 +13:00
2024-09-08 00:09:50 +12:00
// wrap longitude for shortest path, everything to left of australia should be shown on the right
var longitude = parseFloat(waypoint.longitude);
if(longitude < = 100){
longitude += 360;
}
2024-03-18 22:52:28 +13:00
2024-09-08 00:09:50 +12:00
// determine emoji to show as marker icon
const emoji = waypoint.icon === 0 ? 128205 : waypoint.icon;
const emojiText = String.fromCodePoint(emoji)
var tooltip = getTooltipContentForWaypoint(waypoint);
// create waypoint marker
const marker = L.marker([waypoint.latitude, longitude], {
icon: L.divIcon({
className: 'waypoint-label',
iconSize: [26, 26], // increase from 12px to 26px
html: emojiText,
}),
}).bindPopup(tooltip).on('click', function(event) {
// close tooltip on click to prevent tooltip and popup showing at same time
event.target.closeTooltip();
});
2024-03-18 22:52:28 +13:00
2024-09-08 00:09:50 +12:00
// show tooltip on desktop only
if(!isMobile()){
marker.bindTooltip(tooltip, {
interactive: true,
2024-03-18 22:52:28 +13:00
});
2024-09-08 00:09:50 +12:00
}
2024-03-18 22:52:28 +13:00
2024-09-08 00:09:50 +12:00
// add marker to waypoints layer groups
marker.addTo(waypointsLayerGroup);
2024-03-18 22:52:28 +13:00
2024-09-08 00:09:50 +12:00
// add to cache
waypoints.push(waypoint);
2024-03-18 22:52:28 +13:00
}
}
2025-08-10 15:17:04 +02:00
2026-01-08 18:33:16 +01:00
function generateConnectionTooltip(connection, nodeA, nodeB, distance) {
let tooltip = `< b > Connection< / b > `;
tooltip += `< br / > [${escapeString(nodeA.short_name)}] ${escapeString(nodeA.long_name)} < - > [${escapeString(nodeB.short_name)}] ${escapeString(nodeB.long_name)}`;
tooltip += `< br / > Distance: ${distance}`;
tooltip += `< br / > `;
2025-08-10 15:17:04 +02:00
2026-01-08 18:33:16 +01:00
// Direction A -> B
if (connection.direction_ab.avg_snr_db != null) {
tooltip += `< br / > < b > ${escapeString(nodeA.short_name)} -> ${escapeString(nodeB.short_name)}:< / b > `;
tooltip += `< br / > SNR: ${connection.direction_ab.avg_snr_db.toFixed(1)}dB ${getSignalBarsIndicator(connection.direction_ab.avg_snr_db)} (Average of ${connection.direction_ab.total_count} edges)`;
if (connection.direction_ab.last_5_edges.length > 0) {
tooltip += `< br / > Last 5 edges:`;
for (const edge of connection.direction_ab.last_5_edges) {
const timeAgo = moment(new Date(edge.created_at)).fromNow();
const sourceIcon = edge.source === "TRACEROUTE_APP" ? "⇵" : (edge.source === "NEIGHBORINFO_APP" ? "✳" : "?");
tooltip += `< br / > ${edge.snr_db.toFixed(1)}dB ${getSignalBarsIndicator(edge.snr_db)} (${timeAgo} by:${sourceIcon})`;
}
} else {
tooltip += `< br / > No recent edges`;
2025-08-10 15:17:04 +02:00
}
2026-01-08 18:33:16 +01:00
}
2025-08-10 15:17:04 +02:00
2026-01-08 18:33:16 +01:00
// Direction B -> A
if (connection.direction_ba.avg_snr_db != null) {
tooltip += `< br / > < br / > < b > ${escapeString(nodeB.short_name)} -> ${escapeString(nodeA.short_name)}:< / b > `;
tooltip += `< br / > SNR: ${connection.direction_ba.avg_snr_db.toFixed(1)}dB ${getSignalBarsIndicator(connection.direction_ba.avg_snr_db)} (Average of ${connection.direction_ba.total_count} edges)`;
if (connection.direction_ba.last_5_edges.length > 0) {
tooltip += `< br / > Last 5 edges:`;
for (const edge of connection.direction_ba.last_5_edges) {
const timeAgo = moment(new Date(edge.created_at)).fromNow();
const sourceIcon = edge.source === "TRACEROUTE_APP" ? "⇵" : (edge.source === "NEIGHBORINFO_APP" ? "✳" : "?");
tooltip += `< br / > ${edge.snr_db.toFixed(1)}dB ${getSignalBarsIndicator(edge.snr_db)} (${timeAgo} by:${sourceIcon})`;
}
} else {
tooltip += `< br / > No recent edges`;
2025-11-19 21:17:05 +01:00
}
2025-08-10 15:17:04 +02:00
}
2026-01-08 18:33:16 +01:00
// Add terrain profile image
const terrainImageUrl = getTerrainProfileImage(nodeA, nodeB);
tooltip += `< br / > < br / > Terrain images from < a href = "http://www.heywhatsthat.com" target = "_blank" > HeyWhatsThat.com< / a > `;
tooltip += `< br / > < a href = "${terrainImageUrl}" target = "_blank" > < img src = "${terrainImageUrl}" width = "100%" > < / a > `;
return tooltip;
2025-08-10 15:17:04 +02:00
}
2026-01-07 20:32:18 +01:00
function onConnectionsUpdated(connections) {
// Clear existing connections
clearAllConnections();
for (const connection of connections) {
// Find both node markers
const nodeAMarker = findNodeMarkerById(connection.node_a);
const nodeBMarker = findNodeMarkerById(connection.node_b);
// Skip if either node marker doesn't exist
if (!nodeAMarker || !nodeBMarker) {
continue;
}
// Find node objects for names and terrain profile
const nodeA = findNodeById(connection.node_a);
const nodeB = findNodeById(connection.node_b);
if (!nodeA || !nodeB) {
continue;
}
// Calculate distance between nodes
const distanceInMeters = nodeAMarker.getLatLng().distanceTo(nodeBMarker.getLatLng()).toFixed(2);
2026-01-08 18:33:16 +01:00
// Apply distance filter
const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters();
if(configConnectionsMaxDistanceInMeters != null & & parseFloat(distanceInMeters) > configConnectionsMaxDistanceInMeters){
continue;
}
2026-01-07 20:32:18 +01:00
let distance = `${distanceInMeters} meters`;
if (distanceInMeters >= 1000) {
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
distance = `${distanceInKilometers} kilometers`;
}
// Determine line color based on worst average SNR (if colored lines enabled)
const configConnectionsColoredLines = getConfigConnectionsColoredLines();
const worstSnrDb = connection.worst_avg_snr_db;
const lineColor = configConnectionsColoredLines & & worstSnrDb != null ? getColourForSnr(worstSnrDb) : '#2563eb';
// Create polyline (bidirectional, no arrows)
const line = L.polyline([
nodeAMarker.getLatLng(),
nodeBMarker.getLatLng(),
], {
color: lineColor,
opacity: 0.75,
weight: 3,
}).addTo(connectionsLayerGroup);
// Generate tooltip
2026-01-08 18:33:16 +01:00
const tooltip = generateConnectionTooltip(connection, nodeA, nodeB, distance);
2026-01-07 20:32:18 +01:00
// Bind tooltip and popup
line.bindTooltip(tooltip, {
sticky: true,
opacity: 1,
interactive: true,
})
.bindPopup(tooltip)
.on('click', function(event) {
// close tooltip on click to prevent tooltip and popup showing at same time
event.target.closeTooltip();
});
2026-01-08 18:33:16 +01:00
// If both nodes are backbone nodes, also add to backbone layer group with arrows
if (nodeA.is_backbone & & nodeB.is_backbone) {
const backboneLine = L.polyline([
nodeAMarker.getLatLng(),
nodeBMarker.getLatLng(),
], {
color: lineColor,
opacity: 0.75,
weight: 3,
}).arrowheads({
size: '10px',
fill: true,
offsets: {
start: '25px',
end: '25px',
},
}).addTo(backboneConnectionsLayerGroup);
backboneLine.bindTooltip(tooltip, {
sticky: true,
opacity: 1,
interactive: true,
})
.bindPopup(tooltip)
.on('click', function(event) {
event.target.closeTooltip();
});
}
2026-01-07 20:32:18 +01:00
}
}
2024-08-04 19:12:51 +02:00
function onPositionHistoryUpdated(updatedPositionHistories) {
2024-08-20 18:47:50 +12:00
2024-08-04 19:12:51 +02:00
let positionHistoryLinesCords = [];
// add nodes
2024-09-08 00:09:50 +12:00
for(const positionHistory of updatedPositionHistories) {
2024-08-04 19:12:51 +02:00
2024-08-20 18:47:50 +12:00
// skip position history without position
2024-08-04 19:12:51 +02:00
if(!positionHistory.latitude || !positionHistory.longitude){
continue;
}
2024-08-29 00:19:23 +12:00
// find node this position is for
const node = findNodeById(positionHistory.node_id);
if(!node){
continue;
}
2024-08-04 19:12:51 +02:00
// fix lat long
positionHistory.latitude = positionHistory.latitude / 10000000;
positionHistory.longitude = positionHistory.longitude / 10000000;
2024-09-08 00:09:50 +12:00
// skip position history with invalid position
if(!isValidLatLng(positionHistory.latitude, positionHistory.longitude)){
continue;
}
2024-08-04 19:12:51 +02:00
2024-09-08 00:09:50 +12:00
// wrap longitude for shortest path, everything to left of australia should be shown on the right
var longitude = parseFloat(positionHistory.longitude);
if(longitude < = 100){
longitude += 360;
}
2024-08-04 19:12:51 +02:00
2024-09-08 00:09:50 +12:00
positionHistoryLinesCords.push([positionHistory.latitude, longitude]);
2024-08-04 19:12:51 +02:00
2024-09-08 00:09:50 +12:00
let tooltip = "";
if(positionHistory.type === "position"){
tooltip += `< b > Position< / b > `;
} else if(positionHistory.type === "map_report"){
tooltip += `< b > Map Report< / b > `;
}
tooltip += `< br / > [${escapeString(node.short_name)}] ${escapeString(node.long_name)}`;
tooltip += `< br / > ${positionHistory.latitude}, ${positionHistory.longitude}`;
tooltip += `< br / > Heard on: ${moment(new Date(positionHistory.created_at)).format("DD/MM/YYYY hh:mm A")}`;
// add gateway info if available
if(positionHistory.gateway_id){
const gatewayNode = findNodeById(positionHistory.gateway_id);
const gatewayNodeInfo = gatewayNode ? `[${gatewayNode.short_name}] ${gatewayNode.long_name}` : "???";
tooltip += `< br / > Heard by: < a href = "javascript:void(0);" onclick = "goToNode(${positionHistory.gateway_id})" > ${gatewayNodeInfo}< / a > `;
}
2024-08-04 19:12:51 +02:00
2024-09-08 00:09:50 +12:00
// create position history marker
const marker = L.marker([positionHistory.latitude, longitude],{
icon: iconPositionHistory,
}).bindTooltip(tooltip).bindPopup(tooltip).on('click', function(event) {
// close tooltip on click to prevent tooltip and popup showing at same time
event.target.closeTooltip();
});
2024-08-04 19:12:51 +02:00
2024-09-08 00:09:50 +12:00
// add marker to position history layer group
marker.addTo(nodePositionHistoryLayerGroup);
2024-08-04 19:12:51 +02:00
}
2024-08-20 18:47:50 +12:00
// show lines between position history markers
2025-04-25 17:36:52 +12:00
L.polyline(positionHistoryLinesCords, {
color: "#a855f7",
opacity: 1,
}).addTo(nodePositionHistoryLayerGroup);
2024-08-04 19:12:51 +02:00
}
function cleanUpPositionHistory() {
// close tooltips and popups
closeAllPopups();
closeAllTooltips();
2026-01-08 18:33:16 +01:00
// setup node position history layer
2024-08-04 19:12:51 +02:00
nodePositionHistoryLayerGroup.clearLayers();
nodePositionHistoryLayerGroup.removeFrom(map);
nodePositionHistoryLayerGroup.addTo(map);
}
2024-03-12 18:31:17 +13:00
function setLoading(loading){
var reloadButton = document.getElementById("reload-button");
if(loading){
reloadButton.classList.add("animate-spin");
} else {
reloadButton.classList.remove("animate-spin");
}
}
2024-03-30 15:10:05 +13:00
async function reload(goToNodeId, zoom) {
2024-03-12 18:31:17 +13:00
// show loading
setLoading(true);
2024-03-18 22:52:28 +13:00
// clear previous data
clearMap();
2024-03-12 18:31:17 +13:00
// fetch nodes
2024-09-07 23:45:53 +12:00
await window.axios.get('/api/v1/nodes').then(async (response) => {
2024-03-12 18:31:17 +13:00
// update nodes
2024-09-07 23:45:53 +12:00
onNodesUpdated(response.data.nodes);
2024-03-12 18:31:17 +13:00
// hide loading
setLoading(false);
// go to node id if provided
if(goToNodeId){
2024-06-08 15:40:50 +12:00
// go to node
if(window.goToNode(goToNodeId, false, zoom)){
return;
}
// fallback to showing node details since we can't go to the node
window.showNodeDetails(goToNodeId);
2024-03-12 18:31:17 +13:00
}
});
2024-03-18 23:16:54 +13:00
// fetch waypoints (after awaiting nodes, so we can use nodes cache in waypoint tooltips)
2024-09-07 23:45:53 +12:00
await window.axios.get('/api/v1/waypoints').then(async (response) => {
onWaypointsUpdated(response.data.waypoints);
2024-03-18 22:52:28 +13:00
});
2026-01-07 20:32:18 +01:00
// fetch connections (edges)
const connectionsTimePeriodSec = getConfigConnectionsTimePeriodInSeconds();
const connectionsTimeFrom = connectionsTimePeriodSec ? (Date.now() - connectionsTimePeriodSec * 1000) : undefined;
const connectionsParams = new URLSearchParams();
if (connectionsTimeFrom) connectionsParams.set('time_from', connectionsTimeFrom);
await window.axios.get(`/api/v1/connections?${connectionsParams.toString()}`).then(async (response) => {
onConnectionsUpdated(response.data.connections ?? []);
}).catch(() => {
onConnectionsUpdated([]);
});
2024-03-12 18:31:17 +13:00
}
2024-04-02 11:31:46 +13:00
function getRegionFrequencyRange(regionName) {
2024-04-02 11:37:08 +13:00
2024-04-01 03:20:15 +13:00
// determine lora frequency range based on region_name
// https://github.com/meshtastic/firmware/blob/a4c22321fca6fc8da7bab157c3812055603512ba/src/mesh/RadioInterface.cpp#L21
2024-04-02 11:37:08 +13:00
const regionNameToLoraFrequencyRange = {
"US": "902-928 MHz",
"EU_433": "433-434 MHz",
"EU_868": "869.4-869.65 MHz",
"CN": "470-510 MHz",
"JP": "920.8-927.8 MHz",
"ANZ": "915-928 MHz",
"RU": "868.7-869.2 MHz",
"KR": "920-923 MHz",
"TW": "920-925 MHz",
"IN": "865-867 MHz",
"NZ_865": "864-868 MHz",
"TH": "920-925 MHz",
"UA_433": "433-434.7 MHz",
"UA_868": "868-868.6 MHz",
"MY_433": "433-435 MHz",
"MY_919": "919-924 MHz",
"SG_923": "917-925 MHz",
"LORA_24": "2.4-2.4835 GHz",
"UNSET": "902-928 MHz",
2024-04-01 03:20:15 +13:00
}
2024-04-02 11:37:08 +13:00
return regionNameToLoraFrequencyRange[regionName] ?? null;
2024-04-02 11:31:46 +13:00
}
2024-08-26 18:14:16 +12:00
function getPositionPrecisionInMeters(positionPrecision) {
switch(positionPrecision){
case 2: return 5976446;
case 3: return 2988223;
case 4: return 1494111;
case 5: return 747055;
case 6: return 373527;
case 7: return 186763;
case 8: return 93381;
case 9: return 46690;
case 10: return 23345;
case 11: return 11672; // Android LOW_PRECISION
case 12: return 5836;
case 13: return 2918;
case 14: return 1459;
case 15: return 729;
case 16: return 364; // Android MED_PRECISION
case 17: return 182;
case 18: return 91;
case 19: return 45;
case 20: return 22;
case 21: return 11;
case 22: return 5;
case 23: return 2;
case 24: return 1;
case 32: return 0; // Android HIGH_PRECISION
}
return null;
}
function formatPositionPrecision(positionPrecision) {
// get position precision in meters
const positionPrecisionInMeters = getPositionPrecisionInMeters(positionPrecision);
2024-08-26 18:28:26 +12:00
if(positionPrecisionInMeters == null){
2024-08-26 18:14:16 +12:00
return "?";
}
// format kilometers
if(positionPrecisionInMeters > 1000){
const positionPrecisionInKilometers = Math.ceil(positionPrecisionInMeters / 1000);
return `±${positionPrecisionInKilometers}km`;
}
// format meters
return `±${positionPrecisionInMeters}m`;
}
2024-04-02 11:31:46 +13:00
function getTooltipContentForNode(node) {
var loraFrequencyRange = getRegionFrequencyRange(node.region_name);
2024-04-01 03:20:15 +13:00
2024-03-15 18:19:18 +13:00
var tooltip = `< img class = "mb-4 w-40 mx-auto" src = "/images/devices/${node.hardware_model_name}.png" onerror = "this.classList.add('hidden')" / > ` +
2024-04-05 14:51:35 +13:00
`< b > ${escapeString(node.long_name)}< / b > ` +
`< br / > Short Name: ${escapeString(node.short_name)}` +
2024-04-02 11:38:23 +13:00
(node.num_online_local_nodes != null ? `< br / > Local Nodes Online: ${node.num_online_local_nodes}` : '') +
2024-08-26 18:14:16 +12:00
(node.position_precision != null & & node.position_precision !== 32 ? `< br / > Position Precision: ${formatPositionPrecision(node.position_precision)}` : '') +
2024-03-15 18:19:18 +13:00
`< br / > < br / > Role: ${node.role_name}` +
2024-04-01 01:52:45 +13:00
`< br / > Hardware: ${node.hardware_model_name}` +
2025-07-23 08:05:29 +02:00
(node.firmware_version != null ? `< br / > Firmware: ${node.firmware_version}` : '') +
`< br / > OK to MQTT: ${node.ok_to_mqtt}`;
2024-03-15 18:19:18 +13:00
if(node.battery_level){
if(node.battery_level > 100){
tooltip += `< br / > Battery: ${node.battery_level > 100 ? 'Plugged In' : node.battery_level}`;
} else {
tooltip += `< br / > Battery: ${node.battery_level}%`;
}
}
if(node.voltage){
tooltip += `< br / > Voltage: ${Number(node.voltage).toFixed(2)}V`;
}
if(node.channel_utilization){
tooltip += `< br / > Ch Util: ${Number(node.channel_utilization).toFixed(2)}%`;
}
if(node.air_util_tx){
tooltip += `< br / > Air Util: ${Number(node.air_util_tx).toFixed(2)}%`;
}
2024-04-07 20:13:32 +12:00
// ignore alt above 42949000 due to https://github.com/meshtastic/firmware/issues/3109
if(node.altitude & & node.altitude < 42949000 ) {
tooltip += `< br / > Altitude: ${node.altitude}m`;
}
2024-03-15 18:19:18 +13:00
// bottom info
tooltip += `< br / > < br / > ID: ${node.node_id}`;
tooltip += `< br / > Hex ID: ${node.node_id_hex}`;
tooltip += `< br / > Updated: ${moment(new Date(node.updated_at)).fromNow()}`;
2026-01-08 19:02:35 +01:00
tooltip += (node.mqtt_connection_state_updated_at ? `< br / > MQTT Updated: ${moment(new Date(node.mqtt_connection_state_updated_at)).fromNow()}` : '');
2024-03-30 14:26:18 +13:00
tooltip += (node.neighbours_updated_at ? `< br / > Neighbours Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '');
tooltip += (node.position_updated_at ? `< br / > Position Updated: ${moment(new Date(node.position_updated_at)).fromNow()}` : '');
// show details button
2024-04-05 18:21:43 +13:00
tooltip += `< br / > < br / > < button onclick = "showNodeDetails(${node.node_id});" class = "border border-gray-300 bg-gray-100 p-1 w-full rounded hover:bg-gray-200 mb-1" > Show Full Details< / button > `;
2026-01-08 18:33:16 +01:00
tooltip += `< br / > < button onclick = "showNodeConnections(${node.node_id});" class = "border border-gray-300 bg-gray-100 p-1 w-full rounded hover:bg-gray-200" > Show Connections< / button > `;
2024-07-06 00:34:07 +12:00
tooltip += `< / div > `;
2024-03-15 18:19:18 +13:00
return tooltip;
}
2024-03-18 22:52:28 +13:00
function getTooltipContentForWaypoint(waypoint) {
2024-03-18 23:16:54 +13:00
// get from node name
var fromNode = findNodeById(waypoint.from);
2024-04-05 14:51:35 +13:00
var tooltip = `< b > ${escapeString(waypoint.name)}< / b > ` +
(waypoint.description ? `< br / > ${escapeString(waypoint.description)}` : '') +
2024-03-18 22:52:28 +13:00
`< br / > < br / > Expires: ${moment(new Date(waypoint.expire * 1000)).fromNow()}` +
2024-03-18 23:16:54 +13:00
`< br / > Lat/Lng: ${waypoint.latitude}, ${waypoint.longitude}` +
`< br / > < br / > From ID: ${waypoint.from}` +
`< br / > From Hex ID: !${Number(waypoint.from).toString(16)}`;
// show node name this waypoint is from, if possible
if(fromNode != null){
2024-04-05 14:51:35 +13:00
tooltip += `< br / > From Node: < a href = "#" onclick = "goToNode(${waypoint.from})" > ${escapeString(fromNode.long_name) || 'Unnamed Node'}< / a > `;
2024-03-18 23:16:54 +13:00
} else {
tooltip += `< br / > From Node: ???`;
}
2024-03-18 22:52:28 +13:00
// bottom info
tooltip += `< br / > < br / > ID: ${waypoint.waypoint_id}`;
tooltip += `< br / > Updated: ${moment(new Date(waypoint.updated_at)).fromNow()}`;
return tooltip;
}
2026-01-08 18:33:16 +01:00
window._onHideNodeConnectionsClick = function() {
cleanUpNodeConnections();
2024-04-07 12:30:27 +12:00
};
2024-03-12 18:31:17 +13:00
// parse url params
var queryParams = new URLSearchParams(location.search);
var queryNodeId = queryParams.get('node_id');
2024-03-30 15:10:05 +13:00
var queryLat = queryParams.get('lat');
var queryLng = queryParams.get('lng');
var queryZoom = queryParams.get('zoom');
// go to lat/lng if provided
if(queryLat & & queryLng){
const zoomLevel = queryZoom || getConfigZoomLevelGoToNode();
map.flyTo([queryLat, queryLng], zoomLevel, {
animate: false,
});
}
2024-03-12 18:31:17 +13:00
2024-03-30 16:42:44 +13:00
// auto update url when lat/lng/zoom changes
map.on("moveend zoomend", function() {
// check if user enabled auto updating position in url
const autoUpdatePositionInUrl = getConfigAutoUpdatePositionInUrl();
if(!autoUpdatePositionInUrl){
return;
}
// get map info
const latLng = map.getCenter();
const zoom = map.getZoom();
// construct new url
const url = new URL(window.location.href);
url.searchParams.set("lat", latLng.lat);
url.searchParams.set("lng", latLng.lng);
url.searchParams.set("zoom", zoom);
// update current url
if(window.history.replaceState){
window.history.replaceState(null, null, url.toString());
}
});
2024-03-12 18:31:17 +13:00
// reload and go to provided node id
2024-03-30 15:10:05 +13:00
reload(queryNodeId, queryZoom);
2024-03-12 18:31:17 +13:00
2026-01-02 22:20:24 +01:00
// WebSocket connection for real-time messages
var ws = null;
var tracerouteCooldown = {}; // Track last traceroute time per from node (for 20s cooldown)
var activeTracerouteKeys = new Set(); // Track active traceroute visualizations to prevent duplicates
var tracerouteLines = {}; // Track lines for each traceroute route key for cleanup
function connectWebSocket() {
// Determine WebSocket URL - use same hostname as current page, port 8081
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
2026-01-02 22:52:12 +01:00
// Heuristic: if running on localhost, use port 8081; otherwise use /ws path via Nginx
const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
const wsUrl = isLocalhost
? `${wsProtocol}//${location.hostname}:8081`
: `${wsProtocol}//${location.host}/ws`;
2026-01-02 22:20:24 +01:00
console.log('Connecting to WebSocket:', wsUrl);
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log('WebSocket connected');
};
ws.onmessage = function(event) {
try {
const message = JSON.parse(event.data);
handleWebSocketMessage(message);
} catch (e) {
console.error('Error parsing WebSocket message:', e);
}
};
ws.onerror = function(error) {
console.error('WebSocket error:', error);
};
ws.onclose = function() {
console.log('WebSocket disconnected, reconnecting in 5 seconds...');
setTimeout(connectWebSocket, 5000);
};
}
function handleWebSocketMessage(message) {
if (message.type === 'traceroute') {
handleTraceroute(message.data);
}
}
function handleTraceroute(data) {
// Only visualize traceroutes where want_response is false (the reply coming back)
if (data.want_response) {
return;
}
// When want_response is false, from and to are swapped from the original request
// The path goes from 'to' (original sender) through route to 'from' (original destination)
const originalSender = data.to; // This was the original sender
const originalDestination = data.from; // This was the original destination
const route = data.route || [];
const snrTowards = data.snr_towards || [];
// Deduplicate: ignore traceroutes from the same original sender for 20 seconds
const now = Date.now();
if (tracerouteCooldown[originalSender] & & (now - tracerouteCooldown[originalSender]) < 20000 ) {
return; // Still in cooldown period
}
// Create unique key for this traceroute path to prevent duplicate visualizations
// Use original sender (to), original destination (from), and route to create unique key
// (ignoring gateway_id since multiple gateways can receive same route)
const routeKey = `${originalSender}-${originalDestination}-${route.join('-')}`;
if (activeTracerouteKeys.has(routeKey)) {
return; // Already visualizing this route
}
// Mark as active and set cooldown
activeTracerouteKeys.add(routeKey);
tracerouteCooldown[originalSender] = now;
// Build the complete path: to (original sender) -> route[0] -> route[1] -> ... -> from (original destination)
const path = [originalSender]; // Start from original sender
if (route.length > 0) {
path.push(...route);
}
path.push(originalDestination); // End at original destination
// Visualize the traceroute with animated hops
visualizeTraceroute(path, snrTowards, routeKey);
}
function visualizeTraceroute(path, snrTowards, routeKey) {
// Verify all nodes in path exist on map
const pathMarkers = [];
for (const nodeId of path) {
const marker = findNodeMarkerById(nodeId);
if (!marker) {
// Node not on map, skip this traceroute
activeTracerouteKeys.delete(routeKey);
return;
}
pathMarkers.push(marker);
}
// Store lines and overlays for this route key for cleanup
const routeElements = {
lines: [],
startOverlay: null,
endOverlay: null,
};
tracerouteLines[routeKey] = routeElements;
// Color starting node (first in path) green and destination node (last in path) red
const startMarker = pathMarkers[0];
const endMarker = pathMarkers[pathMarkers.length - 1];
const startOverlay = L.marker(startMarker.getLatLng(), {
icon: iconTracerouteStart,
zIndexOffset: 10000, // Ensure it's on top
}).addTo(traceroutesLayerGroup);
const endOverlay = L.marker(endMarker.getLatLng(), {
icon: iconTracerouteEnd,
zIndexOffset: 10000, // Ensure it's on top
}).addTo(traceroutesLayerGroup);
// Store overlays for cleanup
routeElements.startOverlay = startOverlay;
routeElements.endOverlay = endOverlay;
// Animate each hop sequentially
let hopIndex = 0;
const animateNextHop = () => {
if (hopIndex >= pathMarkers.length - 1) {
// All hops animated, cleanup after delay
setTimeout(() => {
if (tracerouteLines[routeKey]) {
const routeElements = tracerouteLines[routeKey];
// Remove all lines
if (routeElements.lines) {
routeElements.lines.forEach(line => {
line.remove();
});
}
// Remove node overlays
if (routeElements.startOverlay) {
routeElements.startOverlay.remove();
}
if (routeElements.endOverlay) {
routeElements.endOverlay.remove();
}
delete tracerouteLines[routeKey];
}
activeTracerouteKeys.delete(routeKey);
2026-01-04 14:11:45 +01:00
}, 2500);
2026-01-02 22:20:24 +01:00
return;
}
const fromMarker = pathMarkers[hopIndex];
const toMarker = pathMarkers[hopIndex + 1];
const snr = hopIndex < snrTowards.length ? snrTowards [ hopIndex ] : null ;
// Use orange color for all traceroute lines
const lineColor = '#f97316'; // orange
// Create animated polyline for this hop with orange dotted style
const line = L.polyline([fromMarker.getLatLng(), toMarker.getLatLng()], {
color: lineColor,
weight: 4,
opacity: 0, // Start invisible
// dashArray: '10, 5', // Dotted line style
zIndexOffset: 10000,
}).addTo(traceroutesLayerGroup);
// Fade in animation
line.setStyle({ opacity: 1.0 });
tracerouteLines[routeKey].lines.push(line);
2026-01-04 14:11:45 +01:00
// Animate next hop after 700ms delay
2026-01-02 22:20:24 +01:00
hopIndex++;
2026-01-04 14:11:45 +01:00
setTimeout(animateNextHop, 700);
2026-01-02 22:20:24 +01:00
};
// Start animation
animateNextHop();
}
// Connect WebSocket when page loads
connectWebSocket();
2024-03-12 18:31:17 +13:00
< / script >
2024-03-13 17:21:18 +13:00
2025-03-16 11:02:27 +01:00
<!-- Google tag (gtag.js) -->
< script async src = "https://www.googletagmanager.com/gtag/js?id=G-2RD5193D15" > < / script >
< script >
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-2RD5193D15');
< / script >
2024-03-12 18:31:17 +13:00
< / body >
2024-04-04 11:09:24 -04:00
< / html >