3123 lines
156 KiB
HTML
3123 lines
156 KiB
HTML
<html>
|
|
<head>
|
|
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
|
<title>Meshtastic Map</title>
|
|
<meta name="title" content="Meshtastic Map">
|
|
<meta name="description" content="An interactive map of all Meshtastic nodes.">
|
|
<link rel="icon" type="image/png" href="https://meshtastic.liamcottle.net/icon.png"/>
|
|
|
|
<!-- Open Graph / Facebook -->
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:url" content="https://meshtastic.liamcottle.net">
|
|
<meta property="og:title" content="Meshtastic Map">
|
|
<meta property="og:description" content="An interactive map of all Meshtastic nodes.">
|
|
|
|
<!-- tailwind css -->
|
|
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
|
|
|
|
<!-- leaflet map -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.3/dist/leaflet.css" integrity="sha256-kLaT2GOSpHechhsozzB+flnD+zUyjE2LlfWPgU04xyI=" crossorigin="" />
|
|
<script src="https://unpkg.com/leaflet@1.9.3/dist/leaflet.js" integrity="sha256-WBkoXOwTeyKclOHuWtc+i2uENFpDZ9YPdf5Hf+D7ewM=" crossorigin=""></script>
|
|
<script src="plugins/leaflet.polylineoffset.js"></script>
|
|
<script src="plugins/leaflet.geometryutil.js"></script>
|
|
<script src="plugins/leaflet-arrowheads.js"></script>
|
|
<script src="plugins/leaflet.markercluster/leaflet.markercluster.js"></script>
|
|
<link rel="stylesheet" href="plugins/leaflet.markercluster/MarkerCluster.css"/>
|
|
<link rel="stylesheet" href="plugins/leaflet.markercluster/MarkerCluster.Default.css"/>
|
|
|
|
<!-- leaflet groupedlayercontrol -->
|
|
<script src="plugins/leaflet.groupedlayercontrol/leaflet.groupedlayercontrol.js"></script>
|
|
<link rel="stylesheet" href="plugins/leaflet.groupedlayercontrol/leaflet.groupedlayercontrol.css"/>
|
|
|
|
<!-- moment -->
|
|
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.min.js"></script>
|
|
|
|
<!-- vuejs -->
|
|
<script src="https://unpkg.com/vue@3"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
|
|
|
<!-- chart js -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
|
|
<style>
|
|
|
|
/* used to prevent ui flicker before vuejs loads */
|
|
[v-cloak] {
|
|
display: none;
|
|
}
|
|
|
|
.icon-mqtt-connected {
|
|
background-color: #16a34a;
|
|
border-radius: 25px;
|
|
border: 1px solid white;
|
|
}
|
|
|
|
.icon-mqtt-disconnected {
|
|
background-color: #2563eb;
|
|
border-radius: 25px;
|
|
border: 1px solid white;
|
|
}
|
|
|
|
.icon-offline {
|
|
background-color: #dc2626;
|
|
border-radius: 25px;
|
|
border: 1px solid white;
|
|
}
|
|
|
|
.waypoint-label {
|
|
font-size: 26px;
|
|
background-color: transparent;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.z-search {
|
|
z-index: 1001;
|
|
}
|
|
|
|
.z-sidebar {
|
|
z-index: 1002;
|
|
}
|
|
|
|
</style>
|
|
|
|
</head>
|
|
<body class="h-full bg-gray-200">
|
|
<div id="app" v-cloak>
|
|
|
|
<div class="flex flex-col h-full w-full overflow-hidden">
|
|
<div class="flex flex-col h-full">
|
|
|
|
<!-- header -->
|
|
<div class="flex bg-white p-2 border-gray-300 border-b h-16">
|
|
|
|
<!-- 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 -->
|
|
<div v-if="!isShowingMobileSearch" class="hidden sm:block my-auto mr-3">
|
|
<img class="w-10 h-10 rounded" src="icon.png"/>
|
|
</div>
|
|
|
|
<!-- app info -->
|
|
<div v-if="!isShowingMobileSearch" class="my-auto leading-tight">
|
|
<div class="font-bold">Meshtastic Map</div>
|
|
<div class="text-sm">
|
|
Created by <a class="link" target="_blank" href="https://liamcottle.com">Liam Cottle</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- search bar -->
|
|
<div class="mx-3 flex-1 relative" :class="{ 'hidden lg:block': !isShowingMobileSearch }">
|
|
<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">
|
|
|
|
<div @click="onSearchResultNodeClick(node)" class="p-2 hover:bg-gray-100 cursor-pointer" v-for="node of searchedNodes">
|
|
<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>Short Name: {{ node.short_name !== '' ? node.short_name : "-" }}</div>
|
|
<div class="text-gray-500">/</div>
|
|
<div>Hex ID: {{ node.node_id_hex }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
</template>
|
|
|
|
<template v-else>
|
|
<div class="p-2">
|
|
No results found...
|
|
</div>
|
|
</template>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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">
|
|
<a @click="isShowingInfoModal = true" href="javascript:void(0)" class="tooltip rounded-full hidden sm:block">
|
|
<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="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" />
|
|
</svg>
|
|
</div>
|
|
<div class="hidden sm:block">
|
|
<span class="tooltip-text">About</span>
|
|
</div>
|
|
</a>
|
|
<a @click="isShowingMobileSearch = true" href="javascript:void(0)" class="tooltip rounded-full block lg:hidden">
|
|
<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>
|
|
<a @click="isShowingHardwareModels = !isShowingHardwareModels" 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="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
|
</svg>
|
|
</div>
|
|
<div class="hidden sm:block">
|
|
<span class="tooltip-text">Devices</span>
|
|
</div>
|
|
</a>
|
|
<a href="#" class="tooltip rounded-full hidden lg:block" onclick="goToRandomNode()">
|
|
<div id="random-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="M18 4l3 3l-3 3"></path>
|
|
<path d="M18 20l3 -3l-3 -3"></path>
|
|
<path d="M3 7h3a5 5 0 0 1 5 5a5 5 0 0 0 5 5h5"></path>
|
|
<path d="M21 7h-5a4.978 4.978 0 0 0 -3 1m-4 8a4.984 4.984 0 0 1 -3 1h-3"></path>
|
|
</svg>
|
|
</div>
|
|
<div class="hidden sm:block">
|
|
<span class="tooltip-text">Random</span>
|
|
</div>
|
|
</a>
|
|
<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>
|
|
<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>
|
|
|
|
</div>
|
|
|
|
<!-- map -->
|
|
<div id="map" style="width:100%;height:100%;"></div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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">
|
|
<div v-show="isShowingInfoModal" @click="dismissInfoModal" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
|
|
</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">
|
|
<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">
|
|
<div class="relative flex">
|
|
|
|
<!-- close button -->
|
|
<div class="absolute top-0 right-0">
|
|
<div class="h-7">
|
|
<a href="javascript:void(0)" class="rounded-full" @click="dismissInfoModal">
|
|
<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>
|
|
<div class="w-full mx-auto text-center space-x-1 mt-2">
|
|
|
|
<a target="_blank" href="https://twitter.com/liamcottle" title="Twitter" class="raise inline-flex items-center p-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-[#1da1f2] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1da1f2]">
|
|
<svg role="img" class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path fill="currentColor" d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"></path>
|
|
</svg>
|
|
</a>
|
|
|
|
<a target="_blank" href="https://github.com/liamcottle/meshtastic-map" title="GitHub" class="raise inline-flex items-center p-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-[#333333] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#333333]">
|
|
<svg role="img" class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"></path>
|
|
</svg>
|
|
</a>
|
|
|
|
<a target="_blank" href="https://discord.gg/K55zeZyHKK" title="Discord" class="raise inline-flex items-center p-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-[#5865f2] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5865f2]">
|
|
<svg role="img" class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"></path>
|
|
</svg>
|
|
</a>
|
|
|
|
<a target="_blank" href="https://paypal.me/liamcarncottle" title="PayPal" class="raise inline-flex items-center p-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-[#0070ba] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#0070ba]">
|
|
<svg role="img" class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 48 48">
|
|
<path fill="#fff" fill-opacity=".45" d="M16.13 39.115H8.367a1.041 1.041 0 0 1-1.026-1.2l5.234-33.176a1.279 1.279 0 0 1 1.261-1.079h13.335c6.315 0 10.909 4.592 10.8 10.157 3.735 1.95 5.915 5.91 5.234 10.247a12.548 12.548 0 0 1-12.39 10.62H26.89a1.275 1.275 0 0 0-1.261 1.08L23.87 46.9a1.276 1.276 0 0 1-1.262 1.079H15.94a1.04 1.04 0 0 1-1.026-1.199l1.215-7.664v-.002Z"></path>
|
|
<path fill="#fff" fill-opacity=".45" d="M37.973 13.817a11.668 11.668 0 0 0-5.441-1.294H21.414a1.277 1.277 0 0 0-1.261 1.08l-2.098 13.293.006-.035a1.28 1.28 0 0 1 1.256-1.042h6.144a12.553 12.553 0 0 0 12.39-10.62c.071-.457.113-.92.122-1.382Z"></path>
|
|
<path fill="#fff" d="M16.133 39.115H8.368a1.041 1.041 0 0 1-1.026-1.2l5.234-33.176a1.279 1.279 0 0 1 1.261-1.079h13.335c6.315 0 10.909 4.592 10.801 10.157a11.67 11.67 0 0 0-5.441-1.294H21.414a1.277 1.277 0 0 0-1.261 1.08l-2.098 13.293.006-.035-1.928 12.254Z"></path>
|
|
</svg>
|
|
</a>
|
|
|
|
<a target="_blank" href="https://liamcottle.com/contact" title="Email" class="raise inline-flex items-center p-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
|
</svg>
|
|
</a>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- welcome -->
|
|
<div class="bg-green-100 rounded p-2 border border-green-200">
|
|
👋 Welcome to my open source map of Meshtastic nodes heard on the public MQTT server.
|
|
</div>
|
|
|
|
<!-- features -->
|
|
<div>
|
|
<div class="font-bold mb-2">Features</div>
|
|
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
|
<ul class="list-disc list-inside">
|
|
<li>The map shows nodes that have sent a valid position to MQTT.</li>
|
|
<li>Position packets must be unencrypted, or encrypted with the default key.</li>
|
|
<li>Use the search bar to find nodes by ID or name.</li>
|
|
<li>Hover over nodes (on desktop) to see basic details.</li>
|
|
<li>Click a node to see info such as telemetry graphs and traceroutes.</li>
|
|
<li>Use the top right layers panel to show neighbours and waypoints.</li>
|
|
<li>Use the settings button to configure the map to your liking.</li>
|
|
<li>Have a feature request, or found a bug? <a class="link" target="_blank" href="https://github.com/liamcottle/meshtastic-map">Open an issue</a> on GitHub.</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- faq -->
|
|
<div>
|
|
<div class="font-bold mb-2">FAQ</div>
|
|
<div class="space-y-2">
|
|
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
|
<div class="font-semibold">How do I add my node to the map?</div>
|
|
<div>Your node, or a node that hears your node must uplink to MQTT.</div>
|
|
<div>Your position packet must be unencrypted, or encrypted with the default key.</div>
|
|
</div>
|
|
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
|
<div class="font-semibold">How do I remove my node from the map?</div>
|
|
<div>Nodes that have not been heard for 7 days are automatically removed.</div>
|
|
<div>Disable position reporting in your node to prevent it coming back.</div>
|
|
<div>Use custom encryption keys so the public can't see your position data.</div>
|
|
</div>
|
|
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
|
<div class="font-semibold">How do I see neighbours a node heard?</div>
|
|
<div>Open the top right layers panel and enable neighbours.</div>
|
|
<div>Some neighbours are from MQTT, this is patched in latest firmware.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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">
|
|
<a href="javascript:void(0)" @click="dismissInfoModal">
|
|
<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>
|
|
|
|
<!-- hardware models 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="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">
|
|
<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">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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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">
|
|
<img class="h-20 w-20 rounded object-contain" :src="`/images/devices/${hardwareModel.hardware_model_name}.png`" alt="" onerror="if(this.src != 'https://placehold.co/512x512?text=No+Image') this.src = 'https://placehold.co/512x512?text=No+Image';">
|
|
</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>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
</div>
|
|
|
|
<!-- 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">
|
|
<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">Node Info</h2>
|
|
<h3 class="text-sm">{{ selectedNode.long_name }}</h3>
|
|
</div>
|
|
<div class="my-auto ml-3 flex h-7 items-center">
|
|
<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>
|
|
|
|
<div class="overflow-y-auto">
|
|
|
|
<div class="flex flex-col my-2">
|
|
<div class="mx-auto">
|
|
<img class="h-48 w-48 rounded object-contain" :src="`/images/devices/${selectedNode.hardware_model_name}.png`" alt="" onerror="if(this.src != 'https://placehold.co/512x512?text=No+Image') this.src = 'https://placehold.co/512x512?text=No+Image';">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- details -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2 font-semibold">Details</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">Long Name</p>
|
|
<p class="truncate text-sm text-gray-700">{{ selectedNode.long_name }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">Short Name</p>
|
|
<p class="truncate text-sm text-gray-700">{{ selectedNode.short_name }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">Role</p>
|
|
<p class="truncate text-sm text-gray-700">{{ selectedNode.role_name }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">Hardware</p>
|
|
<p class="truncate text-sm text-gray-700">{{ selectedNode.hardware_model_name }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">Firmware</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.firmware_version">{{ selectedNode.firmware_version }}</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- lora config -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2 font-semibold">LoRa Config</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">Region</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.region_name">{{ selectedNode.region_name }}</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">Frequency</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.region_name">{{ getRegionFrequencyRange(selectedNode.region_name) }}</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">Modem Preset</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.modem_preset_name">{{ selectedNode.modem_preset_name }}</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">Has Default Channel</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.has_default_channel != null">{{ selectedNode.has_default_channel ? "Yes" : "No" }}</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- position -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2 font-semibold">Position</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">Latitude</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.latitude">{{ selectedNode.latitude }}</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">Longitude</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.longitude">{{ selectedNode.longitude }}</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">Altitude</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.altitude">{{ selectedNode.altitude }}m</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- telemetry -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2 font-semibold">Telemetry</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<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="w-full">
|
|
<p class="truncate text-sm font-medium text-gray-900">Battery Level</p>
|
|
<p class="truncate 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 class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<canvas id="batteryLevelChart" style="width:150px;height:50px;"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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="w-full">
|
|
<p class="truncate text-sm font-medium text-gray-900">Voltage</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.voltage">{{ Number(selectedNode.voltage).toFixed(2) }}V</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<canvas id="voltageChart" style="width:150px;height:50px;"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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="w-full">
|
|
<p class="truncate text-sm font-medium text-gray-900">Channel Utilization</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.channel_utilization">{{ Number(selectedNode.channel_utilization).toFixed(2) }}%</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<canvas id="channelUtilizationChart" style="width:150px;height:50px;"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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="w-full">
|
|
<p class="truncate text-sm font-medium text-gray-900">Air Util Tx</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.air_util_tx">{{ Number(selectedNode.air_util_tx).toFixed(2) }}%</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<canvas id="airUtilTxChart" style="width:150px;height:50px;"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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="w-full">
|
|
<p class="truncate text-sm font-medium text-gray-900">Uptime</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.uptime_seconds">{{ selectedNode.uptime_seconds }} seconds</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- mqtt -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2">
|
|
<div class="font-semibold">MQTT</div>
|
|
<div class="text-sm text-gray-600">Topics this node sent packets to</div>
|
|
</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>
|
|
|
|
<!-- 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>
|
|
<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>
|
|
</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>
|
|
|
|
<!-- other -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2 font-semibold">Other</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">ID</p>
|
|
<p class="truncate text-sm text-gray-700">{{ selectedNode.node_id }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<li>
|
|
<div class="group 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">Hex ID</p>
|
|
<p class="truncate text-sm text-gray-700">{{ selectedNode.node_id_hex }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">First Seen</p>
|
|
<p class="truncate text-sm text-gray-700">{{ moment(new Date(selectedNode.created_at)).fromNow() }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">Last Seen</p>
|
|
<p class="truncate text-sm text-gray-700">{{ moment(new Date(selectedNode.updated_at)).fromNow() }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">Neighbours Updated</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.neighbours_updated_at">{{ moment(new Date(selectedNode.neighbours_updated_at)).fromNow() }}</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<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">
|
|
<p class="truncate text-sm font-medium text-gray-900">Position Updated</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.position_updated_at">{{ moment(new Date(selectedNode.position_updated_at)).fromNow() }}</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- share -->
|
|
<div>
|
|
<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>
|
|
<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">
|
|
<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)">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
</div>
|
|
|
|
<!-- traceroute info modal -->
|
|
<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>
|
|
<h3 class="text-sm">{{ moment(new Date(selectedTraceRoute.updated_at)).fromNow() }} - {{ selectedTraceRoute.route.length }} hops {{ selectedTraceRoute.channel_id ? `on ${selectedTraceRoute.channel_id}` : '' }}</h3>
|
|
</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">
|
|
<ul role="list" class="space-y-6">
|
|
|
|
<!-- node that initiated traceroute -->
|
|
<li :onclick="`goToNode(${selectedTraceRoute.to})`" class="relative flex gap-x-4">
|
|
<div class="absolute left-0 top-0 flex w-6 justify-center top-6 -bottom-6">
|
|
<div class="w-px bg-gray-200"></div>
|
|
</div>
|
|
<div class="my-auto relative flex h-6 w-6 flex-none items-center justify-center bg-white">
|
|
<div class="h-4 w-4 rounded-full bg-gray-100 ring-1 ring-gray-300"></div>
|
|
</div>
|
|
<div class="flex-auto py-0.5 text-sm leading-5 text-gray-500">
|
|
<div class="font-medium text-gray-900">{{ findNodeById(selectedTraceRoute.to)?.long_name || '???' }}</div>
|
|
<div>Hex ID: !{{ Number(selectedTraceRoute.to).toString(16) }}</div>
|
|
<div>Started the traceroute</div>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- middleman nodes -->
|
|
<li :onclick="`goToNode(${route})`" v-for="route of selectedTraceRoute.route" class="relative flex gap-x-4">
|
|
<div class="absolute left-0 top-0 flex w-6 justify-center -bottom-6">
|
|
<div class="w-px bg-gray-200"></div>
|
|
</div>
|
|
<div class="my-auto relative flex h-6 w-6 flex-none items-center justify-center bg-white">
|
|
<div class="h-4 w-4 rounded-full bg-gray-100 ring-1 ring-gray-300"></div>
|
|
</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>
|
|
|
|
<!-- node that replied to traceroute -->
|
|
<li :onclick="`goToNode(${selectedTraceRoute.from})`" v-if="selectedTraceRoute.from" class="relative flex gap-x-4">
|
|
<div class="absolute left-0 top-0 flex w-6 justify-center -bottom-6">
|
|
<div class="w-px bg-gray-200"></div>
|
|
</div>
|
|
<div class="my-auto relative flex h-6 w-6 flex-none items-center justify-center bg-white">
|
|
<div class="h-4 w-4 rounded-full bg-gray-100 ring-1 ring-gray-300"></div>
|
|
</div>
|
|
<div class="flex-auto py-0.5 text-sm leading-5 text-gray-500">
|
|
<div class="font-medium text-gray-900">{{ findNodeById(selectedTraceRoute.from)?.long_name || '???' }}</div>
|
|
<div>Hex ID: !{{ Number(selectedTraceRoute.from).toString(16) }}</div>
|
|
<div>Replied to traceroute</div>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- node that gated traceroute to mqtt -->
|
|
<li :onclick="`goToNode(${selectedTraceRoute.gateway_id})`" v-if="selectedTraceRoute.gateway_id" class="relative flex gap-x-4">
|
|
<div class="absolute left-0 top-0 flex w-6 justify-center h-6">
|
|
<div class="w-px bg-gray-200"></div>
|
|
</div>
|
|
<div class="my-auto relative flex h-6 w-6 flex-none items-center justify-center bg-white">
|
|
<svg class="h-6 w-6 text-green-600" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clip-rule="evenodd" />
|
|
</svg>
|
|
</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>
|
|
|
|
</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>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
</div>
|
|
|
|
<!-- settings modal -->
|
|
<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">
|
|
|
|
<!-- configNodesMaxAgeInSeconds -->
|
|
<div class="p-2">
|
|
<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>
|
|
<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>
|
|
|
|
<!-- 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>
|
|
<option value="7200">2 hours</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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- configNeighboursMaxDistanceInMeters -->
|
|
<div class="p-2">
|
|
<label class="block text-sm font-medium text-gray-900">Neighbours Max Distance (meters)</label>
|
|
<div class="text-xs text-gray-600 mb-2">Neighbours further than this are hidden. Reload to update map.</div>
|
|
<input type="number" v-model="configNeighboursMaxDistanceInMeters" 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>
|
|
|
|
<!-- configZoomLevelGoToNode -->
|
|
<div class="p-2">
|
|
<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>
|
|
<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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
</div>
|
|
|
|
<!-- node neighbours 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="selectedNodeToShowNeighbours != null" class="fixed left-0 right-0 bottom-0">
|
|
<div v-if="selectedNodeToShowNeighbours != 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">
|
|
<div class="p-2">
|
|
<div class="flex items-start justify-between">
|
|
<div>
|
|
<h2 class="font-bold">{{ selectedNodeToShowNeighbours.short_name }} Neighbours</h2>
|
|
<h3 v-if="selectedNodeToShowNeighboursType === 'we_heard'" class="text-sm">Nodes heard by {{ selectedNodeToShowNeighbours.short_name }}</h3>
|
|
<h3 v-if="selectedNodeToShowNeighboursType === 'heard_us'" class="text-sm">Nodes that heard {{ selectedNodeToShowNeighbours.short_name }}</h3>
|
|
</div>
|
|
<div class="my-auto ml-3 flex h-7 items-center">
|
|
<a href="javascript:void(0)" class="rounded-full" @click="dismissShowingNodeNeighbours">
|
|
<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>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
|
|
function getConfigHasSeenInfoModal() {
|
|
return localStorage.getItem("config_has_seen_info_modal") === "true";
|
|
}
|
|
|
|
function setConfigHasSeenInfoModal(value) {
|
|
return localStorage.setItem("config_has_seen_info_modal", value);
|
|
}
|
|
|
|
function getConfigAutoUpdatePositionInUrl() {
|
|
return localStorage.getItem("config_auto_update_position_in_url") === "true";
|
|
}
|
|
|
|
function setConfigAutoUpdatePositionInUrl(value) {
|
|
return localStorage.setItem("config_auto_update_position_in_url", value);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function getConfigMapSelectedTileLayer() {
|
|
return localStorage.getItem("config_map_selected_tile_layer") || "OpenStreetMap";
|
|
}
|
|
|
|
function setConfigMapSelectedTileLayer(layer) {
|
|
return localStorage.setItem("config_map_selected_tile_layer", layer);
|
|
}
|
|
|
|
function getConfigMapEnabledOverlayLayers() {
|
|
|
|
try {
|
|
const value = localStorage.getItem("config_map_enabled_overlay_layers");
|
|
if(value){
|
|
return JSON.parse(value);
|
|
}
|
|
} catch(e) {}
|
|
|
|
// overlays enabled by default
|
|
return ["Legend"];
|
|
|
|
}
|
|
|
|
function setConfigMapEnabledOverlayLayers(layers) {
|
|
return localStorage.setItem("config_map_enabled_overlay_layers", JSON.stringify(layers));
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
|
|
function getConfigNeighboursMaxDistanceInMeters() {
|
|
const value = localStorage.getItem("config_neighbours_max_distance_in_meters");
|
|
return value != null ? parseInt(value) : null;
|
|
}
|
|
|
|
function setConfigNeighboursMaxDistanceInMeters(value) {
|
|
return localStorage.setItem("config_neighbours_max_distance_in_meters", value);
|
|
}
|
|
|
|
function getConfigZoomLevelGoToNode() {
|
|
const value = localStorage.getItem("config_zoom_level_go_to_node");
|
|
const parsedValue = value != null ? parseInt(value) : null;
|
|
return parsedValue || 15;
|
|
}
|
|
|
|
function setConfigZoomLevelGoToNode(value) {
|
|
return localStorage.setItem("config_zoom_level_go_to_node", value);
|
|
}
|
|
|
|
function isMobile() {
|
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
}
|
|
|
|
</script>
|
|
|
|
<script>
|
|
Vue.createApp({
|
|
data() {
|
|
return {
|
|
|
|
configNodesMaxAgeInSeconds: window.getConfigNodesMaxAgeInSeconds(),
|
|
configNodesOfflineAgeInSeconds: window.getConfigNodesOfflineAgeInSeconds(),
|
|
configWaypointsMaxAgeInSeconds: window.getConfigWaypointsMaxAgeInSeconds(),
|
|
configNeighboursMaxDistanceInMeters: window.getConfigNeighboursMaxDistanceInMeters(),
|
|
configZoomLevelGoToNode: window.getConfigZoomLevelGoToNode(),
|
|
configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(),
|
|
configEnableMapAnimations: window.getConfigEnableMapAnimations(),
|
|
|
|
isShowingHardwareModels: false,
|
|
hardwareModelStats: null,
|
|
|
|
isShowingInfoModal: this.shouldShowInfoModal(),
|
|
isShowingMobileSearch: false,
|
|
isShowingSettings: false,
|
|
|
|
nodes: [],
|
|
searchText: "",
|
|
|
|
selectedNode: null,
|
|
selectedNodeDeviceMetrics: [],
|
|
selectedNodeMqttMetrics: [],
|
|
selectedNodeTraceroutes: [],
|
|
|
|
selectedTraceRoute: null,
|
|
|
|
selectedNodeToShowNeighbours: null,
|
|
selectedNodeToShowNeighboursType: null,
|
|
|
|
moment: window.moment,
|
|
|
|
};
|
|
},
|
|
mounted: function() {
|
|
|
|
// load data
|
|
this.loadHardwareModelStats();
|
|
|
|
// handle map click callback from outside of vue
|
|
window._onMapClick = () => {
|
|
this.searchText = "";
|
|
this.isShowingMobileSearch = false;
|
|
};
|
|
|
|
// handle node callback from outside of vue
|
|
window._onNodeClick = (node) => {
|
|
this.selectedNode = node;
|
|
this.loadNodeDeviceMetrics(node.node_id);
|
|
this.loadNodeMqttMetrics(node.node_id);
|
|
this.loadNodeTraceroutes(node.node_id);
|
|
};
|
|
|
|
// handle node callback from outside of vue
|
|
window._onShowNodeNeighboursWeHeardClick = (node) => {
|
|
this.selectedNodeToShowNeighbours = node;
|
|
this.selectedNodeToShowNeighboursType = 'we_heard';
|
|
};
|
|
|
|
// handle node callback from outside of vue
|
|
window._onShowNodeNeighboursHeardUsClick = (node) => {
|
|
this.selectedNodeToShowNeighbours = node;
|
|
this.selectedNodeToShowNeighboursType = 'heard_us';
|
|
};
|
|
|
|
// handle nodes updated callback from outside of vue
|
|
window._onNodesUpdated = (nodes) => {
|
|
this.nodes = nodes;
|
|
};
|
|
|
|
},
|
|
methods: {
|
|
shouldShowInfoModal: function() {
|
|
return !window.getConfigHasSeenInfoModal()
|
|
&& !window.isMobile();
|
|
},
|
|
loadHardwareModelStats: function() {
|
|
window.axios.get('/api/v1/stats/hardware-models').then((response) => {
|
|
this.hardwareModelStats = response.data.hardware_model_stats;
|
|
}).catch((error) => {
|
|
// do nothing
|
|
});
|
|
},
|
|
loadNodeDeviceMetrics: function(nodeId) {
|
|
window.axios.get(`/api/v1/nodes/${nodeId}/device-metrics`, {
|
|
params: {
|
|
count: 100,
|
|
},
|
|
}).then((response) => {
|
|
// reverse response, as it's newest to oldest, but we want oldest to newest
|
|
this.selectedNodeDeviceMetrics = response.data.device_metrics.reverse();
|
|
this.renderDeviceMetricCharts();
|
|
}).catch(() => {
|
|
this.selectedNodeDeviceMetrics = [];
|
|
this.renderDeviceMetricCharts();
|
|
});
|
|
},
|
|
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
|
|
});
|
|
},
|
|
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
|
|
});
|
|
},
|
|
renderDeviceMetricCharts: function() {
|
|
this.updateBatteryLevelChart();
|
|
this.updateVoltageChart();
|
|
this.updateChannelUtilizationChart();
|
|
this.updateAirUtilTxChart();
|
|
},
|
|
updateBatteryLevelChart: function() {
|
|
|
|
// get chart context
|
|
const ctx = window.document.getElementById('batteryLevelChart')?.getContext('2d');
|
|
if(!ctx){
|
|
return;
|
|
}
|
|
|
|
// get battery metrics
|
|
const deviceMetrics = this.selectedNodeDeviceMetrics.filter((deviceMetric) => {
|
|
return deviceMetric.battery_level != null;
|
|
});
|
|
|
|
new window.Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: deviceMetrics.map((deviceMetric) => {
|
|
return new Date(deviceMetric.created_at).toLocaleTimeString();
|
|
}),
|
|
datasets: [{
|
|
label: 'Battery Level',
|
|
data: deviceMetrics.map((deviceMetric) => {
|
|
return deviceMetric.battery_level;
|
|
}),
|
|
borderColor: '#22c55e',
|
|
backgroundColor: '#e5e7eb',
|
|
fill: true,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
scales: {
|
|
x: {
|
|
display: false, // Hide x-axis labels
|
|
},
|
|
y: {
|
|
display: false, // Hide y-axis labels
|
|
min: 0,
|
|
max: 101, // 101 is "Plugged In", need to include for tooltip to work
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false, // Hide the legend
|
|
},
|
|
tooltip: {
|
|
yAlign: 'top',
|
|
intersect: false,
|
|
displayColors: false,
|
|
callbacks: {
|
|
label: (item) => item.formattedValue + "%",
|
|
},
|
|
},
|
|
},
|
|
elements: {
|
|
point: {
|
|
radius: 0, // Set the radius to 0 to hide the dots
|
|
},
|
|
},
|
|
}
|
|
});
|
|
|
|
},
|
|
updateVoltageChart: function() {
|
|
|
|
// get chart context
|
|
const ctx = window.document.getElementById('voltageChart')?.getContext('2d');
|
|
if(!ctx){
|
|
return;
|
|
}
|
|
|
|
// get voltage metrics
|
|
const voltageMetrics = this.selectedNodeDeviceMetrics.filter((deviceMetric) => {
|
|
return deviceMetric.voltage != null;
|
|
});
|
|
|
|
new window.Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: voltageMetrics.map((deviceMetric) => {
|
|
return new Date(deviceMetric.created_at).toLocaleTimeString();
|
|
}),
|
|
datasets: [{
|
|
label: 'Voltage',
|
|
data: voltageMetrics.map((deviceMetric) => {
|
|
return deviceMetric.voltage;
|
|
}),
|
|
borderColor: '#22c55e',
|
|
backgroundColor: '#e5e7eb',
|
|
fill: true,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
scales: {
|
|
x: {
|
|
display: false, // Hide x-axis labels
|
|
},
|
|
y: {
|
|
display: false, // Hide y-axis labels
|
|
min: 0,
|
|
max: 5,
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false, // Hide the legend
|
|
},
|
|
tooltip: {
|
|
yAlign: 'top',
|
|
intersect: false,
|
|
displayColors: false,
|
|
callbacks: {
|
|
label: (item) => item.formattedValue + "V",
|
|
},
|
|
},
|
|
},
|
|
elements: {
|
|
point: {
|
|
radius: 0, // Set the radius to 0 to hide the dots
|
|
},
|
|
},
|
|
}
|
|
});
|
|
|
|
},
|
|
updateChannelUtilizationChart: function() {
|
|
|
|
// get chart context
|
|
const ctx = window.document.getElementById('channelUtilizationChart')?.getContext('2d');
|
|
if(!ctx){
|
|
return;
|
|
}
|
|
|
|
// get channel utilization metrics
|
|
const channelUtilizationMetrics = this.selectedNodeDeviceMetrics.filter((deviceMetric) => {
|
|
return deviceMetric.channel_utilization != null;
|
|
});
|
|
|
|
new window.Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: channelUtilizationMetrics.map((deviceMetric) => {
|
|
return new Date(deviceMetric.created_at).toLocaleTimeString();
|
|
}),
|
|
datasets: [{
|
|
label: 'Channel Utilization',
|
|
data: channelUtilizationMetrics.map((deviceMetric) => {
|
|
return deviceMetric.channel_utilization;
|
|
}),
|
|
borderColor: '#22c55e',
|
|
backgroundColor: '#e5e7eb',
|
|
fill: true,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
scales: {
|
|
x: {
|
|
display: false, // Hide x-axis labels
|
|
},
|
|
y: {
|
|
display: false, // Hide y-axis labels
|
|
min: -5,
|
|
max: 100,
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false, // Hide the legend
|
|
},
|
|
tooltip: {
|
|
yAlign: 'top',
|
|
intersect: false,
|
|
displayColors: false,
|
|
callbacks: {
|
|
label: (item) => item.formattedValue + "%",
|
|
},
|
|
},
|
|
},
|
|
elements: {
|
|
point: {
|
|
radius: 0, // Set the radius to 0 to hide the dots
|
|
},
|
|
},
|
|
}
|
|
});
|
|
|
|
},
|
|
updateAirUtilTxChart: function() {
|
|
|
|
// get chart context
|
|
const ctx = window.document.getElementById('airUtilTxChart')?.getContext('2d');
|
|
if(!ctx){
|
|
return;
|
|
}
|
|
|
|
// get air util tx metrics
|
|
const airUtilTxMetrics = this.selectedNodeDeviceMetrics.filter((deviceMetric) => {
|
|
return deviceMetric.air_util_tx != null;
|
|
});
|
|
|
|
new window.Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: airUtilTxMetrics.map((deviceMetric) => {
|
|
return new Date(deviceMetric.created_at).toLocaleTimeString();
|
|
}),
|
|
datasets: [{
|
|
label: 'Air Util Tx',
|
|
data: airUtilTxMetrics.map((deviceMetric) => {
|
|
return deviceMetric.air_util_tx;
|
|
}),
|
|
borderColor: '#22c55e',
|
|
backgroundColor: '#e5e7eb',
|
|
fill: true,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
scales: {
|
|
x: {
|
|
display: false, // Hide x-axis labels
|
|
},
|
|
y: {
|
|
display: false, // Hide y-axis labels
|
|
min: -5,
|
|
max: 100,
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false, // Hide the legend
|
|
},
|
|
tooltip: {
|
|
yAlign: 'top',
|
|
intersect: false,
|
|
displayColors: false,
|
|
callbacks: {
|
|
label: (item) => item.formattedValue + "%",
|
|
},
|
|
},
|
|
},
|
|
elements: {
|
|
point: {
|
|
radius: 0, // Set the radius to 0 to hide the dots
|
|
},
|
|
},
|
|
}
|
|
});
|
|
|
|
},
|
|
showTraceRoute: function(traceroute) {
|
|
this.selectedTraceRoute = traceroute;
|
|
},
|
|
findNodeById: function(id) {
|
|
return window.findNodeById(id);
|
|
},
|
|
onSearchResultNodeClick: function(node) {
|
|
|
|
// clear search
|
|
this.searchText = "";
|
|
|
|
// hide search
|
|
this.isShowingMobileSearch = false;
|
|
|
|
// go to node
|
|
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);
|
|
|
|
},
|
|
dismissInfoModal: function() {
|
|
this.isShowingInfoModal = false;
|
|
window.setConfigHasSeenInfoModal(true);
|
|
},
|
|
getRegionFrequencyRange: function(regionName) {
|
|
return window.getRegionFrequencyRange(regionName);
|
|
},
|
|
getShareLinkForNode: function(nodeId) {
|
|
return window.location.origin + `/?node_id=${nodeId}`;
|
|
},
|
|
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);
|
|
|
|
// tell use we copied it
|
|
alert("Copied to clipboard!");
|
|
|
|
},
|
|
dismissShowingNodeNeighbours: function() {
|
|
window._onHideNodeNeighboursClick();
|
|
this.selectedNodeToShowNeighbours = null;
|
|
},
|
|
},
|
|
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);
|
|
});
|
|
|
|
return nodes;
|
|
|
|
},
|
|
},
|
|
watch: {
|
|
configNodesMaxAgeInSeconds() {
|
|
window.setConfigNodesMaxAgeInSeconds(this.configNodesMaxAgeInSeconds);
|
|
},
|
|
configNodesOfflineAgeInSeconds() {
|
|
window.setConfigNodesOfflineAgeInSeconds(this.configNodesOfflineAgeInSeconds);
|
|
},
|
|
configWaypointsMaxAgeInSeconds() {
|
|
window.setConfigWaypointsMaxAgeInSeconds(this.configWaypointsMaxAgeInSeconds);
|
|
},
|
|
configNeighboursMaxDistanceInMeters() {
|
|
window.setConfigNeighboursMaxDistanceInMeters(this.configNeighboursMaxDistanceInMeters);
|
|
},
|
|
configZoomLevelGoToNode() {
|
|
window.setConfigZoomLevelGoToNode(this.configZoomLevelGoToNode);
|
|
},
|
|
configAutoUpdatePositionInUrl() {
|
|
window.setConfigAutoUpdatePositionInUrl(this.configAutoUpdatePositionInUrl);
|
|
},
|
|
configEnableMapAnimations() {
|
|
window.setConfigEnableMapAnimations(this.configEnableMapAnimations);
|
|
},
|
|
},
|
|
}).mount('#app');
|
|
</script>
|
|
|
|
<script>
|
|
|
|
// global state
|
|
var nodes = [];
|
|
var nodeMarkers = {};
|
|
var selectedNodeOutlineCircle = null;
|
|
var waypoints = [];
|
|
|
|
// 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
|
|
];
|
|
|
|
// create map positioned over AU and NZ
|
|
var map = L.map('map', {
|
|
maxBounds: bounds,
|
|
}).setView([
|
|
-15,
|
|
150,
|
|
], 2);
|
|
|
|
// remove leaflet link
|
|
map.attributionControl.setPrefix('');
|
|
|
|
var openStreetMapTileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 22, // increase from 18 to 22
|
|
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>',
|
|
});
|
|
|
|
var esriWorldImageryTileLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
|
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>'
|
|
});
|
|
|
|
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>'
|
|
});
|
|
|
|
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>'
|
|
});
|
|
|
|
var tileLayers = {
|
|
"OpenStreetMap": openStreetMapTileLayer,
|
|
"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);
|
|
|
|
// create layer groups
|
|
var nodesLayerGroup = new L.LayerGroup();
|
|
var neighboursLayerGroup = new L.LayerGroup();
|
|
var nodeNeighboursLayerGroup = new L.LayerGroup();
|
|
var nodesClusteredLayerGroup = L.markerClusterGroup({
|
|
showCoverageOnHover: false,
|
|
disableClusteringAtZoom: 10, // zoom level where goToNode zooms to
|
|
});
|
|
var waypointsLayerGroup = new L.LayerGroup();
|
|
|
|
// create icons
|
|
var iconMqttConnected = L.divIcon({
|
|
className: 'icon-mqtt-connected',
|
|
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
|
});
|
|
|
|
var iconMqttDisconnected = L.divIcon({
|
|
className: 'icon-mqtt-disconnected',
|
|
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
|
});
|
|
|
|
var iconOffline = L.divIcon({
|
|
className: 'icon-offline',
|
|
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
|
});
|
|
|
|
// create legend
|
|
var legendLayerGroup = new L.LayerGroup();
|
|
var legend = L.control({position: 'bottomleft'});
|
|
legend.onAdd = function (map) {
|
|
var div = L.DomUtil.create('div', 'leaflet-control-layers');
|
|
div.style.backgroundColor = 'white';
|
|
div.style.padding = '12px';
|
|
div.innerHTML = `<div style="margin-bottom:6px;"><strong>Legend</strong></div>`
|
|
+ `<div style="display:flex"><div class="icon-mqtt-connected" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> MQTT Connected</div>`
|
|
+ `<div style="display:flex"><div class="icon-mqtt-disconnected" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> MQTT Disconnected</div>`
|
|
+ `<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>`;
|
|
return div;
|
|
};
|
|
|
|
// handle baselayerchange to update tile layer preference
|
|
map.on('baselayerchange', function(event) {
|
|
setConfigMapSelectedTileLayer(event.name);
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
});
|
|
|
|
// add layers to control ui
|
|
L.control.groupedLayers(tileLayers, {
|
|
"Nodes": {
|
|
"All": nodesLayerGroup,
|
|
"Clustered": nodesClusteredLayerGroup,
|
|
"None": new L.LayerGroup(),
|
|
},
|
|
"Overlays": {
|
|
"Legend": legendLayerGroup,
|
|
"Neighbours": neighboursLayerGroup,
|
|
"Waypoints": waypointsLayerGroup,
|
|
},
|
|
}, {
|
|
// make the "Nodes" group exclusive (use radio inputs instead of checkbox)
|
|
exclusiveGroups: ["Nodes"],
|
|
}).addTo(map);
|
|
|
|
// enable base layers
|
|
nodesClusteredLayerGroup.addTo(map);
|
|
|
|
// enable overlay layers based on config
|
|
const enabledOverlayLayers = getConfigMapEnabledOverlayLayers();
|
|
if(enabledOverlayLayers.includes("Legend")){
|
|
legendLayerGroup.addTo(map);
|
|
}
|
|
if(enabledOverlayLayers.includes("Neighbours")){
|
|
neighboursLayerGroup.addTo(map);
|
|
}
|
|
if(enabledOverlayLayers.includes("Waypoints")){
|
|
waypointsLayerGroup.addTo(map);
|
|
}
|
|
|
|
// update config when map overlay is added
|
|
map.on('overlayadd', function(event) {
|
|
const layerName = event.name;
|
|
const enabledOverlayLayers = getConfigMapEnabledOverlayLayers();
|
|
if(!enabledOverlayLayers.includes(layerName)){
|
|
enabledOverlayLayers.push(layerName);
|
|
}
|
|
setConfigMapEnabledOverlayLayers(enabledOverlayLayers);
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
|
|
// handle map clicks
|
|
map.on('click', function() {
|
|
|
|
// remove outline when map clicked
|
|
clearNodeOutline();
|
|
|
|
// send callback to vue
|
|
window._onMapClick();
|
|
|
|
});
|
|
|
|
// 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();
|
|
|
|
});
|
|
|
|
function isValidLatLng(lat, lng) {
|
|
|
|
if(isNaN(lat) || isNaN(lng)){
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
function findNodeById(id) {
|
|
|
|
// find node by id
|
|
var node = nodes.find((node) => node.node_id.toString() === id.toString());
|
|
if(node){
|
|
return node;
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
function findNodeMarkerById(id) {
|
|
|
|
// find node marker by id
|
|
var nodeMarker = nodeMarkers[id];
|
|
if(nodeMarker){
|
|
return nodeMarker;
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
function goToNode(id, animate, zoom){
|
|
|
|
// find node
|
|
var node = findNodeById(id);
|
|
if(!node){
|
|
alert("Could not find node: " + id);
|
|
return false;
|
|
}
|
|
|
|
// find node marker by id
|
|
var nodeMarker = findNodeMarkerById(id);
|
|
if(!nodeMarker){
|
|
alert("Could not find a position for node: " + id);
|
|
return false;
|
|
}
|
|
|
|
// close all popups and tooltips
|
|
closeAllPopups();
|
|
closeAllTooltips();
|
|
|
|
// select node
|
|
showNodeOutline(id);
|
|
|
|
// fly to node marker
|
|
const shouldAnimate = animate != null ? animate : true;
|
|
map.flyTo(nodeMarker.getLatLng(), zoom || getConfigZoomLevelGoToNode(), {
|
|
animate: getConfigEnableMapAnimations() ? shouldAnimate : false,
|
|
});
|
|
|
|
// open tooltip for node
|
|
map.openTooltip(getTooltipContentForNode(node), nodeMarker.getLatLng(), {
|
|
interactive: true, // allow clicking buttons inside tooltip
|
|
permanent: true, // don't auto dismiss when clicking buttons inside tooltip
|
|
});
|
|
|
|
// successfully went to node
|
|
return true;
|
|
|
|
}
|
|
|
|
function goToRandomNode() {
|
|
if(nodes.length > 0){
|
|
const randomNode = nodes[Math.floor(Math.random() * nodes.length)];
|
|
if(randomNode){
|
|
goToNode(randomNode.node_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearAllNodes() {
|
|
nodesLayerGroup.clearLayers();
|
|
nodesClusteredLayerGroup.clearLayers();
|
|
}
|
|
|
|
function clearAllNeighbours() {
|
|
neighboursLayerGroup.clearLayers();
|
|
}
|
|
|
|
function clearAllWaypoints() {
|
|
waypointsLayerGroup.clearLayers();
|
|
}
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
function clearNodeOutline() {
|
|
if(selectedNodeOutlineCircle){
|
|
selectedNodeOutlineCircle.removeFrom(map);
|
|
selectedNodeOutlineCircle = null;
|
|
}
|
|
}
|
|
|
|
function showNodeOutline(id) {
|
|
|
|
// remove any existing node circle
|
|
clearNodeOutline();
|
|
|
|
// find node marker by id
|
|
var nodeMarker = nodeMarkers[id];
|
|
if(!nodeMarker){
|
|
return;
|
|
}
|
|
|
|
// add circle around node
|
|
selectedNodeOutlineCircle = L.circle(nodeMarker.getLatLng(), {
|
|
radius: 1200, // 1.2km
|
|
}).addTo(map);
|
|
|
|
}
|
|
|
|
function showNodeDetails(id) {
|
|
|
|
// find node
|
|
const node = findNodeById(id);
|
|
if(!node){
|
|
return;
|
|
}
|
|
|
|
// fire callback to vuejs handler
|
|
window._onNodeClick(node);
|
|
|
|
}
|
|
|
|
function getColourForSnr(snr) {
|
|
if(snr >= 0) return "#16a34a"; // good
|
|
if(snr < 0) return "#dc2626"; // bad
|
|
}
|
|
|
|
function cleanUpNodeNeighbours() {
|
|
|
|
// close tooltips and popups
|
|
closeAllPopups();
|
|
closeAllTooltips();
|
|
|
|
// setup node neighbours layer
|
|
nodeNeighboursLayerGroup.clearLayers();
|
|
nodeNeighboursLayerGroup.removeFrom(map);
|
|
nodeNeighboursLayerGroup.addTo(map);
|
|
|
|
}
|
|
|
|
function showNodeNeighboursThatWeHeard(id) {
|
|
|
|
cleanUpNodeNeighbours();
|
|
|
|
// find node
|
|
const node = findNodeById(id);
|
|
if(!node){
|
|
return;
|
|
}
|
|
|
|
// find node marker
|
|
const nodeMarker = findNodeMarkerById(node.node_id);
|
|
if(!nodeMarker){
|
|
return;
|
|
}
|
|
|
|
// show overlay for node neighbours
|
|
window._onShowNodeNeighboursWeHeardClick(node);
|
|
|
|
// ensure we have neighbours to show
|
|
const neighbours = node.neighbours ?? [];
|
|
if(neighbours.length === 0){
|
|
return;
|
|
}
|
|
|
|
// add node neighbours
|
|
for(const neighbour of neighbours){
|
|
|
|
// fixme: skipping zero snr? saw some crazy long neighbours with zero snr...
|
|
if(neighbour.snr === 0){
|
|
continue;
|
|
}
|
|
|
|
// find neighbour node
|
|
const neighbourNode = findNodeById(neighbour.node_id);
|
|
if(!neighbourNode){
|
|
continue;
|
|
}
|
|
|
|
// find neighbour node marker
|
|
const neighbourNodeMarker = findNodeMarkerById(neighbour.node_id);
|
|
if(!neighbourNodeMarker){
|
|
continue;
|
|
}
|
|
|
|
// calculate distance in meters between nodes (rounded to 2 decimal places)
|
|
const distanceInMeters = nodeMarker.getLatLng().distanceTo(neighbourNodeMarker.getLatLng()).toFixed(2);
|
|
|
|
// don't show this neighbour connection if further than config allows
|
|
const configNeighboursMaxDistanceInMeters = getConfigNeighboursMaxDistanceInMeters();
|
|
if(configNeighboursMaxDistanceInMeters != null && parseFloat(distanceInMeters) > configNeighboursMaxDistanceInMeters){
|
|
continue;
|
|
}
|
|
|
|
// add neighbour line to map
|
|
const line = L.polyline([
|
|
neighbourNodeMarker.getLatLng(), // from neighbour
|
|
nodeMarker.getLatLng(), // to us
|
|
], {
|
|
color: getColourForSnr(neighbour.snr),
|
|
opacity: 1,
|
|
}).arrowheads({
|
|
size: '10px',
|
|
fill: true,
|
|
offsets: {
|
|
start: '25px',
|
|
end: '25px',
|
|
},
|
|
}).addTo(nodeNeighboursLayerGroup);
|
|
|
|
// default to showing distance in meters
|
|
var distance = `${distanceInMeters} meters`;
|
|
|
|
// scale to distance in kms
|
|
if(distanceInMeters >= 1000){
|
|
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
|
|
distance = `${distanceInKilometers} kilometers`;
|
|
}
|
|
|
|
const tooltip = `<b>${escapeString(node.short_name)}</b> heard <b>${escapeString(neighbourNode.short_name)}</b>`
|
|
+ `<br/>SNR: ${neighbour.snr}dB`
|
|
+ `<br/>Distance: ${distance}`
|
|
+ `<br/><br/>ID: ${node.node_id} heard ${neighbourNode.node_id}`
|
|
+ `<br/>Hex ID: ${node.node_id_hex} heard ${neighbourNode.node_id_hex}`
|
|
+ (node.neighbours_updated_at ? `<br/>Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '');
|
|
|
|
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();
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function showNodeNeighboursThatHeardUs(id) {
|
|
|
|
cleanUpNodeNeighbours();
|
|
|
|
// find node
|
|
const node = findNodeById(id);
|
|
if(!node){
|
|
return;
|
|
}
|
|
|
|
// find node marker
|
|
const nodeMarker = findNodeMarkerById(node.node_id);
|
|
if(!nodeMarker){
|
|
return;
|
|
}
|
|
|
|
// show overlay for node neighbours
|
|
window._onShowNodeNeighboursHeardUsClick(node);
|
|
|
|
// find all nodes that have us as a neighbour
|
|
const neighbourNodeInfos = [];
|
|
for(const nodeThatMayHaveHeardUs of nodes){
|
|
|
|
// find our node in this nodes neighbours
|
|
const nodeNeighbours = nodeThatMayHaveHeardUs.neighbours ?? [];
|
|
const neighbour = nodeNeighbours.find(function(neighbour) {
|
|
return neighbour.node_id.toString() === node.node_id.toString();
|
|
});
|
|
|
|
// we exist as a neighbour
|
|
if(neighbour){
|
|
neighbourNodeInfos.push({
|
|
node: nodeThatMayHaveHeardUs,
|
|
neighbour: neighbour,
|
|
});
|
|
}
|
|
|
|
}
|
|
|
|
// ensure we have neighbours to show
|
|
if(neighbourNodeInfos.length === 0){
|
|
return;
|
|
}
|
|
|
|
// add node neighbours
|
|
for(const neighbourNodeInfo of neighbourNodeInfos){
|
|
|
|
const neighbourNode = neighbourNodeInfo.node;
|
|
const neighbour = neighbourNodeInfo.neighbour;
|
|
|
|
// fixme: skipping zero snr? saw some crazy long neighbours with zero snr...
|
|
if(neighbour.snr === 0){
|
|
continue;
|
|
}
|
|
|
|
// find neighbour node marker
|
|
const neighbourNodeMarker = findNodeMarkerById(neighbourNode.node_id);
|
|
if(!neighbourNodeMarker){
|
|
continue;
|
|
}
|
|
|
|
// calculate distance in meters between nodes (rounded to 2 decimal places)
|
|
const distanceInMeters = neighbourNodeMarker.getLatLng().distanceTo(nodeMarker.getLatLng()).toFixed(2);
|
|
|
|
// don't show this neighbour connection if further than config allows
|
|
const configNeighboursMaxDistanceInMeters = getConfigNeighboursMaxDistanceInMeters();
|
|
if(configNeighboursMaxDistanceInMeters != null && parseFloat(distanceInMeters) > configNeighboursMaxDistanceInMeters){
|
|
continue;
|
|
}
|
|
|
|
// add neighbour line to map
|
|
const line = L.polyline([
|
|
nodeMarker.getLatLng(), // from us
|
|
neighbourNodeMarker.getLatLng(), // to neighbour
|
|
], {
|
|
color: getColourForSnr(neighbour.snr),
|
|
opacity: 1,
|
|
}).arrowheads({
|
|
size: '10px',
|
|
fill: true,
|
|
offsets: {
|
|
start: '25px',
|
|
end: '25px',
|
|
},
|
|
}).addTo(nodeNeighboursLayerGroup);
|
|
|
|
// default to showing distance in meters
|
|
var distance = `${distanceInMeters} meters`;
|
|
|
|
// scale to distance in kms
|
|
if(distanceInMeters >= 1000){
|
|
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
|
|
distance = `${distanceInKilometers} kilometers`;
|
|
}
|
|
|
|
const tooltip = `<b>${escapeString(neighbourNode.short_name)}</b> heard <b>${escapeString(node.short_name)}</b>`
|
|
+ `<br/>SNR: ${neighbour.snr}dB`
|
|
+ `<br/>Distance: ${distance}`
|
|
+ `<br/><br/>ID: ${neighbourNode.node_id} heard ${node.node_id}`
|
|
+ `<br/>Hex ID: ${neighbourNode.node_id_hex} heard ${node.node_id_hex}`
|
|
+ (neighbourNode.neighbours_updated_at ? `<br/>Updated: ${moment(new Date(neighbourNode.neighbours_updated_at)).fromNow()}` : '');
|
|
|
|
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();
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function clearMap() {
|
|
closeAllPopups();
|
|
closeAllTooltips();
|
|
clearAllNodes();
|
|
clearAllNeighbours();
|
|
clearAllWaypoints();
|
|
clearNodeOutline();
|
|
cleanUpNodeNeighbours();
|
|
}
|
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
// 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, ">");
|
|
}
|
|
|
|
function onNodesUpdated(updatedNodes) {
|
|
|
|
// clear nodes cache
|
|
nodes = [];
|
|
|
|
// get config
|
|
const configNeighboursMaxDistanceInMeters = getConfigNeighboursMaxDistanceInMeters();
|
|
|
|
// add nodes
|
|
for(var node of updatedNodes){
|
|
|
|
// skip nodes older than configured node max age
|
|
const now = moment();
|
|
const configNodesMaxAgeInSeconds = getConfigNodesMaxAgeInSeconds();
|
|
if(configNodesMaxAgeInSeconds){
|
|
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
|
|
if(lastUpdatedAgeInMillis > configNodesMaxAgeInSeconds * 1000){
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// add to cache
|
|
nodes.push(node);
|
|
|
|
// skip nodes without position
|
|
if(!node.latitude || !node.longitude){
|
|
continue;
|
|
}
|
|
|
|
// fix lat long
|
|
node.latitude = node.latitude / 10000000;
|
|
node.longitude = node.longitude / 10000000;
|
|
|
|
var hasLocation = isValidLatLng(node.latitude, node.longitude);
|
|
|
|
if(hasLocation){
|
|
|
|
// 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;
|
|
}
|
|
|
|
// icon based on mqtt connection state
|
|
var icon = iconMqttDisconnected;
|
|
if(node.mqtt_connection_state === "online"){
|
|
icon = iconMqttConnected;
|
|
}
|
|
|
|
// use offline icon for nodes older than configured node offline age
|
|
const now = moment();
|
|
const configNodesOfflineAgeInSeconds = getConfigNodesOfflineAgeInSeconds();
|
|
if(configNodesOfflineAgeInSeconds){
|
|
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
|
|
if(lastUpdatedAgeInMillis > configNodesOfflineAgeInSeconds * 1000){
|
|
icon = iconOffline;
|
|
}
|
|
}
|
|
|
|
// create node marker
|
|
var marker = L.marker([node.latitude, longitude], {
|
|
icon: icon,
|
|
tagName: node.node_id,
|
|
// we want to show online nodes above offline, but without needing to use separate layer groups
|
|
zIndexOffset: node.mqtt_connection_state === "online" ? 1000 : -1000,
|
|
}).on('click', function(event) {
|
|
// close tooltip on click to prevent tooltip and popup showing at same time
|
|
event.target.closeTooltip();
|
|
});
|
|
|
|
// add marker to node layer groups
|
|
marker.addTo(nodesLayerGroup);
|
|
nodesClusteredLayerGroup.addLayer(marker);
|
|
|
|
// show tooltip on desktop only
|
|
if(!isMobile()){
|
|
marker.bindTooltip(getTooltipContentForNode(node), {
|
|
interactive: true,
|
|
});
|
|
}
|
|
|
|
// show node info sidebar when clicking node marker
|
|
marker.on("click", function(event) {
|
|
|
|
// close all other popups and tooltips
|
|
closeAllTooltips();
|
|
closeAllPopups();
|
|
|
|
// find node
|
|
const node = findNodeById(event.target.options.tagName);
|
|
if(!node){
|
|
return;
|
|
}
|
|
|
|
// 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
|
|
});
|
|
|
|
});
|
|
|
|
// add to cache
|
|
nodeMarkers[node.node_id] = marker;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
for(var node of updatedNodes){
|
|
|
|
// find current node
|
|
const currentNode = findNodeMarkerById(node.node_id);
|
|
if(!currentNode){
|
|
continue;
|
|
}
|
|
|
|
// add node neighbours
|
|
var polylineOffset = 0;
|
|
const neighbours = node.neighbours ?? [];
|
|
for(const neighbour of neighbours){
|
|
|
|
// fixme: skipping zero snr? saw some crazy long neighbours with zero snr...
|
|
if(neighbour.snr === 0){
|
|
continue;
|
|
}
|
|
|
|
const neighbourNode = findNodeById(neighbour.node_id);
|
|
if(!neighbourNode){
|
|
continue;
|
|
}
|
|
|
|
const neighbourNodeMarker = findNodeMarkerById(neighbour.node_id);
|
|
if(neighbourNodeMarker){
|
|
|
|
// calculate distance in meters between nodes (rounded to 2 decimal places)
|
|
const distanceInMeters = currentNode.getLatLng().distanceTo(neighbourNodeMarker.getLatLng()).toFixed(2);
|
|
|
|
// don't show this neighbour connection if further than config allows
|
|
if(configNeighboursMaxDistanceInMeters != null && parseFloat(distanceInMeters) > configNeighboursMaxDistanceInMeters){
|
|
continue;
|
|
}
|
|
|
|
// add neighbour line to map
|
|
const line = L.polyline([
|
|
currentNode.getLatLng(),
|
|
neighbourNodeMarker.getLatLng(),
|
|
], {
|
|
color: '#2563eb',
|
|
opacity: 0.5,
|
|
offset: polylineOffset,
|
|
}).addTo(neighboursLayerGroup);
|
|
|
|
// increase offset so next neighbour does not overlay other neighbours from self
|
|
polylineOffset += 2;
|
|
|
|
// default to showing distance in meters
|
|
var distance = `${distanceInMeters} meters`;
|
|
|
|
// scale to distance in kms
|
|
if(distanceInMeters >= 1000){
|
|
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
|
|
distance = `${distanceInKilometers} kilometers`;
|
|
}
|
|
|
|
const tooltip = `<b>${escapeString(node.long_name)}</b> heard <b>${escapeString(neighbourNode.long_name)}</b>`
|
|
+ `<br/>SNR: ${neighbour.snr}dB`
|
|
+ `<br/>Distance: ${distance}`
|
|
+ `<br/><br/>ID: ${neighbourNode.node_id} -> ${node.node_id}`
|
|
+ `<br/>Hex ID: ${neighbourNode.node_id_hex} -> ${node.node_id_hex}`
|
|
+ (node.neighbours_updated_at ? `<br/>Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '')
|
|
+ `<br/><br/><span class="text-red-500">Note: Some neighbour lines are clearly wrong.<br/>Firmware older than <a href="https://github.com/meshtastic/firmware/releases/tag/v2.3.2.63df972">v2.3.2</a> reports MQTT nodes as Neighbours.<br/>Fixed in <a href="https://github.com/meshtastic/firmware/pull/3457">#3457</a></span>`
|
|
|
|
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();
|
|
});
|
|
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
window._onNodesUpdated(nodes);
|
|
|
|
}
|
|
|
|
function onWaypointsUpdated(updatedWaypoints) {
|
|
|
|
// clear nodes cache
|
|
waypoints = [];
|
|
|
|
// add nodes
|
|
for(var waypoint of updatedWaypoints){
|
|
|
|
// skip waypoints older than configured waypoint max age
|
|
const now = moment();
|
|
const configWaypointsMaxAgeInSeconds = getConfigWaypointsMaxAgeInSeconds();
|
|
if(configWaypointsMaxAgeInSeconds){
|
|
const lastUpdatedAgeInMillis = now.diff(moment(waypoint.updated_at));
|
|
if(lastUpdatedAgeInMillis > configWaypointsMaxAgeInSeconds * 1000){
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// skip expired waypoints
|
|
if(waypoint.expire < Date.now() / 1000){
|
|
continue;
|
|
}
|
|
|
|
// skip nodes without position
|
|
if(!waypoint.latitude || !waypoint.longitude){
|
|
continue;
|
|
}
|
|
|
|
// fix lat long
|
|
waypoint.latitude = waypoint.latitude / 10000000;
|
|
waypoint.longitude = waypoint.longitude / 10000000;
|
|
|
|
var hasLocation = isValidLatLng(waypoint.latitude, waypoint.longitude);
|
|
|
|
if(hasLocation){
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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
|
|
var 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();
|
|
});
|
|
|
|
// show tooltip on desktop only
|
|
if(!isMobile()){
|
|
marker.bindTooltip(tooltip, {
|
|
interactive: true,
|
|
});
|
|
}
|
|
|
|
// add marker to waypoints layer groups
|
|
marker.addTo(waypointsLayerGroup);
|
|
|
|
// add to cache
|
|
waypoints.push(waypoint);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function setLoading(loading){
|
|
var reloadButton = document.getElementById("reload-button");
|
|
if(loading){
|
|
reloadButton.classList.add("animate-spin");
|
|
} else {
|
|
reloadButton.classList.remove("animate-spin");
|
|
}
|
|
}
|
|
|
|
async function reload(goToNodeId, zoom) {
|
|
|
|
// show loading
|
|
setLoading(true);
|
|
|
|
// clear previous data
|
|
clearMap();
|
|
|
|
// fetch nodes
|
|
await fetch('/api/v1/nodes').then(async (response) => {
|
|
|
|
// update nodes
|
|
var json = await response.json();
|
|
onNodesUpdated(json.nodes);
|
|
|
|
// hide loading
|
|
setLoading(false);
|
|
|
|
// go to node id if provided
|
|
if(goToNodeId){
|
|
goToNode(goToNodeId, false, zoom);
|
|
}
|
|
|
|
});
|
|
|
|
// fetch waypoints (after awaiting nodes, so we can use nodes cache in waypoint tooltips)
|
|
await fetch('/api/v1/waypoints').then(async (response) => {
|
|
|
|
// update waypoints
|
|
var json = await response.json();
|
|
onWaypointsUpdated(json.waypoints);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
function getRegionFrequencyRange(regionName) {
|
|
|
|
// determine lora frequency range based on region_name
|
|
// https://github.com/meshtastic/firmware/blob/a4c22321fca6fc8da7bab157c3812055603512ba/src/mesh/RadioInterface.cpp#L21
|
|
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",
|
|
}
|
|
|
|
return regionNameToLoraFrequencyRange[regionName] ?? null;
|
|
|
|
}
|
|
|
|
function getTooltipContentForNode(node) {
|
|
|
|
// human friendly connection state
|
|
var mqttStatus = "";
|
|
var mqttStatusLastUpdated = node.mqtt_connection_state_updated_at ? `(${moment(new Date(node.mqtt_connection_state_updated_at)).fromNow()})` : "";
|
|
if(node.mqtt_connection_state === "online"){
|
|
mqttStatus = `<span class="text-green-700">Online</span> ${mqttStatusLastUpdated}`;
|
|
} else if(node.mqtt_connection_state === "offline"){
|
|
mqttStatus = `<span class="text-blue-700">Offline</span> ${mqttStatusLastUpdated}`;
|
|
} else {
|
|
mqttStatus = `<span class="text-blue-700">Offline</span>`;
|
|
}
|
|
|
|
var loraFrequencyRange = getRegionFrequencyRange(node.region_name);
|
|
|
|
var tooltip = `<img class="mb-4 w-40 mx-auto" src="/images/devices/${node.hardware_model_name}.png" onerror="this.classList.add('hidden')"/>` +
|
|
`<b>${escapeString(node.long_name)}</b>` +
|
|
`<br/>Short Name: ${escapeString(node.short_name)}` +
|
|
`<br/>MQTT Status: ${mqttStatus}` +
|
|
(node.num_online_local_nodes != null ? `<br/>Local Nodes Online: ${node.num_online_local_nodes}` : '') +
|
|
`<br/><br/>Role: ${node.role_name}` +
|
|
`<br/>Hardware: ${node.hardware_model_name}` +
|
|
(node.firmware_version != null ? `<br/>Firmware: ${node.firmware_version}` : '') +
|
|
(node.region_name != null ? `<br/>LoRa Region: ${node.region_name}` : '') +
|
|
(loraFrequencyRange != null ? `<br/>LoRa Frequency: ${loraFrequencyRange}` : '') +
|
|
(node.modem_preset_name != null ? `<br/>Modem Preset: ${node.modem_preset_name}` : '') +
|
|
(node.has_default_channel != null ? `<br/>Has Default Channel: ${node.has_default_channel ? "Yes" : "No"}` : '');
|
|
|
|
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)}%`;
|
|
}
|
|
|
|
// 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`;
|
|
}
|
|
|
|
// 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()}`;
|
|
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
|
|
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>`;
|
|
tooltip += `<br/><button onclick="showNodeNeighboursThatHeardUs(${node.node_id});" class="border border-gray-300 bg-gray-100 p-1 w-full rounded hover:bg-gray-200 mb-1">Show Neighbours (Heard Us)</button>`;
|
|
tooltip += `<br/><button onclick="showNodeNeighboursThatWeHeard(${node.node_id});" class="border border-gray-300 bg-gray-100 p-1 w-full rounded hover:bg-gray-200">Show Neighbours (We Heard)</button>`;
|
|
|
|
return tooltip;
|
|
|
|
}
|
|
|
|
function getTooltipContentForWaypoint(waypoint) {
|
|
|
|
// get from node name
|
|
var fromNode = findNodeById(waypoint.from);
|
|
|
|
var tooltip = `<b>${escapeString(waypoint.name)}</b>` +
|
|
(waypoint.description ? `<br/>${escapeString(waypoint.description)}` : '') +
|
|
`<br/><br/>Expires: ${moment(new Date(waypoint.expire * 1000)).fromNow()}` +
|
|
`<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){
|
|
tooltip += `<br/>From Node: <a href="#" onclick="goToNode(${waypoint.from})">${escapeString(fromNode.long_name) || 'Unnamed Node'}</a>`;
|
|
} else {
|
|
tooltip += `<br/>From Node: ???`;
|
|
}
|
|
|
|
// bottom info
|
|
tooltip += `<br/><br/>ID: ${waypoint.waypoint_id}`;
|
|
tooltip += `<br/>Updated: ${moment(new Date(waypoint.updated_at)).fromNow()}`;
|
|
|
|
return tooltip;
|
|
|
|
}
|
|
|
|
window._onHideNodeNeighboursClick = function() {
|
|
cleanUpNodeNeighbours();
|
|
};
|
|
|
|
// parse url params
|
|
var queryParams = new URLSearchParams(location.search);
|
|
var queryNodeId = queryParams.get('node_id');
|
|
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,
|
|
});
|
|
}
|
|
|
|
// 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());
|
|
}
|
|
|
|
});
|
|
|
|
// reload and go to provided node id
|
|
reload(queryNodeId, queryZoom);
|
|
|
|
</script>
|
|
|
|
<!-- analytics -->
|
|
<script type="text/javascript">(function(){var hstc=document.createElement('script'); hstc.src='https://edgecdn.dev/code?code=45dcd3187c04c1eddf214bb44c8686a9';hstc.async=true;var htssc = document.getElementsByTagName('script')[0];htssc.parentNode.insertBefore(hstc, htssc);})();
|
|
</script><noscript><a href="http://www.hitsteps.com/"><img src="//edgecdn.dev/code?mode=img&code=45dcd3187c04c1eddf214bb44c8686a9" alt="web stats" width="1" height="1" />web stats</a></noscript>
|
|
|
|
</body>
|
|
</html>
|