Compare commits

..

202 commits

Author SHA1 Message Date
8dc7cdb811 Update screenshot.png 2026-02-21 12:00:09 +01:00
b9ed0914d4 Remove DOCTYPE for now 2026-02-21 11:39:18 +01:00
fc4fff532a Update device images 2026-02-21 10:56:15 +01:00
e22b835114 Add DOCTYPE to html 2026-02-21 10:50:28 +01:00
d452e8b3ad Remove Google Analytics 2026-02-21 10:45:30 +01:00
720f3b1529 Fix JavaScript sequence 2026-02-21 10:43:01 +01:00
8c54e71efc Move JavaScript to separate files 2026-02-21 10:38:46 +01:00
6e6bb22a9c Move css to separate file 2026-02-21 10:27:49 +01:00
c090352c32 Update README.md 2026-02-21 10:18:27 +01:00
7a57d252dd Add new map provider 2026-02-21 02:15:27 +01:00
8bb4d9d9dd Update default settings for getConfigNodesOfflineAgeInSeconds 2026-02-21 02:13:27 +01:00
33c24d9fe6 Add ShortSlow mode and update design 2026-02-21 02:11:39 +01:00
a0181b8e0f Rebrand website and remove disclaimer 2026-02-21 02:03:04 +01:00
360694842c Add new device images 2026-02-21 01:54:59 +01:00
Anton Roslund
8fd6730e0d Remove arrowheads from backbone connection 2026-01-11 11:18:01 +01:00
Anton Roslund
1748079708 Add bidirectional connection filtering and minimum SNR configuration to connections UI 2026-01-10 13:43:11 +01:00
Anton Roslund
4a4b5fb7f3 Fix variable assignment in message handler to correctly identify fromNodeId and toNodeId for neighbor information extraction. 2026-01-08 20:58:46 +01:00
Anton Roslund
dc9a45a62a Update position history date format to ISO 8601 2026-01-08 20:50:05 +01:00
Anton Roslund
f3154cb97b Update UI label and description for Connections Max Age configuration to clarify functionality related to edges from traceroutes and neighbor info. 2026-01-08 19:03:29 +01:00
Anton Roslund
57c10383e2 Remove configuration for nodes disconnected age from UI and related functions, streamlining the node status display and tooltip information. 2026-01-08 19:02:35 +01:00
Anton Roslund
f690bb65a7
Merge pull request #68 from Roslund/Collect-edges
Ny Funktionalitet för kopplingar och signalstyrka
2026-01-08 18:44:56 +01:00
Anton Roslund
f79ff5b7e4 Refactor connections feature: update UI for connections time period and add configuration for colored connection lines. Replace traceroute-related functionality with connections logic, including fetching and displaying connections on the map. Enhance tooltips for connections with detailed SNR information and distance metrics. 2026-01-08 18:33:16 +01:00
Anton Roslund
71d32d1cd0 Add optional parameter for filtering connections by node 2026-01-08 18:32:55 +01:00
Anton Roslund
556dde517b Add connections endpoint and UI configuration for connections time period and colored lines 2026-01-07 20:32:18 +01:00
Anton Roslund
1333447398 Extract edges from route_back path 2026-01-07 16:47:20 +01:00
Anton Roslund
58d71c8c74 Extract edges from neighbour info 2026-01-07 16:46:56 +01:00
Anton Roslund
57dce4f099 Capture edges from traceroutes 2026-01-06 16:39:39 +01:00
Anton Roslund
3cfb7e7dff Add logger utility for formated and timestamped console output 2026-01-05 15:58:27 +01:00
Anton Roslund
db4008d86a Enhance error handling in MQTT message processing to ignore MySQL error 1020 related to race conditions 2026-01-05 15:57:17 +01:00
Anton Roslund
d9aaeb4479 Add ADMIN_APP to know portnums 2026-01-05 15:56:36 +01:00
Anton Roslund
6deefed3f7 Revert "Merge pull request #61 from Roslund/dependabot/npm_and_yarn/prisma-7.2.0"
This reverts commit 42b9e304e1, reversing
changes made to 87a3da812a.
2026-01-04 18:24:30 +01:00
Anton Roslund
8ef35660ea Revert "Merge pull request #62 from Roslund/dependabot/npm_and_yarn/prisma/client-7.2.0"
This reverts commit 9d0ade01a4, reversing
changes made to 42b9e304e1.
2026-01-04 18:24:15 +01:00
Anton Roslund
cbbde6c50a Update protobufs 2026-01-04 14:12:01 +01:00
Anton Roslund
ef7053d243 Longer animations for tracetroutes 2026-01-04 14:11:45 +01:00
Anton Roslund
a49c1b73ea
Merge pull request #53 from Roslund/dependabot/npm_and_yarn/npm_and_yarn-4265e88a4c
Bump js-yaml from 3.14.1 to 3.14.2 in the npm_and_yarn group across 1 directory
2026-01-04 17:21:03 +01:00
Anton Roslund
9d0ade01a4
Merge pull request #62 from Roslund/dependabot/npm_and_yarn/prisma/client-7.2.0
Bump @prisma/client from 6.16.2 to 7.2.0
2026-01-04 17:19:59 +01:00
Anton Roslund
42b9e304e1
Merge pull request #61 from Roslund/dependabot/npm_and_yarn/prisma-7.2.0
Bump prisma from 6.16.2 to 7.2.0
2026-01-04 17:19:44 +01:00
Anton Roslund
87a3da812a
Merge pull request #58 from Roslund/dependabot/npm_and_yarn/express-5.2.1
Bump express from 5.1.0 to 5.2.1
2026-01-04 17:18:45 +01:00
Anton Roslund
3441fb2475 fix stale traceroutes when adding the traceroutes overlay. 2026-01-03 11:49:53 +01:00
Anton Roslund
f7fbb38961 Refactor WebSocket connection logic to differentiate between localhost and production environments 2026-01-02 22:52:12 +01:00
Anton Roslund
aadd038880
Merge pull request #64 from Roslund/Websocket-publisher
Add WebSocket support for real-time traceroute visualizations.
2026-01-02 22:23:12 +01:00
Anton Roslund
328fb3e842 Add WebSocket support for real-time traceroute visualizations. 2026-01-02 22:20:24 +01:00
dependabot[bot]
53728e4528
Bump @prisma/client from 6.16.2 to 7.2.0
Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 6.16.2 to 7.2.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.2.0/packages/client)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-17 20:19:12 +00:00
dependabot[bot]
4927ab9920
Bump prisma from 6.16.2 to 7.2.0
Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 6.16.2 to 7.2.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.2.0/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-version: 7.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-17 20:18:48 +00:00
dependabot[bot]
cc07bfdaba
Bump express from 5.1.0 to 5.2.1
Bumps [express](https://github.com/expressjs/express) from 5.1.0 to 5.2.1.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/v5.1.0...v5.2.1)

---
updated-dependencies:
- dependency-name: express
  dependency-version: 5.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 23:49:24 +00:00
Anton Roslund
8fd496c59d Filter nodes and hardware models to include only those updated in the last 30 days 2025-11-21 22:05:31 +01:00
Anton Roslund
7a86783ba4 also use traceroutes for backbone connections layer. 2025-11-19 21:17:05 +01:00
Anton Roslund
8575d87c18 Fix order of backbone neighbours and assume sumetrical connections 2025-11-19 21:16:42 +01:00
Anton Roslund
b107e6489a Add uppdated ad and channel to all traceroute hops. 2025-11-19 20:25:30 +01:00
Anton Roslund
e7afce1f3b update information 2025-11-19 20:03:24 +01:00
dependabot[bot]
c5f7690f0e
Bump js-yaml in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [js-yaml](https://github.com/nodeca/js-yaml).


Updates `js-yaml` from 3.14.1 to 3.14.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.14.1...3.14.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 3.14.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-18 07:27:33 +00:00
Anton Roslund
32204a554d Update colors 2025-11-03 07:13:18 +01:00
Anton Roslund
221aed8e97 New Jitter logic for inprecise positions. 2025-11-02 20:50:21 +01:00
Anton Roslund
737eeb3120
Merge pull request #44 from Jellyfrog/feat/dockerfile
Optimize Dockerfile with multi-stage build
2025-10-05 20:00:11 +02:00
Anton Roslund
1fd9f1c737 add channel_id filter to portnum-counts 2025-10-05 12:22:34 +02:00
Jellyfrog
da7809fd75 Optimize Dockerfile with multi-stage build 2025-10-04 20:16:38 +02:00
Anton Roslund
57d962ae89 add channel parameter to most-active-nodes. 2025-10-02 18:37:17 +02:00
Anton Roslund
3cf7c9479e add ok_to_mqtt to text-messages 2025-09-29 20:46:23 +02:00
Anton Roslund
998259042b Add layergroup for LongFast 2025-09-28 20:26:19 +02:00
Anton Roslund
9eae1d21b6 Fix Dockerfiler 2025-09-28 20:23:18 +02:00
Jellyfrog
7d63cac3b9 Optimize Dockerfile with multi-stage build
Also:
* Skips installing dev dependencies
* Runs as non root
2025-09-28 18:49:36 +02:00
Anton Roslund
0ffbf5e895 better zIndexOffset 2025-09-28 15:57:18 +02:00
Anton Roslund
2d20bf293e Hande SIGTEM and SIGINT for faster docker recreates 2025-09-25 20:36:24 +02:00
Anton Roslund
f09cf5596a import Prisma as well 2025-09-25 20:24:21 +02:00
Anton Roslund
6682131cf1 Update Chanelutilization 2025-09-25 20:17:42 +02:00
Anton Roslund
7df3908c4b Update Protobuffs 2025-09-24 23:01:07 +02:00
Anton Roslund
4d2ad549db MediumFast on top 2025-09-21 19:19:46 +02:00
Anton Roslund
b2473b419a fix date 2025-09-20 12:29:24 +02:00
Anton Roslund
f470b1b1b1
Merge pull request #34 from Roslund/dependabot/npm_and_yarn/mqtt-5.14.1
Bump mqtt from 5.14.0 to 5.14.1
2025-09-20 07:44:44 +02:00
Anton Roslund
e8f796c5ad
Merge branch 'master' into dependabot/npm_and_yarn/mqtt-5.14.1 2025-09-20 07:44:36 +02:00
Anton Roslund
9d60cb07e0
Merge pull request #40 from Roslund/dependabot/npm_and_yarn/prisma-6.16.2
Bump prisma from 6.13.0 to 6.16.2
2025-09-20 07:43:38 +02:00
Anton Roslund
c0c30d189d
Merge branch 'master' into dependabot/npm_and_yarn/prisma-6.16.2 2025-09-20 07:43:17 +02:00
Anton Roslund
6ccdfc7d4b
Merge pull request #39 from Roslund/dependabot/npm_and_yarn/prisma/client-6.16.2
Bump @prisma/client from 6.13.0 to 6.16.2
2025-09-20 07:39:48 +02:00
Anton Roslund
151fabece0
Merge pull request #33 from Roslund/dependabot/npm_and_yarn/jest-30.1.3
Bump jest from 30.0.5 to 30.1.3
2025-09-20 07:38:41 +02:00
Anton Roslund
f08b225a82
Merge pull request #28 from Roslund/dependabot/npm_and_yarn/protobufjs-7.5.4
Bump protobufjs from 7.5.3 to 7.5.4
2025-09-20 07:38:16 +02:00
Anton Roslund
e12252f585 Add announcement for MediumFast 2025-09-20 07:12:08 +02:00
dependabot[bot]
a7b147d3ac
Bump prisma from 6.13.0 to 6.16.2
Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 6.13.0 to 6.16.2.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/6.16.2/packages/cli)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-16 20:24:53 +00:00
dependabot[bot]
4d9c591aea
Bump @prisma/client from 6.13.0 to 6.16.2
Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 6.13.0 to 6.16.2.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/6.16.2/packages/client)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-16 20:24:33 +00:00
dependabot[bot]
b762972ff3
Bump mqtt from 5.14.0 to 5.14.1
Bumps [mqtt](https://github.com/mqttjs/MQTT.js) from 5.14.0 to 5.14.1.
- [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.14.0...v5.14.1)

---
updated-dependencies:
- dependency-name: mqtt
  dependency-version: 5.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-04 20:30:53 +00:00
dependabot[bot]
8851bbc375
Bump jest from 30.0.5 to 30.1.3
Bumps [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) from 30.0.5 to 30.1.3.
- [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.1.3/packages/jest)

---
updated-dependencies:
- dependency-name: jest
  dependency-version: 30.1.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-03 16:15:12 +00:00
dependabot[bot]
435788bda0
Bump protobufjs from 7.5.3 to 7.5.4
Bumps [protobufjs](https://github.com/protobufjs/protobuf.js) from 7.5.3 to 7.5.4.
- [Release notes](https://github.com/protobufjs/protobuf.js/releases)
- [Changelog](https://github.com/protobufjs/protobuf.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/protobufjs/protobuf.js/compare/protobufjs-v7.5.3...protobufjs-v7.5.4)

---
updated-dependencies:
- dependency-name: protobufjs
  dependency-version: 7.5.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-19 06:52:47 +00:00
Anton Roslund
fe520b50b1 Include curvature in terrain profile. 2025-08-18 07:26:19 +02:00
Anton Roslund
b1755d4b73 Add MediumFast filter 2025-08-17 22:31:26 +02:00
Anton Roslund
02529b8b5f woops 2025-08-15 21:21:11 +02:00
Anton Roslund
5358bb8928 Give medium fast nodes slightly different color 2025-08-15 20:39:10 +02:00
Anton Roslund
0be80d4177 Collect channel_id 2025-08-13 21:50:50 +02:00
Anton Roslund
2fd0e9c016
Merge pull request #25 from Roslund/traceroutes
Skip traceroute edges without SNR.
2025-08-10 17:38:36 +02:00
Anton Roslund
42d25add06 Skip traceroute edges without SNR. 2025-08-10 17:37:29 +02:00
Anton Roslund
e4b9585d12
Merge pull request #24 from Roslund/traceroutes
Collect route back. and show initiator and target.
2025-08-10 17:23:04 +02:00
Anton Roslund
f1103748e6 Collect route back. and show initiator and target. 2025-08-10 17:20:47 +02:00
Anton Roslund
07c3cc2c0d
Merge pull request #23 from Roslund/traceroutes
Ability to show traceroutes on map.
2025-08-10 15:20:07 +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
Anton Roslund
162f8da79c Load protobufs from submodule 2025-08-03 21:05:33 +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
Anton Roslund
e11367544d Missed to remove firmware version from update. 2025-07-23 12:27:11 +02:00
Anton Roslund
cbcbeb9a22 Add jitter to imprecise positions 2025-07-23 08:07:33 +02:00
Anton Roslund
4c745123c1 Show ok to MQTT on map 2025-07-23 08:05:29 +02:00
Anton Roslund
800dcfef78 Only change firmware version and ok_to_mqtt if bitfield is present. 2025-07-23 08:04:04 +02: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
Anton Roslund
772faa550b
Merge pull request #14 from Roslund/dependabot/npm_and_yarn/compression-1.8.1
Bump compression from 1.8.0 to 1.8.1
2025-07-18 20:45:31 +02:00
Anton Roslund
a716d401a9
Merge pull request #15 from Roslund/dependabot/npm_and_yarn/npm_and_yarn-96c788614a
Bump the npm_and_yarn group with 2 updates
2025-07-18 20:45:10 +02:00
dependabot[bot]
aeeb95554b
Bump the npm_and_yarn group with 2 updates
Bumps the npm_and_yarn group with 2 updates: [on-headers](https://github.com/jshttp/on-headers) and [compression](https://github.com/expressjs/compression).


Updates `on-headers` from 1.0.2 to 1.1.0
- [Release notes](https://github.com/jshttp/on-headers/releases)
- [Changelog](https://github.com/jshttp/on-headers/blob/master/HISTORY.md)
- [Commits](https://github.com/jshttp/on-headers/compare/v1.0.2...v1.1.0)

Updates `compression` from 1.8.0 to 1.8.1
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/1.8.0...v1.8.1)

---
updated-dependencies:
- dependency-name: on-headers
  dependency-version: 1.1.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: compression
  dependency-version: 1.8.1
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-18 13:11:12 +00:00
dependabot[bot]
bc78a0b0e3
Bump compression from 1.8.0 to 1.8.1
---
updated-dependencies:
- dependency-name: compression
  dependency-version: 1.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-17 20:47:11 +00:00
Anton Roslund
aa57eb543f
Merge pull request #13 from Roslund/dependabot/npm_and_yarn/prisma/client-6.12.0
Bump @prisma/client from 6.11.1 to 6.12.0
2025-07-16 12:07:41 +02:00
Anton Roslund
d5993aebb8
Merge pull request #12 from Roslund/dependabot/npm_and_yarn/prisma-6.12.0
Bump prisma from 6.11.1 to 6.12.0
2025-07-16 12:07:21 +02:00
dependabot[bot]
2b2ca982a0
Bump @prisma/client from 6.11.1 to 6.12.0
Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 6.11.1 to 6.12.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/6.12.0/packages/client)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-15 20:59:09 +00:00
dependabot[bot]
d685826f15
Bump prisma from 6.11.1 to 6.12.0
Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 6.11.1 to 6.12.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/6.12.0/packages/cli)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-15 20:58:48 +00:00
Anton Roslund
91f98a6a09
Merge pull request #9 from Roslund/dependabot/npm_and_yarn/prisma-6.11.1
Bump prisma from 5.16.1 to 6.11.1
2025-07-15 11:17:29 +02:00
Anton Roslund
3452efb1b7
Merge pull request #3 from Roslund/dependabot/npm_and_yarn/prisma/client-6.11.1
Bump @prisma/client from 5.16.1 to 6.11.1
2025-07-15 11:17:17 +02:00
dependabot[bot]
54e83e5cdd
Bump prisma from 5.16.1 to 6.11.1
Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 5.16.1 to 6.11.1.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/6.11.1/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-version: 6.11.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-15 09:16:18 +00:00
Anton Roslund
d3ac48f044
Merge pull request #5 from Roslund/dependabot/npm_and_yarn/jest-30.0.4
Bump jest from 29.7.0 to 30.0.4
2025-07-15 11:14:56 +02:00
Anton Roslund
24b90f6710
Merge pull request #4 from Roslund/dependabot/npm_and_yarn/protobufjs-7.5.3
Bump protobufjs from 7.3.2 to 7.5.3
2025-07-15 11:14:08 +02:00
dependabot[bot]
a77518facf
Bump @prisma/client from 5.16.1 to 6.11.1
Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 5.16.1 to 6.11.1.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/6.11.1/packages/client)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-15 09:11:01 +00:00
Anton Roslund
c36a797967
Merge pull request #8 from Roslund/dependabot/npm_and_yarn/compression-1.8.0
Bump compression from 1.7.4 to 1.8.0
2025-07-15 11:09:35 +02:00
dependabot[bot]
5c83393cb1
Bump jest from 29.7.0 to 30.0.4
Bumps [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) from 29.7.0 to 30.0.4.
- [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.4/packages/jest)

---
updated-dependencies:
- dependency-name: jest
  dependency-version: 30.0.4
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-15 08:31:31 +00:00
dependabot[bot]
5c0a7db8c6
Bump compression from 1.7.4 to 1.8.0
Bumps [compression](https://github.com/expressjs/compression) from 1.7.4 to 1.8.0.
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/1.7.4...1.8.0)

---
updated-dependencies:
- dependency-name: compression
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-15 08:31:10 +00:00
Anton Roslund
201ba0d399
Merge pull request #7 from Roslund/dependabot/npm_and_yarn/command-line-args-6.0.1
Bump command-line-args from 5.2.1 to 6.0.1
2025-07-15 10:30:40 +02:00
dependabot[bot]
b31d10d434
Bump command-line-args from 5.2.1 to 6.0.1
Bumps [command-line-args](https://github.com/75lb/command-line-args) from 5.2.1 to 6.0.1.
- [Release notes](https://github.com/75lb/command-line-args/releases)
- [Commits](https://github.com/75lb/command-line-args/compare/v5.2.1...v6.0.1)

---
updated-dependencies:
- dependency-name: command-line-args
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-15 08:30:31 +00:00
dependabot[bot]
96cc1ad1d5
Bump protobufjs from 7.3.2 to 7.5.3
Bumps [protobufjs](https://github.com/protobufjs/protobuf.js) from 7.3.2 to 7.5.3.
- [Release notes](https://github.com/protobufjs/protobuf.js/releases)
- [Changelog](https://github.com/protobufjs/protobuf.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/protobufjs/protobuf.js/compare/protobufjs-v7.3.2...protobufjs-v7.5.3)

---
updated-dependencies:
- dependency-name: protobufjs
  dependency-version: 7.5.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-15 08:29:57 +00:00
Anton Roslund
ce2121d964
Merge pull request #6 from Roslund/dependabot/npm_and_yarn/command-line-usage-7.0.3
Bump command-line-usage from 7.0.1 to 7.0.3
2025-07-15 10:29:18 +02:00
Anton Roslund
0c5b330681
Merge pull request #10 from Roslund/dependabot/npm_and_yarn/mqtt-5.13.2
Bump mqtt from 5.7.3 to 5.13.2
2025-07-15 10:27:58 +02:00
dependabot[bot]
2d1dd6fbb2
Bump mqtt from 5.7.3 to 5.13.2
Bumps [mqtt](https://github.com/mqttjs/MQTT.js) from 5.7.3 to 5.13.2.
- [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.7.3...v5.13.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-14 14:22:18 +00:00
dependabot[bot]
9e17347a7a
Bump command-line-usage from 7.0.1 to 7.0.3
Bumps [command-line-usage](https://github.com/75lb/command-line-usage) from 7.0.1 to 7.0.3.
- [Release notes](https://github.com/75lb/command-line-usage/releases)
- [Commits](https://github.com/75lb/command-line-usage/compare/v7.0.1...v7.0.3)

---
updated-dependencies:
- dependency-name: command-line-usage
  dependency-version: 7.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-14 13:10:57 +00:00
Anton Roslund
6619e42bad
Create dependabot.yml 2025-07-14 12:54:35 +02:00
Anton Roslund
e95bac1063 Update express to v5.0.0 2025-07-08 16:54:59 +02:00
liamcottle
3eed255eb4 use purple lines between position history markers 2025-07-08 16:37:13 +02:00
liamcottle
2bc82ae7a3 add device image for STATION_G2 2025-07-08 16:34:36 +02:00
liamcottle
9d7a60efa5 remove white background from device images 2025-07-08 16:31:06 +02:00
liamcottle
8c064ac6e6 rotate and optimise image 2025-07-08 16:30:57 +02:00
Travis Hardiman
b3bb02fede replace U+00BA º (not degrees) with U+00B0 ° (degrees)
U+00BA º MASCULINE ORDINAL INDICATOR vs. U+00B0 ° DEGREE SIGN (&deg;)
2025-07-08 16:30:42 +02:00
Iris
11f5898996 Add files via upload 2025-07-08 16:30:25 +02:00
sgtwilko
17be3cb6a9 Change Voltage chart to use suggested min/max (#1)
The Voltage/Current chart often either shows lines with so little variation that you cannot see changes, or the values go off the top/bottom.

This change allows the chart to adapt dynamically to the values being returned.
2025-07-08 16:30:10 +02:00
Anton Roslund
d95af37be5 Add backbone layers 2025-06-27 11:15:52 +02:00
Anton Roslund
e30fb12aa8 More relaxed snr limits 2025-06-26 22:35:34 +02:00
Anton Roslund
6691df73f5 Add IAQ metrics 2025-06-26 22:33:37 +02:00
Anton Roslund
9b69d0ce27 Additional device images 2025-05-13 19:46:23 +02:00
Anton Roslund
cf3f053e12 Found better way to calculate neighbour offset. 2025-04-28 23:26:54 +02:00
Anton Roslund
305f091142 Revert "add configNeighboursPolylineOffset"
This reverts commit 4d1bdba6e0.
2025-04-28 23:22:16 +02:00
Anton Roslund
4d1bdba6e0 add configNeighboursPolylineOffset 2025-04-27 09:10:34 +02:00
Anton Roslund
6210f04ea5 Keep track of the names a node has been using 2025-04-26 22:09:31 +02:00
Anton Roslund
99e31d8692 Add Battery and ChannelUtilization tables 2025-04-26 15:58:56 +02:00
Anton Roslund
55cbdb63ba Add channel-utilization-stats 2025-04-20 22:19:07 +02:00
Anton Roslund
c251c5adb8 Filter packages not sent over LoRa from stats 2025-04-20 11:38:48 +02:00
Anton Roslund
189c89a7ca still testing 2025-04-19 09:34:40 +02:00
Anton Roslund
8b84a3a91c testing in production 2025-04-19 09:32:32 +02:00
Anton Roslund
6cd07fe314 adding battery-stats API 2025-04-19 09:28:49 +02:00
Anton Roslund
fac5a91829 include PKI packages in portnum-counts 2025-04-16 19:26:02 +02:00
Anton Roslund
2fbaab81c5 cleanup of unused apis and functions 2025-04-16 19:16:27 +02:00
Anton Roslund
bfb845ac37 Exclude map reports from from portnum-counts 2025-04-16 19:11:14 +02:00
Anton Roslund
274f0b8efa updates stats for testing 2025-04-16 18:52:51 +02:00
Anton Roslund
c8f322f012 Collect packet_id for ServiceEnvelopes 2025-04-15 20:49:27 +02:00
Anton Roslund
f35a8876f9 fix firmware version bug 2025-04-15 18:03:56 +02:00
Anton Roslund
af2a663dab Collect portnums for ServiceEnvelopes 2025-04-15 17:55:04 +02:00
Anton Roslund
342c8dc87a collect ok-to-mqtt falg 2025-04-15 17:27:15 +02:00
Anton Roslund
ff8bb07f7f Capture firmware version based on bitfield 2025-04-14 19:33:03 +02:00
Anton Roslund
9411c9e4cc remove LoRa region from sidebar and tooltip 2025-04-14 17:31:45 +02:00
Anton Roslund
7ab62a9968 Don't distinguish mqtt nodes with color 2025-04-14 17:21:33 +02:00
Anton Roslund
a3c4667f34 Use self reported height for terrain graph 2025-04-13 08:30:44 +02:00
Anton Roslund
700ee298c4 Get PortNums from protobufs 2025-04-09 10:03:46 +02:00
Anton Roslund
4c6bccc058 filter map reports from portnum-counts 2025-04-07 21:41:06 +02:00
Anton Roslund
cdcefee641 add portnum-counts stats api 2025-04-07 20:52:59 +02:00
Anton Roslund
6e9d425bda add most active nodes stats 2025-03-31 21:11:49 +02:00
Anton Roslund
984771925f don't sort it 2025-03-31 18:59:47 +02:00
Anton Roslund
cadc78d1d4 fix hardware stats API 2025-03-30 16:43:20 +02:00
Anton Roslund
fd36e1b0a2 Add position-precision api 2025-03-30 16:17:51 +02:00
Anton Roslund
c3c92b47f1 Move stats api to separate file 2025-03-30 16:17:26 +02:00
Anton Roslund
48e25dd352 add add/v1/messages-per-hour 2025-03-27 08:38:47 +01:00
Anton Roslund
708451b027 Add CROS headeders to /api requests 2025-03-27 08:32:04 +01:00
Anton Roslund
37e54c76a6 Add openssl to dockerfile 2025-03-27 08:31:16 +01:00
Anton Roslund
b319115fd2 Fix unnecessary line break 2025-03-18 22:33:33 +01:00
Anton Roslund
1eb0b7eeea Add yellow color to snr 2025-03-16 11:32:15 +01:00
Anton Roslund
67fc07d326 Fix terrain tooltip 2025-03-16 11:27:48 +01:00
Anton Roslund
24df50889d Cleanup terrain graphs 2025-03-16 11:02:41 +01:00
Anton Roslund
1f93c8eb9e Add analytics 2025-03-16 11:02:27 +01:00
Anton Roslund
566b8d6086 Add 30d Graphs 2025-03-09 09:40:42 +01:00
Anton Roslund
2b6156ff07 Default to showing all nodes on map, no clustering 2025-03-09 09:28:14 +01:00
Anton Roslund
3c12e5fbe9 Style header 2025-03-09 09:27:43 +01:00
Anton Roslund
ec8093a1ca whitespace 2025-03-05 00:14:39 +01:00
Anton Roslund
55c7a45060 Default to zoom over Stockholm 2025-03-05 00:10:56 +01:00
Anton Roslund
471d8dded6 Update info 2025-03-05 00:10:30 +01:00
Anton Roslund
045008a9df Remove useless buttons 2025-03-05 00:09:46 +01:00
Anton Roslund
fd50a4adeb Reskinn 2025-03-05 00:09:07 +01:00
Anton Roslund
efdf5f850d Merge branch 'liamcottle-master' 2025-01-27 19:02:16 +01:00
Anton Roslund
15bd3ebdc8 Merge 2025-01-27 19:00:02 +01:00
Roslund
a73d858ebd Remove Service Announcement 2024-11-11 19:27:36 +01:00
Roslund
af92253092 Remove neighbourlines mqtt warning 2024-11-11 19:26:03 +01:00
Roslund
7e5a026987 Remove analytics 2024-11-11 19:24:27 +01:00
52 changed files with 7283 additions and 4555 deletions

13
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,13 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: daily
time: '20:00'
open-pull-requests-limit: 10

2
.gitignore vendored
View file

@ -2,5 +2,3 @@
node_modules
# Keep environment variables out of version control
.env
src/external

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "protobufs"]
path = src/protobufs
url = https://github.com/meshtastic/protobufs.git

View file

@ -1,16 +1,29 @@
FROM node:lts-alpine
FROM node:lts-alpine AS build
RUN apk add --no-cache openssl
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
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
# Copy the rest of your source
COPY . .
# Pre-generate prisma client
RUN node_modules/.bin/prisma generate
FROM node:lts-alpine
RUN apk add --no-cache openssl
USER node:node
WORKDIR /app
COPY --from=build --chown=node:node /app .
EXPOSE 8080

View file

@ -65,6 +65,11 @@ git clone https://github.com/liamcottle/meshtastic-map
cd meshtastic-map
```
Install Meshtastic protobufs definitions
```
git clone https://github.com/meshtastic/protobufs src/protobufs
```
Install NodeJS dependencies
```
@ -122,9 +127,6 @@ You will now need to restart the `index.js` and `mqtt.js` scripts.
## MQTT Collector
> Please note, due to the Meshtastic protobuf schema files being locked under a GPLv3 license, these are not provided in this MIT licensed project.
You will need to obtain these files yourself to be able to use the MQTT Collector.
By default, the [MQTT Collector](./src/mqtt.js) connects to the public Meshtastic MQTT server.
Alternatively, you may provide the relevant options shown in the help section below to connect to your own MQTT server along with your own decryption keys.

View file

@ -30,6 +30,18 @@ services:
DATABASE_URL: "mysql://root:password@database:3306/meshtastic-map?connection_limit=100"
MAP_OPTS: "" # add any custom index.js options here
# publishes mqtt packets via websocket
meshtastic-ws:
container_name: meshtastic-ws
build:
context: .
dockerfile: ./Dockerfile
command: /app/docker/ws.sh
ports:
- 8081:8081/tcp
environment:
WS_OPTS: ""
# runs the database to store everything from mqtt
database:
container_name: database

5
docker/ws.sh Executable file
View file

@ -0,0 +1,5 @@
#!/bin/sh
echo "Starting websocket publisher"
exec node src/ws.js ${WS_OPTS}

4203
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,16 +9,18 @@
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/client": "^5.11.0",
"command-line-args": "^5.2.1",
"command-line-usage": "^7.0.1",
"compression": "^1.7.4",
"express": "^5.0.0",
"mqtt": "^5.3.6",
"protobufjs": "^7.2.6"
"@prisma/client": "^6.16.2",
"command-line-args": "^6.0.1",
"command-line-usage": "^7.0.3",
"compression": "^1.8.1",
"cors": "^2.8.5",
"express": "^5.2.1",
"mqtt": "^5.14.1",
"protobufjs": "^7.5.4",
"ws": "^8.18.3"
},
"devDependencies": {
"jest": "^29.7.0",
"prisma": "^5.10.2"
"jest": "^30.1.3",
"prisma": "^6.16.2"
}
}

View file

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

View file

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

View file

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE `service_envelopes` ADD COLUMN `packet_id` BIGINT NULL;
-- CreateIndex
CREATE INDEX `service_envelopes_packet_id_idx` ON `service_envelopes`(`packet_id`);

View file

@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE `battery_stats` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`recorded_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
`avg_battery_level` DECIMAL(5, 2) NULL,
INDEX `battery_stats_recorded_at_idx`(`recorded_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `channel_utilization_stats` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`recorded_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
`avg_channel_utilization` DECIMAL(65, 30) NULL,
INDEX `channel_utilization_stats_recorded_at_idx`(`recorded_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View file

@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE `name_history` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`node_id` BIGINT NOT NULL,
`long_name` VARCHAR(191) NOT NULL,
`short_name` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `name_history_node_id_idx`(`node_id`),
INDEX `name_history_long_name_idx`(`long_name`),
INDEX `name_history_created_at_idx`(`created_at`),
INDEX `name_history_updated_at_idx`(`updated_at`),
UNIQUE INDEX `name_history_node_id_long_name_short_name_key`(`node_id`, `long_name`, `short_name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View file

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

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

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `nodes` ADD COLUMN `channel_id` VARCHAR(191) NULL;

View file

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE `channel_utilization_stats` ADD COLUMN `channel_id` VARCHAR(191) NULL;
-- CreateIndex
CREATE INDEX `channel_utilization_stats_channel_id_idx` ON `channel_utilization_stats`(`channel_id`);

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `text_messages` ADD COLUMN `ok_to_mqtt` BOOLEAN NULL;

View file

@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE `edges` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`from_node_id` BIGINT NOT NULL,
`to_node_id` BIGINT NOT NULL,
`snr` INTEGER NOT NULL,
`from_latitude` INTEGER NULL,
`from_longitude` INTEGER NULL,
`to_latitude` INTEGER NULL,
`to_longitude` INTEGER NULL,
`packet_id` BIGINT NOT NULL,
`channel_id` VARCHAR(191) NULL,
`gateway_id` BIGINT NULL,
`source` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `edges_from_node_id_idx`(`from_node_id`),
INDEX `edges_to_node_id_idx`(`to_node_id`),
INDEX `edges_created_at_idx`(`created_at`),
INDEX `edges_from_node_id_to_node_id_idx`(`from_node_id`, `to_node_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

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?
@ -51,6 +53,12 @@ model Node {
// this column tracks when an mqtt gateway node uplinked a packet
mqtt_connection_state_updated_at DateTime?
ok_to_mqtt Boolean?
is_backbone Boolean?
max_hops Int?
channel_id String?
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
@ -202,6 +210,8 @@ model ServiceEnvelope {
gateway_id BigInt?
to BigInt
from BigInt
portnum Int?
packet_id BigInt?
protobuf Bytes
created_at DateTime @default(now())
@ -210,6 +220,7 @@ model ServiceEnvelope {
@@index(created_at)
@@index(updated_at)
@@index(gateway_id)
@@index(packet_id)
@@map("service_envelopes")
}
@ -228,6 +239,7 @@ model TextMessage {
rx_snr Decimal?
rx_rssi Int?
hop_limit Int?
ok_to_mqtt Boolean?
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
@ -296,3 +308,67 @@ model Waypoint {
@@index(gateway_id)
@@map("waypoints")
}
model NameHistory {
id BigInt @id @default(autoincrement())
node_id BigInt
long_name String
short_name String
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
@@index(node_id)
@@index(long_name)
@@index(created_at)
@@index(updated_at)
@@map("name_history")
// We only want to keep track of unique name and node_id combinations
@@unique([node_id, long_name, short_name])
}
model BatteryStats {
id BigInt @id @default(autoincrement())
recorded_at DateTime? @default(now())
avg_battery_level Decimal? @db.Decimal(5, 2)
@@index([recorded_at])
@@map("battery_stats")
}
model ChannelUtilizationStats {
id BigInt @id @default(autoincrement())
recorded_at DateTime? @default(now())
avg_channel_utilization Decimal?
channel_id String?
@@index([channel_id])
@@index([recorded_at])
@@map("channel_utilization_stats")
}
model Edge {
id BigInt @id @default(autoincrement())
from_node_id BigInt
to_node_id BigInt
snr Int
from_latitude Int?
from_longitude Int?
to_latitude Int?
to_longitude Int?
packet_id BigInt
channel_id String?
gateway_id BigInt?
source String
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
@@index(from_node_id)
@@index(to_node_id)
@@index(created_at)
@@index([from_node_id, to_node_id])
@@map("edges")
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 5.4 MiB

Before After
Before After

View file

@ -1,6 +1,7 @@
// node src/admin.js --purge-node-id 123
// node src/admin.js --purge-node-id '!AABBCCDD'
require('./utils/logger');
const commandLineArgs = require("command-line-args");
const commandLineUsage = require("command-line-usage");

View file

@ -1,9 +1,13 @@
const fs = require("fs");
require('./utils/logger');
const path = require('path');
const express = require('express');
const compression = require('compression');
const protobufjs = require("protobufjs");
const commandLineArgs = require("command-line-args");
const commandLineUsage = require("command-line-usage");
const cors = require('cors');
const statsRoutes = require('./stats.js');
// create prisma db client
const { PrismaClient } = require("@prisma/client");
@ -50,21 +54,24 @@ if(options.help){
// get options and fallback to default values
const port = options["port"] ?? 8080;
// load json
const hardwareModels = JSON.parse(fs.readFileSync(path.join(__dirname, "json/hardware_models.json"), "utf-8"));
const roles = JSON.parse(fs.readFileSync(path.join(__dirname, "json/roles.json"), "utf-8"));
const regionCodes = JSON.parse(fs.readFileSync(path.join(__dirname, "json/region_codes.json"), "utf-8"));
const modemPresets = JSON.parse(fs.readFileSync(path.join(__dirname, "json/modem_presets.json"), "utf-8"));
// load protobufs
const root = new protobufjs.Root();
root.resolvePath = (origin, target) => path.join(__dirname, "protobufs", target);
root.loadSync('meshtastic/mqtt.proto');
const HardwareModel = root.lookupEnum("HardwareModel");
const Role = root.lookupEnum("Config.DeviceConfig.Role");
const RegionCode = root.lookupEnum("Config.LoRaConfig.RegionCode");
const ModemPreset = root.lookupEnum("Config.LoRaConfig.ModemPreset");
// appends extra info for node objects returned from api
function formatNodeInfo(node) {
return {
...node,
node_id_hex: "!" + node.node_id.toString(16),
hardware_model_name: hardwareModels[node.hardware_model] ?? null,
role_name: roles[node.role] ?? null,
region_name: regionCodes[node.region] ?? null,
modem_preset_name: modemPresets[node.modem_preset] ?? null,
hardware_model_name: HardwareModel.valuesById[node.hardware_model] ?? null,
role_name: Role.valuesById[node.role] ?? null,
region_name: RegionCode.valuesById[node.region] ?? null,
modem_preset_name: ModemPreset.valuesById[node.modem_preset] ?? null,
};
}
@ -73,6 +80,9 @@ const app = express();
// enable compression
app.use(compression());
// Apply CORS only to API routes
app.use('/api', cors());
// serve files inside the public folder from /
app.use('/', express.static(path.join(__dirname, 'public')));
@ -80,6 +90,9 @@ app.get('/', async (req, res) => {
res.sendFile(path.join(__dirname, 'public/index.html'));
});
// stats API in separate file
app.use('/api/v1/stats', statsRoutes);
app.get('/api', async (req, res) => {
const links = [
@ -134,6 +147,23 @@ 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/connections",
"description": "Aggregated edges between nodes from traceroutes",
"params": {
"node_id": "Only include connections involving this node id",
"time_from": "Only include edges created after this unix timestamp (milliseconds)",
"time_to": "Only include edges created before this unix timestamp (milliseconds)"
}
},
{
"path": "/api/v1/nodes/:nodeId/position-history",
"description": "Position history for a meshtastic node",
@ -199,6 +229,10 @@ app.get('/api/v1/nodes', async (req, res) => {
where: {
role: role,
hardware_model: hardwareModel,
// Since we removed retention; only include nodes that have been updated in the last 30 days
updated_at: {
gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // within last 30 days
}
},
});
@ -554,6 +588,313 @@ 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.",
});
}
});
// Aggregated edges endpoint
// GET /api/v1/connections?node_id=...&time_from=...&time_to=...
app.get('/api/v1/connections', async (req, res) => {
try {
const nodeId = req.query.node_id ? parseInt(req.query.node_id) : undefined;
const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : undefined;
const timeTo = req.query.time_to ? parseInt(req.query.time_to) : undefined;
// Query edges from database
const edges = await prisma.edge.findMany({
where: {
created_at: {
...(timeFrom && { gte: new Date(timeFrom) }),
...(timeTo && { lte: new Date(timeTo) }),
},
// Only include edges where both nodes have positions
from_latitude: { not: null },
from_longitude: { not: null },
to_latitude: { not: null },
to_longitude: { not: null },
// If node_id is provided, filter edges where either from_node_id or to_node_id matches
...(nodeId !== undefined && {
OR: [
{ from_node_id: nodeId },
{ to_node_id: nodeId },
],
}),
},
orderBy: [
{ created_at: 'desc' },
{ packet_id: 'desc' },
],
});
// Collect all unique node IDs from edges
const nodeIds = new Set();
for (const edge of edges) {
nodeIds.add(edge.from_node_id);
nodeIds.add(edge.to_node_id);
}
// Fetch current positions for all nodes
const nodes = await prisma.node.findMany({
where: {
node_id: { in: Array.from(nodeIds) },
},
select: {
node_id: true,
latitude: true,
longitude: true,
},
});
// Create a map of current node positions
const nodePositions = new Map();
for (const node of nodes) {
nodePositions.set(node.node_id, {
latitude: node.latitude,
longitude: node.longitude,
});
}
// Filter edges: only include edges where both nodes are still at the same location
const validEdges = edges.filter(edge => {
const fromCurrentPos = nodePositions.get(edge.from_node_id);
const toCurrentPos = nodePositions.get(edge.to_node_id);
// Skip if either node doesn't exist or doesn't have a current position
if (!fromCurrentPos || !toCurrentPos ||
fromCurrentPos.latitude === null || fromCurrentPos.longitude === null ||
toCurrentPos.latitude === null || toCurrentPos.longitude === null) {
return false;
}
// Check if stored positions match current positions
const fromMatches = fromCurrentPos.latitude === edge.from_latitude &&
fromCurrentPos.longitude === edge.from_longitude;
const toMatches = toCurrentPos.latitude === edge.to_latitude &&
toCurrentPos.longitude === edge.to_longitude;
return fromMatches && toMatches;
});
// Normalize node pairs: always use min/max to treat A->B and B->A as same connection
const connectionsMap = new Map();
for (const edge of validEdges) {
const nodeA = edge.from_node_id < edge.to_node_id ? edge.from_node_id : edge.to_node_id;
const nodeB = edge.from_node_id < edge.to_node_id ? edge.to_node_id : edge.from_node_id;
const key = `${nodeA}-${nodeB}`;
if (!connectionsMap.has(key)) {
connectionsMap.set(key, {
node_a: nodeA,
node_b: nodeB,
direction_ab: [], // A -> B edges
direction_ba: [], // B -> A edges
});
}
const connection = connectionsMap.get(key);
const isAB = edge.from_node_id === nodeA;
// Add edge to appropriate direction
if (isAB) {
connection.direction_ab.push({
snr: edge.snr,
snr_db: edge.snr / 4, // Convert to dB
created_at: edge.created_at,
packet_id: edge.packet_id,
source: edge.source,
});
} else {
connection.direction_ba.push({
snr: edge.snr,
snr_db: edge.snr / 4,
created_at: edge.created_at,
packet_id: edge.packet_id,
source: edge.source,
});
}
}
// Aggregate each connection
const connections = Array.from(connectionsMap.values()).map(conn => {
// Deduplicate edges by packet_id for each direction (keep first occurrence, which is most recent)
const dedupeByPacketId = (edges) => {
const seen = new Set();
return edges.filter(edge => {
if (seen.has(edge.packet_id)) {
return false;
}
seen.add(edge.packet_id);
return true;
});
};
const deduplicatedAB = dedupeByPacketId(conn.direction_ab);
const deduplicatedBA = dedupeByPacketId(conn.direction_ba);
// Calculate average SNR for A->B (using deduplicated edges)
const avgSnrAB = deduplicatedAB.length > 0
? deduplicatedAB.reduce((sum, e) => sum + e.snr_db, 0) / deduplicatedAB.length
: null;
// Calculate average SNR for B->A (using deduplicated edges)
const avgSnrBA = deduplicatedBA.length > 0
? deduplicatedBA.reduce((sum, e) => sum + e.snr_db, 0) / deduplicatedBA.length
: null;
// Get last 5 edges for each direction (already sorted by created_at DESC, packet_id DESC, now deduplicated)
const last5AB = deduplicatedAB.slice(0, 5);
const last5BA = deduplicatedBA.slice(0, 5);
// Determine worst average SNR
const worstAvgSnrDb = [avgSnrAB, avgSnrBA]
.filter(v => v !== null)
.reduce((min, val) => val < min ? val : min, Infinity);
return {
node_a: conn.node_a,
node_b: conn.node_b,
direction_ab: {
avg_snr_db: avgSnrAB,
last_5_edges: last5AB,
total_count: deduplicatedAB.length, // Use deduplicated count
},
direction_ba: {
avg_snr_db: avgSnrBA,
last_5_edges: last5BA,
total_count: deduplicatedBA.length, // Use deduplicated count
},
worst_avg_snr_db: worstAvgSnrDb !== Infinity ? worstAvgSnrDb : null,
};
}).filter(conn => conn.worst_avg_snr_db !== null); // Only return connections with at least one direction
res.json({
connections: connections,
});
} 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 {
@ -643,42 +984,6 @@ app.get('/api/v1/nodes/:nodeId/position-history', async (req, res) => {
}
});
app.get('/api/v1/stats/hardware-models', async (req, res) => {
try {
// get nodes from db
const results = await prisma.node.groupBy({
by: ['hardware_model'],
orderBy: {
_count: {
hardware_model: 'desc',
},
},
_count: {
hardware_model: true,
},
});
const hardwareModelStats = results.map((result) => {
return {
count: result._count.hardware_model,
hardware_model: result.hardware_model,
hardware_model_name: hardwareModels[result.hardware_model] ?? "UNKNOWN",
};
});
res.json({
hardware_model_stats: hardwareModelStats,
});
} catch(err) {
console.error(err);
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
app.get('/api/v1/text-messages', async (req, res) => {
try {
@ -805,8 +1110,29 @@ app.get('/api/v1/waypoints', async (req, res) => {
}
});
// start express server
const listener = app.listen(port, () => {
const port = listener.address().port;
console.log(`Server running at http://127.0.0.1:${port}`);
});
// Graceful shutdown handlers
function gracefulShutdown(signal) {
console.log(`Received ${signal}. Starting graceful shutdown...`);
// Stop accepting new connections
listener.close(async (err) => {
console.log('HTTP server closed');
await prisma.$disconnect();
console.log('Database connections closed');
console.log('Graceful shutdown completed');
process.exit(0);
});
}
// Handle SIGTERM (Docker, systemd, etc.)
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
// Handle SIGINT (Ctrl+C)
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

View file

@ -1,108 +0,0 @@
{
"0": "UNSET",
"1": "TLORA_V2",
"2": "TLORA_V1",
"3": "TLORA_V2_1_1P6",
"4": "TBEAM",
"5": "HELTEC_V2_0",
"6": "TBEAM_V0P7",
"7": "T_ECHO",
"8": "TLORA_V1_1P3",
"9": "RAK4631",
"10": "HELTEC_V2_1",
"11": "HELTEC_V1",
"12": "LILYGO_TBEAM_S3_CORE",
"13": "RAK11200",
"14": "NANO_G1",
"15": "TLORA_V2_1_1P8",
"16": "TLORA_T3_S3",
"17": "NANO_G1_EXPLORER",
"18": "NANO_G2_ULTRA",
"19": "LORA_TYPE",
"20": "WIPHONE",
"21": "WIO_WM1110",
"22": "RAK2560",
"23": "HELTEC_HRU_3601",
"24": "HELTEC_WIRELESS_BRIDGE",
"25": "STATION_G1",
"26": "RAK11310",
"27": "SENSELORA_RP2040",
"28": "SENSELORA_S3",
"29": "CANARYONE",
"30": "RP2040_LORA",
"31": "STATION_G2",
"32": "LORA_RELAY_V1",
"33": "NRF52840DK",
"34": "PPR",
"35": "GENIEBLOCKS",
"36": "NRF52_UNKNOWN",
"37": "PORTDUINO",
"38": "ANDROID_SIM",
"39": "DIY_V1",
"40": "NRF52840_PCA10059",
"41": "DR_DEV",
"42": "M5STACK",
"43": "HELTEC_V3",
"44": "HELTEC_WSL_V3",
"45": "BETAFPV_2400_TX",
"46": "BETAFPV_900_NANO_TX",
"47": "RPI_PICO",
"48": "HELTEC_WIRELESS_TRACKER",
"49": "HELTEC_WIRELESS_PAPER",
"50": "T_DECK",
"51": "T_WATCH_S3",
"52": "PICOMPUTER_S3",
"53": "HELTEC_HT62",
"54": "EBYTE_ESP32_S3",
"55": "ESP32_S3_PICO",
"56": "CHATTER_2",
"57": "HELTEC_WIRELESS_PAPER_V1_0",
"58": "HELTEC_WIRELESS_TRACKER_V1_0",
"59": "UNPHONE",
"60": "TD_LORAC",
"61": "CDEBYTE_EORA_S3",
"62": "TWC_MESH_V4",
"63": "NRF52_PROMICRO_DIY",
"64": "RADIOMASTER_900_BANDIT_NANO",
"65": "HELTEC_CAPSULE_SENSOR_V3",
"66": "HELTEC_VISION_MASTER_T190",
"67": "HELTEC_VISION_MASTER_E213",
"68": "HELTEC_VISION_MASTER_E290",
"69": "HELTEC_MESH_NODE_T114",
"70": "SENSECAP_INDICATOR",
"71": "TRACKER_T1000_E",
"72": "RAK3172",
"73": "WIO_E5",
"74": "RADIOMASTER_900_BANDIT",
"75": "ME25LS01_4Y10TD",
"76": "RP2040_FEATHER_RFM95",
"77": "M5STACK_COREBASIC",
"78": "M5STACK_CORE2",
"79": "RPI_PICO2",
"80": "M5STACK_CORES3",
"81": "SEEED_XIAO_S3",
"82": "MS24SF1",
"83": "TLORA_C6",
"84": "WISMESH_TAP",
"85": "ROUTASTIC",
"86": "MESH_TAB",
"87": "MESHLINK",
"88": "XIAO_NRF52_KIT",
"89": "THINKNODE_M1",
"90": "THINKNODE_M2",
"91": "T_ETH_ELITE",
"92": "HELTEC_SENSOR_HUB",
"93": "RESERVED_FRIED_CHICKEN",
"94": "HELTEC_MESH_POCKET",
"95": "SEEED_SOLAR_NODE",
"96": "NOMADSTAR_METEOR_PRO",
"97": "CROWPANEL",
"98": "LINK_32",
"99": "SEEED_WIO_TRACKER_L1",
"100": "SEEED_WIO_TRACKER_L1_EINK",
"101": "QWANTZ_TINY_ARMS",
"102": "T_DECK_PRO",
"103": "T_LORA_PAGER",
"104": "GAT562_MESH_TRIAL_TRACKER",
"255": "PRIVATE_HW"
}

View file

@ -1,11 +0,0 @@
{
"0": "LONG_FAST",
"1": "LONG_SLOW",
"2": "VERY_LONG_SLOW",
"3": "MEDIUM_SLOW",
"4": "MEDIUM_FAST",
"5": "SHORT_SLOW",
"6": "SHORT_FAST",
"7": "LONG_MODERATE",
"8": "SHORT_TURBO"
}

View file

@ -1,24 +0,0 @@
{
"0": "UNSET",
"1": "US",
"2": "EU_433",
"3": "EU_868",
"4": "CN",
"5": "JP",
"6": "ANZ",
"7": "KR",
"8": "TW",
"9": "RU",
"10": "IN",
"11": "NZ_865",
"12": "TH",
"13": "LORA_24",
"14": "UA_433",
"15": "UA_868",
"16": "MY_433",
"17": "MY_919",
"18": "SG_923",
"19": "PH_433",
"20": "PH_868",
"21": "PH_915"
}

View file

@ -1,14 +0,0 @@
{
"0": "CLIENT",
"1": "CLIENT_MUTE",
"2": "ROUTER",
"3": "ROUTER_CLIENT",
"4": "REPEATER",
"5": "TRACKER",
"6": "SENSOR",
"7": "TAK",
"8": "CLIENT_HIDDEN",
"9": "LOST_AND_FOUND",
"10": "TAK_TRACKER",
"11": "ROUTER_LATE"
}

View file

@ -1,4 +1,4 @@
const fs = require("fs");
require('./utils/logger');
const crypto = require("crypto");
const path = require("path");
const mqtt = require("mqtt");
@ -22,11 +22,6 @@ const optionsList = [
type: Boolean,
description: 'Display this usage guide.'
},
{
name: "protobufs-path",
type: String,
description: "Path to Protobufs (e.g: ../../protobufs)",
},
{
name: "mqtt-broker-url",
type: String,
@ -212,7 +207,6 @@ if(options.help){
}
// get options and fallback to default values
const protobufsPath = options["protobufs-path"] ?? path.join(path.dirname(__filename), "external/protobufs");
const mqttBrokerUrl = options["mqtt-broker-url"] ?? "mqtt://mqtt.meshtastic.org";
const mqttUsername = options["mqtt-username"] ?? "meshdev";
const mqttPassword = options["mqtt-password"] ?? "large4cats";
@ -229,6 +223,7 @@ const collectNeighbourInfo = options["collect-neighbour-info"] ?? false;
const collectMapReports = options["collect-map-reports"] ?? false;
const decryptionKeys = options["decryption-keys"] ?? [
"1PG7OiApB1nwvP+rz05pAQ==", // add default "AQ==" decryption key
"PjG/mVAqnannyvqmuYAwd0LZa1AV+wkcUQlacmexEXY=", // Årsta mesh? länkad av [x/0!] divideByZero i meshen
];
const dropPacketsNotOkToMqtt = options["drop-packets-not-ok-to-mqtt"] ?? false;
const dropPortnumsWithoutBitfield = options["drop-portnums-without-bitfield"] ?? null;
@ -247,25 +242,6 @@ const purgeTextMessagesAfterSeconds = options["purge-text-messages-after-seconds
const purgeTraceroutesAfterSeconds = options["purge-traceroutes-after-seconds"] ?? null;
const purgeWaypointsAfterSeconds = options["purge-waypoints-after-seconds"] ?? null;
// ensure protobufs exist
if(!fs.existsSync(path.join(protobufsPath, "meshtastic/mqtt.proto"))){
console.error([
"ERROR: MQTT Collector requires Meshtastic protobufs.",
"",
"This project is licensed under the MIT license to allow end users to do as they wish.",
"Unfortunately, the Meshtastic protobuf schema files are licensed under GPLv3, which means they can not be bundled in this project due to license conflicts.",
"https://github.com/liamcottle/meshtastic-map/issues/102",
"https://github.com/meshtastic/protobufs/issues/695",
"",
"If you clone and install the Meshtastic protobufs as described below, your use of those files will be subject to the GPLv3 license.",
"This does not change the license of this project being MIT. Only the parts you add from the Meshtastic project are covered under GPLv3.",
"",
"To use the MQTT Collector, please clone the Meshtastic protobufs into src/external/protobufs",
"git clone https://github.com/meshtastic/protobufs src/external/protobufs",
].join("\n"));
return;
}
// create mqtt client
const client = mqtt.connect(mqttBrokerUrl, {
username: mqttUsername,
@ -275,7 +251,7 @@ const client = mqtt.connect(mqttBrokerUrl, {
// load protobufs
const root = new protobufjs.Root();
root.resolvePath = (origin, target) => path.join(protobufsPath, target);
root.resolvePath = (origin, target) => path.join(__dirname, "protobufs", target);
root.loadSync('meshtastic/mqtt.proto');
const Data = root.lookupType("Data");
const ServiceEnvelope = root.lookupType("ServiceEnvelope");
@ -288,8 +264,9 @@ const User = root.lookupType("User");
const Waypoint = root.lookupType("Waypoint");
// run automatic purge if configured
let purgeInterval = null;
if(purgeIntervalSeconds){
setInterval(async () => {
purgeInterval = setInterval(async () => {
await purgeUnheardNodes();
await purgeOldDeviceMetrics();
await purgeOldEnvironmentMetrics();
@ -766,6 +743,13 @@ client.on("message", async (topic, message) => {
}
// 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);
}
// create service envelope in db
if(collectServiceEnvelopes){
try {
@ -776,6 +760,8 @@ client.on("message", async (topic, message) => {
gateway_id: envelope.gatewayId ? convertHexIdToNumericId(envelope.gatewayId) : null,
to: envelope.packet.to,
from: envelope.packet.from,
portnum: portnum,
packet_id: envelope.packet.id,
protobuf: message,
},
});
@ -840,6 +826,7 @@ client.on("message", async (topic, message) => {
rx_snr: envelope.packet.rxSnr,
rx_rssi: envelope.packet.rxRssi,
hop_limit: envelope.packet.hopLimit,
ok_to_mqtt: isOkToMqtt,
},
});
} catch (e) {
@ -961,6 +948,19 @@ client.on("message", async (topic, message) => {
hardware_model: user.hwModel,
is_licensed: user.isLicensed === true,
role: user.role,
is_unmessagable: user.isUnmessagable,
ok_to_mqtt: isOkToMqtt,
max_hops: envelope.packet.hopStart,
channel_id: envelope.channelId,
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,
@ -968,12 +968,57 @@ client.on("message", async (topic, message) => {
hardware_model: user.hwModel,
is_licensed: user.isLicensed === true,
role: user.role,
is_unmessagable: user.isUnmessagable,
ok_to_mqtt: isOkToMqtt,
max_hops: envelope.packet.hopStart,
channel_id: envelope.channelId,
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',
}),
},
});
} catch (e) {
// Ignore MySQL error 1020 "Record has changed since last read" - this is a race condition
// that occurs when multiple packets arrive concurrently for the same node
const errorMessage = e.message || String(e);
if (!errorMessage.includes('Record has changed since last read')) {
console.error(e);
}
}
// Keep track of the names a node has been using.
try {
await prisma.NameHistory.upsert({
where: {
node_id_long_name_short_name: {
node_id: envelope.packet.from,
long_name: user.longName,
short_name: user.shortName,
}
},
create: {
node_id: envelope.packet.from,
long_name: user.longName,
short_name: user.shortName,
},
update: {
updated_at: new Date(),
}
});
} catch (e) {
// Ignore MySQL error 1020 "Record has changed since last read" - this is a race condition
// that occurs when multiple packets arrive concurrently for the same node
const errorMessage = e.message || String(e);
if (!errorMessage.includes('Record has changed since last read')) {
console.error(e);
}
}
}
else if(portnum === 8) {
@ -1049,6 +1094,70 @@ client.on("message", async (topic, message) => {
console.error(e);
}
// Extract edges from neighbour info
try {
const toNodeId = envelope.packet.from;
const neighbors = neighbourInfo.neighbors || [];
const packetId = envelope.packet.id;
const channelId = envelope.channelId;
const gatewayId = envelope.gatewayId ? convertHexIdToNumericId(envelope.gatewayId) : null;
const edgesToCreate = [];
for(const neighbour of neighbors) {
// Skip if no node ID
if(!neighbour.nodeId) {
continue;
}
// Skip if SNR is invalid (0 or null/undefined)
// Note: SNR can be negative, so we check for 0 specifically
if(neighbour.snr === 0 || neighbour.snr == null) {
continue;
}
const fromNodeId = neighbour.nodeId;
const snr = neighbour.snr;
// Fetch node positions from Node table
const [fromNode, toNode] = await Promise.all([
prisma.node.findUnique({
where: { node_id: fromNodeId },
select: { latitude: true, longitude: true },
}),
prisma.node.findUnique({
where: { node_id: toNodeId },
select: { latitude: true, longitude: true },
}),
]);
// Create edge record
edgesToCreate.push({
from_node_id: fromNodeId,
to_node_id: toNodeId,
snr: snr,
from_latitude: fromNode?.latitude ?? null,
from_longitude: fromNode?.longitude ?? null,
to_latitude: toNode?.latitude ?? null,
to_longitude: toNode?.longitude ?? null,
packet_id: packetId,
channel_id: channelId,
gateway_id: gatewayId,
source: "NEIGHBORINFO_APP",
});
}
// Bulk insert edges
if(edgesToCreate.length > 0) {
await prisma.edge.createMany({
data: edgesToCreate,
skipDuplicates: true, // Skip if exact duplicate exists
});
}
} catch (e) {
// Log error but don't crash - edge extraction is non-critical
console.error("Error extracting edges from neighbour info:", e);
}
// don't store all neighbour infos, but we want to update the existing node above
if(!collectNeighbourInfo){
return;
@ -1291,6 +1400,160 @@ client.on("message", async (topic, message) => {
console.error(e);
}
// Extract edges from traceroute (only for response packets)
if(!envelope.packet.decoded.wantResponse) {
try {
const route = routeDiscovery.route || [];
const snrTowards = routeDiscovery.snrTowards || [];
const originNodeId = envelope.packet.to;
const destinationNodeId = envelope.packet.from;
const packetId = envelope.packet.id;
const channelId = envelope.channelId;
const gatewayId = envelope.gatewayId ? convertHexIdToNumericId(envelope.gatewayId) : null;
// Determine number of edges: route.length + 1
const numEdges = route.length + 1;
const edgesToCreate = [];
// Extract edges from the route path
for(let i = 0; i < numEdges; i++) {
// Get SNR for this edge
if(i >= snrTowards.length) {
// Array length mismatch - skip this edge
continue;
}
const snr = snrTowards[i];
// Skip if SNR is -128 (no SNR recorded)
if(snr === -128) {
continue;
}
// Determine from_node and to_node
let fromNodeId, toNodeId;
if(route.length === 0) {
// Empty route: direct connection (to -> from)
fromNodeId = originNodeId;
toNodeId = destinationNodeId;
} else if(i === 0) {
// First edge: origin -> route[0]
fromNodeId = originNodeId;
toNodeId = route[0];
} else if(i === route.length) {
// Last edge: route[route.length-1] -> destination
fromNodeId = route[route.length - 1];
toNodeId = destinationNodeId;
} else {
// Middle edge: route[i-1] -> route[i]
fromNodeId = route[i - 1];
toNodeId = route[i];
}
// Fetch node positions from Node table
const [fromNode, toNode] = await Promise.all([
prisma.node.findUnique({
where: { node_id: fromNodeId },
select: { latitude: true, longitude: true },
}),
prisma.node.findUnique({
where: { node_id: toNodeId },
select: { latitude: true, longitude: true },
}),
]);
// Create edge record (skip if nodes don't exist, but still create edge with null positions)
edgesToCreate.push({
from_node_id: fromNodeId,
to_node_id: toNodeId,
snr: snr,
from_latitude: fromNode?.latitude ?? null,
from_longitude: fromNode?.longitude ?? null,
to_latitude: toNode?.latitude ?? null,
to_longitude: toNode?.longitude ?? null,
packet_id: packetId,
channel_id: channelId,
gateway_id: gatewayId,
source: "TRACEROUTE_APP",
});
}
// Extract edges from route_back path
const routeBack = routeDiscovery.routeBack || [];
const snrBack = routeDiscovery.snrBack || [];
if(routeBack.length > 0) {
// Number of edges in route_back equals route_back.length
for(let i = 0; i < routeBack.length; i++) {
// Get SNR for this edge
if(i >= snrBack.length) {
// Array length mismatch - skip this edge
continue;
}
const snr = snrBack[i];
// Skip if SNR is -128 (no SNR recorded)
if(snr === -128) {
continue;
}
// Determine from_node and to_node
let fromNodeId, toNodeId;
if(i === 0) {
// First edge: from -> route_back[0]
fromNodeId = destinationNodeId; // 'from' in the packet
toNodeId = routeBack[0];
} else {
// Subsequent edges: route_back[i-1] -> route_back[i]
fromNodeId = routeBack[i - 1];
toNodeId = routeBack[i];
}
// Fetch node positions from Node table
const [fromNode, toNode] = await Promise.all([
prisma.node.findUnique({
where: { node_id: fromNodeId },
select: { latitude: true, longitude: true },
}),
prisma.node.findUnique({
where: { node_id: toNodeId },
select: { latitude: true, longitude: true },
}),
]);
// Create edge record
edgesToCreate.push({
from_node_id: fromNodeId,
to_node_id: toNodeId,
snr: snr,
from_latitude: fromNode?.latitude ?? null,
from_longitude: fromNode?.longitude ?? null,
to_latitude: toNode?.latitude ?? null,
to_longitude: toNode?.longitude ?? null,
packet_id: packetId,
channel_id: channelId,
gateway_id: gatewayId,
source: "TRACEROUTE_APP",
});
}
}
// Bulk insert edges
if(edgesToCreate.length > 0) {
await prisma.edge.createMany({
data: edgesToCreate,
skipDuplicates: true, // Skip if exact duplicate exists
});
}
} catch (e) {
// Log error but don't crash - edge extraction is non-critical
console.error("Error extracting edges from traceroute:", e);
}
}
}
else if(portnum === 73) {
@ -1395,6 +1658,7 @@ client.on("message", async (topic, message) => {
|| portnum === 0 // ignore UNKNOWN_APP
|| portnum === 1 // ignore TEXT_MESSAGE_APP
|| portnum === 5 // ignore ROUTING_APP
|| portnum === 6 // ignore ADMIN_APP
|| portnum === 34 // ignore PAXCOUNTER_APP
|| portnum === 65 // ignore STORE_FORWARD_APP
|| portnum === 66 // ignore RANGE_TEST_APP
@ -1411,6 +1675,32 @@ client.on("message", async (topic, message) => {
}
} catch(e) {
// ignore errors
console.log("error", e);
}
});
// Graceful shutdown handlers
function gracefulShutdown(signal) {
console.log(`Received ${signal}. Starting graceful shutdown...`);
// Clear the purge interval if it exists
if(purgeInterval) {
clearInterval(purgeInterval);
console.log('Purge interval cleared');
}
// Close MQTT client
client.end(false, async () => {
console.log('MQTT client disconnected');
await prisma.$disconnect();
console.log('Database connections closed');
console.log('Graceful shutdown completed');
process.exit(0);
});
}
// Handle SIGTERM (Docker, systemd, etc.)
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
// Handle SIGINT (Ctrl+C)
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

1
src/protobufs Submodule

@ -0,0 +1 @@
Subproject commit c2e45a3fc9cda6aedb72ad3b5b88fcccfa78073e

View file

@ -0,0 +1,115 @@
/* used to prevent ui flicker before vuejs loads */
[v-cloak] {
display: none;
}
.icon-longfast {
background-color: #009016;
border-radius: 25px;
border: 1px solid #2C2D3C;
}
.icon-mediumfast {
background-color: #326be7;
border-radius: 25px;
border: 1px solid #2C2D3C;
}
.icon-shortslow {
background-color: #0077e6;
border-radius: 25px;
border: 1px solid #2C2D3C;
}
.icon-mqtt-connected {
background-color: #2563eb; /* Change to use same color as disconnected // #16a34a; */
border-radius: 25px;
border: 1px solid #2C2D3C;
}
.icon-mqtt-disconnected {
background-color: #2563eb;
border-radius: 25px;
border: 1px solid #2C2D3C;
}
.icon-offline {
background-color: #e2286c;
border-radius: 25px;
border: 1px solid #2C2D3C;
}
.icon-position-history {
background-color: #a855f7;
border-radius: 25px;
border: 1px solid #2C2D3C;
}
.icon-traceroute-start {
background-color: #16a34a; /* green */
border-radius: 25px;
border: 1px solid #2C2D3C;
}
.icon-traceroute-end {
background-color: #dc2626; /* red */
border-radius: 25px;
border: 1px solid #2C2D3C;
}
.waypoint-label {
font-size: 26px;
background-color: transparent;
}
.link {
color: #2563eb;
}
.link:hover {
text-decoration: underline;
}
.tooltip {
position: relative;
display: inline-block;
}
.tooltip .tooltip-text {
visibility: hidden;
width: 80px;
background-color: black;
color: #fff;
text-align: center;
padding: 4px 0;
border-radius: 6px;
position: absolute;
z-index: 10000;
top: 100%;
left: 50%;
margin-top: 8px;
margin-left: -40px; /* Use half of the width (120/2 = 60), to center the tooltip */
}
.tooltip .tooltip-text::after {
content: " ";
position: absolute;
bottom: 100%; /* At the top of the tooltip */
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent transparent black transparent;
}
.tooltip:hover .tooltip-text {
visibility: visible;
}
.z-search {
z-index: 1001;
}
.z-sidebar {
z-index: 1002;
}

956
src/public/assets/js/app.js Normal file
View file

@ -0,0 +1,956 @@
Vue.createApp({
data() {
return {
isShowingAnnouncement: this.shouldShowAnnouncement(),
configNodesMaxAgeInSeconds: window.getConfigNodesMaxAgeInSeconds(),
configNodesOfflineAgeInSeconds: window.getConfigNodesOfflineAgeInSeconds(),
configWaypointsMaxAgeInSeconds: window.getConfigWaypointsMaxAgeInSeconds(),
configConnectionsMaxDistanceInMeters: window.getConfigConnectionsMaxDistanceInMeters(),
configZoomLevelGoToNode: window.getConfigZoomLevelGoToNode(),
configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(),
configEnableMapAnimations: window.getConfigEnableMapAnimations(),
configTemperatureFormat: window.getConfigTemperatureFormat(),
configConnectionsTimePeriodInSeconds: window.getConfigConnectionsTimePeriodInSeconds(),
configConnectionsColoredLines: window.getConfigConnectionsColoredLines(),
configConnectionsBidirectionalOnly: window.getConfigConnectionsBidirectionalOnly(),
configConnectionsMinSnrDb: window.getConfigConnectionsMinSnrDb(),
configConnectionsBidirectionalMinSnr: window.getConfigConnectionsBidirectionalMinSnr(),
isShowingHardwareModels: false,
hardwareModelStats: null,
isShowingInfoModal: this.shouldShowInfoModal(),
isShowingMobileSearch: false,
isShowingSettings: false,
nodes: [],
searchText: "",
selectedNode: null,
selectedNodeDeviceMetrics: [],
selectedNodeEnvironmentMetrics: [],
selectedNodePowerMetrics: [],
selectedNodeMqttMetrics: [],
selectedNodeTraceroutes: [],
deviceMetricsTimeRange: "7d",
environmentMetricsTimeRange: "7d",
powerMetricsTimeRange: "7d",
isPositionHistoryModalExpanded: true,
positionHistoryDateTimeFrom: null,
positionHistoryDateTimeTo: null,
selectedNodePositionHistory: [],
selectedNodeToShowPositionHistory: null,
selectedNodePositionHistoryMarkers: [],
selectedNodePositionHistoryPolyLines: [],
selectedTraceRoute: null,
tracerouteEdges: [],
selectedNodeToShowConnections: null,
moment: window.moment,
};
},
mounted: function() {
// load data
this.loadHardwareModelStats();
// handle map click callback from outside of vue
window._onMapClick = () => {
this.searchText = "";
this.isShowingMobileSearch = false;
};
// handle node callback from outside of vue
window._onNodeClick = (node) => {
this.selectedNode = node;
this.loadNodeDeviceMetrics(node.node_id);
this.loadNodeEnvironmentMetrics(node.node_id);
this.loadNodePowerMetrics(node.node_id);
this.loadNodeMqttMetrics(node.node_id);
this.loadNodeTraceroutes(node.node_id);
//this.loadNodePositionHistory(node.node_id);
};
// handle node callback from outside of vue
window._onShowNodeConnectionsClick = (node) => {
this.selectedNodeToShowConnections = node;
};
// handle nodes updated callback from outside of vue
window._onNodesUpdated = (nodes) => {
this.nodes = nodes;
};
},
methods: {
getAnnouncementId: function() {
// change this when making a new announcement
return "1";
},
shouldShowAnnouncement: function() {
const lastSeenAnnouncementId = window.localStorage.getItem("last-seen-announcement-id");
return lastSeenAnnouncementId?.toString() !== this.getAnnouncementId();
},
dismissAnnouncement: function() {
window.localStorage.setItem("last-seen-announcement-id", this.getAnnouncementId());
this.isShowingAnnouncement = false;
},
shouldShowInfoModal: function() {
return !window.getConfigHasSeenInfoModal()
&& !window.isMobile();
},
loadHardwareModelStats: function() {
window.axios.get('/api/v1/stats/hardware-models').then((response) => {
this.hardwareModelStats = response.data.hardware_model_stats;
}).catch((error) => {
// do nothing
});
},
loadNodeDeviceMetrics: function(nodeId) {
// calculate unix timestamps in milliseconds for supported time ranges
const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10);
// determine how long back to load device metrics from
var timeFrom = threeDaysAgoInMilliseconds;
switch(this.deviceMetricsTimeRange){
case "1d": {
timeFrom = oneDayAgoInMilliseconds;
break;
}
case "3d": {
timeFrom = threeDaysAgoInMilliseconds;
break;
}
case "7d": {
timeFrom = sevenDaysAgoInMilliseconds;
break;
}
case "30d": {
timeFrom = thirtyDaysAgoInMilliseconds;
break;
}
}
window.axios.get(`/api/v1/nodes/${nodeId}/device-metrics`, {
params: {
time_from: timeFrom,
},
}).then((response) => {
// reverse response, as it's newest to oldest, but we want oldest to newest
this.selectedNodeDeviceMetrics = response.data.device_metrics.reverse();
this.renderDeviceMetricCharts();
}).catch(() => {
this.selectedNodeDeviceMetrics = [];
this.renderDeviceMetricCharts();
});
},
loadNodeEnvironmentMetrics: function(nodeId) {
// calculate unix timestamps in milliseconds for supported time ranges
const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10);
// determine how long back to load environment metrics from
var timeFrom = threeDaysAgoInMilliseconds;
switch(this.environmentMetricsTimeRange){
case "1d": {
timeFrom = oneDayAgoInMilliseconds;
break;
}
case "3d": {
timeFrom = threeDaysAgoInMilliseconds;
break;
}
case "7d": {
timeFrom = sevenDaysAgoInMilliseconds;
break;
}
case "30d": {
timeFrom = thirtyDaysAgoInMilliseconds;
break;
}
}
window.axios.get(`/api/v1/nodes/${nodeId}/environment-metrics`, {
params: {
time_from: timeFrom,
},
}).then((response) => {
// reverse response, as it's newest to oldest, but we want oldest to newest
this.selectedNodeEnvironmentMetrics = response.data.environment_metrics.reverse();
this.renderEnvironmentMetricCharts();
}).catch(() => {
this.selectedNodeEnvironmentMetrics = [];
this.renderEnvironmentMetricCharts();
});
},
loadNodePowerMetrics: function(nodeId) {
// calculate unix timestamps in milliseconds for supported time ranges
const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10);
// determine how long back to load power metrics from
var timeFrom = threeDaysAgoInMilliseconds;
switch(this.powerMetricsTimeRange){
case "1d": {
timeFrom = oneDayAgoInMilliseconds;
break;
}
case "3d": {
timeFrom = threeDaysAgoInMilliseconds;
break;
}
case "7d": {
timeFrom = sevenDaysAgoInMilliseconds;
break;
}
case "30d": {
timeFrom = thirtyDaysAgoInMilliseconds;
break;
}
}
window.axios.get(`/api/v1/nodes/${nodeId}/power-metrics`, {
params: {
time_from: timeFrom,
},
}).then((response) => {
// reverse response, as it's newest to oldest, but we want oldest to newest
this.selectedNodePowerMetrics = response.data.power_metrics.reverse();
this.renderPowerMetricCharts();
}).catch(() => {
this.selectedNodePowerMetrics = [];
this.renderPowerMetricCharts();
});
},
loadNodeMqttMetrics: function(nodeId) {
this.selectedNodeMqttMetrics = [];
window.axios.get(`/api/v1/nodes/${nodeId}/mqtt-metrics`).then((response) => {
this.selectedNodeMqttMetrics = response.data.mqtt_metrics;
}).catch(() => {
// do nothing
});
},
loadNodeTraceroutes: function(nodeId) {
this.selectedNodeTraceroutes = [];
window.axios.get(`/api/v1/nodes/${nodeId}/traceroutes`, {
params: {
count: 5,
},
}).then((response) => {
this.selectedNodeTraceroutes = response.data.traceroutes;
}).catch(() => {
// 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() {
try {
this.updateDeviceMetricsChart();
} catch(e) {
console.log(e);
}
},
updateDeviceMetricsChart: function() {
// destroy existing chart
const chartElementId = "deviceMetricsChart";
const existingChart = window.Chart.getChart(chartElementId);
if(existingChart != null){
existingChart.destroy();
}
// get chart element
const chartElement = window.document.getElementById(chartElementId);
if(!chartElement){
return;
}
// create chart data
const labels = [];
const batteryMetrics = [];
const channelUtilizationMetrics = [];
const airUtilTxMetrics = [];
for(const deviceMetric of this.selectedNodeDeviceMetrics){
labels.push(moment(deviceMetric.created_at));
batteryMetrics.push(deviceMetric.battery_level);
channelUtilizationMetrics.push(deviceMetric.channel_utilization);
airUtilTxMetrics.push(deviceMetric.air_util_tx);
}
// create chart
new window.Chart(chartElement, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Battery Level',
borderColor: '#3b82f6',
backgroundColor: '#3b82f6',
pointStyle: false, // no points
fill: false,
data: batteryMetrics,
},
{
label: 'Channel Util',
borderColor: '#22c55e',
backgroundColor: '#22c55e',
showLine: false, // no lines between points
fill: false,
data: channelUtilizationMetrics,
},
{
label: 'Air Util TX',
borderColor: '#f97316',
backgroundColor: '#f97316',
showLine: false, // no lines between points
fill: false,
data: airUtilTxMetrics,
},
],
},
options: {
responsive: true,
borderWidth: 2,
elements: {
point: {
radius: 2,
},
},
scales: {
x: {
position: 'top',
type: 'time',
time: {
unit: 'day',
displayFormats: {
day: 'MMM DD', // Jan 01
},
},
},
y: {
min: 0,
max: 101, // 101 is "Plugged In", need to include for tooltip to work
ticks: {
callback: (label) => `${label}%`,
},
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
mode: "index",
intersect: false,
callbacks: {
label: (item) => {
return `${item.dataset.label}: ${item.formattedValue}%`;
},
},
},
},
}
});
},
renderEnvironmentMetricCharts: function() {
try {
this.updateEnvironmentMetricsChart();
} catch(e) {
console.log(e);
}
},
updateEnvironmentMetricsChart: function() {
// destroy existing chart
const chartElementId = "environmentMetricsChart";
const existingChart = window.Chart.getChart(chartElementId);
if(existingChart != null){
existingChart.destroy();
}
// get chart element
const chartElement = window.document.getElementById(chartElementId);
if(!chartElement){
return;
}
// create chart data
const labels = [];
const temperatureMetrics = [];
const relativeHumidityMetrics = [];
const barometricPressureMetrics = [];
const iaqMetrics = [];
for(const deviceMetric of this.selectedNodeEnvironmentMetrics){
labels.push(moment(deviceMetric.created_at));
temperatureMetrics.push(deviceMetric.temperature);
relativeHumidityMetrics.push(deviceMetric.relative_humidity);
barometricPressureMetrics.push(deviceMetric.barometric_pressure);
iaqMetrics.push(deviceMetric.iaq);
}
// create chart
new window.Chart(chartElement, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Temperature',
suffix: '°C',
borderColor: '#3b82f6',
backgroundColor: '#3b82f6',
pointStyle: false, // no points
fill: false,
data: temperatureMetrics,
yAxisID: 'y',
},
{
label: 'Humidity',
suffix: '%',
borderColor: '#22c55e',
backgroundColor: '#22c55e',
pointStyle: false, // no points
fill: false,
data: relativeHumidityMetrics,
yAxisID: 'y',
},
{
label: 'Pressure',
suffix: 'hPa',
borderColor: '#f97316',
backgroundColor: '#f97316',
pointStyle: false, // no points
fill: false,
data: barometricPressureMetrics,
yAxisID: 'y1',
},
{
label: 'IAQ',
suffix: 'IAQ',
borderColor: '#f472b6',
backgroundColor: '#f472b6',
pointStyle: false, // no points
fill: false,
data: iaqMetrics,
yAxisID: 'yIAQ',
},
],
},
options: {
responsive: true,
borderWidth: 2,
spanGaps: 1000 * 60 * 60 * 24, // only show lines between metrics with a 24 hour or less gap
elements: {
point: {
radius: 2,
},
},
scales: {
x: {
position: 'top',
type: 'time',
time: {
unit: 'day',
displayFormats: {
day: 'MMM DD', // Jan 01
},
},
},
y: {
min: -20,
max: 100,
},
y1: {
min: 800,
max: 1100,
ticks: {
stepSize: 10,
callback: (label) => `${label} hPa`,
},
position: 'right',
grid: {
drawOnChartArea: false, // only want the grid lines for one axis to show up
},
},
yIAQ: {
type: 'linear',
display: false,
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
mode: "index",
intersect: false,
callbacks: {
label: (item) => {
return `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}`;
},
},
},
},
}
});
},
renderPowerMetricCharts: function() {
try {
this.updatePowerMetricsChart();
} catch(e) {
console.log(e);
}
},
updatePowerMetricsChart: function() {
// destroy existing chart
const chartElementId = "powerMetricsChart";
const existingChart = window.Chart.getChart(chartElementId);
if(existingChart != null){
existingChart.destroy();
}
// get chart element
const chartElement = window.document.getElementById(chartElementId);
if(!chartElement){
return;
}
// create chart data
const labels = [];
const channel1VoltageReadings = [];
const channel2VoltageReadings = [];
const channel3VoltageReadings = [];
const channel1CurrentReadings = [];
const channel2CurrentReadings = [];
const channel3CurrentReadings = [];
for(const powerMetric of this.selectedNodePowerMetrics){
labels.push(moment(powerMetric.created_at));
channel1VoltageReadings.push(powerMetric.ch1_voltage);
channel2VoltageReadings.push(powerMetric.ch2_voltage);
channel3VoltageReadings.push(powerMetric.ch3_voltage);
channel1CurrentReadings.push(powerMetric.ch1_current);
channel2CurrentReadings.push(powerMetric.ch2_current);
channel3CurrentReadings.push(powerMetric.ch3_current);
}
// create chart
new window.Chart(chartElement, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Ch1 Voltage',
suffix: "V",
borderColor: '#3b82f6',
backgroundColor: '#3b82f6',
pointStyle: false, // no points
fill: false,
data: channel1VoltageReadings,
yAxisID: 'y',
},
{
label: 'Ch2 Voltage',
suffix: "V",
borderColor: '#22c55e',
backgroundColor: '#22c55e',
pointStyle: false, // no points
fill: false,
data: channel2VoltageReadings,
yAxisID: 'y',
},
{
label: 'Ch3 Voltage',
suffix: "V",
borderColor: '#f97316',
backgroundColor: '#f97316',
pointStyle: false, // no points
fill: false,
data: channel3VoltageReadings,
yAxisID: 'y',
},
{
label: 'Ch1 Current',
suffix: "mA",
borderColor: '#93c5fd',
backgroundColor: '#93c5fd',
pointStyle: false, // no points
fill: false,
data: channel1CurrentReadings,
yAxisID: 'y1',
},
{
label: 'Ch2 Current',
suffix: "mA",
borderColor: '#86efac',
backgroundColor: '#86efac',
pointStyle: false, // no points
fill: false,
data: channel2CurrentReadings,
yAxisID: 'y1',
},
{
label: 'Ch3 Current',
suffix: "mA",
borderColor: '#fdba74',
backgroundColor: '#fdba74',
pointStyle: false, // no points
fill: false,
data: channel3CurrentReadings,
yAxisID: 'y1',
},
],
},
options: {
responsive: true,
borderWidth: 2,
spanGaps: 1000 * 60 * 60 * 3, // only show lines between metrics with a 3 hour or less gap
elements: {
point: {
radius: 2,
},
},
scales: {
x: {
position: 'top',
type: 'time',
time: {
unit: 'day',
displayFormats: {
day: 'MMM DD', // Jan 01
},
},
},
y: {
min: 0,
suggestedMax: 6,
ticks: {
callback: (label) => `${label}V`,
},
},
y1: {
suggestedMin: -50,
suggestedMax: 50,
ticks: {
stepSize: 50,
callback: (label) => `${label}mA`,
},
position: 'right',
grid: {
drawOnChartArea: false, // only want the grid lines for one axis to show up
},
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
mode: "index",
intersect: false,
callbacks: {
label: (item) => {
return `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}`;
},
},
},
},
}
});
},
showTraceRoute: function(traceroute) {
this.selectedTraceRoute = traceroute;
},
findNodeById: function(id) {
return window.findNodeById(id);
},
findNodeMarkerById: function(id) {
return window.findNodeMarkerById(id);
},
onSearchResultNodeClick: function(node) {
// clear search
this.searchText = "";
// hide search
this.isShowingMobileSearch = false;
// go to node
if(window.goToNode(node.node_id)){
return;
}
// fallback to showing node details since we can't go to the node
window.showNodeDetails(node.node_id);
},
dismissInfoModal: function() {
this.isShowingInfoModal = false;
window.setConfigHasSeenInfoModal(true);
},
getRegionFrequencyRange: function(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;
this.isPositionHistoryModalExpanded = true;
// close node info tooltip as position history shows under it
window.closeAllTooltips();
// reset default time range when opening position history ui
// YYYY-MM-DDTHH:mm is the format expected by the datetime-local input type
this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm');
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
// load position history
this.loadNodePositionHistory(nodeId);
},
onPositionHistoryQuickRangeClick: function(range) {
// update position history time range
switch(range){
case "1h": {
this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm');
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
break;
}
case "24h": {
this.positionHistoryDateTimeFrom = moment().subtract(24, "hours").format('YYYY-MM-DDTHH:mm');
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
break;
}
case "7d": {
this.positionHistoryDateTimeFrom = moment().subtract(7, "days").format('YYYY-MM-DDTHH:mm');
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
break;
}
}
// reload position history
const node = this.selectedNodeToShowPositionHistory;
if(node){
this.loadNodePositionHistory(node.node_id);
}
},
getShareLinkForNode: function(nodeId) {
return window.location.origin + `/?node_id=${nodeId}`;
},
copyShareLinkForNode: function(nodeId) {
// make sure copy to clipboard is supported
if(!navigator.clipboard || !navigator.clipboard.writeText){
alert("Clipboard not supported. Site must be served via https on iOS.");
return;
}
// copy share link to clipboard
const url = this.getShareLinkForNode(nodeId);
navigator.clipboard.writeText(url);
// tell user we copied it
alert("Link copied to clipboard!");
},
dismissShowingNodeConnections: function() {
window._onHideNodeConnectionsClick();
this.selectedNodeToShowConnections = null;
},
dismissShowingNodePositionHistory: function() {
this.selectedNodePositionHistory = [];
this.selectedNodeToShowPositionHistory = null;
this.selectedNodePositionHistoryMarkers = [];
this.selectedNodePositionHistoryPolyLines = [];
cleanUpPositionHistory();
},
formatUptimeSeconds: function(secondsToFormat) {
secondsToFormat = Number(secondsToFormat);
var days = Math.floor(secondsToFormat / (3600 * 24));
var hours = Math.floor((secondsToFormat % (3600 * 24)) / 3600);
var minutes = Math.floor((secondsToFormat % 3600) / 60);
var seconds = Math.floor(secondsToFormat % 60);
var daysPlural = days === 1 ? 'day' : 'days';
return `${days} ${daysPlural} ${hours}h ${minutes}m ${seconds}s`;
},
formatTemperature: function(celsius) {
switch(this.configTemperatureFormat){
case "celsius": {
return `${Number(celsius).toFixed(0)}°C`;
}
case "fahrenheit": {
const fahrenheit = this.celsiusToFahrenheit(celsius);
return `${fahrenheit.toFixed(0)}°F`;
}
}
},
convertTemperature: function(celsius) {
switch(this.configTemperatureFormat){
case "celsius": {
return celsius;
}
case "fahrenheit": {
return this.celsiusToFahrenheit(celsius);
}
}
},
getTemperatureUnit: function() {
switch(this.configTemperatureFormat){
case "celsius": return "°C";
case "fahrenheit": return "°F";
}
},
celsiusToFahrenheit: function(celsius) {
return (celsius * 9/5) + 32;
},
getNodeColour(nodeId) {
// convert node id to a hex colour
return "#" + (nodeId & 0x00FFFFFF).toString(16).padStart(6, '0');
},
getNodeTextColour(nodeId) {
// extract rgb components
const r = (nodeId & 0xFF0000) >> 16;
const g = (nodeId & 0x00FF00) >> 8;
const b = nodeId & 0x0000FF;
// calculate brightness
const brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255;
// determine text color based on brightness
return brightness > 0.5 ? "#000000" : "#FFFFFF";
},
},
computed: {
searchedNodes() {
// search nodes
const nodes = this.nodes.filter((node) => {
const matchesId = node.node_id?.toLowerCase()?.includes(this.searchText.toLowerCase());
const matchesHexId = node.node_id_hex?.toLowerCase()?.includes(this.searchText.toLowerCase());
const matchesLongName = node.long_name?.toLowerCase()?.includes(this.searchText.toLowerCase());
const matchesShortName = node.short_name?.toLowerCase()?.includes(this.searchText.toLowerCase());
return matchesId || matchesHexId || matchesLongName || matchesShortName;
});
// order alphabetically by long name
nodes.sort((nodeA, nodeB) => {
const nodeALongName = nodeA.long_name || "";
const nodeBLongName = nodeB.long_name || "";
return nodeALongName.localeCompare(nodeBLongName);
});
// only return the first 500 results to avoid ui lag...
return nodes.slice(0, 500);
},
selectedNodeLatestPowerMetric() {
const [ latestPowerMetric ] = this.selectedNodePowerMetrics.slice(-1);
return latestPowerMetric;
},
},
watch: {
configNodesMaxAgeInSeconds() {
window.setConfigNodesMaxAgeInSeconds(this.configNodesMaxAgeInSeconds);
},
configNodesOfflineAgeInSeconds() {
window.setConfigNodesOfflineAgeInSeconds(this.configNodesOfflineAgeInSeconds);
},
configWaypointsMaxAgeInSeconds() {
window.setConfigWaypointsMaxAgeInSeconds(this.configWaypointsMaxAgeInSeconds);
},
configConnectionsMaxDistanceInMeters() {
window.setConfigConnectionsMaxDistanceInMeters(this.configConnectionsMaxDistanceInMeters);
},
configZoomLevelGoToNode() {
window.setConfigZoomLevelGoToNode(this.configZoomLevelGoToNode);
},
configAutoUpdatePositionInUrl() {
window.setConfigAutoUpdatePositionInUrl(this.configAutoUpdatePositionInUrl);
},
configEnableMapAnimations() {
window.setConfigEnableMapAnimations(this.configEnableMapAnimations);
},
configTemperatureFormat() {
window.setConfigTemperatureFormat(this.configTemperatureFormat);
},
configConnectionsTimePeriodInSeconds() {
window.setConfigConnectionsTimePeriodInSeconds(this.configConnectionsTimePeriodInSeconds);
},
configConnectionsColoredLines() {
window.setConfigConnectionsColoredLines(this.configConnectionsColoredLines);
},
configConnectionsBidirectionalOnly() {
window.setConfigConnectionsBidirectionalOnly(this.configConnectionsBidirectionalOnly);
},
configConnectionsMinSnrDb() {
window.setConfigConnectionsMinSnrDb(this.configConnectionsMinSnrDb);
},
configConnectionsBidirectionalMinSnr() {
window.setConfigConnectionsBidirectionalMinSnr(this.configConnectionsBidirectionalMinSnr);
},
deviceMetricsTimeRange() {
this.loadNodeDeviceMetrics(this.selectedNode.node_id);
},
environmentMetricsTimeRange() {
this.loadNodeEnvironmentMetrics(this.selectedNode.node_id);
},
powerMetricsTimeRange() {
this.loadNodePowerMetrics(this.selectedNode.node_id);
},
},
}).mount('#app');

View file

@ -0,0 +1,199 @@
function getConfigHasSeenInfoModal() {
return localStorage.getItem("config_has_seen_info_modal") === "true";
}
function setConfigHasSeenInfoModal(value) {
return localStorage.setItem("config_has_seen_info_modal", value);
}
function getConfigAutoUpdatePositionInUrl() {
// use user preference, or enable by default
const value = localStorage.getItem("config_auto_update_position_in_url");
return value === "true" || value == null;
}
function setConfigAutoUpdatePositionInUrl(value) {
return localStorage.setItem("config_auto_update_position_in_url", value);
}
function getConfigEnableMapAnimations() {
const value = localStorage.getItem("config_enable_map_animations");
// enable animations by default
if(value === null){
return true;
}
return value === "true";
}
function setConfigEnableMapAnimations(value) {
return localStorage.setItem("config_enable_map_animations", value);
}
function getConfigTemperatureFormat() {
return localStorage.getItem("config_temperature_format") || "celsius";
}
function setConfigTemperatureFormat(format) {
return localStorage.setItem("config_temperature_format", format);
}
function getConfigMapSelectedTileLayer() {
return localStorage.getItem("config_map_selected_tile_layer") || "Thunderforest Neighbourhood";
}
function setConfigMapSelectedTileLayer(layer) {
return localStorage.setItem("config_map_selected_tile_layer", layer);
}
function getConfigMapEnabledOverlayLayers() {
try {
const value = localStorage.getItem("config_map_enabled_overlay_layers");
if(value){
return JSON.parse(value);
}
} catch(e) {}
// overlays enabled by default
return ["Legend", "Position History", "Traceroutes"];
}
function setConfigMapEnabledOverlayLayers(layers) {
return localStorage.setItem("config_map_enabled_overlay_layers", JSON.stringify(layers));
}
function getConfigNodesMaxAgeInSeconds() {
const value = localStorage.getItem("config_nodes_max_age_in_seconds");
return value != null ? parseInt(value) : null;
}
function setConfigNodesMaxAgeInSeconds(value) {
if(value != null){
return localStorage.setItem("config_nodes_max_age_in_seconds", value);
} else {
return localStorage.removeItem("config_nodes_max_age_in_seconds");
}
}
function getConfigNodesOfflineAgeInSeconds() {
const value = localStorage.getItem("config_nodes_offline_age_in_seconds");
return value != null ? parseInt(value) : 10800;
}
function setConfigNodesOfflineAgeInSeconds(value) {
if(value != null){
return localStorage.setItem("config_nodes_offline_age_in_seconds", value);
} else {
return localStorage.removeItem("config_nodes_offline_age_in_seconds");
}
}
function getConfigWaypointsMaxAgeInSeconds() {
const value = localStorage.getItem("config_waypoints_max_age_in_seconds");
return value != null ? parseInt(value) : null;
}
function setConfigWaypointsMaxAgeInSeconds(value) {
if(value != null){
return localStorage.setItem("config_waypoints_max_age_in_seconds", value);
} else {
return localStorage.removeItem("config_waypoints_max_age_in_seconds");
}
}
function getConfigConnectionsMaxDistanceInMeters() {
const value = localStorage.getItem("config_connections_max_distance_in_meters");
// default to 70km (70,000 meters)
return value != null ? parseInt(value) : 70000;
}
function setConfigConnectionsMaxDistanceInMeters(value) {
return localStorage.setItem("config_connections_max_distance_in_meters", value);
}
function getConfigZoomLevelGoToNode() {
const value = localStorage.getItem("config_zoom_level_go_to_node");
const parsedValue = value != null ? parseInt(value) : null;
return parsedValue || 15;
}
function setConfigZoomLevelGoToNode(value) {
return localStorage.setItem("config_zoom_level_go_to_node", value);
}
function getConfigConnectionsTimePeriodInSeconds() {
const value = localStorage.getItem("config_connections_time_period_in_seconds");
// default to 7 days if unset
return value != null ? parseInt(value) : 604800;
}
function setConfigConnectionsTimePeriodInSeconds(value) {
return localStorage.setItem("config_connections_time_period_in_seconds", value);
}
function getConfigConnectionsColoredLines() {
const value = localStorage.getItem("config_connections_colored_lines");
// disable colored lines by default
if(value === null){
return false;
}
return value === "true";
}
function setConfigConnectionsColoredLines(value) {
return localStorage.setItem("config_connections_colored_lines", value);
}
function getConfigConnectionsBidirectionalOnly() {
const value = localStorage.getItem("config_connections_bidirectional_only");
// disable bidirectional filter by default
if(value === null){
return false;
}
return value === "true";
}
function setConfigConnectionsBidirectionalOnly(value) {
return localStorage.setItem("config_connections_bidirectional_only", value);
}
function getConfigConnectionsMinSnrDb() {
const value = localStorage.getItem("config_connections_min_snr_db");
// default to null (unset)
if(value === null || value === ""){
return null;
}
const parsed = parseFloat(value);
return isNaN(parsed) ? null : parsed;
}
function setConfigConnectionsMinSnrDb(value) {
if(value === null || value === "" || value === undefined){
return localStorage.removeItem("config_connections_min_snr_db");
}
// Convert to string for localStorage (handles both number and string inputs)
const stringValue = typeof value === "number" ? value.toString() : String(value);
return localStorage.setItem("config_connections_min_snr_db", stringValue);
}
function getConfigConnectionsBidirectionalMinSnr() {
const value = localStorage.getItem("config_connections_bidirectional_min_snr");
// disable bidirectional minimum SNR by default
if(value === null){
return false;
}
return value === "true";
}
function setConfigConnectionsBidirectionalMinSnr(value) {
return localStorage.setItem("config_connections_bidirectional_min_snr", value);
}
function isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}

1692
src/public/assets/js/map.js Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

File diff suppressed because it is too large Load diff

244
src/stats.js Normal file
View file

@ -0,0 +1,244 @@
const path = require('path');
const express = require('express');
const router = express.Router();
const protobufjs = require("protobufjs");
// create prisma db client
const { Prisma, PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
// load protobufs
const root = new protobufjs.Root();
root.resolvePath = (origin, target) => path.join(__dirname, "protobufs", target);
root.loadSync('meshtastic/mqtt.proto');
const HardwareModel = root.lookupEnum("HardwareModel");
const PortNum = root.lookupEnum("PortNum");
router.get('/hardware-models', async (req, res) => {
try {
// get nodes from db
const results = await prisma.node.groupBy({
by: ['hardware_model'],
where: {
// Since we removed retention; only include nodes that have been updated in the last 30 days
updated_at: {
gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // within last 30 days
}
},
orderBy: {
_count: {
hardware_model: 'desc',
},
},
_count: {
hardware_model: true,
},
});
const hardwareModelStats = results.map((result) => {
return {
count: result._count.hardware_model,
hardware_model: result.hardware_model,
hardware_model_name: HardwareModel.valuesById[result.hardware_model] ?? "UNKNOWN",
};
});
res.json({
hardware_model_stats: hardwareModelStats,
});
} catch(err) {
console.error(err);
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
router.get('/messages-per-hour', async (req, res) => {
try {
const hours = 168;
const now = new Date();
const startTime = new Date(now.getTime() - hours * 60 * 60 * 1000);
const messages = await prisma.textMessage.findMany({
where: { created_at: { gte: startTime } },
select: { packet_id: true, created_at: true },
distinct: ['packet_id'], // Ensures only unique packet_id entries are counted
orderBy: { created_at: 'asc' }
});
// 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 - 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) + ":00:00.000Z"; // zero out the minutes and seconds
uniqueCounts[hourString] = (uniqueCounts[hourString] ?? 0) + 1;
});
// Convert to final result format
const result = Object.entries(uniqueCounts).map(([hour, count]) => ({ hour, count }));
res.json(result);
} catch (error) {
console.error('Error fetching messages:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/most-active-nodes', async (req, res) => {
try {
const channelId = req.query.channel_id;
const result = await prisma.$queryRaw(
Prisma.sql`
SELECT n.long_name, COUNT(*) AS count
FROM (
SELECT DISTINCT \`from\`, packet_id
FROM service_envelopes
WHERE
created_at >= NOW() - INTERVAL 1 DAY
AND packet_id IS NOT NULL
AND portnum != 73
AND \`to\` != 1
${channelId ? Prisma.sql`AND channel_id = ${channelId}` : Prisma.sql``}
) AS unique_packets
JOIN nodes n ON unique_packets.from = n.node_id
GROUP BY n.long_name
ORDER BY count DESC
LIMIT 25;
`
);
res.set('Cache-Control', 'public, max-age=600'); // 10 min cache
res.json(result);
} catch (error) {
console.error('Error fetching data:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/portnum-counts', async (req, res) => {
const nodeId = req.query.nodeId ? parseInt(req.query.nodeId, 10) : null;
const channelId = req.query.channel_id;
const hours = 24;
const now = new Date();
const startTime = new Date(now.getTime() - hours * 60 * 60 * 1000);
try {
const envelopes = await prisma.serviceEnvelope.findMany({
where: {
created_at: { gte: startTime },
...(Number.isInteger(nodeId) ? { from: nodeId } : {}),
...(channelId ? { channel_id: channelId } : {}),
packet_id: { not: null },
to: { not: 1 }, // Filter out NODENUM_BROADCAST_NO_LORA
OR: [
{ portnum: { not: 73 } }, // Exclude portnum 73 (e.g. map reports)
{ portnum: null } // But include PKI packages, they have no portnum
]
},
select: {from: true, packet_id: true, portnum: true, channel_id: true}
});
// Ensure uniqueness based on (from, packet_id)
const seen = new Set();
const counts = {};
for (const envelope of envelopes) {
const uniqueKey = `${envelope.from}-${envelope.packet_id}`;
if (seen.has(uniqueKey)) continue;
seen.add(uniqueKey);
// Override portnum to 512 if channel_id is "PKI"
const portnum = envelope.channel_id === "PKI" ? 512 : (envelope.portnum ?? 0);
counts[portnum] = (counts[portnum] || 0) + 1;
}
const result = Object.entries(counts).map(([portnum, count]) => ({
portnum: parseInt(portnum, 10),
count: count,
label: parseInt(portnum, 10) === 512 ? "PKI" : (PortNum.valuesById[portnum] ?? "UNKNOWN"),
})).sort((a, b) => a.portnum - b.portnum);
res.json(result);
} catch (err) {
console.error("Error in /portnum-counts:", err);
res.status(500).json({ message: "Internal server error" });
}
});
router.get('/battery-stats', async (req, res) => {
const days = parseInt(req.query.days || '1', 10);
try {
const stats = await prisma.$queryRaw`
SELECT id, recorded_at, avg_battery_level
FROM battery_stats
WHERE recorded_at >= NOW() - INTERVAL ${days} DAY
ORDER BY recorded_at DESC;
`;
res.json(stats);
} catch (err) {
console.error('Error fetching battery stats:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/channel-utilization-stats', async (req, res) => {
const days = parseInt(req.query.days || '1', 10);
const channelId = req.query.channel_id; // optional string
try {
const stats = await prisma.$queryRaw(
Prisma.sql`
SELECT recorded_at, channel_id, avg_channel_utilization
FROM channel_utilization_stats
WHERE recorded_at >= NOW() - INTERVAL ${days} DAY
${channelId ? Prisma.sql`AND channel_id = ${channelId}` : Prisma.sql``}
ORDER BY recorded_at DESC;
`
);
res.json(stats);
} catch (err) {
console.error('Error fetching channel utilization stats:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/channel-utilization', async (req, res) => {
const channelId = req.query.channel_id;
try {
const snapshot = await prisma.$queryRaw(
Prisma.sql`
SELECT recorded_at, channel_id, avg_channel_utilization
FROM channel_utilization_stats
WHERE recorded_at = (
SELECT MAX(recorded_at) FROM channel_utilization_stats
)
${channelId ? Prisma.sql`AND channel_id = ${channelId}` : Prisma.sql``}
ORDER BY channel_id;
`
);
res.json(snapshot);
} catch (err) {
console.error('Error fetching latest channel utilization:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;

33
src/utils/logger.js Normal file
View file

@ -0,0 +1,33 @@
// Override console methods to add formatted timestamps
const originalLog = console.log;
const originalError = console.error;
const originalWarn = console.warn;
const originalInfo = console.info;
function formatTimestamp() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
console.log = function(...args) {
originalLog(`${formatTimestamp()} [Info]`, ...args);
};
console.error = function(...args) {
originalError(`${formatTimestamp()} [Error]`, ...args);
};
console.warn = function(...args) {
originalWarn(`${formatTimestamp()} [Warn]`, ...args);
};
console.info = function(...args) {
originalInfo(`${formatTimestamp()} [Info]`, ...args);
};

314
src/ws.js Normal file
View file

@ -0,0 +1,314 @@
require('./utils/logger');
const crypto = require("crypto");
const path = require("path");
const http = require("http");
const mqtt = require("mqtt");
const protobufjs = require("protobufjs");
const commandLineArgs = require("command-line-args");
const commandLineUsage = require("command-line-usage");
const { WebSocketServer } = require("ws");
const optionsList = [
{
name: 'help',
alias: 'h',
type: Boolean,
description: 'Display this usage guide.'
},
{
name: "mqtt-broker-url",
type: String,
description: "MQTT Broker URL (e.g: mqtt://mqtt.meshtastic.org)",
},
{
name: "mqtt-username",
type: String,
description: "MQTT Username (e.g: meshdev)",
},
{
name: "mqtt-password",
type: String,
description: "MQTT Password (e.g: large4cats)",
},
{
name: "mqtt-client-id",
type: String,
description: "MQTT Client ID (e.g: map.example.com)",
},
{
name: "mqtt-topic",
type: String,
multiple: true,
typeLabel: '<topic> ...',
description: "MQTT Topic to subscribe to (e.g: msh/#)",
},
{
name: "decryption-keys",
type: String,
multiple: true,
typeLabel: '<base64DecryptionKey> ...',
description: "Decryption keys encoded in base64 to use when decrypting service envelopes.",
},
{
name: "ws-port",
type: Number,
description: "WebSocket server port (default: 8081)",
},
];
// parse command line args
const options = commandLineArgs(optionsList);
// show help
if(options.help){
const usage = commandLineUsage([
{
header: 'Meshtastic WebSocket Publisher',
content: 'Publishes real-time Meshtastic packets via WebSocket.',
},
{
header: 'Options',
optionList: optionsList,
},
]);
console.log(usage);
process.exit(0);
}
// get options and fallback to default values
const mqttBrokerUrl = options["mqtt-broker-url"] ?? "mqtt://mqtt.meshtastic.org";
const mqttUsername = options["mqtt-username"] ?? "meshdev";
const mqttPassword = options["mqtt-password"] ?? "large4cats";
const mqttClientId = options["mqtt-client-id"] ?? null;
const mqttTopics = options["mqtt-topic"] ?? ["msh/#"];
const decryptionKeys = options["decryption-keys"] ?? [
"1PG7OiApB1nwvP+rz05pAQ==", // add default "AQ==" decryption key
];
const wsPort = options["ws-port"] ?? 8081;
// create mqtt client
const client = mqtt.connect(mqttBrokerUrl, {
username: mqttUsername,
password: mqttPassword,
clientId: mqttClientId,
});
// load protobufs
const root = new protobufjs.Root();
root.resolvePath = (origin, target) => path.join(__dirname, "protobufs", target);
root.loadSync('meshtastic/mqtt.proto');
const Data = root.lookupType("Data");
const ServiceEnvelope = root.lookupType("ServiceEnvelope");
const RouteDiscovery = root.lookupType("RouteDiscovery");
// create HTTP server for WebSocket
const server = http.createServer();
const wss = new WebSocketServer({ server });
// track connected clients
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
console.log(`WebSocket client connected. Total clients: ${clients.size}`);
ws.on('close', () => {
clients.delete(ws);
console.log(`WebSocket client disconnected. Total clients: ${clients.size}`);
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
clients.delete(ws);
});
});
// broadcast message to all connected clients
function broadcast(message) {
const messageStr = JSON.stringify(message);
clients.forEach((client) => {
if (client.readyState === 1) { // WebSocket.OPEN
try {
client.send(messageStr);
} catch (error) {
console.error('Error sending message to client:', error);
}
}
});
}
function createNonce(packetId, fromNode) {
// Expand packetId to 64 bits
const packetId64 = BigInt(packetId);
// Initialize block counter (32-bit, starts at zero)
const blockCounter = 0;
// Create a buffer for the nonce
const buf = Buffer.alloc(16);
// Write packetId, fromNode, and block counter to the buffer
buf.writeBigUInt64LE(packetId64, 0);
buf.writeUInt32LE(fromNode, 8);
buf.writeUInt32LE(blockCounter, 12);
return buf;
}
/**
* References:
* https://github.com/crypto-smoke/meshtastic-go/blob/develop/radio/aes.go#L42
* https://github.com/pdxlocations/Meshtastic-MQTT-Connect/blob/main/meshtastic-mqtt-connect.py#L381
*/
function decrypt(packet) {
// attempt to decrypt with all available decryption keys
for(const decryptionKey of decryptionKeys){
try {
// convert encryption key to buffer
const key = Buffer.from(decryptionKey, "base64");
// create decryption iv/nonce for this packet
const nonceBuffer = createNonce(packet.id, packet.from);
// determine algorithm based on key length
var algorithm = null;
if(key.length === 16){
algorithm = "aes-128-ctr";
} else if(key.length === 32){
algorithm = "aes-256-ctr";
} else {
// skip this key, try the next one...
console.error(`Skipping decryption key with invalid length: ${key.length}`);
continue;
}
// create decipher
const decipher = crypto.createDecipheriv(algorithm, key, nonceBuffer);
// decrypt encrypted packet
const decryptedBuffer = Buffer.concat([decipher.update(packet.encrypted), decipher.final()]);
// parse as data message
return Data.decode(decryptedBuffer);
} catch(e){}
}
// couldn't decrypt
return null;
}
/**
* converts hex id to numeric id, for example: !FFFFFFFF to 4294967295
* @param hexId a node id in hex format with a prepended "!"
* @returns {bigint} the node id in numeric form
*/
function convertHexIdToNumericId(hexId) {
return BigInt('0x' + hexId.replaceAll("!", ""));
}
// subscribe to everything when connected
client.on("connect", () => {
console.log("Connected to MQTT broker");
for(const mqttTopic of mqttTopics){
client.subscribe(mqttTopic);
console.log(`Subscribed to MQTT topic: ${mqttTopic}`);
}
});
// handle message received
client.on("message", async (topic, message) => {
try {
// decode service envelope
const envelope = ServiceEnvelope.decode(message);
if(!envelope.packet){
return;
}
// attempt to decrypt encrypted packets
const isEncrypted = envelope.packet.encrypted?.length > 0;
if(isEncrypted){
const decoded = decrypt(envelope.packet);
if(decoded){
envelope.packet.decoded = decoded;
}
}
// get portnum from decoded packet
const portnum = envelope.packet?.decoded?.portnum;
// check if we can see the decrypted packet data
if(envelope.packet.decoded == null){
return;
}
// handle traceroutes (portnum 70)
if(portnum === 70) {
try {
const routeDiscovery = RouteDiscovery.decode(envelope.packet.decoded.payload);
const traceroute = {
type: "traceroute",
data: {
to: envelope.packet.to,
from: envelope.packet.from,
want_response: envelope.packet.decoded.wantResponse,
route: routeDiscovery.route,
snr_towards: routeDiscovery.snrTowards,
route_back: routeDiscovery.routeBack,
snr_back: routeDiscovery.snrBack,
channel_id: envelope.channelId,
gateway_id: envelope.gatewayId ? Number(convertHexIdToNumericId(envelope.gatewayId)) : null,
packet_id: envelope.packet.id,
}
};
broadcast(traceroute);
} catch (e) {
console.error("Error processing traceroute:", e);
}
}
} catch(e) {
console.error("Error processing MQTT message:", e);
}
});
// start WebSocket server
server.listen(wsPort, () => {
console.log(`WebSocket server running on port ${wsPort}`);
});
// Graceful shutdown handlers
function gracefulShutdown(signal) {
console.log(`Received ${signal}. Starting graceful shutdown...`);
// Close all WebSocket connections
clients.forEach((client) => {
client.close();
});
clients.clear();
// Close WebSocket server
wss.close(() => {
console.log('WebSocket server closed');
});
// Close HTTP server
server.close(() => {
console.log('HTTP server closed');
});
// Close MQTT client
client.end(false, () => {
console.log('MQTT client disconnected');
console.log('Graceful shutdown completed');
process.exit(0);
});
}
// Handle SIGTERM (Docker, systemd, etc.)
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
// Handle SIGINT (Ctrl+C)
process.on('SIGINT', () => gracefulShutdown('SIGINT'));