diff --git a/web/src/components/dashboard/GatewayList.tsx b/web/src/components/dashboard/GatewayList.tsx
index a32f1e1..ade1f14 100644
--- a/web/src/components/dashboard/GatewayList.tsx
+++ b/web/src/components/dashboard/GatewayList.tsx
@@ -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 = () => {
{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 (
-
-
-
-
- {/* 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 (
- <>
-
- {matchingNode.shortName || matchingNode.longName}
-
-
- !{nodeIdHex.slice(-4)}
-
- {matchingNode.position && (
-
- )}
- {matchingNode.environmentMetrics && Object.keys(matchingNode.environmentMetrics).length > 0 && (
-
- )}
- >
- );
- }
- }
-
- // Default to gateway ID if no match
- return (
-
- {gateway.gatewayId}
-
- );
- })()}
-
-
-
- {timeString}
-
-
-
-
-
-
-
-
+ 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}
+ />
);
})}
diff --git a/web/src/components/dashboard/MeshCard.tsx b/web/src/components/dashboard/MeshCard.tsx
new file mode 100644
index 0000000..520205e
--- /dev/null
+++ b/web/src/components/dashboard/MeshCard.tsx
@@ -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 = ({
+ 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" ? (
+
+ ) : (
+
+ );
+ };
+
+ // 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 (
+
+
+
+
+ {nodeData.shortName || nodeData.longName ? (
+ <>
+ {nodeData.shortName || nodeData.longName}
+
+ !{nodeId.toString(16).slice(-4)}
+
+ >
+ ) : (
+ !{nodeId.toString(16)}
+ )}
+ {nodeData.position && (
+
+ )}
+ {nodeData.environmentMetrics &&
+ Object.keys(nodeData.environmentMetrics).length > 0 && (
+
+ )}
+
+
+
+ {timeString}
+
+
+
+ {nodeData.batteryLevel !== undefined && (
+
+
+ 30 ? "text-green-500" : "text-amber-500"
+ }
+ >
+ {nodeData.batteryLevel}%
+
+
+ )}
+
+ {/* Only show observed nodes count for gateways */}
+ {type === "gateway" && observedNodes && observedNodes.length > 0 && (
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/web/src/components/dashboard/NodeList.tsx b/web/src/components/dashboard/NodeList.tsx
index 7514ba2..47c9510 100644
--- a/web/src/components/dashboard/NodeList.tsx
+++ b/web/src/components/dashboard/NodeList.tsx
@@ -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 = () => {
{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 (
-
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"
- }`}
- >
-
-
-
- {node.shortName || node.longName ? (
- <>
- {node.shortName || node.longName}
-
- !{node.nodeId.toString(16).slice(-4)}
-
- >
- ) : (
- !{node.nodeId.toString(16)}
- )}
- {node.position && (
-
- )}
- {node.environmentMetrics && Object.keys(node.environmentMetrics).length > 0 && (
-
- )}
-
-
-
- {timeString}
-
-
-
- {node.batteryLevel !== undefined && (
-
-
- 30
- ? "text-green-500"
- : "text-amber-500"
- }
- >
- {node.batteryLevel}%
-
-
- )}
-
-
-
-
+ type="node"
+ nodeId={node.nodeId}
+ nodeData={node}
+ onClick={handleNodeClick}
+ isRecent={isRecent}
+ isActive={isActive}
+ lastHeard={node.lastHeard}
+ />
);
})}
diff --git a/web/src/components/dashboard/index.ts b/web/src/components/dashboard/index.ts
index 0db7f49..fb548c9 100644
--- a/web/src/components/dashboard/index.ts
+++ b/web/src/components/dashboard/index.ts
@@ -1,3 +1,4 @@
export * from './NodeList';
export * from './GatewayList';
+export * from './MeshCard';
export * from './NodeDetail';