diff --git a/server/server.go b/server/server.go index a653371..e1e7887 100644 --- a/server/server.go +++ b/server/server.go @@ -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 } diff --git a/web/src/components/Counter.tsx b/web/src/components/Counter.tsx new file mode 100644 index 0000000..86b64c7 --- /dev/null +++ b/web/src/components/Counter.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +interface CounterProps { + value: number; + label: string; + valueColor?: string; + className?: string; +} + +export const Counter: React.FC = ({ + value, + label, + valueColor = "text-amber-500", + className = "", +}) => { + return ( +
+ {value} + {label} +
+ ); +}; diff --git a/web/src/components/dashboard/GatewayList.tsx b/web/src/components/dashboard/GatewayList.tsx new file mode 100644 index 0000000..a32f1e1 --- /dev/null +++ b/web/src/components/dashboard/GatewayList.tsx @@ -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 ( +
+
+ +
+ No gateways discovered yet. Waiting for data... +
+ ); + } + + return ( +
+
+

+ Mesh Gateways +

+
+ {gatewayArray.length}{" "} + {gatewayArray.length === 1 ? "gateway" : "gateways"} +
+
+
+ {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 ( +
+
+
+ +
+
+
+
+ {/* Try to find if gateway ID matches a node we know about by its ID */} + {(() => { + // Extract node ID from gateway ID format if possible + const nodeIdMatch = + gateway.gatewayId.match(/^!([0-9a-f]+)/i); + if (nodeIdMatch) { + const nodeIdHex = nodeIdMatch[1]; + const nodeId = parseInt(nodeIdHex, 16); + const matchingNode = nodes[nodeId]; + + if ( + matchingNode && + (matchingNode.shortName || matchingNode.longName) + ) { + return ( + <> + + {matchingNode.shortName || matchingNode.longName} + + + !{nodeIdHex.slice(-4)} + + {matchingNode.position && ( + + )} + {matchingNode.environmentMetrics && Object.keys(matchingNode.environmentMetrics).length > 0 && ( + + )} + + ); + } + } + + // Default to gateway ID if no match + return ( + + {gateway.gatewayId} + + ); + })()} +
+
+ + {timeString} +
+
+
+ + + +
+
+ ); + })} +
+
+ ); +}; diff --git a/web/src/components/dashboard/NodeDetail.tsx b/web/src/components/dashboard/NodeDetail.tsx new file mode 100644 index 0000000..2099ec3 --- /dev/null +++ b/web/src/components/dashboard/NodeDetail.tsx @@ -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 = ({ 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 ( +
+
+ +

Node Not Found

+
+

The node with ID {nodeId} was not found or has not been seen yet.

+
+ ); + } + + // 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 ( +
+
+ +
+ +
+

{nodeName}

+
ID: {nodeId}
+
+ +
+ {/* Basic Info */} +
+

Basic Info

+
+ {node.longName && ( +
+ Name: + {node.longName} +
+ )} + + {node.hwModel && ( +
+ Hardware: + {node.hwModel} +
+ )} + + {node.macAddr && ( +
+ MAC Address: + {node.macAddr} +
+ )} + +
+ Channel: + {node.channelId || 'Unknown'} +
+ +
+ Gateway: + {node.gatewayId || 'Unknown'} +
+ +
+ Messages: + {node.messageCount} +
+
+
+ + {/* Activity */} +
+

Activity

+
+
+ + + {isActive ? 'Active' : 'Inactive'} - last seen {lastSeenText} + +
+ +
+ + Date: + {lastHeardDay} +
+ +
+ + Time: + {lastHeardTime} +
+
+
+ + {/* Position Info */} + {node.position && ( +
+

+ + Position +

+
+ {node.position.latitudeI !== undefined && node.position.longitudeI !== undefined && ( +
+ Coordinates: + + {(node.position.latitudeI / 10000000).toFixed(6)}, {(node.position.longitudeI / 10000000).toFixed(6)} + +
+ )} + + {node.position.altitude !== undefined && ( +
+ Altitude: + {node.position.altitude} m +
+ )} + + {node.position.groundSpeed !== undefined && ( +
+ Speed: + {node.position.groundSpeed} m/s +
+ )} + + {node.position.satsInView !== undefined && ( +
+ Satellites: + {node.position.satsInView} +
+ )} + + {node.position.locationSource && ( +
+ Source: + {node.position.locationSource.replace('LOC_', '')} +
+ )} +
+
+ )} + + {/* Telemetry Info - Device Metrics */} + {node.deviceMetrics && ( +
+

+ + Device Metrics +

+
+ {node.batteryLevel !== undefined && ( +
+ + + Battery: + + 30 ? 'text-green-500' : 'text-amber-500'}`}> + {node.batteryLevel}% + +
+ )} + + {node.deviceMetrics.voltage !== undefined && ( +
+ Voltage: + {node.deviceMetrics.voltage.toFixed(2)} V +
+ )} + + {node.deviceMetrics.channelUtilization !== undefined && ( +
+ Channel Utilization: + {node.deviceMetrics.channelUtilization}% +
+ )} + + {node.deviceMetrics.airUtilTx !== undefined && ( +
+ Air Utilization: + {node.deviceMetrics.airUtilTx}% +
+ )} + + {node.snr !== undefined && ( +
+ + + SNR: + + {node.snr} dB +
+ )} + + {node.deviceMetrics.uptimeSeconds !== undefined && ( +
+ Uptime: + + {Math.floor(node.deviceMetrics.uptimeSeconds / 86400)}d {Math.floor((node.deviceMetrics.uptimeSeconds % 86400) / 3600)}h {Math.floor((node.deviceMetrics.uptimeSeconds % 3600) / 60)}m + +
+ )} +
+
+ )} + + {/* Telemetry Info - Environment Metrics */} + {node.environmentMetrics && Object.keys(node.environmentMetrics).length > 0 && ( +
+

+ + Environment Metrics +

+
+ {node.environmentMetrics.temperature !== undefined && ( +
+ Temperature: + {node.environmentMetrics.temperature}°C +
+ )} + + {node.environmentMetrics.relativeHumidity !== undefined && ( +
+ + + Humidity: + + {node.environmentMetrics.relativeHumidity}% +
+ )} + + {node.environmentMetrics.barometricPressure !== undefined && ( +
+ + + Pressure: + + {node.environmentMetrics.barometricPressure} hPa +
+ )} + + {/* Add other environment metrics here as needed */} +
+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/dashboard/NodeList.tsx b/web/src/components/dashboard/NodeList.tsx new file mode 100644 index 0000000..7514ba2 --- /dev/null +++ b/web/src/components/dashboard/NodeList.tsx @@ -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 ( +
+
+ +
+ No nodes discovered yet. Waiting for data... +
+ ); + } + + return ( +
+
+

Mesh Nodes

+
+ {nodeArray.length} {nodeArray.length === 1 ? "node" : "nodes"} +
+
+
+ {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 ( +
handleNodeClick(node.nodeId)} + className={`flex items-center p-2 rounded-lg cursor-pointer transition-colors ${ + isRecent + ? "bg-neutral-800 hover:bg-neutral-700" + : isActive + ? "bg-neutral-800/80 hover:bg-neutral-700/80" + : "bg-neutral-800/50 hover:bg-neutral-800" + }`} + > +
+
+ +
+
+
+
+ {node.shortName || node.longName ? ( + <> + {node.shortName || node.longName} + + !{node.nodeId.toString(16).slice(-4)} + + + ) : ( + !{node.nodeId.toString(16)} + )} + {node.position && ( + + )} + {node.environmentMetrics && Object.keys(node.environmentMetrics).length > 0 && ( + + )} +
+
+ + {timeString} +
+
+
+ {node.batteryLevel !== undefined && ( +
+ + 30 + ? "text-green-500" + : "text-amber-500" + } + > + {node.batteryLevel}% + +
+ )} + + +
+
+ ); + })} +
+
+ ); +}; diff --git a/web/src/components/dashboard/index.ts b/web/src/components/dashboard/index.ts new file mode 100644 index 0000000..0db7f49 --- /dev/null +++ b/web/src/components/dashboard/index.ts @@ -0,0 +1,3 @@ +export * from './NodeList'; +export * from './GatewayList'; +export * from './NodeDetail'; diff --git a/web/src/components/index.ts b/web/src/components/index.ts index eb0cb6c..aaae9ee 100644 --- a/web/src/components/index.ts +++ b/web/src/components/index.ts @@ -7,4 +7,6 @@ export * from './ConnectionStatus'; export * from './Nav'; export * from './Separator'; export * from './StreamControl'; -export * from './PageWrapper'; \ No newline at end of file +export * from './PageWrapper'; +export * from './Counter'; +export * from './dashboard'; \ No newline at end of file diff --git a/web/src/main.tsx b/web/src/main.tsx index d4259e9..db51d76 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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( , -); +); \ No newline at end of file diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index a6a587c..b8a1b62 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -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" } } } diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index d64716c..df93ebc 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.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("Connecting..."); +export const Route = createRootRoute({ + component: RootLayout, +}); + +function RootLayout() { + const dispatch = useAppDispatch(); + const [connectionStatus, setConnectionStatus] = useState("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 (
- {/* Sidebar Navigation */}