meshtastic-map/src/public/index.html

1336 lines
90 KiB
HTML

<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>DL4AX Meshtastic Map</title>
<meta name="title" content="DL4AX Meshtastic Map">
<meta name="description" content="An interactive map of all Meshtastic nodes.">
<link rel="icon" type="image/png" href="/icon.png"/>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://meshmap.dl4ax.radio">
<meta property="og:title" content="DL4AX Meshtastic Map">
<meta property="og:description" content="An interactive map of all Meshtastic nodes.">
<!-- tailwind css -->
<script src="assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js"></script>
<!-- leaflet map -->
<link rel="stylesheet" href="assets/js/leaflet@1.9.3/dist/leaflet.css" />
<script src="assets/js/leaflet@1.9.3/dist/leaflet.js"></script>
<!-- leaflet plugins -->
<script src="assets/js/leaflet-plugins/leaflet.polylineoffset.js"></script>
<script src="assets/js/leaflet-plugins/leaflet.geometryutil.js"></script>
<script src="assets/js/leaflet-plugins/leaflet-arrowheads.js"></script>
<!-- leaflet markercluster -->
<script src="assets/js/leaflet-plugins/leaflet.markercluster/leaflet.markercluster.js"></script>
<link rel="stylesheet" href="assets/js/leaflet-plugins/leaflet.markercluster/MarkerCluster.css"/>
<link rel="stylesheet" href="assets/js/leaflet-plugins/leaflet.markercluster/MarkerCluster.Default.css"/>
<!-- leaflet groupedlayercontrol -->
<script src="assets/js/leaflet-plugins/leaflet.groupedlayercontrol/leaflet.groupedlayercontrol.js"></script>
<link rel="stylesheet" href="assets/js/leaflet-plugins/leaflet.groupedlayercontrol/leaflet.groupedlayercontrol.css"/>
<!-- moment -->
<script src="assets/js/moment@2.29.1/moment.min.js"></script>
<!-- vuejs -->
<script src="assets/js/vue@3.4.26/dist/vue.global.js"></script>
<!-- axios -->
<script src="assets/js/axios@1.6.8/dist/axios.min.js"></script>
<!-- chart js -->
<script src="assets/js/chart.js@4.4.2/dist/chart.umd.js"></script>
<script src="assets/js/chartjs-adapter-moment/chartjs-adapter-moment.js"></script>
<!-- map css style -->
<link rel="stylesheet" href="assets/css/styles.css"/>
</head>
<body class="h-full bg-gray-200">
<div id="app" v-cloak>
<div class="flex flex-col h-full w-full overflow-hidden">
<div class="flex flex-col h-full">
<!-- header -->
<div class="flex p-3 h-16" style="background-color: #67EA94;">
<!-- close mobile search button -->
<div v-if="isShowingMobileSearch" class="my-auto">
<a @click="isShowingMobileSearch = false" href="javascript:void(0)" class="rounded-full">
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</div>
</a>
</div>
<!-- icon -->
<div v-if="!isShowingMobileSearch" class="sm:block my-auto mr-2 ml-2">
<img class="w-10 h-10 rounded border-2 border-[#2C2D3C]" src="icon.png"/>
</div>
<!-- search bar -->
<div class="mx-3 flex-1 relative" :class="{ 'hidden lg:block': !isShowingMobileSearch }">
<input v-model="searchText" type="text" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" :placeholder="`Search ${nodes.length} nodes...`">
<div v-if="searchText !== ''" class="absolute z-search bg-white w-full border border-gray-200 rounded-lg shadow-md mt-1 overflow-y-scroll max-h-80 divide-y divide-gray-200">
<template v-if="searchedNodes.length > 0">
<div @click="onSearchResultNodeClick(node)" class="flex space-x-2 p-2 hover:bg-gray-100 cursor-pointer" v-for="node of searchedNodes">
<div>
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(node.node_id)}]`, `text-[${getNodeTextColour(node.node_id)}]` ]">
<div class="mx-auto my-auto drop-shadow-sm">{{ node.short_name }}</div>
</div>
</div>
<div>
<div class="text-gray-900" :class="{ 'text-red-500': node.latitude == null || node.longitude == null }">{{ node.long_name !== '' ? node.long_name : "-" }}</div>
<div class="flex space-x-1 text-sm text-gray-700">
<div>{{ node.node_id_hex }} / {{ node.node_id }}</div>
</div>
</div>
</div>
<div v-if="searchedNodes.length === 500" class="text-gray-500 text-sm px-2 py-1">
Only the first 500 results are shown.
</div>
</template>
<template v-else>
<div class="p-2">
No results found...
</div>
</template>
</div>
</div>
<!-- header action buttons -->
<div v-if="!isShowingMobileSearch" class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
<a @click="isShowingMobileSearch = true" href="javascript:void(0)" class="tooltip rounded-full block lg:hidden">
<div id="search-button" class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"></path>
<path d="M21 21l-6 -6"></path>
</svg>
</div>
<div class="hidden sm:block">
<span class="tooltip-text">Search</span>
</div>
</a>
<a @click="isShowingHardwareModels = !isShowingHardwareModels" href="javascript:void(0)" class="tooltip rounded-full hidden sm:block">
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
</div>
<div class="hidden sm:block">
<span class="tooltip-text">Devices</span>
</div>
</a>
<a @click="isShowingSettings = true" href="javascript:void(0)" class="tooltip rounded-full">
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</div>
<div class="hidden sm:block">
<span class="tooltip-text">Settings</span>
</div>
</a>
<a href="#" class="tooltip rounded-full" onclick="reload()">
<div id="reload-button" class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M19.933 13.041a8 8 0 1 1 -9.925 -8.788c3.899 -1 7.935 1.007 9.425 4.747"></path>
<path d="M20 4v5h-5"></path>
</svg>
</div>
<div class="hidden sm:block">
<span class="tooltip-text">Reload</span>
</div>
</a>
</div>
</div>
<!-- map -->
<div id="map" style="width:100%;height:100%;"></div>
</div>
</div>
<!-- hardware models sidebar -->
<div class="relative z-sidebar" role="dialog" aria-modal="true">
<!-- overlay -->
<transition
enter-active-class="transition-opacity duration-300 ease-linear"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-linear"
leave-from-class="opacity-100"
leave-to-class="opacity-0">
<div v-show="isShowingHardwareModels" @click="isShowingHardwareModels = !isShowingHardwareModels" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
</transition>
<!-- sidebar -->
<transition
enter-active-class="transition duration-300 ease-in-out transform"
enter-from-class="-translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition duration-300 ease-in-out transform"
leave-from-class="translate-x-0"
leave-to-class="-translate-x-full">
<div v-show="isShowingHardwareModels" class="fixed top-0 left-0 bottom-0">
<div class="w-screen max-w-md overflow-hidden">
<div class="flex h-full flex-col bg-white shadow-xl">
<!-- slideover header -->
<div class="p-2 border-b border-gray-200 shadow">
<div class="flex items-start justify-between">
<div>
<h2 class="font-bold">Meshtastic Devices</h2>
<h3 class="text-sm">Ordered by most popular</h3>
</div>
<div class="my-auto ml-3 flex h-7 items-center">
<a href="javascript:void(0)" class="rounded-full" @click="isShowingHardwareModels = !isShowingHardwareModels">
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</div>
</a>
</div>
</div>
</div>
<!-- list of hardware models -->
<ul role="list" class="flex-1 divide-y divide-gray-200 overflow-y-auto">
<li v-for="hardwareModel of hardwareModelStats">
<div class="group relative flex items-center">
<a href="#" class="block flex-1 px-4 py-2">
<div class="absolute inset-0 group-hover:bg-gray-100" aria-hidden="true"></div>
<div class="relative flex min-w-0 flex-1 items-center">
<span class="relative inline-block flex-shrink-0 mr-4">
<img class="h-20 w-20 rounded object-contain" :src="`/images/devices/${hardwareModel.hardware_model_name}.png`" alt="" onerror="if(this.src != '/images/no_image.png') this.src = '/images/no_image.png';">
</span>
<div class="truncate">
<p class="truncate text-sm font-medium text-gray-900">{{ hardwareModel.hardware_model_name }}</p>
<p class="truncate text-sm text-gray-500">{{ hardwareModel.count }} nodes on the map</p>
</div>
</div>
</a>
</div>
</li>
</ul>
</div>
</div>
</div>
</transition>
</div>
<!-- node info sidebar -->
<div class="relative z-sidebar" role="dialog" aria-modal="true">
<!-- overlay -->
<transition
enter-active-class="transition-opacity duration-300 ease-linear"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-linear"
leave-from-class="opacity-100"
leave-to-class="opacity-0">
<div v-show="selectedNode != null" @click="selectedNode = null" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
</transition>
<!-- sidebar -->
<transition
enter-active-class="transition duration-300 ease-in-out transform"
enter-from-class="-translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition duration-300 ease-in-out transform"
leave-from-class="translate-x-0"
leave-to-class="-translate-x-full">
<div v-show="selectedNode != null" class="fixed top-0 left-0 bottom-0">
<div v-if="selectedNode != null" class="w-screen max-w-md overflow-hidden">
<div class="flex h-full flex-col bg-white shadow-xl">
<!-- slideover header -->
<div class="p-2 border-b border-gray-200 shadow">
<div class="flex">
<div class="my-auto mr-2">
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedNode.node_id)}]`, `text-[${getNodeTextColour(selectedNode.node_id)}]` ]">
<div class="mx-auto my-auto drop-shadow-sm">{{ selectedNode.short_name }}</div>
</div>
</div>
<div class="my-auto mr-auto">
<h2 class="font-bold">Node Info</h2>
<h3 class="text-sm">{{ selectedNode.long_name }}</h3>
</div>
<div class="my-auto ml-2 flex h-7 items-center space-x-2">
<a href="javascript:void(0)" class="rounded-full" @click="copyShareLinkForNode(selectedNode.node_id)">
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6">
<path d="M216,112v96a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V112A16,16,0,0,1,56,96H80a8,8,0,0,1,0,16H56v96H200V112H176a8,8,0,0,1,0-16h24A16,16,0,0,1,216,112ZM93.66,69.66,120,43.31V136a8,8,0,0,0,16,0V43.31l26.34,26.35a8,8,0,0,0,11.32-11.32l-40-40a8,8,0,0,0-11.32,0l-40,40A8,8,0,0,0,93.66,69.66Z"></path>
</svg>
</div>
</a>
<a href="javascript:void(0)" class="rounded-full" @click="selectedNode = null">
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</div>
</a>
</div>
</div>
</div>
<div class="overflow-y-auto">
<!-- no position banner -->
<div v-if="findNodeMarkerById(selectedNode.node_id) == null" class="flex bg-orange-500 text-white p-2">
<div class="my-auto mr-2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path fill-rule="evenodd" d="m11.54 22.351.07.04.028.016a.76.76 0 0 0 .723 0l.028-.015.071-.041a16.975 16.975 0 0 0 1.144-.742 19.58 19.58 0 0 0 2.683-2.282c1.944-1.99 3.963-4.98 3.963-8.827a8.25 8.25 0 0 0-16.5 0c0 3.846 2.02 6.837 3.963 8.827a19.58 19.58 0 0 0 2.682 2.282 16.975 16.975 0 0 0 1.145.742ZM12 13.5a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" clip-rule="evenodd" />
</svg>
</div>
<div class="my-auto">This node has not reported a position.</div>
</div>
<div class="flex flex-col my-2">
<div class="mx-auto">
<img class="h-48 w-48 rounded object-contain" :src="`/images/devices/${selectedNode.hardware_model_name}.png`" alt="" onerror="if(this.src != '/images/no_image.png') this.src = '/images/no_image.png';">
</div>
</div>
<!-- action buttons -->
<div class="flex space-x-6 p-2 justify-center">
<!-- sent messages -->
<a target="_blank" :href="`/api/v1/text-messages/embed?from=${selectedNode.node_id}`" class="flex flex-col" title="Messages sent from this Node">
<div class="flex mx-auto mb-1">
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m15 11.25-3-3m0 0-3 3m3-3v7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</div>
</div>
<div class="mx-auto text-sm font-medium text-gray-900">Sent Msgs</div>
</a>
<!-- received messages -->
<a target="_blank" :href="`/api/v1/text-messages/embed?to=${selectedNode.node_id}`" class="flex flex-col" title="Messages sent to this Node">
<div class="flex mx-auto mb-1">
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m9 12.75 3 3m0 0 3-3m-3 3v-7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</div>
</div>
<div class="mx-auto text-sm font-medium text-gray-900">Received Msgs</div>
</a>
<!-- gated messages -->
<a target="_blank" :href="`/api/v1/text-messages/embed?gateway_id=${selectedNode.node_id}`" class="flex flex-col" title="Messages gated to MQTT by this Node">
<div class="flex mx-auto mb-1">
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
</div>
</div>
<div class="mx-auto text-sm font-medium text-gray-900">Gated Msgs</div>
</a>
</div>
<!-- details -->
<div>
<div class="bg-gray-200 p-2 font-semibold">Details</div>
<ul role="list" class="flex-1 divide-y divide-gray-200">
<!-- id -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">ID</div>
<div class="ml-auto text-sm text-gray-700">{{ selectedNode.node_id }}</div>
</li>
<!-- hex id -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Hex ID</div>
<div class="ml-auto text-sm text-gray-700">{{ selectedNode.node_id_hex }}</div>
</li>
<!-- role -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Role</div>
<div class="ml-auto text-sm text-gray-700">{{ selectedNode.role_name }}</div>
</li>
<!-- hardware -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Hardware</div>
<div class="ml-auto text-sm text-gray-700">{{ selectedNode.hardware_model_name }}</div>
</li>
<!-- firmware version -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Firmware</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNode.firmware_version">{{ selectedNode.firmware_version }}</span>
<span v-else>???</span>
</div>
</li>
</ul>
</div>
<!-- position -->
<div>
<div @click.stop class="flex bg-gray-200 p-2 font-semibold">
<div class="my-auto">Position</div>
<div class="ml-auto">
<button @click="showNodePositionHistory(selectedNode.node_id)" type="button" class="rounded bg-white px-2 py-1 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
Show History
</button>
</div>
</div>
<ul role="list" class="flex-1 divide-y divide-gray-200">
<!-- position -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Lat/Long</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNode.latitude && selectedNode.longitude">{{ selectedNode.latitude }}, {{ selectedNode.longitude }}</span>
<span v-else>???</span>
</div>
</li>
<!-- altitude -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Altitude</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNode.altitude">{{ selectedNode.altitude }}m</span>
<span v-else>???</span>
</div>
</li>
</ul>
</div>
<!-- device metrics -->
<div>
<div class="flex bg-gray-200 p-2 font-semibold">
<div class="my-auto">Device Metrics</div>
<div class="my-auto ml-auto">
<select v-model="deviceMetricsTimeRange" class="block w-full rounded-md border-0 py-0.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6">
<option value="1d">1 Day</option>
<option value="3d">3 Days</option>
<option value="7d">7 Days</option>
<option value="30d">30 Days</option>
</select>
</div>
</div>
<ul role="list" class="flex-1 divide-y divide-gray-200">
<!-- device metrics chart -->
<li>
<div class="px-4 py-2">
<div class="w-full">
<canvas id="deviceMetricsChart" style="height:150px;"></canvas>
<div class="flex">
<div class="mx-auto flex space-x-2">
<div class="flex mx-auto">
<div class="my-auto w-2 h-2 bg-blue-500 rounded-full"></div>
<div class="my-auto ml-1 text-sm text-gray-500">Battery Level</div>
</div>
<div class="flex mx-auto">
<div class="my-auto w-2 h-2 bg-green-500 rounded-full"></div>
<div class="my-auto ml-1 text-sm text-gray-500">Channel Utilization</div>
</div>
<div class="flex mx-auto">
<div class="my-auto w-2 h-2 bg-orange-500 rounded-full"></div>
<div class="my-auto ml-1 text-sm text-gray-500">Air Util TX</div>
</div>
</div>
</div>
</div>
</div>
</li>
<!-- battery level -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Battery Level</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNode.battery_level">
<span v-if="selectedNode.battery_level > 100">Plugged In</span>
<span v-else>{{ selectedNode.battery_level }}%</span>
</span>
<span v-else>???</span>
</div>
</li>
<!-- voltage -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Voltage</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNode.voltage">{{ Number(selectedNode.voltage).toFixed(2) }}V</span>
<span v-else>???</span>
</div>
</li>
<!-- channel utilization -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Channel Utilization</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNode.channel_utilization">{{ Number(selectedNode.channel_utilization).toFixed(2) }}%</span>
<span v-else>???</span>
</div>
</li>
<!-- air util tx -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Air Util Tx</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNode.air_util_tx">{{ Number(selectedNode.air_util_tx).toFixed(2) }}%</span>
<span v-else>???</span>
</div>
</li>
<!-- air util tx -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Uptime</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNode.uptime_seconds">{{ formatUptimeSeconds(selectedNode.uptime_seconds) }}</span>
<span v-else>???</span>
</div>
</li>
</ul>
</div>
<!-- environment metrics -->
<div>
<div class="flex bg-gray-200 p-2 font-semibold">
<div class="my-auto">Environment Metrics</div>
<div class="my-auto ml-auto">
<select v-model="environmentMetricsTimeRange" class="block w-full rounded-md border-0 py-0.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6">
<option value="1d">1 Day</option>
<option value="3d">3 Days</option>
<option value="7d">7 Days</option>
<option value="30d">30 Days</option>
</select>
</div>
</div>
<ul role="list" class="flex-1 divide-y divide-gray-200">
<!-- environment metrics chart -->
<li>
<div class="px-4 py-2">
<div class="w-full">
<canvas id="environmentMetricsChart" style="height:150px;"></canvas>
<div class="flex">
<div class="mx-auto flex space-x-2">
<div class="flex mx-auto">
<div class="my-auto w-2 h-2 bg-blue-500 rounded-full"></div>
<div class="my-auto ml-1 text-sm text-gray-500">Temperature</div>
</div>
<div class="flex mx-auto">
<div class="my-auto w-2 h-2 bg-green-500 rounded-full"></div>
<div class="my-auto ml-1 text-sm text-gray-500">Humidity</div>
</div>
<div class="flex mx-auto">
<div class="my-auto w-2 h-2 bg-orange-500 rounded-full"></div>
<div class="my-auto ml-1 text-sm text-gray-500">Pressure</div>
</div>
<div class="flex mx-auto">
<div class="my-auto w-2 h-2 bg-pink-400 rounded-full"></div>
<div class="my-auto ml-1 text-sm text-gray-500">IAQ</div>
</div>
</div>
</div>
</div>
</div>
</li>
<!-- temperature -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Temperature</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNode.temperature">{{ formatTemperature(selectedNode.temperature) }}</span>
<span v-else>???</span>
</div>
</li>
<!-- relative humidity -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Relative Humidity</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNode.relative_humidity">{{ Number(selectedNode.relative_humidity).toFixed(0) }}%</span>
<span v-else>???</span>
</div>
</li>
<!-- barometric pressure -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Barometric Pressure</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNode.barometric_pressure">{{ Number(selectedNode.barometric_pressure).toFixed(1) }}hPa</span>
<span v-else>???</span>
</div>
</li>
</ul>
</div>
<!-- power metrics -->
<div>
<div class="flex bg-gray-200 p-2 font-semibold">
<div class="my-auto">Power Metrics</div>
<div class="my-auto ml-auto">
<select v-model="powerMetricsTimeRange" class="block w-full rounded-md border-0 py-0.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6">
<option value="1d">1 Day</option>
<option value="3d">3 Days</option>
<option value="7d">7 Days</option>
<option value="30d">30 Days</option>
</select>
</div>
</div>
<ul role="list" class="flex-1 divide-y divide-gray-200">
<!-- power metrics chart -->
<li>
<div class="px-4 py-2">
<div class="w-full">
<canvas id="powerMetricsChart" style="height:150px;"></canvas>
<div class="flex">
<div class="mx-auto flex space-x-2">
<div class="flex mx-auto">
<div class="my-auto w-2 h-2 bg-blue-500 rounded-full"></div>
<div class="my-auto ml-1 text-sm text-gray-500">Channel 1</div>
</div>
<div class="flex mx-auto">
<div class="my-auto w-2 h-2 bg-green-500 rounded-full"></div>
<div class="my-auto ml-1 text-sm text-gray-500">Channel 2</div>
</div>
<div class="flex mx-auto">
<div class="my-auto w-2 h-2 bg-orange-500 rounded-full"></div>
<div class="my-auto ml-1 text-sm text-gray-500">Channel 3</div>
</div>
</div>
</div>
</div>
</div>
</li>
<!-- channel 1 -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Channel 1</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNodeLatestPowerMetric">
<span v-if="selectedNodeLatestPowerMetric?.ch1_voltage">{{ Number(selectedNodeLatestPowerMetric.ch1_voltage).toFixed(2) }}V</span>
<span v-else>???</span>
<span v-if="selectedNodeLatestPowerMetric?.ch1_current"> / {{ Number(selectedNodeLatestPowerMetric.ch1_current).toFixed(2) }}mA</span>
</span>
<span v-else>???</span>
</div>
</li>
<!-- channel 2 -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Channel 2</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNodeLatestPowerMetric">
<span v-if="selectedNodeLatestPowerMetric?.ch2_voltage">{{ Number(selectedNodeLatestPowerMetric.ch2_voltage).toFixed(2) }}V</span>
<span v-else>???</span>
<span v-if="selectedNodeLatestPowerMetric?.ch2_current"> / {{ Number(selectedNodeLatestPowerMetric.ch2_current).toFixed(2) }}mA</span>
</span>
<span v-else>???</span>
</div>
</li>
<!-- channel 3 -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Channel 3</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNodeLatestPowerMetric">
<span v-if="selectedNodeLatestPowerMetric?.ch3_voltage">{{ Number(selectedNodeLatestPowerMetric.ch3_voltage).toFixed(2) }}V</span>
<span v-else>???</span>
<span v-if="selectedNodeLatestPowerMetric?.ch3_current"> / {{ Number(selectedNodeLatestPowerMetric.ch3_current).toFixed(2) }}mA</span>
</span>
<span v-else>???</span>
</div>
</li>
</ul>
</div>
<!-- mqtt -->
<div>
<div class="bg-gray-200 p-2">
<div class="font-semibold">MQTT</div>
<div class="text-sm text-gray-600">Topics this node sent packets to</div>
</div>
<ul role="list" class="flex-1 divide-y divide-gray-200">
<template v-if="selectedNodeMqttMetrics.length > 0">
<li v-for="mqttMetric of selectedNodeMqttMetrics">
<div class="relative flex items-center">
<div class="block flex-1 px-4 py-2">
<div class="relative flex min-w-0 flex-1 items-center">
<div class="truncate">
<p class="truncate text-sm font-medium text-gray-900">{{ mqttMetric.mqtt_topic }}</p>
<div class="text-sm text-gray-700">Last packet {{ moment(new Date(mqttMetric.last_packet_at)).fromNow() }}</div>
</div>
</div>
</div>
</div>
</li>
</template>
<template v-else>
<li>
<div class="relative flex items-center">
<div class="block flex-1 px-4 py-2">
<div class="relative flex min-w-0 flex-1 items-center">
<div class="truncate">
<div class="text-sm text-gray-700">No packets seen on MQTT</div>
</div>
</div>
</div>
</div>
</li>
</template>
</ul>
</div>
<!-- traceroutes -->
<div>
<div class="bg-gray-200 p-2">
<div class="font-semibold">Trace Routes</div>
<div class="text-sm text-gray-600">Only 5 most recent are shown</div>
</div>
<ul role="list" class="flex-1 divide-y divide-gray-200">
<template v-if="selectedNodeTraceroutes.length > 0">
<li @click="showTraceRoute(traceroute)" v-for="traceroute of selectedNodeTraceroutes">
<div class="relative flex items-center">
<div class="block flex-1 px-4 py-2">
<div class="relative flex min-w-0 flex-1 items-center">
<div>
<p class="text-sm text-gray-900"><span class="font-medium">{{ findNodeById(traceroute.to)?.long_name || '???' }}</span> to <span class="font-medium">{{ findNodeById(traceroute.from)?.long_name || '???' }}</span></p>
<div class="text-sm text-gray-700">{{ moment(new Date(traceroute.updated_at)).fromNow() }} - {{ traceroute.route.length }} hops {{ traceroute.channel_id ? `on ${traceroute.channel_id}` : '' }}</div>
</div>
</div>
</div>
</div>
</li>
</template>
<template v-else>
<li>
<div class="relative flex items-center">
<div class="block flex-1 px-4 py-2">
<div class="relative flex min-w-0 flex-1 items-center">
<div class="truncate">
<div class="text-sm text-gray-700">No traceroutes seen on MQTT</div>
</div>
</div>
</div>
</div>
</li>
</template>
</ul>
</div>
<!-- other -->
<div>
<div class="bg-gray-200 p-2 font-semibold">Other</div>
<ul role="list" class="flex-1 divide-y divide-gray-200">
<!-- first seen -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">First Seen</div>
<div class="ml-auto text-sm text-gray-700">{{ moment(new Date(selectedNode.created_at)).fromNow() }}</div>
</li>
<!-- last seen -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Last Seen</div>
<div class="ml-auto text-sm text-gray-700">{{ moment(new Date(selectedNode.updated_at)).fromNow() }}</div>
</li>
<!-- neighbours updated -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Neighbours Updated</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNode.neighbours_updated_at">{{ moment(new Date(selectedNode.neighbours_updated_at)).fromNow() }}</span>
<span v-else>???</span>
</div>
</li>
<!-- position updated -->
<li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Position Updated</div>
<div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNode.position_updated_at">{{ moment(new Date(selectedNode.position_updated_at)).fromNow() }}</span>
<span v-else>???</span>
</div>
</li>
</ul>
</div>
<!-- share -->
<div>
<div class="flex bg-gray-200 p-2 font-semibold">
<div class="my-auto">Share Link</div>
<div class="ml-auto">
<button @click="copyShareLinkForNode(selectedNode.node_id)" type="button" class="rounded bg-white px-2 py-1 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
Copy
</button>
</div>
</div>
<ul role="list" class="flex-1 divide-y divide-gray-200">
<li>
<div class="relative flex items-center">
<div class="block flex-1 p-2">
<div class="flex space-x-2">
<input type="text" readonly class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" :value="getShareLinkForNode(selectedNode.node_id)">
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
<!-- traceroute info sidebar -->
<div class="relative z-sidebar" role="dialog" aria-modal="true">
<!-- overlay -->
<transition
enter-active-class="transition-opacity duration-300 ease-linear"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-linear"
leave-from-class="opacity-100"
leave-to-class="opacity-0">
<div v-show="selectedTraceRoute != null" @click="selectedTraceRoute = null" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
</transition>
<!-- sidebar -->
<transition
enter-active-class="transition duration-300 ease-in-out transform"
enter-from-class="-translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition duration-300 ease-in-out transform"
leave-from-class="translate-x-0"
leave-to-class="-translate-x-full">
<div v-show="selectedTraceRoute != null" class="fixed top-0 left-0 bottom-0">
<div v-if="selectedTraceRoute != null" class="w-screen max-w-md overflow-hidden">
<div class="flex h-full flex-col bg-white shadow-xl">
<!-- slideover header -->
<div class="p-2 border-b border-gray-200 shadow">
<div class="flex items-start justify-between">
<div>
<h2 class="font-bold">Traceroute #{{ selectedTraceRoute.id }}</h2>
<h3 class="text-sm">{{ moment(new Date(selectedTraceRoute.updated_at)).fromNow() }} - {{ selectedTraceRoute.route.length }} hops {{ selectedTraceRoute.channel_id ? `on ${selectedTraceRoute.channel_id}` : '' }}</h3>
</div>
<div class="my-auto ml-3 flex h-7 items-center">
<a href="javascript:void(0)" class="rounded-full" @click="selectedTraceRoute = null">
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</div>
</a>
</div>
</div>
</div>
<div class="overflow-y-auto">
<!-- details -->
<div class="p-2">
<ul role="list" class="space-y-3">
<!-- node that initiated traceroute -->
<li :onclick="`goToNode(${selectedTraceRoute.to})`" class="relative flex gap-x-4">
<div class="absolute left-0 top-0 flex w-12 justify-center top-3 -bottom-3">
<div class="w-px bg-gray-200"></div>
</div>
<div class="my-auto relative flex flex-none items-center justify-center">
<div>
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedTraceRoute.to)}]`, `text-[${getNodeTextColour(selectedTraceRoute.to)}]` ]">
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(selectedTraceRoute.to)?.short_name ?? "?" }}</div>
</div>
</div>
</div>
<div class="flex-auto py-0.5 text-sm leading-5 text-gray-500">
<div class="font-medium text-gray-900">{{ findNodeById(selectedTraceRoute.to)?.long_name || '???' }}</div>
<div>Hex ID: !{{ Number(selectedTraceRoute.to).toString(16) }}</div>
<div>Started the traceroute</div>
</div>
</li>
<!-- middleman nodes -->
<li :onclick="`goToNode(${route})`" v-for="route of selectedTraceRoute.route" class="relative flex gap-x-4">
<div class="absolute left-0 top-0 flex w-12 justify-center -bottom-3">
<div class="w-px bg-gray-200"></div>
</div>
<div class="my-auto relative flex flex-none items-center justify-center">
<div>
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(route)}]`, `text-[${getNodeTextColour(route)}]` ]">
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(route)?.short_name ?? "?" }}</div>
</div>
</div>
</div>
<div class="flex-auto py-0.5 text-sm leading-5 text-gray-500">
<div class="font-medium text-gray-900">{{ findNodeById(route)?.long_name || '???' }}</div>
<div>Hex ID: !{{ Number(route).toString(16) }}</div>
<div>Forwarded the packet</div>
</div>
</li>
<!-- node that replied to traceroute -->
<li :onclick="`goToNode(${selectedTraceRoute.from})`" v-if="selectedTraceRoute.from" class="relative flex gap-x-4">
<div class="absolute left-0 top-0 flex w-12 justify-center -bottom-3">
<div class="w-px bg-gray-200"></div>
</div>
<div class="my-auto relative flex flex-none items-center justify-center">
<div>
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedTraceRoute.from)}]`, `text-[${getNodeTextColour(selectedTraceRoute.from)}]` ]">
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(selectedTraceRoute.from)?.short_name ?? "?" }}</div>
</div>
</div>
</div>
<div class="flex-auto py-0.5 text-sm leading-5 text-gray-500">
<div class="font-medium text-gray-900">{{ findNodeById(selectedTraceRoute.from)?.long_name || '???' }}</div>
<div>Hex ID: !{{ Number(selectedTraceRoute.from).toString(16) }}</div>
<div>Replied to traceroute</div>
</div>
</li>
<!-- node that gated traceroute to mqtt -->
<li :onclick="`goToNode(${selectedTraceRoute.gateway_id})`" v-if="selectedTraceRoute.gateway_id" class="relative flex gap-x-4">
<div class="absolute left-0 top-0 flex w-12 justify-center h-6">
<div class="w-px bg-gray-200"></div>
</div>
<div class="my-auto relative flex flex-none items-center justify-center">
<div>
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedTraceRoute.gateway_id)}]`, `text-[${getNodeTextColour(selectedTraceRoute.gateway_id)}]` ]">
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(selectedTraceRoute.gateway_id)?.short_name ?? "?" }}</div>
</div>
</div>
</div>
<div class="flex-auto py-0.5 text-sm leading-5 text-gray-500">
<div class="font-medium text-gray-900">{{ findNodeById(selectedTraceRoute.gateway_id)?.long_name || '???' }}</div>
<div>Hex ID: !{{ Number(selectedTraceRoute.gateway_id).toString(16) }}</div>
<div>Gated the packet to MQTT</div>
</div>
</li>
</ul>
</div>
<div>
<div class="bg-gray-200 p-2 font-semibold">Raw Data</div>
<div class="text-sm text-gray-700">
<pre class="bg-gray-100 rounded p-2 overflow-x-auto">{{ JSON.stringify(selectedTraceRoute, null, 4) }}</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
<!-- settings sidebar -->
<div class="relative z-sidebar" role="dialog" aria-modal="true">
<!-- overlay -->
<transition
enter-active-class="transition-opacity duration-300 ease-linear"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-linear"
leave-from-class="opacity-100"
leave-to-class="opacity-0">
<div v-show="isShowingSettings" @click="isShowingSettings = !isShowingSettings" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
</transition>
<!-- sidebar -->
<transition
enter-active-class="transition duration-300 ease-in-out transform"
enter-from-class="-translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition duration-300 ease-in-out transform"
leave-from-class="translate-x-0"
leave-to-class="-translate-x-full">
<div v-show="isShowingSettings" class="fixed top-0 left-0 bottom-0">
<div v-if="isShowingSettings" class="w-screen max-w-md overflow-hidden">
<div class="flex h-full flex-col bg-white shadow-xl">
<!-- slideover header -->
<div class="p-2 border-b border-gray-200 shadow">
<div class="flex items-start justify-between">
<div>
<h2 class="font-bold">Settings</h2>
<h3 class="text-sm">Changes are only saved in this browser.</h3>
</div>
<div class="my-auto ml-3 flex h-7 items-center">
<a href="javascript:void(0)" class="rounded-full" @click="isShowingSettings = false">
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</div>
</a>
</div>
</div>
</div>
<div class="overflow-y-auto divide-y divide-gray-200">
<!-- configNodesMaxAgeInSeconds -->
<div class="p-2">
<label class="block text-sm font-medium text-gray-900">Nodes Max Age</label>
<div class="text-xs text-gray-600 mb-2">Nodes not updated within this time are hidden. Reload to update map.</div>
<select v-model="configNodesMaxAgeInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option :value="null">Show All</option>
<option value="900">15 minutes</option>
<option value="1800">30 minutes</option>
<option value="3600">1 hour</option>
<option value="10800">3 hours</option>
<option value="21600">6 hours</option>
<option value="43200">12 hours</option>
<option value="86400">24 hours</option>
<option value="172800">2 days</option>
<option value="259200">3 days</option>
<option value="345600">4 days</option>
<option value="432000">5 days</option>
<option value="518400">6 days</option>
<option value="604800">7 days</option>
</select>
</div>
<!-- configNodesOfflineAgeInSeconds -->
<div class="p-2">
<label class="block text-sm font-medium text-gray-900">Nodes Offline Age</label>
<div class="text-xs text-gray-600 mb-2">Nodes not updated within this time will show as red icons. Reload to update map.</div>
<select v-model="configNodesOfflineAgeInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option :value="null">Don't show as offline</option>
<option value="900">15 minutes</option>
<option value="1800">30 minutes</option>
<option value="2700">45 minutes</option>
<option value="3600">1 hour</option>
<option value="7200">2 hours</option>
<option value="10800">3 hours</option>
<option value="21600">6 hours</option>
<option value="43200">12 hours</option>
<option value="86400">24 hours</option>
<option value="172800">2 days</option>
<option value="259200">3 days</option>
<option value="345600">4 days</option>
<option value="432000">5 days</option>
<option value="518400">6 days</option>
<option value="604800">7 days</option>
</select>
</div>
<!-- configWaypointsMaxAgeInSeconds -->
<div class="p-2">
<label class="block text-sm font-medium text-gray-900">Waypoints Max Age</label>
<div class="text-xs text-gray-600 mb-2">Waypoints not updated within this time are hidden. Reload to update map.</div>
<select v-model="configWaypointsMaxAgeInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option :value="null">Show All</option>
<option value="900">15 minutes</option>
<option value="1800">30 minutes</option>
<option value="3600">1 hour</option>
<option value="10800">3 hours</option>
<option value="21600">6 hours</option>
<option value="43200">12 hours</option>
<option value="86400">24 hours</option>
<option value="172800">2 days</option>
<option value="259200">3 days</option>
<option value="345600">4 days</option>
<option value="432000">5 days</option>
<option value="518400">6 days</option>
<option value="604800">7 days</option>
</select>
</div>
<!-- configConnectionsTimePeriodInSeconds -->
<div class="p-2">
<label class="block text-sm font-medium text-gray-900">Connections Max Age</label>
<div class="text-xs text-gray-600 mb-2">Edges from traceroutes and neighbour info within this time period are shown in the Connections layer. Reload to update map.</div>
<select v-model="configConnectionsTimePeriodInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option value="300">5 minutes</option>
<option value="900">15 minutes</option>
<option value="3600">1 hour</option>
<option value="21600">6 hours</option>
<option value="43200">12 hours</option>
<option value="86400">24 hours</option>
<option value="172800">2 days</option>
<option value="259200">3 days</option>
<option value="604800">7 days</option>
<option value="1209600">14 days</option>
<option value="2592000">30 days</option>
</select>
</div>
<!-- configConnectionsColoredLines -->
<div class="p-2">
<div class="flex items-start">
<div class="flex items-center h-5">
<input type="checkbox" v-model="configConnectionsColoredLines" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required>
</div>
<label class="ml-2 text-sm font-medium text-gray-900">Colored Connection Lines</label>
</div>
<div class="text-xs text-gray-600">Colors the connection lines by the average SNR in the worst direction. Reload to update map.</div>
</div>
<!-- configConnectionsBidirectionalOnly -->
<div class="p-2">
<div class="flex items-start">
<div class="flex items-center h-5">
<input type="checkbox" v-model="configConnectionsBidirectionalOnly" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required>
</div>
<label class="ml-2 text-sm font-medium text-gray-900">Bidirectional Connections Only</label>
</div>
<div class="text-xs text-gray-600">Only show connections where data flows in both directions. Reload to update map.</div>
</div>
<!-- configConnectionsMinSnrDb -->
<div class="p-2">
<label class="block text-sm font-medium text-gray-900">Connections Minimum SNR (dB)</label>
<div class="text-xs text-gray-600 mb-2">Only show connections where at least one direction has SNR above this threshold. Leave empty to show all connections. Reload to update map.</div>
<input type="number" v-model="configConnectionsMinSnrDb" placeholder="e.g. -10" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<div class="mt-2 flex items-start">
<div class="flex items-center h-5">
<input type="checkbox" v-model="configConnectionsBidirectionalMinSnr" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required>
</div>
<label class="ml-2 text-sm font-medium text-gray-900">Bidirectional Minimum SNR</label>
</div>
<div class="text-xs text-gray-600 ml-6">If checked, all existing directions must meet the minimum SNR threshold (both directions if bidirectional, single direction if unidirectional).</div>
</div>
<!-- configConnectionsMaxDistanceInMeters -->
<div class="p-2">
<label class="block text-sm font-medium text-gray-900">Connections Max Distance (meters)</label>
<div class="text-xs text-gray-600 mb-2">Connections further than this are hidden. Reload to update map.</div>
<input type="number" v-model="configConnectionsMaxDistanceInMeters" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div>
<!-- configZoomLevelGoToNode -->
<div class="p-2">
<label class="block text-sm font-medium text-gray-900">Zoom Level (go to node)</label>
<div class="text-xs text-gray-600 mb-2">How far to zoom map when navigating to a node.</div>
<input type="number" v-model="configZoomLevelGoToNode" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div>
<!-- configTemperatureFormat -->
<div class="p-2">
<label class="block text-sm font-medium text-gray-900">Temperature Format</label>
<div class="text-xs text-gray-600 mb-2">Metrics will be shown in the selected format.</div>
<select v-model="configTemperatureFormat" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option value="celsius">Celsius (°C)</option>
<option value="fahrenheit">Fahrenheit (°F)</option>
</select>
</div>
<!-- configAutoUpdatePositionInUrl -->
<div class="p-2">
<div class="flex items-start">
<div class="flex items-center h-5">
<input type="checkbox" v-model="configAutoUpdatePositionInUrl" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required>
</div>
<label class="ml-2 text-sm font-medium text-gray-900">Auto Update Position in URL</label>
</div>
<div class="text-xs text-gray-600">Sets lat/lng/zoom as query parameters.</div>
</div>
<!-- configEnableMapAnimations -->
<div class="p-2">
<div class="flex items-start">
<div class="flex items-center h-5">
<input type="checkbox" v-model="configEnableMapAnimations" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required>
</div>
<label class="ml-2 text-sm font-medium text-gray-900">Enable Map Animations</label>
</div>
<div class="text-xs text-gray-600">Map will animate flying to nodes.</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
<!-- node connections modal -->
<div class="relative z-sidebar" role="dialog" aria-modal="true">
<!-- sidebar -->
<transition
enter-active-class="transition duration-300 ease-in-out transform"
enter-from-class="translate-y-full"
enter-to-class="translate-y-0"
leave-active-class="transition duration-300 ease-in-out transform"
leave-from-class="translate-y-0"
leave-to-class="translate-y-full">
<div v-show="selectedNodeToShowConnections != null" class="fixed left-0 right-0 bottom-0">
<div v-if="selectedNodeToShowConnections != null" class="mx-auto w-screen max-w-md p-4">
<div class="flex h-full flex-col bg-white shadow-xl rounded-xl border">
<div class="p-2">
<div class="flex items-start justify-between">
<div>
<h2 class="font-bold">{{ selectedNodeToShowConnections.short_name }} Connections</h2>
</div>
<div class="my-auto ml-3 flex h-7 items-center">
<a href="javascript:void(0)" class="rounded-full" @click="dismissShowingNodeConnections">
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
<!-- node position history modal -->
<div class="relative z-sidebar" role="dialog" aria-modal="true">
<!-- sidebar -->
<transition
enter-active-class="transition duration-300 ease-in-out transform"
enter-from-class="translate-y-full"
enter-to-class="translate-y-0"
leave-active-class="transition duration-300 ease-in-out transform"
leave-from-class="translate-y-0"
leave-to-class="translate-y-full">
<div v-show="selectedNodeToShowPositionHistory != null" class="fixed left-0 right-0 bottom-0">
<div v-if="selectedNodeToShowPositionHistory != null" class="mx-auto w-screen max-w-md p-4">
<div class="flex h-full flex-col bg-white shadow-xl rounded-xl border">
<div>
<div class="flex p-2">
<div class="flex my-auto mr-3 space-x-2">
<a href="javascript:void(0)" @click="isPositionHistoryModalExpanded = !isPositionHistoryModalExpanded" class="rounded-full">
<div class="bg-gray-100 hover:bg-gray-200 p-1 rounded-full">
<svg v-if="isPositionHistoryModalExpanded" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</div>
</a>
</div>
<div class="my-auto mr-auto font-bold">{{ selectedNodeToShowPositionHistory.short_name }} Position History</div>
<div class="flex my-auto ml-3 space-x-2">
<a href="javascript:void(0)" class="rounded-full" @click="dismissShowingNodePositionHistory">
<div class="bg-gray-100 hover:bg-gray-200 p-1 rounded-full">
<svg class="size-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</div>
</a>
</div>
</div>
<div v-if="isPositionHistoryModalExpanded" class="divide-y border-t">
<!-- quick range -->
<div class="flex p-2 space-x-2">
<button @click="onPositionHistoryQuickRangeClick('1h')" type="button" class="w-full bg-gray-100 rounded border shadow px-2 py-1 text-sm hover:bg-gray-200 text-center">1 Hour</button>
<button @click="onPositionHistoryQuickRangeClick('24h')" type="button" class="w-full bg-gray-100 rounded border shadow px-2 py-1 text-sm hover:bg-gray-200 text-center">24 Hours</button>
<button @click="onPositionHistoryQuickRangeClick('7d')" type="button" class="w-full bg-gray-100 rounded border shadow px-2 py-1 text-sm hover:bg-gray-200 text-center">7 Days</button>
</div>
<!-- manual range -->
<div class="p-2 space-y-1">
<!-- from -->
<div class="flex items-center">
<label class="text-sm pr-1 min-w-12 text-right">From:</label>
<input v-model="positionHistoryDateTimeFrom" @change="loadNodePositionHistory(selectedNodeToShowPositionHistory.node_id)" type="datetime-local" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1">
</div>
<!-- to -->
<div class="flex items-center">
<label class="text-sm pr-1 min-w-12 text-right">To:</label>
<input v-model="positionHistoryDateTimeTo" @change="loadNodePositionHistory(selectedNodeToShowPositionHistory.node_id)" type="datetime-local" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</div>
<script src="assets/js/config.js"></script>
<script src="assets/js/map.js"></script>
<script src="assets/js/app.js"></script>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-2RD5193D15"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-2RD5193D15');
</script>
</body>
</html>