├── .env_example ├── .github └── workflows │ └── docker-publish.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.multiarch ├── LICENSE.md ├── README.md ├── app.py ├── database.py ├── debug_bar.py ├── docker-compose.yml ├── entrypoint.sh ├── mosquitto.conf ├── requirements.txt ├── static ├── screenshot.png ├── screenshot_1.png ├── screenshot_2.png ├── script.js └── styles.css └── templates └── index.html /.env_example: -------------------------------------------------------------------------------- 1 | DEBUG=False 2 | HOST=0.0.0.0 3 | PORT=5000 4 | MQTT_BROKER=your_mqtt_broker 5 | MQTT_PORT=1883 6 | MQTT_USERNAME=your_username 7 | MQTT_PASSWORD=your_password 8 | MQTT_KEEPALIVE=60 9 | MQTT_VERSION=3.1.1 10 | SECRET_KEY=your-secret-key -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | tags: [ 'v*.*.*' ] 6 | 7 | env: 8 | DOCKER_HUB_REPO: terdia07/mqttui 9 | 10 | jobs: 11 | push_to_registry: 12 | name: Push Docker image to Docker Hub 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out the repo 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v3 20 | 21 | - name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v3 23 | 24 | - name: Log in to Docker Hub 25 | uses: docker/login-action@v3 26 | with: 27 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 28 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 29 | 30 | - name: Extract metadata (tags, labels) for Docker 31 | id: meta 32 | uses: docker/metadata-action@v5 33 | with: 34 | images: ${{ env.DOCKER_HUB_REPO }} 35 | 36 | - name: Build and push Docker image 37 | uses: docker/build-push-action@v5 38 | with: 39 | context: . 40 | file: ./Dockerfile.multiarch 41 | platforms: linux/amd64,linux/arm64,linux/arm/v7 42 | push: true 43 | tags: ${{ steps.meta.outputs.tags }} 44 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | .env 3 | 4 | # Python 5 | __pycache__ 6 | *.pyc 7 | *.pyo 8 | 9 | # Database files 10 | data/ 11 | *.db 12 | *.sqlite 13 | *.sqlite3 14 | 15 | # Logs 16 | *.log 17 | *.log.* 18 | 19 | # macOS 20 | .DS_Store 21 | 22 | # Static assets (if needed) 23 | static/mqttui-logo-svg.svg 24 | static/mqttui-logo.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | 8 | ## [1.3.2] - 2025-08-24 9 | ### Added 10 | - **Complete UI Redesign**: Collapsible sidebar layout for maximum screen real estate utilization 11 | - **Advanced Search Panel**: Comprehensive message filtering with regex patterns, JSON path queries, content search, and time-based filters 12 | - **Message Persistence**: SQLite database storage with automatic cleanup and configurable message limits 13 | - **Filter Presets**: Save and load frequently used search filter combinations 14 | - **Network Visualization Enhancements**: 15 | - Node pinning functionality (double-click to pin/unpin nodes, red color indicates pinned) 16 | - Improved physics engine with overlap prevention 17 | - Fullscreen support for Message Flow diagram 18 | - Right-click context menu for node management 19 | - **Collapsible Sidebar**: Hide/show controls panel to maximize Message Flow and Messages viewing area 20 | - **Enhanced API Endpoints**: 21 | - `/api/messages` with advanced filtering support 22 | - `/api/topics` with statistics 23 | - `/api/filter-presets` for preset management 24 | 25 | ### Changed 26 | - **Layout Architecture**: Complete redesign from grid-based to flexbox sidebar layout 27 | - **Message Flow Area**: Now takes up significantly more screen space (up to 100% width when sidebar hidden) 28 | - **Message Rate Chart**: Moved to compact sidebar position for better space utilization 29 | - **Topic Filtering**: Now actually filters displayed messages instead of just clearing the list 30 | - **Advanced Search**: Collapsible panel, closed by default to reduce visual clutter 31 | - **Screen Space Optimization**: Removed unnecessary padding, maximized content area utilization 32 | 33 | ### Fixed 34 | - **Topic Dropdown Functionality**: Fixed issue where topic selection only cleared messages instead of filtering 35 | - **Message Stacking**: Resolved overlapping div issue in the main content area 36 | - **Layout Responsiveness**: Proper flexbox implementation ensures consistent 50/50 split between Message Flow and Messages 37 | - **Database Thread Safety**: Implemented thread-local connections for concurrent message storage 38 | 39 | ### Technical Improvements 40 | - **Database Schema**: Enhanced with indexes for better query performance 41 | - **Message Filtering**: Support for regex topic patterns, JSON path queries, and content search 42 | - **Network Physics**: Improved node positioning with `avoidOverlap` and better stabilization 43 | - **JavaScript Architecture**: Modular functions for filter management and UI interactions 44 | - **CSS Layout**: Modern flexbox implementation with smooth transitions and animations 45 | 46 | ## [1.3.1] - 2024-08-24 47 | ### Added 48 | - LOG_LEVEL environment variable support for controlling application logging verbosity (#9) 49 | - Topic subscription filtering via MQTT_TOPICS environment variable (#6) 50 | - Multi-architecture Docker support (AMD64, ARM64, ARMv7) with new Dockerfile.multiarch (#3) 51 | - GitHub Actions workflow for automated multi-platform Docker builds 52 | - Enhanced documentation for MQTT broker address format (#7) 53 | - Proper Flask-SocketIO server startup when running app.py directly (#12) 54 | 55 | ### Fixed 56 | - MQTT v5 connection error by adding properties parameter to on_connect callback (#8) 57 | - Application no longer exits prematurely when run outside Docker (#12) 58 | - Improved LOG_LEVEL validation in entrypoint.sh with gunicorn support 59 | 60 | ### Changed 61 | - Enhanced logging configuration with better formatting and level control 62 | - Updated README with comprehensive configuration documentation 63 | - Improved error handling for MQTT v5 connections 64 | 65 | ## [1.3.0] - 2024-08-27 66 | ### Added 67 | - Improved logging for MQTT connection attempts and status 68 | - Client ID specification for MQTT connection 69 | - Support for different MQTT protocol versions 70 | 71 | ### Changed 72 | - Modified app.py to ensure MQTT connection is attempted when run in a container 73 | - Refactored server startup process for better compatibility with both development and production environments 74 | 75 | ### Fixed 76 | - Resolved issues with MQTT connection not being established 77 | - Fixed problems related to username/password authentication for MQTT brokers 78 | - Improved error handling for non-UTF-8 encoded MQTT messages 79 | 80 | ## [1.2.0] - 2024-08-24 81 | ### Added 82 | - Debug Bar feature for enhanced developer insights 83 | - Real-time websocket connection/disconnect status 84 | - MQTT connection status and last message details 85 | - Request duration tracking 86 | - Toggle functionality to show/hide the Debug Bar 87 | 88 | ## [1.0.0] - 2024-08-19 89 | ### Added 90 | - Initial release of MQTT Web Interface 91 | - Real-time visualization of MQTT topic hierarchy and message flow 92 | - Ability to publish messages to MQTT topics 93 | - Display of message statistics (connection count, topic count, message count) 94 | - Interactive network graph showing topic relationships 95 | - Docker support for easy deployment -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt . 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | 8 | COPY . . 9 | 10 | # Use an entrypoint script to allow for variable substitution 11 | COPY entrypoint.sh /entrypoint.sh 12 | RUN chmod +x /entrypoint.sh 13 | 14 | ENTRYPOINT ["/entrypoint.sh"] -------------------------------------------------------------------------------- /Dockerfile.multiarch: -------------------------------------------------------------------------------- 1 | # Multi-architecture Dockerfile supporting both AMD64 and ARM64 (fixes issue #3) 2 | FROM --platform=$TARGETPLATFORM python:3.9-slim 3 | 4 | # Set build arguments for multi-platform builds 5 | ARG TARGETPLATFORM 6 | ARG BUILDPLATFORM 7 | RUN echo "Building on $BUILDPLATFORM for $TARGETPLATFORM" 8 | 9 | WORKDIR /app 10 | 11 | # Install build dependencies for compiling packages on ARM platforms 12 | RUN apt-get update && apt-get install -y \ 13 | build-essential \ 14 | gcc \ 15 | g++ \ 16 | libc6-dev \ 17 | libffi-dev \ 18 | && rm -rf /var/lib/apt/lists/* 19 | 20 | COPY requirements.txt . 21 | RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \ 22 | pip install --no-cache-dir -r requirements.txt 23 | 24 | COPY . . 25 | 26 | # Use an entrypoint script to allow for variable substitution 27 | COPY entrypoint.sh /entrypoint.sh 28 | RUN chmod +x /entrypoint.sh 29 | 30 | ENTRYPOINT ["/entrypoint.sh"] -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2024] [Terry Osayawe] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MQTT Web Interface 2 | 3 | ## Description 4 | 5 | MQTT Web Interface is an open-source web application that provides a real-time visualization of MQTT (Message Queuing Telemetry Transport) message flows. It allows users to monitor MQTT topics, publish messages, and view message statistics through an intuitive web interface. 6 | 7 | ## Screenshot 8 | 9 | ![Message Flow Screenshot](static/screenshot.png) 10 | 11 | ![Application Screenshot](static/screenshot_1.png) 12 | 13 | ![Debug Screenshot](static/screenshot_2.png) 14 | 15 | ## Features 16 | 17 | ### Core Functionality 18 | - Real-time visualization of MQTT topic hierarchy and message flow 19 | - Ability to publish messages to MQTT topics 20 | - Display of message statistics (connection count, topic count, message count) 21 | - Interactive network graph showing topic relationships 22 | - Dockerized for easy deployment 23 | - Debug Bar for enhanced developer insights 24 | - Flexible configuration for both development and production environments 25 | 26 | ### New in v1.3.2 27 | - **Message Persistence**: SQLite database storage with automatic cleanup and configurable limits 28 | - **Advanced Search & Filtering**: Comprehensive message filtering with multiple search options 29 | - **Collapsible Sidebar Layout**: Maximize screen real estate for better visualization 30 | - **Interactive Network Features**: Node pinning, fullscreen mode, and improved layout 31 | - **Filter Presets**: Save and load frequently used search combinations 32 | 33 | ## Installation 34 | 35 | ### Using Docker (Recommended) 36 | 37 | You can quickly get started with the MQTT Web Interface using Docker: 38 | 39 | ```bash 40 | docker pull terdia07/mqttui:v1.3.2 41 | docker run -p 8088:5000 terdia07/mqttui:v1.3.2 42 | ``` 43 | 44 | Then access the application at `http://localhost:8088` 45 | 46 | #### Docker Compose (Full Setup with MQTT Broker) 47 | 48 | For a complete setup with an MQTT broker included: 49 | 50 | ```bash 51 | git clone https://github.com/terdia/mqttui.git 52 | cd mqttui 53 | docker compose up -d 54 | ``` 55 | 56 | This will start: 57 | - Mosquitto MQTT broker on port 1883 58 | - MQTT Web Interface on port 8088 59 | - Automatic database persistence enabled 60 | 61 | ### Manual Installation 62 | 63 | 1. Clone the repository: 64 | ```bash 65 | git clone https://github.com/terdia/mqttui.git 66 | ``` 67 | 2. Navigate to the project directory: 68 | ```bash 69 | cd mqttui 70 | ``` 71 | 3. Install the required dependencies: 72 | ```bash 73 | pip install -r requirements.txt 74 | ``` 75 | 76 | ## Usage 77 | 78 | 1. Set up your MQTT broker details: 79 | - If using Docker, you can pass environment variables: 80 | ```bash 81 | docker run -p 5000:5000 -e MQTT_BROKER=your_broker_address -e MQTT_PORT=1883 terdia07/mqttui:v1.0.0 82 | ``` 83 | - If running manually, set environment variables or modify `app.py` directly. 84 | 85 | 2. Run the application: 86 | - If using Docker, the application starts automatically. 87 | - If installed manually, run: 88 | ```bash 89 | python app.py 90 | ``` 91 | 92 | 3. Open a web browser and navigate to `http://localhost:5000` to access the interface. 93 | 94 | 4. Use the interface to: 95 | - View the MQTT topic hierarchy 96 | - See real-time message flows 97 | - Publish messages to topics 98 | - Monitor connection and message statistics 99 | 100 | ## Using the New Features (v1.3.2) 101 | 102 | ### Collapsible Sidebar 103 | - **Hide Sidebar**: Click the `◀` button in the sidebar header to maximize the Message Flow area 104 | - **Show Sidebar**: When hidden, click `▶ Show Controls` in the main header to restore the sidebar 105 | - **Purpose**: Get maximum screen real estate for viewing large MQTT network topologies 106 | 107 | ### Interactive Network Visualization 108 | - **Pin Nodes**: Double-click any node to pin it in place (turns red when pinned) 109 | - **Unpin Nodes**: Double-click pinned (red) nodes to unpin them 110 | - **Right-click Menu**: Right-click nodes for pin/unpin context menu 111 | - **Fullscreen Mode**: Click `⛶ Fullscreen` button to view Message Flow in fullscreen 112 | - **Reset Layout**: Click `⟲ Reset` to reorganize all nodes and unpin everything 113 | - **Drag & Zoom**: Drag to pan the view, scroll to zoom in/out 114 | 115 | ### Advanced Search & Filtering 116 | Click `🔍 Advanced Search` in the sidebar to expand filtering options: 117 | 118 | - **Topic Filter**: Select specific topics from dropdown 119 | - **Content Search**: Search within message payloads for specific text 120 | - **Regex Topic Pattern**: Use patterns like `sensors/.*` or `home/+/temp` 121 | - **JSON Path & Value**: Query JSON messages (e.g., path: `temperature`, value: `23.5`) 122 | - **Time Range**: Filter by last hour, 6 hours, 24 hours, or week 123 | - **Apply Filters**: Click to apply current filter combination 124 | - **Clear All**: Reset all filters to show all messages 125 | 126 | ### Filter Presets 127 | - **Save Preset**: Configure filters, then click `Save` and enter a name 128 | - **Load Preset**: Select from dropdown and click `Load` to apply saved filters 129 | - **Preset Management**: Presets are stored in the database and persist between sessions 130 | 131 | ### Message Persistence 132 | All MQTT messages are automatically stored in a local SQLite database: 133 | - **Automatic Storage**: Messages saved with timestamps, topic, payload, and metadata 134 | - **Configurable Limits**: Set `DB_MAX_MESSAGES` to control storage (default: 10,000) 135 | - **Auto Cleanup**: Old messages automatically removed when limit exceeded 136 | - **Search History**: All stored messages are searchable with advanced filters 137 | 138 | ## Configuration 139 | 140 | The following environment variables can be used to configure the application: 141 | 142 | - `DEBUG`: Set to `True` for development mode, `False` for production (default: `False`) 143 | - `PORT`: The port on which the application will run (default: `5000`) 144 | - `MQTT_BROKER`: The address of your MQTT broker (default: 'localhost') 145 | - **Format**: Use IP address (e.g., `192.168.1.100`) or hostname (e.g., `mqtt.example.com`) 146 | - **No protocol prefix**: Do not include `mqtt://` or `tcp://` 147 | - **Examples**: `localhost`, `192.168.1.100`, `broker.hivemq.com`, `mqtt.example.com` 148 | - `MQTT_PORT`: The port of your MQTT broker (default: 1883) 149 | - Common ports: `1883` (standard), `8883` (TLS/SSL) 150 | - `MQTT_USERNAME`: The username for authenticated connection (optional) 151 | - `MQTT_PASSWORD`: The password for authenticated connection (optional) 152 | - `MQTT_KEEPALIVE`: Keep-alive time for MQTT connection (default: 60) 153 | - `MQTT_VERSION`: MQTT protocol version to use (default: '3.1.1', options: '3.1.1' or '5') 154 | - `MQTT_TOPICS`: Comma-separated list of topics to subscribe to (default: '#' for all topics) 155 | - **Examples**: `sensors/#`, `home/+/temperature`, `sensors/temp,sensors/humidity` 156 | - `LOG_LEVEL`: Logging level for the application (default: 'INFO') 157 | - Options: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` 158 | 159 | ### Database Configuration (New in v1.3.2) 160 | - `DB_ENABLED`: Enable message persistence (default: 'True') 161 | - `DB_PATH`: Path to SQLite database file (default: './data/mqtt_messages.db') 162 | - `DB_MAX_MESSAGES`: Maximum messages to store before cleanup (default: 10000) 163 | - `DB_CLEANUP_DAYS`: Delete messages older than X days (default: 30) 164 | 165 | ## Development Mode 166 | 167 | To run the application in development mode: 168 | 169 | 1. Set `DEBUG=True` in your `.env` file or export it as an environment variable. 170 | 2. Run the application using Python or Docker as described above. 171 | 172 | In development mode, the application uses Flask's built-in development server with debug features and the Debug Bar enabled. 173 | 174 | ## Production Deployment 175 | 176 | For production deployment: 177 | 178 | 1. Ensure `DEBUG=False` in your environment. 179 | 2. Use the Docker setup for the most straightforward deployment. 180 | 3. The application will use Gunicorn with eventlet workers for better performance and concurrency. 181 | 182 | ## Contributing 183 | 184 | We welcome contributions to the MQTT Web Interface project! Here's how you can contribute: 185 | 186 | 1. Fork the repository 187 | 2. Create a new branch (`git checkout -b feature/AmazingFeature`) 188 | 3. Make your changes 189 | 4. Commit your changes (`git commit -m 'Add some AmazingFeature'`) 190 | 5. Push to the branch (`git push origin feature/AmazingFeature`) 191 | 6. Open a Pull Request 192 | 193 | Please make sure to update tests as appropriate and adhere to the project's coding standards. 194 | 195 | ## Issues 196 | 197 | If you encounter any problems or have suggestions for improvements, please open an issue on the GitHub repository. When reporting issues, please provide as much detail as possible, including: 198 | 199 | - A clear and descriptive title 200 | - Steps to reproduce the issue 201 | - Expected behavior 202 | - Actual behavior 203 | - Any error messages or screenshots 204 | 205 | ## License 206 | 207 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. 208 | 209 | ## Acknowledgments 210 | 211 | - [Flask](https://flask.palletsprojects.com/) - The web framework used 212 | - [Socket.IO](https://socket.io/) - For real-time, bidirectional communication 213 | - [Paho MQTT](https://www.eclipse.org/paho/) - MQTT client library 214 | - [Vis.js](https://visjs.org/) - For network visualization 215 | - [Chart.js](https://www.chartjs.org/) - For creating responsive charts 216 | 217 | ## Contact 218 | 219 | Project Link: [https://github.com/terdia/mqttui](https://github.com/terdia/mqttui) 220 | 221 | ## Disclaimer 222 | 223 | This software is provided "as is", without warranty of any kind, express or implied. Use at your own risk. -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.3.0" 2 | 3 | from flask import Flask, render_template, request, jsonify, send_from_directory 4 | from flask_socketio import SocketIO, emit 5 | import paho.mqtt.client as mqtt 6 | from datetime import datetime, timedelta 7 | import os 8 | from debug_bar import debug_bar, debug_bar_middleware 9 | import logging 10 | import time 11 | from dotenv import load_dotenv 12 | from logging.handlers import RotatingFileHandler 13 | from werkzeug.serving import run_simple 14 | from database import MessageDatabase 15 | 16 | # Load environment variables 17 | load_dotenv() 18 | 19 | # Configuration 20 | DEBUG = os.getenv('DEBUG', 'False').lower() in ('true', '1', 't') 21 | HOST = os.getenv('HOST', '0.0.0.0') 22 | PORT = int(os.getenv('PORT', 5000)) 23 | MQTT_BROKER = os.getenv('MQTT_BROKER', 'localhost') 24 | MQTT_PORT = int(os.getenv('MQTT_PORT', 1883)) 25 | MQTT_USERNAME = os.getenv('MQTT_USERNAME') 26 | MQTT_PASSWORD = os.getenv('MQTT_PASSWORD') 27 | MQTT_KEEPALIVE = int(os.getenv('MQTT_KEEPALIVE', 60)) 28 | MQTT_VERSION = os.getenv('MQTT_VERSION', '3.1.1') 29 | # Support for topic filtering (issue #6) 30 | MQTT_TOPICS = os.getenv('MQTT_TOPICS', '#') # Comma-separated list of topics to subscribe to 31 | 32 | # Database configuration for message persistence 33 | DB_ENABLED = os.getenv('DB_ENABLED', 'True').lower() in ('true', '1', 't') 34 | DB_PATH = os.getenv('DB_PATH', 'mqtt_messages.db') 35 | DB_MAX_MESSAGES = int(os.getenv('DB_MAX_MESSAGES', 10000)) 36 | DB_CLEANUP_DAYS = int(os.getenv('DB_CLEANUP_DAYS', 30)) 37 | 38 | # Set up logging with LOG_LEVEL environment variable support (fixes issue #9) 39 | LOG_LEVEL = os.getenv('LOG_LEVEL', 'DEBUG' if DEBUG else 'INFO').upper() 40 | log_levels = { 41 | 'DEBUG': logging.DEBUG, 42 | 'INFO': logging.INFO, 43 | 'WARNING': logging.WARNING, 44 | 'WARN': logging.WARNING, # Support both WARN and WARNING 45 | 'ERROR': logging.ERROR, 46 | 'CRITICAL': logging.CRITICAL 47 | } 48 | log_level = log_levels.get(LOG_LEVEL, logging.INFO) 49 | if LOG_LEVEL not in log_levels: 50 | print(f"Invalid LOG_LEVEL: {LOG_LEVEL}. Using INFO level.") 51 | 52 | logging.basicConfig(level=log_level, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 53 | if not DEBUG: 54 | handler = RotatingFileHandler('mqttui.log', maxBytes=10000, backupCount=1) 55 | handler.setLevel(log_level) 56 | handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) 57 | logging.getLogger('').addHandler(handler) 58 | 59 | app = Flask(__name__, static_url_path='/static') 60 | app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'your-secret-key') 61 | socketio = SocketIO(app, async_mode='threading') 62 | 63 | # Initialize database for message persistence 64 | db = None 65 | if DB_ENABLED: 66 | try: 67 | db = MessageDatabase(DB_PATH, DB_MAX_MESSAGES) 68 | logging.info(f"Database initialized: {DB_PATH}") 69 | except Exception as e: 70 | logging.error(f"Failed to initialize database: {e}") 71 | db = None 72 | 73 | MQTT_RC_CODES = { 74 | 0: "Connection successful", 75 | 1: "Connection refused - incorrect protocol version", 76 | 2: "Connection refused - invalid client identifier", 77 | 3: "Connection refused - server unavailable", 78 | 4: "Connection refused - bad username or password", 79 | 5: "Connection refused - not authorised", 80 | # MQTT v5 specific codes 81 | 16: "Connection refused - no matching subscribers", 82 | 17: "Connection refused - no subscription existed", 83 | 128: "Connection refused - unspecified error", 84 | 129: "Connection refused - malformed packet", 85 | 130: "Connection refused - protocol error", 86 | 131: "Connection refused - implementation specific error", 87 | 132: "Connection refused - unsupported protocol version", 88 | 133: "Connection refused - client identifier not valid", 89 | 134: "Connection refused - bad user name or password", 90 | 135: "Connection refused - not authorized", 91 | 136: "Connection refused - server unavailable", 92 | 137: "Connection refused - server busy", 93 | 138: "Connection refused - banned", 94 | 139: "Connection refused - server shutting down", 95 | 140: "Connection refused - bad authentication method", 96 | 141: "Connection refused - topic name invalid", 97 | 142: "Connection refused - packet too large", 98 | 143: "Connection refused - quota exceeded", 99 | 144: "Connection refused - payload format invalid", 100 | 145: "Connection refused - retain not supported", 101 | 146: "Connection refused - QoS not supported", 102 | 147: "Connection refused - use another server", 103 | 148: "Connection refused - server moved", 104 | 149: "Connection refused - connection rate exceeded" 105 | } 106 | 107 | app.before_request(debug_bar_middleware) 108 | 109 | @app.after_request 110 | def after_request(response): 111 | debug_bar.record('request', 'status_code', response.status_code) 112 | debug_bar.end_request() 113 | return response 114 | 115 | # MQTT setup 116 | mqtt_version = os.getenv('MQTT_VERSION', '3.1.1') 117 | if mqtt_version == '5': 118 | mqtt_client = mqtt.Client(client_id=f"mqttui_{os.getpid()}", protocol=mqtt.MQTTv5) 119 | logging.info("Using MQTT v5") 120 | else: 121 | mqtt_client = mqtt.Client(client_id=f"mqttui_{os.getpid()}", clean_session=True, protocol=mqtt.MQTTv311) 122 | logging.info("Using MQTT v3.1.1") 123 | 124 | mqtt_broker = os.getenv('MQTT_BROKER', 'localhost') 125 | mqtt_port = int(os.getenv('MQTT_PORT', 1883)) 126 | mqtt_username = os.getenv('MQTT_USERNAME') 127 | mqtt_password = os.getenv('MQTT_PASSWORD') 128 | mqtt_keepalive = int(os.getenv('MQTT_KEEPALIVE', 60)) 129 | 130 | logging.info(f"MQTT Setup - Broker: {mqtt_broker}, Port: {mqtt_port}, Username: {'Set' if mqtt_username else 'Not set'}, Password: {'Set' if mqtt_password else 'Not set'}, Version: {mqtt_version}") 131 | 132 | messages = [] 133 | topics = set() 134 | connection_count = 0 135 | active_websockets = 0 136 | error_log = [] 137 | 138 | @socketio.on('connect') 139 | def handle_connect(): 140 | global active_websockets 141 | active_websockets += 1 142 | debug_bar.record('performance', 'active_websockets', active_websockets) 143 | logging.info(f"WebSocket connected. Total active: {active_websockets}") 144 | 145 | @socketio.on('disconnect') 146 | def handle_disconnect(): 147 | global active_websockets 148 | active_websockets -= 1 149 | debug_bar.record('performance', 'active_websockets', active_websockets) 150 | logging.info(f"WebSocket disconnected. Total active: {active_websockets}") 151 | 152 | def on_connect(client, userdata, flags, rc, properties=None): 153 | # MQTT v5 includes 'properties' parameter, v3.1.1 doesn't (fixes issue #8) 154 | global connection_count 155 | error_message = MQTT_RC_CODES.get(rc, f"Unknown error (rc: {rc})") 156 | connection_status = 'Connected' if rc == 0 else f'Failed: {error_message}' 157 | debug_bar.record('mqtt', 'connection_status', connection_status) 158 | 159 | logging.info(f"MQTT Connection attempt - Result: {connection_status}") 160 | logging.info(f"MQTT Connection details - Broker: {mqtt_broker}, Port: {mqtt_port}, Username: {'Set' if mqtt_username else 'Not set'}, Password: {'Set' if mqtt_password else 'Not set'}, Protocol: MQTT v{mqtt_version}") 161 | 162 | if rc == 0: 163 | connection_count += 1 164 | # Subscribe to specified topics (supports filtering - issue #6) 165 | topics_to_subscribe = [topic.strip() for topic in MQTT_TOPICS.split(',')] 166 | for topic in topics_to_subscribe: 167 | client.subscribe(topic) 168 | logging.info(f"Subscribed to topic: {topic}") 169 | logging.info(f"Connected to MQTT broker at {mqtt_broker}:{mqtt_port}. Total connections: {connection_count}") 170 | debug_bar.remove('mqtt', 'connection_attempt') # Remove connection attempt entry 171 | else: 172 | error_log.append(error_message) 173 | debug_bar.record('mqtt', 'last_error', error_message) 174 | logging.error(f"Connection failed: {error_message}") 175 | time.sleep(5) 176 | connect_mqtt() # Retry connection 177 | 178 | def on_disconnect(client, userdata, rc): 179 | global connection_count 180 | connection_count = max(0, connection_count - 1) 181 | error_message = MQTT_RC_CODES.get(rc, f"Unknown error (rc: {rc})") 182 | disconnect_reason = 'Clean disconnect' if rc == 0 else f'Unexpected disconnect: {error_message}' 183 | debug_bar.record('mqtt', 'last_disconnect', disconnect_reason) 184 | error_log.append(f"Disconnected: {disconnect_reason}") 185 | logging.warning(f"Disconnected from MQTT broker: {disconnect_reason}") 186 | 187 | if rc != 0: 188 | logging.info("Attempting to reconnect...") 189 | client.connect_async(mqtt_broker, mqtt_port, mqtt_keepalive) 190 | 191 | def on_message(client, userdata, msg): 192 | try: 193 | payload = msg.payload.decode() 194 | except UnicodeDecodeError: 195 | payload = msg.payload.hex() 196 | 197 | timestamp = datetime.now() 198 | message = { 199 | 'topic': msg.topic, 200 | 'payload': payload, 201 | 'timestamp': timestamp.isoformat() 202 | } 203 | 204 | # Store in memory for backward compatibility 205 | messages.append(message) 206 | topics.add(msg.topic) 207 | if len(messages) > 100: 208 | messages.pop(0) 209 | 210 | # Store in database if enabled 211 | if db: 212 | try: 213 | db.store_message( 214 | topic=msg.topic, 215 | payload=payload, 216 | timestamp=timestamp, 217 | qos=msg.qos, 218 | retain=msg.retain 219 | ) 220 | except Exception as e: 221 | logging.error(f"Failed to store message in database: {e}") 222 | 223 | # Emit to connected clients 224 | socketio.emit('mqtt_message', message) 225 | debug_bar.record('mqtt', 'last_message', message) 226 | logging.debug(f"MQTT message received: {message}") 227 | 228 | mqtt_client.on_connect = on_connect 229 | mqtt_client.on_message = on_message 230 | mqtt_client.on_disconnect = on_disconnect 231 | 232 | # API endpoints for message history 233 | @app.route('/api/messages') 234 | def get_message_history(): 235 | """Get paginated message history with enhanced filtering""" 236 | try: 237 | # Get query parameters 238 | limit = min(int(request.args.get('limit', 100)), 1000) # Max 1000 messages 239 | offset = int(request.args.get('offset', 0)) 240 | 241 | # Basic filters 242 | topic_filter = request.args.get('topic') 243 | hours = request.args.get('hours') # Messages from last N hours 244 | 245 | # Enhanced filters 246 | content_search = request.args.get('content') # Search in message content 247 | regex_topic = request.args.get('regex_topic') # Regex pattern for topic 248 | json_path = request.args.get('json_path') # JSON path (e.g., "temperature") 249 | json_value = request.args.get('json_value') # Expected value at JSON path 250 | 251 | since = None 252 | if hours: 253 | since = datetime.now() - timedelta(hours=int(hours)) 254 | 255 | if db: 256 | # Get from database with enhanced filtering 257 | messages_list = db.get_messages( 258 | limit=limit, 259 | offset=offset, 260 | topic_filter=topic_filter, 261 | since=since, 262 | content_search=content_search, 263 | regex_topic=regex_topic, 264 | json_path=json_path, 265 | json_value=json_value 266 | ) 267 | # Note: total_count doesn't account for Python filters, so it's approximate 268 | total_count = db.get_message_count(topic_filter=topic_filter, since=since) 269 | else: 270 | # Fallback to in-memory messages (basic filtering only) 271 | messages_list = list(reversed(messages)) # Most recent first 272 | if topic_filter: 273 | messages_list = [m for m in messages_list if m['topic'] == topic_filter] 274 | if content_search: 275 | messages_list = [m for m in messages_list if content_search.lower() in m['payload'].lower()] 276 | 277 | total_count = len(messages_list) 278 | messages_list = messages_list[offset:offset+limit] 279 | 280 | return jsonify({ 281 | 'messages': messages_list, 282 | 'total': total_count, 283 | 'limit': limit, 284 | 'offset': offset, 285 | 'has_more': offset + len(messages_list) < total_count, 286 | 'filters_applied': { 287 | 'topic': topic_filter, 288 | 'content': content_search, 289 | 'regex_topic': regex_topic, 290 | 'json_path': json_path, 291 | 'json_value': json_value, 292 | 'hours': hours 293 | } 294 | }) 295 | 296 | except Exception as e: 297 | logging.error(f"Error getting message history: {e}") 298 | return jsonify({'error': str(e)}), 500 299 | 300 | @app.route('/api/topics') 301 | def get_topic_list(): 302 | """Get list of all topics with statistics""" 303 | try: 304 | if db: 305 | topics_list = db.get_topics() 306 | else: 307 | # Fallback to in-memory topics 308 | topics_list = [{'topic': topic} for topic in sorted(topics)] 309 | 310 | return jsonify({'topics': topics_list}) 311 | 312 | except Exception as e: 313 | logging.error(f"Error getting topics: {e}") 314 | return jsonify({'error': str(e)}), 500 315 | 316 | @app.route('/api/database/stats') 317 | def get_database_stats(): 318 | """Get database size and statistics""" 319 | try: 320 | if db: 321 | stats = db.get_database_size() 322 | stats['enabled'] = True 323 | else: 324 | stats = { 325 | 'enabled': False, 326 | 'message_count': len(messages), 327 | 'topic_count': len(topics) 328 | } 329 | 330 | return jsonify(stats) 331 | 332 | except Exception as e: 333 | logging.error(f"Error getting database stats: {e}") 334 | return jsonify({'error': str(e)}), 500 335 | 336 | @app.route('/api/database/cleanup', methods=['POST']) 337 | def cleanup_database(): 338 | """Clean up old database records""" 339 | try: 340 | if not db: 341 | return jsonify({'error': 'Database not enabled'}), 400 342 | 343 | days = int(request.json.get('days', DB_CLEANUP_DAYS)) 344 | deleted = db.cleanup_old_data(days) 345 | 346 | return jsonify({ 347 | 'success': True, 348 | 'deleted_messages': deleted, 349 | 'days': days 350 | }) 351 | 352 | except Exception as e: 353 | logging.error(f"Error cleaning database: {e}") 354 | return jsonify({'error': str(e)}), 500 355 | 356 | # Filter presets API endpoints 357 | @app.route('/api/filter-presets') 358 | def get_filter_presets(): 359 | """Get all saved filter presets""" 360 | try: 361 | if not db: 362 | return jsonify({'error': 'Database not enabled'}), 400 363 | 364 | presets = db.get_filter_presets() 365 | return jsonify({'presets': presets}) 366 | 367 | except Exception as e: 368 | logging.error(f"Error getting filter presets: {e}") 369 | return jsonify({'error': str(e)}), 500 370 | 371 | @app.route('/api/filter-presets', methods=['POST']) 372 | def save_filter_preset(): 373 | """Save a new filter preset""" 374 | try: 375 | if not db: 376 | return jsonify({'error': 'Database not enabled'}), 400 377 | 378 | data = request.json 379 | name = data.get('name') 380 | description = data.get('description', '') 381 | filters = data.get('filters', {}) 382 | 383 | if not name or not filters: 384 | return jsonify({'error': 'Name and filters are required'}), 400 385 | 386 | success = db.save_filter_preset(name, filters, description) 387 | 388 | if success: 389 | return jsonify({'success': True, 'message': f'Saved preset: {name}'}) 390 | else: 391 | return jsonify({'error': 'Failed to save preset'}), 500 392 | 393 | except Exception as e: 394 | logging.error(f"Error saving filter preset: {e}") 395 | return jsonify({'error': str(e)}), 500 396 | 397 | @app.route('/api/filter-presets/', methods=['DELETE']) 398 | def delete_filter_preset(name): 399 | """Delete a filter preset""" 400 | try: 401 | if not db: 402 | return jsonify({'error': 'Database not enabled'}), 400 403 | 404 | success = db.delete_filter_preset(name) 405 | 406 | if success: 407 | return jsonify({'success': True, 'message': f'Deleted preset: {name}'}) 408 | else: 409 | return jsonify({'error': 'Preset not found'}), 404 410 | 411 | except Exception as e: 412 | logging.error(f"Error deleting filter preset: {e}") 413 | return jsonify({'error': str(e)}), 500 414 | 415 | @app.route('/api/filter-presets//use', methods=['POST']) 416 | def use_filter_preset(name): 417 | """Load and use a filter preset""" 418 | try: 419 | if not db: 420 | return jsonify({'error': 'Database not enabled'}), 400 421 | 422 | filters = db.use_filter_preset(name) 423 | 424 | if filters: 425 | return jsonify({'success': True, 'filters': filters}) 426 | else: 427 | return jsonify({'error': 'Preset not found'}), 404 428 | 429 | except Exception as e: 430 | logging.error(f"Error using filter preset: {e}") 431 | return jsonify({'error': str(e)}), 500 432 | 433 | @app.route('/') 434 | def index(): 435 | return render_template('index.html', messages=messages, topics=list(topics)) 436 | 437 | @app.route('/publish', methods=['POST']) 438 | def publish_message(): 439 | topic = request.form['topic'] 440 | message = request.form['message'] 441 | mqtt_client.publish(topic, message) 442 | debug_bar.record('mqtt', 'last_publish', {'topic': topic, 'message': message}) 443 | return jsonify(success=True) 444 | 445 | @app.route('/stats') 446 | def get_stats(): 447 | return jsonify({ 448 | 'connection_count': connection_count, 449 | 'topic_count': len(topics), 450 | 'message_count': len(messages), 451 | 'errors': error_log 452 | }) 453 | 454 | @app.route('/static/') 455 | def send_static(path): 456 | return send_from_directory('static', path) 457 | 458 | @app.route('/debug-bar') 459 | def get_debug_bar_data(): 460 | try: 461 | data = debug_bar.get_data() 462 | return jsonify(data) 463 | except Exception as e: 464 | logging.error(f"Error fetching debug bar data: {e}") 465 | return jsonify({"error": "Failed to fetch debug bar data"}), 500 466 | 467 | @app.route('/toggle-debug-bar', methods=['POST']) 468 | def toggle_debug_bar(): 469 | if debug_bar.enabled: 470 | debug_bar.disable() 471 | else: 472 | debug_bar.enable() 473 | return jsonify(enabled=debug_bar.enabled) 474 | 475 | @app.route('/record-client-performance', methods=['POST']) 476 | def record_client_performance(): 477 | data = request.json 478 | debug_bar.record('performance', 'page_load_time', f"{data['pageLoadTime']}ms") 479 | debug_bar.record('performance', 'dom_ready_time', f"{data['domReadyTime']}ms") 480 | return jsonify(success=True) 481 | 482 | @app.route('/version') 483 | def get_version(): 484 | return jsonify({'version': __version__}) 485 | 486 | if __name__ == '__main__' or __name__ == 'app': 487 | if mqtt_username and mqtt_password: 488 | mqtt_client.username_pw_set(mqtt_username, mqtt_password) 489 | 490 | def connect_mqtt(): 491 | if mqtt_username and mqtt_password: 492 | mqtt_client.username_pw_set(mqtt_username, mqtt_password) 493 | logging.info("MQTT credentials set") 494 | else: 495 | logging.info("No MQTT credentials provided") 496 | 497 | debug_bar.record('mqtt', 'connection_attempt', f"Connecting to {mqtt_broker}:{mqtt_port}") 498 | debug_bar.record('mqtt', 'broker', mqtt_broker) 499 | debug_bar.record('mqtt', 'port', mqtt_port) 500 | debug_bar.record('mqtt', 'username', mqtt_username if mqtt_username else 'Not set') 501 | debug_bar.record('mqtt', 'password', 'Set' if mqtt_password else 'Not set') 502 | debug_bar.record('mqtt', 'protocol', f'MQTT v{mqtt_version}') 503 | debug_bar.record('mqtt', 'subscribed_topics', MQTT_TOPICS) 504 | 505 | logging.info(f"Attempting to connect to MQTT broker at {mqtt_broker}:{mqtt_port}") 506 | 507 | try: 508 | mqtt_client.connect(mqtt_broker, mqtt_port, mqtt_keepalive) 509 | mqtt_client.loop_start() 510 | except Exception as e: 511 | error_message = f"Failed to connect to MQTT broker: {str(e)}" 512 | debug_bar.record('mqtt', 'connection_error', error_message) 513 | debug_bar.record('mqtt', 'connection_status', 'Failed') 514 | error_log.append(error_message) 515 | logging.error(error_message) 516 | time.sleep(5) 517 | connect_mqtt() # Retry connection 518 | debug_bar.record('mqtt', 'connection_status', 'Failed') 519 | 520 | connect_mqtt() 521 | 522 | # Start the Flask-SocketIO server when running directly 523 | # This prevents the app from exiting prematurely (fixes issue #12) 524 | if __name__ == '__main__': 525 | socketio.run(app, host=HOST, port=PORT, debug=DEBUG) -------------------------------------------------------------------------------- /database.py: -------------------------------------------------------------------------------- 1 | """ 2 | Database module for MQTT message persistence 3 | """ 4 | import sqlite3 5 | import threading 6 | import logging 7 | import re 8 | import json 9 | from datetime import datetime, timedelta 10 | from typing import List, Dict, Optional, Union 11 | import os 12 | 13 | class MessageDatabase: 14 | def __init__(self, db_path: str = "mqtt_messages.db", max_messages: int = 10000): 15 | self.db_path = db_path 16 | self.max_messages = max_messages 17 | self._local = threading.local() 18 | self.init_database() 19 | 20 | def get_connection(self): 21 | """Get thread-local database connection""" 22 | if not hasattr(self._local, 'connection'): 23 | self._local.connection = sqlite3.connect( 24 | self.db_path, 25 | check_same_thread=False, 26 | timeout=30.0 27 | ) 28 | self._local.connection.row_factory = sqlite3.Row 29 | 30 | # Add REGEXP function for advanced topic filtering 31 | self._local.connection.create_function("REGEXP", 2, self._regexp) 32 | 33 | return self._local.connection 34 | 35 | def _regexp(self, pattern, value): 36 | """Custom REGEXP function for SQLite""" 37 | try: 38 | return re.search(pattern, value, re.IGNORECASE) is not None 39 | except Exception: 40 | return False 41 | 42 | def init_database(self): 43 | """Initialize database schema""" 44 | conn = self.get_connection() 45 | try: 46 | cursor = conn.cursor() 47 | 48 | # Messages table 49 | cursor.execute(''' 50 | CREATE TABLE IF NOT EXISTS messages ( 51 | id INTEGER PRIMARY KEY AUTOINCREMENT, 52 | topic TEXT NOT NULL, 53 | payload TEXT NOT NULL, 54 | timestamp DATETIME NOT NULL, 55 | qos INTEGER DEFAULT 0, 56 | retain BOOLEAN DEFAULT 0, 57 | payload_size INTEGER DEFAULT 0, 58 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP 59 | ) 60 | ''') 61 | 62 | # Topics table for faster topic queries 63 | cursor.execute(''' 64 | CREATE TABLE IF NOT EXISTS topics ( 65 | topic TEXT PRIMARY KEY, 66 | last_message_at DATETIME NOT NULL, 67 | message_count INTEGER DEFAULT 1, 68 | first_seen DATETIME DEFAULT CURRENT_TIMESTAMP 69 | ) 70 | ''') 71 | 72 | # Filter presets table for saved searches 73 | cursor.execute(''' 74 | CREATE TABLE IF NOT EXISTS filter_presets ( 75 | id INTEGER PRIMARY KEY AUTOINCREMENT, 76 | name TEXT UNIQUE NOT NULL, 77 | description TEXT, 78 | filters TEXT NOT NULL, -- JSON string of filter parameters 79 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 80 | last_used DATETIME 81 | ) 82 | ''') 83 | 84 | # Create indexes for better performance 85 | cursor.execute(''' 86 | CREATE INDEX IF NOT EXISTS idx_messages_topic 87 | ON messages(topic) 88 | ''') 89 | 90 | cursor.execute(''' 91 | CREATE INDEX IF NOT EXISTS idx_messages_timestamp 92 | ON messages(timestamp DESC) 93 | ''') 94 | 95 | cursor.execute(''' 96 | CREATE INDEX IF NOT EXISTS idx_messages_topic_timestamp 97 | ON messages(topic, timestamp DESC) 98 | ''') 99 | 100 | conn.commit() 101 | logging.info("Database initialized successfully") 102 | 103 | except Exception as e: 104 | logging.error(f"Database initialization error: {e}") 105 | conn.rollback() 106 | raise 107 | 108 | def store_message(self, topic: str, payload: str, timestamp: datetime = None, 109 | qos: int = 0, retain: bool = False) -> bool: 110 | """Store a message in the database""" 111 | if timestamp is None: 112 | timestamp = datetime.now() 113 | 114 | conn = self.get_connection() 115 | try: 116 | cursor = conn.cursor() 117 | 118 | # Store message 119 | cursor.execute(''' 120 | INSERT INTO messages (topic, payload, timestamp, qos, retain, payload_size) 121 | VALUES (?, ?, ?, ?, ?, ?) 122 | ''', (topic, payload, timestamp, qos, retain, len(payload))) 123 | 124 | # Update topics table 125 | cursor.execute(''' 126 | INSERT OR REPLACE INTO topics (topic, last_message_at, message_count, first_seen) 127 | VALUES (?, ?, 128 | COALESCE((SELECT message_count FROM topics WHERE topic = ?) + 1, 1), 129 | COALESCE((SELECT first_seen FROM topics WHERE topic = ?), ?) 130 | ) 131 | ''', (topic, timestamp, topic, topic, timestamp)) 132 | 133 | conn.commit() 134 | 135 | # Clean up old messages if needed 136 | self._cleanup_old_messages() 137 | return True 138 | 139 | except Exception as e: 140 | logging.error(f"Error storing message: {e}") 141 | conn.rollback() 142 | return False 143 | 144 | def get_messages(self, limit: int = 100, offset: int = 0, 145 | topic_filter: str = None, since: datetime = None, 146 | content_search: str = None, regex_topic: str = None, 147 | json_path: str = None, json_value: str = None) -> List[Dict]: 148 | """Retrieve messages from database with enhanced filtering""" 149 | conn = self.get_connection() 150 | try: 151 | cursor = conn.cursor() 152 | 153 | query = ''' 154 | SELECT topic, payload, timestamp, qos, retain, payload_size 155 | FROM messages 156 | WHERE 1=1 157 | ''' 158 | params = [] 159 | 160 | # Basic topic filtering (wildcard support) 161 | if topic_filter: 162 | if '%' in topic_filter or '_' in topic_filter: 163 | query += ' AND topic LIKE ?' 164 | else: 165 | query += ' AND topic = ?' 166 | params.append(topic_filter) 167 | 168 | # Regex topic filtering (more powerful than LIKE) 169 | if regex_topic: 170 | query += ' AND topic REGEXP ?' 171 | params.append(regex_topic) 172 | 173 | # Content search (case-insensitive) 174 | if content_search: 175 | query += ' AND LOWER(payload) LIKE LOWER(?)' 176 | params.append(f'%{content_search}%') 177 | 178 | # Time filtering 179 | if since: 180 | query += ' AND timestamp >= ?' 181 | params.append(since) 182 | 183 | query += ' ORDER BY timestamp DESC LIMIT ? OFFSET ?' 184 | params.extend([limit, offset]) 185 | 186 | cursor.execute(query, params) 187 | rows = cursor.fetchall() 188 | 189 | messages = [dict(row) for row in rows] 190 | 191 | # Apply Python-based filters that can't be done in SQL 192 | if json_path or regex_topic: 193 | messages = self._apply_python_filters( 194 | messages, regex_topic, json_path, json_value 195 | ) 196 | 197 | return messages 198 | 199 | except Exception as e: 200 | logging.error(f"Error retrieving messages: {e}") 201 | return [] 202 | 203 | def _apply_python_filters(self, messages: List[Dict], regex_topic: str = None, 204 | json_path: str = None, json_value: str = None) -> List[Dict]: 205 | """Apply filters that require Python processing""" 206 | filtered = [] 207 | 208 | for msg in messages: 209 | # Regex topic filter (if not handled by SQL REGEXP) 210 | if regex_topic and not re.search(regex_topic, msg['topic'], re.IGNORECASE): 211 | continue 212 | 213 | # JSON path filtering 214 | if json_path and json_value: 215 | try: 216 | payload_json = json.loads(msg['payload']) 217 | # Simple JSON path support (e.g., "temperature", "sensors.temp") 218 | value = self._get_json_path_value(payload_json, json_path) 219 | if value is None or str(value).lower() != json_value.lower(): 220 | continue 221 | except (json.JSONDecodeError, KeyError): 222 | continue 223 | 224 | filtered.append(msg) 225 | 226 | return filtered 227 | 228 | def _get_json_path_value(self, json_obj: dict, path: str): 229 | """Extract value from JSON using simple dot notation path""" 230 | try: 231 | keys = path.split('.') 232 | current = json_obj 233 | for key in keys: 234 | if isinstance(current, dict) and key in current: 235 | current = current[key] 236 | else: 237 | return None 238 | return current 239 | except Exception: 240 | return None 241 | 242 | def get_topics(self) -> List[Dict]: 243 | """Get all topics with statistics""" 244 | conn = self.get_connection() 245 | try: 246 | cursor = conn.cursor() 247 | cursor.execute(''' 248 | SELECT topic, message_count, last_message_at, first_seen 249 | FROM topics 250 | ORDER BY last_message_at DESC 251 | ''') 252 | rows = cursor.fetchall() 253 | return [dict(row) for row in rows] 254 | 255 | except Exception as e: 256 | logging.error(f"Error retrieving topics: {e}") 257 | return [] 258 | 259 | def get_message_count(self, topic_filter: str = None, since: datetime = None) -> int: 260 | """Get total message count with optional filtering""" 261 | conn = self.get_connection() 262 | try: 263 | cursor = conn.cursor() 264 | 265 | query = 'SELECT COUNT(*) FROM messages WHERE 1=1' 266 | params = [] 267 | 268 | if topic_filter: 269 | if '%' in topic_filter or '_' in topic_filter: 270 | query += ' AND topic LIKE ?' 271 | else: 272 | query += ' AND topic = ?' 273 | params.append(topic_filter) 274 | 275 | if since: 276 | query += ' AND timestamp >= ?' 277 | params.append(since) 278 | 279 | cursor.execute(query, params) 280 | return cursor.fetchone()[0] 281 | 282 | except Exception as e: 283 | logging.error(f"Error counting messages: {e}") 284 | return 0 285 | 286 | def _cleanup_old_messages(self): 287 | """Remove old messages to stay within limit""" 288 | conn = self.get_connection() 289 | try: 290 | cursor = conn.cursor() 291 | 292 | # Check if we need cleanup 293 | cursor.execute('SELECT COUNT(*) FROM messages') 294 | count = cursor.fetchone()[0] 295 | 296 | if count > self.max_messages: 297 | # Delete oldest messages 298 | messages_to_delete = count - self.max_messages + 1000 # Delete extra to avoid frequent cleanups 299 | cursor.execute(''' 300 | DELETE FROM messages 301 | WHERE id IN ( 302 | SELECT id FROM messages 303 | ORDER BY timestamp ASC 304 | LIMIT ? 305 | ) 306 | ''', (messages_to_delete,)) 307 | 308 | # Update topic counts (this is approximate) 309 | cursor.execute(''' 310 | UPDATE topics SET message_count = ( 311 | SELECT COUNT(*) FROM messages WHERE messages.topic = topics.topic 312 | ) 313 | ''') 314 | 315 | conn.commit() 316 | logging.info(f"Cleaned up {messages_to_delete} old messages") 317 | 318 | except Exception as e: 319 | logging.error(f"Error during cleanup: {e}") 320 | conn.rollback() 321 | 322 | def cleanup_old_data(self, days: int = 30): 323 | """Remove messages older than specified days""" 324 | cutoff_date = datetime.now() - timedelta(days=days) 325 | conn = self.get_connection() 326 | try: 327 | cursor = conn.cursor() 328 | cursor.execute('DELETE FROM messages WHERE timestamp < ?', (cutoff_date,)) 329 | deleted = cursor.rowcount 330 | 331 | # Clean up topics with no messages 332 | cursor.execute(''' 333 | DELETE FROM topics WHERE topic NOT IN ( 334 | SELECT DISTINCT topic FROM messages 335 | ) 336 | ''') 337 | 338 | conn.commit() 339 | logging.info(f"Cleaned up {deleted} messages older than {days} days") 340 | return deleted 341 | 342 | except Exception as e: 343 | logging.error(f"Error during data cleanup: {e}") 344 | conn.rollback() 345 | return 0 346 | 347 | def get_database_size(self) -> Dict: 348 | """Get database size information""" 349 | try: 350 | size_bytes = os.path.getsize(self.db_path) if os.path.exists(self.db_path) else 0 351 | conn = self.get_connection() 352 | cursor = conn.cursor() 353 | 354 | cursor.execute('SELECT COUNT(*) FROM messages') 355 | message_count = cursor.fetchone()[0] 356 | 357 | cursor.execute('SELECT COUNT(*) FROM topics') 358 | topic_count = cursor.fetchone()[0] 359 | 360 | return { 361 | 'size_bytes': size_bytes, 362 | 'size_mb': round(size_bytes / 1024 / 1024, 2), 363 | 'message_count': message_count, 364 | 'topic_count': topic_count 365 | } 366 | 367 | except Exception as e: 368 | logging.error(f"Error getting database size: {e}") 369 | return {'size_bytes': 0, 'size_mb': 0, 'message_count': 0, 'topic_count': 0} 370 | 371 | def save_filter_preset(self, name: str, filters: Dict, description: str = None) -> bool: 372 | """Save a filter preset""" 373 | conn = self.get_connection() 374 | try: 375 | cursor = conn.cursor() 376 | cursor.execute(''' 377 | INSERT OR REPLACE INTO filter_presets (name, description, filters, last_used) 378 | VALUES (?, ?, ?, ?) 379 | ''', (name, description, json.dumps(filters), datetime.now())) 380 | 381 | conn.commit() 382 | logging.info(f"Saved filter preset: {name}") 383 | return True 384 | 385 | except Exception as e: 386 | logging.error(f"Error saving filter preset: {e}") 387 | conn.rollback() 388 | return False 389 | 390 | def get_filter_presets(self) -> List[Dict]: 391 | """Get all filter presets""" 392 | conn = self.get_connection() 393 | try: 394 | cursor = conn.cursor() 395 | cursor.execute(''' 396 | SELECT name, description, filters, created_at, last_used 397 | FROM filter_presets 398 | ORDER BY last_used DESC, created_at DESC 399 | ''') 400 | rows = cursor.fetchall() 401 | 402 | presets = [] 403 | for row in rows: 404 | preset = dict(row) 405 | preset['filters'] = json.loads(preset['filters']) 406 | presets.append(preset) 407 | 408 | return presets 409 | 410 | except Exception as e: 411 | logging.error(f"Error getting filter presets: {e}") 412 | return [] 413 | 414 | def delete_filter_preset(self, name: str) -> bool: 415 | """Delete a filter preset""" 416 | conn = self.get_connection() 417 | try: 418 | cursor = conn.cursor() 419 | cursor.execute('DELETE FROM filter_presets WHERE name = ?', (name,)) 420 | conn.commit() 421 | 422 | if cursor.rowcount > 0: 423 | logging.info(f"Deleted filter preset: {name}") 424 | return True 425 | return False 426 | 427 | except Exception as e: 428 | logging.error(f"Error deleting filter preset: {e}") 429 | conn.rollback() 430 | return False 431 | 432 | def use_filter_preset(self, name: str) -> Optional[Dict]: 433 | """Load and mark a filter preset as used""" 434 | conn = self.get_connection() 435 | try: 436 | cursor = conn.cursor() 437 | 438 | # Get the preset 439 | cursor.execute(''' 440 | SELECT filters FROM filter_presets WHERE name = ? 441 | ''', (name,)) 442 | row = cursor.fetchone() 443 | 444 | if not row: 445 | return None 446 | 447 | # Update last_used timestamp 448 | cursor.execute(''' 449 | UPDATE filter_presets SET last_used = ? WHERE name = ? 450 | ''', (datetime.now(), name)) 451 | 452 | conn.commit() 453 | return json.loads(row['filters']) 454 | 455 | except Exception as e: 456 | logging.error(f"Error using filter preset: {e}") 457 | return None 458 | 459 | def close(self): 460 | """Close database connection""" 461 | if hasattr(self._local, 'connection'): 462 | self._local.connection.close() -------------------------------------------------------------------------------- /debug_bar.py: -------------------------------------------------------------------------------- 1 | import time 2 | import psutil 3 | from flask import request 4 | from threading import Lock 5 | import logging 6 | 7 | class DebugBarPanel: 8 | def __init__(self, name): 9 | self.name = name 10 | self.data = {} 11 | 12 | def record(self, key, value): 13 | self.data[key] = value 14 | 15 | def get_data(self): 16 | return self.data 17 | 18 | class DebugBar: 19 | def __init__(self): 20 | self.panels = {} 21 | self.enabled = False 22 | self.start_time = None 23 | self.lock = Lock() 24 | try: 25 | self.process = psutil.Process() 26 | except Exception as e: 27 | logging.error(f"Failed to initialize psutil Process: {e}") 28 | self.process = None 29 | 30 | def add_panel(self, name): 31 | with self.lock: 32 | if name not in self.panels: 33 | self.panels[name] = DebugBarPanel(name) 34 | 35 | def record(self, panel, key, value): 36 | with self.lock: 37 | if panel in self.panels: 38 | self.panels[panel].record(key, value) 39 | 40 | def start_request(self): 41 | self.start_time = time.time() 42 | 43 | def end_request(self): 44 | if self.start_time: 45 | duration = time.time() - self.start_time 46 | self.record('request', 'duration', f"{duration:.2f}s") 47 | self.start_time = None 48 | 49 | def get_data(self): 50 | with self.lock: 51 | #self.update_performance_metrics() 52 | return {name: panel.get_data() for name, panel in self.panels.items()} 53 | 54 | def enable(self): 55 | self.enabled = True 56 | 57 | def disable(self): 58 | self.enabled = False 59 | 60 | def remove(self, panel_name, key): 61 | with self.lock: 62 | if panel_name in self.panels: 63 | panel = self.panels[panel_name] 64 | if key in panel.data: 65 | del panel.data[key] 66 | 67 | debug_bar = DebugBar() 68 | 69 | # Initialize default panels 70 | debug_bar.add_panel('mqtt') 71 | debug_bar.add_panel('request') 72 | debug_bar.add_panel('performance') 73 | 74 | def debug_bar_middleware(): 75 | debug_bar.start_request() 76 | debug_bar.record('request', 'path', request.path) 77 | debug_bar.record('request', 'method', request.method) -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mosquitto: 3 | image: eclipse-mosquitto:latest 4 | container_name: mosquitto 5 | ports: 6 | - "1883:1883" 7 | - "9002:9001" 8 | volumes: 9 | - ./mosquitto.conf:/mosquitto/config/mosquitto.conf 10 | 11 | web: 12 | container_name: mqttui 13 | build: . 14 | ports: 15 | - "8088:5000" 16 | environment: 17 | - DEBUG=False 18 | - HOST=0.0.0.0 19 | - PORT=5000 20 | - MQTT_BROKER=mosquitto 21 | - MQTT_PORT=1883 22 | - MQTT_USERNAME= 23 | - MQTT_PASSWORD= 24 | - MQTT_KEEPALIVE=60 25 | - MQTT_VERSION=3.1.1 26 | - SECRET_KEY=your-secret-key 27 | - LOG_LEVEL=INFO 28 | - MQTT_TOPICS=# 29 | - DB_ENABLED=True 30 | - DB_PATH=/app/data/mqtt_messages.db 31 | - DB_MAX_MESSAGES=10000 32 | - DB_CLEANUP_DAYS=30 33 | volumes: 34 | - ./:/app 35 | depends_on: 36 | - mosquitto -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Default to port 5000 if PORT is not set 4 | PORT="${PORT:-5000}" 5 | 6 | # Set default LOG_LEVEL if not provided 7 | LOG_LEVEL="${LOG_LEVEL:-info}" 8 | 9 | # Convert to lowercase for comparison (sh compatible) 10 | LOG_LEVEL_LOWER=$(echo "$LOG_LEVEL" | tr '[:upper:]' '[:lower:]') 11 | 12 | # Validate and normalize log level for gunicorn 13 | case "$LOG_LEVEL_LOWER" in 14 | debug|info|warning|warn|error|critical) 15 | # Convert WARN to WARNING for gunicorn compatibility 16 | if [ "$LOG_LEVEL_LOWER" = "warn" ]; then 17 | LOG_LEVEL="warning" 18 | else 19 | LOG_LEVEL="$LOG_LEVEL_LOWER" 20 | fi 21 | ;; 22 | *) 23 | echo "Invalid LOG_LEVEL: $LOG_LEVEL. Using default: info" 24 | LOG_LEVEL="info" 25 | ;; 26 | esac 27 | 28 | if [ "$DEBUG" = "True" ] || [ "$DEBUG" = "1" ] || [ "$DEBUG" = "true" ]; then 29 | echo "Running in DEBUG mode with log level: $LOG_LEVEL" 30 | exec python -c "from app import app, socketio; socketio.run(app, host='0.0.0.0', port=$PORT, debug=True)" 31 | else 32 | echo "Running in PRODUCTION mode with log level: $LOG_LEVEL" 33 | exec gunicorn --log-level "$LOG_LEVEL" --worker-class eventlet -w 1 -b "0.0.0.0:$PORT" app:app 34 | fi 35 | -------------------------------------------------------------------------------- /mosquitto.conf: -------------------------------------------------------------------------------- 1 | listener 1883 2 | allow_anonymous true 3 | persistence false 4 | 5 | # WebSocket support 6 | listener 9001 7 | protocol websockets -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.0.1 2 | Flask-SocketIO==5.1.1 3 | paho-mqtt==1.5.1 4 | Werkzeug==2.0.1 5 | psutil==5.9.0 6 | python-dotenv==0.19.0 7 | gunicorn==20.1.0 8 | eventlet==0.30.2 -------------------------------------------------------------------------------- /static/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terdia/mqttui/5aadaa92b044e46d0b4b63bca55b4081d77ea4b9/static/screenshot.png -------------------------------------------------------------------------------- /static/screenshot_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terdia/mqttui/5aadaa92b044e46d0b4b63bca55b4081d77ea4b9/static/screenshot_1.png -------------------------------------------------------------------------------- /static/screenshot_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terdia/mqttui/5aadaa92b044e46d0b4b63bca55b4081d77ea4b9/static/screenshot_2.png -------------------------------------------------------------------------------- /static/script.js: -------------------------------------------------------------------------------- 1 | const socket = io(); 2 | let messageChart; 3 | let network; 4 | let nodes; 5 | let edges; 6 | let topicFilter = 'all'; 7 | 8 | function initChart() { 9 | const ctx = document.getElementById('messageChart').getContext('2d'); 10 | messageChart = new Chart(ctx, { 11 | type: 'line', 12 | data: { 13 | labels: [], 14 | datasets: [{ 15 | label: 'Messages per second', 16 | data: [], 17 | borderColor: 'rgb(59, 130, 246)', 18 | tension: 0.1 19 | }] 20 | }, 21 | options: { 22 | responsive: true, 23 | scales: { 24 | y: { 25 | beginAtZero: true, 26 | ticks: { 27 | color: 'rgb(209, 213, 219)' 28 | } 29 | }, 30 | x: { 31 | ticks: { 32 | color: 'rgb(209, 213, 219)' 33 | } 34 | } 35 | }, 36 | plugins: { 37 | legend: { 38 | labels: { 39 | color: 'rgb(209, 213, 219)' 40 | } 41 | } 42 | } 43 | } 44 | }); 45 | } 46 | 47 | function updateMessageList(message) { 48 | if (topicFilter === 'all' || message.topic === topicFilter) { 49 | const messageList = document.getElementById('message-list'); 50 | const messageElement = document.createElement('div'); 51 | messageElement.className = 'mb-2 p-2 bg-gray-700 rounded'; 52 | messageElement.innerHTML = `${message.topic}: ${message.payload}`; 53 | messageList.insertBefore(messageElement, messageList.firstChild); 54 | 55 | if (messageList.childElementCount > 100) { 56 | messageList.removeChild(messageList.lastChild); 57 | } 58 | } 59 | } 60 | 61 | function updateChart() { 62 | const now = new Date(); 63 | messageChart.data.labels.push(now.toLocaleTimeString()); 64 | messageChart.data.datasets[0].data.push(messageCount); 65 | 66 | if (messageChart.data.labels.length > 10) { 67 | messageChart.data.labels.shift(); 68 | messageChart.data.datasets[0].data.shift(); 69 | } 70 | 71 | messageChart.update(); 72 | messageCount = 0; 73 | } 74 | 75 | let messageCount = 0; 76 | 77 | function initNetwork() { 78 | nodes = new vis.DataSet([ 79 | { id: 'broker', label: 'MQTT Broker', shape: 'hexagon', color: '#FFA500', size: 30 } 80 | ]); 81 | edges = new vis.DataSet(); 82 | 83 | const container = document.getElementById('network-visualization'); 84 | const data = { nodes, edges }; 85 | const options = { 86 | physics: { 87 | enabled: true, 88 | stabilization: { 89 | enabled: true, 90 | iterations: 150, 91 | updateInterval: 25, 92 | fit: true 93 | }, 94 | barnesHut: { 95 | gravitationalConstant: -8000, 96 | centralGravity: 0.3, 97 | springLength: 200, 98 | springConstant: 0.04, 99 | damping: 0.09, 100 | avoidOverlap: 0.1 101 | } 102 | }, 103 | nodes: { 104 | font: { 105 | color: '#FFFFFF', 106 | size: 14 107 | }, 108 | borderWidth: 2, 109 | shadow: true, 110 | margin: 10 111 | }, 112 | edges: { 113 | width: 2, 114 | color: { inherit: 'from' }, 115 | smooth: { 116 | type: 'continuous' 117 | }, 118 | arrows: { 119 | to: { enabled: true, scaleFactor: 0.5 } 120 | } 121 | }, 122 | interaction: { 123 | dragNodes: true, 124 | dragView: true, 125 | zoomView: true 126 | }, 127 | layout: { 128 | improvedLayout: true, 129 | clusterThreshold: 150 130 | } 131 | }; 132 | 133 | network = new vis.Network(container, data, options); 134 | 135 | // Auto-fit network after stabilization 136 | network.on('stabilizationIterationsDone', function() { 137 | network.fit({ 138 | animation: { 139 | duration: 1000, 140 | easingFunction: 'easeInOutQuad' 141 | } 142 | }); 143 | }); 144 | 145 | // Add node pinning functionality 146 | let pinnedNodes = new Set(); 147 | 148 | // Double-click to pin/unpin nodes 149 | network.on('doubleClick', function(params) { 150 | if (params.nodes.length > 0) { 151 | const nodeId = params.nodes[0]; 152 | if (pinnedNodes.has(nodeId)) { 153 | // Unpin node 154 | pinnedNodes.delete(nodeId); 155 | nodes.update({ 156 | id: nodeId, 157 | fixed: false, 158 | color: nodes.get(nodeId).color || '#97C2FC' 159 | }); 160 | } else { 161 | // Pin node 162 | pinnedNodes.add(nodeId); 163 | nodes.update({ 164 | id: nodeId, 165 | fixed: true, 166 | color: '#FF6B6B' // Red color to indicate pinned 167 | }); 168 | } 169 | } 170 | }); 171 | 172 | // Right-click context menu for pinning (optional) 173 | network.on('oncontext', function(params) { 174 | params.event.preventDefault(); 175 | if (params.nodes.length > 0) { 176 | const nodeId = params.nodes[0]; 177 | const isPinned = pinnedNodes.has(nodeId); 178 | const action = isPinned ? 'Unpin' : 'Pin'; 179 | 180 | if (confirm(`${action} node "${nodes.get(nodeId).label}"?`)) { 181 | if (isPinned) { 182 | pinnedNodes.delete(nodeId); 183 | nodes.update({ 184 | id: nodeId, 185 | fixed: false, 186 | color: nodes.get(nodeId).color || '#97C2FC' 187 | }); 188 | } else { 189 | pinnedNodes.add(nodeId); 190 | nodes.update({ 191 | id: nodeId, 192 | fixed: true, 193 | color: '#FF6B6B' 194 | }); 195 | } 196 | } 197 | } 198 | }); 199 | } 200 | 201 | function updateNetwork(message) { 202 | if (!nodes || !edges || !network) { 203 | console.error('Network not initialized'); 204 | return; 205 | } 206 | 207 | const topicParts = message.topic.split('/'); 208 | let parentId = 'broker'; 209 | 210 | topicParts.forEach((part, index) => { 211 | const nodeId = topicParts.slice(0, index + 1).join('/'); 212 | if (!nodes.get(nodeId)) { 213 | nodes.add({ 214 | id: nodeId, 215 | label: part, 216 | color: getRandomColor(), 217 | shape: 'dot', 218 | size: 20 - index * 2 219 | }); 220 | 221 | // Auto-fit network when new nodes are added (with delay to avoid excessive fitting) 222 | clearTimeout(window.autoFitTimeout); 223 | window.autoFitTimeout = setTimeout(() => { 224 | if (network) { 225 | network.fit({ 226 | animation: { 227 | duration: 800, 228 | easingFunction: 'easeInOutQuad' 229 | } 230 | }); 231 | } 232 | }, 2000); 233 | } 234 | if (parentId !== nodeId) { 235 | const edgeId = `${parentId}-${nodeId}`; 236 | if (!edges.get(edgeId)) { 237 | edges.add({ id: edgeId, from: parentId, to: nodeId }); 238 | } 239 | } 240 | parentId = nodeId; 241 | }); 242 | 243 | // Animate message flow 244 | const edgeIds = edges.getIds(); 245 | edgeIds.forEach(edgeId => { 246 | edges.update({ id: edgeId, color: { color: '#00ff00' }, width: 4 }); 247 | setTimeout(() => { 248 | edges.update({ id: edgeId, color: { inherit: 'from' }, width: 2 }); 249 | }, 1000); 250 | }); 251 | 252 | // Update the size of the final topic node to indicate message received 253 | const finalNodeId = topicParts.join('/'); 254 | const finalNode = nodes.get(finalNodeId); 255 | nodes.update({ id: finalNodeId, size: finalNode.size + 5 }); 256 | setTimeout(() => { 257 | nodes.update({ id: finalNodeId, size: finalNode.size }); 258 | }, 1000); 259 | } 260 | 261 | socket.on('mqtt_message', function(data) { 262 | updateMessageList(data); 263 | messageCount++; 264 | updateNetwork(data); 265 | updateTopicFilter(data.topic); 266 | }); 267 | 268 | function getRandomColor() { 269 | const letters = '0123456789ABCDEF'; 270 | let color = '#'; 271 | for (let i = 0; i < 6; i++) { 272 | color += letters[Math.floor(Math.random() * 16)]; 273 | } 274 | return color; 275 | } 276 | 277 | document.getElementById('publish-form').addEventListener('submit', function(e) { 278 | e.preventDefault(); 279 | const topic = document.getElementById('topic').value; 280 | const message = document.getElementById('message').value; 281 | 282 | fetch('/publish', { 283 | method: 'POST', 284 | headers: { 285 | 'Content-Type': 'application/x-www-form-urlencoded', 286 | }, 287 | body: `topic=${encodeURIComponent(topic)}&message=${encodeURIComponent(message)}` 288 | }); 289 | 290 | document.getElementById('topic').value = ''; 291 | document.getElementById('message').value = ''; 292 | }); 293 | 294 | function updateStats() { 295 | fetch('/stats') 296 | .then(response => response.json()) 297 | .then(data => { 298 | document.getElementById('connection-count').textContent = data.connection_count; 299 | document.getElementById('topic-count').textContent = data.topic_count; 300 | document.getElementById('message-count').textContent = data.message_count; 301 | }); 302 | } 303 | 304 | function updateTopicFilter(newTopic) { 305 | const topicFilter = document.getElementById('topic-filter'); 306 | if (!Array.from(topicFilter.options).some(option => option.value === newTopic)) { 307 | const option = document.createElement('option'); 308 | option.value = newTopic; 309 | option.textContent = newTopic; 310 | topicFilter.appendChild(option); 311 | } 312 | } 313 | 314 | function loadTopicsFromAPI() { 315 | fetch('/api/topics') 316 | .then(response => response.json()) 317 | .then(data => { 318 | const topicFilter = document.getElementById('topic-filter'); 319 | 320 | // Clear existing options except "All Topics" 321 | const allTopicsOption = topicFilter.querySelector('option[value="all"]'); 322 | topicFilter.innerHTML = ''; 323 | if (allTopicsOption) { 324 | topicFilter.appendChild(allTopicsOption); 325 | } else { 326 | // Create "All Topics" option if it doesn't exist 327 | const option = document.createElement('option'); 328 | option.value = 'all'; 329 | option.textContent = 'All Topics'; 330 | topicFilter.appendChild(option); 331 | } 332 | 333 | // Add topics from API (sorted by most recent) 334 | data.topics.forEach(topic => { 335 | const option = document.createElement('option'); 336 | option.value = topic.topic; 337 | option.textContent = `${topic.topic} (${topic.message_count})`; 338 | topicFilter.appendChild(option); 339 | }); 340 | 341 | console.log(`Loaded ${data.topics.length} topics from API`); 342 | }) 343 | .catch(error => { 344 | console.error('Error loading topics:', error); 345 | }); 346 | } 347 | 348 | document.getElementById('topic-filter').addEventListener('change', function(e) { 349 | topicFilter = e.target.value; 350 | loadFilteredMessages(); 351 | }); 352 | 353 | function loadFilteredMessages(customFilters = {}) { 354 | const messageList = document.getElementById('message-list'); 355 | messageList.innerHTML = '
Loading messages...
'; 356 | 357 | // Build API query parameters 358 | let queryParams = new URLSearchParams(); 359 | queryParams.append('limit', '50'); // Show last 50 messages 360 | 361 | // Add topic filter 362 | if (topicFilter && topicFilter !== 'all') { 363 | queryParams.append('topic', topicFilter); 364 | } 365 | 366 | // Add custom filters 367 | Object.entries(customFilters).forEach(([key, value]) => { 368 | if (value) queryParams.append(key, value); 369 | }); 370 | 371 | fetch(`/api/messages?${queryParams.toString()}`) 372 | .then(response => response.json()) 373 | .then(data => { 374 | messageList.innerHTML = ''; 375 | 376 | if (data.messages.length === 0) { 377 | messageList.innerHTML = '
No messages found for current filter
'; 378 | return; 379 | } 380 | 381 | // Display filtered messages 382 | data.messages.forEach(message => { 383 | const messageElement = document.createElement('div'); 384 | messageElement.className = 'message-item bg-gray-700 p-3 rounded mb-2 hover:bg-gray-600 transition-colors'; 385 | 386 | const timestamp = new Date(message.timestamp).toLocaleTimeString(); 387 | const payload = message.payload.length > 200 ? 388 | message.payload.substring(0, 200) + '...' : message.payload; 389 | 390 | // Pretty print JSON if possible 391 | let formattedPayload = payload; 392 | try { 393 | const parsed = JSON.parse(payload); 394 | formattedPayload = JSON.stringify(parsed, null, 2); 395 | } catch (e) { 396 | // Keep original payload if not JSON 397 | } 398 | 399 | messageElement.innerHTML = ` 400 |
401 | ${message.topic} 402 | ${timestamp} 403 |
404 |
${formattedPayload}
405 | `; 406 | 407 | messageList.appendChild(messageElement); 408 | }); 409 | 410 | // Update filter status 411 | const statusElement = messageList.parentElement.querySelector('.filter-status'); 412 | if (statusElement) statusElement.remove(); 413 | 414 | const status = document.createElement('div'); 415 | status.className = 'filter-status text-sm text-gray-400 mb-3 flex justify-between items-center'; 416 | status.innerHTML = ` 417 | Showing ${data.messages.length} of ${data.total} messages 418 | ${Object.keys(customFilters).length > 0 || (topicFilter && topicFilter !== 'all') ? 419 | '🔍 Filtered' : ''} 420 | `; 421 | messageList.parentElement.insertBefore(status, messageList); 422 | 423 | }) 424 | .catch(error => { 425 | console.error('Error loading messages:', error); 426 | messageList.innerHTML = '
Error loading messages
'; 427 | }); 428 | } 429 | 430 | function setupAdvancedSearchHandlers() { 431 | // Apply Filters Button 432 | document.getElementById('apply-filters-btn').addEventListener('click', function() { 433 | const filters = { 434 | content: document.getElementById('content-search').value, 435 | regex_topic: document.getElementById('regex-topic').value, 436 | json_path: document.getElementById('json-path').value, 437 | json_value: document.getElementById('json-value').value, 438 | hours: document.getElementById('time-filter').value 439 | }; 440 | 441 | // Remove empty filters 442 | Object.keys(filters).forEach(key => { 443 | if (!filters[key]) delete filters[key]; 444 | }); 445 | 446 | loadFilteredMessages(filters); 447 | }); 448 | 449 | // Clear Filters Button 450 | document.getElementById('clear-filters-btn').addEventListener('click', function() { 451 | document.getElementById('topic-filter').value = 'all'; 452 | document.getElementById('content-search').value = ''; 453 | document.getElementById('regex-topic').value = ''; 454 | document.getElementById('json-path').value = ''; 455 | document.getElementById('json-value').value = ''; 456 | document.getElementById('time-filter').value = ''; 457 | topicFilter = 'all'; 458 | loadFilteredMessages(); 459 | }); 460 | 461 | // Load Filter Presets 462 | loadFilterPresets(); 463 | 464 | // Load Preset Button 465 | document.getElementById('load-preset-btn').addEventListener('click', function() { 466 | const presetName = document.getElementById('preset-select').value; 467 | if (!presetName) { 468 | alert('Please select a preset to load'); 469 | return; 470 | } 471 | 472 | fetch(`/api/filter-presets/${encodeURIComponent(presetName)}/use`, { 473 | method: 'POST' 474 | }) 475 | .then(response => response.json()) 476 | .then(data => { 477 | if (data.success) { 478 | // Apply filters to UI 479 | const filters = data.filters; 480 | document.getElementById('content-search').value = filters.content || ''; 481 | document.getElementById('regex-topic').value = filters.regex_topic || ''; 482 | document.getElementById('json-path').value = filters.json_path || ''; 483 | document.getElementById('json-value').value = filters.json_value || ''; 484 | document.getElementById('time-filter').value = filters.hours || ''; 485 | 486 | if (filters.topic) { 487 | document.getElementById('topic-filter').value = filters.topic; 488 | topicFilter = filters.topic; 489 | } 490 | 491 | // Apply the loaded filters 492 | loadFilteredMessages(filters); 493 | } else { 494 | alert('Error loading preset: ' + data.error); 495 | } 496 | }) 497 | .catch(error => { 498 | console.error('Error loading preset:', error); 499 | alert('Error loading preset'); 500 | }); 501 | }); 502 | 503 | // Save Preset Button 504 | document.getElementById('save-preset-btn').addEventListener('click', function() { 505 | const name = prompt('Enter a name for this filter preset:'); 506 | if (!name) return; 507 | 508 | const description = prompt('Enter a description (optional):') || ''; 509 | 510 | const filters = { 511 | content: document.getElementById('content-search').value, 512 | regex_topic: document.getElementById('regex-topic').value, 513 | json_path: document.getElementById('json-path').value, 514 | json_value: document.getElementById('json-value').value, 515 | hours: document.getElementById('time-filter').value 516 | }; 517 | 518 | if (topicFilter && topicFilter !== 'all') { 519 | filters.topic = topicFilter; 520 | } 521 | 522 | // Remove empty filters 523 | Object.keys(filters).forEach(key => { 524 | if (!filters[key]) delete filters[key]; 525 | }); 526 | 527 | if (Object.keys(filters).length === 0) { 528 | alert('No filters to save'); 529 | return; 530 | } 531 | 532 | fetch('/api/filter-presets', { 533 | method: 'POST', 534 | headers: { 535 | 'Content-Type': 'application/json' 536 | }, 537 | body: JSON.stringify({ 538 | name: name, 539 | description: description, 540 | filters: filters 541 | }) 542 | }) 543 | .then(response => response.json()) 544 | .then(data => { 545 | if (data.success) { 546 | alert('Filter preset saved successfully!'); 547 | loadFilterPresets(); // Refresh preset list 548 | } else { 549 | alert('Error saving preset: ' + data.error); 550 | } 551 | }) 552 | .catch(error => { 553 | console.error('Error saving preset:', error); 554 | alert('Error saving preset'); 555 | }); 556 | }); 557 | 558 | // Fullscreen and reset buttons for network 559 | document.getElementById('fullscreen-btn').addEventListener('click', function() { 560 | const networkDiv = document.getElementById('network-visualization'); 561 | if (networkDiv.requestFullscreen) { 562 | networkDiv.requestFullscreen(); 563 | } else if (networkDiv.webkitRequestFullscreen) { 564 | networkDiv.webkitRequestFullscreen(); 565 | } else if (networkDiv.msRequestFullscreen) { 566 | networkDiv.msRequestFullscreen(); 567 | } 568 | }); 569 | 570 | document.getElementById('reset-nodes-btn').addEventListener('click', function() { 571 | if (network) { 572 | // Unpin all nodes first 573 | pinnedNodes.forEach(nodeId => { 574 | const node = nodes.get(nodeId); 575 | if (node) { 576 | nodes.update({ 577 | id: nodeId, 578 | fixed: false, 579 | color: node.originalColor || '#97C2FC' 580 | }); 581 | } 582 | }); 583 | pinnedNodes.clear(); 584 | 585 | // Reset physics and fit to view 586 | network.setOptions({ 587 | physics: { 588 | enabled: true, 589 | stabilization: { 590 | enabled: true, 591 | iterations: 150, 592 | updateInterval: 25, 593 | fit: true 594 | } 595 | } 596 | }); 597 | 598 | // Fit network to container with animation 599 | setTimeout(() => { 600 | network.fit({ 601 | animation: { 602 | duration: 1000, 603 | easingFunction: 'easeInOutQuad' 604 | } 605 | }); 606 | }, 500); 607 | } 608 | }); 609 | } 610 | 611 | function loadFilterPresets() { 612 | fetch('/api/filter-presets') 613 | .then(response => response.json()) 614 | .then(data => { 615 | const presetSelect = document.getElementById('preset-select'); 616 | 617 | // Clear existing options except first 618 | presetSelect.innerHTML = ''; 619 | 620 | // Add presets 621 | data.presets.forEach(preset => { 622 | const option = document.createElement('option'); 623 | option.value = preset.name; 624 | option.textContent = `${preset.name}${preset.description ? ' - ' + preset.description : ''}`; 625 | presetSelect.appendChild(option); 626 | }); 627 | }) 628 | .catch(error => { 629 | console.error('Error loading presets:', error); 630 | }); 631 | } 632 | 633 | 634 | 635 | let debugBar; 636 | let debugBarToggle; 637 | 638 | function initDebugBar() { 639 | debugBar = document.createElement('div'); 640 | debugBar.id = 'debug-bar'; 641 | debugBar.style.display = 'none'; 642 | document.body.appendChild(debugBar); 643 | 644 | debugBarToggle = document.createElement('button'); 645 | debugBarToggle.id = 'debug-bar-toggle'; 646 | debugBarToggle.innerHTML = '🐞 Debug'; 647 | debugBarToggle.onclick = toggleDebugBar; 648 | document.body.appendChild(debugBarToggle); 649 | 650 | const closeButton = document.createElement('button'); 651 | closeButton.id = 'debug-bar-close'; 652 | closeButton.innerHTML = '×'; 653 | closeButton.onclick = closeDebugBar; 654 | debugBar.appendChild(closeButton); 655 | 656 | updateDebugBar(); 657 | setInterval(updateDebugBar, 1000); // Update every second 658 | } 659 | 660 | function toggleDebugBar() { 661 | fetch('/toggle-debug-bar', { method: 'POST' }) 662 | .then(response => response.json()) 663 | .then(data => { 664 | debugBar.style.display = data.enabled ? 'block' : 'none'; 665 | debugBarToggle.classList.toggle('active', data.enabled); 666 | }); 667 | } 668 | 669 | function closeDebugBar() { 670 | debugBar.style.display = 'none'; 671 | fetch('/toggle-debug-bar', { method: 'POST' }); 672 | debugBarToggle.classList.remove('active'); 673 | } 674 | 675 | function trackClientPerformance() { 676 | const perfData = window.performance.timing; 677 | const pageLoadTime = perfData.loadEventEnd - perfData.navigationStart; 678 | const domReadyTime = perfData.domContentLoadedEventEnd - perfData.navigationStart; 679 | 680 | fetch('/record-client-performance', { 681 | method: 'POST', 682 | headers: { 683 | 'Content-Type': 'application/json', 684 | }, 685 | body: JSON.stringify({ 686 | pageLoadTime, 687 | domReadyTime, 688 | }), 689 | }); 690 | } 691 | 692 | function updateDebugBar() { 693 | fetch('/debug-bar') 694 | .then(response => response.json()) 695 | .then(data => { 696 | let content = '
'; 697 | for (const [panelName, panelData] of Object.entries(data)) { 698 | content += `

