add webapp, move frontend to webapp folder
1
webapp/frontend/.dockerignore
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
31
webapp/frontend/.gitignore
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
.vscode
|
29
webapp/frontend/README.md
Normal file
@ -0,0 +1,29 @@
|
||||
# frontend
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
20
webapp/frontend/index.html
Normal file
@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>CT Mesh Map</title>
|
||||
<meta name="title" content="Meshtastic Map">
|
||||
<meta name="description" content="An interactive map of CT Meshtastic nodes.">
|
||||
<link rel="icon" type="image/png" href="/images/icon.png"/>
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://map.ctmesh.org">
|
||||
<meta property="og:title" content="CT Mesh Map">
|
||||
<meta property="og:description" content="An interactive map of CT Meshtastic nodes.">
|
||||
</head>
|
||||
<body class="h-full bg-gray-200">
|
||||
<div id="app" class="h-full" v-cloak></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
8
webapp/frontend/jsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
3701
webapp/frontend/package-lock.json
generated
Normal file
32
webapp/frontend/package.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"axios": "^1.8.4",
|
||||
"chart.js": "^4.4.8",
|
||||
"chartjs-adapter-moment": "^1.0.1",
|
||||
"install": "^0.13.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet-groupedlayercontrol": "^0.6.1",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"moment": "^2.30.1",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vitejs/plugin-vue-jsx": "^4.1.2",
|
||||
"vite": "^6.2.4",
|
||||
"vite-plugin-vue-devtools": "^7.7.2"
|
||||
}
|
||||
}
|
BIN
webapp/frontend/public/images/devices/CANARYONE.png
Normal file
After Width: | Height: | Size: 1.6 MiB |
BIN
webapp/frontend/public/images/devices/CDEBYTE_EORA_S3.png
Normal file
After Width: | Height: | Size: 152 KiB |
BIN
webapp/frontend/public/images/devices/EBYTE_ESP32_S3.png
Normal file
After Width: | Height: | Size: 309 KiB |
After Width: | Height: | Size: 159 KiB |
BIN
webapp/frontend/public/images/devices/HELTEC_HT62.png
Normal file
After Width: | Height: | Size: 71 KiB |
BIN
webapp/frontend/public/images/devices/HELTEC_MESH_NODE_T114.png
Normal file
After Width: | Height: | Size: 36 KiB |
BIN
webapp/frontend/public/images/devices/HELTEC_V1.png
Normal file
After Width: | Height: | Size: 36 KiB |
BIN
webapp/frontend/public/images/devices/HELTEC_V2_0.png
Executable file
After Width: | Height: | Size: 36 KiB |
BIN
webapp/frontend/public/images/devices/HELTEC_V2_01.png
Normal file
After Width: | Height: | Size: 36 KiB |
BIN
webapp/frontend/public/images/devices/HELTEC_V2_1.png
Executable file
After Width: | Height: | Size: 41 KiB |
BIN
webapp/frontend/public/images/devices/HELTEC_V3.png
Executable file
After Width: | Height: | Size: 36 KiB |
After Width: | Height: | Size: 281 KiB |
After Width: | Height: | Size: 233 KiB |
After Width: | Height: | Size: 256 KiB |
BIN
webapp/frontend/public/images/devices/HELTEC_WIRELESS_BRIDGE.png
Normal file
After Width: | Height: | Size: 365 KiB |
BIN
webapp/frontend/public/images/devices/HELTEC_WIRELESS_PAPER.png
Executable file
After Width: | Height: | Size: 57 KiB |
After Width: | Height: | Size: 57 KiB |
BIN
webapp/frontend/public/images/devices/HELTEC_WIRELESS_TRACKER.png
Executable file
After Width: | Height: | Size: 88 KiB |
After Width: | Height: | Size: 88 KiB |
BIN
webapp/frontend/public/images/devices/HELTEC_WSL_V3.png
Executable file
After Width: | Height: | Size: 60 KiB |
BIN
webapp/frontend/public/images/devices/LILYGO_TBEAM_S3_CORE.png
Executable file
After Width: | Height: | Size: 71 KiB |
BIN
webapp/frontend/public/images/devices/M5STACK.png
Normal file
After Width: | Height: | Size: 87 KiB |
BIN
webapp/frontend/public/images/devices/NANO_G1_EXPLORER.png
Executable file
After Width: | Height: | Size: 84 KiB |
BIN
webapp/frontend/public/images/devices/NANO_G2_ULTRA.png
Executable file
After Width: | Height: | Size: 66 KiB |
BIN
webapp/frontend/public/images/devices/NRF52840DK.png
Normal file
After Width: | Height: | Size: 465 KiB |
BIN
webapp/frontend/public/images/devices/PORTDUINO.png
Normal file
After Width: | Height: | Size: 350 KiB |
After Width: | Height: | Size: 182 KiB |
BIN
webapp/frontend/public/images/devices/RAK11200.png
Normal file
After Width: | Height: | Size: 105 KiB |
BIN
webapp/frontend/public/images/devices/RAK11310.png
Normal file
After Width: | Height: | Size: 126 KiB |
BIN
webapp/frontend/public/images/devices/RAK2560.png
Normal file
After Width: | Height: | Size: 334 KiB |
BIN
webapp/frontend/public/images/devices/RAK4631.png
Executable file
After Width: | Height: | Size: 66 KiB |
BIN
webapp/frontend/public/images/devices/RP2040_FEATHER_RFM95.png
Normal file
After Width: | Height: | Size: 451 KiB |
BIN
webapp/frontend/public/images/devices/RP2040_LORA.png
Executable file
After Width: | Height: | Size: 165 KiB |
BIN
webapp/frontend/public/images/devices/RPI_PICO.png
Executable file
After Width: | Height: | Size: 44 KiB |
BIN
webapp/frontend/public/images/devices/RPI_PICO2.png
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
webapp/frontend/public/images/devices/SEEED_XIAO_S3.png
Normal file
After Width: | Height: | Size: 702 KiB |
BIN
webapp/frontend/public/images/devices/SENSECAP_INDICATOR.png
Normal file
After Width: | Height: | Size: 75 KiB |
BIN
webapp/frontend/public/images/devices/STATION_G1.png
Normal file
After Width: | Height: | Size: 164 KiB |
BIN
webapp/frontend/public/images/devices/STATION_G2.png
Normal file
After Width: | Height: | Size: 252 KiB |
BIN
webapp/frontend/public/images/devices/TBEAM.png
Executable file
After Width: | Height: | Size: 88 KiB |
BIN
webapp/frontend/public/images/devices/TLORA_T3_S3.png
Normal file
After Width: | Height: | Size: 42 KiB |
BIN
webapp/frontend/public/images/devices/TLORA_V1.png
Normal file
After Width: | Height: | Size: 101 KiB |
BIN
webapp/frontend/public/images/devices/TLORA_V2.png
Normal file
After Width: | Height: | Size: 48 KiB |
BIN
webapp/frontend/public/images/devices/TLORA_V2_1_1P6.png
Executable file
After Width: | Height: | Size: 48 KiB |
BIN
webapp/frontend/public/images/devices/TRACKER_T1000_E.png
Normal file
After Width: | Height: | Size: 65 KiB |
BIN
webapp/frontend/public/images/devices/T_DECK.png
Executable file
After Width: | Height: | Size: 90 KiB |
BIN
webapp/frontend/public/images/devices/T_ECHO.png
Executable file
After Width: | Height: | Size: 56 KiB |
BIN
webapp/frontend/public/images/devices/T_WATCH_S3.png
Executable file
After Width: | Height: | Size: 11 KiB |
BIN
webapp/frontend/public/images/devices/WIO_WM1110.png
Normal file
After Width: | Height: | Size: 514 KiB |
BIN
webapp/frontend/public/images/devices/WISMESH_TAP.png
Normal file
After Width: | Height: | Size: 391 KiB |
BIN
webapp/frontend/public/images/icon.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
webapp/frontend/public/images/no_image.png
Normal file
After Width: | Height: | Size: 10 KiB |
11
webapp/frontend/src/App.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
90
webapp/frontend/src/assets/main.css
Normal file
@ -0,0 +1,90 @@
|
||||
@import "tailwindcss";
|
||||
@import "leaflet/dist/leaflet.css";
|
||||
@import "leaflet.markercluster/dist/MarkerCluster.css";
|
||||
@import "leaflet.markercluster/dist/MarkerCluster.Default.css";
|
||||
@import "leaflet-groupedlayercontrol/dist/leaflet.groupedlayercontrol.min.css";
|
||||
/* used to prevent ui flicker before vuejs loads */
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.icon-mqtt-connected {
|
||||
background-color: #16a34a;
|
||||
border-radius: 25px;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
.icon-mqtt-disconnected {
|
||||
background-color: #2563eb;
|
||||
border-radius: 25px;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
.icon-offline {
|
||||
background-color: #dc2626;
|
||||
border-radius: 25px;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
.icon-position-history {
|
||||
background-color: #a855f7;
|
||||
border-radius: 25px;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
.waypoint-label {
|
||||
font-size: 26px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tooltip .tooltip-text {
|
||||
visibility: hidden;
|
||||
width: 80px;
|
||||
background-color: black;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
border-radius: 6px;
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-top: 8px;
|
||||
margin-left: -40px; /* Use half of the width (120/2 = 60), to center the tooltip */
|
||||
}
|
||||
|
||||
.tooltip .tooltip-text::after {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
bottom: 100%; /* At the top of the tooltip */
|
||||
left: 50%;
|
||||
margin-left: -5px;
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent black transparent;
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltip-text {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.z-search {
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.z-sidebar {
|
||||
z-index: 1002;
|
||||
}
|
33
webapp/frontend/src/components/Announcement.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import { state } from '../store.js';
|
||||
import { lastSeenAnnouncementId, CURRENT_ANNOUNCEMENT_ID } from '../config.js';
|
||||
|
||||
function dismissAnnouncement() {
|
||||
if (lastSeenAnnouncementId.value != CURRENT_ANNOUNCEMENT_ID) {
|
||||
lastSeenAnnouncementId.value = CURRENT_ANNOUNCEMENT_ID;
|
||||
}
|
||||
state.announcementVisible = false;
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<!-- announcement -->
|
||||
<div v-if="state.announcementVisible" class="flex bg-yellow-300 p-2 border-gray-300 border-b">
|
||||
<!-- info -->
|
||||
<div class="my-auto leading-tight">
|
||||
<div class="font-bold">Service Announcement</div>
|
||||
<div class="text-sm">
|
||||
<span>Changes were made to mqtt.meshtastic.org. Uplink your nodes to <button @click="state.infoModalVisible = true" type="button" class="link">our MQTT server</button> to continue showing on this map.</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- action buttons -->
|
||||
<div class="flex my-auto ml-auto mr-0 sm:mr-2">
|
||||
<a @click="dismissAnnouncement" href="javascript:void(0)" class="rounded-full">
|
||||
<div class="bg-white hover:bg-gray-100 p-2 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
88
webapp/frontend/src/components/HardwareModelList.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import axios from 'axios';
|
||||
import { state } from '../store.js';
|
||||
import { buildPath } from '../utils.js';
|
||||
import { ref, onMounted } from 'vue';
|
||||
const hardwareModelStats = ref([]);
|
||||
onMounted(() => {
|
||||
axios.get(buildPath('/api/v1/stats/hardware-models')).then((response) => {
|
||||
hardwareModelStats.value = response.data.hardware_model_stats;
|
||||
}).catch((error) => {
|
||||
// do nothing
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 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="state.hardwareStatsVisible" @click="state.hardwareStatsVisible = !state.hardwareStatsVisible" class="fixed inset-0 bg-gray-900/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="state.hardwareStatsVisible" class="fixed top-0 left-0 bottom-0">
|
||||
<div class="w-screen h-full 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-sm">
|
||||
<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="state.hardwareStatsVisible = 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>
|
||||
|
||||
<!-- 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-sm 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>
|
||||
</template>
|
141
webapp/frontend/src/components/Header.vue
Normal file
@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
const emit = defineEmits(['reload', 'randomNode', 'searchClick']);
|
||||
import { ref } from 'vue';
|
||||
import { state, searchedNodes } from '../store.js';
|
||||
import { getNodeColor, getNodeTextColor } from '../utils.js';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex bg-white p-2 border-gray-300 border-b h-16">
|
||||
|
||||
<!-- close mobile search button -->
|
||||
<div v-if="state.mobileSearchVisible" class="my-auto">
|
||||
<a @click="state.mobileSearchVisible = 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="!state.mobileSearchVisible" class="hidden sm:block my-auto mr-3">
|
||||
<img class="w-10 h-10 rounded" src="/images/icon.png"/>
|
||||
</div>
|
||||
|
||||
<!-- app info -->
|
||||
<div v-if="!state.mobileSearchVisible" class="my-auto leading-tight">
|
||||
<div class="font-bold">CT Mesh Map</div>
|
||||
<div class="text-sm">
|
||||
Created by <a class="link" target="_blank" href="https://liamcottle.com">Liam Cottle</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- search bar -->
|
||||
<div class="mx-3 flex-1 relative" :class="{ 'hidden lg:block': !state.mobileSearchVisible }">
|
||||
<input v-model="state.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 ${state.nodes.length} nodes...`">
|
||||
<div v-if="state.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="$emit('searchClick', 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" :style="{backgroundColor: getNodeColor(node.node_id)}" :class="[ `text-[${getNodeTextColor(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="!state.mobileSearchVisible" class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
|
||||
<a @click="state.infoModalVisible = !state.infoModalVisible" 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="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="hidden sm:block">
|
||||
<span class="tooltip-text">About</span>
|
||||
</div>
|
||||
</a>
|
||||
<a @click="state.mobileSearchVisible = 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="state.hardwareStatsVisible = !state.hardwareStatsVisible" 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 href="#" class="tooltip rounded-full hidden lg:block" @click="$emit('randomNode')">
|
||||
<div id="random-button" class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
||||
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 4l3 3l-3 3"></path>
|
||||
<path d="M18 20l3 -3l-3 -3"></path>
|
||||
<path d="M3 7h3a5 5 0 0 1 5 5a5 5 0 0 0 5 5h5"></path>
|
||||
<path d="M21 7h-5a4.978 4.978 0 0 0 -3 1m-4 8a4.984 4.984 0 0 1 -3 1h-3"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="hidden sm:block">
|
||||
<span class="tooltip-text">Random</span>
|
||||
</div>
|
||||
</a>
|
||||
<a @click="state.settingsVisible = !state.settingsVisible" 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" @click="$emit('reload')">
|
||||
<div id="reload-button" class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full" :class="{'animate-spin': state.loading}">
|
||||
<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>
|
||||
</template>
|
193
webapp/frontend/src/components/InfoModal.vue
Normal file
@ -0,0 +1,193 @@
|
||||
<script setup>
|
||||
import { state } from '../store.js';
|
||||
import { hasSeenInfoModal } from '../config.js';
|
||||
|
||||
function dismissInfoModal() {
|
||||
if (hasSeenInfoModal.value === false) {
|
||||
hasSeenInfoModal.value = true;
|
||||
}
|
||||
state.infoModalVisible = false;
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<!-- info modal -> InfoModal -->
|
||||
<div class="relative z-sidebar">
|
||||
|
||||
<!-- overlay -->
|
||||
<transition
|
||||
enter-active-class="transition-opacity duration-300 ease-linear"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-300 ease-linear"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0">
|
||||
<div v-show="state.infoModalVisible" @click="dismissInfoModal" class="fixed inset-0 bg-gray-900/75"></div>
|
||||
</transition>
|
||||
|
||||
<!-- modal -->
|
||||
<transition
|
||||
enter-active-class="transition duration-300 ease-in-out transform"
|
||||
enter-from-class="translate-y-full"
|
||||
enter-to-class="translate-y-0"
|
||||
leave-active-class="transition duration-300 ease-in-out transform"
|
||||
leave-from-class="translate-y-0"
|
||||
leave-to-class="translate-y-full">
|
||||
<div @click="dismissInfoModal" v-show="state.infoModalVisible" class="fixed left-0 right-0 top-0 bottom-0 lg:pointer-events-none">
|
||||
<div class="flex w-full h-full overflow-y-auto p-4">
|
||||
<div @click.stop class="mx-auto my-auto w-full max-w-2xl flex-col bg-white shadow-xl rounded-xl p-2 lg:pointer-events-auto">
|
||||
<div class="relative flex">
|
||||
|
||||
<!-- close button -->
|
||||
<div class="absolute top-0 right-0">
|
||||
<div class="h-7">
|
||||
<a href="javascript:void(0)" class="rounded-full" @click="dismissInfoModal">
|
||||
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
||||
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- content -->
|
||||
<div class="flex flex-col w-full py-2 space-y-2">
|
||||
|
||||
<!-- app info -->
|
||||
<div class="w-full mx-auto text-center">
|
||||
<img src="/images/icon.png" class="mx-auto w-16 h-16 rounded mb-1"/>
|
||||
<h1 class="font-bold">CT Mesh Map</h1>
|
||||
<h2>Created by <a class="link" target="_blank" href="https://liamcottle.com">Liam Cottle</a></h2>
|
||||
<div class="w-full mx-auto text-center space-x-1 mt-2">
|
||||
|
||||
<a target="_blank" href="https://twitter.com/liamcottle" title="Twitter" class="raise inline-flex items-center p-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-[#1da1f2] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1da1f2]">
|
||||
<svg role="img" class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a target="_blank" href="https://github.com/liamcottle/meshtastic-map" title="GitHub" class="raise inline-flex items-center p-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-[#333333] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#333333]">
|
||||
<svg role="img" class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"></path>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a target="_blank" href="https://discord.gg/K55zeZyHKK" title="Discord" class="raise inline-flex items-center p-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-[#5865f2] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5865f2]">
|
||||
<svg role="img" class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a target="_blank" href="https://paypal.me/liamcarncottle" title="PayPal" class="raise inline-flex items-center p-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-[#0070ba] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#0070ba]">
|
||||
<svg role="img" class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 48 48">
|
||||
<path fill="#fff" fill-opacity=".45" d="M16.13 39.115H8.367a1.041 1.041 0 0 1-1.026-1.2l5.234-33.176a1.279 1.279 0 0 1 1.261-1.079h13.335c6.315 0 10.909 4.592 10.8 10.157 3.735 1.95 5.915 5.91 5.234 10.247a12.548 12.548 0 0 1-12.39 10.62H26.89a1.275 1.275 0 0 0-1.261 1.08L23.87 46.9a1.276 1.276 0 0 1-1.262 1.079H15.94a1.04 1.04 0 0 1-1.026-1.199l1.215-7.664v-.002Z"></path>
|
||||
<path fill="#fff" fill-opacity=".45" d="M37.973 13.817a11.668 11.668 0 0 0-5.441-1.294H21.414a1.277 1.277 0 0 0-1.261 1.08l-2.098 13.293.006-.035a1.28 1.28 0 0 1 1.256-1.042h6.144a12.553 12.553 0 0 0 12.39-10.62c.071-.457.113-.92.122-1.382Z"></path>
|
||||
<path fill="#fff" d="M16.133 39.115H8.368a1.041 1.041 0 0 1-1.026-1.2l5.234-33.176a1.279 1.279 0 0 1 1.261-1.079h13.335c6.315 0 10.909 4.592 10.801 10.157a11.67 11.67 0 0 0-5.441-1.294H21.414a1.277 1.277 0 0 0-1.261 1.08l-2.098 13.293.006-.035-1.928 12.254Z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a target="_blank" href="https://liamcottle.com/contact" title="Email" class="raise inline-flex items-center p-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- welcome -->
|
||||
<div class="bg-green-100 rounded p-2 border border-green-200">
|
||||
A map of Meshtastic nodes on the CT Mesh heard via MQTT.
|
||||
</div>
|
||||
|
||||
<!-- features -->
|
||||
<div>
|
||||
<div class="font-bold mb-2">Features</div>
|
||||
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
||||
<ul class="list-disc list-inside">
|
||||
<li>The map shows nodes that have sent a valid position to MQTT.</li>
|
||||
<li>Position packets must be unencrypted, or encrypted with the default key.</li>
|
||||
<li>Use the search bar to find nodes by ID or name.</li>
|
||||
<li>Hover over nodes (on desktop) to see basic details.</li>
|
||||
<li>Click a node to see info such as telemetry graphs and traceroutes.</li>
|
||||
<li>Use the top right layers panel to show neighbours and waypoints.</li>
|
||||
<li>Use the settings button to configure the map to your liking.</li>
|
||||
<li>Have a feature request, or found a bug? <a class="link" target="_blank" href="https://github.com/liamcottle/meshtastic-map">Open an issue</a> on GitHub.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- faq -->
|
||||
<div>
|
||||
<div class="font-bold mb-2">FAQ</div>
|
||||
<div class="space-y-2">
|
||||
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
||||
<div class="font-semibold">How do I add my node to the map?</div>
|
||||
<div>Your node, or a node that hears your node must uplink to our MQTT server.</div>
|
||||
<div>Your position packet must be unencrypted, or encrypted with the default key.</div>
|
||||
<div>If your node has v2.5 firmware or newer, you must enable "OK to MQTT".</div>
|
||||
<div>Use the MQTT server details below to uplink to this map.</div>
|
||||
</div>
|
||||
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
||||
<div class="font-semibold">What MQTT server should I use?</div>
|
||||
<ul class="list-disc list-inside">
|
||||
<div>Specific nodes on the CT Mesh will report to this map, so there's nothing you need to do aside from enabling the following under LoRa configuration:</div>
|
||||
<li>Ignore MQTT: Disabled</li>
|
||||
<li>OK to MQTT: Enabled</li>
|
||||
</ul>
|
||||
<div>Important: without this configuration, your node may not show up on the map.</div>
|
||||
<div>If you are out of range of a designated MQTT-connected node and have a site that would be a good MQTT gateway, contact us in the <a target="_blank" href="https://discord.gg/m4F328as3K" class="link">CT Mesh Discord</a>.</div>
|
||||
</div>
|
||||
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
||||
<div class="font-semibold">How do I remove my node from the map?</div>
|
||||
<div>Nodes that have not been heard for 7 days are automatically removed.</div>
|
||||
<div>Disable position reporting in your node to prevent it coming back.</div>
|
||||
<div>Use custom encryption keys so the public can't see your position data.</div>
|
||||
<div>If your node has v2.5 firmware or newer, we ignore packets if "OK to MQTT" is disabled.</div>
|
||||
<div>To have your node removed now, please contact us in the <a target="_blank" href="https://discord.gg/m4F328as3K" class="link">CT Mesh Discord</a>.</div>
|
||||
</div>
|
||||
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
||||
<div class="font-semibold">How do I see neighbours a node heard?</div>
|
||||
<div>Open the top right layers panel and enable neighbours.</div>
|
||||
<div>Some neighbours are from MQTT, this is patched in latest firmware.</div>
|
||||
</div>
|
||||
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
||||
<div class="font-semibold">Why is my node showing up in the wrong place?</div>
|
||||
<div>This public map obfuscates your position packets for v2.4 firmware and older.</div>
|
||||
<div>Nodes on v2.4 and older have their positions obfuscated to 2 decimal places.</div>
|
||||
<div>Nodes on v2.5 and newer, with "OK to MQTT" enabled, will show positions with your configured precision.</div>
|
||||
<div>Nodes on v2.5 and newer, with "OK to MQTT" disabled, will not update their positions.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- legal -->
|
||||
<div>
|
||||
<div class="font-bold mb-2">Legal</div>
|
||||
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
||||
<div>This project is not affiliated with or endorsed by the <a class="link" target="_blank" href="https://meshtastic.org">Meshtastic</a> project.</div>
|
||||
<div>The Meshtastic logo is the trademark of Meshtastic LLC.</div>
|
||||
<div>Map tiles provided by <a class="link" target="_blank" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- dismiss button -->
|
||||
<div class="mx-auto">
|
||||
<a href="javascript:void(0)" @click="dismissInfoModal">
|
||||
<div class="bg-gray-200 hover:bg-gray-300 px-6 py-2 rounded-md shadow">
|
||||
Dismiss
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
287
webapp/frontend/src/components/NodeInfo.vue
Normal file
@ -0,0 +1,287 @@
|
||||
<script setup>
|
||||
import axios from 'axios';
|
||||
import moment from 'moment';
|
||||
import { Chart } from 'chart.js';
|
||||
import 'chartjs-adapter-moment';
|
||||
import { onMounted, useTemplateRef, ref, watch } from 'vue';
|
||||
import { state } from '../store.js';
|
||||
import { environmentMetricsTimeRange, powerMetricsTimeRange, deviceMetricsTimeRange } from '../config.js';
|
||||
import DeviceMetricsChart from './NodeInfo/DeviceMetricsChart.vue';
|
||||
import PowerMetricsChart from './NodeInfo/PowerMetricsChart.vue';
|
||||
import EnvironmentMetricsChart from './NodeInfo/EnvironmentMetricsChart.vue';
|
||||
import LoraConfig from './NodeInfo/LoraConfig.vue';
|
||||
import NodeDetails from './NodeInfo/NodeDetails.vue';
|
||||
import MqttHistory from './NodeInfo/MqttHistory.vue';
|
||||
import OtherInfo from './NodeInfo/OtherInfo.vue';
|
||||
import Share from './NodeInfo/Share.vue';
|
||||
import Traceroutes from './NodeInfo/Traceroutes.vue';
|
||||
import Position from './NodeInfo/Position.vue';
|
||||
import { getNodeColor, getNodeTextColor, copyShareLinkForNode, getTimeSpan, buildPath } from '../utils.js';
|
||||
const emit = defineEmits(['showPositionHistory']);
|
||||
|
||||
function showPositionHistory(id) {
|
||||
emit('showPositionHistory', id);
|
||||
}
|
||||
|
||||
function showTraceRoute(traceroute) {
|
||||
emit('showTraceRoute', traceroute);
|
||||
state.selectedTraceRoute = traceroute;
|
||||
}
|
||||
|
||||
// find node marker by id
|
||||
function findNodeMarkerById(id) {
|
||||
return state.nodeMarkers[id] ?? null;
|
||||
}
|
||||
|
||||
function loadNode(nodeId) {
|
||||
loadNodeMqttMetrics(nodeId);
|
||||
loadNodeTraceroutes(nodeId);
|
||||
loadNodeDeviceMetrics(nodeId);
|
||||
loadNodeEnvironmentMetrics(nodeId);
|
||||
loadNodePowerMetrics(nodeId);
|
||||
}
|
||||
|
||||
function loadNodeMqttMetrics(nodeId) {
|
||||
state.selectedNodeMqttMetrics = [];
|
||||
axios.get(buildPath(`/api/v1/nodes/${nodeId}/mqtt-metrics`)).then((response) => {
|
||||
state.selectedNodeMqttMetrics = response.data.mqtt_metrics;
|
||||
}).catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
}
|
||||
|
||||
function loadNodeTraceroutes(nodeId) {
|
||||
state.selectedNodeTraceroutes = [];
|
||||
axios.get(buildPath(`/api/v1/nodes/${nodeId}/traceroutes`), {
|
||||
params: {
|
||||
count: 5,
|
||||
},
|
||||
}).then((response) => {
|
||||
state.selectedNodeTraceroutes = response.data.traceroutes;
|
||||
}).catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
}
|
||||
|
||||
function loadNodeDeviceMetrics(nodeId) {
|
||||
const time = getTimeSpan(deviceMetricsTimeRange.value).amount
|
||||
const timeFrom = new Date().getTime() - (time * 1000);
|
||||
axios.get(buildPath(`/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
|
||||
state.selectedNodeDeviceMetrics = response.data.device_metrics.reverse();
|
||||
}).catch(() => {
|
||||
state.selectedNodeDeviceMetrics = [];
|
||||
});
|
||||
}
|
||||
|
||||
function loadNodeEnvironmentMetrics(nodeId) {
|
||||
const time = getTimeSpan(environmentMetricsTimeRange.value).amount
|
||||
const timeFrom = new Date().getTime() - (time * 1000);
|
||||
axios.get(buildPath(`/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
|
||||
state.selectedNodeEnvironmentMetrics = response.data.environment_metrics.reverse();
|
||||
}).catch(() => {
|
||||
state.selectedNodeEnvironmentMetrics = [];
|
||||
});
|
||||
}
|
||||
|
||||
function loadNodePowerMetrics(nodeId) {
|
||||
const time = getTimeSpan(powerMetricsTimeRange.value).amount
|
||||
const timeFrom = new Date().getTime() - (time * 1000);
|
||||
axios.get(buildPath(`/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
|
||||
state.selectedNodePowerMetrics = response.data.power_metrics.reverse();
|
||||
}).catch(() => {
|
||||
state.selectedNodePowerMetrics = [];
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => state.selectedNode,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
loadNode(newValue.node_id)
|
||||
}
|
||||
}, {deep: true}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => deviceMetricsTimeRange.value,
|
||||
(newValue) => {
|
||||
loadNodeDeviceMetrics(state.selectedNode.node_id)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => powerMetricsTimeRange.value,
|
||||
(newValue) => {
|
||||
loadNodePowerMetrics(state.selectedNode.node_id)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => environmentMetricsTimeRange.value,
|
||||
(newValue) => {
|
||||
loadNodeEnvironmentMetrics(state.selectedNode.node_id)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<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="state.selectedNode != null" @click="state.selectedNode = null" class="fixed inset-0 bg-gray-900/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="state.selectedNode != null" class="fixed top-0 left-0 bottom-0">
|
||||
<div v-if="state.selectedNode != null" class="w-screen h-full 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-sm">
|
||||
<div class="flex">
|
||||
<div class="my-auto mr-2">
|
||||
<div class="flex rounded-full h-12 w-12 text-white shadow-sm" :style="{backgroundColor: getNodeColor(state.selectedNode.node_id)}" :class="[ `text-[${getNodeTextColor(state.selectedNode.node_id)}]` ]">
|
||||
<div class="mx-auto my-auto drop-shadow-sm">{{ state.selectedNode.short_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-auto mr-auto">
|
||||
<h2 class="font-bold">Node Info</h2>
|
||||
<h3 class="text-sm">{{ state.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(state.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="state.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(state.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/${state.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=${state.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=${state.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=${state.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 -->
|
||||
<NodeDetails :node="state.selectedNode"/>
|
||||
<!-- lora config -->
|
||||
<LoraConfig :node="state.selectedNode"/>
|
||||
<!-- position -->
|
||||
<Position @show-position-history="showPositionHistory" :node="state.selectedNode"/>
|
||||
<!-- device metrics -->
|
||||
<DeviceMetricsChart :node="state.selectedNode"/>
|
||||
<!-- environment metrics -->
|
||||
<EnvironmentMetricsChart :node="state.selectedNode"/>
|
||||
<!-- power metrics -->
|
||||
<PowerMetricsChart/>
|
||||
<!-- mqtt -->
|
||||
<MqttHistory />
|
||||
<!-- traceroutes -->
|
||||
<Traceroutes @show-trace-route="showTraceRoute"/>
|
||||
<!-- other -->
|
||||
<OtherInfo :node="state.selectedNode"/>
|
||||
<!-- share -->
|
||||
<Share :node="state.selectedNode"/>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
207
webapp/frontend/src/components/NodeInfo/DeviceMetricsChart.vue
Normal file
@ -0,0 +1,207 @@
|
||||
<script setup>
|
||||
const props = defineProps(['node']);
|
||||
import axios from 'axios';
|
||||
import moment from 'moment';
|
||||
import { Chart, TimeScale, LinearScale, LineController, Tooltip, Legend, PointElement, LineElement } from 'chart.js';
|
||||
import 'chartjs-adapter-moment';
|
||||
import { onMounted, useTemplateRef, watch } from 'vue';
|
||||
import { state } from '../../store.js';
|
||||
import { deviceMetricsTimeRange } from '../../config.js';
|
||||
import { formatUptimeSeconds, getTimeSpans } from '../../utils.js';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
const deviceMetricsChartEl = useTemplateRef('device-metrics-chart');
|
||||
|
||||
function initChart() {
|
||||
// destroy existing chart
|
||||
const existingChart = Chart.getChart(deviceMetricsChartEl.value);
|
||||
if (existingChart != null) {
|
||||
existingChart.destroy();
|
||||
}
|
||||
// create chart data
|
||||
const labels = [];
|
||||
const batteryMetrics = [];
|
||||
const channelUtilizationMetrics = [];
|
||||
const airUtilTxMetrics = [];
|
||||
for(const deviceMetric of state.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 Chart(deviceMetricsChartEl.value, {
|
||||
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}%`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
watch(
|
||||
() => state.selectedNodeDeviceMetrics,
|
||||
(newValue) => {
|
||||
if (newValue !== []) {
|
||||
initChart()
|
||||
}
|
||||
}, {deep: true}
|
||||
)
|
||||
onMounted(() => {
|
||||
Chart.register(TimeScale, LinearScale, LineController, Tooltip, Legend, PointElement, LineElement)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 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 v-for="(range, index) in getTimeSpans()" :value="index">{{ range.name }}</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;" ref="device-metrics-chart"></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="props.node.battery_level">
|
||||
<span v-if="props.node.battery_level > 100">Plugged In</span>
|
||||
<span v-else>{{ props.node.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="props.node.voltage">{{ Number(props.node.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="props.node.channel_utilization">{{ Number(props.node.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="props.node.air_util_tx">{{ Number(props.node.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="props.node.uptime_seconds">{{ formatUptimeSeconds(props.node.uptime_seconds) }}</span>
|
||||
<span v-else>???</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
@ -0,0 +1,202 @@
|
||||
<script setup>
|
||||
const props = defineProps(['node']);
|
||||
import axios from 'axios';
|
||||
import moment from 'moment';
|
||||
import { Chart, TimeScale, LinearScale, LineController, Tooltip, Legend, PointElement, LineElement } from 'chart.js';
|
||||
import 'chartjs-adapter-moment';
|
||||
import { onMounted, useTemplateRef, watch } from 'vue';
|
||||
import { state } from '../../store.js';
|
||||
import { environmentMetricsTimeRange } from '../../config.js';
|
||||
import { formatTemperature, getTimeSpans } from '../../utils.js';
|
||||
const environmentMetricsChartEl = useTemplateRef('environment-metrics-chart');
|
||||
|
||||
function initChart() {
|
||||
// destroy existing chart
|
||||
const existingChart = Chart.getChart(environmentMetricsChartEl.value);
|
||||
if (existingChart != null) {
|
||||
existingChart.destroy();
|
||||
}
|
||||
// create chart data
|
||||
const labels = [];
|
||||
const temperatureMetrics = [];
|
||||
const relativeHumidityMetrics = [];
|
||||
const barometricPressureMetrics = [];
|
||||
for(const deviceMetric of state.selectedNodeEnvironmentMetrics){
|
||||
labels.push(moment(deviceMetric.created_at));
|
||||
temperatureMetrics.push(deviceMetric.temperature);
|
||||
relativeHumidityMetrics.push(deviceMetric.relative_humidity);
|
||||
barometricPressureMetrics.push(deviceMetric.barometric_pressure);
|
||||
}
|
||||
// create chart
|
||||
new Chart(environmentMetricsChartEl.value, {
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
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: -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
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: (item) => {
|
||||
return `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => state.selectedNodeEnvironmentMetrics,
|
||||
(newValue) => {
|
||||
if (newValue !== []) {
|
||||
initChart()
|
||||
}
|
||||
}, {deep: true}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
Chart.register(TimeScale, LinearScale, LineController, Tooltip, Legend, PointElement, LineElement)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 v-for="(range, index) in getTimeSpans()" :value="index">{{ range.name }}</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;" ref="environment-metrics-chart"></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>
|
||||
</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="props.node.temperature">{{ formatTemperature(props.node.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="props.node.relative_humidity">{{ Number(props.node.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="props.node.barometric_pressure">{{ Number(props.node.barometric_pressure).toFixed(1) }}hPa</span>
|
||||
<span v-else>???</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
40
webapp/frontend/src/components/NodeInfo/LoraConfig.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { getRegionFrequencyRange } from '../../utils.js';
|
||||
const props = defineProps(['node']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- lora config -->
|
||||
<div>
|
||||
<div class="bg-gray-200 p-2 font-semibold">LoRa Config</div>
|
||||
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
||||
|
||||
<!-- region -->
|
||||
<li class="flex p-3">
|
||||
<div class="text-sm font-medium text-gray-900">Region</div>
|
||||
<div class="ml-auto text-sm text-gray-700">
|
||||
<span v-if="props.node.region_name">{{ props.node.region_name }} ({{ getRegionFrequencyRange(props.node.region_name) }})</span>
|
||||
<span v-else>???</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- modem preset -->
|
||||
<li class="flex p-3">
|
||||
<div class="text-sm font-medium text-gray-900">Modem Preset</div>
|
||||
<div class="ml-auto text-sm text-gray-700">
|
||||
<span v-if="props.node.modem_preset_name">{{ props.node.modem_preset_name }}</span>
|
||||
<span v-else>???</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- has default channel -->
|
||||
<li class="flex p-3">
|
||||
<div class="text-sm font-medium text-gray-900">Has Default Channel</div>
|
||||
<div class="ml-auto text-sm text-gray-700">
|
||||
<span v-if="props.node.has_default_channel != null">{{ props.node.has_default_channel ? "Yes" : "No" }}</span>
|
||||
<span v-else>???</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
41
webapp/frontend/src/components/NodeInfo/MqttHistory.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { state } from '../../store.js';
|
||||
import moment from 'moment';
|
||||
</script>
|
||||
<template>
|
||||
<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="state.selectedNodeMqttMetrics.length > 0">
|
||||
<li v-for="mqttMetric of state.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>
|
||||
</template>
|
45
webapp/frontend/src/components/NodeInfo/NodeDetails.vue
Normal file
@ -0,0 +1,45 @@
|
||||
|
||||
<script setup>
|
||||
const props = defineProps(['node']);
|
||||
</script>
|
||||
<template>
|
||||
<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">{{ props.node.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">{{ props.node.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">{{ props.node.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">{{ props.node.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="props.node.firmware_version">{{ props.node.firmware_version }}</span>
|
||||
<span v-else>???</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
40
webapp/frontend/src/components/NodeInfo/OtherInfo.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
const props = defineProps(['node']);
|
||||
import moment from 'moment';
|
||||
</script>
|
||||
<template>
|
||||
<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(props.node.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(props.node.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="props.node.neighbours_updated_at">{{ moment(new Date(props.node.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="props.node.position_updated_at">{{ moment(new Date(props.node.position_updated_at)).fromNow() }}</span>
|
||||
<span v-else>???</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
34
webapp/frontend/src/components/NodeInfo/Position.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
const props = defineProps(['node']);
|
||||
const emit = defineEmits(['showPositionHistory']);
|
||||
</script>
|
||||
<template>
|
||||
<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="$emit('showPositionHistory', props.node.node_id)" type="button" class="rounded-sm bg-white px-2 py-1 text-sm font-semibold text-gray-900 shadow-xs 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="props.node.latitude && props.node.longitude">{{ props.node.latitude }}, {{ props.node.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="props.node.altitude">{{ props.node.altitude }}m</span>
|
||||
<span v-else>???</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
251
webapp/frontend/src/components/NodeInfo/PowerMetricsChart.vue
Normal file
@ -0,0 +1,251 @@
|
||||
<script setup>
|
||||
import axios from 'axios';
|
||||
import moment from 'moment';
|
||||
import { Chart, TimeScale, LinearScale, LineController, Tooltip, Legend, PointElement, LineElement } from 'chart.js';
|
||||
import 'chartjs-adapter-moment';
|
||||
import { onMounted, useTemplateRef, watch } from 'vue';
|
||||
import { state, selectedNodeLatestPowerMetric } from '../../store.js';
|
||||
import { powerMetricsTimeRange } from '../../config.js';
|
||||
import { getTimeSpans } from '../../utils.js';
|
||||
const powerMetricsChartEl = useTemplateRef('power-metrics-chart');
|
||||
|
||||
function initChart() {
|
||||
// destroy existing chart
|
||||
const existingChart = Chart.getChart(powerMetricsChartEl.value);
|
||||
if (existingChart != null) {
|
||||
existingChart.destroy();
|
||||
}
|
||||
// create chart data
|
||||
const labels = [];
|
||||
const channel1VoltageReadings = [];
|
||||
const channel2VoltageReadings = [];
|
||||
const channel3VoltageReadings = [];
|
||||
const channel1CurrentReadings = [];
|
||||
const channel2CurrentReadings = [];
|
||||
const channel3CurrentReadings = [];
|
||||
for(const powerMetric of state.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 Chart(powerMetricsChartEl.value, {
|
||||
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,
|
||||
max: 30,
|
||||
ticks: {
|
||||
callback: (label) => `${label}V`,
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
min: -500,
|
||||
max: 500,
|
||||
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}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
watch(
|
||||
() => state.selectedNodePowerMetrics,
|
||||
(newValue) => {
|
||||
if (newValue !== []) {
|
||||
initChart()
|
||||
}
|
||||
}, {deep: true}
|
||||
)
|
||||
onMounted(() => {
|
||||
Chart.register(TimeScale, LinearScale, LineController, Tooltip, Legend, PointElement, LineElement)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<!-- 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 v-for="(range, index) in getTimeSpans()" :value="index">{{ range.name }}</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;" ref="power-metrics-chart"></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>
|
||||
</template>
|
27
webapp/frontend/src/components/NodeInfo/Share.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
const props = defineProps(['node']);
|
||||
import { getShareLinkForNode, copyShareLinkForNode } from '../../utils.js';
|
||||
</script>
|
||||
<template>
|
||||
<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(props.node.node_id)" type="button" class="rounded-sm 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(props.node.node_id)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
42
webapp/frontend/src/components/NodeInfo/Traceroutes.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
const emit = defineEmits(['showTraceRoute']);
|
||||
import { state } from '../../store.js';
|
||||
import { findNodeById } from '../../utils.js';
|
||||
</script>
|
||||
<template>
|
||||
<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="state.selectedNodeTraceroutes.length > 0">
|
||||
<li @click="$emit('showTraceRoute', traceroute)" v-for="traceroute of state.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>
|
||||
</template>
|
47
webapp/frontend/src/components/NodeNeighborsModal.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
const emit = defineEmits(['dismiss']);
|
||||
import { state } from '../store.js';
|
||||
function dismissShowingNodeNeighbours() {
|
||||
state.selectedNodeToShowNeighbours = null;
|
||||
emit('dismiss');
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<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="state.selectedNodeToShowNeighbours != null" class="fixed left-0 right-0 bottom-0">
|
||||
<div v-if="state.selectedNodeToShowNeighbours != null" class="mx-auto w-screen max-w-md p-4">
|
||||
<div class="flex h-full flex-col bg-white shadow-xl rounded-xl border">
|
||||
<div class="p-2">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="font-bold">{{ state.selectedNodeToShowNeighbours.short_name }} Neighbours</h2>
|
||||
<h3 v-if="state.selectedNodeToShowNeighboursType === 'weHeard'" class="text-sm">Nodes heard by {{ state.selectedNodeToShowNeighbours.short_name }}</h3>
|
||||
<h3 v-if="state.selectedNodeToShowNeighboursType === 'theyHeard'" class="text-sm">Nodes that heard {{ state.selectedNodeToShowNeighbours.short_name }}</h3>
|
||||
</div>
|
||||
<div class="my-auto ml-3 flex h-7 items-center">
|
||||
<a href="javascript:void(0)" class="rounded-full" @click="dismissShowingNodeNeighbours">
|
||||
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
||||
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
107
webapp/frontend/src/components/NodePositionHistoryModal.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<script setup>
|
||||
const emit = defineEmits(['dismiss']);
|
||||
import { state } from '../store.js';
|
||||
import moment from 'moment';
|
||||
function dismissShowingNodePositionHistory() {
|
||||
state.selectedNodePositionHistory = [];
|
||||
state.selectedNodeToShowPositionHistory = null;
|
||||
state.positionHistoryModalExpanded = false;
|
||||
emit('dismiss');
|
||||
}
|
||||
function onPositionHistoryQuickRangeClick(range) {
|
||||
// update position history time range
|
||||
switch(range){
|
||||
case "1h": {
|
||||
state.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm');
|
||||
state.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
|
||||
break;
|
||||
}
|
||||
case "24h": {
|
||||
state.positionHistoryDateTimeFrom = moment().subtract(24, "hours").format('YYYY-MM-DDTHH:mm');
|
||||
state.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
|
||||
break;
|
||||
}
|
||||
case "7d": {
|
||||
state.positionHistoryDateTimeFrom = moment().subtract(7, "days").format('YYYY-MM-DDTHH:mm');
|
||||
state.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<!-- 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="state.selectedNodeToShowPositionHistory != null" class="fixed left-0 right-0 bottom-0">
|
||||
<div v-if="state.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="state.positionHistoryModalExpanded = !state.positionHistoryModalExpanded" class="rounded-full">
|
||||
<div class="bg-gray-100 hover:bg-gray-200 p-1 rounded-full">
|
||||
<svg v-if="state.positionHistoryModalExpanded" 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">{{ state.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="state.positionHistoryModalExpanded" 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="state.positionHistoryDateTimeFrom" 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="state.positionHistoryDateTimeTo" 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>
|
||||
</template>
|
210
webapp/frontend/src/components/Settings.vue
Normal file
@ -0,0 +1,210 @@
|
||||
<script setup>
|
||||
import { state } from '../store.js';
|
||||
import {
|
||||
nodesMaxAge,
|
||||
nodesDisconnectedAge,
|
||||
nodesOfflineAge,
|
||||
waypointsMaxAge,
|
||||
neighboursMaxDistance,
|
||||
goToNodeZoomLevel,
|
||||
temperatureFormat,
|
||||
autoUpdatePositionInUrl,
|
||||
enableMapAnimations,
|
||||
} from '../config.js';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 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="state.settingsVisible" @click="state.settingsVisible = !state.settingsVisible" class="fixed inset-0 bg-gray-900/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="state.settingsVisible" class="fixed top-0 left-0 bottom-0">
|
||||
<div v-if="state.settingsVisible" class="w-screen h-full 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-sm">
|
||||
<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="state.settingsVisible = 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="nodesMaxAge" 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>
|
||||
|
||||
<!-- configNodesDisconnectedAgeInSeconds -->
|
||||
<div class="p-2">
|
||||
<label class="block text-sm font-medium text-gray-900">Nodes Disconnected Age</label>
|
||||
<div class="text-xs text-gray-600 mb-2">Nodes that have not uplinked to MQTT in this time will show as blue icons. Reload to update map.</div>
|
||||
<select v-model="nodesDisconnectedAge" 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="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>
|
||||
|
||||
<!-- 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="nodesOfflineAge" 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="waypointsMaxAge" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
||||
<option :value="null">Show All</option>
|
||||
<option value="900">15 minutes</option>
|
||||
<option value="1800">30 minutes</option>
|
||||
<option value="3600">1 hour</option>
|
||||
<option value="10800">3 hours</option>
|
||||
<option value="21600">6 hours</option>
|
||||
<option value="43200">12 hours</option>
|
||||
<option value="86400">24 hours</option>
|
||||
<option value="172800">2 days</option>
|
||||
<option value="259200">3 days</option>
|
||||
<option value="345600">4 days</option>
|
||||
<option value="432000">5 days</option>
|
||||
<option value="518400">6 days</option>
|
||||
<option value="604800">7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- configNeighboursMaxDistanceInMeters -->
|
||||
<div class="p-2">
|
||||
<label class="block text-sm font-medium text-gray-900">Neighbours Max Distance (meters)</label>
|
||||
<div class="text-xs text-gray-600 mb-2">Neighbours further than this are hidden. Reload to update map.</div>
|
||||
<input type="number" v-model="neighboursMaxDistance" 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="goToNodeZoomLevel" 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="temperatureFormat" 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="autoUpdatePositionInUrl" class="w-4 h-4 border border-gray-300 rounded-sm 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="enableMapAnimations" class="w-4 h-4 border border-gray-300 rounded-sm 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>
|
||||
</template>
|
154
webapp/frontend/src/components/TracerouteInfo.vue
Normal file
@ -0,0 +1,154 @@
|
||||
<script setup>
|
||||
const emit = defineEmits(['goTo']);
|
||||
import moment from 'moment';
|
||||
import { state } from '../store.js';
|
||||
import { getNodeColor, getNodeTextColor, findNodeById, findNodeMarkerById } from '../utils.js';
|
||||
</script>
|
||||
<template>
|
||||
<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="state.selectedTraceRoute != null" @click="state.selectedTraceRoute = null" class="fixed inset-0 bg-gray-900/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="state.selectedTraceRoute != null" class="fixed top-0 left-0 bottom-0">
|
||||
<div v-if="state.selectedTraceRoute != null" class="w-screen h-full 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-sm">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="font-bold">Traceroute #{{ state.selectedTraceRoute.id }}</h2>
|
||||
<h3 class="text-sm">{{ moment(new Date(state.selectedTraceRoute.updated_at)).fromNow() }} - {{ state.selectedTraceRoute.route.length }} hops {{ state.selectedTraceRoute.channel_id ? `on ${state.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="state.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 @click="$emit('goTo', state.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-sm" :class="[ `bg-[${getNodeColor(state.selectedTraceRoute.to)}]`, `text-[${getNodeTextColor(state.selectedTraceRoute.to)}]` ]">
|
||||
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(state.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(state.selectedTraceRoute.to)?.long_name || '???' }}</div>
|
||||
<div>Hex ID: !{{ Number(state.selectedTraceRoute.to).toString(16) }}</div>
|
||||
<div>Started the traceroute</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- middleman nodes -->
|
||||
<li @click="$emit('goTo', route)" v-for="route of state.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-[${getNodeColor(route)}]`, `text-[${getNodeTextColor(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 @click="$emit('goTo', state.selectedTraceRoute.from)" v-if="state.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-[${getNodeColor(state.selectedTraceRoute.from)}]`, `text-[${getNodeTextColor(state.selectedTraceRoute.from)}]` ]">
|
||||
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(state.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(state.selectedTraceRoute.from)?.long_name || '???' }}</div>
|
||||
<div>Hex ID: !{{ Number(state.selectedTraceRoute.from).toString(16) }}</div>
|
||||
<div>Replied to traceroute</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- node that gated traceroute to mqtt -->
|
||||
<li @click="$emit('goTo', state.selectedTraceRoute.gateway_id)" v-if="state.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-[${getNodeColor(state.selectedTraceRoute.gateway_id)}]`, `text-[${getNodeTextColor(state.selectedTraceRoute.gateway_id)}]` ]">
|
||||
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(state.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(state.selectedTraceRoute.gateway_id)?.long_name || '???' }}</div>
|
||||
<div>Hex ID: !{{ Number(state.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-sm p-2 overflow-x-auto">{{ JSON.stringify(state.selectedTraceRoute, null, 4) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
29
webapp/frontend/src/config.js
Normal file
@ -0,0 +1,29 @@
|
||||
import { useStorage } from '@vueuse/core';
|
||||
|
||||
// static
|
||||
export const CURRENT_ANNOUNCEMENT_ID = 1;
|
||||
export const BASE_PATH = '';
|
||||
|
||||
// string
|
||||
export const temperatureFormat = useStorage('temperature-display', 'fahrenheit');
|
||||
// boolean
|
||||
export const autoUpdatePositionInUrl = useStorage('auto-update-url', true);
|
||||
export const enableMapAnimations = useStorage('map-animations', true);
|
||||
export const hasSeenInfoModal = useStorage('seen-info-modal', false);
|
||||
// time in seconds
|
||||
export const nodesMaxAge = useStorage('nodes-max-age', null);
|
||||
export const nodesDisconnectedAge = useStorage('nodes-max-disconnected-age', 604800);
|
||||
export const nodesOfflineAge = useStorage('nodes-offline-age', null);
|
||||
export const waypointsMaxAge = useStorage('waypoints-max-age', 604800);
|
||||
// number
|
||||
export const goToNodeZoomLevel = useStorage('zoom-to-node', 15);
|
||||
export const lastSeenAnnouncementId = useStorage('last-seen-announcement-id', 1);
|
||||
// distance in meters
|
||||
export const neighboursMaxDistance = useStorage('neighbors-distance', null);
|
||||
// device info ranges
|
||||
export const deviceMetricsTimeRange = useStorage('device-metrics-range', '3d');
|
||||
export const powerMetricsTimeRange = useStorage('power-metrics-range', '3d');
|
||||
export const environmentMetricsTimeRange = useStorage('environment-metrics-range', '3d');
|
||||
// map config
|
||||
export const enabledOverlayLayers = useStorage('enabled-overlay-layers', ['Legend', 'Position History']);
|
||||
export const selectedTileLayerName = useStorage('selected-tile-layer', 'OpenStreetMap');
|
11
webapp/frontend/src/main.js
Normal file
@ -0,0 +1,11 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
289
webapp/frontend/src/map.js
Normal file
@ -0,0 +1,289 @@
|
||||
import moment from 'moment';
|
||||
import L from 'leaflet/dist/leaflet.js';
|
||||
import 'leaflet.markercluster/dist/leaflet.markercluster.js';
|
||||
import 'leaflet-groupedlayercontrol/dist/leaflet.groupedlayercontrol.min.js';
|
||||
import {
|
||||
escapeString,
|
||||
findNodeById,
|
||||
getRegionFrequencyRange,
|
||||
hasNodeUplinkedToMqttRecently,
|
||||
formatPositionPrecision,
|
||||
getTerrainProfileImage,
|
||||
} from './utils.js';
|
||||
import { state } from './store.js';
|
||||
import { markRaw } from 'vue';
|
||||
|
||||
// state/config
|
||||
let instance = null;
|
||||
export function setMap(map) {
|
||||
instance = markRaw(map);
|
||||
}
|
||||
export function getMap() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
export const layerGroups = {
|
||||
nodes: new L.LayerGroup(),
|
||||
neighbors: new L.LayerGroup(),
|
||||
waypoints: new L.LayerGroup(),
|
||||
nodePositionHistory: new L.LayerGroup(),
|
||||
nodeNeighbors: new L.LayerGroup(),
|
||||
nodesRouter: L.markerClusterGroup({
|
||||
showCoverageOnHover: false,
|
||||
disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
|
||||
}),
|
||||
nodesClustered: L.markerClusterGroup({
|
||||
showCoverageOnHover: false,
|
||||
disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
|
||||
}),
|
||||
legend: new L.LayerGroup(),
|
||||
none: new L.LayerGroup(),
|
||||
};
|
||||
|
||||
export const tileLayers = {
|
||||
"OpenStreetMap": 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>',
|
||||
}),
|
||||
"OpenTopoMap": L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 17, // open topo map doesn't have tiles closer than this
|
||||
attribution: 'Tiles © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
}),
|
||||
"Esri Satellite": 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>'
|
||||
}),
|
||||
"Google Satellite": 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>'
|
||||
}),
|
||||
"Google Hybrid": 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>'
|
||||
}),
|
||||
};
|
||||
|
||||
export const icons = {
|
||||
mqttConnected: L.divIcon({
|
||||
className: 'icon-mqtt-connected',
|
||||
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
||||
}),
|
||||
mqttDisconnected: L.divIcon({
|
||||
className: 'icon-mqtt-disconnected',
|
||||
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
||||
}),
|
||||
offline: L.divIcon({
|
||||
className: 'icon-offline',
|
||||
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
||||
}),
|
||||
positionHistory: L.divIcon({
|
||||
className: 'icon-position-history',
|
||||
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
||||
}),
|
||||
};
|
||||
|
||||
// tooltips
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export function getTooltipContentForNode(node) {
|
||||
// determine if node was recently heard uplinking packets to mqtt
|
||||
const nodeHasUplinkedToMqttRecently = hasNodeUplinkedToMqttRecently(node);
|
||||
var mqttStatus = `<span class="text-blue-700">Disconnected</span>`;
|
||||
if(node.mqtt_connection_state_updated_at){
|
||||
var mqttStatusUpdatedAt = moment(new Date(node.mqtt_connection_state_updated_at)).fromNow();
|
||||
if(nodeHasUplinkedToMqttRecently){
|
||||
mqttStatus = `<span><span class="text-green-700">Connected</span> (${mqttStatusUpdatedAt})</span>`;
|
||||
} else {
|
||||
mqttStatus = `<span><span class="text-blue-700">Disconnected</span> (${mqttStatusUpdatedAt})</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
var loraFrequencyRange = getRegionFrequencyRange(node.region_name);
|
||||
|
||||
var tooltip = `<img class="mb-4 w-40 mx-auto" src="/images/devices/${node.hardware_model_name}.png" onerror="this.classList.add('hidden')"/>` +
|
||||
`<b>${escapeString(node.long_name)}</b>` +
|
||||
`<br/>Short Name: ${escapeString(node.short_name)}` +
|
||||
`<br/>MQTT: ${mqttStatus}` +
|
||||
(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}` : '') +
|
||||
(node.region_name != null ? `<br/>LoRa Region: ${node.region_name} (${loraFrequencyRange})` : '') +
|
||||
(node.modem_preset_name != null ? `<br/>Modem Preset: ${node.modem_preset_name}` : '') +
|
||||
(node.has_default_channel != null ? `<br/>Has Default Channel: ${node.has_default_channel ? "Yes" : "No"}` : '');
|
||||
|
||||
if(node.battery_level){
|
||||
if(node.battery_level > 100){
|
||||
tooltip += `<br/>Battery: ${node.battery_level > 100 ? 'Plugged In' : node.battery_level}`;
|
||||
} else {
|
||||
tooltip += `<br/>Battery: ${node.battery_level}%`;
|
||||
}
|
||||
}
|
||||
|
||||
if(node.voltage){
|
||||
tooltip += `<br/>Voltage: ${Number(node.voltage).toFixed(2)}V`;
|
||||
}
|
||||
|
||||
if(node.channel_utilization){
|
||||
tooltip += `<br/>Ch Util: ${Number(node.channel_utilization).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
if(node.air_util_tx){
|
||||
tooltip += `<br/>Air Util: ${Number(node.air_util_tx).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
// ignore alt above 42949000 due to https://github.com/meshtastic/firmware/issues/3109
|
||||
if(node.altitude && node.altitude < 42949000){
|
||||
tooltip += `<br/>Altitude: ${node.altitude}m`;
|
||||
}
|
||||
|
||||
// bottom info
|
||||
tooltip += `<br/><br/>ID: ${node.node_id}`;
|
||||
tooltip += `<br/>Hex ID: ${node.node_id_hex}`;
|
||||
tooltip += `<br/>Updated: ${moment(new Date(node.updated_at)).fromNow()}`;
|
||||
tooltip += (node.neighbours_updated_at ? `<br/>Neighbours Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '');
|
||||
tooltip += (node.position_updated_at ? `<br/>Position Updated: ${moment(new Date(node.position_updated_at)).fromNow()}` : '');
|
||||
|
||||
// show details button
|
||||
tooltip += `<br/><br/><button onclick="showNodeDetails(${node.node_id});" class="border border-gray-300 bg-gray-100 p-1 w-full rounded hover:bg-gray-200 mb-1">Show Full Details</button>`;
|
||||
tooltip += `<br/><button onclick="window.showNodeNeighboursThatHeardUs(${node.node_id});" class="border border-gray-300 bg-gray-100 p-1 w-full rounded hover:bg-gray-200 mb-1">Show Neighbours (Heard Us)</button>`;
|
||||
tooltip += `<br/><button onclick="window.showNodeNeighboursThatWeHeard(${node.node_id});" class="border border-gray-300 bg-gray-100 p-1 w-full rounded hover:bg-gray-200">Show Neighbours (We Heard)</button>`;
|
||||
tooltip += `</div>`;
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
export function getNeighbourTooltipContent(type, node, neighbourNode, distanceInMeters, snr) {
|
||||
// default to showing distance in meters
|
||||
let distance = `${distanceInMeters} meters`;
|
||||
|
||||
// scale to distance in kms
|
||||
if (distanceInMeters >= 1000) {
|
||||
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
|
||||
distance = `${distanceInKilometers} kilometers`;
|
||||
}
|
||||
|
||||
const terrainImageUrl = type === 'weHeard' ? getTerrainProfileImage(node, neighbourNode) : getTerrainProfileImage(neighbourNode, node);
|
||||
|
||||
const templates = {
|
||||
'weHeard': `<b>[${escapeString(node.short_name)}] ${escapeString(node.long_name)}</b> heard <b>[${escapeString(neighbourNode.short_name)}] ${escapeString(neighbourNode.long_name)}</b>`
|
||||
+ `<br/>SNR: ${snr}dB`
|
||||
+ `<br/>Distance: ${distance}`
|
||||
+ `<br/><br/>ID: ${node.node_id} heard ${neighbourNode.node_id}`
|
||||
+ `<br/>Hex ID: ${node.node_id_hex} heard ${neighbourNode.node_id_hex}`
|
||||
+ (node.neighbours_updated_at ? `<br/>Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '')
|
||||
+ `<br/><br/>Terrain images from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`
|
||||
+ `<br/><a href="${terrainImageUrl}" target="_blank"><img src="${terrainImageUrl}" width="100%"></a>`,
|
||||
'theyHeard': `<b>[${escapeString(neighbourNode.short_name)}] ${escapeString(neighbourNode.long_name)}</b> heard <b>[${escapeString(node.short_name)}] ${escapeString(node.long_name)}</b>`
|
||||
+ `<br/>SNR: ${snr}dB`
|
||||
+ `<br/>Distance: ${distance}`
|
||||
+ `<br/><br/>ID: ${neighbourNode.node_id} heard ${node.node_id}`
|
||||
+ `<br/>Hex ID: ${neighbourNode.node_id_hex} heard ${node.node_id_hex}`
|
||||
+ (neighbourNode.neighbours_updated_at ? `<br/>Updated: ${moment(new Date(neighbourNode.neighbours_updated_at)).fromNow()}` : '')
|
||||
+ `<br/><br/>Terrain images from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`
|
||||
+ `<br/><a href="${terrainImageUrl}" target="_blank"><img src="${terrainImageUrl}" width="100%"></a>`,
|
||||
}
|
||||
|
||||
return templates[type];
|
||||
}
|
||||
|
||||
// cleanup
|
||||
|
||||
export function clearAllNodes() {
|
||||
layerGroups.nodes.clearLayers();
|
||||
layerGroups.nodesClustered.clearLayers();
|
||||
layerGroups.nodesRouter.clearLayers();
|
||||
};
|
||||
|
||||
export function clearAllNeighbors() {
|
||||
layerGroups.neighbors.clearLayers();
|
||||
};
|
||||
|
||||
export function clearAllWaypoints() {
|
||||
layerGroups.waypoints.clearLayers();
|
||||
};
|
||||
|
||||
export function clearAllPositionHistory() {
|
||||
layerGroups.nodePositionHistory.clearLayers();
|
||||
};
|
||||
|
||||
export function cleanUpPositionHistory() {
|
||||
// close tooltips and popups
|
||||
closeAllPopups();
|
||||
closeAllTooltips();
|
||||
|
||||
// setup node neighbours layer
|
||||
layerGroups.nodePositionHistory.clearLayers();
|
||||
layerGroups.nodePositionHistory.removeFrom(getMap());
|
||||
layerGroups.nodePositionHistory.addTo(getMap());
|
||||
};
|
||||
|
||||
export function closeAllTooltips() {
|
||||
getMap().eachLayer(function(layer) {
|
||||
if (layer.options.pane === 'tooltipPane') {
|
||||
layer.removeFrom(getMap());
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export function closeAllPopups() {
|
||||
getMap().eachLayer(function(layer) {
|
||||
if (layer.options.pane === 'popupPane') {
|
||||
layer.removeFrom(getMap());
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export function cleanUpNodeNeighbors() {
|
||||
// close tooltips and popups
|
||||
closeAllPopups();
|
||||
closeAllTooltips();
|
||||
// setup node neighbours layer
|
||||
layerGroups.nodeNeighbors.clearLayers();
|
||||
layerGroups.nodeNeighbors.removeFrom(getMap());
|
||||
layerGroups.nodeNeighbors.addTo(getMap());
|
||||
};
|
||||
|
||||
export function clearNodeOutline() {
|
||||
if (state.selectedNodeOutlineCircle) {
|
||||
state.selectedNodeOutlineCircle.removeFrom(getMap());
|
||||
state.selectedNodeOutlineCircle = null;
|
||||
}
|
||||
};
|
||||
|
||||
export function clearMap() {
|
||||
closeAllPopups();
|
||||
closeAllTooltips();
|
||||
clearAllNodes();
|
||||
clearAllNeighbors();
|
||||
clearAllWaypoints();
|
||||
clearNodeOutline();
|
||||
cleanUpNodeNeighbors();
|
||||
};
|
15
webapp/frontend/src/router/index.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
60
webapp/frontend/src/store.js
Normal file
@ -0,0 +1,60 @@
|
||||
import { reactive, computed } from 'vue';
|
||||
|
||||
export const state = reactive({
|
||||
// caches
|
||||
nodes: [],
|
||||
waypoints: [],
|
||||
nodeMarkers: {},
|
||||
|
||||
// state
|
||||
searchText: '',
|
||||
selectedNodeOutlineCircle: null,
|
||||
selectedNodeMqttMetrics: [],
|
||||
selectedNodeTraceroutes: [],
|
||||
selectedNodeDeviceMetrics: [],
|
||||
selectedNodePowerMetrics: [],
|
||||
selectedNodeEnvironmentMetrics: [],
|
||||
selectedNodeToShowNeighbours: null,
|
||||
selectedNodeToShowNeighbours: null,
|
||||
selectedNodeToShowNeighboursType: null,
|
||||
|
||||
// position history
|
||||
selectedNodeToShowPositionHistory: null,
|
||||
positionHistoryDateTimeTo: null,
|
||||
positionHistoryDateTimeFrom: null,
|
||||
|
||||
// ui
|
||||
loading: false,
|
||||
settingsVisible: false,
|
||||
hardwareStatsVisible: false,
|
||||
infoModalVisible: false,
|
||||
mobileSearchVisible: false,
|
||||
positionHistoryModalExpanded: false,
|
||||
announcementVisible: false,
|
||||
});
|
||||
|
||||
export const searchedNodes = computed(() => {
|
||||
// search nodes
|
||||
const nodes = state.nodes.filter((node) => {
|
||||
const matchesId = node.node_id?.toLowerCase()?.includes(state.searchText.toLowerCase());
|
||||
const matchesHexId = node.node_id_hex?.toLowerCase()?.includes(state.searchText.toLowerCase());
|
||||
const matchesLongName = node.long_name?.toLowerCase()?.includes(state.searchText.toLowerCase());
|
||||
const matchesShortName = node.short_name?.toLowerCase()?.includes(state.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);
|
||||
});
|
||||
|
||||
export const selectedNodeLatestPowerMetric = computed(() => {
|
||||
const [ latestPowerMetric ] = state.selectedNodePowerMetrics.slice(-1);
|
||||
return latestPowerMetric;
|
||||
});
|
250
webapp/frontend/src/utils.js
Normal file
@ -0,0 +1,250 @@
|
||||
import { state } from './store.js';
|
||||
import { temperatureFormat, nodesDisconnectedAge, BASE_PATH } from './config.js';
|
||||
import moment from 'moment';
|
||||
|
||||
export const timeSpans = {
|
||||
'1d': {name: '1 Day', amount: 86400},
|
||||
'3d': {name: '3 Days', amount: 259200},
|
||||
'7d': {name: '1 Week', amount: 604800},
|
||||
'14d': {name: '2 Weeks', amount: 1209600},
|
||||
'30d': {name: '1 Month', amount: 2592000},
|
||||
'90d': {name: '90 Days', amount: 7776000},
|
||||
};
|
||||
|
||||
export function getTimeSpans() {
|
||||
return timeSpans;
|
||||
};
|
||||
|
||||
export function getTimeSpan(value) {
|
||||
return timeSpans[value];
|
||||
};
|
||||
|
||||
// convert node id to a hex colour
|
||||
export function getNodeColor(nodeId) {
|
||||
return "#" + (nodeId & 0x00FFFFFF).toString(16).padStart(6, '0');
|
||||
};
|
||||
|
||||
export function getNodeTextColor(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";
|
||||
};
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export function getShareLinkForNode(nodeId) {
|
||||
return window.location.origin + `/?node_id=${nodeId}`;
|
||||
};
|
||||
|
||||
export function copyShareLinkForNode(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 = getShareLinkForNode(nodeId);
|
||||
navigator.clipboard.writeText(url);
|
||||
// tell user we copied it
|
||||
alert("Link copied to clipboard!");
|
||||
};
|
||||
|
||||
export function getColorForSnr(snr) {
|
||||
if(snr >= 0) return "#16a34a"; // good
|
||||
if(snr < 0) return "#dc2626"; // bad
|
||||
};
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export 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: 0, // don't include the curvature of the earth in the graphic
|
||||
width: 500,
|
||||
height: 200,
|
||||
pt0: `${node1Latitude},${node1Longitude},${lineColour},${node1ElevationMSL},${node1MarkerColour}`,
|
||||
pt1: `${node2Latitude},${node2Longitude},${lineColour},${node2ElevationMSL},${node2MarkerColour}`,
|
||||
}).toString();
|
||||
};
|
||||
|
||||
export 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`;
|
||||
};
|
||||
|
||||
// escape strings for tooltips etc, to prevent html/script injection
|
||||
// not used in vuejs, as that auto escapes
|
||||
export function escapeString(string) {
|
||||
return string.replace(/</g, "<").replace(/>/g, ">");
|
||||
};
|
||||
|
||||
export function isMobile() {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
};
|
||||
|
||||
// returns true if the element or one of its parents has the class classname
|
||||
export 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;
|
||||
};
|
||||
|
||||
export function formatUptimeSeconds(secondsToFormat) {
|
||||
secondsToFormat = Number(secondsToFormat);
|
||||
const days = Math.floor(secondsToFormat / (3600 * 24));
|
||||
const hours = Math.floor((secondsToFormat % (3600 * 24)) / 3600);
|
||||
const minutes = Math.floor((secondsToFormat % 3600) / 60);
|
||||
const seconds = Math.floor(secondsToFormat % 60);
|
||||
const daysPlural = days === 1 ? 'day' : 'days';
|
||||
return `${days} ${daysPlural} ${hours}h ${minutes}m ${seconds}s`;
|
||||
};
|
||||
|
||||
export function celsiusToFahrenheit(celsius) {
|
||||
return (celsius * 9/5) + 32;
|
||||
};
|
||||
|
||||
|
||||
/** operates on state */
|
||||
|
||||
// find node by id
|
||||
export function findNodeById(id) {
|
||||
const node = state.nodes.find((node) => node.node_id.toString() === id.toString());
|
||||
if (node) return node;
|
||||
return null;
|
||||
};
|
||||
|
||||
// find node marker by id
|
||||
export function findNodeMarkerById(id) {
|
||||
return state.nodeMarkers[id] ?? null;
|
||||
};
|
||||
|
||||
/** operates on config */
|
||||
|
||||
export function formatTemperature(celsius) {
|
||||
switch (temperatureFormat.value) {
|
||||
case "celsius": {
|
||||
return `${Number(celsius).toFixed(0)}ºC`;
|
||||
}
|
||||
case "fahrenheit": {
|
||||
const fahrenheit = celsiusToFahrenheit(celsius);
|
||||
return `${fahrenheit.toFixed(0)}ºF`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getTemperatureUnit() {
|
||||
switch (temperatureFormat.value) {
|
||||
case "celsius": return "ºC";
|
||||
case "fahrenheit": return "ºF";
|
||||
}
|
||||
};
|
||||
|
||||
export function buildPath(endpoint) {
|
||||
return `${BASE_PATH}${endpoint}`
|
||||
};
|
||||
|
||||
// determine if node was recently heard uplinking packets to mqtt
|
||||
export function hasNodeUplinkedToMqttRecently(node) {
|
||||
const now = moment();
|
||||
const millisecondsSinceNodeLastUplinkedToMqtt = now.diff(moment(node.mqtt_connection_state_updated_at));
|
||||
return millisecondsSinceNodeLastUplinkedToMqtt < nodesDisconnectedAge.value * 1000;
|
||||
}
|
911
webapp/frontend/src/views/HomeView.vue
Normal file
@ -0,0 +1,911 @@
|
||||
<script setup>
|
||||
import Header from '../components/Header.vue';
|
||||
import InfoModal from '../components/InfoModal.vue';
|
||||
import HardwareModelList from '../components/HardwareModelList.vue';
|
||||
import Settings from '../components/Settings.vue';
|
||||
import NodeInfo from '../components/NodeInfo.vue';
|
||||
import NodeNeighborsModal from '../components/NodeNeighborsModal.vue';
|
||||
import NodePositionHistoryModal from '../components/NodePositionHistoryModal.vue';
|
||||
import TracerouteInfo from '../components/TracerouteInfo.vue';
|
||||
import Announcement from '../components/Announcement.vue';
|
||||
|
||||
import axios from 'axios';
|
||||
import moment from 'moment';
|
||||
import L from 'leaflet/dist/leaflet.js';
|
||||
import 'leaflet.markercluster/dist/leaflet.markercluster.js';
|
||||
import 'leaflet-groupedlayercontrol/dist/leaflet.groupedlayercontrol.min.js';
|
||||
import { onMounted, useTemplateRef, ref, watch, markRaw } from 'vue';
|
||||
import { state } from '../store.js';
|
||||
import {
|
||||
layerGroups,
|
||||
tileLayers,
|
||||
icons,
|
||||
getTooltipContentForWaypoint,
|
||||
getTooltipContentForNode,
|
||||
getNeighbourTooltipContent,
|
||||
clearAllNodes,
|
||||
clearAllNeighbors,
|
||||
clearAllWaypoints,
|
||||
clearAllPositionHistory,
|
||||
cleanUpPositionHistory,
|
||||
closeAllTooltips,
|
||||
closeAllPopups,
|
||||
cleanUpNodeNeighbors,
|
||||
clearNodeOutline,
|
||||
clearMap,
|
||||
setMap,
|
||||
getMap,
|
||||
} from '../map.js';
|
||||
import {
|
||||
nodesMaxAge,
|
||||
nodesDisconnectedAge,
|
||||
nodesOfflineAge,
|
||||
waypointsMaxAge,
|
||||
enableMapAnimations,
|
||||
goToNodeZoomLevel,
|
||||
autoUpdatePositionInUrl,
|
||||
neighboursMaxDistance,
|
||||
enabledOverlayLayers,
|
||||
selectedTileLayerName,
|
||||
hasSeenInfoModal,
|
||||
lastSeenAnnouncementId,
|
||||
CURRENT_ANNOUNCEMENT_ID,
|
||||
} from '../config.js';
|
||||
import {
|
||||
getColorForSnr,
|
||||
getPositionPrecisionInMeters,
|
||||
getTerrainProfileImage,
|
||||
getRegionFrequencyRange,
|
||||
escapeString,
|
||||
formatPositionPrecision,
|
||||
isMobile,
|
||||
elementOrAnyAncestorHasClass,
|
||||
findNodeById,
|
||||
findNodeMarkerById,
|
||||
hasNodeUplinkedToMqttRecently,
|
||||
buildPath,
|
||||
} from '../utils.js';
|
||||
const mapEl = useTemplateRef('appMap');
|
||||
|
||||
// watchers
|
||||
watch(
|
||||
() => state.positionHistoryDateTimeTo,
|
||||
(newValue) => {
|
||||
if (newValue != null) {
|
||||
loadNodePositionHistory(state.selectedNodeToShowPositionHistory.node_id);
|
||||
}
|
||||
}, {deep: true}
|
||||
);
|
||||
watch(
|
||||
() => state.positionHistoryDateTimeFrom,
|
||||
(newValue) => {
|
||||
if (newValue != null) {
|
||||
loadNodePositionHistory(state.selectedNodeToShowPositionHistory.node_id);
|
||||
}
|
||||
}, {deep: true}
|
||||
);
|
||||
|
||||
function showNodeOutline(id) {
|
||||
// remove any existing node circle
|
||||
clearNodeOutline();
|
||||
|
||||
// find node marker by id
|
||||
const nodeMarker = state.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){
|
||||
state.selectedNodeOutlineCircle = L.circle(nodeMarker.getLatLng(), {
|
||||
radius: getPositionPrecisionInMeters(node.position_precision),
|
||||
}).addTo(getMap());
|
||||
}
|
||||
}
|
||||
|
||||
window.showNodeDetails = function(id) {
|
||||
// find node
|
||||
const node = findNodeById(id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
state.selectedNode = node;
|
||||
}
|
||||
|
||||
window.showNodeNeighboursThatHeardUs = function(id) {
|
||||
cleanUpNodeNeighbors();
|
||||
|
||||
// find node
|
||||
const node = findNodeById(id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
// find node marker
|
||||
const nodeMarker = findNodeMarkerById(node.node_id);
|
||||
if (!nodeMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
// show overlay
|
||||
state.selectedNodeToShowNeighbours = node;
|
||||
state.selectedNodeToShowNeighboursType = 'theyHeard';
|
||||
|
||||
// find all nodes that have us as a neighbour
|
||||
const neighbourNodeInfos = [];
|
||||
for (const nodeThatMayHaveHeardUs of state.nodes) {
|
||||
// find our node in this nodes neighbours
|
||||
const nodeNeighbours = nodeThatMayHaveHeardUs.neighbours ?? [];
|
||||
const neighbour = nodeNeighbours.find(function(neighbour) {
|
||||
return neighbour.node_id.toString() === node.node_id.toString();
|
||||
});
|
||||
|
||||
// we exist as a neighbour
|
||||
if (neighbour) {
|
||||
neighbourNodeInfos.push({
|
||||
node: nodeThatMayHaveHeardUs,
|
||||
neighbour: neighbour,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ensure we have neighbours to show
|
||||
if (neighbourNodeInfos.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// add node neighbours
|
||||
for (const neighbourNodeInfo of neighbourNodeInfos) {
|
||||
|
||||
const neighbourNode = neighbourNodeInfo.node;
|
||||
const neighbour = neighbourNodeInfo.neighbour;
|
||||
|
||||
// fixme: skipping zero snr? saw some crazy long neighbours with zero snr...
|
||||
if (neighbour.snr === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// find neighbour node marker
|
||||
const neighbourNodeMarker = findNodeMarkerById(neighbourNode.node_id);
|
||||
if (!neighbourNodeMarker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// calculate distance in meters between nodes (rounded to 2 decimal places)
|
||||
const distanceInMeters = neighbourNodeMarker.getLatLng().distanceTo(nodeMarker.getLatLng()).toFixed(2);
|
||||
|
||||
// don't show this neighbour connection if further than config allows
|
||||
if (neighboursMaxDistance.value != null && parseFloat(distanceInMeters) > neighboursMaxDistance.value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// add neighbour line to map
|
||||
const line = L.polyline([
|
||||
nodeMarker.getLatLng(), // from us
|
||||
neighbourNodeMarker.getLatLng(), // to neighbour
|
||||
], {
|
||||
color: getColourForSnr(neighbour.snr),
|
||||
opacity: 1,
|
||||
}).arrowheads({
|
||||
size: '10px',
|
||||
fill: true,
|
||||
offsets: {
|
||||
start: '25px',
|
||||
end: '25px',
|
||||
},
|
||||
}).addTo(layerGroups.nodeNeighbors);
|
||||
|
||||
const tooltip = getNeighbourTooltipContent('theyHeard', node, neighbourNode, distanceInMeters, neighbour.snr);
|
||||
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.showNodeNeighboursThatWeHeard = function(id) {
|
||||
cleanUpNodeNeighbors();
|
||||
|
||||
// find node
|
||||
const node = findNodeById(id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
// find node marker
|
||||
const nodeMarker = findNodeMarkerById(node.node_id);
|
||||
if (!nodeMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
// show overlay
|
||||
state.selectedNodeToShowNeighbours = node;
|
||||
state.selectedNodeToShowNeighboursType = 'weHeard';
|
||||
|
||||
// ensure we have neighbours to show
|
||||
const neighbours = node.neighbours ?? [];
|
||||
if (neighbours.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const neighbour of neighbours) {
|
||||
// fixme: skipping zero snr? saw some crazy long neighbours with zero snr...
|
||||
if (neighbour.snr === 0) {
|
||||
continue;
|
||||
}
|
||||
// find neighbor node
|
||||
const neighbourNode = findNodeById(neighbour.node_id);
|
||||
if (!neighbourNode) {
|
||||
continue;
|
||||
}
|
||||
// find neighbor node marker
|
||||
const neighbourNodeMarker = findNodeMarkerById(neighbour.node_id);
|
||||
if (!neighbourNodeMarker) {
|
||||
continue;
|
||||
}
|
||||
// calculate distance in meters between nodes (rounded to 2 decimal places)
|
||||
const distanceInMeters = nodeMarker.getLatLng().distanceTo(neighbourNodeMarker.getLatLng()).toFixed(2);
|
||||
|
||||
if (neighboursMaxDistance.value != null && parseFloat(distanceInMeters) > neighboursMaxDistance.value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// add neighbour line to map
|
||||
const line = L.polyline([
|
||||
neighbourNodeMarker.getLatLng(), // from neighbor
|
||||
nodeMarker.getLatLng(), // to us
|
||||
], {
|
||||
color: getColorForSnr(neighbour.snr),
|
||||
opacity: 1,
|
||||
}).arrowheads({
|
||||
size: '10px',
|
||||
fill: true,
|
||||
offsets: {
|
||||
start: '25px',
|
||||
end: '25px',
|
||||
},
|
||||
}).addTo(layerGroups.nodeNeighbors);
|
||||
|
||||
const tooltip = getNeighbourTooltipContent('weHeard', node, neighbourNode, distanceInMeters, neighbour.snr);
|
||||
line.bindTooltip(tooltip, {
|
||||
sticky: true,
|
||||
opacity: 1,
|
||||
interactive: true,
|
||||
}).bindPopup(tooltip).on('click', function(event) {
|
||||
// close tooltip on click to prevent tooltip and popup showing at same time
|
||||
event.target.closeTooltip();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onNodesUpdated(nodes) {
|
||||
const now = moment();
|
||||
state.nodes = [];
|
||||
for (const node of nodes) {
|
||||
// skip nodes older than configured node max age
|
||||
if (nodesMaxAge.value) {
|
||||
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
|
||||
if (lastUpdatedAgeInMillis > nodesMaxAge.value * 1000) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// skip nodes without position
|
||||
if (!node.latitude || !node.longitude) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip nodes with invalid position
|
||||
if (isNaN(node.latitude) || isNaN(node.longitude)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// fix lat long
|
||||
node.latitude = node.latitude / 10000000;
|
||||
node.longitude = node.longitude / 10000000;
|
||||
|
||||
// wrap longitude for shortest path, everything to left of australia should be shown on the right
|
||||
let longitude = parseFloat(node.longitude);
|
||||
if (longitude <= 100) {
|
||||
longitude += 360;
|
||||
}
|
||||
|
||||
// icon based on mqtt connection state
|
||||
let icon = icons.mqttDisconnected;
|
||||
|
||||
// use offline icon for nodes older than configured node offline age
|
||||
if (nodesOfflineAge) {
|
||||
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
|
||||
if (lastUpdatedAgeInMillis > nodesOfflineAge.value * 1000) {
|
||||
icon = icons.offline;
|
||||
}
|
||||
}
|
||||
|
||||
// determine if node was recently heard uplinking packets to mqtt
|
||||
const nodeHasUplinkedToMqttRecently = hasNodeUplinkedToMqttRecently(node);
|
||||
if (nodeHasUplinkedToMqttRecently) {
|
||||
icon = icons.mqttConnected;
|
||||
}
|
||||
|
||||
// create node marker
|
||||
const marker = L.marker([node.latitude, longitude], {
|
||||
icon: icon,
|
||||
tagName: node.node_id,
|
||||
// we want to show online nodes above offline, but without needing to use separate layer groups
|
||||
zIndexOffset: nodeHasUplinkedToMqttRecently ? 1000 : -1000,
|
||||
}).on('click', function(event) {
|
||||
// close tooltip on click to prevent tooltip and popup showing at same time
|
||||
event.target.closeTooltip();
|
||||
});
|
||||
|
||||
// add marker to node layer groups
|
||||
marker.addTo(layerGroups.nodes);
|
||||
layerGroups.nodesClustered.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') {
|
||||
layerGroups.nodesRouter.addLayer(marker);
|
||||
}
|
||||
|
||||
// show tooltip on desktop only
|
||||
if (!isMobile()) {
|
||||
marker.bindTooltip(getTooltipContentForNode(node), {
|
||||
interactive: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Push node and marker to cache
|
||||
state.nodes.push(node);
|
||||
state.nodeMarkers[node.node_id] = marker;
|
||||
|
||||
// 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
|
||||
getMap().openTooltip(getTooltipContentForNode(node), event.target.getLatLng(), {
|
||||
interactive: true, // allow clicking buttons inside tooltip
|
||||
permanent: true, // don't auto dismiss when clicking buttons inside tooltip
|
||||
});
|
||||
});
|
||||
}
|
||||
for(const node of nodes) {
|
||||
// find current node
|
||||
const currentNode = findNodeMarkerById(node.node_id);
|
||||
if (!currentNode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// add node neighbours
|
||||
var polylineOffset = 0;
|
||||
const neighbours = node.neighbours ?? [];
|
||||
for( const neighbour of neighbours) {
|
||||
// fixme: skipping zero snr? saw some crazy long neighbours with zero snr...
|
||||
if(neighbour.snr === 0){
|
||||
continue;
|
||||
}
|
||||
const neighbourNode = findNodeById(neighbour.node_id);
|
||||
if (!neighbourNode) {
|
||||
continue;
|
||||
}
|
||||
const neighbourNodeMarker = findNodeMarkerById(neighbour.node_id);
|
||||
if (neighbourNodeMarker) {
|
||||
|
||||
// calculate distance in meters between nodes (rounded to 2 decimal places)
|
||||
const distanceInMeters = currentNode.getLatLng().distanceTo(neighbourNodeMarker.getLatLng()).toFixed(2);
|
||||
|
||||
// don't show this neighbour connection if further than config allows
|
||||
if (neighboursMaxDistance.value != null && parseFloat(distanceInMeters) > neighboursMaxDistance.value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// add neighbour line to map
|
||||
const line = L.polyline([
|
||||
currentNode.getLatLng(),
|
||||
neighbourNodeMarker.getLatLng(),
|
||||
], {
|
||||
color: '#2563eb',
|
||||
opacity: 0.75,
|
||||
offset: polylineOffset,
|
||||
}).addTo(layerGroups.neighbors);
|
||||
|
||||
// increase offset so next neighbour does not overlay other neighbours from self
|
||||
polylineOffset += 2;
|
||||
|
||||
const tooltip = getNeighbourTooltipContent('weHeard', node, neighbourNode, distanceInMeters, neighbour.snr);
|
||||
line.bindTooltip(tooltip, {
|
||||
sticky: true,
|
||||
opacity: 1,
|
||||
interactive: true,
|
||||
}).bindPopup(tooltip).on('click', function(event) {
|
||||
// close tooltip on click to prevent tooltip and popup showing at same time
|
||||
event.target.closeTooltip();
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onWaypointsUpdated(waypoints) {
|
||||
state.waypoints = [];
|
||||
const now = moment();
|
||||
// add nodes
|
||||
for (const waypoint of waypoints) {
|
||||
// skip waypoints older than configured waypoint max age
|
||||
if (waypointsMaxAge.value) {
|
||||
const lastUpdatedAgeInMillis = now.diff(moment(waypoint.updated_at));
|
||||
if (lastUpdatedAgeInMillis > waypointsMaxAge.value * 1000) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// skip expired waypoints
|
||||
if (waypoint.expire < Date.now() / 1000) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip waypoints without position
|
||||
if (!waypoint.latitude || !waypoint.longitude) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip nodes with invalid position
|
||||
if (isNaN(waypoint.latitude) || isNaN(waypoint.longitude)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// fix lat long
|
||||
waypoint.latitude = waypoint.latitude / 10000000;
|
||||
waypoint.longitude = waypoint.longitude / 10000000;
|
||||
|
||||
// wrap longitude for shortest path, everything to left of australia should be shown on the right
|
||||
let 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);
|
||||
|
||||
let 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(layerGroups.waypoints)
|
||||
|
||||
// add to cache
|
||||
state.waypoints.push(waypoint);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// skip position history without position
|
||||
if (!positionHistory.latitude || !positionHistory.longitude) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip nodes with invalid position
|
||||
if (isNaN(positionHistory.latitude) || isNaN(positionHistory.longitude)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// fix lat long
|
||||
positionHistory.latitude = positionHistory.latitude / 10000000;
|
||||
positionHistory.longitude = positionHistory.longitude / 10000000;
|
||||
|
||||
// wrap longitude for shortest path, everything to left of australia should be shown on the right
|
||||
let 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("DD/MM/YYYY hh:mm A")}`;
|
||||
|
||||
// add gateway info if available
|
||||
if (positionHistory.gateway_id) {
|
||||
const gatewayNode = findNodeById(positionHistory.gateway_id);
|
||||
const gatewayNodeInfo = gatewayNode ? `[${gatewayNode.short_name}] ${gatewayNode.long_name}` : "???";
|
||||
tooltip += `<br/>Heard by: <a href="javascript:void(0);" onclick="goToNode(${positionHistory.gateway_id})">${gatewayNodeInfo}</a>`;
|
||||
}
|
||||
|
||||
// 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(layerGroups.nodePositionHistory);
|
||||
}
|
||||
// show lines between position history markers
|
||||
L.polyline(positionHistoryLinesCords).addTo(layerGroups.nodePositionHistory);
|
||||
}
|
||||
|
||||
function goToRandomNode() {
|
||||
if (state.nodes.length > 0) {
|
||||
const randomNode = state.nodes[Math.floor(Math.random() * state.nodes.length)];
|
||||
if (randomNode) {
|
||||
// go to node
|
||||
if (goToNode(randomNode.node_id)) {
|
||||
return;
|
||||
}
|
||||
// fallback to showing node details since we can't go to the node
|
||||
window.showNodeDetails(randomNode.node_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showNodePositionHistory(nodeId) {
|
||||
// find node
|
||||
const node = findNodeById(nodeId);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
// update ui
|
||||
state.selectedNode = null;
|
||||
state.selectedNodeToShowPositionHistory = node;
|
||||
state.positionHistoryModalExpanded = true;
|
||||
|
||||
// close node info tooltip as position history shows under it
|
||||
closeAllTooltips();
|
||||
|
||||
// reset default time range when opening position history ui
|
||||
// YYYY-MM-DDTHH:mm is the format expected by the datetime-local input type
|
||||
state.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm');
|
||||
state.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
|
||||
|
||||
// load position history
|
||||
loadNodePositionHistory(nodeId);
|
||||
}
|
||||
|
||||
function loadNodePositionHistory(nodeId) {
|
||||
state.selectedNodePositionHistory = [];
|
||||
axios.get(buildPath(`/api/v1/nodes/${nodeId}/position-history`), {
|
||||
params: {
|
||||
// parse from datetime-local format, and send as unix timestamp in milliseconds
|
||||
time_from: moment(state.positionHistoryDateTimeFrom, "YYYY-MM-DDTHH:mm").format("x"),
|
||||
time_to: moment(state.positionHistoryDateTimeTo, "YYYY-MM-DDTHH:mm").format("x"),
|
||||
},
|
||||
}).then((response) => {
|
||||
state.selectedNodePositionHistory = response.data.position_history;
|
||||
if (state.selectedNodeToShowPositionHistory != null) {
|
||||
clearAllPositionHistory();
|
||||
onPositionHistoryUpdated(response.data.position_history);
|
||||
};
|
||||
}).catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
}
|
||||
|
||||
function reload(goToNodeId, zoom) {
|
||||
// show loading
|
||||
state.loading = true;
|
||||
// clear previous data
|
||||
clearMap();
|
||||
axios.get(buildPath('/api/v1/nodes')).then(response => {
|
||||
// update nodes
|
||||
onNodesUpdated(response.data.nodes);
|
||||
// hide loading
|
||||
state.loading = false;
|
||||
// fetch waypoints (after awaiting nodes, so we can use nodes cache in waypoint tooltips)
|
||||
axios.get(buildPath('/api/v1/waypoints')).then(response => {
|
||||
onWaypointsUpdated(response.data.waypoints);
|
||||
});
|
||||
// go to node id if provided
|
||||
if (goToNodeId) {
|
||||
// go to node
|
||||
if(goToNode(goToNodeId, false, zoom)) {
|
||||
return;
|
||||
}
|
||||
// fallback to showing node details since we can't go to the node
|
||||
window.showNodeDetails(goToNodeId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function goToNode(id, animate, zoom){
|
||||
// find node
|
||||
const node = findNodeById(id);
|
||||
if (!node) {
|
||||
alert("Could not find node: " + id);
|
||||
return false;
|
||||
}
|
||||
|
||||
// find node marker by id
|
||||
const 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;
|
||||
getMap().flyTo(nodeMarker.getLatLng(), parseFloat(zoom || goToNodeZoomLevel.value), {
|
||||
animate: enableMapAnimations.value ? shouldAnimate : false,
|
||||
});
|
||||
|
||||
// open tooltip for node
|
||||
getMap().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 onSearchResultNodeClick(node) {
|
||||
// clear search
|
||||
state.searchText = '';
|
||||
|
||||
// hide search
|
||||
state.mobileSearchVisible = false;
|
||||
|
||||
// go to node
|
||||
if (goToNode(node.node_id) ){
|
||||
return;
|
||||
}
|
||||
|
||||
// fallback to showing node details since we can't go to the node
|
||||
window.showNodeDetails(node.node_id);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// set map bounds to be a little more than full size to prevent panning off screen
|
||||
const bounds = [
|
||||
[-100, 70], // top left
|
||||
[100, 500], // bottom right
|
||||
];
|
||||
// create map positioned over AU and NZ
|
||||
setMap(L.map(mapEl.value, {
|
||||
maxBounds: bounds,
|
||||
}));
|
||||
// set view
|
||||
getMap().setView([-15, 150], 2);
|
||||
// remove leaflet link
|
||||
getMap().attributionControl.setPrefix('');
|
||||
|
||||
// use tile layer based on config
|
||||
const selectedTileLayer = tileLayers[selectedTileLayerName.value] || tileLayers['OpenStreetMap'];
|
||||
selectedTileLayer.addTo(getMap());
|
||||
|
||||
// handle baselayerchange to update tile layer preference
|
||||
getMap().on('baselayerchange', function(event) {
|
||||
selectedTileLayerName.value = event.name;
|
||||
});
|
||||
|
||||
// create legend
|
||||
const legend = L.control({position: 'bottomleft'});
|
||||
legend.onAdd = function (map) {
|
||||
const div = L.DomUtil.create('div', 'leaflet-control-layers');
|
||||
div.style.backgroundColor = 'white';
|
||||
div.style.padding = '12px';
|
||||
div.innerHTML = `<div style="margin-bottom:6px;"><strong>Legend</strong></div>`
|
||||
+ `<div style="display:flex"><div class="icon-mqtt-connected" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> MQTT Connected</div>`
|
||||
+ `<div style="display:flex"><div class="icon-mqtt-disconnected" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> MQTT Disconnected</div>`
|
||||
+ `<div style="display:flex"><div class="icon-offline" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> Offline Too Long</div>`;
|
||||
return div;
|
||||
};
|
||||
// handle adding/remove legend on map (can't use L.Control as an overlay, so we toggle an empty L.LayerGroup)
|
||||
getMap().on('overlayadd overlayremove', function(event) {
|
||||
if (event.name === 'Legend') {
|
||||
if (event.type === 'overlayadd') {
|
||||
getMap().addControl(legend);
|
||||
} else if(event.type === 'overlayremove') {
|
||||
getMap().removeControl(legend);
|
||||
}
|
||||
}
|
||||
});
|
||||
// add layers to control ui
|
||||
L.control.groupedLayers(tileLayers, {
|
||||
'Nodes': {
|
||||
'All': layerGroups.nodes,
|
||||
'Routers': layerGroups.nodesRouter,
|
||||
'Clustered': layerGroups.nodesClustered,
|
||||
'None': layerGroups.none,
|
||||
},
|
||||
'Overlays': {
|
||||
'Legend': layerGroups.legend,
|
||||
'Neighbors': layerGroups.neighbors,
|
||||
'Waypoints': layerGroups.waypoints,
|
||||
'Position History': layerGroups.nodePositionHistory,
|
||||
},
|
||||
}, {
|
||||
// make the "Nodes" group exclusive (use radio inputs instead of checkbox)
|
||||
exclusiveGroups: ['Nodes'],
|
||||
}).addTo(getMap());
|
||||
|
||||
// enable base layers
|
||||
layerGroups.nodesClustered.addTo(getMap());
|
||||
|
||||
// enable overlay layers based on config
|
||||
if (enabledOverlayLayers.value.includes('Legend')) {
|
||||
layerGroups.legend.addTo(getMap());
|
||||
}
|
||||
if (enabledOverlayLayers.value.includes('Neighbors')) {
|
||||
layerGroups.neighbors.addTo(getMap());
|
||||
}
|
||||
if (enabledOverlayLayers.value.includes('Waypoints')) {
|
||||
layerGroups.waypoints.addTo(getMap());
|
||||
}
|
||||
if (enabledOverlayLayers.value.includes('Position History')) {
|
||||
layerGroups.nodePositionHistory.addTo(getMap());
|
||||
}
|
||||
// update config when map overlay is added/removed
|
||||
getMap().on('overlayremove', function(event) {
|
||||
const layerName = event.name;
|
||||
enabledOverlayLayers.value = enabledOverlayLayers.value.filter(function(enabledOverlayLayer) {
|
||||
return enabledOverlayLayer !== layerName;
|
||||
});
|
||||
});
|
||||
|
||||
getMap().on('overlayadd', function(event) {
|
||||
const layerName = event.name;
|
||||
if (!enabledOverlayLayers.value.includes(layerName)) {
|
||||
enabledOverlayLayers.value.push(layerName);
|
||||
}
|
||||
});
|
||||
|
||||
getMap().on('click', function(event) {
|
||||
// remove outline when map clicked
|
||||
clearNodeOutline();
|
||||
// clear search
|
||||
state.searchText = '';
|
||||
state.mobileSearchVisible = false;
|
||||
// do nothing when clicking inside tooltip
|
||||
const clickedElement = event.originalEvent.target;
|
||||
if (elementOrAnyAncestorHasClass(clickedElement, 'leaflet-tooltip')) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeAllTooltips();
|
||||
closeAllPopups();
|
||||
});
|
||||
|
||||
// auto update url when lat/lng/zoom changes
|
||||
getMap().on('moveend zoomend', function() {
|
||||
// check if user enabled auto updating position in url
|
||||
if (!autoUpdatePositionInUrl.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// get map info
|
||||
const latLng = getMap().getCenter();
|
||||
const zoom = getMap().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());
|
||||
}
|
||||
});
|
||||
|
||||
// parse url params
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const queryNodeId = queryParams.get('node_id');
|
||||
const queryLat = queryParams.get('lat');
|
||||
const queryLng = queryParams.get('lng');
|
||||
const queryZoom = queryParams.get('zoom');
|
||||
|
||||
// go to lat/lng if provided
|
||||
if(queryLat && queryLng){
|
||||
const zoomLevel = queryZoom || goToNodeZoomLevel.value
|
||||
getMap().flyTo([queryLat, queryLng], parseFloat(zoomLevel), {
|
||||
animate: false,
|
||||
});
|
||||
}
|
||||
|
||||
// reload and go to provided node id
|
||||
reload(queryNodeId, queryZoom);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (lastSeenAnnouncementId.value !== CURRENT_ANNOUNCEMENT_ID) {
|
||||
state.announcementVisible = true;
|
||||
}
|
||||
if (!isMobile() && hasSeenInfoModal.value === false) {
|
||||
state.infoModalVisible = true;
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full w-full overflow-hidden">
|
||||
<div class="flex flex-col h-full">
|
||||
<Announcement />
|
||||
<Header
|
||||
@reload="reload"
|
||||
@random-node="goToRandomNode"
|
||||
@search-click="onSearchResultNodeClick"
|
||||
/>
|
||||
<div id="map" style="width:100%;height:100%;" ref="appMap"></div>
|
||||
</div>
|
||||
</div>
|
||||
<InfoModal />
|
||||
<HardwareModelList />
|
||||
<Settings />
|
||||
<NodeInfo @show-position-history="showNodePositionHistory"/>
|
||||
<NodeNeighborsModal @dismiss="cleanUpNodeNeighbors" />
|
||||
<NodePositionHistoryModal @dismiss="cleanUpPositionHistory" />
|
||||
<TracerouteInfo @go-to="goToNode" />
|
||||
</template>
|
22
webapp/frontend/vite.config.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
vueDevTools(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|