mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-22 14:01:27 +00:00
Fix owner portal login; add tile worker and cross-network improvements
Owner portal fixes: - Fix session node ID case mismatch: decryptOwnerSession was lowercasing node IDs but /owner/live uppercased the ?nodeId= param, so the find() never matched — users always got "Node is not owned by this session" - Revert sameSite strict → lax (broke sessions after March 18 deploy) - Remove LOWER() wrappers from all owner live SQL (node IDs are uppercase) - Add Fireball's second node (TS17 Yagi North) to owner_account_nodes Tile worker: - Server-side PNG tile rendering via @napi-rs/canvas - Tile snapshot cache, Redis-backed tile cache, tile worker service - /api/tiles/nodes/:z/:x/:y.png endpoint with rate limiter - UK tile TTL logic Cross-network connectivity: - Add 7-day hourly history series to /cross-network-connectivity response - Cache result for 60s to avoid repeated join-heavy query - Fix query to use literal interval instead of interpolated variable Other: - Remove LOWER() from packet/node SQL throughout (IDs stored uppercase) - Various performance and cleanup improvements Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
5d0281b922
commit
acd71b0913
27 files changed
+5682
-371
No files matched your search
Generated
+291
@@ -9,6 +9,8 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@michaelhart/meshcore-decoder": "^0.2.7",
|
||||
"@napi-rs/canvas": "^0.1.58",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
@@ -18,6 +20,7 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.17.6",
|
||||
@@ -70,6 +73,240 @@
|
||||
"meshcore-decoder": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
|
||||
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.97",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.97",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.97",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
|
||||
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
|
||||
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
|
||||
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
|
||||
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
|
||||
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
|
||||
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
|
||||
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
|
||||
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
|
||||
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
|
||||
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
|
||||
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ed25519": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-2.3.0.tgz",
|
||||
@@ -88,6 +325,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/compression": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
@@ -440,6 +687,42 @@
|
||||
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
|
||||
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw=="
|
||||
},
|
||||
"node_modules/compressible": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||
"dependencies": {
|
||||
"mime-db": ">= 1.43.0 < 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"compressible": "~2.0.18",
|
||||
"debug": "2.6.9",
|
||||
"negotiator": "~0.6.4",
|
||||
"on-headers": "~1.1.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/negotiator": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
@@ -1245,6 +1528,14 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
|
||||
+181
-41
@@ -3,7 +3,11 @@ import { rateLimit } from 'express-rate-limit';
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
import { isIP } from 'node:net';
|
||||
import mqtt from 'mqtt';
|
||||
import { Redis } from 'ioredis';
|
||||
import { getNodes, getNodeHistory, getNodeAdverts, getPathHistoryCache, getRecentPacketEvents, getRecentPackets, query, MIN_LINK_OBSERVATIONS } from '../db/index.js';
|
||||
import { renderNodeTile } from '../tiles/renderer.js';
|
||||
import { getTileSnapshotNodes } from '../tiles/snapshot.js';
|
||||
import { isUkTile, UK_TILE_TTL_MS } from '../tiles/worker.js';
|
||||
import { addOwnerNodeForUsername, getBestNodeForMqttUsername, getOwnerNodeIdsForUsername } from '../db/ownerAuth.js';
|
||||
import { getWorkerHealthOverview } from '../health/status.js';
|
||||
import { resolveRequestNetwork } from '../http/requestScope.js';
|
||||
@@ -22,11 +26,13 @@ const INFERRED_NODES_CACHE_TTL_MS = 60_000; // 7-day packet scan, changes slowl
|
||||
const PATH_HISTORY_CACHE_TTL_MS = 60_000; // history cache is rebuilt by worker, not real-time
|
||||
const COVERAGE_CACHE_TTL_MS = 30_000; // geometry changes only on coverage rebuild
|
||||
const CHARTS_CACHE_TTL_MS = 30 * 60_000; // 30 min — background refresh keeps it warm
|
||||
const CROSS_NETWORK_CACHE_TTL_MS = 60_000; // dashboard polls every minute; avoid re-running the join-heavy query
|
||||
const statsCache = new Map<string, { ts: number; data: unknown }>();
|
||||
const inferredNodesCache = new Map<string, { ts: number; data: unknown }>();
|
||||
const pathHistoryCache = new Map<string, { ts: number; data: unknown }>();
|
||||
const coverageCache = new Map<string, { ts: number; data: unknown }>();
|
||||
const chartsCache = new Map<string, { ts: number; data: unknown }>();
|
||||
const crossNetworkCache = new Map<string, { ts: number; data: unknown }>();
|
||||
const chartsInflight = new Map<string, Promise<unknown>>();
|
||||
const OWNER_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const MQTT_USERNAME_MAX_LEN = 128;
|
||||
@@ -80,6 +86,19 @@ const STATS_CHARTS_LIMITER = rateLimit({
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many stats chart requests, slow down' },
|
||||
});
|
||||
const TILE_CACHE_TTL_MS = 30_000; // on-demand non-UK tiles
|
||||
const TILE_CACHE_TTL_UK_MS = UK_TILE_TTL_MS; // on-demand UK tiles (matches worker TTL)
|
||||
const TILE_LIMITER = rateLimit({ windowMs: 60_000, max: 600, standardHeaders: true, legacyHeaders: false });
|
||||
|
||||
let tileRedis: Redis | null = null;
|
||||
function getTileRedis(): Redis {
|
||||
if (tileRedis) return tileRedis;
|
||||
const redisUrl = process.env['REDIS_URL'] ?? 'redis://redis:6379';
|
||||
tileRedis = new Redis(redisUrl);
|
||||
tileRedis.on('error', (e: Error) => console.error('[redis/tiles] error', e.message));
|
||||
return tileRedis;
|
||||
}
|
||||
|
||||
const PROHIBITED_NODE_MARKER = '🚫';
|
||||
const HIDDEN_NODE_MASK_RADIUS_MILES = 1;
|
||||
|
||||
@@ -119,8 +138,8 @@ function decryptOwnerSession(token: string): OwnerSession | null {
|
||||
const parsed = JSON.parse(decoded) as Partial<OwnerSession>;
|
||||
if (!Array.isArray(parsed.nodeIds) || typeof parsed.exp !== 'number') return null;
|
||||
const nodeIds = parsed.nodeIds
|
||||
.map((value) => String(value).trim().toLowerCase())
|
||||
.filter((value) => /^[0-9a-f]{64}$/.test(value));
|
||||
.map((value) => String(value).trim().toUpperCase())
|
||||
.filter((value) => /^[0-9A-F]{64}$/.test(value));
|
||||
if (nodeIds.length < 1) return null;
|
||||
const mqttUsername = typeof parsed.mqttUsername === 'string' ? parsed.mqttUsername.trim() : undefined;
|
||||
return { nodeIds, exp: parsed.exp, mqttUsername: mqttUsername || undefined };
|
||||
@@ -340,7 +359,7 @@ async function buildOwnerDashboard(nodeIds: string[]) {
|
||||
}>(
|
||||
`SELECT node_id, name, network, last_seen, advert_count, lat, lon, iata
|
||||
FROM nodes
|
||||
WHERE LOWER(node_id) = ANY($1::text[])
|
||||
WHERE node_id = ANY($1::text[])
|
||||
ORDER BY last_seen DESC NULLS LAST`,
|
||||
[nodeIds],
|
||||
),
|
||||
@@ -349,14 +368,14 @@ async function buildOwnerDashboard(nodeIds: string[]) {
|
||||
COUNT(*) FILTER (WHERE time > NOW() - INTERVAL '24 hours')::int AS packets_24h,
|
||||
COUNT(*) FILTER (WHERE time > NOW() - INTERVAL '7 days')::int AS packets_7d
|
||||
FROM packets
|
||||
WHERE LOWER(src_node_id) = ANY($1::text[])`,
|
||||
WHERE src_node_id = ANY($1::text[])`,
|
||||
[nodeIds],
|
||||
),
|
||||
query<{ packets_24h: number }>(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE time > NOW() - INTERVAL '24 hours')::int AS packets_24h
|
||||
FROM packets
|
||||
WHERE LOWER(rx_node_id) = ANY($1::text[])`,
|
||||
WHERE rx_node_id = ANY($1::text[])`,
|
||||
[nodeIds],
|
||||
),
|
||||
]);
|
||||
@@ -409,8 +428,8 @@ type NetworkFilters = {
|
||||
};
|
||||
|
||||
function normalizeObserverQuery(value: unknown): string | undefined {
|
||||
const observer = String(value ?? '').trim().toLowerCase();
|
||||
return observer && /^[0-9a-f]{64}$/.test(observer) ? observer : undefined;
|
||||
const observer = String(value ?? '').trim().toUpperCase();
|
||||
return observer && /^[0-9A-F]{64}$/.test(observer) ? observer : undefined;
|
||||
}
|
||||
|
||||
function normalizeIp(value: string | undefined): string {
|
||||
@@ -468,9 +487,9 @@ function networkFilters(network?: string, observer?: string): NetworkFilters {
|
||||
if (networkParam) packetConditions.push(`network = ${networkParam}`);
|
||||
else {
|
||||
packetConditions.push(`network IS DISTINCT FROM 'test'`);
|
||||
packetConditions.push(`LOWER(COALESCE(rx_node_id, '')) NOT IN (SELECT LOWER(node_id) FROM nodes WHERE network = 'test')`);
|
||||
packetConditions.push(`COALESCE(rx_node_id, '') NOT IN (SELECT node_id FROM nodes WHERE network = 'test')`);
|
||||
}
|
||||
if (observerParam) packetConditions.push(`LOWER(rx_node_id) = LOWER(${observerParam})`);
|
||||
if (observerParam) packetConditions.push(`rx_node_id = ${observerParam}`);
|
||||
|
||||
const nodeConditions = (alias?: string) => {
|
||||
const prefix = alias ? `${alias}.` : '';
|
||||
@@ -480,13 +499,13 @@ function networkFilters(network?: string, observer?: string): NetworkFilters {
|
||||
if (observerParam) {
|
||||
conditions.push(
|
||||
`(
|
||||
LOWER(${prefix}node_id) = LOWER(${observerParam})
|
||||
${prefix}node_id = ${observerParam}
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM packets p
|
||||
WHERE LOWER(p.rx_node_id) = LOWER(${observerParam})
|
||||
WHERE p.rx_node_id = ${observerParam}
|
||||
${networkParam ? `AND p.network = ${networkParam}` : ''}
|
||||
AND LOWER(p.src_node_id) = LOWER(${prefix}node_id)
|
||||
AND p.src_node_id = ${prefix}node_id
|
||||
)
|
||||
)`,
|
||||
);
|
||||
@@ -506,9 +525,9 @@ function networkFilters(network?: string, observer?: string): NetworkFilters {
|
||||
} else {
|
||||
conditions.push(`${prefix}network IS DISTINCT FROM 'test'`);
|
||||
conditions.push(`split_part(${prefix}topic, '/', 1) <> 'meshcore-test'`);
|
||||
conditions.push(`LOWER(COALESCE(${prefix}rx_node_id, '')) NOT IN (SELECT LOWER(node_id) FROM nodes WHERE network = 'test')`);
|
||||
conditions.push(`COALESCE(${prefix}rx_node_id, '') NOT IN (SELECT node_id FROM nodes WHERE network = 'test')`);
|
||||
}
|
||||
if (observerParam) conditions.push(`LOWER(${prefix}rx_node_id) = LOWER(${observerParam})`);
|
||||
if (observerParam) conditions.push(`${prefix}rx_node_id = ${observerParam}`);
|
||||
return conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '';
|
||||
},
|
||||
nodes: nodeConditions().length > 0 ? `AND ${nodeConditions().join(' AND ')}` : '',
|
||||
@@ -1651,7 +1670,7 @@ router.post('/owner/login', OWNER_LOGIN_LIMITER, async (req, res) => {
|
||||
res.cookie(OWNER_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: isSecureRequest(req),
|
||||
sameSite: 'strict',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: OWNER_SESSION_TTL_MS,
|
||||
});
|
||||
@@ -1698,9 +1717,9 @@ router.get('/owner/live', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedNodeId = String(req.query['nodeId'] ?? '').trim().toLowerCase();
|
||||
const requestedNodeId = String(req.query['nodeId'] ?? '').trim().toUpperCase();
|
||||
const selectedNodeId = requestedNodeId
|
||||
? ownedNodeIds.find((id) => id.toLowerCase() === requestedNodeId)
|
||||
? ownedNodeIds.find((id) => id === requestedNodeId)
|
||||
: ownedNodeIds[0];
|
||||
if (!selectedNodeId) {
|
||||
res.status(403).json({ error: 'Node is not owned by this session' });
|
||||
@@ -1735,7 +1754,7 @@ router.get('/owner/live', async (req, res) => {
|
||||
}>(
|
||||
`SELECT node_id, name, network, iata, advert_count, last_seen, lat, lon, role
|
||||
FROM nodes
|
||||
WHERE LOWER(node_id) = LOWER($1)
|
||||
WHERE node_id = $1
|
||||
LIMIT 1`,
|
||||
[selectedNodeId],
|
||||
),
|
||||
@@ -1760,10 +1779,10 @@ router.get('/owner/live', async (req, res) => {
|
||||
MAX(p.time)::text AS last_seen
|
||||
FROM packets p
|
||||
LEFT JOIN nodes n ON n.node_id = p.src_node_id
|
||||
WHERE LOWER(p.rx_node_id) = LOWER($1)
|
||||
WHERE p.rx_node_id = $1
|
||||
AND p.hop_count = 0
|
||||
AND p.src_node_id IS NOT NULL
|
||||
AND LOWER(p.src_node_id) <> LOWER($1)
|
||||
AND p.src_node_id <> $1
|
||||
AND p.time > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY p.src_node_id, n.name, n.network, n.iata, n.lat, n.lon
|
||||
ORDER BY packets_24h DESC
|
||||
@@ -1806,7 +1825,7 @@ router.get('/owner/live', async (req, res) => {
|
||||
ORDER BY p.time DESC
|
||||
) AS rn
|
||||
FROM packets p
|
||||
WHERE LOWER(p.rx_node_id) = LOWER($1)
|
||||
WHERE p.rx_node_id = $1
|
||||
)
|
||||
SELECT
|
||||
r.time::text AS time,
|
||||
@@ -1855,10 +1874,10 @@ router.get('/owner/live', async (req, res) => {
|
||||
MAX(p.time)::text AS last_seen,
|
||||
MIN(p.hop_count) AS best_hops
|
||||
FROM packets p
|
||||
LEFT JOIN nodes n ON LOWER(n.node_id) = LOWER(p.rx_node_id)
|
||||
WHERE LOWER(p.src_node_id) = LOWER($1)
|
||||
LEFT JOIN nodes n ON n.node_id = p.rx_node_id
|
||||
WHERE p.src_node_id = $1
|
||||
AND p.rx_node_id IS NOT NULL
|
||||
AND LOWER(p.rx_node_id) <> LOWER($1)
|
||||
AND p.rx_node_id <> $1
|
||||
AND p.time > NOW() - INTERVAL '7 days'
|
||||
GROUP BY p.rx_node_id, n.name, n.network, n.iata, n.lat, n.lon
|
||||
ORDER BY packets_24h DESC, packets_7d DESC, last_seen DESC
|
||||
@@ -1878,20 +1897,20 @@ router.get('/owner/live', async (req, res) => {
|
||||
last_observed: string | null;
|
||||
}>(
|
||||
`SELECT
|
||||
CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.node_b_id ELSE nl.node_a_id END AS peer_node_id,
|
||||
CASE WHEN nl.node_a_id = $1 THEN nl.node_b_id ELSE nl.node_a_id END AS peer_node_id,
|
||||
peer.name AS peer_name,
|
||||
peer.network AS peer_network,
|
||||
CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.count_a_to_b ELSE nl.count_b_to_a END AS owner_to_peer,
|
||||
CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.count_b_to_a ELSE nl.count_a_to_b END AS peer_to_owner,
|
||||
CASE WHEN nl.node_a_id = $1 THEN nl.count_a_to_b ELSE nl.count_b_to_a END AS owner_to_peer,
|
||||
CASE WHEN nl.node_a_id = $1 THEN nl.count_b_to_a ELSE nl.count_a_to_b END AS peer_to_owner,
|
||||
nl.observed_count,
|
||||
nl.itm_path_loss_db,
|
||||
nl.itm_viable,
|
||||
nl.force_viable,
|
||||
nl.last_observed::text AS last_observed
|
||||
FROM node_links nl
|
||||
JOIN nodes peer ON LOWER(peer.node_id) = LOWER(CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.node_b_id ELSE nl.node_a_id END)
|
||||
WHERE (LOWER(nl.node_a_id) = LOWER($1)
|
||||
OR LOWER(nl.node_b_id) = LOWER($1))
|
||||
JOIN nodes peer ON peer.node_id = CASE WHEN nl.node_a_id = $1 THEN nl.node_b_id ELSE nl.node_a_id END
|
||||
WHERE (nl.node_a_id = $1
|
||||
OR nl.node_b_id = $1)
|
||||
AND (
|
||||
nl.force_viable = true
|
||||
OR nl.itm_viable = true
|
||||
@@ -1913,7 +1932,7 @@ router.get('/owner/live', async (req, res) => {
|
||||
time_bucket('1 hour', time)::text AS bucket,
|
||||
COUNT(DISTINCT packet_hash)::int AS adverts
|
||||
FROM packets
|
||||
WHERE LOWER(src_node_id) = LOWER($1)
|
||||
WHERE src_node_id = $1
|
||||
AND packet_type = 4
|
||||
AND time > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY bucket
|
||||
@@ -1953,7 +1972,7 @@ router.get('/owner/live', async (req, res) => {
|
||||
ELSE NULL
|
||||
END AS tx_publish_calls
|
||||
FROM node_status_samples
|
||||
WHERE LOWER(node_id) = LOWER($1)
|
||||
WHERE node_id = $1
|
||||
AND time > NOW() - INTERVAL '24 hours'
|
||||
ORDER BY time ASC`,
|
||||
[selectedNodeId],
|
||||
@@ -2296,7 +2315,7 @@ async function computeChartsData(network: string | undefined, observer: string |
|
||||
// top repeated first-2-hex prefix collisions across known repeaters
|
||||
query(`
|
||||
WITH prefix_counts AS (
|
||||
SELECT SUBSTRING(LOWER(n.node_id) FROM 1 FOR 2) AS prefix, COUNT(*)::int AS node_count
|
||||
SELECT LEFT(n.node_id, 2) AS prefix, COUNT(*)::int AS node_count
|
||||
FROM nodes n
|
||||
WHERE n.node_id ~ '^[0-9A-Fa-f]{64}$'
|
||||
AND (n.name IS NULL OR n.name NOT LIKE '%🚫%')
|
||||
@@ -2327,11 +2346,11 @@ async function computeChartsData(network: string | undefined, observer: string |
|
||||
COALESCE(NULLIF(TRIM(UPPER(n.iata)), ''), 'UNK') AS iata,
|
||||
COUNT(DISTINCT p.packet_hash) FILTER (WHERE p.time > NOW() - INTERVAL '24 hours') AS packets_24h,
|
||||
COUNT(DISTINCT p.packet_hash) AS packets_7d,
|
||||
COUNT(DISTINCT LOWER(p.rx_node_id)) FILTER (WHERE n.last_seen > NOW() - INTERVAL '1 minute') AS active_observers,
|
||||
COUNT(DISTINCT LOWER(p.rx_node_id)) AS observers,
|
||||
COUNT(DISTINCT p.rx_node_id) FILTER (WHERE n.last_seen > NOW() - INTERVAL '1 minute') AS active_observers,
|
||||
COUNT(DISTINCT p.rx_node_id) AS observers,
|
||||
MAX(p.time)::text AS last_packet_at
|
||||
FROM packets p
|
||||
LEFT JOIN nodes n ON LOWER(n.node_id) = LOWER(p.rx_node_id)
|
||||
LEFT JOIN nodes n ON n.node_id = p.rx_node_id
|
||||
WHERE p.time > NOW() - INTERVAL '7 days'
|
||||
AND p.rx_node_id IS NOT NULL
|
||||
AND p.rx_node_id <> ''
|
||||
@@ -2347,7 +2366,7 @@ async function computeChartsData(network: string | undefined, observer: string |
|
||||
time_bucket('1 day', p.time) AS day,
|
||||
COUNT(DISTINCT p.packet_hash) AS count
|
||||
FROM packets p
|
||||
LEFT JOIN nodes n ON LOWER(n.node_id) = LOWER(p.rx_node_id)
|
||||
LEFT JOIN nodes n ON n.node_id = p.rx_node_id
|
||||
WHERE p.time > NOW() - INTERVAL '7 days'
|
||||
AND p.rx_node_id IS NOT NULL
|
||||
AND p.rx_node_id <> ''
|
||||
@@ -2450,7 +2469,7 @@ async function computeChartsData(network: string | undefined, observer: string |
|
||||
) AS decoded_nodes
|
||||
FROM multibyte m
|
||||
JOIN hop_matches hm ON hm.packet_hash = m.packet_hash
|
||||
LEFT JOIN nodes n ON LOWER(n.node_id) = LOWER(hm.matched_node_id)
|
||||
LEFT JOIN nodes n ON n.node_id = hm.matched_node_id
|
||||
GROUP BY m.packet_hash
|
||||
),
|
||||
latest_multibyte AS (
|
||||
@@ -2699,7 +2718,21 @@ router.get('/observer-activity', EXPENSIVE_LIMITER, async (req, res) => {
|
||||
// Outbound: MME observers see it with fewer hops, non-MME also received it within 120s
|
||||
// Inbound: non-MME observers see it with fewer hops, MME also received it within 120s
|
||||
router.get('/cross-network-connectivity', EXPENSIVE_LIMITER, async (_req, res) => {
|
||||
const result = await query<{ last_inbound: string | null; last_outbound: string | null }>(`
|
||||
const cacheKey = 'teesside';
|
||||
const cached = crossNetworkCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.ts < CROSS_NETWORK_CACHE_TTL_MS) {
|
||||
res.json(cached.data);
|
||||
return;
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<typeof query<{ last_inbound: string | null; last_outbound: string | null }>>>;
|
||||
let historyResult: Awaited<ReturnType<typeof query<{
|
||||
bucket: string;
|
||||
inbound_count: string;
|
||||
outbound_count: string;
|
||||
}>>>;
|
||||
try {
|
||||
result = await query<{ last_inbound: string | null; last_outbound: string | null }>(`
|
||||
WITH mme_observers AS (
|
||||
SELECT DISTINCT rx_node_id AS node_id
|
||||
FROM packets
|
||||
@@ -2734,18 +2767,89 @@ router.get('/cross-network-connectivity', EXPENSIVE_LIMITER, async (_req, res) =
|
||||
FROM cross_heard
|
||||
WHERE mme_min_hops != other_min_hops
|
||||
`);
|
||||
historyResult = await query<{
|
||||
bucket: string;
|
||||
inbound_count: string;
|
||||
outbound_count: string;
|
||||
}>(`
|
||||
WITH mme_observers AS (
|
||||
SELECT DISTINCT rx_node_id AS node_id
|
||||
FROM packets
|
||||
WHERE time > NOW() - INTERVAL '24 hours'
|
||||
AND network = 'teesside'
|
||||
AND rx_node_id IS NOT NULL
|
||||
),
|
||||
cross_heard AS (
|
||||
SELECT
|
||||
p.packet_hash,
|
||||
MIN(p.hop_count) FILTER (WHERE p.rx_node_id IN (SELECT node_id FROM mme_observers)) AS mme_min_hops,
|
||||
MIN(p.hop_count) FILTER (WHERE p.rx_node_id NOT IN (SELECT node_id FROM mme_observers)) AS other_min_hops,
|
||||
MIN(p.time) FILTER (WHERE p.rx_node_id IN (SELECT node_id FROM mme_observers)) AS mme_first_seen,
|
||||
MIN(p.time) FILTER (WHERE p.rx_node_id NOT IN (SELECT node_id FROM mme_observers)) AS other_first_seen
|
||||
FROM packets p
|
||||
WHERE p.time > NOW() - INTERVAL '7 days'
|
||||
AND p.hop_count IS NOT NULL
|
||||
AND p.rx_node_id IS NOT NULL
|
||||
AND p.packet_hash IS NOT NULL
|
||||
GROUP BY p.packet_hash
|
||||
HAVING
|
||||
MIN(p.hop_count) FILTER (WHERE p.rx_node_id IN (SELECT node_id FROM mme_observers)) IS NOT NULL
|
||||
AND MIN(p.hop_count) FILTER (WHERE p.rx_node_id NOT IN (SELECT node_id FROM mme_observers)) IS NOT NULL
|
||||
AND ABS(EXTRACT(EPOCH FROM (
|
||||
MIN(p.time) FILTER (WHERE p.rx_node_id IN (SELECT node_id FROM mme_observers)) -
|
||||
MIN(p.time) FILTER (WHERE p.rx_node_id NOT IN (SELECT node_id FROM mme_observers))
|
||||
))) <= 120
|
||||
),
|
||||
classified AS (
|
||||
SELECT
|
||||
date_trunc('hour', mme_first_seen) AS bucket,
|
||||
CASE WHEN other_min_hops < mme_min_hops THEN 1 ELSE 0 END AS inbound_count,
|
||||
CASE WHEN mme_min_hops < other_min_hops THEN 1 ELSE 0 END AS outbound_count
|
||||
FROM cross_heard
|
||||
WHERE mme_min_hops != other_min_hops
|
||||
AND mme_first_seen IS NOT NULL
|
||||
),
|
||||
buckets AS (
|
||||
SELECT generate_series(
|
||||
date_trunc('hour', NOW() - INTERVAL '7 days'),
|
||||
date_trunc('hour', NOW()),
|
||||
INTERVAL '1 hour'
|
||||
) AS bucket
|
||||
)
|
||||
SELECT
|
||||
to_char(b.bucket, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS bucket,
|
||||
COALESCE(SUM(c.inbound_count), 0)::text AS inbound_count,
|
||||
COALESCE(SUM(c.outbound_count), 0)::text AS outbound_count
|
||||
FROM buckets b
|
||||
LEFT JOIN classified c ON c.bucket = b.bucket
|
||||
GROUP BY b.bucket
|
||||
ORDER BY b.bucket
|
||||
`);
|
||||
} catch (err) {
|
||||
console.error('[api] GET /cross-network-connectivity', (err as Error).message);
|
||||
res.status(500).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const lastInbound = result.rows[0]?.last_inbound ?? null;
|
||||
const lastOutbound = result.rows[0]?.last_outbound ?? null;
|
||||
|
||||
res.json({
|
||||
const responseData = {
|
||||
inbound: !!lastInbound,
|
||||
outbound: !!lastOutbound,
|
||||
lastInbound,
|
||||
lastOutbound,
|
||||
windowHours: 2,
|
||||
historyWindowHours: 7 * 24,
|
||||
history: historyResult.rows.map((row) => ({
|
||||
bucket: row.bucket,
|
||||
inboundCount: Number(row.inbound_count ?? 0),
|
||||
outboundCount: Number(row.outbound_count ?? 0),
|
||||
})),
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
crossNetworkCache.set(cacheKey, { ts: Date.now(), data: responseData });
|
||||
res.json(responseData);
|
||||
});
|
||||
|
||||
// GET /api/radio-history?target=<nodeName>&limit=<n> — proxies radio bot POST /history
|
||||
@@ -2767,6 +2871,42 @@ router.get('/radio-history', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tiles/nodes/:z/:x/:y.png — server-side node tile rendering
|
||||
router.get('/tiles/nodes/:z/:x/:y.png', TILE_LIMITER, async (req: Request, res: Response) => {
|
||||
const network = resolveRequestNetwork(req.query['network'], req.headers);
|
||||
const z = parseInt(req.params.z!, 10);
|
||||
const x = parseInt(req.params.x!, 10);
|
||||
const y = parseInt((req.params.y ?? '').replace('.png', ''), 10);
|
||||
if (isNaN(z) || isNaN(x) || isNaN(y) || z < 0 || z > 18) { res.status(400).end(); return; }
|
||||
|
||||
const cacheKey = `tile:nodes:${network ?? 'all'}:${z}:${x}:${y}`;
|
||||
const redis = getTileRedis();
|
||||
const cached = await redis.getBuffer(cacheKey).catch(() => null);
|
||||
if (cached) {
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.set('Cache-Control', 'public, max-age=30');
|
||||
res.send(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 'all' means no network filter — pass undefined so buildNodeScopeClause
|
||||
// falls back to "IS DISTINCT FROM 'test'" rather than network = 'all'.
|
||||
const nodeNetwork = network === 'all' ? undefined : network;
|
||||
const snapshotNodes = await getTileSnapshotNodes(nodeNetwork);
|
||||
const nodes = snapshotNodes.length > 0 ? snapshotNodes : await getNodes(nodeNetwork);
|
||||
const png = await renderNodeTile(z, x, y, nodes);
|
||||
const ttl = isUkTile(z, x, y) ? TILE_CACHE_TTL_UK_MS : TILE_CACHE_TTL_MS;
|
||||
await redis.set(cacheKey, png, 'PX', ttl).catch(() => {});
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.set('Cache-Control', 'public, max-age=30');
|
||||
res.send(png);
|
||||
} catch (err) {
|
||||
console.error('[api] GET /tiles/nodes', (err as Error).message);
|
||||
res.status(500).end();
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/radio-stats — proxies radio bot GET /state (port 3011)
|
||||
router.get('/radio-stats', async (_req, res) => {
|
||||
const radioBotUrl = process.env['RADIO_BOT_URL'] ?? 'http://meshcore-radio-bot:3011';
|
||||
|
||||
+17
-9
@@ -67,7 +67,7 @@ function buildPacketScopeClause(
|
||||
conditions.push(`split_part(${prefix}topic, '/', 1) <> 'meshcore-test'`);
|
||||
}
|
||||
if (placeholders.observerParam) {
|
||||
conditions.push(`LOWER(${prefix}rx_node_id) = LOWER(${placeholders.observerParam})`);
|
||||
conditions.push(`${prefix}rx_node_id = ${placeholders.observerParam}`);
|
||||
}
|
||||
return conditions.length > 0 ? ` AND ${conditions.join(' AND ')}` : '';
|
||||
}
|
||||
@@ -87,10 +87,9 @@ function buildNodeScopeClause(
|
||||
${placeholders.networkParam} = 'teesside'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM packets p_scope
|
||||
WHERE LOWER(p_scope.src_node_id) = LOWER(${prefix}node_id)
|
||||
AND p_scope.network = 'teesside'
|
||||
AND split_part(p_scope.topic, '/', 1) <> 'meshcore-test'
|
||||
FROM node_network_sightings s
|
||||
WHERE s.node_id = ${prefix}node_id
|
||||
AND s.network = 'teesside'
|
||||
)
|
||||
)
|
||||
)`,
|
||||
@@ -101,13 +100,13 @@ function buildNodeScopeClause(
|
||||
|
||||
if (placeholders.observerParam) {
|
||||
const observerNodeScope = [
|
||||
`LOWER(${prefix}node_id) = LOWER(${placeholders.observerParam})`,
|
||||
`${prefix}node_id = ${placeholders.observerParam}`,
|
||||
`EXISTS (
|
||||
SELECT 1
|
||||
FROM packets p
|
||||
WHERE LOWER(p.rx_node_id) = LOWER(${placeholders.observerParam})`,
|
||||
WHERE p.rx_node_id = ${placeholders.observerParam}`,
|
||||
placeholders.networkParam ? `AND p.network = ${placeholders.networkParam}` : '',
|
||||
`AND LOWER(p.src_node_id) = LOWER(${prefix}node_id)
|
||||
`AND p.src_node_id = ${prefix}node_id
|
||||
)`,
|
||||
].filter(Boolean).join(' ');
|
||||
conditions.push(`(${observerNodeScope})`);
|
||||
@@ -260,6 +259,7 @@ export async function insertPacket(p: {
|
||||
const storedPayload = p.payload
|
||||
? (p.summary ? { ...p.payload, _summary: p.summary } : p.payload)
|
||||
: (p.summary ? { _summary: p.summary } : null);
|
||||
const network = p.network ?? 'teesside';
|
||||
await pool.query(
|
||||
`INSERT INTO packets
|
||||
(time, packet_hash, rx_node_id, src_node_id, topic, packet_type, route_type,
|
||||
@@ -268,8 +268,16 @@ export async function insertPacket(p: {
|
||||
[p.packetHash, p.rxNodeId, p.srcNodeId, p.topic, p.packetType,
|
||||
p.routeType, p.hopCount, p.rssi, p.snr,
|
||||
storedPayload ? JSON.stringify(storedPayload) : null, p.rawHex, p.advertCount ?? null,
|
||||
p.pathHashes ?? null, inferredPathHashSizeBytes, p.network ?? 'teesside']
|
||||
p.pathHashes ?? null, inferredPathHashSizeBytes, network]
|
||||
);
|
||||
if (p.srcNodeId && network !== 'test') {
|
||||
pool.query(
|
||||
`INSERT INTO node_network_sightings (node_id, network, last_seen_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (node_id, network) DO UPDATE SET last_seen_at = NOW()`,
|
||||
[p.srcNodeId, network]
|
||||
).catch(() => {}); // fire-and-forget, non-critical
|
||||
}
|
||||
}
|
||||
|
||||
export async function insertNodeStatusSample(sample: {
|
||||
|
||||
@@ -89,13 +89,13 @@ export async function getOwnerNodeIdsForUsername(mqttUsername: string): Promise<
|
||||
const normalized = mqttUsername.trim();
|
||||
if (!normalized) return [];
|
||||
const res = await ownerPool.query<{ node_id: string }>(
|
||||
`SELECT DISTINCT LOWER(oan.node_id) AS node_id
|
||||
`SELECT DISTINCT oan.node_id
|
||||
FROM owner_account_nodes oan
|
||||
JOIN owner_accounts oa ON oa.mqtt_username = oan.mqtt_username
|
||||
WHERE oa.is_active = true
|
||||
AND oan.mqtt_username = $1
|
||||
AND oan.node_id ~ '^[0-9a-fA-F]{64}$'
|
||||
ORDER BY LOWER(oan.node_id) ASC`,
|
||||
AND oan.node_id ~ '^[0-9A-F]{64}$'
|
||||
ORDER BY oan.node_id ASC`,
|
||||
[normalized],
|
||||
);
|
||||
return res.rows.map((row) => row.node_id);
|
||||
@@ -115,8 +115,8 @@ export async function ensureOwnerAccount(mqttUsername: string): Promise<void> {
|
||||
|
||||
export async function addOwnerNodeForUsername(mqttUsername: string, nodeId: string): Promise<void> {
|
||||
const normalizedUsername = mqttUsername.trim();
|
||||
const normalizedNodeId = nodeId.trim().toLowerCase();
|
||||
if (!normalizedUsername || !/^[0-9a-f]{64}$/.test(normalizedNodeId)) return;
|
||||
const normalizedNodeId = nodeId.trim().toUpperCase();
|
||||
if (!normalizedUsername || !/^[0-9A-F]{64}$/.test(normalizedNodeId)) return;
|
||||
await ensureOwnerAccount(normalizedUsername);
|
||||
await ownerPool.query(
|
||||
`INSERT INTO owner_account_nodes (mqtt_username, node_id)
|
||||
@@ -128,8 +128,8 @@ export async function addOwnerNodeForUsername(mqttUsername: string, nodeId: stri
|
||||
|
||||
export async function upsertMqttNodeLogin(mqttUsername: string, nodeId: string): Promise<void> {
|
||||
const normalizedUsername = mqttUsername.trim();
|
||||
const normalizedNodeId = nodeId.trim().toLowerCase();
|
||||
if (!normalizedUsername || !/^[0-9a-f]{64}$/.test(normalizedNodeId)) return;
|
||||
const normalizedNodeId = nodeId.trim().toUpperCase();
|
||||
if (!normalizedUsername || !/^[0-9A-F]{64}$/.test(normalizedNodeId)) return;
|
||||
await ownerPool.query(
|
||||
`INSERT INTO mqtt_node_logins (mqtt_username, node_id, last_connected_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
@@ -153,9 +153,9 @@ export async function getBestNodeForMqttUsername(mqttUsername: string): Promise<
|
||||
|
||||
export async function getMappedOwnerNodeIds(): Promise<string[]> {
|
||||
const res = await ownerPool.query<{ node_id: string }>(
|
||||
`SELECT DISTINCT LOWER(node_id) AS node_id
|
||||
`SELECT DISTINCT node_id
|
||||
FROM owner_account_nodes
|
||||
WHERE node_id ~ '^[0-9a-fA-F]{64}$'`,
|
||||
WHERE node_id ~ '^[0-9A-F]{64}$'`,
|
||||
);
|
||||
return res.rows.map((row) => row.node_id);
|
||||
}
|
||||
@@ -20,6 +20,17 @@ CREATE TABLE IF NOT EXISTS nodes (
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Tracks which networks each node has been heard on (maintained by insertPacket).
|
||||
-- Used for cross-network node visibility (e.g. showing ukmesh nodes on teesside site).
|
||||
-- Replaces expensive correlated EXISTS subquery against the packets hypertable.
|
||||
CREATE TABLE IF NOT EXISTS node_network_sightings (
|
||||
node_id TEXT NOT NULL,
|
||||
network TEXT NOT NULL,
|
||||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (node_id, network)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planned_nodes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_pubkey TEXT NOT NULL,
|
||||
|
||||
@@ -111,6 +111,7 @@ async function currentWorkers(precomputedStats?: ReturnType<typeof systemStats>)
|
||||
const [
|
||||
viewshedDepth,
|
||||
linkDepth,
|
||||
tileState,
|
||||
viewshedRecent,
|
||||
linkRecent,
|
||||
viewshedLast,
|
||||
@@ -122,6 +123,7 @@ async function currentWorkers(precomputedStats?: ReturnType<typeof systemStats>)
|
||||
] = await Promise.all([
|
||||
r.llen('meshcore:viewshed_jobs'),
|
||||
r.llen('meshcore:link_jobs'),
|
||||
r.hgetall('meshcore:tile_worker:state'),
|
||||
query<{ count: string }>(`SELECT COUNT(*) AS count FROM node_coverage WHERE calculated_at > NOW() - INTERVAL '1 hour'`),
|
||||
query<{ count: string }>(`SELECT COUNT(*) AS count FROM node_links WHERE itm_computed_at > NOW() - INTERVAL '1 hour'`),
|
||||
query<{ ts: string | null }>(`SELECT MAX(calculated_at)::text AS ts FROM node_coverage`),
|
||||
@@ -157,6 +159,19 @@ async function currentWorkers(precomputedStats?: ReturnType<typeof systemStats>)
|
||||
const learningRecent = learningLast ? (Date.now() - Date.parse(learningLast)) <= 60 * 60_000 : false;
|
||||
const backfillLinks = Number(backfillState.rows[0]?.links ?? 0);
|
||||
const backfillLast = backfillState.rows[0]?.last_observed ?? null;
|
||||
const tileStatus = tileState.status || 'idle';
|
||||
const tileDone = Number(tileState.done_tiles ?? 0);
|
||||
const tileTotal = Number(tileState.total_tiles ?? 0);
|
||||
const tileRemaining = tileStatus === 'running'
|
||||
? Math.max(0, Number.isFinite(Number(tileState.remaining_tiles)) ? Number(tileState.remaining_tiles) : tileTotal - tileDone)
|
||||
: 0;
|
||||
const tileLastActivity = tileState.updated_at || tileState.last_pass_finished_at || tileState.started_at || null;
|
||||
const tileLastPassFinished = tileState.last_pass_finished_at ? Date.parse(tileState.last_pass_finished_at) : Number.NaN;
|
||||
const tileProcessed1h = (
|
||||
Number.isFinite(tileLastPassFinished) && (Date.now() - tileLastPassFinished) <= 60 * 60_000
|
||||
)
|
||||
? Number(tileState.last_pass_tiles ?? 0)
|
||||
: (tileStatus === 'running' ? tileDone : 0);
|
||||
return [
|
||||
{
|
||||
worker_name: 'viewshed-worker',
|
||||
@@ -180,6 +195,17 @@ async function currentWorkers(precomputedStats?: ReturnType<typeof systemStats>)
|
||||
mem_used_pct: memPct,
|
||||
disk_used_pct: diskPct,
|
||||
},
|
||||
{
|
||||
worker_name: 'tile-worker',
|
||||
status: tileStatus,
|
||||
queue_depth: tileRemaining,
|
||||
processed_1h: tileProcessed1h,
|
||||
last_activity_at: tileLastActivity,
|
||||
cpu_load_1m: load,
|
||||
cpu_usage_pct: stats.cpu.usage_pct,
|
||||
mem_used_pct: memPct,
|
||||
disk_used_pct: diskPct,
|
||||
},
|
||||
{
|
||||
worker_name: 'path-learning',
|
||||
status: learningRecent ? 'running' : 'idle',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { startMqttConnectionMonitor } from './mqtt/connectionMonitor.js';
|
||||
import { initWebSocketServer, broadcastPacket, broadcastNodeUpdate, broadcastNodeUpsert } from './ws/server.js';
|
||||
import apiRoutes from './api/routes.js';
|
||||
import { isViewshedEligibleCoordinate, queueViewshedJob, queueLinkJob } from './queue/publisher.js';
|
||||
import { rebuildTileSnapshotFromDb, upsertTileSnapshotNode } from './tiles/snapshot.js';
|
||||
|
||||
const ALLOWED_ORIGINS = (process.env['ALLOWED_ORIGINS'] ?? '')
|
||||
.split(',')
|
||||
@@ -25,6 +26,8 @@ async function main() {
|
||||
// 1. Initialise DB schema + retention policy
|
||||
await initDb();
|
||||
await initOwnerAuthDb();
|
||||
const tileSnapshotCount = await rebuildTileSnapshotFromDb();
|
||||
console.log(`[app] tile snapshot initialised (${tileSnapshotCount} nodes)`);
|
||||
|
||||
// Queue viewshed jobs for any node with a position but no coverage yet
|
||||
// (catches nodes that existed before the worker was added)
|
||||
@@ -66,6 +69,7 @@ async function main() {
|
||||
onNodeSeen((nodeId, meta) => broadcastNodeUpdate(nodeId, meta));
|
||||
onNodeUpsert((node) => {
|
||||
broadcastNodeUpsert(node);
|
||||
void upsertTileSnapshotNode(node).catch((err: Error) => console.error('[tile-snapshot] upsert failed:', err.message));
|
||||
// Queue a viewshed job only for visible repeaters (role=2 or unknown)
|
||||
const isHidden = typeof node.name === 'string' && node.name.includes('🚫');
|
||||
const isNonRepeater = typeof node.role === 'number' && node.role !== 2;
|
||||
|
||||
+43
-12
@@ -7,6 +7,7 @@ import type {
|
||||
import { insertNodeStatusSample, insertPacket, upsertNode, incrementAdvertCount, query } from '../db/index.js';
|
||||
import { invalidateResolveCache, setResolveCache } from '../path-beta/resolveCache.js';
|
||||
import { resolvePool } from '../path-beta/resolvePool.js';
|
||||
import { upsertTileSnapshotNode } from '../tiles/snapshot.js';
|
||||
import type { LivePacket } from '../types/index.js';
|
||||
import { decodePacketCompat } from './decodePacket.js';
|
||||
|
||||
@@ -461,7 +462,7 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
|
||||
const firmware = json['firmware_version'] as string | undefined;
|
||||
|
||||
const nodeId = originId ?? observerKey;
|
||||
void upsertNode(nodeId, {
|
||||
upsertNode(nodeId, {
|
||||
name: origin,
|
||||
iata,
|
||||
publicKey: originId,
|
||||
@@ -469,12 +470,21 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
|
||||
firmwareVersion: (firmware && firmware !== 'unknown') ? firmware : undefined,
|
||||
network,
|
||||
allowTestOverride: network === 'test' && nodeId === observerKey,
|
||||
});
|
||||
}).catch((err: Error) => console.error('[mqtt] upsertNode error:', err.message));
|
||||
void upsertTileSnapshotNode({
|
||||
node_id: nodeId,
|
||||
name: origin,
|
||||
iata,
|
||||
public_key: originId,
|
||||
network,
|
||||
last_seen: new Date().toISOString(),
|
||||
is_online: true,
|
||||
}).catch((err: Error) => console.error('[tile-snapshot] upsert failed:', err.message));
|
||||
const telemetry = extractStatusTelemetry(json, {
|
||||
allowRawStatsOnly: network === 'test',
|
||||
});
|
||||
if (telemetry) {
|
||||
void insertNodeStatusSample({
|
||||
insertNodeStatusSample({
|
||||
nodeId,
|
||||
network,
|
||||
batteryMv: telemetry.batteryMv,
|
||||
@@ -484,7 +494,7 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
|
||||
channelUtilization: telemetry.channelUtilization,
|
||||
airUtilTx: telemetry.airUtilTx,
|
||||
stats: telemetry.stats,
|
||||
});
|
||||
}).catch((err: Error) => console.error('[mqtt] insertNodeStatusSample error:', err.message));
|
||||
}
|
||||
emitNode(nodeId, { network, observerId: observerKey });
|
||||
return;
|
||||
@@ -555,7 +565,7 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
|
||||
const nodeId = senderKey ?? observerKey;
|
||||
|
||||
if (network !== 'test') {
|
||||
void upsertNode(nodeId, {
|
||||
upsertNode(nodeId, {
|
||||
name: appData?.['name'] as string | undefined,
|
||||
lat: loc?.['latitude'],
|
||||
lon: loc?.['longitude'],
|
||||
@@ -563,10 +573,10 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
|
||||
iata,
|
||||
publicKey: senderKey,
|
||||
network,
|
||||
});
|
||||
}).catch((err: Error) => console.error('[mqtt] upsertNode error:', err.message));
|
||||
|
||||
if (decodedHash && tryCountAdvert(decodedHash)) {
|
||||
void incrementAdvertCount(nodeId);
|
||||
incrementAdvertCount(nodeId).catch((err: Error) => console.error('[mqtt] incrementAdvertCount error:', err.message));
|
||||
}
|
||||
|
||||
emitNodeUpsert({
|
||||
@@ -583,6 +593,18 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
|
||||
is_online: true,
|
||||
advert_count: advertCount,
|
||||
});
|
||||
void upsertTileSnapshotNode({
|
||||
node_id: nodeId,
|
||||
name: appData?.['name'] as string | undefined,
|
||||
lat: loc?.['latitude'],
|
||||
lon: loc?.['longitude'],
|
||||
role: appData?.['deviceRole'] as number | undefined,
|
||||
iata,
|
||||
network,
|
||||
public_key: senderKey,
|
||||
last_seen: new Date().toISOString(),
|
||||
is_online: true,
|
||||
}).catch((err: Error) => console.error('[tile-snapshot] upsert failed:', err.message));
|
||||
}
|
||||
|
||||
innerPayload = inner;
|
||||
@@ -635,22 +657,22 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
void upsertNode(observerKey, {
|
||||
upsertNode(observerKey, {
|
||||
iata,
|
||||
network,
|
||||
allowTestOverride: network === 'test',
|
||||
});
|
||||
}).catch((err: Error) => console.error('[mqtt] upsertNode error:', err.message));
|
||||
emitNode(observerKey, { network, observerId: observerKey });
|
||||
|
||||
if (useTxAdvertFallback && originId) {
|
||||
await upsertNode(originId, {
|
||||
upsertNode(originId, {
|
||||
name: origin,
|
||||
iata,
|
||||
publicKey: originId,
|
||||
network,
|
||||
});
|
||||
}).catch((err: Error) => console.error('[mqtt] upsertNode error:', err.message));
|
||||
if (tryCountAdvert(finalHash)) {
|
||||
void incrementAdvertCount(originId);
|
||||
incrementAdvertCount(originId).catch((err: Error) => console.error('[mqtt] incrementAdvertCount error:', err.message));
|
||||
}
|
||||
emitNodeUpsert({
|
||||
node_id: originId,
|
||||
@@ -663,6 +685,15 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
|
||||
is_online: true,
|
||||
advert_count: advertCount,
|
||||
});
|
||||
void upsertTileSnapshotNode({
|
||||
node_id: originId,
|
||||
name: origin,
|
||||
iata,
|
||||
network,
|
||||
public_key: originId,
|
||||
last_seen: new Date().toISOString(),
|
||||
is_online: true,
|
||||
}).catch((err: Error) => console.error('[tile-snapshot] upsert failed:', err.message));
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -26,8 +26,12 @@ async function processLine(line: string): Promise<void> {
|
||||
if (!m) return;
|
||||
const [, nodePrefix, mqttUsername] = m;
|
||||
if (!nodePrefix || !mqttUsername || mqttUsername === 'backend') return;
|
||||
const nodeId = await resolveNodeId(nodePrefix);
|
||||
if (nodeId) await upsertMqttNodeLogin(mqttUsername, nodeId);
|
||||
try {
|
||||
const nodeId = await resolveNodeId(nodePrefix);
|
||||
if (nodeId) await upsertMqttNodeLogin(mqttUsername, nodeId);
|
||||
} catch (err) {
|
||||
console.error('[conn-monitor] processLine error:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanRange(start: number, end: number): Promise<void> {
|
||||
@@ -76,7 +80,7 @@ export function startMqttConnectionMonitor(): void {
|
||||
}
|
||||
}
|
||||
|
||||
void init();
|
||||
setInterval(() => void poll(), POLL_INTERVAL_MS);
|
||||
init().catch((err: Error) => console.error('[conn-monitor] init error:', err.message));
|
||||
setInterval(() => poll().catch((err: Error) => console.error('[conn-monitor] poll error:', err.message)), POLL_INTERVAL_MS);
|
||||
console.log('[conn-monitor] started, monitoring', LOG_PATH);
|
||||
}
|
||||
@@ -1343,7 +1343,7 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
|
||||
FROM packets
|
||||
WHERE packet_hash = $1
|
||||
AND ($2 = 'all' OR network = $2)
|
||||
${observer ? 'AND LOWER(rx_node_id) = LOWER($3)' : ''}
|
||||
${observer ? 'AND rx_node_id = $3' : ''}
|
||||
ORDER BY COALESCE(cardinality(path_hashes), 0) DESC,
|
||||
CASE WHEN path_hash_size_bytes IS NOT NULL THEN 1 ELSE 0 END DESC,
|
||||
CASE WHEN src_node_id IS NOT NULL THEN 1 ELSE 0 END DESC,
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Server-side node tile renderer.
|
||||
* Generates 256×256 RGBA PNG tiles showing node dots using only Node built-ins
|
||||
* (no native canvas/skia dependency — uses zlib for PNG deflate).
|
||||
*/
|
||||
import { deflate } from 'node:zlib';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const deflateAsync = promisify(deflate);
|
||||
|
||||
const TILE_SIZE = 256;
|
||||
const BUFFER = 4;
|
||||
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const PROHIBITED_MARKER = '\u{1F6AB}'; // 🚫
|
||||
|
||||
// ── Privacy masking (FNV-1a hash, port of frontend/src/utils/pathing.ts) ─────
|
||||
|
||||
function hashSeed(input: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function stablePointWithinMiles(lat: number, lon: number, seed: string): [number, number] {
|
||||
const radiusKm = 1.609344; // 1 mile
|
||||
const distanceUnit = hashSeed(`${seed}:distance`) / 0xffffffff;
|
||||
const bearingUnit = hashSeed(`${seed}:bearing`) / 0xffffffff;
|
||||
const distanceKm = Math.sqrt(distanceUnit) * radiusKm;
|
||||
const bearing = bearingUnit * Math.PI * 2;
|
||||
const latRad = lat * (Math.PI / 180);
|
||||
const dLat = (distanceKm / 111) * Math.cos(bearing);
|
||||
const lonScale = Math.max(0.01, Math.cos(latRad));
|
||||
const dLon = (distanceKm / (111 * lonScale)) * Math.sin(bearing);
|
||||
return [lat + dLat, lon + dLon];
|
||||
}
|
||||
|
||||
// ── Tile math ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function tileBounds(z: number, x: number, y: number) {
|
||||
const pow2 = Math.pow(2, z);
|
||||
const lonW = (x / pow2) * 360 - 180;
|
||||
const lonE = ((x + 1) / pow2) * 360 - 180;
|
||||
const nN = Math.PI - (2 * Math.PI * y) / pow2;
|
||||
const nS = Math.PI - (2 * Math.PI * (y + 1)) / pow2;
|
||||
const latN = Math.atan(Math.sinh(nN)) * (180 / Math.PI);
|
||||
const latS = Math.atan(Math.sinh(nS)) * (180 / Math.PI);
|
||||
return { lonW, lonE, latN, latS };
|
||||
}
|
||||
|
||||
function latToMercY(lat: number): number {
|
||||
const latRad = lat * (Math.PI / 180);
|
||||
return Math.log(Math.tan(Math.PI / 4 + latRad / 2));
|
||||
}
|
||||
|
||||
// ── Pure-JS PNG encoder ───────────────────────────────────────────────────────
|
||||
|
||||
function crc32(buf: Uint8Array): number {
|
||||
const table = crc32Table();
|
||||
let crc = 0xffffffff;
|
||||
for (const b of buf) {
|
||||
crc = (crc >>> 8) ^ table[(crc ^ b) & 0xff]!;
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
let _crcTable: Uint32Array | null = null;
|
||||
function crc32Table(): Uint32Array {
|
||||
if (_crcTable) return _crcTable;
|
||||
_crcTable = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = (c & 1) ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
_crcTable[n] = c;
|
||||
}
|
||||
return _crcTable;
|
||||
}
|
||||
|
||||
function pngChunk(type: string, data: Uint8Array): Uint8Array {
|
||||
const typeBytes = new TextEncoder().encode(type);
|
||||
const out = new Uint8Array(4 + 4 + data.length + 4);
|
||||
const view = new DataView(out.buffer);
|
||||
view.setUint32(0, data.length);
|
||||
out.set(typeBytes, 4);
|
||||
out.set(data, 8);
|
||||
const crcData = new Uint8Array(4 + data.length);
|
||||
crcData.set(typeBytes);
|
||||
crcData.set(data, 4);
|
||||
view.setUint32(8 + data.length, crc32(crcData));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function encodePng(pixels: Uint8Array, width: number, height: number): Promise<Buffer> {
|
||||
// PNG signature
|
||||
const sig = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
// IHDR
|
||||
const ihdr = new Uint8Array(13);
|
||||
const ihdrView = new DataView(ihdr.buffer);
|
||||
ihdrView.setUint32(0, width);
|
||||
ihdrView.setUint32(4, height);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // color type: RGBA
|
||||
// bytes 10-12: compression, filter, interlace = 0
|
||||
|
||||
// Raw scanlines with filter byte 0 prepended to each row
|
||||
const stride = width * 4;
|
||||
const filtered = new Uint8Array(height * (stride + 1));
|
||||
for (let y = 0; y < height; y++) {
|
||||
filtered[y * (stride + 1)] = 0; // filter type: None
|
||||
filtered.set(pixels.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
||||
}
|
||||
|
||||
const compressed = await deflateAsync(Buffer.from(filtered.buffer));
|
||||
|
||||
const chunks = [
|
||||
sig,
|
||||
pngChunk('IHDR', ihdr),
|
||||
pngChunk('IDAT', new Uint8Array(compressed.buffer, compressed.byteOffset, compressed.byteLength)),
|
||||
pngChunk('IEND', new Uint8Array(0)),
|
||||
];
|
||||
|
||||
const total = chunks.reduce((sum, c) => sum + c.length, 0);
|
||||
const out = Buffer.allocUnsafe(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Software rasteriser ───────────────────────────────────────────────────────
|
||||
|
||||
function blendPixel(pixels: Uint8Array, px: number, py: number, r: number, g: number, b: number, a: number) {
|
||||
const xi = Math.round(px);
|
||||
const yi = Math.round(py);
|
||||
if (xi < 0 || xi >= TILE_SIZE || yi < 0 || yi >= TILE_SIZE) return;
|
||||
const idx = (yi * TILE_SIZE + xi) * 4;
|
||||
// Alpha-composite over existing pixel
|
||||
const srcA = a / 255;
|
||||
const dstA = pixels[idx + 3]! / 255;
|
||||
const outA = srcA + dstA * (1 - srcA);
|
||||
if (outA < 1e-6) return;
|
||||
pixels[idx]! = Math.round((r * srcA + pixels[idx]! * dstA * (1 - srcA)) / outA);
|
||||
pixels[idx + 1]! = Math.round((g * srcA + pixels[idx + 1]! * dstA * (1 - srcA)) / outA);
|
||||
pixels[idx + 2]! = Math.round((b * srcA + pixels[idx + 2]! * dstA * (1 - srcA)) / outA);
|
||||
pixels[idx + 3]! = Math.round(outA * 255);
|
||||
}
|
||||
|
||||
/** Filled anti-aliased circle using radial distance */
|
||||
function drawDot(pixels: Uint8Array, cx: number, cy: number, radius: number, r: number, g: number, b: number, a: number) {
|
||||
const r0 = radius;
|
||||
const x0 = Math.floor(cx - r0 - 1);
|
||||
const x1 = Math.ceil(cx + r0 + 1);
|
||||
const y0 = Math.floor(cy - r0 - 1);
|
||||
const y1 = Math.ceil(cy + r0 + 1);
|
||||
for (let py = y0; py <= y1; py++) {
|
||||
for (let px = x0; px <= x1; px++) {
|
||||
const dist = Math.hypot(px - cx, py - cy);
|
||||
// Soft edge: full alpha inside, fades over 1px at edge
|
||||
const alpha = Math.max(0, Math.min(1, r0 + 0.5 - dist));
|
||||
if (alpha > 0) {
|
||||
blendPixel(pixels, px, py, r, g, b, Math.round(a * alpha));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dashed circle ring */
|
||||
function drawDashedCircle(
|
||||
pixels: Uint8Array, cx: number, cy: number, radius: number,
|
||||
r: number, g: number, b: number, a: number,
|
||||
dashLen = 4, gapLen = 6, lineWidth = 1.4,
|
||||
) {
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const steps = Math.ceil(circumference * 3); // oversample for smooth curve
|
||||
const halfW = lineWidth / 2;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const angle = (2 * Math.PI * i) / steps;
|
||||
// Arc length position for dash/gap pattern
|
||||
const arcLen = (angle / (2 * Math.PI)) * circumference;
|
||||
const period = dashLen + gapLen;
|
||||
const phase = arcLen % period;
|
||||
if (phase > dashLen) continue; // in gap
|
||||
const px = cx + Math.cos(angle) * radius;
|
||||
const py = cy + Math.sin(angle) * radius;
|
||||
drawDot(pixels, px, py, halfW, r, g, b, a);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Node type ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type NodeRow = {
|
||||
node_id: string;
|
||||
name: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
role: number | null;
|
||||
last_seen: string;
|
||||
is_online: boolean;
|
||||
};
|
||||
|
||||
function isValidCoord(lat: number | null, lon: number | null): lat is number {
|
||||
if (typeof lat !== 'number' || typeof lon !== 'number') return false;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
if (Math.abs(lat) < 5 && Math.abs(lon) < 5) return false;
|
||||
return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
|
||||
}
|
||||
|
||||
// RGBA components at 0.9 alpha (230/255) — mirrors App.tsx gpuNodes colour logic
|
||||
const COLORS = {
|
||||
stale: [255, 68, 68, 178] as [number, number, number, number], // red, 0.7 alpha
|
||||
offline: [100, 100, 100, 230] as [number, number, number, number],
|
||||
companion: [251, 146, 60, 230] as [number, number, number, number],
|
||||
room: [168, 85, 247, 230] as [number, number, number, number],
|
||||
repeater: [ 34, 211, 238, 230] as [number, number, number, number],
|
||||
} as const;
|
||||
|
||||
function nodeColor(node: NodeRow, now: number): [number, number, number, number] {
|
||||
if (now - new Date(node.last_seen).getTime() > SEVEN_DAYS_MS) return COLORS.stale;
|
||||
if (!node.is_online) return COLORS.offline;
|
||||
if (node.role === 1) return COLORS.companion;
|
||||
if (node.role === 3) return COLORS.room;
|
||||
return COLORS.repeater;
|
||||
}
|
||||
|
||||
// ── Empty tile (pre-computed once, reused for every tile with no nodes) ───────
|
||||
|
||||
// Lazily computed so module load stays fast.
|
||||
let _emptyTile: Promise<Buffer> | null = null;
|
||||
function emptyTile(): Promise<Buffer> {
|
||||
if (_emptyTile) return _emptyTile;
|
||||
_emptyTile = encodePng(new Uint8Array(TILE_SIZE * TILE_SIZE * 4), TILE_SIZE, TILE_SIZE);
|
||||
return _emptyTile;
|
||||
}
|
||||
|
||||
// ── Tile index (bucket nodes by tile coordinate per zoom, computed once) ──────
|
||||
|
||||
export type TileIndex = {
|
||||
/** z → Map<"x:y", nodes that fall in that tile> */
|
||||
byZoom: Map<number, Map<string, NodeRow[]>>;
|
||||
/** Privacy nodes — included in every tile render (very rare, usually <5) */
|
||||
prohibited: NodeRow[];
|
||||
};
|
||||
|
||||
function nodeTileXY(lat: number, lon: number, z: number): [number, number] {
|
||||
const pow2 = Math.pow(2, z);
|
||||
const x = Math.floor((lon + 180) / 360 * pow2);
|
||||
const latRad = lat * Math.PI / 180;
|
||||
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * pow2);
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-bucket valid, non-stale nodes by tile coordinate for every zoom in
|
||||
* [zMin, zMax]. Call once per network, then pass the result to
|
||||
* renderTileFromIndex for fast per-tile rendering.
|
||||
*/
|
||||
export function buildTileIndex(nodes: NodeRow[], zMin: number, zMax: number): TileIndex {
|
||||
const byZoom = new Map<number, Map<string, NodeRow[]>>();
|
||||
const prohibited: NodeRow[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
// Initialise zoom maps
|
||||
for (let z = zMin; z <= zMax; z++) byZoom.set(z, new Map());
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!isValidCoord(node.lat, node.lon)) continue;
|
||||
if (now - new Date(node.last_seen).getTime() > FOURTEEN_DAYS_MS) continue;
|
||||
|
||||
if (node.name?.includes(PROHIBITED_MARKER)) {
|
||||
prohibited.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let z = zMin; z <= zMax; z++) {
|
||||
const [x, y] = nodeTileXY(node.lat, node.lon!, z);
|
||||
const key = `${x}:${y}`;
|
||||
const zMap = byZoom.get(z)!;
|
||||
const bucket = zMap.get(key);
|
||||
if (bucket) bucket.push(node);
|
||||
else zMap.set(key, [node]);
|
||||
}
|
||||
}
|
||||
|
||||
return { byZoom, prohibited };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a tile using a pre-built index. Returns the cached empty PNG
|
||||
* immediately when there are no nodes to draw.
|
||||
*/
|
||||
export function renderTileFromIndex(z: number, x: number, y: number, index: TileIndex): Promise<Buffer> {
|
||||
const tileNodes = index.byZoom.get(z)?.get(`${x}:${y}`);
|
||||
if (!tileNodes?.length && !index.prohibited.length) return emptyTile();
|
||||
const combined = tileNodes ? [...tileNodes, ...index.prohibited] : index.prohibited;
|
||||
return renderNodeTile(z, x, y, combined);
|
||||
}
|
||||
|
||||
// ── Main export ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function renderNodeTile(z: number, x: number, y: number, nodes: NodeRow[]): Promise<Buffer> {
|
||||
const { lonW, lonE, latN, latS } = tileBounds(z, x, y);
|
||||
const lonRange = lonE - lonW;
|
||||
const mercN = latToMercY(latN);
|
||||
const mercS = latToMercY(latS);
|
||||
const mercRange = mercN - mercS;
|
||||
const pow2z = Math.pow(2, z);
|
||||
const now = Date.now();
|
||||
|
||||
// Transparent pixel buffer: TILE_SIZE × TILE_SIZE × RGBA
|
||||
const pixels = new Uint8Array(TILE_SIZE * TILE_SIZE * 4); // all zeros = transparent
|
||||
|
||||
const DOT_R = 3.5;
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!isValidCoord(node.lat, node.lon)) continue;
|
||||
if (now - new Date(node.last_seen).getTime() > FOURTEEN_DAYS_MS) continue;
|
||||
|
||||
const isProhibited = Boolean(node.name?.includes(PROHIBITED_MARKER));
|
||||
|
||||
let dotLat = node.lat;
|
||||
let dotLon = node.lon!;
|
||||
let circleLat: number | null = null;
|
||||
let circleLon: number | null = null;
|
||||
|
||||
if (isProhibited) {
|
||||
const center = stablePointWithinMiles(node.lat, node.lon!, node.node_id);
|
||||
const activityKey = node.last_seen ?? 'unknown';
|
||||
const point = stablePointWithinMiles(center[0], center[1], `${node.node_id}|${activityKey}`);
|
||||
circleLat = center[0];
|
||||
circleLon = center[1];
|
||||
dotLat = point[0];
|
||||
dotLon = point[1];
|
||||
}
|
||||
|
||||
// Compute pixel position for the dot
|
||||
const mercY = latToMercY(dotLat);
|
||||
const px = (dotLon - lonW) / lonRange * TILE_SIZE;
|
||||
const py = (mercN - mercY) / mercRange * TILE_SIZE;
|
||||
|
||||
const [r, g, b, a] = nodeColor(node, now);
|
||||
|
||||
// Draw dashed circle for privacy nodes
|
||||
if (isProhibited && circleLat !== null && circleLon !== null) {
|
||||
const cMercY = latToMercY(circleLat);
|
||||
const cpx = (circleLon - lonW) / lonRange * TILE_SIZE;
|
||||
const cpy = (mercN - cMercY) / mercRange * TILE_SIZE;
|
||||
|
||||
const metersPerPixel = 156543.03 * Math.cos(circleLat * Math.PI / 180) / pow2z;
|
||||
const radiusPx = 1609.344 / metersPerPixel;
|
||||
|
||||
if (radiusPx >= 4) {
|
||||
drawDashedCircle(pixels, cpx, cpy, radiusPx, 245, 158, 11, 140);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip dot if outside tile (with buffer)
|
||||
if (px < -BUFFER || px > TILE_SIZE + BUFFER || py < -BUFFER || py > TILE_SIZE + BUFFER) continue;
|
||||
|
||||
drawDot(pixels, px, py, DOT_R, r, g, b, a);
|
||||
// Stroke ring: slightly darker
|
||||
drawDot(pixels, px, py, DOT_R + 0.8, Math.round(r * 0.65), Math.round(g * 0.65), Math.round(b * 0.65), Math.round(a * 0.85));
|
||||
}
|
||||
|
||||
return encodePng(pixels, TILE_SIZE, TILE_SIZE);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Redis } from 'ioredis';
|
||||
import { query } from '../db/index.js';
|
||||
import type { NodeRow } from './renderer.js';
|
||||
|
||||
const TILE_SNAPSHOT_NODE_KEY_PREFIX = 'meshcore:tile_snapshot:node:';
|
||||
const TILE_SNAPSHOT_IDS_ALL_KEY = 'meshcore:tile_snapshot:ids:all';
|
||||
const TILE_SNAPSHOT_IDS_NETWORK_PREFIX = 'meshcore:tile_snapshot:ids:network:';
|
||||
const TILE_SNAPSHOT_META_KEY = 'meshcore:tile_snapshot:meta';
|
||||
|
||||
type SnapshotNode = NodeRow & { network?: string | null };
|
||||
|
||||
let redisClient: Redis | null = null;
|
||||
|
||||
function redis(): Redis {
|
||||
if (!redisClient) {
|
||||
const redisUrl = process.env['REDIS_URL'] ?? 'redis://redis:6379';
|
||||
redisClient = new Redis(redisUrl);
|
||||
redisClient.on('error', (err) => console.error('[tile-snapshot] redis error', err.message));
|
||||
}
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
function nodeKey(nodeId: string): string {
|
||||
return `${TILE_SNAPSHOT_NODE_KEY_PREFIX}${nodeId}`;
|
||||
}
|
||||
|
||||
function networkIdsKey(network: string): string {
|
||||
return `${TILE_SNAPSHOT_IDS_NETWORK_PREFIX}${network}`;
|
||||
}
|
||||
|
||||
function toNullableString(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const text = String(value).trim();
|
||||
return text === '' ? null : text;
|
||||
}
|
||||
|
||||
function toNullableNumber(value: unknown): number | null {
|
||||
if (value == null || value === '') return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function toBoolean(value: unknown, fallback = false): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 't') return true;
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'f') return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeSnapshotNode(value: Partial<SnapshotNode> & { node_id?: unknown; last_seen?: unknown }): SnapshotNode | null {
|
||||
const nodeId = toNullableString(value.node_id);
|
||||
const lastSeen = toNullableString(value.last_seen);
|
||||
if (!nodeId || !lastSeen) return null;
|
||||
return {
|
||||
node_id: nodeId,
|
||||
name: toNullableString(value.name),
|
||||
lat: toNullableNumber(value.lat),
|
||||
lon: toNullableNumber(value.lon),
|
||||
role: toNullableNumber(value.role),
|
||||
last_seen: lastSeen,
|
||||
is_online: toBoolean(value.is_online, true),
|
||||
network: toNullableString(value.network),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSnapshotNode(raw: string | null): SnapshotNode | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<SnapshotNode>;
|
||||
return normalizeSnapshotNode(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTileSnapshotNodes(network?: string): Promise<NodeRow[]> {
|
||||
const r = redis();
|
||||
const ids = await r.smembers(network ? networkIdsKey(network) : TILE_SNAPSHOT_IDS_ALL_KEY);
|
||||
if (ids.length < 1) return [];
|
||||
const rows = await r.mget(ids.map((id) => nodeKey(id)));
|
||||
return rows
|
||||
.map(parseSnapshotNode)
|
||||
.filter((node): node is SnapshotNode => Boolean(node))
|
||||
.sort((a, b) => Date.parse(b.last_seen) - Date.parse(a.last_seen));
|
||||
}
|
||||
|
||||
export async function rebuildTileSnapshotFromDb(): Promise<number> {
|
||||
const res = await query<SnapshotNode>(
|
||||
`SELECT node_id, name, lat, lon, role, last_seen::text AS last_seen, is_online, network
|
||||
FROM nodes
|
||||
WHERE network IS DISTINCT FROM 'test'`,
|
||||
);
|
||||
const r = redis();
|
||||
const allIds = new Set<string>();
|
||||
const seenNetworks = new Set<string>();
|
||||
for (const row of res.rows) {
|
||||
const network = toNullableString(row.network);
|
||||
if (network) seenNetworks.add(network);
|
||||
}
|
||||
const pipe = r.pipeline();
|
||||
|
||||
pipe.del(TILE_SNAPSHOT_IDS_ALL_KEY);
|
||||
for (const network of ['teesside', 'ukmesh', ...seenNetworks]) {
|
||||
pipe.del(networkIdsKey(network));
|
||||
}
|
||||
for (const row of res.rows) {
|
||||
const node = normalizeSnapshotNode(row);
|
||||
if (!node) continue;
|
||||
const serialized = JSON.stringify(node);
|
||||
pipe.set(nodeKey(node.node_id), serialized);
|
||||
pipe.sadd(TILE_SNAPSHOT_IDS_ALL_KEY, node.node_id);
|
||||
allIds.add(node.node_id);
|
||||
if (node.network) {
|
||||
pipe.sadd(networkIdsKey(node.network), node.node_id);
|
||||
}
|
||||
}
|
||||
pipe.hset(TILE_SNAPSHOT_META_KEY, {
|
||||
refreshed_at: new Date().toISOString(),
|
||||
node_count: String(allIds.size),
|
||||
});
|
||||
|
||||
await pipe.exec();
|
||||
return allIds.size;
|
||||
}
|
||||
|
||||
export async function upsertTileSnapshotNode(node: Record<string, unknown>): Promise<void> {
|
||||
const nodeId = toNullableString(node['node_id']);
|
||||
if (!nodeId) return;
|
||||
|
||||
const r = redis();
|
||||
const existing = parseSnapshotNode(await r.get(nodeKey(nodeId)));
|
||||
const mergedInput: Partial<SnapshotNode> & { node_id: string; last_seen: string; is_online: boolean } = {
|
||||
node_id: nodeId,
|
||||
name: toNullableString(node['name']) ?? existing?.name ?? null,
|
||||
lat: toNullableNumber(node['lat']) ?? existing?.lat ?? null,
|
||||
lon: toNullableNumber(node['lon']) ?? existing?.lon ?? null,
|
||||
role: toNullableNumber(node['role']) ?? existing?.role ?? null,
|
||||
last_seen: toNullableString(node['last_seen']) ?? existing?.last_seen ?? new Date().toISOString(),
|
||||
is_online: node['is_online'] != null ? toBoolean(node['is_online'], true) : (existing?.is_online ?? true),
|
||||
network: toNullableString(node['network']) ?? existing?.network ?? null,
|
||||
};
|
||||
const merged = normalizeSnapshotNode(mergedInput);
|
||||
if (!merged) return;
|
||||
|
||||
const pipe = r.pipeline();
|
||||
if (existing?.network && existing.network !== merged.network) {
|
||||
pipe.srem(networkIdsKey(existing.network), nodeId);
|
||||
}
|
||||
if (merged.network === 'test') {
|
||||
pipe.srem(TILE_SNAPSHOT_IDS_ALL_KEY, nodeId);
|
||||
if (existing?.network) pipe.srem(networkIdsKey(existing.network), nodeId);
|
||||
pipe.del(nodeKey(nodeId));
|
||||
} else {
|
||||
pipe.set(nodeKey(nodeId), JSON.stringify(merged));
|
||||
pipe.sadd(TILE_SNAPSHOT_IDS_ALL_KEY, nodeId);
|
||||
if (merged.network) pipe.sadd(networkIdsKey(merged.network), nodeId);
|
||||
pipe.hset(TILE_SNAPSHOT_META_KEY, 'updated_at', new Date().toISOString());
|
||||
}
|
||||
await pipe.exec();
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Tile pre-rendering worker.
|
||||
*
|
||||
* On startup and every hour, renders all node tiles covering the UK
|
||||
* (z=5–13) for each active network and writes them to Redis with a
|
||||
* 90-minute TTL. Everything outside the UK bbox continues to render
|
||||
* on-demand via the tile API route.
|
||||
*
|
||||
* Yields to the event loop every batch flush so tile compression
|
||||
* does not monopolise the worker event loop.
|
||||
* is never starved.
|
||||
*/
|
||||
import { Redis } from 'ioredis';
|
||||
import { buildTileIndex, renderTileFromIndex } from './renderer.js';
|
||||
import { getTileSnapshotNodes } from './snapshot.js';
|
||||
|
||||
// UK bounding box — same as the viewshed eligibility check in index.ts
|
||||
const UK_LAT_MIN = 49.5;
|
||||
const UK_LAT_MAX = 61.5;
|
||||
const UK_LON_MIN = -8.5;
|
||||
const UK_LON_MAX = 2.5;
|
||||
|
||||
export const UK_ZOOM_MIN = 5;
|
||||
export const UK_ZOOM_MAX = 13;
|
||||
|
||||
// Tile TTL: 90 min. Worker runs every 60 min, so tiles never expire
|
||||
// between passes. On-demand route also uses this TTL for UK tiles.
|
||||
export const UK_TILE_TTL_MS = 90 * 60 * 1000;
|
||||
|
||||
const REFRESH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const BATCH_SIZE = 50; // tiles per pipeline flush + event-loop yield
|
||||
const TILE_WORKER_STATE_KEY = 'meshcore:tile_worker:state';
|
||||
const TILE_WORKER_RESUME_MAX_AGE_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
// Networks to pre-render. Each entry is [db_network, cache_key_scope].
|
||||
// 'all' scope uses undefined for getNodes() (returns all non-test nodes).
|
||||
const NETWORKS: Array<{ db: string | undefined; scope: string }> = [
|
||||
{ db: undefined, scope: 'all' },
|
||||
{ db: 'teesside', scope: 'teesside' },
|
||||
];
|
||||
|
||||
type TileCursor = {
|
||||
scopeIndex: number;
|
||||
z: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
function firstCursor(): TileCursor {
|
||||
const firstScopeIndex = 0;
|
||||
const { xMin, yMin } = ukTileRange(UK_ZOOM_MIN);
|
||||
return {
|
||||
scopeIndex: firstScopeIndex,
|
||||
z: UK_ZOOM_MIN,
|
||||
x: xMin,
|
||||
y: yMin,
|
||||
};
|
||||
}
|
||||
|
||||
function parseInteger(value: string | undefined): number | null {
|
||||
if (value == null || value === '') return null;
|
||||
const num = Number(value);
|
||||
return Number.isInteger(num) ? num : null;
|
||||
}
|
||||
|
||||
function parseResumeCursor(state: Record<string, string>): TileCursor | null {
|
||||
if (state['status'] !== 'running') return null;
|
||||
const updatedAt = state['updated_at'] ? Date.parse(state['updated_at']) : Number.NaN;
|
||||
if (!Number.isFinite(updatedAt) || Date.now() - updatedAt > TILE_WORKER_RESUME_MAX_AGE_MS) return null;
|
||||
|
||||
const scopeIndex = parseInteger(state['next_scope_index']);
|
||||
const z = parseInteger(state['next_zoom']);
|
||||
const x = parseInteger(state['next_x']);
|
||||
const y = parseInteger(state['next_y']);
|
||||
if (scopeIndex == null || z == null || x == null || y == null) return null;
|
||||
if (scopeIndex < 0 || scopeIndex >= NETWORKS.length) return null;
|
||||
if (z < UK_ZOOM_MIN || z > UK_ZOOM_MAX) return null;
|
||||
|
||||
const { xMin, xMax, yMin, yMax } = ukTileRange(z);
|
||||
if (x < xMin || x > xMax || y < yMin || y > yMax) return null;
|
||||
return { scopeIndex, z, x, y };
|
||||
}
|
||||
|
||||
function nextCursor(cursor: TileCursor): TileCursor | null {
|
||||
const { scopeIndex, z, x, y } = cursor;
|
||||
const range = ukTileRange(z);
|
||||
if (y < range.yMax) {
|
||||
return { scopeIndex, z, x, y: y + 1 };
|
||||
}
|
||||
if (x < range.xMax) {
|
||||
return { scopeIndex, z, x: x + 1, y: range.yMin };
|
||||
}
|
||||
if (z < UK_ZOOM_MAX) {
|
||||
const nextZ = z + 1;
|
||||
const nextRange = ukTileRange(nextZ);
|
||||
return { scopeIndex, z: nextZ, x: nextRange.xMin, y: nextRange.yMin };
|
||||
}
|
||||
if (scopeIndex + 1 < NETWORKS.length) {
|
||||
const nextScopeIndex = scopeIndex + 1;
|
||||
const nextRange = ukTileRange(UK_ZOOM_MIN);
|
||||
return { scopeIndex: nextScopeIndex, z: UK_ZOOM_MIN, x: nextRange.xMin, y: nextRange.yMin };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cursorMatches(scopeIndex: number, z: number, x: number, y: number, cursor: TileCursor): boolean {
|
||||
return cursor.scopeIndex === scopeIndex && cursor.z === z && cursor.x === x && cursor.y === y;
|
||||
}
|
||||
|
||||
export function isUkTile(z: number, x: number, y: number): boolean {
|
||||
const pow2 = Math.pow(2, z);
|
||||
const lonW = (x / pow2) * 360 - 180;
|
||||
const lonE = ((x + 1) / pow2) * 360 - 180;
|
||||
if (lonE < UK_LON_MIN || lonW > UK_LON_MAX) return false;
|
||||
const nN = Math.PI - (2 * Math.PI * y) / pow2;
|
||||
const nS = Math.PI - (2 * Math.PI * (y + 1)) / pow2;
|
||||
const latN = Math.atan(Math.sinh(nN)) * (180 / Math.PI);
|
||||
const latS = Math.atan(Math.sinh(nS)) * (180 / Math.PI);
|
||||
return latN >= UK_LAT_MIN && latS <= UK_LAT_MAX;
|
||||
}
|
||||
|
||||
function ukTileRange(z: number) {
|
||||
const pow2 = Math.pow(2, z);
|
||||
const xMin = Math.floor((UK_LON_MIN + 180) / 360 * pow2);
|
||||
const xMax = Math.floor((UK_LON_MAX + 180) / 360 * pow2);
|
||||
function mercY(lat: number) {
|
||||
const r = lat * Math.PI / 180;
|
||||
return Math.floor((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2 * pow2);
|
||||
}
|
||||
return { xMin, xMax, yMin: mercY(UK_LAT_MAX), yMax: mercY(UK_LAT_MIN) };
|
||||
}
|
||||
|
||||
function totalUkTiles(): number {
|
||||
let n = 0;
|
||||
for (let z = UK_ZOOM_MIN; z <= UK_ZOOM_MAX; z++) {
|
||||
const { xMin, xMax, yMin, yMax } = ukTileRange(z);
|
||||
n += (xMax - xMin + 1) * (yMax - yMin + 1);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
async function renderPass(redis: Redis): Promise<void> {
|
||||
const tileCount = totalUkTiles();
|
||||
const start = Date.now();
|
||||
const totalTilesAllScopes = tileCount * NETWORKS.length;
|
||||
const previousState = await redis.hgetall(TILE_WORKER_STATE_KEY);
|
||||
const resumeCursor = parseResumeCursor(previousState);
|
||||
const resumed = Boolean(resumeCursor);
|
||||
const startCursor = resumeCursor ?? firstCursor();
|
||||
|
||||
await redis.hset(TILE_WORKER_STATE_KEY, {
|
||||
status: 'running',
|
||||
updated_at: new Date().toISOString(),
|
||||
started_at: resumed ? (previousState['started_at'] || new Date(start).toISOString()) : new Date(start).toISOString(),
|
||||
total_tiles: String(totalTilesAllScopes),
|
||||
done_tiles: resumed ? String(Number(previousState['done_tiles'] ?? 0)) : '0',
|
||||
remaining_tiles: resumed
|
||||
? String(Math.max(0, totalTilesAllScopes - Number(previousState['done_tiles'] ?? 0)))
|
||||
: String(totalTilesAllScopes),
|
||||
scope: resumed ? String(previousState['scope'] ?? '') : '',
|
||||
zoom: resumed ? String(previousState['zoom'] ?? '') : '',
|
||||
next_scope_index: String(startCursor.scopeIndex),
|
||||
next_zoom: String(startCursor.z),
|
||||
next_x: String(startCursor.x),
|
||||
next_y: String(startCursor.y),
|
||||
resumed_from_checkpoint: resumed ? '1' : '0',
|
||||
last_error: '',
|
||||
});
|
||||
|
||||
let globalDone = resumed ? Number(previousState['done_tiles'] ?? 0) : 0;
|
||||
|
||||
if (resumed) {
|
||||
console.log(
|
||||
`[tile-worker] resuming pass from scope=${NETWORKS[startCursor.scopeIndex]?.scope ?? startCursor.scopeIndex} z=${startCursor.z} x=${startCursor.x} y=${startCursor.y} done=${globalDone.toLocaleString()}/${totalTilesAllScopes.toLocaleString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let scopeIndex = startCursor.scopeIndex; scopeIndex < NETWORKS.length; scopeIndex++) {
|
||||
const { db, scope } = NETWORKS[scopeIndex]!;
|
||||
const nodes = await getTileSnapshotNodes(db);
|
||||
|
||||
// Build spatial index once per network — buckets each node into the
|
||||
// exact tile(s) it falls in at every zoom level. Avoids iterating
|
||||
// all nodes for every tile.
|
||||
const index = buildTileIndex(nodes, UK_ZOOM_MIN, UK_ZOOM_MAX);
|
||||
console.log(`[tile-worker] ${scope} — ${nodes.length} nodes indexed (${index.prohibited.length} prohibited)`);
|
||||
|
||||
let done = 0;
|
||||
const resumingThisScope = startCursor.scopeIndex === scopeIndex;
|
||||
// Pipeline batches writes to Redis — 100 SET commands per round-trip
|
||||
// instead of one per tile. We flush and yield to the event loop every
|
||||
// BATCH_SIZE tiles so HTTP/WS serving is never starved.
|
||||
let pipe = redis.pipeline();
|
||||
let pipeCount = 0;
|
||||
let pendingNextCursor: TileCursor | null = resumingThisScope ? startCursor : null;
|
||||
|
||||
const flush = async () => {
|
||||
if (pipeCount === 0) return;
|
||||
await pipe.exec();
|
||||
pipe = redis.pipeline();
|
||||
pipeCount = 0;
|
||||
await redis.hset(TILE_WORKER_STATE_KEY, {
|
||||
status: 'running',
|
||||
updated_at: new Date().toISOString(),
|
||||
done_tiles: String(globalDone),
|
||||
remaining_tiles: String(Math.max(0, totalTilesAllScopes - globalDone)),
|
||||
scope,
|
||||
zoom: pendingNextCursor ? String(pendingNextCursor.z) : String(UK_ZOOM_MAX),
|
||||
next_scope_index: pendingNextCursor ? String(pendingNextCursor.scopeIndex) : String(NETWORKS.length),
|
||||
next_zoom: pendingNextCursor ? String(pendingNextCursor.z) : '',
|
||||
next_x: pendingNextCursor ? String(pendingNextCursor.x) : '',
|
||||
next_y: pendingNextCursor ? String(pendingNextCursor.y) : '',
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
};
|
||||
|
||||
const startZoom = resumingThisScope ? startCursor.z : UK_ZOOM_MIN;
|
||||
for (let z = startZoom; z <= UK_ZOOM_MAX; z++) {
|
||||
const { xMin, xMax, yMin, yMax } = ukTileRange(z);
|
||||
const startX = resumingThisScope && z === startCursor.z ? startCursor.x : xMin;
|
||||
for (let x = startX; x <= xMax; x++) {
|
||||
const startY = resumingThisScope && z === startCursor.z && x === startCursor.x ? startCursor.y : yMin;
|
||||
for (let y = startY; y <= yMax; y++) {
|
||||
const key = `tile:nodes:${scope}:${z}:${x}:${y}`;
|
||||
// Empty tiles return the cached 334-byte buffer instantly (no deflate).
|
||||
const png = await renderTileFromIndex(z, x, y, index);
|
||||
pipe.set(key, png, 'PX', UK_TILE_TTL_MS);
|
||||
done++;
|
||||
globalDone++;
|
||||
pipeCount++;
|
||||
pendingNextCursor = nextCursor({ scopeIndex, z, x, y });
|
||||
|
||||
if (pipeCount >= BATCH_SIZE) await flush();
|
||||
}
|
||||
}
|
||||
|
||||
await flush(); // flush remainder at end of each zoom level
|
||||
await redis.hset(TILE_WORKER_STATE_KEY, {
|
||||
status: 'running',
|
||||
updated_at: new Date().toISOString(),
|
||||
done_tiles: String(globalDone),
|
||||
remaining_tiles: String(Math.max(0, totalTilesAllScopes - globalDone)),
|
||||
scope,
|
||||
zoom: String(z),
|
||||
next_scope_index: pendingNextCursor ? String(pendingNextCursor.scopeIndex) : String(NETWORKS.length),
|
||||
next_zoom: pendingNextCursor ? String(pendingNextCursor.z) : '',
|
||||
next_x: pendingNextCursor ? String(pendingNextCursor.x) : '',
|
||||
next_y: pendingNextCursor ? String(pendingNextCursor.y) : '',
|
||||
});
|
||||
const pct = Math.round(done / tileCount * 100);
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`[tile-worker] ${scope} z=${z} — ${done.toLocaleString()}/${tileCount.toLocaleString()} tiles (${pct}%) — ${elapsed}s elapsed`);
|
||||
}
|
||||
await flush();
|
||||
|
||||
const totalElapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`[tile-worker] ${scope} complete — ${done.toLocaleString()} tiles in ${totalElapsed}s`);
|
||||
}
|
||||
|
||||
await redis.hset(TILE_WORKER_STATE_KEY, {
|
||||
status: 'idle',
|
||||
updated_at: new Date().toISOString(),
|
||||
done_tiles: String(totalTilesAllScopes),
|
||||
remaining_tiles: '0',
|
||||
last_pass_finished_at: new Date().toISOString(),
|
||||
last_pass_tiles: String(totalTilesAllScopes),
|
||||
next_scope_index: '',
|
||||
next_zoom: '',
|
||||
next_x: '',
|
||||
next_y: '',
|
||||
});
|
||||
}
|
||||
|
||||
export function startTileWorker(): void {
|
||||
const redisUrl = process.env['REDIS_URL'] ?? 'redis://redis:6379';
|
||||
const redis = new Redis(redisUrl);
|
||||
redis.on('error', (e: Error) => console.error('[tile-worker] redis error', e.message));
|
||||
|
||||
let running = false;
|
||||
|
||||
const run = () => {
|
||||
if (running) {
|
||||
console.log('[tile-worker] skipping pass start — previous pass still running');
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
console.log(`[tile-worker] starting pass — ${totalUkTiles()} UK tiles × ${NETWORKS.length} networks`);
|
||||
void renderPass(redis)
|
||||
.catch(async (err: Error) => {
|
||||
await redis.hset(TILE_WORKER_STATE_KEY, {
|
||||
status: 'error',
|
||||
updated_at: new Date().toISOString(),
|
||||
last_error: err.message,
|
||||
}).catch(() => {});
|
||||
console.error('[tile-worker] pass failed:', err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
running = false;
|
||||
});
|
||||
};
|
||||
|
||||
// First pass after 5 s (let server finish starting up), then every hour.
|
||||
setTimeout(run, 5_000);
|
||||
setInterval(run, REFRESH_INTERVAL_MS);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'node:process';
|
||||
import { initDb } from '../db/index.js';
|
||||
import { captureWorkerHealthSnapshot } from '../health/status.js';
|
||||
import { startTileWorker } from '../tiles/worker.js';
|
||||
|
||||
const SNAPSHOT_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
@@ -19,6 +20,8 @@ async function main() {
|
||||
setInterval(() => {
|
||||
void captureOnce('scheduled');
|
||||
}, SNAPSHOT_INTERVAL_MS);
|
||||
|
||||
startTileWorker();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'node:process';
|
||||
import { initDb } from '../db/index.js';
|
||||
import { startTileWorker } from '../tiles/worker.js';
|
||||
|
||||
async function main() {
|
||||
await initDb();
|
||||
startTileWorker();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[tile-worker] fatal startup error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -297,7 +297,7 @@ export function initWebSocketServer(httpServer: Server): WebSocketServer {
|
||||
|
||||
const scheduleFlush = () => {
|
||||
if (flushTimeout !== null) return;
|
||||
flushTimeout = setTimeout(flushMessageQueue, 50);
|
||||
flushTimeout = setTimeout(flushMessageQueue, 16);
|
||||
};
|
||||
|
||||
// Fan-out Redis messages to all connected WS clients - now batched
|
||||
|
||||
@@ -122,6 +122,29 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
tile-worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
restart: unless-stopped
|
||||
command: ["node", "dist/workers/tile.js"]
|
||||
cpus: 1.0
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore}
|
||||
DATABASE_APPLICATION_NAME: meshcore-tile-worker
|
||||
DATABASE_POOL_MAX: 2
|
||||
DATABASE_STATEMENT_TIMEOUT_MS: 10000
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
|
||||
NODE_ENV: production
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
backend:
|
||||
condition: service_started
|
||||
|
||||
link-backfill-worker:
|
||||
build:
|
||||
context: .
|
||||
|
||||
Generated
+403
-17
@@ -16,7 +16,9 @@
|
||||
"polygon-clipping": "^0.15.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1"
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.14",
|
||||
@@ -261,6 +263,14 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -352,21 +362,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@deck.gl/geo-layers": {
|
||||
"version": "9.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.2.10.tgz",
|
||||
"integrity": "sha512-Rt271ANfwZbGQIH83yxvbxaguBK6QQ3OrdxT05dN0B2NxsBwzaFcnB1UTB0Fna5+SVGBwZAMUr0FzunG/V5ljA==",
|
||||
"license": "MIT",
|
||||
"version": "9.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.2.11.tgz",
|
||||
"integrity": "sha512-Mr3yvKyZMPmQ3ho0hSqcJu1p7a881RqQaq/dRaPs2VP56UAkfk1e10zxXnrZ9/Dmo2MR5PH0j8tkOoGR3zKbfA==",
|
||||
"dependencies": {
|
||||
"@loaders.gl/3d-tiles": "^4.3.4",
|
||||
"@loaders.gl/gis": "^4.3.4",
|
||||
"@loaders.gl/loader-utils": "^4.3.4",
|
||||
"@loaders.gl/mvt": "^4.3.4",
|
||||
"@loaders.gl/schema": "^4.3.4",
|
||||
"@loaders.gl/terrain": "^4.3.4",
|
||||
"@loaders.gl/tiles": "^4.3.4",
|
||||
"@loaders.gl/wms": "^4.3.4",
|
||||
"@luma.gl/gltf": "^9.2.6",
|
||||
"@luma.gl/shadertools": "^9.2.6",
|
||||
"@loaders.gl/3d-tiles": "~4.3.4",
|
||||
"@loaders.gl/gis": "~4.3.4",
|
||||
"@loaders.gl/loader-utils": "~4.3.4",
|
||||
"@loaders.gl/mvt": "~4.3.4",
|
||||
"@loaders.gl/schema": "~4.3.4",
|
||||
"@loaders.gl/terrain": "~4.3.4",
|
||||
"@loaders.gl/tiles": "~4.3.4",
|
||||
"@loaders.gl/wms": "~4.3.4",
|
||||
"@luma.gl/gltf": "~9.2.6",
|
||||
"@luma.gl/shadertools": "~9.2.6",
|
||||
"@math.gl/core": "^4.1.0",
|
||||
"@math.gl/culling": "^4.1.0",
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
@@ -380,7 +389,7 @@
|
||||
"@deck.gl/extensions": "~9.2.0",
|
||||
"@deck.gl/layers": "~9.2.0",
|
||||
"@deck.gl/mesh-layers": "~9.2.0",
|
||||
"@loaders.gl/core": "^4.3.4",
|
||||
"@loaders.gl/core": "~4.3.4",
|
||||
"@luma.gl/core": "~9.2.6",
|
||||
"@luma.gl/engine": "~9.2.6"
|
||||
}
|
||||
@@ -1873,6 +1882,60 @@
|
||||
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2101,6 +2164,14 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -2108,6 +2179,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/core-assert": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz",
|
||||
@@ -2140,9 +2223,118 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2161,6 +2353,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
|
||||
},
|
||||
"node_modules/deep-strict-equal": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-strict-equal/-/deep-strict-equal-0.2.0.tgz",
|
||||
@@ -2173,6 +2370,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/draco3d": {
|
||||
"version": "1.5.7",
|
||||
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
|
||||
@@ -2241,6 +2447,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz",
|
||||
@@ -2351,6 +2570,14 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-buffer": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
|
||||
@@ -2434,6 +2661,11 @@
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz",
|
||||
@@ -2528,6 +2760,14 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
@@ -2610,6 +2850,21 @@
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||
@@ -2641,6 +2896,11 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||
@@ -2665,6 +2925,71 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
||||
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||
"dependencies": {
|
||||
"react-router": "7.13.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||
"dependencies": {
|
||||
"fast-equals": "^5.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
@@ -2680,6 +3005,36 @@
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-is": "^18.3.1",
|
||||
"react-smooth": "^4.0.4",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"victory-vendor": "^36.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts-scale": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||
"dependencies": {
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
@@ -2765,6 +3120,11 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
@@ -2836,6 +3196,11 @@
|
||||
"texture-compressor": "bin/texture-compressor.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -2893,6 +3258,27 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
+2
-58
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Map as LeafletMap } from 'leaflet';
|
||||
import { MapView } from './components/Map/MapView.js';
|
||||
import type { DeckViewState, GPUNodeData } from './components/Map/DeckGLOverlay.js';
|
||||
import type { DeckViewState } from './components/Map/DeckGLOverlay.js';
|
||||
import { DeckGLOverlay } from './components/Map/DeckGLOverlay.js';
|
||||
import { FilterPanel, type Filters } from './components/FilterPanel/FilterPanel.js';
|
||||
import { PacketFeed } from './components/PacketFeed.js';
|
||||
@@ -17,7 +17,7 @@ import { usePacketPathOverlay } from './hooks/usePacketPathOverlay.js';
|
||||
import { useAppMessageHandler } from './hooks/useAppMessageHandler.js';
|
||||
import { getCurrentSite } from './config/site.js';
|
||||
import { uncachedEndpoint, withScopeParams } from './utils/api.js';
|
||||
import { buildHiddenCoordMask, resolvePathNodeIds, hasCoords, maskNodePoint } from './utils/pathing.js';
|
||||
import { buildHiddenCoordMask, resolvePathNodeIds, hasCoords } from './utils/pathing.js';
|
||||
|
||||
type PacketHistorySegment = {
|
||||
positions: [[number, number], [number, number]];
|
||||
@@ -60,7 +60,6 @@ export const App: React.FC = () => {
|
||||
const [isPageVisible, setIsPageVisible] = useState(
|
||||
() => (typeof document === 'undefined' ? true : document.visibilityState === 'visible'),
|
||||
);
|
||||
const [prefixFocusActive, setPrefixFocusActive] = useState(false);
|
||||
const clashRestoreRef = useRef<{ coverage: boolean; clientNodes: boolean } | null>(null);
|
||||
const prevHexClashesRef = useRef<boolean>(DEFAULT_FILTERS.hexClashes);
|
||||
|
||||
@@ -153,58 +152,6 @@ export const App: React.FC = () => {
|
||||
return result;
|
||||
}, [pinnedPacketSnapshot, filters.betaPaths, packets, nodes]);
|
||||
|
||||
// GPU node data — computed here so DeckGLOverlay can render dots without Leaflet SVG elements.
|
||||
// Colors mirror the markerColor() logic in NodeMarker.tsx (hex-clash colours are never needed
|
||||
// here because showGpuNodes=false during clash mode).
|
||||
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const gpuNodes = useMemo<GPUNodeData[]>(() => {
|
||||
const now = Date.now();
|
||||
const result: GPUNodeData[] = [];
|
||||
|
||||
const addNode = (node: MeshNode, isInferredVariant: boolean) => {
|
||||
if (!hasCoords(node)) return;
|
||||
if (now - new Date(node.last_seen).getTime() > FOURTEEN_DAYS_MS) return;
|
||||
if (pathNodeIds && !pathNodeIds.has(node.node_id.toLowerCase())) return;
|
||||
|
||||
const isStale = now - new Date(node.last_seen).getTime() > SEVEN_DAYS_MS;
|
||||
const variant = (isInferredVariant || node.is_inferred)
|
||||
? 'inferred'
|
||||
: (node.role === 1 ? 'companion' : node.role === 3 ? 'room' : 'repeater');
|
||||
|
||||
const masked = maskNodePoint(node, hiddenCoordMask);
|
||||
const maskedLat = masked?.[0] ?? node.lat!;
|
||||
const maskedLon = masked?.[1] ?? node.lon!;
|
||||
|
||||
const alpha = 178; // 0.7 * 255
|
||||
let color: [number, number, number, number];
|
||||
if (isStale) color = [255, 68, 68, alpha];
|
||||
else if (!node.is_online) color = [102, 102, 102, alpha];
|
||||
else if (variant === 'companion') color = [255, 152, 0, alpha];
|
||||
else if (variant === 'room') color = [206, 147, 216, alpha];
|
||||
else if (variant === 'inferred') color = [109, 220, 122, 230];
|
||||
else color = [ 0, 196, 255, alpha]; // repeater
|
||||
|
||||
result.push({ id: node.node_id, position: [maskedLon, maskedLat], color, radius: 3.5 });
|
||||
};
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
const role = node.role;
|
||||
if (role !== undefined && role !== 2 && role !== 1 && role !== 3) continue;
|
||||
if ((role === 1 || role === 3) && !filters.clientNodes) continue;
|
||||
addNode(node, false);
|
||||
}
|
||||
for (const node of inferredNodes) {
|
||||
addNode(node, true);
|
||||
}
|
||||
|
||||
return result;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodes, inferredNodes, hiddenCoordMask, pathNodeIds, filters.clientNodes]);
|
||||
|
||||
const handlePrefixFocusActiveChange = useCallback((active: boolean) => {
|
||||
setPrefixFocusActive(active);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
@@ -415,7 +362,6 @@ export const App: React.FC = () => {
|
||||
hiddenCoordMask={hiddenCoordMask}
|
||||
pathNodeIds={pathNodeIds}
|
||||
onMapReady={setMap}
|
||||
onPrefixFocusActiveChange={handlePrefixFocusActiveChange}
|
||||
/>
|
||||
<DeckGLOverlay
|
||||
arcs={arcs}
|
||||
@@ -427,8 +373,6 @@ export const App: React.FC = () => {
|
||||
betaCompletionPaths={betaCompletionPaths}
|
||||
showBetaPaths={filters.betaPaths || pinnedPacketId !== null}
|
||||
pathFadingOut={pathFadingOut}
|
||||
gpuNodes={gpuNodes}
|
||||
showGpuNodes={!filters.hexClashes && !prefixFocusActive}
|
||||
viewState={deckViewState}
|
||||
hiddenCoordMask={hiddenCoordMask}
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import DeckGL from '@deck.gl/react';
|
||||
import { ArcLayer, LineLayer, PathLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import { ArcLayer, LineLayer, PathLayer } from '@deck.gl/layers';
|
||||
import { PathStyleExtension } from '@deck.gl/extensions';
|
||||
import type { PathStyleExtensionProps } from '@deck.gl/extensions';
|
||||
import type { PacketArc } from '../../hooks/useNodes.js';
|
||||
@@ -38,13 +38,6 @@ type HistorySegmentWithColor = HistorySegment & {
|
||||
width: number;
|
||||
};
|
||||
|
||||
export interface GPUNodeData {
|
||||
id: string;
|
||||
position: [number, number]; // [lon, lat] — deck.gl / GeoJSON convention
|
||||
color: [number, number, number, number]; // RGBA fill
|
||||
radius: number; // pixels
|
||||
}
|
||||
|
||||
interface Props {
|
||||
// Live arc trails
|
||||
arcs: PacketArc[];
|
||||
@@ -62,10 +55,6 @@ interface Props {
|
||||
/** When true, opacity transitions to 0 (deck.gl handles the interpolation). */
|
||||
pathFadingOut: boolean;
|
||||
|
||||
// GPU-rendered node markers (repeaters, companions, room servers, inferred)
|
||||
gpuNodes: GPUNodeData[];
|
||||
showGpuNodes: boolean;
|
||||
|
||||
viewState: DeckViewState;
|
||||
hiddenCoordMask: Map<string, HiddenMaskGeometry>;
|
||||
}
|
||||
@@ -92,35 +81,12 @@ function useDeckLayers(
|
||||
betaCompletionPaths: [number, number][][],
|
||||
showBetaPaths: boolean,
|
||||
pathFadingOut: boolean,
|
||||
gpuNodes: GPUNodeData[],
|
||||
showGpuNodes: boolean,
|
||||
hiddenCoordMask: Map<string, HiddenMaskGeometry>,
|
||||
) {
|
||||
return useMemo(() => {
|
||||
const now = Date.now();
|
||||
const layers = [];
|
||||
|
||||
// ── Node markers (replaces Leaflet CircleMarker SVG elements) ────────────
|
||||
if (showGpuNodes && gpuNodes.length > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<GPUNodeData>({
|
||||
id: 'node-markers',
|
||||
data: gpuNodes,
|
||||
getPosition: (d) => d.position,
|
||||
getFillColor: (d) => d.color,
|
||||
getLineColor: (d) => [d.color[0], d.color[1], d.color[2], 200] as [number, number, number, number],
|
||||
getRadius: (d) => d.radius,
|
||||
radiusUnits: 'pixels',
|
||||
radiusMinPixels: 2,
|
||||
stroked: true,
|
||||
lineWidthUnits: 'pixels',
|
||||
lineWidthMinPixels: 1,
|
||||
getLineWidth: 1,
|
||||
pickable: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Arc trails ───────────────────────────────────────────────────────────
|
||||
if (showArcs && arcs.length > 0) {
|
||||
const visible = arcs.filter((a) => now - a.ts < ARC_TTL_MS);
|
||||
@@ -253,7 +219,6 @@ function useDeckLayers(
|
||||
packetHistorySegments, showPacketHistory,
|
||||
betaPaths, betaLowSegments, betaCompletionPaths,
|
||||
showBetaPaths, pathFadingOut,
|
||||
gpuNodes, showGpuNodes,
|
||||
hiddenCoordMask,
|
||||
]);
|
||||
}
|
||||
@@ -263,7 +228,6 @@ export const DeckGLOverlay: React.FC<Props> = React.memo(({
|
||||
packetHistorySegments, showPacketHistory,
|
||||
betaPaths, betaLowSegments, betaCompletionPaths,
|
||||
showBetaPaths, pathFadingOut,
|
||||
gpuNodes, showGpuNodes,
|
||||
viewState, hiddenCoordMask,
|
||||
}) => {
|
||||
const layers = useDeckLayers(
|
||||
@@ -271,7 +235,6 @@ export const DeckGLOverlay: React.FC<Props> = React.memo(({
|
||||
packetHistorySegments, showPacketHistory,
|
||||
betaPaths, betaLowSegments, betaCompletionPaths,
|
||||
showBetaPaths, pathFadingOut,
|
||||
gpuNodes, showGpuNodes,
|
||||
hiddenCoordMask,
|
||||
);
|
||||
|
||||
|
||||
@@ -286,6 +286,14 @@ export const MapView = React.memo(({
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
const [tilePending, setTilePending] = useState(0);
|
||||
const tilePendingRef = useRef(0);
|
||||
const tileHandlers = useMemo(() => ({
|
||||
tileloadstart: () => { tilePendingRef.current += 1; setTilePending(tilePendingRef.current); },
|
||||
tileload: () => { tilePendingRef.current = Math.max(0, tilePendingRef.current - 1); setTilePending(tilePendingRef.current); },
|
||||
tileerror: () => { tilePendingRef.current = Math.max(0, tilePendingRef.current - 1); setTilePending(tilePendingRef.current); },
|
||||
}), []);
|
||||
|
||||
const [deckViewState, setDeckViewState] = useState<DeckViewState>({
|
||||
longitude: DEFAULT_CENTER[1], latitude: DEFAULT_CENTER[0], zoom: DEFAULT_ZOOM, pitch: 0, bearing: 0,
|
||||
});
|
||||
@@ -600,6 +608,12 @@ export const MapView = React.memo(({
|
||||
return (
|
||||
<div className="map-area">
|
||||
<NodeSearch nodes={nodes} map={map} />
|
||||
{gpuRendered && tilePending > 0 && (
|
||||
<div className="tile-loading-badge">
|
||||
<span className="tile-loading-badge__spinner" />
|
||||
{tilePending} tile{tilePending !== 1 ? 's' : ''} rendering
|
||||
</div>
|
||||
)}
|
||||
<MapContainer
|
||||
ref={setMap}
|
||||
center={DEFAULT_CENTER}
|
||||
@@ -618,6 +632,18 @@ export const MapView = React.memo(({
|
||||
updateWhenIdle={false}
|
||||
/>
|
||||
|
||||
{/* Server-rendered node dot tiles */}
|
||||
{gpuRendered && (
|
||||
<TileLayer
|
||||
url="/api/tiles/nodes/{z}/{x}/{y}.png"
|
||||
tileSize={256}
|
||||
zIndex={400}
|
||||
updateWhenIdle={false}
|
||||
keepBuffer={2}
|
||||
eventHandlers={tileHandlers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sync Leaflet map position to deck.gl */}
|
||||
<LeafletDeckSyncer onViewStateChange={handleViewStateChange} />
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ interface Props {
|
||||
|
||||
const VISIBLE_ROWS = 8;
|
||||
|
||||
export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, mqttObserverCount = 0, onPacketClick, pinnedPacketId }) => {
|
||||
export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, onPacketClick, pinnedPacketId }) => {
|
||||
const visible = useMemo(
|
||||
() => packets.filter((p) => p.packetType === 4 || p.packetType === 5).slice(0, VISIBLE_ROWS).reverse(),
|
||||
[packets],
|
||||
@@ -57,8 +57,6 @@ export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, mqttObs
|
||||
? (TYPE_LABELS[p.packetType] ?? `T${p.packetType}`)
|
||||
: '???';
|
||||
const observerIata = p.rxNodeId ? nodes.get(p.rxNodeId)?.iata : undefined;
|
||||
const allObserversHeard = mqttObserverCount > 1 && p.observerIds.length >= mqttObserverCount;
|
||||
|
||||
const rawContent = p.summary;
|
||||
const content = rawContent?.includes('🚫') ? '[redacted]' : rawContent;
|
||||
const display = content && content.length > 28
|
||||
@@ -69,16 +67,6 @@ export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, mqttObs
|
||||
? (p.advertCount === 1 ? 'NEW' : `${p.advertCount}`)
|
||||
: undefined;
|
||||
|
||||
// Resolve observer IDs to names for display
|
||||
const MAX_OBSERVER_NAMES = 3;
|
||||
const observerNames = p.observerIds.map((id) => {
|
||||
const node = nodes.get(id);
|
||||
return node?.name ?? node?.iata ?? id.slice(0, 6);
|
||||
});
|
||||
const observerDisplay = observerNames.length > MAX_OBSERVER_NAMES
|
||||
? [...observerNames.slice(0, MAX_OBSERVER_NAMES), `+${observerNames.length - MAX_OBSERVER_NAMES}`]
|
||||
: observerNames;
|
||||
|
||||
const isPinned = pinnedPacketId === p.id;
|
||||
|
||||
return (
|
||||
@@ -90,10 +78,8 @@ export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, mqttObs
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onPacketClick?.(p)}
|
||||
>
|
||||
{(observerIata || allObserversHeard) && (
|
||||
<span className={`packet-item__iata${allObserversHeard ? ' packet-item__iata--all' : ''}`}>
|
||||
{allObserversHeard ? 'ALL' : observerIata}
|
||||
</span>
|
||||
{observerIata && (
|
||||
<span className="packet-item__iata">{observerIata}</span>
|
||||
)}
|
||||
{p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 && (
|
||||
<span className="packet-item__path-bytes">{p.pathHashSizeBytes}</span>
|
||||
@@ -109,8 +95,8 @@ export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, mqttObs
|
||||
<span className="packet-item__hops">↑{p.hopCount}</span>
|
||||
)}
|
||||
<span className="packet-item__counts">
|
||||
{observerDisplay.length > 0 && (
|
||||
<span className="count count--rx">{observerDisplay.join(' · ')}</span>
|
||||
{p.observerIds.length > 0 && (
|
||||
<span className="count count--rx">{p.observerIds.length}rx</span>
|
||||
)}
|
||||
{p.txCount > 0 && <span className="count count--tx">{p.txCount}tx</span>}
|
||||
</span>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -196,7 +196,7 @@ export function useAppMessageHandler({
|
||||
}, [handlePacket, handleNodeUpdate, handleNodeUpsert, handleCoverageUpdate, applyLinkUpdate, onPacketObserved]);
|
||||
|
||||
// Throttle flush — batches bursts from the WebSocket into single React renders
|
||||
const BATCH_INTERVAL_MS = 100;
|
||||
const BATCH_INTERVAL_MS = 16;
|
||||
const scheduleFlush = useCallback(() => {
|
||||
if (rafRef.current !== null) return;
|
||||
rafRef.current = window.setTimeout(() => {
|
||||
|
||||
@@ -3,12 +3,12 @@ import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { App } from './App.js';
|
||||
import { Layout } from './pages/Layout.js';
|
||||
import { HomePage } from './pages/HomePage.js';
|
||||
import { InstallPage } from './pages/InstallPage.js';
|
||||
import { OpenSourcePage } from './pages/OpenSourcePage.js';
|
||||
import { StatsPage } from './pages/StatsPage.js';
|
||||
import { PacketsPage } from './pages/PacketsPage.js';
|
||||
import { OwnerPortalPage } from './pages/OwnerPortalPage.js';
|
||||
import { TeessieDashboard } from './pages/teesside/TeessieDashboard.js';
|
||||
import { UKLayout } from './pages/ukmesh/UKLayout.js';
|
||||
import { UKHomePage } from './pages/ukmesh/UKHomePage.js';
|
||||
import { UKInstallPage } from './pages/ukmesh/UKInstallPage.js';
|
||||
@@ -62,8 +62,8 @@ ReactDOM.createRoot(root).render(
|
||||
) : (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route index element={<TeessieDashboard />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="about" element={<Navigate to="/" replace />} />
|
||||
<Route path="install" element={<InstallPage />} />
|
||||
<Route path="mqtt" element={<Navigate to="/install" replace />} />
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LiveStatsSection } from '../components/LiveStatsSection.js';
|
||||
import { getCurrentSite } from '../config/site.js';
|
||||
|
||||
export const HomePage: React.FC = () => {
|
||||
const site = getCurrentSite();
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="site-home">
|
||||
<div className="site-content site-home__grid">
|
||||
<div className="site-home__intro">
|
||||
<h1 className="site-home__title">Teesside Mesh Network</h1>
|
||||
<p className="site-home__body">
|
||||
A regional MeshCore deployment for Teesside and the wider North East. The website documents the
|
||||
network, the live map shows what the observer hears, and the MQTT path lets repeater owners feed
|
||||
packet data into the shared view.
|
||||
</p>
|
||||
<div className="site-home__actions">
|
||||
<a href={site.appUrl} className="site-btn site-btn--primary">Open live map</a>
|
||||
<Link to="/install" className="site-btn site-btn--ghost">Install MeshCore</Link>
|
||||
<Link to="/stats" className="site-btn site-btn--ghost">Network stats</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="site-home__panel">
|
||||
<h2>Network overview</h2>
|
||||
<div className="site-home__meta">
|
||||
<div className="site-home__meta-row">
|
||||
<span>Coverage</span>
|
||||
<strong>Teesside and North East England</strong>
|
||||
</div>
|
||||
<div className="site-home__meta-row">
|
||||
<span>Band</span>
|
||||
<strong>LoRa 868 MHz</strong>
|
||||
</div>
|
||||
<div className="site-home__meta-row">
|
||||
<span>Channel</span>
|
||||
<strong>Public</strong>
|
||||
</div>
|
||||
<div className="site-home__meta-row">
|
||||
<span>Observer ingest</span>
|
||||
<strong>MQTT via UK Mesh broker</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
Teesside views are filtered from the shared ingest path. Observers using IATA <strong>MME</strong>{' '}
|
||||
show up here and in the wider UK stack.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LiveStatsSection network={site.network} />
|
||||
|
||||
<section className="site-section">
|
||||
<div className="site-content">
|
||||
<div className="site-section__head">
|
||||
<h2>What MeshCore is</h2>
|
||||
<p>
|
||||
MeshCore is open-source firmware for LoRa radios. Each node can relay packets, which is what lets
|
||||
traffic move across repeaters long after it has left the original transmitter. This site tracks the
|
||||
Teesside observer view, node positions, relay behaviour, and the public data that sits behind the live map.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="site-section">
|
||||
<div className="site-content">
|
||||
<div className="site-section__head">
|
||||
<h2>Use the network</h2>
|
||||
<p>Everything on the public site should answer one of three questions: what MeshCore is, how to join, and how to contribute useful coverage.</p>
|
||||
</div>
|
||||
<div className="site-home__cards">
|
||||
<div className="site-home__card">
|
||||
<h3>Get on the air</h3>
|
||||
<p>
|
||||
A handheld node is the fastest way in. Flash the firmware in a browser, pair it to your phone,
|
||||
set the UK profile, and use the default Public channel.
|
||||
</p>
|
||||
<Link to="/install">Open the install guide</Link>
|
||||
</div>
|
||||
|
||||
<div className="site-home__card">
|
||||
<h3>Feed the dashboards</h3>
|
||||
<p>
|
||||
Repeater owners can run `meshcoretomqtt` on a Pi or other Linux host, publish packets over MQTT,
|
||||
and contribute live telemetry back into the map and stats pages.
|
||||
</p>
|
||||
<Link to="/install">Observer setup</Link>
|
||||
</div>
|
||||
|
||||
<div className="site-home__card">
|
||||
<h3>Inspect live traffic</h3>
|
||||
<p>
|
||||
The map shows repeater locations, live packets, path predictions, coverage modelling, and the
|
||||
stats pages that sit behind the public network view.
|
||||
</p>
|
||||
<a href={site.appUrl}>Open the live map</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="site-section site-section--dark">
|
||||
<div className="site-content">
|
||||
<div className="site-section__head">
|
||||
<h2>Radio profile</h2>
|
||||
<p>These are the settings used on the Teesside side of the network.</p>
|
||||
</div>
|
||||
<div className="site-home__specs">
|
||||
<div className="site-home__spec-row">
|
||||
<span>Profile</span>
|
||||
<strong>EU/UK Narrow</strong>
|
||||
</div>
|
||||
<div className="site-home__spec-row">
|
||||
<span>Frequency</span>
|
||||
<strong>869.618 MHz</strong>
|
||||
</div>
|
||||
<div className="site-home__spec-row">
|
||||
<span>Bandwidth</span>
|
||||
<strong>62.5 kHz</strong>
|
||||
</div>
|
||||
<div className="site-home__spec-row">
|
||||
<span>Spreading factor</span>
|
||||
<strong>SF8</strong>
|
||||
</div>
|
||||
<div className="site-home__spec-row">
|
||||
<span>Coding rate</span>
|
||||
<strong>CR8</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="site-section">
|
||||
<div className="site-content">
|
||||
<div className="site-home__join">
|
||||
<div>
|
||||
<h2>Join the conversation</h2>
|
||||
<p>
|
||||
The network is community-run. If you want help with hardware, range testing, observer setup, or
|
||||
repeater placement, the Discord is where that happens.
|
||||
</p>
|
||||
</div>
|
||||
<div className="site-home__join-actions">
|
||||
<a href="https://discord.gg/bSuST8xvet" target="_blank" rel="noopener noreferrer" className="site-btn site-btn--primary">
|
||||
Join Discord
|
||||
</a>
|
||||
<Link to="/open-source" className="site-btn site-btn--ghost">View source</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -162,6 +162,40 @@ html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tile-loading-badge {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
right: 14px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(15, 15, 20, 0.82);
|
||||
border: 1px solid rgba(34, 211, 238, 0.25);
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
backdrop-filter: blur(4px);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tile-loading-badge__spinner {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 1.5px solid rgba(34, 211, 238, 0.3);
|
||||
border-top-color: rgba(34, 211, 238, 0.9);
|
||||
border-radius: 50%;
|
||||
animation: tile-spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes tile-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
Reference in new issue
Block a user