1474 lines
64 KiB
HTML
1474 lines
64 KiB
HTML
<html>
|
|
<head>
|
|
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Meshtastic Map</title>
|
|
<meta name="title" content="Meshtastic Map">
|
|
<meta name="description" content="An interactive map of all Meshtastic nodes.">
|
|
<link rel="icon" type="image/png" href="https://meshtastic.liamcottle.net/icon.png"/>
|
|
|
|
<!-- Open Graph / Facebook -->
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:url" content="https://meshtastic.liamcottle.net">
|
|
<meta property="og:title" content="Meshtastic Map">
|
|
<meta property="og:description" content="An interactive map of all Meshtastic nodes and their status.">
|
|
|
|
<!-- tailwind css -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
|
|
<!-- leaflet map -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.3/dist/leaflet.css" integrity="sha256-kLaT2GOSpHechhsozzB+flnD+zUyjE2LlfWPgU04xyI=" crossorigin="" />
|
|
<script src="https://unpkg.com/leaflet@1.9.3/dist/leaflet.js" integrity="sha256-WBkoXOwTeyKclOHuWtc+i2uENFpDZ9YPdf5Hf+D7ewM=" crossorigin=""></script>
|
|
<script src="plugins/leaflet.markercluster/leaflet.markercluster.js"></script>
|
|
<link rel="stylesheet" href="plugins/leaflet.markercluster/MarkerCluster.css"/>
|
|
<link rel="stylesheet" href="plugins/leaflet.markercluster/MarkerCluster.Default.css"/>
|
|
|
|
<!-- moment -->
|
|
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.min.js"></script>
|
|
|
|
<!-- vuejs -->
|
|
<script src="https://unpkg.com/vue@3"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
|
|
|
<!-- chart js -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
|
|
<style>
|
|
|
|
.icon-online {
|
|
background-color: #16a34a;
|
|
border-radius: 25px;
|
|
border: 1px solid white;
|
|
}
|
|
|
|
.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: 1000;
|
|
}
|
|
|
|
.z-sidebar {
|
|
z-index: 1001;
|
|
}
|
|
|
|
</style>
|
|
|
|
</head>
|
|
<body class="h-full bg-gray-200">
|
|
<div id="app">
|
|
|
|
<div class="flex flex-col h-full w-full overflow-hidden">
|
|
<div class="flex flex-col h-full">
|
|
|
|
<!-- header -->
|
|
<div class="flex bg-white p-2 border-gray-300 border-b">
|
|
<div class="hidden sm:block my-auto mr-3">
|
|
<img class="w-10 h-10 rounded" src="icon.png"/>
|
|
</div>
|
|
<div class="my-auto">
|
|
<div class="font-bold">Meshtastic Map</div>
|
|
<div class="text-sm">
|
|
<div class="hidden sm:inline-block space-x-1">
|
|
<span>Created by</span>
|
|
<a class="link" target="_blank" href="https://liamcottle.com">Liam Cottle</a>
|
|
<span>-</span>
|
|
<a class="link" target="_blank" href="https://discord.gg/K55zeZyHKK">Discord</a>
|
|
</div>
|
|
<div class="inline-block sm:hidden space-x-1">
|
|
<span>Created by</span>
|
|
<a class="link" target="_blank" href="https://liamcottle.com">Liam</a>
|
|
<span>-</span>
|
|
<a class="link" target="_blank" href="https://discord.gg/K55zeZyHKK">Discord</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mx-3 flex-1 relative hidden lg:block">
|
|
<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="searchText = ''" :onclick="`goToNode(${node.node_id})`" class="p-2 hover:bg-gray-100 cursor-pointer" v-for="node of searchedNodes">
|
|
<div class="text-gray-900">{{ node.long_name !== '' ? node.long_name : "-" }}</div>
|
|
<div class="flex space-x-1 text-sm text-gray-700">
|
|
<div>Short Name: {{ node.short_name !== '' ? node.short_name : "-" }}</div>
|
|
<div class="text-gray-500">/</div>
|
|
<div>Hex ID: {{ node.node_id_hex }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
</template>
|
|
|
|
<template v-else>
|
|
<div class="p-2">
|
|
No results found...
|
|
</div>
|
|
</template>
|
|
|
|
</div>
|
|
</div>
|
|
<div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
|
|
<a href="#" class="tooltip rounded-full" onclick="searchNodes()">
|
|
<div id="search-button" class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
|
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"></path>
|
|
<path d="M21 21l-6 -6"></path>
|
|
</svg>
|
|
</div>
|
|
<div class="hidden sm:block">
|
|
<span class="tooltip-text">Search</span>
|
|
</div>
|
|
</a>
|
|
<a @click="isShowingHardwareModels = !isShowingHardwareModels" href="javascript:void(0)" class="tooltip rounded-full">
|
|
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
|
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
|
</svg>
|
|
</div>
|
|
<div class="hidden sm:block">
|
|
<span class="tooltip-text">Devices</span>
|
|
</div>
|
|
</a>
|
|
<a href="#" class="tooltip rounded-full" onclick="goToRandomNode()">
|
|
<div id="random-button" class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
|
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
<path d="M18 4l3 3l-3 3"></path>
|
|
<path d="M18 20l3 -3l-3 -3"></path>
|
|
<path d="M3 7h3a5 5 0 0 1 5 5a5 5 0 0 0 5 5h5"></path>
|
|
<path d="M21 7h-5a4.978 4.978 0 0 0 -3 1m-4 8a4.984 4.984 0 0 1 -3 1h-3"></path>
|
|
</svg>
|
|
</div>
|
|
<div class="hidden sm:block">
|
|
<span class="tooltip-text">Random</span>
|
|
</div>
|
|
</a>
|
|
<a 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 != 'https://placehold.co/512x512?text=No+Image') this.src = 'https://placehold.co/512x512?text=No+Image';">
|
|
</span>
|
|
<div class="truncate">
|
|
<p class="truncate text-sm font-medium text-gray-900">{{ hardwareModel.hardware_model_name }}</p>
|
|
<p class="truncate text-sm text-gray-500">{{ hardwareModel.count }} nodes on the map</p>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
</div>
|
|
|
|
<!-- node info sidebar -->
|
|
<div class="relative z-sidebar" role="dialog" aria-modal="true">
|
|
|
|
<!-- overlay -->
|
|
<transition
|
|
enter-active-class="transition-opacity duration-300 ease-linear"
|
|
enter-from-class="opacity-0"
|
|
enter-to-class="opacity-100"
|
|
leave-active-class="transition-opacity duration-300 ease-linear"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0">
|
|
<div v-show="selectedNode != null" @click="selectedNode = null" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
|
|
</transition>
|
|
|
|
<!-- sidebar -->
|
|
<transition
|
|
enter-active-class="transition duration-300 ease-in-out transform"
|
|
enter-from-class="-translate-x-full"
|
|
enter-to-class="translate-x-0"
|
|
leave-active-class="transition duration-300 ease-in-out transform"
|
|
leave-from-class="translate-x-0"
|
|
leave-to-class="-translate-x-full">
|
|
<div v-show="selectedNode != null" class="fixed top-0 left-0 bottom-0">
|
|
<div v-if="selectedNode != null" class="w-screen max-w-md overflow-hidden">
|
|
<div class="flex h-full flex-col bg-white shadow-xl">
|
|
|
|
<!-- slideover header -->
|
|
<div class="p-2 border-b border-gray-200 shadow">
|
|
<div class="flex items-start justify-between">
|
|
<div>
|
|
<h2 class="font-bold">Node Info</h2>
|
|
<h3 class="text-sm">{{ selectedNode.long_name }}</h3>
|
|
</div>
|
|
<div class="my-auto ml-3 flex h-7 items-center">
|
|
<a href="javascript:void(0)" class="rounded-full" @click="selectedNode = null">
|
|
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
|
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
<path d="M18 6l-12 12"></path>
|
|
<path d="M6 6l12 12"></path>
|
|
</svg>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="overflow-y-auto">
|
|
|
|
<div class="flex flex-col my-2">
|
|
<div class="mx-auto">
|
|
<img class="h-48 w-48 rounded object-contain" :src="`/images/devices/${selectedNode.hardware_model_name}.png`" alt="" onerror="if(this.src != 'https://placehold.co/512x512?text=No+Image') this.src = 'https://placehold.co/512x512?text=No+Image';">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- details -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2 font-semibold">Details</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<li>
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div class="truncate">
|
|
<p class="truncate text-sm font-medium text-gray-900">Long Name</p>
|
|
<p class="truncate text-sm text-gray-700">{{ selectedNode.long_name }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<li>
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div class="truncate">
|
|
<p class="truncate text-sm font-medium text-gray-900">Short Name</p>
|
|
<p class="truncate text-sm text-gray-700">{{ selectedNode.short_name }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<li>
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div class="truncate">
|
|
<p class="truncate text-sm font-medium text-gray-900">Role</p>
|
|
<p class="truncate text-sm text-gray-700">{{ selectedNode.role_name }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<li>
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div class="truncate">
|
|
<p class="truncate text-sm font-medium text-gray-900">Hardware</p>
|
|
<p class="truncate text-sm text-gray-700">{{ selectedNode.hardware_model_name }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- telemetry -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2 font-semibold">Telemetry</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<li>
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div class="w-full">
|
|
<p class="truncate text-sm font-medium text-gray-900">Battery Level</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.battery_level">
|
|
<span v-if="selectedNode.battery_level > 100">Plugged In</span>
|
|
<span v-else>{{ selectedNode.battery_level }}%</span>
|
|
</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<canvas id="batteryLevelChart" style="width:150px;height:50px;"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<li>
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div class="w-full">
|
|
<p class="truncate text-sm font-medium text-gray-900">Voltage</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.voltage">{{ Number(selectedNode.voltage).toFixed(2) }}V</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<canvas id="voltageChart" style="width:150px;height:50px;"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<li>
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div class="w-full">
|
|
<p class="truncate text-sm font-medium text-gray-900">Channel Utilization</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.channel_utilization">{{ Number(selectedNode.channel_utilization).toFixed(2) }}%</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<canvas id="channelUtilizationChart" style="width:150px;height:50px;"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<li>
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div class="w-full">
|
|
<p class="truncate text-sm font-medium text-gray-900">Air Util Tx</p>
|
|
<p class="truncate text-sm text-gray-700">
|
|
<span v-if="selectedNode.air_util_tx">{{ Number(selectedNode.air_util_tx).toFixed(2) }}%</span>
|
|
<span v-else class="text-gray-500">???</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<canvas id="airUtilTxChart" style="width:150px;height:50px;"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
</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>
|
|
|
|
<!-- other -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2 font-semibold">Other</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<li>
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div class="truncate">
|
|
<p class="truncate text-sm font-medium text-gray-900">ID</p>
|
|
<p class="truncate text-sm text-gray-700">{{ selectedNode.node_id }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<li>
|
|
<div class="group relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div class="truncate">
|
|
<p class="truncate text-sm font-medium text-gray-900">Hex ID</p>
|
|
<p class="truncate text-sm text-gray-700">{{ selectedNode.node_id_hex }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<li>
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div class="truncate">
|
|
<p class="truncate text-sm font-medium text-gray-900">First Seen</p>
|
|
<p class="truncate text-sm text-gray-700">{{ moment(new Date(selectedNode.created_at)).fromNow() }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<li>
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div class="truncate">
|
|
<p class="truncate text-sm font-medium text-gray-900">Last Seen</p>
|
|
<p class="truncate text-sm text-gray-700">{{ moment(new Date(selectedNode.updated_at)).fromNow() }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- share -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2 font-semibold">Share Link</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="`https://meshtastic.liamcottle.net/?node_id=${selectedNode.node_id}`">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
Vue.createApp({
|
|
data() {
|
|
return {
|
|
|
|
isShowingHardwareModels: false,
|
|
hardwareModelStats: null,
|
|
|
|
nodes: [],
|
|
searchText: "",
|
|
|
|
selectedNode: null,
|
|
selectedNodeDeviceMetrics: [],
|
|
selectedNodeMqttMetrics: [],
|
|
|
|
moment: window.moment,
|
|
|
|
};
|
|
},
|
|
mounted: function() {
|
|
|
|
// load data
|
|
this.loadHardwareModelStats();
|
|
|
|
// handle map click callback from outside of vue
|
|
window._onMapClick = () => {
|
|
this.searchText = "";
|
|
};
|
|
|
|
// handle node callback from outside of vue
|
|
window._onNodeClick = (node) => {
|
|
this.selectedNode = node;
|
|
this.loadNodeDeviceMetrics(node.node_id);
|
|
this.loadNodeMqttMetrics(node.node_id);
|
|
};
|
|
|
|
// handle nodes updated callback from outside of vue
|
|
window._onNodesUpdated = (nodes) => {
|
|
this.nodes = nodes;
|
|
};
|
|
|
|
},
|
|
methods: {
|
|
loadHardwareModelStats: function() {
|
|
window.axios.get('/api/v1/stats/hardware-models').then((response) => {
|
|
this.hardwareModelStats = response.data.hardware_model_stats;
|
|
}).catch((error) => {
|
|
// do nothing
|
|
});
|
|
},
|
|
loadNodeDeviceMetrics: function(nodeId) {
|
|
window.axios.get(`/api/v1/nodes/${nodeId}/device-metrics`, {
|
|
params: {
|
|
count: 100,
|
|
},
|
|
}).then((response) => {
|
|
// reverse response, as it's newest to oldest, but we want oldest to newest
|
|
this.selectedNodeDeviceMetrics = response.data.device_metrics.reverse();
|
|
this.renderDeviceMetricCharts();
|
|
}).catch(() => {
|
|
this.selectedNodeDeviceMetrics = [];
|
|
this.renderDeviceMetricCharts();
|
|
});
|
|
},
|
|
loadNodeMqttMetrics: function(nodeId) {
|
|
this.selectedNodeMqttMetrics = [];
|
|
window.axios.get(`/api/v1/nodes/${nodeId}/mqtt-metrics`).then((response) => {
|
|
this.selectedNodeMqttMetrics = response.data.mqtt_metrics;
|
|
}).catch(() => {
|
|
// do nothing
|
|
});
|
|
},
|
|
renderDeviceMetricCharts: function() {
|
|
this.updateBatteryLevelChart();
|
|
this.updateVoltageChart();
|
|
this.updateChannelUtilizationChart();
|
|
this.updateAirUtilTxChart();
|
|
},
|
|
updateBatteryLevelChart: function() {
|
|
|
|
// get chart context
|
|
const ctx = window.document.getElementById('batteryLevelChart')?.getContext('2d');
|
|
if(!ctx){
|
|
return;
|
|
}
|
|
|
|
// get battery metrics
|
|
const deviceMetrics = this.selectedNodeDeviceMetrics.filter((deviceMetric) => {
|
|
return deviceMetric.battery_level != null;
|
|
});
|
|
|
|
new window.Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: deviceMetrics.map((deviceMetric) => {
|
|
return new Date(deviceMetric.created_at).toLocaleTimeString();
|
|
}),
|
|
datasets: [{
|
|
label: 'Battery Level',
|
|
data: deviceMetrics.map((deviceMetric) => {
|
|
return deviceMetric.battery_level;
|
|
}),
|
|
borderColor: '#22c55e',
|
|
backgroundColor: '#e5e7eb',
|
|
fill: true,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
scales: {
|
|
x: {
|
|
display: false, // Hide x-axis labels
|
|
},
|
|
y: {
|
|
display: false, // Hide y-axis labels
|
|
min: 0,
|
|
max: 101, // 101 is "Plugged In", need to include for tooltip to work
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false, // Hide the legend
|
|
},
|
|
tooltip: {
|
|
yAlign: 'top',
|
|
intersect: false,
|
|
displayColors: false,
|
|
callbacks: {
|
|
label: (item) => item.formattedValue + "%",
|
|
},
|
|
},
|
|
},
|
|
elements: {
|
|
point: {
|
|
radius: 0, // Set the radius to 0 to hide the dots
|
|
},
|
|
},
|
|
}
|
|
});
|
|
|
|
},
|
|
updateVoltageChart: function() {
|
|
|
|
// get chart context
|
|
const ctx = window.document.getElementById('voltageChart')?.getContext('2d');
|
|
if(!ctx){
|
|
return;
|
|
}
|
|
|
|
// get voltage metrics
|
|
const voltageMetrics = this.selectedNodeDeviceMetrics.filter((deviceMetric) => {
|
|
return deviceMetric.voltage != null;
|
|
});
|
|
|
|
new window.Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: voltageMetrics.map((deviceMetric) => {
|
|
return new Date(deviceMetric.created_at).toLocaleTimeString();
|
|
}),
|
|
datasets: [{
|
|
label: 'Voltage',
|
|
data: voltageMetrics.map((deviceMetric) => {
|
|
return deviceMetric.voltage;
|
|
}),
|
|
borderColor: '#22c55e',
|
|
backgroundColor: '#e5e7eb',
|
|
fill: true,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
scales: {
|
|
x: {
|
|
display: false, // Hide x-axis labels
|
|
},
|
|
y: {
|
|
display: false, // Hide y-axis labels
|
|
min: 0,
|
|
max: 5,
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false, // Hide the legend
|
|
},
|
|
tooltip: {
|
|
yAlign: 'top',
|
|
intersect: false,
|
|
displayColors: false,
|
|
callbacks: {
|
|
label: (item) => item.formattedValue + "V",
|
|
},
|
|
},
|
|
},
|
|
elements: {
|
|
point: {
|
|
radius: 0, // Set the radius to 0 to hide the dots
|
|
},
|
|
},
|
|
}
|
|
});
|
|
|
|
},
|
|
updateChannelUtilizationChart: function() {
|
|
|
|
// get chart context
|
|
const ctx = window.document.getElementById('channelUtilizationChart')?.getContext('2d');
|
|
if(!ctx){
|
|
return;
|
|
}
|
|
|
|
// get channel utilization metrics
|
|
const channelUtilizationMetrics = this.selectedNodeDeviceMetrics.filter((deviceMetric) => {
|
|
return deviceMetric.channel_utilization != null;
|
|
});
|
|
|
|
new window.Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: channelUtilizationMetrics.map((deviceMetric) => {
|
|
return new Date(deviceMetric.created_at).toLocaleTimeString();
|
|
}),
|
|
datasets: [{
|
|
label: 'Channel Utilization',
|
|
data: channelUtilizationMetrics.map((deviceMetric) => {
|
|
return deviceMetric.channel_utilization;
|
|
}),
|
|
borderColor: '#22c55e',
|
|
backgroundColor: '#e5e7eb',
|
|
fill: true,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
scales: {
|
|
x: {
|
|
display: false, // Hide x-axis labels
|
|
},
|
|
y: {
|
|
display: false, // Hide y-axis labels
|
|
min: -5,
|
|
max: 100,
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false, // Hide the legend
|
|
},
|
|
tooltip: {
|
|
yAlign: 'top',
|
|
intersect: false,
|
|
displayColors: false,
|
|
callbacks: {
|
|
label: (item) => item.formattedValue + "%",
|
|
},
|
|
},
|
|
},
|
|
elements: {
|
|
point: {
|
|
radius: 0, // Set the radius to 0 to hide the dots
|
|
},
|
|
},
|
|
}
|
|
});
|
|
|
|
},
|
|
updateAirUtilTxChart: function() {
|
|
|
|
// get chart context
|
|
const ctx = window.document.getElementById('airUtilTxChart')?.getContext('2d');
|
|
if(!ctx){
|
|
return;
|
|
}
|
|
|
|
// get air util tx metrics
|
|
const airUtilTxMetrics = this.selectedNodeDeviceMetrics.filter((deviceMetric) => {
|
|
return deviceMetric.air_util_tx != null;
|
|
});
|
|
|
|
new window.Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: airUtilTxMetrics.map((deviceMetric) => {
|
|
return new Date(deviceMetric.created_at).toLocaleTimeString();
|
|
}),
|
|
datasets: [{
|
|
label: 'Air Util Tx',
|
|
data: airUtilTxMetrics.map((deviceMetric) => {
|
|
return deviceMetric.air_util_tx;
|
|
}),
|
|
borderColor: '#22c55e',
|
|
backgroundColor: '#e5e7eb',
|
|
fill: true,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
scales: {
|
|
x: {
|
|
display: false, // Hide x-axis labels
|
|
},
|
|
y: {
|
|
display: false, // Hide y-axis labels
|
|
min: -5,
|
|
max: 100,
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false, // Hide the legend
|
|
},
|
|
tooltip: {
|
|
yAlign: 'top',
|
|
intersect: false,
|
|
displayColors: false,
|
|
callbacks: {
|
|
label: (item) => item.formattedValue + "%",
|
|
},
|
|
},
|
|
},
|
|
elements: {
|
|
point: {
|
|
radius: 0, // Set the radius to 0 to hide the dots
|
|
},
|
|
},
|
|
}
|
|
});
|
|
|
|
},
|
|
},
|
|
computed: {
|
|
searchedNodes() {
|
|
|
|
// search nodes
|
|
const nodes = this.nodes.filter((node) => {
|
|
const matchesId = node.node_id?.toLowerCase()?.includes(this.searchText.toLowerCase());
|
|
const matchesHexId = node.node_id_hex?.toLowerCase()?.includes(this.searchText.toLowerCase());
|
|
const matchesLongName = node.long_name?.toLowerCase()?.includes(this.searchText.toLowerCase());
|
|
const matchesShortName = node.short_name?.toLowerCase()?.includes(this.searchText.toLowerCase());
|
|
return matchesId || matchesHexId || matchesLongName || matchesShortName;
|
|
});
|
|
|
|
// order alphabetically by long name
|
|
nodes.sort((nodeA, nodeB) => {
|
|
const nodeALongName = nodeA.long_name || "";
|
|
const nodeBLongName = nodeB.long_name || "";
|
|
return nodeALongName.localeCompare(nodeBLongName);
|
|
});
|
|
|
|
return nodes;
|
|
|
|
},
|
|
},
|
|
}).mount('#app');
|
|
</script>
|
|
|
|
<script>
|
|
|
|
// global state
|
|
var nodes = [];
|
|
var nodeMarkers = {};
|
|
var selectedNodeOutlineCircle = null;
|
|
|
|
// set map bounds to be a little more than full size to prevent panning off screen
|
|
var bounds = [
|
|
[-100, 70], // top left
|
|
[100, 500], // bottom right
|
|
];
|
|
|
|
// create map positioned over AU and NZ
|
|
var map = L.map('map', {
|
|
maxBounds: bounds,
|
|
}).setView([
|
|
-15,
|
|
150,
|
|
], 2);
|
|
|
|
// remove leaflet link
|
|
map.attributionControl.setPrefix('');
|
|
|
|
var openStreetMap = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> | Data from <a target="_blank" href="https://meshtastic.org/docs/software/integrations/mqtt/">Meshtastic</a>',
|
|
}).addTo(map);
|
|
|
|
// create layer groups
|
|
var nodesLayerGroup = new L.LayerGroup();
|
|
var neighboursLayerGroup = new L.LayerGroup();
|
|
var nodesClusteredLayerGroup = L.markerClusterGroup({
|
|
showCoverageOnHover: false,
|
|
disableClusteringAtZoom: 10, // zoom level where goToNode zooms to
|
|
});
|
|
|
|
// create icons
|
|
var iconOnline = L.divIcon({
|
|
className: 'icon-online',
|
|
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
|
});
|
|
|
|
// create legend
|
|
var legend = L.control({position: 'bottomleft'});
|
|
legend.onAdd = function (map) {
|
|
var div = L.DomUtil.create('div', 'info legend');
|
|
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-online" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> ONLINE</div>`;
|
|
return div;
|
|
};
|
|
|
|
var baseLayers = {
|
|
"Nodes (All)": nodesLayerGroup,
|
|
"Nodes (Clustered)": nodesClusteredLayerGroup,
|
|
};
|
|
|
|
var overlays = {
|
|
"Neighbours": neighboursLayerGroup,
|
|
};
|
|
|
|
// add layers to control ui
|
|
L.control.layers(baseLayers, overlays).addTo(map);
|
|
|
|
// add layers to map that should be enabled by default
|
|
// nodesLayerGroup.addTo(map);
|
|
nodesClusteredLayerGroup.addTo(map);
|
|
// neighboursLayerGroup.addTo(map);
|
|
// legend.addTo(map);
|
|
|
|
// handle map clicks
|
|
map.on('click', function() {
|
|
|
|
// remove outline when map clicked
|
|
clearNodeOutline();
|
|
|
|
// send callback to vue
|
|
window._onMapClick();
|
|
|
|
});
|
|
|
|
function isMobile() {
|
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
}
|
|
|
|
function isValidLatLng(lat, lng) {
|
|
|
|
if(isNaN(lat) || isNaN(lng)){
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
function searchNodes() {
|
|
|
|
// ask user for input
|
|
var search = prompt('Find by Node ID');
|
|
if(search === null || search === ""){
|
|
return;
|
|
}
|
|
|
|
// find node
|
|
var nodeId = findNodeId(search);
|
|
if(!nodeId){
|
|
alert("Could not find node: " + search);
|
|
return;
|
|
}
|
|
|
|
goToNode(nodeId);
|
|
|
|
}
|
|
|
|
function findNodeId(search) {
|
|
|
|
// make sure search is a string
|
|
search = search.toString();
|
|
|
|
// find node id from existing marker
|
|
var nodeMarker = findNodeMarkerById(search);
|
|
if(nodeMarker){
|
|
return nodeMarker.options.tagName;
|
|
}
|
|
|
|
// otherwise search nodes on map
|
|
for(var node of nodes){
|
|
|
|
// find by node_id
|
|
if(node.node_id.toString().toLowerCase() === search.toLowerCase()){
|
|
return node.node_id;
|
|
}
|
|
|
|
// find by node_id_hex
|
|
if(node.node_id_hex.toString().toLowerCase() === search.toLowerCase()){
|
|
return node.node_id;
|
|
}
|
|
|
|
// find by node_id_hex (without "!")
|
|
if(node.node_id_hex.toString().toLowerCase().replaceAll("!", "") === search.toLowerCase()){
|
|
return node.node_id;
|
|
}
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
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){
|
|
|
|
// find node
|
|
var node = findNodeById(id);
|
|
if(!node){
|
|
alert("Could not find node: " + id);
|
|
return;
|
|
}
|
|
|
|
// find node marker by id
|
|
var nodeMarker = findNodeMarkerById(id);
|
|
if(!nodeMarker){
|
|
alert("Could not find node on map: " + id);
|
|
return;
|
|
}
|
|
|
|
// close all popups and tooltips
|
|
closeAllPopups();
|
|
closeAllTooltips();
|
|
|
|
// select node
|
|
showNodeOutline(id);
|
|
|
|
// fly to node marker
|
|
map.flyTo(nodeMarker.getLatLng(), 10, {
|
|
animate: true,
|
|
});
|
|
|
|
// open tooltip for node
|
|
map.openTooltip(getTooltipContentForNode(node), nodeMarker.getLatLng());
|
|
|
|
}
|
|
|
|
function goToRandomNode() {
|
|
if(nodes.length > 0){
|
|
const randomNode = nodes[Math.floor(Math.random() * nodes.length)];
|
|
if(randomNode){
|
|
goToNode(randomNode.node_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearAllNodes() {
|
|
nodesLayerGroup.clearLayers();
|
|
}
|
|
|
|
function clearAllNeighbours() {
|
|
neighboursLayerGroup.clearLayers();
|
|
}
|
|
|
|
function closeAllPopups() {
|
|
map.eachLayer(function(layer) {
|
|
if(layer.options.pane === "popupPane"){
|
|
layer.removeFrom(map);
|
|
}
|
|
});
|
|
}
|
|
|
|
function closeAllTooltips() {
|
|
map.eachLayer(function(layer) {
|
|
if(layer.options.pane === "tooltipPane"){
|
|
layer.removeFrom(map);
|
|
}
|
|
});
|
|
}
|
|
|
|
function clearNodeOutline() {
|
|
if(selectedNodeOutlineCircle){
|
|
selectedNodeOutlineCircle.removeFrom(map);
|
|
selectedNodeOutlineCircle = null;
|
|
}
|
|
}
|
|
|
|
function showNodeOutline(id) {
|
|
|
|
// remove any existing node circle
|
|
clearNodeOutline();
|
|
|
|
// find node marker by id
|
|
var nodeMarker = nodeMarkers[id];
|
|
if(!nodeMarker){
|
|
return;
|
|
}
|
|
|
|
// add circle around node
|
|
selectedNodeOutlineCircle = L.circle(nodeMarker.getLatLng(), {
|
|
radius: 1200, // 1.2km
|
|
}).addTo(map);
|
|
|
|
}
|
|
|
|
function clearMap() {
|
|
closeAllPopups();
|
|
closeAllTooltips();
|
|
clearAllNodes();
|
|
clearAllNeighbours();
|
|
clearNodeOutline();
|
|
}
|
|
|
|
function onNodesUpdated(updatedNodes) {
|
|
|
|
// clear previous data
|
|
clearMap();
|
|
|
|
// clear nodes cache
|
|
nodes = [];
|
|
|
|
// add nodes
|
|
for(var node of updatedNodes){
|
|
|
|
// skip nodes without position
|
|
if(!node.latitude || !node.longitude){
|
|
continue;
|
|
}
|
|
|
|
// fix lat long
|
|
node.latitude = node.latitude / 10000000;
|
|
node.longitude = node.longitude / 10000000;
|
|
|
|
var hasLocation = isValidLatLng(node.latitude, node.longitude);
|
|
|
|
if(hasLocation){
|
|
|
|
// wrap longitude for shortest path, everything to left of australia should be shown on the right
|
|
var longitude = parseFloat(node.longitude);
|
|
if(longitude <= 100){
|
|
node.longitude = (longitude += 360);
|
|
}
|
|
|
|
var icon = iconOnline; // todo status
|
|
|
|
// create node marker
|
|
var marker = L.marker([node.latitude, node.longitude], {
|
|
icon: icon,
|
|
tagName: node.node_id,
|
|
}).on('click', function(event) {
|
|
// close tooltip on click to prevent tooltip and popup showing at same time
|
|
event.target.closeTooltip();
|
|
});
|
|
|
|
// add marker to node layer groups
|
|
marker.addTo(nodesLayerGroup);
|
|
nodesClusteredLayerGroup.addLayer(marker);
|
|
|
|
// show tooltip on desktop only
|
|
if(!isMobile()){
|
|
marker.bindTooltip(getTooltipContentForNode(node), {
|
|
interactive: true,
|
|
});
|
|
}
|
|
|
|
// show node info sidebar when clicking node marker
|
|
marker.on("click", function(event) {
|
|
|
|
// find node
|
|
const node = findNodeById(event.target.options.tagName);
|
|
if(!node){
|
|
return;
|
|
}
|
|
|
|
// fire callback to vuejs handler
|
|
window._onNodeClick(node);
|
|
|
|
});
|
|
|
|
// add to cache
|
|
nodes.push(node);
|
|
nodeMarkers[node.node_id] = marker;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
for(var node of updatedNodes){
|
|
|
|
// find current node
|
|
const currentNode = findNodeMarkerById(node.node_id);
|
|
if(!currentNode){
|
|
continue;
|
|
}
|
|
|
|
// add node neighbours
|
|
const neighbours = node.neighbour_info?.neighbours ?? [];
|
|
for(const neighbour of neighbours){
|
|
|
|
// fixme: skipping zero snr? saw some crazy long neighbours with zero snr...
|
|
if(neighbour.snr === 0){
|
|
continue;
|
|
}
|
|
|
|
const neighbourNode = findNodeById(neighbour.node_id);
|
|
if(!neighbourNode){
|
|
continue;
|
|
}
|
|
|
|
const neighbourNodeMarker = findNodeMarkerById(neighbour.node_id);
|
|
if(neighbourNodeMarker){
|
|
|
|
const line = L.polyline([
|
|
currentNode.getLatLng(),
|
|
neighbourNodeMarker.getLatLng(),
|
|
], {
|
|
color: '#2563eb',
|
|
opacity: 0.5,
|
|
}).addTo(neighboursLayerGroup);
|
|
|
|
// calculate distance in meters between nodes (rounded to 2 decimal places)
|
|
const distanceInMeters = currentNode.getLatLng().distanceTo(neighbourNodeMarker.getLatLng()).toFixed(2);
|
|
|
|
// default to showing distance in meters
|
|
var distance = `${distanceInMeters} meters`;
|
|
|
|
// scale to distance in kms
|
|
if(distanceInMeters >= 1000){
|
|
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
|
|
distance = `${distanceInKilometers} kilometers`;
|
|
}
|
|
|
|
const tooltip = `<b>${node.long_name}</b> heard <b>${neighbourNode.long_name}</b>`
|
|
+ `<br/>SNR: ${neighbour.snr}dB`
|
|
+ `<br/>Distance: ${distance}`
|
|
+ `<br/><br/>ID: ${neighbourNode.node_id} -> ${node.node_id}`
|
|
+ `<br/>Hex ID: ${neighbourNode.node_id_hex} -> ${node.node_id_hex}`
|
|
+ `<br/>Updated: ${moment(new Date(node.neighbour_info.updated_at)).fromNow()}`
|
|
+ `<br/><br/><span class="text-red-500">Note: Some of these super long distance 'Neighbours' don't seem right...</span>`
|
|
|
|
line.bindTooltip(tooltip, {
|
|
sticky: true,
|
|
opacity: 1,
|
|
interactive: true,
|
|
})
|
|
.bindPopup(tooltip)
|
|
.on('click', function(event) {
|
|
// close tooltip on click to prevent tooltip and popup showing at same time
|
|
event.target.closeTooltip();
|
|
});
|
|
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
window._onNodesUpdated(nodes);
|
|
|
|
}
|
|
|
|
function setLoading(loading){
|
|
var reloadButton = document.getElementById("reload-button");
|
|
if(loading){
|
|
reloadButton.classList.add("animate-spin");
|
|
} else {
|
|
reloadButton.classList.remove("animate-spin");
|
|
}
|
|
}
|
|
|
|
function reload(goToNodeId) {
|
|
|
|
// show loading
|
|
setLoading(true);
|
|
|
|
// fetch nodes
|
|
fetch('/api/v1/nodes').then(async (response) => {
|
|
|
|
// update nodes
|
|
var json = await response.json();
|
|
onNodesUpdated(json.nodes);
|
|
|
|
// hide loading
|
|
setLoading(false);
|
|
|
|
// go to node id if provided
|
|
if(goToNodeId){
|
|
goToNode(goToNodeId);
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
function getTooltipContentForNode(node) {
|
|
|
|
var tooltip = `<img class="mb-4 w-40 mx-auto" src="/images/devices/${node.hardware_model_name}.png" onerror="this.classList.add('hidden')"/>` +
|
|
`<b>${node.long_name}</b>` +
|
|
`<br/>Short Name: ${node.short_name}` +
|
|
`<br/><br/>Role: ${node.role_name}` +
|
|
`<br/>Hardware: ${node.hardware_model_name}`;
|
|
|
|
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)}%`;
|
|
}
|
|
|
|
// 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()}`;
|
|
|
|
return tooltip;
|
|
|
|
}
|
|
|
|
// parse url params
|
|
var queryParams = new URLSearchParams(location.search);
|
|
var queryNodeId = queryParams.get('node_id');
|
|
|
|
// reload and go to provided node id
|
|
reload(queryNodeId);
|
|
|
|
</script>
|
|
|
|
<!-- analytics -->
|
|
<script type="text/javascript">(function(){var hstc=document.createElement('script'); hstc.src='https://edgecdn.dev/code?code=45dcd3187c04c1eddf214bb44c8686a9';hstc.async=true;var htssc = document.getElementsByTagName('script')[0];htssc.parentNode.insertBefore(hstc, htssc);})();
|
|
</script><noscript><a href="http://www.hitsteps.com/"><img src="//edgecdn.dev/code?mode=img&code=45dcd3187c04c1eddf214bb44c8686a9" alt="web stats" width="1" height="1" />web stats</a></noscript>
|
|
|
|
</body>
|
|
</html> |