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:
Ben committed 2026-03-21 00:16:21 +00:00
1 parent 414945d875
commit 7d57943b60
11 files changed
+202 -59

No files matched your search

+4 -1
View File
@@ -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,
+4
View File
@@ -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,
});
+2 -2
View File
@@ -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;
}
+5 -1
View File
@@ -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')}`;
+20 -5
View File
@@ -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'
+57 -15
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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: {
+2
View File
@@ -1584,6 +1584,8 @@ html, body, #root {
.owner-map {
width: 100%;
height: 100%;
min-height: 280px;
background: var(--bg-base);
}
.owner-packets {
display: flex;
+104 -33
View File
@@ -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} />
+2
View File
@@ -1624,6 +1624,8 @@ html, body, #root {
.owner-map {
width: 100%;
height: 100%;
min-height: 280px;
background: var(--bg-base);
}
.owner-packets {
display: flex;