diff --git a/.gitignore b/.gitignore index 5858cdf..bacaf01 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist/ # Mosquitto credentials (generated file, not for source control) mosquitto/passwd +mosquitto/acl # Key files scripts/keys/ @@ -33,3 +34,4 @@ __pycache__/ CLAUDE.md AI_MEMORY.md knowledge.md +multipath.md diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index b98e259..56a71e5 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -3,7 +3,7 @@ import { rateLimit } from 'express-rate-limit'; import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; import { isIP } from 'node:net'; import mqtt from 'mqtt'; -import { getNodes, getNodeHistory, getPathHistoryCache, getRecentPacketEvents, getRecentPackets, query, MIN_LINK_OBSERVATIONS } from '../db/index.js'; +import { getNodes, getNodeHistory, getNodeAdverts, getPathHistoryCache, getRecentPacketEvents, getRecentPackets, query, MIN_LINK_OBSERVATIONS } from '../db/index.js'; import { addOwnerNodeForUsername, getMappedOwnerNodeIds, getOwnerNodeIdsForUsername } from '../db/ownerAuth.js'; import { getWorkerHealthOverview } from '../health/status.js'; import { resolveRequestNetwork } from '../http/requestScope.js'; @@ -246,7 +246,7 @@ async function autoLinkOwnerNodeIds(mqttUsername: string): Promise { const res = await query<{ node_id: string }>( `SELECT n.node_id FROM nodes n - WHERE n.role = 2 + WHERE n.role IN (1, 2) AND COALESCE(n.network, '') <> 'test' AND n.last_seen > NOW() - INTERVAL '30 minutes' AND NOT (LOWER(n.node_id) = ANY($1::text[])) @@ -1179,6 +1179,23 @@ router.get('/nodes/:id/history', async (req, res) => { } }); +// GET /api/nodes/:publicKey/adverts?hours=24 — advert packets for a node by public key +router.get('/nodes/:id/adverts', async (req, res) => { + try { + const publicKey = req.params['id']!; + if (!/^[0-9a-fA-F]{64}$/.test(publicKey)) { + res.status(400).json({ error: 'Invalid public key format' }); + return; + } + const hours = Math.min(Number(req.query['hours'] ?? 24), 672); + const adverts = await getNodeAdverts(publicKey, hours); + res.json(adverts); + } catch (err) { + console.error('[api] GET /nodes/:id/adverts', (err as Error).message); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // GET /api/packets/recent?limit=200 router.get('/packets/recent', async (req, res) => { try { @@ -1939,7 +1956,7 @@ router.get('/owner/live', async (req, res) => { const prev = i > 0 ? samples[i - 1]! : null; const batteryPct = sample.batteryMv == null ? null - : clamp(((sample.batteryMv - 3300) / 900) * 100, 0, 100); + : clamp(((sample.batteryMv - 3000) / 1200) * 100, 0, 100); let channelUtilPct = sample.channelUtilization; let airUtilTxPct = sample.airUtilTx; diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index dfa8234..97fedb3 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -323,6 +323,22 @@ export async function getNodeHistory(nodeId: string, hours = 24) { return res.rows; } +export async function getNodeAdverts(nodePublicKey: string, hours = 24, limit = 100) { + // Get location packets (packet_type = 4) where payload->>'publicKey' = this public key + // Location packets are sent as part of the advert broadcast + const res = await pool.query( + `SELECT time, packet_hash + FROM packets + WHERE packet_type = 4 + AND payload->>'publicKey' = $1 + AND time > NOW() - INTERVAL '1 hour' * $2 + ORDER BY time DESC + LIMIT $3`, + [nodePublicKey, hours, limit] + ); + return res.rows; +} + export async function getRecentPackets(limit = 200, network?: string, observer?: string) { const scope = buildScopePlaceholders(2, network, observer); const fiveMinAgo = 'NOW() - INTERVAL \'5 minutes\''; diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 9c43d3d..a042070 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -132,6 +132,11 @@ CREATE INDEX IF NOT EXISTS packets_src_idx ON packets (src_node_id, time DESC CREATE INDEX IF NOT EXISTS packets_network_time_idx ON packets (network, time DESC); CREATE INDEX IF NOT EXISTS packets_path_hashes_idx ON packets USING GIN (path_hashes) WHERE path_hashes IS NOT NULL; +-- Performance optimization indexes +CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON nodes(last_seen DESC); +CREATE INDEX IF NOT EXISTS idx_packets_time_hash ON packets(time DESC, packet_hash); +CREATE INDEX IF NOT EXISTS idx_nodes_network_last_seen ON nodes(network, last_seen DESC) WHERE is_online = TRUE; + -- ─── Observer / repeater status telemetry samples ─────────────────────────── CREATE TABLE IF NOT EXISTS node_status_samples ( diff --git a/backend/src/index.ts b/backend/src/index.ts index ba45051..8abf005 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,7 +8,7 @@ import { initOwnerAuthDb } from './db/ownerAuth.js'; import { startMqttClient, onPacket, onNodeSeen, onNodeUpsert } from './mqtt/client.js'; import { initWebSocketServer, broadcastPacket, broadcastNodeUpdate, broadcastNodeUpsert } from './ws/server.js'; import apiRoutes from './api/routes.js'; -import { queueViewshedJob, queueLinkJob } from './queue/publisher.js'; +import { isViewshedEligibleCoordinate, queueViewshedJob, queueLinkJob } from './queue/publisher.js'; const ALLOWED_ORIGINS = (process.env['ALLOWED_ORIGINS'] ?? '') .split(',') @@ -31,6 +31,9 @@ async function main() { `SELECT n.node_id, n.lat, n.lon FROM nodes n LEFT JOIN node_coverage nc ON n.node_id = nc.node_id WHERE n.lat IS NOT NULL AND n.lon IS NOT NULL + AND n.lat BETWEEN 49.5 AND 61.5 + AND n.lon BETWEEN -8.5 AND 2.5 + AND NOT (ABS(n.lat) < 1e-9 AND ABS(n.lon) < 1e-9) AND (nc.node_id IS NULL OR nc.model_version < $1) AND (n.name IS NULL OR n.name NOT LIKE '%🚫%') AND (n.role IS NULL OR n.role = 2)`, @@ -61,7 +64,7 @@ async function main() { // 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; - if (!isHidden && !isNonRepeater && typeof node.lat === 'number' && typeof node.lon === 'number') { + if (!isHidden && !isNonRepeater && typeof node.lat === 'number' && typeof node.lon === 'number' && isViewshedEligibleCoordinate(node.lat, node.lon)) { queueViewshedJob(node.node_id as string, node.lat, node.lon); } }); diff --git a/backend/src/mqtt/client.ts b/backend/src/mqtt/client.ts index d99e3e5..298f920 100644 --- a/backend/src/mqtt/client.ts +++ b/backend/src/mqtt/client.ts @@ -532,6 +532,10 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { } else if (decoded.payloadType === 7) { const inner = decodedInner as unknown as Record | undefined; srcNodeId = inner?.['senderPublicKey'] as string | undefined; + } else if (decoded.payloadType === 1) { + // Router/Advert packets - extract origin_id from payload + const inner = decodedInner as unknown as Record | undefined; + srcNodeId = inner?.['origin_id'] as string | undefined; } } } catch { @@ -556,6 +560,11 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { innerPayload = buildAdvertFallbackPayload(originId, origin); } + // For Router packets (type 1), also try originId as fallback + if (!srcNodeId && resolvedPacketType === 1 && originId) { + srcNodeId = originId; + } + if (resolvedPacketType == null) { return; } diff --git a/backend/src/queue/publisher.ts b/backend/src/queue/publisher.ts index 013eb36..b90f5d8 100644 --- a/backend/src/queue/publisher.ts +++ b/backend/src/queue/publisher.ts @@ -4,6 +4,11 @@ const VIEWSHED_JOB_QUEUE = 'meshcore:viewshed_jobs'; const VIEWSHED_PENDING_SET = 'meshcore:viewshed_pending'; const LINK_JOB_QUEUE = 'meshcore:link_jobs'; +const UK_LAT_MIN = 49.5; +const UK_LAT_MAX = 61.5; +const UK_LON_MIN = -8.5; +const UK_LON_MAX = 2.5; + let pub: Redis | null = null; function getPublisher(): Redis { @@ -21,8 +26,16 @@ export async function closeQueuePublisher(): Promise { pub = null; } +/** Push a viewshed calculation job for a node with a known position. */ +export function isViewshedEligibleCoordinate(lat: number, lon: number): boolean { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; + if (Math.abs(lat) < 1e-9 && Math.abs(lon) < 1e-9) return false; + return lat >= UK_LAT_MIN && lat <= UK_LAT_MAX && lon >= UK_LON_MIN && lon <= UK_LON_MAX; +} + /** Push a viewshed calculation job for a node with a known position. */ export function queueViewshedJob(nodeId: string, lat: number, lon: number): void { + if (!isViewshedEligibleCoordinate(lat, lon)) return; const publisher = getPublisher(); const job = JSON.stringify({ node_id: nodeId, lat, lon }); void publisher diff --git a/backend/src/ws/server.ts b/backend/src/ws/server.ts index f03e515..77e1d6c 100644 --- a/backend/src/ws/server.ts +++ b/backend/src/ws/server.ts @@ -70,7 +70,9 @@ function nodeMatchesScope(nodeId: string | undefined, scope: ClientScope): boole function shouldSendMessage(msg: WSMessage, scope: ClientScope): boolean { if (msg.type === 'packet') { - return packetMatchesScope(msg.data as Partial, scope); + const packet = msg.data as Partial; + const matchesScope = packetMatchesScope(packet, scope); + return matchesScope; } if (msg.type === 'node_update') { diff --git a/frontend/src/components/Map/MapView.tsx b/frontend/src/components/Map/MapView.tsx index bfd0486..fd641e9 100644 --- a/frontend/src/components/Map/MapView.tsx +++ b/frontend/src/components/Map/MapView.tsx @@ -718,7 +718,7 @@ export const MapView = React.memo(({ {/* Confirmed link lines — ITM-viable node pairs */} {effectiveShowLinks && linkLines.length > 0 && ( - + {linkLines.map((line) => { const obs = Math.max(1, line.observedCount); const strength = Math.log10(obs + 1); @@ -741,7 +741,7 @@ export const MapView = React.memo(({ )} {clashModeActive && ( - + {visibleClashPathLines.map((line) => ( [lat, lon] as LatLngExpression); } @@ -172,26 +176,75 @@ export const NodeMarker: React.FC = React.memo(({ amber: coverageToPolygons(nodeCoverage.strength_geoms?.amber), green: coverageToPolygons(nodeCoverage.strength_geoms?.green), } : { red: [], amber: [], green: [] }; - const showSamePrefixRow = (node.role === undefined || node.role === 2) && typeof samePrefixRepeaterCount === 'number'; + const showSamePrefixRow = isRepeaterNode(node.role) && typeof samePrefixRepeaterCount === 'number'; + const isRepeater = isRepeaterNode(node.role); + + // Simple popup content for repeaters - just name and coords (respecting privacy) + const repeaterPopupContent = ( +
+
{displayName}
+ {node.public_key && ( +
+ Public key + {node.public_key} +
+ )} +
+ Status + {statusLabel} +
+
+ Position + {prohibited ? 'Redacted' : `${lat.toFixed(5)}, ${lon.toFixed(5)}`} +
+ {prohibited && ( +
+ Location + Redacted within 1 mile radius +
+ )} +
+ ); return ( <> - - { - if (links !== null) return; // already fetched - fetch(`/api/nodes/${node.node_id}/links`) - .then((r) => r.json()) - .then((data: NodeLink[]) => setLinks(data)) - .catch(() => setLinks([])); - }, - }}> + {isRepeater ? ( + // Lightweight CircleMarker for repeaters with simple popup + + {repeaterPopupContent} + + ) : ( + + { + if (links !== null) return; // already fetched + fetch(`/api/nodes/${node.node_id}/links`) + .then((r) => r.json()) + .then((data: NodeLink[]) => setLinks(data)) + .catch(() => setLinks([])); + }, + }}>
{displayName}
+ {node.public_key && ( +
+ Public key + {node.public_key} +
+ )} {node.role !== undefined && node.role !== 2 && (
Type @@ -304,7 +357,8 @@ export const NodeMarker: React.FC = React.memo(({ )}
- + + )} {prohibited && ( }> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/shared/SiteLayout.tsx b/frontend/src/pages/shared/SiteLayout.tsx index 541acfd..c1c496c 100644 --- a/frontend/src/pages/shared/SiteLayout.tsx +++ b/frontend/src/pages/shared/SiteLayout.tsx @@ -14,6 +14,7 @@ type SiteLayoutProps = { showOpenSource?: boolean; showPackets: boolean; showStats: boolean; + showRepeaterSearch?: boolean; }; type NavItem = { @@ -46,6 +47,7 @@ export const SiteLayout: React.FC = ({ showOpenSource = true, showPackets, showStats, + showRepeaterSearch = false, }) => { const COOKIE_CONSENT_KEY = 'meshcore-cookie-consent-v1'; const [menuOpen, setMenuOpen] = useState(false); @@ -62,6 +64,7 @@ export const SiteLayout: React.FC = ({ const navItems: NavItem[] = [ { to: '/', label: 'Home', enabled: true }, { to: '/feed', label: 'Feed', enabled: showFeed }, + { to: '/repeater', label: 'Repeater Search', enabled: showRepeaterSearch }, { to: '/about', label: 'What is MeshCore', enabled: showAbout }, { to: '/install', label: 'Install', enabled: showInstall }, { to: '/mqtt', label: 'MQTT', enabled: showMqtt }, diff --git a/frontend/src/pages/ukmesh/UKLayout.tsx b/frontend/src/pages/ukmesh/UKLayout.tsx index e67cd68..22fc7ed 100644 --- a/frontend/src/pages/ukmesh/UKLayout.tsx +++ b/frontend/src/pages/ukmesh/UKLayout.tsx @@ -10,6 +10,7 @@ export const UKLayout: React.FC = () => { footerName={site.footerName} appUrl={site.appUrl} showFeed + showRepeaterSearch showAbout={false} showMqtt={false} showHealth={false} diff --git a/frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx b/frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx new file mode 100644 index 0000000..c20bcee --- /dev/null +++ b/frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx @@ -0,0 +1,485 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react'; + +interface MeshNode { + node_id: string; + name?: string; + lat?: number; + lon?: number; + iata?: string; + role?: number; + last_seen: string; + is_online: boolean; + hardware_model?: string; + public_key?: string; + advert_count?: number; + elevation_m?: number; +} + +interface NodeLink { + peer_id: string; + peer_name: string | null; + observed_count: number; + itm_path_loss_db: number | null; + count_this_to_peer: number; + count_peer_to_this: number; +} + +interface PacketHistory { + time: string; + packet_hash: string; + src_node_id: string; + topic: string; + packet_type: number; + hop_count: number; + rssi: number; + snr: number; +} + +interface AdvertPacket { + time: string; + packet_hash: string; +} + +function timeAgo(iso: string): string { + const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (secs < 60) return `${secs}s ago`; + if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; + if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; + return `${Math.floor(secs / 86400)}d ago`; +} + +function predictNextAdvert(packets: T[]): { nextAdvert: Date; avgInterval: number; samples: number } | null { + if (packets.length < 2) return null; + + // Sort by time ascending (oldest first) + const sorted = [...packets].sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()); + + // First deduplicate by packet_hash (same advert received by different nodes) + // Keep the earliest time for each unique hash + const byHash = new Map(); + for (const pkt of sorted) { + if (!byHash.has(pkt.packet_hash)) { + byHash.set(pkt.packet_hash, pkt); + } + } + let unique = Array.from(byHash.values()).sort((a, b) => + new Date(a.time).getTime() - new Date(b.time).getTime() + ); + + // Filter out packets that are within 30 seconds of each other + // (these are duplicates from different observers receiving the same packet) + const MIN_INTERVAL = 30; + const filtered: T[] = []; + for (const pkt of unique) { + if (filtered.length === 0) { + filtered.push(pkt); + } else { + const lastTime = new Date(filtered[filtered.length - 1].time).getTime(); + const thisTime = new Date(pkt.time).getTime(); + if ((thisTime - lastTime) / 1000 >= MIN_INTERVAL) { + filtered.push(pkt); + } + } + } + unique = filtered; + + if (unique.length < 2) return null; + + // Take last 10 unique packets + const recent = unique.slice(-10); + + // Calculate intervals between consecutive packets + const intervals: number[] = []; + for (let i = 1; i < recent.length; i++) { + const prev = new Date(recent[i - 1].time).getTime(); + const curr = new Date(recent[i].time).getTime(); + const interval = (curr - prev) / 1000; // convert to seconds + if (interval > 0 && interval < 259200) { // ignore invalid intervals (> 3 days) + intervals.push(interval); + } + } + + if (intervals.length < 1) return null; + + // Filter out outliers - only use intervals within 50% of median + // This captures the recent consistent pattern and excludes old outlier intervals + const sortedIntervals = [...intervals].sort((a, b) => a - b); + const medianIdx = Math.floor(sortedIntervals.length / 2); + const median = sortedIntervals.length % 2 === 0 + ? (sortedIntervals[medianIdx - 1] + sortedIntervals[medianIdx]) / 2 + : sortedIntervals[medianIdx]; + + const filteredIntervals = intervals.filter(i => + i >= median * 0.5 && i <= median * 1.5 + ); + + // Use filtered intervals if we have enough, otherwise fall back to all intervals + const intervalsToUse = filteredIntervals.length >= 2 ? filteredIntervals : intervals; + + const avgInterval = intervalsToUse.reduce((a, b) => a + b, 0) / intervalsToUse.length; + const lastPacketTime = new Date(recent[recent.length - 1].time).getTime(); + const nextAdvert = new Date(lastPacketTime + avgInterval * 1000); + + return { nextAdvert, avgInterval, samples: intervals.length }; +} + +function formatInterval(seconds: number): string { + if (seconds < 60) return `${Math.round(seconds)}s`; + if (seconds < 3600) return `${Math.round(seconds / 60)}m`; + return `${Math.round(seconds / 3600)}h`; +} + +function formatTimeUntil(date: Date): string { + const secs = Math.floor((date.getTime() - Date.now()) / 1000); + if (secs <= 0) return 'now'; + if (secs < 60) return `${secs}s`; + if (secs < 3600) return `${Math.floor(secs / 60)}m`; + return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`; +} + +export const UKRepeaterSearchPage: React.FC = () => { + const [searchQuery, setSearchQuery] = useState(''); + const [showResults, setShowResults] = useState(false); + const [nodes, setNodes] = useState([]); + const [selectedNode, setSelectedNode] = useState(null); + const [links, setLinks] = useState([]); + const [history, setHistory] = useState([]); + const [adverts, setAdverts] = useState([]); + const [loadingDetails, setLoadingDetails] = useState(false); + const [copiedKey, setCopiedKey] = useState(false); + const searchRef = useRef(null); + + // Load nodes on mount + useEffect(() => { + fetch('/api/nodes?network=ukmesh') + .then(r => r.json()) + .then(data => setNodes(Array.isArray(data) ? data : [])) + .catch(() => setNodes([])); + }, []); + + // Click outside to close search dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (searchRef.current && !searchRef.current.contains(event.target as Node)) { + setShowResults(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const searchResults = useMemo(() => { + if (!searchQuery.trim()) return []; + const q = searchQuery.toLowerCase(); + return nodes + .filter(n => { + // Exclude nodes marked as disabled (🚫 in name) + if (n.name && n.name.includes('🚫')) return false; + const nameMatch = n.name && n.name.toLowerCase().includes(q); + const keyMatch = n.public_key && n.public_key.toLowerCase().includes(q); + const iataMatch = n.iata && n.iata.toLowerCase().includes(q); + return nameMatch || keyMatch || iataMatch; + }) + .slice(0, 10); + }, [searchQuery, nodes]); + + // Calculate predicted next advert based on advert packets + const prediction = useMemo(() => { + if (!adverts.length) return null; + return predictNextAdvert(adverts); + }, [adverts]); + + const selectNode = async (node: MeshNode) => { + setSelectedNode(node); + setSearchQuery(node.name || node.public_key?.slice(0, 16) || ''); + setShowResults(false); + setLoadingDetails(true); + setLinks([]); + setHistory([]); + setAdverts([]); + setCopiedKey(false); + + try { + const [linksRes, historyRes, advertsRes] = await Promise.all([ + fetch(`/api/nodes/${node.node_id}/links`), + fetch(`/api/nodes/${node.node_id}/history?hours=24`), + fetch(`/api/nodes/${node.public_key}/adverts?hours=168`) + ]); + + const linksData = await linksRes.json(); + const historyData = await historyRes.json(); + const advertsData = await advertsRes.json(); + + setLinks(Array.isArray(linksData) ? linksData : []); + setHistory(Array.isArray(historyData) ? historyData : []); + setAdverts(Array.isArray(advertsData) ? advertsData : []); + } catch { + // Ignore errors + } finally { + setLoadingDetails(false); + } + }; + + const copyPublicKey = async () => { + if (selectedNode?.public_key) { + await navigator.clipboard.writeText(selectedNode.public_key); + setCopiedKey(true); + setTimeout(() => setCopiedKey(false), 2000); + } + }; + + return ( + <> +
+
+

Repeater Search

+

Search for a repeater by name or public key to view detailed information.

+
+
+ +
+
+
+ { setSearchQuery(e.target.value); setShowResults(true); }} + onFocus={() => setShowResults(true)} + placeholder="Search by repeater name, IATA code, or public key..." + className="repeater-search-box__input" + autoFocus + /> + {showResults && ( +
+ {searchQuery && searchResults.length === 0 ? ( +
+ No repeaters found matching "{searchQuery}" +
+ ) : ( + searchResults.map(node => ( + + )) + )} + {searchResults.length > 0 && ( +
+ {searchResults.length} result{searchResults.length !== 1 ? 's' : ''} +
+ )} +
+ )} +
+ + {!selectedNode ? ( +
+
+ + + + +

Select a Repeater

+

Search for a repeater above to view its details, neighbours, and packet history.

+
+
+ ) : ( +
+
+

{selectedNode.name || 'Unknown Repeater'}

+ + {selectedNode.is_online ? 'Online' : 'Offline'} + +
+ +
+

+ + + + + Details +

+
+
+ Public Key + + {selectedNode.public_key || 'N/A'} + + {selectedNode.public_key && ( + + )} +
+
+ Position + + {selectedNode.lat && selectedNode.lon + ? ( + <> + {selectedNode.lat.toFixed(5)}
+ {selectedNode.lon.toFixed(5)} + + ) + : 'Unknown'} +
+
+
+ Elevation + + {selectedNode.elevation_m !== undefined && selectedNode.elevation_m !== null + ? `${Math.round(selectedNode.elevation_m)} m` + : 'N/A'} + +
+
+ Network + {selectedNode.iata || 'N/A'} +
+
+ Hardware + {selectedNode.hardware_model || 'Unknown'} +
+
+ Last Seen + {timeAgo(selectedNode.last_seen)} +
+
+ Advert Count + {selectedNode.advert_count?.toLocaleString() || '0'} +
+ {prediction && ( +
+ Predicted Next Advert + + {prediction.samples >= 3 ? formatTimeUntil(prediction.nextAdvert) : 'Collecting data...'} + + + ~{formatInterval(prediction.avgInterval)} interval ({prediction.samples} samples{ prediction.samples < 3 ? ' - need 3+' : '' }) + +
+ )} +
+
+ + {loadingDetails ? ( +
+
+ Loading details... +
+ ) : ( + <> +
+

+ + + + + + + Confirmed Neighbours {links.length > 0 && {links.length}} +

+ {links.length === 0 ? ( +

No neighbours found for this node.

+ ) : ( + <> +

Nodes with confirmed two-way communication

+
+ {links.map(link => ( +
+
+ + {link.peer_name || `${link.peer_id.slice(0, 12)}...`} + + + {link.peer_id} + +
+
+ Seen {link.observed_count}× + {link.itm_path_loss_db !== null && ( + · {Math.round(link.itm_path_loss_db)} dB loss + )} + · TX: {link.count_this_to_peer} + · RX: {link.count_peer_to_this} +
+
+ ))} +
+ + )} +
+ +
+

+ + + + Recent Packets {history.length > 0 && {history.length}} +

+ {history.length === 0 ? ( +

No packet history available for this node.

+ ) : ( + <> +

Last 24 hours of packet activity

+
+ + + + + + + + + + + + {history.slice(0, 50).map((pkt, idx) => ( + + + + + + + + ))} + +
TimeHopsRSSISNRFrom
{timeAgo(pkt.time)}{pkt.hop_count ?? '-'}{pkt.rssi ?? '-'}{pkt.snr ?? '-'}{pkt.src_node_id?.slice(0, 12) || '-'}...
+
+ + )} +
+ + )} +
+ )} +
+
+ + ); +}; diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 7195931..d2bc766 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -539,6 +539,13 @@ html, body, #root { 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; } @@ -3286,3 +3293,416 @@ html, body, #root { 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); + word-break: break-all; + line-height: 1.4; +} + +.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; +} + +/* Consistent text sizes for repeater stats */ +.repeater-details-card .site-stats-grid .site-stat__value { + font-size: 28px; + font-weight: 700; +} + +.repeater-details-card .site-stats-grid .site-stat__label { + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.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); } +} + +/* 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/mosquitto/acl b/mosquitto/acl deleted file mode 100644 index 9ea3086..0000000 --- a/mosquitto/acl +++ /dev/null @@ -1,25 +0,0 @@ -# backend service — subscribe to all network topic prefixes -user backend -topic readwrite meshcore/# -topic readwrite ukmesh/# -topic readwrite meshcore-test/# - -# teesside node observers — publish to meshcore/{IATA}/{PUBKEY}/packets -user node1 -topic write meshcore/# - - -user test -topic write meshcore-test/# - -user mrlm -topic write meshcore/# - -user jackster1337 -topic write meshcore/# - -user NE35 -topic write meshcore/# - -user Lorddc -topic write meshcore/# diff --git a/nginx.app.conf b/nginx.app.conf index d2188fd..ce72945 100644 --- a/nginx.app.conf +++ b/nginx.app.conf @@ -4,6 +4,15 @@ server { index index.html; resolver 127.0.0.11 valid=10s ipv6=off; + # Gzip compression for static assets + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; + gzip_min_length 256; + gzip_disable "MSIE [1-6]\.(?!.*SV1)"; + # WebSocket upgrade — must come before the /api block location /ws { set $backend_upstream http://backend:3000; diff --git a/viewshed-worker/worker.py b/viewshed-worker/worker.py index 864e55b..9b6125d 100644 --- a/viewshed-worker/worker.py +++ b/viewshed-worker/worker.py @@ -115,6 +115,19 @@ SUPPORT_CONTEXT = { 'updated_at': 0.0, } +UK_LAT_MIN = 49.5 +UK_LAT_MAX = 61.5 +UK_LON_MIN = -8.5 +UK_LON_MAX = 2.5 + + +def is_viewshed_eligible_coordinate(lat: float, lon: float) -> bool: + if not math.isfinite(lat) or not math.isfinite(lon): + return False + if abs(lat) < 1e-9 and abs(lon) < 1e-9: + return False + return UK_LAT_MIN <= lat <= UK_LAT_MAX and UK_LON_MIN <= lon <= UK_LON_MAX + def current_usable_path_loss_db() -> float: return float(RF_CALIBRATION['usable_path_loss_db']) @@ -212,10 +225,13 @@ def refresh_support_context(db, force: bool = False) -> None: FROM nodes WHERE lat IS NOT NULL AND lon IS NOT NULL + AND lat BETWEEN %s AND %s + AND lon BETWEEN %s AND %s + AND NOT (ABS(lat) < 1e-9 AND ABS(lon) < 1e-9) AND (name IS NULL OR name NOT LIKE %s) AND (role IS NULL OR role = 2) ''', - ('%🚫%',), + (UK_LAT_MIN, UK_LAT_MAX, UK_LON_MIN, UK_LON_MAX, '%🚫%',), ) repeater_rows = cur.fetchall() cur.execute( @@ -639,6 +655,9 @@ def sample_elevation(vrt_path: str, lat: float, lon: float) -> float: # ── Viewshed calculation ────────────────────────────────────────────────────── def calculate_viewshed(node_id: str, lat: float, lon: float) -> Optional[tuple[dict, dict[str, dict], float, float]]: + if not is_viewshed_eligible_coordinate(lat, lon): + log.info(f'Skipping viewshed for {node_id[:12]}… outside UK coverage bounds at ({lat:.4f}, {lon:.4f})') + return None with tempfile.TemporaryDirectory() as tmp: # 1. Download the observer's own tile and sample terrain elevation. # This single tile is sufficient to determine node height; we need @@ -1093,10 +1112,13 @@ def enqueue_uncovered(db, r_client): FROM nodes n LEFT JOIN node_coverage nc ON n.node_id = nc.node_id WHERE n.lat IS NOT NULL AND n.lon IS NOT NULL + AND n.lat BETWEEN %s AND %s + AND n.lon BETWEEN %s AND %s + AND NOT (ABS(n.lat) < 1e-9 AND ABS(n.lon) < 1e-9) AND (nc.node_id IS NULL OR nc.model_version < %s) AND (n.name IS NULL OR n.name NOT LIKE %s) AND (n.role IS NULL OR n.role = 2) - ''', (COVERAGE_MODEL_VERSION, '%🚫%',)) + ''', (UK_LAT_MIN, UK_LAT_MAX, UK_LON_MIN, UK_LON_MAX, COVERAGE_MODEL_VERSION, '%🚫%',)) rows = cur.fetchall() if rows: log.info(f'Queuing {len(rows)} existing node(s) for viewshed calculation (model v{COVERAGE_MODEL_VERSION})') @@ -1128,6 +1150,9 @@ def process_job(db, r_client, job: dict): lat = float(job['lat']) lon = float(job['lon']) try: + if not is_viewshed_eligible_coordinate(lat, lon): + log.info(f'Skipping out-of-UK viewshed job {node_id[:12]}… at ({lat:.4f}, {lon:.4f})') + return # Skip hidden (🚫) or non-repeater nodes regardless of how the job arrived with db.cursor() as cur: cur.execute('SELECT name, role FROM nodes WHERE node_id = %s', (node_id,))