Add owner auth DB, website refresh, and coverage rollback

This commit is contained in:
Ben
2026-03-07 03:23:52 +00:00
parent 729a966e6a
commit 05514fdc18
20 changed files with 1638 additions and 664 deletions
+1
View File
@@ -12,5 +12,6 @@ COPY backend/package.json ./
RUN npm install --omit=dev && npm cache clean --force
COPY --from=backend-builder /build/backend/dist ./dist
COPY --from=backend-builder /build/backend/src/db/schema.sql ./dist/db/schema.sql
COPY --from=backend-builder /build/backend/src/db/owner-auth.sql ./dist/db/owner-auth.sql
EXPOSE 3000
CMD ["node", "dist/index.js"]
+168 -13
View File
@@ -3,6 +3,7 @@ import { rateLimit } from 'express-rate-limit';
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import mqtt from 'mqtt';
import { getNodes, getNodeHistory, getRecentPackets, query, MIN_LINK_OBSERVATIONS } from '../db/index.js';
import { getOwnerNodeIdsForUsername } from '../db/ownerAuth.js';
import { getWorkerHealthOverview } from '../health/status.js';
import { resolveBetaPathForPacketHash } from '../path-beta/resolver.js';
@@ -20,6 +21,7 @@ const OWNER_LOGIN_LIMITER = rateLimit({
type OwnerSession = {
nodeIds: string[];
exp: number;
mqttUsername?: string;
};
function getOwnerCookieKey(): Buffer {
@@ -60,7 +62,8 @@ function decryptOwnerSession(token: string): OwnerSession | null {
.map((value) => String(value).trim().toLowerCase())
.filter((value) => /^[0-9a-f]{64}$/.test(value));
if (nodeIds.length < 1) return null;
return { nodeIds, exp: parsed.exp };
const mqttUsername = typeof parsed.mqttUsername === 'string' ? parsed.mqttUsername.trim() : undefined;
return { nodeIds, exp: parsed.exp, mqttUsername: mqttUsername || undefined };
} catch {
return null;
}
@@ -100,6 +103,13 @@ function parseOwnerMqttUsernameMap(): Map<string, string[]> {
return map;
}
async function resolveOwnerNodeIds(mqttUsername: string): Promise<string[]> {
const databaseNodeIds = await getOwnerNodeIdsForUsername(mqttUsername);
if (databaseNodeIds.length > 0) return databaseNodeIds;
const legacyMap = parseOwnerMqttUsernameMap();
return legacyMap.get(mqttUsername) ?? [];
}
function verifyMqttCredentials(mqttUsername: string, mqttPassword: string): Promise<boolean> {
const brokerUrl = String(process.env['MQTT_BROKER_URL'] ?? 'ws://mosquitto:9001');
const clientId = `owner-auth-${randomBytes(6).toString('hex')}`;
@@ -379,7 +389,7 @@ router.get('/coverage', async (req, res) => {
const network = req.query['network'] as string | undefined;
const filters = networkFilters(network);
const result = await query(
`SELECT nc.node_id, nc.geom, nc.antenna_height_m, nc.radius_m, nc.calculated_at
`SELECT nc.node_id, nc.geom, nc.strength_geoms, nc.antenna_height_m, nc.radius_m, nc.calculated_at
FROM node_coverage nc
JOIN nodes n ON n.node_id = nc.node_id
WHERE (n.name IS NULL OR n.name NOT LIKE '%🚫%')
@@ -618,13 +628,8 @@ router.post('/owner/login', OWNER_LOGIN_LIMITER, async (req, res) => {
res.status(400).json({ error: 'Missing MQTT username or password' });
return;
}
const usernameMap = parseOwnerMqttUsernameMap();
if (usernameMap.size < 1) {
res.status(503).json({ error: 'Owner login is not configured on the server' });
return;
}
const mappedNodeIds = usernameMap.get(mqttUsername);
if (!mappedNodeIds || mappedNodeIds.length < 1) {
const mappedNodeIds = await resolveOwnerNodeIds(mqttUsername);
if (mappedNodeIds.length < 1) {
res.status(403).json({ error: 'Invalid MQTT credentials' });
return;
}
@@ -644,6 +649,7 @@ router.post('/owner/login', OWNER_LOGIN_LIMITER, async (req, res) => {
const token = encryptOwnerSession({
nodeIds: mappedNodeIds,
exp: Date.now() + OWNER_SESSION_TTL_MS,
mqttUsername,
});
res.cookie(OWNER_COOKIE_NAME, token, {
httpOnly: true,
@@ -662,7 +668,13 @@ router.post('/owner/login', OWNER_LOGIN_LIMITER, async (req, res) => {
// GET /api/owner/session — resolve dashboard from encrypted cookie
router.get('/owner/session', async (req, res) => {
try {
const sessionNodeIds = await requireOwnerSession(req, res);
const session = getOwnerSession(req);
if (!session) {
res.clearCookie(OWNER_COOKIE_NAME, { path: '/' });
res.status(401).json({ error: 'Not logged in' });
return;
}
const sessionNodeIds = session.nodeIds;
if (!sessionNodeIds) return;
const dashboard = await buildOwnerDashboard(sessionNodeIds);
@@ -672,7 +684,7 @@ router.get('/owner/session', async (req, res) => {
return;
}
res.json({ ok: true, dashboard });
res.json({ ok: true, dashboard, mqttUsername: session.mqttUsername ?? null });
} catch (err) {
console.error('[api] GET /owner/session', (err as Error).message);
res.status(500).json({ error: 'Internal server error' });
@@ -698,7 +710,14 @@ router.get('/owner/live', async (req, res) => {
return;
}
const [ownerNodeResult, incomingResult, packetResult] = await Promise.all([
const [
ownerNodeResult,
incomingResult,
packetResult,
heardByResult,
linkHealthResult,
advertTrendResult,
] = await Promise.all([
query<{
node_id: string;
name: string | null;
@@ -711,7 +730,7 @@ router.get('/owner/live', async (req, res) => {
}>(
`SELECT node_id, name, network, iata, advert_count, last_seen, lat, lon
FROM nodes
WHERE node_id = $1
WHERE LOWER(node_id) = LOWER($1)
LIMIT 1`,
[selectedNodeId],
),
@@ -807,6 +826,90 @@ router.get('/owner/live', async (req, res) => {
LIMIT 5`,
[selectedNodeId],
),
query<{
node_id: string;
name: string | null;
network: string | null;
iata: string | null;
lat: number | null;
lon: number | null;
packets_24h: number;
packets_7d: number;
last_seen: string | null;
best_hops: number | null;
}>(
`SELECT
p.rx_node_id AS node_id,
n.name,
n.network,
n.iata,
n.lat,
n.lon,
COUNT(DISTINCT CASE WHEN p.time > NOW() - INTERVAL '24 hours' THEN p.packet_hash END)::int AS packets_24h,
COUNT(DISTINCT p.packet_hash)::int AS packets_7d,
MAX(p.time)::text AS last_seen,
MIN(p.hop_count) AS best_hops
FROM packets p
LEFT JOIN nodes n ON LOWER(n.node_id) = LOWER(p.rx_node_id)
WHERE LOWER(p.src_node_id) = LOWER($1)
AND p.rx_node_id IS NOT NULL
AND LOWER(p.rx_node_id) <> LOWER($1)
AND p.time > NOW() - INTERVAL '7 days'
GROUP BY p.rx_node_id, n.name, n.network, n.iata, n.lat, n.lon
ORDER BY packets_24h DESC, packets_7d DESC, last_seen DESC
LIMIT 20`,
[selectedNodeId],
),
query<{
peer_node_id: string;
peer_name: string | null;
peer_network: string | null;
owner_to_peer: number;
peer_to_owner: number;
observed_count: number;
itm_path_loss_db: number | null;
itm_viable: boolean | null;
force_viable: boolean;
last_observed: string | null;
}>(
`SELECT
CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.node_b_id ELSE nl.node_a_id END AS peer_node_id,
peer.name AS peer_name,
peer.network AS peer_network,
CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.count_a_to_b ELSE nl.count_b_to_a END AS owner_to_peer,
CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.count_b_to_a ELSE nl.count_a_to_b END AS peer_to_owner,
nl.observed_count,
nl.itm_path_loss_db,
nl.itm_viable,
nl.force_viable,
nl.last_observed::text AS last_observed
FROM node_links nl
JOIN nodes peer ON LOWER(peer.node_id) = LOWER(CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.node_b_id ELSE nl.node_a_id END)
WHERE LOWER(nl.node_a_id) = LOWER($1)
OR LOWER(nl.node_b_id) = LOWER($1)
ORDER BY
COALESCE(nl.itm_viable, false) DESC,
nl.force_viable DESC,
nl.observed_count DESC,
nl.itm_path_loss_db ASC NULLS LAST
LIMIT 12`,
[selectedNodeId],
),
query<{
bucket: string;
adverts: number;
}>(
`SELECT
time_bucket('1 hour', time)::text AS bucket,
COUNT(DISTINCT packet_hash)::int AS adverts
FROM packets
WHERE LOWER(src_node_id) = LOWER($1)
AND packet_type = 4
AND time > NOW() - INTERVAL '24 hours'
GROUP BY bucket
ORDER BY bucket`,
[selectedNodeId],
),
]);
const ownerNode = ownerNodeResult.rows[0];
@@ -815,6 +918,54 @@ router.get('/owner/live', async (req, res) => {
return;
}
const heardBy = heardByResult.rows.map((row) => ({
...row,
packets_24h: Number(row.packets_24h ?? 0),
packets_7d: Number(row.packets_7d ?? 0),
best_hops: row.best_hops == null ? null : Number(row.best_hops),
last_seen: row.last_seen ? new Date(row.last_seen).toISOString() : null,
}));
const linkHealth = linkHealthResult.rows.map((row) => ({
...row,
owner_to_peer: Number(row.owner_to_peer ?? 0),
peer_to_owner: Number(row.peer_to_owner ?? 0),
observed_count: Number(row.observed_count ?? 0),
itm_path_loss_db: row.itm_path_loss_db == null ? null : Number(row.itm_path_loss_db),
itm_viable: row.itm_viable == null ? null : Boolean(row.itm_viable),
force_viable: Boolean(row.force_viable),
last_observed: row.last_observed ? new Date(row.last_observed).toISOString() : null,
}));
const advertTrend24h = (() => {
const byHour = new Map<string, number>();
for (const row of advertTrendResult.rows) {
byHour.set(new Date(row.bucket).toISOString(), Number(row.adverts ?? 0));
}
const series: Array<{ bucket: string; adverts: number }> = [];
const now = new Date();
now.setUTCMinutes(0, 0, 0);
for (let i = 23; i >= 0; i--) {
const bucket = new Date(now.getTime() - i * 60 * 60 * 1000).toISOString();
series.push({ bucket, adverts: byHour.get(bucket) ?? 0 });
}
return series;
})();
const alerts: Array<{ level: 'info' | 'warn' | 'error'; message: string }> = [];
const ownerLastSeenMs = ownerNode.last_seen ? new Date(ownerNode.last_seen).getTime() : 0;
const minsSinceSeen = ownerLastSeenMs ? Math.max(0, Math.round((Date.now() - ownerLastSeenMs) / 60000)) : null;
const adverts24h = advertTrend24h.reduce((sum, point) => sum + point.adverts, 0);
const viableLinks = linkHealth.filter((link) => link.itm_viable || link.force_viable);
if (minsSinceSeen == null) alerts.push({ level: 'error', message: 'No last-seen timestamp is available for this repeater.' });
else if (minsSinceSeen >= 120) alerts.push({ level: 'error', message: `Repeater has not been seen for ${minsSinceSeen} minutes.` });
else if (minsSinceSeen >= 30) alerts.push({ level: 'warn', message: `Repeater has been quiet for ${minsSinceSeen} minutes.` });
else alerts.push({ level: 'info', message: 'Repeater is active and has checked in recently.' });
if (adverts24h < 1) alerts.push({ level: 'warn', message: 'No advert packets from this repeater were recorded in the last 24 hours.' });
if (heardBy.length < 1) alerts.push({ level: 'warn', message: 'No other nodes have heard this repeater in the last 7 days.' });
if (viableLinks.length < 1) alerts.push({ level: 'warn', message: 'No viable RF links are currently stored for this repeater.' });
res.json({
nodeId: selectedNodeId,
ownerNode: {
@@ -827,6 +978,10 @@ router.get('/owner/live', async (req, res) => {
packets_24h: Number(row.packets_24h ?? 0),
last_seen: row.last_seen ? new Date(row.last_seen).toISOString() : null,
})),
heardBy,
linkHealth,
advertTrend24h,
alerts,
recentPackets: packetResult.rows.map((row) => ({
...row,
time: new Date(row.time).toISOString(),
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS owner_accounts (
mqtt_username TEXT PRIMARY KEY,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS owner_account_nodes (
mqtt_username TEXT NOT NULL REFERENCES owner_accounts(mqtt_username) ON DELETE CASCADE,
node_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (mqtt_username, node_id)
);
CREATE INDEX IF NOT EXISTS owner_account_nodes_node_idx
ON owner_account_nodes(node_id);
+96
View File
@@ -0,0 +1,96 @@
import pg from 'pg';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const { Pool } = pg;
const OWNER_DB_NAME = process.env['OWNER_POSTGRES_DB'] ?? 'meshcore_owner_auth';
function getPrimaryDatabaseUrl(): string {
const raw = String(process.env['DATABASE_URL'] ?? '').trim();
if (!raw) throw new Error('DATABASE_URL is required');
return raw;
}
function withDatabaseName(connectionString: string, databaseName: string): string {
const url = new URL(connectionString);
url.pathname = `/${databaseName}`;
return url.toString();
}
function getOwnerDatabaseUrl(): string {
return String(process.env['OWNER_DATABASE_URL'] ?? '').trim()
|| withDatabaseName(getPrimaryDatabaseUrl(), OWNER_DB_NAME);
}
function getAdminDatabaseUrl(): string {
return withDatabaseName(getPrimaryDatabaseUrl(), 'postgres');
}
const ownerPool = new Pool({
connectionString: getOwnerDatabaseUrl(),
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
ownerPool.on('error', (err) => {
console.error('[owner-auth] unexpected pool error', err.message);
});
function quoteIdentifier(value: string): string {
return `"${value.replace(/"/g, '""')}"`;
}
async function ensureOwnerDatabase(): Promise<void> {
const ownerUrl = new URL(getOwnerDatabaseUrl());
const databaseName = ownerUrl.pathname.replace(/^\//, '').trim();
if (!databaseName) throw new Error('OWNER_DATABASE_URL is missing a database name');
const adminPool = new Pool({
connectionString: getAdminDatabaseUrl(),
max: 1,
idleTimeoutMillis: 5_000,
connectionTimeoutMillis: 5_000,
});
try {
const exists = await adminPool.query<{ exists: number }>(
'SELECT 1 AS exists FROM pg_database WHERE datname = $1',
[databaseName],
);
if (exists.rowCount && exists.rows[0]?.exists === 1) return;
await adminPool.query(`CREATE DATABASE ${quoteIdentifier(databaseName)}`);
console.log(`[owner-auth] created database ${databaseName}`);
} finally {
await adminPool.end().catch(() => undefined);
}
}
export async function initOwnerAuthDb(): Promise<void> {
await ensureOwnerDatabase();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const schemaPath = path.join(__dirname, 'owner-auth.sql');
const sql = fs.readFileSync(schemaPath, 'utf8');
await ownerPool.query(sql);
console.log('[owner-auth] schema initialised');
}
export async function getOwnerNodeIdsForUsername(mqttUsername: string): Promise<string[]> {
const normalized = mqttUsername.trim();
if (!normalized) return [];
const res = await ownerPool.query<{ node_id: string }>(
`SELECT node_id
FROM owner_account_nodes oan
JOIN owner_accounts oa ON oa.mqtt_username = oan.mqtt_username
WHERE oa.is_active = true
AND oan.mqtt_username = $1
ORDER BY oan.created_at ASC`,
[normalized],
);
return Array.from(new Set(
res.rows
.map((row) => row.node_id.trim().toLowerCase())
.filter((nodeId) => /^[0-9a-f]{64}$/.test(nodeId)),
));
}
+4
View File
@@ -143,10 +143,14 @@ ALTER TABLE node_links ADD COLUMN IF NOT EXISTS force_viable BOOLEAN NOT NULL
CREATE TABLE IF NOT EXISTS node_coverage (
node_id TEXT PRIMARY KEY,
geom JSONB NOT NULL, -- GeoJSON Polygon or MultiPolygon
strength_geoms JSONB,
antenna_height_m DOUBLE PRECISION DEFAULT 10,
radius_m DOUBLE PRECISION DEFAULT 30000,
model_version INTEGER NOT NULL DEFAULT 1,
calculated_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE node_coverage ADD COLUMN IF NOT EXISTS strength_geoms JSONB;
ALTER TABLE node_coverage ADD COLUMN IF NOT EXISTS model_version INTEGER NOT NULL DEFAULT 1;
-- ─── Learned path priors from historical packets ─────────────────────────────
+8 -3
View File
@@ -4,6 +4,7 @@ import express from 'express';
import cors from 'cors';
import { rateLimit } from 'express-rate-limit';
import { initDb, query } from './db/index.js';
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';
@@ -15,10 +16,12 @@ const ALLOWED_ORIGINS = (process.env['ALLOWED_ORIGINS'] ?? '')
.filter(Boolean);
const PORT = Number(process.env['PORT'] ?? 3000);
const COVERAGE_MODEL_VERSION = Number(process.env['COVERAGE_MODEL_VERSION'] ?? 3);
async function main() {
// 1. Initialise DB schema + retention policy
await initDb();
await initOwnerAuthDb();
// Queue viewshed jobs for any node with a position but no coverage yet
// (catches nodes that existed before the worker was added)
@@ -26,12 +29,14 @@ async function main() {
const uncovered = await query<{ node_id: string; lat: number; lon: number }>(
`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 nc.node_id IS NULL
WHERE n.lat IS NOT NULL AND n.lon IS NOT NULL
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)`
AND (n.role IS NULL OR n.role = 2)`,
[COVERAGE_MODEL_VERSION],
);
if (uncovered.rows.length > 0) {
console.log(`[app] queuing ${uncovered.rows.length} node(s) for viewshed`);
console.log(`[app] queuing ${uncovered.rows.length} node(s) for viewshed (model v${COVERAGE_MODEL_VERSION})`);
// Jobs are pushed here but the Redis pub client isn't ready yet —
// defer until after initWebSocketServer wires up the Redis client.
process.nextTick(() => {
+4
View File
@@ -50,9 +50,11 @@ services:
OPENTOPODATA_API: ${OPENTOPODATA_API:-https://api.opentopodata.org}
PORT: ${PORT:-3000}
MESHCORE_CHANNEL_SECRETS: ${MESHCORE_CHANNEL_SECRETS:-}
OWNER_DATABASE_URL: ${OWNER_DATABASE_URL:-postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${OWNER_POSTGRES_DB:-meshcore_owner_auth}}
OWNER_MQTT_USERNAME_MAP: ${OWNER_MQTT_USERNAME_MAP:-}
OWNER_COOKIE_SECRET: ${OWNER_COOKIE_SECRET:-}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3001,http://localhost:3002}
COVERAGE_MODEL_VERSION: ${COVERAGE_MODEL_VERSION:-2}
NODE_ENV: production
depends_on:
timescaledb:
@@ -451,6 +453,8 @@ services:
SRTM_DIR: /data/srtm
WORKER_MODE: viewshed
NUM_WORKERS: '1'
COVERAGE_MODEL: ${COVERAGE_MODEL:-terrain_los}
COVERAGE_MODEL_VERSION: ${COVERAGE_MODEL_VERSION:-2}
volumes:
- srtm_data:/data/srtm
deploy:
+4 -1
View File
@@ -62,7 +62,10 @@ export const LiveStatsSection: React.FC<LiveStatsSectionProps> = ({ network }) =
return (
<section className="site-stats-section">
<div className="site-content">
<p className="site-stats-section__eyebrow">Live network stats · updates every 30s</p>
<div className="site-section__head">
<h2>Live network stats</h2>
<p>Updates every 30 seconds from the shared packet feed.</p>
</div>
<div className="site-stats-grid">
<StatCard value={stats.packetsDay} label="Packets in the last 24 hours" />
<StatCard value={stats.totalNodes} label="Nodes ever heard on the network" />
+16 -12
View File
@@ -48,11 +48,17 @@ const LeafletDeckSyncer: React.FC<SyncerProps> = ({ onViewStateChange }) => {
return null;
};
// GeoJSON rings are [lon, lat]; Leaflet wants [lat, lon].
function ringToLatLng(ring: number[][]): LatLngExpression[] {
return ring.map(([lon, lat]) => [lat, lon] as LatLngExpression);
}
function geomToRings(geom: { type: string; coordinates: unknown } | null | undefined): LatLngExpression[][] {
if (!geom) return [];
if (geom.type === 'Polygon') return [ringToLatLng((geom.coordinates as number[][][])[0])];
if (geom.type === 'MultiPolygon') return (geom.coordinates as number[][][][]).map((poly) => ringToLatLng(poly[0]));
return [];
}
function hasCoords(node: MeshNode | null | undefined): node is MeshNode & { lat: number; lon: number } {
return typeof node?.lat === 'number' && typeof node?.lon === 'number';
}
@@ -67,13 +73,11 @@ function isHiddenMapNode(node: MeshNode | null | undefined): boolean {
// - no opacity stacking: single SVG <path> element, one fill pass ✓
function useCoverageDisplayRings(coverage: NodeCoverage[]): LatLngExpression[][] {
return useMemo(() => {
return coverage.flatMap((c) => {
if (c.geom.type === 'Polygon')
return [ringToLatLng((c.geom.coordinates as number[][][])[0])];
if (c.geom.type === 'MultiPolygon')
return (c.geom.coordinates as number[][][][]).map((poly) => ringToLatLng(poly[0]));
return [];
});
const rings: LatLngExpression[][] = [];
for (const c of coverage) {
rings.push(...geomToRings(c.geom));
}
return rings;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [coverage]);
}
@@ -519,10 +523,10 @@ export const MapView: React.FC<MapViewProps> = ({
<Polygon
positions={coverageRings as LatLngExpression[][]}
pathOptions={{
fillColor: '#1ec850',
fillOpacity: 0.22,
weight: 0,
fillRule: 'nonzero',
fillColor: '#22c55e',
fillOpacity: 0.18,
weight: 0,
fillRule: 'nonzero',
}}
interactive={false}
/>
+52 -20
View File
@@ -74,12 +74,16 @@ function ringToLatLng(ring: number[][]): LatLngExpression[] {
return ring.map(([lon, lat]) => [lat, lon] as LatLngExpression);
}
function coverageToRings(cov: NodeCoverage): LatLngExpression[][] {
const geom = cov.geom;
if (geom.type === 'Polygon')
return [(geom.coordinates as number[][][])[0]!].map(ringToLatLng);
if (geom.type === 'MultiPolygon')
return (geom.coordinates as number[][][][]).map((poly) => ringToLatLng(poly[0]!));
function coverageToPolygons(geom: { type: string; coordinates: unknown } | null | undefined): LatLngExpression[][][] {
if (!geom) return [];
if (geom.type === 'Polygon') {
const polygon = geom.coordinates as number[][][];
return [polygon.map((ring) => ringToLatLng(ring))];
}
if (geom.type === 'MultiPolygon') {
const multiPolygon = geom.coordinates as number[][][][];
return multiPolygon.map((polygon) => polygon.map((ring) => ringToLatLng(ring)));
}
return [];
}
@@ -141,7 +145,11 @@ export const NodeMarker: React.FC<Props> = React.memo(({
? 'var(--danger)'
: node.is_online ? 'var(--online)' : 'var(--offline)';
const previewRings = showPreview && nodeCoverage ? coverageToRings(nodeCoverage) : [];
const previewBands = showPreview && nodeCoverage ? {
red: coverageToPolygons(nodeCoverage.strength_geoms?.red ?? nodeCoverage.geom),
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';
return (
@@ -243,20 +251,44 @@ export const NodeMarker: React.FC<Props> = React.memo(({
</Popup>
</Marker>
{previewRings.length > 0 && (
{(previewBands.red.length > 0 || previewBands.amber.length > 0 || previewBands.green.length > 0) && (
<Pane name={`cov-preview-${node.node_id}`} style={{ zIndex: 351 }}>
<Polygon
positions={previewRings as LatLngExpression[][]}
pathOptions={{
fillColor: '#1ec850',
fillOpacity: 0.10,
weight: 1,
color: '#1ec850',
opacity: 0.5,
fillRule: 'nonzero',
}}
interactive={false}
/>
{previewBands.red.length > 0 && (
<Polygon
positions={previewBands.red as unknown as LatLngExpression[][]}
pathOptions={{
fillColor: '#ef4444',
fillOpacity: 0.12,
weight: 0,
fillRule: 'nonzero',
}}
interactive={false}
/>
)}
{previewBands.amber.length > 0 && (
<Polygon
positions={previewBands.amber as unknown as LatLngExpression[][]}
pathOptions={{
fillColor: '#f59e0b',
fillOpacity: 0.18,
weight: 0,
fillRule: 'nonzero',
}}
interactive={false}
/>
)}
{previewBands.green.length > 0 && (
<Polygon
positions={previewBands.green as unknown as LatLngExpression[][]}
pathOptions={{
fillColor: '#22c55e',
fillOpacity: 0.28,
weight: 0,
fillRule: 'nonzero',
}}
interactive={false}
/>
)}
</Pane>
)}
</>
@@ -22,10 +22,11 @@ export const DisclaimerModal: React.FC<DisclaimerModalProps> = ({ onClose }) =>
<section>
<h3>Coverage map</h3>
<p>
The green coverage layer is a radio horizon estimate computed from SRTM terrain data.
It assumes each repeater antenna is mounted <strong>5 metres above ground level</strong>.
Actual coverage will vary with antenna height, local obstacles, foliage, and radio
conditions. Treat it as a rough guide, not a guarantee of connectivity.
The green coverage layer is a precomputed RF estimate built from terrain data and a
simplified diffraction/path-loss model. It assumes the source repeater and the receiving
repeater are both mounted <strong>5 metres above ground level</strong>. Actual coverage
will still vary with local obstacles, foliage, antenna placement, and radio settings, so
treat it as a guide rather than a guarantee of connectivity.
</p>
</section>
</div>
@@ -33,4 +34,3 @@ export const DisclaimerModal: React.FC<DisclaimerModalProps> = ({ onClose }) =>
</div>
</div>
);
+10 -2
View File
@@ -21,7 +21,11 @@ type UseAppMessageHandlerParams = {
handlePacket: (data: LivePacketData) => void;
handleNodeUpdate: (data: { nodeId: string; ts: number }) => void;
handleNodeUpsert: (data: Partial<MeshNode> & { node_id: string }) => void;
handleCoverageUpdate: (data: { node_id: string; geom: { type: string; coordinates: unknown } }) => void;
handleCoverageUpdate: (data: {
node_id: string;
geom: { type: string; coordinates: unknown };
strength_geoms?: Partial<Record<'green' | 'amber' | 'red', { type: string; coordinates: unknown }>>;
}) => void;
applyInitialViablePairs: (pairs?: [string, string][]) => void;
applyInitialViableLinks: (links?: ViableLinkSnapshot[]) => void;
applyLinkUpdate: (update: {
@@ -76,7 +80,11 @@ export function useAppMessageHandler({
}
if (msg.type === 'coverage_update') {
handleCoverageUpdate(msg.data as { node_id: string; geom: { type: string; coordinates: unknown } });
handleCoverageUpdate(msg.data as {
node_id: string;
geom: { type: string; coordinates: unknown };
strength_geoms?: Partial<Record<'green' | 'amber' | 'red', { type: string; coordinates: unknown }>>;
});
return;
}
+3 -2
View File
@@ -3,6 +3,7 @@ import { useState, useCallback, useEffect } from 'react';
export interface NodeCoverage {
node_id: string;
geom: { type: string; coordinates: unknown };
strength_geoms?: Partial<Record<'green' | 'amber' | 'red', { type: string; coordinates: unknown }>>;
antenna_height_m?: number;
radius_m?: number;
calculated_at?: string;
@@ -21,10 +22,10 @@ export function useCoverage(network?: string) {
}, [network]);
// Called when a coverage_update WS message arrives
const handleCoverageUpdate = useCallback((update: { node_id: string; geom: NodeCoverage['geom'] }) => {
const handleCoverageUpdate = useCallback((update: { node_id: string; geom: NodeCoverage['geom']; strength_geoms?: NodeCoverage['strength_geoms'] }) => {
setCoverage((prev) => {
const filtered = prev.filter((c) => c.node_id !== update.node_id);
return [...filtered, { node_id: update.node_id, geom: update.geom }];
return [...filtered, { node_id: update.node_id, geom: update.geom, strength_geoms: update.strength_geoms }];
});
}, []);
+1 -24
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useState } from 'react';
type HealthPayload = {
system: {
@@ -109,16 +109,6 @@ export const HealthPage: React.FC = () => {
};
}, []);
const historyByWorker = useMemo(() => {
const grouped = new Map<string, HealthPayload['history']>();
for (const row of data?.history ?? []) {
const list = grouped.get(row.worker_name) ?? [];
list.push(row);
grouped.set(row.worker_name, list);
}
return grouped;
}, [data]);
return (
<>
<section className="site-page-hero">
@@ -147,9 +137,6 @@ export const HealthPage: React.FC = () => {
<div className="health-workers-grid">
{(data?.workers ?? []).map((worker) => {
const statusClass = worker.status === 'running' ? 'health-pill health-pill--ok' : 'health-pill';
const hist = historyByWorker.get(worker.worker_name) ?? [];
const peakQueue = Math.max(1, ...hist.map((h) => h.queue_depth));
const queueBars = hist.slice(0, 48).reverse();
const isExpanded = expandedWorker === worker.worker_name;
const description = workerDescriptions[worker.worker_name] ?? 'No description available for this worker.';
@@ -177,16 +164,6 @@ export const HealthPage: React.FC = () => {
<div className="health-kv"><span>Processed 1h</span><strong>{worker.processed_1h}</strong></div>
<div className="health-kv"><span>Last Activity</span><strong>{timeAgo(worker.last_activity_at)}</strong></div>
</div>
<div className="health-spark" aria-label="Recent queue depth">
{queueBars.map((row, idx) => (
<span
key={`${worker.worker_name}-${idx}`}
className="health-spark__bar"
style={{ height: `${Math.max(8, (row.queue_depth / peakQueue) * 100)}%` }}
title={`${new Date(row.ts).toLocaleTimeString()} queue=${row.queue_depth}`}
/>
))}
</div>
{isExpanded && (
<p className="health-card__desc">{description}</p>
)}
+125 -96
View File
@@ -8,117 +8,146 @@ export const HomePage: React.FC = () => {
return (
<>
{/* ── Hero ─────────────────────────────────────────────────────── */}
<section className="site-hero">
<div className="site-hero__glow" aria-hidden />
<div className="site-content">
<div className="site-hero__badge">North East England · LoRa 868 MHz</div>
<h1 className="site-hero__title">
Teesside<br />
<span className="site-hero__title--accent">Mesh Network</span>
</h1>
<p className="site-hero__sub">
An experimental off-grid communications network built on{' '}
<a href="https://meshcore.co.uk" target="_blank" rel="noopener noreferrer">MeshCore</a>,{' '}
a free, open-source LoRa mesh platform. No internet. No infrastructure. Just radio.
</p>
<div className="site-hero__actions">
<a href={site.appUrl} className="site-btn site-btn--primary">Open Live Map </a>
<Link to="/about" className="site-btn site-btn--ghost">Learn more</Link>
<section className="site-home">
<div className="site-content site-home__grid">
<div className="site-home__intro">
<h1 className="site-home__title">Teesside Mesh Network</h1>
<p className="site-home__body">
A regional MeshCore deployment for Teesside and the wider North East. The website documents the
network, the live map shows what the observer hears, and the MQTT path lets repeater owners feed
packet data into the shared view.
</p>
<div className="site-home__actions">
<a href={site.appUrl} className="site-btn site-btn--primary">Open live map</a>
<Link to="/install" className="site-btn site-btn--ghost">Install MeshCore</Link>
<Link to="/mqtt" className="site-btn site-btn--ghost">MQTT observer setup</Link>
</div>
</div>
<section className="site-home__panel">
<h2>Network overview</h2>
<div className="site-home__meta">
<div className="site-home__meta-row">
<span>Coverage</span>
<strong>Teesside and North East England</strong>
</div>
<div className="site-home__meta-row">
<span>Band</span>
<strong>LoRa 868 MHz</strong>
</div>
<div className="site-home__meta-row">
<span>Channel</span>
<strong>Public</strong>
</div>
<div className="site-home__meta-row">
<span>Observer ingest</span>
<strong>MQTT via UK Mesh broker</strong>
</div>
</div>
<p>
Teesside views are filtered from the shared ingest path. Observers using IATA <strong>MME</strong>{' '}
show up here and in the wider UK stack.
</p>
</section>
</div>
</section>
<LiveStatsSection network={site.network} />
{/* ── Radio config ─────────────────────────────────────────────── */}
<section className="site-stats-section site-stats-section--alt">
<div className="site-content">
<p className="site-stats-section__eyebrow">Network radio configuration</p>
<div className="site-stats-grid site-stats-grid--6">
<div className="site-stat">
<span className="site-stat__value">EU/UK Narrow</span>
<span className="site-stat__label">Profile</span>
</div>
<div className="site-stat">
<span className="site-stat__value">869.618</span>
<span className="site-stat__label">Frequency (MHz)</span>
</div>
<div className="site-stat">
<span className="site-stat__value">62.5<span className="site-stat__suffix">kHz</span></span>
<span className="site-stat__label">Bandwidth</span>
</div>
<div className="site-stat">
<span className="site-stat__value">SF8</span>
<span className="site-stat__label">Spreading Factor</span>
</div>
<div className="site-stat">
<span className="site-stat__value">CR8</span>
<span className="site-stat__label">Coding Rate</span>
</div>
</div>
</div>
</section>
{/* ── About cards ─────────────────────────────────────────────── */}
<section className="site-section">
<div className="site-content site-cards-row">
<div className="site-card">
<div className="site-card__icon">📡</div>
<h2 className="site-card__title">What is MeshCore?</h2>
<p className="site-card__body">
MeshCore is open-source firmware for ESP32 LoRa hardware. Each node acts as
both a radio and a relay. Packets hop between nodes automatically, extending
range far beyond what a single radio can achieve.
</p>
<Link to="/about" className="site-card__link">Learn more </Link>
<div className="site-content">
<div className="site-section__head">
<h2>Use the network</h2>
<p>Everything on the public site should answer one of three questions: what MeshCore is, how to join, and how to contribute useful coverage.</p>
</div>
<div className="site-home__cards">
<div className="site-home__card">
<h3>What MeshCore is</h3>
<p>
MeshCore is open-source firmware for ESP32 LoRa hardware. Nodes relay packets between each other,
so a message can keep moving after it has left the original transmitter.
</p>
<Link to="/about">Read the overview</Link>
</div>
<div className="site-card">
<div className="site-card__icon">🔧</div>
<h2 className="site-card__title">Get on the network</h2>
<p className="site-card__body">
All you need is a supported LoRa board and about 10 minutes. Flash the firmware
in your browser, configure your node, and you're on the air. In the UK, 868 MHz
operation is licence-exempt when run within Ofcom IR2030 limits.
</p>
<Link to="/install" className="site-card__link">Install guide </Link>
<div className="site-home__card">
<h3>Get on the air</h3>
<p>
A handheld node is the fastest way in. Flash the firmware in a browser, pair it to your phone,
set the UK profile, and use the default Public channel.
</p>
<Link to="/install">Open the install guide</Link>
</div>
<div className="site-home__card">
<h3>Feed the dashboards</h3>
<p>
Repeater owners can run `meshcoretomqtt` on a Pi or other Linux host, publish packets over MQTT,
and contribute live telemetry back into the map and stats pages.
</p>
<Link to="/mqtt">MQTT setup</Link>
</div>
<div className="site-home__card">
<h3>Inspect live traffic</h3>
<p>
The map shows repeater locations, live packets, path predictions, coverage modelling, and the
stats pages that sit behind the public network view.
</p>
<a href={site.appUrl}>Open the live map</a>
</div>
</div>
<div className="site-card">
<div className="site-card__icon">🗺</div>
<h2 className="site-card__title">Live map & analytics</h2>
<p className="site-card__body">
The live dashboard shows every packet we hear in real time: node positions,
relay paths, RF coverage, and a decoded packet feed. Built entirely on open
source tools.
</p>
<a href={site.appUrl} className="site-card__link">Open map </a>
</div>
</div>
</section>
{/* ── Discord CTA ─────────────────────────────────────────────── */}
<section className="site-section site-section--dark">
<div className="site-content site-cta">
<div className="site-cta__text">
<h2 className="site-cta__title">Join the conversation</h2>
<p className="site-cta__body">
We hang out in the <strong>North East England</strong> regional channel on the
MeshCore Discord. Come say hello, ask questions, or arrange a test contact.
DM <strong>ibengr</strong> to find out more.
</p>
<div className="site-content">
<div className="site-section__head">
<h2>Radio profile</h2>
<p>These are the settings used on the Teesside side of the network.</p>
</div>
<div className="site-home__specs">
<div className="site-home__spec-row">
<span>Profile</span>
<strong>EU/UK Narrow</strong>
</div>
<div className="site-home__spec-row">
<span>Frequency</span>
<strong>869.618 MHz</strong>
</div>
<div className="site-home__spec-row">
<span>Bandwidth</span>
<strong>62.5 kHz</strong>
</div>
<div className="site-home__spec-row">
<span>Spreading factor</span>
<strong>SF8</strong>
</div>
<div className="site-home__spec-row">
<span>Coding rate</span>
<strong>CR8</strong>
</div>
</div>
</div>
</section>
<section className="site-section">
<div className="site-content">
<div className="site-home__join">
<div>
<h2>Join the conversation</h2>
<p>
The network is community-run. If you want help with hardware, range testing, observer setup, or
repeater placement, the Discord is where that happens.
</p>
</div>
<div className="site-home__join-actions">
<a href="https://discord.gg/bSuST8xvet" target="_blank" rel="noopener noreferrer" className="site-btn site-btn--primary">
Join Discord
</a>
<Link to="/open-source" className="site-btn site-btn--ghost">View source</Link>
</div>
</div>
<a
href="https://discord.gg/bSuST8xvet"
target="_blank"
rel="noopener noreferrer"
className="site-btn site-btn--primary"
>
Join Discord
</a>
</div>
</section>
</>
+256 -110
View File
@@ -26,8 +26,15 @@ type OwnerDashboard = {
type OwnerSessionResponse = {
ok: boolean;
dashboard: OwnerDashboard;
mqttUsername?: string | null;
};
const OWNER_SESSION_EVENT = 'meshcore-owner-session';
function publishOwnerSession(mqttUsername: string | null) {
window.dispatchEvent(new CustomEvent(OWNER_SESSION_EVENT, { detail: { mqttUsername } }));
}
type LivePeer = {
node_id: string;
name: string | null;
@@ -55,6 +62,21 @@ type OwnerLiveResponse = {
nodeId: string;
ownerNode: OwnerNode;
incomingPeers: LivePeer[];
heardBy: Array<LivePeer & { packets_7d: number; best_hops: number | null }>;
linkHealth: Array<{
peer_node_id: string;
peer_name: string | null;
peer_network: string | null;
owner_to_peer: number;
peer_to_owner: number;
observed_count: number;
itm_path_loss_db: number | null;
itm_viable: boolean | null;
force_viable: boolean;
last_observed: string | null;
}>;
advertTrend24h: Array<{ bucket: string; adverts: number }>;
alerts: Array<{ level: 'info' | 'warn' | 'error'; message: string }>;
recentPackets: LivePacket[];
};
@@ -100,6 +122,53 @@ function cleanPacketBody(packet: LivePacket): string | null {
return body;
}
function formatCompactTs(ts: string | null): string {
if (!ts) return '-';
return new Date(ts).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatPathLoss(value: number | null): string {
if (value == null || !Number.isFinite(value)) return '-';
return `${value.toFixed(1)} dB`;
}
function linkBadge(link: OwnerLiveResponse['linkHealth'][number]): string {
if (link.force_viable) return 'Forced';
if (link.itm_viable) return 'Viable';
if (link.itm_path_loss_db != null && link.itm_path_loss_db <= 137.88) return 'Weak';
return 'Unproven';
}
const TrendBars: React.FC<{ points: Array<{ bucket: string; adverts: number }> }> = ({ points }) => {
const max = Math.max(1, ...points.map((point) => point.adverts));
return (
<div className="owner-trend">
<div className="owner-trend__bars" aria-label="Advert trend for the last 24 hours">
{points.map((point) => {
const height = Math.max(10, Math.round((point.adverts / max) * 100));
return (
<div
key={point.bucket}
className="owner-trend__bar"
title={`${formatCompactTs(point.bucket)} · ${point.adverts} advert${point.adverts === 1 ? '' : 's'}`}
style={{ height: `${height}%` }}
/>
);
})}
</div>
<div className="owner-trend__meta">
<span>24h advert trend</span>
<strong>{points.reduce((sum, point) => sum + point.adverts, 0)}</strong>
</div>
</div>
);
};
const MAP_CENTER: LatLngExpression = [54.6, -1.2];
const FitToNodes: React.FC<{ points: Array<{ lat: number; lon: number }> }> = ({ points }) => {
@@ -139,6 +208,7 @@ export const OwnerPortalPage: React.FC = () => {
.then((json) => {
if (cancelled) return;
setDashboard(json?.dashboard ?? null);
publishOwnerSession(json?.mqttUsername ?? null);
if (json?.dashboard?.nodes?.[0]?.node_id) {
setSelectedNodeId(json.dashboard.nodes[0].node_id);
}
@@ -147,6 +217,7 @@ export const OwnerPortalPage: React.FC = () => {
.catch(() => {
if (cancelled) return;
setDashboard(null);
publishOwnerSession(null);
setLoading(false);
});
return () => {
@@ -179,6 +250,7 @@ export const OwnerPortalPage: React.FC = () => {
})
.then((json) => {
setDashboard(json.dashboard);
publishOwnerSession(json.mqttUsername ?? mqttUsername.trim());
if (json.dashboard.nodes[0]?.node_id) {
setSelectedNodeId(json.dashboard.nodes[0].node_id);
}
@@ -197,6 +269,7 @@ export const OwnerPortalPage: React.FC = () => {
setDashboard(null);
setLive(null);
setError(null);
publishOwnerSession(null);
});
};
@@ -258,6 +331,18 @@ export const OwnerPortalPage: React.FC = () => {
[live],
);
const strongestLink = useMemo(() => {
const links = live?.linkHealth ?? [];
return links
.filter((link) => link.itm_path_loss_db != null)
.sort((a, b) => (a.itm_path_loss_db ?? Number.POSITIVE_INFINITY) - (b.itm_path_loss_db ?? Number.POSITIVE_INFINITY))[0] ?? null;
}, [live]);
const viableLinkCount = useMemo(
() => (live?.linkHealth ?? []).filter((link) => link.itm_viable || link.force_viable).length,
[live],
);
return (
<>
<section className="site-page-hero">
@@ -269,7 +354,7 @@ export const OwnerPortalPage: React.FC = () => {
</div>
</section>
<div className="site-content site-prose">
<div className="site-content site-prose site-prose--wide">
{loading ? <p className="prose-note">Checking login session...</p> : null}
{!loading && !dashboard ? (
<section className="prose-section owner-login">
@@ -341,121 +426,182 @@ export const OwnerPortalPage: React.FC = () => {
<div className="site-stat"><span className="site-stat__value">{live?.ownerNode.advert_count ?? 0}</span><span className="site-stat__label">Adverts</span></div>
<div className="site-stat"><span className="site-stat__value">{fmtTs(live?.ownerNode.last_seen ?? null)}</span><span className="site-stat__label">Last Seen</span></div>
<div className="site-stat"><span className="site-stat__value">{live?.incomingPeers.length ?? 0}</span><span className="site-stat__label">Direct Senders (24h)</span></div>
<div className="site-stat"><span className="site-stat__value">{live?.heardBy.length ?? 0}</span><span className="site-stat__label">Heard By (7d)</span></div>
<div className="site-stat"><span className="site-stat__value">{viableLinkCount}</span><span className="site-stat__label">Viable Links</span></div>
<div className="site-stat"><span className="site-stat__value">{strongestLink?.peer_name ?? '-'}</span><span className="site-stat__label">Strongest Link</span></div>
<div className="site-stat"><span className="site-stat__value">{formatPathLoss(strongestLink?.itm_path_loss_db ?? null)}</span><span className="site-stat__label">Best Path Loss</span></div>
<div className="site-stat"><span className="site-stat__value">{(live?.advertTrend24h ?? []).reduce((sum, point) => sum + point.adverts, 0)}</span><span className="site-stat__label">Adverts (24h)</span></div>
<div className="site-stat"><span className="site-stat__value">{dashboard.totals.packets24h}</span><span className="site-stat__label">Packets Sent (24h)</span></div>
</div>
{liveError ? <p className="prose-note owner-login__error">Live data error: {liveError}</p> : null}
</section>
<section className="prose-section">
<h2>Direct Sender Map</h2>
<p className="prose-note">Fixed view for 0-hop direct senders in the last 24 hours. Nodes at 0,0 are hidden from this map.</p>
<div className="owner-map-wrap">
<MapContainer
center={MAP_CENTER}
zoom={7}
className="owner-map"
zoomControl={false}
dragging={false}
scrollWheelZoom={false}
doubleClickZoom={false}
boxZoom={false}
keyboard={false}
touchZoom={false}
>
<TileLayer
attribution='&copy; OpenStreetMap contributors &copy; CARTO'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
<FitToNodes points={mapPoints} />
{ownerCoord ? (
<CircleMarker center={[ownerCoord.lat, ownerCoord.lon]} radius={8} pathOptions={{ color: '#00c4ff', weight: 2 }}>
<Popup>
<strong>{live?.ownerNode.name ?? 'Owner repeater'}</strong><br />
{live?.ownerNode.network} · {live?.ownerNode.iata ?? '-'}
</Popup>
</CircleMarker>
) : null}
{mapPeers.map((peer) => (
<CircleMarker key={peer.node_id} center={[peer.lat, peer.lon]} radius={6} pathOptions={{ color: '#ffb300', weight: 2 }}>
<Popup>
<strong>{peer.name ?? peer.node_id}</strong><br />
{peer.network ?? 'Unknown'} · {peer.iata ?? '-'}<br />
Packets 24h: {peer.packets_24h}
</Popup>
</CircleMarker>
))}
{ownerCoord
? mapPeers.map((peer) => (
<Polyline
key={`link-${peer.node_id}`}
positions={[
[ownerCoord.lat, ownerCoord.lon],
[peer.lat, peer.lon],
]}
pathOptions={{ color: '#00c4ff', weight: 1.5, opacity: 0.6 }}
/>
))
: null}
</MapContainer>
</div>
</section>
<section className="prose-section">
<h2>Direct Senders (24h)</h2>
<div className="owner-table-wrap">
<table className="owner-table">
<thead>
<tr>
<th>Name</th>
<th>Network</th>
<th>IATA</th>
<th>Packets 24h</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody>
{(live?.incomingPeers ?? []).map((peer) => (
<tr key={peer.node_id}>
<td>{peer.name ?? peer.node_id}</td>
<td>{peer.network ?? '-'}</td>
<td>{peer.iata ?? '-'}</td>
<td>{peer.packets_24h}</td>
<td>{fmtTs(peer.last_seen)}</td>
</tr>
))}
{(live?.incomingPeers ?? []).length === 0 ? (
<tr>
<td colSpan={5}>No direct sender nodes found in the last 24 hours.</td>
</tr>
<div className="owner-dashboard-grid">
<section className="prose-section owner-panel owner-panel--map">
<div className="owner-panel__head">
<div>
<h2>Direct Sender Map</h2>
<p className="prose-note">0-hop direct senders in the last 24 hours. Nodes at 0,0 are hidden.</p>
</div>
</div>
<div className="owner-map-wrap">
<MapContainer
center={MAP_CENTER}
zoom={7}
className="owner-map"
zoomControl={false}
dragging={false}
scrollWheelZoom={false}
doubleClickZoom={false}
boxZoom={false}
keyboard={false}
touchZoom={false}
>
<TileLayer
attribution='&copy; OpenStreetMap contributors &copy; CARTO'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
<FitToNodes points={mapPoints} />
{ownerCoord ? (
<CircleMarker center={[ownerCoord.lat, ownerCoord.lon]} radius={8} pathOptions={{ color: '#00c4ff', weight: 2 }}>
<Popup>
<strong>{live?.ownerNode.name ?? 'Owner repeater'}</strong><br />
{live?.ownerNode.network} · {live?.ownerNode.iata ?? '-'}
</Popup>
</CircleMarker>
) : null}
</tbody>
</table>
</div>
</section>
{mapPeers.map((peer) => (
<CircleMarker key={peer.node_id} center={[peer.lat, peer.lon]} radius={6} pathOptions={{ color: '#ffb300', weight: 2 }}>
<Popup>
<strong>{peer.name ?? peer.node_id}</strong><br />
{peer.network ?? 'Unknown'} · {peer.iata ?? '-'}<br />
Packets 24h: {peer.packets_24h}
</Popup>
</CircleMarker>
))}
{ownerCoord
? mapPeers.map((peer) => (
<Polyline
key={`link-${peer.node_id}`}
positions={[
[ownerCoord.lat, ownerCoord.lon],
[peer.lat, peer.lon],
]}
pathOptions={{ color: '#00c4ff', weight: 1.5, opacity: 0.6 }}
/>
))
: null}
</MapContainer>
</div>
</section>
<section className="prose-section">
<h2>Live Packets Received By Repeater</h2>
<div className="owner-packets">
{(live?.recentPackets ?? []).map((packet, idx) => (
<article key={`${packet.time}-${packet.packet_hash ?? `row-${idx}`}`} className="owner-packet">
<div className="owner-packet__head">
<strong>{PACKET_LABELS[Number(packet.packet_type ?? -1)] ?? `Type ${packet.packet_type ?? '?'}`}</strong>
<span>{fmtTs(packet.time)}</span>
</div>
<div className="owner-packet__meta">
<span>From Node: {packet.src_node_name ?? packet.src_node_id ?? '-'}</span>
<span>Sender: {packet.sender ?? '-'}</span>
<span>Hops: {packet.hop_count ?? '-'}</span>
<span>Route: {ROUTE_LABELS[Number(packet.route_type ?? -1)] ?? (packet.route_type ?? '-')}</span>
<span>Hash: {packet.packet_hash ?? '-'}</span>
</div>
{cleanPacketBody(packet) ? <p className="owner-packet__body">{cleanPacketBody(packet)}</p> : null}
</article>
))}
{(live?.recentPackets ?? []).length === 0 ? (
<p className="prose-note">No packets received by this repeater yet.</p>
) : null}
</div>
</section>
<section className="prose-section owner-panel owner-panel--alerts">
<div className="owner-panel__head"><h2>Alerts</h2></div>
<div className="owner-alerts">
{(live?.alerts ?? []).map((alert, idx) => (
<article key={`${alert.level}-${idx}`} className={`owner-alert owner-alert--${alert.level}`}>
<strong>{alert.level.toUpperCase()}</strong>
<span>{alert.message}</span>
</article>
))}
</div>
</section>
<section className="prose-section owner-panel owner-panel--trend">
<div className="owner-panel__head"><h2>Advert Trend</h2></div>
<TrendBars points={live?.advertTrend24h ?? []} />
</section>
<section className="prose-section owner-panel owner-panel--links">
<div className="owner-panel__head"><h2>RF Link Health</h2></div>
<div className="owner-list">
{(live?.linkHealth ?? []).slice(0, 8).map((link) => (
<article key={link.peer_node_id} className="owner-list__row">
<div className="owner-list__primary">
<strong>{link.peer_name ?? link.peer_node_id}</strong>
<span>{link.peer_network ?? '-'}</span>
</div>
<div className="owner-list__metrics">
<span>{linkBadge(link)}</span>
<span>{formatPathLoss(link.itm_path_loss_db)}</span>
<span>{link.owner_to_peer}/{link.peer_to_owner}</span>
<span>{link.observed_count} obs</span>
</div>
</article>
))}
{(live?.linkHealth ?? []).length === 0 ? (
<p className="prose-note">No link health data has been calculated for this repeater yet.</p>
) : null}
</div>
</section>
<section className="prose-section owner-panel owner-panel--heard">
<div className="owner-panel__head"><h2>Heard By</h2></div>
<div className="owner-list">
{(live?.heardBy ?? []).slice(0, 8).map((peer) => (
<article key={peer.node_id} className="owner-list__row">
<div className="owner-list__primary">
<strong>{peer.name ?? peer.node_id}</strong>
<span>{peer.network ?? '-'} · {peer.iata ?? '-'}</span>
</div>
<div className="owner-list__metrics">
<span>{peer.packets_24h} / 24h</span>
<span>{peer.packets_7d} / 7d</span>
<span>{peer.best_hops == null ? '-' : `${peer.best_hops} hops`}</span>
</div>
</article>
))}
{(live?.heardBy ?? []).length === 0 ? (
<p className="prose-note">No nodes have heard this repeater in the last 7 days.</p>
) : null}
</div>
</section>
<section className="prose-section owner-panel owner-panel--senders">
<div className="owner-panel__head"><h2>Direct Senders</h2></div>
<div className="owner-list">
{(live?.incomingPeers ?? []).slice(0, 8).map((peer) => (
<article key={peer.node_id} className="owner-list__row">
<div className="owner-list__primary">
<strong>{peer.name ?? peer.node_id}</strong>
<span>{peer.network ?? '-'} · {peer.iata ?? '-'}</span>
</div>
<div className="owner-list__metrics">
<span>{peer.packets_24h} / 24h</span>
<span>{formatCompactTs(peer.last_seen)}</span>
</div>
</article>
))}
{(live?.incomingPeers ?? []).length === 0 ? (
<p className="prose-note">No direct sender nodes found in the last 24 hours.</p>
) : null}
</div>
</section>
<section className="prose-section owner-panel owner-panel--packets">
<div className="owner-panel__head"><h2>Live Packets Received By Repeater</h2></div>
<div className="owner-packets">
{(live?.recentPackets ?? []).map((packet, idx) => (
<article key={`${packet.time}-${packet.packet_hash ?? `row-${idx}`}`} className="owner-packet">
<div className="owner-packet__head">
<strong>{PACKET_LABELS[Number(packet.packet_type ?? -1)] ?? `Type ${packet.packet_type ?? '?'}`}</strong>
<span>{fmtTs(packet.time)}</span>
</div>
<div className="owner-packet__meta">
<span>From: {packet.src_node_name ?? packet.src_node_id ?? '-'}</span>
<span>Sender: {packet.sender ?? '-'}</span>
<span>Hops: {packet.hop_count ?? '-'}</span>
<span>Route: {ROUTE_LABELS[Number(packet.route_type ?? -1)] ?? (packet.route_type ?? '-')}</span>
</div>
{cleanPacketBody(packet) ? <p className="owner-packet__body">{cleanPacketBody(packet)}</p> : null}
</article>
))}
{(live?.recentPackets ?? []).length === 0 ? (
<p className="prose-note">No packets received by this repeater yet.</p>
) : null}
</div>
</section>
</div>
</>
) : null}
+42 -2
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
type SiteLayoutProps = {
@@ -15,6 +15,13 @@ type NavItem = {
enabled: boolean;
};
const OWNER_SESSION_EVENT = 'meshcore-owner-session';
type OwnerSessionSummary = {
ok: boolean;
mqttUsername?: string | null;
};
function navClassName({ isActive }: { isActive: boolean }): string {
return isActive ? 'site-nav__link site-nav__link--active' : 'site-nav__link';
}
@@ -27,6 +34,7 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
showStats,
}) => {
const [menuOpen, setMenuOpen] = useState(false);
const [ownerLabel, setOwnerLabel] = useState<string | null>(null);
const navigate = useNavigate();
const navItems: NavItem[] = [
@@ -46,6 +54,38 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
navigate(to);
};
useEffect(() => {
let cancelled = false;
const loadOwnerSession = () => {
fetch('/api/owner/session', { cache: 'no-store' })
.then(async (res) => {
if (!res.ok) return null;
return (await res.json()) as OwnerSessionSummary;
})
.then((json) => {
if (cancelled) return;
setOwnerLabel(json?.mqttUsername?.trim() || null);
})
.catch(() => {
if (cancelled) return;
setOwnerLabel(null);
});
};
const handleOwnerSession = (event: Event) => {
const detail = (event as CustomEvent<{ mqttUsername?: string | null }>).detail;
setOwnerLabel(detail?.mqttUsername?.trim() || null);
};
loadOwnerSession();
window.addEventListener(OWNER_SESSION_EVENT, handleOwnerSession as EventListener);
return () => {
cancelled = true;
window.removeEventListener(OWNER_SESSION_EVENT, handleOwnerSession as EventListener);
};
}, []);
return (
<div className="site-layout">
<nav className="site-nav">
@@ -72,7 +112,7 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
onClick={() => handleNavClick('/login')}
className={({ isActive }) => isActive ? 'site-nav__app-btn site-nav__app-btn--active' : 'site-nav__app-btn'}
>
Login
{ownerLabel ?? 'Login'}
</NavLink>
</div>
+121 -96
View File
@@ -8,117 +8,142 @@ export const UKHomePage: React.FC = () => {
return (
<>
{/* ── Hero ─────────────────────────────────────────────────────── */}
<section className="site-hero">
<div className="site-hero__glow" aria-hidden />
<div className="site-content">
<div className="site-hero__badge">United Kingdom · LoRa 868 MHz</div>
<h1 className="site-hero__title">
UK<br />
<span className="site-hero__title--accent">Mesh Network</span>
</h1>
<p className="site-hero__sub">
A UK-wide off-grid communications network built on{' '}
<a href="https://meshcore.co.uk" target="_blank" rel="noopener noreferrer">MeshCore</a>,{' '}
a free, open-source LoRa mesh platform. No internet. No infrastructure. Just radio.
</p>
<div className="site-hero__actions">
<a href={site.appUrl} className="site-btn site-btn--primary">Open Live Map </a>
<Link to="/about" className="site-btn site-btn--ghost">Learn more</Link>
<section className="site-home">
<div className="site-content site-home__grid">
<div className="site-home__intro">
<h1 className="site-home__title">UK Mesh Network</h1>
<p className="site-home__body">
The UK-wide public site for MeshCore traffic, repeater coverage, observer ingestion, and the
supporting documentation behind the live map. Teesside is part of this wider feed rather than a
separate stack.
</p>
<div className="site-home__actions">
<a href={site.appUrl} className="site-btn site-btn--primary">Open live map</a>
<Link to="/install" className="site-btn site-btn--ghost">Install MeshCore</Link>
<Link to="/mqtt" className="site-btn site-btn--ghost">Become an observer</Link>
</div>
</div>
<section className="site-home__panel">
<h2>Network overview</h2>
<div className="site-home__meta">
<div className="site-home__meta-row">
<span>Coverage</span>
<strong>United Kingdom</strong>
</div>
<div className="site-home__meta-row">
<span>Band</span>
<strong>LoRa 868 MHz</strong>
</div>
<div className="site-home__meta-row">
<span>Channel</span>
<strong>Public</strong>
</div>
<div className="site-home__meta-row">
<span>Observer ingest</span>
<strong>Shared MQTT broker</strong>
</div>
</div>
</section>
</div>
</section>
<LiveStatsSection />
{/* ── Radio config ─────────────────────────────────────────────── */}
<section className="site-stats-section site-stats-section--alt">
<div className="site-content">
<p className="site-stats-section__eyebrow">Network radio configuration</p>
<div className="site-stats-grid site-stats-grid--6">
<div className="site-stat">
<span className="site-stat__value">EU/UK Narrow</span>
<span className="site-stat__label">Profile</span>
</div>
<div className="site-stat">
<span className="site-stat__value">869.618</span>
<span className="site-stat__label">Frequency (MHz)</span>
</div>
<div className="site-stat">
<span className="site-stat__value">62.5<span className="site-stat__suffix">kHz</span></span>
<span className="site-stat__label">Bandwidth</span>
</div>
<div className="site-stat">
<span className="site-stat__value">SF8</span>
<span className="site-stat__label">Spreading Factor</span>
</div>
<div className="site-stat">
<span className="site-stat__value">CR8</span>
<span className="site-stat__label">Coding Rate</span>
</div>
</div>
</div>
</section>
{/* ── About cards ─────────────────────────────────────────────── */}
<section className="site-section">
<div className="site-content site-cards-row">
<div className="site-card">
<div className="site-card__icon">📡</div>
<h2 className="site-card__title">What is MeshCore?</h2>
<p className="site-card__body">
MeshCore is open-source firmware for ESP32 LoRa hardware. Each node acts as
both a radio and a relay. Packets hop between nodes automatically, extending
range far beyond what a single radio can achieve.
</p>
<Link to="/about" className="site-card__link">Learn more </Link>
<div className="site-content">
<div className="site-section__head">
<h2>Use the network</h2>
<p>The public site covers the national network, the observer feed, and the operational pages that sit around the live map.</p>
</div>
<div className="site-home__cards">
<div className="site-home__card">
<h3>What MeshCore is</h3>
<p>
MeshCore is open-source firmware for LoRa hardware. Each node can forward packets, which is what
makes long regional chains and repeater coverage possible.
</p>
<Link to="/about">Read the overview</Link>
</div>
<div className="site-card">
<div className="site-card__icon">🔧</div>
<h2 className="site-card__title">Become an observer</h2>
<p className="site-card__body">
Connect your repeater node to the UK Mesh MQTT broker and contribute live
packet data from your area. All you need is a Linux device, a USB cable,
and about 15 minutes.
</p>
<Link to="/mqtt" className="site-card__link">Observer setup </Link>
<div className="site-home__card">
<h3>Get on the air</h3>
<p>
Flash a supported device, pair it to your phone, and use the UK public profile. That gets you
onto the same channel used across the wider network.
</p>
<Link to="/install">Open the install guide</Link>
</div>
<div className="site-home__card">
<h3>Become an observer</h3>
<p>
Connect a repeater or room server to the broker, publish packets over MQTT, and add another view
of the network from your own location.
</p>
<Link to="/mqtt">Observer setup</Link>
</div>
<div className="site-home__card">
<h3>Inspect live traffic</h3>
<p>
The live app shows repeater positions, path predictions, coverage layers, decoded packets, and
the supporting stats for the UK feed.
</p>
<a href={site.appUrl}>Open the live map</a>
</div>
</div>
<div className="site-card">
<div className="site-card__icon">🗺</div>
<h2 className="site-card__title">Live map and analytics</h2>
<p className="site-card__body">
The live dashboard shows every packet heard across the UK in real time: node
positions, relay paths, RF coverage, and a decoded packet feed. Built entirely
on open source tools.
</p>
<a href={site.appUrl} className="site-card__link">Open map </a>
</div>
</div>
</section>
{/* ── Discord CTA ─────────────────────────────────────────────── */}
<section className="site-section site-section--dark">
<div className="site-content site-cta">
<div className="site-cta__text">
<h2 className="site-cta__title">Join the conversation</h2>
<p className="site-cta__body">
We hang out on the MeshCore Discord. Come say hello, ask questions, or
coordinate coverage with other UK operators. DM <strong>ibengr</strong> to
get set up as an observer.
</p>
<div className="site-content">
<div className="site-section__head">
<h2>Radio profile</h2>
<p>These are the network settings used across the UK public deployment.</p>
</div>
<div className="site-home__specs">
<div className="site-home__spec-row">
<span>Profile</span>
<strong>EU/UK Narrow</strong>
</div>
<div className="site-home__spec-row">
<span>Frequency</span>
<strong>869.618 MHz</strong>
</div>
<div className="site-home__spec-row">
<span>Bandwidth</span>
<strong>62.5 kHz</strong>
</div>
<div className="site-home__spec-row">
<span>Spreading factor</span>
<strong>SF8</strong>
</div>
<div className="site-home__spec-row">
<span>Coding rate</span>
<strong>CR8</strong>
</div>
</div>
</div>
</section>
<section className="site-section">
<div className="site-content">
<div className="site-home__join">
<div>
<h2>Join the conversation</h2>
<p>
Coverage, observer credentials, and repeater coordination all run through the MeshCore Discord.
That is where to go if you want to add another observer or compare notes with other UK operators.
</p>
</div>
<div className="site-home__join-actions">
<a href="https://discord.gg/bSuST8xvet" target="_blank" rel="noopener noreferrer" className="site-btn site-btn--primary">
Join Discord
</a>
<Link to="/open-source" className="site-btn site-btn--ghost">View source</Link>
</div>
</div>
<a
href="https://discord.gg/bSuST8xvet"
target="_blank"
rel="noopener noreferrer"
className="site-btn site-btn--primary"
>
Join Discord
</a>
</div>
</section>
</>
+465 -192
View File
@@ -915,7 +915,7 @@ html, body, #root {
}
.site-main { flex: 1; }
.site-content {
max-width: 1100px;
max-width: 1240px;
margin: 0 auto;
padding: 0 24px;
}
@@ -924,10 +924,10 @@ html, body, #root {
.site-nav {
display: flex;
align-items: center;
gap: 32px;
height: 56px;
padding: 0 24px;
background: var(--bg-panel);
gap: 24px;
min-height: 60px;
padding: 10px 24px;
background: #09111a;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
@@ -939,7 +939,6 @@ html, body, #root {
gap: 8px;
text-decoration: none;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 15px;
font-weight: 600;
flex-shrink: 0;
@@ -952,39 +951,43 @@ html, body, #root {
.site-nav__links {
display: flex;
align-items: center;
gap: 4px;
gap: 8px;
flex: 1;
flex-wrap: wrap;
}
.site-nav__link {
padding: 6px 12px;
padding: 8px 10px;
color: var(--text-secondary);
text-decoration: none;
font-size: 13px;
border-radius: var(--radius);
border-radius: 6px;
transition: color var(--transition), background var(--transition);
}
.site-nav__link:hover { color: var(--text-primary); background: var(--bg-hover); }
.site-nav__link--active { color: var(--accent); }
.site-nav__link--active {
color: var(--text-primary);
background: rgba(0, 196, 255, 0.08);
}
.site-nav__app-btn {
padding: 7px 16px;
background: var(--accent-dim);
border: 1px solid var(--accent-glow);
color: var(--accent);
padding: 8px 12px;
background: transparent;
border: 1px solid var(--border-bright);
color: var(--text-primary);
text-decoration: none;
font-size: 13px;
font-family: var(--font-mono);
border-radius: var(--radius);
border-radius: 6px;
flex-shrink: 0;
margin-left: auto;
transition: background var(--transition), border-color var(--transition);
}
.site-nav__app-btn:hover {
background: rgba(0, 196, 255, 0.25);
border-color: var(--accent);
background: var(--bg-hover);
border-color: var(--accent-glow);
}
.site-nav__app-btn--active {
background: rgba(0, 196, 255, 0.25);
border-color: var(--accent);
background: rgba(0, 196, 255, 0.08);
border-color: var(--border-bright);
}
/* ── Owner portal ─────────────────────────────────────────────────────── */
@@ -1028,7 +1031,7 @@ html, body, #root {
gap: 12px;
}
.owner-summary-grid.site-stats-grid--6 {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.owner-summary-grid .site-stat {
align-items: flex-start;
@@ -1047,6 +1050,144 @@ html, body, #root {
text-transform: uppercase;
letter-spacing: 0.08em;
}
.owner-dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 16px;
align-items: start;
grid-auto-rows: 320px;
}
.owner-panel {
display: flex;
flex-direction: column;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
min-width: 0;
margin-bottom: 0;
height: 100%;
overflow: hidden;
}
.owner-panel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.owner-panel__head h2 {
margin: 0;
}
.owner-alerts {
display: grid;
gap: 10px;
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
}
.owner-alert {
display: grid;
gap: 4px;
border-radius: var(--radius);
padding: 12px;
border: 1px solid var(--border);
background: var(--bg-panel-alt);
}
.owner-alert strong {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
}
.owner-alert span {
color: var(--text-primary);
font-size: 13px;
line-height: 1.45;
}
.owner-alert--info {
border-color: rgba(0,196,255,0.22);
}
.owner-alert--warn {
border-color: rgba(255,179,0,0.3);
}
.owner-alert--error {
border-color: rgba(255,100,100,0.32);
}
.owner-trend {
display: grid;
gap: 10px;
flex: 1;
min-height: 0;
}
.owner-trend__bars {
height: 100%;
min-height: 0;
display: grid;
grid-template-columns: repeat(24, minmax(0, 1fr));
align-items: end;
gap: 4px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.owner-trend__bar {
min-height: 10px;
background: linear-gradient(180deg, rgba(0,196,255,0.95), rgba(0,196,255,0.45));
border-radius: 3px 3px 0 0;
}
.owner-trend__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--text-secondary);
font-size: 12px;
}
.owner-trend__meta strong {
color: var(--text-primary);
font-family: var(--font-mono);
}
.owner-list {
display: grid;
gap: 10px;
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
}
.owner-list__row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-panel-alt);
}
.owner-list__primary {
display: grid;
gap: 4px;
min-width: 0;
}
.owner-list__primary strong {
color: var(--text-primary);
font-size: 13px;
overflow-wrap: anywhere;
}
.owner-list__primary span {
color: var(--text-secondary);
font-size: 11px;
}
.owner-list__metrics {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
color: var(--text-secondary);
font-size: 11px;
font-family: var(--font-mono);
}
.owner-table-wrap {
width: 100%;
overflow-x: auto;
@@ -1106,16 +1247,20 @@ html, body, #root {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
flex: 1;
min-height: 0;
}
.owner-map {
width: 100%;
height: 320px;
height: 100%;
}
.owner-packets {
display: flex;
flex-direction: column;
gap: 10px;
height: 360px;
height: auto;
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
}
@@ -1164,9 +1309,11 @@ html, body, #root {
.site-btn {
display: inline-flex;
align-items: center;
padding: 10px 22px;
border-radius: var(--radius);
font-size: 14px;
justify-content: center;
min-height: 40px;
padding: 0 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
@@ -1174,113 +1321,133 @@ html, body, #root {
border: 1px solid transparent;
}
.site-btn--primary {
background: var(--accent);
color: #000;
border-color: var(--accent);
background: #0d1e31;
color: var(--text-primary);
border-color: rgba(0, 196, 255, 0.28);
}
.site-btn--primary:hover { background: #33cfff; border-color: #33cfff; }
.site-btn--primary:hover { background: #11263d; border-color: rgba(0, 196, 255, 0.4); }
.site-btn--ghost {
background: transparent;
color: var(--text-primary);
border-color: var(--border-bright);
border-color: var(--border);
}
.site-btn--ghost:hover { background: var(--bg-hover); border-color: var(--accent-glow); }
/* ── Hero ──────────────────────────────────────────────────────────────── */
.site-hero {
position: relative;
padding: 96px 0 72px;
overflow: hidden;
/* ── Home ──────────────────────────────────────────────────────────────── */
.site-home {
padding: 40px 0 28px;
border-bottom: 1px solid var(--border);
}
.site-hero__glow {
position: absolute;
top: -120px;
left: 50%;
transform: translateX(-50%);
width: 800px;
height: 500px;
background: radial-gradient(ellipse at center, rgba(0,196,255,0.07) 0%, transparent 70%);
pointer-events: none;
.site-home__grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.9fr);
gap: 24px;
align-items: start;
}
.site-hero__badge {
display: inline-block;
padding: 4px 12px;
background: var(--accent-dim);
border: 1px solid var(--accent-glow);
border-radius: 20px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent);
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 24px;
.site-home__intro {
display: grid;
gap: 18px;
}
.site-hero__title {
font-family: var(--font-mono);
font-size: clamp(40px, 7vw, 72px);
.site-home__title {
font-size: clamp(34px, 5vw, 54px);
line-height: 1.05;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 24px;
letter-spacing: -0.03em;
}
.site-hero__title--accent { color: var(--accent); }
.site-hero__sub {
font-size: 17px;
.site-home__body {
max-width: 760px;
font-size: 16px;
line-height: 1.7;
color: var(--text-secondary);
max-width: 560px;
margin: 0 0 36px;
}
.site-hero__sub a { color: var(--accent); text-decoration: none; }
.site-hero__sub a:hover { text-decoration: underline; }
.site-hero__actions { display: flex; gap: 12px; flex-wrap: wrap; }
.site-home__actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.site-home__panel,
.site-home__card,
.site-home__join {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
}
.site-home__panel {
padding: 20px;
display: grid;
gap: 16px;
}
.site-home__panel h2,
.site-home__join h2,
.site-section__head h2 {
font-size: 24px;
line-height: 1.2;
margin: 0;
}
.site-home__panel p,
.site-home__join p,
.site-section__head p {
color: var(--text-secondary);
line-height: 1.65;
margin: 0;
}
.site-home__meta {
display: grid;
gap: 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.site-home__meta-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-top: 1px solid rgba(32, 80, 140, 0.18);
}
.site-home__meta-row:first-child { border-top: 0; }
.site-home__meta-row span {
color: var(--text-secondary);
}
.site-home__meta-row strong {
color: var(--text-primary);
text-align: right;
}
/* ── Live stats ────────────────────────────────────────────────────────── */
.site-stats-section {
padding: 48px 0;
border-top: 1px solid var(--border);
padding: 28px 0;
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
}
.site-stats-section__eyebrow {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 24px;
background: #09111a;
}
.site-stats-section--alt {
background: var(--bg-panel-alt);
background: var(--bg-panel);
}
.site-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
gap: 12px;
}
.site-stats-grid--6 {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.site-stat {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 6px;
padding: 28px 24px;
background: var(--bg-panel-alt);
align-items: flex-start;
justify-content: flex-start;
text-align: left;
gap: 10px;
min-height: 132px;
padding: 18px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
}
.site-stat__value {
font-family: var(--font-mono);
font-size: clamp(28px, 4vw, 44px);
font-size: clamp(24px, 3vw, 38px);
font-weight: 700;
color: var(--text-primary);
line-height: 1;
line-height: 1.1;
}
.site-stat__suffix {
font-size: 0.6em;
@@ -1288,7 +1455,7 @@ html, body, #root {
margin-left: 4px;
}
.site-stat__label {
font-size: 13px;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.4;
}
@@ -1302,81 +1469,132 @@ html, body, #root {
}
/* ── Sections ──────────────────────────────────────────────────────────── */
.site-section { padding: 64px 0; }
.site-section { padding: 32px 0; }
.site-section--dark { background: var(--bg-panel); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.site-cards-row {
.site-section__head {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 8px;
margin-bottom: 20px;
max-width: 760px;
}
.site-home__cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.site-card {
.site-card,
.site-home__card {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 28px;
display: flex;
flex-direction: column;
border-radius: 8px;
padding: 20px;
display: grid;
gap: 12px;
transition: border-color var(--transition), background var(--transition);
}
.site-card:hover { border-color: var(--border-bright); background: var(--bg-panel-alt); }
.site-card__icon { font-size: 28px; }
.site-card__title { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0; }
.site-card__body { font-size: 14px; line-height: 1.65; color: var(--text-secondary); flex: 1; }
.site-card__link { font-size: 13px; color: var(--accent); text-decoration: none; font-family: var(--font-mono); }
.site-card__link:hover { text-decoration: underline; }
/* ── CTA row ───────────────────────────────────────────────────────────── */
.site-cta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 32px;
flex-wrap: wrap;
padding: 12px 0;
.site-card__icon { display: none; }
.site-card__title,
.site-home__card h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.site-card__body,
.site-home__card p {
font-size: 14px;
line-height: 1.65;
color: var(--text-secondary);
margin: 0;
}
.site-card__link,
.site-home__card a {
font-size: 13px;
color: var(--accent);
text-decoration: none;
font-family: var(--font-mono);
}
.site-card__link:hover,
.site-home__card a:hover { text-decoration: underline; }
.site-home__specs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.site-home__spec-row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 18px;
background: #0b121c;
border: 1px solid var(--border);
border-radius: 8px;
}
.site-home__spec-row span {
color: var(--text-secondary);
font-size: 13px;
}
.site-home__spec-row strong {
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 18px;
}
.site-home__join {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
flex-wrap: wrap;
padding: 20px;
}
.site-home__join > div:first-child {
max-width: 760px;
}
.site-home__join-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.site-cta__title { font-size: 22px; font-weight: 700; margin: 0 0 8px; }
.site-cta__body { font-size: 14px; color: var(--text-secondary); line-height: 1.6; max-width: 560px; margin: 0; }
/* ── Page hero (inner pages) ───────────────────────────────────────────── */
.site-page-hero {
padding: 64px 0 48px;
padding: 32px 0 20px;
border-bottom: 1px solid var(--border);
background: #09111a;
}
.site-page-hero__title {
font-family: var(--font-mono);
font-size: clamp(28px, 5vw, 48px);
font-size: clamp(28px, 4vw, 42px);
font-weight: 700;
color: var(--text-primary);
margin: 0 0 16px;
margin: 0 0 10px;
letter-spacing: -0.02em;
}
.site-page-hero__sub {
font-size: 16px;
font-size: 15px;
color: var(--text-secondary);
line-height: 1.65;
max-width: 620px;
line-height: 1.7;
max-width: 780px;
margin: 0;
}
/* ── Prose content ─────────────────────────────────────────────────────── */
.site-prose { padding: 48px 24px 80px; max-width: 800px; }
.prose-section { margin-bottom: 56px; }
.site-prose { padding: 32px 24px 56px; max-width: 980px; }
.site-prose--wide { max-width: 1280px; }
.prose-section { margin-bottom: 40px; }
.prose-section h2 {
display: flex;
align-items: center;
gap: 12px;
font-family: var(--font-mono);
font-size: 20px;
display: block;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 20px;
padding-bottom: 12px;
margin: 0 0 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.prose-section h3 { font-size: 16px; color: var(--text-primary); margin: 24px 0 12px; }
.prose-section h3 { font-size: 16px; color: var(--text-primary); margin: 20px 0 10px; }
.prose-section p {
font-size: 15px;
line-height: 1.75;
line-height: 1.7;
color: var(--text-secondary);
margin: 0 0 16px;
}
@@ -1397,51 +1615,42 @@ html, body, #root {
.prose-section code {
font-family: var(--font-mono);
font-size: 13px;
background: var(--bg-panel-alt);
background: #0b121c;
border: 1px solid var(--border);
padding: 1px 6px;
border-radius: var(--radius);
color: var(--accent);
}
.prose-step {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
background: var(--accent-dim);
border: 1px solid var(--accent-glow);
border-radius: 50%;
font-size: 12px;
display: inline-block;
min-width: 18px;
margin-right: 10px;
font-family: var(--font-mono);
font-size: 14px;
color: var(--accent);
flex-shrink: 0;
}
.prose-note {
background: var(--bg-panel-alt);
background: #0b121c;
border: 1px solid var(--border);
border-left: 3px solid var(--accent-glow);
border-left: 0;
padding: 12px 16px;
border-radius: var(--radius);
border-radius: 8px;
font-size: 14px !important;
color: var(--text-secondary) !important;
}
.prose-section--muted {
background: var(--bg-panel);
background: #0b121c;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 28px 32px;
margin-bottom: 56px;
border-radius: 8px;
padding: 20px 24px;
margin-bottom: 40px;
}
.prose-section--muted h2 { border-bottom-color: transparent; }
.prose-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 24px; }
.prose-facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
gap: 12px;
margin: 24px 0;
}
.prose-facts--3x2 {
@@ -1450,12 +1659,14 @@ html, body, #root {
.prose-fact {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 20px;
background: var(--bg-panel-alt);
gap: 6px;
padding: 16px 18px;
background: #0b121c;
border: 1px solid var(--border);
border-radius: 8px;
}
.prose-fact__value { font-family: var(--font-mono); font-size: 22px; font-weight: 700; color: var(--accent); }
.prose-fact__label { font-size: 12px; color: var(--text-muted); }
.prose-fact__value { font-family: var(--font-mono); font-size: 18px; font-weight: 700; color: var(--accent); }
.prose-fact__label { font-size: 12px; color: var(--text-secondary); }
.prose-steps { padding-left: 24px; margin: 0 0 16px; counter-reset: none; }
.prose-steps li { margin-bottom: 12px; }
@@ -1475,8 +1686,8 @@ html, body, #root {
.code-block {
background: #020608;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px 24px;
border-radius: 8px;
padding: 16px 18px;
margin: 16px 0;
overflow-x: auto;
}
@@ -1497,20 +1708,18 @@ html, body, #root {
margin: 20px 0;
}
.hw-card {
background: var(--bg-panel-alt);
background: #0b121c;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px 20px;
border-radius: 8px;
padding: 16px 18px;
position: relative;
}
.hw-card--recommended { border-color: var(--accent-glow); }
.hw-card--recommended { border-color: rgba(0, 196, 255, 0.28); }
.hw-card__badge {
display: inline-block;
font-family: var(--font-mono);
font-size: 10px;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.hw-card__name { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
@@ -1527,9 +1736,9 @@ html, body, #root {
display: flex;
flex-direction: column;
gap: 4px;
background: var(--bg-panel-alt);
background: #0b121c;
border: 1px solid var(--border);
border-radius: var(--radius);
border-radius: 8px;
padding: 14px 16px;
text-decoration: none;
transition: border-color var(--transition), background var(--transition);
@@ -1544,8 +1753,14 @@ html, body, #root {
gap: 14px;
}
.health-workers-grid > * {
min-width: 0;
}
.health-card {
gap: 14px;
min-width: 0;
overflow: hidden;
}
.health-card--interactive {
@@ -1565,13 +1780,20 @@ html, body, #root {
}
.health-card__head {
display: flex;
align-items: center;
justify-content: space-between;
display: grid;
grid-template-columns: 1fr;
align-items: start;
gap: 8px;
min-width: 0;
}
.health-card__head .site-card__title {
min-width: 0;
overflow-wrap: anywhere;
}
.health-pill {
justify-self: start;
font-size: 10px;
letter-spacing: 0.08em;
font-family: var(--font-mono);
@@ -1579,6 +1801,9 @@ html, body, #root {
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 8px;
max-width: 100%;
overflow-wrap: anywhere;
text-align: center;
}
.health-pill--ok {
@@ -1590,19 +1815,33 @@ html, body, #root {
.health-card__stats {
display: grid;
gap: 6px;
min-width: 0;
}
.health-kv {
display: flex;
align-items: center;
justify-content: space-between;
display: grid;
grid-template-columns: 1fr;
align-items: start;
font-size: 12px;
color: var(--text-secondary);
gap: 4px;
min-width: 0;
}
.health-kv span {
min-width: 0;
}
.health-kv strong,
.health-kv span {
overflow-wrap: anywhere;
}
.health-kv strong {
color: var(--text-primary);
font-family: var(--font-mono);
text-align: left;
min-width: 0;
}
.health-spark {
@@ -1629,6 +1868,10 @@ html, body, #root {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.55;
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.health-system-grid .site-stat__value {
@@ -1741,15 +1984,15 @@ html, body, #root {
.site-footer {
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
gap: 12px;
height: 48px;
min-height: 56px;
font-size: 12px;
color: var(--text-muted);
border-top: 1px solid var(--border);
background: var(--bg-panel);
background: #09111a;
flex-wrap: wrap;
padding: 0 24px;
padding: 12px 24px;
}
.site-footer a { color: var(--text-muted); text-decoration: none; }
.site-footer a:hover { color: var(--text-secondary); }
@@ -1901,19 +2144,49 @@ html, body, #root {
/* ── Website content / sections ──────────────────────────────────────── */
.site-content { padding: 0 16px; }
.site-hero { padding: 56px 0 40px; }
.site-hero__sub { font-size: 15px; }
.site-section { padding: 40px 0; }
.site-page-hero { padding: 40px 0 32px; }
.site-cta { flex-direction: column; gap: 16px; align-items: flex-start; }
.site-home { padding: 28px 0 24px; }
.site-home__grid { grid-template-columns: 1fr; }
.site-home__meta-row { flex-direction: column; align-items: flex-start; }
.site-home__meta-row strong { text-align: left; }
.site-home__title { font-size: 34px; }
.site-section { padding: 24px 0; }
.site-page-hero { padding: 28px 0 18px; }
.site-home__join { align-items: flex-start; }
.site-prose { padding: 24px 0 56px; }
.site-prose--wide { max-width: 100%; }
.prose-section--muted { padding: 20px; }
.owner-summary-grid { grid-template-columns: 1fr; }
.owner-dashboard-grid { grid-template-columns: 1fr; }
.owner-panel--map,
.owner-panel--packets { grid-row: auto; grid-column: auto; }
.owner-alerts,
.owner-list { grid-template-columns: 1fr; }
.owner-list__row { grid-template-columns: 1fr; }
.owner-list__metrics { justify-content: flex-start; }
.owner-roadmap { grid-template-columns: 1fr; }
.owner-head { flex-direction: column; align-items: flex-start; }
.owner-map { height: 260px; }
}
@media (max-width: 1180px) {
.owner-summary-grid.site-stats-grid--6 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.owner-dashboard-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 860px) {
.owner-summary-grid.site-stats-grid--6 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.owner-dashboard-grid {
grid-template-columns: 1fr;
grid-auto-rows: minmax(280px, auto);
}
}
/* ─── Stats page ─────────────────────────────────────────────────────────── */
.stats-page__summary {
display: grid;
+241 -86
View File
@@ -40,18 +40,29 @@ SRTM_DIR = Path(os.environ.get('SRTM_DIR', '/data/srtm'))
REDIS_URL = os.environ.get('REDIS_URL', 'redis://redis:6379')
DATABASE_URL = os.environ.get('DATABASE_URL')
WORKER_MODE = os.environ.get('WORKER_MODE', 'all').lower()
COVERAGE_MODEL = os.environ.get('COVERAGE_MODEL', 'rf_radial_100m').lower()
JOB_QUEUE = 'meshcore:viewshed_jobs'
LINK_JOB_QUEUE = 'meshcore:link_jobs'
LIVE_CHANNEL = 'meshcore:live'
ANTENNA_HEIGHT_M = 5 # observer height above ground (m) — fixed 5 m antenna
ANTENNA_HEIGHT_M = 5 # source repeater antenna above ground (m)
COVERAGE_TARGET_HEIGHT_M = 5 # target repeater antenna above ground (m) for coverage polygons
COVERAGE_MODEL_VERSION = int(os.environ.get(
'COVERAGE_MODEL_VERSION',
'3' if COVERAGE_MODEL == 'rf_radial_100m' else '2',
))
MIN_LINK_OBSERVATIONS = 5 # must match backend db/index.ts
PREFIX_AMBIGUITY_RADIUS_KM = 45.0 # only penalize same-prefix ambiguity when nodes are realistically in range
MAX_RADIUS_M = 100_000 # absolute cap on viewshed radius (m)
SIMPLIFY_DEG = 0.001 # Douglas-Peucker tolerance (~100 m)
N_RAYS = 720 # number of radial rays cast from the observer
STEP_M = 50.0 # ray step size in metres
ANGLE_EPS = 1e-9 # numerical tolerance for horizon comparisons
RF_RADIAL_STEP_M = 100.0 # radial search precision for RF coverage mode
RF_N_RAYS = 360 # 1-degree azimuth resolution keeps RF mode tractable
RF_RADIUS_MULTIPLIER = 1.35 # search beyond geometric horizon to allow limited diffraction gain
RF_MIN_RADIUS_M = 20_000 # avoid under-searching low-elevation repeaters
# Radio horizon parameters
K_FACTOR = 4 / 3 # effective Earth radius multiplier (standard troposphere)
@@ -64,6 +75,11 @@ LAMBDA_M = 3e8 / (FREQ_MHZ * 1e6) # wavelength ~0.345 m
LINK_BUDGET_DB = 148.0 # 17 dBm TX + ~130 dBm RX sensitivity (LoRa SF10 BW125) +1 dB SRTM DSM correction
FADE_MARGIN_DB = 10.0 # safety / link margin
PROFILE_STEP_M = 250.0 # terrain profile sample spacing (m)
SIGNAL_THRESHOLDS_DB = {
'green': 120.0,
'amber': 135.0,
'red': LINK_BUDGET_DB - FADE_MARGIN_DB,
}
def compute_path_loss(lat1: float, lon1: float, elev1: float,
lat2: float, lon2: float, elev2: float,
@@ -82,7 +98,6 @@ def compute_path_loss(lat1: float, lon1: float, elev1: float,
if d_total < 1.0:
return 0.0, True
# Free-space path loss (dB)
fspl = 20 * math.log10(4 * math.pi * d_total / LAMBDA_M)
# Terrain profile: N evenly-spaced samples along the path
@@ -128,6 +143,46 @@ def compute_path_loss(lat1: float, lon1: float, elev1: float,
v = excess_h * math.sqrt(2 * (d1 + d2) / (LAMBDA_M * d1 * d2))
max_v = max(max_v, v)
return compute_path_loss_from_profile(
np.asarray(dists, dtype=np.float32),
np.asarray(heights, dtype=np.float32),
h_tx,
h_rx,
)
def compute_path_loss_from_profile(dists: np.ndarray,
heights: np.ndarray,
h_tx: float,
h_rx: float) -> tuple[float, bool]:
d_total = float(dists[-1]) if len(dists) else 0.0
if d_total < 1.0:
return 0.0, True
# Free-space path loss (dB)
fspl = 20 * math.log10(4 * math.pi * d_total / LAMBDA_M)
if len(dists) <= 2:
viable = fspl < LINK_BUDGET_DB - FADE_MARGIN_DB
return fspl, viable
d1 = dists[1:-1].astype(np.float64)
d2 = d_total - d1
valid = (d1 > 0) & (d2 > 0)
if not np.any(valid):
viable = fspl < LINK_BUDGET_DB - FADE_MARGIN_DB
return fspl, viable
d1 = d1[valid]
d2 = d2[valid]
profile_h = heights[1:-1].astype(np.float64)[valid]
los_h = h_tx + (h_rx - h_tx) * (d1 / d_total)
earth_bulge = (d1 * d2) / (2 * K_FACTOR * R_EARTH_M)
excess_h = profile_h + earth_bulge - los_h
with np.errstate(divide='ignore', invalid='ignore'):
vs = excess_h * np.sqrt(2 * (d1 + d2) / (LAMBDA_M * d1 * d2))
max_v = float(np.max(vs)) if vs.size else -999.0
# ITU-R P.526 knife-edge diffraction loss (dB)
if max_v <= -0.78:
diff_loss = 0.0
@@ -141,6 +196,112 @@ def compute_path_loss(lat1: float, lon1: float, elev1: float,
return total_loss, viable
def resolve_rf_radial_boundaries(lat: float,
lon: float,
elev: np.ndarray,
gt: tuple[float, float, float, float, float, float],
observer_h: float,
base_radius_m: float) -> tuple[dict[str, list[tuple[float, float]]], float]:
search_radius_m = min(MAX_RADIUS_M, max(base_radius_m * RF_RADIUS_MULTIPLIER, RF_MIN_RADIUS_M))
n_rows, n_cols = elev.shape
dpmlat = 1.0 / 111_320.0
dpmlon = 1.0 / (111_320.0 * math.cos(math.radians(lat)))
ds_arr = np.arange(RF_RADIAL_STEP_M, search_radius_m + RF_RADIAL_STEP_M, RF_RADIAL_STEP_M, dtype=np.float32)
thetas = np.linspace(0.0, 2.0 * math.pi, RF_N_RAYS, endpoint=False, dtype=np.float32)
cos_t = np.cos(thetas)
sin_t = np.sin(thetas)
boundaries: dict[str, list[tuple[float, float]]] = {key: [] for key in SIGNAL_THRESHOLDS_DB}
max_reached = 0.0
for theta_idx in range(RF_N_RAYS):
pt_lats = lat + sin_t[theta_idx] * ds_arr * dpmlat
pt_lons = lon + cos_t[theta_idx] * ds_arr * dpmlon
pxs = np.clip(((pt_lons - gt[0]) / gt[1]).astype(np.int32), 0, n_cols - 1)
pys = np.clip(((pt_lats - gt[3]) / gt[5]).astype(np.int32), 0, n_rows - 1)
hs = elev[pys, pxs].astype(np.float32)
losses: list[float] = []
for idx in range(len(ds_arr)):
dists = ds_arr[:idx + 1]
heights = hs[:idx + 1]
h_rx = float(heights[-1]) + COVERAGE_TARGET_HEIGHT_M
loss, _viable = compute_path_loss_from_profile(dists, heights, observer_h, h_rx)
losses.append(loss)
losses_arr = np.asarray(losses, dtype=np.float32)
for band, threshold in SIGNAL_THRESHOLDS_DB.items():
passing = np.where(losses_arr <= threshold)[0]
if passing.size < 1:
end_dist = float(ds_arr[0])
else:
end_dist = float(ds_arr[int(passing[-1])])
if band == 'red':
max_reached = max(max_reached, end_dist)
boundaries[band].append((
lon + float(cos_t[theta_idx]) * end_dist * dpmlon,
lat + float(sin_t[theta_idx]) * end_dist * dpmlat,
))
for band_boundary in boundaries.values():
if band_boundary:
band_boundary.append(band_boundary[0])
return boundaries, max_reached
def clip_and_simplify_polygon(poly) -> Optional[dict]:
if poly.is_empty:
return None
if not poly.is_valid:
poly = poly.buffer(0)
if UK_MAINLAND is not None:
poly = poly.intersection(UK_MAINLAND)
if poly.is_empty:
return None
result = poly.simplify(SIMPLIFY_DEG, preserve_topology=True)
if result.is_empty or result.geom_type not in ('Polygon', 'MultiPolygon'):
return None
return mapping(result)
def build_exclusive_strength_geoms(band_polys: dict[str, ShapelyPolygon]) -> dict[str, dict]:
"""Convert nested strength polygons into exclusive green/amber/red areas.
The strongest band should own the fill for a location. Without this, the
frontend ends up stacking green over amber over red and the center reads as
muddy yellow instead of a clean strength gradient.
"""
exclusive: dict[str, dict] = {}
green_poly = band_polys.get('green')
if green_poly is not None and not green_poly.is_empty:
clipped_green = clip_and_simplify_polygon(green_poly)
if clipped_green is not None:
exclusive['green'] = clipped_green
amber_poly = band_polys.get('amber')
if amber_poly is not None and not amber_poly.is_empty:
amber_only = amber_poly
if green_poly is not None and not green_poly.is_empty:
amber_only = amber_only.difference(green_poly)
clipped_amber = clip_and_simplify_polygon(amber_only)
if clipped_amber is not None:
exclusive['amber'] = clipped_amber
red_poly = band_polys.get('red')
if red_poly is not None and not red_poly.is_empty:
red_only = red_poly
if amber_poly is not None and not amber_poly.is_empty:
red_only = red_only.difference(amber_poly)
elif green_poly is not None and not green_poly.is_empty:
red_only = red_only.difference(green_poly)
clipped_red = clip_and_simplify_polygon(red_only)
if clipped_red is not None:
exclusive['red'] = clipped_red
return exclusive
def build_link_vrt(lat1: float, lon1: float, lat2: float, lon2: float,
tmp_dir: str) -> Optional[str]:
"""Build a GDAL VRT from already-cached SRTM tiles covering the path.
@@ -260,7 +421,7 @@ 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, float]]:
def calculate_viewshed(node_id: str, lat: float, lon: float) -> Optional[tuple[dict, dict[str, dict], float, float]]:
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
@@ -338,106 +499,97 @@ def calculate_viewshed(node_id: str, lat: float, lon: float) -> Optional[tuple[d
f'horizon={radius_m / 1000:.1f} km'
)
# 6. Vectorised raycasting viewshed.
#
# For each of N_RAYS directions, walk outward in STEP_M increments tracking
# the maximum "elevation angle" seen so far (corrected for Earth curvature).
# When a step's angle falls below the running maximum, the terrain at that
# step is in the shadow of an earlier ridge → the ray terminates.
# The stop-point of each ray becomes a vertex of the coverage boundary.
#
# Elevation angle formula:
# angle(d) = (terrain_h - observer_h - d² / (2·k·R)) / d
# where the d²/(2kR) term accounts for Earth's curvature under the ray.
# A ray is blocked when angle(d) < running_max — no wrap-around artefacts.
observer_h = elevation_m + ANTENNA_HEIGHT_M
dpmlat = 1.0 / 111_320.0 # deg/m northward
dpmlon = 1.0 / (111_320.0 * math.cos(math.radians(lat))) # deg/m eastward
R_eff_2 = 2.0 * K_FACTOR * R_EARTH_M # 2kR curvature denom
strength_geoms: dict[str, dict] = {}
if COVERAGE_MODEL == 'terrain_los':
# Vectorised raycasting terrain line-of-sight model.
dpmlat = 1.0 / 111_320.0 # deg/m northward
dpmlon = 1.0 / (111_320.0 * math.cos(math.radians(lat))) # deg/m eastward
R_eff_2 = 2.0 * K_FACTOR * R_EARTH_M # 2kR curvature denom
n_steps = max(1, int(radius_m / STEP_M))
ds_arr = np.linspace(STEP_M, radius_m, n_steps) # (M,) distances in metres
thetas = np.linspace(0.0, 2.0 * math.pi, N_RAYS, endpoint=False) # (N,) angles
n_steps = max(1, int(radius_m / STEP_M))
ds_arr = np.linspace(STEP_M, radius_m, n_steps) # (M,) distances in metres
thetas = np.linspace(0.0, 2.0 * math.pi, N_RAYS, endpoint=False) # (N,) angles
# Ray sample coordinates: (N, M)
sin_t = np.sin(thetas)[:, None] # (N, 1)
cos_t = np.cos(thetas)[:, None] # (N, 1)
pt_lats = lat + sin_t * ds_arr[None, :] * dpmlat # (N, M)
pt_lons = lon + cos_t * ds_arr[None, :] * dpmlon # (N, M)
# Ray sample coordinates: (N, M)
sin_t = np.sin(thetas)[:, None] # (N, 1)
cos_t = np.cos(thetas)[:, None] # (N, 1)
pt_lats = lat + sin_t * ds_arr[None, :] * dpmlat # (N, M)
pt_lons = lon + cos_t * ds_arr[None, :] * dpmlon # (N, M)
# Pixel indices — clamped to raster bounds (N, M)
# gt[0]=x_origin (lon), gt[1]=px_width (deg/px), gt[3]=y_origin (lat), gt[5]=px_height (<0)
pxs = np.clip(((pt_lons - gt[0]) / gt[1]).astype(np.int32), 0, n_cols - 1)
pys = np.clip(((pt_lats - gt[3]) / gt[5]).astype(np.int32), 0, n_rows - 1)
# Pixel indices — clamped to raster bounds (N, M)
pxs = np.clip(((pt_lons - gt[0]) / gt[1]).astype(np.int32), 0, n_cols - 1)
pys = np.clip(((pt_lats - gt[3]) / gt[5]).astype(np.int32), 0, n_rows - 1)
# Terrain heights at each ray step: (N, M)
hs = elev[pys, pxs]
# Terrain heights at each ray step: (N, M)
hs = elev[pys, pxs]
# Elevation angles with Earth-curvature correction: (N, M)
angles = (hs - observer_h - ds_arr[None, :] ** 2 / R_eff_2) / ds_arr[None, :]
# Angles with Earth-curvature correction: (N, M)
curvature = ds_arr[None, :] ** 2 / R_eff_2
terrain_angles = (hs - observer_h - curvature) / ds_arr[None, :]
target_angles = ((hs + COVERAGE_TARGET_HEIGHT_M) - observer_h - curvature) / ds_arr[None, :]
# Running max along each ray — only terrain AT OR ABOVE the observer
# height can establish a blocking horizon. Terrain below the observer
# always lets the ray "see past" it; coverage is only cut off when
# something taller than the node rises into the line of sight.
blocking = np.where(hs >= observer_h, angles, -np.inf) # (N, M)
running_max = np.maximum.accumulate(blocking, axis=1) # (N, M)
running_max = np.maximum.accumulate(terrain_angles, axis=1)
prev_max = np.concatenate([np.full((N_RAYS, 1), -np.inf), running_max[:, :-1]], axis=1)
in_shadow = target_angles + ANGLE_EPS < prev_max
# "Previous" running max — shift one step so we compare current angle with
# the max established BEFORE this step. First column = -inf (never blocked).
prev_max = np.concatenate([np.full((N_RAYS, 1), -np.inf), running_max[:, :-1]], axis=1)
in_shadow = angles < prev_max # (N, M): True where ray is terrain-blocked
has_shadow = in_shadow.any(axis=1)
first_shad = np.where(has_shadow, in_shadow.argmax(axis=1), n_steps)
last_js = np.clip(first_shad - 1, 0, n_steps - 1)
last_ds = ds_arr[last_js]
# Index of first shadow step per ray; n_steps if never shadowed.
has_shadow = in_shadow.any(axis=1) # (N,)
first_shad = np.where(has_shadow, in_shadow.argmax(axis=1), n_steps) # (N,)
last_js = np.clip(first_shad - 1, 0, n_steps - 1) # (N,)
last_ds = ds_arr[last_js] # (N,)
# Build boundary ring in (lon, lat) GeoJSON order.
lons_b = lon + np.cos(thetas) * last_ds * dpmlon # (N,)
lats_b = lat + np.sin(thetas) * last_ds * dpmlat # (N,)
boundary = list(zip(lons_b.tolist(), lats_b.tolist()))
boundary.append(boundary[0]) # close ring
poly = ShapelyPolygon(boundary)
if not poly.is_valid:
poly = poly.buffer(0)
# 7. Clip to UK mainland — removes coverage that extends into the sea.
if UK_MAINLAND is not None:
poly = poly.intersection(UK_MAINLAND)
if poly.is_empty:
log.warning(f'{node_id}: viewshed entirely at sea — skipping')
lons_b = lon + np.cos(thetas) * last_ds * dpmlon
lats_b = lat + np.sin(thetas) * last_ds * dpmlat
boundary = list(zip(lons_b.tolist(), lats_b.tolist()))
boundary.append(boundary[0])
poly = ShapelyPolygon(boundary)
clipped = clip_and_simplify_polygon(poly)
if clipped is None:
log.warning(f'{node_id}: degenerate geometry after clipping — skipping')
return None
# 8. Simplify (~100 m tolerance) to reduce stored polygon size.
result = poly.simplify(SIMPLIFY_DEG, preserve_topology=True)
if result.is_empty or result.geom_type not in ('Polygon', 'MultiPolygon'):
log.warning(f'{node_id}: degenerate geometry after clipping — skipping')
return None
return mapping(result), radius_m, elevation_m
geom = clipped
strength_geoms = {'green': geom}
elif COVERAGE_MODEL == 'rf_radial_100m':
band_boundaries, radius_m = resolve_rf_radial_boundaries(lat, lon, elev, gt, observer_h, radius_m)
raw_band_polys: dict[str, ShapelyPolygon] = {}
for band, band_boundary in band_boundaries.items():
if len(band_boundary) < 4:
continue
band_poly = ShapelyPolygon(band_boundary)
if not band_poly.is_empty:
raw_band_polys[band] = band_poly
strength_geoms = build_exclusive_strength_geoms(raw_band_polys)
geom = clip_and_simplify_polygon(raw_band_polys.get('red')) if raw_band_polys.get('red') is not None else None
if geom is None:
log.warning(f'{node_id}: degenerate RF coverage geometry after clipping — skipping')
return None
else:
raise ValueError(f'Unknown COVERAGE_MODEL={COVERAGE_MODEL}')
return geom, strength_geoms, radius_m, elevation_m
# ── DB helpers ────────────────────────────────────────────────────────────────
def already_calculated(db, node_id: str) -> bool:
with db.cursor() as cur:
cur.execute('SELECT 1 FROM node_coverage WHERE node_id = %s', (node_id,))
cur.execute(
'SELECT 1 FROM node_coverage WHERE node_id = %s AND model_version >= %s',
(node_id, COVERAGE_MODEL_VERSION),
)
return cur.fetchone() is not None
def store_coverage(db, node_id: str, geom: dict, radius_m: float, elevation_m: float):
def store_coverage(db, node_id: str, geom: dict, strength_geoms: dict[str, dict], radius_m: float, elevation_m: float):
with db.cursor() as cur:
cur.execute(
'''INSERT INTO node_coverage (node_id, geom, antenna_height_m, radius_m)
VALUES (%s, %s::jsonb, %s, %s)
'''INSERT INTO node_coverage (node_id, geom, strength_geoms, antenna_height_m, radius_m, model_version)
VALUES (%s, %s::jsonb, %s::jsonb, %s, %s, %s)
ON CONFLICT (node_id) DO UPDATE
SET geom = EXCLUDED.geom,
strength_geoms = EXCLUDED.strength_geoms,
antenna_height_m = EXCLUDED.antenna_height_m,
radius_m = EXCLUDED.radius_m,
model_version = EXCLUDED.model_version,
calculated_at = NOW()''',
(node_id, json.dumps(geom), ANTENNA_HEIGHT_M, radius_m),
(node_id, json.dumps(geom), json.dumps(strength_geoms), ANTENNA_HEIGHT_M, radius_m, COVERAGE_MODEL_VERSION),
)
cur.execute(
'UPDATE nodes SET elevation_m = %s WHERE node_id = %s',
@@ -695,13 +847,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 nc.node_id IS NULL
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, '%🚫%',))
rows = cur.fetchall()
if rows:
log.info(f'Queuing {len(rows)} existing node(s) for viewshed calculation')
log.info(f'Queuing {len(rows)} existing node(s) for viewshed calculation (model v{COVERAGE_MODEL_VERSION})')
for node_id, lat, lon in rows:
r_client.lpush(JOB_QUEUE, json.dumps({'node_id': node_id, 'lat': lat, 'lon': lon}))
@@ -735,13 +887,13 @@ def process_job(db, r_client, job: dict):
if result is None:
return
geom, radius_m, elevation_m = result
store_coverage(db, node_id, geom, radius_m, elevation_m)
geom, strength_geoms, radius_m, elevation_m = result
store_coverage(db, node_id, geom, strength_geoms, radius_m, elevation_m)
log.info(f'Done in {time.time() - t0:.1f}s — notifying frontend')
r_client.publish(LIVE_CHANNEL, json.dumps({
'type': 'coverage_update',
'data': {'node_id': node_id, 'geom': geom},
'data': {'node_id': node_id, 'geom': geom, 'strength_geoms': strength_geoms},
'ts': int(time.time() * 1000),
}))
r_client.publish(LIVE_CHANNEL, json.dumps({
@@ -805,7 +957,10 @@ def worker_loop():
log.error(f'{name}: job error: {exc}', exc_info=True)
def main():
log.info(f'Viewshed worker starting (mode={WORKER_MODE})')
log.info(
f'Viewshed worker starting (mode={WORKER_MODE}, '
f'coverage_model={COVERAGE_MODEL}, model_version={COVERAGE_MODEL_VERSION})'
)
SRTM_DIR.mkdir(parents=True, exist_ok=True)
# Connect once just to enqueue any nodes that lack coverage, then hand off