Info page

This commit is contained in:
Daniel Pupius
2025-04-30 15:08:03 -07:00
parent e2ced4e939
commit 68fc353673
12 changed files with 345 additions and 16 deletions

21
LICENSE Normal file
View File

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

10
main.go
View File

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

View File

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

View File

@@ -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 (
<div className="text-neutral-400 italic mb-4">
Waiting for connection information...
</div>
);
}
return (
<div className="space-y-4">
{connectionInfo.mqttServer && (
<div>
<h3 className="text-sm font-semibold text-neutral-200 mb-1">MQTT Server</h3>
<div className="bg-neutral-900/50 p-2 rounded font-mono text-xs text-neutral-300 overflow-auto">
{connectionInfo.mqttServer}
</div>
</div>
)}
{connectionInfo.mqttTopic && (
<div>
<h3 className="text-sm font-semibold text-neutral-200 mb-1">MQTT Topic</h3>
<div className="bg-neutral-900/50 p-2 rounded font-mono text-xs text-neutral-300 break-all">
{connectionInfo.mqttTopic}
</div>
</div>
)}
<div>
<h3 className="text-sm font-semibold text-neutral-200 mb-1">Connection Status</h3>
<div className={`flex items-center ${connectionInfo.connected ? 'text-green-500' : 'text-red-500'}`}>
<div className={`h-2 w-2 rounded-full mr-2 ${connectionInfo.connected ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span>{connectionInfo.connected ? 'Connected' : 'Disconnected'}</span>
</div>
</div>
</div>
);
};

View File

@@ -17,6 +17,18 @@ export interface ApiResponse<T> {
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
@@ -104,6 +117,25 @@ export function streamPackets(
});
}
/**
* 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);

View File

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

View File

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

View File

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

90
web/src/routes/info.tsx Normal file
View File

@@ -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 (
<PageWrapper>
<div className="max-w-3xl lg:p-12">
<h1 className="text-2xl font-semibold mb-4 text-neutral-100">{SITE_TITLE}</h1>
<p className="text-neutral-400 mb-6">{SITE_DESCRIPTION}</p>
<InfoSection title="About this site">
<p className="text-neutral-400 mb-4">
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.
</p>
<div className="flex items-center mb-6">
<a
href="https://meshtastic.org"
target="_blank"
rel="noopener noreferrer"
className="text-pink-400 hover:text-pink-300 inline-flex items-center transition-colors"
>
Learn more about Meshtastic
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</div>
<h3 className="text-lg font-semibold mb-2 text-neutral-100">What is Meshtastic?</h3>
<p className="text-neutral-400 mb-4">
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.
</p>
</InfoSection>
<InfoSection title="MQTT Connection Details">
<MqttConnectionInfo />
</InfoSection>
<InfoSection title="Privacy Considerations">
<p className="text-neutral-400 mb-2">
Position data shown on this dashboard may have reduced precision for privacy reasons,
depending on the configuration of the Meshtastic network and MQTT server.
</p>
<p className="text-neutral-400">
This dashboard only displays information that has been explicitly shared via the
Meshtastic mesh network and relayed through an MQTT gateway.
</p>
</InfoSection>
<InfoSection title="Source Code & License">
<div className="flex items-center mb-3">
<a
href="https://github.com/dpup/meshstream"
target="_blank"
rel="noopener noreferrer"
className="text-pink-400 hover:text-pink-300 inline-flex items-center transition-colors"
>
<Github className="mr-2 h-5 w-5" />
github.com/dpup/meshstream
</a>
</div>
<p className="text-neutral-400">
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.
</p>
</InfoSection>
</div>
</PageWrapper>
);
}
function InfoSection({ title, children } : { title: string; children: React.ReactNode }) {
return (
<div className="bg-neutral-700/40 rounded-lg p-6 mb-6 effect-inset">
<h2 className="text-xl font-semibold mb-3 text-neutral-100">{title}</h2>
{children}
</div>
);
}

View File

@@ -23,7 +23,7 @@ function RootComponent() {
Home
</Link>
<Link
to="/packets"
to="/stream"
className="hover:underline"
activeProps={{
className: "font-bold underline",

View File

@@ -1,11 +1,13 @@
import { configureStore } from '@reduxjs/toolkit';
import packetReducer from './slices/packetSlice';
import aggregatorReducer from './slices/aggregatorSlice';
import connectionReducer from './slices/connectionSlice';
export const store = configureStore({
reducer: {
packets: packetReducer,
aggregator: aggregatorReducer,
connection: connectionReducer,
},
});

View File

@@ -0,0 +1,54 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
// Interface for MQTT connection information
export interface ConnectionInfo {
mqttServer: string;
mqttTopic: string;
connected: boolean;
serverTime?: number;
message?: string;
}
// State for the connection slice
interface ConnectionState {
info: ConnectionInfo | null;
}
// Initial state
const initialState: ConnectionState = {
info: null,
};
// Create the connection slice
const connectionSlice = createSlice({
name: "connection",
initialState,
reducers: {
// Update connection information
updateConnectionInfo(state, action: PayloadAction<ConnectionInfo>) {
state.info = action.payload;
},
// Reset connection information
resetConnectionInfo(state) {
state.info = null;
},
// Update connection status only
updateConnectionStatus(state, action: PayloadAction<boolean>) {
if (state.info) {
state.info.connected = action.payload;
}
},
},
});
// Export actions
export const {
updateConnectionInfo,
resetConnectionInfo,
updateConnectionStatus,
} = connectionSlice.actions;
// Export reducer
export default connectionSlice.reducer;