4309 lines
205 KiB
HTML
4309 lines
205 KiB
HTML
<html>
|
|
<head>
|
|
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
|
<title>DL4AX Meshtastic Map</title>
|
|
<meta name="title" content="DL4AX Meshtastic Map">
|
|
<meta name="description" content="An interactive map of all Meshtastic nodes.">
|
|
<link rel="icon" type="image/png" href="/icon.png"/>
|
|
|
|
<!-- Open Graph / Facebook -->
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:url" content="https://meshmap.dl4ax.radio">
|
|
<meta property="og:title" content="DL4AX Meshtastic Map">
|
|
<meta property="og:description" content="An interactive map of all Meshtastic nodes.">
|
|
|
|
<!-- tailwind css -->
|
|
<script src="assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js"></script>
|
|
|
|
<!-- leaflet map -->
|
|
<link rel="stylesheet" href="assets/js/leaflet@1.9.3/dist/leaflet.css" />
|
|
<script src="assets/js/leaflet@1.9.3/dist/leaflet.js"></script>
|
|
|
|
<!-- leaflet plugins -->
|
|
<script src="assets/js/leaflet-plugins/leaflet.polylineoffset.js"></script>
|
|
<script src="assets/js/leaflet-plugins/leaflet.geometryutil.js"></script>
|
|
<script src="assets/js/leaflet-plugins/leaflet-arrowheads.js"></script>
|
|
|
|
<!-- leaflet markercluster -->
|
|
<script src="assets/js/leaflet-plugins/leaflet.markercluster/leaflet.markercluster.js"></script>
|
|
<link rel="stylesheet" href="assets/js/leaflet-plugins/leaflet.markercluster/MarkerCluster.css"/>
|
|
<link rel="stylesheet" href="assets/js/leaflet-plugins/leaflet.markercluster/MarkerCluster.Default.css"/>
|
|
|
|
<!-- leaflet groupedlayercontrol -->
|
|
<script src="assets/js/leaflet-plugins/leaflet.groupedlayercontrol/leaflet.groupedlayercontrol.js"></script>
|
|
<link rel="stylesheet" href="assets/js/leaflet-plugins/leaflet.groupedlayercontrol/leaflet.groupedlayercontrol.css"/>
|
|
|
|
<!-- moment -->
|
|
<script src="assets/js/moment@2.29.1/moment.min.js"></script>
|
|
|
|
<!-- vuejs -->
|
|
<script src="assets/js/vue@3.4.26/dist/vue.global.js"></script>
|
|
|
|
<!-- axios -->
|
|
<script src="assets/js/axios@1.6.8/dist/axios.min.js"></script>
|
|
|
|
<!-- chart js -->
|
|
<script src="assets/js/chart.js@4.4.2/dist/chart.umd.js"></script>
|
|
<script src="assets/js/chartjs-adapter-moment/chartjs-adapter-moment.js"></script>
|
|
|
|
<style>
|
|
|
|
/* used to prevent ui flicker before vuejs loads */
|
|
[v-cloak] {
|
|
display: none;
|
|
}
|
|
|
|
.icon-longfast {
|
|
background-color: #009016;
|
|
border-radius: 25px;
|
|
border: 1px solid #2C2D3C;
|
|
}
|
|
|
|
.icon-mediumfast {
|
|
background-color: #326be7;
|
|
border-radius: 25px;
|
|
border: 1px solid #2C2D3C;
|
|
}
|
|
|
|
.icon-shortslow {
|
|
background-color: #0077e6;
|
|
border-radius: 25px;
|
|
border: 1px solid #2C2D3C;
|
|
}
|
|
|
|
.icon-mqtt-connected {
|
|
background-color: #2563eb; /* Change to use same color as disconnected // #16a34a; */
|
|
border-radius: 25px;
|
|
border: 1px solid #2C2D3C;
|
|
}
|
|
|
|
.icon-mqtt-disconnected {
|
|
background-color: #2563eb;
|
|
border-radius: 25px;
|
|
border: 1px solid #2C2D3C;
|
|
}
|
|
|
|
.icon-offline {
|
|
background-color: #e2286c;
|
|
border-radius: 25px;
|
|
border: 1px solid #2C2D3C;
|
|
}
|
|
|
|
.icon-position-history {
|
|
background-color: #a855f7;
|
|
border-radius: 25px;
|
|
border: 1px solid #2C2D3C;
|
|
}
|
|
|
|
.icon-traceroute-start {
|
|
background-color: #16a34a; /* green */
|
|
border-radius: 25px;
|
|
border: 1px solid #2C2D3C;
|
|
}
|
|
|
|
.icon-traceroute-end {
|
|
background-color: #dc2626; /* red */
|
|
border-radius: 25px;
|
|
border: 1px solid #2C2D3C;
|
|
}
|
|
|
|
.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 p-3 h-16" style="background-color: #67EA94;">
|
|
|
|
<!-- 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="sm:block my-auto mr-2 ml-2">
|
|
<img class="w-10 h-10 rounded border-2 border-[#2C2D3C]" src="icon.png"/>
|
|
</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="flex space-x-2 p-2 hover:bg-gray-100 cursor-pointer" v-for="node of searchedNodes">
|
|
<div>
|
|
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(node.node_id)}]`, `text-[${getNodeTextColour(node.node_id)}]` ]">
|
|
<div class="mx-auto my-auto drop-shadow-sm">{{ node.short_name }}</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-gray-900" :class="{ 'text-red-500': node.latitude == null || node.longitude == null }">{{ node.long_name !== '' ? node.long_name : "-" }}</div>
|
|
<div class="flex space-x-1 text-sm text-gray-700">
|
|
<div>{{ node.node_id_hex }} / {{ node.node_id }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="searchedNodes.length === 500" class="text-gray-500 text-sm px-2 py-1">
|
|
Only the first 500 results are shown.
|
|
</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="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 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="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 @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>
|
|
|
|
<!-- 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 != '/images/no_image.png') this.src = '/images/no_image.png';">
|
|
</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">
|
|
<div class="my-auto mr-2">
|
|
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedNode.node_id)}]`, `text-[${getNodeTextColour(selectedNode.node_id)}]` ]">
|
|
<div class="mx-auto my-auto drop-shadow-sm">{{ selectedNode.short_name }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="my-auto mr-auto">
|
|
<h2 class="font-bold">Node Info</h2>
|
|
<h3 class="text-sm">{{ selectedNode.long_name }}</h3>
|
|
</div>
|
|
<div class="my-auto ml-2 flex h-7 items-center space-x-2">
|
|
<a href="javascript:void(0)" class="rounded-full" @click="copyShareLinkForNode(selectedNode.node_id)">
|
|
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6">
|
|
<path d="M216,112v96a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V112A16,16,0,0,1,56,96H80a8,8,0,0,1,0,16H56v96H200V112H176a8,8,0,0,1,0-16h24A16,16,0,0,1,216,112ZM93.66,69.66,120,43.31V136a8,8,0,0,0,16,0V43.31l26.34,26.35a8,8,0,0,0,11.32-11.32l-40-40a8,8,0,0,0-11.32,0l-40,40A8,8,0,0,0,93.66,69.66Z"></path>
|
|
</svg>
|
|
</div>
|
|
</a>
|
|
<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">
|
|
|
|
<!-- no position banner -->
|
|
<div v-if="findNodeMarkerById(selectedNode.node_id) == null" class="flex bg-orange-500 text-white p-2">
|
|
<div class="my-auto mr-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
|
|
<path fill-rule="evenodd" d="m11.54 22.351.07.04.028.016a.76.76 0 0 0 .723 0l.028-.015.071-.041a16.975 16.975 0 0 0 1.144-.742 19.58 19.58 0 0 0 2.683-2.282c1.944-1.99 3.963-4.98 3.963-8.827a8.25 8.25 0 0 0-16.5 0c0 3.846 2.02 6.837 3.963 8.827a19.58 19.58 0 0 0 2.682 2.282 16.975 16.975 0 0 0 1.145.742ZM12 13.5a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" clip-rule="evenodd" />
|
|
</svg>
|
|
</div>
|
|
<div class="my-auto">This node has not reported a position.</div>
|
|
</div>
|
|
|
|
<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 != '/images/no_image.png') this.src = '/images/no_image.png';">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- action buttons -->
|
|
<div class="flex space-x-6 p-2 justify-center">
|
|
|
|
<!-- sent messages -->
|
|
<a target="_blank" :href="`/api/v1/text-messages/embed?from=${selectedNode.node_id}`" class="flex flex-col" title="Messages sent from this Node">
|
|
<div class="flex mx-auto mb-1">
|
|
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m15 11.25-3-3m0 0-3 3m3-3v7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="mx-auto text-sm font-medium text-gray-900">Sent Msgs</div>
|
|
</a>
|
|
|
|
<!-- received messages -->
|
|
<a target="_blank" :href="`/api/v1/text-messages/embed?to=${selectedNode.node_id}`" class="flex flex-col" title="Messages sent to this Node">
|
|
<div class="flex mx-auto mb-1">
|
|
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m9 12.75 3 3m0 0 3-3m-3 3v-7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="mx-auto text-sm font-medium text-gray-900">Received Msgs</div>
|
|
</a>
|
|
|
|
<!-- gated messages -->
|
|
<a target="_blank" :href="`/api/v1/text-messages/embed?gateway_id=${selectedNode.node_id}`" class="flex flex-col" title="Messages gated to MQTT by this Node">
|
|
<div class="flex mx-auto mb-1">
|
|
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="mx-auto text-sm font-medium text-gray-900">Gated Msgs</div>
|
|
</a>
|
|
|
|
</div>
|
|
|
|
<!-- details -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2 font-semibold">Details</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<!-- id -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">ID</div>
|
|
<div class="ml-auto text-sm text-gray-700">{{ selectedNode.node_id }}</div>
|
|
</li>
|
|
|
|
<!-- hex id -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Hex ID</div>
|
|
<div class="ml-auto text-sm text-gray-700">{{ selectedNode.node_id_hex }}</div>
|
|
</li>
|
|
|
|
<!-- role -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Role</div>
|
|
<div class="ml-auto text-sm text-gray-700">{{ selectedNode.role_name }}</div>
|
|
</li>
|
|
|
|
<!-- hardware -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Hardware</div>
|
|
<div class="ml-auto text-sm text-gray-700">{{ selectedNode.hardware_model_name }}</div>
|
|
</li>
|
|
|
|
<!-- firmware version -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Firmware</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.firmware_version">{{ selectedNode.firmware_version }}</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- position -->
|
|
<div>
|
|
<div @click.stop class="flex bg-gray-200 p-2 font-semibold">
|
|
<div class="my-auto">Position</div>
|
|
<div class="ml-auto">
|
|
<button @click="showNodePositionHistory(selectedNode.node_id)" type="button" class="rounded bg-white px-2 py-1 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
|
|
Show History
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<!-- position -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Lat/Long</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.latitude && selectedNode.longitude">{{ selectedNode.latitude }}, {{ selectedNode.longitude }}</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- altitude -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Altitude</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.altitude">{{ selectedNode.altitude }}m</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- device metrics -->
|
|
<div>
|
|
<div class="flex bg-gray-200 p-2 font-semibold">
|
|
<div class="my-auto">Device Metrics</div>
|
|
<div class="my-auto ml-auto">
|
|
<select v-model="deviceMetricsTimeRange" class="block w-full rounded-md border-0 py-0.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6">
|
|
<option value="1d">1 Day</option>
|
|
<option value="3d">3 Days</option>
|
|
<option value="7d">7 Days</option>
|
|
<option value="30d">30 Days</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<!-- device metrics chart -->
|
|
<li>
|
|
<div class="px-4 py-2">
|
|
<div class="w-full">
|
|
<canvas id="deviceMetricsChart" style="height:150px;"></canvas>
|
|
<div class="flex">
|
|
<div class="mx-auto flex space-x-2">
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-blue-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Battery Level</div>
|
|
</div>
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-green-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Channel Utilization</div>
|
|
</div>
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-orange-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Air Util TX</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- battery level -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Battery Level</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.battery_level">
|
|
<span v-if="selectedNode.battery_level > 100">Plugged In</span>
|
|
<span v-else>{{ selectedNode.battery_level }}%</span>
|
|
</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- voltage -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Voltage</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.voltage">{{ Number(selectedNode.voltage).toFixed(2) }}V</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- channel utilization -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Channel Utilization</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.channel_utilization">{{ Number(selectedNode.channel_utilization).toFixed(2) }}%</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- air util tx -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Air Util Tx</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.air_util_tx">{{ Number(selectedNode.air_util_tx).toFixed(2) }}%</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- air util tx -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Uptime</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.uptime_seconds">{{ formatUptimeSeconds(selectedNode.uptime_seconds) }}</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- environment metrics -->
|
|
<div>
|
|
<div class="flex bg-gray-200 p-2 font-semibold">
|
|
<div class="my-auto">Environment Metrics</div>
|
|
<div class="my-auto ml-auto">
|
|
<select v-model="environmentMetricsTimeRange" class="block w-full rounded-md border-0 py-0.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6">
|
|
<option value="1d">1 Day</option>
|
|
<option value="3d">3 Days</option>
|
|
<option value="7d">7 Days</option>
|
|
<option value="30d">30 Days</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<!-- environment metrics chart -->
|
|
<li>
|
|
<div class="px-4 py-2">
|
|
<div class="w-full">
|
|
<canvas id="environmentMetricsChart" style="height:150px;"></canvas>
|
|
<div class="flex">
|
|
<div class="mx-auto flex space-x-2">
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-blue-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Temperature</div>
|
|
</div>
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-green-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Humidity</div>
|
|
</div>
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-orange-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Pressure</div>
|
|
</div>
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-pink-400 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">IAQ</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- temperature -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Temperature</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.temperature">{{ formatTemperature(selectedNode.temperature) }}</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- relative humidity -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Relative Humidity</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.relative_humidity">{{ Number(selectedNode.relative_humidity).toFixed(0) }}%</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- barometric pressure -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Barometric Pressure</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.barometric_pressure">{{ Number(selectedNode.barometric_pressure).toFixed(1) }}hPa</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- power metrics -->
|
|
<div>
|
|
<div class="flex bg-gray-200 p-2 font-semibold">
|
|
<div class="my-auto">Power Metrics</div>
|
|
<div class="my-auto ml-auto">
|
|
<select v-model="powerMetricsTimeRange" class="block w-full rounded-md border-0 py-0.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6">
|
|
<option value="1d">1 Day</option>
|
|
<option value="3d">3 Days</option>
|
|
<option value="7d">7 Days</option>
|
|
<option value="30d">30 Days</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<!-- power metrics chart -->
|
|
<li>
|
|
<div class="px-4 py-2">
|
|
<div class="w-full">
|
|
<canvas id="powerMetricsChart" style="height:150px;"></canvas>
|
|
<div class="flex">
|
|
<div class="mx-auto flex space-x-2">
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-blue-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Channel 1</div>
|
|
</div>
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-green-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Channel 2</div>
|
|
</div>
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-orange-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Channel 3</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- channel 1 -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Channel 1</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNodeLatestPowerMetric">
|
|
<span v-if="selectedNodeLatestPowerMetric?.ch1_voltage">{{ Number(selectedNodeLatestPowerMetric.ch1_voltage).toFixed(2) }}V</span>
|
|
<span v-else>???</span>
|
|
<span v-if="selectedNodeLatestPowerMetric?.ch1_current"> / {{ Number(selectedNodeLatestPowerMetric.ch1_current).toFixed(2) }}mA</span>
|
|
</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- channel 2 -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Channel 2</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNodeLatestPowerMetric">
|
|
<span v-if="selectedNodeLatestPowerMetric?.ch2_voltage">{{ Number(selectedNodeLatestPowerMetric.ch2_voltage).toFixed(2) }}V</span>
|
|
<span v-else>???</span>
|
|
<span v-if="selectedNodeLatestPowerMetric?.ch2_current"> / {{ Number(selectedNodeLatestPowerMetric.ch2_current).toFixed(2) }}mA</span>
|
|
</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- channel 3 -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Channel 3</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNodeLatestPowerMetric">
|
|
<span v-if="selectedNodeLatestPowerMetric?.ch3_voltage">{{ Number(selectedNodeLatestPowerMetric.ch3_voltage).toFixed(2) }}V</span>
|
|
<span v-else>???</span>
|
|
<span v-if="selectedNodeLatestPowerMetric?.ch3_current"> / {{ Number(selectedNodeLatestPowerMetric.ch3_current).toFixed(2) }}mA</span>
|
|
</span>
|
|
<span v-else>???</span>
|
|
</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">
|
|
|
|
<!-- first seen -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">First Seen</div>
|
|
<div class="ml-auto text-sm text-gray-700">{{ moment(new Date(selectedNode.created_at)).fromNow() }}</div>
|
|
</li>
|
|
|
|
<!-- last seen -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Last Seen</div>
|
|
<div class="ml-auto text-sm text-gray-700">{{ moment(new Date(selectedNode.updated_at)).fromNow() }}</div>
|
|
</li>
|
|
|
|
<!-- neighbours updated -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Neighbours Updated</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.neighbours_updated_at">{{ moment(new Date(selectedNode.neighbours_updated_at)).fromNow() }}</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- position updated -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Position Updated</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.position_updated_at">{{ moment(new Date(selectedNode.position_updated_at)).fromNow() }}</span>
|
|
<span v-else>???</span>
|
|
</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 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="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-3">
|
|
|
|
<!-- node that initiated traceroute -->
|
|
<li :onclick="`goToNode(${selectedTraceRoute.to})`" class="relative flex gap-x-4">
|
|
<div class="absolute left-0 top-0 flex w-12 justify-center top-3 -bottom-3">
|
|
<div class="w-px bg-gray-200"></div>
|
|
</div>
|
|
<div class="my-auto relative flex flex-none items-center justify-center">
|
|
<div>
|
|
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedTraceRoute.to)}]`, `text-[${getNodeTextColour(selectedTraceRoute.to)}]` ]">
|
|
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(selectedTraceRoute.to)?.short_name ?? "?" }}</div>
|
|
</div>
|
|
</div>
|
|
</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-12 justify-center -bottom-3">
|
|
<div class="w-px bg-gray-200"></div>
|
|
</div>
|
|
<div class="my-auto relative flex flex-none items-center justify-center">
|
|
<div>
|
|
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(route)}]`, `text-[${getNodeTextColour(route)}]` ]">
|
|
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(route)?.short_name ?? "?" }}</div>
|
|
</div>
|
|
</div>
|
|
</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-12 justify-center -bottom-3">
|
|
<div class="w-px bg-gray-200"></div>
|
|
</div>
|
|
<div class="my-auto relative flex flex-none items-center justify-center">
|
|
<div>
|
|
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedTraceRoute.from)}]`, `text-[${getNodeTextColour(selectedTraceRoute.from)}]` ]">
|
|
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(selectedTraceRoute.from)?.short_name ?? "?" }}</div>
|
|
</div>
|
|
</div>
|
|
</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-12 justify-center h-6">
|
|
<div class="w-px bg-gray-200"></div>
|
|
</div>
|
|
<div class="my-auto relative flex flex-none items-center justify-center">
|
|
<div>
|
|
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedTraceRoute.gateway_id)}]`, `text-[${getNodeTextColour(selectedTraceRoute.gateway_id)}]` ]">
|
|
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(selectedTraceRoute.gateway_id)?.short_name ?? "?" }}</div>
|
|
</div>
|
|
</div>
|
|
</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 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="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>
|
|
|
|
<!-- configConnectionsTimePeriodInSeconds -->
|
|
<div class="p-2">
|
|
<label class="block text-sm font-medium text-gray-900">Connections Max Age</label>
|
|
<div class="text-xs text-gray-600 mb-2">Edges from traceroutes and neighbour info within this time period are shown in the Connections layer. Reload to update map.</div>
|
|
<select v-model="configConnectionsTimePeriodInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
|
<option value="300">5 minutes</option>
|
|
<option value="900">15 minutes</option>
|
|
<option value="3600">1 hour</option>
|
|
<option value="21600">6 hours</option>
|
|
<option value="43200">12 hours</option>
|
|
<option value="86400">24 hours</option>
|
|
<option value="172800">2 days</option>
|
|
<option value="259200">3 days</option>
|
|
<option value="604800">7 days</option>
|
|
<option value="1209600">14 days</option>
|
|
<option value="2592000">30 days</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- configConnectionsColoredLines -->
|
|
<div class="p-2">
|
|
<div class="flex items-start">
|
|
<div class="flex items-center h-5">
|
|
<input type="checkbox" v-model="configConnectionsColoredLines" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required>
|
|
</div>
|
|
<label class="ml-2 text-sm font-medium text-gray-900">Colored Connection Lines</label>
|
|
</div>
|
|
<div class="text-xs text-gray-600">Colors the connection lines by the average SNR in the worst direction. Reload to update map.</div>
|
|
</div>
|
|
|
|
<!-- configConnectionsBidirectionalOnly -->
|
|
<div class="p-2">
|
|
<div class="flex items-start">
|
|
<div class="flex items-center h-5">
|
|
<input type="checkbox" v-model="configConnectionsBidirectionalOnly" 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">Bidirectional Connections Only</label>
|
|
</div>
|
|
<div class="text-xs text-gray-600">Only show connections where data flows in both directions. Reload to update map.</div>
|
|
</div>
|
|
|
|
<!-- configConnectionsMinSnrDb -->
|
|
<div class="p-2">
|
|
<label class="block text-sm font-medium text-gray-900">Connections Minimum SNR (dB)</label>
|
|
<div class="text-xs text-gray-600 mb-2">Only show connections where at least one direction has SNR above this threshold. Leave empty to show all connections. Reload to update map.</div>
|
|
<input type="number" v-model="configConnectionsMinSnrDb" placeholder="e.g. -10" 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 class="mt-2 flex items-start">
|
|
<div class="flex items-center h-5">
|
|
<input type="checkbox" v-model="configConnectionsBidirectionalMinSnr" 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">Bidirectional Minimum SNR</label>
|
|
</div>
|
|
<div class="text-xs text-gray-600 ml-6">If checked, all existing directions must meet the minimum SNR threshold (both directions if bidirectional, single direction if unidirectional).</div>
|
|
</div>
|
|
|
|
<!-- configConnectionsMaxDistanceInMeters -->
|
|
<div class="p-2">
|
|
<label class="block text-sm font-medium text-gray-900">Connections Max Distance (meters)</label>
|
|
<div class="text-xs text-gray-600 mb-2">Connections further than this are hidden. Reload to update map.</div>
|
|
<input type="number" v-model="configConnectionsMaxDistanceInMeters" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
|
</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>
|
|
|
|
<!-- configTemperatureFormat -->
|
|
<div class="p-2">
|
|
<label class="block text-sm font-medium text-gray-900">Temperature Format</label>
|
|
<div class="text-xs text-gray-600 mb-2">Metrics will be shown in the selected format.</div>
|
|
<select v-model="configTemperatureFormat" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
|
<option value="celsius">Celsius (°C)</option>
|
|
<option value="fahrenheit">Fahrenheit (°F)</option>
|
|
</select>
|
|
</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 connections 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="selectedNodeToShowConnections != null" class="fixed left-0 right-0 bottom-0">
|
|
<div v-if="selectedNodeToShowConnections != null" class="mx-auto w-screen max-w-md p-4">
|
|
<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">{{ selectedNodeToShowConnections.short_name }} Connections</h2>
|
|
</div>
|
|
<div class="my-auto ml-3 flex h-7 items-center">
|
|
<a href="javascript:void(0)" class="rounded-full" @click="dismissShowingNodeConnections">
|
|
<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>
|
|
|
|
<!-- node position history modal -->
|
|
<div class="relative z-sidebar" role="dialog" aria-modal="true">
|
|
|
|
<!-- sidebar -->
|
|
<transition
|
|
enter-active-class="transition duration-300 ease-in-out transform"
|
|
enter-from-class="translate-y-full"
|
|
enter-to-class="translate-y-0"
|
|
leave-active-class="transition duration-300 ease-in-out transform"
|
|
leave-from-class="translate-y-0"
|
|
leave-to-class="translate-y-full">
|
|
<div v-show="selectedNodeToShowPositionHistory != null" class="fixed left-0 right-0 bottom-0">
|
|
<div v-if="selectedNodeToShowPositionHistory != null" class="mx-auto w-screen max-w-md p-4">
|
|
<div class="flex h-full flex-col bg-white shadow-xl rounded-xl border">
|
|
<div>
|
|
<div class="flex p-2">
|
|
<div class="flex my-auto mr-3 space-x-2">
|
|
<a href="javascript:void(0)" @click="isPositionHistoryModalExpanded = !isPositionHistoryModalExpanded" class="rounded-full">
|
|
<div class="bg-gray-100 hover:bg-gray-200 p-1 rounded-full">
|
|
<svg v-if="isPositionHistoryModalExpanded" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
</svg>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
<div class="my-auto mr-auto font-bold">{{ selectedNodeToShowPositionHistory.short_name }} Position History</div>
|
|
<div class="flex my-auto ml-3 space-x-2">
|
|
<a href="javascript:void(0)" class="rounded-full" @click="dismissShowingNodePositionHistory">
|
|
<div class="bg-gray-100 hover:bg-gray-200 p-1 rounded-full">
|
|
<svg class="size-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
<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 v-if="isPositionHistoryModalExpanded" class="divide-y border-t">
|
|
|
|
<!-- quick range -->
|
|
<div class="flex p-2 space-x-2">
|
|
<button @click="onPositionHistoryQuickRangeClick('1h')" type="button" class="w-full bg-gray-100 rounded border shadow px-2 py-1 text-sm hover:bg-gray-200 text-center">1 Hour</button>
|
|
<button @click="onPositionHistoryQuickRangeClick('24h')" type="button" class="w-full bg-gray-100 rounded border shadow px-2 py-1 text-sm hover:bg-gray-200 text-center">24 Hours</button>
|
|
<button @click="onPositionHistoryQuickRangeClick('7d')" type="button" class="w-full bg-gray-100 rounded border shadow px-2 py-1 text-sm hover:bg-gray-200 text-center">7 Days</button>
|
|
</div>
|
|
|
|
<!-- manual range -->
|
|
<div class="p-2 space-y-1">
|
|
|
|
<!-- from -->
|
|
<div class="flex items-center">
|
|
<label class="text-sm pr-1 min-w-12 text-right">From:</label>
|
|
<input v-model="positionHistoryDateTimeFrom" @change="loadNodePositionHistory(selectedNodeToShowPositionHistory.node_id)" type="datetime-local" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1">
|
|
</div>
|
|
|
|
<!-- to -->
|
|
<div class="flex items-center">
|
|
<label class="text-sm pr-1 min-w-12 text-right">To:</label>
|
|
<input v-model="positionHistoryDateTimeTo" @change="loadNodePositionHistory(selectedNodeToShowPositionHistory.node_id)" type="datetime-local" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1">
|
|
</div>
|
|
|
|
</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() {
|
|
// use user preference, or enable by default
|
|
const value = localStorage.getItem("config_auto_update_position_in_url");
|
|
return value === "true" || value == null;
|
|
}
|
|
|
|
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 getConfigTemperatureFormat() {
|
|
return localStorage.getItem("config_temperature_format") || "celsius";
|
|
}
|
|
|
|
function setConfigTemperatureFormat(format) {
|
|
return localStorage.setItem("config_temperature_format", format);
|
|
}
|
|
|
|
function getConfigMapSelectedTileLayer() {
|
|
return localStorage.getItem("config_map_selected_tile_layer") || "Thunderforest Neighbourhood";
|
|
}
|
|
|
|
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", "Position History", "Traceroutes"];
|
|
|
|
}
|
|
|
|
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) : 10800;
|
|
}
|
|
|
|
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 getConfigConnectionsMaxDistanceInMeters() {
|
|
const value = localStorage.getItem("config_connections_max_distance_in_meters");
|
|
// default to 70km (70,000 meters)
|
|
return value != null ? parseInt(value) : 70000;
|
|
}
|
|
|
|
function setConfigConnectionsMaxDistanceInMeters(value) {
|
|
return localStorage.setItem("config_connections_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 getConfigConnectionsTimePeriodInSeconds() {
|
|
const value = localStorage.getItem("config_connections_time_period_in_seconds");
|
|
// default to 7 days if unset
|
|
return value != null ? parseInt(value) : 604800;
|
|
}
|
|
|
|
function setConfigConnectionsTimePeriodInSeconds(value) {
|
|
return localStorage.setItem("config_connections_time_period_in_seconds", value);
|
|
}
|
|
|
|
function getConfigConnectionsColoredLines() {
|
|
const value = localStorage.getItem("config_connections_colored_lines");
|
|
// disable colored lines by default
|
|
if(value === null){
|
|
return false;
|
|
}
|
|
return value === "true";
|
|
}
|
|
|
|
function setConfigConnectionsColoredLines(value) {
|
|
return localStorage.setItem("config_connections_colored_lines", value);
|
|
}
|
|
|
|
function getConfigConnectionsBidirectionalOnly() {
|
|
const value = localStorage.getItem("config_connections_bidirectional_only");
|
|
// disable bidirectional filter by default
|
|
if(value === null){
|
|
return false;
|
|
}
|
|
return value === "true";
|
|
}
|
|
|
|
function setConfigConnectionsBidirectionalOnly(value) {
|
|
return localStorage.setItem("config_connections_bidirectional_only", value);
|
|
}
|
|
|
|
function getConfigConnectionsMinSnrDb() {
|
|
const value = localStorage.getItem("config_connections_min_snr_db");
|
|
// default to null (unset)
|
|
if(value === null || value === ""){
|
|
return null;
|
|
}
|
|
const parsed = parseFloat(value);
|
|
return isNaN(parsed) ? null : parsed;
|
|
}
|
|
|
|
function setConfigConnectionsMinSnrDb(value) {
|
|
if(value === null || value === "" || value === undefined){
|
|
return localStorage.removeItem("config_connections_min_snr_db");
|
|
}
|
|
// Convert to string for localStorage (handles both number and string inputs)
|
|
const stringValue = typeof value === "number" ? value.toString() : String(value);
|
|
return localStorage.setItem("config_connections_min_snr_db", stringValue);
|
|
}
|
|
|
|
function getConfigConnectionsBidirectionalMinSnr() {
|
|
const value = localStorage.getItem("config_connections_bidirectional_min_snr");
|
|
// disable bidirectional minimum SNR by default
|
|
if(value === null){
|
|
return false;
|
|
}
|
|
return value === "true";
|
|
}
|
|
|
|
function setConfigConnectionsBidirectionalMinSnr(value) {
|
|
return localStorage.setItem("config_connections_bidirectional_min_snr", value);
|
|
}
|
|
|
|
function isMobile() {
|
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
}
|
|
|
|
</script>
|
|
|
|
<script>
|
|
Vue.createApp({
|
|
data() {
|
|
return {
|
|
|
|
isShowingAnnouncement: this.shouldShowAnnouncement(),
|
|
|
|
configNodesMaxAgeInSeconds: window.getConfigNodesMaxAgeInSeconds(),
|
|
configNodesOfflineAgeInSeconds: window.getConfigNodesOfflineAgeInSeconds(),
|
|
configWaypointsMaxAgeInSeconds: window.getConfigWaypointsMaxAgeInSeconds(),
|
|
configConnectionsMaxDistanceInMeters: window.getConfigConnectionsMaxDistanceInMeters(),
|
|
configZoomLevelGoToNode: window.getConfigZoomLevelGoToNode(),
|
|
configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(),
|
|
configEnableMapAnimations: window.getConfigEnableMapAnimations(),
|
|
configTemperatureFormat: window.getConfigTemperatureFormat(),
|
|
configConnectionsTimePeriodInSeconds: window.getConfigConnectionsTimePeriodInSeconds(),
|
|
configConnectionsColoredLines: window.getConfigConnectionsColoredLines(),
|
|
configConnectionsBidirectionalOnly: window.getConfigConnectionsBidirectionalOnly(),
|
|
configConnectionsMinSnrDb: window.getConfigConnectionsMinSnrDb(),
|
|
configConnectionsBidirectionalMinSnr: window.getConfigConnectionsBidirectionalMinSnr(),
|
|
|
|
isShowingHardwareModels: false,
|
|
hardwareModelStats: null,
|
|
|
|
isShowingInfoModal: this.shouldShowInfoModal(),
|
|
isShowingMobileSearch: false,
|
|
isShowingSettings: false,
|
|
|
|
nodes: [],
|
|
searchText: "",
|
|
|
|
selectedNode: null,
|
|
selectedNodeDeviceMetrics: [],
|
|
selectedNodeEnvironmentMetrics: [],
|
|
selectedNodePowerMetrics: [],
|
|
selectedNodeMqttMetrics: [],
|
|
selectedNodeTraceroutes: [],
|
|
|
|
deviceMetricsTimeRange: "7d",
|
|
environmentMetricsTimeRange: "7d",
|
|
powerMetricsTimeRange: "7d",
|
|
|
|
isPositionHistoryModalExpanded: true,
|
|
positionHistoryDateTimeFrom: null,
|
|
positionHistoryDateTimeTo: null,
|
|
selectedNodePositionHistory: [],
|
|
selectedNodeToShowPositionHistory: null,
|
|
selectedNodePositionHistoryMarkers: [],
|
|
selectedNodePositionHistoryPolyLines: [],
|
|
|
|
selectedTraceRoute: null,
|
|
tracerouteEdges: [],
|
|
|
|
selectedNodeToShowConnections: 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.loadNodeEnvironmentMetrics(node.node_id);
|
|
this.loadNodePowerMetrics(node.node_id);
|
|
this.loadNodeMqttMetrics(node.node_id);
|
|
this.loadNodeTraceroutes(node.node_id);
|
|
//this.loadNodePositionHistory(node.node_id);
|
|
};
|
|
|
|
// handle node callback from outside of vue
|
|
window._onShowNodeConnectionsClick = (node) => {
|
|
this.selectedNodeToShowConnections = node;
|
|
};
|
|
|
|
// handle nodes updated callback from outside of vue
|
|
window._onNodesUpdated = (nodes) => {
|
|
this.nodes = nodes;
|
|
};
|
|
|
|
},
|
|
methods: {
|
|
getAnnouncementId: function() {
|
|
// change this when making a new announcement
|
|
return "1";
|
|
},
|
|
shouldShowAnnouncement: function() {
|
|
const lastSeenAnnouncementId = window.localStorage.getItem("last-seen-announcement-id");
|
|
return lastSeenAnnouncementId?.toString() !== this.getAnnouncementId();
|
|
},
|
|
dismissAnnouncement: function() {
|
|
window.localStorage.setItem("last-seen-announcement-id", this.getAnnouncementId());
|
|
this.isShowingAnnouncement = false;
|
|
},
|
|
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) {
|
|
|
|
// calculate unix timestamps in milliseconds for supported time ranges
|
|
const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
|
|
const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
|
|
const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
|
|
const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10);
|
|
|
|
// determine how long back to load device metrics from
|
|
var timeFrom = threeDaysAgoInMilliseconds;
|
|
switch(this.deviceMetricsTimeRange){
|
|
case "1d": {
|
|
timeFrom = oneDayAgoInMilliseconds;
|
|
break;
|
|
}
|
|
case "3d": {
|
|
timeFrom = threeDaysAgoInMilliseconds;
|
|
break;
|
|
}
|
|
case "7d": {
|
|
timeFrom = sevenDaysAgoInMilliseconds;
|
|
break;
|
|
}
|
|
case "30d": {
|
|
timeFrom = thirtyDaysAgoInMilliseconds;
|
|
break;
|
|
}
|
|
}
|
|
|
|
window.axios.get(`/api/v1/nodes/${nodeId}/device-metrics`, {
|
|
params: {
|
|
time_from: timeFrom,
|
|
},
|
|
}).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();
|
|
});
|
|
},
|
|
loadNodeEnvironmentMetrics: function(nodeId) {
|
|
|
|
// calculate unix timestamps in milliseconds for supported time ranges
|
|
const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
|
|
const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
|
|
const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
|
|
const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10);
|
|
|
|
// determine how long back to load environment metrics from
|
|
var timeFrom = threeDaysAgoInMilliseconds;
|
|
switch(this.environmentMetricsTimeRange){
|
|
case "1d": {
|
|
timeFrom = oneDayAgoInMilliseconds;
|
|
break;
|
|
}
|
|
case "3d": {
|
|
timeFrom = threeDaysAgoInMilliseconds;
|
|
break;
|
|
}
|
|
case "7d": {
|
|
timeFrom = sevenDaysAgoInMilliseconds;
|
|
break;
|
|
}
|
|
case "30d": {
|
|
timeFrom = thirtyDaysAgoInMilliseconds;
|
|
break;
|
|
}
|
|
}
|
|
|
|
window.axios.get(`/api/v1/nodes/${nodeId}/environment-metrics`, {
|
|
params: {
|
|
time_from: timeFrom,
|
|
},
|
|
}).then((response) => {
|
|
// reverse response, as it's newest to oldest, but we want oldest to newest
|
|
this.selectedNodeEnvironmentMetrics = response.data.environment_metrics.reverse();
|
|
this.renderEnvironmentMetricCharts();
|
|
}).catch(() => {
|
|
this.selectedNodeEnvironmentMetrics = [];
|
|
this.renderEnvironmentMetricCharts();
|
|
});
|
|
},
|
|
loadNodePowerMetrics: function(nodeId) {
|
|
|
|
// calculate unix timestamps in milliseconds for supported time ranges
|
|
const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
|
|
const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
|
|
const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
|
|
const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10);
|
|
|
|
// determine how long back to load power metrics from
|
|
var timeFrom = threeDaysAgoInMilliseconds;
|
|
switch(this.powerMetricsTimeRange){
|
|
case "1d": {
|
|
timeFrom = oneDayAgoInMilliseconds;
|
|
break;
|
|
}
|
|
case "3d": {
|
|
timeFrom = threeDaysAgoInMilliseconds;
|
|
break;
|
|
}
|
|
case "7d": {
|
|
timeFrom = sevenDaysAgoInMilliseconds;
|
|
break;
|
|
}
|
|
case "30d": {
|
|
timeFrom = thirtyDaysAgoInMilliseconds;
|
|
break;
|
|
}
|
|
}
|
|
|
|
window.axios.get(`/api/v1/nodes/${nodeId}/power-metrics`, {
|
|
params: {
|
|
time_from: timeFrom,
|
|
},
|
|
}).then((response) => {
|
|
// reverse response, as it's newest to oldest, but we want oldest to newest
|
|
this.selectedNodePowerMetrics = response.data.power_metrics.reverse();
|
|
this.renderPowerMetricCharts();
|
|
}).catch(() => {
|
|
this.selectedNodePowerMetrics = [];
|
|
this.renderPowerMetricCharts();
|
|
});
|
|
},
|
|
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
|
|
});
|
|
},
|
|
loadNodePositionHistory: function(nodeId) {
|
|
this.selectedNodePositionHistory = [];
|
|
window.axios.get(`/api/v1/nodes/${nodeId}/position-history`, {
|
|
params: {
|
|
// parse from datetime-local format, and send as unix timestamp in milliseconds
|
|
time_from: moment(this.positionHistoryDateTimeFrom, "YYYY-MM-DDTHH:mm").format("x"),
|
|
time_to: moment(this.positionHistoryDateTimeTo, "YYYY-MM-DDTHH:mm").format("x"),
|
|
},
|
|
}).then((response) => {
|
|
this.selectedNodePositionHistory = response.data.position_history;
|
|
if(this.selectedNodeToShowPositionHistory != null){
|
|
clearAllPositionHistory();
|
|
onPositionHistoryUpdated(response.data.position_history);
|
|
}
|
|
|
|
}).catch(() => {
|
|
// do nothing
|
|
});
|
|
},
|
|
renderDeviceMetricCharts: function() {
|
|
try {
|
|
this.updateDeviceMetricsChart();
|
|
} catch(e) {
|
|
console.log(e);
|
|
}
|
|
},
|
|
updateDeviceMetricsChart: function() {
|
|
|
|
// destroy existing chart
|
|
const chartElementId = "deviceMetricsChart";
|
|
const existingChart = window.Chart.getChart(chartElementId);
|
|
if(existingChart != null){
|
|
existingChart.destroy();
|
|
}
|
|
|
|
// get chart element
|
|
const chartElement = window.document.getElementById(chartElementId);
|
|
if(!chartElement){
|
|
return;
|
|
}
|
|
|
|
// create chart data
|
|
const labels = [];
|
|
const batteryMetrics = [];
|
|
const channelUtilizationMetrics = [];
|
|
const airUtilTxMetrics = [];
|
|
for(const deviceMetric of this.selectedNodeDeviceMetrics){
|
|
labels.push(moment(deviceMetric.created_at));
|
|
batteryMetrics.push(deviceMetric.battery_level);
|
|
channelUtilizationMetrics.push(deviceMetric.channel_utilization);
|
|
airUtilTxMetrics.push(deviceMetric.air_util_tx);
|
|
}
|
|
|
|
// create chart
|
|
new window.Chart(chartElement, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
label: 'Battery Level',
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: '#3b82f6',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: batteryMetrics,
|
|
},
|
|
{
|
|
label: 'Channel Util',
|
|
borderColor: '#22c55e',
|
|
backgroundColor: '#22c55e',
|
|
showLine: false, // no lines between points
|
|
fill: false,
|
|
data: channelUtilizationMetrics,
|
|
},
|
|
{
|
|
label: 'Air Util TX',
|
|
borderColor: '#f97316',
|
|
backgroundColor: '#f97316',
|
|
showLine: false, // no lines between points
|
|
fill: false,
|
|
data: airUtilTxMetrics,
|
|
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
borderWidth: 2,
|
|
elements: {
|
|
point: {
|
|
radius: 2,
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
position: 'top',
|
|
type: 'time',
|
|
time: {
|
|
unit: 'day',
|
|
displayFormats: {
|
|
day: 'MMM DD', // Jan 01
|
|
},
|
|
},
|
|
},
|
|
y: {
|
|
min: 0,
|
|
max: 101, // 101 is "Plugged In", need to include for tooltip to work
|
|
ticks: {
|
|
callback: (label) => `${label}%`,
|
|
},
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false,
|
|
},
|
|
tooltip: {
|
|
mode: "index",
|
|
intersect: false,
|
|
callbacks: {
|
|
label: (item) => {
|
|
return `${item.dataset.label}: ${item.formattedValue}%`;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
});
|
|
|
|
},
|
|
renderEnvironmentMetricCharts: function() {
|
|
try {
|
|
this.updateEnvironmentMetricsChart();
|
|
} catch(e) {
|
|
console.log(e);
|
|
}
|
|
},
|
|
updateEnvironmentMetricsChart: function() {
|
|
|
|
// destroy existing chart
|
|
const chartElementId = "environmentMetricsChart";
|
|
const existingChart = window.Chart.getChart(chartElementId);
|
|
if(existingChart != null){
|
|
existingChart.destroy();
|
|
}
|
|
|
|
// get chart element
|
|
const chartElement = window.document.getElementById(chartElementId);
|
|
if(!chartElement){
|
|
return;
|
|
}
|
|
|
|
// create chart data
|
|
const labels = [];
|
|
const temperatureMetrics = [];
|
|
const relativeHumidityMetrics = [];
|
|
const barometricPressureMetrics = [];
|
|
const iaqMetrics = [];
|
|
for(const deviceMetric of this.selectedNodeEnvironmentMetrics){
|
|
labels.push(moment(deviceMetric.created_at));
|
|
temperatureMetrics.push(deviceMetric.temperature);
|
|
relativeHumidityMetrics.push(deviceMetric.relative_humidity);
|
|
barometricPressureMetrics.push(deviceMetric.barometric_pressure);
|
|
iaqMetrics.push(deviceMetric.iaq);
|
|
}
|
|
|
|
// create chart
|
|
new window.Chart(chartElement, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
label: 'Temperature',
|
|
suffix: '°C',
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: '#3b82f6',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: temperatureMetrics,
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'Humidity',
|
|
suffix: '%',
|
|
borderColor: '#22c55e',
|
|
backgroundColor: '#22c55e',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: relativeHumidityMetrics,
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'Pressure',
|
|
suffix: 'hPa',
|
|
borderColor: '#f97316',
|
|
backgroundColor: '#f97316',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: barometricPressureMetrics,
|
|
yAxisID: 'y1',
|
|
|
|
},
|
|
{
|
|
label: 'IAQ',
|
|
suffix: 'IAQ',
|
|
borderColor: '#f472b6',
|
|
backgroundColor: '#f472b6',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: iaqMetrics,
|
|
yAxisID: 'yIAQ',
|
|
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
borderWidth: 2,
|
|
spanGaps: 1000 * 60 * 60 * 24, // only show lines between metrics with a 24 hour or less gap
|
|
elements: {
|
|
point: {
|
|
radius: 2,
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
position: 'top',
|
|
type: 'time',
|
|
time: {
|
|
unit: 'day',
|
|
displayFormats: {
|
|
day: 'MMM DD', // Jan 01
|
|
},
|
|
},
|
|
},
|
|
y: {
|
|
min: -20,
|
|
max: 100,
|
|
},
|
|
y1: {
|
|
min: 800,
|
|
max: 1100,
|
|
ticks: {
|
|
stepSize: 10,
|
|
callback: (label) => `${label} hPa`,
|
|
},
|
|
position: 'right',
|
|
grid: {
|
|
drawOnChartArea: false, // only want the grid lines for one axis to show up
|
|
},
|
|
},
|
|
yIAQ: {
|
|
type: 'linear',
|
|
display: false,
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false,
|
|
},
|
|
tooltip: {
|
|
mode: "index",
|
|
intersect: false,
|
|
callbacks: {
|
|
label: (item) => {
|
|
return `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}`;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
});
|
|
|
|
},
|
|
renderPowerMetricCharts: function() {
|
|
try {
|
|
this.updatePowerMetricsChart();
|
|
} catch(e) {
|
|
console.log(e);
|
|
}
|
|
},
|
|
updatePowerMetricsChart: function() {
|
|
|
|
// destroy existing chart
|
|
const chartElementId = "powerMetricsChart";
|
|
const existingChart = window.Chart.getChart(chartElementId);
|
|
if(existingChart != null){
|
|
existingChart.destroy();
|
|
}
|
|
|
|
// get chart element
|
|
const chartElement = window.document.getElementById(chartElementId);
|
|
if(!chartElement){
|
|
return;
|
|
}
|
|
|
|
// create chart data
|
|
const labels = [];
|
|
const channel1VoltageReadings = [];
|
|
const channel2VoltageReadings = [];
|
|
const channel3VoltageReadings = [];
|
|
const channel1CurrentReadings = [];
|
|
const channel2CurrentReadings = [];
|
|
const channel3CurrentReadings = [];
|
|
for(const powerMetric of this.selectedNodePowerMetrics){
|
|
labels.push(moment(powerMetric.created_at));
|
|
channel1VoltageReadings.push(powerMetric.ch1_voltage);
|
|
channel2VoltageReadings.push(powerMetric.ch2_voltage);
|
|
channel3VoltageReadings.push(powerMetric.ch3_voltage);
|
|
channel1CurrentReadings.push(powerMetric.ch1_current);
|
|
channel2CurrentReadings.push(powerMetric.ch2_current);
|
|
channel3CurrentReadings.push(powerMetric.ch3_current);
|
|
}
|
|
|
|
// create chart
|
|
new window.Chart(chartElement, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
label: 'Ch1 Voltage',
|
|
suffix: "V",
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: '#3b82f6',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: channel1VoltageReadings,
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'Ch2 Voltage',
|
|
suffix: "V",
|
|
borderColor: '#22c55e',
|
|
backgroundColor: '#22c55e',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: channel2VoltageReadings,
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'Ch3 Voltage',
|
|
suffix: "V",
|
|
borderColor: '#f97316',
|
|
backgroundColor: '#f97316',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: channel3VoltageReadings,
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'Ch1 Current',
|
|
suffix: "mA",
|
|
borderColor: '#93c5fd',
|
|
backgroundColor: '#93c5fd',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: channel1CurrentReadings,
|
|
yAxisID: 'y1',
|
|
},
|
|
{
|
|
label: 'Ch2 Current',
|
|
suffix: "mA",
|
|
borderColor: '#86efac',
|
|
backgroundColor: '#86efac',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: channel2CurrentReadings,
|
|
yAxisID: 'y1',
|
|
},
|
|
{
|
|
label: 'Ch3 Current',
|
|
suffix: "mA",
|
|
borderColor: '#fdba74',
|
|
backgroundColor: '#fdba74',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: channel3CurrentReadings,
|
|
yAxisID: 'y1',
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
borderWidth: 2,
|
|
spanGaps: 1000 * 60 * 60 * 3, // only show lines between metrics with a 3 hour or less gap
|
|
elements: {
|
|
point: {
|
|
radius: 2,
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
position: 'top',
|
|
type: 'time',
|
|
time: {
|
|
unit: 'day',
|
|
displayFormats: {
|
|
day: 'MMM DD', // Jan 01
|
|
},
|
|
},
|
|
},
|
|
y: {
|
|
min: 0,
|
|
suggestedMax: 6,
|
|
ticks: {
|
|
callback: (label) => `${label}V`,
|
|
},
|
|
},
|
|
y1: {
|
|
suggestedMin: -50,
|
|
suggestedMax: 50,
|
|
ticks: {
|
|
stepSize: 50,
|
|
callback: (label) => `${label}mA`,
|
|
},
|
|
position: 'right',
|
|
grid: {
|
|
drawOnChartArea: false, // only want the grid lines for one axis to show up
|
|
},
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false,
|
|
},
|
|
tooltip: {
|
|
mode: "index",
|
|
intersect: false,
|
|
callbacks: {
|
|
label: (item) => {
|
|
return `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}`;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
});
|
|
|
|
},
|
|
showTraceRoute: function(traceroute) {
|
|
this.selectedTraceRoute = traceroute;
|
|
},
|
|
findNodeById: function(id) {
|
|
return window.findNodeById(id);
|
|
},
|
|
findNodeMarkerById: function(id) {
|
|
return window.findNodeMarkerById(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);
|
|
},
|
|
showNodePositionHistory: function(nodeId) {
|
|
|
|
// find node
|
|
const node = findNodeById(nodeId);
|
|
if(!node){
|
|
return;
|
|
}
|
|
|
|
// update ui
|
|
this.selectedNode = null;
|
|
this.selectedNodeToShowPositionHistory = node;
|
|
this.isPositionHistoryModalExpanded = true;
|
|
|
|
// close node info tooltip as position history shows under it
|
|
window.closeAllTooltips();
|
|
|
|
// reset default time range when opening position history ui
|
|
// YYYY-MM-DDTHH:mm is the format expected by the datetime-local input type
|
|
this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm');
|
|
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
|
|
|
|
// load position history
|
|
this.loadNodePositionHistory(nodeId);
|
|
|
|
},
|
|
onPositionHistoryQuickRangeClick: function(range) {
|
|
|
|
// update position history time range
|
|
switch(range){
|
|
case "1h": {
|
|
this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm');
|
|
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
|
|
break;
|
|
}
|
|
case "24h": {
|
|
this.positionHistoryDateTimeFrom = moment().subtract(24, "hours").format('YYYY-MM-DDTHH:mm');
|
|
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
|
|
break;
|
|
}
|
|
case "7d": {
|
|
this.positionHistoryDateTimeFrom = moment().subtract(7, "days").format('YYYY-MM-DDTHH:mm');
|
|
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
|
|
break;
|
|
}
|
|
}
|
|
|
|
// reload position history
|
|
const node = this.selectedNodeToShowPositionHistory;
|
|
if(node){
|
|
this.loadNodePositionHistory(node.node_id);
|
|
}
|
|
|
|
},
|
|
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 user we copied it
|
|
alert("Link copied to clipboard!");
|
|
|
|
},
|
|
dismissShowingNodeConnections: function() {
|
|
window._onHideNodeConnectionsClick();
|
|
this.selectedNodeToShowConnections = null;
|
|
},
|
|
dismissShowingNodePositionHistory: function() {
|
|
this.selectedNodePositionHistory = [];
|
|
this.selectedNodeToShowPositionHistory = null;
|
|
this.selectedNodePositionHistoryMarkers = [];
|
|
this.selectedNodePositionHistoryPolyLines = [];
|
|
cleanUpPositionHistory();
|
|
},
|
|
formatUptimeSeconds: function(secondsToFormat) {
|
|
secondsToFormat = Number(secondsToFormat);
|
|
var days = Math.floor(secondsToFormat / (3600 * 24));
|
|
var hours = Math.floor((secondsToFormat % (3600 * 24)) / 3600);
|
|
var minutes = Math.floor((secondsToFormat % 3600) / 60);
|
|
var seconds = Math.floor(secondsToFormat % 60);
|
|
var daysPlural = days === 1 ? 'day' : 'days';
|
|
return `${days} ${daysPlural} ${hours}h ${minutes}m ${seconds}s`;
|
|
},
|
|
formatTemperature: function(celsius) {
|
|
switch(this.configTemperatureFormat){
|
|
case "celsius": {
|
|
return `${Number(celsius).toFixed(0)}°C`;
|
|
}
|
|
case "fahrenheit": {
|
|
const fahrenheit = this.celsiusToFahrenheit(celsius);
|
|
return `${fahrenheit.toFixed(0)}°F`;
|
|
}
|
|
}
|
|
},
|
|
convertTemperature: function(celsius) {
|
|
switch(this.configTemperatureFormat){
|
|
case "celsius": {
|
|
return celsius;
|
|
}
|
|
case "fahrenheit": {
|
|
return this.celsiusToFahrenheit(celsius);
|
|
}
|
|
}
|
|
},
|
|
getTemperatureUnit: function() {
|
|
switch(this.configTemperatureFormat){
|
|
case "celsius": return "°C";
|
|
case "fahrenheit": return "°F";
|
|
}
|
|
},
|
|
celsiusToFahrenheit: function(celsius) {
|
|
return (celsius * 9/5) + 32;
|
|
},
|
|
getNodeColour(nodeId) {
|
|
// convert node id to a hex colour
|
|
return "#" + (nodeId & 0x00FFFFFF).toString(16).padStart(6, '0');
|
|
},
|
|
getNodeTextColour(nodeId) {
|
|
|
|
// extract rgb components
|
|
const r = (nodeId & 0xFF0000) >> 16;
|
|
const g = (nodeId & 0x00FF00) >> 8;
|
|
const b = nodeId & 0x0000FF;
|
|
|
|
// calculate brightness
|
|
const brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255;
|
|
|
|
// determine text color based on brightness
|
|
return brightness > 0.5 ? "#000000" : "#FFFFFF";
|
|
|
|
},
|
|
},
|
|
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);
|
|
});
|
|
|
|
// only return the first 500 results to avoid ui lag...
|
|
return nodes.slice(0, 500);
|
|
|
|
},
|
|
selectedNodeLatestPowerMetric() {
|
|
const [ latestPowerMetric ] = this.selectedNodePowerMetrics.slice(-1);
|
|
return latestPowerMetric;
|
|
},
|
|
},
|
|
watch: {
|
|
configNodesMaxAgeInSeconds() {
|
|
window.setConfigNodesMaxAgeInSeconds(this.configNodesMaxAgeInSeconds);
|
|
},
|
|
configNodesOfflineAgeInSeconds() {
|
|
window.setConfigNodesOfflineAgeInSeconds(this.configNodesOfflineAgeInSeconds);
|
|
},
|
|
configWaypointsMaxAgeInSeconds() {
|
|
window.setConfigWaypointsMaxAgeInSeconds(this.configWaypointsMaxAgeInSeconds);
|
|
},
|
|
configConnectionsMaxDistanceInMeters() {
|
|
window.setConfigConnectionsMaxDistanceInMeters(this.configConnectionsMaxDistanceInMeters);
|
|
},
|
|
configZoomLevelGoToNode() {
|
|
window.setConfigZoomLevelGoToNode(this.configZoomLevelGoToNode);
|
|
},
|
|
configAutoUpdatePositionInUrl() {
|
|
window.setConfigAutoUpdatePositionInUrl(this.configAutoUpdatePositionInUrl);
|
|
},
|
|
configEnableMapAnimations() {
|
|
window.setConfigEnableMapAnimations(this.configEnableMapAnimations);
|
|
},
|
|
configTemperatureFormat() {
|
|
window.setConfigTemperatureFormat(this.configTemperatureFormat);
|
|
},
|
|
configConnectionsTimePeriodInSeconds() {
|
|
window.setConfigConnectionsTimePeriodInSeconds(this.configConnectionsTimePeriodInSeconds);
|
|
},
|
|
configConnectionsColoredLines() {
|
|
window.setConfigConnectionsColoredLines(this.configConnectionsColoredLines);
|
|
},
|
|
configConnectionsBidirectionalOnly() {
|
|
window.setConfigConnectionsBidirectionalOnly(this.configConnectionsBidirectionalOnly);
|
|
},
|
|
configConnectionsMinSnrDb() {
|
|
window.setConfigConnectionsMinSnrDb(this.configConnectionsMinSnrDb);
|
|
},
|
|
configConnectionsBidirectionalMinSnr() {
|
|
window.setConfigConnectionsBidirectionalMinSnr(this.configConnectionsBidirectionalMinSnr);
|
|
},
|
|
deviceMetricsTimeRange() {
|
|
this.loadNodeDeviceMetrics(this.selectedNode.node_id);
|
|
},
|
|
environmentMetricsTimeRange() {
|
|
this.loadNodeEnvironmentMetrics(this.selectedNode.node_id);
|
|
},
|
|
powerMetricsTimeRange() {
|
|
this.loadNodePowerMetrics(this.selectedNode.node_id);
|
|
},
|
|
},
|
|
}).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 NRW
|
|
if(!isMobile()){
|
|
var map = L.map('map', {
|
|
maxBounds: bounds,
|
|
}).setView([
|
|
51.1,
|
|
366.82,
|
|
], 9);
|
|
} else {
|
|
var map = L.map('map', {
|
|
maxBounds: bounds,
|
|
}).setView([
|
|
51.1,
|
|
366.82,
|
|
], 8);
|
|
}
|
|
|
|
// remove leaflet link
|
|
map.attributionControl.setPrefix('');
|
|
|
|
var openThunderforestLandscapeMapTileLayer = L.tileLayer('https://tiles.nixware.dev/landscape/{z}/{x}/{y}.png', {
|
|
maxZoom: 22,
|
|
attribution: 'Tiles © <a href="https://www.thunderforest.com/contact/">Gravitystorm Limited | Data from <a target="_blank" href="https://meshtastic.org/docs/software/integrations/mqtt/">Meshtastic</a>',
|
|
});
|
|
|
|
var openThunderforestAtlasMapTileLayer = L.tileLayer('https://tiles.nixware.dev/atlas/{z}/{x}/{y}.png', {
|
|
maxZoom: 22,
|
|
attribution: 'Tiles © <a href="https://www.thunderforest.com/contact/">Gravitystorm Limited | Data from <a target="_blank" href="https://meshtastic.org/docs/software/integrations/mqtt/">Meshtastic</a>',
|
|
});
|
|
|
|
var openThunderforestNeighbourhoodMapTileLayer = L.tileLayer('https://tiles.nixware.dev/neighbourhood/{z}/{x}/{y}.png', {
|
|
maxZoom: 22,
|
|
attribution: 'Tiles © <a href="https://www.thunderforest.com/contact/">Gravitystorm Limited | Data from <a target="_blank" href="https://meshtastic.org/docs/software/integrations/mqtt/">Meshtastic</a>',
|
|
});
|
|
|
|
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 openTopoMapTileLayer = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 17, // open topo map doesn't have tiles closer than this
|
|
attribution: 'Tiles © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap | 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 = {
|
|
"Thunderforest Neighbourhood": openThunderforestNeighbourhoodMapTileLayer,
|
|
"Thunderforest Landscape": openThunderforestLandscapeMapTileLayer,
|
|
"Thunderforest Atlas": openThunderforestAtlasMapTileLayer,
|
|
"OpenStreetMap": openStreetMapTileLayer,
|
|
"OpenTopoMap": openTopoMapTileLayer,
|
|
"Esri Satellite": esriWorldImageryTileLayer,
|
|
"Google Satellite": googleSatelliteTileLayer,
|
|
"Google Hybrid": googleHybridTileLayer,
|
|
};
|
|
|
|
// use tile layer based on config
|
|
const selectedTileLayerName = getConfigMapSelectedTileLayer();
|
|
const selectedTileLayer = tileLayers[selectedTileLayerName] || openThunderforestNeighbourhoodMapTileLayer;
|
|
selectedTileLayer.addTo(map);
|
|
|
|
// create layer groups
|
|
var nodesLayerGroup = new L.LayerGroup();
|
|
var backboneConnectionsLayerGroup = new L.LayerGroup();
|
|
var nodeConnectionsLayerGroup = new L.LayerGroup();
|
|
var nodesClusteredLayerGroup = L.markerClusterGroup({
|
|
showCoverageOnHover: false,
|
|
disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
|
|
});
|
|
var nodesRouterLayerGroup = L.markerClusterGroup({
|
|
showCoverageOnHover: false,
|
|
disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
|
|
});
|
|
var nodesBackboneLayerGroup = new L.LayerGroup();
|
|
//var nodesMediumFastLayerGroup = new L.LayerGroup();
|
|
var nodesShortSlowLayerGroup = new L.LayerGroup();
|
|
var nodesLongFastLayerGroup = new L.LayerGroup();
|
|
var waypointsLayerGroup = new L.LayerGroup();
|
|
var nodePositionHistoryLayerGroup = new L.LayerGroup();
|
|
var traceroutesLayerGroup = new L.LayerGroup();
|
|
var connectionsLayerGroup = 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 iconLongFast = L.divIcon({
|
|
className: 'icon-longfast',
|
|
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
|
});
|
|
|
|
/*var iconMediumFast = L.divIcon({
|
|
className: 'icon-mediumfast',
|
|
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
|
});*/
|
|
|
|
var iconShortSlow = L.divIcon({
|
|
className: 'icon-shortslow',
|
|
iconSize: [16, 16],
|
|
});
|
|
|
|
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
|
|
});
|
|
|
|
var iconPositionHistory = L.divIcon({
|
|
className: 'icon-position-history',
|
|
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
|
});
|
|
|
|
var iconTracerouteStart = L.divIcon({
|
|
className: 'icon-traceroute-start',
|
|
iconSize: [16, 16],
|
|
});
|
|
|
|
var iconTracerouteEnd = L.divIcon({
|
|
className: 'icon-traceroute-end',
|
|
iconSize: [16, 16],
|
|
});
|
|
|
|
// 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-shortslow" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> ShortSlow</div>`
|
|
//+ `<div style="display:flex"><div class="icon-mediumfast" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> MediumFast</div>`
|
|
+ `<div style="display:flex"><div class="icon-longfast" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> LongFast</div>`
|
|
+ `<div style="display:flex"><div class="icon-offline" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> Offline Too Long</div>`
|
|
+ `<div style="display:flex"><svg width="16" height="3" style="margin-right:4px;margin-top:auto;margin-bottom:auto;"><line x1="0" y1="1.5" x2="16" y2="1.5" stroke="#f97316" stroke-width="4"/></svg> Traceroute</div>`;
|
|
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,
|
|
"Routers": nodesRouterLayerGroup,
|
|
"Backbone": nodesBackboneLayerGroup,
|
|
"ShortSlow": nodesShortSlowLayerGroup,
|
|
//"MediumFast": nodesMediumFastLayerGroup,
|
|
"LongFast": nodesLongFastLayerGroup,
|
|
"Clustered": nodesClusteredLayerGroup,
|
|
"None": new L.LayerGroup(),
|
|
},
|
|
"Overlays": {
|
|
"Legend": legendLayerGroup,
|
|
"Backbone Connections": backboneConnectionsLayerGroup,
|
|
"Connections": connectionsLayerGroup,
|
|
"Waypoints": waypointsLayerGroup,
|
|
"Position History": nodePositionHistoryLayerGroup,
|
|
"Traceroutes": traceroutesLayerGroup,
|
|
},
|
|
}, {
|
|
// make the "Nodes" group exclusive (use radio inputs instead of checkbox)
|
|
exclusiveGroups: ["Nodes"],
|
|
}).addTo(map);
|
|
|
|
// enable base layers
|
|
nodesLayerGroup.addTo(map);
|
|
|
|
// enable overlay layers based on config
|
|
const enabledOverlayLayers = getConfigMapEnabledOverlayLayers();
|
|
if(enabledOverlayLayers.includes("Legend")){
|
|
legendLayerGroup.addTo(map);
|
|
}
|
|
if(enabledOverlayLayers.includes("Backbone Connection")){
|
|
backboneConnectionsLayerGroup.addTo(map);
|
|
}
|
|
if(enabledOverlayLayers.includes("Connections")){
|
|
connectionsLayerGroup.addTo(map);
|
|
}
|
|
if(enabledOverlayLayers.includes("Waypoints")){
|
|
waypointsLayerGroup.addTo(map);
|
|
}
|
|
if(enabledOverlayLayers.includes("Position History")){
|
|
nodePositionHistoryLayerGroup.addTo(map);
|
|
}
|
|
if(enabledOverlayLayers.includes("Traceroutes")){
|
|
traceroutesLayerGroup.addTo(map);
|
|
}
|
|
|
|
map.on('overlayadd', function(event) {
|
|
// update config when map overlay is added
|
|
const layerName = event.name;
|
|
const enabledOverlayLayers = getConfigMapEnabledOverlayLayers();
|
|
if(!enabledOverlayLayers.includes(layerName)){
|
|
enabledOverlayLayers.push(layerName);
|
|
}
|
|
setConfigMapEnabledOverlayLayers(enabledOverlayLayers);
|
|
|
|
// clear traceroutes layer when traceroutes overlay is added
|
|
if (layerName === "Traceroutes") {
|
|
traceroutesLayerGroup.clearLayers();
|
|
}
|
|
});
|
|
|
|
// 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){
|
|
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){
|
|
|
|
// go to node
|
|
if(window.goToNode(randomNode.node_id)){
|
|
return;
|
|
}
|
|
|
|
// fallback to showing node details since we can't go to the node
|
|
window.showNodeDetails(randomNode.node_id);
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearAllNodes() {
|
|
nodesLayerGroup.clearLayers();
|
|
nodesClusteredLayerGroup.clearLayers();
|
|
nodesRouterLayerGroup.clearLayers();
|
|
nodesBackboneLayerGroup.clearLayers();
|
|
nodesShortSlowLayerGroup.clearLayers();
|
|
//nodesMediumFastLayerGroup.clearLayers();
|
|
nodesLongFastLayerGroup.clearLayers();
|
|
}
|
|
|
|
function clearAllBackboneConnections() {
|
|
backboneConnectionsLayerGroup.clearLayers();
|
|
}
|
|
|
|
function clearAllWaypoints() {
|
|
waypointsLayerGroup.clearLayers();
|
|
}
|
|
|
|
function clearAllTraceroutes() {
|
|
traceroutesLayerGroup.clearLayers();
|
|
}
|
|
|
|
function clearAllConnections() {
|
|
connectionsLayerGroup.clearLayers();
|
|
backboneConnectionsLayerGroup.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 clearAllPositionHistory() {
|
|
nodePositionHistoryLayerGroup.clearLayers();
|
|
}
|
|
|
|
function clearNodeOutline() {
|
|
if(selectedNodeOutlineCircle){
|
|
selectedNodeOutlineCircle.removeFrom(map);
|
|
selectedNodeOutlineCircle = null;
|
|
}
|
|
}
|
|
|
|
function showNodeOutline(id) {
|
|
|
|
// remove any existing node circle
|
|
clearNodeOutline();
|
|
|
|
// find node marker by id
|
|
const nodeMarker = nodeMarkers[id];
|
|
if(!nodeMarker){
|
|
return;
|
|
}
|
|
|
|
// find node by id
|
|
const node = findNodeById(id);
|
|
if(!node){
|
|
return;
|
|
}
|
|
|
|
// add position precision circle around node
|
|
if(node.position_precision != null && node.position_precision > 0 && node.position_precision < 32){
|
|
selectedNodeOutlineCircle = L.circle(nodeMarker.getLatLng(), {
|
|
radius: getPositionPrecisionInMeters(node.position_precision),
|
|
}).addTo(map);
|
|
}
|
|
|
|
}
|
|
|
|
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 >= -4) return "#16a34a"; // good
|
|
if(snr > -8) return "#fff200"; // medium-good
|
|
if(snr > -12) return "#ff9f1c"; // medium
|
|
return "#dc2626"; // bad
|
|
}
|
|
|
|
function getSignalBarsIndicator(snrDb) {
|
|
if(snrDb == null) return '';
|
|
|
|
// Determine number of bars based on SNR
|
|
let bars = 0;
|
|
if(snrDb >= -4) bars = 4; // good
|
|
else if(snrDb > -8) bars = 3; // medium-good
|
|
else if(snrDb > -12) bars = 2; // medium
|
|
else bars = 1; // bad
|
|
|
|
const color = getColourForSnr(snrDb);
|
|
|
|
// Create 4 bars with increasing height
|
|
let indicator = '<span style="display: inline-flex; align-items: flex-end; gap: 2px; height: 12px; vertical-align: middle; margin-left: 4px;">';
|
|
|
|
// Bar heights: 4px, 6px, 8px, 10px
|
|
const barHeights = [4, 6, 8, 10];
|
|
const barWidth = 2;
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
const height = barHeights[i];
|
|
const isActive = i < bars;
|
|
const barColor = isActive ? color : '#d1d5db'; // gray for inactive bars
|
|
indicator += `<span style="width: ${barWidth}px; height: ${height}px; background-color: ${barColor}; border-radius: 1px; display: inline-block;"></span>`;
|
|
}
|
|
|
|
indicator += '</span>';
|
|
return indicator;
|
|
}
|
|
|
|
function cleanUpNodeConnections() {
|
|
|
|
// close tooltips and popups
|
|
closeAllPopups();
|
|
closeAllTooltips();
|
|
|
|
// setup node connections layer
|
|
nodeConnectionsLayerGroup.clearLayers();
|
|
nodeConnectionsLayerGroup.removeFrom(map);
|
|
nodeConnectionsLayerGroup.addTo(map);
|
|
|
|
}
|
|
|
|
function getTerrainProfileImage(node1, node2) {
|
|
|
|
// line colour between nodes
|
|
const lineColour = "0000FF"; // blue
|
|
|
|
// node 1 (left side of image)
|
|
const node1MarkerColour = "0000FF"; // blue
|
|
const node1Latitude = node1.latitude;
|
|
const node1Longitude = node1.longitude;
|
|
const node1ElevationMSL = node1.altitude ?? "";
|
|
|
|
// node 2 (right side of image)
|
|
const node2MarkerColour = "0000FF"; // blue
|
|
const node2Latitude = node2.latitude;
|
|
const node2Longitude = node2.longitude;
|
|
const node2ElevationMSL = node2.altitude ?? "";
|
|
|
|
// generate terrain profile image url
|
|
return "https://heywhatsthat.com/bin/profile-0904.cgi?" + new URLSearchParams({
|
|
src: "meshtastic.liamcottle.net",
|
|
axes: 1, // include grid lines and a scale
|
|
metric: 1, // show metric units
|
|
curvature: 1,
|
|
width: 500,
|
|
height: 200,
|
|
pt0: `${node1Latitude},${node1Longitude},${lineColour},${node1ElevationMSL},${node1MarkerColour}`,
|
|
pt1: `${node2Latitude},${node2Longitude},${lineColour},${node2ElevationMSL},${node2MarkerColour}`,
|
|
}).toString();
|
|
|
|
}
|
|
|
|
async function showNodeConnections(id) {
|
|
|
|
cleanUpNodeConnections();
|
|
|
|
// 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 connections
|
|
window._onShowNodeConnectionsClick(node);
|
|
|
|
// Fetch connections for this node
|
|
const connectionsTimePeriodSec = getConfigConnectionsTimePeriodInSeconds();
|
|
const connectionsTimeFrom = connectionsTimePeriodSec ? (Date.now() - connectionsTimePeriodSec * 1000) : undefined;
|
|
const connectionsParams = new URLSearchParams();
|
|
connectionsParams.set('node_id', id);
|
|
if (connectionsTimeFrom) connectionsParams.set('time_from', connectionsTimeFrom);
|
|
|
|
try {
|
|
const response = await window.axios.get(`/api/v1/connections?${connectionsParams.toString()}`);
|
|
const connections = response.data.connections ?? [];
|
|
|
|
for (const connection of connections) {
|
|
// Convert to numbers for comparison since API returns strings
|
|
const nodeA = parseInt(connection.node_a, 10);
|
|
const nodeB = parseInt(connection.node_b, 10);
|
|
|
|
const otherNodeId = nodeA === id ? nodeB : nodeA;
|
|
const otherNode = findNodeById(otherNodeId);
|
|
const otherNodeMarker = findNodeMarkerById(otherNodeId);
|
|
|
|
if (!otherNode || !otherNodeMarker) continue;
|
|
|
|
// Apply bidirectional filter
|
|
const configConnectionsBidirectionalOnly = getConfigConnectionsBidirectionalOnly();
|
|
if(configConnectionsBidirectionalOnly){
|
|
const hasDirectionAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null;
|
|
const hasDirectionBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null;
|
|
if(!hasDirectionAB || !hasDirectionBA){
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Apply minimum SNR filter
|
|
const configConnectionsMinSnrDb = getConfigConnectionsMinSnrDb();
|
|
if(configConnectionsMinSnrDb != null){
|
|
const snrAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null ? connection.direction_ab.avg_snr_db : null;
|
|
const snrBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null ? connection.direction_ba.avg_snr_db : null;
|
|
|
|
const configConnectionsBidirectionalMinSnr = getConfigConnectionsBidirectionalMinSnr();
|
|
let hasSnrAboveThreshold;
|
|
|
|
if(configConnectionsBidirectionalMinSnr){
|
|
// Bidirectional mode: ALL existing directions must meet threshold
|
|
const directionsToCheck = [];
|
|
if(snrAB != null) directionsToCheck.push(snrAB);
|
|
if(snrBA != null) directionsToCheck.push(snrBA);
|
|
|
|
if(directionsToCheck.length === 0){
|
|
// No SNR data in either direction, skip
|
|
hasSnrAboveThreshold = false;
|
|
} else {
|
|
// All existing directions must be above threshold
|
|
hasSnrAboveThreshold = directionsToCheck.every(snr => snr > configConnectionsMinSnrDb);
|
|
}
|
|
} else {
|
|
// Default mode: EITHER direction has SNR above threshold
|
|
hasSnrAboveThreshold = (snrAB != null && snrAB > configConnectionsMinSnrDb) || (snrBA != null && snrBA > configConnectionsMinSnrDb);
|
|
}
|
|
|
|
if(!hasSnrAboveThreshold){
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Calculate distance
|
|
const distanceInMeters = nodeMarker.getLatLng().distanceTo(otherNodeMarker.getLatLng()).toFixed(2);
|
|
const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters();
|
|
if(configConnectionsMaxDistanceInMeters != null && parseFloat(distanceInMeters) > configConnectionsMaxDistanceInMeters){
|
|
continue;
|
|
}
|
|
|
|
let distance = `${distanceInMeters} meters`;
|
|
if (distanceInMeters >= 1000) {
|
|
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
|
|
distance = `${distanceInKilometers} kilometers`;
|
|
}
|
|
|
|
// Determine line color
|
|
const configConnectionsColoredLines = getConfigConnectionsColoredLines();
|
|
const worstSnrDb = connection.worst_avg_snr_db;
|
|
const lineColor = configConnectionsColoredLines && worstSnrDb != null ? getColourForSnr(worstSnrDb) : '#2563eb';
|
|
|
|
// Create bidirectional line
|
|
const line = L.polyline([
|
|
nodeMarker.getLatLng(),
|
|
otherNodeMarker.getLatLng(),
|
|
], {
|
|
color: lineColor,
|
|
opacity: 0.75,
|
|
weight: 3,
|
|
}).addTo(nodeConnectionsLayerGroup);
|
|
|
|
// Generate tooltip using standardized function
|
|
const tooltipNodeA = findNodeById(connection.node_a);
|
|
const tooltipNodeB = findNodeById(connection.node_b);
|
|
const tooltip = generateConnectionTooltip(connection, tooltipNodeA, tooltipNodeB, distance);
|
|
|
|
line.bindTooltip(tooltip, {
|
|
sticky: true,
|
|
opacity: 1,
|
|
interactive: true,
|
|
})
|
|
.bindPopup(tooltip)
|
|
.on('click', function(event) {
|
|
event.target.closeTooltip();
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching connections:', err);
|
|
}
|
|
|
|
}
|
|
|
|
function clearMap() {
|
|
closeAllPopups();
|
|
closeAllTooltips();
|
|
clearAllNodes();
|
|
clearAllBackboneConnections();
|
|
clearAllWaypoints();
|
|
clearAllTraceroutes();
|
|
clearAllConnections();
|
|
clearNodeOutline();
|
|
cleanUpNodeConnections();
|
|
}
|
|
|
|
// 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 now = moment();
|
|
const configNodesMaxAgeInSeconds = getConfigNodesMaxAgeInSeconds();
|
|
const configNodesOfflineAgeInSeconds = getConfigNodesOfflineAgeInSeconds();
|
|
const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters();
|
|
|
|
// add nodes
|
|
for(const node of updatedNodes){
|
|
|
|
// skip nodes older than configured node max age
|
|
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;
|
|
|
|
// skip nodes with invalid position
|
|
if(!isValidLatLng(node.latitude, node.longitude)){
|
|
continue;
|
|
}
|
|
|
|
// 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 channel preset
|
|
var icon = iconLongFast;
|
|
|
|
if (node.channel_id == "ShortSlow") {
|
|
icon = iconShortSlow;
|
|
}
|
|
|
|
/*if (node.channel_id == "MediumFast") {
|
|
icon = iconMediumFast;
|
|
}*/
|
|
|
|
// use offline icon for nodes older than configured node offline age
|
|
if(configNodesOfflineAgeInSeconds){
|
|
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
|
|
if(lastUpdatedAgeInMillis > configNodesOfflineAgeInSeconds * 1000){
|
|
icon = iconOffline;
|
|
}
|
|
}
|
|
|
|
// determine zIndexOffset: MediumFast (1000), LongFast (-1000), Offline (-2000)
|
|
var zIndexOffset = 1000;
|
|
if(icon == iconOffline){
|
|
zIndexOffset = -2000;
|
|
} else if(node.channel_id == 'LongFast'){
|
|
zIndexOffset = -1000;
|
|
}
|
|
|
|
// To not have overlapping nodes.
|
|
var latJitter = 0;
|
|
var lonJitter = 0;
|
|
// If position pression > 45m apply random jitter within a small circle to avoid diagonal-only displacement
|
|
if (node.position_precision < 19) {
|
|
const maxMeters = 40;
|
|
const r = maxMeters * Math.sqrt(Math.random());
|
|
const theta = 2 * Math.PI * Math.random();
|
|
const dy = r * Math.sin(theta);
|
|
const dx = r * Math.cos(theta);
|
|
const metersPerDegLat = 111320;
|
|
const metersPerDegLon = 111320 * Math.cos(node.latitude * Math.PI / 180);
|
|
latJitter = dy / metersPerDegLat;
|
|
lonJitter = metersPerDegLon ? (dx / metersPerDegLon) : 0;
|
|
}
|
|
|
|
// create node marker
|
|
const marker = L.marker([node.latitude + latJitter, longitude + lonJitter], {
|
|
icon: icon,
|
|
tagName: node.node_id,
|
|
// zIndex: offline (-2000) < has channel_id (-1000) < others (1000)
|
|
zIndexOffset: zIndexOffset,
|
|
}).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);
|
|
|
|
// add markers for routers and repeaters to routers layer group
|
|
if(node.role_name === "ROUTER"
|
|
|| node.role_name === "ROUTER_CLIENT"
|
|
|| node.role_name === "ROUTER_LATE"
|
|
|| node.role_name === "REPEATER"){
|
|
nodesRouterLayerGroup.addLayer(marker);
|
|
}
|
|
|
|
// add markers for backbone to layer group
|
|
if(node.is_backbone) {
|
|
nodesBackboneLayerGroup.addLayer(marker);
|
|
}
|
|
|
|
if(node.channel_id == "ShortSlow") {
|
|
nodesShortSlowLayerGroup.addLayer(marker);
|
|
}
|
|
|
|
// add markers for MediumFast channel to layer group
|
|
/*if(node.channel_id == "MediumFast") {
|
|
nodesMediumFastLayerGroup.addLayer(marker);
|
|
}*/
|
|
|
|
// add markers for LongFast channel to layer group
|
|
if(node.channel_id == "LongFast") {
|
|
nodesLongFastLayerGroup.addLayer(marker);
|
|
}
|
|
|
|
// show tooltip on desktop only
|
|
if(!isMobile()){
|
|
marker.bindTooltip(getTooltipContentForNode(node), {
|
|
interactive: true,
|
|
});
|
|
}
|
|
|
|
// show node info tooltip 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;
|
|
}
|
|
|
|
// show position precision outline
|
|
showNodeOutline(node.node_id);
|
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
window._onNodesUpdated(nodes);
|
|
|
|
}
|
|
|
|
function onWaypointsUpdated(updatedWaypoints) {
|
|
|
|
// clear nodes cache
|
|
waypoints = [];
|
|
|
|
// get config
|
|
const now = moment();
|
|
const configWaypointsMaxAgeInSeconds = getConfigWaypointsMaxAgeInSeconds();
|
|
|
|
// add nodes
|
|
for(const waypoint of updatedWaypoints){
|
|
|
|
// skip waypoints older than configured waypoint max age
|
|
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 waypoints without position
|
|
if(!waypoint.latitude || !waypoint.longitude){
|
|
continue;
|
|
}
|
|
|
|
// fix lat long
|
|
waypoint.latitude = waypoint.latitude / 10000000;
|
|
waypoint.longitude = waypoint.longitude / 10000000;
|
|
|
|
// skip waypoints with invalid position
|
|
if(!isValidLatLng(waypoint.latitude, waypoint.longitude)){
|
|
continue;
|
|
}
|
|
|
|
// 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
|
|
const marker = L.marker([waypoint.latitude, longitude], {
|
|
icon: L.divIcon({
|
|
className: 'waypoint-label',
|
|
iconSize: [26, 26], // increase from 12px to 26px
|
|
html: emojiText,
|
|
}),
|
|
}).bindPopup(tooltip).on('click', function(event) {
|
|
// close tooltip on click to prevent tooltip and popup showing at same time
|
|
event.target.closeTooltip();
|
|
});
|
|
|
|
// 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 generateConnectionTooltip(connection, nodeA, nodeB, distance) {
|
|
let tooltip = `<b>Connection</b>`;
|
|
tooltip += `<br/>[${escapeString(nodeA.short_name)}] ${escapeString(nodeA.long_name)} <-> [${escapeString(nodeB.short_name)}] ${escapeString(nodeB.long_name)}`;
|
|
tooltip += `<br/>Distance: ${distance}`;
|
|
tooltip += `<br/>`;
|
|
|
|
// Direction A -> B
|
|
if (connection.direction_ab.avg_snr_db != null) {
|
|
tooltip += `<br/><b>${escapeString(nodeA.short_name)} -> ${escapeString(nodeB.short_name)}:</b>`;
|
|
tooltip += `<br/>SNR: ${connection.direction_ab.avg_snr_db.toFixed(1)}dB ${getSignalBarsIndicator(connection.direction_ab.avg_snr_db)} (Average of ${connection.direction_ab.total_count} edges)`;
|
|
if (connection.direction_ab.last_5_edges.length > 0) {
|
|
tooltip += `<br/>Last 5 edges:`;
|
|
for (const edge of connection.direction_ab.last_5_edges) {
|
|
const timeAgo = moment(new Date(edge.created_at)).fromNow();
|
|
const sourceIcon = edge.source === "TRACEROUTE_APP" ? "⇵" : (edge.source === "NEIGHBORINFO_APP" ? "✳" : "?");
|
|
tooltip += `<br/> ${edge.snr_db.toFixed(1)}dB ${getSignalBarsIndicator(edge.snr_db)} (${timeAgo} by:${sourceIcon})`;
|
|
}
|
|
} else {
|
|
tooltip += `<br/>No recent edges`;
|
|
}
|
|
}
|
|
|
|
// Direction B -> A
|
|
if (connection.direction_ba.avg_snr_db != null) {
|
|
tooltip += `<br/><br/><b>${escapeString(nodeB.short_name)} -> ${escapeString(nodeA.short_name)}:</b>`;
|
|
tooltip += `<br/>SNR: ${connection.direction_ba.avg_snr_db.toFixed(1)}dB ${getSignalBarsIndicator(connection.direction_ba.avg_snr_db)} (Average of ${connection.direction_ba.total_count} edges)`;
|
|
if (connection.direction_ba.last_5_edges.length > 0) {
|
|
tooltip += `<br/>Last 5 edges:`;
|
|
for (const edge of connection.direction_ba.last_5_edges) {
|
|
const timeAgo = moment(new Date(edge.created_at)).fromNow();
|
|
const sourceIcon = edge.source === "TRACEROUTE_APP" ? "⇵" : (edge.source === "NEIGHBORINFO_APP" ? "✳" : "?");
|
|
tooltip += `<br/> ${edge.snr_db.toFixed(1)}dB ${getSignalBarsIndicator(edge.snr_db)} (${timeAgo} by:${sourceIcon})`;
|
|
}
|
|
} else {
|
|
tooltip += `<br/>No recent edges`;
|
|
}
|
|
}
|
|
|
|
// Add terrain profile image
|
|
const terrainImageUrl = getTerrainProfileImage(nodeA, nodeB);
|
|
tooltip += `<br/><br/>Terrain images from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`;
|
|
tooltip += `<br/><a href="${terrainImageUrl}" target="_blank"><img src="${terrainImageUrl}" width="100%"></a>`;
|
|
|
|
return tooltip;
|
|
}
|
|
|
|
function onConnectionsUpdated(connections) {
|
|
// Clear existing connections
|
|
clearAllConnections();
|
|
|
|
for (const connection of connections) {
|
|
// Find both node markers
|
|
const nodeAMarker = findNodeMarkerById(connection.node_a);
|
|
const nodeBMarker = findNodeMarkerById(connection.node_b);
|
|
|
|
// Skip if either node marker doesn't exist
|
|
if (!nodeAMarker || !nodeBMarker) {
|
|
continue;
|
|
}
|
|
|
|
// Find node objects for names and terrain profile
|
|
const nodeA = findNodeById(connection.node_a);
|
|
const nodeB = findNodeById(connection.node_b);
|
|
|
|
if (!nodeA || !nodeB) {
|
|
continue;
|
|
}
|
|
|
|
// Apply bidirectional filter
|
|
const configConnectionsBidirectionalOnly = getConfigConnectionsBidirectionalOnly();
|
|
if(configConnectionsBidirectionalOnly){
|
|
const hasDirectionAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null;
|
|
const hasDirectionBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null;
|
|
if(!hasDirectionAB || !hasDirectionBA){
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Apply minimum SNR filter
|
|
const configConnectionsMinSnrDb = getConfigConnectionsMinSnrDb();
|
|
if(configConnectionsMinSnrDb != null){
|
|
const snrAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null ? connection.direction_ab.avg_snr_db : null;
|
|
const snrBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null ? connection.direction_ba.avg_snr_db : null;
|
|
|
|
const configConnectionsBidirectionalMinSnr = getConfigConnectionsBidirectionalMinSnr();
|
|
let hasSnrAboveThreshold;
|
|
|
|
if(configConnectionsBidirectionalMinSnr){
|
|
// Bidirectional mode: ALL existing directions must meet threshold
|
|
const directionsToCheck = [];
|
|
if(snrAB != null) directionsToCheck.push(snrAB);
|
|
if(snrBA != null) directionsToCheck.push(snrBA);
|
|
|
|
if(directionsToCheck.length === 0){
|
|
// No SNR data in either direction, skip
|
|
hasSnrAboveThreshold = false;
|
|
} else {
|
|
// All existing directions must be above threshold
|
|
hasSnrAboveThreshold = directionsToCheck.every(snr => snr > configConnectionsMinSnrDb);
|
|
}
|
|
} else {
|
|
// Default mode: EITHER direction has SNR above threshold
|
|
hasSnrAboveThreshold = (snrAB != null && snrAB > configConnectionsMinSnrDb) || (snrBA != null && snrBA > configConnectionsMinSnrDb);
|
|
}
|
|
|
|
if(!hasSnrAboveThreshold){
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Calculate distance between nodes
|
|
const distanceInMeters = nodeAMarker.getLatLng().distanceTo(nodeBMarker.getLatLng()).toFixed(2);
|
|
|
|
// Apply distance filter
|
|
const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters();
|
|
if(configConnectionsMaxDistanceInMeters != null && parseFloat(distanceInMeters) > configConnectionsMaxDistanceInMeters){
|
|
continue;
|
|
}
|
|
|
|
let distance = `${distanceInMeters} meters`;
|
|
if (distanceInMeters >= 1000) {
|
|
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
|
|
distance = `${distanceInKilometers} kilometers`;
|
|
}
|
|
|
|
// Determine line color based on worst average SNR (if colored lines enabled)
|
|
const configConnectionsColoredLines = getConfigConnectionsColoredLines();
|
|
const worstSnrDb = connection.worst_avg_snr_db;
|
|
const lineColor = configConnectionsColoredLines && worstSnrDb != null ? getColourForSnr(worstSnrDb) : '#2563eb';
|
|
|
|
// Create polyline (bidirectional, no arrows)
|
|
const line = L.polyline([
|
|
nodeAMarker.getLatLng(),
|
|
nodeBMarker.getLatLng(),
|
|
], {
|
|
color: lineColor,
|
|
opacity: 0.75,
|
|
weight: 3,
|
|
}).addTo(connectionsLayerGroup);
|
|
|
|
// Generate tooltip
|
|
const tooltip = generateConnectionTooltip(connection, nodeA, nodeB, distance);
|
|
|
|
// Bind tooltip and popup
|
|
line.bindTooltip(tooltip, {
|
|
sticky: true,
|
|
opacity: 1,
|
|
interactive: true,
|
|
})
|
|
.bindPopup(tooltip)
|
|
.on('click', function(event) {
|
|
// close tooltip on click to prevent tooltip and popup showing at same time
|
|
event.target.closeTooltip();
|
|
});
|
|
|
|
// If both nodes are backbone nodes, also add to backbone layer group
|
|
if (nodeA.is_backbone && nodeB.is_backbone) {
|
|
const backboneLine = L.polyline([
|
|
nodeAMarker.getLatLng(),
|
|
nodeBMarker.getLatLng(),
|
|
], {
|
|
color: lineColor,
|
|
opacity: 0.75,
|
|
weight: 3,
|
|
}).addTo(backboneConnectionsLayerGroup);
|
|
|
|
backboneLine.bindTooltip(tooltip, {
|
|
sticky: true,
|
|
opacity: 1,
|
|
interactive: true,
|
|
})
|
|
.bindPopup(tooltip)
|
|
.on('click', function(event) {
|
|
event.target.closeTooltip();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function onPositionHistoryUpdated(updatedPositionHistories) {
|
|
|
|
let positionHistoryLinesCords = [];
|
|
|
|
// add nodes
|
|
for(const positionHistory of updatedPositionHistories) {
|
|
|
|
// skip position history without position
|
|
if(!positionHistory.latitude || !positionHistory.longitude){
|
|
continue;
|
|
}
|
|
|
|
// find node this position is for
|
|
const node = findNodeById(positionHistory.node_id);
|
|
if(!node){
|
|
continue;
|
|
}
|
|
|
|
// fix lat long
|
|
positionHistory.latitude = positionHistory.latitude / 10000000;
|
|
positionHistory.longitude = positionHistory.longitude / 10000000;
|
|
|
|
// skip position history with invalid position
|
|
if(!isValidLatLng(positionHistory.latitude, positionHistory.longitude)){
|
|
continue;
|
|
}
|
|
|
|
// wrap longitude for shortest path, everything to left of australia should be shown on the right
|
|
var longitude = parseFloat(positionHistory.longitude);
|
|
if(longitude <= 100){
|
|
longitude += 360;
|
|
}
|
|
|
|
positionHistoryLinesCords.push([positionHistory.latitude, longitude]);
|
|
|
|
let tooltip = "";
|
|
if(positionHistory.type === "position"){
|
|
tooltip += `<b>Position</b>`;
|
|
} else if(positionHistory.type === "map_report"){
|
|
tooltip += `<b>Map Report</b>`;
|
|
}
|
|
tooltip += `<br/>[${escapeString(node.short_name)}] ${escapeString(node.long_name)}`;
|
|
tooltip += `<br/>${positionHistory.latitude}, ${positionHistory.longitude}`;
|
|
tooltip += `<br/>Heard on: ${moment(new Date(positionHistory.created_at)).format("YYYY-MM-DD HH:mm")}`;
|
|
|
|
// add gateway info if available
|
|
if(positionHistory.gateway_id){
|
|
const gatewayNode = findNodeById(positionHistory.gateway_id);
|
|
const gatewayNodeInfo = gatewayNode ? `[${gatewayNode.short_name}] ${gatewayNode.long_name}` : "???";
|
|
tooltip += `<br/>Heard by: <a href="javascript:void(0);" onclick="goToNode(${positionHistory.gateway_id})">${gatewayNodeInfo}</a>`;
|
|
}
|
|
|
|
// create position history marker
|
|
const marker = L.marker([positionHistory.latitude, longitude],{
|
|
icon: iconPositionHistory,
|
|
}).bindTooltip(tooltip).bindPopup(tooltip).on('click', function(event) {
|
|
// close tooltip on click to prevent tooltip and popup showing at same time
|
|
event.target.closeTooltip();
|
|
});
|
|
|
|
// add marker to position history layer group
|
|
marker.addTo(nodePositionHistoryLayerGroup);
|
|
|
|
}
|
|
|
|
// show lines between position history markers
|
|
L.polyline(positionHistoryLinesCords, {
|
|
color: "#a855f7",
|
|
opacity: 1,
|
|
}).addTo(nodePositionHistoryLayerGroup);
|
|
|
|
}
|
|
|
|
function cleanUpPositionHistory() {
|
|
|
|
// close tooltips and popups
|
|
closeAllPopups();
|
|
closeAllTooltips();
|
|
|
|
// setup node position history layer
|
|
nodePositionHistoryLayerGroup.clearLayers();
|
|
nodePositionHistoryLayerGroup.removeFrom(map);
|
|
nodePositionHistoryLayerGroup.addTo(map);
|
|
|
|
}
|
|
|
|
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 window.axios.get('/api/v1/nodes').then(async (response) => {
|
|
|
|
// update nodes
|
|
onNodesUpdated(response.data.nodes);
|
|
|
|
// hide loading
|
|
setLoading(false);
|
|
|
|
// go to node id if provided
|
|
if(goToNodeId){
|
|
|
|
// go to node
|
|
if(window.goToNode(goToNodeId, false, zoom)){
|
|
return;
|
|
}
|
|
|
|
// fallback to showing node details since we can't go to the node
|
|
window.showNodeDetails(goToNodeId);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
// fetch waypoints (after awaiting nodes, so we can use nodes cache in waypoint tooltips)
|
|
await window.axios.get('/api/v1/waypoints').then(async (response) => {
|
|
onWaypointsUpdated(response.data.waypoints);
|
|
});
|
|
|
|
// fetch connections (edges)
|
|
const connectionsTimePeriodSec = getConfigConnectionsTimePeriodInSeconds();
|
|
const connectionsTimeFrom = connectionsTimePeriodSec ? (Date.now() - connectionsTimePeriodSec * 1000) : undefined;
|
|
const connectionsParams = new URLSearchParams();
|
|
if (connectionsTimeFrom) connectionsParams.set('time_from', connectionsTimeFrom);
|
|
await window.axios.get(`/api/v1/connections?${connectionsParams.toString()}`).then(async (response) => {
|
|
onConnectionsUpdated(response.data.connections ?? []);
|
|
}).catch(() => {
|
|
onConnectionsUpdated([]);
|
|
});
|
|
|
|
}
|
|
|
|
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 getPositionPrecisionInMeters(positionPrecision) {
|
|
switch(positionPrecision){
|
|
case 2: return 5976446;
|
|
case 3: return 2988223;
|
|
case 4: return 1494111;
|
|
case 5: return 747055;
|
|
case 6: return 373527;
|
|
case 7: return 186763;
|
|
case 8: return 93381;
|
|
case 9: return 46690;
|
|
case 10: return 23345;
|
|
case 11: return 11672; // Android LOW_PRECISION
|
|
case 12: return 5836;
|
|
case 13: return 2918;
|
|
case 14: return 1459;
|
|
case 15: return 729;
|
|
case 16: return 364; // Android MED_PRECISION
|
|
case 17: return 182;
|
|
case 18: return 91;
|
|
case 19: return 45;
|
|
case 20: return 22;
|
|
case 21: return 11;
|
|
case 22: return 5;
|
|
case 23: return 2;
|
|
case 24: return 1;
|
|
case 32: return 0; // Android HIGH_PRECISION
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function formatPositionPrecision(positionPrecision) {
|
|
|
|
// get position precision in meters
|
|
const positionPrecisionInMeters = getPositionPrecisionInMeters(positionPrecision);
|
|
if(positionPrecisionInMeters == null){
|
|
return "?";
|
|
}
|
|
|
|
// format kilometers
|
|
if(positionPrecisionInMeters > 1000){
|
|
const positionPrecisionInKilometers = Math.ceil(positionPrecisionInMeters / 1000);
|
|
return `±${positionPrecisionInKilometers}km`;
|
|
}
|
|
|
|
// format meters
|
|
return `±${positionPrecisionInMeters}m`;
|
|
|
|
}
|
|
|
|
function getTooltipContentForNode(node) {
|
|
|
|
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)}` +
|
|
(node.num_online_local_nodes != null ? `<br/>Local Nodes Online: ${node.num_online_local_nodes}` : '') +
|
|
(node.position_precision != null && node.position_precision !== 32 ? `<br/>Position Precision: ${formatPositionPrecision(node.position_precision)}` : '') +
|
|
`<br/><br/>Role: ${node.role_name}` +
|
|
`<br/>Hardware: ${node.hardware_model_name}` +
|
|
(node.firmware_version != null ? `<br/>Firmware: ${node.firmware_version}` : '') +
|
|
`<br/>OK to MQTT: ${node.ok_to_mqtt}`;
|
|
|
|
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.mqtt_connection_state_updated_at ? `<br/>MQTT Updated: ${moment(new Date(node.mqtt_connection_state_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="showNodeConnections(${node.node_id});" class="border border-gray-300 bg-gray-100 p-1 w-full rounded hover:bg-gray-200">Show Connections</button>`;
|
|
tooltip += `</div>`;
|
|
|
|
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._onHideNodeConnectionsClick = function() {
|
|
cleanUpNodeConnections();
|
|
};
|
|
|
|
// 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);
|
|
|
|
// WebSocket connection for real-time messages
|
|
var ws = null;
|
|
var tracerouteCooldown = {}; // Track last traceroute time per from node (for 20s cooldown)
|
|
var activeTracerouteKeys = new Set(); // Track active traceroute visualizations to prevent duplicates
|
|
var tracerouteLines = {}; // Track lines for each traceroute route key for cleanup
|
|
|
|
function connectWebSocket() {
|
|
// Determine WebSocket URL - use same hostname as current page, port 8081
|
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
// Heuristic: if running on localhost, use port 8081; otherwise use /ws path via Nginx
|
|
const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
|
const wsUrl = isLocalhost
|
|
? `${wsProtocol}//${location.hostname}:8081`
|
|
: `${wsProtocol}//${location.host}/ws`;
|
|
|
|
console.log('Connecting to WebSocket:', wsUrl);
|
|
ws = new WebSocket(wsUrl);
|
|
|
|
ws.onopen = function() {
|
|
console.log('WebSocket connected');
|
|
};
|
|
|
|
ws.onmessage = function(event) {
|
|
try {
|
|
const message = JSON.parse(event.data);
|
|
handleWebSocketMessage(message);
|
|
} catch (e) {
|
|
console.error('Error parsing WebSocket message:', e);
|
|
}
|
|
};
|
|
|
|
ws.onerror = function(error) {
|
|
console.error('WebSocket error:', error);
|
|
};
|
|
|
|
ws.onclose = function() {
|
|
console.log('WebSocket disconnected, reconnecting in 5 seconds...');
|
|
setTimeout(connectWebSocket, 5000);
|
|
};
|
|
}
|
|
|
|
function handleWebSocketMessage(message) {
|
|
if (message.type === 'traceroute') {
|
|
handleTraceroute(message.data);
|
|
}
|
|
}
|
|
|
|
function handleTraceroute(data) {
|
|
// Only visualize traceroutes where want_response is false (the reply coming back)
|
|
if (data.want_response) {
|
|
return;
|
|
}
|
|
|
|
// When want_response is false, from and to are swapped from the original request
|
|
// The path goes from 'to' (original sender) through route to 'from' (original destination)
|
|
const originalSender = data.to; // This was the original sender
|
|
const originalDestination = data.from; // This was the original destination
|
|
const route = data.route || [];
|
|
const snrTowards = data.snr_towards || [];
|
|
|
|
// Deduplicate: ignore traceroutes from the same original sender for 20 seconds
|
|
const now = Date.now();
|
|
if (tracerouteCooldown[originalSender] && (now - tracerouteCooldown[originalSender]) < 20000) {
|
|
return; // Still in cooldown period
|
|
}
|
|
|
|
// Create unique key for this traceroute path to prevent duplicate visualizations
|
|
// Use original sender (to), original destination (from), and route to create unique key
|
|
// (ignoring gateway_id since multiple gateways can receive same route)
|
|
const routeKey = `${originalSender}-${originalDestination}-${route.join('-')}`;
|
|
if (activeTracerouteKeys.has(routeKey)) {
|
|
return; // Already visualizing this route
|
|
}
|
|
|
|
// Mark as active and set cooldown
|
|
activeTracerouteKeys.add(routeKey);
|
|
tracerouteCooldown[originalSender] = now;
|
|
|
|
// Build the complete path: to (original sender) -> route[0] -> route[1] -> ... -> from (original destination)
|
|
const path = [originalSender]; // Start from original sender
|
|
if (route.length > 0) {
|
|
path.push(...route);
|
|
}
|
|
path.push(originalDestination); // End at original destination
|
|
|
|
// Visualize the traceroute with animated hops
|
|
visualizeTraceroute(path, snrTowards, routeKey);
|
|
}
|
|
|
|
function visualizeTraceroute(path, snrTowards, routeKey) {
|
|
// Verify all nodes in path exist on map
|
|
const pathMarkers = [];
|
|
for (const nodeId of path) {
|
|
const marker = findNodeMarkerById(nodeId);
|
|
if (!marker) {
|
|
// Node not on map, skip this traceroute
|
|
activeTracerouteKeys.delete(routeKey);
|
|
return;
|
|
}
|
|
pathMarkers.push(marker);
|
|
}
|
|
|
|
// Store lines and overlays for this route key for cleanup
|
|
const routeElements = {
|
|
lines: [],
|
|
startOverlay: null,
|
|
endOverlay: null,
|
|
};
|
|
tracerouteLines[routeKey] = routeElements;
|
|
|
|
// Color starting node (first in path) green and destination node (last in path) red
|
|
const startMarker = pathMarkers[0];
|
|
const endMarker = pathMarkers[pathMarkers.length - 1];
|
|
|
|
const startOverlay = L.marker(startMarker.getLatLng(), {
|
|
icon: iconTracerouteStart,
|
|
zIndexOffset: 10000, // Ensure it's on top
|
|
}).addTo(traceroutesLayerGroup);
|
|
|
|
const endOverlay = L.marker(endMarker.getLatLng(), {
|
|
icon: iconTracerouteEnd,
|
|
zIndexOffset: 10000, // Ensure it's on top
|
|
}).addTo(traceroutesLayerGroup);
|
|
|
|
// Store overlays for cleanup
|
|
routeElements.startOverlay = startOverlay;
|
|
routeElements.endOverlay = endOverlay;
|
|
|
|
// Animate each hop sequentially
|
|
let hopIndex = 0;
|
|
const animateNextHop = () => {
|
|
if (hopIndex >= pathMarkers.length - 1) {
|
|
// All hops animated, cleanup after delay
|
|
setTimeout(() => {
|
|
if (tracerouteLines[routeKey]) {
|
|
const routeElements = tracerouteLines[routeKey];
|
|
// Remove all lines
|
|
if (routeElements.lines) {
|
|
routeElements.lines.forEach(line => {
|
|
line.remove();
|
|
});
|
|
}
|
|
// Remove node overlays
|
|
if (routeElements.startOverlay) {
|
|
routeElements.startOverlay.remove();
|
|
}
|
|
if (routeElements.endOverlay) {
|
|
routeElements.endOverlay.remove();
|
|
}
|
|
delete tracerouteLines[routeKey];
|
|
}
|
|
activeTracerouteKeys.delete(routeKey);
|
|
}, 2500);
|
|
return;
|
|
}
|
|
|
|
const fromMarker = pathMarkers[hopIndex];
|
|
const toMarker = pathMarkers[hopIndex + 1];
|
|
const snr = hopIndex < snrTowards.length ? snrTowards[hopIndex] : null;
|
|
|
|
// Use orange color for all traceroute lines
|
|
const lineColor = '#f97316'; // orange
|
|
|
|
// Create animated polyline for this hop with orange dotted style
|
|
const line = L.polyline([fromMarker.getLatLng(), toMarker.getLatLng()], {
|
|
color: lineColor,
|
|
weight: 4,
|
|
opacity: 0, // Start invisible
|
|
// dashArray: '10, 5', // Dotted line style
|
|
zIndexOffset: 10000,
|
|
}).addTo(traceroutesLayerGroup);
|
|
|
|
// Fade in animation
|
|
line.setStyle({ opacity: 1.0 });
|
|
tracerouteLines[routeKey].lines.push(line);
|
|
|
|
// Animate next hop after 700ms delay
|
|
hopIndex++;
|
|
setTimeout(animateNextHop, 700);
|
|
};
|
|
|
|
// Start animation
|
|
animateNextHop();
|
|
}
|
|
|
|
// Connect WebSocket when page loads
|
|
connectWebSocket();
|
|
|
|
</script>
|
|
|
|
<!-- Google tag (gtag.js) -->
|
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-2RD5193D15"></script>
|
|
<script>
|
|
window.dataLayer = window.dataLayer || [];
|
|
function gtag(){dataLayer.push(arguments);}
|
|
gtag('js', new Date());
|
|
|
|
gtag('config', 'G-2RD5193D15');
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|