From acd71b0913bf0a1bea2e6d833e5b64af7c0c61ea Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 19 Mar 2026 15:55:47 +0000 Subject: [PATCH] Fix owner portal login; add tile worker and cross-network improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/package-lock.json | 291 ++ backend/src/api/routes.ts | 222 +- backend/src/db/index.ts | 26 +- backend/src/db/ownerAuth.ts | 18 +- backend/src/db/schema.sql | 11 + backend/src/health/status.ts | 26 + backend/src/index.ts | 4 + backend/src/mqtt/client.ts | 55 +- backend/src/mqtt/connectionMonitor.ts | 12 +- backend/src/path-beta/resolver.ts | 2 +- backend/src/tiles/renderer.ts | 373 ++ backend/src/tiles/snapshot.ts | 164 + backend/src/tiles/worker.ts | 305 ++ backend/src/workers/health.ts | 3 + backend/src/workers/tile.ts | 13 + backend/src/ws/server.ts | 2 +- docker-compose.yml | 23 + frontend/package-lock.json | 420 +- frontend/src/App.tsx | 60 +- frontend/src/components/Map/DeckGLOverlay.tsx | 39 +- frontend/src/components/Map/MapView.tsx | 26 + frontend/src/components/PacketFeed.tsx | 24 +- frontend/src/globals.css | 3735 +++++++++++++++++ frontend/src/hooks/useAppMessageHandler.ts | 2 +- frontend/src/main.tsx | 4 +- frontend/src/pages/HomePage.tsx | 159 - frontend/src/styles/globals.css | 34 + 27 files changed, 5682 insertions(+), 371 deletions(-) create mode 100644 backend/src/tiles/renderer.ts create mode 100644 backend/src/tiles/snapshot.ts create mode 100644 backend/src/tiles/worker.ts create mode 100644 backend/src/workers/tile.ts create mode 100644 frontend/src/globals.css delete mode 100644 frontend/src/pages/HomePage.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index a1c9dc2..8d900d1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index 6c5efd5..8c658c1 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -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(); const inferredNodesCache = new Map(); const pathHistoryCache = new Map(); const coverageCache = new Map(); const chartsCache = new Map(); +const crossNetworkCache = new Map(); const chartsInflight = new Map>(); 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; 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>>; + let historyResult: Awaited>>; + 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=&limit= — 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'; diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 9a3265b..ba6d7b9 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -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: { diff --git a/backend/src/db/ownerAuth.ts b/backend/src/db/ownerAuth.ts index 31af1ed..457b3df 100644 --- a/backend/src/db/ownerAuth.ts +++ b/backend/src/db/ownerAuth.ts @@ -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 { export async function addOwnerNodeForUsername(mqttUsername: string, nodeId: string): Promise { 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 { 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 { 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); } diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index a042070..44a4ec5 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -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, diff --git a/backend/src/health/status.ts b/backend/src/health/status.ts index 1623976..972f6d5 100644 --- a/backend/src/health/status.ts +++ b/backend/src/health/status.ts @@ -111,6 +111,7 @@ async function currentWorkers(precomputedStats?: ReturnType) const [ viewshedDepth, linkDepth, + tileState, viewshedRecent, linkRecent, viewshedLast, @@ -122,6 +123,7 @@ async function currentWorkers(precomputedStats?: ReturnType) ] = 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) 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) 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', diff --git a/backend/src/index.ts b/backend/src/index.ts index 8cff6ea..469eab2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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; diff --git a/backend/src/mqtt/client.ts b/backend/src/mqtt/client.ts index 4efea90..39fd0f5 100644 --- a/backend/src/mqtt/client.ts +++ b/backend/src/mqtt/client.ts @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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)); } { diff --git a/backend/src/mqtt/connectionMonitor.ts b/backend/src/mqtt/connectionMonitor.ts index 19d2c0d..f5f7576 100644 --- a/backend/src/mqtt/connectionMonitor.ts +++ b/backend/src/mqtt/connectionMonitor.ts @@ -26,8 +26,12 @@ async function processLine(line: string): Promise { 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 { @@ -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); } diff --git a/backend/src/path-beta/resolver.ts b/backend/src/path-beta/resolver.ts index 8a3c6a0..8d083b4 100644 --- a/backend/src/path-beta/resolver.ts +++ b/backend/src/path-beta/resolver.ts @@ -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, diff --git a/backend/src/tiles/renderer.ts b/backend/src/tiles/renderer.ts new file mode 100644 index 0000000..e4e81a2 --- /dev/null +++ b/backend/src/tiles/renderer.ts @@ -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 { + // 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 | null = null; +function emptyTile(): Promise { + 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>; + /** 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>(); + 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 { + 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 { + 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); +} diff --git a/backend/src/tiles/snapshot.ts b/backend/src/tiles/snapshot.ts new file mode 100644 index 0000000..14ab81c --- /dev/null +++ b/backend/src/tiles/snapshot.ts @@ -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 & { 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; + return normalizeSnapshotNode(parsed); + } catch { + return null; + } +} + +export async function getTileSnapshotNodes(network?: string): Promise { + 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 { + const res = await query( + `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(); + const seenNetworks = new Set(); + 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): Promise { + const nodeId = toNullableString(node['node_id']); + if (!nodeId) return; + + const r = redis(); + const existing = parseSnapshotNode(await r.get(nodeKey(nodeId))); + const mergedInput: Partial & { 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(); +} diff --git a/backend/src/tiles/worker.ts b/backend/src/tiles/worker.ts new file mode 100644 index 0000000..b87c843 --- /dev/null +++ b/backend/src/tiles/worker.ts @@ -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): 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 { + 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((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); +} diff --git a/backend/src/workers/health.ts b/backend/src/workers/health.ts index cdd1a08..06e1615 100644 --- a/backend/src/workers/health.ts +++ b/backend/src/workers/health.ts @@ -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) => { diff --git a/backend/src/workers/tile.ts b/backend/src/workers/tile.ts new file mode 100644 index 0000000..12ad32d --- /dev/null +++ b/backend/src/workers/tile.ts @@ -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); +}); diff --git a/backend/src/ws/server.ts b/backend/src/ws/server.ts index 6bc6f06..886776f 100644 --- a/backend/src/ws/server.ts +++ b/backend/src/ws/server.ts @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 1d27a4a..9c88fb1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: . diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 304cea4..3cbc611 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7ae9be1..3ed9bdf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(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(() => { - 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} /> { betaCompletionPaths={betaCompletionPaths} showBetaPaths={filters.betaPaths || pinnedPacketId !== null} pathFadingOut={pathFadingOut} - gpuNodes={gpuNodes} - showGpuNodes={!filters.hexClashes && !prefixFocusActive} viewState={deckViewState} hiddenCoordMask={hiddenCoordMask} /> diff --git a/frontend/src/components/Map/DeckGLOverlay.tsx b/frontend/src/components/Map/DeckGLOverlay.tsx index f9a80be..9bef7d1 100644 --- a/frontend/src/components/Map/DeckGLOverlay.tsx +++ b/frontend/src/components/Map/DeckGLOverlay.tsx @@ -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; } @@ -92,35 +81,12 @@ function useDeckLayers( betaCompletionPaths: [number, number][][], showBetaPaths: boolean, pathFadingOut: boolean, - gpuNodes: GPUNodeData[], - showGpuNodes: boolean, hiddenCoordMask: Map, ) { return useMemo(() => { const now = Date.now(); const layers = []; - // ── Node markers (replaces Leaflet CircleMarker SVG elements) ──────────── - if (showGpuNodes && gpuNodes.length > 0) { - layers.push( - new ScatterplotLayer({ - 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 = 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 = React.memo(({ packetHistorySegments, showPacketHistory, betaPaths, betaLowSegments, betaCompletionPaths, showBetaPaths, pathFadingOut, - gpuNodes, showGpuNodes, hiddenCoordMask, ); diff --git a/frontend/src/components/Map/MapView.tsx b/frontend/src/components/Map/MapView.tsx index 1b3c5b6..aa096ae 100644 --- a/frontend/src/components/Map/MapView.tsx +++ b/frontend/src/components/Map/MapView.tsx @@ -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({ 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 (
+ {gpuRendered && tilePending > 0 && ( +
+ + {tilePending} tile{tilePending !== 1 ? 's' : ''} rendering +
+ )} + {/* Server-rendered node dot tiles */} + {gpuRendered && ( + + )} + {/* Sync Leaflet map position to deck.gl */} diff --git a/frontend/src/components/PacketFeed.tsx b/frontend/src/components/PacketFeed.tsx index 97e0150..e2aceda 100644 --- a/frontend/src/components/PacketFeed.tsx +++ b/frontend/src/components/PacketFeed.tsx @@ -25,7 +25,7 @@ interface Props { const VISIBLE_ROWS = 8; -export const PacketFeed: React.FC = React.memo(({ packets, nodes, mqttObserverCount = 0, onPacketClick, pinnedPacketId }) => { +export const PacketFeed: React.FC = 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 = 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 = 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 = React.memo(({ packets, nodes, mqttObs tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && onPacketClick?.(p)} > - {(observerIata || allObserversHeard) && ( - - {allObserversHeard ? 'ALL' : observerIata} - + {observerIata && ( + {observerIata} )} {p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 && ( {p.pathHashSizeBytes} @@ -109,8 +95,8 @@ export const PacketFeed: React.FC = React.memo(({ packets, nodes, mqttObs ↑{p.hopCount} )} - {observerDisplay.length > 0 && ( - {observerDisplay.join(' · ')} + {p.observerIds.length > 0 && ( + {p.observerIds.length}rx )} {p.txCount > 0 && {p.txCount}tx} diff --git a/frontend/src/globals.css b/frontend/src/globals.css new file mode 100644 index 0000000..11cf27e --- /dev/null +++ b/frontend/src/globals.css @@ -0,0 +1,3735 @@ +/* ─── Design System ─────────────────────────────────────────────────────── */ +:root { + /* Backgrounds */ + --bg-base: #080d14; + --bg-panel: #0d1520; + --bg-panel-alt: #111c2b; + --bg-hover: #162030; + --bg-active: #1a2840; + + /* Borders */ + --border: rgba(32, 80, 140, 0.35); + --border-bright: rgba(0, 196, 255, 0.25); + + /* Accent — electric cyan */ + --accent: #00c4ff; + --accent-dim: rgba(0, 196, 255, 0.15); + --accent-glow: rgba(0, 196, 255, 0.4); + + /* Amber for warning / secondary signals */ + --amber: #ffb300; + --amber-dim: rgba(255, 179, 0, 0.15); + + /* Text */ + --text-primary: #d6e8ff; + --text-secondary: #6b8aaa; + --text-muted: #3a5070; + + /* Status */ + --online: #00e676; + --offline: #546e7a; + --danger: #ff1744; + --inferred: #6ddc7a; + + /* Device-type colours */ + --companion: #ff9800; /* ChatNode (role 1) — orange */ + --room-server: #ce93d8; /* RoomServer (role 3) — lavender */ + + /* Typography */ + --font-mono: 'Share Tech Mono', 'Courier New', monospace; + --font-body: 'Inter', system-ui, sans-serif; + + /* Spacing */ + --radius: 4px; + --radius-lg: 8px; + + /* Transitions */ + --transition: 150ms ease; + --transition-slow: 350ms ease; +} + +/* ─── Reset ─────────────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body, #root { + width: 100%; + height: 100%; + background: var(--bg-base); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 13px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* ─── Layout ─────────────────────────────────────────────────────────────── */ +.app-shell { + display: grid; + grid-template-rows: 48px 1fr; + grid-template-columns: 1fr; + height: 100%; + overflow: hidden; + position: relative; +} + +/* ─── Header / Topbar ────────────────────────────────────────────────────── */ +.topbar { + display: flex; + align-items: center; + gap: 24px; + padding: 0 20px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + z-index: 1000; + position: relative; +} + +.topbar__logo { + font-family: var(--font-mono); + font-size: 15px; + color: var(--accent); + letter-spacing: 0.12em; + text-transform: uppercase; + display: flex; + align-items: center; + gap: 8px; +} + +.topbar__logo svg { + width: 20px; + height: 20px; + color: var(--accent); +} + +.topbar__divider { + width: 1px; + height: 20px; + background: var(--border); +} + +.topbar__stats { + display: flex; + gap: 20px; + margin-left: auto; +} + +/* ─── Telemetry Stat Block ───────────────────────────────────────────────── */ +.stat { + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.stat__label { + font-size: 9px; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.stat__value { + font-family: var(--font-mono); + font-size: 16px; + color: var(--accent); + line-height: 1.1; + transition: color var(--transition); +} + +.stat__value--amber { color: var(--amber); } +.stat__value--online { color: var(--online); } +.stat__value--danger { color: var(--danger); } + +.stat__value.tick { + animation: tick-flash 0.2s ease; +} + +@keyframes tick-flash { + 0% { color: #fff; } + 100% { color: var(--accent); } +} + +/* ─── Map Container ─────────────────────────────────────────────────────── */ +.map-layer { + position: relative; + overflow: hidden; + min-height: 0; +} + +.map-area { + position: relative; + overflow: hidden; + height: 100%; +} + +.leaflet-container { + width: 100%; + height: 100%; + background: var(--bg-base); +} + +/* deck.gl canvas sits on top */ +.deckgl-canvas { + pointer-events: none; +} + +/* ─── Node Search ────────────────────────────────────────────────────────── */ +.node-search { + position: absolute; + top: 14px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + width: 260px; +} + +.node-search__input { + width: 100%; + box-sizing: border-box; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-family: inherit; + font-size: 0.82rem; + padding: 7px 12px; + outline: none; + transition: border-color 0.15s; +} +.node-search__input::placeholder { color: var(--text-muted); } +.node-search__input:focus { border-color: var(--accent); } + +.node-search__results { + position: absolute; + top: calc(100% + 4px); + left: 0; + width: 100%; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} + +.node-search__result { + padding: 7px 12px; + font-size: 0.82rem; + color: var(--text-primary); + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + justify-content: space-between; + align-items: center; +} +.node-search__result:hover { background: var(--bg-hover); color: var(--accent); } +.node-search__result-name { + overflow: hidden; + text-overflow: ellipsis; +} +.node-search__result-key { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--text-muted); + flex-shrink: 0; + margin-left: 8px; +} + +/* ─── Filter Panel ───────────────────────────────────────────────────────── */ +.filter-panel { + position: absolute; + top: 16px; + right: 16px; + z-index: 800; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + width: 200px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 2px; + box-shadow: 0 8px 32px rgba(0,0,0,0.6), 0 0 0 1px var(--border); + backdrop-filter: blur(8px); +} + +.filter-panel__title { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.filter-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 7px 6px; + border-radius: var(--radius); + cursor: pointer; + transition: background var(--transition); + user-select: none; +} + +.filter-row:hover { + background: var(--bg-hover); +} + +.filter-row__label { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); +} + +.filter-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.filter-dot--hollow { + border: 2px solid; + box-sizing: border-box; +} + +.filter-toggle { + width: 28px; + height: 16px; + border-radius: 8px; + background: var(--bg-active); + border: 1px solid var(--border); + position: relative; + transition: background var(--transition), border-color var(--transition); + flex-shrink: 0; +} + +.filter-toggle::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--text-muted); + transition: transform var(--transition), background var(--transition); +} + +.filter-toggle--on { + background: var(--accent-dim); + border-color: var(--accent); +} + +.filter-toggle--on::after { + transform: translateX(12px); + background: var(--accent); +} + +.filter-slider { + padding: 2px 10px 8px; + display: flex; + flex-direction: column; + gap: 4px; +} +.filter-slider__label { + font-size: 10px; + color: var(--text-muted); + font-family: var(--font-mono); + letter-spacing: 0.08em; +} +.filter-slider__input { + width: 100%; + accent-color: #a855f7; + cursor: pointer; +} + +.filter-beta-note { + font-size: 10px; + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 8px; + margin-bottom: 8px; + font-family: var(--font-mono); +} + +.filter-beta-note strong { + color: var(--accent); +} + +/* ─── Node Marker ────────────────────────────────────────────────────────── */ +.node-marker { + position: relative; + width: var(--marker-size, 12px); + height: var(--marker-size, 12px); +} + +.node-marker__core { + width: var(--marker-size, 12px); + height: var(--marker-size, 12px); + border-radius: 50%; + background: var(--accent); + border: var(--marker-border, 2px) solid rgba(0,196,255,0.5); + box-shadow: 0 0 8px var(--accent-glow); + position: relative; + z-index: 2; +} + +.node-marker--offline .node-marker__core { + background: var(--offline); + border-color: rgba(84,110,122,0.5); + box-shadow: none; +} + +.node-marker--stale .node-marker__core { + background: var(--danger); + border-color: rgba(255,23,68,0.4); + box-shadow: 0 0 6px rgba(255,23,68,0.25); +} + +.node-marker__pulse { + position: absolute; + top: 50%; + left: 50%; + width: var(--marker-size, 12px); + height: var(--marker-size, 12px); + border-radius: 50%; + background: var(--accent); + transform: translate(-50%, -50%); + z-index: 1; + opacity: 0; +} + +.node-marker--active .node-marker__pulse { + animation: pulse-ring 1.2s ease-out; +} + +@keyframes pulse-ring { + 0% { transform: translate(-50%, -50%) scale(1); opacity: 0.7; } + 100% { transform: translate(-50%, -50%) scale(4); opacity: 0; } +} + + +.node-marker--companion .node-marker__core { + background: var(--companion); + border-color: rgba(255,152,0,0.5); + box-shadow: 0 0 8px rgba(255,152,0,0.35); +} +.node-marker--companion .node-marker__pulse { background: var(--companion); } + +.node-marker--room .node-marker__core { + background: var(--room-server); + border-color: rgba(206,147,216,0.5); + box-shadow: 0 0 8px rgba(206,147,216,0.35); +} +.node-marker--room .node-marker__pulse { background: var(--room-server); } + +.node-marker--inferred .node-marker__core { + background: rgba(109, 220, 122, 0.18); + border-color: rgba(109, 220, 122, 0.92); + box-shadow: 0 0 0 1px rgba(109, 220, 122, 0.28), 0 0 14px rgba(109, 220, 122, 0.18); +} + +.node-marker--inferred .node-marker__pulse { + background: rgba(109, 220, 122, 0.7); + opacity: 0.32; +} + +.node-marker--restore .node-marker__core, +.node-marker--restore .node-marker__pulse { + animation: node-restore-fade 1.2s ease; +} + +.node-marker--hex-offender .node-marker__core { + background: #ef4444; + border-color: rgba(239, 68, 68, 0.85); + box-shadow: 0 0 10px rgba(239, 68, 68, 0.45); +} +.node-marker--hex-offender .node-marker__pulse { background: #ef4444; } + +.node-marker--hex-clear .node-marker__core { + background: #22c55e; + border-color: rgba(34, 197, 94, 0.85); + box-shadow: 0 0 8px rgba(34, 197, 94, 0.4); +} +.node-marker--hex-clear .node-marker__pulse { background: #22c55e; } + +/* ─── Node Cluster (unused now) ─────────────────────────────────────────────── */ +.node-cluster-container { + background: transparent !important; + border: none !important; +} + +/* ─── Map Zoom Hint ─────────────────────────────────────────────────────────── */ +.map-zoom-hint { + position: absolute; + bottom: 16px; + right: 16px; + background: rgba(0, 0, 0, 0.75); + color: #9ca3af; + padding: 8px 14px; + border-radius: 6px; + font-size: 12px; + font-family: system-ui, -apple-system, sans-serif; + z-index: 1000; + backdrop-filter: blur(4px); +} + +@keyframes node-restore-fade { + 0% { opacity: 0; } + 100% { opacity: 1; } +} + +/* ─── Node Popup ─────────────────────────────────────────────────────────── */ +.leaflet-popup-content-wrapper { + background: var(--bg-panel) !important; + border: 1px solid var(--border-bright) !important; + border-radius: var(--radius-lg) !important; + box-shadow: 0 8px 32px rgba(0,0,0,0.7) !important; + color: var(--text-primary) !important; + padding: 0 !important; +} + +.leaflet-popup-content { + margin: 0 !important; +} + +.leaflet-popup-tip { + background: var(--bg-panel) !important; +} + +.node-popup { + padding: 12px 14px; + min-width: 200px; +} + +.node-popup__name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin: 4px 0; +} + +.node-popup__row { + display: flex; + justify-content: space-between; + gap: 16px; + font-size: 11px; + color: var(--text-secondary); + padding: 2px 0; + border-top: 1px solid var(--border); + margin-top: 6px; +} + +.node-popup__row span:last-child { + color: var(--text-primary); + font-family: var(--font-mono); +} + +.node-popup__mono { + font-family: var(--font-mono); + font-size: 9px; + word-break: break-all; + line-height: 1.3; +} + +.node-popup__row--inline { + align-items: center; +} + +.node-popup__inline-value { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.node-popup__inline-btn { + border: 1px solid rgba(0, 196, 255, 0.5); + background: transparent; + color: var(--accent); + border-radius: 4px; + padding: 2px 6px; + font-size: 10px; + font-family: var(--font-mono); + letter-spacing: 0.04em; + cursor: pointer; +} + +.node-popup__inline-btn:hover { + background: rgba(0, 196, 255, 0.14); +} + +.node-popup__inline-btn--active { + border-color: rgba(0, 196, 255, 0.9); + background: rgba(0, 196, 255, 0.16); +} + +.node-popup__coverage-btn { + display: block; + width: 100%; + margin-top: 10px; + padding: 5px 0; + background: transparent; + border: 1px solid #1ec85066; + border-radius: 4px; + color: #1ec850; + font-size: 11px; + font-family: var(--font-mono); + letter-spacing: 0.06em; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.node-popup__coverage-btn:hover { + background: #1ec85018; + border-color: #1ec850; +} +.node-popup__coverage-btn--active { + background: #1ec85018; + border-color: #1ec850; + color: #1ec850; +} +.node-popup__neighbours-loading { + margin-top: 8px; + font-size: 10px; + color: var(--text-muted); +} +.node-popup__neighbours { + margin-top: 10px; + border-top: 1px solid rgba(255,255,255,0.08); + padding-top: 8px; +} +.node-popup__neighbours-title { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin-bottom: 5px; +} +.node-popup__neighbour-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + padding: 2px 0; +} +.node-popup__neighbour-name { + font-size: 11px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} +.node-popup__neighbour-meta { + font-size: 10px; + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; +} + +.links-legend-inline { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 10px; + margin-top: 8px; +} + +.links-legend-inline--muted { + opacity: 0.6; +} + +.links-legend-inline--compact { + margin: 6px 8px 0; + padding: 7px 9px; +} + +.links-legend-inline__title { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-muted); + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 6px; +} + +.links-legend-inline__grid { + display: grid; + grid-template-columns: 1fr; + gap: 3px; +} + +.links-legend-inline__row { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 3px; +} + +.links-legend__swatch { + width: 10px; + height: 10px; + border-radius: 2px; + border: 1px solid rgba(255, 255, 255, 0.2); + flex-shrink: 0; +} + +/* ─── Live packet feed (bottom left) ────────────────────────────────────── */ +.packet-feed { + position: absolute; + bottom: 16px; + left: 16px; + z-index: 800; + width: 420px; + height: 236px; + overflow: hidden; + overflow-x: hidden; + pointer-events: auto; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.packet-item { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 7px 11px; + display: flex; + align-items: center; + gap: 9px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1; + transition: opacity 0.24s ease, background 0.15s ease, border-color 0.15s ease; + margin-bottom: 3px; + will-change: transform, opacity; +} + +.packet-item--fading { + opacity: 0; +} + +.packet-item--clickable { + cursor: pointer; +} + +.packet-item--clickable:hover { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.15); +} + +.packet-item--pinned { + background: rgba(0, 196, 255, 0.08); + border-color: rgba(0, 196, 255, 0.4); +} + +.packet-item--pinned:hover { + background: rgba(0, 196, 255, 0.12); +} + +.packet-item__pin { + font-size: 7px; + color: var(--accent); + opacity: 0.7; + flex-shrink: 0; + animation: pin-pulse 2s ease-in-out infinite; +} + +@keyframes pin-pulse { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 0.3; } +} + +@keyframes slide-in { + from { transform: translateY(8px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +@keyframes packet-shift-up { + from { transform: translateY(6px); } + to { transform: translateY(0); } +} + +.packet-item--new { + animation: slide-in 0.18s ease; +} + +.packet-item__type { + font-size: 11px; + color: var(--accent); + min-width: 34px; + flex-shrink: 0; +} + +.packet-item__path-bytes { + font-size: 10px; + color: #f87171; + min-width: 24px; + flex-shrink: 0; + font-weight: 700; + letter-spacing: 0.04em; +} + +.packet-item__iata { + font-size: 10px; + color: var(--text-primary); + min-width: 30px; + flex-shrink: 0; +} + +.packet-item__iata--all { + color: var(--online); + font-weight: 700; +} + +.packet-item__advert-badge { + font-size: 11px; + color: var(--amber); + white-space: nowrap; + flex-shrink: 0; +} + +.packet-item__summary { + color: var(--text-secondary); + font-size: 11px; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.packet-item__summary--empty { + visibility: hidden; +} + +.packet-item__counts { + display: flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} + +.count { + font-size: 10px; + font-weight: 600; + padding: 1px 4px; + border-radius: 3px; + letter-spacing: 0.04em; +} + +.count--rx { + background: rgba(0, 230, 118, 0.12); + color: var(--online); + border: 1px solid rgba(0, 230, 118, 0.22); +} + +.count--tx { + background: rgba(0, 196, 255, 0.1); + color: var(--accent); + border: 1px solid rgba(0, 196, 255, 0.18); +} + +.packet-item__hops { + font-size: 11px; + color: var(--amber); +} + +/* ─── Connectivity status bar ────────────────────────────────────────────── */ +.conn-indicator { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; +} + +.conn-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--offline); + transition: background var(--transition-slow); +} + +.conn-dot--connected { + background: var(--online); + box-shadow: 0 0 6px var(--online); + animation: blink-dot 3s ease infinite; +} + +@keyframes blink-dot { + 0%, 90%, 100% { opacity: 1; } + 95% { opacity: 0.4; } +} + +/* ─── Scrollbar ──────────────────────────────────────────────────────────── */ +::-webkit-scrollbar { width: 4px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + +/* ══════════════════════════════════════════════════════════════════════════ */ +/* ─── Website / Landing pages ────────────────────────────────────────────── */ +/* ══════════════════════════════════════════════════════════════════════════ */ + +/* App home button */ +.topbar__brand { + display: flex; + align-items: center; + gap: 6px; + text-decoration: none; + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + flex-shrink: 0; + white-space: nowrap; + transition: color var(--transition); +} +.topbar__brand:hover { color: var(--accent); } +.topbar__brand-icon { + color: var(--accent); + font-size: 16px; +} + +/* Info button in topbar */ +.topbar__info-btn { + width: 22px; + height: 22px; + border-radius: 50%; + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 12px; + font-style: italic; + font-weight: bold; + cursor: pointer; + flex-shrink: 0; + line-height: 1; + transition: color var(--transition), border-color var(--transition), background var(--transition); +} +.topbar__info-btn:hover { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-dim); +} + +/* Disclaimer modal */ +.disclaimer-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + padding: 16px; +} +.disclaimer-modal { + background: var(--bg-panel); + border: 1px solid var(--border-bright); + border-radius: 12px; + padding: 28px 32px; + max-width: 520px; + width: 100%; +} +.disclaimer-modal__title { + font-family: var(--font-mono); + font-size: 16px; + color: var(--accent); + margin: 0 0 20px; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.disclaimer-modal__body { + display: flex; + flex-direction: column; + gap: 18px; + margin-bottom: 24px; +} +.disclaimer-modal__body h3 { + font-family: var(--font-mono); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-secondary); + margin: 0 0 6px; +} +.disclaimer-modal__body p { + font-size: 14px; + color: var(--text-primary); + line-height: 1.6; + margin: 0; +} +.disclaimer-modal__close { + display: block; + width: 100%; + padding: 10px; + background: var(--accent); + color: #000; + border: none; + border-radius: 6px; + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: opacity var(--transition); +} +.disclaimer-modal__close:hover { + opacity: 0.85; +} + +.stats-page__path-link { + appearance: none; + border: 0; + background: none; + padding: 0; + margin: 0; + color: var(--accent); + cursor: pointer; + font: inherit; + font-weight: 700; + text-align: left; + display: block; + max-width: 100%; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.stats-page__path-link:hover { + text-decoration: underline; +} + +.stats-page__path-modal { + background: var(--bg-panel); + border: 1px solid var(--border-bright); + border-radius: 12px; + width: min(960px, 100%); + max-height: min(88vh, 900px); + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.stats-page__path-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.stats-page__path-modal-title { + margin: 0; + font-family: var(--font-mono); + font-size: 16px; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.stats-page__path-modal-sub { + margin: 6px 0 0; + color: var(--text-muted); + font-size: 13px; +} + +.stats-page__path-modal-close { + width: auto; + min-width: 110px; +} + +.stats-page__path-modal-map { + height: 420px; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} + +.stats-page__path-modal-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; +} + +.stats-page__path-modal-node { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(11, 23, 37, 0.55); +} + +.stats-page__path-modal-node span { + display: inline-flex; + width: 22px; + height: 22px; + align-items: center; + justify-content: center; + border-radius: 999px; + background: rgba(0, 196, 255, 0.12); + color: var(--accent); + font-family: var(--font-mono); + font-size: 11px; + flex: 0 0 22px; +} + +.stats-page__path-modal-node strong { + color: var(--text-primary); + font-size: 13px; + line-height: 1.35; +} + +.health-kv strong { + min-width: 0; + overflow-wrap: anywhere; +} + +.stats-page__path-node-label { + background: transparent; + border: 0; + box-shadow: none; + color: #ffffff; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; + margin: 0; + padding: 0; +} + +.stats-page__path-node-label::before { + display: none; +} + +/* ── Layout shell ──────────────────────────────────────────────────────── */ +.site-layout { + display: flex; + flex-direction: column; + min-height: 100vh; + background: var(--bg-base); + color: var(--text-primary); +} +.site-main { flex: 1; } +.site-content { + max-width: 1240px; + margin: 0 auto; + padding: 0 24px; +} + +/* ── Navigation ────────────────────────────────────────────────────────── */ +.site-nav { + display: flex; + align-items: center; + gap: 24px; + min-height: 60px; + padding: 10px 24px; + background: #09111a; + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 1000; +} +.site-nav__brand { + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: var(--text-primary); + font-size: 15px; + font-weight: 600; + flex-shrink: 0; +} +.site-nav__brand:hover { color: var(--accent); } +.site-nav__icon { + color: var(--accent); + font-size: 18px; +} +.site-nav__links { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + flex-wrap: wrap; +} +.site-nav__link { + padding: 8px 10px; + color: var(--text-secondary); + text-decoration: none; + font-size: 13px; + border-radius: 6px; + transition: color var(--transition), background var(--transition); +} +.site-nav__link:hover { color: var(--text-primary); background: var(--bg-hover); } +.site-nav__link--active { + color: var(--text-primary); + background: rgba(0, 196, 255, 0.08); +} +.site-nav__link--map { + color: var(--accent); + font-weight: 500; +} +.site-nav__app-btn { + padding: 8px 12px; + background: transparent; + border: 1px solid var(--border-bright); + color: var(--text-primary); + text-decoration: none; + font-size: 13px; + font-family: var(--font-mono); + border-radius: 6px; + flex-shrink: 0; + margin-left: auto; + transition: background var(--transition), border-color var(--transition); +} +.site-nav__app-btn:hover { + background: var(--bg-hover); + border-color: var(--accent-glow); +} +.site-nav__app-btn--active { + background: rgba(0, 196, 255, 0.08); + border-color: var(--border-bright); +} + +/* ── Owner portal ─────────────────────────────────────────────────────── */ +.owner-login__form { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 14px; + max-width: 680px; +} +.owner-login__label { + color: var(--text-secondary); + font-size: 13px; +} +.owner-login__input { + width: 100%; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-primary); + padding: 10px 12px; + font-family: var(--font-mono); + font-size: 13px; +} +.owner-login__input:focus { + outline: none; + border-color: var(--accent); +} +.owner-login__button { + width: fit-content; + min-width: 140px; +} +.owner-login__error { + color: #ff8f8f; + margin-top: 10px; +} +.owner-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.owner-summary-grid.site-stats-grid--6 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} +.owner-summary-grid .site-stat { + align-items: flex-start; + text-align: left; + min-height: 110px; + padding: 16px 14px; +} +.owner-summary-grid .site-stat__value { + font-size: clamp(0.95rem, 1.55vw, 1.45rem); + line-height: 1.25; + max-width: 100%; + overflow-wrap: anywhere; +} +.owner-summary-grid .site-stat__label { + font-size: clamp(10px, 0.9vw, 12px); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.owner-dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 16px; + align-items: start; + grid-auto-rows: 320px; +} +.owner-panel { + display: flex; + flex-direction: column; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + min-width: 0; + margin-bottom: 0; + height: 100%; + overflow: hidden; +} +.owner-panel__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} +.owner-panel__head h2 { + margin: 0; +} +.owner-panel__head--compact { + margin-bottom: 10px; +} +.owner-panel__head--compact h3 { + margin: 0; + font-size: 15px; +} +.owner-panel__head--compact p { + margin: 4px 0 0; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.45; +} +.owner-alerts { + display: grid; + gap: 10px; + flex: 1; + min-height: 0; + overflow-y: auto; + padding-right: 4px; +} +.owner-alert { + display: grid; + gap: 4px; + border-radius: var(--radius); + padding: 12px; + border: 1px solid var(--border); + background: var(--bg-panel-alt); +} +.owner-alert strong { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.08em; +} +.owner-alert span { + color: var(--text-primary); + font-size: 13px; + line-height: 1.45; +} +.owner-alert--info { + border-color: rgba(0,196,255,0.22); +} +.owner-alert--warn { + border-color: rgba(255,179,0,0.3); +} +.owner-alert--error { + border-color: rgba(255,100,100,0.32); +} +.owner-trend { + display: grid; + gap: 10px; + flex: 1; + min-height: 0; +} +.owner-trend__bars { + height: 100%; + min-height: 0; + display: grid; + grid-template-columns: repeat(24, minmax(0, 1fr)); + align-items: end; + gap: 4px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +.owner-trend__bar { + min-height: 10px; + background: linear-gradient(180deg, rgba(0,196,255,0.95), rgba(0,196,255,0.45)); + border-radius: 3px 3px 0 0; +} +.owner-trend__meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--text-secondary); + font-size: 12px; +} +.owner-trend__meta strong { + color: var(--text-primary); + font-family: var(--font-mono); +} +.owner-telemetry-panel { + margin-bottom: 16px; + height: auto; + overflow: hidden; +} +.owner-telemetry-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} +.owner-telemetry-metric { + display: flex; + flex-direction: column; + min-width: 0; + padding: 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-panel-alt); + overflow: hidden; +} +.owner-telemetry-metric__value { + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 15px; + white-space: nowrap; +} +.owner-telemetry-metric__chart { + height: 88px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)); + overflow: hidden; +} +.owner-telemetry-metric--stat { + justify-content: space-between; +} +.owner-telemetry-metric__stat { + min-height: 88px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)); +} +.owner-telemetry-metric__stat strong { + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 26px; + line-height: 1; +} +.owner-telemetry-metric__chart svg { + width: 100%; + height: 100%; + display: block; +} +.owner-telemetry-metric__empty { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + font-size: 12px; +} +.owner-telemetry-metric__footer { + margin-top: 8px; + display: flex; + justify-content: space-between; + gap: 10px; + color: var(--text-secondary); + font-size: 10px; + font-family: var(--font-mono); +} +.owner-list { + display: grid; + gap: 10px; + flex: 1; + min-height: 0; + overflow-y: auto; + padding-right: 4px; +} +.owner-list__row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: start; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-panel-alt); +} +.owner-list__primary { + display: grid; + gap: 4px; + min-width: 0; +} +.owner-list__primary strong { + color: var(--text-primary); + font-size: 13px; + overflow-wrap: anywhere; +} +.owner-list__primary span { + color: var(--text-secondary); + font-size: 11px; +} +.owner-list__metrics { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; + color: var(--text-secondary); + font-size: 11px; + font-family: var(--font-mono); +} +.owner-table-wrap { + width: 100%; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius); +} +.owner-table { + width: 100%; + min-width: 820px; + border-collapse: collapse; +} +.owner-table th, +.owner-table td { + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +.owner-table th { + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.owner-table td code { + font-family: var(--font-mono); + color: var(--text-primary); +} +.owner-roadmap { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.owner-roadmap__item { + padding: 16px; +} +.owner-select { + margin: 12px 0 16px; + display: flex; + flex-direction: column; + gap: 6px; +} +.owner-select label { + color: var(--text-secondary); + font-size: 12px; +} +.owner-select__input { + max-width: 360px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-primary); + padding: 8px 10px; +} +.owner-map-wrap { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + flex: 1; + min-height: 0; +} +.owner-map { + width: 100%; + height: 100%; +} +.owner-packets { + display: flex; + flex-direction: column; + gap: 10px; + height: auto; + flex: 1; + min-height: 0; + overflow-y: auto; + padding-right: 4px; +} +.owner-packet { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 12px; + background: var(--bg-panel); +} +.owner-packet__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.owner-packet__head strong { + font-size: 13px; + letter-spacing: 0.04em; + color: var(--accent); +} +.owner-packet__head span { + color: var(--text-secondary); + font-size: 11px; + font-family: var(--font-mono); +} +.owner-packet__meta { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 8px; + color: var(--text-secondary); + font-size: 11px; +} +.owner-packet__body { + margin-top: 8px; + color: var(--text-primary); + font-size: 13px; + line-height: 1.45; +} + +/* Mobile controls wrapper (filter grid + search) — hidden on desktop */ +.mobile-controls { display: none; } +.mobile-search { display: none; } + +/* ── Buttons ───────────────────────────────────────────────────────────── */ +.site-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + padding: 0 16px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: background var(--transition), border-color var(--transition), color var(--transition); + border: 1px solid transparent; +} +.site-btn--primary { + background: #0d1e31; + color: var(--text-primary); + border-color: rgba(0, 196, 255, 0.28); +} +.site-btn--primary:hover { background: #11263d; border-color: rgba(0, 196, 255, 0.4); } +.site-btn--ghost { + background: transparent; + color: var(--text-primary); + border-color: var(--border); +} +.site-btn--ghost:hover { background: var(--bg-hover); border-color: var(--accent-glow); } + +/* ── Home ──────────────────────────────────────────────────────────────── */ +.site-home { + padding: 40px 0 28px; + border-bottom: 1px solid var(--border); +} +.site-home__grid { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.9fr); + gap: 24px; + align-items: start; +} +.site-home__intro { + display: grid; + gap: 18px; +} +.site-home__title { + font-size: clamp(34px, 5vw, 54px); + line-height: 1.05; + letter-spacing: -0.03em; +} +.site-home__body { + max-width: 760px; + font-size: 16px; + line-height: 1.7; + color: var(--text-secondary); +} +.site-home__actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} +.site-home__panel, +.site-home__card, +.site-home__join { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 8px; +} +.site-home__panel { + padding: 20px; + display: grid; + gap: 16px; +} +.site-home__panel h2, +.site-home__join h2, +.site-section__head h2 { + font-size: 24px; + line-height: 1.2; + margin: 0; +} +.site-home__panel p, +.site-home__join p, +.site-section__head p { + color: var(--text-secondary); + line-height: 1.65; + margin: 0; +} +.site-home__meta { + display: grid; + gap: 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} +.site-home__meta-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 12px 0; + border-top: 1px solid rgba(32, 80, 140, 0.18); +} +.site-home__meta-row:first-child { border-top: 0; } +.site-home__meta-row span { + color: var(--text-secondary); +} +.site-home__meta-row strong { + color: var(--text-primary); + text-align: right; +} + +/* ── Live stats ────────────────────────────────────────────────────────── */ +.site-stats-section { + padding: 28px 0; + border-bottom: 1px solid var(--border); + background: #09111a; +} +.site-stats-section--alt { + background: var(--bg-panel); +} +.site-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} +.site-stats-grid--6 { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} +.site-stat { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + text-align: left; + gap: 10px; + min-height: 132px; + padding: 18px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 8px; +} +.site-stat__value { + font-family: var(--font-mono); + font-size: clamp(24px, 3vw, 38px); + font-weight: 700; + color: var(--text-primary); + line-height: 1.1; +} +.site-stat__suffix { + font-size: 0.6em; + color: var(--text-secondary); + margin-left: 4px; +} +.site-stat__label { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.4; +} +.site-stat__hash { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 11px; + color: var(--accent); + opacity: 0.7; + letter-spacing: 0.04em; + margin-top: 4px; +} + +/* ── Sections ──────────────────────────────────────────────────────────── */ +.site-section { padding: 32px 0; } +.site-section--dark { background: var(--bg-panel); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } +.site-section__head { + display: grid; + gap: 8px; + margin-bottom: 20px; + max-width: 760px; +} +.site-home__cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; +} +.site-card, +.site-home__card { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 20px; + display: grid; + gap: 12px; + transition: border-color var(--transition), background var(--transition); +} +.site-card:hover { border-color: var(--border-bright); background: var(--bg-panel-alt); } +.site-card__icon { display: none; } +.site-card__title, +.site-home__card h3 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} +.site-card__body, +.site-home__card p { + font-size: 14px; + line-height: 1.65; + color: var(--text-secondary); + margin: 0; +} +.site-card__link, +.site-home__card a:not(.site-btn) { + font-size: 13px; + color: var(--accent); + text-decoration: none; + font-family: var(--font-mono); +} +.site-card__link:hover, +.site-home__card a:not(.site-btn):hover { text-decoration: underline; } +.site-home__specs { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} +.site-home__spec-row { + display: flex; + flex-direction: column; + gap: 8px; + padding: 18px; + background: #0b121c; + border: 1px solid var(--border); + border-radius: 8px; +} +.site-home__spec-row span { + color: var(--text-secondary); + font-size: 13px; +} +.site-home__spec-row strong { + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 18px; +} +.site-home__join { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + flex-wrap: wrap; + padding: 20px; +} +.site-home__join > div:first-child { + max-width: 760px; +} +.site-home__join-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.dev-monitor__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + margin-top: 8px; +} + +.dev-monitor__card { + padding: 20px 22px; +} + +.dev-monitor__card--wide { + margin-top: 18px; +} + +.dev-monitor__meta { + display: grid; + gap: 10px; +} + +.dev-monitor__row { + display: flex; + justify-content: space-between; + gap: 16px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-soft); +} + +.dev-monitor__row:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.dev-monitor__row span { + color: var(--text-secondary); +} + +.dev-monitor__row strong { + text-align: right; + color: var(--text-primary); +} + +.dev-monitor__summary { + margin-top: 14px; + color: var(--text-secondary); +} + +.dev-monitor__chips { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.dev-monitor__chip { + display: inline-flex; + align-items: center; + padding: 8px 12px; + border-radius: 999px; + border: 1px solid var(--border-soft); + background: var(--bg-panel-alt); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 12px; +} + +.dev-monitor__section-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.dev-monitor__table-wrap { + overflow-x: auto; +} + +.dev-monitor__table { + width: 100%; + min-width: 760px; + border-collapse: collapse; +} + +.dev-monitor__table th, +.dev-monitor__table td { + text-align: left; + padding: 10px 12px; + border-top: 1px solid var(--border-soft); + vertical-align: top; + font-size: 14px; +} + +.dev-monitor__table th { + color: var(--text-secondary); + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.dev-monitor__mono { + font-family: var(--font-mono); + font-size: 12px; +} + +.dev-monitor__empty, +.dev-monitor__empty-row { + color: var(--text-secondary); +} + +.dev-status-shell { + min-height: 100vh; + padding: 24px; + background: #081019; +} + +.dev-status-page { + max-width: 1200px; + margin: 0 auto; +} + +.dev-status-page__header { + position: relative; + margin-bottom: 20px; +} + +.dev-status-page__last-seen { + position: absolute; + top: 0; + right: 0; + font-size: 13px; + color: var(--text-secondary); +} + +.dev-status-page__header h1 { + margin: 0 0 6px; + font-size: 32px; + color: var(--text-primary); +} + +.dev-status-page__header p { + margin: 0; + color: var(--text-secondary); +} + +.dev-status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +.dev-status-card { + margin-bottom: 16px; + padding: 18px 20px; + border: 1px solid var(--border); + border-radius: 10px; + background: #0b121c; +} + +.dev-status-card--fixed { + height: 560px; + display: flex; + flex-direction: column; +} + +.dev-status-card h2 { + margin: 0 0 14px; + font-size: 16px; + color: var(--text-primary); +} + +.dev-status-list { + display: grid; + gap: 10px; +} + +.dev-status-list > div { + display: flex; + justify-content: space-between; + gap: 16px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-soft); +} + +.dev-status-list > div:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.dev-status-list span { + color: var(--text-secondary); +} + +.dev-status-list strong { + color: var(--text-primary); + text-align: right; +} + +.dev-status-list__summary strong { + max-width: min(62%, 420px); + overflow-wrap: anywhere; +} + +.dev-status-note { + margin: 14px 0 0; + color: var(--text-secondary); +} + +.dev-status-list--compact { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); +} + +.dev-telemetry { + display: grid; + gap: 14px; +} + +.dev-telemetry__section { + display: grid; + gap: 10px; +} + +.dev-telemetry__section h3 { + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + font-family: var(--font-mono); + color: var(--text-muted); +} + +.dev-telemetry__charts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.dev-telemetry-chart { + display: grid; + gap: 10px; + padding: 12px 14px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.02); + min-width: 0; +} + +.dev-telemetry-chart__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.dev-telemetry-chart__head h3 { + margin: 0; + font-size: 13px; + color: var(--text-primary); + letter-spacing: 0; + text-transform: none; + font-family: var(--font-body); +} + +.dev-telemetry-chart__head p { + margin: 2px 0 0; + color: var(--text-muted); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.dev-telemetry-chart__head strong { + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 14px; + white-space: nowrap; +} + +.dev-telemetry-chart__body { + height: 160px; +} + +.dev-telemetry-chart__empty { + height: 100%; + display: grid; + place-items: center; + color: var(--text-secondary); + font-size: 12px; +} + +.dev-telemetry-chart__foot { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--text-muted); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.dev-telemetry-chart__tooltip { + background: #0d1520; + border: 1px solid rgba(0, 196, 255, 0.25); + border-radius: 6px; + padding: 8px 12px; + font-size: 12px; +} + +.dev-telemetry-chart__tooltip p { + margin: 0 0 4px; + color: #6b8aaa; +} + +.dev-telemetry-chart__tooltip strong { + color: #e8f0fb; +} + +.dev-telemetry__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px; +} + +.dev-telemetry__item { + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.02); + min-width: 0; +} + +.dev-telemetry__item span { + color: var(--text-muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.dev-telemetry__item strong { + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + overflow-wrap: anywhere; +} + +.dev-status-table-wrap { + overflow-x: auto; + overflow-y: auto; + flex: 1; +} + +.dev-status-table { + width: 100%; + min-width: 720px; + border-collapse: collapse; +} + +.dev-status-table th, +.dev-status-table td { + padding: 10px 12px; + border-top: 1px solid var(--border-soft); + text-align: left; + vertical-align: top; + font-size: 14px; +} + +.dev-status-table th { + color: var(--text-secondary); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.dev-status-mono { + font-family: var(--font-mono); + font-size: 12px; +} + +.dev-status-empty { + color: var(--text-secondary); +} + +.uk-feed-page { + max-width: 100%; +} + +.uk-feed-grid { + margin-bottom: 16px; +} + +.uk-feed-region-control { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 140px; + padding: 6px 10px; + border: 1px solid var(--border-soft); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); +} + +.uk-feed-region-select { + width: auto; + max-width: 100%; + padding: 0; + border: 0; + background: transparent; + color: var(--text-primary); + font: inherit; + font-weight: 700; + line-height: 1.2; + text-align: right; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + cursor: pointer; +} + +.uk-feed-region-caret { + color: var(--text-muted); + font-size: 11px; + line-height: 1; +} + +.uk-feed-region-select:focus { + outline: none; + color: var(--accent); +} + +.uk-feed-region-select option { + background: #0b121c; + color: var(--text-primary); +} + +.uk-feed-checkbox-label { + display: inline-flex; + align-items: center; + cursor: pointer; +} + +.uk-feed-checkbox-label input[type='checkbox'] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--accent); +} + +.uk-feed-packets-card { + margin-bottom: 16px; +} + +.uk-feed-packets-list { + display: grid; + gap: 10px; +} + +.uk-feed-packet-row { + min-height: 72px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(8, 16, 25, 0.85); +} + +.uk-feed-packet-row__meta { + display: grid; + grid-template-columns: 92px 70px 110px minmax(120px, 160px) minmax(100px, 140px); + gap: 12px; + align-items: center; + margin-bottom: 8px; + color: var(--text-secondary); +} + +.uk-feed-packet-row__observer { + color: var(--accent); + font-weight: 500; + font-size: 0.85rem; + grid-column: 1 / -1; +} + +.uk-feed-packet-row__summary { + margin: 0; + color: var(--text-primary); + line-height: 1.45; + min-height: 20px; +} + +/* ── Page hero (inner pages) ───────────────────────────────────────────── */ +.site-page-hero { + padding: 14px 0 10px; + border-bottom: 1px solid var(--border); + background: #09111a; +} +.site-page-hero .site-content { + display: flex; + align-items: baseline; + gap: 16px; + flex-wrap: wrap; +} +.site-page-hero__title { + font-size: clamp(20px, 2.2vw, 28px); + font-weight: 700; + color: var(--text-primary); + margin: 0; + letter-spacing: -0.02em; + line-height: 1.1; + flex: 0 0 auto; +} +.site-page-hero__sub { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.45; + max-width: 820px; + margin: 0; + flex: 1 1 360px; +} +.site-page-hero--stats { + margin-bottom: 16px; +} + +/* ── Prose content ─────────────────────────────────────────────────────── */ +.site-prose { padding: 32px 24px 56px; max-width: 980px; } +.site-prose--wide { max-width: 1280px; } +.prose-section { margin-bottom: 40px; } +.prose-section h2 { + display: block; + font-size: 24px; + font-weight: 700; + color: var(--text-primary); + margin: 0 0 16px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border); +} +.prose-section h3 { font-size: 16px; color: var(--text-primary); margin: 20px 0 10px; } +.prose-section p { + font-size: 15px; + line-height: 1.7; + color: var(--text-secondary); + margin: 0 0 16px; +} +.prose-section a:not(.site-btn) { color: var(--accent); text-decoration: none; } +.prose-section a:not(.site-btn):hover { text-decoration: underline; } +.prose-section ul, .prose-section ol { + padding-left: 24px; + margin: 0 0 16px; +} +.prose-section li { + font-size: 15px; + line-height: 1.75; + color: var(--text-secondary); + margin-bottom: 8px; +} +.prose-section li strong { color: var(--text-primary); } +.prose-section strong { color: var(--text-primary); } +.prose-section code { + font-family: var(--font-mono); + font-size: 13px; + background: #0b121c; + border: 1px solid var(--border); + padding: 1px 6px; + border-radius: var(--radius); + color: var(--accent); +} +.prose-step { + display: inline-block; + min-width: 18px; + margin-right: 10px; + font-family: var(--font-mono); + font-size: 14px; + color: var(--accent); +} +.prose-note { + background: #0b121c; + border: 1px solid var(--border); + border-left: 0; + padding: 12px 16px; + border-radius: 8px; + font-size: 14px !important; + color: var(--text-secondary) !important; +} +.prose-section--muted { + background: #0b121c; + border: 1px solid var(--border); + border-radius: 8px; + padding: 20px 24px; + margin-bottom: 40px; +} +.prose-section--muted h2 { border-bottom-color: transparent; } +.prose-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 24px; } +.prose-facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 12px; + margin: 24px 0; +} +.prose-facts--3x2 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.prose-fact { + display: flex; + flex-direction: column; + gap: 6px; + padding: 16px 18px; + background: #0b121c; + border: 1px solid var(--border); + border-radius: 8px; +} +.prose-fact__value { font-family: var(--font-mono); font-size: 18px; font-weight: 700; color: var(--accent); } +.prose-fact__label { font-size: 12px; color: var(--text-secondary); } +.prose-steps { padding-left: 24px; margin: 0 0 16px; counter-reset: none; } +.prose-steps li { margin-bottom: 12px; } + +@media (max-width: 700px) { + .prose-facts--3x2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 460px) { + .prose-facts--3x2 { + grid-template-columns: 1fr; + } +} + +/* ── Code blocks ───────────────────────────────────────────────────────── */ +.code-block { + background: #020608; + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px 18px; + margin: 16px 0; + overflow-x: auto; +} +.code-block pre { + margin: 0; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.7; + color: #a8c8e8; + white-space: pre; +} + +/* ── Hardware cards (install page) ─────────────────────────────────────── */ +.hw-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin: 20px 0; +} +.hw-card { + background: #0b121c; + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px 18px; + position: relative; +} +.hw-card--recommended { border-color: rgba(0, 196, 255, 0.28); } +.hw-card__badge { + display: inline-block; + font-family: var(--font-mono); + font-size: 10px; + color: var(--accent); + margin-bottom: 8px; +} +.hw-card__name { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; } +.hw-card__detail { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); } + +/* ── Open source library grid ──────────────────────────────────────────── */ +.lib-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 10px; + margin-top: 16px; +} +.lib-card { + display: flex; + flex-direction: column; + gap: 4px; + background: #0b121c; + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px 16px; + text-decoration: none; + transition: border-color var(--transition), background var(--transition); +} +.lib-card:hover { border-color: var(--border-bright); background: var(--bg-hover); } +.lib-card__name { font-family: var(--font-mono); font-size: 13px; color: var(--accent); } +.lib-card__role { font-size: 12px; color: var(--text-secondary); line-height: 1.4; } + +.health-workers-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 14px; +} + +@media (min-width: 1500px) { + .health-workers-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } +} + +.health-workers-grid > * { + min-width: 0; +} + +.health-card { + gap: 14px; + min-width: 0; + overflow: hidden; +} + +.health-card--interactive { + cursor: pointer; + transition: border-color var(--transition), background var(--transition); +} + +.health-card--interactive:hover, +.health-card--interactive:focus-visible { + border-color: var(--border-bright); + background: var(--bg-hover); + outline: none; +} + +.health-card--expanded { + border-color: var(--accent-glow); +} + +.health-card__head { + display: grid; + grid-template-columns: 1fr; + align-items: start; + gap: 8px; + min-width: 0; +} + +.health-card__head .site-card__title { + min-width: 0; + overflow-wrap: anywhere; +} + +.health-pill { + justify-self: start; + font-size: 10px; + letter-spacing: 0.08em; + font-family: var(--font-mono); + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: 999px; + padding: 2px 8px; + max-width: 100%; + overflow-wrap: anywhere; + text-align: center; +} + +.health-pill--ok { + color: var(--online); + border-color: rgba(0, 230, 118, 0.45); + background: rgba(0, 230, 118, 0.1); +} + +.health-card__stats { + display: grid; + gap: 6px; + min-width: 0; +} + +.health-kv { + display: grid; + grid-template-columns: 1fr; + align-items: start; + font-size: 12px; + color: var(--text-secondary); + gap: 4px; + min-width: 0; +} + +.health-kv span { + min-width: 0; +} + +.health-kv strong, +.health-kv span { + overflow-wrap: anywhere; +} + +.health-kv strong { + color: var(--text-primary); + font-family: var(--font-mono); + text-align: left; + min-width: 0; +} + +.health-spark { + height: 44px; + display: flex; + align-items: flex-end; + gap: 2px; + border-top: 1px solid var(--border); + padding-top: 8px; +} + +.health-spark__bar { + width: 4px; + min-height: 8px; + background: var(--accent); + opacity: 0.7; + border-radius: 2px 2px 0 0; +} + +.health-card__desc { + margin: 0; + padding-top: 10px; + border-top: 1px solid var(--border); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; +} + +.health-system-grid .site-stat__value { + font-size: clamp(24px, 3.2vw, 36px); +} + +.health-system-grid.site-stats-grid--6 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +@media (max-width: 900px) { + .health-system-grid.site-stats-grid--6 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 560px) { + .health-system-grid.site-stats-grid--6 { + grid-template-columns: 1fr; + } +} + +.health-meta { + margin-top: 14px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 14px 16px; + background: var(--bg-panel-alt); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 8px 18px; +} + +/* ── Open source banner ─────────────────────────────────────────────────── */ +.oss-banner { + display: flex; + gap: 16px; + align-items: flex-start; + background: var(--accent-dim); + border: 1px solid var(--accent-glow); + border-radius: var(--radius-lg); + padding: 20px 24px; +} +.oss-banner__icon { font-size: 24px; flex-shrink: 0; margin-top: 2px; } +.oss-banner strong { color: var(--text-primary); display: block; margin-bottom: 6px; font-size: 15px; } +.oss-banner p { font-size: 14px; color: var(--text-secondary); margin: 0; line-height: 1.6; } +.oss-banner a:not(.site-btn) { color: var(--accent); text-decoration: none; } +.oss-banner a:not(.site-btn):hover { text-decoration: underline; } + +/* ── Packet type cards ──────────────────────────────────────────────────── */ +.packet-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; + margin-top: 8px; +} +.packet-card { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px 18px; + transition: border-color var(--transition); +} +.packet-card:hover { border-color: var(--border-bright); } +.packet-card__header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 10px; +} +.packet-card__code { + font-family: var(--font-mono); + font-size: 13px; + font-weight: 700; + color: var(--accent); + background: var(--accent-dim); + border: 1px solid var(--border-bright); + border-radius: 4px; + padding: 2px 8px; + flex-shrink: 0; + letter-spacing: 0.05em; +} +.packet-card__name { + display: block; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} +.packet-card__label { + display: block; + font-size: 11px; + color: var(--text-muted); + margin-top: 1px; + font-family: var(--font-mono); +} +.packet-card__desc { + font-size: 14px; + color: var(--text-primary); + margin: 0 0 8px; + line-height: 1.5; +} +.packet-card__detail { + font-size: 13px; + color: var(--text-secondary); + margin: 0; + line-height: 1.6; +} + +/* ── Footer ────────────────────────────────────────────────────────────── */ +.site-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 56px; + font-size: 12px; + color: var(--text-muted); + border-top: 1px solid var(--border); + background: #09111a; + flex-wrap: wrap; + padding: 12px 24px; +} +.site-footer a { color: var(--text-muted); text-decoration: none; } +.site-footer a:hover { color: var(--text-secondary); } +.site-footer__sep { color: var(--border); } + +.cookie-banner { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 1100; + max-width: 420px; + display: flex; + align-items: flex-end; + gap: 14px; + padding: 14px 16px; + border: 1px solid var(--border-bright); + border-radius: var(--radius-lg); + background: rgba(9, 17, 26, 0.96); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(10px); +} + +.cookie-banner__body { + display: grid; + gap: 4px; +} + +.cookie-banner__body strong { + color: var(--text-primary); + font-size: 14px; +} + +.cookie-banner__body p { + margin: 0; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.45; +} + +.cookie-banner__button { + flex-shrink: 0; + min-width: 88px; + padding: 9px 12px; + border: 1px solid var(--border-bright); + border-radius: var(--radius); + background: var(--accent-dim); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; + cursor: pointer; + transition: background var(--transition), border-color var(--transition); +} + +.cookie-banner__button:hover { + background: rgba(0, 196, 255, 0.22); + border-color: var(--accent-glow); +} + +/* ── Hamburger (hidden on desktop) ────────────────────────────────────────── */ +.site-nav__hamburger { + display: none; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + margin-left: auto; + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-secondary); + cursor: pointer; + font-size: 18px; + line-height: 1; + flex-shrink: 0; +} +.site-nav__hamburger:hover { color: var(--text-primary); background: var(--bg-hover); } + +/* ══════════════════════════════════════════════════════════════════════════ */ +/* ─── Mobile (≤640px) ────────────────────────────────────────────────────── */ +/* ══════════════════════════════════════════════════════════════════════════ */ +@media (max-width: 640px) { + + /* ── Analytics app topbar ────────────────────────────────────────────── */ + .topbar { gap: 10px; padding: 0 12px; } + .topbar__stats { display: none; } + + /* ── App shell: add rows for mobile controls + map ──────────────────── */ + .app-shell { grid-template-rows: 48px auto 1fr; } + + /* ── Desktop filter panel: hide on mobile (replaced by mobile-controls) */ + .filter-panel { display: none; } + + /* ── Mobile controls: 2x2 filter grid + search, always in flow ───────── */ + .mobile-controls { + display: flex; + flex-direction: column; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + } + .mobile-filter-grid { + display: grid; + grid-template-columns: 1fr 1fr; + padding: 4px 8px; + } + .mobile-filter-grid .filter-row { padding: 8px 6px; } + .mobile-filter-wrap { + border-top: 1px solid var(--border); + } + .mobile-filter-wrap--hidden { + display: none; + } + + /* ── Mobile search: shown below filter grid ──────────────────────────── */ +.mobile-search { + display: block; + padding: 8px 12px; + border-top: 1px solid var(--border); + } + .mobile-links-legend-wrap { + border-top: 1px solid var(--border); + padding-top: 2px; + } + .mobile-links-legend-wrap--hidden { + display: none; + } + .mobile-legend-toggle { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + border: 0; + border-top: 1px solid var(--border); + background: var(--bg-panel); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 8px 12px; + } + .links-legend-inline--compact .links-legend-inline__grid { + grid-template-columns: 1fr 1fr; + column-gap: 10px; + row-gap: 5px; + } + .links-legend-inline--compact .links-legend-inline__row { + margin-bottom: 0; + min-width: 0; + font-size: 10px; + } + .mobile-search .node-search { + position: static; + transform: none; + width: 100%; + top: auto; + left: auto; + } + + /* ── In-map node search: hidden on mobile (replaced by mobile-search) ── */ + .map-area .node-search { display: none; } + + /* ── Packet feed: full width at bottom, max 4 items ─────────────────── */ + .packet-feed { width: calc(100% - 32px); height: 156px; } + + /* ── Website nav: keep bar fixed height, dropdown pops below it ─────── */ + .site-nav { + height: 52px; + padding: 0 16px; + gap: 12px; + position: sticky; /* already sticky — needed for dropdown anchor */ + } + .site-nav__hamburger { display: flex; } + + /* Dropdown pops out absolutely below the nav bar */ + .site-nav__links { + display: none; + position: absolute; + top: 52px; + left: 0; + right: 0; + flex-direction: column; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + padding: 8px 16px 14px; + gap: 0; + z-index: 999; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + } + .site-nav__links--open { display: flex; } + .site-nav__link { padding: 12px 4px; font-size: 14px; border-bottom: 1px solid var(--border); } + /* App button inside dropdown: reset to plain link style */ + .site-nav__links .site-nav__app-btn { + margin-left: 0; + background: transparent; + border: none; + border-bottom: 1px solid var(--border); + border-radius: 0; + padding: 12px 4px; + font-family: var(--font-body); + font-size: 14px; + } + + .cookie-banner { + left: 16px; + right: 16px; + bottom: 16px; + max-width: none; + flex-direction: column; + align-items: stretch; + } + .cookie-banner__button { width: 100%; } + + /* ── Website content / sections ──────────────────────────────────────── */ + .site-content { padding: 0 16px; } + .site-home { padding: 28px 0 24px; } + .site-home__grid { grid-template-columns: 1fr; } + .site-home__meta-row { flex-direction: column; align-items: flex-start; } + .site-home__meta-row strong { text-align: left; } + .site-home__title { font-size: 34px; } + .site-section { padding: 24px 0; } + .site-page-hero { + padding: 16px 0 12px; + } + .site-page-hero .site-content { + display: block; + } + .site-page-hero__title { + margin-bottom: 6px; + } + .site-page-hero--stats { + margin-bottom: 12px; + } + .site-home__join { align-items: flex-start; } + .dev-monitor__grid { grid-template-columns: 1fr; } + .dev-monitor__row { flex-direction: column; align-items: flex-start; } + .dev-monitor__row strong { text-align: left; } + .dev-monitor__section-head { flex-direction: column; align-items: flex-start; } + .dev-status-shell { padding: 16px; } + .dev-status-grid { grid-template-columns: 1fr; } + .dev-status-list > div { flex-direction: column; align-items: flex-start; } + .dev-status-list strong { text-align: left; } + .dev-status-card--fixed { height: auto; } + .site-prose { padding: 24px 0 56px; } + .site-prose--wide { max-width: 100%; } + .prose-section--muted { padding: 20px; } + .owner-summary-grid { grid-template-columns: 1fr; } + .owner-dashboard-grid { grid-template-columns: 1fr; } + .owner-panel--map, + .owner-panel--packets { grid-row: auto; grid-column: auto; } + .owner-alerts, + .owner-list { grid-template-columns: 1fr; } + .owner-list__row { grid-template-columns: 1fr; } + .owner-list__metrics { justify-content: flex-start; } + .owner-telemetry-strip { grid-template-columns: 1fr; } + .owner-roadmap { grid-template-columns: 1fr; } + .owner-head { flex-direction: column; align-items: flex-start; } + .owner-map { height: 260px; } +} + +@media (max-width: 1180px) { + .dev-telemetry__charts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .owner-summary-grid.site-stats-grid--6 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .owner-dashboard-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 860px) { + .dev-telemetry__charts { + grid-template-columns: 1fr; + } + + .owner-summary-grid.site-stats-grid--6 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .uk-feed-packet-row__meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .owner-dashboard-grid { + grid-template-columns: 1fr; + grid-auto-rows: minmax(280px, auto); + } +} + +/* ─── Stats page ─────────────────────────────────────────────────────────── */ +.stats-page__summary { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 12px; + margin: 0 0 32px; +} + +.stats-page__stat { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.stats-page__stat-label { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + font-family: var(--font-mono); +} + +.stats-page__stat-value { + font-size: 24px; + font-weight: 700; + font-family: var(--font-mono); + line-height: 1; +} + +.stats-page__stat-sub { + font-size: 11px; + color: var(--text-secondary); +} + +.stats-page__row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 16px; +} + +.stats-page__observer-section { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + margin: 0 0 16px; +} + +.stats-page__observer-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; +} + +.stats-page__observer-card { + border: 1px solid rgba(0, 196, 255, 0.12); + background: rgba(10, 20, 32, 0.55); + border-radius: var(--radius-md); + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; + min-height: 190px; +} + +.stats-page__observer-card-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.stats-page__observer-iata { + font-size: 22px; + line-height: 1; + font-weight: 700; + font-family: var(--font-mono); + color: var(--text-primary); +} + +.stats-page__observer-last { + font-size: 11px; + color: var(--text-muted); + text-align: right; +} + +.stats-page__observer-metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.stats-page__observer-metric { + display: flex; + flex-direction: column; + gap: 4px; +} + +.stats-page__observer-metric span { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.stats-page__observer-metric strong { + font-size: 16px; + color: var(--text-primary); + font-family: var(--font-mono); +} + +.stats-page__observer-chart { + margin-top: auto; +} + +.stats-page__chart { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px 16px 12px; +} + +.stats-page__chart-header { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 12px; +} + +.stats-page__chart-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + font-family: var(--font-mono); +} + +.stats-page__chart-sub { + font-size: 11px; + color: var(--text-muted); +} + +.stats-page__pie-legend { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; + max-height: 220px; + overflow-y: auto; +} + +.stats-page__pie-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.stats-page__pie-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.stats-page__pie-label { + color: var(--text-secondary); + flex: 1; +} + +.stats-page__pie-count { + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 11px; +} + +@media (max-width: 900px) { + .stats-page__summary { + grid-template-columns: repeat(3, 1fr); + } + .stats-page__row { + grid-template-columns: 1fr; + } + .stats-page__observer-metrics { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 480px) { + .stats-page__summary { + grid-template-columns: repeat(2, 1fr); + } + .stats-page__observer-card-head { + flex-direction: column; + align-items: flex-start; + } + .stats-page__observer-last { + text-align: left; + } + .stats-page__observer-metrics { + grid-template-columns: 1fr; + } +} + +/* Repeater Search Page */ +.repeater-search-box { + position: relative; + max-width: 600px; + margin-bottom: 32px; +} + +.repeater-search-box__input { + width: 100%; + padding: 14px 18px; + font-size: 15px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-panel); + color: var(--text-primary); + box-sizing: border-box; +} + +.repeater-search-box__input:focus { + outline: none; + border-color: var(--accent); +} + +.repeater-search-box__results { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--bg-panel); + border: 1px solid var(--border); + border-top: none; + border-radius: 0 0 8px 8px; + max-height: 320px; + overflow-y: auto; + z-index: 100; +} + +.repeater-search-box__result { + display: block; + width: 100%; + padding: 12px 18px; + text-align: left; + background: none; + border: none; + border-bottom: 1px solid var(--border); + cursor: pointer; + box-sizing: border-box; +} + +.repeater-search-box__result:last-child { + border-bottom: none; +} + +.repeater-search-box__result:hover { + background: var(--bg-tertiary); +} + +.repeater-search-box__result-name { + display: block; + font-weight: 600; + font-size: 14px; + color: var(--text-primary); +} + +.repeater-search-box__result-meta { + display: block; + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; + font-family: var(--font-mono); +} + +/* Repeater Details Card */ +.repeater-details-card { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} + +.repeater-details-card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 20px 24px; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} + +.repeater-details-card__header h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.02em; +} + +.repeater-details-card__status { + padding: 4px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.repeater-details-card__status--online { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + +.repeater-details-card__status--offline { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +.repeater-details-card__section { + padding: 24px; + border-bottom: 1px solid var(--border); +} + +.repeater-details-card__section:last-child { + border-bottom: none; +} + +.repeater-details-card__section h3 { + margin: 0 0 4px; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.01em; +} + +.repeater-details-card__desc { + margin: 0 0 16px; + font-size: 13px; + color: var(--text-secondary); +} + +.repeater-details-card__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 20px; +} + +.repeater-details-card__field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.repeater-details-card__label { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.repeater-details-card__value { + font-size: 14px; + color: var(--text-primary); + line-height: 1.4; +} + +.repeater-details-card__value--mono { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.02em; +} + +.repeater-details-card__meta { + font-size: 11px; + color: var(--text-secondary); + margin-top: 2px; +} + +.repeater-details-card__loading { + padding: 32px; + text-align: center; + color: var(--text-secondary); +} + +.repeater-details-card__neighbours { + display: grid; + grid-template-columns: 1fr; + gap: 8px; +} + +@media (min-width: 768px) { + .repeater-details-card__neighbours { + grid-template-columns: repeat(3, 1fr); + } +} + +.repeater-details-card__neighbour { + padding: 14px 16px; + background: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--border); +} + +.repeater-details-card__neighbour-main { + display: flex; + flex-direction: column; + gap: 2px; +} + +.repeater-details-card__neighbour-name { + font-weight: 600; + font-size: 14px; + color: var(--text-primary); +} + +.repeater-details-card__neighbour-id { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); +} + +.repeater-details-card__neighbour-stats { + margin-top: 6px; + font-size: 12px; + color: var(--text-secondary); +} + +.repeater-details-card__table-wrap { + overflow-x: auto; + margin: 0 -24px; + padding: 0 24px; +} + +.repeater-details-card__table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + min-width: 500px; +} + +.repeater-details-card__table th, +.repeater-details-card__table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +.repeater-details-card__table th { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + background: var(--bg-secondary); +} + +/* No results state */ +.repeater-search-box__no-results { + padding: 16px 18px; + text-align: center; + color: var(--text-secondary); + font-size: 14px; +} + +.repeater-search-box__count { + padding: 8px 18px; + font-size: 12px; + color: var(--text-secondary); + background: var(--bg-secondary); + border-top: 1px solid var(--border); +} + +/* Empty state */ +.repeater-details-card__empty { + padding: 64px 32px; + text-align: center; +} + +.repeater-details-card__empty-icon { + width: 48px; + height: 48px; + margin: 0 auto 16px; + opacity: 0.4; +} + +.repeater-details-card__empty h3 { + margin: 0 0 8px; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +.repeater-details-card__empty p { + margin: 0; + font-size: 14px; + color: var(--text-secondary); +} + +.repeater-details-card__empty-msg { + padding: 24px; + text-align: center; + color: var(--text-secondary); + font-size: 14px; + background: var(--bg-secondary); + border-radius: 8px; +} + +/* Copy button */ +.repeater-details-card__copy-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 12px; + font-weight: 500; + color: var(--accent); + background: transparent; + border: 1px solid var(--accent); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +.repeater-details-card__copy-btn:hover { + background: var(--accent); + color: white; +} + +.repeater-details-card__copy-icon { + width: 14px; + height: 14px; +} + +/* Map link */ +.repeater-details-card__map-link { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 12px; + font-weight: 500; + color: var(--accent); + text-decoration: none; + background: transparent; + border: 1px solid var(--accent); + border-radius: 6px; + transition: all 0.2s ease; +} + +.repeater-details-card__map-link:hover { + background: var(--accent); + color: white; +} + +.repeater-details-card__map-icon { + width: 14px; + height: 14px; +} + +/* Section icons */ +.repeater-details-card__section-icon { + width: 18px; + height: 18px; + margin-right: 8px; + vertical-align: middle; + opacity: 0.7; +} + +.repeater-details-card__section h3 { + display: flex; + align-items: center; +} + + +.repeater-details-card__count-badge { + display: inline-block; + margin-left: 8px; + padding: 2px 8px; + font-size: 12px; + font-weight: 600; + background: var(--accent); + color: white; + border-radius: 10px; +} + +/* Loading spinner */ +.repeater-details-card__spinner { + display: inline-block; + width: 20px; + height: 20px; + margin-right: 12px; + vertical-align: middle; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: repeater-spin 0.8s linear infinite; +} + +@keyframes repeater-spin { + to { transform: rotate(360deg); } +} + +/* Marching dashes on beta path overlays — GPU-animated, no JS rAF needed. + Speed matches former JS loop: 0.3px/frame × 60fps = 18px/s. + dashArray '6 9' = 15px cycle → period 15/18 ≈ 0.833s. + dashArray '4 7' = 11px cycle → period 11/18 ≈ 0.611s. + CSS animation beats SVG presentation attributes so Leaflet's _updateStyle + cannot interfere — it never sets stroke-dashoffset. */ +@keyframes march-dashes-15 { + to { stroke-dashoffset: -15px; } +} +@keyframes march-dashes-11 { + to { stroke-dashoffset: -11px; } +} +.beta-red-path-overlay, +.beta-purple-path-overlay { + animation: march-dashes-15 0.833s linear infinite !important; +} +.beta-completion-path-overlay { + animation: march-dashes-11 0.611s linear infinite !important; +} + +/* Packet type labels */ +.repeater-details-card__packet-type { + display: inline-block; + padding: 2px 8px; + font-size: 11px; + font-weight: 500; + background: var(--bg-tertiary); + color: var(--text-primary); + border-radius: 4px; +} diff --git a/frontend/src/hooks/useAppMessageHandler.ts b/frontend/src/hooks/useAppMessageHandler.ts index 71a4716..a32b20a 100644 --- a/frontend/src/hooks/useAppMessageHandler.ts +++ b/frontend/src/hooks/useAppMessageHandler.ts @@ -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(() => { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e34e9e5..d07426a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( ) : ( + } /> }> - } /> } /> } /> } /> diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx deleted file mode 100644 index f40bb3a..0000000 --- a/frontend/src/pages/HomePage.tsx +++ /dev/null @@ -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 ( - <> -
-
-
-

Teesside Mesh Network

-

- 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. -

-
- Open live map - Install MeshCore - Network stats -
-
- -
-

Network overview

-
-
- Coverage - Teesside and North East England -
-
- Band - LoRa 868 MHz -
-
- Channel - Public -
-
- Observer ingest - MQTT via UK Mesh broker -
-
-

- Teesside views are filtered from the shared ingest path. Observers using IATA MME{' '} - show up here and in the wider UK stack. -

-
-
-
- - - -
-
-
-

What MeshCore is

-

- 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. -

-
-
-
- -
-
-
-

Use the network

-

Everything on the public site should answer one of three questions: what MeshCore is, how to join, and how to contribute useful coverage.

-
-
-
-

Get on the air

-

- 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. -

- Open the install guide -
- -
-

Feed the dashboards

-

- 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. -

- Observer setup -
- -
-

Inspect live traffic

-

- The map shows repeater locations, live packets, path predictions, coverage modelling, and the - stats pages that sit behind the public network view. -

- Open the live map -
-
-
-
- -
-
-
-

Radio profile

-

These are the settings used on the Teesside side of the network.

-
-
-
- Profile - EU/UK Narrow -
-
- Frequency - 869.618 MHz -
-
- Bandwidth - 62.5 kHz -
-
- Spreading factor - SF8 -
-
- Coding rate - CR8 -
-
-
-
- -
-
-
-
-

Join the conversation

-

- The network is community-run. If you want help with hardware, range testing, observer setup, or - repeater placement, the Discord is where that happens. -

-
-
- - Join Discord - - View source -
-
-
-
- - ); -}; diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 11cf27e..e66c8d7 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -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%;