Updates to node details

This commit is contained in:
Daniel Pupius
2025-04-24 20:10:56 -07:00
parent 33fcc87f41
commit 0184cba1ef
4 changed files with 541 additions and 222 deletions
+3 -3
View File
@@ -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() {
+8 -3
View File
@@ -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()
+523 -210
View File
@@ -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<HTMLDivElement>(null);
const mapInstanceRef = useRef<google.maps.Map | null>(null);
const markerRef = useRef<google.maps.Marker | null>(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 <div ref={mapRef} className="w-full h-full min-h-[300px] rounded-lg overflow-hidden" />;
};
// Battery level component with visual indicator
const BatteryLevel: React.FC<{level: number}> = ({level}) => {
let color = 'bg-green-500';
let icon = <BatteryFull className="w-5 h-5" />;
if (level <= 20) {
color = 'bg-red-500';
icon = <BatteryLow className="w-5 h-5" />;
} else if (level <= 50) {
color = 'bg-amber-500';
icon = <BatteryMedium className="w-5 h-5" />;
}
return (
<div className="flex flex-col">
<div className="flex items-center justify-between mb-1">
<span className="text-neutral-400 flex items-center">
{icon}
<span className="ml-1.5">Battery</span>
</span>
<span className={`${level > 30 ? 'text-green-500' : 'text-amber-500'} font-medium`}>
{level}%
</span>
</div>
<div className="w-full bg-neutral-700 rounded-full h-2.5">
<div className={`${color} h-2.5 rounded-full`} style={{width: `${level}%`}}></div>
</div>
</div>
);
};
// 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 (
<div className="flex flex-col">
<div className="flex items-center justify-between mb-1">
<span className="text-neutral-400 flex items-center">
<Signal className="w-5 h-5" />
<span className="ml-1.5">Signal</span>
</span>
<span className="text-neutral-200">
{snr} dB <span className="text-xs">({strengthText})</span>
</span>
</div>
<div className="w-full bg-neutral-700 rounded-full h-2.5">
<div className={`${strengthClass} h-2.5 rounded-full`} style={{width: `${percentage}%`}}></div>
</div>
</div>
);
};
// 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<NodeDetailProps> = ({ nodeId }) => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
@@ -74,241 +262,366 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ 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 (
<div className="space-y-6">
<div className="flex items-center">
{/* Header with back button and basic node info */}
<div className="flex items-center p-4 bg-neutral-800/50 rounded-lg">
<button
onClick={handleBack}
className="flex items-center mr-3 px-2 py-1 text-sm bg-neutral-800 hover:bg-neutral-700 rounded transition-colors"
className="flex items-center mr-4 p-2 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-700 rounded-full transition-colors"
>
<ArrowLeft className="w-4 h-4 mr-1" />
Back
<ArrowLeft className="w-5 h-5" />
</button>
<div className={`p-2 mr-3 rounded-full ${isActive ? 'bg-green-900/30 text-green-500' : 'bg-neutral-700/30 text-neutral-500'}`}>
<Radio className="w-5 h-5" />
</div>
<h1 className="text-xl font-semibold text-neutral-200">{nodeName}</h1>
<div className="ml-auto text-sm text-neutral-400">ID: {nodeId}</div>
<div className="flex-1 flex flex-col md:flex-row md:items-center">
<h1 className="text-xl font-semibold text-neutral-200 mr-3">{nodeName}</h1>
<div className="text-sm text-neutral-400 flex items-center">
<span className={`inline-block w-2 h-2 rounded-full mr-2 ${isActive ? 'bg-green-500' : 'bg-neutral-500'}`}></span>
{isActive ? 'Active' : 'Inactive'} - last seen {lastSeenText}
</div>
</div>
<div className="text-sm text-neutral-400 bg-neutral-900/50 px-3 py-1.5 rounded font-mono">
ID: {nodeId.toString(16)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Basic Info */}
<div className="bg-neutral-800/50 p-4 rounded-lg">
<h2 className="font-semibold mb-3 text-neutral-300">Basic Info</h2>
<div className="space-y-2">
{node.longName && (
<div className="flex justify-between">
<span className="text-neutral-400">Name:</span>
<span className="text-neutral-200">{node.longName}</span>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Basic Info */}
<div className="bg-neutral-800/50 p-4 rounded-lg">
<h2 className="font-semibold mb-4 text-neutral-300 border-b border-neutral-700 pb-2">Device Information</h2>
<div className="space-y-3">
{node.longName && (
<div className="flex justify-between items-center">
<span className="text-neutral-400">Name:</span>
<span className="text-neutral-200">{node.longName}</span>
</div>
)}
{node.hwModel && (
<div className="flex justify-between">
<span className="text-neutral-400">Hardware:</span>
<span className="text-neutral-200">{node.hwModel}</span>
</div>
)}
{node.hwModel && (
<div className="flex justify-between items-center">
<span className="text-neutral-400">Hardware:</span>
<span className="text-neutral-200">{node.hwModel}</span>
</div>
)}
{node.macAddr && (
<div className="flex justify-between">
<span className="text-neutral-400">MAC Address:</span>
<span className="text-neutral-200 font-mono text-sm">{node.macAddr}</span>
</div>
)}
{node.macAddr && (
<div className="flex justify-between items-center">
<span className="text-neutral-400">MAC Address:</span>
<span className="text-neutral-200 font-mono text-sm">{node.macAddr}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-neutral-400">Channel:</span>
<span className="text-neutral-200">{node.channelId || 'Unknown'}</span>
<div className="flex justify-between items-center">
<span className="text-neutral-400">Channels:</span>
<div className="flex flex-col items-end">
{node.channelId ? (
<Link
to="/channel/$channelId"
params={{ channelId: node.channelId }}
className="text-neutral-200 flex items-center hover:text-blue-400 transition-colors"
>
<Wifi className="w-4 h-4 mr-1.5 text-blue-400" />
{node.channelId}
</Link>
) : (
<span className="text-neutral-400 italic">None detected</span>
)}
</div>
</div>
<div className="mt-4 pt-3 border-t border-neutral-700">
<div className="flex items-center justify-between">
<span className="text-neutral-400">Packets:</span>
<div className="flex space-x-3">
<div className="flex flex-col items-center">
<span className="text-amber-500 font-medium text-lg">{node.messageCount}</span>
<span className="text-xs text-neutral-500">Total</span>
</div>
<div className="flex flex-col items-center">
<span className="text-green-500 font-medium text-lg">{node.textMessageCount}</span>
<span className="text-xs text-neutral-500">Text</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-between">
<span className="text-neutral-400">Gateway:</span>
<span className="text-neutral-200 font-mono text-xs truncate max-w-[220px]">{node.gatewayId || 'Unknown'}</span>
</div>
{/* Activity */}
<div className="bg-neutral-800/50 p-4 rounded-lg">
<h2 className="font-semibold mb-4 text-neutral-300 border-b border-neutral-700 pb-2">Last Activity</h2>
<div className="space-y-3">
<div className="flex items-center text-sm bg-neutral-700/50 p-2 rounded">
<Calendar className="w-4 h-4 mr-2 text-neutral-300" />
<span className="text-neutral-400">Date:</span>
<span className="ml-auto text-neutral-200">{lastHeardDay}</span>
</div>
<div className="flex justify-between">
<span className="text-neutral-400">Messages:</span>
<span className="text-neutral-200">{node.messageCount}</span>
<div className="flex items-center text-sm bg-neutral-700/50 p-2 rounded">
<Clock className="w-4 h-4 mr-2 text-neutral-300" />
<span className="text-neutral-400">Time:</span>
<span className="ml-auto text-neutral-200">{lastHeardTime}</span>
</div>
{node.deviceMetrics?.uptimeSeconds !== undefined && (
<div className="flex items-center mt-3 bg-neutral-700/50 p-2 rounded">
<Timer className="w-4 h-4 mr-2 text-neutral-300" />
<span className="text-neutral-400">Uptime:</span>
<span className="ml-auto text-neutral-200">
{formatUptime(node.deviceMetrics.uptimeSeconds)}
</span>
</div>
)}
<div className="mt-2 pt-3 border-t border-neutral-700">
<div className="flex justify-between">
<span className="text-neutral-400">Gateways:</span>
<div className="flex flex-col items-end">
{node.gatewayId ? (
<Link
to="/node/$nodeId"
params={{ nodeId: node.gatewayId.substring(1) }}
className="text-neutral-200 font-mono text-xs truncate max-w-[180px] hover:text-blue-400 transition-colors flex items-center"
>
{node.gatewayId}
<ChevronRight className="w-4 h-4 text-neutral-500 ml-1" />
</Link>
) : (
<span className="text-neutral-400 italic">None detected</span>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Activity */}
<div className="bg-neutral-800/50 p-4 rounded-lg">
<h2 className="font-semibold mb-3 text-neutral-300">Activity</h2>
<div className="space-y-2">
<div className="flex items-center mb-3">
<span className={`inline-block w-3 h-3 rounded-full mr-2 ${isActive ? 'bg-green-500' : 'bg-neutral-500'}`}></span>
<span className="text-neutral-200">
{isActive ? 'Active' : 'Inactive'} - last seen {lastSeenText}
</span>
</div>
<div className="flex items-center mb-1.5">
<Calendar className="w-4 h-4 mr-2 text-neutral-400" />
<span className="text-neutral-400">Date:</span>
<span className="ml-auto text-neutral-200">{lastHeardDay}</span>
</div>
<div className="flex items-center">
<Clock className="w-4 h-4 mr-2 text-neutral-400" />
<span className="text-neutral-400">Time:</span>
<span className="ml-auto text-neutral-200">{lastHeardTime}</span>
</div>
</div>
</div>
{/* Position Info */}
{node.position && (
<div className="bg-neutral-800/50 p-4 rounded-lg">
<h2 className="font-semibold mb-3 text-neutral-300 flex items-center">
<Map className="w-4 h-4 mr-2" />
Position
</h2>
<div className="space-y-2">
{node.position.latitudeI !== undefined && node.position.longitudeI !== undefined && (
<div className="flex justify-between">
{/* Position Map */}
{hasPosition && (
<div className="bg-neutral-800/50 p-4 rounded-lg mt-4">
<h2 className="font-semibold mb-3 text-neutral-300 flex items-center">
<Map className="w-4 h-4 mr-2" />
Node Location
</h2>
<div className="h-[350px] mt-3 rounded-lg overflow-hidden">
<GoogleMap lat={latitude} lng={longitude} zoom={14} />
</div>
<div className="grid grid-cols-2 gap-4 mt-3 text-sm">
<div className="bg-neutral-700/50 p-2 rounded">
<span className="text-neutral-400">Coordinates:</span>
<span className="text-neutral-200 font-mono">
{(node.position.latitudeI / 10000000).toFixed(6)}, {(node.position.longitudeI / 10000000).toFixed(6)}
<span className="float-right text-neutral-200 font-mono">
{latitude.toFixed(6)}, {longitude.toFixed(6)}
</span>
</div>
)}
{node.position.altitude !== undefined && (
<div className="flex justify-between">
<span className="text-neutral-400">Altitude:</span>
<span className="text-neutral-200">{node.position.altitude} m</span>
</div>
)}
{node.position.groundSpeed !== undefined && (
<div className="flex justify-between">
<span className="text-neutral-400">Speed:</span>
<span className="text-neutral-200">{node.position.groundSpeed} m/s</span>
</div>
)}
{node.position.satsInView !== undefined && (
<div className="flex justify-between">
<span className="text-neutral-400">Satellites:</span>
<span className="text-neutral-200">{node.position.satsInView}</span>
</div>
)}
{node.position.locationSource && (
<div className="flex justify-between">
<span className="text-neutral-400">Source:</span>
<span className="text-neutral-200">{node.position.locationSource.replace('LOC_', '')}</span>
</div>
)}
{node.position?.altitude !== undefined && (
<div className="bg-neutral-700/50 p-2 rounded">
<span className="text-neutral-400">Altitude:</span>
<span className="float-right text-neutral-200">{node.position.altitude} m</span>
</div>
)}
{node.position?.satsInView !== undefined && (
<div className="bg-neutral-700/50 p-2 rounded">
<span className="text-neutral-400">Satellites:</span>
<span className="float-right text-neutral-200">{node.position.satsInView}</span>
</div>
)}
{node.position?.groundSpeed !== undefined && (
<div className="bg-neutral-700/50 p-2 rounded">
<span className="text-neutral-400">Speed:</span>
<span className="float-right text-neutral-200">{node.position.groundSpeed} m/s</span>
</div>
)}
</div>
</div>
</div>
)}
)}
</div>
{/* Telemetry Info - Device Metrics */}
{node.deviceMetrics && (
<div className="bg-neutral-800/50 p-4 rounded-lg">
<h2 className="font-semibold mb-3 text-neutral-300 flex items-center">
<Cpu className="w-4 h-4 mr-2" />
Device Metrics
</h2>
<div className="space-y-2">
{node.batteryLevel !== undefined && (
<div className="flex justify-between items-center">
<span className="text-neutral-400 flex items-center">
<Battery className="w-4 h-4 mr-1.5" />
Battery:
</span>
<span className={`${node.batteryLevel > 30 ? 'text-green-500' : 'text-amber-500'}`}>
{node.batteryLevel}%
</span>
</div>
)}
{node.deviceMetrics.voltage !== undefined && (
<div className="flex justify-between">
<span className="text-neutral-400">Voltage:</span>
<span className="text-neutral-200">{node.deviceMetrics.voltage.toFixed(2)} V</span>
</div>
)}
{node.deviceMetrics.channelUtilization !== undefined && (
<div className="flex justify-between">
<span className="text-neutral-400">Channel Utilization:</span>
<span className="text-neutral-200">{node.deviceMetrics.channelUtilization}%</span>
</div>
)}
{node.deviceMetrics.airUtilTx !== undefined && (
<div className="flex justify-between">
<span className="text-neutral-400">Air Utilization:</span>
<span className="text-neutral-200">{node.deviceMetrics.airUtilTx}%</span>
</div>
)}
{node.snr !== undefined && (
<div className="flex justify-between items-center">
<span className="text-neutral-400 flex items-center">
<Signal className="w-4 h-4 mr-1.5" />
SNR:
</span>
<span className="text-neutral-200">{node.snr} dB</span>
</div>
)}
{node.deviceMetrics.uptimeSeconds !== undefined && (
<div className="flex justify-between">
<span className="text-neutral-400">Uptime:</span>
<span className="text-neutral-200">
{Math.floor(node.deviceMetrics.uptimeSeconds / 86400)}d {Math.floor((node.deviceMetrics.uptimeSeconds % 86400) / 3600)}h {Math.floor((node.deviceMetrics.uptimeSeconds % 3600) / 60)}m
</span>
</div>
)}
<div className="space-y-6">
{/* Telemetry Info - Device Metrics */}
{(node.deviceMetrics || node.batteryLevel !== undefined || node.snr !== undefined) && (
<div className="bg-neutral-800/50 p-4 rounded-lg">
<h2 className="font-semibold mb-4 text-neutral-300 border-b border-neutral-700 pb-2 flex items-center">
<Cpu className="w-4 h-4 mr-2" />
Device Status
</h2>
<div className="space-y-4">
{node.batteryLevel !== undefined && (
<BatteryLevel level={node.batteryLevel} />
)}
{node.deviceMetrics?.voltage !== undefined && (
<div className="flex justify-between items-center mt-2">
<span className="text-neutral-400 flex items-center">
<Zap className="w-4 h-4 mr-1.5" />
Voltage:
</span>
<span className="text-neutral-200">{node.deviceMetrics.voltage.toFixed(2)} V</span>
</div>
)}
{node.snr !== undefined && (
<SignalStrength snr={node.snr} />
)}
{(node.deviceMetrics?.channelUtilization !== undefined || node.deviceMetrics?.airUtilTx !== undefined) && (
<div className="mt-3 pt-3 border-t border-neutral-700">
<div className="text-sm text-neutral-400 mb-2 flex items-center">
<BarChart className="w-4 h-4 mr-1.5" />
Channel Utilization:
</div>
{node.deviceMetrics?.channelUtilization !== undefined && (
<div className="flex flex-col mb-2">
<div className="flex justify-between text-xs mb-1">
<span>Total</span>
<span>{node.deviceMetrics.channelUtilization}%</span>
</div>
<div className="w-full bg-neutral-700 rounded-full h-1.5">
<div
className={`bg-blue-500 h-1.5 rounded-full`}
style={{width: `${node.deviceMetrics.channelUtilization}%`}}
></div>
</div>
</div>
)}
{node.deviceMetrics?.airUtilTx !== undefined && (
<div className="flex flex-col">
<div className="flex justify-between text-xs mb-1">
<span>Transmit</span>
<span>{node.deviceMetrics.airUtilTx}%</span>
</div>
<div className="w-full bg-neutral-700 rounded-full h-1.5">
<div
className={`bg-green-500 h-1.5 rounded-full`}
style={{width: `${node.deviceMetrics.airUtilTx}%`}}
></div>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
)}
{/* Telemetry Info - Environment Metrics */}
{node.environmentMetrics && Object.keys(node.environmentMetrics).length > 0 && (
<div className="bg-neutral-800/50 p-4 rounded-lg">
<h2 className="font-semibold mb-3 text-neutral-300 flex items-center">
<Thermometer className="w-4 h-4 mr-2" />
Environment Metrics
</h2>
<div className="space-y-2">
{node.environmentMetrics.temperature !== undefined && (
<div className="flex justify-between">
<span className="text-neutral-400">Temperature:</span>
<span className="text-neutral-200">{node.environmentMetrics.temperature}°C</span>
</div>
)}
{node.environmentMetrics.relativeHumidity !== undefined && (
<div className="flex justify-between items-center">
<span className="text-neutral-400 flex items-center">
<Droplets className="w-4 h-4 mr-1.5" />
Humidity:
</span>
<span className="text-neutral-200">{node.environmentMetrics.relativeHumidity}%</span>
</div>
)}
{node.environmentMetrics.barometricPressure !== undefined && (
<div className="flex justify-between items-center">
<span className="text-neutral-400 flex items-center">
<Gauge className="w-4 h-4 mr-1.5" />
Pressure:
</span>
<span className="text-neutral-200">{node.environmentMetrics.barometricPressure} hPa</span>
</div>
)}
{/* Add other environment metrics here as needed */}
{/* Telemetry Info - Environment Metrics */}
{node.environmentMetrics && Object.keys(node.environmentMetrics).length > 0 && (
<div className="bg-neutral-800/50 p-4 rounded-lg">
<h2 className="font-semibold mb-4 text-neutral-300 border-b border-neutral-700 pb-2 flex items-center">
<Thermometer className="w-4 h-4 mr-2" />
Environment Data
</h2>
<div className="space-y-4">
{node.environmentMetrics.temperature !== undefined && (
<div className="flex flex-col">
<div className="flex justify-between mb-1">
<span className="text-neutral-400">Temperature:</span>
<span className={`
${node.environmentMetrics.temperature > 30 ? 'text-red-500' :
node.environmentMetrics.temperature < 10 ? 'text-blue-500' :
'text-green-500'} font-medium
`}>
{node.environmentMetrics.temperature}°C
</span>
</div>
<div className="w-full bg-neutral-700 rounded-full h-2">
{/* Temp scale: -10°C to 40°C mapped to 0-100% */}
<div
className={`
${node.environmentMetrics.temperature > 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))}%`
}}
></div>
</div>
</div>
)}
{node.environmentMetrics.relativeHumidity !== undefined && (
<div className="flex flex-col mt-3">
<div className="flex justify-between mb-1">
<span className="text-neutral-400 flex items-center">
<Droplets className="w-4 h-4 mr-1.5" />
Humidity:
</span>
<span className="text-blue-400 font-medium">
{node.environmentMetrics.relativeHumidity}%
</span>
</div>
<div className="w-full bg-neutral-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full"
style={{width: `${node.environmentMetrics.relativeHumidity}%`}}
></div>
</div>
</div>
)}
{node.environmentMetrics.barometricPressure !== undefined && (
<div className="flex justify-between items-center mt-3">
<span className="text-neutral-400 flex items-center">
<Gauge className="w-4 h-4 mr-1.5" />
Pressure:
</span>
<span className="text-neutral-200">
{node.environmentMetrics.barometricPressure} hPa
</span>
</div>
)}
{node.environmentMetrics.soilMoisture !== undefined && (
<div className="flex flex-col mt-3">
<div className="flex justify-between mb-1">
<span className="text-neutral-400">Soil Moisture:</span>
<span className="text-green-400 font-medium">
{node.environmentMetrics.soilMoisture}%
</span>
</div>
<div className="w-full bg-neutral-700 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full"
style={{width: `${node.environmentMetrics.soilMoisture}%`}}
></div>
</div>
</div>
)}
</div>
</div>
</div>
)}
)}
{/* Warning for low battery */}
{node.batteryLevel !== undefined && node.batteryLevel < 20 && (
<div className="bg-red-900/30 border border-red-800 p-4 rounded-lg">
<div className="flex items-center text-red-400">
<AlertTriangle className="w-5 h-5 mr-2" />
<h3 className="font-medium">Low Battery Warning</h3>
</div>
<p className="text-sm mt-2 text-neutral-300">
This node's battery level is critically low at {node.batteryLevel}%.
The device may stop reporting soon.
</p>
</div>
)}
</div>
</div>
</div>
);
+7 -6
View File
@@ -17,10 +17,10 @@ export interface ApiResponse<T> {
}
// 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,
});
}
}
}