${panelName}

    `; 699 | for (const [key, value] of Object.entries(panelData)) { 700 | let displayValue = value; 701 | if (typeof value === 'object' && value !== null) { 702 | displayValue = '
    ' + JSON.stringify(value, null, 2) + '
    '; 703 | } 704 | content += `
  • ${key}: ${displayValue}
  • `; 705 | } 706 | content += '
'; 707 | } 708 | content += '
'; 709 | debugBar.innerHTML = content; 710 | debugBar.appendChild(document.getElementById('debug-bar-close')); 711 | }); 712 | } 713 | function toggleAdvancedSearch() { 714 | const content = document.getElementById('advanced-search-content'); 715 | const icon = document.getElementById('search-toggle-icon'); 716 | 717 | if (content.classList.contains('hidden')) { 718 | content.classList.remove('hidden'); 719 | icon.textContent = '▼'; 720 | } else { 721 | content.classList.add('hidden'); 722 | icon.textContent = '▶'; 723 | } 724 | } 725 | 726 | document.addEventListener('DOMContentLoaded', function() { 727 | initChart(); 728 | initNetwork(); 729 | setInterval(updateChart, 1000); 730 | setInterval(updateStats, 5000); 731 | initDebugBar(); 732 | trackClientPerformance(); 733 | 734 | // Load existing topics from API 735 | loadTopicsFromAPI(); 736 | // Refresh topics every 30 seconds 737 | setInterval(loadTopicsFromAPI, 30000); 738 | 739 | // Load initial messages 740 | loadFilteredMessages(); 741 | 742 | // Setup advanced search UI 743 | setupAdvancedSearchHandlers(); 744 | }); -------------------------------------------------------------------------------- /static/styles.css: -------------------------------------------------------------------------------- 1 | /* Custom scrollbar for Webkit browsers */ 2 | #message-list::-webkit-scrollbar { 3 | width: 8px; 4 | } 5 | 6 | #message-list::-webkit-scrollbar-track { 7 | background: #1F2937; 8 | } 9 | 10 | #message-list::-webkit-scrollbar-thumb { 11 | background: #4B5563; 12 | border-radius: 4px; 13 | } 14 | 15 | #message-list::-webkit-scrollbar-thumb:hover { 16 | background: #6B7280; 17 | } 18 | 19 | /* Smooth transitions for dark mode */ 20 | body { 21 | transition: background-color 0.3s, color 0.3s; 22 | } 23 | 24 | /* Additional styles for better readability */ 25 | #message-list div { 26 | background-color: rgba(55, 65, 81, 0.5); 27 | padding: 0.5rem; 28 | border-radius: 0.25rem; 29 | } 30 | 31 | #network-visualization { 32 | border: 1px solid #4B5563; 33 | border-radius: 0.25rem; 34 | } 35 | 36 | /* Improve form input styling */ 37 | input[type="text"], textarea, select { 38 | width: 100%; 39 | padding: 0.5rem; 40 | border-radius: 0.25rem; 41 | background-color: #374151; 42 | border: 1px solid #4B5563; 43 | color: #F3F4F6; 44 | } 45 | 46 | input[type="text"]:focus, textarea:focus, select:focus { 47 | outline: none; 48 | border-color: #60A5FA; 49 | box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2); 50 | } 51 | 52 | /* Debug bar styles */ 53 | #debug-bar { 54 | position: fixed; 55 | bottom: 0; 56 | left: 0; 57 | right: 0; 58 | background-color: rgba(0, 0, 0, 0.95); 59 | color: #fff; 60 | padding: 15px; 61 | font-family: 'Courier New', monospace; 62 | font-size: 13px; 63 | max-height: 60%; 64 | overflow-y: auto; 65 | z-index: 1000; 66 | border-top: 2px solid #3b82f6; 67 | box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.5); 68 | } 69 | 70 | #debug-bar h2 { 71 | margin-top: 0; 72 | font-size: 18px; 73 | color: #3b82f6; 74 | } 75 | 76 | #debug-bar h3 { 77 | margin-top: 10px; 78 | font-size: 16px; 79 | color: #60a5fa; 80 | border-bottom: 1px solid #3b82f6; 81 | padding-bottom: 5px; 82 | } 83 | 84 | #debug-bar ul { 85 | list-style-type: none; 86 | padding-left: 0; 87 | } 88 | 89 | #debug-bar li { 90 | margin-bottom: 8px; 91 | word-break: break-all; 92 | } 93 | 94 | #debug-bar pre { 95 | background-color: rgba(255, 255, 255, 0.1); 96 | padding: 5px; 97 | border-radius: 3px; 98 | overflow-x: auto; 99 | } 100 | 101 | #debug-bar-toggle { 102 | position: fixed; 103 | bottom: 10px; 104 | right: 10px; 105 | background-color: #3b82f6; 106 | color: white; 107 | border: none; 108 | padding: 8px 12px; 109 | border-radius: 4px; 110 | cursor: pointer; 111 | z-index: 1001; 112 | font-weight: bold; 113 | transition: background-color 0.3s; 114 | } 115 | 116 | #debug-bar-toggle:hover { 117 | background-color: #2563eb; 118 | } 119 | 120 | #debug-bar-toggle.active { 121 | background-color: #1e40af; 122 | } 123 | 124 | #debug-bar-close { 125 | position: absolute; 126 | top: 5px; 127 | right: 5px; 128 | background: none; 129 | border: none; 130 | color: #fff; 131 | font-size: 20px; 132 | cursor: pointer; 133 | } 134 | 135 | .debug-content { 136 | display: flex; 137 | flex-wrap: wrap; 138 | gap: 10px; 139 | } 140 | 141 | .debug-panel { 142 | background-color: rgba(59, 130, 246, 0.1); 143 | border-radius: 4px; 144 | padding: 10px; 145 | flex: 1 1 calc(33% - 10px); 146 | min-width: 250px; 147 | margin-bottom: 10px; 148 | } 149 | 150 | .debug-panel h3 { 151 | margin-top: 0; 152 | } 153 | 154 | /* Responsive design for smaller screens */ 155 | @media (max-width: 768px) { 156 | .debug-panel { 157 | flex: 1 1 100%; 158 | } 159 | } -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | MQTT Web Interface 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 119 | 120 | 121 |
122 | 123 |
124 |
125 |

MQTT Web Interface

126 | 127 |
128 |
129 | 130 | 131 |
132 | 133 |
134 |
135 |

Message Flow

136 |
137 | 138 | 139 |
140 |
141 |
142 |
143 | 144 | 145 |
146 |

💬 Messages

147 |
148 |
149 |
150 |
151 |
152 | 153 | 174 | 175 | 176 | 177 | --------------------------------------------------------------------------------