Compare commits

...

20 Commits

Author SHA1 Message Date
Anton Roslund
42d25add06 Skip traceroute edges without SNR. 2025-08-10 17:37:29 +02:00
Anton Roslund
f1103748e6 Collect route back. and show initiator and target. 2025-08-10 17:20:47 +02:00
Anton Roslund
dff6ed035a Ability to show traceroutes on map. 2025-08-10 15:17:04 +02:00
Anton Roslund
a9e749a336 Collect node max hops 2025-08-10 10:41:39 +02:00
Anton Roslund
ce8adb88a4 Provide propper timestamps 2025-08-09 16:33:49 +02:00
Anton Roslund
41bafcaaff Fixed current hour bug 2025-08-09 16:13:56 +02:00
Anton Roslund
35d1fdbc6f Allow environment graphs to span a full day. 2025-08-06 21:45:20 +02:00
Anton Roslund
63af2fbf9c Update Dockerfile for way faster build 2025-08-06 21:43:14 +02:00
Anton Roslund
c777a7bce2 add .git to .dockerignore for faster smaller builds. 2025-08-06 21:24:06 +02:00
Anton Roslund
5984b8b243 Merge pull request #22 from Roslund/additional_node_info
Collect is_unmessagable, and public_key.
2025-08-06 20:00:57 +02:00
Anton Roslund
1ee526caf7 Collect is_unmessagable, and public_key. 2025-08-06 19:48:26 +02:00
Anton Roslund
a7b99c3027 Merge pull request #21 from Roslund/use-protobuf-submodule
Load protobufs from submodule
2025-08-04 12:29:40 +02:00
Anton Roslund
b668892248 Merge pull request #18 from Roslund/dependabot/npm_and_yarn/prisma/client-6.13.0
Bump @prisma/client from 6.12.0 to 6.13.0
2025-08-04 07:39:14 +02:00
Anton Roslund
0053b9f774 Merge pull request #19 from Roslund/dependabot/npm_and_yarn/prisma-6.13.0
Bump prisma from 6.12.0 to 6.13.0
2025-08-04 07:39:01 +02:00
dependabot[bot]
435c122c21 Bump prisma from 6.12.0 to 6.13.0
Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 6.12.0 to 6.13.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/6.13.0/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-version: 6.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-04 05:36:35 +00:00
Anton Roslund
c5028f911f Merge pull request #20 from Roslund/dependabot/npm_and_yarn/mqtt-5.14.0
Bump mqtt from 5.13.2 to 5.14.0
2025-08-04 07:36:04 +02:00
Anton Roslund
7fee84de77 Merge pull request #17 from Roslund/dependabot/npm_and_yarn/jest-30.0.5
Bump jest from 30.0.4 to 30.0.5
2025-08-04 07:35:11 +02:00
dependabot[bot]
ca9d1d9de0 Bump mqtt from 5.13.2 to 5.14.0
Bumps [mqtt](https://github.com/mqttjs/MQTT.js) from 5.13.2 to 5.14.0.
- [Release notes](https://github.com/mqttjs/MQTT.js/releases)
- [Changelog](https://github.com/mqttjs/MQTT.js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mqttjs/MQTT.js/compare/v5.13.2...v5.14.0)

---
updated-dependencies:
- dependency-name: mqtt
  dependency-version: 5.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-30 21:20:37 +00:00
dependabot[bot]
8eb5c695f0 Bump @prisma/client from 6.12.0 to 6.13.0
Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 6.12.0 to 6.13.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/6.13.0/packages/client)

---
updated-dependencies:
- dependency-name: "@prisma/client"
  dependency-version: 6.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-29 20:45:41 +00:00
dependabot[bot]
e27f92d5c6 Bump jest from 30.0.4 to 30.0.5
Bumps [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) from 30.0.4 to 30.0.5.
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.0.5/packages/jest)

---
updated-dependencies:
- dependency-name: jest
  dependency-version: 30.0.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-22 20:51:12 +00:00
11 changed files with 1313 additions and 414 deletions

View File

@@ -1,2 +1,3 @@
.env
node_modules
.git

View File

@@ -1,12 +1,16 @@
FROM node:lts-alpine
# add project files to /app
ADD ./ /app
WORKDIR /app
# Copy only package files and install deps
# This layer will be cached as long as package*.json don't change
COPY package*.json package-lock.json* ./
RUN npm ci
# Copy the rest of your source
COPY . .
RUN apk add --no-cache openssl
# install node dependencies
RUN npm install
EXPOSE 8080

1296
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,17 +9,17 @@
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/client": "^6.12.0",
"@prisma/client": "^6.13.0",
"command-line-args": "^6.0.1",
"command-line-usage": "^7.0.3",
"compression": "^1.8.1",
"cors": "^2.8.5",
"express": "^5.0.0",
"mqtt": "^5.13.2",
"mqtt": "^5.14.0",
"protobufjs": "^7.5.3"
},
"devDependencies": {
"jest": "^30.0.4",
"prisma": "^6.12.0"
"jest": "^30.0.5",
"prisma": "^6.13.0"
}
}

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `nodes` ADD COLUMN `is_unmessagable` BOOLEAN NULL,
ADD COLUMN `public_key` VARCHAR(191) NULL;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `nodes` ADD COLUMN `max_hops` INTEGER NULL;

