mirror of
https://github.com/dpup/meshstream.git
synced 2026-08-01 06:23:07 +02:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,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
@@ -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,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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
Vendored
+19
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user