diff --git a/Dockerfile b/Dockerfile index fe25484..618d212 100644 --- a/Dockerfile +++ b/Dockerfile @@ -113,6 +113,14 @@ ENV MESHSTREAM_MQTT_TOPIC_PREFIX=msh/US/bayarea ENV MESHSTREAM_MQTT_CLIENT_ID=meshstream ENV MESHSTREAM_CHANNEL_KEYS="" +# MQTT connection tuning parameters +ENV MESHSTREAM_MQTT_KEEPALIVE=60 +ENV MESHSTREAM_MQTT_CONNECT_TIMEOUT=30s +ENV MESHSTREAM_MQTT_PING_TIMEOUT=10s +ENV MESHSTREAM_MQTT_MAX_RECONNECT=5m +ENV MESHSTREAM_MQTT_USE_TLS=false +ENV MESHSTREAM_MQTT_TLS_PORT=8883 + # Note: Web app configuration is set at build time # and baked into the static files diff --git a/docker-compose.yml b/docker-compose.yml index 01c78c0..3e76b52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,15 @@ services: - MESHSTREAM_MQTT_USERNAME=${MESHSTREAM_MQTT_USERNAME:-meshdev} - MESHSTREAM_MQTT_PASSWORD=${MESHSTREAM_MQTT_PASSWORD:-large4cats} - MESHSTREAM_MQTT_TOPIC_PREFIX=${MESHSTREAM_MQTT_TOPIC_PREFIX:-msh/US/bayarea} + - MESHSTREAM_MQTT_CLIENT_ID=${MESHSTREAM_MQTT_CLIENT_ID:-meshstream-${HOSTNAME:-local}} + + # MQTT connection tuning parameters + - MESHSTREAM_MQTT_KEEPALIVE=${MESHSTREAM_MQTT_KEEPALIVE:-60} + - MESHSTREAM_MQTT_CONNECT_TIMEOUT=${MESHSTREAM_MQTT_CONNECT_TIMEOUT:-30s} + - MESHSTREAM_MQTT_PING_TIMEOUT=${MESHSTREAM_MQTT_PING_TIMEOUT:-10s} + - MESHSTREAM_MQTT_MAX_RECONNECT=${MESHSTREAM_MQTT_MAX_RECONNECT:-5m} + - MESHSTREAM_MQTT_USE_TLS=${MESHSTREAM_MQTT_USE_TLS:-false} + - MESHSTREAM_MQTT_TLS_PORT=${MESHSTREAM_MQTT_TLS_PORT:-8883} # Server configuration - MESHSTREAM_SERVER_HOST=${MESHSTREAM_SERVER_HOST:-0.0.0.0} diff --git a/main.go b/main.go index bd01bf6..79192db 100644 --- a/main.go +++ b/main.go @@ -19,11 +19,17 @@ import ( // Config holds all the configuration parameters type Config struct { // MQTT Configuration - MQTTBroker string - MQTTUsername string - MQTTPassword string - MQTTTopicPrefix string - MQTTClientID string + MQTTBroker string + MQTTUsername string + MQTTPassword string + MQTTTopicPrefix string + MQTTClientID string + MQTTKeepAlive int + MQTTConnectTimeout time.Duration + MQTTPingTimeout time.Duration + MQTTMaxReconnect time.Duration + MQTTUseTLS bool + MQTTTLSPort int // Web server configuration ServerHost string @@ -69,6 +75,14 @@ func parseConfig() *Config { flag.StringVar(&config.MQTTTopicPrefix, "mqtt-topic-prefix", getEnv("MQTT_TOPIC_PREFIX", "msh/US/CA/Motherlode"), "MQTT topic prefix") flag.StringVar(&config.MQTTClientID, "mqtt-client-id", getEnv("MQTT_CLIENT_ID", "meshstream-client"), "MQTT client ID") + // MQTT connection tuning parameters + flag.IntVar(&config.MQTTKeepAlive, "mqtt-keepalive", intFromEnv("MQTT_KEEPALIVE", 60), "MQTT keep alive interval in seconds") + flag.DurationVar(&config.MQTTConnectTimeout, "mqtt-connect-timeout", durationFromEnv("MQTT_CONNECT_TIMEOUT", 30*time.Second), "MQTT connection timeout") + flag.DurationVar(&config.MQTTPingTimeout, "mqtt-ping-timeout", durationFromEnv("MQTT_PING_TIMEOUT", 10*time.Second), "MQTT ping timeout") + flag.DurationVar(&config.MQTTMaxReconnect, "mqtt-max-reconnect", durationFromEnv("MQTT_MAX_RECONNECT", 5*time.Minute), "MQTT maximum reconnect interval") + flag.BoolVar(&config.MQTTUseTLS, "mqtt-use-tls", boolFromEnv("MQTT_USE_TLS", false), "Use TLS for MQTT connection") + flag.IntVar(&config.MQTTTLSPort, "mqtt-tls-port", intFromEnv("MQTT_TLS_PORT", 8883), "MQTT TLS port") + // Web server configuration flag.StringVar(&config.ServerHost, "server-host", getEnv("SERVER_HOST", "localhost"), "Web server host") flag.StringVar(&config.ServerPort, "server-port", getEnv("SERVER_PORT", "8080"), "Web server port") @@ -105,6 +119,20 @@ func mustParseDuration(durationStr string) time.Duration { return duration } +// Helper function to parse duration from environment with default +func durationFromEnv(key string, defaultValue time.Duration) time.Duration { + envVal := getEnv(key, "") + if envVal == "" { + return defaultValue + } + duration, err := time.ParseDuration(envVal) + if err != nil { + fmt.Fprintf(os.Stderr, "Invalid duration format for %s: %s\n", key, envVal) + return defaultValue + } + return duration +} + // Helper function to parse int from environment func intFromEnv(key string, defaultValue int) int { envVal := getEnv(key, "") @@ -156,7 +184,7 @@ func main() { err := decoder.AddChannelKey(channelName, channelKey) if err != nil { - logger.Errorw("Failed to initialize channel key", "channel", channelName, "error", err) + logger.Errorw("Failed to initialize channel key", "channel", channelName, "key", channelKey, "error", err) } else { logger.Infof("Initialized channel key for '%s'", channelName) } @@ -164,14 +192,22 @@ func main() { // Configure and create the MQTT client mqttConfig := mqtt.Config{ + // Basic connection parameters Broker: config.MQTTBroker, Username: config.MQTTUsername, Password: config.MQTTPassword, ClientID: config.MQTTClientID, Topic: config.MQTTTopicPrefix + "/#", + + // Advanced connection parameters + KeepAlive: config.MQTTKeepAlive, + ConnectTimeout: config.MQTTConnectTimeout, + PingTimeout: config.MQTTPingTimeout, + MaxReconnectTime: config.MQTTMaxReconnect, + UseTLS: config.MQTTUseTLS, + TLSPort: config.MQTTTLSPort, } - logger.Infof("Connecting to MQTT broker: %s with topic prefix: %s", config.MQTTBroker, config.MQTTTopicPrefix) mqttClient := mqtt.NewClient(mqttConfig, logger) // Connect to the MQTT broker diff --git a/mqtt/client.go b/mqtt/client.go index e7dcc5a..b0e113e 100644 --- a/mqtt/client.go +++ b/mqtt/client.go @@ -13,11 +13,20 @@ import ( // Config holds configuration for the MQTT client type Config struct { + // Connection settings Broker string Username string Password string ClientID string Topic string + + // Connection tuning parameters + KeepAlive int // Keep alive interval in seconds (default: 60) + ConnectTimeout time.Duration // Connection timeout (default: 30s) + PingTimeout time.Duration // Ping timeout (default: 10s) + MaxReconnectTime time.Duration // Maximum time between reconnect attempts (default: 5m) + UseTLS bool // Whether to use TLS/SSL (default: false) + TLSPort int // TLS port to use if UseTLS is true (default: 8883) } // Client manages the MQTT connection and message processing @@ -41,26 +50,91 @@ func NewClient(config Config, logger logging.Logger) *Client { // Connect establishes a connection to the MQTT broker func (c *Client) Connect() error { + // Set default values for optional parameters + keepAlive := 60 + if c.config.KeepAlive > 0 { + keepAlive = c.config.KeepAlive + } + + connectTimeout := 30 * time.Second + if c.config.ConnectTimeout > 0 { + connectTimeout = c.config.ConnectTimeout + } + + pingTimeout := 10 * time.Second + if c.config.PingTimeout > 0 { + pingTimeout = c.config.PingTimeout + } + + maxReconnectTime := 5 * time.Minute + if c.config.MaxReconnectTime > 0 { + maxReconnectTime = c.config.MaxReconnectTime + } + + // Determine protocol and port + protocol := "tcp" + port := 1883 + if c.config.UseTLS { + protocol = "ssl" + port = 8883 + if c.config.TLSPort > 0 { + port = c.config.TLSPort + } + } + + // Log detailed connection settings + c.logger.Infow("Connecting to MQTT broker with settings", + "broker", c.config.Broker, + "port", port, + "protocol", protocol, + "clientID", c.config.ClientID, + "username", c.config.Username, + "passwordLength", len(c.config.Password), + "topic", c.config.Topic, + "keepAlive", keepAlive, + "connectTimeout", connectTimeout, + "pingTimeout", pingTimeout, + "maxReconnectTime", maxReconnectTime, + "useTLS", c.config.UseTLS, + ) + opts := mqtt.NewClientOptions() - opts.AddBroker(fmt.Sprintf("tcp://%s:1883", c.config.Broker)) + opts.AddBroker(fmt.Sprintf("%s://%s:%d", protocol, c.config.Broker, port)) opts.SetClientID(c.config.ClientID) opts.SetUsername(c.config.Username) opts.SetPassword(c.config.Password) opts.SetDefaultPublishHandler(c.messageHandler) - opts.SetPingTimeout(1 * time.Second) + opts.SetKeepAlive(time.Duration(keepAlive) * time.Second) + opts.SetConnectTimeout(connectTimeout) + opts.SetPingTimeout(pingTimeout) + opts.SetMaxReconnectInterval(maxReconnectTime) + opts.SetAutoReconnect(true) + opts.SetCleanSession(true) opts.OnConnect = c.connectHandler opts.OnConnectionLost = c.connectionLostHandler + opts.OnReconnecting = c.reconnectingHandler // Create and start the client c.client = mqtt.NewClient(opts) if token := c.client.Connect(); token.Wait() && token.Error() != nil { + c.logger.Errorw("Failed to connect to MQTT broker", + "error", token.Error(), + "broker", c.config.Broker, + "clientID", c.config.ClientID) return fmt.Errorf("error connecting to MQTT broker: %v", token.Error()) } // Subscribe to the configured topic token := c.client.Subscribe(c.config.Topic, 0, nil) token.Wait() - c.logger.Infof("Subscribed to topic: %s", c.config.Topic) + if token.Error() != nil { + c.logger.Errorw("Failed to subscribe to topic", + "error", token.Error(), + "topic", c.config.Topic) + return fmt.Errorf("error subscribing to topic %s: %v", c.config.Topic, token.Error()) + } + + c.logger.Infof("Successfully subscribed to topic: %s", c.config.Topic) return nil } @@ -127,10 +201,24 @@ func (c *Client) messageHandler(client mqtt.Client, msg mqtt.Message) { // connectHandler is called when the client connects to the broker func (c *Client) connectHandler(client mqtt.Client) { - c.logger.Info("Connected to MQTT Broker") + c.logger.Infow("Connected to MQTT Broker", + "broker", c.config.Broker, + "clientID", c.config.ClientID, + "topic", c.config.Topic) } // connectionLostHandler is called when the client loses connection func (c *Client) connectionLostHandler(client mqtt.Client, err error) { - c.logger.Errorw("Connection lost", "error", err) + c.logger.Errorw("Connection lost", + "error", err, + "errorType", fmt.Sprintf("%T", err), + "broker", c.config.Broker, + "clientID", c.config.ClientID) +} + +// reconnectingHandler is called when the client is attempting to reconnect +func (c *Client) reconnectingHandler(client mqtt.Client, opts *mqtt.ClientOptions) { + c.logger.Infow("Attempting to reconnect to MQTT broker", + "broker", c.config.Broker, + "clientID", c.config.ClientID) } diff --git a/scripts/push-to-ecr.sh b/scripts/push-to-ecr.sh index a291d48..5c5b3bd 100755 --- a/scripts/push-to-ecr.sh +++ b/scripts/push-to-ecr.sh @@ -47,6 +47,13 @@ if ! docker buildx version &> /dev/null; then fi echo "Building Docker image for Meshstream using buildx (linux/amd64 platform)..." +echo "Generating a unique MQTT client ID if not set..." +# Generate a unique client ID if not already set +if [ -z "${MESHSTREAM_MQTT_CLIENT_ID}" ]; then + export MESHSTREAM_MQTT_CLIENT_ID="meshstream-aws-$(date +%s)" + echo "Using generated MQTT client ID: ${MESHSTREAM_MQTT_CLIENT_ID}" +fi + docker buildx build \ --platform linux/amd64 \ --build-arg MESHSTREAM_API_BASE_URL="${MESHSTREAM_API_BASE_URL:-}" \ diff --git a/web/src/components/Nav.tsx b/web/src/components/Nav.tsx index e9caa12..c12d02e 100644 --- a/web/src/components/Nav.tsx +++ b/web/src/components/Nav.tsx @@ -1,7 +1,8 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; import { Link } from "@tanstack/react-router"; import { ConnectionStatus } from "./ConnectionStatus"; import { Separator } from "./Separator"; +import { Button } from "./ui/Button"; import { SITE_TITLE } from "../lib/config"; import { Info, @@ -11,6 +12,8 @@ import { MessageSquare, Map, LucideIcon, + Menu, + X, } from "lucide-react"; // Define navigation item structure @@ -60,47 +63,124 @@ const navigationItems: NavItem[] = [ ]; export const Nav: React.FC = ({ connectionStatus }) => { + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [isMobileView, setIsMobileView] = useState(false); + + // Handle window resize to determine if we're in mobile view + useEffect(() => { + const handleResize = () => { + setIsMobileView(window.innerWidth < 768); + if (window.innerWidth >= 768) { + setIsMobileMenuOpen(false); + } + }; + + // Set initial state + handleResize(); + + // Add event listener + window.addEventListener("resize", handleResize); + + // Clean up + return () => window.removeEventListener("resize", handleResize); + }, []); + + // Navigation links component to avoid duplication + const NavLinks = () => ( + + ); + return ( - + {/* Desktop sidebar (hidden on mobile) */} + + + {/* Content padding spacer for mobile view - this will be positioned outside the main content area */} +
+ ); }; diff --git a/web/src/components/PageWrapper.tsx b/web/src/components/PageWrapper.tsx index 51f2bbf..9d8245b 100644 --- a/web/src/components/PageWrapper.tsx +++ b/web/src/components/PageWrapper.tsx @@ -9,7 +9,7 @@ interface PageWrapperProps { */ export const PageWrapper: React.FC = ({ children }) => { return ( -
+
{children}
); diff --git a/web/src/components/ui/Button.tsx b/web/src/components/ui/Button.tsx index 5d049dd..87b7023 100644 --- a/web/src/components/ui/Button.tsx +++ b/web/src/components/ui/Button.tsx @@ -4,7 +4,7 @@ import { cn } from "../../lib/cn"; export interface ButtonProps extends React.ButtonHTMLAttributes { /** Button content */ - children: React.ReactNode; + children?: React.ReactNode; /** Optional icon to display before the text */ icon?: LucideIcon; /** Button variant */ diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index a1738e4..be1bb3d 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -82,9 +82,9 @@ function RootLayout() { }, [dispatch]); return ( -
+
diff --git a/web/src/styles/index.css b/web/src/styles/index.css index 32f3758..48d9f82 100644 --- a/web/src/styles/index.css +++ b/web/src/styles/index.css @@ -5,7 +5,7 @@ /* Set default background and text colors and font family */ @layer base { html, body { - @apply bg-neutral-800; + @apply bg-neutral-700; @apply text-neutral-200; @apply font-sans; }