From 1c8aadb339cdb2cb6755d41af09d34eda5ddbca3 Mon Sep 17 00:00:00 2001 From: gadgethd <111318106+gadgethd@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:17:28 +0100 Subject: [PATCH] Expand owner, packet, and map experiences --- frontend/package-lock.json | 80 +++++ frontend/package.json | 4 + frontend/src/App.tsx | 168 ++++++++-- .../components/FilterPanel/FilterPanel.tsx | 2 + frontend/src/components/LazyChartMount.tsx | 33 ++ frontend/src/components/LoadingIndicator.tsx | 3 + frontend/src/components/Map/DeckGLOverlay.tsx | 165 +++++++++- .../components/Map/LinkQualitySparkline.tsx | 35 +++ .../components/Map/LiveOverlayController.tsx | 35 ++- frontend/src/components/Map/MapLibreMap.tsx | 295 ++++++++++++------ .../src/components/Map/NodePopupContent.tsx | 28 +- frontend/src/components/Map/NodeSearch.tsx | 116 +++++-- .../src/components/Map/geojsonBuilders.ts | 32 ++ frontend/src/components/Map/mapConfig.ts | 3 +- frontend/src/components/Map/types.ts | 1 + .../components/ObserverRegistrationForm.tsx | 41 +++ frontend/src/components/PacketDetailPanel.tsx | 2 + frontend/src/components/PacketFeed.tsx | 121 ++++--- frontend/src/components/app/AppTopBar.tsx | 40 +++ .../src/components/app/MobileControls.tsx | 28 +- .../src/components/app/WatchlistPanel.tsx | 17 +- .../components/owner/OwnerPortalSections.tsx | 79 +++++ frontend/src/config/mapModes.ts | 4 + frontend/src/hooks/useAppMessageHandler.ts | 9 +- frontend/src/hooks/useDashboardStats.ts | 1 + frontend/src/hooks/useNodes.ts | 26 +- frontend/src/hooks/usePacketDetailData.ts | 152 +++++++++ frontend/src/hooks/useWebSocket.ts | 6 +- frontend/src/main.tsx | 24 ++ frontend/src/pages/OwnerPortalPage.tsx | 96 +++--- frontend/src/pages/SpamTransparencyPage.tsx | 62 +++- frontend/src/pages/StatsPage.tsx | 26 +- frontend/src/pages/StatusPage.tsx | 33 +- frontend/src/pages/TopologyPage.tsx | 68 +++- frontend/src/pages/network-intelligence.css | 7 + frontend/src/pages/owner-portal.css | 31 ++ frontend/src/pages/shared/SiteLayout.tsx | 15 + frontend/src/pages/site-content.css | 44 +++ frontend/src/pages/site-shell.css | 12 + frontend/src/pages/spam-page.css | 10 + frontend/src/pages/stats-page.css | 21 ++ .../src/pages/ukmesh/PacketDetailPanel.tsx | 149 +-------- frontend/src/pages/ukmesh/UKCompanionPage.tsx | 8 + frontend/src/pages/ukmesh/UKFeedPage.tsx | 66 ++-- frontend/src/pages/ukmesh/UKHomePage.tsx | 47 ++- frontend/src/pages/ukmesh/UKInstallPage.tsx | 39 ++- frontend/src/pages/ukmesh/UKLayout.tsx | 1 + .../src/pages/ukmesh/UKRepeaterSearchPage.tsx | 50 ++- frontend/src/pages/ukmesh/feed-page.css | 9 + frontend/src/store/overlayStore.ts | 8 +- frontend/src/styles/globals.css | 43 +++ frontend/src/styles/map-app.css | 89 +++++- frontend/src/styles/tokens.css | 71 +++++ frontend/src/utils/mapUrlState.ts | 2 + 54 files changed, 2074 insertions(+), 483 deletions(-) create mode 100644 frontend/src/components/LazyChartMount.tsx create mode 100644 frontend/src/components/Map/LinkQualitySparkline.tsx create mode 100644 frontend/src/components/ObserverRegistrationForm.tsx create mode 100644 frontend/src/components/PacketDetailPanel.tsx create mode 100644 frontend/src/components/owner/OwnerPortalSections.tsx create mode 100644 frontend/src/hooks/usePacketDetailData.ts create mode 100644 frontend/src/styles/tokens.css diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2a09a7b..b710906 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,10 +8,13 @@ "name": "meshcore-analytics-frontend", "version": "1.0.0", "dependencies": { + "@deck.gl/aggregation-layers": "^9.3.7", "@deck.gl/core": "^9.1.0", "@deck.gl/extensions": "^9.1.0", "@deck.gl/layers": "^9.1.0", "@deck.gl/mapbox": "^9.1.0", + "d3-force": "^3.0.0", + "fuse.js": "^7.5.0", "maplibre-gl": "^4.7.1", "polygon-clipping": "^0.15.7", "react": "^18.3.1", @@ -23,6 +26,7 @@ "devDependencies": { "@axe-core/playwright": "^4.12.1", "@playwright/test": "^1.61.1", + "@types/d3-force": "^3.0.10", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", @@ -314,6 +318,24 @@ "node": ">=6.9.0" } }, + "node_modules/@deck.gl/aggregation-layers": { + "version": "9.3.7", + "resolved": "https://registry.npmjs.org/@deck.gl/aggregation-layers/-/aggregation-layers-9.3.7.tgz", + "integrity": "sha512-eRzddMlHuBBFeSfhBig5V/32psav5JLvRYjuMqHgcwWXKfanAcxsKBfDSA4tzwWpnDU4id9sfdGW1RDzbVgY5Q==", + "license": "MIT", + "dependencies": { + "@luma.gl/shadertools": "^9.3.3", + "@math.gl/core": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "d3-hexbin": "^0.2.1" + }, + "peerDependencies": { + "@deck.gl/core": "~9.3.0", + "@deck.gl/layers": "~9.3.0", + "@luma.gl/core": "~9.3.3", + "@luma.gl/engine": "~9.3.3" + } + }, "node_modules/@deck.gl/core": { "version": "9.3.6", "resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.3.6.tgz", @@ -1510,6 +1532,13 @@ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -1900,6 +1929,15 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -1908,6 +1946,20 @@ "node": ">=12" } }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -1916,6 +1968,12 @@ "node": ">=12" } }, + "node_modules/d3-hexbin": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/d3-hexbin/-/d3-hexbin-0.2.2.tgz", + "integrity": "sha512-KS3fUT2ReD4RlGCjvCEm1RgMtp2NFZumdMu4DBzQK8AZv3fXRM6Xm8I4fSU07UXvH4xxg03NwWKWdvxfS/yc4w==", + "license": "BSD-3-Clause" + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -1935,6 +1993,15 @@ "node": ">=12" } }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -2148,6 +2215,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fuse.js": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.5.0.tgz", + "integrity": "sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0711c97..28c74fe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,10 +12,13 @@ "test:mobile": "playwright test mobile.spec.ts --project=mobile-regression" }, "dependencies": { + "@deck.gl/aggregation-layers": "^9.3.7", "@deck.gl/core": "^9.1.0", "@deck.gl/extensions": "^9.1.0", "@deck.gl/layers": "^9.1.0", "@deck.gl/mapbox": "^9.1.0", + "d3-force": "^3.0.0", + "fuse.js": "^7.5.0", "maplibre-gl": "^4.7.1", "polygon-clipping": "^0.15.7", "react": "^18.3.1", @@ -29,6 +32,7 @@ "@playwright/test": "^1.61.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/d3-force": "^3.0.10", "@vitejs/plugin-react": "^4.3.3", "tsx": "^4.23.1", "typescript": "^5.6.3", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e44f7d3..7ba5de6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,6 +38,7 @@ const DEFAULT_FILTERS: Filters = { terrain: false, clientNodes: false, packetHistory: false, + heatmap: false, betaPaths: false, betaPathThreshold: 0.45, hexClashes: false, @@ -46,6 +47,10 @@ const DEFAULT_FILTERS: Filters = { const DISCLAIMER_KEY = 'meshcore-disclaimer-dismissed'; const FILTERS_KEY = 'meshcore-app-filters-v3'; +const LEGACY_FILTERS_KEY = 'meshcore-app-filters-v2'; +const FILTERS_VERSION = 3; +const MAP_FETCH_TIMEOUT_MS = 4_000; +const OTHER_FETCH_TIMEOUT_MS = 15_000; const ignoreCoverageUpdate = () => {}; const TimelineControl = React.lazy(() => import('./components/app/TimelineControl.js').then((module) => ({ default: module.TimelineControl }))); const PlannerComparison = React.lazy(() => import('./components/app/PlannerComparison.js').then((module) => ({ default: module.PlannerComparison }))); @@ -55,8 +60,23 @@ export const App: React.FC = () => { const [filters, setFilters] = useState(() => { let stored = DEFAULT_FILTERS; try { - const raw = localStorage.getItem(FILTERS_KEY); - if (raw) stored = { ...DEFAULT_FILTERS, ...JSON.parse(raw) as Partial, betaPathThreshold: 0.45 }; + const raw = localStorage.getItem(FILTERS_KEY) ?? localStorage.getItem(LEGACY_FILTERS_KEY); + if (raw) { + const parsed = JSON.parse(raw) as { version?: number; filters?: Partial } & Partial; + const version = typeof parsed.version === 'number' + ? parsed.version + : (localStorage.getItem(FILTERS_KEY) ? FILTERS_VERSION : 2); + const migrated = version === FILTERS_VERSION + ? (parsed.filters ?? parsed) + : version === 2 + ? (parsed.filters ?? parsed) + : null; + if (migrated) { + stored = { ...DEFAULT_FILTERS, ...migrated, betaPathThreshold: 0.45 }; + localStorage.setItem(FILTERS_KEY, JSON.stringify({ version: FILTERS_VERSION, filters: stored })); + localStorage.removeItem(LEGACY_FILTERS_KEY); + } + } } catch { stored = DEFAULT_FILTERS; } @@ -72,8 +92,11 @@ export const App: React.FC = () => { }); const [initialMapView] = useState(() => initialMapViewFromUrl()); const [shareLabel, setShareLabel] = useState('Copy view link'); + const [annotation, setAnnotation] = useState(() => new URLSearchParams(window.location.search).get('note') ?? ''); const [selectedNodeId, setSelectedNodeId] = useState(() => new URLSearchParams(window.location.search).get('node')); const [filtersCollapsed, setFiltersCollapsed] = useState(() => !!new URLSearchParams(window.location.search).get('node')); + const [fullScreenMap, setFullScreenMap] = useState(false); + const [highContrast, setHighContrast] = useState(() => localStorage.getItem('meshcore-contrast') === 'high'); // MapLibre map instance — used by MobileControls/NodeSearch for flyTo const [mlMap, setMlMap] = useState(null); const [showDisclaimer, setShowDisclaimer] = useState(() => !localStorage.getItem(DISCLAIMER_KEY)); @@ -84,22 +107,27 @@ export const App: React.FC = () => { const [initialStateLoaded, setInitialStateLoaded] = useState(false); const [initialPollLoaded, setInitialPollLoaded] = useState(false); const [pollRefreshing, setPollRefreshing] = useState(false); + const [mapLight, setMapLight] = useState(() => localStorage.getItem('map-theme') === 'light'); + const [showShortcutGuide, setShowShortcutGuide] = useState(false); const [isPageVisible, setIsPageVisible] = useState( () => (typeof document === 'undefined' ? true : document.visibilityState === 'visible'), ); const clashRestoreRef = useRef<{ clientNodes: boolean } | null>(null); const prevHexClashesRef = useRef(DEFAULT_FILTERS.hexClashes); + const wsConnectedRef = useRef(false); - const networkFilter = site.networkFilter; + const requestedNetwork = new URLSearchParams(window.location.search).get('network'); + const networkFilter = requestedNetwork === 'teesside' || requestedNetwork === 'ukmesh' + ? requestedNetwork + : site.networkFilter; const observerFilter = site.observerId; const stats = useDashboardStats(fetchedStats); useEffect(() => { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('/sw.js').catch(() => {}); - } - }, []); + document.documentElement.dataset.contrast = highContrast ? 'high' : 'standard'; + localStorage.setItem('meshcore-contrast', highContrast ? 'high' : 'standard'); + }, [highContrast]); useEffect(() => { if (typeof document === 'undefined') return undefined; @@ -109,7 +137,7 @@ export const App: React.FC = () => { }, []); useEffect(() => { - localStorage.setItem(FILTERS_KEY, JSON.stringify(filters)); + localStorage.setItem(FILTERS_KEY, JSON.stringify({ version: FILTERS_VERSION, filters })); writeFiltersToUrl(filters, activeMode); }, [activeMode, filters]); @@ -162,6 +190,31 @@ export const App: React.FC = () => { window.setTimeout(() => setShareLabel('Copy view link'), 1800); }, [mlMap]); + const handleEditAnnotation = useCallback(() => { + const next = window.prompt('Add a short annotation to this shared map URL:', annotation); + if (next === null) return; + const clean = next.trim().slice(0, 240); + setAnnotation(clean); + const url = new URL(window.location.href); + if (clean) url.searchParams.set('note', clean); + else url.searchParams.delete('note'); + window.history.replaceState(null, '', url); + }, [annotation]); + + const handleMapThemeToggle = useCallback(() => { + setMapLight((current) => { + const next = !current; + localStorage.setItem('map-theme', next ? 'light' : 'dark'); + return next; + }); + }, []); + + const handleNetworkChange = useCallback((network: string) => { + const url = new URL(window.location.href); + url.searchParams.set('network', network); + window.location.assign(url); + }, []); + // Keep the fast poll to live data that changes independently of the socket. // Expensive inferred/path overlays use their own, slower conditional polls. useEffect(() => { @@ -171,7 +224,10 @@ export const App: React.FC = () => { const scheduleNext = () => { if (cancelled || !isPageVisible) return; - timer = window.setTimeout(() => { void syncLiveData(); }, 10_000); + timer = window.setTimeout( + () => { void syncLiveData(); }, + wsConnectedRef.current ? 60_000 : 10_000, + ); }; const syncLiveData = async () => { @@ -182,10 +238,12 @@ export const App: React.FC = () => { try { const [packetsRes, statsRes] = await Promise.allSettled([ fetch(uncachedEndpoint(withScopeParams('/api/packets/recent?limit=12', { network: networkFilter, observer: observerFilter })), { - cache: 'no-store', signal: controller.signal, + cache: 'no-store', + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(MAP_FETCH_TIMEOUT_MS)]), }), fetch(uncachedEndpoint(withScopeParams('/api/stats', { network: networkFilter, observer: observerFilter })), { - cache: 'no-store', signal: controller.signal, + cache: 'no-store', + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(MAP_FETCH_TIMEOUT_MS)]), }), ]); @@ -241,7 +299,10 @@ export const App: React.FC = () => { try { const response = await fetch( uncachedEndpoint(withScopeParams('/api/inferred-nodes', { network: networkFilter, observer: observerFilter })), - { cache: 'no-store', signal: controller.signal }, + { + cache: 'no-store', + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(OTHER_FETCH_TIMEOUT_MS)]), + }, ); if (!response.ok || cancelled) return; const payload = await response.json() as { @@ -270,7 +331,7 @@ export const App: React.FC = () => { }, [isPageVisible, networkFilter, observerFilter]); useEffect(() => { - if (!filters.packetHistory) { + if (!filters.packetHistory && !filters.heatmap) { setPacketHistorySegments([]); return undefined; } @@ -290,7 +351,10 @@ export const App: React.FC = () => { try { const response = await fetch( uncachedEndpoint(withScopeParams('/api/path-beta/multibyte-paths', { network: networkFilter, observer: observerFilter })), - { cache: 'no-store', signal: controller.signal }, + { + cache: 'no-store', + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(OTHER_FETCH_TIMEOUT_MS)]), + }, ); if (!response.ok || cancelled) return; const payload = await response.json() as { segments?: PacketHistorySegment[] }; @@ -311,7 +375,7 @@ export const App: React.FC = () => { controller?.abort(); if (timer) window.clearTimeout(timer); }; - }, [filters.packetHistory, isPageVisible, networkFilter, observerFilter]); + }, [filters.heatmap, filters.packetHistory, isPageVisible, networkFilter, observerFilter]); useEffect(() => { const wasHexClashes = prevHexClashesRef.current; @@ -335,6 +399,7 @@ export const App: React.FC = () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, message, stack, page: window.location.href, userAgent: navigator.userAgent }), + signal: AbortSignal.timeout(OTHER_FETCH_TIMEOUT_MS), }).catch(() => {}); }; @@ -383,14 +448,50 @@ export const App: React.FC = () => { }); const wsState = useWebSocket(handleMessage, { network: networkFilter, observer: observerFilter }); + wsConnectedRef.current = wsState === 'connected'; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + const isEditing = target?.matches('input, textarea, select, [contenteditable="true"]'); + if (event.key === '?' && !isEditing) { + event.preventDefault(); + setShowShortcutGuide((current) => !current); + } else if (event.key === 'Escape') { + setShowShortcutGuide(false); + setSelectedNodeId(null); + } else if (!isEditing && event.key.toLowerCase() === 'f') { + event.preventDefault(); + window.dispatchEvent(new Event('meshcore:focus-search')); + } else if (!isEditing && event.key.toLowerCase() === 's') { + event.preventDefault(); + void handleShare(); + } else if (!isEditing && event.key.toLowerCase() === 't') { + setFilters((current) => ({ ...current, terrain: !current.terrain })); + } else if (!isEditing && event.key.toLowerCase() === 'l') { + setFilters((current) => ({ ...current, betaPaths: !current.betaPaths })); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [handleShare]); return ( -
+
setShowDisclaimer(true)} stats={stats} + mapLight={mapLight} + onToggleMapTheme={handleMapThemeToggle} + network={networkFilter ?? 'ukmesh'} + onNetworkChange={handleNetworkChange} + annotation={annotation} + onEditAnnotation={handleEditAnnotation} + onShowShortcuts={() => setShowShortcutGuide(true)} + highContrast={highContrast} + onToggleContrast={() => setHighContrast((value) => !value)} /> { onShare={handleShare} shareLabel={shareLabel} onNodeSelect={setSelectedNodeId} + fullScreenMap={fullScreenMap} + onToggleFullScreenMap={() => setFullScreenMap((value) => !value)} />
@@ -419,6 +522,7 @@ export const App: React.FC = () => { selectedNodeId={selectedNodeId} onNodeSelect={setSelectedNodeId} onMapReady={setMlMap} + mapLight={mapLight} /> { observer={observerFilter} packetHistorySegments={packetHistorySegments} /> - {(!initialStateLoaded || !initialPollLoaded) && ( + {(!initialStateLoaded && !initialPollLoaded) && ( )} @@ -440,7 +544,7 @@ export const App: React.FC = () => { )}
- { collapsed={filtersCollapsed} onToggleCollapse={() => setFiltersCollapsed((value) => !value)} nodeOpen={selectedNodeId != null} - /> + />} {VIEWSHED_ENABLED && } - {filters.livePackets && } + {filters.livePackets && !fullScreenMap && } + + {annotation && ( + + )} + + {showShortcutGuide && ( +
setShowShortcutGuide(false)}> +
event.stopPropagation()}> +

Keyboard shortcuts

+
+
Esc
Close panels or cancel a map tool
+
F
Focus node search
+
S
Copy the current map link
+
T
Toggle 3D terrain
+
L
Toggle Live Path
+
?
Show or hide this guide
+
+
+
+ )} {showDisclaimer && }
diff --git a/frontend/src/components/FilterPanel/FilterPanel.tsx b/frontend/src/components/FilterPanel/FilterPanel.tsx index 626d76b..312eaae 100644 --- a/frontend/src/components/FilterPanel/FilterPanel.tsx +++ b/frontend/src/components/FilterPanel/FilterPanel.tsx @@ -10,6 +10,7 @@ export interface Filters { terrain: boolean; clientNodes: boolean; packetHistory: boolean; + heatmap: boolean; betaPaths: boolean; betaPathThreshold: number; // 0–1 hexClashes: boolean; @@ -40,6 +41,7 @@ export const FILTER_ROWS: Array<{ key: keyof Filters; label: string; color: stri { key: 'livePackets', label: 'Live Feed', color: '#00c4ff' }, { key: 'terrain', label: '3D Terrain', color: '#60a5fa' }, { key: 'packetHistory', label: 'Paths', color: '#00c4ff', hollow: true }, + { key: 'heatmap', label: 'Packet Heatmap', color: '#ef4444' }, { key: 'betaPaths', label: 'Live Path', color: '#a855f7', hollow: true }, { key: 'hexClashes', label: 'Hex Clashes', color: '#f97316' }, { key: 'clientNodes', label: 'Companion / Room', color: '#ff9800' }, diff --git a/frontend/src/components/LazyChartMount.tsx b/frontend/src/components/LazyChartMount.tsx new file mode 100644 index 0000000..c63a1cd --- /dev/null +++ b/frontend/src/components/LazyChartMount.tsx @@ -0,0 +1,33 @@ +import React, { memo, useEffect, useRef, useState } from 'react'; + +const LazyChartMount: React.FC<{ children: React.ReactNode; minHeight?: number }> = ({ + children, + minHeight = 220, +}) => { + const rootRef = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const root = rootRef.current; + if (!root || visible) return; + if (!('IntersectionObserver' in window)) { + setVisible(true); + return; + } + const observer = new IntersectionObserver(([entry]) => { + if (!entry?.isIntersecting) return; + setVisible(true); + observer.disconnect(); + }, { rootMargin: '300px 0px' }); + observer.observe(root); + return () => observer.disconnect(); + }, [visible]); + + return ( +
+ {visible ? children : + ); +}; + +export default memo(LazyChartMount); diff --git a/frontend/src/components/LoadingIndicator.tsx b/frontend/src/components/LoadingIndicator.tsx index 1eb43cf..85a8278 100644 --- a/frontend/src/components/LoadingIndicator.tsx +++ b/frontend/src/components/LoadingIndicator.tsx @@ -6,13 +6,16 @@ type LoadingIndicatorProps = { label?: string; variant?: LoadingIndicatorVariant; className?: string; + visible?: boolean; }; export const LoadingIndicator: React.FC = ({ label = 'Loading...', variant = 'block', className = '', + visible = true, }) => { + if (!visible) return null; const cls = ['loading-indicator', `loading-indicator--${variant}`, className] .filter(Boolean) .join(' '); diff --git a/frontend/src/components/Map/DeckGLOverlay.tsx b/frontend/src/components/Map/DeckGLOverlay.tsx index c40ac65..d298dd0 100644 --- a/frontend/src/components/Map/DeckGLOverlay.tsx +++ b/frontend/src/components/Map/DeckGLOverlay.tsx @@ -7,11 +7,12 @@ */ import React, { useEffect, useRef, useMemo } from 'react'; import { MapboxOverlay } from '@deck.gl/mapbox'; -import { ArcLayer, LineLayer, PathLayer, ScatterplotLayer } from '@deck.gl/layers'; +import { ArcLayer, LineLayer, PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers'; +import { HeatmapLayer } from '@deck.gl/aggregation-layers'; import { PathStyleExtension } from '@deck.gl/extensions'; import type { PathStyleExtensionProps } from '@deck.gl/extensions'; import type { Layer } from '@deck.gl/core'; -import type maplibregl from 'maplibre-gl'; +import maplibregl from 'maplibre-gl'; import type { PacketArc } from '../../hooks/useNodes.js'; import type { HiddenMaskGeometry } from '../../utils/pathing.js'; import { maskPoint } from '../../utils/pathing.js'; @@ -31,6 +32,14 @@ type HistorySegmentWithColor = HistorySegment & { width: number; }; +type ConsistencySegment = { + positions: [[number, number], [number, number]]; + midpoint: [number, number]; + observerCount: number; +}; + +type HeatPoint = { position: [number, number]; weight: number }; + interface Props { map: maplibregl.Map | null; @@ -41,6 +50,7 @@ interface Props { // Packet path history (link-segment heat map) packetHistorySegments: HistorySegment[]; showPacketHistory: boolean; + showHeatmap: boolean; // Beta path overlays betaPaths: [number, number][][]; @@ -49,6 +59,10 @@ interface Props { clashPathLines: ClashPathLine[]; showBetaPaths: boolean; pathFadingOut: boolean; + betaConfidence: number | null; + pathObserverCount: number; + pathAlternatives: number; + pathSummary: string | null; hiddenCoordMask: Map; positionElevations: Map; @@ -108,6 +122,9 @@ function buildLayers( losProfiles: LosProfile[] | null, customLosSegments: CustomLosSegment[], customLosStart: CustomLosPoint | null, + consistencySegments: ConsistencySegment[], + onPathSegmentClick: (segment: ConsistencySegment) => void, + showHeatmap: boolean, ): Layer[] { const now = Date.now(); const layers: Layer[] = []; @@ -173,6 +190,30 @@ function buildLayers( ); } + if (showHeatmap && packetHistorySegments.length > 0) { + const heatPoints: HeatPoint[] = packetHistorySegments.flatMap((segment) => [ + { position: [segment.positions[0][1], segment.positions[0][0]], weight: segment.count / 2 }, + { position: [segment.positions[1][1], segment.positions[1][0]], weight: segment.count / 2 }, + ]); + layers.push(new HeatmapLayer({ + id: 'packet-heatmap', + data: heatPoints, + getPosition: (point) => point.position, + getWeight: (point) => point.weight, + radiusPixels: 45, + intensity: 1.2, + threshold: 0.03, + colorRange: [ + [0, 196, 255, 0], + [0, 196, 255, 130], + [251, 191, 36, 190], + [249, 115, 22, 220], + [239, 68, 68, 245], + [255, 255, 255, 255], + ], + })); + } + // ── Beta path overlays ───────────────────────────────────────────────────── if (showBetaPaths) { const targetOpacity = pathFadingOut ? 0 : 1; @@ -234,6 +275,54 @@ function buildLayers( }), ); } + + if (consistencySegments.length > 0) { + const badgeColor = (count: number): [number, number, number, number] => { + const ratio = count / Math.max(1, Math.max(...consistencySegments.map((segment) => segment.observerCount))); + if (ratio >= 0.75 && count > 1) return [34, 197, 94, 245]; + if (ratio >= 0.4 && count > 1) return [251, 191, 36, 245]; + return [239, 68, 68, 245]; + }; + layers.push( + new LineLayer({ + id: 'path-consistency-hit-targets', + data: consistencySegments, + getSourcePosition: (segment) => toXYZ(segment.positions[0], hiddenCoordMask, positionElevations, useTerrainElevation), + getTargetPosition: (segment) => toXYZ(segment.positions[1], hiddenCoordMask, positionElevations, useTerrainElevation), + getColor: [0, 0, 0, 1], + getWidth: 14, + widthUnits: 'pixels', + pickable: true, + onClick: ({ object }) => { if (object) onPathSegmentClick(object); }, + }), + new ScatterplotLayer({ + id: 'path-consistency-badges', + data: consistencySegments, + getPosition: (segment) => toXYZ(segment.midpoint, hiddenCoordMask, positionElevations, useTerrainElevation), + getFillColor: (segment) => badgeColor(segment.observerCount), + getLineColor: [255, 255, 255, 230], + getRadius: 9, + radiusUnits: 'pixels', + stroked: true, + lineWidthUnits: 'pixels', + getLineWidth: 1.5, + pickable: true, + onClick: ({ object }) => { if (object) onPathSegmentClick(object); }, + }), + new TextLayer({ + id: 'path-consistency-labels', + data: consistencySegments, + getPosition: (segment) => toXYZ(segment.midpoint, hiddenCoordMask, positionElevations, useTerrainElevation), + getText: (segment) => String(segment.observerCount), + getColor: [255, 255, 255, 255], + getSize: 10, + sizeUnits: 'pixels', + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + pickable: false, + }), + ); + } } // ── Hex clash paths ─────────────────────────────────────────────────────── @@ -351,8 +440,13 @@ export const DeckGLOverlay: React.FC = ({ map, arcs, showArcs, packetHistorySegments, showPacketHistory, + showHeatmap, betaPaths, betaLowSegments, betaCompletionPaths, clashPathLines, showBetaPaths, pathFadingOut, + betaConfidence, + pathObserverCount, + pathAlternatives, + pathSummary, hiddenCoordMask, positionElevations, useTerrainElevation, @@ -361,6 +455,67 @@ export const DeckGLOverlay: React.FC = ({ customLosStart, }) => { const overlayRef = useRef(null); + const explanationPopupRef = useRef(null); + + const consistencySegments = useMemo(() => { + const counts = new Map(); + const coordKey = (point: [number, number]) => `${point[0].toFixed(5)},${point[1].toFixed(5)}`; + for (const path of betaPaths) { + const seenForObserver = new Set(); + for (let index = 0; index < path.length - 1; index += 1) { + const a = path[index]!; + const b = path[index + 1]!; + const aKey = coordKey(a); + const bKey = coordKey(b); + const key = aKey < bKey ? `${aKey}|${bKey}` : `${bKey}|${aKey}`; + if (seenForObserver.has(key)) continue; + seenForObserver.add(key); + const existing = counts.get(key); + if (existing) existing.observerCount += 1; + else counts.set(key, { + positions: [a, b], + midpoint: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], + observerCount: 1, + }); + } + } + return Array.from(counts.values()); + }, [betaPaths]); + + const onPathSegmentClick = useMemo(() => (segment: ConsistencySegment) => { + if (!map) return; + explanationPopupRef.current?.remove(); + const container = document.createElement('div'); + container.className = 'path-explanation-popover'; + const title = document.createElement('strong'); + title.textContent = 'Path segment evidence'; + const details = document.createElement('dl'); + const rows: Array<[string, string]> = [ + ['Confidence', betaConfidence == null ? 'N/A' : `${Math.round(betaConfidence * 100)}%`], + ['Observers', `${segment.observerCount} confirming${pathObserverCount ? ` of ${pathObserverCount}` : ''}`], + ['Evidence', `${segment.observerCount} independent path${segment.observerCount === 1 ? '' : 's'}`], + ['Top alternatives', String(pathAlternatives)], + ]; + for (const [label, value] of rows) { + const row = document.createElement('div'); + const term = document.createElement('dt'); + const description = document.createElement('dd'); + term.textContent = label; + description.textContent = value; + row.append(term, description); + details.append(row); + } + container.append(title, details); + if (pathSummary) { + const summary = document.createElement('p'); + summary.textContent = pathSummary; + container.append(summary); + } + explanationPopupRef.current = new maplibregl.Popup({ closeButton: true, maxWidth: '290px' }) + .setLngLat([segment.midpoint[1], segment.midpoint[0]]) + .setDOMContent(container) + .addTo(map); + }, [betaConfidence, map, pathAlternatives, pathObserverCount, pathSummary]); // Create/destroy the MapboxOverlay when the map instance changes useEffect(() => { @@ -372,6 +527,7 @@ export const DeckGLOverlay: React.FC = ({ overlayRef.current = overlay; return () => { + explanationPopupRef.current?.remove(); map.removeControl(overlay as unknown as maplibregl.IControl); overlayRef.current = null; }; @@ -389,11 +545,14 @@ export const DeckGLOverlay: React.FC = ({ losProfiles, customLosSegments, customLosStart, + consistencySegments, + onPathSegmentClick, + showHeatmap, ), [arcs, showArcs, packetHistorySegments, showPacketHistory, betaPaths, betaLowSegments, betaCompletionPaths, clashPathLines, showBetaPaths, pathFadingOut, hiddenCoordMask, positionElevations, useTerrainElevation, losProfiles, - customLosSegments, customLosStart], + customLosSegments, customLosStart, consistencySegments, onPathSegmentClick, showHeatmap], ); // Push updated layers to the overlay imperatively diff --git a/frontend/src/components/Map/LinkQualitySparkline.tsx b/frontend/src/components/Map/LinkQualitySparkline.tsx new file mode 100644 index 0000000..33ec147 --- /dev/null +++ b/frontend/src/components/Map/LinkQualitySparkline.tsx @@ -0,0 +1,35 @@ +import { memo, useEffect, useState } from 'react'; +import { Line, LineChart, ResponsiveContainer, Tooltip, YAxis } from 'recharts'; + +type Point = { time: string; snr: number | null; path_loss: number | null }; + +export const LinkQualitySparkline = memo(function LinkQualitySparkline({ + source, + target, +}: { + source: string; + target: string; +}) { + const [points, setPoints] = useState([]); + useEffect(() => { + const controller = new AbortController(); + fetch(`/api/links/${encodeURIComponent(`${source}:${target}`)}/history?hours=72`, { signal: controller.signal }) + .then((response) => response.ok ? response.json() as Promise<{ points?: Point[] }> : { points: [] }) + .then((value) => setPoints(value.points ?? [])) + .catch(() => {}); + return () => controller.abort(); + }, [source, target]); + if (points.length === 0) return No 72h radio samples; + return ( +
+ + + + new Date(String(value)).toLocaleString()} /> + + + + +
+ ); +}); diff --git a/frontend/src/components/Map/LiveOverlayController.tsx b/frontend/src/components/Map/LiveOverlayController.tsx index 3e489e5..e34f67b 100644 --- a/frontend/src/components/Map/LiveOverlayController.tsx +++ b/frontend/src/components/Map/LiveOverlayController.tsx @@ -35,12 +35,31 @@ export const LiveOverlayController: React.FC = ({ const customLosSegments = useOverlayStore((state) => state.customLosSegments); const customLosStart = useOverlayStore((state) => state.customLosStart); const clashPathLines = useOverlayStore((state) => state.clashPathLines); + const losProfilesKey = useMemo(() => Object.entries(losProfilesByNodeId) + .map(([nodeId, profiles]) => `${nodeId}:${profiles.length}:${profiles.map((profile) => `${profile.peer_id}:${profile.profile.length}:${profile.itm_viable ? 1 : 0}`).join(',')}`) + .sort() + .join('|'), [losProfilesByNodeId]); const losProfiles = useMemo( () => Object.values(losProfilesByNodeId).flat(), - [losProfilesByNodeId], + // The key avoids flattening again when Zustand produces an equivalent + // record while unrelated overlay state changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + [losProfilesKey], ); const nodes = useNodeMap(); - const hiddenCoordMask = useMemo(() => buildHiddenCoordMask(nodes.values()), [nodes]); + const nodeCoordinateKey = useMemo(() => Array.from(nodes.values()) + .map((node) => `${node.node_id}:${node.lat ?? ''}:${node.lon ?? ''}:${node.name?.includes('🚫') ? 1 : 0}`) + .sort() + .join('|'), [nodes]); + const nodeElevationKey = useMemo(() => Array.from(nodes.values()) + .map((node) => `${node.node_id}:${node.lat ?? ''}:${node.lon ?? ''}:${node.elevation_m ?? 0}`) + .sort() + .join('|'), [nodes]); + const hiddenCoordMask = useMemo( + () => buildHiddenCoordMask(nodes.values()), + // eslint-disable-next-line react-hooks/exhaustive-deps + [nodeCoordinateKey], + ); const positionElevations = useMemo(() => { const elevations = new Map(); for (const node of nodes.values()) { @@ -51,9 +70,11 @@ export const LiveOverlayController: React.FC = ({ elevations.set(positionKey(maskedLat, maskedLon), elevation); } return elevations; - }, [hiddenCoordMask, nodes]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hiddenCoordMask, nodeElevationKey]); const setPathNodeIds = useOverlayStore((state) => state.setPathNodeIds); const setBetaMetrics = useOverlayStore((state) => state.setBetaMetrics); + const pathExplanation = useOverlayStore((state) => state.pathExplanation); const { packetPaths, @@ -88,7 +109,8 @@ export const LiveOverlayController: React.FC = ({ else index.set(key, new Set([node.node_id.toLowerCase()])); } return index; - }, [nodes]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodeCoordinateKey]); const pathNodeIdsPrevRef = React.useRef | null>(null); const pathNodeIds = useMemo | null>(() => { @@ -159,11 +181,16 @@ export const LiveOverlayController: React.FC = ({ showArcs={filters.livePackets} packetHistorySegments={packetHistorySegments} showPacketHistory={filters.packetHistory} + showHeatmap={filters.heatmap} betaPaths={renderedPaths} betaLowSegments={betaLowConfidenceSegments} betaCompletionPaths={betaCompletionPaths} clashPathLines={clashPathLines} showBetaPaths={filters.betaPaths || pinnedPacketId !== null} + betaConfidence={betaPathConfidence} + pathObserverCount={activePacketSnapshot?.observerIds.length ?? 0} + pathAlternatives={pathExplanation?.alternativesConsidered ?? betaPermutationCount ?? 0} + pathSummary={pathExplanation?.summary ?? null} pathFadingOut={pathFadingOut} hiddenCoordMask={hiddenCoordMask} positionElevations={positionElevations} diff --git a/frontend/src/components/Map/MapLibreMap.tsx b/frontend/src/components/Map/MapLibreMap.tsx index f6a2f19..2bf5848 100644 --- a/frontend/src/components/Map/MapLibreMap.tsx +++ b/frontend/src/components/Map/MapLibreMap.tsx @@ -10,7 +10,6 @@ import { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; import maplibregl from 'maplibre-gl'; -import 'maplibre-gl/dist/maplibre-gl.css'; import type { MeshNode } from '../../hooks/useNodes.js'; import { nodeStore } from '../../hooks/useNodes.js'; import { coverageStore, type NodeCoverage } from '../../hooks/useCoverage.js'; @@ -27,6 +26,7 @@ import { DEFAULT_CENTER, DEFAULT_ZOOM, EMPTY_FC, + MAP_ARC_REFRESH_INTERVAL_MS, MAP_REFRESH_INTERVAL_MS, MAP_STYLE, MAP_STYLE_LIGHT, @@ -44,13 +44,17 @@ import { buildPlannedPinGeoJSON, buildPrivacyRingsGeoJSON, computeClashData, + ALL_MAP_SOURCE_DIRTY_FLAGS, + mergeMapSourceDirtyFlags, } from './geojsonBuilders.js'; +import type { MapSourceDirtyFlags } from './geojsonBuilders.js'; import { NodePopupContent } from './NodePopupContent.js'; import { NodeLegend } from './NodeLegend.js'; import { ActivitySparkline } from './ActivitySparkline.js'; import { PlannedRepeaterPopup } from './PlannedRepeaterPopup.js'; import { useWatchlist } from '../../hooks/useWatchlist.js'; import type { + ClashComputation, CustomLosPoint, LosProfile, MapLibreMapProps, @@ -62,6 +66,34 @@ import type { import { sampleElevationAt } from '../../utils/terrainSampler.js'; import { computeCustomLos } from '../../utils/customLos.js'; +const NODE_LINK_CACHE_TTL_MS = 5 * 60_000; +const nodeLinksCache = new Map }>(); + +function fetchNodeLinks(nodeId: string): Promise { + const cached = nodeLinksCache.get(nodeId); + if (cached?.rows && Date.now() - (cached.fetchedAt ?? 0) < NODE_LINK_CACHE_TTL_MS) { + return Promise.resolve(cached.rows); + } + if (cached?.pending) return cached.pending; + + const pending = fetch(`/api/nodes/${encodeURIComponent(nodeId)}/links`, { + signal: AbortSignal.timeout(15_000), + }) + .then(async (response) => { + if (!response.ok) throw new Error(`links request failed: ${response.status}`); + const payload = await response.json() as NodeLink[]; + const rows = Array.isArray(payload) ? payload : []; + nodeLinksCache.set(nodeId, { rows, fetchedAt: Date.now() }); + return rows; + }) + .catch((error) => { + nodeLinksCache.delete(nodeId); + throw error; + }); + nodeLinksCache.set(nodeId, { ...cached, pending }); + return pending; +} + // ── Main Component ──────────────────────────────────────────────────────────── export function MapLibreMap({ @@ -77,6 +109,7 @@ export function MapLibreMap({ selectedNodeId = null, onNodeSelect, onMapReady, + mapLight, }: MapLibreMapProps) { const containerRef = useRef(null); const mapRef = useRef(null); @@ -98,6 +131,14 @@ export function MapLibreMap({ const setClashPathLines = useOverlayStore((state) => state.setClashPathLines); const hiddenCoordMaskRef = useRef>(new Map()); const refreshTimerRef = useRef(null); + const lastArcActivityRef = useRef(0); + const dirtyFlagsRef = useRef({ ...ALL_MAP_SOURCE_DIRTY_FLAGS }); + const clashRef = useRef({ + clashOffenderNodeIds: new Set(), + clashRelayIds: new Set(), + clashPathLines: [], + clashModeActive: false, + }); const selectedNodeIdRef = useRef(selectedNodeId); const onNodeSelectRef = useRef(onNodeSelect); onNodeSelectRef.current = onNodeSelect; @@ -134,18 +175,20 @@ export function MapLibreMap({ const watchlist = useWatchlist(); const [copyLinkLabel, setCopyLinkLabel] = useState('Copy link'); - // -- Map theme (light/dark) ------------------------------------------------- - const [mapLight, setMapLight] = useState(() => localStorage.getItem('map-theme') === 'light'); + // MapLibre's stylesheet is loaded only when the map component mounts, keeping + // map-specific CSS out of non-map entry points. + useEffect(() => { + void import('maplibre-gl/dist/maplibre-gl.css'); + }, []); - const toggleMapTheme = useCallback(() => { - setMapLight((prev) => { - const next = !prev; - localStorage.setItem('map-theme', next ? 'light' : 'dark'); + // -- Map theme (light/dark) ------------------------------------------------- + useEffect(() => { const map = mapRef.current; if (map && mapLoadedRef.current) { - const oldId = next ? 'carto-dark' : 'carto-light'; - const newId = next ? 'carto-light' : 'carto-dark'; - const variant = next ? 'light_all' : 'dark_all'; + const oldId = mapLight ? 'carto-dark' : 'carto-light'; + const newId = mapLight ? 'carto-light' : 'carto-dark'; + const variant = mapLight ? 'light_all' : 'dark_all'; + if (map.getSource(newId)) return; if (map.getLayer('background')) map.removeLayer('background'); if (map.getLayer('bg-fill')) map.removeLayer('bg-fill'); if (map.getSource(oldId)) map.removeSource(oldId); @@ -162,7 +205,7 @@ export function MapLibreMap({ // Insert bg-fill + basemap at the very bottom const firstLayer = map.getStyle().layers[0]?.id; map.addLayer( - { id: 'bg-fill', type: 'background', paint: { 'background-color': next ? '#e8e8e8' : '#080d14' } }, + { id: 'bg-fill', type: 'background', paint: { 'background-color': mapLight ? '#e8e8e8' : '#080d14' } }, firstLayer, ); map.addLayer( @@ -170,9 +213,7 @@ export function MapLibreMap({ map.getStyle().layers[1]?.id, // after bg-fill, before everything else ); } - return next; - }); - }, []); + }, [mapLight]); // -- LOS profiles (client-side, multi-node, auto-expire) ------------------- @@ -216,8 +257,7 @@ export function MapLibreMap({ const ANTENNA_H = 10; const EXAG = TERRAIN_CONFIG.exaggeration; try { - const links = await fetch(`/api/nodes/${nodeId}/links`) - .then((r) => r.json()) as NodeLink[]; + const links = await fetchNodeLinks(nodeId); const sourceNode = nodesRef.current.get(nodeId); if (!sourceNode || !hasCoords(sourceNode)) { setLosProfilesForNode(nodeId, []); @@ -524,71 +564,104 @@ export function MapLibreMap({ const refreshMapSources = useCallback(() => { if (!mapLoadedRef.current || !mapRef.current) return; + const dirty = dirtyFlagsRef.current; + dirtyFlagsRef.current = { + nodes: false, + privacy: false, + links: false, + coverage: false, + clash: false, + plannedLinks: false, + }; const nodes = nodesRef.current; const coverage = viewshedEnabled ? coverageRef.current : []; const viablePairsArr = viablePairsRef.current; const linkMetrics = linkMetricsRef.current; const currentPathNodeIds = pathNodeIdsRef.current; - const currentHiddenCoordMask = buildHiddenMask(nodes); - hiddenCoordMaskRef.current = currentHiddenCoordMask; + const maskDirty = dirty.nodes || dirty.privacy || dirty.links || dirty.clash || dirty.plannedLinks; + const currentHiddenCoordMask = maskDirty ? buildHiddenMask(nodes) : hiddenCoordMaskRef.current; + if (maskDirty) hiddenCoordMaskRef.current = currentHiddenCoordMask; - const clash = computeClashData( - nodes, - coverage, - viablePairsArr, - linkMetrics, - showHexClashesRef.current, - maxHexClashHopsRef.current, - focusedNodeId, - focusedPrefixNodeIds, - ); + const clashDirty = dirty.clash || dirty.nodes || dirty.coverage || dirty.links; + const clash = clashDirty + ? computeClashData( + nodes, + coverage, + viablePairsArr, + linkMetrics, + showHexClashesRef.current, + maxHexClashHopsRef.current, + focusedNodeId, + focusedPrefixNodeIds, + ) + : clashRef.current; + if (clashDirty) clashRef.current = clash; - const nodeGeoJSON = buildNodeGeoJSON( - nodes, - currentHiddenCoordMask, - showClientNodesRef.current, - showLinksRef.current, - new Set(viablePairsArr.flatMap(([aId, bId]) => [aId.toLowerCase(), bId.toLowerCase()])), - clash.clashOffenderNodeIds, - clash.clashRelayIds, - clash.clashModeActive, - clash.clashModeActive ? null : currentPathNodeIds, - replayNodeIdsRef.current, - ); - (mapRef.current.getSource('nodes') as maplibregl.GeoJSONSource | undefined)?.setData(nodeGeoJSON); + if (dirty.nodes || clashDirty) { + const nodeGeoJSON = buildNodeGeoJSON( + nodes, + currentHiddenCoordMask, + showClientNodesRef.current, + showLinksRef.current, + new Set(viablePairsArr.flatMap(([aId, bId]) => [aId.toLowerCase(), bId.toLowerCase()])), + clash.clashOffenderNodeIds, + clash.clashRelayIds, + clash.clashModeActive, + clash.clashModeActive ? null : currentPathNodeIds, + replayNodeIdsRef.current, + ); + (mapRef.current.getSource('nodes') as maplibregl.GeoJSONSource | undefined)?.setData(nodeGeoJSON); + } - const privacyGeoJSON = buildPrivacyRingsGeoJSON(nodes, currentHiddenCoordMask); - (mapRef.current.getSource('privacy-rings') as maplibregl.GeoJSONSource | undefined)?.setData(privacyGeoJSON); + if (dirty.privacy || dirty.nodes) { + const privacyGeoJSON = buildPrivacyRingsGeoJSON(nodes, currentHiddenCoordMask); + (mapRef.current.getSource('privacy-rings') as maplibregl.GeoJSONSource | undefined)?.setData(privacyGeoJSON); + } - const linksGeoJSON = showLinksRef.current - ? buildLinksGeoJSON(nodes, viablePairsArr, linkMetrics, currentHiddenCoordMask) - : EMPTY_FC; - (mapRef.current.getSource('viable-links') as maplibregl.GeoJSONSource | undefined)?.setData(linksGeoJSON); - mapRef.current.setLayoutProperty('viable-links-layer', 'visibility', showLinksRef.current ? 'visible' : 'none'); + if (dirty.links || dirty.nodes) { + const linksGeoJSON = showLinksRef.current + ? buildLinksGeoJSON(nodes, viablePairsArr, linkMetrics, currentHiddenCoordMask) + : EMPTY_FC; + (mapRef.current.getSource('viable-links') as maplibregl.GeoJSONSource | undefined)?.setData(linksGeoJSON); + mapRef.current.setLayoutProperty('viable-links-layer', 'visibility', showLinksRef.current ? 'visible' : 'none'); + } - const coverageGeoJSON = viewshedEnabled && selectedCoverageRef.current && !clash.clashModeActive - ? buildCoverageGeoJSON([selectedCoverageRef.current]) - : EMPTY_FC; - (mapRef.current.getSource('coverage') as maplibregl.GeoJSONSource | undefined)?.setData(coverageGeoJSON); - mapRef.current.setLayoutProperty('coverage-fill', 'visibility', - viewshedEnabled && selectedCoverageRef.current && !clash.clashModeActive ? 'visible' : 'none'); + if (dirty.coverage || clashDirty) { + const coverageGeoJSON = viewshedEnabled && selectedCoverageRef.current && !clash.clashModeActive + ? buildCoverageGeoJSON([selectedCoverageRef.current]) + : EMPTY_FC; + (mapRef.current.getSource('coverage') as maplibregl.GeoJSONSource | undefined)?.setData(coverageGeoJSON); + mapRef.current.setLayoutProperty('coverage-fill', 'visibility', + viewshedEnabled && selectedCoverageRef.current && !clash.clashModeActive ? 'visible' : 'none'); + } - setClashPathLines(clash.clashModeActive ? clash.clashPathLines : []); - (mapRef.current.getSource('clash-lines') as maplibregl.GeoJSONSource | undefined)?.setData(EMPTY_FC); - mapRef.current.setLayoutProperty('clash-lines-layer', 'visibility', 'none'); + if (clashDirty) { + setClashPathLines(clash.clashModeActive ? clash.clashPathLines : []); + (mapRef.current.getSource('clash-lines') as maplibregl.GeoJSONSource | undefined)?.setData(EMPTY_FC); + mapRef.current.setLayoutProperty('clash-lines-layer', 'visibility', 'none'); + } - // Rebuild planned-link lines against the freshly-updated node positions/mask. - updatePlannedLinks(); + if (dirty.plannedLinks || dirty.nodes || dirty.links) updatePlannedLinks(); }, [viewshedEnabled, focusedNodeId, focusedPrefixNodeIds, setClashPathLines, updatePlannedLinks]); - const scheduleRefresh = useCallback(() => { + const scheduleRefresh = useCallback((flags: Partial = ALL_MAP_SOURCE_DIRTY_FLAGS) => { + dirtyFlagsRef.current = mergeMapSourceDirtyFlags(dirtyFlagsRef.current, flags); if (refreshTimerRef.current !== null) return; + const interval = Date.now() - lastArcActivityRef.current < 5_000 + ? MAP_ARC_REFRESH_INTERVAL_MS + : MAP_REFRESH_INTERVAL_MS; refreshTimerRef.current = window.setTimeout(() => { refreshTimerRef.current = null; refreshMapSources(); - }, MAP_REFRESH_INTERVAL_MS); + }, interval); }, [refreshMapSources]); + useEffect(() => { + const noteArcActivity = () => { lastArcActivityRef.current = Date.now(); }; + window.addEventListener('meshcore:packet-observed', noteArcActivity); + return () => window.removeEventListener('meshcore:packet-observed', noteArcActivity); + }, []); + useEffect(() => { viewshedEnabledRef.current = viewshedEnabled; if (!viewshedEnabled) { @@ -646,7 +719,7 @@ export function MapLibreMap({ const map = new maplibregl.Map({ container: containerRef.current, - style: localStorage.getItem('map-theme') === 'light' ? MAP_STYLE_LIGHT : MAP_STYLE, + style: mapLight ? MAP_STYLE_LIGHT : MAP_STYLE, center: initialView ? [initialView.lon, initialView.lat] : [DEFAULT_CENTER[1], DEFAULT_CENTER[0]], zoom: initialView?.zoom ?? DEFAULT_ZOOM, maxPitch: 0, @@ -699,6 +772,52 @@ export function MapLibreMap({ }, }); + // Observer quality is a separate, cached overlay so live node refreshes do + // not rebuild health metrics. A coloured ring keeps the role colour intact. + map.addSource('observer-health', { type: 'geojson', data: EMPTY_FC }); + map.addLayer({ + id: 'observer-health-rings', + type: 'circle', + source: 'observer-health', + paint: { + 'circle-radius': [ + 'interpolate', ['linear'], ['zoom'], + 6, 5.5, 9, 7, 11, 8.5, 13, 11, 16, 14, + ], + 'circle-color': 'rgba(0,0,0,0)', + 'circle-stroke-width': 2, + 'circle-stroke-color': [ + 'match', ['get', 'quality'], + 'good', '#22c55e', + 'watch', '#fbbf24', + '#ef4444', + ], + 'circle-stroke-opacity': 0.95, + }, + }); + void fetch('/api/observers/health', { signal: AbortSignal.timeout(10_000) }) + .then((response) => response.ok ? response.json() as Promise> : []) + .then((observers) => { + const source = map.getSource('observer-health') as maplibregl.GeoJSONSource | undefined; + source?.setData({ + type: 'FeatureCollection', + features: observers.map((observer) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [observer.lon, observer.lat] }, + properties: { + node_id: observer.node_id, + name: observer.name, + score: observer.score, + quality: observer.quality, + }, + })), + }); + }) + .catch(() => {}); + // ── Selected-node highlight (recolour + ring) ────────────────────────── // Two circle layers over node-dots, filtered to the selected node id. // A soft halo underneath, a bright solid marker on top. @@ -1068,19 +1187,22 @@ export function MapLibreMap({ map.remove(); mapRef.current = null; }; + // Initial theme is captured on mount; subsequent theme changes are applied by + // the dedicated theme effect without rebuilding the MapLibre instance. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialView, onMapReady, onNodeSelect, refreshMapSources, setClashPathLines]); // -- Imperative source updates --------------------------------------------- useEffect(() => { inferredNodesRef.current = inferredNodes; - scheduleRefresh(); + scheduleRefresh({ nodes: true }); }, [inferredNodes, scheduleRefresh]); useEffect(() => { showLinksRef.current = showLinks; updatePlannedLinks(); - scheduleRefresh(); + scheduleRefresh({ nodes: true, links: true, plannedLinks: true, clash: true }); }, [showLinks, scheduleRefresh, updatePlannedLinks]); useEffect(() => { @@ -1124,17 +1246,17 @@ export function MapLibreMap({ useEffect(() => { showClientNodesRef.current = showClientNodes; - scheduleRefresh(); + scheduleRefresh({ nodes: true }); }, [showClientNodes, scheduleRefresh]); useEffect(() => { showHexClashesRef.current = showHexClashes; - scheduleRefresh(); + scheduleRefresh({ nodes: true, clash: true, coverage: true }); }, [showHexClashes, scheduleRefresh]); useEffect(() => { maxHexClashHopsRef.current = maxHexClashHops; - scheduleRefresh(); + scheduleRefresh({ nodes: true, clash: true, coverage: true }); }, [maxHexClashHops, scheduleRefresh]); useEffect(() => { @@ -1143,20 +1265,24 @@ export function MapLibreMap({ useEffect(() => { const unsubscribeNodes = nodeStore.subscribe(() => { - nodesRef.current = nodeStore.getState().nodes; - scheduleRefresh(); + const nextNodes = nodeStore.getState().nodes; + // Packet/message updates share the same node Map reference. Ignore those + // emissions so live feed traffic does not trigger a 3.6k-node rebuild. + if (nextNodes === nodesRef.current) return; + nodesRef.current = nextNodes; + scheduleRefresh({ nodes: true, privacy: true, links: true, clash: true, plannedLinks: true }); if (selectedNodeIdRef.current) setPopupVersion((value) => value + 1); }); const unsubscribeCoverage = coverageStore.subscribe(() => { if (!viewshedEnabledRef.current) return; coverageRef.current = coverageStore.getState().coverage; - scheduleRefresh(); + scheduleRefresh({ coverage: true, clash: true, nodes: true }); }); const unsubscribeLinks = linkStateStore.subscribe(() => { const linkState = linkStateStore.getState(); viablePairsRef.current = linkState.viablePairsArr; linkMetricsRef.current = linkState.linkMetrics; - scheduleRefresh(); + scheduleRefresh({ links: true, clash: true, nodes: true, plannedLinks: true }); }); const unsubscribeOverlay = useOverlayStore.subscribe((overlayState) => { if ( @@ -1165,7 +1291,7 @@ export function MapLibreMap({ ) return; pathNodeIdsRef.current = overlayState.pathNodeIds; replayNodeIdsRef.current = overlayState.replayNodeIds; - scheduleRefresh(); + scheduleRefresh({ nodes: true }); }); return () => { @@ -1177,7 +1303,7 @@ export function MapLibreMap({ }, [scheduleRefresh]); useEffect(() => { - scheduleRefresh(); + scheduleRefresh({ nodes: true, clash: true }); }, [focusedNodeId, focusedPrefixNodeIds, scheduleRefresh]); const toggleCoverageForNode = useCallback((nodeId: string) => { @@ -1230,13 +1356,12 @@ export function MapLibreMap({ if (!selectedNodeId) { setPopupLinks(null); return; } const node = getNode(selectedNodeId); if (!node) return; - const controller = new AbortController(); + let cancelled = false; setPopupLinks(null); - fetch(`/api/nodes/${selectedNodeId}/links`, { signal: controller.signal }) - .then((r) => r.json() as Promise) - .then((rows) => setPopupLinks(Array.isArray(rows) ? rows : [])) - .catch((err) => { if ((err as DOMException).name !== 'AbortError') setPopupLinks([]); }); - return () => controller.abort(); + void fetchNodeLinks(selectedNodeId) + .then((rows) => { if (!cancelled) setPopupLinks(rows); }) + .catch(() => { if (!cancelled) setPopupLinks([]); }); + return () => { cancelled = true; }; }, [selectedNodeId, getNode]); // eslint-disable-line react-hooks/exhaustive-deps // Highlight the selected node on the map (bright recolour + ring). Cleared @@ -1357,18 +1482,6 @@ export function MapLibreMap({ Repeater )} -
{/* Custom LOS status hint */} diff --git a/frontend/src/components/Map/NodePopupContent.tsx b/frontend/src/components/Map/NodePopupContent.tsx index d7e3e5d..fd1068a 100644 --- a/frontend/src/components/Map/NodePopupContent.tsx +++ b/frontend/src/components/Map/NodePopupContent.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { useState } from 'react'; import { LoadingIndicator } from '../LoadingIndicator.js'; import { NODE_STALE_AFTER_MS } from './mapConfig.js'; import type { NodeFeatureProps, NodeLink } from './types.js'; +import { LinkQualitySparkline } from './LinkQualitySparkline.js'; const GPU_ROLE_LABELS: Record = { 1: 'Companion Radio', 2: 'Repeater', 3: 'Room Server', 4: 'Sensor', @@ -58,10 +59,19 @@ export const NodePopupContent: React.FC<{ const displayName = props.is_prohibited ? `Redacted ${fallbackName}` : (props.name ?? `Unknown ${fallbackName}`); + const [tab, setTab] = useState<'info' | 'links' | 'activity' | 'path' | 'status'>('info'); return (
{!hideName &&
{displayName}
} +
+ {(['info', 'links', 'activity', 'path', 'status'] as const).map((value) => ( + + ))} +
+ {tab === 'info' && <> {props.public_key && (
Public key @@ -110,6 +120,17 @@ export const NodePopupContent: React.FC<{ {Math.round(props.elevation_m)} m ASL
)} + } + {tab === 'status' && <> +
Status{statusLabel}
+ {props.hardware_model &&
Hardware{props.hardware_model}
} +
Freshness{timeAgo(props.last_seen)}
+ } + {tab === 'activity' && <> +
Last seen{timeAgo(props.last_seen)}
+
Adverts{props.advert_count ?? 'No samples'}
+ } + {tab === 'path' && <> {viewshedEnabled && isRepeater && !props.is_prohibited && (
)} + } + {tab === 'links' && <> {links === null && (
@@ -172,11 +195,14 @@ export const NodePopupContent: React.FC<{ {lk.observed_count}× seen {lk.itm_path_loss_db != null && <> · {Math.round(lk.itm_path_loss_db)} dB} +
); })}
)} + {links !== null && links.length === 0 &&
No confirmed neighbours.
} + }
); }; diff --git a/frontend/src/components/Map/NodeSearch.tsx b/frontend/src/components/Map/NodeSearch.tsx index b325408..edbc7ab 100644 --- a/frontend/src/components/Map/NodeSearch.tsx +++ b/frontend/src/components/Map/NodeSearch.tsx @@ -1,4 +1,5 @@ import React, { useState, useMemo, useEffect, useRef } from 'react'; +import Fuse from 'fuse.js'; import type maplibregl from 'maplibre-gl'; import type { MeshNode } from '../../hooks/useNodes.js'; import { useNodeMap } from '../../hooks/useNodes.js'; @@ -16,19 +17,37 @@ export const NodeSearch: React.FC = ({ map, onNodeSelect }) => const [open, setOpen] = useState(false); const containerRef = useRef(null); const watchlist = useWatchlist(); + const [nearbyNodes, setNearbyNodes] = useState(null); + const [geoStatus, setGeoStatus] = useState(null); + + const searchableNodes = useMemo(() => Array.from(nodes.values()).filter( + (node) => isValidMapCoord(node.lat, node.lon) && !node.name?.includes('🚫'), + ), [nodes]); + const fuse = useMemo(() => new Fuse(searchableNodes, { + keys: [ + { name: 'name', weight: 0.55 }, + { name: 'iata', weight: 0.3 }, + { name: 'node_id', weight: 0.1 }, + { name: 'public_key', weight: 0.05 }, + ], + threshold: 0.35, + ignoreLocation: true, + includeScore: true, + }), [searchableNodes]); const results = useMemo(() => { - if (!query.trim()) return []; - const q = query.toLowerCase(); - return Array.from(nodes.values()) - .filter((n) => { - if (!isValidMapCoord(n.lat, n.lon)) return false; - const nameMatch = n.name && !n.name.includes('🚫') && n.name.toLowerCase().includes(q); - const keyMatch = n.public_key && n.public_key.toLowerCase().includes(q); - return nameMatch || keyMatch; - }) + if (!query.trim()) return nearbyNodes ?? []; + const q = query.trim().toLowerCase(); + const prefixMatches = searchableNodes.filter((node) => + node.name?.toLowerCase().startsWith(q) + || node.iata?.toLowerCase().startsWith(q) + || node.node_id.toLowerCase().startsWith(q) + || node.public_key?.toLowerCase().startsWith(q), + ); + const fuzzyMatches = fuse.search(q).map((result) => result.item); + return Array.from(new Map([...prefixMatches, ...fuzzyMatches].map((node) => [node.node_id, node])).values()) .slice(0, 6); - }, [query, nodes]); + }, [fuse, nearbyNodes, query, searchableNodes]); useEffect(() => { const handler = (e: MouseEvent) => { @@ -39,38 +58,85 @@ export const NodeSearch: React.FC = ({ map, onNodeSelect }) => return () => document.removeEventListener('mousedown', handler); }, []); + useEffect(() => { + const focusSearch = () => { + const input = containerRef.current?.querySelector('input'); + input?.focus(); + setOpen(true); + }; + window.addEventListener('meshcore:focus-search', focusSearch); + return () => window.removeEventListener('meshcore:focus-search', focusSearch); + }, []); + + const findNearby = () => { + if (!navigator.geolocation) { + setGeoStatus('Location is not supported by this browser.'); + setOpen(true); + return; + } + setGeoStatus('Requesting location permission…'); + setOpen(true); + navigator.geolocation.getCurrentPosition( + ({ coords }) => { + const distanceKm = (node: MeshNode) => { + const lat1 = coords.latitude * Math.PI / 180; + const lat2 = node.lat! * Math.PI / 180; + const dLat = lat2 - lat1; + const dLon = (node.lon! - coords.longitude) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + }; + const nearest = searchableNodes + .filter((node) => node.role === undefined || node.role === 2) + .sort((a, b) => distanceKm(a) - distanceKm(b)) + .slice(0, 6); + setNearbyNodes(nearest); + setGeoStatus(nearest.length ? 'Nearest repeaters' : 'No nearby repeaters found.'); + map?.flyTo({ center: [coords.longitude, coords.latitude], zoom: 11 }); + }, + (error) => setGeoStatus(error.code === error.PERMISSION_DENIED + ? 'Location permission was denied.' + : 'Unable to determine your location.'), + { enableHighAccuracy: false, timeout: 10_000, maximumAge: 300_000 }, + ); + }; + const select = (node: MeshNode) => { // MapLibre flyTo: center is [lon, lat] map?.flyTo({ center: [node.lon!, node.lat!], zoom: 15 }); onNodeSelect?.(node.node_id); setQuery(''); + setNearbyNodes(null); + setGeoStatus(null); setOpen(false); }; return (
- { setQuery(e.target.value); setOpen(true); }} - onFocus={() => { if (query) setOpen(true); }} - onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false); }} - /> - {open && results.length > 0 && ( +
+ { setQuery(e.target.value); setNearbyNodes(null); setGeoStatus(null); setOpen(true); }} + onFocus={() => { if (query || nearbyNodes) setOpen(true); }} + onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false); }} + /> + +
+ {open && (results.length > 0 || geoStatus) && (
+ {geoStatus &&
{geoStatus}
} {results.map((node) => ( ))} - + }
)}
diff --git a/frontend/src/components/Map/geojsonBuilders.ts b/frontend/src/components/Map/geojsonBuilders.ts index 1e9c217..92f7514 100644 --- a/frontend/src/components/Map/geojsonBuilders.ts +++ b/frontend/src/components/Map/geojsonBuilders.ts @@ -19,6 +19,38 @@ import { } from './mapConfig.js'; import type { ClashComputation, NodeFeatureProps, PlannedRepeater } from './types.js'; +export type MapSourceDirtyFlags = { + nodes: boolean; + privacy: boolean; + links: boolean; + coverage: boolean; + clash: boolean; + plannedLinks: boolean; +}; + +export const ALL_MAP_SOURCE_DIRTY_FLAGS: MapSourceDirtyFlags = { + nodes: true, + privacy: true, + links: true, + coverage: true, + clash: true, + plannedLinks: true, +}; + +export function mergeMapSourceDirtyFlags( + current: MapSourceDirtyFlags, + incoming: Partial, +): MapSourceDirtyFlags { + return { + nodes: current.nodes || !!incoming.nodes, + privacy: current.privacy || !!incoming.privacy, + links: current.links || !!incoming.links, + coverage: current.coverage || !!incoming.coverage, + clash: current.clash || !!incoming.clash, + plannedLinks: current.plannedLinks || !!incoming.plannedLinks, + }; +} + function circleLineString( lat: number, lon: number, diff --git a/frontend/src/components/Map/mapConfig.ts b/frontend/src/components/Map/mapConfig.ts index fb78cbf..0ab009d 100644 --- a/frontend/src/components/Map/mapConfig.ts +++ b/frontend/src/components/Map/mapConfig.ts @@ -6,7 +6,8 @@ export const DEFAULT_ZOOM = 11; // stale nodes are still visible as stale instead of disappearing immediately. export const NODE_STALE_AFTER_MS = 14 * 24 * 60 * 60 * 1000; export const NODE_HIDE_AFTER_MS = 28 * 24 * 60 * 60 * 1000; -export const MAP_REFRESH_INTERVAL_MS = 100; +export const MAP_REFRESH_INTERVAL_MS = 250; +export const MAP_ARC_REFRESH_INTERVAL_MS = 100; export const LINK_GREEN_THRESHOLD_DB = 121.5; export const LINK_AMBER_THRESHOLD_DB = 129.5; diff --git a/frontend/src/components/Map/types.ts b/frontend/src/components/Map/types.ts index 5f3b2bc..5a90a45 100644 --- a/frontend/src/components/Map/types.ts +++ b/frontend/src/components/Map/types.ts @@ -81,6 +81,7 @@ export interface MapLibreMapProps { selectedNodeId?: string | null; onNodeSelect?: (nodeId: string | null) => void; onMapReady?: (map: maplibregl.Map) => void; + mapLight: boolean; } export interface PopupNodeView { diff --git a/frontend/src/components/ObserverRegistrationForm.tsx b/frontend/src/components/ObserverRegistrationForm.tsx new file mode 100644 index 0000000..13aa0d2 --- /dev/null +++ b/frontend/src/components/ObserverRegistrationForm.tsx @@ -0,0 +1,41 @@ +import { FormEvent, useState } from 'react'; + +export function ObserverRegistrationForm() { + const [publicKey, setPublicKey] = useState(''); + const [iata, setIata] = useState(''); + const [name, setName] = useState(''); + const [contact, setContact] = useState(''); + const [message, setMessage] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const submit = (event: FormEvent) => { + event.preventDefault(); + setSubmitting(true); + setMessage(null); + fetch('/api/observers/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ publicKey, iata, name, contact }), + }) + .then(async (response) => { + const value = await response.json().catch(() => ({})) as { error?: string; requestId?: string }; + if (!response.ok) throw new Error(value.error ?? `HTTP ${response.status}`); + setMessage(`Request ${value.requestId ?? ''} submitted. An operator will provision the MQTT account.`); + }) + .catch((error: Error) => setMessage(error.message)) + .finally(() => setSubmitting(false)); + }; + + return ( +
+ + + + + + {message &&

{message}

} +
+ ); +} diff --git a/frontend/src/components/PacketDetailPanel.tsx b/frontend/src/components/PacketDetailPanel.tsx new file mode 100644 index 0000000..d94ee31 --- /dev/null +++ b/frontend/src/components/PacketDetailPanel.tsx @@ -0,0 +1,2 @@ +export { PacketDetailPanel } from '../pages/ukmesh/PacketDetailPanel.js'; +export type { LazyPath, LazyPathNode, LazyPathResult, ResolvedPath } from '../pages/ukmesh/PacketDetailPanel.js'; diff --git a/frontend/src/components/PacketFeed.tsx b/frontend/src/components/PacketFeed.tsx index 65d9d51..3736570 100644 --- a/frontend/src/components/PacketFeed.tsx +++ b/frontend/src/components/PacketFeed.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useMessages, useNodeMap } from '../hooks/useNodes.js'; import { useOverlayStore } from '../store/overlayStore.js'; import { useWatchlist } from '../hooks/useWatchlist.js'; +import type { AggregatedPacket } from '../hooks/useNodes.js'; const TYPE_LABELS: Record = { 0: 'REQ', @@ -17,6 +18,66 @@ const TYPE_LABELS: Record = { 11: 'CTL', }; +type PacketFeedItemProps = { + packet: AggregatedPacket; + observerIata?: string; + isPinned: boolean; + isNew: boolean; + isWatched: boolean; + onTogglePacket: (packet: AggregatedPacket) => void; + onToggleWatch: (category: 'packet_type', id: string, label: string) => void; +}; + +const PacketFeedItem: React.FC = React.memo(({ + packet: p, + observerIata, + isPinned, + isNew, + isWatched, + onTogglePacket, + onToggleWatch, +}) => { + const typeLabel = p.packetType !== undefined + ? (TYPE_LABELS[p.packetType] ?? `T${p.packetType}`) + : '???'; + const display = p.summary?.includes('🚫') ? '[redacted]' : p.summary; + const advertBadge = p.packetType === 4 && typeof p.advertCount === 'number' + ? (p.advertCount === 1 ? 'NEW' : `${p.advertCount}`) + : undefined; + const packetTypeId = String(p.packetType ?? 'unknown'); + + return ( +
onTogglePacket(p)} + role="button" + tabIndex={0} + onKeyDown={(event) => event.key === 'Enter' && onTogglePacket(p)} + > + {observerIata && {observerIata}} + {p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 && {p.pathHashSizeBytes}} + {typeLabel} + {advertBadge && {advertBadge}} + {display ?? '\u00A0'} + {p.hopCount !== undefined && p.hopCount > 0 && ↑{p.hopCount}} + + {p.observerIds.length > 0 && {p.observerIds.length}rx} + {p.txCount > 0 && {p.txCount}tx} + + + {isPinned && ●} +
+ ); +}); + export const PacketFeed: React.FC = React.memo(() => { // GRP messages only — kept in their own store, never evicted by ADV packets const messages = useMessages(); @@ -66,58 +127,18 @@ export const PacketFeed: React.FC = React.memo(() => { return (
{visible.map((p) => { - const typeLabel = p.packetType !== undefined - ? (TYPE_LABELS[p.packetType] ?? `T${p.packetType}`) - : '???'; const observerIata = p.rxNodeId ? nodes.get(p.rxNodeId)?.iata : undefined; - const rawContent = p.summary; - const display = rawContent?.includes('🚫') ? '[redacted]' : rawContent; - - const advertBadge = p.packetType === 4 && typeof p.advertCount === 'number' - ? (p.advertCount === 1 ? 'NEW' : `${p.advertCount}`) - : undefined; - - const isPinned = pinnedPacketId === p.id; - return ( -
togglePinnedPacket(p)} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && togglePinnedPacket(p)} - > - {observerIata && ( - {observerIata} - )} - {p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 && ( - {p.pathHashSizeBytes} - )} - {typeLabel} - {advertBadge && ( - {advertBadge} - )} - - {display ?? '\u00A0'} - - {p.hopCount !== undefined && p.hopCount > 0 && ( - ↑{p.hopCount} - )} - - {p.observerIds.length > 0 && ( - {p.observerIds.length}rx - )} - {p.txCount > 0 && {p.txCount}tx} - - - {isPinned && ●} -
+ ); })}
diff --git a/frontend/src/components/app/AppTopBar.tsx b/frontend/src/components/app/AppTopBar.tsx index 8c09da9..9c04d9f 100644 --- a/frontend/src/components/app/AppTopBar.tsx +++ b/frontend/src/components/app/AppTopBar.tsx @@ -8,6 +8,15 @@ type AppTopBarProps = { wsState: WSReadyState; onShowDisclaimer: () => void; stats: DashboardStats; + mapLight: boolean; + onToggleMapTheme: () => void; + network: string; + onNetworkChange: (network: string) => void; + annotation: string; + onEditAnnotation: () => void; + onShowShortcuts: () => void; + highContrast: boolean; + onToggleContrast: () => void; }; const ConnIndicator: React.FC<{ state: WSReadyState }> = ({ state }) => ( @@ -36,6 +45,15 @@ export const AppTopBar: React.FC = ({ wsState, onShowDisclaimer, stats, + mapLight, + onToggleMapTheme, + network, + onNetworkChange, + annotation, + onEditAnnotation, + onShowShortcuts, + highContrast, + onToggleContrast, }) => (
@@ -49,6 +67,28 @@ export const AppTopBar: React.FC = ({
+ + + + + + {!fullScreenMap && - -
+ } + {!fullScreenMap &&
{FILTER_ROWS.map(({ key, label, color, hollow }) => (
)} -
-
+
} + {!fullScreenMap &&
-
+
}
); }; diff --git a/frontend/src/components/app/WatchlistPanel.tsx b/frontend/src/components/app/WatchlistPanel.tsx index b149a61..83c0643 100644 --- a/frontend/src/components/app/WatchlistPanel.tsx +++ b/frontend/src/components/app/WatchlistPanel.tsx @@ -1,10 +1,23 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useWatchlist } from '../../hooks/useWatchlist.js'; export const WatchlistPanel: React.FC = () => { const { entries, remove } = useWatchlist(); + const [open, setOpen] = useState(() => { + try { return localStorage.getItem('meshcore-watchlist-collapsed-v1') !== '1'; } + catch { return true; } + }); return ( -
+
{ + const next = event.currentTarget.open; + setOpen(next); + try { localStorage.setItem('meshcore-watchlist-collapsed-v1', next ? '0' : '1'); } + catch { /* persistence is best-effort */ } + }} + > Watchlist {entries.length} {entries.length === 0 ?

Star nodes, searches, regions, packet types, or spam incidents to keep them here.

: (
    {entries.map((entry) => ( diff --git a/frontend/src/components/owner/OwnerPortalSections.tsx b/frontend/src/components/owner/OwnerPortalSections.tsx new file mode 100644 index 0000000..76ea6e4 --- /dev/null +++ b/frontend/src/components/owner/OwnerPortalSections.tsx @@ -0,0 +1,79 @@ +import { FormEvent, ReactNode, useEffect, useState } from 'react'; +import { LoadingIndicator } from '../LoadingIndicator.js'; + +export function OwnerLoginSection(props: { + username: string; + password: string; + submitting: boolean; + error: string | null; + onUsername: (value: string) => void; + onPassword: (value: string) => void; + onSubmit: (event: FormEvent) => void; +}) { + return ( +
    +

    Login

    +

    Enter the MQTT credentials associated with your repeater observer.

    +
    + + props.onUsername(event.target.value)} maxLength={128} /> + + props.onPassword(event.target.value)} maxLength={256} /> + +
    + {props.error &&

    {props.error}

    } +
    + ); +} + +export function OwnerSection({ children }: { children: ReactNode }) { + return <>{children}; +} + +type Rule = { id: string; node_id: string; rule_type: string; threshold: number; channels: { webhook?: string }; enabled: boolean }; +async function ownerCsrf(): Promise { + const response = await fetch('/api/owner/csrf', { cache: 'no-store' }); + const value = await response.json() as { csrfToken?: string }; + if (!response.ok || !value.csrfToken) throw new Error('Could not prepare secure request'); + return value.csrfToken; +} + +export function OwnerAlertSettings({ nodes, selectedNodeId }: { nodes: Array<{ node_id: string; name?: string | null }>; selectedNodeId: string }) { + const [rules, setRules] = useState([]); + const [ruleType, setRuleType] = useState('offline_minutes'); + const [threshold, setThreshold] = useState('30'); + const [webhook, setWebhook] = useState(''); + const [message, setMessage] = useState(null); + const load = () => fetch('/api/owner/alert-rules').then((response) => response.ok ? response.json() as Promise : []).then(setRules).catch(() => {}); + useEffect(() => { void load(); }, []); + const submit = (event: FormEvent) => { + event.preventDefault(); + ownerCsrf().then((csrfToken) => fetch('/api/owner/alert-rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }, + body: JSON.stringify({ nodeId: selectedNodeId, ruleType, threshold: Number(threshold), webhook, enabled: true }), + })).then(async (response) => { + const value = await response.json().catch(() => ({})) as { error?: string }; + if (!response.ok) throw new Error(value.error ?? `HTTP ${response.status}`); + setMessage('Alert rule saved.'); + void load(); + }).catch((error: Error) => setMessage(error.message)); + }; + return ( +
    +

    Alert settings

    +

    Create per-node health rules. Webhooks are delivered at most once per hour while a condition remains active.

    +
    + + + + + +
    + {message &&

    {message}

    } +
    {rules.map((rule) =>
    {rule.rule_type.replace(/_/g, ' ')} · {rule.threshold}
    )}
    +
    + ); +} diff --git a/frontend/src/config/mapModes.ts b/frontend/src/config/mapModes.ts index 470d9df..44dd2ed 100644 --- a/frontend/src/config/mapModes.ts +++ b/frontend/src/config/mapModes.ts @@ -24,6 +24,7 @@ export function filtersForMapMode(mode: MapMode, current: Filters): Filters { terrain: false, clientNodes: false, packetHistory: false, + heatmap: false, betaPaths: true, hexClashes: false, }; @@ -35,6 +36,7 @@ export function filtersForMapMode(mode: MapMode, current: Filters): Filters { terrain: false, clientNodes: false, packetHistory: true, + heatmap: false, betaPaths: false, hexClashes: true, }; @@ -46,6 +48,7 @@ export function filtersForMapMode(mode: MapMode, current: Filters): Filters { terrain: true, clientNodes: false, packetHistory: false, + heatmap: false, betaPaths: false, hexClashes: false, }; @@ -57,6 +60,7 @@ export function filtersForMapMode(mode: MapMode, current: Filters): Filters { terrain: false, clientNodes: false, packetHistory: false, + heatmap: false, betaPaths: false, hexClashes: false, }; diff --git a/frontend/src/hooks/useAppMessageHandler.ts b/frontend/src/hooks/useAppMessageHandler.ts index 65fbf87..e4543a7 100644 --- a/frontend/src/hooks/useAppMessageHandler.ts +++ b/frontend/src/hooks/useAppMessageHandler.ts @@ -207,13 +207,12 @@ export function useAppMessageHandler({ ]); // Throttle flush — batches bursts from the WebSocket into single React renders - const BATCH_INTERVAL_MS = 16; const scheduleFlush = useCallback(() => { if (rafRef.current !== null) return; - rafRef.current = window.setTimeout(() => { + rafRef.current = window.requestAnimationFrame(() => { rafRef.current = null; flushPending(); - }, BATCH_INTERVAL_MS); + }); }, [flushPending]); // Cleanup pending flush on unmount @@ -221,7 +220,7 @@ export function useAppMessageHandler({ flushRef.current = flushPending; return () => { if (rafRef.current !== null) { - clearTimeout(rafRef.current); + cancelAnimationFrame(rafRef.current); } // Flush any remaining pending updates flushPending(); @@ -251,7 +250,7 @@ export function useAppMessageHandler({ // immediately so the UI never waits on the 16ms batch window. if (packet.packetType === 5) { if (rafRef.current !== null) { - clearTimeout(rafRef.current); + cancelAnimationFrame(rafRef.current); rafRef.current = null; } pending.packets.push(packet); diff --git a/frontend/src/hooks/useDashboardStats.ts b/frontend/src/hooks/useDashboardStats.ts index cb0dc6b..a9babba 100644 --- a/frontend/src/hooks/useDashboardStats.ts +++ b/frontend/src/hooks/useDashboardStats.ts @@ -35,6 +35,7 @@ export function useDashboardStats(externalStats: DashboardStats | null): Dashboa useEffect(() => { const handlePacketObserved = () => { + if (document.hidden) return; setLocalPacketsDay((n) => n + 1); }; window.addEventListener('meshcore:packet-observed', handlePacketObserved as EventListener); diff --git a/frontend/src/hooks/useNodes.ts b/frontend/src/hooks/useNodes.ts index 09fa5fa..bbd8927 100644 --- a/frontend/src/hooks/useNodes.ts +++ b/frontend/src/hooks/useNodes.ts @@ -206,16 +206,17 @@ function isObserverSelfEchoLoop(packet: LivePacketData, nodes: Map matchesObserverPathHash(packet.rxNodeId, hash)); } function handlePacket(packetOrArray: LivePacketData | LivePacketData[]) { const incomingPackets = Array.isArray(packetOrArray) ? packetOrArray : [packetOrArray]; if (incomingPackets.length === 0) return; - let next = state.packets; + let next = state.packets.slice(); + const packetIndex = new Map(next.map((packet, index) => [packet.packetHash, index])); for (const packet of incomingPackets) { - const idx = next.findIndex((p) => p.packetHash === packet.packetHash); + const idx = packetIndex.get(packet.packetHash) ?? -1; if (idx >= 0) { const current = next[idx]!; @@ -255,19 +256,23 @@ function handlePacket(packetOrArray: LivePacketData | LivePacketData[]) { firstSeenTs: current.firstSeenTs ?? current.ts, ts: packet.ts, }; - next = next.map((p, i) => i === idx ? entry : p); + next[idx] = entry; } else { const entry = createAggregatedPacketFromLive(packet); - next = [entry, ...next].slice(0, FEED_MAX_PACKETS); + packetIndex.set(packet.packetHash, next.length); + next.push(entry); } } + next.sort((a, b) => b.ts - a.ts); + if (next.length > FEED_MAX_PACKETS) next.length = FEED_MAX_PACKETS; // Also maintain the messages (type=5 only) array separately so GRP messages // are never evicted by a flood of ADV packets. - let nextMessages = state.messages; + let nextMessages = state.messages.slice(); + const messageIndex = new Map(nextMessages.map((message, index) => [message.packetHash, index])); for (const packet of incomingPackets) { if (packet.packetType !== 5) continue; - const msgIdx = nextMessages.findIndex((m) => m.packetHash === packet.packetHash); + const msgIdx = messageIndex.get(packet.packetHash) ?? -1; if (msgIdx >= 0) { const cur = nextMessages[msgIdx]!; const updated: AggregatedPacket = { @@ -282,12 +287,15 @@ function handlePacket(packetOrArray: LivePacketData | LivePacketData[]) { txCount: cur.txCount + (packet.direction === 'tx' ? 1 : 0), ts: packet.ts, }; - nextMessages = nextMessages.map((m, i) => (i === msgIdx ? updated : m)); + nextMessages[msgIdx] = updated; } else { const entry = createAggregatedPacketFromLive(packet); - nextMessages = [entry, ...nextMessages].slice(0, FEED_MAX_MESSAGES); + messageIndex.set(packet.packetHash, nextMessages.length); + nextMessages.push(entry); } } + nextMessages.sort((a, b) => b.ts - a.ts); + if (nextMessages.length > FEED_MAX_MESSAGES) nextMessages.length = FEED_MAX_MESSAGES; setState({ ...state, diff --git a/frontend/src/hooks/usePacketDetailData.ts b/frontend/src/hooks/usePacketDetailData.ts new file mode 100644 index 0000000..20feaa2 --- /dev/null +++ b/frontend/src/hooks/usePacketDetailData.ts @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { LazyPathResult, ResolvedPath } from '../pages/ukmesh/PacketDetailPanel.js'; + +export type PacketDetail = { + packetHash: string; + time: string; + rxNodeId: string | null; + srcNodeId: string | null; + topic: string; + packetType: number | null; + routeType: number | null; + hopCount: number | null; + rssi: number | null; + snr: number | null; + payload: Record | null; + pathHashes: string[] | null; + pathHashSizeBytes: number | null; + rawHex: string | null; + observations: Array<{ rxNodeId: string | null; time: string; rssi: number | null; snr: number | null; hopCount: number | null }>; +}; + +export type RadioState = { + frequency?: number; + sf?: number; + bw?: number; + cr?: number; + channel?: string; +}; + +type StaticData = { detail: PacketDetail | null; radio: RadioState | null }; +const CACHE_TTL_MS = 5 * 60_000; +const staticCache = new Map(); +const staticInflight = new Map>(); +const pathCache = new Map(); + +async function cachedStatic(packetHash: string, network: string): Promise { + const key = `${network}:${packetHash}`; + const cached = staticCache.get(key); + if (cached && cached.expiresAt > Date.now()) return cached.value; + const pending = staticInflight.get(key); + if (pending) return pending; + const request = Promise.all([ + fetch(`/api/packets/${packetHash}?network=${encodeURIComponent(network)}`, { signal: AbortSignal.timeout(10_000) }) + .then((response) => response.ok ? response.json() as Promise : null) + .catch(() => null), + fetch('/api/radio-stats', { signal: AbortSignal.timeout(8_000) }) + .then((response) => response.ok ? response.json() as Promise : null) + .catch(() => null), + ]).then(([detail, radio]) => { + const value = { detail, radio }; + staticCache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, value }); + return value; + }).finally(() => staticInflight.delete(key)); + staticInflight.set(key, request); + return request; +} + +export function usePacketDetailData(input: { + packetHash: string; + network: string; + observerKey: string; + hasPathHashes: boolean; + cachedLazyPath?: LazyPathResult | null; +}) { + const { packetHash, network, observerKey, hasPathHashes, cachedLazyPath } = input; + const [detail, setDetail] = useState(null); + const [radio, setRadio] = useState(null); + const [resolvedPaths, setResolvedPaths] = useState([]); + const [loading, setLoading] = useState(true); + const [pathLoading, setPathLoading] = useState(false); + const [lazyPath, setLazyPath] = useState(null); + const [lazyStatus, setLazyStatus] = useState<'idle' | 'settling' | 'loading' | 'done' | 'notfound' | 'error'>('idle'); + const [lazyCountdown, setLazyCountdown] = useState(0); + + useEffect(() => { + let active = true; + setLoading(true); + void cachedStatic(packetHash, network).then((value) => { + if (!active) return; + setDetail(value.detail); + setRadio(value.radio); + setLoading(false); + }); + return () => { active = false; }; + }, [packetHash, network]); + + useEffect(() => { + let active = true; + const key = `${network}:${packetHash}:${observerKey}`; + const cached = pathCache.get(key); + if (cached && cached.expiresAt > Date.now()) { + setResolvedPaths(cached.value); + setPathLoading(false); + return; + } + setPathLoading(true); + const netParam = network ? `&network=${encodeURIComponent(network)}` : ''; + fetch(`/api/path-beta/resolve-multi?hash=${packetHash}${netParam}`, { signal: AbortSignal.timeout(12_000) }) + .then((response) => response.ok ? response.json() as Promise<{ results?: ResolvedPath[] }> : { results: [] }) + .then((value) => { + if (!active) return; + const paths = value.results ?? []; + pathCache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, value: paths }); + setResolvedPaths(paths); + }) + .catch(() => { if (active) setResolvedPaths([]); }) + .finally(() => { if (active) setPathLoading(false); }); + return () => { active = false; }; + }, [packetHash, network, observerKey]); + + const fetchLazyPath = useCallback(async () => { + setLazyStatus('loading'); + try { + const netParam = network ? `&network=${encodeURIComponent(network)}` : ''; + const response = await fetch(`/api/path-lazy/resolve?hash=${packetHash}${netParam}`, { signal: AbortSignal.timeout(12_000) }); + if (response.status === 404) { setLazyStatus('notfound'); return; } + if (!response.ok) { setLazyStatus('error'); return; } + setLazyPath(await response.json() as LazyPathResult); + setLazyStatus('done'); + } catch { + setLazyStatus('error'); + } + }, [packetHash, network]); + + useEffect(() => { + if (cachedLazyPath) { + setLazyPath(cachedLazyPath); + setLazyStatus('done'); + setLazyCountdown(0); + return; + } + if (!hasPathHashes) { + setLazyStatus('notfound'); + return; + } + setLazyPath(null); + setLazyStatus('settling'); + setLazyCountdown(10); + const tick = window.setInterval(() => setLazyCountdown((value) => Math.max(0, value - 1)), 1_000); + const timer = window.setTimeout(() => { + window.clearInterval(tick); + setLazyCountdown(0); + void fetchLazyPath(); + }, 10_000); + return () => { + window.clearInterval(tick); + window.clearTimeout(timer); + }; + }, [cachedLazyPath, fetchLazyPath, hasPathHashes, observerKey]); + + return { detail, radio, resolvedPaths, loading, pathLoading, lazyPath, lazyStatus, lazyCountdown }; +} diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 288f2d6..bed216e 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -62,9 +62,11 @@ export function useWebSocket(onMessage: MessageHandler, scope: ApiScope = {}) { ws.onclose = () => { if (!shouldReconnectRef.current) return; setReadyState('disconnected'); - const delay = retryDelayRef.current; + const baseDelay = retryDelayRef.current; + const jitter = Math.random() * 1000; + const delay = baseDelay + jitter; retryDelayRef.current = Math.min(15000, retryDelayRef.current * 1.5); - console.log(`[ws] disconnected — reconnecting in ${Math.round(delay / 1000)}s`); + console.log(`[ws] disconnected — reconnecting in ${(delay / 1000).toFixed(1)}s (${Math.round(jitter)}ms jitter)`); timerRef.current = setTimeout(connect, delay); }; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 842bc64..40c0c92 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { LoadingIndicator } from './components/LoadingIndicator.js'; import { AppErrorBoundary } from './components/app/AppErrorBoundary.js'; +import './styles/tokens.css'; import './styles/globals.css'; const App = lazy(() => import('./App.js').then(({ App: Component }) => ({ default: Component }))); @@ -32,6 +33,29 @@ const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || host const isDashboardBuild = buildSite === 'ukmesh' && buildNetwork === 'ukmesh'; const isAppDomain = !appHostname || hostname === appHostname || (isLocalhost && isDashboardBuild); +if ('serviceWorker' in navigator) { + let refreshing = false; + const reloadForUpdate = () => { + if (refreshing) return; + refreshing = true; + window.location.reload(); + }; + navigator.serviceWorker.addEventListener('controllerchange', reloadForUpdate); + navigator.serviceWorker.register('/sw.js') + .then((registration) => { + registration.addEventListener('updatefound', () => { + const installing = registration.installing; + if (!installing) return; + installing.addEventListener('statechange', () => { + if (installing.state === 'installed' && navigator.serviceWorker.controller) { + reloadForUpdate(); + } + }); + }); + }) + .catch(() => {}); +} + // Title is managed per-route by SeoHead; only set a fallback for the app domain if (isAppDomain) document.title = 'MeshCore Analytics'; diff --git a/frontend/src/pages/OwnerPortalPage.tsx b/frontend/src/pages/OwnerPortalPage.tsx index 02a4a40..1cbaf83 100644 --- a/frontend/src/pages/OwnerPortalPage.tsx +++ b/frontend/src/pages/OwnerPortalPage.tsx @@ -4,6 +4,7 @@ import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { Area, AreaChart, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { LoadingIndicator } from '../components/LoadingIndicator.js'; +import { OwnerAlertSettings, OwnerLoginSection, OwnerSection } from '../components/owner/OwnerPortalSections.js'; import { DEFAULT_CENTER, MAP_STYLE } from '../components/Map/mapConfig'; type OwnerNode = { @@ -173,6 +174,17 @@ type LastHopStrengthPoint = { type OwnerLastHopStrengthResponse = { points: LastHopStrengthPoint[]; }; +const lastHopSeriesCache = new Map(); + +async function fetchOwnerCsrfToken(): Promise { + const response = await fetch('/api/owner/csrf', { cache: 'no-store' }); + if (!response.ok) throw new Error('Unable to initialize secure owner session'); + const body = await response.json() as { csrfToken?: unknown }; + if (typeof body.csrfToken !== 'string' || !body.csrfToken) { + throw new Error('Unable to initialize secure owner session'); + } + return body.csrfToken; +} type MappedPeer = LivePeer & { lat: number; lon: number }; @@ -842,6 +854,7 @@ export const OwnerPortalPage: React.FC = () => { const [live, setLive] = useState(null); const [liveError, setLiveError] = useState(null); const [lastHopStrength, setLastHopStrength] = useState([]); + const [activeSection, setActiveSection] = useState<'dashboard' | 'live' | 'settings'>('dashboard'); useEffect(() => { let cancelled = false; @@ -917,14 +930,18 @@ export const OwnerPortalPage: React.FC = () => { } setSubmitting(true); setError(null); - fetch('/api/owner/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - mqttUsername: mqttUsername.trim(), - mqttPassword: mqttPassword.trim(), - }), - }) + fetchOwnerCsrfToken() + .then((csrfToken) => fetch('/api/owner/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + }, + body: JSON.stringify({ + mqttUsername: mqttUsername.trim(), + mqttPassword: mqttPassword.trim(), + }), + })) .then(async (res) => { const body = await res.json().catch(() => ({})); if (!res.ok) { @@ -948,7 +965,11 @@ export const OwnerPortalPage: React.FC = () => { }; const handleLogout = () => { - fetch('/api/owner/logout', { method: 'POST' }) + fetchOwnerCsrfToken() + .then((csrfToken) => fetch('/api/owner/logout', { + method: 'POST', + headers: { 'X-CSRF-Token': csrfToken }, + })) .finally(() => { setDashboard(null); setLive(null); @@ -998,6 +1019,11 @@ export const OwnerPortalPage: React.FC = () => { let cancelled = false; const load = () => { + const cached = lastHopSeriesCache.get(selectedNodeId); + if (cached && cached.expiresAt > Date.now()) { + setLastHopStrength(cached.points); + return; + } fetch(`/api/owner/live-last-hop?nodeId=${encodeURIComponent(selectedNodeId)}`, { cache: 'no-store' }) .then(async (res) => { const json = await res.json().catch(() => ({})); @@ -1006,7 +1032,9 @@ export const OwnerPortalPage: React.FC = () => { }) .then((json) => { if (cancelled) return; - setLastHopStrength(json.points ?? []); + const points = json.points ?? []; + lastHopSeriesCache.set(selectedNodeId, { expiresAt: Date.now() + 5 * 60_000, points }); + setLastHopStrength(points); }) .catch(() => { if (cancelled) return; @@ -1074,45 +1102,15 @@ export const OwnerPortalPage: React.FC = () => {
    {loading ? : null} {!loading && !dashboard ? ( -
    -

    Login

    -

    - Enter the MQTT credentials associated with your repeater observer. -

    -
    - - setMqttUsername(e.target.value)} - placeholder="Enter username" - maxLength={128} - /> - - setMqttPassword(e.target.value)} - placeholder="Enter password" - maxLength={256} - /> - -
    - {error ?

    {error}

    : null} -
    + ) : null} {!loading && dashboard ? ( <> -
    + + {activeSection === 'dashboard' &&

    Dashboard

    {liveError ?

    Live data error: {liveError}

    : null} -
    +
    } -
    + {activeSection === 'live' &&

    Node Telemetry

    @@ -1303,7 +1301,9 @@ export const OwnerPortalPage: React.FC = () => { ) : null}
    -
    + } + + {activeSection === 'settings' && } ) : null} diff --git a/frontend/src/pages/SpamTransparencyPage.tsx b/frontend/src/pages/SpamTransparencyPage.tsx index 0901145..7b038b8 100644 --- a/frontend/src/pages/SpamTransparencyPage.tsx +++ b/frontend/src/pages/SpamTransparencyPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { LoadingIndicator } from '../components/LoadingIndicator.js'; @@ -151,7 +151,7 @@ const ZONE_CLOSED = '#ffb300'; // Lazily mounted (only when the user opens it) and fitted to the zone. // --------------------------------------------------------------------------- -function OriginMiniMap({ +const OriginMiniMap = memo(function OriginMiniMap({ zone, active, }: { @@ -222,15 +222,22 @@ function OriginMiniMap({ ); -} +}); // --------------------------------------------------------------------------- // Incident card // --------------------------------------------------------------------------- -function IncidentCard({ incident }: { incident: PublicIncident }) { +const IncidentCard = memo(function IncidentCard({ + incident, + mapOpen, + onToggleMap, +}: { + incident: PublicIncident; + mapOpen: boolean; + onToggleMap: () => void; +}) { const [expanded, setExpanded] = useState(false); - const [showMap, setShowMap] = useState(false); const [detail, setDetail] = useState(null); const [loadingDetail, setLoadingDetail] = useState(false); const watchlist = useWatchlist(); @@ -281,9 +288,9 @@ function IncidentCard({ incident }: { incident: PublicIncident }) { )} - {showMap && o.zone && } + {mapOpen && o.zone && } {incident.similarUsernames.length > 0 && ( @@ -348,6 +355,36 @@ function IncidentCard({ incident }: { incident: PublicIncident }) { )} ); +}); + +function VirtualIncidentList({ + incidents, + openMapId, + setOpenMapId, +}: { + incidents: PublicIncident[]; + openMapId: string | null; + setOpenMapId: (id: string | null) => void; +}) { + const [scrollTop, setScrollTop] = useState(0); + const rowEstimate = 430; + const viewport = 760; + const start = Math.max(0, Math.floor(scrollTop / rowEstimate) - 2); + const end = Math.min(incidents.length, start + Math.ceil(viewport / rowEstimate) + 5); + return ( +
    setScrollTop(event.currentTarget.scrollTop)}> + -); +)); const EmptyPacketState: React.FC<{ label?: string }> = ({ label = 'No packet data in this window.' }) => (
    {label}
    @@ -424,7 +427,16 @@ export const StatsPage: React.FC = () => {
    {loading && ( - +
    + +
    + {Array.from({ length: 6 }, (_, index) =>
    )} +
    +
    +
    +
    +
    +
    )} {data && ( diff --git a/frontend/src/pages/StatusPage.tsx b/frontend/src/pages/StatusPage.tsx index b4fba2c..06b15a4 100644 --- a/frontend/src/pages/StatusPage.tsx +++ b/frontend/src/pages/StatusPage.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { LoadingIndicator } from '../components/LoadingIndicator.js'; +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import './network-intelligence.css'; type HealthPayload = { @@ -8,9 +9,10 @@ type HealthPayload = { maintenance: { active: boolean; message: string | null }; ingest: { active_nodes: number; stale_nodes: number; global_last_packet_at: string | null; packet_age_minutes: number | null }; operational_checks: Array<{ check_name: string; status: string; latency_ms: number; detail: string | null; ts: string }>; - database: { size_bytes: number; dead_rows: number; oldest_vacuum_at: string | null; tables_needing_vacuum: number }; - workers: Array<{ worker_name: string; status: string; queue_depth: number; last_activity_at: string | null }>; + database: { size_bytes: number; dead_rows: number; oldest_vacuum_at: string | null; tables_needing_vacuum: number; connection_count: number; max_connections: number; cache_hit_ratio: number }; + workers: Array<{ worker_name: string; status: string; queue_depth: number; processed_1h?: number; last_activity_at: string | null }>; }; +type FirmwarePayload = { total: number; versions: Array<{ hardware_model: string; firmware_version: string; count: number }> }; function formatBytes(value: number): string { if (!Number.isFinite(value) || value <= 0) return 'Unknown'; @@ -20,6 +22,7 @@ function formatBytes(value: number): string { export const StatusPage: React.FC = () => { const [data, setData] = useState(null); const [error, setError] = useState(false); + const [firmware, setFirmware] = useState(null); useEffect(() => { let active = true; const load = () => fetch('/api/health', { cache: 'no-store' }) @@ -30,6 +33,14 @@ export const StatusPage: React.FC = () => { const timer = window.setInterval(() => void load(), 60_000); return () => { active = false; window.clearInterval(timer); }; }, []); + useEffect(() => { + const controller = new AbortController(); + fetch('/api/repeaters/firmware', { signal: controller.signal }) + .then((response) => response.ok ? response.json() as Promise : null) + .then(setFirmware) + .catch(() => {}); + return () => controller.abort(); + }, []); return (
    @@ -47,9 +58,23 @@ export const StatusPage: React.FC = () => {

    Public ingest

    Active observer nodes
    {data.ingest.active_nodes}
    Stale observers
    {data.ingest.stale_nodes}
    Latest packet age
    {data.ingest.packet_age_minutes == null ? 'Unknown' : `${data.ingest.packet_age_minutes} min`}

    Synthetic journeys

    {data.operational_checks.length === 0 ?

    Monitoring is starting.

    : data.operational_checks.map((check) =>
    {check.check_name.replace(/_/g, ' ')}{check.latency_ms} ms
    )}
    -

    Background workers

    {data.workers.map((worker) =>
    {worker.worker_name}{worker.queue_depth} queued
    )}
    -

    Data maintenance

    Database size
    {formatBytes(data.database.size_bytes)}
    Dead rows reported
    {data.database.dead_rows.toLocaleString()}
    Vacuum attention
    {data.database.tables_needing_vacuum} tables
    +

    Background workers

    {data.workers.map((worker) =>
    {worker.worker_name}{worker.queue_depth} queued · {worker.processed_1h ?? 0}/h · {worker.last_activity_at ? `seen ${Math.max(0, Math.floor((Date.now() - Date.parse(worker.last_activity_at)) / 60_000))}m ago` : 'no activity'}
    )}
    +

    Database

    Disk footprint
    {formatBytes(data.database.size_bytes)}
    Connections
    {data.database.connection_count}/{data.database.max_connections}
    Cache hit ratio
    {data.database.cache_hit_ratio.toFixed(2)}%
    Vacuum attention
    {data.database.tables_needing_vacuum} tables
    +
    +

    Repeater firmware distribution

    + {firmware && firmware.versions.length > 0 ? ( + + + + + + [value, item.payload.hardware_model]} /> + + + + ) :

    Firmware telemetry is not yet available.

    } +

    Status values are deliberately aggregated. Hostnames, addresses, credentials, and private node identities are never included.

    )} diff --git a/frontend/src/pages/TopologyPage.tsx b/frontend/src/pages/TopologyPage.tsx index 7a07aaf..bbc4277 100644 --- a/frontend/src/pages/TopologyPage.tsx +++ b/frontend/src/pages/TopologyPage.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, type Simulation, type SimulationNodeDatum } from 'd3-force'; import { LoadingIndicator } from '../components/LoadingIndicator.js'; import { getCurrentSite } from '../config/site.js'; import { withScopeParams } from '../utils/api.js'; @@ -11,6 +12,7 @@ type TopologyNode = { lon: number | null; degree: number; observations: number; + region?: string | null; }; type TopologyLink = { @@ -45,7 +47,7 @@ type RfValidationPayload = { }>; }; -type PlotNode = TopologyNode & { x: number; y: number }; +type PlotNode = TopologyNode & SimulationNodeDatum & { x: number; y: number }; function compactNumber(value: number): string { return new Intl.NumberFormat('en-GB', { notation: 'compact', maximumFractionDigits: 1 }).format(value); @@ -58,6 +60,9 @@ export const TopologyPage: React.FC = () => { const [selectedNodeId, setSelectedNodeId] = useState(null); const [strongOnly, setStrongOnly] = useState(false); const [rfValidation, setRfValidation] = useState(null); + const [region, setRegion] = useState('all'); + const [plotNodes, setPlotNodes] = useState([]); + const simulationRef = useRef | null>(null); useEffect(() => { const controller = new AbortController(); @@ -82,11 +87,14 @@ export const TopologyPage: React.FC = () => { return () => controller.abort(); }, [site.networkFilter]); - const plot = useMemo(() => { + useEffect(() => { const located = (payload?.nodes ?? []).filter( (node): node is TopologyNode & { lat: number; lon: number } => node.lat != null && node.lon != null, ); - if (located.length === 0) return { nodes: [] as PlotNode[], links: [] as TopologyLink[] }; + if (located.length === 0) { + setPlotNodes([]); + return; + } const lats = located.map((node) => node.lat); const lons = located.map((node) => node.lon); const minLat = Math.min(...lats); @@ -100,14 +108,28 @@ export const TopologyPage: React.FC = () => { x: 40 + ((node.lon - minLon) / lonSpan) * 920, y: 560 - ((node.lat - minLat) / latSpan) * 520, })); + const simulation = forceSimulation(nodes) + .force('charge', forceManyBody().strength(-28)) + .force('center', forceCenter(500, 300).strength(0.035)) + .force('collision', forceCollide().radius((node) => Math.min(14, 5 + Math.sqrt(node.degree)))) + .force('links', forceLink( + (payload?.links ?? []).map((link) => ({ source: link.source, target: link.target })), + ).id((node) => node.nodeId).distance(40).strength(0.08)) + .alpha(0.5) + .on('tick', () => setPlotNodes(nodes.map((node) => ({ ...node, x: node.x ?? 500, y: node.y ?? 300 })))); + simulationRef.current = simulation; + return () => { simulation.stop(); simulationRef.current = null; }; + }, [payload]); + + const plot = useMemo(() => { + const nodes = plotNodes.filter((node) => region === 'all' || (node.region ?? 'Unknown') === region); const ids = new Set(nodes.map((node) => node.nodeId)); return { nodes, - links: (payload?.links ?? []).filter((link) => ( - ids.has(link.source) && ids.has(link.target) && (!strongOnly || link.strongObservations > 0) - )), + links: (payload?.links ?? []).filter((link) => ids.has(link.source) && ids.has(link.target) && (!strongOnly || link.strongObservations > 0)), }; - }, [payload, strongOnly]); + }, [payload?.links, plotNodes, region, strongOnly]); + const regions = useMemo(() => [...new Set((payload?.nodes ?? []).map((node) => node.region ?? 'Unknown'))].sort(), [payload]); const nodesById = useMemo(() => new Map(plot.nodes.map((node) => [node.nodeId, node])), [plot.nodes]); const selected = payload?.nodes.find((node) => node.nodeId === selectedNodeId) ?? null; @@ -130,6 +152,12 @@ export const TopologyPage: React.FC = () => { setStrongOnly(event.target.checked)} /> Multibyte evidence only +
{error &&
{error}
} @@ -181,6 +209,25 @@ export const TopologyPage: React.FC = () => { tabIndex={0} aria-label={`${node.name ?? node.nodeId.slice(0, 8)}, ${node.degree} links`} onClick={() => setSelectedNodeId(node.nodeId)} + onPointerDown={(event) => { + const svg = event.currentTarget.ownerSVGElement; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const move = (pointer: PointerEvent) => { + node.fx = ((pointer.clientX - rect.left) / rect.width) * 1000; + node.fy = ((pointer.clientY - rect.top) / rect.height) * 600; + simulationRef.current?.alphaTarget(0.25).restart(); + }; + const up = () => { + node.fx = null; + node.fy = null; + simulationRef.current?.alphaTarget(0); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up, { once: true }); + }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedNodeId(node.nodeId); }} > {node.name ?? node.nodeId.slice(0, 8)} · {node.degree} links · {compactNumber(node.observations)} observations @@ -200,6 +247,11 @@ export const TopologyPage: React.FC = () => { {compactNumber(selected.observations)} observations {bridgeIds.has(selected.nodeId) && Likely bridge between network segments} {isolatedIds.has(selected.nodeId) && No recent viable relationships} + {selected.lat != null && selected.lon != null && ( +
+ Zoom to node on map + + )} ) : ( diff --git a/frontend/src/pages/network-intelligence.css b/frontend/src/pages/network-intelligence.css index a91036a..eb242c1 100644 --- a/frontend/src/pages/network-intelligence.css +++ b/frontend/src/pages/network-intelligence.css @@ -105,3 +105,10 @@ .status-page__problem { flex-direction: column; gap: 5px; } .status-page__grid { grid-template-columns: 1fr; } } +.status-page__firmware { + margin-top: 18px; + padding: 18px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-panel); +} diff --git a/frontend/src/pages/owner-portal.css b/frontend/src/pages/owner-portal.css index d35c890..7a05c85 100644 --- a/frontend/src/pages/owner-portal.css +++ b/frontend/src/pages/owner-portal.css @@ -430,3 +430,34 @@ font-size: 13px; line-height: 1.45; } +.owner-section-tabs { + position: sticky; + top: 0; + z-index: 5; + display: flex; + gap: 8px; + padding: 10px 0; + background: var(--bg-base); +} +.owner-section-tabs button { + flex: 1; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 10px; + background: var(--bg-panel); + color: var(--text-secondary); + text-transform: capitalize; +} +.owner-section-tabs button[aria-pressed='true'] { border-color: var(--accent); color: var(--text-primary); } +.owner-settings__form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.owner-settings__form label { display: grid; gap: 4px; } +.owner-settings__form input, .owner-settings__form select { min-width: 0; padding: 10px; background: var(--bg-panel); border: 1px solid var(--border); color: var(--text-primary); } +.owner-settings__rules article { display: flex; justify-content: space-between; gap: 10px; padding: 10px; border-bottom: 1px solid var(--border); } +@media (max-width: 700px) { + .site-prose--wide { padding-inline: 12px; } + .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: auto; } + .owner-panel { min-height: 300px; } + .owner-telemetry-strip { grid-template-columns: 1fr; } + .owner-settings__form { grid-template-columns: 1fr; } +} diff --git a/frontend/src/pages/shared/SiteLayout.tsx b/frontend/src/pages/shared/SiteLayout.tsx index c6d71e4..3204c98 100644 --- a/frontend/src/pages/shared/SiteLayout.tsx +++ b/frontend/src/pages/shared/SiteLayout.tsx @@ -19,6 +19,7 @@ type SiteLayoutProps = { showCompanion?: boolean; showRegions?: boolean; showTopology?: boolean; + showSpam?: boolean; }; type NavItem = { @@ -56,11 +57,13 @@ export const SiteLayout: React.FC = ({ showCompanion = false, showRegions = false, showTopology = false, + showSpam = false, }) => { const COOKIE_CONSENT_KEY = 'meshcore-cookie-consent-v1'; const [menuOpen, setMenuOpen] = useState(false); const navRef = useRef(null); const [ownerLabel, setOwnerLabel] = useState(null); + const [activeSpamIncidents, setActiveSpamIncidents] = useState(0); const [cookieConsent, setCookieConsent] = useState(() => { try { return localStorage.getItem(COOKIE_CONSENT_KEY) === '1'; @@ -74,6 +77,7 @@ export const SiteLayout: React.FC = ({ { to: '/companion', label: 'Companions', enabled: showCompanion }, { to: '/regions', label: 'Regions', enabled: showRegions }, { to: '/topology', label: 'Topology', enabled: showTopology }, + { to: '/spam', label: 'Spam', enabled: showSpam }, { to: '/about', label: 'What is MeshCore', enabled: showAbout }, { to: '/install', label: 'Install', enabled: showInstall }, { to: '/mqtt', label: 'MQTT', enabled: showMqtt }, @@ -86,6 +90,16 @@ export const SiteLayout: React.FC = ({ const closeMenu = () => setMenuOpen(false); + useEffect(() => { + if (!showSpam) return; + const controller = new AbortController(); + fetch('/api/spam/messages/status', { signal: controller.signal }) + .then((response) => response.ok ? response.json() as Promise<{ activeIncidents?: number }> : null) + .then((value) => setActiveSpamIncidents(Number(value?.activeIncidents ?? 0))) + .catch(() => {}); + return () => controller.abort(); + }, [showSpam]); + useEffect(() => { if (!menuOpen) return undefined; @@ -178,6 +192,7 @@ export const SiteLayout: React.FC = ({ className={navClassName} > {item.label} + {item.to === '/spam' && activeSpamIncidents > 0 && {activeSpamIncidents}} ))} Health Check diff --git a/frontend/src/pages/site-content.css b/frontend/src/pages/site-content.css index 5b6710b..793f7ba 100644 --- a/frontend/src/pages/site-content.css +++ b/frontend/src/pages/site-content.css @@ -707,3 +707,47 @@ flex: 1 1 100%; } } +.observer-registration { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-md); + margin-top: var(--space-lg); +} +.observer-registration label { display: grid; gap: var(--space-xs); color: var(--text-secondary); } +.observer-registration input { + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 10px 12px; + background: var(--bg-panel); + color: var(--text-primary); +} +.observer-registration button, +.observer-registration p { grid-column: 1 / -1; } +.hw-card { text-align: left; cursor: pointer; color: inherit; } +@media (max-width: 640px) { + .observer-registration { grid-template-columns: 1fr; } +} +.site-btn--donate { + border-color: var(--color-gold); + background: var(--color-gold); + color: #111827; + font-weight: 800; +} +.site-home-health { border-block: 1px solid var(--border); background: var(--bg-panel); } +.site-home-health__inner { display: flex; align-items: center; gap: 18px; padding-block: 13px; } +.site-home-health__inner > div { display: flex; align-items: center; gap: 8px; } +.site-home-health__inner > span { color: var(--text-secondary); } +.site-home-health__inner > a { margin-left: auto; color: var(--accent); } +.site-home-health__dot { width: 11px; height: 11px; border-radius: 50%; background: var(--color-amber); } +.site-home-health--healthy .site-home-health__dot { background: var(--color-green); } +.site-home-health--poor .site-home-health__dot { background: var(--color-red); } +.site-home-feed { display: grid; gap: 8px; } +.site-home-feed article { display: grid; grid-template-columns: 110px 1fr auto; gap: 12px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-md); } +.site-home-feed time { color: var(--text-muted); } +@media (max-width: 640px) { + .site-home-health__inner { align-items: flex-start; flex-direction: column; gap: 5px; } + .site-home-health__inner > a { margin-left: 0; } + .site-home-feed article { grid-template-columns: 1fr auto; } + .site-home-feed article span { grid-column: 1 / -1; grid-row: 2; } +} diff --git a/frontend/src/pages/site-shell.css b/frontend/src/pages/site-shell.css index c1f255d..95fbfe8 100644 --- a/frontend/src/pages/site-shell.css +++ b/frontend/src/pages/site-shell.css @@ -192,3 +192,15 @@ /* Mobile controls wrapper (filter grid + search) — hidden on desktop */ .mobile-controls { display: none; } .mobile-search { display: none; } +.site-nav__badge { + display: inline-grid; + place-items: center; + min-width: 18px; + height: 18px; + margin-left: 5px; + padding-inline: 4px; + border-radius: 999px; + background: var(--color-red); + color: #fff; + font: 10px var(--font-mono); +} diff --git a/frontend/src/pages/spam-page.css b/frontend/src/pages/spam-page.css index 3a82307..14707d8 100644 --- a/frontend/src/pages/spam-page.css +++ b/frontend/src/pages/spam-page.css @@ -448,3 +448,13 @@ @media (max-width: 340px) { .sm-grid { grid-template-columns: 1fr; } } +.sm-virtual-list { + max-height: min(760px, 75vh); + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.sm-virtual-list .sm-card { + contain: layout paint; +} diff --git a/frontend/src/pages/stats-page.css b/frontend/src/pages/stats-page.css index 4c7f717..1a1d333 100644 --- a/frontend/src/pages/stats-page.css +++ b/frontend/src/pages/stats-page.css @@ -309,3 +309,24 @@ grid-template-columns: 1fr; } } +/* Dashboard loading shells reserve final layout space and avoid chart jumps. */ +.skeleton-shimmer { + position: relative; + overflow: hidden; + min-height: 84px; + background: var(--bg-panel-alt); +} +.skeleton-shimmer::after { + position: absolute; + inset: 0; + content: ''; + transform: translateX(-100%); + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.09), transparent); + animation: skeleton-shimmer 1.35s infinite; +} +.stats-page__chart-skeleton { height: 220px; border-radius: var(--radius-md); } +.stats-page__loading-shell .stats-page__chart { min-height: 310px; } +@keyframes skeleton-shimmer { to { transform: translateX(100%); } } +@media (prefers-reduced-motion: reduce) { + .skeleton-shimmer::after { animation: none; } +} diff --git a/frontend/src/pages/ukmesh/PacketDetailPanel.tsx b/frontend/src/pages/ukmesh/PacketDetailPanel.tsx index 80238c7..782911e 100644 --- a/frontend/src/pages/ukmesh/PacketDetailPanel.tsx +++ b/frontend/src/pages/ukmesh/PacketDetailPanel.tsx @@ -1,30 +1,13 @@ -import React, { useEffect, useRef, useMemo, useCallback } from 'react'; +import React, { useEffect, useRef, useMemo } from 'react'; import type maplibregl from 'maplibre-gl'; import { LoadingIndicator } from '../../components/LoadingIndicator.js'; import type { MeshNode } from '../../hooks/useNodes.js'; import type { FeedPacket } from './UKFeedPage.js'; import { buildPathNodePopupContent } from './pathNodePopup.js'; +import { usePacketDetailData, type RadioState } from '../../hooks/usePacketDetailData.js'; // ── Types ───────────────────────────────────────────────────────────────────── -type PacketDetail = { - packetHash: string; - time: string; - rxNodeId: string | null; - srcNodeId: string | null; - topic: string; - packetType: number | null; - routeType: number | null; - hopCount: number | null; - rssi: number | null; - snr: number | null; - payload: Record | null; - pathHashes: string[] | null; - pathHashSizeBytes: number | null; - rawHex: string | null; - observations: Array<{ rxNodeId: string | null; time: string; rssi: number | null; snr: number | null; hopCount: number | null }>; -}; - export type ResolvedPath = { ok: boolean; mode: 'resolved' | 'fallback' | 'none'; @@ -34,14 +17,6 @@ export type ResolvedPath = { redSegments?: [[number, number], [number, number]][]; }; -type RadioState = { - frequency?: number; - sf?: number; - bw?: number; - cr?: number; - channel?: string; -}; - export type LazyPathNode = { position: number; hash: string; @@ -462,120 +437,14 @@ export const PacketDetailPanel: React.FC<{ onClose: () => void; cachedLazyPath?: LazyPathResult | null; }> = ({ packet, nodeMap, network, onClose, cachedLazyPath }) => { - const [detail, setDetail] = React.useState(null); - const [resolvedPaths, setResolvedPaths] = React.useState([]); - const [radio, setRadio] = React.useState(null); - const [loading, setLoading] = React.useState(true); - const [pathLoading, setPathLoading] = React.useState(false); - const [lazyPath, setLazyPath] = React.useState(null); - const [lazyStatus, setLazyStatus] = React.useState<'idle' | 'settling' | 'loading' | 'done' | 'notfound' | 'error'>('idle'); - const [lazyCountdown, setLazyCountdown] = React.useState(0); - const lazyTimerRef = useRef | null>(null); - const lazyTickRef = useRef | null>(null); - - // Fetch static detail + radio once per packet hash - useEffect(() => { - let cancelled = false; - setLoading(true); - setDetail(null); - - Promise.all([ - fetch(`/api/packets/${packet.packet_hash}?network=${encodeURIComponent(network)}`, { cache: 'no-store' }) - .then((r) => r.ok ? r.json() as Promise : null) - .catch(() => null), - fetch('/api/radio-stats', { cache: 'no-store' }) - .then((r) => r.ok ? r.json() as Promise : null) - .catch(() => null), - ]).then(([d, r]) => { - if (cancelled) return; - setDetail(d); - setRadio(r); - setLoading(false); - }); - - return () => { cancelled = true; }; - }, [packet.packet_hash, network]); - - // Lazy path fetch — called once settling is done - const fetchLazyPath = useCallback(async () => { - setLazyStatus('loading'); - try { - const netParam = network ? `&network=${encodeURIComponent(network)}` : ''; - const r = await fetch(`/api/path-lazy/resolve?hash=${packet.packet_hash}${netParam}`, { cache: 'no-store' }); - if (r.status === 404) { setLazyStatus('notfound'); return; } - if (!r.ok) { setLazyStatus('error'); return; } - const data = await r.json() as LazyPathResult; - setLazyPath(data); - setLazyStatus('done'); - } catch { - setLazyStatus('error'); - } - }, [packet.packet_hash, network]); - - // Re-fetch resolved paths whenever the observer list changes (new MQTT observation) const observerKey = (packet.observer_node_ids ?? []).slice().sort().join(','); - useEffect(() => { - let cancelled = false; - setPathLoading(true); - - const netParam = network ? `&network=${encodeURIComponent(network)}` : ''; - fetch(`/api/path-beta/resolve-multi?hash=${packet.packet_hash}${netParam}`, { cache: 'no-store' }) - .then((r): Promise => { - if (!r.ok) return Promise.resolve([]); - return (r.json() as Promise<{ results?: ResolvedPath[] }>).then((data) => data.results ?? []); - }) - .catch(() => []) - .then((paths) => { - if (cancelled) return; - setResolvedPaths(paths); - setPathLoading(false); - }); - - return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [packet.packet_hash, network, observerKey]); - - // Lazy path: use background-cached result if available, otherwise settle-and-fetch. - const SETTLE_MS = 10_000; - useEffect(() => { - if (lazyTimerRef.current) clearTimeout(lazyTimerRef.current); - if (lazyTickRef.current) clearInterval(lazyTickRef.current); - - // If the background cache in UKFeedPage already has a result, use it immediately. - if (cachedLazyPath) { - setLazyPath(cachedLazyPath); - setLazyStatus('done'); - setLazyCountdown(0); - return; - } - - // Don't attempt lazy resolution for packets with no path hashes - if (!packet.path_hashes?.length) { - setLazyStatus('notfound'); - return; - } - - // Otherwise run the in-panel settle timer as a fallback. - setLazyPath(null); - setLazyStatus('settling'); - setLazyCountdown(SETTLE_MS / 1000); - - lazyTickRef.current = setInterval(() => { - setLazyCountdown((c) => Math.max(0, c - 1)); - }, 1000); - - lazyTimerRef.current = setTimeout(() => { - if (lazyTickRef.current) clearInterval(lazyTickRef.current); - setLazyCountdown(0); - void fetchLazyPath(); - }, SETTLE_MS); - - return () => { - if (lazyTimerRef.current) clearTimeout(lazyTimerRef.current); - if (lazyTickRef.current) clearInterval(lazyTickRef.current); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [packet.packet_hash, network, observerKey, cachedLazyPath]); + const { detail, resolvedPaths, radio, loading, pathLoading, lazyPath, lazyStatus, lazyCountdown } = usePacketDetailData({ + packetHash: packet.packet_hash, + network, + observerKey, + hasPathHashes: Boolean(packet.path_hashes?.length), + cachedLazyPath, + }); // Observer info const rxNodeId = detail?.rxNodeId ?? packet.rx_node_id ?? null; diff --git a/frontend/src/pages/ukmesh/UKCompanionPage.tsx b/frontend/src/pages/ukmesh/UKCompanionPage.tsx index 38c79a9..0034a5c 100644 --- a/frontend/src/pages/ukmesh/UKCompanionPage.tsx +++ b/frontend/src/pages/ukmesh/UKCompanionPage.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useWebSocket } from '../../hooks/useWebSocket.js'; import type { WSMessage } from '../../hooks/useWebSocket.js'; import { LoadingIndicator } from '../../components/LoadingIndicator.js'; +import { ObserverRegistrationForm } from '../../components/ObserverRegistrationForm.js'; interface CompanionEntry { sender: string; @@ -142,6 +143,13 @@ export const UKCompanionPage: React.FC = () => { )} +
+
+

Register an observer station

+

Submit the station key and region for rate-limited operator review. Broker credentials are issued out of band.

+ +
+
); }; diff --git a/frontend/src/pages/ukmesh/UKFeedPage.tsx b/frontend/src/pages/ukmesh/UKFeedPage.tsx index c6baf60..ab77d0a 100644 --- a/frontend/src/pages/ukmesh/UKFeedPage.tsx +++ b/frontend/src/pages/ukmesh/UKFeedPage.tsx @@ -6,7 +6,7 @@ import { useMessages, useNodes, type MeshNode, type LivePacketData, type Aggrega import type { RecentPacketRow } from '../../hooks/packetFeed.js'; import { chartStatsEndpoint, uncachedEndpoint } from '../../utils/api.js'; import { LoadingIndicator } from '../../components/LoadingIndicator.js'; -import { PathMap } from './PacketDetailPanel.js'; +import { PacketDetailPanel, PathMap } from './PacketDetailPanel.js'; import type { LazyPathResult, LazyPath, LazyPathNode } from './PacketDetailPanel.js'; export type FeedPacket = { @@ -406,6 +406,9 @@ export const UKFeedPage: React.FC = () => { const [searchQuery, setSearchQuery] = useState(''); const [now, setNow] = useState(() => Date.now()); const [viewportHeight, setViewportHeight] = useState(() => window.innerHeight); + const [listScrollTop, setListScrollTop] = useState(0); + const [detailOpen, setDetailOpen] = useState(false); + const packetListRef = useRef(null); // Use useNodes hook like the main App does const { @@ -536,13 +539,6 @@ export const UKFeedPage: React.FC = () => { ); }, [messagePackets, packets]); - const visiblePacketLimit = useMemo(() => { - // Layout height: calc(100vh - 140px); chat header ~55px; rows vary 50–80px - // Use 80px to guarantee no scrollbar when observers wrap to two lines - const listHeight = Math.max(400, viewportHeight - 200); - return Math.max(6, Math.floor(listHeight / 80)); - }, [viewportHeight]); - const availableIatas = useMemo(() => { return regionOptions; }, [regionOptions]); @@ -598,14 +594,20 @@ export const UKFeedPage: React.FC = () => { const latestPacket = filteredPackets[0] ?? null; const globalLatestPacket = packets[0] ?? null; // unfiltered, for connection status const recentPackets = useMemo(() => { - if (searchQuery.trim()) return filteredPackets; - const messageViewActive = selectedMessageScope !== 'all' || messagesOnly; - // Message views: show up to 50 messages (scrollable) - // All-packets view: cap to viewport rows so there's no pointless scrollbar - return messageViewActive - ? filteredPackets.slice(0, 50) - : filteredPackets.slice(0, visiblePacketLimit); - }, [filteredPackets, messagesOnly, searchQuery, selectedMessageScope, visiblePacketLimit]); + return filteredPackets.slice(0, MAX_PACKETS); + }, [filteredPackets]); + const virtualRows = useMemo(() => { + const rowHeight = 76; + const visibleHeight = Math.max(320, viewportHeight - 200); + const start = Math.max(0, Math.floor(listScrollTop / rowHeight) - 5); + const end = Math.min(recentPackets.length, start + Math.ceil(visibleHeight / rowHeight) + 10); + return { + rows: recentPackets.slice(start, end), + start, + top: start * rowHeight, + bottom: Math.max(0, (recentPackets.length - end) * rowHeight), + }; + }, [listScrollTop, recentPackets, viewportHeight]); // Always derive selectedPacket from the live list so new MQTT observers are picked up const selectedPacket = useMemo( @@ -837,14 +839,20 @@ export const UKFeedPage: React.FC = () => { onChange={(e) => setSearchQuery(e.target.value)} /> -
- {recentPackets.length > 0 ? recentPackets.map((packet) => { +
setListScrollTop(event.currentTarget.scrollTop)} + > + {recentPackets.length > 0 ? <> +
)} @@ -1004,6 +1017,19 @@ export const UKFeedPage: React.FC = () => {
)} + {detailOpen && selectedPacket && ( +
+
+ setDetailOpen(false)} + /> +
+
+ )} ); }; diff --git a/frontend/src/pages/ukmesh/UKHomePage.tsx b/frontend/src/pages/ukmesh/UKHomePage.tsx index 3510a7b..53eae0f 100644 --- a/frontend/src/pages/ukmesh/UKHomePage.tsx +++ b/frontend/src/pages/ukmesh/UKHomePage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { LiveStatsSection } from '../../components/LiveStatsSection.js'; import { getCurrentSite } from '../../config/site.js'; @@ -8,6 +8,26 @@ const meshcoreSupportPostUrl = 'https://blog.meshcore.io/2026/07/04/help-us-save export const UKHomePage: React.FC = () => { const site = getCurrentSite(); + const [regions, setRegions] = useState>([]); + const [recent, setRecent] = useState>([]); + useEffect(() => { + const controller = new AbortController(); + void Promise.allSettled([ + fetch('/api/stats/charts?network=ukmesh', { signal: controller.signal }).then((response) => response.json()), + fetch('/api/packets/recent?network=ukmesh&limit=5', { signal: controller.signal }).then((response) => response.json()), + ]).then(([stats, packets]) => { + if (stats.status === 'fulfilled') setRegions(Array.isArray(stats.value?.observerRegions) ? stats.value.observerRegions : []); + if (packets.status === 'fulfilled') setRecent(Array.isArray(packets.value) ? packets.value : []); + }); + return () => controller.abort(); + }, []); + const regionalHealth = useMemo(() => { + if (regions.length === 0) return { status: 'watch', label: 'Health data starting', score: 0 }; + const score = Math.round(regions.reduce((sum, region) => sum + Number(region.health?.score ?? 0), 0) / regions.length); + return score >= 75 ? { status: 'healthy', label: 'Regions healthy', score } + : score >= 45 ? { status: 'watch', label: 'Regions need attention', score } + : { status: 'poor', label: 'Regional disruption', score }; + }, [regions]); return ( <> @@ -20,6 +40,7 @@ export const UKHomePage: React.FC = () => { supporting documentation behind the live map.

+ Donate to MeshCore Open live map Install MeshCore Network stats @@ -50,8 +71,32 @@ export const UKHomePage: React.FC = () => {
+
+
+
{regionalHealth.label}
+ {regionalHealth.score}% aggregate health · {regions.length} reporting regions + View regional detail → +
+
+ +
+
+

Recent feed

Open live feed →
+
+ {recent.map((packet) => ( +
+ {packet.packet_hash.slice(0, 12)} + {packet.summary || `Packet type ${packet.packet_type ?? 'unknown'}`} + +
+ ))} + {recent.length === 0 &&

Waiting for recent public packets…

} +
+
+
+
diff --git a/frontend/src/pages/ukmesh/UKInstallPage.tsx b/frontend/src/pages/ukmesh/UKInstallPage.tsx index 8dde8db..5b3f73d 100644 --- a/frontend/src/pages/ukmesh/UKInstallPage.tsx +++ b/frontend/src/pages/ukmesh/UKInstallPage.tsx @@ -1,6 +1,17 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { ObserverRegistrationForm } from '../../components/ObserverRegistrationForm.js'; -export const UKInstallPage: React.FC = () => ( +const HARDWARE = [ + { id: 'v4', name: 'Heltec WiFi LoRa 32 V4', detail: 'ESP32-S3 · 868 MHz · OLED · USB-C · Li-Po', usb: 'USB-C', boot: 'Hold PRG while tapping RST if Web Serial cannot find the board.' }, + { id: 'v3', name: 'Heltec WiFi LoRa 32 V3', detail: 'ESP32 · 868 MHz · OLED · USB-C', usb: 'USB-C', boot: 'Hold PRG while connecting USB to enter the ROM bootloader.' }, + { id: 't3s3', name: 'LILYGO T3S3', detail: 'ESP32-S3 · 868 MHz · E-Paper optional', usb: 'USB-C', boot: 'Hold BOOT, tap RESET, then release BOOT.' }, + { id: 't114', name: 'Heltec Mesh Node T114', detail: 'nRF52840 · 868 MHz · Ultra-compact', usb: 'USB-C', boot: 'Double-tap RESET to expose the UF2 boot drive.' }, +] as const; + +export const UKInstallPage: React.FC = () => { + const [hardwareId, setHardwareId] = useState<(typeof HARDWARE)[number]['id']>('v4'); + const hardware = HARDWARE.find((entry) => entry.id === hardwareId)!; + return ( <>
@@ -18,6 +29,18 @@ export const UKInstallPage: React.FC = () => (

+ {HARDWARE.map((entry) => )} + {/* Legacy card markup intentionally replaced by the interactive selector.
Recommended
Heltec WiFi LoRa 32 V4
@@ -35,6 +58,10 @@ export const UKInstallPage: React.FC = () => (
Heltec Mesh Node T114
nRF52840 · 868 MHz · Ultra-compact
+ */} +
+
+ {hardware.name}: use a {hardware.usb} data cable. {hardware.boot}

@@ -59,7 +86,7 @@ export const UKInstallPage: React.FC = () => ( .

  • - Select your device hardware from the dropdown, then select{' '} + Select {hardware.name} from the hardware dropdown, then select{' '} Companion Radio (Bluetooth) as the firmware type.
  • @@ -179,8 +206,12 @@ Password: `} Flasher ↗{' '} to flash a firmware build with the MQTT bridge built in directly to your repeater node — no separate Linux device needed.
  • +

    Request access

    +

    The API records a provisioning request; MQTT password creation remains operator-controlled so credentials are never exposed by the public site.

    +
    -); + ); +}; diff --git a/frontend/src/pages/ukmesh/UKLayout.tsx b/frontend/src/pages/ukmesh/UKLayout.tsx index a12da19..db9d729 100644 --- a/frontend/src/pages/ukmesh/UKLayout.tsx +++ b/frontend/src/pages/ukmesh/UKLayout.tsx @@ -22,6 +22,7 @@ export const UKLayout: React.FC = () => { showCompanion showRegions={false} showTopology + showSpam showAbout={false} showInstall showBestPractice diff --git a/frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx b/frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx index 1520d67..68221db 100644 --- a/frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx +++ b/frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx @@ -41,6 +41,35 @@ interface AdvertPacket { packet_hash: string; } +type NodeDetailBundle = { links: NodeLink[]; history: PacketHistory[]; adverts: AdvertPacket[] }; +const NODE_DETAIL_TTL_MS = 5 * 60_000; +const nodeDetailCache = new Map(); + +async function fetchJsonWithTimeout(url: string, timeoutMs = 8_000): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + +async function loadNodeDetails(node: MeshNode): Promise { + const cacheKey = node.node_id.toUpperCase(); + const cached = nodeDetailCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) return cached.value; + const publicKey = node.public_key ?? node.node_id; + const [links, history, adverts] = await Promise.all([ + fetchJsonWithTimeout(`/api/nodes/${node.node_id}/links`), + fetchJsonWithTimeout(`/api/nodes/${node.node_id}/history?hours=24`), + fetchJsonWithTimeout(`/api/nodes/${publicKey}/adverts?hours=168`), + ]); + const value = { + links: Array.isArray(links) ? links : [], + history: Array.isArray(history) ? history : [], + adverts: Array.isArray(adverts) ? adverts : [], + }; + nodeDetailCache.set(cacheKey, { expiresAt: Date.now() + NODE_DETAIL_TTL_MS, value }); + return value; +} + function timeAgo(iso: string): string { const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); if (secs < 60) return `${secs}s ago`; @@ -154,10 +183,10 @@ export const UKRepeaterSearchPage: React.FC = () => { // Load nodes on mount useEffect(() => { let cancelled = false; - fetch('/api/nodes?network=ukmesh') + fetch('/api/nodes/map?network=ukmesh&fields=node_id,name,lat,lon,iata,role,last_seen,is_online,hardware_model') .then(r => r.json()) .then(data => { - if (!cancelled) setNodes(Array.isArray(data) ? data : []); + if (!cancelled) setNodes(Array.isArray(data) ? data.map((node: MeshNode) => ({ ...node, public_key: node.node_id })) : []); }) .catch(() => { if (!cancelled) setNodes([]); @@ -215,19 +244,10 @@ export const UKRepeaterSearchPage: React.FC = () => { setCopiedKey(false); try { - const [linksRes, historyRes, advertsRes] = await Promise.all([ - fetch(`/api/nodes/${node.node_id}/links`), - fetch(`/api/nodes/${node.node_id}/history?hours=24`), - fetch(`/api/nodes/${node.public_key}/adverts?hours=168`) - ]); - - const linksData = await linksRes.json(); - const historyData = await historyRes.json(); - const advertsData = await advertsRes.json(); - - setLinks(Array.isArray(linksData) ? linksData : []); - setHistory(Array.isArray(historyData) ? historyData : []); - setAdverts(Array.isArray(advertsData) ? advertsData : []); + const details = await loadNodeDetails(node); + setLinks(details.links); + setHistory(details.history); + setAdverts(details.adverts); } catch { // Ignore errors } finally { diff --git a/frontend/src/pages/ukmesh/feed-page.css b/frontend/src/pages/ukmesh/feed-page.css index b62cdd1..5befc1d 100644 --- a/frontend/src/pages/ukmesh/feed-page.css +++ b/frontend/src/pages/ukmesh/feed-page.css @@ -110,6 +110,15 @@ min-width: 0; } +.uk-feed-detail-modal { + width: min(1100px, calc(100vw - 24px)); + max-height: calc(100vh - 24px); + overflow: auto; + border: 1px solid var(--border-bright); + border-radius: var(--radius-lg); + background: var(--bg-base); +} + /* Mobile-only elements — hidden on desktop */ .uk-feed-mobile-stats, .uk-feed-mobile-detail { diff --git a/frontend/src/store/overlayStore.ts b/frontend/src/store/overlayStore.ts index 58bcfa7..4baef6e 100644 --- a/frontend/src/store/overlayStore.ts +++ b/frontend/src/store/overlayStore.ts @@ -15,7 +15,13 @@ type OverlayStoreState = { betaRemainingHops: number | null; replayNodeIds: Set | null; replayTime: string | null; - pathExplanation: { evidenceLevel: string; summary: string; reasons: string[]; limitations?: string[] } | null; + pathExplanation: { + evidenceLevel: string; + summary: string; + reasons: string[]; + alternativesConsidered?: number; + limitations?: string[]; + } | null; // Multi-node LOS support losNodeIds: Set; losLoadingIds: Set; diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 878c899..2bd3f5e 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -311,6 +311,46 @@ button .loading-indicator--inline, background: var(--accent-dim); } +.topbar__network, +.topbar__tool-btn { + height: 28px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-panel-alt); + color: var(--text-secondary); + font: 10px var(--font-mono); +} +.topbar__network { max-width: 110px; padding: 0 7px; } +.topbar__tool-btn { padding: 0 8px; cursor: pointer; } +.topbar__tool-btn:hover, +.topbar__tool-btn--active { border-color: var(--accent); color: var(--accent); } +.topbar__shortcut-btn { width: 28px; padding: 0; font-size: 14px; } + +.shortcut-guide { + position: fixed; + inset: 0; + z-index: 2400; + display: grid; + place-items: center; + padding: 18px; + background: rgba(0, 0, 0, 0.72); +} +.shortcut-guide__panel { + width: min(430px, 100%); + padding: 20px; + border: 1px solid var(--border-bright); + border-radius: 12px; + background: var(--bg-panel); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55); +} +.shortcut-guide__panel header { display: flex; align-items: center; justify-content: space-between; } +.shortcut-guide__panel h2 { color: var(--accent); font: 16px var(--font-mono); } +.shortcut-guide__panel header button { border: 0; background: transparent; color: var(--text-muted); font-size: 24px; cursor: pointer; } +.shortcut-guide__panel dl { display: grid; gap: 7px; margin-top: 16px; } +.shortcut-guide__panel dl div { display: grid; grid-template-columns: 58px 1fr; align-items: center; gap: 10px; } +.shortcut-guide__panel dt { padding: 4px 7px; border: 1px solid var(--border); border-radius: 4px; color: var(--text-primary); font: 11px var(--font-mono); text-align: center; } +.shortcut-guide__panel dd { color: var(--text-secondary); font-size: 12px; } + /* Disclaimer modal */ .disclaimer-overlay { position: fixed; @@ -525,6 +565,9 @@ button .loading-indicator--inline, /* ── Analytics app topbar ────────────────────────────────────────────── */ .topbar { gap: 10px; padding: 0 12px; } .topbar__stats { display: none; } + .topbar__divider, + .topbar__tool-btn:not(.topbar__shortcut-btn) { display: none; } + .topbar__network { margin-left: auto; max-width: 92px; } /* ── App shell: add rows for mobile controls + map ──────────────────── */ .app-shell { grid-template-rows: 48px auto 1fr; } diff --git a/frontend/src/styles/map-app.css b/frontend/src/styles/map-app.css index 9e38f15..a1f0e02 100644 --- a/frontend/src/styles/map-app.css +++ b/frontend/src/styles/map-app.css @@ -47,6 +47,21 @@ width: 260px; } +.node-search__controls { display: flex; } +.node-search__controls .node-search__input { border-radius: 6px 0 0 6px; } +.node-search__nearby { + flex: 0 0 34px; + border: 1px solid var(--border); + border-left: 0; + border-radius: 0 6px 6px 0; + background: var(--bg-panel-alt); + color: var(--accent); + font-size: 17px; + cursor: pointer; +} +.node-search__nearby:hover { background: var(--bg-hover); } +.node-search__status { padding: 6px 10px; border-bottom: 1px solid var(--border); color: var(--text-muted); font: 9px var(--font-mono); } + .node-search__input { width: 100%; box-sizing: border-box; @@ -110,6 +125,33 @@ .node-search__save { width: 100%; padding: 7px 10px; border: 0; border-top: 1px solid var(--border); background: var(--bg-panel-alt); color: var(--accent); font: 10px var(--font-mono); text-align: left; cursor: pointer; } .node-search__save:hover { background: var(--bg-hover); } +.map-annotation { + position: absolute; + top: 62px; + left: 50%; + z-index: 790; + max-width: min(520px, calc(100vw - 500px)); + overflow: hidden; + padding: 7px 12px; + transform: translateX(-50%); + border: 1px solid rgba(251, 191, 36, 0.6); + border-radius: 999px; + background: rgba(13, 21, 32, 0.94); + color: #fbbf24; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.path-explanation-popover { min-width: 220px; color: #172033; font-family: var(--font-body); } +.path-explanation-popover > strong { display: block; margin-bottom: 7px; font-size: 13px; } +.path-explanation-popover dl { display: grid; gap: 3px; margin: 0; } +.path-explanation-popover dl div { display: flex; justify-content: space-between; gap: 12px; } +.path-explanation-popover dt { color: #64748b; } +.path-explanation-popover dd { margin: 0; font-weight: 600; } +.path-explanation-popover p { margin: 7px 0 0; color: #475569; font-size: 11px; } + /* ─── Filter Panel ───────────────────────────────────────────────────────── */ .filter-panel { position: absolute; @@ -1249,6 +1291,29 @@ /* App-specific responsive rules live after the app base rules so lazy-loaded CSS cannot reverse the mobile cascade. */ @media (max-width: 640px) { + .mobile-fullscreen-toggle { + position: fixed; + z-index: 1200; + top: 56px; + right: 8px; + border: 1px solid var(--accent); + border-radius: var(--radius-md); + padding: 7px 10px; + background: rgba(8, 13, 20, 0.92); + color: var(--text-primary); + font: 11px var(--font-mono); + } + .app-shell[data-mobile-fullscreen='true'] .topbar, + .app-shell[data-mobile-fullscreen='true'] .node-dock, + .app-shell[data-mobile-fullscreen='true'] .node-legend, + .app-shell[data-mobile-fullscreen='true'] .timeline-control, + .app-shell[data-mobile-fullscreen='true'] .planner-comparison { + display: none; + } + .app-shell[data-mobile-fullscreen='true'] { + grid-template-rows: 1fr; + } + .map-annotation { top: 98px; max-width: calc(100vw - 30px); } .filter-panel { display: none; } .filter-launcher { display: none; } .node-drawer, @@ -1413,4 +1478,26 @@ display: block; width: 100%; height: 28px; -} \ No newline at end of file +} +.node-popup__tabs { + display: flex; + gap: 3px; + overflow-x: auto; + margin: 0 0 8px; +} +.node-popup__tabs button { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 4px 6px; + background: transparent; + color: var(--text-secondary); + font: 10px var(--font-mono); + cursor: pointer; +} +.node-popup__tabs button[aria-selected='true'] { + border-color: var(--accent); + background: var(--accent-dim); + color: var(--text-primary); +} +.node-popup__link-spark { width: 100%; margin-top: 4px; } +.node-popup__muted { color: var(--text-muted); font-size: 11px; } diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 0000000..d4ce006 --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -0,0 +1,71 @@ +:root { + --color-bg: #0a0f1a; + --color-surface: #111827; + --color-primary: #00c4ff; + --color-primary-dim: #0099cc; + --color-gold: #fbbf24; + --color-red: #ef4444; + --color-green: #22c55e; + --color-amber: #ff9f43; + --color-purple: #a78bfa; + --color-text: #e2e8f0; + --color-text-dim: #6b7280; + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --space-xs: 4px; + --space-sm: 8px; + --space-md: 12px; + --space-lg: 16px; + --space-xl: 24px; + --font-mono: 'Iosevka', 'JetBrains Mono', monospace; + --font-sans: 'Inter', -apple-system, 'Segoe UI', sans-serif; +} + +:root[data-contrast='high'] { + --color-bg: #000; + --color-surface: #101010; + --color-primary: #5cdbff; + --color-primary-dim: #24bce8; + --color-gold: #ffd75e; + --color-red: #ff6b6b; + --color-green: #55ef88; + --color-amber: #ffc266; + --color-purple: #c7b5ff; + --color-text: #fff; + --color-text-dim: #c7d2df; + --bg-base: var(--color-bg); + --bg-panel: var(--color-surface); + --accent: var(--color-primary); + --text-primary: var(--color-text); + --text-secondary: #d6e2ef; + --text-muted: var(--color-text-dim); + --border: rgba(255, 255, 255, 0.55); + --border-bright: rgba(92, 219, 255, 0.85); + --online: var(--color-green); + --danger: var(--color-red); +} + +@media (prefers-contrast: more) { + :root:not([data-contrast='standard']) { + --color-bg: #000; + --color-surface: #101010; + --color-primary: #5cdbff; + --color-text: #fff; + --color-text-dim: #c7d2df; + --bg-base: var(--color-bg); + --bg-panel: var(--color-surface); + --accent: var(--color-primary); + --text-primary: var(--color-text); + --text-secondary: #d6e2ef; + --text-muted: var(--color-text-dim); + --border: rgba(255, 255, 255, 0.55); + --online: #55ef88; + --danger: #ff6b6b; + } +} + +.maplibregl-map { + --map-label-color: var(--color-text); + --map-link-color: var(--color-primary); +} diff --git a/frontend/src/utils/mapUrlState.ts b/frontend/src/utils/mapUrlState.ts index aa5a3c6..1072922 100644 --- a/frontend/src/utils/mapUrlState.ts +++ b/frontend/src/utils/mapUrlState.ts @@ -6,6 +6,7 @@ const BOOLEAN_FILTER_KEYS = [ 'terrain', 'clientNodes', 'packetHistory', + 'heatmap', 'betaPaths', 'hexClashes', ] as const satisfies ReadonlyArray; @@ -16,6 +17,7 @@ const FILTER_QUERY_NAMES: Record<(typeof BOOLEAN_FILTER_KEYS)[number], string> = terrain: 'terrain', clientNodes: 'clients', packetHistory: 'paths', + heatmap: 'heatmap', betaPaths: 'live-path', hexClashes: 'clashes', };