feat(topology): track mesh node connections and render as map polylines

Add passive topology tracking from four sources with confidence ranking:
- Zero-hop MQTT observations (continuous, per-gateway, highest frequency)
- Traceroute replies (RouteDiscovery, full path + SNR data)
- NeighborInfo packets (self-reported neighbor SNR)
- relay_node inferred links (1-hop packets with known relay)

Backend: add rx_snr (field 62) and rx_rssi (field 63) to meshstream Data
proto; extract from MeshPacket in decoder.go; regenerate Go bindings.

Frontend:
- topologySlice: LinkObservation model, per-direction confidence merge,
  24h TTL pruning, 2000-edge cap, Redux-pure (timestamp from payload)
- aggregatorSlice: add hopsFromGateway to NodeData
- __root.tsx: dispatch processTopologyPacket after each SSE message
- NetworkMap: render polylines colored by SNR (green/yellow/red/gray),
  dimmed at 0.4 opacity for viaMqtt edges; Links toggle button in legend
- NodeDetail: Connections section showing per-edge SNR, source badge,
  viaMqtt badge, and last-seen time

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Daniel Pupius
2026-03-15 16:43:48 +00:00
parent 8779da32a8
commit e8e1a112ab
37 changed files with 651 additions and 72 deletions
+2
View File
@@ -112,6 +112,8 @@ func DecodeMessage(payload []byte, topicInfo *meshtreampb.TopicInfo) *meshtreamp
data.ViaMqtt = packet.GetViaMqtt()
data.NextHop = packet.GetNextHop()
data.RelayNode = packet.GetRelayNode()
data.RxSnr = packet.GetRxSnr()
data.RxRssi = packet.GetRxRssi()
// Process the payload
if packet.GetDecoded() != nil {
+2 -2
View File
@@ -38,8 +38,8 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: google/protobuf/descriptor.proto
package descriptorpb
+24 -5
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshstream/meshstream.proto
package meshtreampb
@@ -222,7 +222,10 @@ type Data struct {
// Error tracking
DecodeError string `protobuf:"bytes,60,opt,name=decode_error,json=decodeError,proto3" json:"decode_error,omitempty"`
// Reception timestamp (added by decoder)
RxTime uint64 `protobuf:"varint,61,opt,name=rx_time,json=rxTime,proto3" json:"rx_time,omitempty"`
RxTime uint64 `protobuf:"varint,61,opt,name=rx_time,json=rxTime,proto3" json:"rx_time,omitempty"`
// RF reception quality (measured at gateway)
RxSnr float32 `protobuf:"fixed32,62,opt,name=rx_snr,json=rxSnr,proto3" json:"rx_snr,omitempty"` // SNR at receiving gateway (dB)
RxRssi int32 `protobuf:"varint,63,opt,name=rx_rssi,json=rxRssi,proto3" json:"rx_rssi,omitempty"` // RSSI at receiving gateway (dBm)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -663,6 +666,20 @@ func (x *Data) GetRxTime() uint64 {
return 0
}
func (x *Data) GetRxSnr() float32 {
if x != nil {
return x.RxSnr
}
return 0
}
func (x *Data) GetRxRssi() int32 {
if x != nil {
return x.RxRssi
}
return 0
}
type isData_Payload interface {
isData_Payload()
}
@@ -852,7 +869,7 @@ const file_meshstream_meshstream_proto_rawDesc = "" +
"\aversion\x18\x03 \x01(\tR\aversion\x12\x16\n" +
"\x06format\x18\x04 \x01(\tR\x06format\x12\x18\n" +
"\achannel\x18\x05 \x01(\tR\achannel\x12\x17\n" +
"\auser_id\x18\x06 \x01(\tR\x06userId\"\x94\x0e\n" +
"\auser_id\x18\x06 \x01(\tR\x06userId\"\xc4\x0e\n" +
"\x04Data\x12\x1d\n" +
"\n" +
"channel_id\x18\x01 \x01(\tR\tchannelId\x12\x1d\n" +
@@ -916,7 +933,9 @@ const file_meshstream_meshstream_proto_rawDesc = "" +
"\x06source\x186 \x01(\rR\x06source\x12#\n" +
"\rwant_response\x187 \x01(\bR\fwantResponse\x12!\n" +
"\fdecode_error\x18< \x01(\tR\vdecodeError\x12\x17\n" +
"\arx_time\x18= \x01(\x04R\x06rxTimeB\t\n" +
"\arx_time\x18= \x01(\x04R\x06rxTime\x12\x15\n" +
"\x06rx_snr\x18> \x01(\x02R\x05rxSnr\x12\x17\n" +
"\arx_rssi\x18? \x01(\x05R\x06rxRssiB\t\n" +
"\apayloadB(Z&proto/generated/meshstream;meshtreampbb\x06proto3"
var (
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/admin.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/apponly.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/atak.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/cannedmessages.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/channel.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/clientonly.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/config.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/connection_status.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/device_ui.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/deviceonly.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/interdevice.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/localonly.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/mesh.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/module_config.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/mqtt.proto
package meshtastic
+2 -2
View File
@@ -7,8 +7,8 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/nanopb.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/paxcount.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/portnums.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/powermon.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/remote_hardware.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/rtttl.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/storeforward.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/telemetry.proto
package meshtastic
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// protoc-gen-go v1.36.11
// protoc v4.25.1
// source: meshtastic/xmodem.proto
package meshtastic
+5 -1
View File
@@ -91,7 +91,11 @@ message Data {
// Error tracking
string decode_error = 60;
// Reception timestamp (added by decoder)
uint64 rx_time = 61;
// RF reception quality (measured at gateway)
float rx_snr = 62; // SNR at receiving gateway (dB)
int32 rx_rssi = 63; // RSSI at receiving gateway (dBm)
}
+75 -2
View File
@@ -2,6 +2,7 @@ import React, { useRef, useEffect, useState, useCallback } from "react";
import { useAppSelector } from "../../hooks";
import { useNavigate } from "@tanstack/react-router";
import { NodeData, GatewayData } from "../../store/slices/aggregatorSlice";
import { LinkObservation } from "../../store/slices/topologySlice";
import { Position } from "../../lib/types";
import { getActivityLevel, getNodeColors, getStatusText, formatLastSeen } from "../../lib/activity";
import { GOOGLE_MAPS_ID } from "../../lib/config";
@@ -13,13 +14,15 @@ interface NetworkMapProps {
onAutoZoomChange?: (enabled: boolean) => void;
/** Whether the map should take all available space (default: false) */
fullHeight?: boolean;
/** Whether to show topology link polylines (default: true) */
showLinks?: boolean;
}
/**
* NetworkMap displays all nodes with position data on a Google Map
*/
export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, NetworkMapProps>(
({ height, fullHeight = false, onAutoZoomChange }, ref) => {
({ height, fullHeight = false, onAutoZoomChange, showLinks = true }, ref) => {
const navigate = useNavigate();
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<google.maps.Map | null>(null);
@@ -31,10 +34,12 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
const [autoZoomEnabled, setAutoZoomEnabled] = useState(true);
// Using any for the event listener since TypeScript can't find the MapsEventListener interface
const zoomListenerRef = useRef<any>(null);
const polylinesRef = useRef<Record<string, google.maps.Polyline>>({});
const [isGoogleMapsLoaded, setIsGoogleMapsLoaded] = useState(false);
// Get nodes data from the store
const { nodes, gateways } = useAppSelector((state) => state.aggregator);
const topologyLinks = useAppSelector((state) => state.topology.links);
// Expose the resetAutoZoom function via ref
React.useImperativeHandle(ref, () => ({
@@ -268,6 +273,71 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
}
}, [autoZoomEnabled, fitMapToBounds, createMarker, updateMarker]);
// Update topology polylines on the map
const updateLinks = useCallback((
links: Record<string, LinkObservation>,
nodePositions: MapNode[],
visible: boolean
): void => {
if (!mapInstanceRef.current || !window.google?.maps) return;
// Build position lookup
const posMap = new Map<number, google.maps.LatLngLiteral>();
for (const node of nodePositions) {
posMap.set(node.id, {
lat: node.position.latitudeI / 10000000,
lng: node.position.longitudeI / 10000000,
});
}
const activeKeys = new Set<string>();
for (const link of Object.values(links)) {
const posA = posMap.get(link.nodeA);
const posB = posMap.get(link.nodeB);
if (!posA || !posB) continue;
activeKeys.add(link.key);
// Determine color based on best available SNR
const snr = link.snrAtoB ?? link.snrBtoA;
let strokeColor: string;
if (snr === undefined) {
strokeColor = "#6b7280"; // gray — no SNR data
} else if (snr >= 5) {
strokeColor = "#22c55e"; // green — strong
} else if (snr >= 0) {
strokeColor = "#eab308"; // yellow — marginal
} else {
strokeColor = "#ef4444"; // red — weak
}
const strokeOpacity = link.viaMqtt ? 0.4 : 0.7;
if (polylinesRef.current[link.key]) {
const pl = polylinesRef.current[link.key];
pl.setPath([posA, posB]);
pl.setOptions({ strokeColor, strokeOpacity, visible });
} else {
polylinesRef.current[link.key] = new google.maps.Polyline({
path: [posA, posB],
geodesic: true,
strokeColor,
strokeOpacity,
strokeWeight: 2,
map: visible ? mapInstanceRef.current : null,
});
}
}
// Remove polylines for edges no longer in state
for (const key of Object.keys(polylinesRef.current)) {
if (!activeKeys.has(key)) {
polylinesRef.current[key].setMap(null);
delete polylinesRef.current[key];
}
}
}, []);
// Check for Google Maps API and initialize
const tryInitializeMap = useCallback(() => {
if (mapRef.current && window.google && window.google.maps) {
@@ -284,6 +354,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
// Update markers and fit the map
updateNodeMarkers(nodesWithPosition);
updateLinks(topologyLinks, nodesWithPosition, showLinks);
return true;
} catch (error) {
console.error("Error initializing map:", error);
@@ -292,7 +363,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
}
console.warn("Cannot initialize map - prerequisites not met");
return false;
}, [nodesWithPosition, updateNodeMarkers, initializeMap]);
}, [nodesWithPosition, topologyLinks, showLinks, updateNodeMarkers, updateLinks, initializeMap]);
// Check for Google Maps API loading - make sure all required objects are available
useEffect(() => {
@@ -378,6 +449,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
const markers = markersRef;
const animatingNodes = animatingNodesRef;
const infoWindow = infoWindowRef;
const polylines = polylinesRef;
return () => {
if (zoomListener.current && window.google && window.google.maps) {
window.google.maps.event.removeListener(zoomListener.current);
@@ -387,6 +459,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
Object.values(animatingNodes.current).forEach(timeoutId =>
window.clearTimeout(timeoutId)
);
Object.values(polylines.current).forEach(pl => pl.setMap(null));
if (infoWindow.current) {
infoWindow.current.close();
}
+128
View File
@@ -2,6 +2,7 @@ import React, { useEffect } from "react";
import { useNavigate, Link } from "@tanstack/react-router";
import { useAppSelector, useAppDispatch } from "../../hooks";
import { selectNode } from "../../store/slices/aggregatorSlice";
import { LinkSource } from "../../store/slices/topologySlice";
import {
getActivityLevel,
getNodeColors,
@@ -91,6 +92,7 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { nodes, gateways } = useAppSelector((state) => state.aggregator);
const topologyLinks = useAppSelector((state) => state.topology.links);
// Construct the gateway ID format from the node ID
const gatewayId = `!${nodeId.toString(16).toLowerCase()}`;
@@ -601,6 +603,15 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
</Section>
)}
{/* Connections - Full Width */}
<Section
title="Connections"
icon={<Network className="w-4 h-4" />}
className="mt-6"
>
<NodeConnections nodeId={nodeId} links={topologyLinks} nodes={nodes} />
</Section>
{/* Recent Packets - Full Width */}
<Section
title="Recent Packets"
@@ -612,3 +623,120 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
</div>
);
};
// ── Source badge helpers ──────────────────────────────────────────────────────
const SOURCE_LABELS: Record<LinkSource, string> = {
traceroute: "Traceroute",
zero_hop: "Zero-hop",
neighbor_info: "Neighbor",
relay_inferred: "Relayed",
unknown: "Unknown",
};
const SOURCE_COLORS: Record<LinkSource, string> = {
traceroute: "bg-blue-900 text-blue-300",
zero_hop: "bg-green-900 text-green-300",
neighbor_info: "bg-yellow-900 text-yellow-300",
relay_inferred: "bg-neutral-700 text-neutral-300",
unknown: "bg-neutral-800 text-neutral-500",
};
const SOURCE_CONFIDENCE: Record<LinkSource, number> = {
traceroute: 4,
neighbor_info: 3,
zero_hop: 2,
relay_inferred: 1,
unknown: 0,
};
function bestSource(a: LinkSource, b: LinkSource): LinkSource {
return SOURCE_CONFIDENCE[a] >= SOURCE_CONFIDENCE[b] ? a : b;
}
// ── NodeConnections component ────────────────────────────────────────────────
interface NodeConnectionsProps {
nodeId: number;
links: Record<string, import("../../store/slices/topologySlice").LinkObservation>;
nodes: Record<number, import("../../store/slices/aggregatorSlice").NodeData>;
}
const NodeConnections: React.FC<NodeConnectionsProps> = ({ nodeId, links, nodes }) => {
const edges = Object.values(links).filter(
(l) => l.nodeA === nodeId || l.nodeB === nodeId
);
if (edges.length === 0) {
return (
<p className="text-sm text-neutral-500">No connections observed yet.</p>
);
}
return (
<div className="space-y-2">
{edges.map((link) => {
const neighborId = link.nodeA === nodeId ? link.nodeB : link.nodeA;
const neighborNode = nodes[neighborId];
const neighborName =
neighborNode?.shortName ??
neighborNode?.longName ??
`!${neighborId.toString(16)}`;
// Determine SNR direction relative to nodeId
// snrAtoB = SNR at nodeB receiving from nodeA
// snrBtoA = SNR at nodeA receiving from nodeB
// "outgoing SNR" = SNR that the neighbor measured (i.e., neighbor receiving from nodeId)
// "incoming SNR" = SNR that nodeId measured (i.e., nodeId receiving from neighbor)
const outgoingSnr =
nodeId === link.nodeA ? link.snrAtoB : link.snrBtoA;
const incomingSnr =
nodeId === link.nodeA ? link.snrBtoA : link.snrAtoB;
const source = bestSource(link.sourceAtoB, link.sourceBtoA);
const secondsAgo = Math.floor(Date.now() / 1000) - link.lastSeen;
return (
<div
key={link.key}
className="flex items-start justify-between p-3 bg-neutral-800 rounded-lg border border-neutral-700"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm text-white font-medium truncate">
{neighborName}
</span>
<span
className={`text-xs px-1.5 py-0.5 rounded font-medium ${SOURCE_COLORS[source]}`}
>
{SOURCE_LABELS[source]}
</span>
{link.viaMqtt && (
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-900 text-purple-300 font-medium">
MQTT
</span>
)}
</div>
<div className="flex gap-3 text-xs text-neutral-400">
{outgoingSnr !== undefined && (
<span> {outgoingSnr.toFixed(1)} dB</span>
)}
{incomingSnr !== undefined && (
<span> {incomingSnr.toFixed(1)} dB</span>
)}
{link.rssiAtoB !== undefined && nodeId === link.nodeB && (
<span className="text-neutral-500">
RSSI {link.rssiAtoB} dBm
</span>
)}
</div>
</div>
<div className="text-xs text-neutral-500 ml-3 shrink-0">
{formatLastSeen(secondsAgo)}
</div>
</div>
);
})}
</div>
);
};
+4
View File
@@ -394,6 +394,10 @@ export interface Data {
// Reception timestamp (added by decoder)
rxTime?: number;
// RF reception quality (measured at gateway)
rxSnr?: number; // SNR at receiving gateway (dB)
rxRssi?: number; // RSSI at receiving gateway (dBm)
}
// Packet represents a complete decoded MQTT message
+6
View File
@@ -6,6 +6,8 @@ import { streamPackets, StreamEvent, ConnectionInfoEvent } from "../lib/api";
import { processNewPacket } from "../store/slices/aggregatorSlice";
import { addPacket } from "../store/slices/packetSlice";
import { updateConnectionInfo, updateConnectionStatus } from "../store/slices/connectionSlice";
import { processTopologyPacket } from "../store/slices/topologySlice";
import { store } from "../store";
import { createRootRoute } from "@tanstack/react-router";
export const Route = createRootRoute({
@@ -56,6 +58,10 @@ function RootLayout() {
// Process message for both the aggregator and packet display
dispatch(processNewPacket(event.data));
dispatch(addPacket(event.data));
// Dispatch topology processing (receives all copies including multi-gateway)
const timestamp = event.data.data.rxTime || Math.floor(Date.now() / 1000);
const nodeIds = Object.keys(store.getState().aggregator.nodes).map(Number);
dispatch(processTopologyPacket({ packet: event.data, timestamp, nodeIds }));
} else if (event.type === "bad_data") {
console.warn("[SSE] Received bad data:", event.data);
}
+26 -13
View File
@@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { PageWrapper } from "../components";
import { NetworkMap } from "../components/dashboard";
import { Button } from "../components/ui";
import { Locate } from "lucide-react";
import { Locate, GitBranch } from "lucide-react";
import { getNodeColors, ActivityLevel } from "../lib/activity";
export const Route = createFileRoute("/map")({
@@ -13,6 +13,7 @@ export const Route = createFileRoute("/map")({
function MapPage() {
// State to track if auto-zoom is enabled (forwarded from the NetworkMap component)
const [autoZoomEnabled, setAutoZoomEnabled] = React.useState(true);
const [showLinks, setShowLinks] = React.useState(true);
const mapRef = React.useRef<{ resetAutoZoom?: () => void }>({});
// Function to reset auto-zoom, will be called by the button
@@ -26,10 +27,11 @@ function MapPage() {
<PageWrapper>
<div className="flex flex-col h-[calc(100vh-7rem)] md:h-[calc(100vh-5rem)]">
<div className="flex-1 flex flex-col">
<NetworkMap
<NetworkMap
fullHeight
ref={mapRef as any}
onAutoZoomChange={setAutoZoomEnabled}
showLinks={showLinks}
/>
<div className="mt-2 rounded-lg p-2 text-xs flex items-center justify-between effect-inset">
@@ -48,17 +50,28 @@ function MapPage() {
</div>
{/* Always show the button, but disable it when auto-zoom is enabled */}
<Button
size="sm"
variant="secondary"
onClick={handleResetZoom}
icon={Locate}
disabled={autoZoomEnabled}
title={autoZoomEnabled ? "Auto-zoom is already enabled" : "Enable auto-zoom to fit all nodes"}
>
Auto-zoom
</Button>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={showLinks ? "primary" : "secondary"}
onClick={() => setShowLinks((v) => !v)}
icon={GitBranch}
title={showLinks ? "Hide link polylines" : "Show link polylines"}
>
Links
</Button>
{/* Always show the button, but disable it when auto-zoom is enabled */}
<Button
size="sm"
variant="secondary"
onClick={handleResetZoom}
icon={Locate}
disabled={autoZoomEnabled}
title={autoZoomEnabled ? "Auto-zoom is already enabled" : "Enable auto-zoom to fit all nodes"}
>
Auto-zoom
</Button>
</div>
</div>
</div>
</div>
+2
View File
@@ -2,12 +2,14 @@ import { configureStore } from '@reduxjs/toolkit';
import packetReducer from './slices/packetSlice';
import aggregatorReducer from './slices/aggregatorSlice';
import connectionReducer from './slices/connectionSlice';
import topologyReducer from './slices/topologySlice';
export const store = configureStore({
reducer: {
packets: packetReducer,
aggregator: aggregatorReducer,
connection: connectionReducer,
topology: topologyReducer,
},
});
+6
View File
@@ -29,6 +29,8 @@ export interface NodeData {
// Fields for gateway nodes
isGateway?: boolean;
observedNodeCount?: number;
// Number of hops from the gateway (0 = direct RF link)
hopsFromGateway?: number;
// MapReport payload for this node
mapReport?: MapReport;
// User-specific fields
@@ -260,6 +262,10 @@ const processPacket = (state: AggregatorState, packet: Packet) => {
node.textMessageCount++;
}
// Track hop distance from gateway
const hops = (data.hopStart ?? 0) - (data.hopLimit ?? 0);
if (hops >= 0) node.hopsFromGateway = hops;
// Set channelId and gatewayId if available
if (channelId) {
node.channelId = channelId;
+304
View File
@@ -0,0 +1,304 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Packet, PortNum } from "../../lib/types";
export type LinkSource =
| "traceroute"
| "neighbor_info"
| "zero_hop"
| "relay_inferred"
| "unknown";
export interface LinkObservation {
key: string;
nodeA: number; // lower numeric node ID
nodeB: number; // higher numeric node ID
snrAtoB?: number; // SNR in dB at nodeB receiving from nodeA
snrBtoA?: number; // SNR in dB at nodeA receiving from nodeB
rssiAtoB?: number; // RSSI in dBm at nodeB receiving from nodeA (gateway-measured)
sourceAtoB: LinkSource; // confidence for snrAtoB direction
sourceBtoA: LinkSource; // confidence for snrBtoA direction
viaMqtt?: boolean; // true if any observation crossed an MQTT bridge
lastSeen: number; // unix timestamp
hopCount?: number; // total traceroute path length
}
interface TopologyState {
links: Record<string, LinkObservation>;
// packetKey → timestamp for dedup (traceroute + neighborInfo)
processedTraceroutes: Record<string, number>;
}
const initialState: TopologyState = {
links: {},
processedTraceroutes: {},
};
const SOURCE_CONFIDENCE: Record<LinkSource, number> = {
traceroute: 4,
neighbor_info: 3,
zero_hop: 2,
relay_inferred: 1,
unknown: 0,
};
const MAX_LINKS = 2000;
// Upsert a directed observation: sender transmitted, receiver measured snr/rssi.
// Updates the canonical link entry, applying per-direction confidence rules.
function upsertObservation(
state: TopologyState,
sender: number,
receiver: number,
snr: number | undefined,
rssi: number | undefined,
source: LinkSource,
viaMqtt: boolean,
timestamp: number,
hopCount?: number
): void {
const nodeA = Math.min(sender, receiver);
const nodeB = Math.max(sender, receiver);
const key = `${nodeA}-${nodeB}`;
if (!state.links[key]) {
state.links[key] = {
key,
nodeA,
nodeB,
sourceAtoB: "unknown",
sourceBtoA: "unknown",
lastSeen: timestamp,
};
}
const link = state.links[key];
link.lastSeen = Math.max(link.lastSeen, timestamp);
if (viaMqtt) link.viaMqtt = true;
if (hopCount !== undefined) link.hopCount = hopCount;
// "sender→receiver": if sender===nodeA, this is AtoB (nodeB received from nodeA)
const isAtoB = sender === nodeA;
const currentSource: LinkSource = isAtoB
? link.sourceAtoB
: link.sourceBtoA;
if (
currentSource === "unknown" ||
SOURCE_CONFIDENCE[source] > SOURCE_CONFIDENCE[currentSource]
) {
if (isAtoB) {
link.sourceAtoB = source;
if (snr !== undefined) link.snrAtoB = snr;
if (rssi !== undefined) link.rssiAtoB = rssi;
} else {
link.sourceBtoA = source;
if (snr !== undefined) link.snrBtoA = snr;
// No rssi for BtoA — only gateway-measured (AtoB) rssi is tracked
}
}
}
function resolvePortNum(portNum: PortNum | string): PortNum | undefined {
if (typeof portNum === "number") return portNum;
const n = PortNum[portNum as keyof typeof PortNum];
return typeof n === "number" ? n : undefined;
}
function processTopology(
state: TopologyState,
packet: Packet,
timestamp: number,
nodeIds: number[]
): void {
const { data } = packet;
// ── Zero-hop observations (no dedup; each gateway copy is independent) ──
if (
data.hopStart !== undefined &&
data.hopLimit !== undefined &&
data.hopStart === data.hopLimit &&
data.gatewayId?.startsWith("!")
) {
const gatewayNum = parseInt(data.gatewayId.substring(1), 16);
if (!isNaN(gatewayNum) && data.from !== gatewayNum) {
upsertObservation(
state,
data.from,
gatewayNum,
data.rxSnr,
data.rxRssi,
"zero_hop",
!!data.viaMqtt,
timestamp
);
}
}
const portNum = resolvePortNum(data.portNum);
// ── Traceroute replies ──
if (
portNum === PortNum.TRACEROUTE_APP &&
!data.wantResponse &&
data.routeDiscovery
) {
const packetKey = `!${data.from.toString(16).toLowerCase()}_${data.id}`;
if (state.processedTraceroutes[packetKey]) return;
state.processedTraceroutes[packetKey] = timestamp;
const rd = data.routeDiscovery;
// Forward path: data.to → ...route → data.from
const forwardPath = [data.to, ...(rd.route ?? []), data.from];
const snrTowards = rd.snrTowards ?? [];
const hopCount = forwardPath.length;
for (let i = 0; i < forwardPath.length - 1; i++) {
const cap = Math.min(snrTowards.length, forwardPath.length - 1);
const snr = i < cap ? snrTowards[i] / 4 : undefined;
upsertObservation(
state,
forwardPath[i], // sender
forwardPath[i + 1], // receiver
snr,
undefined,
"traceroute",
!!data.viaMqtt,
timestamp,
hopCount
);
}
// Return path: data.from → ...routeBack → data.to
const returnPath = [data.from, ...(rd.routeBack ?? []), data.to];
const snrBack = rd.snrBack ?? [];
for (let i = 0; i < returnPath.length - 1; i++) {
const cap = Math.min(snrBack.length, returnPath.length - 1);
const snr = i < cap ? snrBack[i] / 4 : undefined;
upsertObservation(
state,
returnPath[i],
returnPath[i + 1],
snr,
undefined,
"traceroute",
!!data.viaMqtt,
timestamp,
hopCount
);
}
return;
}
// ── NeighborInfo packets ──
if (portNum === PortNum.NEIGHBORINFO_APP && data.neighborInfo) {
const ni = data.neighborInfo;
const broadcaster = ni.nodeId;
const packetKey = `!${broadcaster.toString(16).toLowerCase()}_${data.id}`;
if (state.processedTraceroutes[packetKey]) return;
state.processedTraceroutes[packetKey] = timestamp;
for (const neighbor of ni.neighbors ?? []) {
// broadcaster received from neighbor → sender=neighbor, receiver=broadcaster
upsertObservation(
state,
neighbor.nodeId,
broadcaster,
neighbor.snr,
undefined,
"neighbor_info",
!!data.viaMqtt,
timestamp
);
}
return;
}
// ── relay_node inferred links (1-hop packets with known relay) ──
if (
data.hopStart !== undefined &&
data.hopLimit !== undefined &&
data.hopStart - data.hopLimit === 1 &&
data.relayNode !== undefined &&
data.relayNode !== 0 &&
data.gatewayId?.startsWith("!")
) {
const relayByte = data.relayNode & 0xff;
const candidates = nodeIds.filter((id) => (id & 0xff) === relayByte);
if (candidates.length === 1) {
const relay = candidates[0];
const gatewayNum = parseInt(data.gatewayId.substring(1), 16);
if (!isNaN(gatewayNum)) {
// from → relay
upsertObservation(
state,
data.from,
relay,
undefined,
undefined,
"relay_inferred",
!!data.viaMqtt,
timestamp
);
// relay → gateway
upsertObservation(
state,
relay,
gatewayNum,
undefined,
undefined,
"relay_inferred",
!!data.viaMqtt,
timestamp
);
}
}
}
}
const topologySlice = createSlice({
name: "topology",
initialState,
reducers: {
processTopologyPacket: (
state,
action: PayloadAction<{
packet: Packet;
timestamp: number;
nodeIds: number[];
}>
) => {
const { packet, timestamp, nodeIds } = action.payload;
// Prune edges and dedup entries older than 24 hours
const cutoff = timestamp - 86400;
for (const key of Object.keys(state.links)) {
if (state.links[key].lastSeen < cutoff) delete state.links[key];
}
for (const key of Object.keys(state.processedTraceroutes)) {
if (state.processedTraceroutes[key] < cutoff)
delete state.processedTraceroutes[key];
}
processTopology(state, packet, timestamp, nodeIds);
// Enforce 2000-edge cap: remove oldest when over limit
const linkCount = Object.keys(state.links).length;
if (linkCount > MAX_LINKS) {
const sorted = Object.entries(state.links).sort(
([, a], [, b]) => a.lastSeen - b.lastSeen
);
const toRemove = sorted.slice(0, linkCount - MAX_LINKS);
for (const [key] of toRemove) delete state.links[key];
}
},
clearTopology: (state) => {
state.links = {};
state.processedTraceroutes = {};
},
},
});
export const { processTopologyPacket, clearTopology } = topologySlice.actions;
export default topologySlice.reducer;
+19 -1
View File
@@ -47,11 +47,19 @@ declare namespace google {
class InfoWindow {
constructor(opts?: InfoWindowOptions);
setContent(content: string): void;
setContent(content: string | HTMLElement): void;
open(map?: Map, anchor?: any): void;
close(): void;
}
class Polyline {
constructor(opts?: PolylineOptions);
setMap(map: Map | null): void;
setPath(path: LatLngLiteral[]): void;
setOptions(opts: PolylineOptions): void;
setVisible(visible: boolean): void;
}
class LatLngBounds {
constructor();
extend(point: LatLngLiteral): void;
@@ -99,6 +107,16 @@ declare namespace google {
position?: LatLngLiteral;
}
interface PolylineOptions {
path?: LatLngLiteral[];
geodesic?: boolean;
strokeColor?: string;
strokeOpacity?: number;
strokeWeight?: number;
map?: Map | null;
visible?: boolean;
}
// Event-related functionality
const event: {
addListener(instance: object, event: string, listener: (Event) => void): MapsEventListener;