Expand owner, packet, and map experiences

This commit is contained in:
gadgethd committed 2026-07-28 23:17:28 +01:00
1 parent 14a345296a
commit 1c8aadb339
54 files changed
+2074 -483

No files matched your search

+80
View File
@@ -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",
+4
View File
@@ -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",
+147 -21
View File
@@ -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<Filters>(() => {
let stored = DEFAULT_FILTERS;
try {
const raw = localStorage.getItem(FILTERS_KEY);
if (raw) stored = { ...DEFAULT_FILTERS, ...JSON.parse(raw) as Partial<Filters>, 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<Filters> } & Partial<Filters>;
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<string | null>(() => new URLSearchParams(window.location.search).get('node'));
const [filtersCollapsed, setFiltersCollapsed] = useState<boolean>(() => !!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<maplibregl.Map | null>(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<boolean>(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 (
<div className="app-shell" data-node-open={selectedNodeId ? 'true' : undefined} data-live-feed={filters.livePackets ? 'true' : undefined}>
<div className="app-shell" data-node-open={selectedNodeId ? 'true' : undefined} data-live-feed={filters.livePackets ? 'true' : undefined} data-mobile-fullscreen={fullScreenMap ? 'true' : undefined}>
<AppTopBar
homeUrl={site.appHomeUrl}
wsState={wsState}
onShowDisclaimer={() => 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)}
/>
<MobileControls
@@ -403,6 +504,8 @@ export const App: React.FC = () => {
onShare={handleShare}
shareLabel={shareLabel}
onNodeSelect={setSelectedNodeId}
fullScreenMap={fullScreenMap}
onToggleFullScreenMap={() => setFullScreenMap((value) => !value)}
/>
<div className="map-layer">
@@ -419,6 +522,7 @@ export const App: React.FC = () => {
selectedNodeId={selectedNodeId}
onNodeSelect={setSelectedNodeId}
onMapReady={setMlMap}
mapLight={mapLight}
/>
<LiveOverlayController
map={mlMap}
@@ -427,9 +531,9 @@ export const App: React.FC = () => {
observer={observerFilter}
packetHistorySegments={packetHistorySegments}
/>
{(!initialStateLoaded || !initialPollLoaded) && (
{(!initialStateLoaded && !initialPollLoaded) && (
<LoadingIndicator
label={!initialStateLoaded ? 'Loading network nodes...' : 'Loading dashboard data...'}
label="Loading network nodes..."
variant="overlay"
/>
)}
@@ -440,7 +544,7 @@ export const App: React.FC = () => {
)}
</div>
<FilterPanel
{!fullScreenMap && <FilterPanel
filters={filters}
onChange={handleFiltersChange}
activeMode={activeMode}
@@ -451,14 +555,36 @@ export const App: React.FC = () => {
collapsed={filtersCollapsed}
onToggleCollapse={() => setFiltersCollapsed((value) => !value)}
nodeOpen={selectedNodeId != null}
/>
/>}
<React.Suspense fallback={null}>
<TimelineControl network={networkFilter} observer={observerFilter} />
{VIEWSHED_ENABLED && <PlannerComparison enabled />}
</React.Suspense>
{filters.livePackets && <PacketFeed />}
{filters.livePackets && !fullScreenMap && <PacketFeed />}
{annotation && (
<button type="button" className="map-annotation" onClick={handleEditAnnotation} title="Edit shared annotation">
{annotation}
</button>
)}
{showShortcutGuide && (
<div className="shortcut-guide" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" onClick={() => setShowShortcutGuide(false)}>
<div className="shortcut-guide__panel" onClick={(event) => event.stopPropagation()}>
<header><h2>Keyboard shortcuts</h2><button type="button" onClick={() => setShowShortcutGuide(false)} aria-label="Close">×</button></header>
<dl>
<div><dt>Esc</dt><dd>Close panels or cancel a map tool</dd></div>
<div><dt>F</dt><dd>Focus node search</dd></div>
<div><dt>S</dt><dd>Copy the current map link</dd></div>
<div><dt>T</dt><dd>Toggle 3D terrain</dd></div>
<div><dt>L</dt><dd>Toggle Live Path</dd></div>
<div><dt>?</dt><dd>Show or hide this guide</dd></div>
</dl>
</div>
</div>
)}
{showDisclaimer && <DisclaimerModal viewshedEnabled={VIEWSHED_ENABLED} onClose={dismissDisclaimer} />}
</div>
@@ -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' },
@@ -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<HTMLDivElement>(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 (
<div ref={rootRef} style={{ minHeight }}>
{visible ? children : <div className="stats-page__chart-skeleton skeleton-shimmer" aria-hidden="true" />}
</div>
);
};
export default memo(LazyChartMount);
@@ -6,13 +6,16 @@ type LoadingIndicatorProps = {
label?: string;
variant?: LoadingIndicatorVariant;
className?: string;
visible?: boolean;
};
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
label = 'Loading...',
variant = 'block',
className = '',
visible = true,
}) => {
if (!visible) return null;
const cls = ['loading-indicator', `loading-indicator--${variant}`, className]
.filter(Boolean)
.join(' ');
+162 -3
View File
@@ -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<string, HiddenMaskGeometry>;
positionElevations: Map<string, number>;
@@ -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<HeatPoint>({
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<ConsistencySegment>({
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<ConsistencySegment>({
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<ConsistencySegment>({
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<Props> = ({
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<Props> = ({
customLosStart,
}) => {
const overlayRef = useRef<MapboxOverlay | null>(null);
const explanationPopupRef = useRef<maplibregl.Popup | null>(null);
const consistencySegments = useMemo<ConsistencySegment[]>(() => {
const counts = new Map<string, ConsistencySegment>();
const coordKey = (point: [number, number]) => `${point[0].toFixed(5)},${point[1].toFixed(5)}`;
for (const path of betaPaths) {
const seenForObserver = new Set<string>();
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<Props> = ({
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<Props> = ({
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
@@ -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<Point[]>([]);
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 <span className="node-popup__muted">No 72h radio samples</span>;
return (
<div className="node-popup__link-spark" aria-label="72 hour link quality">
<ResponsiveContainer width="100%" height={70}>
<LineChart data={points}>
<YAxis hide domain={['dataMin - 2', 'dataMax + 2']} />
<Tooltip labelFormatter={(value) => new Date(String(value)).toLocaleString()} />
<Line type="monotone" dataKey="snr" stroke="var(--color-green)" strokeWidth={2} dot={false} connectNulls />
<Line type="monotone" dataKey="path_loss" stroke="var(--color-primary)" strokeWidth={1.5} dot={false} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
);
});
@@ -35,12 +35,31 @@ export const LiveOverlayController: React.FC<LiveOverlayControllerProps> = ({
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<string, number>();
for (const node of nodes.values()) {
@@ -51,9 +70,11 @@ export const LiveOverlayController: React.FC<LiveOverlayControllerProps> = ({
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<LiveOverlayControllerProps> = ({
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<Set<string> | null>(null);
const pathNodeIds = useMemo<Set<string> | null>(() => {
@@ -159,11 +181,16 @@ export const LiveOverlayController: React.FC<LiveOverlayControllerProps> = ({
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}
+204 -91
View File
@@ -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<string, { rows?: NodeLink[]; fetchedAt?: number; pending?: Promise<NodeLink[]> }>();
function fetchNodeLinks(nodeId: string): Promise<NodeLink[]> {
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<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
@@ -98,6 +131,14 @@ export function MapLibreMap({
const setClashPathLines = useOverlayStore((state) => state.setClashPathLines);
const hiddenCoordMaskRef = useRef<Map<string, HiddenMaskGeometry>>(new Map());
const refreshTimerRef = useRef<number | null>(null);
const lastArcActivityRef = useRef(0);
const dirtyFlagsRef = useRef<MapSourceDirtyFlags>({ ...ALL_MAP_SOURCE_DIRTY_FLAGS });
const clashRef = useRef<ClashComputation>({
clashOffenderNodeIds: new Set(),
clashRelayIds: new Set(),
clashPathLines: [],
clashModeActive: false,
});
const selectedNodeIdRef = useRef<string | null>(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<MapSourceDirtyFlags> = 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<Array<{
node_id: string; name: string | null; lat: number; lon: number;
score: number; quality: 'good' | 'watch' | 'poor';
}>> : [])
.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<NodeLink[]>)
.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
</button>
)}
<button
type="button"
className={`map-tools__btn${mapLight ? ' map-tools__btn--active' : ''}`}
onClick={(e) => { e.stopPropagation(); toggleMapTheme(); }}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
>
{mapLight
? <><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>Light</>
: <><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>Dark</>
}
</button>
</div>
{/* Custom LOS status hint */}
@@ -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<number, string> = {
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 (
<div className="node-popup">
{!hideName && <div className="node-popup__name">{displayName}</div>}
<div className="node-popup__tabs" role="tablist" aria-label="Node details">
{(['info', 'links', 'activity', 'path', 'status'] as const).map((value) => (
<button key={value} type="button" role="tab" aria-selected={tab === value} onClick={() => setTab(value)}>
{value[0].toUpperCase() + value.slice(1)}
</button>
))}
</div>
{tab === 'info' && <>
{props.public_key && (
<div className="node-popup__row">
<span>Public key</span>
@@ -110,6 +120,17 @@ export const NodePopupContent: React.FC<{
<span>{Math.round(props.elevation_m)} m ASL</span>
</div>
)}
</>}
{tab === 'status' && <>
<div className="node-popup__row"><span>Status</span><span style={{ color: statusColor }}>{statusLabel}</span></div>
{props.hardware_model && <div className="node-popup__row"><span>Hardware</span><span>{props.hardware_model}</span></div>}
<div className="node-popup__row"><span>Freshness</span><span>{timeAgo(props.last_seen)}</span></div>
</>}
{tab === 'activity' && <>
<div className="node-popup__row"><span>Last seen</span><span>{timeAgo(props.last_seen)}</span></div>
<div className="node-popup__row"><span>Adverts</span><span>{props.advert_count ?? 'No samples'}</span></div>
</>}
{tab === 'path' && <>
{viewshedEnabled && isRepeater && !props.is_prohibited && (
<div className="node-popup__row" style={{ marginTop: 6 }}>
<button
@@ -151,6 +172,8 @@ export const NodePopupContent: React.FC<{
</button>
</div>
)}
</>}
{tab === 'links' && <>
{links === null && (
<div className="node-popup__neighbours-loading">
<LoadingIndicator label="Loading neighbours..." variant="inline" />
@@ -172,11 +195,14 @@ export const NodePopupContent: React.FC<{
{lk.observed_count}× seen
{lk.itm_path_loss_db != null && <> &middot; {Math.round(lk.itm_path_loss_db)} dB</>}
</span>
<LinkQualitySparkline source={props.node_id} target={lk.peer_id} />
</div>
);
})}
</div>
)}
{links !== null && links.length === 0 && <div className="node-popup__muted">No confirmed neighbours.</div>}
</>}
</div>
);
};
+91 -25
View File
@@ -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<NodeSearchProps> = ({ map, onNodeSelect }) =>
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const watchlist = useWatchlist();
const [nearbyNodes, setNearbyNodes] = useState<MeshNode[] | null>(null);
const [geoStatus, setGeoStatus] = useState<string | null>(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<NodeSearchProps> = ({ map, onNodeSelect }) =>
return () => document.removeEventListener('mousedown', handler);
}, []);
useEffect(() => {
const focusSearch = () => {
const input = containerRef.current?.querySelector<HTMLInputElement>('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 (
<div ref={containerRef} className="node-search">
<input
className="node-search__input"
type="text"
placeholder="Search nodes…"
value={query}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => { if (query) setOpen(true); }}
onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false); }}
/>
{open && results.length > 0 && (
<div className="node-search__controls">
<input
className="node-search__input"
type="text"
placeholder="Name, IATA or prefix…"
value={query}
onChange={(e) => { setQuery(e.target.value); setNearbyNodes(null); setGeoStatus(null); setOpen(true); }}
onFocus={() => { if (query || nearbyNodes) setOpen(true); }}
onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false); }}
/>
<button type="button" className="node-search__nearby" onClick={findNearby} title="Find nearby repeaters" aria-label="Find nearby repeaters">⌖</button>
</div>
{open && (results.length > 0 || geoStatus) && (
<div className="node-search__results">
{geoStatus && <div className="node-search__status">{geoStatus}</div>}
{results.map((node) => (
<button type="button" key={node.node_id} className="node-search__result" onClick={() => select(node)}>
<span className="node-search__result-name">{node.name}</span>
{node.public_key && (
<span className="node-search__result-key">{node.public_key.slice(0, 8)}…</span>
)}
<span className="node-search__result-key">{node.iata ?? node.public_key?.slice(0, 8) ?? node.node_id.slice(0, 8)}</span>
</button>
))}
<button type="button" className="node-search__save" onClick={() => { watchlist.toggle('search', query.trim().toLowerCase(), query.trim()); setOpen(false); }}>
{query.trim() && <button type="button" className="node-search__save" onClick={() => { watchlist.toggle('search', query.trim().toLowerCase(), query.trim()); setOpen(false); }}>
{watchlist.isWatched('search', query.trim().toLowerCase()) ? '★ Saved search' : '☆ Save this search'}
</button>
</button>}
</div>
)}
</div>
@@ -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>,
): 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,
+2 -1
View File
@@ -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;
+1
View File
@@ -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 {
@@ -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<string | null>(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 (
<form className="observer-registration" onSubmit={submit}>
<label>Observer public key<input value={publicKey} onChange={(event) => setPublicKey(event.target.value)} maxLength={64} required /></label>
<label>IATA region<input value={iata} onChange={(event) => setIata(event.target.value.toUpperCase())} maxLength={8} required /></label>
<label>Display name<input value={name} onChange={(event) => setName(event.target.value)} maxLength={100} /></label>
<label>Discord/email contact<input value={contact} onChange={(event) => setContact(event.target.value)} maxLength={200} required /></label>
<button type="submit" className="site-btn site-btn--primary" disabled={submitting}>
{submitting ? 'Submitting…' : 'Request MQTT observer user'}
</button>
{message && <p className="prose-note" role="status">{message}</p>}
</form>
);
}
@@ -0,0 +1,2 @@
export { PacketDetailPanel } from '../pages/ukmesh/PacketDetailPanel.js';
export type { LazyPath, LazyPathNode, LazyPathResult, ResolvedPath } from '../pages/ukmesh/PacketDetailPanel.js';
+71 -50
View File
@@ -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<number, string> = {
0: 'REQ',
@@ -17,6 +18,66 @@ const TYPE_LABELS: Record<number, string> = {
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<PacketFeedItemProps> = 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 (
<div
className={`packet-item packet-item--clickable${isPinned ? ' packet-item--pinned' : ''}${isNew ? ' packet-item--new' : ''}`}
onClick={() => onTogglePacket(p)}
role="button"
tabIndex={0}
onKeyDown={(event) => event.key === 'Enter' && onTogglePacket(p)}
>
{observerIata && <span className="packet-item__iata">{observerIata}</span>}
{p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 && <span className="packet-item__path-bytes">{p.pathHashSizeBytes}</span>}
<span className="packet-item__type">{typeLabel}</span>
{advertBadge && <span className="packet-item__advert-badge">{advertBadge}</span>}
<span className={`packet-item__summary${display ? '' : ' packet-item__summary--empty'}`}>{display ?? '\u00A0'}</span>
{p.hopCount !== undefined && p.hopCount > 0 && <span className="packet-item__hops">↑{p.hopCount}</span>}
<span className="packet-item__counts">
{p.observerIds.length > 0 && <span className="count count--rx">{p.observerIds.length}rx</span>}
{p.txCount > 0 && <span className="count count--tx">{p.txCount}tx</span>}
</span>
<button
type="button"
className="packet-item__watch"
aria-label={`${isWatched ? 'Stop watching' : 'Watch'} ${typeLabel} packets`}
onClick={(event) => {
event.stopPropagation();
onToggleWatch('packet_type', packetTypeId, `${typeLabel} packets`);
}}
>{isWatched ? '★' : '☆'}</button>
{isPinned && <span className="packet-item__pin">●</span>}
</div>
);
});
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 (
<div className="packet-feed" ref={feedRef}>
{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 (
<div
key={p.id}
className={`packet-item packet-item--clickable${isPinned ? ' packet-item--pinned' : ''}${newestVisibleId === p.id ? ' packet-item--new' : ''}`}
onClick={() => togglePinnedPacket(p)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && togglePinnedPacket(p)}
>
{observerIata && (
<span className="packet-item__iata">{observerIata}</span>
)}
{p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 && (
<span className="packet-item__path-bytes">{p.pathHashSizeBytes}</span>
)}
<span className="packet-item__type">{typeLabel}</span>
{advertBadge && (
<span className="packet-item__advert-badge">{advertBadge}</span>
)}
<span className={`packet-item__summary${display ? '' : ' packet-item__summary--empty'}`}>
{display ?? '\u00A0'}
</span>
{p.hopCount !== undefined && p.hopCount > 0 && (
<span className="packet-item__hops">↑{p.hopCount}</span>
)}
<span className="packet-item__counts">
{p.observerIds.length > 0 && (
<span className="count count--rx">{p.observerIds.length}rx</span>
)}
{p.txCount > 0 && <span className="count count--tx">{p.txCount}tx</span>}
</span>
<button
type="button"
className="packet-item__watch"
aria-label={`${watchlist.isWatched('packet_type', String(p.packetType ?? 'unknown')) ? 'Stop watching' : 'Watch'} ${typeLabel} packets`}
onClick={(event) => { event.stopPropagation(); watchlist.toggle('packet_type', String(p.packetType ?? 'unknown'), `${typeLabel} packets`); }}
>{watchlist.isWatched('packet_type', String(p.packetType ?? 'unknown')) ? '★' : '☆'}</button>
{isPinned && <span className="packet-item__pin">●</span>}
</div>
<PacketFeedItem
key={p.packetHash || p.id}
packet={p}
observerIata={observerIata}
isPinned={pinnedPacketId === p.id}
isNew={newestVisibleId === p.id}
isWatched={watchlist.isWatched('packet_type', String(p.packetType ?? 'unknown'))}
onTogglePacket={togglePinnedPacket}
onToggleWatch={watchlist.toggle}
/>
);
})}
</div>
+40
View File
@@ -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<AppTopBarProps> = ({
wsState,
onShowDisclaimer,
stats,
mapLight,
onToggleMapTheme,
network,
onNetworkChange,
annotation,
onEditAnnotation,
onShowShortcuts,
highContrast,
onToggleContrast,
}) => (
<header className="topbar">
<a href={homeUrl} className="topbar__brand" title="Home">
@@ -49,6 +67,28 @@ export const AppTopBar: React.FC<AppTopBarProps> = ({
</div>
<div className="topbar__divider" />
<ConnIndicator state={wsState} />
<select
className="topbar__network"
value={network}
onChange={(event) => onNetworkChange(event.target.value)}
aria-label="Network region"
title="Switch network"
>
<option value="ukmesh">UK Mesh</option>
<option value="teesside">Teesside</option>
</select>
<button type="button" className="topbar__tool-btn" onClick={onToggleMapTheme} title="Toggle map theme">
{mapLight ? '☀ Light' : '☾ Dark'}
</button>
<button type="button" className={`topbar__tool-btn${highContrast ? ' topbar__tool-btn--active' : ''}`} onClick={onToggleContrast} aria-pressed={highContrast} title="Toggle high contrast">
◐ Contrast
</button>
<button type="button" className={`topbar__tool-btn${annotation ? ' topbar__tool-btn--active' : ''}`} onClick={onEditAnnotation} title="Add a shareable note">
Note
</button>
<button type="button" className="topbar__tool-btn topbar__shortcut-btn" onClick={onShowShortcuts} title="Keyboard shortcuts">
?
</button>
<button
className="topbar__info-btn"
onClick={onShowDisclaimer}
+20 -8
View File
@@ -15,6 +15,8 @@ type MobileControlsProps = {
onShare: () => void;
shareLabel: string;
onNodeSelect: (nodeId: string) => void;
fullScreenMap: boolean;
onToggleFullScreenMap: () => void;
};
export const MobileControls: React.FC<MobileControlsProps> = ({
@@ -27,19 +29,29 @@ export const MobileControls: React.FC<MobileControlsProps> = ({
onShare,
shareLabel,
onNodeSelect,
fullScreenMap,
onToggleFullScreenMap,
}) => {
const [showFilters, setShowFilters] = useState(false);
return (
<div className="mobile-controls">
<MapModeSelector
<button
type="button"
className="mobile-fullscreen-toggle"
onClick={onToggleFullScreenMap}
aria-pressed={fullScreenMap}
>
{fullScreenMap ? 'Restore panels' : 'Full-screen map'}
</button>
{!fullScreenMap && <MapModeSelector
activeMode={activeMode}
viewshedEnabled={viewshedEnabled}
onChange={onModeChange}
onShare={onShare}
shareLabel={shareLabel}
/>
<button
/>}
{!fullScreenMap && <button
type="button"
className="mobile-legend-toggle"
onClick={() => setShowFilters((v) => !v)}
@@ -47,8 +59,8 @@ export const MobileControls: React.FC<MobileControlsProps> = ({
>
<span>Layers</span>
<span>{showFilters ? 'Hide' : 'Show'}</span>
</button>
<div className={`mobile-filter-wrap${showFilters ? '' : ' mobile-filter-wrap--hidden'}`}>
</button>}
{!fullScreenMap && <div className={`mobile-filter-wrap${showFilters ? '' : ' mobile-filter-wrap--hidden'}`}>
<div className="mobile-filter-grid">
{FILTER_ROWS.map(({ key, label, color, hollow }) => (
<button
@@ -89,10 +101,10 @@ export const MobileControls: React.FC<MobileControlsProps> = ({
/>
</div>
)}
</div>
<div className="mobile-search">
</div>}
{!fullScreenMap && <div className="mobile-search">
<NodeSearch map={map} onNodeSelect={onNodeSelect} />
</div>
</div>}
</div>
);
};
+15 -2
View File
@@ -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 (
<details className="watchlist-panel">
<details
className="watchlist-panel"
open={open}
onToggle={(event) => {
const next = event.currentTarget.open;
setOpen(next);
try { localStorage.setItem('meshcore-watchlist-collapsed-v1', next ? '0' : '1'); }
catch { /* persistence is best-effort */ }
}}
>
<summary>Watchlist <span>{entries.length}</span></summary>
{entries.length === 0 ? <p>Star nodes, searches, regions, packet types, or spam incidents to keep them here.</p> : (
<ul>{entries.map((entry) => (
@@ -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 (
<section className="prose-section owner-login">
<h2>Login</h2>
<p className="prose-note">Enter the MQTT credentials associated with your repeater observer.</p>
<form className="owner-login__form" onSubmit={props.onSubmit}>
<label className="owner-login__label" htmlFor="owner-username">MQTT username</label>
<input id="owner-username" className="owner-login__input" autoComplete="username" value={props.username} onChange={(event) => props.onUsername(event.target.value)} maxLength={128} />
<label className="owner-login__label" htmlFor="owner-key">MQTT password</label>
<input id="owner-key" className="owner-login__input" type="password" autoComplete="current-password" value={props.password} onChange={(event) => props.onPassword(event.target.value)} maxLength={256} />
<button className="site-btn site-btn--primary owner-login__button" type="submit" disabled={props.submitting}>
{props.submitting ? <LoadingIndicator label="Logging in..." variant="inline" /> : 'Login'}
</button>
</form>
{props.error && <p className="prose-note owner-login__error">{props.error}</p>}
</section>
);
}
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<string> {
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<Rule[]>([]);
const [ruleType, setRuleType] = useState('offline_minutes');
const [threshold, setThreshold] = useState('30');
const [webhook, setWebhook] = useState('');
const [message, setMessage] = useState<string | null>(null);
const load = () => fetch('/api/owner/alert-rules').then((response) => response.ok ? response.json() as Promise<Rule[]> : []).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 (
<section className="prose-section owner-settings">
<h2>Alert settings</h2>
<p>Create per-node health rules. Webhooks are delivered at most once per hour while a condition remains active.</p>
<form className="owner-settings__form" onSubmit={submit}>
<label>Node<select value={selectedNodeId} disabled>{nodes.map((node) => <option key={node.node_id} value={node.node_id}>{node.name ?? node.node_id}</option>)}</select></label>
<label>Condition<select value={ruleType} onChange={(event) => setRuleType(event.target.value)}><option value="offline_minutes">Offline minutes</option><option value="battery_below_mv">Battery below mV</option><option value="link_loss_above_db">Path loss above dB</option></select></label>
<label>Threshold<input type="number" min="1" value={threshold} onChange={(event) => setThreshold(event.target.value)} /></label>
<label>HTTPS webhook (optional)<input type="url" value={webhook} onChange={(event) => setWebhook(event.target.value)} /></label>
<button type="submit" className="site-btn site-btn--primary">Save rule</button>
</form>
{message && <p role="status">{message}</p>}
<div className="owner-settings__rules">{rules.map((rule) => <article key={rule.id}><span>{rule.rule_type.replace(/_/g, ' ')} · {rule.threshold}</span><button type="button" onClick={() => void ownerCsrf().then((csrfToken) => fetch(`/api/owner/alert-rules/${rule.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrfToken } })).then(load)}>Remove</button></article>)}</div>
</section>
);
}
+4
View File
@@ -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,
};
+4 -5
View File
@@ -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);
+1
View File
@@ -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);
+17 -9
View File
@@ -206,16 +206,17 @@ function isObserverSelfEchoLoop(packet: LivePacketData, nodes: Map<string, MeshN
if (!packet.rxNodeId || !packet.path || packet.path.length < 3) return false;
const observer = nodes.get(packet.rxNodeId);
if (!observer || observer.role !== 2) return false;
return matchesObserverPathHash(packet.rxNodeId, packet.path[0]) && matchesObserverPathHash(packet.rxNodeId, packet.path[packet.path.length - 1]);
return packet.path.every((hash) => 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,
+152
View File
@@ -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<string, unknown> | 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<string, { expiresAt: number; value: StaticData }>();
const staticInflight = new Map<string, Promise<StaticData>>();
const pathCache = new Map<string, { expiresAt: number; value: ResolvedPath[] }>();
async function cachedStatic(packetHash: string, network: string): Promise<StaticData> {
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<PacketDetail> : null)
.catch(() => null),
fetch('/api/radio-stats', { signal: AbortSignal.timeout(8_000) })
.then((response) => response.ok ? response.json() as Promise<RadioState> : 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<PacketDetail | null>(null);
const [radio, setRadio] = useState<RadioState | null>(null);
const [resolvedPaths, setResolvedPaths] = useState<ResolvedPath[]>([]);
const [loading, setLoading] = useState(true);
const [pathLoading, setPathLoading] = useState(false);
const [lazyPath, setLazyPath] = useState<LazyPathResult | null>(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 };
}
+4 -2
View File
@@ -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);
};
+24
View File
@@ -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';
+48 -48
View File
@@ -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<string, { expiresAt: number; points: LastHopStrengthPoint[] }>();
async function fetchOwnerCsrfToken(): Promise<string> {
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<OwnerLiveResponse | null>(null);
const [liveError, setLiveError] = useState<string | null>(null);
const [lastHopStrength, setLastHopStrength] = useState<LastHopStrengthPoint[]>([]);
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 = () => {
<div className="site-content site-prose site-prose--wide">
{loading ? <LoadingIndicator label="Checking login session..." variant="block" /> : null}
{!loading && !dashboard ? (
<section className="prose-section owner-login">
<h2>Login</h2>
<p className="prose-note">
Enter the MQTT credentials associated with your repeater observer.
</p>
<form className="owner-login__form" onSubmit={handleLogin}>
<label className="owner-login__label" htmlFor="owner-username">MQTT username</label>
<input
id="owner-username"
className="owner-login__input"
type="text"
autoComplete="username"
value={mqttUsername}
onChange={(e) => setMqttUsername(e.target.value)}
placeholder="Enter username"
maxLength={128}
/>
<label className="owner-login__label" htmlFor="owner-key">MQTT password</label>
<input
id="owner-key"
className="owner-login__input"
type="password"
autoComplete="current-password"
value={mqttPassword}
onChange={(e) => setMqttPassword(e.target.value)}
placeholder="Enter password"
maxLength={256}
/>
<button className="site-btn site-btn--primary owner-login__button" type="submit" disabled={submitting}>
{submitting ? <LoadingIndicator label="Logging in..." variant="inline" /> : 'Login'}
</button>
</form>
{error ? <p className="prose-note owner-login__error">{error}</p> : null}
</section>
<OwnerLoginSection username={mqttUsername} password={mqttPassword} submitting={submitting} error={error} onUsername={setMqttUsername} onPassword={setMqttPassword} onSubmit={handleLogin} />
) : null}
{!loading && dashboard ? (
<>
<section className="prose-section">
<nav className="owner-section-tabs" aria-label="Owner portal sections">
{(['dashboard', 'live', 'settings'] as const).map((section) => <button key={section} type="button" aria-pressed={activeSection === section} onClick={() => setActiveSection(section)}>{section}</button>)}
</nav>
{activeSection === 'dashboard' && <OwnerSection><section className="prose-section">
<div className="owner-head">
<h2>Dashboard</h2>
<button type="button" className="site-btn site-btn--ghost" onClick={handleLogout}>
@@ -1151,9 +1149,9 @@ export const OwnerPortalPage: React.FC = () => {
<div className="site-stat"><span className="site-stat__value">{live?.packetsReceived24h ?? 0}</span><span className="site-stat__label">Packets Received (24h)</span></div>
</div>
{liveError ? <p className="prose-note owner-login__error">Live data error: {liveError}</p> : null}
</section>
</section></OwnerSection>}
<section className="owner-panel owner-telemetry-panel">
{activeSection === 'live' && <OwnerSection><section className="owner-panel owner-telemetry-panel">
<div className="owner-panel__head">
<div>
<h2>Node Telemetry</h2>
@@ -1303,7 +1301,9 @@ export const OwnerPortalPage: React.FC = () => {
) : null}
</div>
</section>
</div>
</div></OwnerSection>}
{activeSection === 'settings' && <OwnerAlertSettings nodes={dashboard.nodes} selectedNodeId={selectedNodeId} />}
</>
) : null}
+50 -12
View File
@@ -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({
</div>
</div>
);
}
});
// ---------------------------------------------------------------------------
// 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<IncidentDetail | null>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
const watchlist = useWatchlist();
@@ -281,9 +288,9 @@ function IncidentCard({ incident }: { incident: PublicIncident }) {
<button
type="button"
className="sm-origin__head sm-origin__toggle"
onClick={() => o.zone && setShowMap((v) => !v)}
onClick={() => o.zone && onToggleMap()}
disabled={!o.zone}
aria-expanded={showMap}
aria-expanded={mapOpen}
title={o.zone ? 'Show the coarse origin area on a map' : 'No location estimate available'}
>
<span className="sm-k">Estimated origin</span>
@@ -296,11 +303,11 @@ function IncidentCard({ incident }: { incident: PublicIncident }) {
</div>
{o.reasons.length > 0 && <div className="sm-reasons">{o.reasons.join(' · ')}</div>}
{o.zone && (
<button type="button" className="sm-origin__maplink" onClick={() => setShowMap((v) => !v)}>
{showMap ? '▾ Hide map' : '🗺 View area on map'}
<button type="button" className="sm-origin__maplink" onClick={onToggleMap}>
{mapOpen ? '▾ Hide map' : '🗺 View area on map'}
</button>
)}
{showMap && o.zone && <OriginMiniMap zone={o.zone} active={incident.status === 'active'} />}
{mapOpen && o.zone && <OriginMiniMap zone={o.zone} active={incident.status === 'active'} />}
</div>
{incident.similarUsernames.length > 0 && (
@@ -348,6 +355,36 @@ function IncidentCard({ incident }: { incident: PublicIncident }) {
)}
</div>
);
});
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 (
<div className="sm-virtual-list" onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}>
<div aria-hidden="true" style={{ height: start * rowEstimate }} />
{incidents.slice(start, end).map((incident) => (
<IncidentCard
key={incident.id}
incident={incident}
mapOpen={openMapId === incident.id}
onToggleMap={() => setOpenMapId(openMapId === incident.id ? null : incident.id)}
/>
))}
<div aria-hidden="true" style={{ height: Math.max(0, (incidents.length - end) * rowEstimate) }} />
</div>
);
}
// ---------------------------------------------------------------------------
@@ -359,6 +396,7 @@ export function SpamPage() {
const [incidents, setIncidents] = useState<PublicIncident[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [showLow, setShowLow] = useState(false);
const [openMapId, setOpenMapId] = useState<string | null>(null);
const mapEl = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
@@ -500,7 +538,7 @@ export function SpamPage() {
{active.length === 0 ? (
<p className="sm-muted">Nothing active. The mesh looks clean right now.</p>
) : (
active.map((i) => <IncidentCard key={i.id} incident={i} />)
<VirtualIncidentList incidents={active} openMapId={openMapId} setOpenMapId={setOpenMapId} />
)}
</section>
@@ -509,7 +547,7 @@ export function SpamPage() {
{historical.length === 0 ? (
<p className="sm-muted">No past incidents recorded{showLow ? '' : ' above the confidence threshold'}.</p>
) : (
historical.map((i) => <IncidentCard key={i.id} incident={i} />)
<VirtualIncidentList incidents={historical} openMapId={openMapId} setOpenMapId={setOpenMapId} />
)}
</section>
</>
+19 -7
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { lazy, memo, Suspense, useState, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
AreaChart, Area, BarChart, Bar,
@@ -172,7 +172,7 @@ function fmtTrafficPct(value: number | undefined): string {
}
// ── Stat card ─────────────────────────────────────────────────────────────────
const StatCard: React.FC<{ label: string; value: string; sub?: string; color?: string }> = ({
const StatCard: React.FC<{ label: string; value: string; sub?: string; color?: string }> = memo(({
label, value, sub, color = C_CYAN,
}) => (
<div className="stats-page__stat">
@@ -180,10 +180,11 @@ const StatCard: React.FC<{ label: string; value: string; sub?: string; color?: s
<span className="stats-page__stat-value" style={{ color }}>{value}</span>
{sub && <span className="stats-page__stat-sub">{sub}</span>}
</div>
);
));
// ── Chart card ────────────────────────────────────────────────────────────────
const ChartCard: React.FC<{ title: string; sub?: string; children: React.ReactNode; tall?: boolean }> = ({
const LazyChartMount = lazy(() => import('../components/LazyChartMount.js'));
const ChartCard: React.FC<{ title: string; sub?: string; children: React.ReactNode; tall?: boolean }> = memo(({
title, sub, children, tall,
}) => (
<div className={`stats-page__chart${tall ? ' stats-page__chart--tall' : ''}`}>
@@ -191,9 +192,11 @@ const ChartCard: React.FC<{ title: string; sub?: string; children: React.ReactNo
<span className="stats-page__chart-title">{title}</span>
{sub && <span className="stats-page__chart-sub">{sub}</span>}
</div>
{children}
<Suspense fallback={<div className="stats-page__chart-skeleton skeleton-shimmer" aria-hidden="true" />}>
<LazyChartMount minHeight={220}>{children}</LazyChartMount>
</Suspense>
</div>
);
));
const EmptyPacketState: React.FC<{ label?: string }> = ({ label = 'No packet data in this window.' }) => (
<div className="stats-page__empty">{label}</div>
@@ -424,7 +427,16 @@ export const StatsPage: React.FC = () => {
<div className="site-content">
{loading && (
<LoadingIndicator label="Loading stats..." variant="block" />
<div className="stats-page__loading-shell" aria-label="Loading stats">
<LoadingIndicator label="Loading stats..." variant="block" />
<div className="stats-page__summary">
{Array.from({ length: 6 }, (_, index) => <div key={index} className="stats-page__stat skeleton-shimmer" />)}
</div>
<div className="stats-page__row">
<div className="stats-page__chart skeleton-shimmer" />
<div className="stats-page__chart skeleton-shimmer" />
</div>
</div>
)}
{data && (
+29 -4
View File
@@ -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<HealthPayload | null>(null);
const [error, setError] = useState(false);
const [firmware, setFirmware] = useState<FirmwarePayload | null>(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<FirmwarePayload> : null)
.then(setFirmware)
.catch(() => {});
return () => controller.abort();
}, []);
return (
<div className="status-page site-content">
@@ -47,9 +58,23 @@ export const StatusPage: React.FC = () => {
<div className="status-page__grid">
<section><h2>Public ingest</h2><dl><div><dt>Active observer nodes</dt><dd>{data.ingest.active_nodes}</dd></div><div><dt>Stale observers</dt><dd>{data.ingest.stale_nodes}</dd></div><div><dt>Latest packet age</dt><dd>{data.ingest.packet_age_minutes == null ? 'Unknown' : `${data.ingest.packet_age_minutes} min`}</dd></div></dl></section>
<section><h2>Synthetic journeys</h2><div className="status-page__checks">{data.operational_checks.length === 0 ? <p>Monitoring is starting.</p> : data.operational_checks.map((check) => <div key={check.check_name}><span className={`status-page__dot status-page__dot--${check.status}`} /><strong>{check.check_name.replace(/_/g, ' ')}</strong><small>{check.latency_ms} ms</small></div>)}</div></section>
<section><h2>Background workers</h2><div className="status-page__checks">{data.workers.map((worker) => <div key={worker.worker_name}><span className={`status-page__dot status-page__dot--${worker.status === 'running' || worker.status === 'completed' || worker.status === 'idle' ? 'ok' : worker.status}`} /><strong>{worker.worker_name}</strong><small>{worker.queue_depth} queued</small></div>)}</div></section>
<section><h2>Data maintenance</h2><dl><div><dt>Database size</dt><dd>{formatBytes(data.database.size_bytes)}</dd></div><div><dt>Dead rows reported</dt><dd>{data.database.dead_rows.toLocaleString()}</dd></div><div><dt>Vacuum attention</dt><dd>{data.database.tables_needing_vacuum} tables</dd></div></dl></section>
<section><h2>Background workers</h2><div className="status-page__checks">{data.workers.map((worker) => <div key={worker.worker_name}><span className={`status-page__dot status-page__dot--${worker.status === 'running' || worker.status === 'completed' || worker.status === 'idle' ? 'ok' : worker.status}`} /><strong>{worker.worker_name}</strong><small>{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'}</small></div>)}</div></section>
<section><h2>Database</h2><dl><div><dt>Disk footprint</dt><dd>{formatBytes(data.database.size_bytes)}</dd></div><div><dt>Connections</dt><dd>{data.database.connection_count}/{data.database.max_connections}</dd></div><div><dt>Cache hit ratio</dt><dd>{data.database.cache_hit_ratio.toFixed(2)}%</dd></div><div><dt>Vacuum attention</dt><dd>{data.database.tables_needing_vacuum} tables</dd></div></dl></section>
</div>
<section className="status-page__firmware">
<h2>Repeater firmware distribution</h2>
{firmware && firmware.versions.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={firmware.versions.slice(0, 16)} margin={{ left: 8, right: 8, bottom: 70 }}>
<CartesianGrid stroke="rgba(255,255,255,.1)" />
<XAxis dataKey="firmware_version" angle={-35} textAnchor="end" interval={0} height={80} />
<YAxis allowDecimals={false} />
<Tooltip formatter={(value: number, _name, item) => [value, item.payload.hardware_model]} />
<Bar dataKey="count" fill="var(--color-primary)" />
</BarChart>
</ResponsiveContainer>
) : <p>Firmware telemetry is not yet available.</p>}
</section>
<p className="status-page__privacy">Status values are deliberately aggregated. Hostnames, addresses, credentials, and private node identities are never included.</p>
</>
)}
+60 -8
View File
@@ -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<string | null>(null);
const [strongOnly, setStrongOnly] = useState(false);
const [rfValidation, setRfValidation] = useState<RfValidationPayload | null>(null);
const [region, setRegion] = useState('all');
const [plotNodes, setPlotNodes] = useState<PlotNode[]>([]);
const simulationRef = useRef<Simulation<PlotNode, undefined> | 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<PlotNode>().radius((node) => Math.min(14, 5 + Math.sqrt(node.degree))))
.force('links', forceLink<PlotNode, { source: string | PlotNode; target: string | PlotNode }>(
(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 = () => {
<input type="checkbox" checked={strongOnly} onChange={(event) => setStrongOnly(event.target.checked)} />
Multibyte evidence only
</label>
<label className="topology-page__toggle">Region
<select value={region} onChange={(event) => setRegion(event.target.value)}>
<option value="all">All regions</option>
{regions.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
</header>
{error && <div className="topology-page__error" role="alert">{error}</div>}
@@ -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); }}
>
<title>{node.name ?? node.nodeId.slice(0, 8)} · {node.degree} links · {compactNumber(node.observations)} observations</title>
@@ -200,6 +247,11 @@ export const TopologyPage: React.FC = () => {
<span>{compactNumber(selected.observations)} observations</span>
{bridgeIds.has(selected.nodeId) && <span className="topology-page__flag">Likely bridge between network segments</span>}
{isolatedIds.has(selected.nodeId) && <span className="topology-page__flag">No recent viable relationships</span>}
{selected.lat != null && selected.lon != null && (
<a className="site-btn site-btn--primary" href={`${site.mapHomeUrl}?map=${selected.lat.toFixed(5)},${selected.lon.toFixed(5)},13&node=${encodeURIComponent(selected.nodeId)}`}>
Zoom to node on map
</a>
)}
<button type="button" onClick={() => setSelectedNodeId(null)}>Show hub ranking</button>
</div>
) : (
@@ -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);
}
+31
View File
@@ -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; }
}
+15
View File
@@ -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<SiteLayoutProps> = ({
showCompanion = false,
showRegions = false,
showTopology = false,
showSpam = false,
}) => {
const COOKIE_CONSENT_KEY = 'meshcore-cookie-consent-v1';
const [menuOpen, setMenuOpen] = useState(false);
const navRef = useRef<HTMLElement>(null);
const [ownerLabel, setOwnerLabel] = useState<string | null>(null);
const [activeSpamIncidents, setActiveSpamIncidents] = useState(0);
const [cookieConsent, setCookieConsent] = useState<boolean>(() => {
try {
return localStorage.getItem(COOKIE_CONSENT_KEY) === '1';
@@ -74,6 +77,7 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
{ 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<SiteLayoutProps> = ({
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<SiteLayoutProps> = ({
className={navClassName}
>
{item.label}
{item.to === '/spam' && activeSpamIncidents > 0 && <span className="site-nav__badge" aria-label={`${activeSpamIncidents} active incidents`}>{activeSpamIncidents}</span>}
</NavLink>
))}
<a href="https://healthcheck.ukmesh.com" className="site-nav__link">Health Check</a>
+44
View File
@@ -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; }
}
+12
View File
@@ -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);
}
+10
View File
@@ -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;
}
+21
View File
@@ -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; }
}
+9 -140
View File
@@ -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<string, unknown> | 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<PacketDetail | null>(null);
const [resolvedPaths, setResolvedPaths] = React.useState<ResolvedPath[]>([]);
const [radio, setRadio] = React.useState<RadioState | null>(null);
const [loading, setLoading] = React.useState(true);
const [pathLoading, setPathLoading] = React.useState(false);
const [lazyPath, setLazyPath] = React.useState<LazyPathResult | null>(null);
const [lazyStatus, setLazyStatus] = React.useState<'idle' | 'settling' | 'loading' | 'done' | 'notfound' | 'error'>('idle');
const [lazyCountdown, setLazyCountdown] = React.useState(0);
const lazyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lazyTickRef = useRef<ReturnType<typeof setInterval> | 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<PacketDetail> : null)
.catch(() => null),
fetch('/api/radio-stats', { cache: 'no-store' })
.then((r) => r.ok ? r.json() as Promise<RadioState> : 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<ResolvedPath[]> => {
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;
@@ -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 = () => {
)}
</div>
</section>
<section className="site-section">
<div className="site-content site-prose">
<h2>Register an observer station</h2>
<p>Submit the station key and region for rate-limited operator review. Broker credentials are issued out of band.</p>
<ObserverRegistrationForm />
</div>
</section>
</>
);
};
+46 -20
View File
@@ -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<string>('');
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<HTMLDivElement>(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)}
/>
</div>
<div className="uk-feed-packets-list">
{recentPackets.length > 0 ? recentPackets.map((packet) => {
<div
className="uk-feed-packets-list"
ref={packetListRef}
onScroll={(event) => setListScrollTop(event.currentTarget.scrollTop)}
>
{recentPackets.length > 0 ? <>
<div aria-hidden="true" style={{ height: virtualRows.top }} />
{virtualRows.rows.map((packet, virtualIndex) => {
const iatas = packetObserverIatas(packet, nodeMap);
const observerDisplay = iatas.length === 0 ? 'unknown' : iatas.join(' · ');
const isSelected = selectedPacketHash === packet.packet_hash;
const cachedLazyPath = isSelected ? (lazyCache.get(packet.packet_hash) ?? null) : null;
return (
<React.Fragment key={`${packet.packet_hash}-${packet.time}`}>
<React.Fragment key={`${packet.packet_hash}-${packet.time}-${virtualRows.start + virtualIndex}`}>
<article
className={`uk-feed-packet-row${isSelected ? ' uk-feed-packet-row--selected' : ''}`}
onClick={() => setSelectedPacketHash(isSelected ? null : packet.packet_hash)}
@@ -895,7 +903,9 @@ export const UKFeedPage: React.FC = () => {
)}
</React.Fragment>
);
}) : (
})}
<div aria-hidden="true" style={{ height: virtualRows.bottom }} />
</> : (
<p className="dev-status-empty">No public packets have arrived yet.</p>
)}
</div>
@@ -958,6 +968,9 @@ export const UKFeedPage: React.FC = () => {
>
Repeater tree
</button>
<button className="uk-feed-stats__tree-toggle" onClick={() => setDetailOpen(true)}>
Packet details
</button>
</div>
</div>
)}
@@ -1004,6 +1017,19 @@ export const UKFeedPage: React.FC = () => {
</div>
</div>
)}
{detailOpen && selectedPacket && (
<div className="disclaimer-overlay" role="dialog" aria-modal="true" aria-label="Packet details">
<div className="uk-feed-detail-modal">
<PacketDetailPanel
packet={selectedPacket}
nodeMap={nodeMap}
network={site.networkFilter ?? site.network}
cachedLazyPath={selectedLazyPath}
onClose={() => setDetailOpen(false)}
/>
</div>
</div>
)}
</>
);
};
+46 -1
View File
@@ -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<Array<{ iata: string; health?: { score?: number; status?: 'healthy' | 'watch' | 'poor' }; lastPacketAt?: string | null }>>([]);
const [recent, setRecent] = useState<Array<{ packet_hash: string; time: string; summary?: string | null; packet_type?: number | null }>>([]);
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.
</p>
<div className="site-home__actions">
<a href={meshcoreDonationUrl} target="_blank" rel="noopener noreferrer" className="site-btn site-btn--donate">Donate to MeshCore</a>
<a href={site.appUrl} className="site-btn site-btn--primary">Open live map</a>
<Link to="/install" className="site-btn site-btn--ghost">Install MeshCore</Link>
<Link to="/stats" className="site-btn site-btn--ghost">Network stats</Link>
@@ -50,8 +71,32 @@ export const UKHomePage: React.FC = () => {
</div>
</section>
<section className={`site-home-health site-home-health--${regionalHealth.status}`}>
<div className="site-content site-home-health__inner">
<div><span className="site-home-health__dot" /><strong>{regionalHealth.label}</strong></div>
<span>{regionalHealth.score}% aggregate health · {regions.length} reporting regions</span>
<Link to="/stats">View regional detail →</Link>
</div>
</section>
<LiveStatsSection />
<section className="site-section site-section--dark">
<div className="site-content">
<div className="site-section__head"><h2>Recent feed</h2><Link to="/feed">Open live feed →</Link></div>
<div className="site-home-feed">
{recent.map((packet) => (
<article key={`${packet.packet_hash}:${packet.time}`}>
<code>{packet.packet_hash.slice(0, 12)}</code>
<span>{packet.summary || `Packet type ${packet.packet_type ?? 'unknown'}`}</span>
<time>{new Date(packet.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</time>
</article>
))}
{recent.length === 0 && <p>Waiting for recent public packets…</p>}
</div>
</div>
</section>
<section className="site-section">
<div className="site-content">
<div className="site-home__support">
+35 -4
View File
@@ -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 (
<>
<div className="site-content site-prose">
@@ -18,6 +29,18 @@ export const UKInstallPage: React.FC = () => (
</p>
<div className="hw-cards">
{HARDWARE.map((entry) => <button
type="button"
key={entry.id}
className={`hw-card${entry.id === hardwareId ? ' hw-card--recommended' : ''}`}
aria-pressed={entry.id === hardwareId}
onClick={() => setHardwareId(entry.id)}
>
{entry.id === 'v4' && <div className="hw-card__badge">Recommended</div>}
<div className="hw-card__name">{entry.name}</div>
<div className="hw-card__detail">{entry.detail}</div>
</button>)}
{/* Legacy card markup intentionally replaced by the interactive selector.
<div className="hw-card hw-card--recommended">
<div className="hw-card__badge">Recommended</div>
<div className="hw-card__name">Heltec WiFi LoRa 32 V4</div>
@@ -35,6 +58,10 @@ export const UKInstallPage: React.FC = () => (
<div className="hw-card__name">Heltec Mesh Node T114</div>
<div className="hw-card__detail">nRF52840 · 868 MHz · Ultra-compact</div>
</div>
*/}
</div>
<div className="prose-note" role="status">
<strong>{hardware.name}:</strong> use a {hardware.usb} data cable. {hardware.boot}
</div>
<p className="prose-note">
@@ -59,7 +86,7 @@ export const UKInstallPage: React.FC = () => (
</a>.
</li>
<li>
Select your <strong>device hardware</strong> from the dropdown, then select{' '}
Select <strong>{hardware.name}</strong> from the hardware dropdown, then select{' '}
<strong>Companion Radio (Bluetooth)</strong> as the firmware type.
</li>
<li>
@@ -179,8 +206,12 @@ Password: <your password>`}</pre>
<a href="https://flasher.ukmesh.com" target="_blank" rel="noopener noreferrer">Flasher ↗</a>{' '}
to flash a firmware build with the MQTT bridge built in directly to your repeater node — no separate Linux device needed.
</div>
<h3>Request access</h3>
<p>The API records a provisioning request; MQTT password creation remains operator-controlled so credentials are never exposed by the public site.</p>
<ObserverRegistrationForm />
</section>
</div>
</>
);
);
};
+1
View File
@@ -22,6 +22,7 @@ export const UKLayout: React.FC = () => {
showCompanion
showRegions={false}
showTopology
showSpam
showAbout={false}
showInstall
showBestPractice
@@ -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<string, { expiresAt: number; value: NodeDetailBundle }>();
async function fetchJsonWithTimeout<T>(url: string, timeoutMs = 8_000): Promise<T> {
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<T>;
}
async function loadNodeDetails(node: MeshNode): Promise<NodeDetailBundle> {
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<NodeLink[]>(`/api/nodes/${node.node_id}/links`),
fetchJsonWithTimeout<PacketHistory[]>(`/api/nodes/${node.node_id}/history?hours=24`),
fetchJsonWithTimeout<AdvertPacket[]>(`/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 {
+9
View File
@@ -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 {
+7 -1
View File
@@ -15,7 +15,13 @@ type OverlayStoreState = {
betaRemainingHops: number | null;
replayNodeIds: Set<string> | 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<string>;
losLoadingIds: Set<string>;
+43
View File
@@ -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; }
+88 -1
View File
@@ -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;
}
}
.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; }
+71
View File
@@ -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);
}
+2
View File
@@ -6,6 +6,7 @@ const BOOLEAN_FILTER_KEYS = [
'terrain',
'clientNodes',
'packetHistory',
'heatmap',
'betaPaths',
'hexClashes',
] as const satisfies ReadonlyArray<keyof Filters>;
@@ -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',
};