View File

@@ -21,6 +21,8 @@ model Node {
hardware_model Int
role Int
is_licensed Boolean?
public_key String?
is_unmessagable Boolean?
firmware_version String?
region Int?
@@ -52,7 +54,8 @@ model Node {
mqtt_connection_state_updated_at DateTime?
ok_to_mqtt Boolean?
is_backbone Boolean?
is_backbone Boolean?
max_hops Int?
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt

View File

@@ -146,6 +146,14 @@ app.get('/api', async (req, res) => {
"path": "/api/v1/nodes/:nodeId/traceroutes",
"description": "Trace routes for a meshtastic node",
},
{
"path": "/api/v1/traceroutes",
"description": "Recent traceroute edges across all nodes",
"params": {
"time_from": "Only include traceroutes updated after this unix timestamp (milliseconds)",
"time_to": "Only include traceroutes updated before this unix timestamp (milliseconds)"
}
},
{
"path": "/api/v1/nodes/:nodeId/position-history",
"description": "Position history for a meshtastic node",
@@ -566,6 +574,125 @@ app.get('/api/v1/nodes/:nodeId/traceroutes', async (req, res) => {
}
});
// Aggregated recent traceroute edges (global), filtered by updated_at
// Returns deduplicated edges with the latest SNR and timestamp.
// GET /api/v1/nodes/traceroutes?time_from=...&time_to=...
app.get('/api/v1/traceroutes', async (req, res) => {
try {
const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : undefined;
const timeTo = req.query.time_to ? parseInt(req.query.time_to) : undefined;
// Pull recent traceroutes within the time window. We only want replies (want_response=false)
// and those that were actually gated to MQTT (gateway_id not null)
const traces = await prisma.traceRoute.findMany({
where: {
want_response: false,
gateway_id: { not: null },
updated_at: {
gte: timeFrom ? new Date(timeFrom) : undefined,
lte: timeTo ? new Date(timeTo) : undefined,
},
},
orderBy: { id: 'desc' },
take: 5000, // cap to keep response bounded; UI can page/adjust time window if needed
});
// Normalize JSON fields that may be strings (depending on driver)
const normalized = traces.map((t) => {
const trace = { ...t };
if (typeof trace.route === 'string') {
try { trace.route = JSON.parse(trace.route); } catch(_) {}
}
if (typeof trace.route_back === 'string') {
try { trace.route_back = JSON.parse(trace.route_back); } catch(_) {}
}
if (typeof trace.snr_towards === 'string') {
try { trace.snr_towards = JSON.parse(trace.snr_towards); } catch(_) {}
}
if (typeof trace.snr_back === 'string') {
try { trace.snr_back = JSON.parse(trace.snr_back); } catch(_) {}
}
return trace;
});
// Build edges from both forward (towards) and reverse (back) paths.
// Forward path: to → route[] → from, using snr_towards
// Reverse path: from → route_back[] → to, using snr_back
const edgeKey = (a, b) => `${String(a)}->${String(b)}`;
const edges = new Map();
function upsertEdgesFromPath(trace, pathNodes, pathSnrs) {
for (let i = 0; i < pathNodes.length - 1; i++) {
const hopFrom = pathNodes[i];
const hopTo = pathNodes[i + 1];
const snr = typeof (pathSnrs && pathSnrs[i]) === 'number' ? pathSnrs[i] : null;
// Skip edges without SNR data
if (snr === null) continue;
const key = edgeKey(hopFrom, hopTo);
const existing = edges.get(key);
if (!existing) {
edges.set(key, {
from: hopFrom,
to: hopTo,
snr: snr,
updated_at: trace.updated_at,
channel_id: trace.channel_id ?? null,
gateway_id: trace.gateway_id ?? null,
traceroute_from: trace.from, // original initiator
traceroute_to: trace.to, // original target
});
} else if (new Date(trace.updated_at) > new Date(existing.updated_at)) {
existing.snr = snr;
existing.updated_at = trace.updated_at;
existing.channel_id = trace.channel_id ?? existing.channel_id;
existing.gateway_id = trace.gateway_id ?? existing.gateway_id;
existing.traceroute_from = trace.from;
existing.traceroute_to = trace.to;
}
}
}
for (const tr of normalized) {
// Forward path
const forwardPath = [];
if (tr.to != null) forwardPath.push(Number(tr.to));
if (Array.isArray(tr.route)) {
for (const hop of tr.route) {
if (hop != null) forwardPath.push(Number(hop));
}
}
if (tr.from != null) forwardPath.push(Number(tr.from));
const forwardSnrs = Array.isArray(tr.snr_towards) ? tr.snr_towards : [];
upsertEdgesFromPath(tr, forwardPath, forwardSnrs);
// Reverse path
const reversePath = [];
if (tr.from != null) reversePath.push(Number(tr.from));
if (Array.isArray(tr.route_back)) {
for (const hop of tr.route_back) {
if (hop != null) reversePath.push(Number(hop));
}
}
if (tr.to != null) reversePath.push(Number(tr.to));
const reverseSnrs = Array.isArray(tr.snr_back) ? tr.snr_back : [];
upsertEdgesFromPath(tr, reversePath, reverseSnrs);
}
res.json({
traceroute_edges: Array.from(edges.values()),
});
} catch (err) {
console.error(err);
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
app.get('/api/v1/nodes/:nodeId/position-history', async (req, res) => {
try {

View File

@@ -917,7 +917,6 @@ client.on("message", async (topic, message) => {
else if(portnum === 4) {
const user = User.decode(envelope.packet.decoded.payload);
let isOkToMqtt = null
if(logKnownPacketTypes) {
console.log("NODEINFO_APP", {
@@ -926,7 +925,9 @@ client.on("message", async (topic, message) => {
});
}
// check if bitfield is available, then check if ok-to-mqtt
// check if bitfield is available, then set ok-to-mqtt
// else leave undefined to let Prisma ignore it.
let isOkToMqtt
if(bitfield != null){
isOkToMqtt = Boolean(bitfield & BITFIELD_OK_TO_MQTT_MASK);
}
@@ -944,15 +945,18 @@ client.on("message", async (topic, message) => {
hardware_model: user.hwModel,
is_licensed: user.isLicensed === true,
role: user.role,
// Since packages beeing forwarded by older firmwars dropps the bitfield
// We only want to set form nodes that have the bitfield set.
// That way we can get a more correct reading firmware status in the mesh.
// This works since we had the old code:
// firmware_version: (bitfield != null) ? '2.5.0 or newer' : '2.4.3 or older',
...(bitfield != null && {
firmware_version: '2.5.0 or newer',
ok_to_mqtt: isOkToMqtt,
}),
is_unmessagable: user.isUnmessagable,
ok_to_mqtt: isOkToMqtt,
max_hops: envelope.packet.hopStart,
firmware_version: '<2.5.0',
...(user.publicKey != '' && {
firmware_version: '>2.5.0',
public_key: user.publicKey?.toString("base64"),
}),
...(user.isUnmessagable != null && {
firmware_version: '>2.6.8',
}),
},
update: {
long_name: user.longName,
@@ -960,9 +964,17 @@ client.on("message", async (topic, message) => {
hardware_model: user.hwModel,
is_licensed: user.isLicensed === true,
role: user.role,
...(bitfield != null && {
firmware_version: '2.5.0 or newer',
ok_to_mqtt: isOkToMqtt,
is_unmessagable: user.isUnmessagable,
ok_to_mqtt: isOkToMqtt,
max_hops: envelope.packet.hopStart,
firmware_version: '<2.5.0',
...(user.publicKey != '' && {
firmware_version: '>2.5.0',
public_key: user.publicKey?.toString("base64"),
}),
...(user.isUnmessagable != null && {
firmware_version: '>2.6.8',
}),
},
});
@@ -1429,6 +1441,6 @@ client.on("message", async (topic, message) => {
}
} catch(e) {
// ignore errors
console.log("error", e);
}
});

View File

@@ -1315,6 +1315,28 @@
</select>
</div>
<!-- configTraceroutesMaxAgeInSeconds -->
<div class="p-2">
<label class="block text-sm font-medium text-gray-900">Traceroutes Max Age</label>
<div class="text-xs text-gray-600 mb-2">Traceroute edges older than this time are hidden. Reload to update map.</div>
<select v-model="configTraceroutesMaxAgeInSeconds" 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>
@@ -1613,6 +1635,20 @@
}
}
function getConfigTraceroutesMaxAgeInSeconds() {
const value = localStorage.getItem("config_traceroutes_max_age_in_seconds");
// default to 3 days if unset, to limit payloads
return value != null ? parseInt(value) : 259200;
}
function setConfigTraceroutesMaxAgeInSeconds(value) {
if(value != null){
return localStorage.setItem("config_traceroutes_max_age_in_seconds", value);
} else {
return localStorage.removeItem("config_traceroutes_max_age_in_seconds");
}
}
function getConfigNeighboursMaxDistanceInMeters() {
const value = localStorage.getItem("config_neighbours_max_distance_in_meters");
return value != null ? parseInt(value) : null;
@@ -1649,6 +1685,7 @@
configNodesDisconnectedAgeInSeconds: window.getConfigNodesDisconnectedAgeInSeconds(),
configNodesOfflineAgeInSeconds: window.getConfigNodesOfflineAgeInSeconds(),
configWaypointsMaxAgeInSeconds: window.getConfigWaypointsMaxAgeInSeconds(),
configTraceroutesMaxAgeInSeconds: window.getConfigTraceroutesMaxAgeInSeconds(),
configNeighboursMaxDistanceInMeters: window.getConfigNeighboursMaxDistanceInMeters(),
configZoomLevelGoToNode: window.getConfigZoomLevelGoToNode(),
configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(),
@@ -1685,6 +1722,7 @@
selectedNodePositionHistoryPolyLines: [],
selectedTraceRoute: null,
tracerouteEdges: [],
selectedNodeToShowNeighbours: null,
selectedNodeToShowNeighboursType: null,
@@ -2123,7 +2161,7 @@
options: {
responsive: true,
borderWidth: 2,
spanGaps: 1000 * 60 * 60 * 3, // only show lines between metrics with a 3 hour or less gap
spanGaps: 1000 * 60 * 60 * 24, // only show lines between metrics with a 24 hour or less gap
elements: {
point: {
radius: 2,
@@ -2560,6 +2598,9 @@
configWaypointsMaxAgeInSeconds() {
window.setConfigWaypointsMaxAgeInSeconds(this.configWaypointsMaxAgeInSeconds);
},
configTraceroutesMaxAgeInSeconds() {
window.setConfigTraceroutesMaxAgeInSeconds(this.configTraceroutesMaxAgeInSeconds);
},
configNeighboursMaxDistanceInMeters() {
window.setConfigNeighboursMaxDistanceInMeters(this.configNeighboursMaxDistanceInMeters);
},
@@ -2595,6 +2636,7 @@
var nodeMarkers = {};
var selectedNodeOutlineCircle = null;
var waypoints = [];
var tracerouteEdgesCache = [];
// set map bounds to be a little more than full size to prevent panning off screen
var bounds = [
@@ -2669,6 +2711,7 @@
var nodesBackboneLayerGroup = new L.LayerGroup();
var waypointsLayerGroup = new L.LayerGroup();
var nodePositionHistoryLayerGroup = new L.LayerGroup();
var traceroutesLayerGroup = new L.LayerGroup();
// create icons
var iconMqttConnected = L.divIcon({
@@ -2736,6 +2779,7 @@
"Backbone Connection": backboneNeighboursLayerGroup,
"Waypoints": waypointsLayerGroup,
"Position History": nodePositionHistoryLayerGroup,
"Traceroutes": traceroutesLayerGroup,
},
}, {
// make the "Nodes" group exclusive (use radio inputs instead of checkbox)
@@ -2762,6 +2806,9 @@
if(enabledOverlayLayers.includes("Position History")){
nodePositionHistoryLayerGroup.addTo(map);
}
if(enabledOverlayLayers.includes("Traceroutes")){
traceroutesLayerGroup.addTo(map);
}
// update config when map overlay is added
map.on('overlayadd', function(event) {
@@ -2913,6 +2960,10 @@
waypointsLayerGroup.clearLayers();
}
function clearAllTraceroutes() {
traceroutesLayerGroup.clearLayers();
}
function closeAllPopups() {
map.eachLayer(function(layer) {
if(layer.options.pane === "popupPane"){
@@ -3048,6 +3099,51 @@
// show overlay for node neighbours
window._onShowNodeNeighboursWeHeardClick(node);
// Overlay ALL traceroute edges that terminate at this node (edge.to == node.node_id)
for (const edge of tracerouteEdgesCache) {
if (String(edge.to) !== String(node.node_id)) continue;
const fromMarker = findNodeMarkerById(edge.from);
if (!fromMarker) continue;
const snrDb = (typeof edge.snr === 'number') ? (edge.snr === -128 ? null : (Number(edge.snr) / 4)) : null;
const trColour = snrDb != null ? getColourForSnr(snrDb) : '#6b7280';
const trTooltip = (() => {
const fromNode = findNodeById(edge.from);
const toNode = findNodeById(node.node_id);
const distanceInMeters = fromMarker.getLatLng().distanceTo(nodeMarker.getLatLng()).toFixed(2);
let distance = `${distanceInMeters} meters`;
if (distanceInMeters >= 1000) {
const km = (distanceInMeters / 1000).toFixed(2);
distance = `${km} kilometers`;
}
const terrainImageUrl = getTerrainProfileImage(fromNode, toNode);
const targetNode = edge.traceroute_from ? findNodeById(edge.traceroute_from) : null;
const initiatorNode = edge.traceroute_to ? findNodeById(edge.traceroute_to) : null;
return `<b>Traceroute hop</b>`
+ `<br/>from <b>[${escapeString(fromNode.short_name)}] ${escapeString(fromNode.long_name)}</b>`
+ ` to <b>[${escapeString(toNode.short_name)}] ${escapeString(toNode.long_name)}</b>`
+ `<br/>SNR: ${snrDb != null ? snrDb + 'dB' : '?'}`
+ `<br/>Distance: ${distance}`
+ (initiatorNode ? `<br/>Traceroute from: <b>[${escapeString(initiatorNode.short_name)}] ${escapeString(initiatorNode.long_name)}</b>` : '')
+ (targetNode ? `<br/>Traceroute to: <b>[${escapeString(targetNode.short_name)}] ${escapeString(targetNode.long_name)}</b>` : '')
+ `<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>`;
})();
L.polyline([
fromMarker.getLatLng(),
nodeMarker.getLatLng(),
], {
color: trColour,
opacity: 0.9,
}).arrowheads({ size: '10px', fill: true, offsets: { start: '25px', end: '25px' } })
.addTo(nodeNeighboursLayerGroup)
.bindTooltip(trTooltip, { sticky: true, opacity: 1, interactive: true })
.bindPopup(trTooltip);
}
// ensure we have neighbours to show
const neighbours = node.neighbours ?? [];
if(neighbours.length === 0){
@@ -3171,6 +3267,51 @@
}
// Overlay ALL traceroute edges that originate from this node (edge.from == node.node_id)
for (const edge of tracerouteEdgesCache) {
if (String(edge.from) !== String(node.node_id)) continue;
const toMarker = findNodeMarkerById(edge.to);
if (!toMarker) continue;
const snrDb = (typeof edge.snr === 'number') ? (edge.snr === -128 ? null : (Number(edge.snr) / 4)) : null;
const trColour = snrDb != null ? getColourForSnr(snrDb) : '#6b7280';
const trTooltip2 = (() => {
const fromNode = findNodeById(node.node_id);
const toNode = findNodeById(edge.to);
const distanceInMeters = nodeMarker.getLatLng().distanceTo(toMarker.getLatLng()).toFixed(2);
let distance = `${distanceInMeters} meters`;
if (distanceInMeters >= 1000) {
const km = (distanceInMeters / 1000).toFixed(2);
distance = `${km} kilometers`;
}
const terrainImageUrl = getTerrainProfileImage(fromNode, toNode);
const targetNode = edge.traceroute_from ? findNodeById(edge.traceroute_from) : null;
const initiatorNode = edge.traceroute_to ? findNodeById(edge.traceroute_to) : null;
return `<b>Traceroute hop</b>`
+ `<br/>from <b>[${escapeString(fromNode.short_name)}] ${escapeString(fromNode.long_name)}</b>`
+ ` to <b>[${escapeString(toNode.short_name)}] ${escapeString(toNode.long_name)}</b>`
+ `<br/>SNR: ${snrDb != null ? snrDb + 'dB' : '?'}`
+ `<br/>Distance: ${distance}`
+ (initiatorNode ? `<br/>Traceroute from: <b>[${escapeString(initiatorNode.short_name)}] ${escapeString(initiatorNode.long_name)}</b>` : '')
+ (targetNode ? `<br/>Traceroute to: <b>[${escapeString(targetNode.short_name)}] ${escapeString(targetNode.long_name)}</b>` : '')
+ `<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>`;
})();
L.polyline([
nodeMarker.getLatLng(),
toMarker.getLatLng(),
], {
color: trColour,
opacity: 0.9,
}).arrowheads({ size: '10px', fill: true, offsets: { start: '25px', end: '25px' } })
.addTo(nodeNeighboursLayerGroup)
.bindTooltip(trTooltip2, { sticky: true, opacity: 1, interactive: true })
.bindPopup(trTooltip2);
}
// ensure we have neighbours to show
if(neighbourNodeInfos.length === 0){
return;
@@ -3257,6 +3398,7 @@
clearAllNodes();
clearAllNeighbours();
clearAllWaypoints();
clearAllTraceroutes();
clearNodeOutline();
cleanUpNodeNeighbours();
}
@@ -3624,6 +3766,72 @@
}
function onTracerouteEdgesUpdated(edges) {
traceroutesLayerGroup.clearLayers();
tracerouteEdgesCache = edges;
for (const edge of edges) {
// Convert SNR for traceroutes: snr/4 dB; -128 means unknown
const snrDb = (typeof edge.snr === 'number')
? (edge.snr === -128 ? null : (Number(edge.snr) / 4))
: null;
const fromNode = findNodeById(edge.from);
const toNode = findNodeById(edge.to);
if (!fromNode || !toNode) continue;
const fromMarker = findNodeMarkerById(edge.from);
const toMarker = findNodeMarkerById(edge.to);
if (!fromMarker || !toMarker) continue;
const distanceInMeters = fromMarker.getLatLng().distanceTo(toMarker.getLatLng()).toFixed(2);
let distance = `${distanceInMeters} meters`;
if (distanceInMeters >= 1000) {
const km = (distanceInMeters / 1000).toFixed(2);
distance = `${km} kilometers`;
}
const colour = '#f97316';
const terrainImageUrl = getTerrainProfileImage(fromNode, toNode);
// This is backwards. It's because the traceroute packet is sent from the target node.
const targetNode = edge.traceroute_from ? findNodeById(edge.traceroute_from) : null;
const initiatorNode = edge.traceroute_to ? findNodeById(edge.traceroute_to) : null;
const tooltip = `<b>Traceroute hop</b>`
+ `<br/>from <b>[${escapeString(fromNode.short_name)}] ${escapeString(fromNode.long_name)}</b>`
+ ` to <b>[${escapeString(toNode.short_name)}] ${escapeString(toNode.long_name)}</b>`
+ `<br/>SNR: ${snrDb != null ? snrDb + 'dB' : '?'}`
+ `<br/>Distance: ${distance}`
+ (initiatorNode ? `<br/>Traceroute from: <b>[${escapeString(initiatorNode.short_name)}] ${escapeString(initiatorNode.long_name)}</b>` : '')
+ (targetNode ? `<br/>Traceroute to: <b>[${escapeString(targetNode.short_name)}] ${escapeString(targetNode.long_name)}</b>` : '')
+ (edge.updated_at ? `<br/>Updated: ${moment(new Date(edge.updated_at)).fromNow()}` : '')
+ (edge.channel_id ? `<br/>Channel: ${edge.channel_id}` : '')
+ `<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>`;
const line = L.polyline([
fromMarker.getLatLng(),
toMarker.getLatLng(),
], {
color: colour,
opacity: 0.9,
}).addTo(traceroutesLayerGroup);
line.bindTooltip(tooltip, {
sticky: true,
opacity: 1,
interactive: true,
}).bindPopup(tooltip)
.on('click', function(event) {
event.target.closeTooltip();
});
}
}
function onPositionHistoryUpdated(updatedPositionHistories) {
let positionHistoryLinesCords = [];
@@ -3756,6 +3964,17 @@
onWaypointsUpdated(response.data.waypoints);
});
// fetch traceroute edges
const traceroutesMaxAgeSec = getConfigTraceroutesMaxAgeInSeconds();
const timeFrom = traceroutesMaxAgeSec ? (Date.now() - traceroutesMaxAgeSec * 1000) : undefined;
const params = new URLSearchParams();
if (timeFrom) params.set('time_from', timeFrom);
await window.axios.get(`/api/v1/traceroutes?${params.toString()}`).then(async (response) => {
onTracerouteEdgesUpdated(response.data.traceroute_edges ?? []);
}).catch(() => {
onTracerouteEdgesUpdated([]);
});
}
function getRegionFrequencyRange(regionName) {

View File

@@ -64,19 +64,19 @@ router.get('/messages-per-hour', async (req, res) => {
orderBy: { created_at: 'asc' }
});
// Pre-fill `uniqueCounts` with zeros for all hours
// Pre-fill `uniqueCounts` with zeros for all hours, including the current hour
const uniqueCounts = Object.fromEntries(
Array.from({ length: hours }, (_, i) => {
const hourTime = new Date(now.getTime() - (hours - i) * 60 * 60 * 1000);
const hourString = hourTime.toISOString().slice(0, 13); // YYYY-MM-DD HH
const hourTime = new Date(now.getTime() - (hours - 1 - i) * 60 * 60 * 1000);
const hourString = hourTime.toISOString().slice(0, 13) + ":00:00.000Z"; // zero out the minutes and seconds
return [hourString, 0];
})
);
// Populate actual message counts
messages.forEach(({ created_at }) => {
const hourString = created_at.toISOString().slice(0, 13); // YYYY-MM-DD HH
uniqueCounts[hourString]++;
const hourString = created_at.toISOString().slice(0, 13) + ":00:00.000Z"; // zero out the minutes and seconds
uniqueCounts[hourString] = (uniqueCounts[hourString] ?? 0) + 1;
});
// Convert to final result format