mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-22 22:11:31 +00:00
Expand owner, packet, and map experiences
This commit is contained in:
1 parent
14a345296a
commit
1c8aadb339
54 files changed
+2074
-483
No files matched your search
Generated
+80
@@ -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",
|
||||
|
||||
@@ -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
@@ -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(' ');
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && <> · {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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
);
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user