From 68fc3536736de4b69d0c575097810ceb10588008 Mon Sep 17 00:00:00 2001 From: Daniel Pupius Date: Wed, 30 Apr 2025 15:08:03 -0700 Subject: [PATCH] Info page --- LICENSE | 21 ++++++ main.go | 10 ++- server/server.go | 44 ++++++++--- web/src/components/MqttConnectionInfo.tsx | 47 ++++++++++++ web/src/lib/api.ts | 33 +++++++++ web/src/lib/types.ts | 14 +++- web/src/routeTree.gen.ts | 26 +++++++ web/src/routes/__root.tsx | 18 ++++- web/src/routes/info.tsx | 90 +++++++++++++++++++++++ web/src/routes/root.tsx | 2 +- web/src/store/index.ts | 2 + web/src/store/slices/connectionSlice.ts | 54 ++++++++++++++ 12 files changed, 345 insertions(+), 16 deletions(-) create mode 100644 LICENSE create mode 100644 web/src/components/MqttConnectionInfo.tsx create mode 100644 web/src/routes/info.tsx create mode 100644 web/src/store/slices/connectionSlice.ts diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f58d255 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Daniel Pupius + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/main.go b/main.go index 53f2737..2a7321c 100644 --- a/main.go +++ b/main.go @@ -226,10 +226,12 @@ func main() { // Start the web server webServer := server.New(server.Config{ - Host: config.ServerHost, - Port: config.ServerPort, - Broker: broker, - Logger: logger, + Host: config.ServerHost, + Port: config.ServerPort, + Broker: broker, + Logger: logger, + MQTTServer: config.MQTTBroker, + MQTTTopicPath: config.MQTTTopicPrefix + "/#", }) // Start the server in a goroutine diff --git a/server/server.go b/server/server.go index 7fddad8..0586ee9 100644 --- a/server/server.go +++ b/server/server.go @@ -17,10 +17,21 @@ import ( // Config holds server configuration type Config struct { - Host string - Port string - Logger logging.Logger - Broker *mqtt.Broker // The MQTT message broker + Host string + Port string + Logger logging.Logger + Broker *mqtt.Broker // The MQTT message broker + MQTTServer string // MQTT server hostname + MQTTTopicPath string // MQTT topic path being subscribed to +} + +// Create connection info JSON to send to the client +type ConnectionInfo struct { + Message string `json:"message"` + MQTTServer string `json:"mqttServer"` + MQTTTopic string `json:"mqttTopic"` + Connected bool `json:"connected"` + ServerTime int64 `json:"serverTime"` } // Server encapsulates the HTTP server functionality @@ -144,19 +155,34 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { heartbeatTicker := time.NewTicker(30 * time.Second) defer heartbeatTicker.Stop() - // Send an initial message with an additional 1.5k payload. This force buffer - // flush so the client knows the connection is open. + // Send an initial message with connection information and an additional padding payload. + // This forces buffer flush so the client knows the connection is open. w.WriteHeader(http.StatusOK) - initialMessage := "Connected to stream" + connectionInfo := ConnectionInfo{ + Message: "Connected to stream", + MQTTServer: s.config.MQTTServer, + MQTTTopic: s.config.MQTTTopicPath, + Connected: true, + ServerTime: time.Now().Unix(), + } + + // Convert to JSON + infoJson, err := json.Marshal(connectionInfo) + if err != nil { + logger.Errorw("Failed to marshal connection info", "error", err) + infoJson = []byte(`{"message":"Connected to stream"}`) + } + + // Create padding for buffer flush paddingSize := 1500 padding := make([]byte, paddingSize) for i := 0; i < paddingSize; i++ { padding[i] = byte('A' + (i % 26)) } - // Send the event with the padded data - fmt.Fprintf(w, "event: info\ndata: %s\n\n", initialMessage) + // Send the event with connection info and padded data + fmt.Fprintf(w, "event: connection_info\ndata: %s\n\n", infoJson) fmt.Fprintf(w, "event: padding\ndata: %s\n\n", padding) flusher.Flush() diff --git a/web/src/components/MqttConnectionInfo.tsx b/web/src/components/MqttConnectionInfo.tsx new file mode 100644 index 0000000..e6e675b --- /dev/null +++ b/web/src/components/MqttConnectionInfo.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { useAppSelector } from "../hooks"; + +/** + * Component that displays MQTT connection details + */ +export const MqttConnectionInfo: React.FC = () => { + const connectionInfo = useAppSelector(state => state.connection.info); + + if (!connectionInfo) { + return ( +
+ Waiting for connection information... +
+ ); + } + + return ( +
+ {connectionInfo.mqttServer && ( +
+

MQTT Server

+
+ {connectionInfo.mqttServer} +
+
+ )} + + {connectionInfo.mqttTopic && ( +
+

MQTT Topic

+
+ {connectionInfo.mqttTopic} +
+
+ )} + +
+

Connection Status

+
+
+ {connectionInfo.connected ? 'Connected' : 'Disconnected'} +
+
+
+ ); +}; \ No newline at end of file diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ee866c1..6ffc300 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -17,6 +17,18 @@ export interface ApiResponse { error?: string; } +// Connection info event type +export interface ConnectionInfoEvent { + type: "connection_info"; + data: { + mqttServer: string; + mqttTopic: string; + connected: boolean; + serverTime?: number; + message?: string; + }; +} + // Re-export types export type { InfoEvent, @@ -78,6 +90,7 @@ export function streamPackets( // Remove all event listeners source.removeEventListener("message", handleMessage as EventListener); source.removeEventListener("info", handleInfo as EventListener); + source.removeEventListener("connection_info", handleConnectionInfo as EventListener); source.onerror = null; // Close the connection @@ -103,6 +116,25 @@ export function streamPackets( data: String(evtData), }); } + + /** + * Handle connection info events + */ + function handleConnectionInfo(event: Event): void { + const evtData = (event as any).data; + try { + // Parse the connection info JSON + const parsedData = JSON.parse(String(evtData)); + + // Forward the connection info to the caller + onEvent({ + type: "connection_info", + data: parsedData, + }); + } catch (error) { + console.warn("[SSE] Failed to parse connection info:", error); + } + } /** * Handle message events @@ -185,6 +217,7 @@ export function streamPackets( // Set up event handlers source.addEventListener("info", handleInfo as EventListener); source.addEventListener("message", handleMessage as EventListener); + source.addEventListener("connection_info", handleConnectionInfo as EventListener); source.onerror = handleError; } catch (error) { console.error("[SSE] Failed to create EventSource:", error); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index d70851e..82d749e 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -423,10 +423,22 @@ export interface PaddingEvent { data: string; // Random data to trigger a flush. } +export interface ConnectionInfoEvent { + type: "connection_info"; + data: { + mqttServer: string; + mqttTopic: string; + connected: boolean; + serverTime?: number; + message?: string; + }; +} + export type StreamEvent = | InfoEvent | MessageEvent | PaddingEvent - | BadDataEvent; + | BadDataEvent + | ConnectionInfoEvent; export type StreamEventHandler = (event: StreamEvent) => void; diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 60dde62..28091c9 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as rootRoute } from './routes/__root' import { Route as StreamImport } from './routes/stream' import { Route as RootImport } from './routes/root' import { Route as MapImport } from './routes/map' +import { Route as InfoImport } from './routes/info' import { Route as HomeImport } from './routes/home' import { Route as DemoImport } from './routes/demo' import { Route as ChannelsImport } from './routes/channels' @@ -41,6 +42,12 @@ const MapRoute = MapImport.update({ getParentRoute: () => rootRoute, } as any) +const InfoRoute = InfoImport.update({ + id: '/info', + path: '/info', + getParentRoute: () => rootRoute, +} as any) + const HomeRoute = HomeImport.update({ id: '/home', path: '/home', @@ -109,6 +116,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HomeImport parentRoute: typeof rootRoute } + '/info': { + id: '/info' + path: '/info' + fullPath: '/info' + preLoaderRoute: typeof InfoImport + parentRoute: typeof rootRoute + } '/map': { id: '/map' path: '/map' @@ -154,6 +168,7 @@ export interface FileRoutesByFullPath { '/channels': typeof ChannelsRoute '/demo': typeof DemoRoute '/home': typeof HomeRoute + '/info': typeof InfoRoute '/map': typeof MapRoute '/root': typeof RootRoute '/stream': typeof StreamRoute @@ -166,6 +181,7 @@ export interface FileRoutesByTo { '/channels': typeof ChannelsRoute '/demo': typeof DemoRoute '/home': typeof HomeRoute + '/info': typeof InfoRoute '/map': typeof MapRoute '/root': typeof RootRoute '/stream': typeof StreamRoute @@ -179,6 +195,7 @@ export interface FileRoutesById { '/channels': typeof ChannelsRoute '/demo': typeof DemoRoute '/home': typeof HomeRoute + '/info': typeof InfoRoute '/map': typeof MapRoute '/root': typeof RootRoute '/stream': typeof StreamRoute @@ -193,6 +210,7 @@ export interface FileRouteTypes { | '/channels' | '/demo' | '/home' + | '/info' | '/map' | '/root' | '/stream' @@ -204,6 +222,7 @@ export interface FileRouteTypes { | '/channels' | '/demo' | '/home' + | '/info' | '/map' | '/root' | '/stream' @@ -215,6 +234,7 @@ export interface FileRouteTypes { | '/channels' | '/demo' | '/home' + | '/info' | '/map' | '/root' | '/stream' @@ -228,6 +248,7 @@ export interface RootRouteChildren { ChannelsRoute: typeof ChannelsRoute DemoRoute: typeof DemoRoute HomeRoute: typeof HomeRoute + InfoRoute: typeof InfoRoute MapRoute: typeof MapRoute RootRoute: typeof RootRoute StreamRoute: typeof StreamRoute @@ -240,6 +261,7 @@ const rootRouteChildren: RootRouteChildren = { ChannelsRoute: ChannelsRoute, DemoRoute: DemoRoute, HomeRoute: HomeRoute, + InfoRoute: InfoRoute, MapRoute: MapRoute, RootRoute: RootRoute, StreamRoute: StreamRoute, @@ -261,6 +283,7 @@ export const routeTree = rootRoute "/channels", "/demo", "/home", + "/info", "/map", "/root", "/stream", @@ -280,6 +303,9 @@ export const routeTree = rootRoute "/home": { "filePath": "home.tsx" }, + "/info": { + "filePath": "info.tsx" + }, "/map": { "filePath": "map.tsx" }, diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 2116e04..a1738e4 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -2,9 +2,10 @@ import { Outlet } from "@tanstack/react-router"; import { useState, useEffect, useRef } from "react"; import { useAppDispatch } from "../hooks"; import { Nav } from "../components"; -import { streamPackets, StreamEvent } from "../lib/api"; +import { streamPackets, StreamEvent, ConnectionInfoEvent } from "../lib/api"; import { processNewPacket } from "../store/slices/aggregatorSlice"; import { addPacket } from "../store/slices/packetSlice"; +import { updateConnectionInfo, updateConnectionStatus } from "../store/slices/connectionSlice"; import { createRootRoute } from "@tanstack/react-router"; export const Route = createRootRoute({ @@ -38,7 +39,19 @@ function RootLayout() { console.log("[SSE] Connection restored successfully"); connectionAttemptsRef.current = 0; isReconnectingRef.current = false; + + // Update connection status in Redux + dispatch(updateConnectionStatus(true)); } + } else if (event.type === "connection_info") { + // Handle connection info events + console.log("[SSE] Connection info received:", event.data); + + // Update connection info in Redux + dispatch(updateConnectionInfo((event as ConnectionInfoEvent).data)); + + // Update UI connection status + setConnectionStatus(`Connected to ${event.data.mqttServer}`); } else if (event.type === "message") { // Process message for both the aggregator and packet display dispatch(processNewPacket(event.data)); @@ -53,6 +66,9 @@ function RootLayout() { setConnectionStatus("Connection error. Reconnecting..."); isReconnectingRef.current = true; + // Update connection status in Redux + dispatch(updateConnectionStatus(false)); + // Increment connection attempts for UI tracking connectionAttemptsRef.current += 1; console.log( diff --git a/web/src/routes/info.tsx b/web/src/routes/info.tsx new file mode 100644 index 0000000..0c86b3d --- /dev/null +++ b/web/src/routes/info.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { PageWrapper } from "../components"; +import { SITE_TITLE, SITE_DESCRIPTION } from "../lib/config"; +import { ExternalLink, Github } from "lucide-react"; +import { MqttConnectionInfo } from "../components/MqttConnectionInfo"; + +export const Route = createFileRoute("/info")({ + component: InfoPage, +}); + +function InfoPage() { + return ( + +
+

