mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-09-16 03:55:34 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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\'';
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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/#
|
||||
@@ -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;
|
||||
|
||||
@@ -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,))
|
||||
|
||||
Reference in New Issue
Block a user