diff --git a/web/src/components/dashboard/BatteryLevel.tsx b/web/src/components/dashboard/BatteryLevel.tsx new file mode 100644 index 0000000..29f054e --- /dev/null +++ b/web/src/components/dashboard/BatteryLevel.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { BatteryFull, BatteryMedium, BatteryLow } from "lucide-react"; + +interface BatteryLevelProps { + /** Battery level percentage (0-100) */ + level: number; +} + +/** + * Battery level component with visual indicator + */ +export const BatteryLevel: React.FC = ({ 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-mono text-sm`} + > + {level}% + +
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/dashboard/EnvironmentMetrics.tsx b/web/src/components/dashboard/EnvironmentMetrics.tsx new file mode 100644 index 0000000..cecf59c --- /dev/null +++ b/web/src/components/dashboard/EnvironmentMetrics.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import { Thermometer, Droplets, Gauge } from "lucide-react"; +import { KeyValuePair } from "../ui/KeyValuePair"; + +interface EnvironmentMetricsProps { + /** Temperature in Celsius (optional) */ + temperature?: number; + /** Relative humidity percentage (optional) */ + relativeHumidity?: number; + /** Barometric pressure in hPa (optional) */ + barometricPressure?: number; + /** Soil moisture percentage (optional) */ + soilMoisture?: number; +} + +/** + * Component to display environmental metrics + */ +export const EnvironmentMetrics: React.FC = ({ + temperature, + relativeHumidity, + barometricPressure, + soilMoisture +}) => { + return ( +
+ {temperature !== undefined && ( +
+
+ + + Temperature + + 30 + ? "text-red-500" + : temperature < 10 + ? "text-blue-500" + : "text-green-500" + } font-mono text-sm + `} + > + {temperature}°C + +
+
+ {/* Temp scale: -10°C to 40°C mapped to 0-100% */} +
30 + ? "bg-red-500" + : temperature < 10 + ? "bg-blue-500" + : "bg-green-500" + } h-2 rounded-full + `} + style={{ + width: `${Math.max(0, Math.min(100, ((temperature + 10) / 50) * 100))}%`, + }} + >
+
+
+ )} + + {relativeHumidity !== undefined && ( +
+
+ + + Humidity + + + {relativeHumidity}% + +
+
+
+
+
+ )} + + {barometricPressure !== undefined && ( + } + monospace={true} + inset={true} + /> + )} + + {soilMoisture !== undefined && ( +
+
+ + + Soil Moisture + + + {soilMoisture}% + +
+
+
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/web/src/components/dashboard/GoogleMap.tsx b/web/src/components/dashboard/GoogleMap.tsx new file mode 100644 index 0000000..2a8da2a --- /dev/null +++ b/web/src/components/dashboard/GoogleMap.tsx @@ -0,0 +1,130 @@ +import React, { useRef, useEffect } from "react"; +import { calculateAccuracyFromPrecisionBits, calculateZoomFromAccuracy } from "../../lib/mapUtils"; + +interface GoogleMapProps { + /** Latitude coordinate */ + lat: number; + /** Longitude coordinate */ + lng: number; + /** Optional zoom level (1-20) */ + zoom?: number; + /** Precision bits used for accuracy calculation */ + precisionBits?: number; +} + +/** + * Google Maps component that uses the API loaded via script tag + */ +export const GoogleMap: React.FC = ({ + lat, + lng, + zoom, + precisionBits, +}) => { + const mapRef = useRef(null); + const mapInstanceRef = useRef(null); + const markerRef = useRef(null); + + // Calculate accuracy in meters based on precision bits + const accuracyMeters = calculateAccuracyFromPrecisionBits(precisionBits); + + // If zoom is not provided, calculate based on accuracy + const effectiveZoom = zoom || calculateZoomFromAccuracy(accuracyMeters); + + useEffect(() => { + if (mapRef.current && window.google && window.google.maps) { + // Create map instance + const mapOptions: google.maps.MapOptions = { + center: { lat, lng }, + zoom: effectiveZoom, + 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); + + // Only add the center marker if we don't have precision information or + // it's very accurate. + if (precisionBits === undefined || accuracyMeters < 100) { + 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, + }, + }); + } + + // Circle will always be shown, using default 300m accuracy if no + // precision bits. + new google.maps.Circle({ + strokeColor: "#22c55e", + strokeOpacity: 0.8, + strokeWeight: 2.5, + fillColor: "#4ade80", + fillOpacity: 0.4, + map: mapInstanceRef.current, + center: { lat, lng }, + radius: accuracyMeters, + }); + } + }, [lat, lng, effectiveZoom, accuracyMeters, precisionBits]); + + return ( +
+ ); +}; diff --git a/web/src/components/dashboard/LowBatteryWarning.tsx b/web/src/components/dashboard/LowBatteryWarning.tsx new file mode 100644 index 0000000..ad34ead --- /dev/null +++ b/web/src/components/dashboard/LowBatteryWarning.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { AlertTriangle, BatteryLow } from "lucide-react"; + +interface LowBatteryWarningProps { + /** Battery level percentage (0-100) */ + batteryLevel: number; +} + +/** + * Warning component for low battery level + */ +export const LowBatteryWarning: React.FC = ({ + batteryLevel +}) => { + if (batteryLevel >= 20) { + return null; + } + + return ( +
+
+ +

+ LOW BATTERY WARNING +

+
+

+ + Battery level critically low at{" "} + + {batteryLevel}% + + Device may stop reporting soon. +

+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/dashboard/MeshCard.tsx b/web/src/components/dashboard/MeshCard.tsx index 520205e..11f0679 100644 --- a/web/src/components/dashboard/MeshCard.tsx +++ b/web/src/components/dashboard/MeshCard.tsx @@ -7,7 +7,6 @@ export interface MeshCardProps { type: "node" | "gateway"; nodeId: number; nodeData: NodeData; - gatewayId?: string; observedNodes?: number[]; onClick?: (nodeId: number) => void; isActive?: boolean; @@ -19,7 +18,6 @@ export const MeshCard: React.FC = ({ type, nodeId, nodeData, - gatewayId, observedNodes = [], onClick, isActive = false, diff --git a/web/src/components/dashboard/NodeDetail.tsx b/web/src/components/dashboard/NodeDetail.tsx index 583b1d3..1dc088b 100644 --- a/web/src/components/dashboard/NodeDetail.tsx +++ b/web/src/components/dashboard/NodeDetail.tsx @@ -1,421 +1,42 @@ -import React, { useEffect, useRef, useCallback } from "react"; +import React, { useEffect } from "react"; import { useNavigate, Link } from "@tanstack/react-router"; import { useAppSelector, useAppDispatch } from "../../hooks"; import { selectNode } from "../../store/slices/aggregatorSlice"; -import { RegionCode, ModemPreset, Packet } from "../../lib/types"; -import { KeyValuePair } from "../ui/KeyValuePair"; -import { Separator } from "../Separator"; -import { PacketRenderer } from "../packets/PacketRenderer"; -import { +import { ArrowLeft, Radio, Cpu, - Thermometer, - Gauge, - Signal, - Droplets, - Map, - Calendar, Clock, - Wifi, - BarChart, - BatteryFull, - BatteryMedium, - BatteryLow, - AlertTriangle, + Calendar, + Map, Zap, - Timer, ChevronRight, + Signal, + Wifi, Users, Earth, TableConfig, Save, - MessageSquare, + MessageSquare } from "lucide-react"; +import { Separator } from "../Separator"; +import { KeyValuePair } from "../ui/KeyValuePair"; +import { Section } from "../ui/Section"; +import { BatteryLevel } from "./BatteryLevel"; +import { SignalStrength } from "./SignalStrength"; +import { GoogleMap } from "./GoogleMap"; +import { NodePositionData } from "./NodePositionData"; +import { EnvironmentMetrics } from "./EnvironmentMetrics"; +import { NodePacketList } from "./NodePacketList"; +import { LowBatteryWarning } from "./LowBatteryWarning"; +import { UtilizationMetrics } from "./UtilizationMetrics"; +import { calculateAccuracyFromPrecisionBits } from "../../lib/mapUtils"; +import { formatUptime, getRegionName, getModemPresetName } from "../../utils/formatters"; interface NodeDetailProps { nodeId: number; } -// Function to calculate the position accuracy in meters using precision bits -const calculateAccuracyFromPrecisionBits = (precisionBits?: number): number => { - if (!precisionBits) return 300; // Default accuracy of 300m - - // Each precision bit halves the accuracy radius - // Starting with Earth's circumference (~40075km), calculate the precision - // For reference: 24 bits = ~2.4m accuracy, 21 bits = ~19m accuracy - const earthCircumference = 40075000; // in meters - const accuracy = earthCircumference / 2 ** precisionBits / 2; - - // Limit to reasonable values - return Math.max(1, Math.min(accuracy, 10000)); -}; - -// Calculate appropriate zoom level based on accuracy -const calculateZoomFromAccuracy = (accuracyMeters: number): number => { - // Roughly map accuracy to zoom level (higher accuracy = higher zoom) - // < 10m: zoom 18 - // < 50m: zoom 16 - // < 100m: zoom 15 - // < 500m: zoom 14 - // < 1km: zoom 13 - // < 5km: zoom 11 - // >= 5km: zoom 10 - if (accuracyMeters < 10) return 18; - if (accuracyMeters < 50) return 16; - if (accuracyMeters < 100) return 15; - if (accuracyMeters < 500) return 14; - if (accuracyMeters < 1000) return 13; - if (accuracyMeters < 5000) return 11; - return 10; -}; - -// Google Maps component that uses the API loaded via script tag -const GoogleMap: React.FC<{ - lat: number; - lng: number; - zoom?: number; - precisionBits?: number; -}> = ({ lat, lng, zoom, precisionBits }) => { - const mapRef = useRef(null); - const mapInstanceRef = useRef(null); - const markerRef = useRef(null); - - // Calculate accuracy in meters based on precision bits - const accuracyMeters = calculateAccuracyFromPrecisionBits(precisionBits); - - // If zoom is not provided, calculate based on accuracy - const effectiveZoom = zoom || calculateZoomFromAccuracy(accuracyMeters); - - useEffect(() => { - if (mapRef.current && window.google && window.google.maps) { - // Create map instance - const mapOptions: google.maps.MapOptions = { - center: { lat, lng }, - zoom: effectiveZoom, - 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); - - // Only add the center marker if we don't have precision information or - // it's very accurate. - if (precisionBits === undefined || accuracyMeters < 100) { - 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, - }, - }); - } - - // Circle will always be shown, using default 300m accuracy if no - // precision bits. - new google.maps.Circle({ - strokeColor: "#22c55e", - strokeOpacity: 0.8, - strokeWeight: 2.5, - fillColor: "#4ade80", - fillOpacity: 0.4, - map: mapInstanceRef.current, - center: { lat, lng }, - radius: accuracyMeters, - }); - } - }, [lat, lng, effectiveZoom, accuracyMeters]); - - 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-mono text-sm`} - > - {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"; - let textColor = "text-red-500"; - - if (snr > 10) { - strengthClass = "bg-green-500"; - strengthText = "Excellent"; - textColor = "text-green-500"; - } else if (snr > 5) { - strengthClass = "bg-green-400"; - strengthText = "Good"; - textColor = "text-green-400"; - } else if (snr > 0) { - strengthClass = "bg-amber-500"; - strengthText = "Fair"; - textColor = "text-amber-500"; - } - - // 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}) - - -
-
-
-
-
- ); -}; - -// Section component for consistent section styling -const Section: React.FC<{ - title: string; - icon?: React.ReactNode; - children: React.ReactNode; - className?: string; -}> = ({ title, icon, children, className = "" }) => { - return ( -
-

