Break up NodeDetail into sub components

This commit is contained in:
Daniel Pupius
2025-04-25 17:56:25 -07:00
parent ca231c81bd
commit 6c891a1b88
16 changed files with 849 additions and 629 deletions
@@ -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<BatteryLevelProps> = ({ level }) => {
let color = "bg-green-500";
let icon = <BatteryFull className="w-4 h-4" />;
if (level <= 20) {
color = "bg-red-500";
icon = <BatteryLow className="w-4 h-4" />;
} else if (level <= 50) {
color = "bg-amber-500";
icon = <BatteryMedium className="w-4 h-4" />;
}
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-mono text-sm`}
>
{level}%
</span>
</div>
<div className="w-full bg-neutral-700/70 rounded-full h-2 effect-inset">
<div
className={`${color} h-2 rounded-full`}
style={{ width: `${level}%` }}
></div>
</div>
</div>
);
};
@@ -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<EnvironmentMetricsProps> = ({
temperature,
relativeHumidity,
barometricPressure,
soilMoisture
}) => {
return (
<div className="space-y-4">
{temperature !== undefined && (
<div className="flex flex-col">
<div className="flex justify-between mb-1 bg-neutral-700/50 p-2 rounded effect-inset">
<span className="text-neutral-400 flex items-center">
<Thermometer className="w-3 h-3 mr-2" />
Temperature
</span>
<span
className={`
${
temperature > 30
? "text-red-500"
: temperature < 10
? "text-blue-500"
: "text-green-500"
} font-mono text-sm
`}
>
{temperature}°C
</span>
</div>
<div className="w-full bg-neutral-700/70 rounded-full h-2 effect-inset mt-1">
{/* Temp scale: -10°C to 40°C mapped to 0-100% */}
<div
className={`
${
temperature > 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))}%`,
}}
></div>
</div>
</div>
)}
{relativeHumidity !== undefined && (
<div className="flex flex-col mt-3">
<div className="flex justify-between mb-1 bg-neutral-700/50 p-2 rounded effect-inset">
<span className="text-neutral-400 flex items-center">
<Droplets className="w-3 h-3 mr-2" />
Humidity
</span>
<span className="text-blue-400 font-mono text-sm">
{relativeHumidity}%
</span>
</div>
<div className="w-full bg-neutral-700/70 rounded-full h-2 effect-inset mt-1">
<div
className="bg-blue-500 h-2 rounded-full"
style={{
width: `${relativeHumidity}%`,
}}
></div>
</div>
</div>
)}
{barometricPressure !== undefined && (
<KeyValuePair
label="Pressure"
value={`${barometricPressure} hPa`}
icon={<Gauge className="w-3 h-3" />}
monospace={true}
inset={true}
/>
)}
{soilMoisture !== undefined && (
<div className="flex flex-col mt-3">
<div className="flex justify-between mb-1 bg-neutral-700/50 p-2 rounded effect-inset">
<span className="text-neutral-400 flex items-center">
<Droplets className="w-3 h-3 mr-2" />
Soil Moisture
</span>
<span className="text-green-400 font-mono text-sm">
{soilMoisture}%
</span>
</div>
<div className="w-full bg-neutral-700/70 rounded-full h-2 effect-inset mt-1">
<div
className="bg-green-500 h-2 rounded-full"
style={{
width: `${soilMoisture}%`,
}}
></div>
</div>
</div>
)}
</div>
);
};
+130
View File
@@ -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<GoogleMapProps> = ({
lat,
lng,
zoom,
precisionBits,
}) => {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<google.maps.Map | null>(null);
const markerRef = useRef<google.maps.Marker | null>(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 (
<div
ref={mapRef}
className="w-full h-full min-h-[300px] rounded-lg overflow-hidden effect-inset"
/>
);
};
@@ -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<LowBatteryWarningProps> = ({
batteryLevel
}) => {
if (batteryLevel >= 20) {
return null;
}
return (
<div className="bg-red-900/30 border border-red-800 p-4 rounded-lg effect-inset mt-4">
<div className="flex items-center text-red-400">
<AlertTriangle className="w-4 h-4 mr-2" />
<h3 className="font-medium font-mono tracking-wider">
LOW BATTERY WARNING
</h3>
</div>
<p className="text-sm mt-2 text-neutral-300 flex items-center">
<BatteryLow className="w-3 h-3 mr-1.5 text-red-400" />
Battery level critically low at{" "}
<span className="font-mono text-red-400 mx-1">
{batteryLevel}%
</span>
Device may stop reporting soon.
</p>
</div>
);
};
@@ -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<MeshCardProps> = ({
type,
nodeId,
nodeData,
gatewayId,
observedNodes = [],
onClick,
isActive = false,
+44 -620
View File
@@ -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<HTMLDivElement>(null);
const mapInstanceRef = useRef<google.maps.Map | null>(null);
const markerRef = useRef<google.maps.Marker | null>(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 (
<div
ref={mapRef}
className="w-full h-full min-h-[300px] rounded-lg overflow-hidden effect-inset"
/>
);
};
// Battery level component with visual indicator
const BatteryLevel: React.FC<{ level: number }> = ({ level }) => {
let color = "bg-green-500";
let icon = <BatteryFull className="w-4 h-4" />;
if (level <= 20) {
color = "bg-red-500";
icon = <BatteryLow className="w-4 h-4" />;
} else if (level <= 50) {
color = "bg-amber-500";
icon = <BatteryMedium className="w-4 h-4" />;
}
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-mono text-sm`}
>
{level}%
</span>
</div>
<div className="w-full bg-neutral-700/70 rounded-full h-2 effect-inset">
<div
className={`${color} h-2 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";
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 (
<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-4 h-4" />
<span className="ml-1.5">Signal</span>
</span>
<span className="flex items-center">
<span className="font-mono text-sm">{snr} dB</span>
<span className={`${textColor} text-xs ml-1.5`}>
({strengthText})
</span>
</span>
</div>
<div className="w-full bg-neutral-700/70 rounded-full h-2 effect-inset">
<div
className={`${strengthClass} h-2 rounded-full`}
style={{ width: `${percentage}%` }}
></div>
</div>
</div>
);
};
// 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 (
<div
className={`bg-neutral-800/50 p-4 rounded-lg effect-inset ${className}`}
>
<h2 className="font-semibold mb-4 text-neutral-300 border-b border-neutral-700 pb-2 flex items-center">
{icon && <span className="mr-2">{icon}</span>}
{title}
</h2>
<div className="space-y-3">{children}</div>
</div>
);
};
// 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<string, string> = {
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<string, string> = {
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 (
<div className="p-6 effect-inset rounded-lg border border-neutral-950/60 bg-neutral-800/50 text-neutral-400 text-center">
No packets found for this node
</div>
);
}
return (
<div className="flex flex-col h-full w-full">
<div className="flex justify-between items-center mb-2">
<h3 className="text-sm text-neutral-400 px-2">
Showing {nodePackets.length} of{" "}
{
packets.filter(
(p) => p.data.from === nodeId || p.data.to === nodeId
).length
}{" "}
recent packets
</h3>
</div>
<Separator className="mx-0 mb-4" />
<ul className="space-y-8 w-full">
{nodePackets.map((packet, index) => (
<li key={getPacketKey(packet, index)}>
<PacketRenderer packet={packet} />
</li>
))}
</ul>
</div>
);
};
export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
@@ -445,10 +66,6 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ 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<NodeDetailProps> = ({ nodeId }) => {
{(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-3 h-3 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 className="font-mono">
{node.deviceMetrics.channelUtilization}%
</span>
</div>
<div className="w-full bg-neutral-700/70 rounded-full h-2 effect-inset">
<div
className={`bg-blue-500 h-2 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 className="font-mono">
{node.deviceMetrics.airUtilTx}%
</span>
</div>
<div className="w-full bg-neutral-700/70 rounded-full h-2 effect-inset">
<div
className={`bg-green-500 h-2 rounded-full`}
style={{
width: `${node.deviceMetrics.airUtilTx}%`,
}}
></div>
</div>
</div>
)}
</div>
<UtilizationMetrics
channelUtilization={node.deviceMetrics.channelUtilization}
airUtilTx={node.deviceMetrics.airUtilTx}
/>
)}
</div>
</Section>
)}
{/* Warning for low battery */}
{node.batteryLevel !== undefined && node.batteryLevel < 20 && (
<div className="bg-red-900/30 border border-red-800 p-4 rounded-lg effect-inset mt-4">
<div className="flex items-center text-red-400">
<AlertTriangle className="w-4 h-4 mr-2" />
<h3 className="font-medium font-mono tracking-wider">
LOW BATTERY WARNING
</h3>
</div>
<p className="text-sm mt-2 text-neutral-300 flex items-center">
<BatteryLow className="w-3 h-3 mr-1.5 text-red-400" />
Battery level critically low at{" "}
<span className="font-mono text-red-400 mx-1">
{node.batteryLevel}%
</span>
Device may stop reporting soon.
</p>
</div>
{node.batteryLevel !== undefined && (
<LowBatteryWarning batteryLevel={node.batteryLevel} />
)}
</div>
@@ -808,7 +370,7 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
<KeyValuePair
label="Uptime"
value={formatUptime(node.deviceMetrics.uptimeSeconds)}
icon={<Timer className="w-3 h-3" />}
icon={<Clock className="w-3 h-3" />}
monospace={true}
highlight={node.deviceMetrics.uptimeSeconds > 86400}
inset={true}
@@ -867,7 +429,7 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
</div>
</Section>
{/* Telemetry Info - Environment Metrics */}
{/* Environment Metrics */}
{node.environmentMetrics &&
Object.keys(node.environmentMetrics).length > 0 && (
<Section
@@ -875,102 +437,12 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
icon={<Thermometer className="w-4 h-4" />}
className="mt-4"
>
<div className="space-y-4">
{node.environmentMetrics.temperature !== undefined && (
<div className="flex flex-col">
<div className="flex justify-between mb-1 bg-neutral-700/50 p-2 rounded effect-inset">
<span className="text-neutral-400 flex items-center">
<Thermometer className="w-3 h-3 mr-2" />
Temperature
</span>
<span
className={`
${
node.environmentMetrics.temperature > 30
? "text-red-500"
: node.environmentMetrics.temperature < 10
? "text-blue-500"
: "text-green-500"
} font-mono text-sm
`}
>
{node.environmentMetrics.temperature}°C
</span>
</div>
<div className="w-full bg-neutral-700/70 rounded-full h-2 effect-inset mt-1">
{/* 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 bg-neutral-700/50 p-2 rounded effect-inset">
<span className="text-neutral-400 flex items-center">
<Droplets className="w-3 h-3 mr-2" />
Humidity
</span>
<span className="text-blue-400 font-mono text-sm">
{node.environmentMetrics.relativeHumidity}%
</span>
</div>
<div className="w-full bg-neutral-700/70 rounded-full h-2 effect-inset mt-1">
<div
className="bg-blue-500 h-2 rounded-full"
style={{
width: `${node.environmentMetrics.relativeHumidity}%`,
}}
></div>
</div>
</div>
)}
{node.environmentMetrics.barometricPressure !== undefined && (
<KeyValuePair
label="Pressure"
value={`${node.environmentMetrics.barometricPressure} hPa`}
icon={<Gauge className="w-3 h-3" />}
monospace={true}
inset={true}
/>
)}
{node.environmentMetrics.soilMoisture !== undefined && (
<div className="flex flex-col mt-3">
<div className="flex justify-between mb-1 bg-neutral-700/50 p-2 rounded effect-inset">
<span className="text-neutral-400 flex items-center">
<Droplets className="w-3 h-3 mr-2" />
Soil Moisture
</span>
<span className="text-green-400 font-mono text-sm">
{node.environmentMetrics.soilMoisture}%
</span>
</div>
<div className="w-full bg-neutral-700/70 rounded-full h-2 effect-inset mt-1">
<div
className="bg-green-500 h-2 rounded-full"
style={{
width: `${node.environmentMetrics.soilMoisture}%`,
}}
></div>
</div>
</div>
)}
</div>
<EnvironmentMetrics
temperature={node.environmentMetrics.temperature}
relativeHumidity={node.environmentMetrics.relativeHumidity}
barometricPressure={node.environmentMetrics.barometricPressure}
soilMoisture={node.environmentMetrics.soilMoisture}
/>
</Section>
)}
</div>
@@ -990,63 +462,15 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
precisionBits={precisionBits}
/>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3 text-sm">
<KeyValuePair
label="Coordinates"
value={`${latitude.toFixed(6)}, ${longitude.toFixed(6)}`}
monospace={true}
inset={true}
/>
{node.position?.altitude !== undefined && (
<KeyValuePair
label="Altitude"
value={`${node.position.altitude} m`}
monospace={true}
inset={true}
/>
)}
{/* Position Accuracy */}
<KeyValuePair
label="Accuracy"
value={
positionAccuracy < 1000
? `±${positionAccuracy.toFixed(0)} m`
: `±${(positionAccuracy / 1000).toFixed(1)} km`
}
monospace={true}
inset={true}
/>
{precisionBits !== undefined && (
<KeyValuePair
label="Precision"
value={`${precisionBits} bits`}
monospace={true}
inset={true}
/>
)}
{node.position?.satsInView !== undefined && (
<KeyValuePair
label="Satellites"
value={node.position.satsInView}
monospace={true}
highlight={node.position.satsInView > 6}
inset={true}
/>
)}
{node.position?.groundSpeed !== undefined && (
<KeyValuePair
label="Speed"
value={`${node.position.groundSpeed} m/s`}
monospace={true}
inset={true}
/>
)}
</div>
<NodePositionData
latitude={latitude}
longitude={longitude}
altitude={node.position?.altitude}
positionAccuracy={positionAccuracy}
precisionBits={precisionBits}
satsInView={node.position?.satsInView}
groundSpeed={node.position?.groundSpeed}
/>
</Section>
)}
@@ -1060,4 +484,4 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
</Section>
</div>
);
};
};
@@ -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<NodePacketListProps> = ({ 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 (
<div className="p-6 effect-inset rounded-lg border border-neutral-950/60 bg-neutral-800/50 text-neutral-400 text-center">
No packets found for this node
</div>
);
}
return (
<div className="flex flex-col h-full w-full">
<div className="flex justify-between items-center mb-2">
<h3 className="text-sm text-neutral-400 px-2">
Showing {nodePackets.length} of{" "}
{
packets.filter(
(p) => p.data.from === nodeId || p.data.to === nodeId
).length
}{" "}
recent packets
</h3>
</div>
<Separator className="mx-0 mb-4" />
<ul className="space-y-8 w-full">
{nodePackets.map((packet, index) => (
<li key={getPacketKey(packet, index)}>
<PacketRenderer packet={packet} />
</li>
))}
</ul>
</div>
);
};
@@ -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<NodePositionDataProps> = ({
latitude,
longitude,
altitude,
positionAccuracy,
precisionBits,
satsInView,
groundSpeed
}) => {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3 text-sm">
<KeyValuePair
label="Coordinates"
value={`${latitude.toFixed(6)}, ${longitude.toFixed(6)}`}
monospace={true}
inset={true}
/>
{altitude !== undefined && (
<KeyValuePair
label="Altitude"
value={`${altitude} m`}
monospace={true}
inset={true}
/>
)}
<KeyValuePair
label="Accuracy"
value={
positionAccuracy < 1000
? `±${positionAccuracy.toFixed(0)} m`
: `±${(positionAccuracy / 1000).toFixed(1)} km`
}
monospace={true}
inset={true}
/>
{precisionBits !== undefined && (
<KeyValuePair
label="Precision"
value={`${precisionBits} bits`}
monospace={true}
inset={true}
/>
)}
{satsInView !== undefined && (
<KeyValuePair
label="Satellites"
value={satsInView}
monospace={true}
highlight={satsInView > 6}
inset={true}
/>
)}
{groundSpeed !== undefined && (
<KeyValuePair
label="Speed"
value={`${groundSpeed} m/s`}
monospace={true}
inset={true}
/>
)}
</div>
);
};
@@ -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<SignalStrengthProps> = ({ 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 (
<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-4 h-4" />
<span className="ml-1.5">Signal</span>
</span>
<span className="flex items-center">
<span className="font-mono text-sm">{snr} dB</span>
<span className={`${textColor} text-xs ml-1.5`}>
({strengthText})
</span>
</span>
</div>
<div className="w-full bg-neutral-700/70 rounded-full h-2 effect-inset">
<div
className={`${strengthClass} h-2 rounded-full`}
style={{ width: `${percentage}%` }}
></div>
</div>
</div>
);
};
@@ -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<UtilizationMetricsProps> = ({
channelUtilization,
airUtilTx
}) => {
if (channelUtilization === undefined && airUtilTx === undefined) {
return null;
}
return (
<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-3 h-3 mr-1.5" />
Channel Utilization:
</div>
{channelUtilization !== undefined && (
<div className="flex flex-col mb-2">
<div className="flex justify-between text-xs mb-1">
<span>Total</span>
<span className="font-mono">
{channelUtilization}%
</span>
</div>
<div className="w-full bg-neutral-700/70 rounded-full h-2 effect-inset">
<div
className={`bg-blue-500 h-2 rounded-full`}
style={{
width: `${channelUtilization}%`,
}}
></div>
</div>
</div>
)}
{airUtilTx !== undefined && (
<div className="flex flex-col">
<div className="flex justify-between text-xs mb-1">
<span>Transmit</span>
<span className="font-mono">
{airUtilTx}%
</span>
</div>
<div className="w-full bg-neutral-700/70 rounded-full h-2 effect-inset">
<div
className={`bg-green-500 h-2 rounded-full`}
style={{
width: `${airUtilTx}%`,
}}
></div>
</div>
</div>
)}
</div>
);
};
+8
View File
@@ -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';
+38
View File
@@ -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<SectionProps> = ({
title,
icon,
children,
className = ""
}) => {
return (
<div
className={cn(
"bg-neutral-800/50 p-4 rounded-lg effect-inset",
className
)}
>
<h2 className="font-semibold mb-4 text-neutral-300 border-b border-neutral-700 pb-2 flex items-center">
{icon && <span className="mr-2">{icon}</span>}
{title}
</h2>
<div className="space-y-3">{children}</div>
</div>
);
};
+3
View File
@@ -0,0 +1,3 @@
export { Button } from './Button';
export { Section } from './Section';
export { KeyValuePair } from './KeyValuePair';
+53 -6
View File
@@ -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
+8 -1
View File
@@ -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 (
+73
View File
@@ -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<string, string> = {
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<string, string> = {
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})`;
};