mirror of
https://github.com/dpup/meshstream.git
synced 2026-03-28 17:42:37 +01:00
Add a dashboard showing observed nodes and gateways
This commit is contained in:
@@ -87,10 +87,7 @@ func (s *Server) Stop() error {
|
||||
|
||||
// handleStatus is a placeholder API endpoint that returns server status
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
// Ensure logger is in context
|
||||
ctx = logging.EnsureLogger(ctx)
|
||||
logger := logging.FromContext(ctx).Named("api.status")
|
||||
logger := s.logger.Named("api.status")
|
||||
|
||||
status := map[string]interface{}{
|
||||
"status": "ok",
|
||||
@@ -105,11 +102,8 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleStream handles Server-Sent Events streaming of MQTT messages
|
||||
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
logger := s.logger.Named("api.sse")
|
||||
ctx := r.Context()
|
||||
// Ensure we have a logger in the context
|
||||
ctx = logging.EnsureLogger(ctx)
|
||||
// Create request-scoped logger
|
||||
logger := logging.FromContext(ctx).Named("sse")
|
||||
|
||||
// Check if the server is shutting down
|
||||
if s.isShuttingDown.Load() {
|
||||
@@ -153,6 +147,7 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
// Client disconnected, unsubscribe and return
|
||||
logger.Info("Client disconnected, unsubscribing from broker")
|
||||
s.config.Broker.Unsubscribe(packetChan)
|
||||
http.Error(w, "Client disconnected", http.StatusGone)
|
||||
return
|
||||
|
||||
case <-s.shutdown:
|
||||
@@ -161,12 +156,14 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "event: info\ndata: Server shutting down, connection closed\n\n")
|
||||
flusher.Flush()
|
||||
s.config.Broker.Unsubscribe(packetChan)
|
||||
http.Error(w, "Server is shutting down", http.StatusServiceUnavailable)
|
||||
return
|
||||
|
||||
case packet, ok := <-packetChan:
|
||||
if !ok {
|
||||
// Channel closed, probably shutting down
|
||||
logger.Info("Packet channel closed, ending stream")
|
||||
http.Error(w, "Server is shutting down", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
24
web/src/components/Counter.tsx
Normal file
24
web/src/components/Counter.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
interface CounterProps {
|
||||
value: number;
|
||||
label: string;
|
||||
valueColor?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Counter: React.FC<CounterProps> = ({
|
||||
value,
|
||||
label,
|
||||
valueColor = "text-amber-500",
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`text-xs bg-neutral-600/30 rounded px-1.5 py-0.5 text-neutral-400 flex items-center shrink-0 ${className}`}
|
||||
>
|
||||
<span className={`${valueColor} font-medium mr-0.5`}>{value}</span>
|
||||
<span className="sm:inline">{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
140
web/src/components/dashboard/GatewayList.tsx
Normal file
140
web/src/components/dashboard/GatewayList.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React from "react";
|
||||
import { useAppSelector } from "../../hooks";
|
||||
import { RefreshCw, Signal, MapPin, Thermometer } from "lucide-react";
|
||||
import { Counter } from "../Counter";
|
||||
|
||||
export const GatewayList: React.FC = () => {
|
||||
const { gateways, nodes } = useAppSelector((state) => state.aggregator);
|
||||
|
||||
// Convert gateways object to array for sorting
|
||||
const gatewayArray = Object.values(gateways);
|
||||
|
||||
// Sort by number of observed nodes (highest first), then by last heard
|
||||
const sortedGateways = gatewayArray.sort((a, b) => {
|
||||
// Primary sort: number of observed nodes (descending)
|
||||
if (a.observedNodes.length !== b.observedNodes.length) {
|
||||
return b.observedNodes.length - a.observedNodes.length;
|
||||
}
|
||||
// Secondary sort: last heard time (most recent first)
|
||||
return b.lastHeard - a.lastHeard;
|
||||
});
|
||||
|
||||
if (gatewayArray.length === 0) {
|
||||
return (
|
||||
<div className="p-6 text-neutral-400 text-center border border-neutral-700 rounded bg-neutral-800/50">
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<RefreshCw className="w-5 h-5 mr-2 animate-spin" />
|
||||
</div>
|
||||
No gateways discovered yet. Waiting for data...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-200">
|
||||
Mesh Gateways
|
||||
</h2>
|
||||
<div className="text-sm text-neutral-400 bg-neutral-800/70 px-2 py-0.5 rounded">
|
||||
{gatewayArray.length}{" "}
|
||||
{gatewayArray.length === 1 ? "gateway" : "gateways"}
|
||||
</div>
|
||||
</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;
|
||||
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
315
web/src/components/dashboard/NodeDetail.tsx
Normal file
315
web/src/components/dashboard/NodeDetail.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useAppSelector, useAppDispatch } from "../../hooks";
|
||||
import { selectNode } from "../../store/slices/aggregatorSlice";
|
||||
import { ArrowLeft, Radio, Battery, Cpu, Thermometer, Gauge, Signal, Droplets, Map, Calendar, Clock } from "lucide-react";
|
||||
|
||||
interface NodeDetailProps {
|
||||
nodeId: number;
|
||||
}
|
||||
|
||||
export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { nodes } = useAppSelector((state) => state.aggregator);
|
||||
const node = nodes[nodeId];
|
||||
|
||||
useEffect(() => {
|
||||
// Update selected node in the store
|
||||
dispatch(selectNode(nodeId));
|
||||
|
||||
// Clear selection when component unmounts
|
||||
return () => {
|
||||
dispatch(selectNode(undefined));
|
||||
};
|
||||
}, [dispatch, nodeId]);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate({ to: "/" });
|
||||
};
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="p-6 text-red-400 border border-red-900 rounded bg-neutral-900">
|
||||
<div className="flex items-center mb-3">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center mr-3 px-2 py-1 text-sm bg-neutral-800 hover:bg-neutral-700 rounded transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
Back
|
||||
</button>
|
||||
<h1 className="text-xl font-semibold">Node Not Found</h1>
|
||||
</div>
|
||||
<p>The node with ID {nodeId} was not found or has not been seen yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Format node name
|
||||
const nodeName = node.shortName || node.longName || `Node ${nodeId.toString(16)}`;
|
||||
|
||||
// Format timestamps
|
||||
const lastHeardDate = new Date(node.lastHeard * 1000);
|
||||
const lastHeardTime = lastHeardDate.toLocaleTimeString();
|
||||
const lastHeardDay = lastHeardDate.toLocaleDateString();
|
||||
|
||||
// Calculate how recently node was active
|
||||
const secondsAgo = Math.floor(Date.now() / 1000) - node.lastHeard;
|
||||
const minutesAgo = Math.floor(secondsAgo / 60);
|
||||
const hoursAgo = Math.floor(minutesAgo / 60);
|
||||
const daysAgo = Math.floor(hoursAgo / 24);
|
||||
|
||||
let lastSeenText = '';
|
||||
if (secondsAgo < 60) {
|
||||
lastSeenText = `${secondsAgo} seconds ago`;
|
||||
} else if (minutesAgo < 60) {
|
||||
lastSeenText = `${minutesAgo} minute${minutesAgo > 1 ? 's' : ''} ago`;
|
||||
} else if (hoursAgo < 24) {
|
||||
lastSeenText = `${hoursAgo} hour${hoursAgo > 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
lastSeenText = `${daysAgo} day${daysAgo > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
// Is node active
|
||||
const isActive = secondsAgo < 600; // 10 minutes
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center mr-3 px-2 py-1 text-sm bg-neutral-800 hover:bg-neutral-700 rounded transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
Back
|
||||
</button>
|
||||
<div className={`p-2 mr-3 rounded-full ${isActive ? 'bg-green-900/30 text-green-500' : 'bg-neutral-700/30 text-neutral-500'}`}>
|
||||
<Radio className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-neutral-200">{nodeName}</h1>
|
||||
<div className="ml-auto text-sm text-neutral-400">ID: {nodeId}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Basic Info */}
|
||||
<div className="bg-neutral-800/50 p-4 rounded-lg">
|
||||
<h2 className="font-semibold mb-3 text-neutral-300">Basic Info</h2>
|
||||
<div className="space-y-2">
|
||||
{node.longName && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Name:</span>
|
||||
<span className="text-neutral-200">{node.longName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.hwModel && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Hardware:</span>
|
||||
<span className="text-neutral-200">{node.hwModel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.macAddr && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">MAC Address:</span>
|
||||
<span className="text-neutral-200 font-mono text-sm">{node.macAddr}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Channel:</span>
|
||||
<span className="text-neutral-200">{node.channelId || 'Unknown'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Gateway:</span>
|
||||
<span className="text-neutral-200 font-mono text-xs truncate max-w-[220px]">{node.gatewayId || 'Unknown'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Messages:</span>
|
||||
<span className="text-neutral-200">{node.messageCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity */}
|
||||
<div className="bg-neutral-800/50 p-4 rounded-lg">
|
||||
<h2 className="font-semibold mb-3 text-neutral-300">Activity</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center mb-3">
|
||||
<span className={`inline-block w-3 h-3 rounded-full mr-2 ${isActive ? 'bg-green-500' : 'bg-neutral-500'}`}></span>
|
||||
<span className="text-neutral-200">
|
||||
{isActive ? 'Active' : 'Inactive'} - last seen {lastSeenText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mb-1.5">
|
||||
<Calendar className="w-4 h-4 mr-2 text-neutral-400" />
|
||||
<span className="text-neutral-400">Date:</span>
|
||||
<span className="ml-auto text-neutral-200">{lastHeardDay}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Clock className="w-4 h-4 mr-2 text-neutral-400" />
|
||||
<span className="text-neutral-400">Time:</span>
|
||||
<span className="ml-auto text-neutral-200">{lastHeardTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position Info */}
|
||||
{node.position && (
|
||||
<div className="bg-neutral-800/50 p-4 rounded-lg">
|
||||
<h2 className="font-semibold mb-3 text-neutral-300 flex items-center">
|
||||
<Map className="w-4 h-4 mr-2" />
|
||||
Position
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{node.position.latitudeI !== undefined && node.position.longitudeI !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Coordinates:</span>
|
||||
<span className="text-neutral-200 font-mono">
|
||||
{(node.position.latitudeI / 10000000).toFixed(6)}, {(node.position.longitudeI / 10000000).toFixed(6)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.position.altitude !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Altitude:</span>
|
||||
<span className="text-neutral-200">{node.position.altitude} m</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.position.groundSpeed !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Speed:</span>
|
||||
<span className="text-neutral-200">{node.position.groundSpeed} m/s</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.position.satsInView !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Satellites:</span>
|
||||
<span className="text-neutral-200">{node.position.satsInView}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.position.locationSource && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Source:</span>
|
||||
<span className="text-neutral-200">{node.position.locationSource.replace('LOC_', '')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Telemetry Info - Device Metrics */}
|
||||
{node.deviceMetrics && (
|
||||
<div className="bg-neutral-800/50 p-4 rounded-lg">
|
||||
<h2 className="font-semibold mb-3 text-neutral-300 flex items-center">
|
||||
<Cpu className="w-4 h-4 mr-2" />
|
||||
Device Metrics
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{node.batteryLevel !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-neutral-400 flex items-center">
|
||||
<Battery className="w-4 h-4 mr-1.5" />
|
||||
Battery:
|
||||
</span>
|
||||
<span className={`${node.batteryLevel > 30 ? 'text-green-500' : 'text-amber-500'}`}>
|
||||
{node.batteryLevel}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.deviceMetrics.voltage !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Voltage:</span>
|
||||
<span className="text-neutral-200">{node.deviceMetrics.voltage.toFixed(2)} V</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.deviceMetrics.channelUtilization !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Channel Utilization:</span>
|
||||
<span className="text-neutral-200">{node.deviceMetrics.channelUtilization}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.deviceMetrics.airUtilTx !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Air Utilization:</span>
|
||||
<span className="text-neutral-200">{node.deviceMetrics.airUtilTx}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.snr !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-neutral-400 flex items-center">
|
||||
<Signal className="w-4 h-4 mr-1.5" />
|
||||
SNR:
|
||||
</span>
|
||||
<span className="text-neutral-200">{node.snr} dB</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.deviceMetrics.uptimeSeconds !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Uptime:</span>
|
||||
<span className="text-neutral-200">
|
||||
{Math.floor(node.deviceMetrics.uptimeSeconds / 86400)}d {Math.floor((node.deviceMetrics.uptimeSeconds % 86400) / 3600)}h {Math.floor((node.deviceMetrics.uptimeSeconds % 3600) / 60)}m
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Telemetry Info - Environment Metrics */}
|
||||
{node.environmentMetrics && Object.keys(node.environmentMetrics).length > 0 && (
|
||||
<div className="bg-neutral-800/50 p-4 rounded-lg">
|
||||
<h2 className="font-semibold mb-3 text-neutral-300 flex items-center">
|
||||
<Thermometer className="w-4 h-4 mr-2" />
|
||||
Environment Metrics
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{node.environmentMetrics.temperature !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-400">Temperature:</span>
|
||||
<span className="text-neutral-200">{node.environmentMetrics.temperature}°C</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.environmentMetrics.relativeHumidity !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-neutral-400 flex items-center">
|
||||
<Droplets className="w-4 h-4 mr-1.5" />
|
||||
Humidity:
|
||||
</span>
|
||||
<span className="text-neutral-200">{node.environmentMetrics.relativeHumidity}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.environmentMetrics.barometricPressure !== undefined && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-neutral-400 flex items-center">
|
||||
<Gauge className="w-4 h-4 mr-1.5" />
|
||||
Pressure:
|
||||
</span>
|
||||
<span className="text-neutral-200">{node.environmentMetrics.barometricPressure} hPa</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add other environment metrics here as needed */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
147
web/src/components/dashboard/NodeList.tsx
Normal file
147
web/src/components/dashboard/NodeList.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
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";
|
||||
|
||||
export const NodeList: React.FC = () => {
|
||||
const { nodes } = useAppSelector((state) => state.aggregator);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Convert nodes object to array for sorting
|
||||
const nodeArray = Object.values(nodes);
|
||||
|
||||
// Sort by node ID (stable)
|
||||
const sortedNodes = nodeArray.sort((a, b) => a.nodeId - b.nodeId);
|
||||
|
||||
const handleNodeClick = (nodeId: number) => {
|
||||
navigate({ to: "/node/$nodeId", params: { nodeId: nodeId.toString() } });
|
||||
};
|
||||
|
||||
if (nodeArray.length === 0) {
|
||||
return (
|
||||
<div className="p-6 text-neutral-400 text-center border border-neutral-700 rounded bg-neutral-800/50">
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<RefreshCw className="w-5 h-5 mr-2 animate-spin" />
|
||||
</div>
|
||||
No nodes discovered yet. Waiting for data...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-200">Mesh Nodes</h2>
|
||||
<div className="text-sm text-neutral-400 bg-neutral-800/70 px-2 py-0.5 rounded">
|
||||
{nodeArray.length} {nodeArray.length === 1 ? "node" : "nodes"}
|
||||
</div>
|
||||
</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;
|
||||
|
||||
// Determine node activity status:
|
||||
// Recent: < 10 minutes (green)
|
||||
// Active: 10-30 minutes (blue)
|
||||
// Inactive: > 30 minutes (grey)
|
||||
const isRecent = secondsSinceLastHeard < 600; // 10 minutes
|
||||
const isActive = !isRecent && secondsSinceLastHeard < 1800; // 10-30 minutes
|
||||
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
3
web/src/components/dashboard/index.ts
Normal file
3
web/src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './NodeList';
|
||||
export * from './GatewayList';
|
||||
export * from './NodeDetail';
|
||||
@@ -7,4 +7,6 @@ export * from './ConnectionStatus';
|
||||
export * from './Nav';
|
||||
export * from './Separator';
|
||||
export * from './StreamControl';
|
||||
export * from './PageWrapper';
|
||||
export * from './PageWrapper';
|
||||
export * from './Counter';
|
||||
export * from './dashboard';
|
||||
@@ -3,13 +3,24 @@ import ReactDOM from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from './store';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { router } from './routes';
|
||||
import { routeTree } from './routeTree.gen';
|
||||
import { createRouter } from '@tanstack/react-router';
|
||||
import './styles/index.css';
|
||||
|
||||
// Create a new router instance
|
||||
const router = createRouter({ routeTree });
|
||||
|
||||
// Register the router instance for type safety
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<RouterProvider router={router} />
|
||||
</Provider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
);
|
||||
@@ -16,6 +16,7 @@ import { Route as PacketsImport } from './routes/packets'
|
||||
import { Route as HomeImport } from './routes/home'
|
||||
import { Route as DemoImport } from './routes/demo'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
import { Route as NodeNodeIdImport } from './routes/node.$nodeId'
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
@@ -49,6 +50,12 @@ const IndexRoute = IndexImport.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const NodeNodeIdRoute = NodeNodeIdImport.update({
|
||||
id: '/node/$nodeId',
|
||||
path: '/node/$nodeId',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -88,6 +95,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof RootImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/node/$nodeId': {
|
||||
id: '/node/$nodeId'
|
||||
path: '/node/$nodeId'
|
||||
fullPath: '/node/$nodeId'
|
||||
preLoaderRoute: typeof NodeNodeIdImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +113,7 @@ export interface FileRoutesByFullPath {
|
||||
'/home': typeof HomeRoute
|
||||
'/packets': typeof PacketsRoute
|
||||
'/root': typeof RootRoute
|
||||
'/node/$nodeId': typeof NodeNodeIdRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesByTo {
|
||||
@@ -107,6 +122,7 @@ export interface FileRoutesByTo {
|
||||
'/home': typeof HomeRoute
|
||||
'/packets': typeof PacketsRoute
|
||||
'/root': typeof RootRoute
|
||||
'/node/$nodeId': typeof NodeNodeIdRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesById {
|
||||
@@ -116,14 +132,22 @@ export interface FileRoutesById {
|
||||
'/home': typeof HomeRoute
|
||||
'/packets': typeof PacketsRoute
|
||||
'/root': typeof RootRoute
|
||||
'/node/$nodeId': typeof NodeNodeIdRoute
|
||||
}
|
||||
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/demo' | '/home' | '/packets' | '/root'
|
||||
fullPaths: '/' | '/demo' | '/home' | '/packets' | '/root' | '/node/$nodeId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/demo' | '/home' | '/packets' | '/root'
|
||||
id: '__root__' | '/' | '/demo' | '/home' | '/packets' | '/root'
|
||||
to: '/' | '/demo' | '/home' | '/packets' | '/root' | '/node/$nodeId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/demo'
|
||||
| '/home'
|
||||
| '/packets'
|
||||
| '/root'
|
||||
| '/node/$nodeId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
||||
@@ -133,6 +157,7 @@ export interface RootRouteChildren {
|
||||
HomeRoute: typeof HomeRoute
|
||||
PacketsRoute: typeof PacketsRoute
|
||||
RootRoute: typeof RootRoute
|
||||
NodeNodeIdRoute: typeof NodeNodeIdRoute
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
@@ -141,6 +166,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
HomeRoute: HomeRoute,
|
||||
PacketsRoute: PacketsRoute,
|
||||
RootRoute: RootRoute,
|
||||
NodeNodeIdRoute: NodeNodeIdRoute,
|
||||
}
|
||||
|
||||
export const routeTree = rootRoute
|
||||
@@ -157,11 +183,12 @@ export const routeTree = rootRoute
|
||||
"/demo",
|
||||
"/home",
|
||||
"/packets",
|
||||
"/root"
|
||||
"/root",
|
||||
"/node/$nodeId"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.ts"
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/demo": {
|
||||
"filePath": "demo.tsx"
|
||||
@@ -174,6 +201,9 @@ export const routeTree = rootRoute
|
||||
},
|
||||
"/root": {
|
||||
"filePath": "root.tsx"
|
||||
},
|
||||
"/node/$nodeId": {
|
||||
"filePath": "node.$nodeId.tsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,106 @@
|
||||
import { Outlet } from "@tanstack/react-router";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAppDispatch } from "../hooks";
|
||||
import { Nav } from "../components";
|
||||
import { streamPackets, StreamEvent } from "../lib/api";
|
||||
import { processNewPacket } from "../store/slices/aggregatorSlice";
|
||||
import { createRootRoute } from "@tanstack/react-router";
|
||||
|
||||
export default function Root() {
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<string>("Connecting...");
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
function RootLayout() {
|
||||
const dispatch = useAppDispatch();
|
||||
const [connectionStatus, setConnectionStatus] = useState<string>("Connecting...");
|
||||
const [connectionAttempts, setConnectionAttempts] = useState(0);
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Set up Server-Sent Events connection
|
||||
const cleanup = streamPackets(
|
||||
// Event handler for all event types
|
||||
(event: StreamEvent) => {
|
||||
if (event.type === "info") {
|
||||
// Handle info events (connection status, etc.)
|
||||
setConnectionStatus(event.data);
|
||||
}
|
||||
},
|
||||
// On error
|
||||
() => {
|
||||
setConnectionStatus("Connection error. Reconnecting...");
|
||||
}
|
||||
);
|
||||
// Connection management
|
||||
let reconnectTimer: number | null = null;
|
||||
const MAX_RECONNECT_DELAY = 10000; // Maximum reconnect delay in ms (10 seconds)
|
||||
const INITIAL_RECONNECT_DELAY = 1000; // Start with 1 second
|
||||
|
||||
// Clean up connection when component unmounts
|
||||
return cleanup;
|
||||
}, []);
|
||||
// Calculate exponential backoff delay with a cap
|
||||
const getReconnectDelay = (attempts: number) => {
|
||||
const delay = Math.min(
|
||||
INITIAL_RECONNECT_DELAY * Math.pow(1.5, attempts),
|
||||
MAX_RECONNECT_DELAY
|
||||
);
|
||||
return delay;
|
||||
};
|
||||
|
||||
const connectToEventStream = () => {
|
||||
// Log connection attempt
|
||||
const attemptNum = connectionAttempts + 1;
|
||||
console.log(`[SSE] Connection attempt ${attemptNum}${isReconnecting ? ' (reconnecting)' : ''}`);
|
||||
|
||||
// Set up Server-Sent Events connection
|
||||
const cleanup = streamPackets(
|
||||
// Event handler for all event types
|
||||
(event: StreamEvent) => {
|
||||
if (event.type === "info") {
|
||||
// Handle info events (connection status, etc.)
|
||||
console.log(`[SSE] Info: ${event.data}`);
|
||||
setConnectionStatus(event.data);
|
||||
|
||||
// Reset connection attempts on successful connection
|
||||
if (event.data.includes("Connected") && isReconnecting) {
|
||||
console.log("[SSE] Connection restored successfully");
|
||||
setConnectionAttempts(0);
|
||||
setIsReconnecting(false);
|
||||
}
|
||||
} else if (event.type === "message") {
|
||||
// Process message for the aggregator
|
||||
dispatch(processNewPacket(event.data));
|
||||
} else if (event.type === "bad_data") {
|
||||
console.warn("[SSE] Received bad data:", event.data);
|
||||
}
|
||||
},
|
||||
// On error handler
|
||||
(error) => {
|
||||
console.error("[SSE] Connection error:", error);
|
||||
setConnectionStatus("Connection error. Reconnecting...");
|
||||
setIsReconnecting(true);
|
||||
|
||||
// Increment connection attempts
|
||||
const newAttempts = connectionAttempts + 1;
|
||||
setConnectionAttempts(newAttempts);
|
||||
|
||||
// Calculate backoff delay
|
||||
const reconnectDelay = getReconnectDelay(newAttempts);
|
||||
console.log(`[SSE] Will attempt to reconnect in ${reconnectDelay}ms (attempt ${newAttempts})`);
|
||||
|
||||
// Close the current connection
|
||||
cleanup();
|
||||
|
||||
// Schedule reconnection
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
connectToEventStream();
|
||||
}, reconnectDelay);
|
||||
}
|
||||
);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
if (reconnectTimer) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
};
|
||||
|
||||
// Initial connection
|
||||
const cleanupFn = connectToEventStream();
|
||||
|
||||
// Cleanup when component unmounts
|
||||
return cleanupFn;
|
||||
}, [dispatch, connectionAttempts, isReconnecting]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-neutral-900">
|
||||
{/* Sidebar Navigation */}
|
||||
<Nav connectionStatus={connectionStatus} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="ml-64 flex-1 py-6 overflow-hidden flex flex-col">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -1,116 +1,17 @@
|
||||
import React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { PageWrapper } from "../components/PageWrapper";
|
||||
import { TextMessagePacket } from "../components/packets/TextMessagePacket";
|
||||
import { PositionPacket } from "../components/packets/PositionPacket";
|
||||
import { NodeInfoPacket } from "../components/packets/NodeInfoPacket";
|
||||
import { TelemetryPacket } from "../components/packets/TelemetryPacket";
|
||||
import { ErrorPacket } from "../components/packets/ErrorPacket";
|
||||
import { WaypointPacket } from "../components/packets/WaypointPacket";
|
||||
import { MapReportPacket } from "../components/packets/MapReportPacket";
|
||||
import { GenericPacket } from "../components/packets/GenericPacket";
|
||||
import { PageWrapper } from "../components";
|
||||
|
||||
// Import sample data
|
||||
import textMessageData from "../../fixtures/text_message.json";
|
||||
import positionData from "../../fixtures/position.json";
|
||||
import nodeInfoData from "../../fixtures/nodeinfo.json";
|
||||
import telemetryData1 from "../../fixtures/telemetry1.json";
|
||||
import telemetryData2 from "../../fixtures/telemetry2.json";
|
||||
import decodeErrorData from "../../fixtures/decode_error.json";
|
||||
import waypointData from "../../fixtures/waypoint.json";
|
||||
import mapReportData from "../../fixtures/map_report.json";
|
||||
|
||||
export const Route = createFileRoute("/demo")({
|
||||
export const Route = createFileRoute('/demo')({
|
||||
component: DemoPage,
|
||||
});
|
||||
|
||||
export function DemoPage() {
|
||||
function DemoPage() {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-medium mb-2 text-neutral-200">
|
||||
Packet Card Variations
|
||||
</h2>
|
||||
<p className="text-neutral-400 mb-4">
|
||||
This page shows all the different packet card variations with sample
|
||||
data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<section>
|
||||
<h3 className="text-md font-medium mb-4 text-neutral-300">
|
||||
Text Message Packet
|
||||
</h3>
|
||||
<TextMessagePacket packet={textMessageData} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md font-medium mb-4 text-neutral-300">
|
||||
Position Packet
|
||||
</h3>
|
||||
<PositionPacket packet={positionData} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md font-medium mb-4 text-neutral-300">
|
||||
Node Info Packet
|
||||
</h3>
|
||||
<NodeInfoPacket packet={nodeInfoData} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md font-medium mb-4 text-neutral-300">
|
||||
Telemetry Packet (Device Metrics)
|
||||
</h3>
|
||||
<TelemetryPacket packet={telemetryData1} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md font-medium mb-4 text-neutral-300">
|
||||
Telemetry Packet (Environmental Data)
|
||||
</h3>
|
||||
<TelemetryPacket packet={telemetryData2} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md font-medium mb-4 text-neutral-300">
|
||||
Waypoint Packet
|
||||
</h3>
|
||||
<WaypointPacket packet={waypointData} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md font-medium mb-4 text-neutral-300">
|
||||
Map Report Packet
|
||||
</h3>
|
||||
<MapReportPacket packet={mapReportData} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md font-medium mb-4 text-neutral-300">
|
||||
Error Packet
|
||||
</h3>
|
||||
<ErrorPacket packet={decodeErrorData} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md font-medium mb-4 text-neutral-300">
|
||||
Generic Packet (Unknown Type)
|
||||
</h3>
|
||||
<GenericPacket
|
||||
packet={{
|
||||
...textMessageData,
|
||||
data: {
|
||||
...textMessageData.data,
|
||||
portNum: "UNKNOWN_APP",
|
||||
textMessage: undefined,
|
||||
binaryData: "SGVsbG8gV29ybGQh",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-4 text-neutral-200">Component Demo</h1>
|
||||
<p className="text-neutral-300 mb-4">
|
||||
This page demonstrates various UI components used in the application.
|
||||
</p>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,27 @@
|
||||
import { InfoMessage, Separator, PageWrapper } from "../components";
|
||||
import { PageWrapper, NodeList, GatewayList } from "../components";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export function IndexPage() {
|
||||
export const Route = createFileRoute('/home')({
|
||||
component: HomePage,
|
||||
});
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div>
|
||||
<p className="mb-4 text-neutral-200">
|
||||
This application provides a real-time view of Meshtastic network
|
||||
traffic.
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-bold mb-2 text-neutral-200">Network Dashboard</h1>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Real-time view of your Meshtastic mesh network traffic
|
||||
</p>
|
||||
|
||||
<InfoMessage
|
||||
message="Click on the Packets link in the navigation to view incoming messages from the Meshtastic network."
|
||||
type="info"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-neutral-800 border border-neutral-700 p-5 rounded shadow-inner">
|
||||
<h3 className="text-lg font-semibold mb-3 text-neutral-200">
|
||||
About Meshtastic
|
||||
</h3>
|
||||
<p className="text-neutral-300">
|
||||
Meshtastic is an open source, off-grid, decentralized mesh
|
||||
communication platform. It allows devices to communicate without
|
||||
cellular service or internet.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="bg-neutral-900/50 p-3 rounded-lg border border-neutral-800">
|
||||
<GatewayList />
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-800 border border-neutral-700 p-5 rounded shadow-inner">
|
||||
<h3 className="text-lg font-semibold mb-3 text-neutral-200">
|
||||
Data Privacy
|
||||
</h3>
|
||||
<p className="text-neutral-300">
|
||||
All data is processed locally. Position data on public servers has
|
||||
reduced precision for privacy protection.
|
||||
</p>
|
||||
|
||||
<div className="bg-neutral-900/50 p-3 rounded-lg border border-neutral-800">
|
||||
<NodeList />
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Router, Route, RootRoute } from "@tanstack/react-router";
|
||||
import Root from "./__root";
|
||||
import { IndexPage } from "./home";
|
||||
import { PacketsRoute } from "./packets";
|
||||
import { DemoPage } from "./demo";
|
||||
|
||||
const rootRoute = new RootRoute({
|
||||
component: Root,
|
||||
});
|
||||
|
||||
const indexRoute = new Route({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
component: IndexPage,
|
||||
});
|
||||
|
||||
const packetsRoute = new Route({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/packets",
|
||||
component: PacketsRoute,
|
||||
});
|
||||
|
||||
const demoRoute = new Route({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/demo",
|
||||
component: DemoPage,
|
||||
});
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
packetsRoute,
|
||||
demoRoute,
|
||||
]);
|
||||
|
||||
export const router = new Router({ routeTree });
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
8
web/src/routes/index.tsx
Normal file
8
web/src/routes/index.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
beforeLoad: () => {
|
||||
// Redirect to the home page
|
||||
throw redirect({ to: '/home' });
|
||||
},
|
||||
});
|
||||
20
web/src/routes/node.$nodeId.tsx
Normal file
20
web/src/routes/node.$nodeId.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { PageWrapper, NodeDetail } from "../components";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/node/$nodeId")({
|
||||
component: NodePage,
|
||||
});
|
||||
|
||||
function NodePage() {
|
||||
// Get the node ID from the route params
|
||||
const { nodeId } = Route.useParams();
|
||||
|
||||
// Convert nodeId string to number
|
||||
const nodeIdNum = parseInt(nodeId, 10);
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<NodeDetail nodeId={nodeIdNum} />
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -3,23 +3,26 @@ import { useAppDispatch } from "../hooks";
|
||||
import { PacketList, PageWrapper } from "../components";
|
||||
import { addPacket } from "../store/slices/packetSlice";
|
||||
import { streamPackets, StreamEvent } from "../lib/api";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export function PacketsRoute() {
|
||||
export const Route = createFileRoute('/packets')({
|
||||
component: PacketsPage,
|
||||
});
|
||||
|
||||
function PacketsPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
// Set up Server-Sent Events connection using our API utility
|
||||
// Subscribe to packet events for this route specifically
|
||||
const cleanup = streamPackets(
|
||||
// Event handler for all event types
|
||||
(event: StreamEvent) => {
|
||||
if (event.type === "message") {
|
||||
// Handle message events (actual packet data)
|
||||
// Only handle for packet display in this route
|
||||
dispatch(addPacket(event.data));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Clean up connection when component unmounts
|
||||
return cleanup;
|
||||
}, [dispatch]);
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export function Root() {
|
||||
export const Route = createFileRoute('/root')({
|
||||
component: RootComponent,
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-100">
|
||||
<header className="bg-blue-600 text-white shadow-md">
|
||||
@@ -9,7 +14,7 @@ export function Root() {
|
||||
<h1 className="text-2xl font-bold">Meshstream</h1>
|
||||
<nav className="space-x-4">
|
||||
<Link
|
||||
to="/"
|
||||
to="/home"
|
||||
className="hover:underline"
|
||||
activeProps={{
|
||||
className: "font-bold underline",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import packetReducer from './slices/packetSlice';
|
||||
import aggregatorReducer from './slices/aggregatorSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
packets: packetReducer,
|
||||
aggregator: aggregatorReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
263
web/src/store/slices/aggregatorSlice.ts
Normal file
263
web/src/store/slices/aggregatorSlice.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { Packet, DeviceMetrics, EnvironmentMetrics, Position, User, Telemetry } from "../../lib/types";
|
||||
|
||||
// Types for aggregated data
|
||||
export interface NodeData {
|
||||
nodeId: number;
|
||||
shortName?: string;
|
||||
longName?: string;
|
||||
macAddr?: string;
|
||||
hwModel?: string;
|
||||
lastHeard: number; // timestamp
|
||||
position?: Position;
|
||||
deviceMetrics?: DeviceMetrics;
|
||||
environmentMetrics?: EnvironmentMetrics;
|
||||
batteryLevel?: number;
|
||||
snr?: number;
|
||||
messageCount: number;
|
||||
textMessageCount: number;
|
||||
channelId?: string;
|
||||
gatewayId?: string;
|
||||
}
|
||||
|
||||
export interface TextMessage {
|
||||
id: number;
|
||||
from: number;
|
||||
fromName?: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
channelId: string;
|
||||
gatewayId: string;
|
||||
}
|
||||
|
||||
export interface GatewayData {
|
||||
gatewayId: string;
|
||||
channelIds: string[]; // Changed from Set to array for Redux serialization
|
||||
lastHeard: number;
|
||||
nodeCount: number;
|
||||
messageCount: number;
|
||||
textMessageCount: number;
|
||||
observedNodes: number[]; // Array of node IDs observed through this gateway
|
||||
}
|
||||
|
||||
export interface ChannelData {
|
||||
channelId: string;
|
||||
gateways: string[]; // Changed from Set to array for Redux serialization
|
||||
nodes: number[]; // Changed from Set to array for Redux serialization
|
||||
messageCount: number;
|
||||
lastMessage?: number;
|
||||
}
|
||||
|
||||
interface AggregatorState {
|
||||
nodes: Record<number, NodeData>;
|
||||
gateways: Record<string, GatewayData>;
|
||||
channels: Record<string, ChannelData>;
|
||||
messages: Record<string, TextMessage[]>;
|
||||
selectedNodeId?: number;
|
||||
}
|
||||
|
||||
const initialState: AggregatorState = {
|
||||
nodes: {},
|
||||
gateways: {},
|
||||
channels: {},
|
||||
messages: {},
|
||||
};
|
||||
|
||||
// Helper to create a key for message collections (by channelId)
|
||||
const getChannelKey = (channelId: string): string => {
|
||||
return `channel_${channelId}`;
|
||||
};
|
||||
|
||||
// Maximum number of messages to keep per channel
|
||||
const MAX_MESSAGES_PER_CHANNEL = 100;
|
||||
|
||||
// Function to process a packet and update the state accordingly
|
||||
const processPacket = (state: AggregatorState, packet: Packet) => {
|
||||
const { data } = packet;
|
||||
const { channelId, gatewayId } = data;
|
||||
const nodeId = data.from;
|
||||
const timestamp = data.rxTime || Math.floor(Date.now() / 1000);
|
||||
|
||||
// Update gateway data, but only if it's reporting packets from a different nodeId
|
||||
// (a true gateway is relaying data from other nodes, not just its own data)
|
||||
if (gatewayId && nodeId !== undefined && gatewayId !== `!${nodeId.toString(16)}`) {
|
||||
if (!state.gateways[gatewayId]) {
|
||||
state.gateways[gatewayId] = {
|
||||
gatewayId,
|
||||
channelIds: [],
|
||||
lastHeard: timestamp,
|
||||
nodeCount: 0,
|
||||
messageCount: 0,
|
||||
textMessageCount: 0,
|
||||
observedNodes: [],
|
||||
};
|
||||
}
|
||||
|
||||
const gateway = state.gateways[gatewayId];
|
||||
gateway.lastHeard = Math.max(gateway.lastHeard, timestamp);
|
||||
gateway.messageCount++;
|
||||
|
||||
// Track text messages
|
||||
if (data.textMessage) {
|
||||
gateway.textMessageCount++;
|
||||
}
|
||||
|
||||
if (channelId && !gateway.channelIds.includes(channelId)) {
|
||||
gateway.channelIds.push(channelId);
|
||||
}
|
||||
|
||||
// Record node in observed nodes
|
||||
if (!gateway.observedNodes.includes(nodeId)) {
|
||||
gateway.observedNodes.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Update channel data
|
||||
if (channelId) {
|
||||
if (!state.channels[channelId]) {
|
||||
state.channels[channelId] = {
|
||||
channelId,
|
||||
gateways: [],
|
||||
nodes: [],
|
||||
messageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const channel = state.channels[channelId];
|
||||
channel.messageCount++;
|
||||
channel.lastMessage = timestamp;
|
||||
|
||||
if (gatewayId && !channel.gateways.includes(gatewayId)) {
|
||||
channel.gateways.push(gatewayId);
|
||||
}
|
||||
|
||||
if (nodeId !== undefined && !channel.nodes.includes(nodeId)) {
|
||||
channel.nodes.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Update node data based on various packet types
|
||||
if (nodeId !== undefined) {
|
||||
// Initialize node if not exists
|
||||
if (!state.nodes[nodeId]) {
|
||||
state.nodes[nodeId] = {
|
||||
nodeId,
|
||||
lastHeard: timestamp,
|
||||
messageCount: 0,
|
||||
textMessageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const node = state.nodes[nodeId];
|
||||
node.lastHeard = Math.max(node.lastHeard, timestamp);
|
||||
node.messageCount++;
|
||||
|
||||
// Track text messages
|
||||
if (data.textMessage) {
|
||||
node.textMessageCount++;
|
||||
}
|
||||
|
||||
// Set channelId and gatewayId if available
|
||||
if (channelId) {
|
||||
node.channelId = channelId;
|
||||
}
|
||||
if (gatewayId) {
|
||||
node.gatewayId = gatewayId;
|
||||
}
|
||||
|
||||
// Update node info if available
|
||||
if (data.nodeInfo) {
|
||||
updateNodeInfo(node, data.nodeInfo);
|
||||
}
|
||||
|
||||
// Update position if available
|
||||
if (data.position) {
|
||||
node.position = { ...data.position };
|
||||
}
|
||||
|
||||
// Update telemetry if available
|
||||
if (data.telemetry) {
|
||||
updateTelemetry(node, data.telemetry);
|
||||
}
|
||||
}
|
||||
|
||||
// Process text messages
|
||||
if (data.textMessage && nodeId !== undefined && channelId) {
|
||||
const channelKey = getChannelKey(channelId);
|
||||
|
||||
if (!state.messages[channelKey]) {
|
||||
state.messages[channelKey] = [];
|
||||
}
|
||||
|
||||
// Add the new message
|
||||
const nodeName = state.nodes[nodeId]?.shortName || state.nodes[nodeId]?.longName;
|
||||
|
||||
state.messages[channelKey].push({
|
||||
id: data.id || Math.random(),
|
||||
from: nodeId,
|
||||
fromName: nodeName,
|
||||
text: data.textMessage,
|
||||
timestamp,
|
||||
channelId,
|
||||
gatewayId: gatewayId || '',
|
||||
});
|
||||
|
||||
// Sort messages by timestamp (newest first)
|
||||
state.messages[channelKey].sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
// Limit the number of messages per channel
|
||||
if (state.messages[channelKey].length > MAX_MESSAGES_PER_CHANNEL) {
|
||||
state.messages[channelKey] = state.messages[channelKey].slice(0, MAX_MESSAGES_PER_CHANNEL);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to update node info from User object
|
||||
const updateNodeInfo = (node: NodeData, nodeInfo: User) => {
|
||||
if (nodeInfo.shortName) node.shortName = nodeInfo.shortName;
|
||||
if (nodeInfo.longName) node.longName = nodeInfo.longName;
|
||||
if (nodeInfo.macaddr) node.macAddr = nodeInfo.macaddr;
|
||||
if (nodeInfo.hwModel) node.hwModel = nodeInfo.hwModel;
|
||||
if (nodeInfo.batteryLevel !== undefined) node.batteryLevel = nodeInfo.batteryLevel;
|
||||
if (nodeInfo.snr !== undefined) node.snr = nodeInfo.snr;
|
||||
};
|
||||
|
||||
// Helper to update telemetry data
|
||||
const updateTelemetry = (node: NodeData, telemetry: Telemetry) => {
|
||||
if (telemetry.deviceMetrics) {
|
||||
node.deviceMetrics = { ...telemetry.deviceMetrics };
|
||||
|
||||
// Update battery level from device metrics if available
|
||||
if (telemetry.deviceMetrics.batteryLevel !== undefined) {
|
||||
node.batteryLevel = telemetry.deviceMetrics.batteryLevel;
|
||||
}
|
||||
}
|
||||
|
||||
if (telemetry.environmentMetrics) {
|
||||
node.environmentMetrics = { ...telemetry.environmentMetrics };
|
||||
}
|
||||
};
|
||||
|
||||
const aggregatorSlice = createSlice({
|
||||
name: "aggregator",
|
||||
initialState,
|
||||
reducers: {
|
||||
processNewPacket: (state, action: PayloadAction<Packet>) => {
|
||||
processPacket(state, action.payload);
|
||||
},
|
||||
clearAggregatedData: (state) => {
|
||||
state.nodes = {};
|
||||
state.gateways = {};
|
||||
state.channels = {};
|
||||
state.messages = {};
|
||||
state.selectedNodeId = undefined;
|
||||
},
|
||||
selectNode: (state, action: PayloadAction<number | undefined>) => {
|
||||
state.selectedNodeId = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { processNewPacket, clearAggregatedData, selectNode } = aggregatorSlice.actions;
|
||||
|
||||
export default aggregatorSlice.reducer;
|
||||
Reference in New Issue
Block a user