- {icon && {icon}} - {title} -

-
{children}
-
- ); -}; - -// Use the new centralized KeyValuePair component from ui folder - -// 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"; -}; - -// Helper function to get region name -const getRegionName = (region: RegionCode | string | undefined): string => { - if (region === undefined) return "Unknown"; - - // Map of region codes to display names - const regionNames: Record = { - UNSET: "Unset", - US: "US", - EU_433: "EU 433MHz", - EU_868: "EU 868MHz", - CN: "China", - JP: "Japan", - ANZ: "Australia/NZ", - KR: "Korea", - TW: "Taiwan", - RU: "Russia", - IN: "India", - NZ_865: "New Zealand 865MHz", - TH: "Thailand", - LORA_24: "LoRa 2.4GHz", - UA_433: "Ukraine 433MHz", - UA_868: "Ukraine 868MHz", - MY_433: "Malaysia 433MHz", - }; - - // Get the name from the map, or return unknown with the value - return regionNames[region] || `Unknown (${region})`; -}; - -// Helper function to get modem preset name -const getModemPresetName = ( - preset: ModemPreset | string | undefined -): string => { - if (preset === undefined) return "Unknown"; - - // Map of modem presets to display names - const presetNames: Record = { - UNSET: "Unset", - LONG_FAST: "Long Fast", - LONG_SLOW: "Long Slow", - VERY_LONG_SLOW: "Very Long Slow", - MEDIUM_SLOW: "Medium Slow", - MEDIUM_FAST: "Medium Fast", - SHORT_SLOW: "Short Slow", - SHORT_FAST: "Short Fast", - ULTRA_FAST: "Ultra Fast", - }; - - // Get the name from the map, or return unknown with the value - return presetNames[preset] || `Unknown (${preset})`; -}; - -// Component to render packets associated with a specific node -const NodePacketList: React.FC<{ nodeId: number }> = ({ nodeId }) => { - const { packets } = useAppSelector((state) => state.packets); - // Fixed number of packets to display - const MAX_PACKETS = 20; - - // Get packets from this node (sent or received) - const nodePackets = packets - .filter( - (packet) => packet.data.from === nodeId || packet.data.to === nodeId - ) - .slice(0, MAX_PACKETS); // Show fixed number of packets - - // Generate a reproducible packet key - const getPacketKey = useCallback((packet: Packet, index: number): string => { - if (packet.data.id !== undefined && packet.data.from !== undefined) { - const fromId = `!${packet.data.from.toString(16).toLowerCase()}`; - return `${fromId}_${packet.data.id}`; - } - return `fallback_${index}`; - }, []); - - if (nodePackets.length === 0) { - return ( -
- No packets found for this node -
- ); - } - - return ( -
-
-

- Showing {nodePackets.length} of{" "} - { - packets.filter( - (p) => p.data.from === nodeId || p.data.to === nodeId - ).length - }{" "} - recent packets -

-
- - - -
    - {nodePackets.map((packet, index) => ( -
  • - -
  • - ))} -
-
- ); -}; - export const NodeDetail: React.FC = ({ nodeId }) => { const dispatch = useAppDispatch(); const navigate = useNavigate(); @@ -445,10 +66,6 @@ export const NodeDetail: React.FC = ({ nodeId }) => { // Add observed nodes info observedNodeCount: gateway.observedNodes.length, }; - - // Look for packets from this node that might have MapReport data - // No direct access to packets store here, so we'll rely on - // the data being properly populated in the aggregatorSlice } } @@ -714,73 +331,18 @@ export const NodeDetail: React.FC = ({ nodeId }) => { {(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}% - -
-
-
-
-
- )} -
+ )}
)} {/* Warning for low battery */} - {node.batteryLevel !== undefined && node.batteryLevel < 20 && ( -
-
- -

- LOW BATTERY WARNING -

-
-

- - Battery level critically low at{" "} - - {node.batteryLevel}% - - Device may stop reporting soon. -

-
+ {node.batteryLevel !== undefined && ( + )}
@@ -808,7 +370,7 @@ export const NodeDetail: React.FC = ({ nodeId }) => { } + icon={} monospace={true} highlight={node.deviceMetrics.uptimeSeconds > 86400} inset={true} @@ -867,7 +429,7 @@ export const NodeDetail: React.FC = ({ nodeId }) => { - {/* Telemetry Info - Environment Metrics */} + {/* Environment Metrics */} {node.environmentMetrics && Object.keys(node.environmentMetrics).length > 0 && (
= ({ nodeId }) => { icon={} className="mt-4" > -
- {node.environmentMetrics.temperature !== undefined && ( -
-
- - - Temperature - - 30 - ? "text-red-500" - : node.environmentMetrics.temperature < 10 - ? "text-blue-500" - : "text-green-500" - } font-mono text-sm - `} - > - {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 && ( - } - monospace={true} - inset={true} - /> - )} - - {node.environmentMetrics.soilMoisture !== undefined && ( -
-
- - - Soil Moisture - - - {node.environmentMetrics.soilMoisture}% - -
-
-
-
-
- )} -
+
)} @@ -990,63 +462,15 @@ export const NodeDetail: React.FC = ({ nodeId }) => { precisionBits={precisionBits} /> -
- - - {node.position?.altitude !== undefined && ( - - )} - - {/* Position Accuracy */} - - - {precisionBits !== undefined && ( - - )} - - {node.position?.satsInView !== undefined && ( - 6} - inset={true} - /> - )} - - {node.position?.groundSpeed !== undefined && ( - - )} -
+ )} @@ -1060,4 +484,4 @@ export const NodeDetail: React.FC = ({ nodeId }) => { ); -}; +}; \ No newline at end of file diff --git a/web/src/components/dashboard/NodePacketList.tsx b/web/src/components/dashboard/NodePacketList.tsx new file mode 100644 index 0000000..d540519 --- /dev/null +++ b/web/src/components/dashboard/NodePacketList.tsx @@ -0,0 +1,69 @@ +import React, { useCallback } from "react"; +import { useAppSelector } from "../../hooks"; +import { PacketRenderer } from "../packets/PacketRenderer"; +import { Packet } from "../../lib/types"; +import { Separator } from "../Separator"; + +interface NodePacketListProps { + /** Node ID to filter packets by */ + nodeId: number; +} + +/** + * Component to render packets associated with a specific node + */ +export const NodePacketList: React.FC = ({ nodeId }) => { + const { packets } = useAppSelector((state) => state.packets); + // Fixed number of packets to display + const MAX_PACKETS = 20; + + // Get packets from this node (sent or received) + const nodePackets = packets + .filter( + (packet) => packet.data.from === nodeId || packet.data.to === nodeId + ) + .slice(0, MAX_PACKETS); // Show fixed number of packets + + // Generate a reproducible packet key + const getPacketKey = useCallback((packet: Packet, index: number): string => { + if (packet.data.id !== undefined && packet.data.from !== undefined) { + const fromId = `!${packet.data.from.toString(16).toLowerCase()}`; + return `${fromId}_${packet.data.id}`; + } + return `fallback_${index}`; + }, []); + + if (nodePackets.length === 0) { + return ( +
+ No packets found for this node +
+ ); + } + + return ( +
+
+

+ Showing {nodePackets.length} of{" "} + { + packets.filter( + (p) => p.data.from === nodeId || p.data.to === nodeId + ).length + }{" "} + recent packets +

+
+ + + +
    + {nodePackets.map((packet, index) => ( +
  • + +
  • + ))} +
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/dashboard/NodePositionData.tsx b/web/src/components/dashboard/NodePositionData.tsx new file mode 100644 index 0000000..7d8fd97 --- /dev/null +++ b/web/src/components/dashboard/NodePositionData.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { KeyValuePair } from "../ui/KeyValuePair"; + +interface NodePositionDataProps { + /** Latitude in decimal degrees */ + latitude: number; + /** Longitude in decimal degrees */ + longitude: number; + /** Altitude in meters (optional) */ + altitude?: number; + /** Position accuracy in meters */ + positionAccuracy: number; + /** Precision bits used for position accuracy calculation (optional) */ + precisionBits?: number; + /** Number of satellites in view (optional) */ + satsInView?: number; + /** Ground speed in m/s (optional) */ + groundSpeed?: number; +} + +/** + * Component to display node position metadata + */ +export const NodePositionData: React.FC = ({ + latitude, + longitude, + altitude, + positionAccuracy, + precisionBits, + satsInView, + groundSpeed +}) => { + return ( +
+ + + {altitude !== undefined && ( + + )} + + + + {precisionBits !== undefined && ( + + )} + + {satsInView !== undefined && ( + 6} + inset={true} + /> + )} + + {groundSpeed !== undefined && ( + + )} +
+ ); +}; \ No newline at end of file diff --git a/web/src/components/dashboard/SignalStrength.tsx b/web/src/components/dashboard/SignalStrength.tsx new file mode 100644 index 0000000..b3e6c5d --- /dev/null +++ b/web/src/components/dashboard/SignalStrength.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { Signal } from "lucide-react"; + +interface SignalStrengthProps { + /** Signal-to-noise ratio (SNR) in dB */ + snr: number; +} + +/** + * Signal strength component with visual indicator + * SNR is typically in dB, with values from -20 to +20 + * Higher is better: < 0 is poor, > 10 is excellent + */ +export const SignalStrength: React.FC = ({ snr }) => { + let strengthClass = "bg-red-500"; + let strengthText = "Poor"; + let textColor = "text-red-500"; + + if (snr > 10) { + strengthClass = "bg-green-500"; + strengthText = "Excellent"; + textColor = "text-green-500"; + } else if (snr > 5) { + strengthClass = "bg-green-400"; + strengthText = "Good"; + textColor = "text-green-400"; + } else if (snr > 0) { + strengthClass = "bg-amber-500"; + strengthText = "Fair"; + textColor = "text-amber-500"; + } + + // 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}) + + +
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/dashboard/UtilizationMetrics.tsx b/web/src/components/dashboard/UtilizationMetrics.tsx new file mode 100644 index 0000000..73ba1ef --- /dev/null +++ b/web/src/components/dashboard/UtilizationMetrics.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { BarChart } from "lucide-react"; + +interface UtilizationMetricsProps { + /** Channel utilization percentage (optional) */ + channelUtilization?: number; + /** Air utilization for transmission percentage (optional) */ + airUtilTx?: number; +} + +/** + * Component to display channel utilization metrics + */ +export const UtilizationMetrics: React.FC = ({ + channelUtilization, + airUtilTx +}) => { + if (channelUtilization === undefined && airUtilTx === undefined) { + return null; + } + + return ( +
+
+ + Channel Utilization: +
+ + {channelUtilization !== undefined && ( +
+
+ Total + + {channelUtilization}% + +
+
+
+
+
+ )} + + {airUtilTx !== undefined && ( +
+
+ Transmit + + {airUtilTx}% + +
+
+
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/web/src/components/dashboard/index.ts b/web/src/components/dashboard/index.ts index fb548c9..7ace4ba 100644 --- a/web/src/components/dashboard/index.ts +++ b/web/src/components/dashboard/index.ts @@ -2,3 +2,11 @@ export * from './NodeList'; export * from './GatewayList'; export * from './MeshCard'; export * from './NodeDetail'; +export * from './BatteryLevel'; +export * from './SignalStrength'; +export * from './GoogleMap'; +export * from './NodePacketList'; +export * from './NodePositionData'; +export * from './EnvironmentMetrics'; +export * from './LowBatteryWarning'; +export * from './UtilizationMetrics'; diff --git a/web/src/components/ui/Section.tsx b/web/src/components/ui/Section.tsx new file mode 100644 index 0000000..77c6419 --- /dev/null +++ b/web/src/components/ui/Section.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { cn } from "../../lib/cn"; + +interface SectionProps { + /** Section title */ + title: string; + /** Optional icon to display next to the title */ + icon?: React.ReactNode; + /** Section content */ + children: React.ReactNode; + /** Additional class names */ + className?: string; +} + +/** + * Section component for consistent section styling + */ +export const Section: React.FC = ({ + title, + icon, + children, + className = "" +}) => { + return ( +
+

+ {icon && {icon}} + {title} +

+
{children}
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/ui/index.ts b/web/src/components/ui/index.ts new file mode 100644 index 0000000..7a0eecb --- /dev/null +++ b/web/src/components/ui/index.ts @@ -0,0 +1,3 @@ +export { Button } from './Button'; +export { Section } from './Section'; +export { KeyValuePair } from './KeyValuePair'; \ No newline at end of file diff --git a/web/src/lib/mapUtils.ts b/web/src/lib/mapUtils.ts index d0688b9..71f5f1b 100644 --- a/web/src/lib/mapUtils.ts +++ b/web/src/lib/mapUtils.ts @@ -2,6 +2,49 @@ * Utility functions for working with maps and coordinates */ +/** + * Calculate the position accuracy in meters using precision bits + * @param precisionBits Number of precision bits used in position encoding + * @returns Accuracy radius in meters + */ +export const calculateAccuracyFromPrecisionBits = ( + precisionBits?: number +): number => { + if (!precisionBits) return 300; // Default accuracy of 300m + + // Each precision bit halves the accuracy radius + // Starting with Earth's circumference (~40075km), calculate the precision + // For reference: 24 bits = ~2.4m accuracy, 21 bits = ~19m accuracy + const earthCircumference = 40075000; // in meters + const accuracy = earthCircumference / 2 ** precisionBits / 2; + + // Limit to reasonable values + return Math.max(1, Math.min(accuracy, 10000)); +}; + +/** + * Calculate appropriate zoom level based on accuracy + * @param accuracyMeters Accuracy in meters + * @returns Zoom level (1-20) + */ +export const calculateZoomFromAccuracy = (accuracyMeters: number): number => { + // Roughly map accuracy to zoom level (higher accuracy = higher zoom) + // < 10m: zoom 18 + // < 50m: zoom 16 + // < 100m: zoom 15 + // < 500m: zoom 14 + // < 1km: zoom 13 + // < 5km: zoom 11 + // >= 5km: zoom 10 + if (accuracyMeters < 10) return 18; + if (accuracyMeters < 50) return 16; + if (accuracyMeters < 100) return 15; + if (accuracyMeters < 500) return 14; + if (accuracyMeters < 1000) return 13; + if (accuracyMeters < 5000) return 11; + return 10; +}; + /** * Generate a Google Maps Static API URL for a given latitude and longitude * @param latitude The latitude in decimal degrees @@ -11,7 +54,6 @@ * @param height The image height in pixels * @param nightMode Whether to use dark styling for the map * @param precisionBits Optional precision bits to determine how to display the marker - * @param accuracyMeters Optional accuracy in meters (used when precisionBits is provided) * @returns A URL string for the Google Maps Static API */ export const getStaticMapUrl = ( @@ -21,8 +63,7 @@ export const getStaticMapUrl = ( width: number = 300, height: number = 200, nightMode: boolean = true, - precisionBits?: number, - accuracyMeters?: number + precisionBits?: number ): string => { // Get API key from environment variable const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || ""; @@ -40,12 +81,18 @@ export const getStaticMapUrl = ( // Only add marker if we don't have precision information if (precisionBits === undefined) { - mapUrl.searchParams.append("markers", `color:green|${latitude},${longitude}`); - } + mapUrl.searchParams.append( + "markers", + `color:green|${latitude},${longitude}` + ); + } // With static maps we can't draw circles directly, so we use a marker with different color // even when we have precision information, but we'll show it differently in the interactive map else { - mapUrl.searchParams.append("markers", `color:green|${latitude},${longitude}`); + mapUrl.searchParams.append( + "markers", + `color:green|${latitude},${longitude}` + ); } // Apply night mode styling using the simpler approach diff --git a/web/src/routes/channel.$channelId.tsx b/web/src/routes/channel.$channelId.tsx index 83eab7b..feab8a6 100644 --- a/web/src/routes/channel.$channelId.tsx +++ b/web/src/routes/channel.$channelId.tsx @@ -60,7 +60,14 @@ function ChannelPage() { console.log(`[Channel] Available messages:`, messages); console.log(`[Channel] Looking for key:`, channelKey); } - }, [channel, channelId, channelMessages.length, navigate, messages]); + }, [ + channel, + channelId, + channelKey, + channelMessages.length, + navigate, + messages, + ]); if (!channel) { return ( diff --git a/web/src/utils/formatters.ts b/web/src/utils/formatters.ts new file mode 100644 index 0000000..fe33c14 --- /dev/null +++ b/web/src/utils/formatters.ts @@ -0,0 +1,73 @@ +import { RegionCode, ModemPreset } from "../lib/types"; + +/** + * Format uptime into a human-readable string + */ +export 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"; +}; + +/** + * Get region name from region code + */ +export const getRegionName = (region: RegionCode | string | undefined): string => { + if (region === undefined) return "Unknown"; + + // Map of region codes to display names + const regionNames: Record = { + UNSET: "Unset", + US: "US", + EU_433: "EU 433MHz", + EU_868: "EU 868MHz", + CN: "China", + JP: "Japan", + ANZ: "Australia/NZ", + KR: "Korea", + TW: "Taiwan", + RU: "Russia", + IN: "India", + NZ_865: "New Zealand 865MHz", + TH: "Thailand", + LORA_24: "LoRa 2.4GHz", + UA_433: "Ukraine 433MHz", + UA_868: "Ukraine 868MHz", + MY_433: "Malaysia 433MHz", + }; + + // Get the name from the map, or return unknown with the value + return regionNames[region] || `Unknown (${region})`; +}; + +/** + * Get modem preset name from preset code + */ +export const getModemPresetName = ( + preset: ModemPreset | string | undefined +): string => { + if (preset === undefined) return "Unknown"; + + // Map of modem presets to display names + const presetNames: Record = { + UNSET: "Unset", + LONG_FAST: "Long Fast", + LONG_SLOW: "Long Slow", + VERY_LONG_SLOW: "Very Long Slow", + MEDIUM_SLOW: "Medium Slow", + MEDIUM_FAST: "Medium Fast", + SHORT_SLOW: "Short Slow", + SHORT_FAST: "Short Fast", + ULTRA_FAST: "Ultra Fast", + }; + + // Get the name from the map, or return unknown with the value + return presetNames[preset] || `Unknown (${preset})`; +}; \ No newline at end of file