From 0184cba1eff6f307485825da29ab27e94b78aa0e Mon Sep 17 00:00:00 2001 From: Daniel Pupius Date: Thu, 24 Apr 2025 20:10:56 -0700 Subject: [PATCH] Updates to node details --- main.go | 6 +- server/server.go | 11 +- web/src/components/dashboard/NodeDetail.tsx | 733 ++++++++++++++------ web/src/lib/api.ts | 13 +- 4 files changed, 541 insertions(+), 222 deletions(-) diff --git a/main.go b/main.go index e5a2e8b..15bc98f 100644 --- a/main.go +++ b/main.go @@ -17,8 +17,7 @@ const ( mqttBroker = "mqtt.bayme.sh" mqttUsername = "meshdev" mqttPassword = "large4cats" - mqttTopicPrefix = "msh/US/bayarea" - logsDir = "./logs" + mqttTopicPrefix = "msh/US/CA" // Web server configuration serverHost = "localhost" @@ -81,7 +80,8 @@ func main() { Host: serverHost, Port: serverPort, Broker: broker, - }, logger) + Logger: logger, + }) // Start the server in a goroutine go func() { diff --git a/server/server.go b/server/server.go index e1e7887..994c71c 100644 --- a/server/server.go +++ b/server/server.go @@ -18,6 +18,7 @@ import ( type Config struct { Host string Port string + Logger logging.Logger Broker *mqtt.Broker // The MQTT message broker } @@ -34,8 +35,8 @@ type Server struct { } // New creates a new server instance -func New(config Config, logger logging.Logger) *Server { - serverLogger := logger.Named("server") +func New(config Config) *Server { + serverLogger := config.Logger.Named("server") if config.Broker == nil { serverLogger.Info("Warning: Server created without a broker, streaming will not work") @@ -102,9 +103,11 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { // handleStream handles Server-Sent Events streaming of MQTT messages func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { - logger := s.logger.Named("api.sse") + logger := s.logger.Named("api.sse").With("remoteAddr", r.RemoteAddr) ctx := r.Context() + logger.Infow("SSE stream requested") + // Check if the server is shutting down if s.isShuttingDown.Load() { http.Error(w, "Server is shutting down", http.StatusServiceUnavailable) @@ -186,6 +189,8 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { return } + logger.Info("Sending packet to client", "packetID", packet.Data.Id) + // Send the event fmt.Fprintf(w, "event: message\ndata: %s\n\n", data) flusher.Flush() diff --git a/web/src/components/dashboard/NodeDetail.tsx b/web/src/components/dashboard/NodeDetail.tsx index 2099ec3..7badf44 100644 --- a/web/src/components/dashboard/NodeDetail.tsx +++ b/web/src/components/dashboard/NodeDetail.tsx @@ -1,13 +1,201 @@ -import React, { useEffect } from "react"; -import { useNavigate } from "@tanstack/react-router"; +import React, { useEffect, useRef } from "react"; +import { useNavigate, Link } from "@tanstack/react-router"; import { useAppSelector, useAppDispatch } from "../../hooks"; import { selectNode } from "../../store/slices/aggregatorSlice"; -import { ArrowLeft, Radio, Battery, Cpu, Thermometer, Gauge, Signal, Droplets, Map, Calendar, Clock } from "lucide-react"; +import { + ArrowLeft, Radio, Battery, Cpu, Thermometer, Gauge, Signal, + Droplets, Map, Calendar, Clock, Wifi, BarChart, + BatteryFull, BatteryMedium, BatteryLow, AlertTriangle, + Zap, Timer, ChevronRight +} from "lucide-react"; interface NodeDetailProps { nodeId: number; } +// Google Maps API is already loaded - this creates a map component +const GoogleMap: React.FC<{lat: number, lng: number, zoom?: number}> = ({lat, lng, zoom = 14}) => { + const mapRef = useRef(null); + const mapInstanceRef = useRef(null); + const markerRef = useRef(null); + + useEffect(() => { + if (mapRef.current && window.google && window.google.maps) { + // Create map instance + const mapOptions: google.maps.MapOptions = { + center: { lat, lng }, + zoom, + mapTypeId: google.maps.MapTypeId.HYBRID, + mapTypeControl: false, + streetViewControl: false, + fullscreenControl: false, + zoomControl: true, + styles: [ + { + "featureType": "all", + "elementType": "labels.text.fill", + "stylers": [{"color": "#ffffff"}] + }, + { + "featureType": "all", + "elementType": "labels.text.stroke", + "stylers": [{"visibility": "off"}] + }, + { + "featureType": "administrative", + "elementType": "geometry", + "stylers": [{"visibility": "on"}, {"color": "#2d2d2d"}] + }, + { + "featureType": "landscape", + "elementType": "geometry", + "stylers": [{"color": "#1a1a1a"}] + }, + { + "featureType": "poi", + "elementType": "geometry", + "stylers": [{"color": "#1a1a1a"}] + }, + { + "featureType": "road", + "elementType": "geometry.fill", + "stylers": [{"color": "#2d2d2d"}] + }, + { + "featureType": "road", + "elementType": "geometry.stroke", + "stylers": [{"color": "#333333"}] + }, + { + "featureType": "water", + "elementType": "geometry", + "stylers": [{"color": "#0f252e"}] + } + ] + }; + + mapInstanceRef.current = new google.maps.Map(mapRef.current, mapOptions); + + // Add marker + markerRef.current = new google.maps.Marker({ + position: { lat, lng }, + map: mapInstanceRef.current, + title: `Node Position`, + icon: { + path: google.maps.SymbolPath.CIRCLE, + scale: 8, + fillColor: "#4ade80", + fillOpacity: 1, + strokeColor: "#22c55e", + strokeWeight: 2, + }, + }); + + // Add accuracy circle if available + if (markerRef.current) { + new google.maps.Circle({ + strokeColor: "#22c55e", + strokeOpacity: 0.5, + strokeWeight: 1, + fillColor: "#4ade80", + fillOpacity: 0.15, + map: mapInstanceRef.current, + center: { lat, lng }, + radius: 300 // 300m accuracy as default + }); + } + + } + }, [lat, lng, zoom]); + + return
; +}; + +// Battery level component with visual indicator +const BatteryLevel: React.FC<{level: number}> = ({level}) => { + let color = 'bg-green-500'; + let icon = ; + + if (level <= 20) { + color = 'bg-red-500'; + icon = ; + } else if (level <= 50) { + color = 'bg-amber-500'; + icon = ; + } + + return ( +
+
+ + {icon} + Battery + + 30 ? 'text-green-500' : 'text-amber-500'} font-medium`}> + {level}% + +
+
+
+
+
+ ); +}; + +// Signal strength component with visual indicator +const SignalStrength: React.FC<{snr: number}> = ({snr}) => { + // SNR is typically in dB, with values from -20 to +20 + // Higher is better: < 0 is poor, > 10 is excellent + let strengthClass = 'bg-red-500'; + let strengthText = 'Poor'; + + if (snr > 10) { + strengthClass = 'bg-green-500'; + strengthText = 'Excellent'; + } else if (snr > 5) { + strengthClass = 'bg-green-400'; + strengthText = 'Good'; + } else if (snr > 0) { + strengthClass = 'bg-amber-500'; + strengthText = 'Fair'; + } + + // Calculate width percentage (0-100%) + // Map SNR from -20...+20 to 0...100% + const percentage = Math.max(0, Math.min(100, ((snr + 20) / 40) * 100)); + + return ( +
+
+ + + Signal + + + {snr} dB ({strengthText}) + +
+
+
+
+
+ ); +}; + +// Format uptime into a human-readable string +const formatUptime = (seconds: number): string => { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + + return parts.join(' ') || '< 1m'; +}; + export const NodeDetail: React.FC = ({ nodeId }) => { const dispatch = useAppDispatch(); const navigate = useNavigate(); @@ -74,241 +262,366 @@ export const NodeDetail: React.FC = ({ nodeId }) => { // Is node active const isActive = secondsAgo < 600; // 10 minutes + // Get position data if available + const hasPosition = node.position && + node.position.latitudeI !== undefined && + node.position.longitudeI !== undefined; + + const latitude = hasPosition ? node.position!.latitudeI! / 10000000 : 0; + const longitude = hasPosition ? node.position!.longitudeI! / 10000000 : 0; + return (
-
+ {/* Header with back button and basic node info */} +
-

{nodeName}

-
ID: {nodeId}
+
+

{nodeName}

+
+ + {isActive ? 'Active' : 'Inactive'} - last seen {lastSeenText} +
+
+
+ ID: {nodeId.toString(16)} +
-
- {/* Basic Info */} -
-

Basic Info

-
- {node.longName && ( -
- Name: - {node.longName} -
- )} +
+
+
+ {/* Basic Info */} +
+

Device Information

+
+ {node.longName && ( +
+ Name: + {node.longName} +
+ )} - {node.hwModel && ( -
- Hardware: - {node.hwModel} -
- )} + {node.hwModel && ( +
+ Hardware: + {node.hwModel} +
+ )} - {node.macAddr && ( -
- MAC Address: - {node.macAddr} -
- )} + {node.macAddr && ( +
+ MAC Address: + {node.macAddr} +
+ )} -
- Channel: - {node.channelId || 'Unknown'} +
+ Channels: +
+ {node.channelId ? ( + + + {node.channelId} + + ) : ( + None detected + )} +
+
+ +
+
+ Packets: +
+
+ {node.messageCount} + Total +
+
+ {node.textMessageCount} + Text +
+
+
+
+
-
- Gateway: - {node.gatewayId || 'Unknown'} -
+ {/* Activity */} +
+

Last Activity

+
+
+ + Date: + {lastHeardDay} +
-
- Messages: - {node.messageCount} +
+ + Time: + {lastHeardTime} +
+ + {node.deviceMetrics?.uptimeSeconds !== undefined && ( +
+ + Uptime: + + {formatUptime(node.deviceMetrics.uptimeSeconds)} + +
+ )} + +
+
+ Gateways: +
+ {node.gatewayId ? ( + + {node.gatewayId} + + + ) : ( + None detected + )} +
+
+
+
-
- {/* Activity */} -
-

Activity

-
-
- - - {isActive ? 'Active' : 'Inactive'} - last seen {lastSeenText} - -
- -
- - Date: - {lastHeardDay} -
- -
- - Time: - {lastHeardTime} -
-
-
- - {/* Position Info */} - {node.position && ( -
-

- - Position -

-
- {node.position.latitudeI !== undefined && node.position.longitudeI !== undefined && ( -
+ {/* Position Map */} + {hasPosition && ( +
+

+ + Node Location +

+
+ +
+
+
Coordinates: - - {(node.position.latitudeI / 10000000).toFixed(6)}, {(node.position.longitudeI / 10000000).toFixed(6)} + + {latitude.toFixed(6)}, {longitude.toFixed(6)}
- )} - - {node.position.altitude !== undefined && ( -
- Altitude: - {node.position.altitude} m -
- )} - - {node.position.groundSpeed !== undefined && ( -
- Speed: - {node.position.groundSpeed} m/s -
- )} - - {node.position.satsInView !== undefined && ( -
- Satellites: - {node.position.satsInView} -
- )} - - {node.position.locationSource && ( -
- Source: - {node.position.locationSource.replace('LOC_', '')} -
- )} + + {node.position?.altitude !== undefined && ( +
+ Altitude: + {node.position.altitude} m +
+ )} + + {node.position?.satsInView !== undefined && ( +
+ Satellites: + {node.position.satsInView} +
+ )} + + {node.position?.groundSpeed !== undefined && ( +
+ Speed: + {node.position.groundSpeed} m/s +
+ )} +
-
- )} + )} +
- {/* Telemetry Info - Device Metrics */} - {node.deviceMetrics && ( -
-

- - Device Metrics -

-
- {node.batteryLevel !== undefined && ( -
- - - Battery: - - 30 ? 'text-green-500' : 'text-amber-500'}`}> - {node.batteryLevel}% - -
- )} - - {node.deviceMetrics.voltage !== undefined && ( -
- Voltage: - {node.deviceMetrics.voltage.toFixed(2)} V -
- )} - - {node.deviceMetrics.channelUtilization !== undefined && ( -
- Channel Utilization: - {node.deviceMetrics.channelUtilization}% -
- )} - - {node.deviceMetrics.airUtilTx !== undefined && ( -
- Air Utilization: - {node.deviceMetrics.airUtilTx}% -
- )} - - {node.snr !== undefined && ( -
- - - SNR: - - {node.snr} dB -
- )} - - {node.deviceMetrics.uptimeSeconds !== undefined && ( -
- Uptime: - - {Math.floor(node.deviceMetrics.uptimeSeconds / 86400)}d {Math.floor((node.deviceMetrics.uptimeSeconds % 86400) / 3600)}h {Math.floor((node.deviceMetrics.uptimeSeconds % 3600) / 60)}m - -
- )} +
+ {/* Telemetry Info - Device Metrics */} + {(node.deviceMetrics || node.batteryLevel !== undefined || node.snr !== undefined) && ( +
+

+ + Device Status +

+
+ {node.batteryLevel !== undefined && ( + + )} + + {node.deviceMetrics?.voltage !== undefined && ( +
+ + + Voltage: + + {node.deviceMetrics.voltage.toFixed(2)} V +
+ )} + + {node.snr !== undefined && ( + + )} + + {(node.deviceMetrics?.channelUtilization !== undefined || node.deviceMetrics?.airUtilTx !== undefined) && ( +
+
+ + Channel Utilization: +
+ + {node.deviceMetrics?.channelUtilization !== undefined && ( +
+
+ Total + {node.deviceMetrics.channelUtilization}% +
+
+
+
+
+ )} + + {node.deviceMetrics?.airUtilTx !== undefined && ( +
+
+ Transmit + {node.deviceMetrics.airUtilTx}% +
+
+
+
+
+ )} +
+ )} +
-
- )} + )} - {/* Telemetry Info - Environment Metrics */} - {node.environmentMetrics && Object.keys(node.environmentMetrics).length > 0 && ( -
-

- - Environment Metrics -

-
- {node.environmentMetrics.temperature !== undefined && ( -
- Temperature: - {node.environmentMetrics.temperature}°C -
- )} - - {node.environmentMetrics.relativeHumidity !== undefined && ( -
- - - Humidity: - - {node.environmentMetrics.relativeHumidity}% -
- )} - - {node.environmentMetrics.barometricPressure !== undefined && ( -
- - - Pressure: - - {node.environmentMetrics.barometricPressure} hPa -
- )} - - {/* Add other environment metrics here as needed */} + {/* Telemetry Info - Environment Metrics */} + {node.environmentMetrics && Object.keys(node.environmentMetrics).length > 0 && ( +
+

+ + Environment Data +

+
+ {node.environmentMetrics.temperature !== undefined && ( +
+
+ Temperature: + 30 ? 'text-red-500' : + node.environmentMetrics.temperature < 10 ? 'text-blue-500' : + 'text-green-500'} font-medium + `}> + {node.environmentMetrics.temperature}°C + +
+
+ {/* Temp scale: -10°C to 40°C mapped to 0-100% */} +
30 ? 'bg-red-500' : + node.environmentMetrics.temperature < 10 ? 'bg-blue-500' : + 'bg-green-500'} h-2 rounded-full + `} + style={{ + width: `${Math.max(0, Math.min(100, ((node.environmentMetrics.temperature + 10) / 50) * 100))}%` + }} + >
+
+
+ )} + + {node.environmentMetrics.relativeHumidity !== undefined && ( +
+
+ + + Humidity: + + + {node.environmentMetrics.relativeHumidity}% + +
+
+
+
+
+ )} + + {node.environmentMetrics.barometricPressure !== undefined && ( +
+ + + Pressure: + + + {node.environmentMetrics.barometricPressure} hPa + +
+ )} + + {node.environmentMetrics.soilMoisture !== undefined && ( +
+
+ Soil Moisture: + + {node.environmentMetrics.soilMoisture}% + +
+
+
+
+
+ )} +
-
- )} + )} + + {/* Warning for low battery */} + {node.batteryLevel !== undefined && node.batteryLevel < 20 && ( +
+
+ +

Low Battery Warning

+
+

+ This node's battery level is critically low at {node.batteryLevel}%. + The device may stop reporting soon. +

+
+ )} +
); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index fb9dfe9..aee523f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -17,10 +17,10 @@ export interface ApiResponse { } // Re-export types -export type { - InfoEvent, - MessageEvent, - BadDataEvent, +export type { + InfoEvent, + MessageEvent, + BadDataEvent, StreamEvent, StreamEventHandler, }; @@ -34,9 +34,10 @@ export function streamPackets( ): () => void { const evtSource = new EventSource(API_ENDPOINTS.STREAM); + console.log("[SSE] Connecting to stream...", evtSource); + // Handle info events specifically evtSource.addEventListener("info", (event) => { - // Info events are just strings onEvent({ type: "info", data: event.data, @@ -80,4 +81,4 @@ function handleEventData(data: string, callback: StreamEventHandler): void { data, }); } -} \ No newline at end of file +}