mirror of
https://github.com/dpup/meshstream.git
synced 2026-05-14 13:25:42 +02:00
Consolidate MeshCard
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from './NodeList';
|
||||
export * from './GatewayList';
|
||||
export * from './MeshCard';
|
||||
export * from './NodeDetail';
|
||||
|
||||
Reference in New Issue
Block a user