{SITE_TITLE}

+

{SITE_DESCRIPTION}

+ + +

+ This site provides real-time visualization and monitoring of the Meshtastic network via MQTT. + It allows you to view node positions, track message traffic, and monitor the status of devices in the mesh. +

+ + + +

What is Meshtastic?

+

+ Meshtastic is an open source, off-grid, decentralized mesh networking platform that allows + for text messaging, GPS location sharing, and data transmission without the need for cellular + service or internet access. It uses affordable, low-power radio devices based on LoRa technology. +

+
+ + + + + + +

+ Position data shown on this dashboard may have reduced precision for privacy reasons, + depending on the configuration of the Meshtastic network and MQTT server. +

+

+ This dashboard only displays information that has been explicitly shared via the + Meshtastic mesh network and relayed through an MQTT gateway. +

+
+ + + +

+ Meshstream is open source software licensed under the MIT License. You are free to use, + modify, and distribute this software according to the terms of the license. +

+
+
+
+ ); +} + + +function InfoSection({ title, children } : { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} \ No newline at end of file diff --git a/web/src/routes/root.tsx b/web/src/routes/root.tsx index 5497902..63a4a9d 100644 --- a/web/src/routes/root.tsx +++ b/web/src/routes/root.tsx @@ -23,7 +23,7 @@ function RootComponent() { Home ) { + state.info = action.payload; + }, + + // Reset connection information + resetConnectionInfo(state) { + state.info = null; + }, + + // Update connection status only + updateConnectionStatus(state, action: PayloadAction) { + if (state.info) { + state.info.connected = action.payload; + } + }, + }, +}); + +// Export actions +export const { + updateConnectionInfo, + resetConnectionInfo, + updateConnectionStatus, +} = connectionSlice.actions; + +// Export reducer +export default connectionSlice.reducer; \ No newline at end of file