mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-09-01 17:08:17 +00:00
Add owner auth DB, website refresh, and coverage rollback
This commit is contained in:
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
@@ -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)),
|
||||
));
|
||||
}
|
||||
@@ -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 ─────────────────────────────
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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,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,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
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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='© OpenStreetMap contributors © 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='© OpenStreetMap contributors © 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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user