mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-22 22:11:31 +00:00
Improve owner dashboard telemetry and bare-domain support
- narrow owner last-hop telemetry to the selected MQTT node and cache it per node with hourly background refresh - tighten repeater-only last-hop classification and clean up the owner chart selector/filtering - fix owner direct-sender map basemap, static behavior, and viewport fitting/centering - allow bare ukmesh.com and teessidemesh.com host handling in backend/frontend config
This commit is contained in:
1 parent
414945d875
commit
7d57943b60
11 files changed
+202
-59
No files matched your search
@@ -18,7 +18,7 @@ import {
|
||||
} from './bootstrap/caches.js';
|
||||
import { getNodes, getNodeHistory, getNodeAdverts, getPathHistoryCache, getRecentPacketEvents, getRecentPackets, query } from '../db/index.js';
|
||||
import { resolveRequestNetwork } from '../http/requestScope.js';
|
||||
import { autoLinkOwnerNodeIds, buildOwnerDashboard, resolveOwnerNodeIds, verifyMqttCredentials } from '../owner/ownerAccess.js';
|
||||
import { autoLinkOwnerNodeIds, buildOwnerDashboard, listMappedOwnerNodeIds, resolveOwnerNodeIds, verifyMqttCredentials } from '../owner/ownerAccess.js';
|
||||
import { encryptOwnerSession, getOwnerSession, isSecureRequest } from '../owner/ownerSession.js';
|
||||
import { getResolveCache, setResolveCache } from '../path-beta/resolveCache.js';
|
||||
import { resolvePool } from '../path-beta/resolvePool.js';
|
||||
@@ -52,6 +52,7 @@ router.use(nodeStatusRoutes);
|
||||
router.use(radioRoutes);
|
||||
const OWNER_COOKIE_NAME = 'meshcore_owner_session';
|
||||
const OWNER_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const OWNER_LAST_HOP_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
const MQTT_USERNAME_MAX_LEN = 128;
|
||||
const MQTT_PASSWORD_MAX_LEN = 128;
|
||||
|
||||
@@ -99,6 +100,7 @@ registerOwnerRoutes(router, {
|
||||
ownerCookieName: OWNER_COOKIE_NAME,
|
||||
ownerLiveCacheTtlMs: OWNER_LIVE_CACHE_TTL_MS,
|
||||
ownerLiveCache,
|
||||
ownerLastHopCacheTtlMs: OWNER_LAST_HOP_CACHE_TTL_MS,
|
||||
ownerSessionTtlMs: OWNER_SESSION_TTL_MS,
|
||||
mqttUsernameMaxLen: MQTT_USERNAME_MAX_LEN,
|
||||
mqttPasswordMaxLen: MQTT_PASSWORD_MAX_LEN,
|
||||
@@ -107,6 +109,7 @@ registerOwnerRoutes(router, {
|
||||
verifyMqttCredentials,
|
||||
resolveOwnerNodeIds,
|
||||
autoLinkOwnerNodeIds,
|
||||
listMappedOwnerNodeIds,
|
||||
buildOwnerDashboard,
|
||||
encryptOwnerSession,
|
||||
isSecureRequest,
|
||||
|
||||
@@ -27,6 +27,7 @@ type OwnerRouteDeps = {
|
||||
ownerCookieName: string;
|
||||
ownerLiveCacheTtlMs: number;
|
||||
ownerLiveCache: Map<string, OwnerLiveCacheEntry>;
|
||||
ownerLastHopCacheTtlMs: number;
|
||||
ownerSessionTtlMs: number;
|
||||
mqttUsernameMaxLen: number;
|
||||
mqttPasswordMaxLen: number;
|
||||
@@ -35,6 +36,7 @@ type OwnerRouteDeps = {
|
||||
verifyMqttCredentials: VerifyMqttCredentialsFn;
|
||||
resolveOwnerNodeIds: ResolveOwnerNodeIdsFn;
|
||||
autoLinkOwnerNodeIds: ResolveOwnerNodeIdsFn;
|
||||
listMappedOwnerNodeIds: () => Promise<string[]>;
|
||||
buildOwnerDashboard: BuildOwnerDashboardFn;
|
||||
encryptOwnerSession: EncryptOwnerSessionFn;
|
||||
isSecureRequest: IsSecureRequestFn;
|
||||
@@ -51,9 +53,11 @@ export function registerOwnerRoutes(router: Router, deps: OwnerRouteDeps): void
|
||||
const service = createOwnerService({
|
||||
ownerLiveCacheTtlMs: deps.ownerLiveCacheTtlMs,
|
||||
ownerLiveCache: deps.ownerLiveCache,
|
||||
ownerLastHopCacheTtlMs: deps.ownerLastHopCacheTtlMs,
|
||||
verifyMqttCredentials: deps.verifyMqttCredentials,
|
||||
resolveOwnerNodeIds: deps.resolveOwnerNodeIds,
|
||||
autoLinkOwnerNodeIds: deps.autoLinkOwnerNodeIds,
|
||||
listMappedOwnerNodeIds: deps.listMappedOwnerNodeIds,
|
||||
buildOwnerDashboard: deps.buildOwnerDashboard,
|
||||
repository,
|
||||
});
|
||||
|
||||
@@ -32,8 +32,8 @@ export function inferForcedNetwork(headers: IncomingHttpHeaders): ForcedScope |
|
||||
const candidates = [host, origin, referer];
|
||||
|
||||
if (candidates.some((value) => value === 'test.ukmesh.com' || value.endsWith('.test.ukmesh.com'))) return 'test';
|
||||
if (candidates.some((value) => value === 'app.ukmesh.com' || value === 'www.ukmesh.com')) return 'all';
|
||||
if (candidates.some((value) => value === 'app.teessidemesh.com' || value === 'www.teessidemesh.com')) return 'teesside';
|
||||
if (candidates.some((value) => value === 'app.ukmesh.com' || value === 'www.ukmesh.com' || value === 'ukmesh.com')) return 'all';
|
||||
if (candidates.some((value) => value === 'app.teessidemesh.com' || value === 'www.teessidemesh.com' || value === 'teessidemesh.com')) return 'teesside';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import mqtt from 'mqtt';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { addOwnerNodeForUsername, getBestNodeForMqttUsername, getOwnerNodeIdsForUsername } from '../db/ownerAuth.js';
|
||||
import { addOwnerNodeForUsername, getBestNodeForMqttUsername, getMappedOwnerNodeIds, getOwnerNodeIdsForUsername } from '../db/ownerAuth.js';
|
||||
import { query } from '../db/index.js';
|
||||
|
||||
function parseOwnerMqttUsernameMap(): Map<string, string[]> {
|
||||
@@ -44,6 +44,10 @@ export async function autoLinkOwnerNodeIds(mqttUsername: string): Promise<string
|
||||
return [nodeId];
|
||||
}
|
||||
|
||||
export async function listMappedOwnerNodeIds(): Promise<string[]> {
|
||||
return getMappedOwnerNodeIds();
|
||||
}
|
||||
|
||||
export 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')}`;
|
||||
|
||||
@@ -60,7 +60,7 @@ export function createOwnerRepository(deps: OwnerRepositoryDeps) {
|
||||
ROW_NUMBER() OVER (PARTITION BY uh.rx_node_id, uh.receiver_side_hash ORDER BY n.node_id) AS rn
|
||||
FROM unique_receiver_targets uh
|
||||
JOIN nodes n
|
||||
ON (n.role IS NULL OR n.role = 2)
|
||||
ON (n.role IS NULL OR n.role NOT IN (1, 3))
|
||||
AND UPPER(n.node_id) LIKE uh.receiver_side_hash || '%'
|
||||
),
|
||||
inferred_last_hop AS (
|
||||
@@ -84,7 +84,7 @@ export function createOwnerRepository(deps: OwnerRepositoryDeps) {
|
||||
FROM unique_receiver_targets uh
|
||||
JOIN nodes rx ON rx.node_id = uh.rx_node_id
|
||||
JOIN nodes n
|
||||
ON (n.role IS NULL OR n.role = 2)
|
||||
ON (n.role IS NULL OR n.role NOT IN (1, 3))
|
||||
AND UPPER(LEFT(n.node_id, 2)) = UPPER(LEFT(uh.receiver_side_hash, 2))
|
||||
LEFT JOIN node_links nl
|
||||
ON (
|
||||
@@ -101,19 +101,34 @@ export function createOwnerRepository(deps: OwnerRepositoryDeps) {
|
||||
SELECT
|
||||
time_bucket('1 hour', op.time)::text AS bucket,
|
||||
CASE
|
||||
WHEN op.hop_count = 0 AND op.src_node_id IS NOT NULL AND NOT (op.src_node_id = ANY($1::text[])) THEN op.src_node_id
|
||||
WHEN op.hop_count = 0
|
||||
AND op.src_node_id IS NOT NULL
|
||||
AND NOT (op.src_node_id = ANY($1::text[]))
|
||||
AND src.node_id IS NOT NULL
|
||||
AND (src.role IS NULL OR src.role NOT IN (1, 3))
|
||||
THEN op.src_node_id
|
||||
WHEN rl.match_count = 1 AND rl.rn = 1 THEN rl.node_id
|
||||
WHEN ilh.rn = 1 THEN ilh.node_id
|
||||
ELSE NULL
|
||||
END AS last_hop_node_id,
|
||||
CASE
|
||||
WHEN op.hop_count = 0 AND op.src_node_id IS NOT NULL AND NOT (op.src_node_id = ANY($1::text[])) THEN COALESCE(src.name, op.src_node_id)
|
||||
WHEN op.hop_count = 0
|
||||
AND op.src_node_id IS NOT NULL
|
||||
AND NOT (op.src_node_id = ANY($1::text[]))
|
||||
AND src.node_id IS NOT NULL
|
||||
AND (src.role IS NULL OR src.role NOT IN (1, 3))
|
||||
THEN COALESCE(src.name, op.src_node_id)
|
||||
WHEN rl.match_count = 1 AND rl.rn = 1 THEN rl.name
|
||||
WHEN ilh.rn = 1 THEN ilh.name
|
||||
ELSE 'Unresolved'
|
||||
END AS last_hop_name,
|
||||
CASE
|
||||
WHEN op.hop_count = 0 AND op.src_node_id IS NOT NULL AND NOT (op.src_node_id = ANY($1::text[])) THEN 'direct'
|
||||
WHEN op.hop_count = 0
|
||||
AND op.src_node_id IS NOT NULL
|
||||
AND NOT (op.src_node_id = ANY($1::text[]))
|
||||
AND src.node_id IS NOT NULL
|
||||
AND (src.role IS NULL OR src.role NOT IN (1, 3))
|
||||
THEN 'direct'
|
||||
WHEN rl.match_count = 1 AND rl.rn = 1 THEN 'resolved'
|
||||
WHEN ilh.rn = 1 THEN 'inferred'
|
||||
ELSE 'unresolved'
|
||||
|
||||
@@ -25,9 +25,11 @@ type LastHopStrengthPoint = {
|
||||
type OwnerServiceDeps = {
|
||||
ownerLiveCacheTtlMs: number;
|
||||
ownerLiveCache: Map<string, OwnerLiveCacheEntry>;
|
||||
ownerLastHopCacheTtlMs: number;
|
||||
verifyMqttCredentials: (mqttUsername: string, mqttPassword: string) => Promise<boolean>;
|
||||
resolveOwnerNodeIds: (mqttUsername: string) => Promise<string[]>;
|
||||
autoLinkOwnerNodeIds: (mqttUsername: string) => Promise<string[]>;
|
||||
listMappedOwnerNodeIds: () => Promise<string[]>;
|
||||
buildOwnerDashboard: (nodeIds: string[]) => Promise<OwnerDashboard>;
|
||||
repository: OwnerRepository;
|
||||
};
|
||||
@@ -38,13 +40,59 @@ export function createOwnerService(deps: OwnerServiceDeps) {
|
||||
const {
|
||||
ownerLiveCacheTtlMs,
|
||||
ownerLiveCache,
|
||||
ownerLastHopCacheTtlMs,
|
||||
verifyMqttCredentials,
|
||||
resolveOwnerNodeIds,
|
||||
autoLinkOwnerNodeIds,
|
||||
listMappedOwnerNodeIds,
|
||||
buildOwnerDashboard,
|
||||
repository,
|
||||
} = deps;
|
||||
const ownerLastHopCache = new Map<string, OwnerLiveCacheEntry>();
|
||||
let ownerLastHopRefreshInFlight = false;
|
||||
|
||||
async function buildOwnerLastHopResponse(nodeId: string): Promise<{ points: LastHopStrengthPoint[] }> {
|
||||
const lastHopStrengthResult = await repository.fetchLastHopStrength([nodeId]);
|
||||
return {
|
||||
points: lastHopStrengthResult.rows.map((row) => ({
|
||||
bucket: new Date(row.bucket).toISOString(),
|
||||
lastHopNodeId: row.last_hop_node_id,
|
||||
lastHopName: row.last_hop_name,
|
||||
resolution: row.resolution,
|
||||
avgSnr: row.avg_snr == null ? null : Number(row.avg_snr.toFixed(2)),
|
||||
avgRssi: row.avg_rssi == null ? null : Number(row.avg_rssi.toFixed(2)),
|
||||
sampleCount: Number(row.sample_count ?? 0),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshOwnerLastHopCacheForNode(nodeId: string): Promise<void> {
|
||||
const responseData = await buildOwnerLastHopResponse(nodeId);
|
||||
ownerLastHopCache.set(nodeId, { ts: Date.now(), data: responseData });
|
||||
}
|
||||
|
||||
async function refreshAllOwnerLastHopCaches(tag: 'initial' | 'scheduled'): Promise<void> {
|
||||
if (ownerLastHopRefreshInFlight) {
|
||||
console.warn(`[owner-last-hop] ${tag} refresh skipped; previous refresh still running`);
|
||||
return;
|
||||
}
|
||||
ownerLastHopRefreshInFlight = true;
|
||||
try {
|
||||
const nodeIds = Array.from(new Set((await listMappedOwnerNodeIds()).map((nodeId) => nodeId.trim().toUpperCase()).filter(Boolean)));
|
||||
for (const nodeId of nodeIds) {
|
||||
try {
|
||||
await refreshOwnerLastHopCacheForNode(nodeId);
|
||||
} catch (err) {
|
||||
console.error(`[owner-last-hop] failed to refresh ${nodeId}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
console.log(`[owner-last-hop] ${tag} refresh complete for ${nodeIds.length} node(s)`);
|
||||
} catch (err) {
|
||||
console.error(`[owner-last-hop] ${tag} refresh failed`, (err as Error).message);
|
||||
} finally {
|
||||
ownerLastHopRefreshInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateOwner(mqttUsername: string, mqttPassword: string): Promise<{ dashboard: OwnerDashboard; nodeIds: string[] }> {
|
||||
const authOk = await verifyMqttCredentials(mqttUsername, mqttPassword);
|
||||
@@ -295,29 +343,23 @@ export function createOwnerService(deps: OwnerServiceDeps) {
|
||||
throw new Error('NODE_NOT_OWNED');
|
||||
}
|
||||
|
||||
const cacheKey = ownedNodeIds.slice().sort().join(',');
|
||||
const cacheKey = selectedNodeId;
|
||||
const cacheEntry = ownerLastHopCache.get(cacheKey);
|
||||
if (cacheEntry && Date.now() - cacheEntry.ts < 60_000) {
|
||||
if (cacheEntry && Date.now() - cacheEntry.ts < ownerLastHopCacheTtlMs) {
|
||||
return cacheEntry.data as { points: LastHopStrengthPoint[] };
|
||||
}
|
||||
|
||||
const lastHopStrengthResult = await repository.fetchLastHopStrength(ownedNodeIds);
|
||||
const responseData = {
|
||||
points: lastHopStrengthResult.rows.map((row) => ({
|
||||
bucket: new Date(row.bucket).toISOString(),
|
||||
lastHopNodeId: row.last_hop_node_id,
|
||||
lastHopName: row.last_hop_name,
|
||||
resolution: row.resolution,
|
||||
avgSnr: row.avg_snr == null ? null : Number(row.avg_snr.toFixed(2)),
|
||||
avgRssi: row.avg_rssi == null ? null : Number(row.avg_rssi.toFixed(2)),
|
||||
sampleCount: Number(row.sample_count ?? 0),
|
||||
})),
|
||||
};
|
||||
|
||||
const responseData = await buildOwnerLastHopResponse(selectedNodeId);
|
||||
ownerLastHopCache.set(cacheKey, { ts: Date.now(), data: responseData });
|
||||
return responseData;
|
||||
}
|
||||
|
||||
void refreshAllOwnerLastHopCaches('initial');
|
||||
const ownerLastHopRefreshTimer = setInterval(() => {
|
||||
void refreshAllOwnerLastHopCaches('scheduled');
|
||||
}, ownerLastHopCacheTtlMs);
|
||||
ownerLastHopRefreshTimer.unref?.();
|
||||
|
||||
return {
|
||||
authenticateOwner,
|
||||
getSessionDashboard,
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ services:
|
||||
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},https://test.ukmesh.com
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3001,http://localhost:3002,https://ukmesh.com,https://www.ukmesh.com,https://app.ukmesh.com,https://teessidemesh.com,https://www.teessidemesh.com,https://app.teessidemesh.com,https://test.ukmesh.com}
|
||||
RADIO_BOT_URL: ${RADIO_BOT_URL:-}
|
||||
COVERAGE_MODEL_VERSION: ${COVERAGE_MODEL_VERSION:-5}
|
||||
NODE_ENV: production
|
||||
|
||||
@@ -38,7 +38,7 @@ const SITE_CONFIGS: Record<SiteId, SiteConfig> = {
|
||||
network: 'ukmesh',
|
||||
networkFilter: 'ukmesh',
|
||||
appUrl: 'https://app.ukmesh.com',
|
||||
appHomeUrl: 'https://www.ukmesh.com',
|
||||
appHomeUrl: 'https://ukmesh.com',
|
||||
mapHomeUrl: 'https://app.ukmesh.com',
|
||||
},
|
||||
dev: {
|
||||
|
||||
@@ -1584,6 +1584,8 @@ html, body, #root {
|
||||
.owner-map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 280px;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
.owner-packets {
|
||||
display: flex;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { Area, AreaChart, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import { DEFAULT_CENTER, MAP_STYLE } from '../components/Map/mapConfig';
|
||||
|
||||
type OwnerNode = {
|
||||
node_id: string;
|
||||
@@ -260,12 +261,13 @@ const OwnerTelemetryTooltip: React.FC<{
|
||||
};
|
||||
|
||||
const LAST_HOP_COLORS = ['#00c4ff', '#6ddc7a', '#ff9f43', '#f472b6', '#a78bfa', '#facc15'];
|
||||
const MIN_LAST_HOP_SAMPLES = 10;
|
||||
|
||||
const LastHopStrengthTooltip: React.FC<{
|
||||
active?: boolean;
|
||||
payload?: Array<{ dataKey: string; value: number; color: string; payload: Record<string, unknown> }>;
|
||||
label?: string;
|
||||
seriesByKey: Map<string, { label: string; totalSamples: number }>;
|
||||
seriesByKey: Map<string, { label: string; totalSamples: number; bucketCount: number }>;
|
||||
}> = ({ active, payload, label, seriesByKey }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
@@ -294,23 +296,35 @@ const LastHopStrengthChart: React.FC<{ points: LastHopStrengthPoint[] }> = ({ po
|
||||
);
|
||||
|
||||
const chartState = useMemo(() => {
|
||||
const totals = new Map<string, { key: string; label: string; totalSamples: number; resolution: LastHopStrengthPoint['resolution'] }>();
|
||||
const totals = new Map<string, {
|
||||
key: string;
|
||||
label: string;
|
||||
totalSamples: number;
|
||||
bucketCount: number;
|
||||
buckets: Set<string>;
|
||||
resolution: LastHopStrengthPoint['resolution'];
|
||||
}>();
|
||||
for (const point of visiblePoints) {
|
||||
const key = point.lastHopNodeId ?? `unresolved:${point.lastHopName}`;
|
||||
const existing = totals.get(key);
|
||||
if (existing) {
|
||||
existing.totalSamples += point.sampleCount;
|
||||
existing.buckets.add(point.bucket);
|
||||
existing.bucketCount = existing.buckets.size;
|
||||
} else {
|
||||
totals.set(key, {
|
||||
key,
|
||||
label: point.lastHopName,
|
||||
totalSamples: point.sampleCount,
|
||||
bucketCount: 1,
|
||||
buckets: new Set([point.bucket]),
|
||||
resolution: point.resolution,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const allSeries = Array.from(totals.values())
|
||||
.filter((series) => series.totalSamples >= MIN_LAST_HOP_SAMPLES)
|
||||
.sort((a, b) => b.totalSamples - a.totalSamples || a.label.localeCompare(b.label))
|
||||
const selected = selectedSeriesKey
|
||||
? allSeries.filter((series) => series.key === selectedSeriesKey)
|
||||
@@ -352,11 +366,11 @@ const LastHopStrengthChart: React.FC<{ points: LastHopStrengthPoint[] }> = ({ po
|
||||
</div>
|
||||
</div>
|
||||
<div className="owner-telemetry-metric__chart">
|
||||
<div className="owner-telemetry-metric__empty">No received signal samples yet</div>
|
||||
<div className="owner-telemetry-metric__empty">No last-hop repeaters with 10+ samples yet</div>
|
||||
</div>
|
||||
<div className="owner-telemetry-metric__footer">
|
||||
<span>Last 7d</span>
|
||||
<span>No samples</span>
|
||||
<span>Minimum 10 samples</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -412,7 +426,7 @@ const LastHopStrengthChart: React.FC<{ points: LastHopStrengthPoint[] }> = ({ po
|
||||
</div>
|
||||
<div className="owner-telemetry-metric__footer">
|
||||
<span>Last 7d</span>
|
||||
<span>{selectedSeriesKey ? 'Filtered to one repeater' : `${chartState.allSeries.length} repeaters`}</span>
|
||||
<span>{selectedSeriesKey ? 'Filtered to one repeater' : `${chartState.allSeries.length} repeaters with 10+ samples`}</span>
|
||||
</div>
|
||||
<div className="owner-telemetry-metric__series-list">
|
||||
<button
|
||||
@@ -428,9 +442,9 @@ const LastHopStrengthChart: React.FC<{ points: LastHopStrengthPoint[] }> = ({ po
|
||||
type="button"
|
||||
className={`owner-telemetry-metric__series-btn${selectedSeriesKey === series.key ? ' owner-telemetry-metric__series-btn--active' : ''}`}
|
||||
onClick={() => setSelectedSeriesKey(series.key)}
|
||||
title={`${series.label} (${series.totalSamples} samples)`}
|
||||
title={`${series.label} · ${series.bucketCount} hourly point${series.bucketCount === 1 ? '' : 's'} · ${series.totalSamples} packets in last 7d`}
|
||||
>
|
||||
{series.label} ({series.totalSamples})
|
||||
{series.label} ({series.bucketCount}h)
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -522,8 +536,6 @@ const TelemetryStatCard: React.FC<{
|
||||
</article>
|
||||
);
|
||||
|
||||
const CARTO_DARK_TILES = 'https://{a-d}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
|
||||
const OwnerMapView: React.FC<{
|
||||
ownerCoord: { lat: number; lon: number } | null;
|
||||
peers: MappedPeer[];
|
||||
@@ -536,25 +548,60 @@ const OwnerMapView: React.FC<{
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: {
|
||||
version: 8,
|
||||
sources: { tiles: { type: 'raster', tiles: [CARTO_DARK_TILES], tileSize: 256, maxzoom: 19, attribution: '© OpenStreetMap © CARTO' } },
|
||||
layers: [{ id: 'bg', type: 'raster', source: 'tiles' }],
|
||||
},
|
||||
center: [-1.2, 54.6],
|
||||
style: MAP_STYLE,
|
||||
center: [DEFAULT_CENTER[1], DEFAULT_CENTER[0]],
|
||||
zoom: 7,
|
||||
attributionControl: false,
|
||||
});
|
||||
|
||||
// Disable interaction — purely static display
|
||||
// Keep the map as a static regional backdrop while preserving marker clicks.
|
||||
map.scrollZoom.disable();
|
||||
map.dragPan.disable();
|
||||
map.boxZoom.disable();
|
||||
map.dragPan.disable();
|
||||
map.dragRotate.disable();
|
||||
map.doubleClickZoom.disable();
|
||||
map.keyboard.disable();
|
||||
map.touchZoomRotate.disable();
|
||||
|
||||
const EMPTY: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] };
|
||||
const popup = new maplibregl.Popup({
|
||||
closeButton: true,
|
||||
closeOnClick: true,
|
||||
offset: 14,
|
||||
maxWidth: '260px',
|
||||
});
|
||||
|
||||
const applyViewport = () => {
|
||||
map.resize();
|
||||
if (allPoints.length === 1) {
|
||||
map.setCenter([allPoints[0].lon, allPoints[0].lat]);
|
||||
map.setZoom(8);
|
||||
return;
|
||||
}
|
||||
if (allPoints.length > 1) {
|
||||
const centerPoints = peers.length > 0 ? peers : allPoints;
|
||||
const centerLons = centerPoints.map((pt) => pt.lon);
|
||||
const centerLats = centerPoints.map((pt) => pt.lat);
|
||||
const centerLon = (Math.min(...centerLons) + Math.max(...centerLons)) / 2;
|
||||
const centerLat = (Math.min(...centerLats) + Math.max(...centerLats)) / 2;
|
||||
const maxLonDelta = Math.max(...allPoints.map((pt) => Math.abs(pt.lon - centerLon)));
|
||||
const maxLatDelta = Math.max(...allPoints.map((pt) => Math.abs(pt.lat - centerLat)));
|
||||
const lonExtent = Math.max(0.02, maxLonDelta * 2.25);
|
||||
const latExtent = Math.max(0.02, maxLatDelta * 2.25);
|
||||
const paddedBounds = new maplibregl.LngLatBounds(
|
||||
[centerLon - lonExtent, centerLat - latExtent],
|
||||
[centerLon + lonExtent, centerLat + latExtent],
|
||||
);
|
||||
const padding = { top: 24, right: 24, bottom: 24, left: 24 };
|
||||
const camera = map.cameraForBounds(paddedBounds, { padding });
|
||||
map.jumpTo({
|
||||
center: [centerLon, centerLat],
|
||||
zoom: camera?.zoom ?? map.getZoom(),
|
||||
bearing: 0,
|
||||
pitch: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const buildNodeFC = (): GeoJSON.FeatureCollection => ({
|
||||
type: 'FeatureCollection',
|
||||
@@ -562,12 +609,20 @@ const OwnerMapView: React.FC<{
|
||||
...(ownerCoord ? [{
|
||||
type: 'Feature' as const,
|
||||
geometry: { type: 'Point' as const, coordinates: [ownerCoord.lon, ownerCoord.lat] },
|
||||
properties: { kind: 'owner' },
|
||||
properties: {
|
||||
kind: 'owner',
|
||||
name: 'Selected owner node',
|
||||
details: ownerCoord ? `${ownerCoord.lat.toFixed(4)}, ${ownerCoord.lon.toFixed(4)}` : '',
|
||||
},
|
||||
}] : []),
|
||||
...peers.map((p) => ({
|
||||
type: 'Feature' as const,
|
||||
geometry: { type: 'Point' as const, coordinates: [p.lon, p.lat] },
|
||||
properties: { kind: 'peer' },
|
||||
properties: {
|
||||
kind: 'peer',
|
||||
name: p.name ?? p.node_id,
|
||||
details: `${p.packets_24h} packets / 24h${p.iata ? ` · ${p.iata}` : ''}${p.network ? ` · ${p.network}` : ''}`,
|
||||
},
|
||||
})),
|
||||
],
|
||||
});
|
||||
@@ -589,23 +644,42 @@ const OwnerMapView: React.FC<{
|
||||
paint: { 'line-color': '#00c4ff', 'line-width': 1.5, 'line-opacity': 0.6 } });
|
||||
map.addLayer({ id: 'owner-nodes-layer', type: 'circle', source: 'owner-nodes',
|
||||
paint: {
|
||||
'circle-radius': ['case', ['==', ['get', 'kind'], 'owner'], 8, 6],
|
||||
'circle-color': 'transparent',
|
||||
'circle-radius': ['case', ['==', ['get', 'kind'], 'owner'], 8, 6.5],
|
||||
'circle-color': ['case', ['==', ['get', 'kind'], 'owner'], 'rgba(0,196,255,0.18)', 'rgba(255,179,0,0.18)'],
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': ['case', ['==', ['get', 'kind'], 'owner'], '#00c4ff', '#ffb300'],
|
||||
} });
|
||||
|
||||
if (allPoints.length === 1) {
|
||||
map.setCenter([allPoints[0].lon, allPoints[0].lat]);
|
||||
map.setZoom(10);
|
||||
} else if (allPoints.length > 1) {
|
||||
const bounds = new maplibregl.LngLatBounds();
|
||||
for (const pt of allPoints) bounds.extend([pt.lon, pt.lat]);
|
||||
map.fitBounds(bounds, { padding: 24, animate: false });
|
||||
}
|
||||
map.on('mouseenter', 'owner-nodes-layer', () => {
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
});
|
||||
map.on('mouseleave', 'owner-nodes-layer', () => {
|
||||
map.getCanvas().style.cursor = '';
|
||||
});
|
||||
map.on('click', 'owner-nodes-layer', (event) => {
|
||||
const feature = event.features?.[0];
|
||||
if (!feature || feature.geometry.type !== 'Point') return;
|
||||
const coordinates = feature.geometry.coordinates.slice() as [number, number];
|
||||
const name = String(feature.properties?.name ?? 'Node');
|
||||
const details = String(feature.properties?.details ?? '');
|
||||
popup
|
||||
.setLngLat(coordinates)
|
||||
.setHTML(`<div class="node-popup"><div class="node-popup__title">${name}</div><div class="node-popup__meta">${details}</div></div>`)
|
||||
.addTo(map);
|
||||
});
|
||||
|
||||
applyViewport();
|
||||
requestAnimationFrame(applyViewport);
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
applyViewport();
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
popup.remove();
|
||||
map.remove();
|
||||
void EMPTY; // silence unused warning
|
||||
};
|
||||
@@ -932,10 +1006,7 @@ export const OwnerPortalPage: React.FC = () => {
|
||||
<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><h2>Direct Sender Map</h2></div>
|
||||
</div>
|
||||
<div className="owner-map-wrap">
|
||||
<OwnerMapView ownerCoord={ownerCoord} peers={mapPeers} allPoints={mapPoints} />
|
||||
|
||||
@@ -1624,6 +1624,8 @@ html, body, #root {
|
||||
.owner-map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 280px;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
.owner-packets {
|
||||
display: flex;
|
||||
|
||||
Reference in new issue
Block a user