Add a dashboard showing observed nodes and gateways

This commit is contained in:
Daniel Pupius
2025-04-24 09:59:06 -07:00
parent 07299d892c
commit d04f52d379
19 changed files with 1109 additions and 226 deletions

View File

@@ -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
}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -0,0 +1,3 @@
export * from './NodeList';
export * from './GatewayList';
export * from './NodeDetail';

View File

@@ -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';

View File

@@ -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>,
);
);

View File

@@ -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"
}
}
}

View File

@@ -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>

View File

@@ -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>
);
}
}

View File

@@ -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>

View File

@@ -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
View 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' });
},
});

View 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>
);
}

View File

@@ -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]);

View File

@@ -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",

View File

@@ -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,
},
});

View 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;