Consolidate MeshCard

This commit is contained in:
Daniel Pupius
2025-04-24 10:35:59 -07:00
parent d04f52d379
commit 5ba6899c94
4 changed files with 201 additions and 178 deletions
+32 -88
View File
@@ -1,7 +1,7 @@
import React from "react";
import { useAppSelector } from "../../hooks";
import { RefreshCw, Signal, MapPin, Thermometer } from "lucide-react";
import { Counter } from "../Counter";
import { RefreshCw } from "lucide-react";
import { MeshCard } from "./MeshCard";
export const GatewayList: React.FC = () => {
const { gateways, nodes } = useAppSelector((state) => state.aggregator);
@@ -43,95 +43,39 @@ export const GatewayList: React.FC = () => {
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 gap-2">
{sortedGateways.map((gateway) => {
// Format last heard time
const lastHeardDate = new Date(gateway.lastHeard * 1000);
const timeString = lastHeardDate.toLocaleTimeString();
// Determine if gateway is active (heard in last 5 minutes)
const isActive = Date.now() / 1000 - gateway.lastHeard < 300;
// Extract node ID from gateway ID format if possible
const nodeIdMatch = gateway.gatewayId.match(/^!([0-9a-f]+)/i);
let nodeId = 0;
let matchingNode = null;
if (nodeIdMatch) {
const nodeIdHex = nodeIdMatch[1];
nodeId = parseInt(nodeIdHex, 16);
matchingNode = nodes[nodeId];
}
// Determine if gateway is active (using stricter timeframe for gateways)
const secondsSinceLastHeard = Date.now() / 1000 - gateway.lastHeard;
const isRecent = secondsSinceLastHeard < 300; // 5 minutes for gateways
const isActive = !isRecent && secondsSinceLastHeard < 900; // 5-15 minutes for gateways
return (
<div
<MeshCard
key={gateway.gatewayId}
className={`flex items-center p-2 rounded-lg ${isActive ? "bg-neutral-800" : "bg-neutral-800/50"}`}
>
<div className="mr-2">
<div
className={`p-1.5 rounded-full ${isActive ? "bg-green-900/30 text-green-500" : "bg-neutral-700/30 text-neutral-500"}`}
>
<Signal className="w-4 h-4" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-neutral-200 truncate flex items-center gap-1">
{/* Try to find if gateway ID matches a node we know about by its ID */}
{(() => {
// Extract node ID from gateway ID format if possible
const nodeIdMatch =
gateway.gatewayId.match(/^!([0-9a-f]+)/i);
if (nodeIdMatch) {
const nodeIdHex = nodeIdMatch[1];
const nodeId = parseInt(nodeIdHex, 16);
const matchingNode = nodes[nodeId];
if (
matchingNode &&
(matchingNode.shortName || matchingNode.longName)
) {
return (
<>
<span>
{matchingNode.shortName || matchingNode.longName}
</span>
<span className="text-neutral-500 text-xs">
!{nodeIdHex.slice(-4)}
</span>
{matchingNode.position && (
<MapPin className="w-3 h-3 text-blue-400 ml-1" />
)}
{matchingNode.environmentMetrics && Object.keys(matchingNode.environmentMetrics).length > 0 && (
<Thermometer className="w-3 h-3 text-amber-400 ml-1" />
)}
</>
);
}
}
// Default to gateway ID if no match
return (
<span className="truncate max-w-[160px]">
{gateway.gatewayId}
</span>
);
})()}
</div>
<div className="text-xs text-neutral-400 flex items-center">
<span
className={`inline-block w-1.5 h-1.5 rounded-full mr-1 ${isActive ? "bg-green-500" : "bg-neutral-500"}`}
></span>
{timeString}
</div>
</div>
<div className="flex items-center space-x-1 sm:space-x-2 flex-wrap flex-shrink-0 ml-1">
<Counter
value={gateway.observedNodes.length}
label="nodes"
valueColor="text-sky-500"
className="mr-1"
/>
<Counter
value={gateway.textMessageCount}
label="txt"
valueColor="text-teal-500"
className="mr-1"
/>
<Counter
value={gateway.messageCount}
label="pkts"
valueColor="text-amber-500"
/>
</div>
</div>
type="gateway"
nodeId={nodeId}
nodeData={matchingNode || {
nodeId,
lastHeard: gateway.lastHeard,
messageCount: gateway.messageCount,
textMessageCount: gateway.textMessageCount
}}
gatewayId={gateway.gatewayId}
observedNodes={gateway.observedNodes}
isRecent={isRecent}
isActive={isActive}
lastHeard={gateway.lastHeard}
/>
);
})}
</div>
+157
View File
@@ -0,0 +1,157 @@
import React from "react";
import { Radio, Signal, Battery, MapPin, Thermometer } from "lucide-react";
import { Counter } from "../Counter";
import { NodeData } from "../../store/slices/aggregatorSlice";
export interface MeshCardProps {
type: "node" | "gateway";
nodeId: number;
nodeData: NodeData;
gatewayId?: string;
observedNodes?: number[];
onClick?: (nodeId: number) => void;
isActive?: boolean;
isRecent?: boolean;
lastHeard: number;
}
export const MeshCard: React.FC<MeshCardProps> = ({
type,
nodeId,
nodeData,
gatewayId,
observedNodes = [],
onClick,
isActive = false,
isRecent = false,
lastHeard,
}) => {
// Format last heard time
const lastHeardDate = new Date(lastHeard * 1000);
const timeString = lastHeardDate.toLocaleTimeString();
// Handle click event
const handleClick = () => {
if (onClick) {
onClick(nodeId);
}
};
// Get icon based on type
const getIcon = () => {
return type === "gateway" ? (
<Signal className="w-4 h-4" />
) : (
<Radio className="w-4 h-4" />
);
};
// Get card style based on activity
const getCardStyle = () => {
if (isRecent) {
return "bg-neutral-800 hover:bg-neutral-700";
} else if (isActive) {
return "bg-neutral-800/80 hover:bg-neutral-700/80";
} else {
return "bg-neutral-800/50 hover:bg-neutral-800";
}
};
// Get icon style based on activity
const getIconStyle = () => {
if (isRecent) {
return "bg-green-900/30 text-green-500";
} else if (isActive) {
return "bg-green-900/50 text-green-700";
} else {
return "bg-neutral-700/30 text-neutral-500";
}
};
// Get status dot color
const getStatusDotStyle = () => {
if (isRecent) {
return "bg-green-500";
} else if (isActive) {
return "bg-green-700";
} else {
return "bg-neutral-500";
}
};
return (
<div
onClick={handleClick}
className={`flex items-center p-2 rounded-lg ${getCardStyle()} ${onClick ? "cursor-pointer transition-colors" : ""}`}
>
<div className="mr-2">
<div className={`p-1.5 rounded-full ${getIconStyle()}`}>
{getIcon()}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-neutral-200 truncate flex items-center gap-1">
{nodeData.shortName || nodeData.longName ? (
<>
<span>{nodeData.shortName || nodeData.longName}</span>
<span className="text-neutral-500 text-xs">
!{nodeId.toString(16).slice(-4)}
</span>
</>
) : (
<span>!{nodeId.toString(16)}</span>
)}
{nodeData.position && (
<MapPin className="w-3 h-3 text-blue-400 ml-1" />
)}
{nodeData.environmentMetrics &&
Object.keys(nodeData.environmentMetrics).length > 0 && (
<Thermometer className="w-3 h-3 text-amber-400 ml-1" />
)}
</div>
<div className="text-xs text-neutral-400 flex items-center">
<span
className={`inline-block w-1.5 h-1.5 rounded-full mr-1 ${getStatusDotStyle()}`}
></span>
{timeString}
</div>
</div>
<div className="flex items-center space-x-1 sm:space-x-2 flex-wrap flex-shrink-0">
{nodeData.batteryLevel !== undefined && (
<div className="flex items-center text-xs">
<Battery className="w-3.5 h-3.5 mr-0.5" />
<span
className={
nodeData.batteryLevel > 30 ? "text-green-500" : "text-amber-500"
}
>
{nodeData.batteryLevel}%
</span>
</div>
)}
{/* Only show observed nodes count for gateways */}
{type === "gateway" && observedNodes && observedNodes.length > 0 && (
<Counter
value={observedNodes.length}
label="nodes"
valueColor="text-sky-500"
className="mr-1"
/>
)}
<Counter
value={nodeData.textMessageCount}
label="txt"
valueColor="text-teal-500"
className="mr-1"
/>
<Counter
value={nodeData.messageCount}
label="pkts"
valueColor="text-amber-500"
/>
</div>
</div>
);
};
+11 -90
View File
@@ -1,8 +1,8 @@
import React from "react";
import { useNavigate } from "@tanstack/react-router";
import { useAppSelector } from "../../hooks";
import { Radio, Battery, RefreshCw, MapPin, Thermometer } from "lucide-react";
import { Counter } from "../Counter";
import { RefreshCw } from "lucide-react";
import { MeshCard } from "./MeshCard";
export const NodeList: React.FC = () => {
const { nodes } = useAppSelector((state) => state.aggregator);
@@ -39,11 +39,6 @@ export const NodeList: React.FC = () => {
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 gap-2">
{sortedNodes.map((node) => {
// Format last heard time
const lastHeardDate = new Date(node.lastHeard * 1000);
const timeString = lastHeardDate.toLocaleTimeString();
const dateString = lastHeardDate.toLocaleDateString();
// Calculate time since last heard (in seconds)
const secondsSinceLastHeard = Date.now() / 1000 - node.lastHeard;
@@ -55,90 +50,16 @@ export const NodeList: React.FC = () => {
const isActive = !isRecent && secondsSinceLastHeard < 1800; // 10-30 minutes
return (
<div
<MeshCard
key={node.nodeId}
onClick={() => handleNodeClick(node.nodeId)}
className={`flex items-center p-2 rounded-lg cursor-pointer transition-colors ${
isRecent
? "bg-neutral-800 hover:bg-neutral-700"
: isActive
? "bg-neutral-800/80 hover:bg-neutral-700/80"
: "bg-neutral-800/50 hover:bg-neutral-800"
}`}
>
<div className="mr-2">
<div
className={`p-1.5 rounded-full ${
isRecent
? "bg-green-900/30 text-green-500"
: isActive
? "bg-green-900/50 text-green-700"
: "bg-neutral-700/30 text-neutral-500"
}`}
>
<Radio className="w-4 h-4" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-neutral-200 truncate flex items-center gap-1">
{node.shortName || node.longName ? (
<>
<span>{node.shortName || node.longName}</span>
<span className="text-neutral-500 text-xs">
!{node.nodeId.toString(16).slice(-4)}
</span>
</>
) : (
<span>!{node.nodeId.toString(16)}</span>
)}
{node.position && (
<MapPin className="w-3 h-3 text-blue-400 ml-1" />
)}
{node.environmentMetrics && Object.keys(node.environmentMetrics).length > 0 && (
<Thermometer className="w-3 h-3 text-amber-400 ml-1" />
)}
</div>
<div className="text-xs text-neutral-400 flex items-center">
<span
className={`inline-block w-1.5 h-1.5 rounded-full mr-1 ${
isRecent
? "bg-green-500"
: isActive
? "bg-green-700"
: "bg-neutral-500"
}`}
></span>
{timeString}
</div>
</div>
<div className="flex items-center space-x-1 sm:space-x-2 flex-wrap flex-shrink-0">
{node.batteryLevel !== undefined && (
<div className="flex items-center text-xs">
<Battery className="w-3.5 h-3.5 mr-0.5" />
<span
className={
node.batteryLevel > 30
? "text-green-500"
: "text-amber-500"
}
>
{node.batteryLevel}%
</span>
</div>
)}
<Counter
value={node.textMessageCount}
label="txt"
valueColor="text-teal-500"
className="mr-1"
/>
<Counter
value={node.messageCount}
label="pkts"
valueColor="text-amber-500"
/>
</div>
</div>
type="node"
nodeId={node.nodeId}
nodeData={node}
onClick={handleNodeClick}
isRecent={isRecent}
isActive={isActive}
lastHeard={node.lastHeard}
/>
);
})}
</div>
+1
View File
@@ -1,3 +1,4 @@
export * from './NodeList';
export * from './GatewayList';
export * from './MeshCard';
export * from './NodeDetail';