Add repeater search page, UK bounds filter, and performance improvements

- New /repeater route with search and detailed repeater cards
- UK geographic bounds filter for viewshed calculations
- Advert packets API endpoint (GET /api/nodes/:id/adverts)
- Battery calculation fix (3.0V-4.2V range)
- NodeMarker optimizations (CircleMarker for repeaters)
- Map z-index adjustments for link lines
- Database performance indexes
- Nginx gzip compression
- Exclude mosquitto/acl and multipath.md from git

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ben
2026-03-14 22:31:10 +00:00
co-authored by Claude Opus 4.6
parent 6242fbaa98
commit ad4ce0dbb1
18 changed files with 1093 additions and 52 deletions
+2
View File
@@ -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
+20 -3
View File
@@ -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<string[]> {
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;
+16
View File
@@ -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\'';
+5
View File
@@ -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 (
+5 -2
View File
@@ -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);
}
});
+9
View File
@@ -532,6 +532,10 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
} else if (decoded.payloadType === 7) {
const inner = decodedInner as unknown as Record<string, unknown> | 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<string, unknown> | undefined;
srcNodeId = inner?.['origin_id'] as string | undefined;
}
}
} catch {
@@ -556,6 +560,11 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
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;
}
+13
View File
@@ -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<void> {
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
+3 -1
View File
@@ -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<LivePacket>, scope);
const packet = msg.data as Partial<LivePacket>;
const matchesScope = packetMatchesScope(packet, scope);
return matchesScope;
}
if (msg.type === 'node_update') {
+2 -2
View File
@@ -718,7 +718,7 @@ export const MapView = React.memo(({
{/* Confirmed link lines — ITM-viable node pairs */}
{effectiveShowLinks && linkLines.length > 0 && (
<Pane name="linksPane" style={{ zIndex: 400 }}>
<Pane name="linksPane" style={{ zIndex: 650 }}>
{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 && (
<Pane name="hexClashPane" style={{ zIndex: 405 }}>
<Pane name="hexClashPane" style={{ zIndex: 660 }}>
{visibleClashPathLines.map((line) => (
<Polyline
key={line.key}
+71 -17
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { Circle, Marker, Popup, Polygon, Pane } from 'react-leaflet';
import { Circle, CircleMarker, Marker, Popup, Polygon, Pane } from 'react-leaflet';
import L from 'leaflet';
import type { LatLngExpression } from 'leaflet';
import type { MeshNode } from '../../hooks/useNodes.js';
@@ -78,6 +78,10 @@ function roleZIndexOffset(role: number | undefined): number {
return 300;
}
function isRepeaterNode(role: number | undefined): boolean {
return role === undefined || role === 2;
}
function ringToLatLng(ring: number[][]): LatLngExpression[] {
return ring.map(([lon, lat]) => [lat, lon] as LatLngExpression);
}
@@ -172,26 +176,75 @@ export const NodeMarker: React.FC<Props> = 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 = (
<div className="node-popup">
<div className="node-popup__name">{displayName}</div>
{node.public_key && (
<div className="node-popup__row">
<span>Public key</span>
<span className="node-popup__mono">{node.public_key}</span>
</div>
)}
<div className="node-popup__row">
<span>Status</span>
<span style={{ color: statusColor }}>{statusLabel}</span>
</div>
<div className="node-popup__row">
<span>Position</span>
<span>{prohibited ? 'Redacted' : `${lat.toFixed(5)}, ${lon.toFixed(5)}`}</span>
</div>
{prohibited && (
<div className="node-popup__row">
<span>Location</span>
<span>Redacted within 1 mile radius</span>
</div>
)}
</div>
);
return (
<>
<Marker
position={[lat, lon]}
icon={buildIcon(node.is_online, isActive || isHighlighted, isStale, variant, markerSize, isRestoring, hexClashState)}
zIndexOffset={roleZIndexOffset(node.role)}
>
<Popup eventHandlers={{
add: () => {
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
<CircleMarker
center={[lat, lon]}
radius={3}
pathOptions={{
color: isStale ? '#ff4444' : (node.is_online ? '#00c4ff' : '#666'),
fillColor: isStale ? '#ff4444' : (node.is_online ? '#00c4ff' : '#888'),
fillOpacity: 0.7,
weight: 1,
}}
>
<Popup>{repeaterPopupContent}</Popup>
</CircleMarker>
) : (
<Marker
position={[lat, lon]}
icon={buildIcon(node.is_online, isActive || isHighlighted, isStale, variant, markerSize, isRestoring, hexClashState)}
zIndexOffset={roleZIndexOffset(node.role)}
>
<Popup eventHandlers={{
add: () => {
if (links !== null) return; // already fetched
fetch(`/api/nodes/${node.node_id}/links`)
.then((r) => r.json())
.then((data: NodeLink[]) => setLinks(data))
.catch(() => setLinks([]));
},
}}>
<div className="node-popup">
<div className="node-popup__name">{displayName}</div>
{node.public_key && (
<div className="node-popup__row">
<span>Public key</span>
<span className="node-popup__mono">{node.public_key}</span>
</div>
)}
{node.role !== undefined && node.role !== 2 && (
<div className="node-popup__row">
<span>Type</span>
@@ -304,7 +357,8 @@ export const NodeMarker: React.FC<Props> = React.memo(({
)}
</div>
</Popup>
</Marker>
</Marker>
)}
{prohibited && (
<Circle
+2
View File
@@ -13,6 +13,7 @@ import { UKLayout } from './pages/ukmesh/UKLayout.js';
import { UKHomePage } from './pages/ukmesh/UKHomePage.js';
import { UKInstallPage } from './pages/ukmesh/UKInstallPage.js';
import { UKFeedPage } from './pages/ukmesh/UKFeedPage.js';
import { UKRepeaterSearchPage } from './pages/ukmesh/UKRepeaterSearchPage.js';
import { DevLayout } from './pages/dev/DevLayout.js';
import { DevHomePage } from './pages/dev/DevHomePage.js';
import { getCurrentSite } from './config/site.js';
@@ -47,6 +48,7 @@ ReactDOM.createRoot(root).render(
<Route element={<UKLayout />}>
<Route index element={<UKHomePage />} />
<Route path="feed" element={<UKFeedPage />} />
<Route path="repeater" element={<UKRepeaterSearchPage />} />
<Route path="about" element={<Navigate to="/" replace />} />
<Route path="install" element={<UKInstallPage />} />
<Route path="mqtt" element={<Navigate to="/install" replace />} />
+3
View File
@@ -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<SiteLayoutProps> = ({
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<SiteLayoutProps> = ({
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 },
+1
View File
@@ -10,6 +10,7 @@ export const UKLayout: React.FC = () => {
footerName={site.footerName}
appUrl={site.appUrl}
showFeed
showRepeaterSearch
showAbout={false}
showMqtt={false}
showHealth={false}
@@ -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<T extends { time: string; packet_hash: string }>(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<string, T>();
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<MeshNode[]>([]);
const [selectedNode, setSelectedNode] = useState<MeshNode | null>(null);
const [links, setLinks] = useState<NodeLink[]>([]);
const [history, setHistory] = useState<PacketHistory[]>([]);
const [adverts, setAdverts] = useState<AdvertPacket[]>([]);
const [loadingDetails, setLoadingDetails] = useState(false);
const [copiedKey, setCopiedKey] = useState(false);
const searchRef = useRef<HTMLDivElement>(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 (
<>
<section className="site-page-hero">
<div className="site-content">
<h1>Repeater Search</h1>
<p>Search for a repeater by name or public key to view detailed information.</p>
</div>
</section>
<section className="site-section">
<div className="site-content">
<div className="repeater-search-box" ref={searchRef}>
<input
type="text"
value={searchQuery}
onChange={(e) => { 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 && (
<div className="repeater-search-box__results">
{searchQuery && searchResults.length === 0 ? (
<div className="repeater-search-box__no-results">
No repeaters found matching "{searchQuery}"
</div>
) : (
searchResults.map(node => (
<button
key={node.node_id}
className="repeater-search-box__result"
onClick={() => selectNode(node)}
>
<span className="repeater-search-box__result-name">{node.name || 'Unknown'}</span>
<span className="repeater-search-box__result-meta">
{node.iata ? `${node.iata} · ` : ''}{node.public_key?.slice(0, 16)}... · {node.is_online ? 'Online' : 'Offline'}
</span>
</button>
))
)}
{searchResults.length > 0 && (
<div className="repeater-search-box__count">
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
</div>
)}
</div>
)}
</div>
{!selectedNode ? (
<div className="repeater-details-card">
<div className="repeater-details-card__empty">
<svg className="repeater-details-card__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<h3>Select a Repeater</h3>
<p>Search for a repeater above to view its details, neighbours, and packet history.</p>
</div>
</div>
) : (
<div className="repeater-details-card">
<div className="repeater-details-card__header">
<h2>{selectedNode.name || 'Unknown Repeater'}</h2>
<span className={`repeater-details-card__status ${selectedNode.is_online ? 'repeater-details-card__status--online' : 'repeater-details-card__status--offline'}`}>
{selectedNode.is_online ? 'Online' : 'Offline'}
</span>
</div>
<div className="repeater-details-card__section">
<h3>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="repeater-details-card__section-icon">
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
Details
</h3>
<div className="site-stats-grid">
<div className="site-stat">
<span className="site-stat__label">Public Key</span>
<span className="site-stat__value" style={{ fontSize: '11px', wordBreak: 'break-all' as const }}>
{selectedNode.public_key || 'N/A'}
</span>
{selectedNode.public_key && (
<button
className="repeater-details-card__copy-btn"
onClick={copyPublicKey}
>
{copiedKey ? (
<>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="repeater-details-card__copy-icon">
<polyline points="20 6 9 17 4 12" />
</svg>
Copied!
</>
) : (
<>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="repeater-details-card__copy-icon">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Copy
</>
)}
</button>
)}
</div>
<div className="site-stat">
<span className="site-stat__label">Position</span>
<span className="site-stat__value">
{selectedNode.lat && selectedNode.lon
? (
<>
{selectedNode.lat.toFixed(5)}<br />
{selectedNode.lon.toFixed(5)}
</>
)
: 'Unknown'}
</span>
</div>
<div className="site-stat">
<span className="site-stat__label">Elevation</span>
<span className="site-stat__value">
{selectedNode.elevation_m !== undefined && selectedNode.elevation_m !== null
? `${Math.round(selectedNode.elevation_m)} m`
: 'N/A'}
</span>
</div>
<div className="site-stat">
<span className="site-stat__label">Network</span>
<span className="site-stat__value">{selectedNode.iata || 'N/A'}</span>
</div>
<div className="site-stat">
<span className="site-stat__label">Hardware</span>
<span className="site-stat__value">{selectedNode.hardware_model || 'Unknown'}</span>
</div>
<div className="site-stat">
<span className="site-stat__label">Last Seen</span>
<span className="site-stat__value">{timeAgo(selectedNode.last_seen)}</span>
</div>
<div className="site-stat">
<span className="site-stat__label">Advert Count</span>
<span className="site-stat__value">{selectedNode.advert_count?.toLocaleString() || '0'}</span>
</div>
{prediction && (
<div className="site-stat">
<span className="site-stat__label">Predicted Next Advert</span>
<span className="site-stat__value">
{prediction.samples >= 3 ? formatTimeUntil(prediction.nextAdvert) : 'Collecting data...'}
</span>
<span className="site-stat__hash">
~{formatInterval(prediction.avgInterval)} interval ({prediction.samples} samples{ prediction.samples < 3 ? ' - need 3+' : '' })
</span>
</div>
)}
</div>
</div>
{loadingDetails ? (
<div className="repeater-details-card__loading">
<div className="repeater-details-card__spinner"></div>
Loading details...
</div>
) : (
<>
<div className="repeater-details-card__section">
<h3>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="repeater-details-card__section-icon">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Confirmed Neighbours {links.length > 0 && <span className="repeater-details-card__count-badge">{links.length}</span>}
</h3>
{links.length === 0 ? (
<p className="repeater-details-card__empty-msg">No neighbours found for this node.</p>
) : (
<>
<p className="repeater-details-card__desc">Nodes with confirmed two-way communication</p>
<div className="repeater-details-card__neighbours">
{links.map(link => (
<div key={link.peer_id} className="repeater-details-card__neighbour">
<div className="repeater-details-card__neighbour-main">
<span className="repeater-details-card__neighbour-name">
{link.peer_name || `${link.peer_id.slice(0, 12)}...`}
</span>
<span className="repeater-details-card__neighbour-id">
{link.peer_id}
</span>
</div>
<div className="repeater-details-card__neighbour-stats">
<span>Seen {link.observed_count}×</span>
{link.itm_path_loss_db !== null && (
<span> · {Math.round(link.itm_path_loss_db)} dB loss</span>
)}
<span> · TX: {link.count_this_to_peer}</span>
<span> · RX: {link.count_peer_to_this}</span>
</div>
</div>
))}
</div>
</>
)}
</div>
<div className="repeater-details-card__section">
<h3>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="repeater-details-card__section-icon">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
Recent Packets {history.length > 0 && <span className="repeater-details-card__count-badge">{history.length}</span>}
</h3>
{history.length === 0 ? (
<p className="repeater-details-card__empty-msg">No packet history available for this node.</p>
) : (
<>
<p className="repeater-details-card__desc">Last 24 hours of packet activity</p>
<div className="repeater-details-card__table-wrap">
<table className="repeater-details-card__table">
<thead>
<tr>
<th>Time</th>
<th>Hops</th>
<th>RSSI</th>
<th>SNR</th>
<th>From</th>
</tr>
</thead>
<tbody>
{history.slice(0, 50).map((pkt, idx) => (
<tr key={idx}>
<td>{timeAgo(pkt.time)}</td>
<td>{pkt.hop_count ?? '-'}</td>
<td>{pkt.rssi ?? '-'}</td>
<td>{pkt.snr ?? '-'}</td>
<td>{pkt.src_node_id?.slice(0, 12) || '-'}...</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
</>
)}
</div>
)}
</div>
</section>
</>
);
};
+420
View File
@@ -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;
}
-25
View File
@@ -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/#
+9
View File
@@ -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;
+27 -2
View File
@@ -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,))