Merge pull request #54 from KomelT/feature/location-history

Implement position history feature
This commit is contained in:
Liam Cottle
2024-08-20 18:49:35 +12:00
committed by GitHub
3 changed files with 283 additions and 4 deletions

View File

@ -433,6 +433,90 @@ app.get('/api/v1/nodes/:nodeId/traceroutes', async (req, res) => {
} }
}); });
app.get('/api/v1/nodes/:nodeId/position-history', async (req, res) => {
try {
// defaults
const nowInMilliseconds = new Date().getTime();
const oneHourAgoInMilliseconds = new Date().getTime() - (3600 * 1000);
// get request params
const nodeId = parseInt(req.params.nodeId);
const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : oneHourAgoInMilliseconds;
const timeTo = req.query.time_to ? parseInt(req.query.time_to) : nowInMilliseconds;
// find node
const node = await prisma.node.findFirst({
where: {
node_id: nodeId,
},
});
// make sure node exists
if(!node){
res.status(404).json({
message: "Not Found",
});
return;
}
const positions = await prisma.position.findMany({
where: {
node_id: nodeId,
created_at: {
gte: new Date(timeFrom),
lte: new Date(timeTo),
},
}
});
const mapReports = await prisma.mapReport.findMany({
where: {
node_id: nodeId,
created_at: {
gte: new Date(timeFrom),
lte: new Date(timeTo),
},
}
});
const positionHistory = []
positions.forEach((position) => {
positionHistory.push({
node_id: position.node_id,
latitude: position.latitude,
longitude: position.longitude,
altitude: position.altitude,
created_at: position.created_at,
});
});
mapReports.forEach((mapReport) => {
positionHistory.push({
node_id: mapReport.node_id,
latitude: mapReport.latitude,
longitude: mapReport.longitude,
altitude: mapReport.altitude,
created_at: mapReport.created_at,
});
});
// sort oldest to newest
positionHistory.sort((a, b) => a.created_at - b.created_at);
res.json({
position_history: positionHistory,
});
} catch(err) {
console.error(err);
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
app.get('/api/v1/stats/hardware-models', async (req, res) => { app.get('/api/v1/stats/hardware-models', async (req, res) => {
try { try {

View File

@ -135,11 +135,11 @@ const mqttUsername = options["mqtt-username"] ?? "meshdev";
const mqttPassword = options["mqtt-password"] ?? "large4cats"; const mqttPassword = options["mqtt-password"] ?? "large4cats";
const mqttTopic = options["mqtt-topic"] ?? "msh/#"; const mqttTopic = options["mqtt-topic"] ?? "msh/#";
const collectServiceEnvelopes = options["collect-service-envelopes"] ?? false; const collectServiceEnvelopes = options["collect-service-envelopes"] ?? false;
const collectPositions = options["collect-positions"] ?? false; const collectPositions = options["collect-positions"] ?? true;
const collectTextMessages = options["collect-text-messages"] ?? false; const collectTextMessages = options["collect-text-messages"] ?? false;
const collectWaypoints = options["collect-waypoints"] ?? true; const collectWaypoints = options["collect-waypoints"] ?? true;
const collectNeighbourInfo = options["collect-neighbour-info"] ?? false; const collectNeighbourInfo = options["collect-neighbour-info"] ?? false;
const collectMapReports = options["collect-map-reports"] ?? false; const collectMapReports = options["collect-map-reports"] ?? true;
const decryptionKeys = options["decryption-keys"] ?? [ const decryptionKeys = options["decryption-keys"] ?? [
"1PG7OiApB1nwvP+rz05pAQ==", // add default "AQ==" decryption key "1PG7OiApB1nwvP+rz05pAQ==", // add default "AQ==" decryption key
]; ];

View File

@ -66,6 +66,12 @@
border: 1px solid white; border: 1px solid white;
} }
.icon-position-history {
background-color: #a855f7;
border-radius: 25px;
border: 1px solid white;
}
.waypoint-label { .waypoint-label {
font-size: 26px; font-size: 26px;
background-color: transparent; background-color: transparent;
@ -748,7 +754,14 @@
<!-- position --> <!-- position -->
<div> <div>
<div class="bg-gray-200 p-2 font-semibold">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"> <ul role="list" class="flex-1 divide-y divide-gray-200">
<li> <li>
@ -1589,6 +1602,58 @@
</div> </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 border-b">
<div class="my-auto mr-auto font-bold">{{ selectedNodeToShowPositionHistory.short_name }} Position History</div>
<div class="my-auto ml-3">
<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="w-5 h-5" 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 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>
</transition>
</div>
</div> </div>
<script> <script>
@ -1652,7 +1717,7 @@
} catch(e) {} } catch(e) {}
// overlays enabled by default // overlays enabled by default
return ["Legend"]; return ["Legend", "Position History"];
} }
@ -1755,6 +1820,14 @@
selectedNodeMqttMetrics: [], selectedNodeMqttMetrics: [],
selectedNodeTraceroutes: [], selectedNodeTraceroutes: [],
// YYYY-MM-DDTHH:mm is the format expected by the datetime-local input type
positionHistoryDateTimeFrom: moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm'),
positionHistoryDateTimeTo: moment().format('YYYY-MM-DDTHH:mm'),
selectedNodePositionHistory: [],
selectedNodeToShowPositionHistory: null,
selectedNodePositionHistoryMarkers: [],
selectedNodePositionHistoryPolyLines: [],
selectedTraceRoute: null, selectedTraceRoute: null,
selectedNodeToShowNeighbours: null, selectedNodeToShowNeighbours: null,
@ -1783,6 +1856,7 @@
this.loadNodePowerMetrics(node.node_id); this.loadNodePowerMetrics(node.node_id);
this.loadNodeMqttMetrics(node.node_id); this.loadNodeMqttMetrics(node.node_id);
this.loadNodeTraceroutes(node.node_id); this.loadNodeTraceroutes(node.node_id);
//this.loadNodePositionHistory(node.node_id);
}; };
// handle node callback from outside of vue // handle node callback from outside of vue
@ -1877,6 +1951,25 @@
// do nothing // do nothing
}); });
}, },
loadNodePositionHistory: function(nodeId) {
this.selectedNodePositionHistory = [];
window.axios.get(`/api/v1/nodes/${nodeId}/position-history`, {
params: {
// parse from datetime-local format, and send as unix timestamp in milliseconds
time_from: moment(this.positionHistoryDateTimeFrom, "YYYY-MM-DDTHH:mm").format("x"),
time_to: moment(this.positionHistoryDateTimeTo, "YYYY-MM-DDTHH:mm").format("x"),
},
}).then((response) => {
this.selectedNodePositionHistory = response.data.position_history;
if(this.selectedNodeToShowPositionHistory != null){
clearAllPositionHistory();
onPositionHistoryUpdated(response.data.position_history);
}
}).catch(() => {
// do nothing
});
},
renderDeviceMetricCharts: function() { renderDeviceMetricCharts: function() {
this.updateBatteryLevelChart(); this.updateBatteryLevelChart();
this.updateVoltageChart(); this.updateVoltageChart();
@ -2489,6 +2582,22 @@
getRegionFrequencyRange: function(regionName) { getRegionFrequencyRange: function(regionName) {
return window.getRegionFrequencyRange(regionName); return window.getRegionFrequencyRange(regionName);
}, },
showNodePositionHistory: function(nodeId) {
// find node
const node = findNodeById(nodeId);
if(!node){
return;
}
// update ui
this.selectedNode = null;
this.selectedNodeToShowPositionHistory = node;
// load position history
this.loadNodePositionHistory(nodeId);
},
getShareLinkForNode: function(nodeId) { getShareLinkForNode: function(nodeId) {
return window.location.origin + `/?node_id=${nodeId}`; return window.location.origin + `/?node_id=${nodeId}`;
}, },
@ -2512,6 +2621,13 @@
window._onHideNodeNeighboursClick(); window._onHideNodeNeighboursClick();
this.selectedNodeToShowNeighbours = null; this.selectedNodeToShowNeighbours = null;
}, },
dismissShowingNodePositionHistory: function() {
this.selectedNodePositionHistory = [];
this.selectedNodeToShowPositionHistory = null;
this.selectedNodePositionHistoryMarkers = [];
this.selectedNodePositionHistoryPolyLines = [];
cleanUpPositionHistory();
},
formatUptimeSeconds: function(secondsToFormat) { formatUptimeSeconds: function(secondsToFormat) {
secondsToFormat = Number(secondsToFormat); secondsToFormat = Number(secondsToFormat);
var days = Math.floor(secondsToFormat / (3600 * 24)); var days = Math.floor(secondsToFormat / (3600 * 24));
@ -2615,6 +2731,7 @@
var nodeMarkers = {}; var nodeMarkers = {};
var selectedNodeOutlineCircle = null; var selectedNodeOutlineCircle = null;
var waypoints = []; var waypoints = [];
var positionHistories = [];
// set map bounds to be a little more than full size to prevent panning off screen // set map bounds to be a little more than full size to prevent panning off screen
var bounds = [ var bounds = [
@ -2680,6 +2797,7 @@
disableClusteringAtZoom: 10, // zoom level where node clustering is disabled disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
}); });
var waypointsLayerGroup = new L.LayerGroup(); var waypointsLayerGroup = new L.LayerGroup();
var nodePositionHistoryLayerGroup = new L.LayerGroup();
// create icons // create icons
var iconMqttConnected = L.divIcon({ var iconMqttConnected = L.divIcon({
@ -2697,6 +2815,11 @@
iconSize: [16, 16], // increase from 12px to 16px to make hover easier iconSize: [16, 16], // increase from 12px to 16px to make hover easier
}); });
var iconPositionHistory = L.divIcon({
className: 'icon-position-history',
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
// create legend // create legend
var legendLayerGroup = new L.LayerGroup(); var legendLayerGroup = new L.LayerGroup();
var legend = L.control({position: 'bottomleft'}); var legend = L.control({position: 'bottomleft'});
@ -2739,6 +2862,7 @@
"Legend": legendLayerGroup, "Legend": legendLayerGroup,
"Neighbours": neighboursLayerGroup, "Neighbours": neighboursLayerGroup,
"Waypoints": waypointsLayerGroup, "Waypoints": waypointsLayerGroup,
"Position History": nodePositionHistoryLayerGroup,
}, },
}, { }, {
// make the "Nodes" group exclusive (use radio inputs instead of checkbox) // make the "Nodes" group exclusive (use radio inputs instead of checkbox)
@ -2759,6 +2883,9 @@
if(enabledOverlayLayers.includes("Waypoints")){ if(enabledOverlayLayers.includes("Waypoints")){
waypointsLayerGroup.addTo(map); waypointsLayerGroup.addTo(map);
} }
if(enabledOverlayLayers.includes("Position History")){
nodePositionHistoryLayerGroup.addTo(map);
}
// update config when map overlay is added // update config when map overlay is added
map.on('overlayadd', function(event) { map.on('overlayadd', function(event) {
@ -2917,6 +3044,10 @@
}); });
} }
function clearAllPositionHistory() {
nodePositionHistoryLayerGroup.clearLayers();
}
function clearNodeOutline() { function clearNodeOutline() {
if(selectedNodeOutlineCircle){ if(selectedNodeOutlineCircle){
selectedNodeOutlineCircle.removeFrom(map); selectedNodeOutlineCircle.removeFrom(map);
@ -3504,6 +3635,70 @@
} }
function onPositionHistoryUpdated(updatedPositionHistories) {
let positionHistoryLinesCords = [];
// add nodes
for(var positionHistory of updatedPositionHistories) {
// skip position history without position
if(!positionHistory.latitude || !positionHistory.longitude){
continue;
}
// fix lat long
positionHistory.latitude = positionHistory.latitude / 10000000;
positionHistory.longitude = positionHistory.longitude / 10000000;
var hasLocation = isValidLatLng(positionHistory.latitude, positionHistory.longitude);
if(hasLocation){
// wrap longitude for shortest path, everything to left of australia should be shown on the right
var longitude = parseFloat(positionHistory.longitude);
if(longitude <= 100){
longitude += 360;
}
positionHistoryLinesCords.push([positionHistory.latitude, longitude]);
let tooltip = "";
tooltip += `<b>${moment(new Date(positionHistory.created_at)).format("DD/MM/YYYY hh:mm A")}</b></br>`;
tooltip += `Position: ${positionHistory.latitude}, ${positionHistory.longitude}</br>`;
// create position history marker
const marker = L.marker([positionHistory.latitude, longitude],{
icon: iconPositionHistory,
}).bindPopup(tooltip).on('click', function(event) {
// close tooltip on click to prevent tooltip and popup showing at same time
event.target.closeTooltip();
});
// add marker to position history layer group
marker.addTo(nodePositionHistoryLayerGroup);
}
}
// show lines between position history markers
L.polyline(positionHistoryLinesCords).addTo(nodePositionHistoryLayerGroup);
}
function cleanUpPositionHistory() {
// close tooltips and popups
closeAllPopups();
closeAllTooltips();
// setup node neighbours layer
nodePositionHistoryLayerGroup.clearLayers();
nodePositionHistoryLayerGroup.removeFrom(map);
nodePositionHistoryLayerGroup.addTo(map);
}
function setLoading(loading){ function setLoading(loading){
var reloadButton = document.getElementById("reload-button"); var reloadButton = document.getElementById("reload-button");
if(loading){ if(loading){