mirror of
https://github.com/Roslund/meshtastic-map.git
synced 2026-03-28 17:43:03 +01:00
Compare commits
113 Commits
docker-imp
...
traceroute
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42d25add06 | ||
|
|
f1103748e6 | ||
|
|
dff6ed035a | ||
|
|
a9e749a336 | ||
|
|
ce8adb88a4 | ||
|
|
41bafcaaff | ||
|
|
35d1fdbc6f | ||
|
|
63af2fbf9c | ||
|
|
c777a7bce2 | ||
|
|
5984b8b243 | ||
|
|
1ee526caf7 | ||
|
|
a7b99c3027 | ||
|
|
b668892248 | ||
|
|
0053b9f774 | ||
|
|
435c122c21 | ||
|
|
c5028f911f | ||
|
|
7fee84de77 | ||
|
|
162f8da79c | ||
|
|
ca9d1d9de0 | ||
|
|
8eb5c695f0 | ||
|
|
e11367544d | ||
|
|
cbcbeb9a22 | ||
|
|
4c745123c1 | ||
|
|
800dcfef78 | ||
|
|
e27f92d5c6 | ||
|
|
772faa550b | ||
|
|
a716d401a9 | ||
|
|
aeeb95554b | ||
|
|
bc78a0b0e3 | ||
|
|
aa57eb543f | ||
|
|
d5993aebb8 | ||
|
|
2b2ca982a0 | ||
|
|
d685826f15 | ||
|
|
91f98a6a09 | ||
|
|
3452efb1b7 | ||
|
|
54e83e5cdd | ||
|
|
d3ac48f044 | ||
|
|
24b90f6710 | ||
|
|
a77518facf | ||
|
|
c36a797967 | ||
|
|
5c83393cb1 | ||
|
|
5c0a7db8c6 | ||
|
|
201ba0d399 | ||
|
|
b31d10d434 | ||
|
|
96cc1ad1d5 | ||
|
|
ce2121d964 | ||
|
|
0c5b330681 | ||
|
|
2d1dd6fbb2 | ||
|
|
9e17347a7a | ||
|
|
6619e42bad | ||
|
|
e95bac1063 | ||
|
|
3eed255eb4 | ||
|
|
2bc82ae7a3 | ||
|
|
9d7a60efa5 | ||
|
|
8c064ac6e6 | ||
|
|
b3bb02fede | ||
|
|
11f5898996 | ||
|
|
17be3cb6a9 | ||
|
|
d95af37be5 | ||
|
|
e30fb12aa8 | ||
|
|
6691df73f5 | ||
|
|
9b69d0ce27 | ||
|
|
cf3f053e12 | ||
|
|
305f091142 | ||
|
|
4d1bdba6e0 | ||
|
|
6210f04ea5 | ||
|
|
99e31d8692 | ||
|
|
55cbdb63ba | ||
|
|
c251c5adb8 | ||
|
|
189c89a7ca | ||
|
|
8b84a3a91c | ||
|
|
6cd07fe314 | ||
|
|
fac5a91829 | ||
|
|
2fbaab81c5 | ||
|
|
bfb845ac37 | ||
|
|
274f0b8efa | ||
|
|
c8f322f012 | ||
|
|
f35a8876f9 | ||
|
|
af2a663dab | ||
|
|
342c8dc87a | ||
|
|
ff8bb07f7f | ||
|
|
9411c9e4cc | ||
|
|
7ab62a9968 | ||
|
|
a3c4667f34 | ||
|
|
700ee298c4 | ||
|
|
4c6bccc058 | ||
|
|
cdcefee641 | ||
|
|
6e9d425bda | ||
|
|
984771925f | ||
|
|
cadc78d1d4 | ||
|
|
fd36e1b0a2 | ||
|
|
c3c92b47f1 | ||
|
|
48e25dd352 | ||
|
|
708451b027 | ||
|
|
37e54c76a6 | ||
|
|
b319115fd2 | ||
|
|
1eb0b7eeea | ||
|
|
67fc07d326 | ||
|
|
24df50889d | ||
|
|
1f93c8eb9e | ||
|
|
566b8d6086 | ||
|
|
2b6156ff07 | ||
|
|
3c12e5fbe9 | ||
|
|
ec8093a1ca | ||
|
|
55c7a45060 | ||
|
|
471d8dded6 | ||
|
|
045008a9df | ||
|
|
fd50a4adeb | ||
|
|
efdf5f850d | ||
|
|
15bd3ebdc8 | ||
|
|
a73d858ebd | ||
|
|
af92253092 | ||
|
|
7e5a026987 |
@@ -1,3 +1,3 @@
|
||||
.env
|
||||
node_modules
|
||||
.git
|
||||
.git
|
||||
|
||||
13
.github/dependabot.yml
vendored
Normal file
13
.github/dependabot.yml
vendored
Normal 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
2
.gitignore
vendored
@@ -2,5 +2,3 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
src/external
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "protobufs"]
|
||||
path = src/protobufs
|
||||
url = https://github.com/meshtastic/protobufs.git
|
||||
@@ -13,4 +13,4 @@ COPY . .
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
|
||||
EXPOSE 8080
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -122,9 +122,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.
|
||||
|
||||
|
||||
4199
package-lock.json
generated
4199
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -9,16 +9,17 @@
|
||||
"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",
|
||||
"@prisma/client": "^6.13.0",
|
||||
"command-line-args": "^6.0.1",
|
||||
"command-line-usage": "^7.0.3",
|
||||
"compression": "^1.8.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.0.0",
|
||||
"mqtt": "^5.3.6",
|
||||
"protobufjs": "^7.2.6"
|
||||
"mqtt": "^5.14.0",
|
||||
"protobufjs": "^7.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0",
|
||||
"prisma": "^5.10.2"
|
||||
"jest": "^30.0.5",
|
||||
"prisma": "^6.13.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `nodes` ADD COLUMN `ok_to_mqtt` BOOLEAN NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `service_envelopes` ADD COLUMN `portnum` INTEGER NULL;
|
||||
@@ -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`);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `nodes` ADD COLUMN `is_backbone` BOOLEAN NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `nodes` ADD COLUMN `is_unmessagable` BOOLEAN NULL,
|
||||
ADD COLUMN `public_key` VARCHAR(191) NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `nodes` ADD COLUMN `max_hops` INTEGER NULL;
|
||||
@@ -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,10 @@ 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?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
@@ -202,6 +208,8 @@ model ServiceEnvelope {
|
||||
gateway_id BigInt?
|
||||
to BigInt
|
||||
from BigInt
|
||||
portnum Int?
|
||||
packet_id BigInt?
|
||||
protobuf Bytes
|
||||
|
||||
created_at DateTime @default(now())
|
||||
@@ -210,6 +218,7 @@ model ServiceEnvelope {
|
||||
@@index(created_at)
|
||||
@@index(updated_at)
|
||||
@@index(gateway_id)
|
||||
@@index(packet_id)
|
||||
@@map("service_envelopes")
|
||||
}
|
||||
|
||||
@@ -296,3 +305,41 @@ 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?
|
||||
|
||||
@@index([recorded_at])
|
||||
@@map("channel_utilization_stats")
|
||||
}
|
||||
196
src/index.js
196
src/index.js
@@ -1,9 +1,12 @@
|
||||
const fs = require("fs");
|
||||
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 +53,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 +79,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 +89,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 +146,14 @@ app.get('/api', async (req, res) => {
|
||||
"path": "/api/v1/nodes/:nodeId/traceroutes",
|
||||
"description": "Trace routes for a meshtastic node",
|
||||
},
|
||||
{
|
||||
"path": "/api/v1/traceroutes",
|
||||
"description": "Recent traceroute edges across all nodes",
|
||||
"params": {
|
||||
"time_from": "Only include traceroutes updated after this unix timestamp (milliseconds)",
|
||||
"time_to": "Only include traceroutes updated before this unix timestamp (milliseconds)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/api/v1/nodes/:nodeId/position-history",
|
||||
"description": "Position history for a meshtastic node",
|
||||
@@ -554,6 +574,125 @@ app.get('/api/v1/nodes/:nodeId/traceroutes', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Aggregated recent traceroute edges (global), filtered by updated_at
|
||||
// Returns deduplicated edges with the latest SNR and timestamp.
|
||||
// GET /api/v1/nodes/traceroutes?time_from=...&time_to=...
|
||||
app.get('/api/v1/traceroutes', async (req, res) => {
|
||||
try {
|
||||
|
||||
const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : undefined;
|
||||
const timeTo = req.query.time_to ? parseInt(req.query.time_to) : undefined;
|
||||
|
||||
// Pull recent traceroutes within the time window. We only want replies (want_response=false)
|
||||
// and those that were actually gated to MQTT (gateway_id not null)
|
||||
const traces = await prisma.traceRoute.findMany({
|
||||
where: {
|
||||
want_response: false,
|
||||
gateway_id: { not: null },
|
||||
updated_at: {
|
||||
gte: timeFrom ? new Date(timeFrom) : undefined,
|
||||
lte: timeTo ? new Date(timeTo) : undefined,
|
||||
},
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take: 5000, // cap to keep response bounded; UI can page/adjust time window if needed
|
||||
});
|
||||
|
||||
// Normalize JSON fields that may be strings (depending on driver)
|
||||
const normalized = traces.map((t) => {
|
||||
const trace = { ...t };
|
||||
if (typeof trace.route === 'string') {
|
||||
try { trace.route = JSON.parse(trace.route); } catch(_) {}
|
||||
}
|
||||
if (typeof trace.route_back === 'string') {
|
||||
try { trace.route_back = JSON.parse(trace.route_back); } catch(_) {}
|
||||
}
|
||||
if (typeof trace.snr_towards === 'string') {
|
||||
try { trace.snr_towards = JSON.parse(trace.snr_towards); } catch(_) {}
|
||||
}
|
||||
if (typeof trace.snr_back === 'string') {
|
||||
try { trace.snr_back = JSON.parse(trace.snr_back); } catch(_) {}
|
||||
}
|
||||
return trace;
|
||||
});
|
||||
|
||||
// Build edges from both forward (towards) and reverse (back) paths.
|
||||
// Forward path: to → route[] → from, using snr_towards
|
||||
// Reverse path: from → route_back[] → to, using snr_back
|
||||
const edgeKey = (a, b) => `${String(a)}->${String(b)}`;
|
||||
const edges = new Map();
|
||||
|
||||
function upsertEdgesFromPath(trace, pathNodes, pathSnrs) {
|
||||
for (let i = 0; i < pathNodes.length - 1; i++) {
|
||||
const hopFrom = pathNodes[i];
|
||||
const hopTo = pathNodes[i + 1];
|
||||
const snr = typeof (pathSnrs && pathSnrs[i]) === 'number' ? pathSnrs[i] : null;
|
||||
|
||||
// Skip edges without SNR data
|
||||
if (snr === null) continue;
|
||||
|
||||
const key = edgeKey(hopFrom, hopTo);
|
||||
const existing = edges.get(key);
|
||||
if (!existing) {
|
||||
edges.set(key, {
|
||||
from: hopFrom,
|
||||
to: hopTo,
|
||||
snr: snr,
|
||||
updated_at: trace.updated_at,
|
||||
channel_id: trace.channel_id ?? null,
|
||||
gateway_id: trace.gateway_id ?? null,
|
||||
traceroute_from: trace.from, // original initiator
|
||||
traceroute_to: trace.to, // original target
|
||||
});
|
||||
} else if (new Date(trace.updated_at) > new Date(existing.updated_at)) {
|
||||
existing.snr = snr;
|
||||
existing.updated_at = trace.updated_at;
|
||||
existing.channel_id = trace.channel_id ?? existing.channel_id;
|
||||
existing.gateway_id = trace.gateway_id ?? existing.gateway_id;
|
||||
existing.traceroute_from = trace.from;
|
||||
existing.traceroute_to = trace.to;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const tr of normalized) {
|
||||
// Forward path
|
||||
const forwardPath = [];
|
||||
if (tr.to != null) forwardPath.push(Number(tr.to));
|
||||
if (Array.isArray(tr.route)) {
|
||||
for (const hop of tr.route) {
|
||||
if (hop != null) forwardPath.push(Number(hop));
|
||||
}
|
||||
}
|
||||
if (tr.from != null) forwardPath.push(Number(tr.from));
|
||||
const forwardSnrs = Array.isArray(tr.snr_towards) ? tr.snr_towards : [];
|
||||
upsertEdgesFromPath(tr, forwardPath, forwardSnrs);
|
||||
|
||||
// Reverse path
|
||||
const reversePath = [];
|
||||
if (tr.from != null) reversePath.push(Number(tr.from));
|
||||
if (Array.isArray(tr.route_back)) {
|
||||
for (const hop of tr.route_back) {
|
||||
if (hop != null) reversePath.push(Number(hop));
|
||||
}
|
||||
}
|
||||
if (tr.to != null) reversePath.push(Number(tr.to));
|
||||
const reverseSnrs = Array.isArray(tr.snr_back) ? tr.snr_back : [];
|
||||
upsertEdgesFromPath(tr, reversePath, reverseSnrs);
|
||||
}
|
||||
|
||||
res.json({
|
||||
traceroute_edges: Array.from(edges.values()),
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({
|
||||
message: "Something went wrong, try again later.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/v1/nodes/:nodeId/position-history', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -643,42 +782,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,6 +908,7 @@ app.get('/api/v1/waypoints', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// start express server
|
||||
const listener = app.listen(port, () => {
|
||||
const port = listener.address().port;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
86
src/mqtt.js
86
src/mqtt.js
@@ -1,4 +1,3 @@
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const path = require("path");
|
||||
const mqtt = require("mqtt");
|
||||
@@ -22,11 +21,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 +206,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 +222,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 +241,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 +250,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");
|
||||
@@ -776,6 +751,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,
|
||||
},
|
||||
});
|
||||
@@ -948,6 +925,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 or update node in db
|
||||
try {
|
||||
await prisma.node.upsert({
|
||||
@@ -961,6 +945,18 @@ 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,
|
||||
|
||||
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 +964,46 @@ 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,
|
||||
|
||||
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) {
|
||||
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) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
else if(portnum === 8) {
|
||||
@@ -1411,6 +1441,6 @@ client.on("message", async (topic, message) => {
|
||||
}
|
||||
|
||||
} catch(e) {
|
||||
// ignore errors
|
||||
console.log("error", e);
|
||||
}
|
||||
});
|
||||
|
||||
1
src/protobufs
Submodule
1
src/protobufs
Submodule
Submodule src/protobufs added at 1ecf94da98
BIN
src/public/images/devices/HELTEC_VISION_MASTER_E213.png
Normal file
BIN
src/public/images/devices/HELTEC_VISION_MASTER_E213.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 5.4 KiB |
@@ -3,15 +3,15 @@
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>Meshtastic Map</title>
|
||||
<title>STHLM-MESH MAP</title>
|
||||
<meta name="title" content="Meshtastic Map">
|
||||
<meta name="description" content="An interactive map of all Meshtastic nodes.">
|
||||
<link rel="icon" type="image/png" href="/icon.png"/>
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://meshtastic.liamcottle.net">
|
||||
<meta property="og:title" content="Meshtastic Map">
|
||||
<meta property="og:url" content="https://map.sthlm-mesh.se">
|
||||
<meta property="og:title" content="STHLM-MESH MAP">
|
||||
<meta property="og:description" content="An interactive map of all Meshtastic nodes.">
|
||||
|
||||
<!-- tailwind css -->
|
||||
@@ -56,7 +56,7 @@
|
||||
}
|
||||
|
||||
.icon-mqtt-connected {
|
||||
background-color: #16a34a;
|
||||
background-color: #2563eb; /* Change to use same color as disconnected // #16a34a; */
|
||||
border-radius: 25px;
|
||||
border: 1px solid white;
|
||||
}
|
||||
@@ -145,32 +145,8 @@
|
||||
<div class="flex flex-col h-full w-full overflow-hidden">
|
||||
<div class="flex flex-col h-full">
|
||||
|
||||
<!-- announcement -->
|
||||
<div v-if="isShowingAnnouncement" class="flex bg-gray-900 text-white p-2 border-gray-300 border-b">
|
||||
|
||||
<!-- info -->
|
||||
<div class="my-auto leading-tight">
|
||||
<div class="font-bold">Introducing MeshCore</div>
|
||||
<div class="text-sm">
|
||||
<span>Looking for a new mesh project to tinker with? Check out <a target="_blank" href="https://meshcore.nz" class="underline">MeshCore</a></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- action buttons -->
|
||||
<div class="flex my-auto ml-auto mr-0 sm:mr-2">
|
||||
<a @click="dismissAnnouncement" href="javascript:void(0)" class="rounded-full">
|
||||
<div class="bg-white text-black hover:bg-gray-100 p-2 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- header -->
|
||||
<div class="flex bg-white p-2 border-gray-300 border-b h-16">
|
||||
<div class="flex p-3 h-16" style="background-color: 30a552;">
|
||||
|
||||
<!-- close mobile search button -->
|
||||
<div v-if="isShowingMobileSearch" class="my-auto">
|
||||
@@ -184,16 +160,13 @@
|
||||
</div>
|
||||
|
||||
<!-- icon -->
|
||||
<div v-if="!isShowingMobileSearch" class="hidden sm:block my-auto mr-3">
|
||||
<img class="w-10 h-10 rounded" src="icon.png"/>
|
||||
<div v-if="!isShowingMobileSearch" class="hidden sm:block my-auto mr-2 ml-2">
|
||||
<img class="w-8 h-8 rounded" src="icon.png"/>
|
||||
</div>
|
||||
|
||||
<!-- app info -->
|
||||
<div v-if="!isShowingMobileSearch" class="my-auto leading-tight">
|
||||
<div class="font-bold">Meshtastic Map</div>
|
||||
<div class="text-sm">
|
||||
Created by <a class="link" target="_blank" href="https://liamcottle.com">Liam Cottle</a>
|
||||
</div>
|
||||
<a href="https://sthlm-mesh.se"><div class="font-bold" style="color: #ffffff; font-size: 1.25rem;">STHLM-MESH</div></a>
|
||||
</div>
|
||||
|
||||
<!-- search bar -->
|
||||
@@ -253,30 +226,6 @@
|
||||
<span class="tooltip-text">Search</span>
|
||||
</div>
|
||||
</a>
|
||||
<a @click="isShowingHardwareModels = !isShowingHardwareModels" href="javascript:void(0)" class="tooltip rounded-full hidden sm:block">
|
||||
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
||||
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="hidden sm:block">
|
||||
<span class="tooltip-text">Devices</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="#" class="tooltip rounded-full hidden lg:block" onclick="goToRandomNode()">
|
||||
<div id="random-button" class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
||||
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 4l3 3l-3 3"></path>
|
||||
<path d="M18 20l3 -3l-3 -3"></path>
|
||||
<path d="M3 7h3a5 5 0 0 1 5 5a5 5 0 0 0 5 5h5"></path>
|
||||
<path d="M21 7h-5a4.978 4.978 0 0 0 -3 1m-4 8a4.984 4.984 0 0 1 -3 1h-3"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="hidden sm:block">
|
||||
<span class="tooltip-text">Random</span>
|
||||
</div>
|
||||
</a>
|
||||
<a @click="isShowingSettings = true" href="javascript:void(0)" class="tooltip rounded-full">
|
||||
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
||||
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
@@ -360,112 +309,35 @@
|
||||
<img src="./icon.png" class="mx-auto w-16 h-16 rounded mb-1"/>
|
||||
<h1 class="font-bold">Meshtastic Map</h1>
|
||||
<h2>Created by <a class="link" target="_blank" href="https://liamcottle.com">Liam Cottle</a></h2>
|
||||
<div class="w-full mx-auto text-center space-x-1 mt-2">
|
||||
|
||||
<a target="_blank" href="https://twitter.com/liamcottle" title="Twitter" class="raise inline-flex items-center p-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-[#1da1f2] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1da1f2]">
|
||||
<svg role="img" class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a target="_blank" href="https://github.com/liamcottle/meshtastic-map" title="GitHub" class="raise inline-flex items-center p-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-[#333333] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#333333]">
|
||||
<svg role="img" class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"></path>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a target="_blank" href="https://discord.gg/K55zeZyHKK" title="Discord" class="raise inline-flex items-center p-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-[#5865f2] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5865f2]">
|
||||
<svg role="img" class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a target="_blank" href="https://paypal.me/liamcarncottle" title="PayPal" class="raise inline-flex items-center p-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-[#0070ba] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#0070ba]">
|
||||
<svg role="img" class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 48 48">
|
||||
<path fill="#fff" fill-opacity=".45" d="M16.13 39.115H8.367a1.041 1.041 0 0 1-1.026-1.2l5.234-33.176a1.279 1.279 0 0 1 1.261-1.079h13.335c6.315 0 10.909 4.592 10.8 10.157 3.735 1.95 5.915 5.91 5.234 10.247a12.548 12.548 0 0 1-12.39 10.62H26.89a1.275 1.275 0 0 0-1.261 1.08L23.87 46.9a1.276 1.276 0 0 1-1.262 1.079H15.94a1.04 1.04 0 0 1-1.026-1.199l1.215-7.664v-.002Z"></path>
|
||||
<path fill="#fff" fill-opacity=".45" d="M37.973 13.817a11.668 11.668 0 0 0-5.441-1.294H21.414a1.277 1.277 0 0 0-1.261 1.08l-2.098 13.293.006-.035a1.28 1.28 0 0 1 1.256-1.042h6.144a12.553 12.553 0 0 0 12.39-10.62c.071-.457.113-.92.122-1.382Z"></path>
|
||||
<path fill="#fff" d="M16.133 39.115H8.368a1.041 1.041 0 0 1-1.026-1.2l5.234-33.176a1.279 1.279 0 0 1 1.261-1.079h13.335c6.315 0 10.909 4.592 10.801 10.157a11.67 11.67 0 0 0-5.441-1.294H21.414a1.277 1.277 0 0 0-1.261 1.08l-2.098 13.293.006-.035-1.928 12.254Z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a target="_blank" href="https://liamcottle.com/contact" title="Email" class="raise inline-flex items-center p-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
<h2>Forked by <a class="link" target="_blank" href="http://github.com/Roslund/">Roslund</a></h2>
|
||||
</div>
|
||||
|
||||
<!-- welcome -->
|
||||
<div class="bg-green-100 rounded p-2 border border-green-200">
|
||||
👋 Welcome to my open source map of Meshtastic nodes heard on MQTT.
|
||||
</div>
|
||||
|
||||
<!-- features -->
|
||||
<!-- Beskrivning -->
|
||||
<div>
|
||||
<div class="font-bold mb-2">Features</div>
|
||||
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
||||
<ul class="list-disc list-inside">
|
||||
<li>The map shows nodes that have sent a valid position to MQTT.</li>
|
||||
<li>Position packets must be unencrypted, or encrypted with the default key.</li>
|
||||
<li>Use the search bar to find nodes by ID or name.</li>
|
||||
<li>Hover over nodes (on desktop) to see basic details.</li>
|
||||
<li>Click a node to see info such as telemetry graphs and traceroutes.</li>
|
||||
<li>Use the top right layers panel to show neighbours and waypoints.</li>
|
||||
<li>Use the settings button to configure the map to your liking.</li>
|
||||
<li>Have a feature request, or found a bug? <a class="link" target="_blank" href="https://github.com/liamcottle/meshtastic-map">Open an issue</a> on GitHub.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- faq -->
|
||||
<div>
|
||||
<div class="font-bold mb-2">FAQ</div>
|
||||
<div class="font-bold mb-2">Beskrivning</div>
|
||||
<div class="space-y-2">
|
||||
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
||||
<div class="font-semibold">How do I add my node to the map?</div>
|
||||
<div>Your node, or a node that hears your node must uplink to our MQTT server.</div>
|
||||
<div>Your position packet must be unencrypted, or encrypted with the default key.</div>
|
||||
<div>If your node has v2.5 firmware or newer, you must enable "OK to MQTT".</div>
|
||||
<div>Use the MQTT server details below to uplink to this map.</div>
|
||||
<div>Detta är <b>STHLM-MESH</b>'s egen karta, som drivs av våran MQTT Server. Taken är att ha en karta som enbart fokuserar på stockholm. Den är baserad på Liam Cottle's open source projekt Meshtastic Map. Våran fork finner du på Github. MQTT servern vidarebefordrar alla paket till Liam Cottle's Karta.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-bold mb-2">Beskrivning</div>
|
||||
<div class="space-y-2">
|
||||
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
||||
<div class="font-semibold">Hur kan jag ansluta min nod till MQTT servern?</div>
|
||||
<div>Då vi enbart vill analysera Meshen i stockholm är MQTT servern inte öppen för alla.</div>
|
||||
<div>Vill du koppla upp din nod, kontakta @Roslund på Discord.</div>
|
||||
</div>
|
||||
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
||||
<div class="font-semibold">What MQTT server should I use?</div>
|
||||
<div class="font-semibold">Inställningar:</div>
|
||||
<ul class="list-disc list-inside">
|
||||
<li>Address: mqtt.meshtastic.liamcottle.net</li>
|
||||
<li>Username: uplink</li>
|
||||
<li>Password: uplink</li>
|
||||
<li>Address: mqtt.sthlm-mesh.se</li>
|
||||
<li>Encryption Enabled: Yes</li>
|
||||
<li>JSON Output: No</li>
|
||||
<li>TLS Enabled: No</li>
|
||||
</ul>
|
||||
<div>Please note, nodes can only uplink to this server. Downlink is disabled.</div>
|
||||
<div>We suggest running dedicated nodes for uplinking to the map.</div>
|
||||
</div>
|
||||
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
||||
<div class="font-semibold">How do I remove my node from the map?</div>
|
||||
<div>Nodes that have not been heard for 7 days are automatically removed.</div>
|
||||
<div>Disable position reporting in your node to prevent it coming back.</div>
|
||||
<div>Use custom encryption keys so the public can't see your position data.</div>
|
||||
<div>If your node has v2.5 firmware or newer, we ignore packets if "OK to MQTT" is disabled.</div>
|
||||
<div>To have your node removed now, please <a target="_blank" href="https://liamcottle.com/contact" class="link">contact me</a>.</div>
|
||||
</div>
|
||||
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
||||
<div class="font-semibold">How do I see neighbours a node heard?</div>
|
||||
<div>Open the top right layers panel and enable neighbours.</div>
|
||||
<div>Some neighbours are from MQTT, this is patched in latest firmware.</div>
|
||||
</div>
|
||||
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
||||
<div class="font-semibold">Why is my node showing up in the wrong place?</div>
|
||||
<div>This public map obfuscates your position packets for v2.4 firmware and older.</div>
|
||||
<div>Nodes on v2.4 and older have their positions obfuscated to 2 decimal places.</div>
|
||||
<div>Nodes on v2.5 and newer, with "OK to MQTT" enabled, will show positions with your configured precision.</div>
|
||||
<div>Nodes on v2.5 and newer, with "OK to MQTT" disabled, will not update their positions.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- legal -->
|
||||
<div>
|
||||
<div class="font-bold mb-2">Legal</div>
|
||||
@@ -728,41 +600,6 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- lora config -->
|
||||
<div>
|
||||
<div class="bg-gray-200 p-2 font-semibold">LoRa Config</div>
|
||||
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
||||
|
||||
<!-- region -->
|
||||
<li class="flex p-3">
|
||||
<div class="text-sm font-medium text-gray-900">Region</div>
|
||||
<div class="ml-auto text-sm text-gray-700">
|
||||
<span v-if="selectedNode.region_name">{{ selectedNode.region_name }} ({{ getRegionFrequencyRange(selectedNode.region_name) }})</span>
|
||||
<span v-else>???</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- modem preset -->
|
||||
<li class="flex p-3">
|
||||
<div class="text-sm font-medium text-gray-900">Modem Preset</div>
|
||||
<div class="ml-auto text-sm text-gray-700">
|
||||
<span v-if="selectedNode.modem_preset_name">{{ selectedNode.modem_preset_name }}</span>
|
||||
<span v-else>???</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- has default channel -->
|
||||
<li class="flex p-3">
|
||||
<div class="text-sm font-medium text-gray-900">Has Default Channel</div>
|
||||
<div class="ml-auto text-sm text-gray-700">
|
||||
<span v-if="selectedNode.has_default_channel != null">{{ selectedNode.has_default_channel ? "Yes" : "No" }}</span>
|
||||
<span v-else>???</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- position -->
|
||||
<div>
|
||||
<div @click.stop class="flex bg-gray-200 p-2 font-semibold">
|
||||
@@ -805,6 +642,7 @@
|
||||
<option value="1d">1 Day</option>
|
||||
<option value="3d">3 Days</option>
|
||||
<option value="7d">7 Days</option>
|
||||
<option value="30d">30 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -895,6 +733,7 @@
|
||||
<option value="1d">1 Day</option>
|
||||
<option value="3d">3 Days</option>
|
||||
<option value="7d">7 Days</option>
|
||||
<option value="30d">30 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -919,6 +758,10 @@
|
||||
<div class="my-auto w-2 h-2 bg-orange-500 rounded-full"></div>
|
||||
<div class="my-auto ml-1 text-sm text-gray-500">Pressure</div>
|
||||
</div>
|
||||
<div class="flex mx-auto">
|
||||
<div class="my-auto w-2 h-2 bg-pink-400 rounded-full"></div>
|
||||
<div class="my-auto ml-1 text-sm text-gray-500">IAQ</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -964,6 +807,7 @@
|
||||
<option value="1d">1 Day</option>
|
||||
<option value="3d">3 Days</option>
|
||||
<option value="7d">7 Days</option>
|
||||
<option value="30d">30 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1471,6 +1315,28 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- configTraceroutesMaxAgeInSeconds -->
|
||||
<div class="p-2">
|
||||
<label class="block text-sm font-medium text-gray-900">Traceroutes Max Age</label>
|
||||
<div class="text-xs text-gray-600 mb-2">Traceroute edges older than this time are hidden. Reload to update map.</div>
|
||||
<select v-model="configTraceroutesMaxAgeInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
||||
<option :value="null">Show All</option>
|
||||
<option value="900">15 minutes</option>
|
||||
<option value="1800">30 minutes</option>
|
||||
<option value="3600">1 hour</option>
|
||||
<option value="10800">3 hours</option>
|
||||
<option value="21600">6 hours</option>
|
||||
<option value="43200">12 hours</option>
|
||||
<option value="86400">24 hours</option>
|
||||
<option value="172800">2 days</option>
|
||||
<option value="259200">3 days</option>
|
||||
<option value="345600">4 days</option>
|
||||
<option value="432000">5 days</option>
|
||||
<option value="518400">6 days</option>
|
||||
<option value="604800">7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- configNeighboursMaxDistanceInMeters -->
|
||||
<div class="p-2">
|
||||
<label class="block text-sm font-medium text-gray-900">Neighbours Max Distance (meters)</label>
|
||||
@@ -1769,6 +1635,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getConfigTraceroutesMaxAgeInSeconds() {
|
||||
const value = localStorage.getItem("config_traceroutes_max_age_in_seconds");
|
||||
// default to 3 days if unset, to limit payloads
|
||||
return value != null ? parseInt(value) : 259200;
|
||||
}
|
||||
|
||||
function setConfigTraceroutesMaxAgeInSeconds(value) {
|
||||
if(value != null){
|
||||
return localStorage.setItem("config_traceroutes_max_age_in_seconds", value);
|
||||
} else {
|
||||
return localStorage.removeItem("config_traceroutes_max_age_in_seconds");
|
||||
}
|
||||
}
|
||||
|
||||
function getConfigNeighboursMaxDistanceInMeters() {
|
||||
const value = localStorage.getItem("config_neighbours_max_distance_in_meters");
|
||||
return value != null ? parseInt(value) : null;
|
||||
@@ -1805,6 +1685,7 @@
|
||||
configNodesDisconnectedAgeInSeconds: window.getConfigNodesDisconnectedAgeInSeconds(),
|
||||
configNodesOfflineAgeInSeconds: window.getConfigNodesOfflineAgeInSeconds(),
|
||||
configWaypointsMaxAgeInSeconds: window.getConfigWaypointsMaxAgeInSeconds(),
|
||||
configTraceroutesMaxAgeInSeconds: window.getConfigTraceroutesMaxAgeInSeconds(),
|
||||
configNeighboursMaxDistanceInMeters: window.getConfigNeighboursMaxDistanceInMeters(),
|
||||
configZoomLevelGoToNode: window.getConfigZoomLevelGoToNode(),
|
||||
configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(),
|
||||
@@ -1828,9 +1709,9 @@
|
||||
selectedNodeMqttMetrics: [],
|
||||
selectedNodeTraceroutes: [],
|
||||
|
||||
deviceMetricsTimeRange: "3d",
|
||||
environmentMetricsTimeRange: "3d",
|
||||
powerMetricsTimeRange: "3d",
|
||||
deviceMetricsTimeRange: "7d",
|
||||
environmentMetricsTimeRange: "7d",
|
||||
powerMetricsTimeRange: "7d",
|
||||
|
||||
isPositionHistoryModalExpanded: true,
|
||||
positionHistoryDateTimeFrom: null,
|
||||
@@ -1841,6 +1722,7 @@
|
||||
selectedNodePositionHistoryPolyLines: [],
|
||||
|
||||
selectedTraceRoute: null,
|
||||
tracerouteEdges: [],
|
||||
|
||||
selectedNodeToShowNeighbours: null,
|
||||
selectedNodeToShowNeighboursType: null,
|
||||
@@ -1892,7 +1774,7 @@
|
||||
methods: {
|
||||
getAnnouncementId: function() {
|
||||
// change this when making a new announcement
|
||||
return "2";
|
||||
return "1";
|
||||
},
|
||||
shouldShowAnnouncement: function() {
|
||||
const lastSeenAnnouncementId = window.localStorage.getItem("last-seen-announcement-id");
|
||||
@@ -1919,6 +1801,7 @@
|
||||
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;
|
||||
@@ -1935,6 +1818,10 @@
|
||||
timeFrom = sevenDaysAgoInMilliseconds;
|
||||
break;
|
||||
}
|
||||
case "30d": {
|
||||
timeFrom = thirtyDaysAgoInMilliseconds;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window.axios.get(`/api/v1/nodes/${nodeId}/device-metrics`, {
|
||||
@@ -1956,6 +1843,7 @@
|
||||
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;
|
||||
@@ -1972,6 +1860,10 @@
|
||||
timeFrom = sevenDaysAgoInMilliseconds;
|
||||
break;
|
||||
}
|
||||
case "30d": {
|
||||
timeFrom = thirtyDaysAgoInMilliseconds;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window.axios.get(`/api/v1/nodes/${nodeId}/environment-metrics`, {
|
||||
@@ -1993,6 +1885,7 @@
|
||||
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;
|
||||
@@ -2009,6 +1902,10 @@
|
||||
timeFrom = sevenDaysAgoInMilliseconds;
|
||||
break;
|
||||
}
|
||||
case "30d": {
|
||||
timeFrom = thirtyDaysAgoInMilliseconds;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window.axios.get(`/api/v1/nodes/${nodeId}/power-metrics`, {
|
||||
@@ -2202,11 +2099,13 @@
|
||||
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
|
||||
@@ -2246,12 +2145,23 @@
|
||||
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 * 3, // only show lines between metrics with a 3 hour or less gap
|
||||
spanGaps: 1000 * 60 * 60 * 24, // only show lines between metrics with a 24 hour or less gap
|
||||
elements: {
|
||||
point: {
|
||||
radius: 2,
|
||||
@@ -2284,6 +2194,10 @@
|
||||
drawOnChartArea: false, // only want the grid lines for one axis to show up
|
||||
},
|
||||
},
|
||||
yIAQ: {
|
||||
type: 'linear',
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
@@ -2684,6 +2598,9 @@
|
||||
configWaypointsMaxAgeInSeconds() {
|
||||
window.setConfigWaypointsMaxAgeInSeconds(this.configWaypointsMaxAgeInSeconds);
|
||||
},
|
||||
configTraceroutesMaxAgeInSeconds() {
|
||||
window.setConfigTraceroutesMaxAgeInSeconds(this.configTraceroutesMaxAgeInSeconds);
|
||||
},
|
||||
configNeighboursMaxDistanceInMeters() {
|
||||
window.setConfigNeighboursMaxDistanceInMeters(this.configNeighboursMaxDistanceInMeters);
|
||||
},
|
||||
@@ -2719,6 +2636,7 @@
|
||||
var nodeMarkers = {};
|
||||
var selectedNodeOutlineCircle = null;
|
||||
var waypoints = [];
|
||||
var tracerouteEdgesCache = [];
|
||||
|
||||
// set map bounds to be a little more than full size to prevent panning off screen
|
||||
var bounds = [
|
||||
@@ -2726,13 +2644,13 @@
|
||||
[100, 500], // bottom right
|
||||
];
|
||||
|
||||
// create map positioned over AU and NZ
|
||||
// create map positioned over Stockholm
|
||||
var map = L.map('map', {
|
||||
maxBounds: bounds,
|
||||
}).setView([
|
||||
-15,
|
||||
150,
|
||||
], 2);
|
||||
59.3,
|
||||
378.1,
|
||||
], 10);
|
||||
|
||||
// remove leaflet link
|
||||
map.attributionControl.setPrefix('');
|
||||
@@ -2780,6 +2698,7 @@
|
||||
// create layer groups
|
||||
var nodesLayerGroup = new L.LayerGroup();
|
||||
var neighboursLayerGroup = new L.LayerGroup();
|
||||
var backboneNeighboursLayerGroup = new L.LayerGroup();
|
||||
var nodeNeighboursLayerGroup = new L.LayerGroup();
|
||||
var nodesClusteredLayerGroup = L.markerClusterGroup({
|
||||
showCoverageOnHover: false,
|
||||
@@ -2789,8 +2708,10 @@
|
||||
showCoverageOnHover: false,
|
||||
disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
|
||||
});
|
||||
var nodesBackboneLayerGroup = new L.LayerGroup();
|
||||
var waypointsLayerGroup = new L.LayerGroup();
|
||||
var nodePositionHistoryLayerGroup = new L.LayerGroup();
|
||||
var traceroutesLayerGroup = new L.LayerGroup();
|
||||
|
||||
// create icons
|
||||
var iconMqttConnected = L.divIcon({
|
||||
@@ -2848,14 +2769,17 @@
|
||||
"Nodes": {
|
||||
"All": nodesLayerGroup,
|
||||
"Routers": nodesRouterLayerGroup,
|
||||
"Backbone": nodesBackboneLayerGroup,
|
||||
"Clustered": nodesClusteredLayerGroup,
|
||||
"None": new L.LayerGroup(),
|
||||
},
|
||||
"Overlays": {
|
||||
"Legend": legendLayerGroup,
|
||||
"Neighbours": neighboursLayerGroup,
|
||||
"Backbone Connection": backboneNeighboursLayerGroup,
|
||||
"Waypoints": waypointsLayerGroup,
|
||||
"Position History": nodePositionHistoryLayerGroup,
|
||||
"Traceroutes": traceroutesLayerGroup,
|
||||
},
|
||||
}, {
|
||||
// make the "Nodes" group exclusive (use radio inputs instead of checkbox)
|
||||
@@ -2863,7 +2787,7 @@
|
||||
}).addTo(map);
|
||||
|
||||
// enable base layers
|
||||
nodesClusteredLayerGroup.addTo(map);
|
||||
nodesLayerGroup.addTo(map);
|
||||
|
||||
// enable overlay layers based on config
|
||||
const enabledOverlayLayers = getConfigMapEnabledOverlayLayers();
|
||||
@@ -2873,12 +2797,18 @@
|
||||
if(enabledOverlayLayers.includes("Neighbours")){
|
||||
neighboursLayerGroup.addTo(map);
|
||||
}
|
||||
if(enabledOverlayLayers.includes("Backbone Connection")){
|
||||
backboneNeighboursLayerGroup.addTo(map);
|
||||
}
|
||||
if(enabledOverlayLayers.includes("Waypoints")){
|
||||
waypointsLayerGroup.addTo(map);
|
||||
}
|
||||
if(enabledOverlayLayers.includes("Position History")){
|
||||
nodePositionHistoryLayerGroup.addTo(map);
|
||||
}
|
||||
if(enabledOverlayLayers.includes("Traceroutes")){
|
||||
traceroutesLayerGroup.addTo(map);
|
||||
}
|
||||
|
||||
// update config when map overlay is added
|
||||
map.on('overlayadd', function(event) {
|
||||
@@ -3018,16 +2948,22 @@
|
||||
nodesLayerGroup.clearLayers();
|
||||
nodesClusteredLayerGroup.clearLayers();
|
||||
nodesRouterLayerGroup.clearLayers();
|
||||
nodesBackboneLayerGroup.clearLayers();
|
||||
}
|
||||
|
||||
function clearAllNeighbours() {
|
||||
neighboursLayerGroup.clearLayers();
|
||||
backboneNeighboursLayerGroup.clearLayers();
|
||||
}
|
||||
|
||||
function clearAllWaypoints() {
|
||||
waypointsLayerGroup.clearLayers();
|
||||
}
|
||||
|
||||
function clearAllTraceroutes() {
|
||||
traceroutesLayerGroup.clearLayers();
|
||||
}
|
||||
|
||||
function closeAllPopups() {
|
||||
map.eachLayer(function(layer) {
|
||||
if(layer.options.pane === "popupPane"){
|
||||
@@ -3095,8 +3031,9 @@
|
||||
}
|
||||
|
||||
function getColourForSnr(snr) {
|
||||
if(snr >= 0) return "#16a34a"; // good
|
||||
if(snr < 0) return "#dc2626"; // bad
|
||||
if(snr >= -5) return "#16a34a"; // good
|
||||
if(snr > -15) return "#fff200"; // meh
|
||||
if(snr <= -15) return "#dc2626"; // bad
|
||||
}
|
||||
|
||||
function cleanUpNodeNeighbours() {
|
||||
@@ -3121,13 +3058,13 @@
|
||||
const node1MarkerColour = "0000FF"; // blue
|
||||
const node1Latitude = node1.latitude;
|
||||
const node1Longitude = node1.longitude;
|
||||
const node1ElevationMSL = ""; // node1.altitude ?? "";
|
||||
const node1ElevationMSL = node1.altitude ?? "";
|
||||
|
||||
// node 2 (right side of image)
|
||||
const node2MarkerColour = "0000FF"; // blue
|
||||
const node2Latitude = node2.latitude;
|
||||
const node2Longitude = node2.longitude;
|
||||
const node2ElevationMSL = ""; // node2.altitude ?? "";
|
||||
const node2ElevationMSL = node2.altitude ?? "";
|
||||
|
||||
// generate terrain profile image url
|
||||
return "https://heywhatsthat.com/bin/profile-0904.cgi?" + new URLSearchParams({
|
||||
@@ -3162,6 +3099,51 @@
|
||||
// show overlay for node neighbours
|
||||
window._onShowNodeNeighboursWeHeardClick(node);
|
||||
|
||||
// Overlay ALL traceroute edges that terminate at this node (edge.to == node.node_id)
|
||||
for (const edge of tracerouteEdgesCache) {
|
||||
if (String(edge.to) !== String(node.node_id)) continue;
|
||||
|
||||
const fromMarker = findNodeMarkerById(edge.from);
|
||||
if (!fromMarker) continue;
|
||||
|
||||
const snrDb = (typeof edge.snr === 'number') ? (edge.snr === -128 ? null : (Number(edge.snr) / 4)) : null;
|
||||
const trColour = snrDb != null ? getColourForSnr(snrDb) : '#6b7280';
|
||||
|
||||
const trTooltip = (() => {
|
||||
const fromNode = findNodeById(edge.from);
|
||||
const toNode = findNodeById(node.node_id);
|
||||
const distanceInMeters = fromMarker.getLatLng().distanceTo(nodeMarker.getLatLng()).toFixed(2);
|
||||
let distance = `${distanceInMeters} meters`;
|
||||
if (distanceInMeters >= 1000) {
|
||||
const km = (distanceInMeters / 1000).toFixed(2);
|
||||
distance = `${km} kilometers`;
|
||||
}
|
||||
const terrainImageUrl = getTerrainProfileImage(fromNode, toNode);
|
||||
const targetNode = edge.traceroute_from ? findNodeById(edge.traceroute_from) : null;
|
||||
const initiatorNode = edge.traceroute_to ? findNodeById(edge.traceroute_to) : null;
|
||||
return `<b>Traceroute hop</b>`
|
||||
+ `<br/>from <b>[${escapeString(fromNode.short_name)}] ${escapeString(fromNode.long_name)}</b>`
|
||||
+ ` to <b>[${escapeString(toNode.short_name)}] ${escapeString(toNode.long_name)}</b>`
|
||||
+ `<br/>SNR: ${snrDb != null ? snrDb + 'dB' : '?'}`
|
||||
+ `<br/>Distance: ${distance}`
|
||||
+ (initiatorNode ? `<br/>Traceroute from: <b>[${escapeString(initiatorNode.short_name)}] ${escapeString(initiatorNode.long_name)}</b>` : '')
|
||||
+ (targetNode ? `<br/>Traceroute to: <b>[${escapeString(targetNode.short_name)}] ${escapeString(targetNode.long_name)}</b>` : '')
|
||||
+ `<br/><br/>Terrain images from <a href=\"http://www.heywhatsthat.com\" target=\"_blank\">HeyWhatsThat.com</a>`
|
||||
+ `<br/><a href=\"${terrainImageUrl}\" target=\"_blank\"><img src=\"${terrainImageUrl}\" width=\"100%\"></a>`;
|
||||
})();
|
||||
|
||||
L.polyline([
|
||||
fromMarker.getLatLng(),
|
||||
nodeMarker.getLatLng(),
|
||||
], {
|
||||
color: trColour,
|
||||
opacity: 0.9,
|
||||
}).arrowheads({ size: '10px', fill: true, offsets: { start: '25px', end: '25px' } })
|
||||
.addTo(nodeNeighboursLayerGroup)
|
||||
.bindTooltip(trTooltip, { sticky: true, opacity: 1, interactive: true })
|
||||
.bindPopup(trTooltip);
|
||||
}
|
||||
|
||||
// ensure we have neighbours to show
|
||||
const neighbours = node.neighbours ?? [];
|
||||
if(neighbours.length === 0){
|
||||
@@ -3227,8 +3209,6 @@
|
||||
const tooltip = `<b>[${escapeString(node.short_name)}] ${escapeString(node.long_name)}</b> heard <b>[${escapeString(neighbourNode.short_name)}] ${escapeString(neighbourNode.long_name)}</b>`
|
||||
+ `<br/>SNR: ${neighbour.snr}dB`
|
||||
+ `<br/>Distance: ${distance}`
|
||||
+ `<br/><br/>ID: ${node.node_id} heard ${neighbourNode.node_id}`
|
||||
+ `<br/>Hex ID: ${node.node_id_hex} heard ${neighbourNode.node_id_hex}`
|
||||
+ (node.neighbours_updated_at ? `<br/>Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '')
|
||||
+ `<br/><br/>Terrain images from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`
|
||||
+ `<br/><a href="${terrainImageUrl}" target="_blank"><img src="${terrainImageUrl}" width="100%"></a>`;
|
||||
@@ -3287,6 +3267,51 @@
|
||||
|
||||
}
|
||||
|
||||
// Overlay ALL traceroute edges that originate from this node (edge.from == node.node_id)
|
||||
for (const edge of tracerouteEdgesCache) {
|
||||
if (String(edge.from) !== String(node.node_id)) continue;
|
||||
|
||||
const toMarker = findNodeMarkerById(edge.to);
|
||||
if (!toMarker) continue;
|
||||
|
||||
const snrDb = (typeof edge.snr === 'number') ? (edge.snr === -128 ? null : (Number(edge.snr) / 4)) : null;
|
||||
const trColour = snrDb != null ? getColourForSnr(snrDb) : '#6b7280';
|
||||
|
||||
const trTooltip2 = (() => {
|
||||
const fromNode = findNodeById(node.node_id);
|
||||
const toNode = findNodeById(edge.to);
|
||||
const distanceInMeters = nodeMarker.getLatLng().distanceTo(toMarker.getLatLng()).toFixed(2);
|
||||
let distance = `${distanceInMeters} meters`;
|
||||
if (distanceInMeters >= 1000) {
|
||||
const km = (distanceInMeters / 1000).toFixed(2);
|
||||
distance = `${km} kilometers`;
|
||||
}
|
||||
const terrainImageUrl = getTerrainProfileImage(fromNode, toNode);
|
||||
const targetNode = edge.traceroute_from ? findNodeById(edge.traceroute_from) : null;
|
||||
const initiatorNode = edge.traceroute_to ? findNodeById(edge.traceroute_to) : null;
|
||||
return `<b>Traceroute hop</b>`
|
||||
+ `<br/>from <b>[${escapeString(fromNode.short_name)}] ${escapeString(fromNode.long_name)}</b>`
|
||||
+ ` to <b>[${escapeString(toNode.short_name)}] ${escapeString(toNode.long_name)}</b>`
|
||||
+ `<br/>SNR: ${snrDb != null ? snrDb + 'dB' : '?'}`
|
||||
+ `<br/>Distance: ${distance}`
|
||||
+ (initiatorNode ? `<br/>Traceroute from: <b>[${escapeString(initiatorNode.short_name)}] ${escapeString(initiatorNode.long_name)}</b>` : '')
|
||||
+ (targetNode ? `<br/>Traceroute to: <b>[${escapeString(targetNode.short_name)}] ${escapeString(targetNode.long_name)}</b>` : '')
|
||||
+ `<br/><br/>Terrain images from <a href=\"http://www.heywhatsthat.com\" target=\"_blank\">HeyWhatsThat.com</a>`
|
||||
+ `<br/><a href=\"${terrainImageUrl}\" target=\"_blank\"><img src=\"${terrainImageUrl}\" width=\"100%\"></a>`;
|
||||
})();
|
||||
|
||||
L.polyline([
|
||||
nodeMarker.getLatLng(),
|
||||
toMarker.getLatLng(),
|
||||
], {
|
||||
color: trColour,
|
||||
opacity: 0.9,
|
||||
}).arrowheads({ size: '10px', fill: true, offsets: { start: '25px', end: '25px' } })
|
||||
.addTo(nodeNeighboursLayerGroup)
|
||||
.bindTooltip(trTooltip2, { sticky: true, opacity: 1, interactive: true })
|
||||
.bindPopup(trTooltip2);
|
||||
}
|
||||
|
||||
// ensure we have neighbours to show
|
||||
if(neighbourNodeInfos.length === 0){
|
||||
return;
|
||||
@@ -3348,8 +3373,6 @@
|
||||
const tooltip = `<b>[${escapeString(neighbourNode.short_name)}] ${escapeString(neighbourNode.long_name)}</b> heard <b>[${escapeString(node.short_name)}] ${escapeString(node.long_name)}</b>`
|
||||
+ `<br/>SNR: ${neighbour.snr}dB`
|
||||
+ `<br/>Distance: ${distance}`
|
||||
+ `<br/><br/>ID: ${neighbourNode.node_id} heard ${node.node_id}`
|
||||
+ `<br/>Hex ID: ${neighbourNode.node_id_hex} heard ${node.node_id_hex}`
|
||||
+ (neighbourNode.neighbours_updated_at ? `<br/>Updated: ${moment(new Date(neighbourNode.neighbours_updated_at)).fromNow()}` : '')
|
||||
+ `<br/><br/>Terrain images from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`
|
||||
+ `<br/><a href="${terrainImageUrl}" target="_blank"><img src="${terrainImageUrl}" width="100%"></a>`;
|
||||
@@ -3375,6 +3398,7 @@
|
||||
clearAllNodes();
|
||||
clearAllNeighbours();
|
||||
clearAllWaypoints();
|
||||
clearAllTraceroutes();
|
||||
clearNodeOutline();
|
||||
cleanUpNodeNeighbours();
|
||||
}
|
||||
@@ -3472,9 +3496,15 @@
|
||||
if(nodeHasUplinkedToMqttRecently){
|
||||
icon = iconMqttConnected;
|
||||
}
|
||||
// To not have overlapping nodes.
|
||||
// Should probbably check if there is allready an other node in the same position before applying jitter.
|
||||
var jitter = 0
|
||||
if (node.position_precision != 32) {
|
||||
jitter = 0.0005 * (Math.random() - 0.5);
|
||||
}
|
||||
|
||||
// create node marker
|
||||
const marker = L.marker([node.latitude, longitude], {
|
||||
const marker = L.marker([node.latitude + jitter, longitude + jitter], {
|
||||
icon: icon,
|
||||
tagName: node.node_id,
|
||||
// we want to show online nodes above offline, but without needing to use separate layer groups
|
||||
@@ -3496,6 +3526,11 @@
|
||||
nodesRouterLayerGroup.addLayer(marker);
|
||||
}
|
||||
|
||||
// add markers for backbone to layer group
|
||||
if(node.is_backbone) {
|
||||
nodesBackboneLayerGroup.addLayer(marker);
|
||||
}
|
||||
|
||||
// show tooltip on desktop only
|
||||
if(!isMobile()){
|
||||
marker.bindTooltip(getTooltipContentForNode(node), {
|
||||
@@ -3541,7 +3576,6 @@
|
||||
}
|
||||
|
||||
// add node neighbours
|
||||
var polylineOffset = 0;
|
||||
const neighbours = node.neighbours ?? [];
|
||||
for(const neighbour of neighbours){
|
||||
|
||||
@@ -3566,6 +3600,10 @@
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check our neighour also has us as a neighbour.
|
||||
const matchingNode = updatedNodes.find(n => n.node_id == neighbour.node_id);
|
||||
const symmetrical = matchingNode?.neighbours?.some(n => String(n.node_id) === String(node.node_id)) ?? false;
|
||||
|
||||
// add neighbour line to map
|
||||
const line = L.polyline([
|
||||
currentNode.getLatLng(),
|
||||
@@ -3573,11 +3611,31 @@
|
||||
], {
|
||||
color: '#2563eb',
|
||||
opacity: 0.75,
|
||||
offset: polylineOffset,
|
||||
// if we have a symmetrical connection, offset the the line so they don't overlapp
|
||||
offset: symmetrical ? 3 : 0,
|
||||
}).addTo(neighboursLayerGroup);
|
||||
|
||||
// increase offset so next neighbour does not overlay other neighbours from self
|
||||
polylineOffset += 2;
|
||||
// additional line for backbone neighbours
|
||||
const backboneNeighbourLine = L.polyline([
|
||||
currentNode.getLatLng(),
|
||||
neighbourNodeMarker.getLatLng(),
|
||||
], {
|
||||
color: getColourForSnr(neighbour.snr),
|
||||
opacity: 0.75,
|
||||
offset: symmetrical ? 3 : 0,
|
||||
}).arrowheads({
|
||||
size: '10px',
|
||||
fill: true,
|
||||
offsets: {
|
||||
start: '25px',
|
||||
end: '25px',
|
||||
},
|
||||
})
|
||||
|
||||
// If both nodes are backbone nodes add to layer group
|
||||
if(node.is_backbone && updatedNodes.find(n => n.node_id == neighbour.node_id)?.is_backbone) {
|
||||
backboneNeighbourLine.addTo(backboneNeighboursLayerGroup);
|
||||
}
|
||||
|
||||
// default to showing distance in meters
|
||||
var distance = `${distanceInMeters} meters`;
|
||||
@@ -3593,8 +3651,6 @@
|
||||
const tooltip = `<b>[${escapeString(node.short_name)}] ${escapeString(node.long_name)}</b> heard <b>[${escapeString(neighbourNode.short_name)}] ${escapeString(neighbourNode.long_name)}</b>`
|
||||
+ `<br/>SNR: ${neighbour.snr}dB`
|
||||
+ `<br/>Distance: ${distance}`
|
||||
+ `<br/><br/>ID: ${node.node_id} heard ${neighbourNode.node_id}`
|
||||
+ `<br/>Hex ID: ${node.node_id_hex} heard ${neighbourNode.node_id_hex}`
|
||||
+ (node.neighbours_updated_at ? `<br/>Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '')
|
||||
+ `<br/><br/>Terrain images from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`
|
||||
+ `<br/><a href="${terrainImageUrl}" target="_blank"><img src="${terrainImageUrl}" width="100%"></a>`;
|
||||
@@ -3610,6 +3666,17 @@
|
||||
event.target.closeTooltip();
|
||||
});
|
||||
|
||||
backboneNeighbourLine.bindTooltip(tooltip, {
|
||||
sticky: true,
|
||||
opacity: 1,
|
||||
interactive: true,
|
||||
})
|
||||
.bindPopup(tooltip)
|
||||
.on('click', function(event) {
|
||||
// close tooltip on click to prevent tooltip and popup showing at same time
|
||||
event.target.closeTooltip();
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3699,6 +3766,72 @@
|
||||
|
||||
}
|
||||
|
||||
function onTracerouteEdgesUpdated(edges) {
|
||||
|
||||
traceroutesLayerGroup.clearLayers();
|
||||
|
||||
tracerouteEdgesCache = edges;
|
||||
|
||||
for (const edge of edges) {
|
||||
// Convert SNR for traceroutes: snr/4 dB; -128 means unknown
|
||||
const snrDb = (typeof edge.snr === 'number')
|
||||
? (edge.snr === -128 ? null : (Number(edge.snr) / 4))
|
||||
: null;
|
||||
const fromNode = findNodeById(edge.from);
|
||||
const toNode = findNodeById(edge.to);
|
||||
if (!fromNode || !toNode) continue;
|
||||
|
||||
const fromMarker = findNodeMarkerById(edge.from);
|
||||
const toMarker = findNodeMarkerById(edge.to);
|
||||
if (!fromMarker || !toMarker) continue;
|
||||
|
||||
const distanceInMeters = fromMarker.getLatLng().distanceTo(toMarker.getLatLng()).toFixed(2);
|
||||
|
||||
let distance = `${distanceInMeters} meters`;
|
||||
if (distanceInMeters >= 1000) {
|
||||
const km = (distanceInMeters / 1000).toFixed(2);
|
||||
distance = `${km} kilometers`;
|
||||
}
|
||||
|
||||
const colour = '#f97316';
|
||||
|
||||
const terrainImageUrl = getTerrainProfileImage(fromNode, toNode);
|
||||
|
||||
// This is backwards. It's because the traceroute packet is sent from the target node.
|
||||
const targetNode = edge.traceroute_from ? findNodeById(edge.traceroute_from) : null;
|
||||
const initiatorNode = edge.traceroute_to ? findNodeById(edge.traceroute_to) : null;
|
||||
|
||||
const tooltip = `<b>Traceroute hop</b>`
|
||||
+ `<br/>from <b>[${escapeString(fromNode.short_name)}] ${escapeString(fromNode.long_name)}</b>`
|
||||
+ ` to <b>[${escapeString(toNode.short_name)}] ${escapeString(toNode.long_name)}</b>`
|
||||
+ `<br/>SNR: ${snrDb != null ? snrDb + 'dB' : '?'}`
|
||||
+ `<br/>Distance: ${distance}`
|
||||
+ (initiatorNode ? `<br/>Traceroute from: <b>[${escapeString(initiatorNode.short_name)}] ${escapeString(initiatorNode.long_name)}</b>` : '')
|
||||
+ (targetNode ? `<br/>Traceroute to: <b>[${escapeString(targetNode.short_name)}] ${escapeString(targetNode.long_name)}</b>` : '')
|
||||
+ (edge.updated_at ? `<br/>Updated: ${moment(new Date(edge.updated_at)).fromNow()}` : '')
|
||||
+ (edge.channel_id ? `<br/>Channel: ${edge.channel_id}` : '')
|
||||
+ `<br/><br/>Terrain images from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`
|
||||
+ `<br/><a href="${terrainImageUrl}" target="_blank"><img src="${terrainImageUrl}" width="100%"></a>`;
|
||||
|
||||
const line = L.polyline([
|
||||
fromMarker.getLatLng(),
|
||||
toMarker.getLatLng(),
|
||||
], {
|
||||
color: colour,
|
||||
opacity: 0.9,
|
||||
}).addTo(traceroutesLayerGroup);
|
||||
|
||||
line.bindTooltip(tooltip, {
|
||||
sticky: true,
|
||||
opacity: 1,
|
||||
interactive: true,
|
||||
}).bindPopup(tooltip)
|
||||
.on('click', function(event) {
|
||||
event.target.closeTooltip();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onPositionHistoryUpdated(updatedPositionHistories) {
|
||||
|
||||
let positionHistoryLinesCords = [];
|
||||
@@ -3831,6 +3964,17 @@
|
||||
onWaypointsUpdated(response.data.waypoints);
|
||||
});
|
||||
|
||||
// fetch traceroute edges
|
||||
const traceroutesMaxAgeSec = getConfigTraceroutesMaxAgeInSeconds();
|
||||
const timeFrom = traceroutesMaxAgeSec ? (Date.now() - traceroutesMaxAgeSec * 1000) : undefined;
|
||||
const params = new URLSearchParams();
|
||||
if (timeFrom) params.set('time_from', timeFrom);
|
||||
await window.axios.get(`/api/v1/traceroutes?${params.toString()}`).then(async (response) => {
|
||||
onTracerouteEdgesUpdated(response.data.traceroute_edges ?? []);
|
||||
}).catch(() => {
|
||||
onTracerouteEdgesUpdated([]);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function getRegionFrequencyRange(regionName) {
|
||||
@@ -3937,9 +4081,7 @@
|
||||
`<br/><br/>Role: ${node.role_name}` +
|
||||
`<br/>Hardware: ${node.hardware_model_name}` +
|
||||
(node.firmware_version != null ? `<br/>Firmware: ${node.firmware_version}` : '') +
|
||||
(node.region_name != null ? `<br/>LoRa Region: ${node.region_name} (${loraFrequencyRange})` : '') +
|
||||
(node.modem_preset_name != null ? `<br/>Modem Preset: ${node.modem_preset_name}` : '') +
|
||||
(node.has_default_channel != null ? `<br/>Has Default Channel: ${node.has_default_channel ? "Yes" : "No"}` : '');
|
||||
`<br/>OK to MQTT: ${node.ok_to_mqtt}`;
|
||||
|
||||
if(node.battery_level){
|
||||
if(node.battery_level > 100){
|
||||
@@ -4060,9 +4202,15 @@
|
||||
|
||||
</script>
|
||||
|
||||
<!-- analytics -->
|
||||
<script type="text/javascript">(function(){var hstc=document.createElement('script'); hstc.src='https://edgecdn.dev/code?code=45dcd3187c04c1eddf214bb44c8686a9';hstc.async=true;var htssc = document.getElementsByTagName('script')[0];htssc.parentNode.insertBefore(hstc, htssc);})();
|
||||
</script><noscript><a href="http://www.hitsteps.com/"><img src="//edgecdn.dev/code?mode=img&code=45dcd3187c04c1eddf214bb44c8686a9" alt="web stats" width="1" height="1" />web stats</a></noscript>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-2RD5193D15"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-2RD5193D15');
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
205
src/stats.js
Normal file
205
src/stats.js
Normal file
@@ -0,0 +1,205 @@
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const protobufjs = require("protobufjs");
|
||||
|
||||
// create prisma db client
|
||||
const { 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'],
|
||||
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 result = await prisma.$queryRaw`
|
||||
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
|
||||
) 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 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 } : {}),
|
||||
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);
|
||||
|
||||
try {
|
||||
const stats = await prisma.$queryRaw`
|
||||
SELECT recorded_at, avg_channel_utilization
|
||||
FROM channel_utilization_stats
|
||||
WHERE recorded_at >= NOW() - INTERVAL ${days} DAY
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user