Showing preview only (468K chars total). Download the full file or copy to clipboard to get everything.
Repository: terdia/mqttui
Branch: main
Commit: ec08e99df1a3
Files: 96
Total size: 440.5 KB
Directory structure:
gitextract_wv3k4jzs/
├── .env_example
├── .github/
│ └── workflows/
│ └── docker-publish.yml
├── .gitignore
├── CHANGELOG.md
├── Dockerfile
├── Dockerfile.multiarch
├── LICENSE.md
├── README.md
├── app.py
├── database.py
├── debug_bar.py
├── demo.sh
├── docker-compose.yml
├── entrypoint.sh
├── mosquitto.conf
├── mqttui/
│ ├── __init__.py
│ ├── analytics.py
│ ├── app.py
│ ├── auth.py
│ ├── broker_manager.py
│ ├── database.py
│ ├── events.py
│ ├── extensions.py
│ ├── helpers.py
│ ├── logging_config.py
│ ├── models.py
│ ├── mqtt_client.py
│ ├── plugins/
│ │ ├── __init__.py
│ │ ├── examples/
│ │ │ ├── __init__.py
│ │ │ ├── json_formatter.py
│ │ │ └── topic_logger.py
│ │ ├── hookspec.py
│ │ ├── models.py
│ │ ├── registry.py
│ │ └── runner.py
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── alerts.py
│ │ ├── analytics.py
│ │ ├── api.py
│ │ ├── api_v1.py
│ │ ├── brokers.py
│ │ ├── debug.py
│ │ ├── main.py
│ │ ├── metrics.py
│ │ ├── plugins.py
│ │ └── rules.py
│ ├── rules/
│ │ ├── __init__.py
│ │ ├── actions.py
│ │ ├── cooldown.py
│ │ ├── engine.py
│ │ ├── evaluator.py
│ │ ├── models.py
│ │ └── ssrf.py
│ ├── socketio_batch.py
│ └── state.py
├── pytest.ini
├── requirements-dev.txt
├── requirements.txt
├── static/
│ ├── css/
│ │ ├── input.css
│ │ └── output.css
│ ├── script.js
│ └── styles.css
├── templates/
│ ├── base.html
│ ├── index.html
│ ├── login.html
│ └── partials/
│ ├── alert_row.html
│ ├── alerts_list.html
│ ├── analytics.html
│ ├── brokers.html
│ ├── dry_run_result.html
│ ├── plugins.html
│ ├── rule_form.html
│ ├── rule_row.html
│ └── rules_list.html
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_alerts_api.py
│ ├── test_analytics.py
│ ├── test_api_v1.py
│ ├── test_app_factory.py
│ ├── test_auth.py
│ ├── test_cooldown.py
│ ├── test_database.py
│ ├── test_frontend.py
│ ├── test_observability.py
│ ├── test_plugin_registry.py
│ ├── test_plugin_runner.py
│ ├── test_plugins_api.py
│ ├── test_routes.py
│ ├── test_rules_api.py
│ ├── test_rules_engine.py
│ ├── test_rules_evaluator.py
│ ├── test_rules_models.py
│ ├── test_ux_features.py
│ └── test_webhook.py
└── wsgi.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .env_example
================================================
DEBUG=False
HOST=0.0.0.0
PORT=5000
MQTT_BROKER=your_mqtt_broker
MQTT_PORT=1883
MQTT_USERNAME=your_username
MQTT_PASSWORD=your_password
MQTT_KEEPALIVE=60
MQTT_VERSION=3.1.1
MQTT_TOPICS=#
SECRET_KEY=your-secret-key
LOG_LEVEL=INFO
DB_ENABLED=True
DB_PATH=mqtt_messages.db
DB_MAX_MESSAGES=10000
DB_CLEANUP_DAYS=30
# TLS/SSL (for MQTTS connections)
MQTT_TLS=false
MQTT_TLS_CA_CERTS=
MQTT_TLS_CERTFILE=
MQTT_TLS_KEYFILE=
MQTT_TLS_INSECURE=false
# v2.0 Authentication
MQTTUI_ADMIN_USER=admin
MQTTUI_ADMIN_PASSWORD=changeme
MQTTUI_RATE_LIMIT=30/minute
================================================
FILE: .github/workflows/docker-publish.yml
================================================
name: Docker
on:
push:
tags: [ 'v*.*.*' ]
env:
DOCKER_HUB_REPO: terdia07/mqttui
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.DOCKER_HUB_REPO }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.multiarch
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
================================================
FILE: .gitignore
================================================
# Environment variables
.env
# Python
__pycache__
*.pyc
*.pyo
# Database files
data/
*.db
*.sqlite
*.sqlite3
# Logs
*.log
*.log.*
# macOS
.DS_Store
# Static assets (if needed)
static/mqttui-logo-svg.svg
static/mqttui-logo.png
# SQLite WAL/SHM temp files
*.db-shm
*.db-wal
# GSD planning artifacts
.planning/
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.1.0] - 2026-03-24
### Added
- **Multi-broker support**: Connect to multiple MQTT brokers simultaneously from the Brokers tab
- **Broker management API**: Full CRUD at `/api/v1/brokers/` with connect/disconnect endpoints
- **Broker management UI**: Add, edit, remove, connect, disconnect brokers with live status indicators
- **Broker filtering**: Filter messages by source broker in Advanced Search dropdown
- **Broker name badges**: Each message shows which broker it came from
- **Demo script updated**: `./demo.sh` populates both brokers with different data profiles (home vs warehouse)
- **Backward compatible**: Existing env var config auto-seeds a "Default" broker on first run
### Fixed
- Debug bar performance tab now uses modern Performance API (deprecated `window.performance.timing` replaced)
## [2.0.0] - 2026-03-24
### Added
#### Automation Rules Engine
- **IF/THEN automation rules**: Create rules that evaluate conditions against incoming MQTT messages and fire actions automatically
- **11-operator condition evaluator**: eq, ne, gt, lt, gte, lte, contains, not_contains, regex, exists, not_exists
- **Compound conditions**: `all` (AND) and `any` (OR) for complex logic
- **JSON path support**: Dot-notation paths like `sensors.outdoor.temp` for nested payload fields
- **Action types**: Publish to topic, webhook HTTP POST, log to alert history
- **Loop detection**: `__source` marker prevents feedback loops, per-rule rate limiting (10/min default), global circuit breaker (100/min)
- **Time-based rules**: APScheduler with cron expressions (e.g., "every 5 minutes")
- **Hot-reload**: Rule changes take effect immediately without app restart
- **Dry-run testing**: Test rules against sample payloads before activating
- **Rules REST API**: Full CRUD at `/api/v1/rules/` with enable/disable and dry-run endpoints
#### Alerting & Notifications
- **Telegram alerts**: First-class action type — enter bot token + chat ID, get alerts in Telegram with Markdown formatting
- **Slack alerts**: First-class action type — enter incoming webhook URL, get alerts in Slack with mrkdwn formatting
- **HTTP webhook delivery**: Generic webhook with customizable payload templates using `{{topic}}`, `{{payload}}`, `{{timestamp}}` variables
- **Retry with exponential backoff**: 3 retries at 1s, 5s, 25s on server errors
- **SSRF protection**: Blocks webhook URLs pointing to private/reserved addresses (RFC-1918, localhost, link-local)
- **Alert deduplication**: Configurable cooldown window (5 min default) prevents alert storms
- **Alert history**: Persistent record of all alerts with delivery status, viewable via API and UI
#### Modern Frontend
- **Alpine.js component architecture**: Reactive UI components replacing vanilla JS
- **htmx interactions**: Form submissions, CRUD operations, and pagination without full page reloads
- **Socket.IO batching**: Server-side 100ms batch window handles 1000+ msg/sec without browser flooding
- **Rules Editor UI**: Create, edit, delete, enable/disable, and dry-run test rules inline
- **Alert History panel**: Filterable by rule, severity, and time range with pagination
- **Tab navigation**: Dashboard / Rules / Alerts / Analytics / Plugins tabs
- **Favorites toggle**: Filter message list to show only bookmarked topics
- **Advanced Search**: Filter messages by topic, content, regex, JSON path, and time range
- **Redesigned message rate chart**: Gradient area chart with 30-second rolling window and live msg/s counter
- **Demo script**: `./demo.sh` populates realistic test data across all features
#### REST API & Authentication
- **Versioned API**: All endpoints under `/api/v1/` prefix with consistent JSON envelope responses
- **OpenAPI documentation**: Swagger UI at `/api/v1/docs` with full endpoint documentation
- **User authentication**: Flask-Login with username/password, session cookies
- **API tokens**: `X-API-Key` header for programmatic access, token CRUD endpoints
- **Rate limiting**: Configurable per-IP limit on publish endpoint (30/min default)
- **CORS support**: Cross-origin API access for external consumers
- **SECRET_KEY guard**: Application refuses to start with insecure key in production
#### Analytics & Observability
- **Per-topic analytics**: Message rate counters and numeric payload histograms
- **Analytics dashboard**: Real-time top-topics-by-rate widget with histogram drill-down
- **Structured logging**: structlog with JSON output (production) and colored console (development)
- **Prometheus metrics**: `/metrics` endpoint with message counters, rule firing rates, connection gauges
- **Topic favorites**: Star/bookmark topics for quick access
- **Retained message indicator**: Visual "R" badge on retained messages
#### Plugin Architecture
- **Plugin hook specification**: `MQTTUIPlugin` base class with `on_message`, `on_connect`, `on_rule_trigger` hooks via pluggy
- **Subprocess isolation**: Plugins run in separate processes with empty environment — no access to app, database, or MQTT client
- **Entry-point discovery**: Standard Python packaging via `importlib.metadata` entry_points
- **Plugin management UI**: View installed plugins, enable/disable via toggle
- **Bundled examples**: JSON Formatter and Topic Logger plugins included
### Changed
#### Architecture Overhaul
- **Flask application factory**: Monolithic `app.py` refactored into `mqttui/` package with blueprints
- **gevent async mode**: Replaced unmaintained eventlet with gevent for WebSocket support
- **paho-mqtt 2.x**: Upgraded to modern `CallbackAPIVersion.VERSION2` callback API
- **Flask 3.1.x**: Upgraded from Flask 2.0.1 with compatible Werkzeug
- **SQLite WAL mode**: Write-ahead logging with `busy_timeout` on all connections for concurrent access
- **Blinker event bus**: Internal signals (`mqtt_message_received`, `rule_fired`, `alert_triggered`) decouple all components
- **Python 3.11**: Docker base image upgraded from 3.9
- **Tailwind CSS v4**: Standalone CLI replacing CDN v2.x (no Node.js dependency)
#### Testing
- **218+ automated tests**: pytest infrastructure with shared fixtures across all 7 phases
- **Test categories**: App factory, database, routes, rules evaluator, rules engine, rules API, auth, API v1, webhook, cooldown, alerts API, frontend partials, analytics, observability, UX features, plugin registry, plugin runner, plugins API
### New Environment Variables
- `MQTTUI_ADMIN_USER`: Admin username (default: admin)
- `MQTTUI_ADMIN_PASSWORD`: Admin password (required for first run)
- `MQTTUI_RATE_LIMIT`: Publish rate limit (default: 30/minute)
- `SECRET_KEY`: Flask secret key (required in production, must not be "dev" or "change-me")
## [1.3.2] - 2025-08-24
### Added
- **Complete UI Redesign**: Collapsible sidebar layout for maximum screen real estate utilization
- **Advanced Search Panel**: Comprehensive message filtering with regex patterns, JSON path queries, content search, and time-based filters
- **Message Persistence**: SQLite database storage with automatic cleanup and configurable message limits
- **Filter Presets**: Save and load frequently used search filter combinations
- **Network Visualization Enhancements**:
- Node pinning functionality (double-click to pin/unpin nodes, red color indicates pinned)
- Improved physics engine with overlap prevention
- Fullscreen support for Message Flow diagram
- Right-click context menu for node management
- **Collapsible Sidebar**: Hide/show controls panel to maximize Message Flow and Messages viewing area
- **Enhanced API Endpoints**:
- `/api/messages` with advanced filtering support
- `/api/topics` with statistics
- `/api/filter-presets` for preset management
### Changed
- **Layout Architecture**: Complete redesign from grid-based to flexbox sidebar layout
- **Message Flow Area**: Now takes up significantly more screen space (up to 100% width when sidebar hidden)
- **Message Rate Chart**: Moved to compact sidebar position for better space utilization
- **Topic Filtering**: Now actually filters displayed messages instead of just clearing the list
- **Advanced Search**: Collapsible panel, closed by default to reduce visual clutter
- **Screen Space Optimization**: Removed unnecessary padding, maximized content area utilization
### Fixed
- **Topic Dropdown Functionality**: Fixed issue where topic selection only cleared messages instead of filtering
- **Message Stacking**: Resolved overlapping div issue in the main content area
- **Layout Responsiveness**: Proper flexbox implementation ensures consistent 50/50 split between Message Flow and Messages
- **Database Thread Safety**: Implemented thread-local connections for concurrent message storage
### Technical Improvements
- **Database Schema**: Enhanced with indexes for better query performance
- **Message Filtering**: Support for regex topic patterns, JSON path queries, and content search
- **Network Physics**: Improved node positioning with `avoidOverlap` and better stabilization
- **JavaScript Architecture**: Modular functions for filter management and UI interactions
- **CSS Layout**: Modern flexbox implementation with smooth transitions and animations
## [1.3.1] - 2024-08-24
### Added
- LOG_LEVEL environment variable support for controlling application logging verbosity (#9)
- Topic subscription filtering via MQTT_TOPICS environment variable (#6)
- Multi-architecture Docker support (AMD64, ARM64, ARMv7) with new Dockerfile.multiarch (#3)
- GitHub Actions workflow for automated multi-platform Docker builds
- Enhanced documentation for MQTT broker address format (#7)
- Proper Flask-SocketIO server startup when running app.py directly (#12)
### Fixed
- MQTT v5 connection error by adding properties parameter to on_connect callback (#8)
- Application no longer exits prematurely when run outside Docker (#12)
- Improved LOG_LEVEL validation in entrypoint.sh with gunicorn support
### Changed
- Enhanced logging configuration with better formatting and level control
- Updated README with comprehensive configuration documentation
- Improved error handling for MQTT v5 connections
## [1.3.0] - 2024-08-27
### Added
- Improved logging for MQTT connection attempts and status
- Client ID specification for MQTT connection
- Support for different MQTT protocol versions
### Changed
- Modified app.py to ensure MQTT connection is attempted when run in a container
- Refactored server startup process for better compatibility with both development and production environments
### Fixed
- Resolved issues with MQTT connection not being established
- Fixed problems related to username/password authentication for MQTT brokers
- Improved error handling for non-UTF-8 encoded MQTT messages
## [1.2.0] - 2024-08-24
### Added
- Debug Bar feature for enhanced developer insights
- Real-time websocket connection/disconnect status
- MQTT connection status and last message details
- Request duration tracking
- Toggle functionality to show/hide the Debug Bar
## [1.0.0] - 2024-08-19
### Added
- Initial release of MQTT Web Interface
- Real-time visualization of MQTT topic hierarchy and message flow
- Ability to publish messages to MQTT topics
- Display of message statistics (connection count, topic count, message count)
- Interactive network graph showing topic relationships
- Docker support for easy deployment
================================================
FILE: Dockerfile
================================================
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
================================================
FILE: Dockerfile.multiarch
================================================
# Multi-architecture Dockerfile supporting both AMD64 and ARM64 (fixes issue #3)
FROM --platform=$TARGETPLATFORM python:3.11-slim
# Set build arguments for multi-platform builds
ARG TARGETPLATFORM
ARG BUILDPLATFORM
RUN echo "Building on $BUILDPLATFORM for $TARGETPLATFORM"
WORKDIR /app
# Install build dependencies for compiling packages on ARM platforms
RUN apt-get update && apt-get install -y \
build-essential \
gcc \
g++ \
libc6-dev \
libffi-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
pip install --no-cache-dir -r requirements.txt
COPY . .
# Use an entrypoint script to allow for variable substitution
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
================================================
FILE: LICENSE.md
================================================
MIT License
Copyright (c) [2024] [Terry Osayawe]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# MQTTUI — Intelligent MQTT Web Interface
An open-source web application that monitors, visualizes, and **automates** your MQTT infrastructure. Create automation rules, get webhook alerts, track per-topic analytics, and extend with plugins — all from a single interface.

## What's New in v2.1
- **Multi-Broker Support** — Connect to multiple MQTT brokers simultaneously, manage from the Brokers tab, filter messages by broker
- **Telegram & Slack Alerts** — First-class action types in the rules editor, no raw webhook URLs needed
## What's New in v2.0
- **Automation Rules Engine** — Create IF/THEN rules: when a topic matches a pattern and payload meets a condition, automatically publish, fire a webhook, or log an alert
- **Alerting** — Telegram, Slack, and HTTP webhook notifications with retry/backoff, SSRF protection, and alert deduplication
- **Modern UI** — Alpine.js + htmx component architecture with tabbed navigation (Dashboard / Rules / Alerts / Analytics / Plugins / Brokers)
- **REST API** — Versioned `/api/v1/` endpoints with OpenAPI docs at `/api/v1/docs`
- **User Authentication** — Login with username/password, API tokens for programmatic access
- **Analytics** — Per-topic message rates, payload histograms, Prometheus `/metrics` endpoint
- **Plugin Architecture** — Extend with custom handlers running in subprocess isolation
- **218+ Automated Tests** — Full pytest suite across all features
## Features
### Core
- **Multi-broker** — connect to multiple MQTT brokers simultaneously, filter messages by broker
- Real-time MQTT message streaming via Socket.IO (handles 1000+ msg/sec with server-side batching)
- Interactive topic hierarchy network graph (Vis.js)
- Publish messages to any topic
- SQLite message persistence with advanced search (regex, JSON path, time range)
- Filter presets — save and load search combinations
- MQTT v3.1.1 and v5 protocol support
- Docker deployment with multi-arch support (AMD64, ARM64, ARMv7)
### Automation
- **Rules Engine**: 11 condition operators (eq, gt, contains, regex, exists, etc.) with compound all/any logic
- **JSON Path Conditions**: Match nested payload fields like `sensors.outdoor.temp > 30`
- **Actions**: Publish to topic, HTTP webhook, log alert
- **Loop Detection**: `__source` marker + per-rule rate limiting + global circuit breaker
- **Time-Based Rules**: Cron schedules via APScheduler (e.g., publish heartbeat every 5 minutes)
- **Dry-Run Testing**: Test rules against sample payloads before activating
- **Hot-Reload**: Rule changes take effect immediately, no restart needed
### Alerting & Notifications
- **Telegram**: Enter bot token + chat ID — get alerts directly in Telegram
- **Slack**: Enter incoming webhook URL — get alerts in your Slack channel
- **HTTP Webhooks**: Generic webhook with customizable payload templates (`{{topic}}`, `{{payload}}`, `{{timestamp}}`)
- Retry with exponential backoff (1s / 5s / 25s) on server errors
- SSRF protection — blocks private/reserved addresses
- Alert deduplication with configurable cooldown (5 min default)
- Persistent alert history with delivery status tracking
### Analytics & Observability
- Per-topic message rate counters and numeric payload histograms
- Structured JSON logging (structlog) for production
- Prometheus-compatible `/metrics` endpoint
- Topic favorites/bookmarks for quick access
- Retained message indicator
### Plugin System
- `MQTTUIPlugin` base class with `on_message`, `on_connect`, `on_rule_trigger` hooks
- Subprocess isolation — plugins cannot access app internals
- Python entry-point discovery (`pip install` your plugin)
- Management UI with enable/disable toggles
- Bundled examples: JSON Formatter, Topic Logger
## Quick Start
### Docker Compose (Recommended)
```bash
git clone https://github.com/terdia/mqttui.git
cd mqttui
docker compose up -d
```
This starts:
- Mosquitto MQTT broker on port 1883
- MQTTUI on port 8088
Open `http://localhost:8088` and log in with the admin credentials you set.
### Docker
```bash
docker pull terdia07/mqttui:v2.0
docker run -p 8088:5000 \
-e MQTT_BROKER=your_broker \
-e MQTTUI_ADMIN_USER=admin \
-e MQTTUI_ADMIN_PASSWORD=changeme \
-e SECRET_KEY=your-secret-key \
terdia07/mqttui:v2.0
```
### Manual Installation
```bash
git clone https://github.com/terdia/mqttui.git
cd mqttui
pip install -r requirements.txt
# Set required environment variables
export MQTTUI_ADMIN_USER=admin
export MQTTUI_ADMIN_PASSWORD=changeme
export SECRET_KEY=your-secret-key
export MQTT_BROKER=localhost
python wsgi.py
```
### Running Tests
```bash
pip install -r requirements.txt
pytest tests/ -q
```
### Demo Data
Populate realistic test data across all features (requires Docker Compose running):
```bash
./demo.sh
```
This creates 200+ messages, 4 automation rules, triggers alerts, sets up filter presets, and bookmarks topics. Great for evaluating all tabs and features.
## Configuration
### Required Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `MQTTUI_ADMIN_USER` | Admin username | `admin` |
| `MQTTUI_ADMIN_PASSWORD` | Admin password | *(required)* |
| `SECRET_KEY` | Flask secret key (must not be "dev" in production) | *(required)* |
### MQTT Connection
| Variable | Description | Default |
|----------|-------------|---------|
| `MQTT_BROKER` | Broker address (IP or hostname, no protocol prefix) | `localhost` |
| `MQTT_PORT` | Broker port | `1883` |
| `MQTT_USERNAME` | Broker username | *(optional)* |
| `MQTT_PASSWORD` | Broker password | *(optional)* |
| `MQTT_KEEPALIVE` | Keep-alive interval (seconds) | `60` |
| `MQTT_VERSION` | Protocol version (`3.1.1` or `5`) | `3.1.1` |
| `MQTT_TOPICS` | Topics to subscribe (comma-separated) | `#` |
### TLS/SSL (MQTTS)
| Variable | Description | Default |
|----------|-------------|---------|
| `MQTT_TLS` | Enable TLS connection | `false` |
| `MQTT_TLS_CA_CERTS` | Path to CA certificate file | *(optional)* |
| `MQTT_TLS_CERTFILE` | Path to client certificate | *(optional)* |
| `MQTT_TLS_KEYFILE` | Path to client private key | *(optional)* |
| `MQTT_TLS_INSECURE` | Skip certificate verification (not recommended) | `false` |
**Example — connect to a TLS broker:**
```bash
docker run -p 8088:5000 \
-e MQTT_BROKER=broker.hivemq.com \
-e MQTT_PORT=8883 \
-e MQTT_TLS=true \
terdia07/mqttui:v2.0.0
```
### Application
| Variable | Description | Default |
|----------|-------------|---------|
| `DEBUG` | Enable development mode | `False` |
| `PORT` | Application port | `5000` |
| `LOG_LEVEL` | Logging level (DEBUG/INFO/WARNING/ERROR) | `INFO` |
| `MQTTUI_RATE_LIMIT` | Publish endpoint rate limit | `30/minute` |
### Database
| Variable | Description | Default |
|----------|-------------|---------|
| `DB_ENABLED` | Enable message persistence | `True` |
| `DB_PATH` | SQLite database path | `./data/mqtt_messages.db` |
| `DB_MAX_MESSAGES` | Max stored messages | `10000` |
| `DB_CLEANUP_DAYS` | Auto-delete messages older than X days | `30` |
## API Documentation
All API endpoints are under `/api/v1/` with OpenAPI documentation at `/api/v1/docs`.
### Key Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/messages` | Message history with filtering |
| `POST` | `/api/v1/publish` | Publish MQTT message |
| `GET` | `/api/v1/topics` | Topic list with stats |
| `GET/POST` | `/api/v1/rules/` | List / create automation rules |
| `PUT/DELETE` | `/api/v1/rules/<id>` | Update / delete rule |
| `POST` | `/api/v1/rules/<id>/test` | Dry-run test a rule |
| `POST` | `/api/v1/rules/<id>/enable` | Enable rule |
| `GET` | `/api/v1/alerts/` | Alert history |
| `GET` | `/api/v1/analytics/topics` | Per-topic analytics |
| `GET` | `/api/v1/plugins/` | List plugins |
| `GET` | `/metrics` | Prometheus metrics |
All data endpoints require authentication (session cookie or `X-API-Key` header).
## Tech Stack
- **Backend**: Python 3.11, Flask 3.1.x, Flask-SocketIO + gevent, paho-mqtt 2.1.0
- **Database**: SQLite with WAL mode
- **Frontend**: Alpine.js 3.x, htmx 2.x, Tailwind CSS v4, Vis.js, Chart.js
- **Real-time**: Socket.IO with server-side 100ms batching
- **Scheduling**: APScheduler with GeventScheduler
- **Plugins**: pluggy + subprocess isolation
- **Observability**: structlog + prometheus_client
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Run tests (`pytest tests/ -q`)
4. Commit your changes
5. Push and open a Pull Request
## License
MIT License — see [LICENSE.md](LICENSE.md) for details.
## Links
- [GitHub Repository](https://github.com/terdia/mqttui)
- [Docker Hub](https://hub.docker.com/r/terdia07/mqttui)
- [Changelog](CHANGELOG.md)
================================================
FILE: app.py
================================================
__version__ = "1.3.0"
from flask import Flask, render_template, request, jsonify, send_from_directory
from flask_socketio import SocketIO, emit
import paho.mqtt.client as mqtt
from datetime import datetime, timedelta
import os
from debug_bar import debug_bar, debug_bar_middleware
import logging
import time
from dotenv import load_dotenv
from logging.handlers import RotatingFileHandler
from werkzeug.serving import run_simple
from database import MessageDatabase
# Load environment variables
load_dotenv()
# Configuration
DEBUG = os.getenv('DEBUG', 'False').lower() in ('true', '1', 't')
HOST = os.getenv('HOST', '0.0.0.0')
PORT = int(os.getenv('PORT', 5000))
MQTT_BROKER = os.getenv('MQTT_BROKER', 'localhost')
MQTT_PORT = int(os.getenv('MQTT_PORT', 1883))
MQTT_USERNAME = os.getenv('MQTT_USERNAME')
MQTT_PASSWORD = os.getenv('MQTT_PASSWORD')
MQTT_KEEPALIVE = int(os.getenv('MQTT_KEEPALIVE', 60))
MQTT_VERSION = os.getenv('MQTT_VERSION', '3.1.1')
# Support for topic filtering (issue #6)
MQTT_TOPICS = os.getenv('MQTT_TOPICS', '#') # Comma-separated list of topics to subscribe to
# Database configuration for message persistence
DB_ENABLED = os.getenv('DB_ENABLED', 'True').lower() in ('true', '1', 't')
DB_PATH = os.getenv('DB_PATH', 'mqtt_messages.db')
DB_MAX_MESSAGES = int(os.getenv('DB_MAX_MESSAGES', 10000))
DB_CLEANUP_DAYS = int(os.getenv('DB_CLEANUP_DAYS', 30))
# Set up logging with LOG_LEVEL environment variable support (fixes issue #9)
LOG_LEVEL = os.getenv('LOG_LEVEL', 'DEBUG' if DEBUG else 'INFO').upper()
log_levels = {
'DEBUG': logging.DEBUG,
'INFO': logging.INFO,
'WARNING': logging.WARNING,
'WARN': logging.WARNING, # Support both WARN and WARNING
'ERROR': logging.ERROR,
'CRITICAL': logging.CRITICAL
}
log_level = log_levels.get(LOG_LEVEL, logging.INFO)
if LOG_LEVEL not in log_levels:
print(f"Invalid LOG_LEVEL: {LOG_LEVEL}. Using INFO level.")
logging.basicConfig(level=log_level, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
if not DEBUG:
handler = RotatingFileHandler('mqttui.log', maxBytes=10000, backupCount=1)
handler.setLevel(log_level)
handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
logging.getLogger('').addHandler(handler)
app = Flask(__name__, static_url_path='/static')
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'your-secret-key')
socketio = SocketIO(app, async_mode='threading')
# Initialize database for message persistence
db = None
if DB_ENABLED:
try:
db = MessageDatabase(DB_PATH, DB_MAX_MESSAGES)
logging.info(f"Database initialized: {DB_PATH}")
except Exception as e:
logging.error(f"Failed to initialize database: {e}")
db = None
MQTT_RC_CODES = {
0: "Connection successful",
1: "Connection refused - incorrect protocol version",
2: "Connection refused - invalid client identifier",
3: "Connection refused - server unavailable",
4: "Connection refused - bad username or password",
5: "Connection refused - not authorised",
# MQTT v5 specific codes
16: "Connection refused - no matching subscribers",
17: "Connection refused - no subscription existed",
128: "Connection refused - unspecified error",
129: "Connection refused - malformed packet",
130: "Connection refused - protocol error",
131: "Connection refused - implementation specific error",
132: "Connection refused - unsupported protocol version",
133: "Connection refused - client identifier not valid",
134: "Connection refused - bad user name or password",
135: "Connection refused - not authorized",
136: "Connection refused - server unavailable",
137: "Connection refused - server busy",
138: "Connection refused - banned",
139: "Connection refused - server shutting down",
140: "Connection refused - bad authentication method",
141: "Connection refused - topic name invalid",
142: "Connection refused - packet too large",
143: "Connection refused - quota exceeded",
144: "Connection refused - payload format invalid",
145: "Connection refused - retain not supported",
146: "Connection refused - QoS not supported",
147: "Connection refused - use another server",
148: "Connection refused - server moved",
149: "Connection refused - connection rate exceeded"
}
app.before_request(debug_bar_middleware)
@app.after_request
def after_request(response):
debug_bar.record('request', 'status_code', response.status_code)
debug_bar.end_request()
return response
# MQTT setup
mqtt_version = os.getenv('MQTT_VERSION', '3.1.1')
if mqtt_version == '5':
mqtt_client = mqtt.Client(client_id=f"mqttui_{os.getpid()}", protocol=mqtt.MQTTv5)
logging.info("Using MQTT v5")
else:
mqtt_client = mqtt.Client(client_id=f"mqttui_{os.getpid()}", clean_session=True, protocol=mqtt.MQTTv311)
logging.info("Using MQTT v3.1.1")
mqtt_broker = os.getenv('MQTT_BROKER', 'localhost')
mqtt_port = int(os.getenv('MQTT_PORT', 1883))
mqtt_username = os.getenv('MQTT_USERNAME')
mqtt_password = os.getenv('MQTT_PASSWORD')
mqtt_keepalive = int(os.getenv('MQTT_KEEPALIVE', 60))
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}")
messages = []
topics = set()
connection_count = 0
active_websockets = 0
error_log = []
@socketio.on('connect')
def handle_connect():
global active_websockets
active_websockets += 1
debug_bar.record('performance', 'active_websockets', active_websockets)
logging.info(f"WebSocket connected. Total active: {active_websockets}")
@socketio.on('disconnect')
def handle_disconnect():
global active_websockets
active_websockets -= 1
debug_bar.record('performance', 'active_websockets', active_websockets)
logging.info(f"WebSocket disconnected. Total active: {active_websockets}")
def on_connect(client, userdata, flags, rc, properties=None):
# MQTT v5 includes 'properties' parameter, v3.1.1 doesn't (fixes issue #8)
global connection_count
error_message = MQTT_RC_CODES.get(rc, f"Unknown error (rc: {rc})")
connection_status = 'Connected' if rc == 0 else f'Failed: {error_message}'
debug_bar.record('mqtt', 'connection_status', connection_status)
logging.info(f"MQTT Connection attempt - Result: {connection_status}")
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}")
if rc == 0:
connection_count += 1
# Subscribe to specified topics (supports filtering - issue #6)
topics_to_subscribe = [topic.strip() for topic in MQTT_TOPICS.split(',')]
for topic in topics_to_subscribe:
client.subscribe(topic)
logging.info(f"Subscribed to topic: {topic}")
logging.info(f"Connected to MQTT broker at {mqtt_broker}:{mqtt_port}. Total connections: {connection_count}")
debug_bar.remove('mqtt', 'connection_attempt') # Remove connection attempt entry
else:
error_log.append(error_message)
debug_bar.record('mqtt', 'last_error', error_message)
logging.error(f"Connection failed: {error_message}")
time.sleep(5)
connect_mqtt() # Retry connection
def on_disconnect(client, userdata, rc):
global connection_count
connection_count = max(0, connection_count - 1)
error_message = MQTT_RC_CODES.get(rc, f"Unknown error (rc: {rc})")
disconnect_reason = 'Clean disconnect' if rc == 0 else f'Unexpected disconnect: {error_message}'
debug_bar.record('mqtt', 'last_disconnect', disconnect_reason)
error_log.append(f"Disconnected: {disconnect_reason}")
logging.warning(f"Disconnected from MQTT broker: {disconnect_reason}")
if rc != 0:
logging.info("Attempting to reconnect...")
client.connect_async(mqtt_broker, mqtt_port, mqtt_keepalive)
def on_message(client, userdata, msg):
try:
payload = msg.payload.decode()
except UnicodeDecodeError:
payload = msg.payload.hex()
timestamp = datetime.now()
message = {
'topic': msg.topic,
'payload': payload,
'timestamp': timestamp.isoformat()
}
# Store in memory for backward compatibility
messages.append(message)
topics.add(msg.topic)
if len(messages) > 100:
messages.pop(0)
# Store in database if enabled
if db:
try:
db.store_message(
topic=msg.topic,
payload=payload,
timestamp=timestamp,
qos=msg.qos,
retain=msg.retain
)
except Exception as e:
logging.error(f"Failed to store message in database: {e}")
# Emit to connected clients
socketio.emit('mqtt_message', message)
debug_bar.record('mqtt', 'last_message', message)
logging.debug(f"MQTT message received: {message}")
mqtt_client.on_connect = on_connect
mqtt_client.on_message = on_message
mqtt_client.on_disconnect = on_disconnect
# API endpoints for message history
@app.route('/api/messages')
def get_message_history():
"""Get paginated message history with enhanced filtering"""
try:
# Get query parameters
limit = min(int(request.args.get('limit', 100)), 1000) # Max 1000 messages
offset = int(request.args.get('offset', 0))
# Basic filters
topic_filter = request.args.get('topic')
hours = request.args.get('hours') # Messages from last N hours
# Enhanced filters
content_search = request.args.get('content') # Search in message content
regex_topic = request.args.get('regex_topic') # Regex pattern for topic
json_path = request.args.get('json_path') # JSON path (e.g., "temperature")
json_value = request.args.get('json_value') # Expected value at JSON path
since = None
if hours:
since = datetime.now() - timedelta(hours=int(hours))
if db:
# Get from database with enhanced filtering
messages_list = db.get_messages(
limit=limit,
offset=offset,
topic_filter=topic_filter,
since=since,
content_search=content_search,
regex_topic=regex_topic,
json_path=json_path,
json_value=json_value
)
# Note: total_count doesn't account for Python filters, so it's approximate
total_count = db.get_message_count(topic_filter=topic_filter, since=since)
else:
# Fallback to in-memory messages (basic filtering only)
messages_list = list(reversed(messages)) # Most recent first
if topic_filter:
messages_list = [m for m in messages_list if m['topic'] == topic_filter]
if content_search:
messages_list = [m for m in messages_list if content_search.lower() in m['payload'].lower()]
total_count = len(messages_list)
messages_list = messages_list[offset:offset+limit]
return jsonify({
'messages': messages_list,
'total': total_count,
'limit': limit,
'offset': offset,
'has_more': offset + len(messages_list) < total_count,
'filters_applied': {
'topic': topic_filter,
'content': content_search,
'regex_topic': regex_topic,
'json_path': json_path,
'json_value': json_value,
'hours': hours
}
})
except Exception as e:
logging.error(f"Error getting message history: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/topics')
def get_topic_list():
"""Get list of all topics with statistics"""
try:
if db:
topics_list = db.get_topics()
else:
# Fallback to in-memory topics
topics_list = [{'topic': topic} for topic in sorted(topics)]
return jsonify({'topics': topics_list})
except Exception as e:
logging.error(f"Error getting topics: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/database/stats')
def get_database_stats():
"""Get database size and statistics"""
try:
if db:
stats = db.get_database_size()
stats['enabled'] = True
else:
stats = {
'enabled': False,
'message_count': len(messages),
'topic_count': len(topics)
}
return jsonify(stats)
except Exception as e:
logging.error(f"Error getting database stats: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/database/cleanup', methods=['POST'])
def cleanup_database():
"""Clean up old database records"""
try:
if not db:
return jsonify({'error': 'Database not enabled'}), 400
days = int(request.json.get('days', DB_CLEANUP_DAYS))
deleted = db.cleanup_old_data(days)
return jsonify({
'success': True,
'deleted_messages': deleted,
'days': days
})
except Exception as e:
logging.error(f"Error cleaning database: {e}")
return jsonify({'error': str(e)}), 500
# Filter presets API endpoints
@app.route('/api/filter-presets')
def get_filter_presets():
"""Get all saved filter presets"""
try:
if not db:
return jsonify({'error': 'Database not enabled'}), 400
presets = db.get_filter_presets()
return jsonify({'presets': presets})
except Exception as e:
logging.error(f"Error getting filter presets: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/filter-presets', methods=['POST'])
def save_filter_preset():
"""Save a new filter preset"""
try:
if not db:
return jsonify({'error': 'Database not enabled'}), 400
data = request.json
name = data.get('name')
description = data.get('description', '')
filters = data.get('filters', {})
if not name or not filters:
return jsonify({'error': 'Name and filters are required'}), 400
success = db.save_filter_preset(name, filters, description)
if success:
return jsonify({'success': True, 'message': f'Saved preset: {name}'})
else:
return jsonify({'error': 'Failed to save preset'}), 500
except Exception as e:
logging.error(f"Error saving filter preset: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/filter-presets/<name>', methods=['DELETE'])
def delete_filter_preset(name):
"""Delete a filter preset"""
try:
if not db:
return jsonify({'error': 'Database not enabled'}), 400
success = db.delete_filter_preset(name)
if success:
return jsonify({'success': True, 'message': f'Deleted preset: {name}'})
else:
return jsonify({'error': 'Preset not found'}), 404
except Exception as e:
logging.error(f"Error deleting filter preset: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/filter-presets/<name>/use', methods=['POST'])
def use_filter_preset(name):
"""Load and use a filter preset"""
try:
if not db:
return jsonify({'error': 'Database not enabled'}), 400
filters = db.use_filter_preset(name)
if filters:
return jsonify({'success': True, 'filters': filters})
else:
return jsonify({'error': 'Preset not found'}), 404
except Exception as e:
logging.error(f"Error using filter preset: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/')
def index():
return render_template('index.html', messages=messages, topics=list(topics))
@app.route('/publish', methods=['POST'])
def publish_message():
topic = request.form['topic']
message = request.form['message']
mqtt_client.publish(topic, message)
debug_bar.record('mqtt', 'last_publish', {'topic': topic, 'message': message})
return jsonify(success=True)
@app.route('/stats')
def get_stats():
return jsonify({
'connection_count': connection_count,
'topic_count': len(topics),
'message_count': len(messages),
'errors': error_log
})
@app.route('/static/<path:path>')
def send_static(path):
return send_from_directory('static', path)
@app.route('/debug-bar')
def get_debug_bar_data():
try:
data = debug_bar.get_data()
return jsonify(data)
except Exception as e:
logging.error(f"Error fetching debug bar data: {e}")
return jsonify({"error": "Failed to fetch debug bar data"}), 500
@app.route('/toggle-debug-bar', methods=['POST'])
def toggle_debug_bar():
if debug_bar.enabled:
debug_bar.disable()
else:
debug_bar.enable()
return jsonify(enabled=debug_bar.enabled)
@app.route('/record-client-performance', methods=['POST'])
def record_client_performance():
data = request.json
debug_bar.record('performance', 'page_load_time', f"{data['pageLoadTime']}ms")
debug_bar.record('performance', 'dom_ready_time', f"{data['domReadyTime']}ms")
return jsonify(success=True)
@app.route('/version')
def get_version():
return jsonify({'version': __version__})
if __name__ == '__main__' or __name__ == 'app':
if mqtt_username and mqtt_password:
mqtt_client.username_pw_set(mqtt_username, mqtt_password)
def connect_mqtt():
if mqtt_username and mqtt_password:
mqtt_client.username_pw_set(mqtt_username, mqtt_password)
logging.info("MQTT credentials set")
else:
logging.info("No MQTT credentials provided")
debug_bar.record('mqtt', 'connection_attempt', f"Connecting to {mqtt_broker}:{mqtt_port}")
debug_bar.record('mqtt', 'broker', mqtt_broker)
debug_bar.record('mqtt', 'port', mqtt_port)
debug_bar.record('mqtt', 'username', mqtt_username if mqtt_username else 'Not set')
debug_bar.record('mqtt', 'password', 'Set' if mqtt_password else 'Not set')
debug_bar.record('mqtt', 'protocol', f'MQTT v{mqtt_version}')
debug_bar.record('mqtt', 'subscribed_topics', MQTT_TOPICS)
logging.info(f"Attempting to connect to MQTT broker at {mqtt_broker}:{mqtt_port}")
try:
mqtt_client.connect(mqtt_broker, mqtt_port, mqtt_keepalive)
mqtt_client.loop_start()
except Exception as e:
error_message = f"Failed to connect to MQTT broker: {str(e)}"
debug_bar.record('mqtt', 'connection_error', error_message)
debug_bar.record('mqtt', 'connection_status', 'Failed')
error_log.append(error_message)
logging.error(error_message)
time.sleep(5)
connect_mqtt() # Retry connection
debug_bar.record('mqtt', 'connection_status', 'Failed')
connect_mqtt()
# Start the Flask-SocketIO server when running directly
# This prevents the app from exiting prematurely (fixes issue #12)
if __name__ == '__main__':
socketio.run(app, host=HOST, port=PORT, debug=DEBUG)
================================================
FILE: database.py
================================================
"""
Database module for MQTT message persistence
"""
import sqlite3
import threading
import logging
import re
import json
from datetime import datetime, timedelta
from typing import List, Dict, Optional, Union
import os
class MessageDatabase:
def __init__(self, db_path: str = "mqtt_messages.db", max_messages: int = 10000):
self.db_path = db_path
self.max_messages = max_messages
self._local = threading.local()
self.init_database()
def get_connection(self):
"""Get thread-local database connection"""
if not hasattr(self._local, 'connection'):
self._local.connection = sqlite3.connect(
self.db_path,
check_same_thread=False,
timeout=30.0
)
self._local.connection.row_factory = sqlite3.Row
# Add REGEXP function for advanced topic filtering
self._local.connection.create_function("REGEXP", 2, self._regexp)
return self._local.connection
def _regexp(self, pattern, value):
"""Custom REGEXP function for SQLite"""
try:
return re.search(pattern, value, re.IGNORECASE) is not None
except Exception:
return False
def init_database(self):
"""Initialize database schema"""
conn = self.get_connection()
try:
cursor = conn.cursor()
# Messages table
cursor.execute('''
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic TEXT NOT NULL,
payload TEXT NOT NULL,
timestamp DATETIME NOT NULL,
qos INTEGER DEFAULT 0,
retain BOOLEAN DEFAULT 0,
payload_size INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
# Topics table for faster topic queries
cursor.execute('''
CREATE TABLE IF NOT EXISTS topics (
topic TEXT PRIMARY KEY,
last_message_at DATETIME NOT NULL,
message_count INTEGER DEFAULT 1,
first_seen DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
# Filter presets table for saved searches
cursor.execute('''
CREATE TABLE IF NOT EXISTS filter_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
description TEXT,
filters TEXT NOT NULL, -- JSON string of filter parameters
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME
)
''')
# Create indexes for better performance
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_messages_topic
ON messages(topic)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_messages_timestamp
ON messages(timestamp DESC)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_messages_topic_timestamp
ON messages(topic, timestamp DESC)
''')
conn.commit()
logging.info("Database initialized successfully")
except Exception as e:
logging.error(f"Database initialization error: {e}")
conn.rollback()
raise
def store_message(self, topic: str, payload: str, timestamp: datetime = None,
qos: int = 0, retain: bool = False) -> bool:
"""Store a message in the database"""
if timestamp is None:
timestamp = datetime.now()
conn = self.get_connection()
try:
cursor = conn.cursor()
# Store message
cursor.execute('''
INSERT INTO messages (topic, payload, timestamp, qos, retain, payload_size)
VALUES (?, ?, ?, ?, ?, ?)
''', (topic, payload, timestamp, qos, retain, len(payload)))
# Update topics table
cursor.execute('''
INSERT OR REPLACE INTO topics (topic, last_message_at, message_count, first_seen)
VALUES (?, ?,
COALESCE((SELECT message_count FROM topics WHERE topic = ?) + 1, 1),
COALESCE((SELECT first_seen FROM topics WHERE topic = ?), ?)
)
''', (topic, timestamp, topic, topic, timestamp))
conn.commit()
# Clean up old messages if needed
self._cleanup_old_messages()
return True
except Exception as e:
logging.error(f"Error storing message: {e}")
conn.rollback()
return False
def get_messages(self, limit: int = 100, offset: int = 0,
topic_filter: str = None, since: datetime = None,
content_search: str = None, regex_topic: str = None,
json_path: str = None, json_value: str = None) -> List[Dict]:
"""Retrieve messages from database with enhanced filtering"""
conn = self.get_connection()
try:
cursor = conn.cursor()
query = '''
SELECT topic, payload, timestamp, qos, retain, payload_size
FROM messages
WHERE 1=1
'''
params = []
# Basic topic filtering (wildcard support)
if topic_filter:
if '%' in topic_filter or '_' in topic_filter:
query += ' AND topic LIKE ?'
else:
query += ' AND topic = ?'
params.append(topic_filter)
# Regex topic filtering (more powerful than LIKE)
if regex_topic:
query += ' AND topic REGEXP ?'
params.append(regex_topic)
# Content search (case-insensitive)
if content_search:
query += ' AND LOWER(payload) LIKE LOWER(?)'
params.append(f'%{content_search}%')
# Time filtering
if since:
query += ' AND timestamp >= ?'
params.append(since)
query += ' ORDER BY timestamp DESC LIMIT ? OFFSET ?'
params.extend([limit, offset])
cursor.execute(query, params)
rows = cursor.fetchall()
messages = [dict(row) for row in rows]
# Apply Python-based filters that can't be done in SQL
if json_path or regex_topic:
messages = self._apply_python_filters(
messages, regex_topic, json_path, json_value
)
return messages
except Exception as e:
logging.error(f"Error retrieving messages: {e}")
return []
def _apply_python_filters(self, messages: List[Dict], regex_topic: str = None,
json_path: str = None, json_value: str = None) -> List[Dict]:
"""Apply filters that require Python processing"""
filtered = []
for msg in messages:
# Regex topic filter (if not handled by SQL REGEXP)
if regex_topic and not re.search(regex_topic, msg['topic'], re.IGNORECASE):
continue
# JSON path filtering
if json_path and json_value:
try:
payload_json = json.loads(msg['payload'])
# Simple JSON path support (e.g., "temperature", "sensors.temp")
value = self._get_json_path_value(payload_json, json_path)
if value is None or str(value).lower() != json_value.lower():
continue
except (json.JSONDecodeError, KeyError):
continue
filtered.append(msg)
return filtered
def _get_json_path_value(self, json_obj: dict, path: str):
"""Extract value from JSON using simple dot notation path"""
try:
keys = path.split('.')
current = json_obj
for key in keys:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return None
return current
except Exception:
return None
def get_topics(self) -> List[Dict]:
"""Get all topics with statistics"""
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute('''
SELECT topic, message_count, last_message_at, first_seen
FROM topics
ORDER BY last_message_at DESC
''')
rows = cursor.fetchall()
return [dict(row) for row in rows]
except Exception as e:
logging.error(f"Error retrieving topics: {e}")
return []
def get_message_count(self, topic_filter: str = None, since: datetime = None) -> int:
"""Get total message count with optional filtering"""
conn = self.get_connection()
try:
cursor = conn.cursor()
query = 'SELECT COUNT(*) FROM messages WHERE 1=1'
params = []
if topic_filter:
if '%' in topic_filter or '_' in topic_filter:
query += ' AND topic LIKE ?'
else:
query += ' AND topic = ?'
params.append(topic_filter)
if since:
query += ' AND timestamp >= ?'
params.append(since)
cursor.execute(query, params)
return cursor.fetchone()[0]
except Exception as e:
logging.error(f"Error counting messages: {e}")
return 0
def _cleanup_old_messages(self):
"""Remove old messages to stay within limit"""
conn = self.get_connection()
try:
cursor = conn.cursor()
# Check if we need cleanup
cursor.execute('SELECT COUNT(*) FROM messages')
count = cursor.fetchone()[0]
if count > self.max_messages:
# Delete oldest messages
messages_to_delete = count - self.max_messages + 1000 # Delete extra to avoid frequent cleanups
cursor.execute('''
DELETE FROM messages
WHERE id IN (
SELECT id FROM messages
ORDER BY timestamp ASC
LIMIT ?
)
''', (messages_to_delete,))
# Update topic counts (this is approximate)
cursor.execute('''
UPDATE topics SET message_count = (
SELECT COUNT(*) FROM messages WHERE messages.topic = topics.topic
)
''')
conn.commit()
logging.info(f"Cleaned up {messages_to_delete} old messages")
except Exception as e:
logging.error(f"Error during cleanup: {e}")
conn.rollback()
def cleanup_old_data(self, days: int = 30):
"""Remove messages older than specified days"""
cutoff_date = datetime.now() - timedelta(days=days)
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute('DELETE FROM messages WHERE timestamp < ?', (cutoff_date,))
deleted = cursor.rowcount
# Clean up topics with no messages
cursor.execute('''
DELETE FROM topics WHERE topic NOT IN (
SELECT DISTINCT topic FROM messages
)
''')
conn.commit()
logging.info(f"Cleaned up {deleted} messages older than {days} days")
return deleted
except Exception as e:
logging.error(f"Error during data cleanup: {e}")
conn.rollback()
return 0
def get_database_size(self) -> Dict:
"""Get database size information"""
try:
size_bytes = os.path.getsize(self.db_path) if os.path.exists(self.db_path) else 0
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute('SELECT COUNT(*) FROM messages')
message_count = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM topics')
topic_count = cursor.fetchone()[0]
return {
'size_bytes': size_bytes,
'size_mb': round(size_bytes / 1024 / 1024, 2),
'message_count': message_count,
'topic_count': topic_count
}
except Exception as e:
logging.error(f"Error getting database size: {e}")
return {'size_bytes': 0, 'size_mb': 0, 'message_count': 0, 'topic_count': 0}
def save_filter_preset(self, name: str, filters: Dict, description: str = None) -> bool:
"""Save a filter preset"""
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute('''
INSERT OR REPLACE INTO filter_presets (name, description, filters, last_used)
VALUES (?, ?, ?, ?)
''', (name, description, json.dumps(filters), datetime.now()))
conn.commit()
logging.info(f"Saved filter preset: {name}")
return True
except Exception as e:
logging.error(f"Error saving filter preset: {e}")
conn.rollback()
return False
def get_filter_presets(self) -> List[Dict]:
"""Get all filter presets"""
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute('''
SELECT name, description, filters, created_at, last_used
FROM filter_presets
ORDER BY last_used DESC, created_at DESC
''')
rows = cursor.fetchall()
presets = []
for row in rows:
preset = dict(row)
preset['filters'] = json.loads(preset['filters'])
presets.append(preset)
return presets
except Exception as e:
logging.error(f"Error getting filter presets: {e}")
return []
def delete_filter_preset(self, name: str) -> bool:
"""Delete a filter preset"""
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute('DELETE FROM filter_presets WHERE name = ?', (name,))
conn.commit()
if cursor.rowcount > 0:
logging.info(f"Deleted filter preset: {name}")
return True
return False
except Exception as e:
logging.error(f"Error deleting filter preset: {e}")
conn.rollback()
return False
def use_filter_preset(self, name: str) -> Optional[Dict]:
"""Load and mark a filter preset as used"""
conn = self.get_connection()
try:
cursor = conn.cursor()
# Get the preset
cursor.execute('''
SELECT filters FROM filter_presets WHERE name = ?
''', (name,))
row = cursor.fetchone()
if not row:
return None
# Update last_used timestamp
cursor.execute('''
UPDATE filter_presets SET last_used = ? WHERE name = ?
''', (datetime.now(), name))
conn.commit()
return json.loads(row['filters'])
except Exception as e:
logging.error(f"Error using filter preset: {e}")
return None
def close(self):
"""Close database connection"""
if hasattr(self._local, 'connection'):
self._local.connection.close()
================================================
FILE: debug_bar.py
================================================
import time
import psutil
from flask import request
from threading import Lock
import logging
class DebugBarPanel:
def __init__(self, name):
self.name = name
self.data = {}
def record(self, key, value):
self.data[key] = value
def get_data(self):
return self.data
class DebugBar:
def __init__(self):
self.panels = {}
self.enabled = False
self.start_time = None
self.lock = Lock()
try:
self.process = psutil.Process()
except Exception as e:
logging.error(f"Failed to initialize psutil Process: {e}")
self.process = None
def add_panel(self, name):
with self.lock:
if name not in self.panels:
self.panels[name] = DebugBarPanel(name)
def record(self, panel, key, value):
with self.lock:
if panel in self.panels:
self.panels[panel].record(key, value)
def start_request(self):
self.start_time = time.time()
def end_request(self):
if self.start_time:
duration = time.time() - self.start_time
self.record('request', 'duration', f"{duration:.2f}s")
self.start_time = None
def get_data(self):
with self.lock:
#self.update_performance_metrics()
return {name: panel.get_data() for name, panel in self.panels.items()}
def enable(self):
self.enabled = True
def disable(self):
self.enabled = False
def remove(self, panel_name, key):
with self.lock:
if panel_name in self.panels:
panel = self.panels[panel_name]
if key in panel.data:
del panel.data[key]
debug_bar = DebugBar()
# Initialize default panels
debug_bar.add_panel('mqtt')
debug_bar.add_panel('request')
debug_bar.add_panel('performance')
def debug_bar_middleware():
debug_bar.start_request()
debug_bar.record('request', 'path', request.path)
debug_bar.record('request', 'method', request.method)
================================================
FILE: demo.sh
================================================
#!/bin/bash
# =============================================================================
# MQTTUI v2.1 Demo Script
# Populates realistic MQTT data across multiple brokers to test all features
# Usage: ./demo.sh
# Requires: docker compose running (mosquitto + mosquitto2 + mqttui containers)
# =============================================================================
set -e
BROKER1="mosquitto"
BROKER2="mosquitto2"
API_URL="http://localhost:8088/api/v1"
PUB1="docker exec $BROKER1 mosquitto_pub"
PUB2="docker exec $BROKER2 mosquitto_pub"
# Colors
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE} MQTTUI v2.1 Demo Data Generator (Multi-Broker)${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
# Check containers are running
for c in $BROKER1 $BROKER2 mqttui; do
if ! docker ps --format '{{.Names}}' | grep -q "^${c}$"; then
echo "Error: $c container not running. Run: docker compose up -d"
exit 1
fi
done
# ---------------------------------------------------------------------------
# 1. Login and get session cookie
# ---------------------------------------------------------------------------
echo -e "${YELLOW}[1/8] Authenticating...${NC}"
COOKIE_JAR="/tmp/mqttui-demo-cookies.txt"
LOGIN_RESP=$(curl -s -c "$COOKIE_JAR" -X POST "http://localhost:8088/login" \
-d "username=admin&password=admin" \
-o /dev/null -w "%{http_code}")
if [ "$LOGIN_RESP" != "302" ] && [ "$LOGIN_RESP" != "200" ]; then
echo " Login failed (HTTP $LOGIN_RESP). Check MQTTUI_ADMIN_USER/PASSWORD."
exit 1
fi
echo -e " ${GREEN}✓ Logged in as admin${NC}"
# ---------------------------------------------------------------------------
# 2. Set up second broker connection
# ---------------------------------------------------------------------------
echo -e "${YELLOW}[2/8] Setting up second broker...${NC}"
# Check if Broker 2 already exists
EXISTING=$(curl -s -b "$COOKIE_JAR" "$API_URL/brokers/" | python3 -c "
import sys,json
d=json.load(sys.stdin)
brokers = (d.get('data') or d).get('brokers',[])
print(len([b for b in brokers if b.get('host') == '$BROKER2']))
" 2>/dev/null)
if [ "$EXISTING" = "0" ]; then
curl -s -b "$COOKIE_JAR" -X POST "$API_URL/brokers/" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Warehouse\",
\"host\": \"$BROKER2\",
\"port\": 1883,
\"topics\": \"#\",
\"is_active\": true
}" > /dev/null
echo -e " ${GREEN}✓ Added 'Warehouse' broker ($BROKER2:1883)${NC}"
sleep 2 # Wait for connection
else
echo -e " ${GREEN}✓ Second broker already exists${NC}"
fi
BROKER_COUNT=$(curl -s -b "$COOKIE_JAR" "$API_URL/brokers/" | python3 -c "
import sys,json; d=json.load(sys.stdin)
brokers = (d.get('data') or d).get('brokers',[])
connected = sum(1 for b in brokers if b.get('connected'))
print(f'{connected}/{len(brokers)} connected')
" 2>/dev/null)
echo -e " Brokers: ${GREEN}$BROKER_COUNT${NC}"
# ---------------------------------------------------------------------------
# 3. Publish to Broker 1 — Home automation sensors
# ---------------------------------------------------------------------------
echo -e "${YELLOW}[3/8] Publishing to Broker 1 (Default) — Home sensors...${NC}"
# Temperature sensors
for i in $(seq 1 40); do
temp=$(echo "scale=1; 18 + ($RANDOM % 200) / 10" | bc)
$PUB1 -t "home/living-room/temperature" -m "{\"value\": $temp, \"unit\": \"C\", \"device\": \"DHT22\"}"
$PUB1 -t "home/bedroom/temperature" -m "{\"value\": $temp, \"unit\": \"C\", \"device\": \"DHT22\"}"
done
echo -e " ${GREEN}✓ 80 temperature readings${NC}"
# Humidity
for i in $(seq 1 20); do
humidity=$((40 + RANDOM % 40))
$PUB1 -t "home/living-room/humidity" -m "{\"value\": $humidity, \"unit\": \"%\"}"
done
echo -e " ${GREEN}✓ 20 humidity readings${NC}"
# Motion sensors
for i in $(seq 1 15); do
room=$(echo "hallway kitchen garage front-door" | tr ' ' '\n' | sort -R 2>/dev/null | head -1 || awk 'BEGIN{srand()}{a[NR]=$0}END{print a[int(rand()*NR)+1]}')
$PUB1 -t "home/$room/motion" -m "{\"detected\": true, \"confidence\": $((70 + RANDOM % 30))}"
done
echo -e " ${GREEN}✓ 15 motion events${NC}"
# Battery levels
for i in $(seq 1 10); do
device=$(echo "thermostat doorbell smoke-detector leak-sensor" | tr ' ' '\n' | sort -R 2>/dev/null | head -1 || awk 'BEGIN{srand()}{a[NR]=$0}END{print a[int(rand()*NR)+1]}')
battery=$((5 + RANDOM % 95))
$PUB1 -t "home/$device/battery" -m "{\"level\": $battery, \"charging\": false}"
done
echo -e " ${GREEN}✓ 10 battery levels${NC}"
# Retained status
$PUB1 -t "system/home/status" -m '{"status": "online", "version": "2.1.0"}' -r
echo -e " ${GREEN}✓ 1 retained status message${NC}"
echo -e " ${GREEN}Broker 1 total: 126 messages${NC}"
# ---------------------------------------------------------------------------
# 4. Publish to Broker 2 — Warehouse / industrial sensors
# ---------------------------------------------------------------------------
echo -e "${YELLOW}[4/8] Publishing to Broker 2 (Warehouse) — Industrial sensors...${NC}"
# Warehouse temperature (cold storage monitoring)
for i in $(seq 1 40); do
temp=$(echo "scale=1; -5 + ($RANDOM % 150) / 10" | bc)
zone=$(echo "zone-A zone-B zone-C" | tr ' ' '\n' | sort -R 2>/dev/null | head -1 || awk 'BEGIN{srand()}{a[NR]=$0}END{print a[int(rand()*NR)+1]}')
$PUB2 -t "warehouse/$zone/temperature" -m "{\"value\": $temp, \"unit\": \"C\", \"sensor\": \"PT100\"}"
done
echo -e " ${GREEN}✓ 40 cold storage temperature readings${NC}"
# Conveyor belt speed
for i in $(seq 1 20); do
line=$(echo "line-1 line-2 line-3" | tr ' ' '\n' | sort -R 2>/dev/null | head -1 || awk 'BEGIN{srand()}{a[NR]=$0}END{print a[int(rand()*NR)+1]}')
speed=$(echo "scale=1; 1 + ($RANDOM % 50) / 10" | bc)
$PUB2 -t "warehouse/$line/conveyor/speed" -m "{\"value\": $speed, \"unit\": \"m/s\"}"
done
echo -e " ${GREEN}✓ 20 conveyor speed readings${NC}"
# Door access events
for i in $(seq 1 15); do
door=$(echo "main loading-dock office emergency" | tr ' ' '\n' | sort -R 2>/dev/null | head -1 || awk 'BEGIN{srand()}{a[NR]=$0}END{print a[int(rand()*NR)+1]}')
action=$(echo "opened closed" | tr ' ' '\n' | sort -R 2>/dev/null | head -1 || awk 'BEGIN{srand()}{a[NR]=$0}END{print a[int(rand()*NR)+1]}')
$PUB2 -t "warehouse/doors/$door" -m "{\"action\": \"$action\", \"badge_id\": \"EMP-$((100 + RANDOM % 900))\"}"
done
echo -e " ${GREEN}✓ 15 door access events${NC}"
# Power meters
for i in $(seq 1 10); do
meter=$(echo "main-panel hvac compressor lighting" | tr ' ' '\n' | sort -R 2>/dev/null | head -1 || awk 'BEGIN{srand()}{a[NR]=$0}END{print a[int(rand()*NR)+1]}')
watts=$((500 + RANDOM % 9500))
$PUB2 -t "warehouse/power/$meter" -m "{\"watts\": $watts, \"voltage\": 240}"
done
echo -e " ${GREEN}✓ 10 power meter readings${NC}"
# Retained status
$PUB2 -t "system/warehouse/status" -m '{"status": "online", "zones": 3, "conveyors": 3}' -r
echo -e " ${GREEN}✓ 1 retained status message${NC}"
echo -e " ${GREEN}Broker 2 total: 86 messages${NC}"
# ---------------------------------------------------------------------------
# 5. Create automation rules (spanning both brokers)
# ---------------------------------------------------------------------------
echo -e "${YELLOW}[5/8] Creating automation rules...${NC}"
# Rule 1: High temperature alert (home)
curl -s -b "$COOKIE_JAR" -X POST "$API_URL/rules/" \
-H "Content-Type: application/json" \
-d '{
"name": "Home High Temp Alert",
"description": "Alert when home temperature exceeds 35°C",
"trigger_topic": "home/+/temperature",
"condition": {"path": "value", "op": "gt", "value": 35},
"action": {"type": "publish", "topic": "alerts/high-temp", "payload": "{\"alert\": \"Home temperature exceeded 35°C\"}"},
"rate_limit_per_min": 5
}' > /dev/null
echo -e " ${GREEN}✓ Home High Temp Alert${NC}"
# Rule 2: Cold storage warning (warehouse — temp too high for cold storage)
curl -s -b "$COOKIE_JAR" -X POST "$API_URL/rules/" \
-H "Content-Type: application/json" \
-d '{
"name": "Cold Storage Warning",
"description": "Alert when warehouse temp rises above 5°C",
"trigger_topic": "warehouse/+/temperature",
"condition": {"path": "value", "op": "gt", "value": 5},
"action": {"type": "log", "severity": "critical", "message": "Cold storage temperature exceeded threshold"},
"rate_limit_per_min": 10
}' > /dev/null
echo -e " ${GREEN}✓ Cold Storage Warning (warehouse temp > 5°C)${NC}"
# Rule 3: Low battery warning
curl -s -b "$COOKIE_JAR" -X POST "$API_URL/rules/" \
-H "Content-Type: application/json" \
-d '{
"name": "Low Battery Warning",
"description": "Log when any device battery drops below 20%",
"trigger_topic": "home/+/battery",
"condition": {"path": "level", "op": "lt", "value": 20},
"action": {"type": "log", "severity": "warning", "message": "Low battery detected"},
"rate_limit_per_min": 10
}' > /dev/null
echo -e " ${GREEN}✓ Low Battery Warning${NC}"
# Rule 4: Emergency door monitor (warehouse)
curl -s -b "$COOKIE_JAR" -X POST "$API_URL/rules/" \
-H "Content-Type: application/json" \
-d '{
"name": "Emergency Door Monitor",
"description": "Log when emergency door is opened",
"trigger_topic": "warehouse/doors/emergency",
"condition": {"path": "action", "op": "eq", "value": "opened"},
"action": {"type": "log", "severity": "critical", "message": "Emergency door opened!"},
"rate_limit_per_min": 30
}' > /dev/null
echo -e " ${GREEN}✓ Emergency Door Monitor${NC}"
# Rule 5: Motion logger
curl -s -b "$COOKIE_JAR" -X POST "$API_URL/rules/" \
-H "Content-Type: application/json" \
-d '{
"name": "Motion Event Logger",
"description": "Log all motion detection events",
"trigger_topic": "home/+/motion",
"condition": {},
"action": {"type": "log", "severity": "info", "message": "Motion detected"},
"rate_limit_per_min": 30
}' > /dev/null
echo -e " ${GREEN}✓ Motion Event Logger${NC}"
# ---------------------------------------------------------------------------
# 6. Trigger rules with matching messages
# ---------------------------------------------------------------------------
echo -e "${YELLOW}[6/8] Triggering rules with matching messages...${NC}"
# Trigger home high temp
for i in $(seq 1 5); do
temp=$(echo "scale=1; 36 + ($RANDOM % 50) / 10" | bc)
$PUB1 -t "home/living-room/temperature" -m "{\"value\": $temp, \"unit\": \"C\", \"device\": \"DHT22\"}"
sleep 0.3
done
echo -e " ${GREEN}✓ 5 high-temp messages on Broker 1${NC}"
# Trigger cold storage warnings
for i in $(seq 1 5); do
temp=$(echo "scale=1; 6 + ($RANDOM % 40) / 10" | bc)
zone=$(echo "zone-A zone-B zone-C" | tr ' ' '\n' | sort -R 2>/dev/null | head -1 || awk 'BEGIN{srand()}{a[NR]=$0}END{print a[int(rand()*NR)+1]}')
$PUB2 -t "warehouse/$zone/temperature" -m "{\"value\": $temp, \"unit\": \"C\", \"sensor\": \"PT100\"}"
sleep 0.3
done
echo -e " ${GREEN}✓ 5 cold storage warnings on Broker 2${NC}"
# Trigger low battery
for i in $(seq 1 3); do
device=$(echo "thermostat doorbell smoke-detector" | tr ' ' '\n' | sort -R 2>/dev/null | head -1 || awk 'BEGIN{srand()}{a[NR]=$0}END{print a[int(rand()*NR)+1]}')
battery=$((3 + RANDOM % 15))
$PUB1 -t "home/$device/battery" -m "{\"level\": $battery, \"charging\": false}"
sleep 0.3
done
echo -e " ${GREEN}✓ 3 low-battery messages${NC}"
# Trigger emergency door
$PUB2 -t "warehouse/doors/emergency" -m '{"action": "opened", "badge_id": "EMP-999"}'
echo -e " ${GREEN}✓ 1 emergency door event on Broker 2${NC}"
sleep 3
# ---------------------------------------------------------------------------
# 7. Create filter presets + bookmarks
# ---------------------------------------------------------------------------
echo -e "${YELLOW}[7/8] Creating filter presets and bookmarks...${NC}"
curl -s -b "$COOKIE_JAR" -X POST "$API_URL/filter-presets" \
-H "Content-Type: application/json" \
-d '{"name": "Home Sensors", "description": "All home sensor data", "filters": {"regex_topic": "home/.*"}}' > /dev/null
echo -e " ${GREEN}✓ 'Home Sensors' preset${NC}"
curl -s -b "$COOKIE_JAR" -X POST "$API_URL/filter-presets" \
-H "Content-Type: application/json" \
-d '{"name": "Warehouse Only", "description": "All warehouse data", "filters": {"regex_topic": "warehouse/.*"}}' > /dev/null
echo -e " ${GREEN}✓ 'Warehouse Only' preset${NC}"
curl -s -b "$COOKIE_JAR" -X POST "$API_URL/filter-presets" \
-H "Content-Type: application/json" \
-d '{"name": "Alerts", "description": "Alert topics only", "filters": {"regex_topic": "alerts/.*"}}' > /dev/null
echo -e " ${GREEN}✓ 'Alerts' preset${NC}"
for topic in "home/living-room/temperature" "warehouse/zone-A/temperature" "warehouse/doors/emergency"; do
curl -s -b "$COOKIE_JAR" -X POST "$API_URL/topics/$(echo $topic | sed 's/\//%2F/g')/bookmark" > /dev/null 2>&1
echo -e " ${GREEN}✓ Bookmarked: $topic${NC}"
done
# ---------------------------------------------------------------------------
# 8. Verify data via API
# ---------------------------------------------------------------------------
echo -e "${YELLOW}[8/8] Verifying data via API...${NC}"
MSG_COUNT=$(curl -s -b "$COOKIE_JAR" "$API_URL/messages?limit=1" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',d).get('total',0))" 2>/dev/null)
echo -e " Messages stored: ${GREEN}$MSG_COUNT${NC}"
BROKER_STATUS=$(curl -s -b "$COOKIE_JAR" "$API_URL/brokers/" | python3 -c "
import sys,json
d=json.load(sys.stdin)
brokers = (d.get('data') or d).get('brokers',[])
for b in brokers:
status = '✓ Connected' if b.get('connected') else '✗ Disconnected'
print(f\" {b['name']} ({b['host']}:{b['port']}): {status}\")
" 2>/dev/null)
echo -e " Brokers:"
echo -e "${GREEN}$BROKER_STATUS${NC}"
RULE_COUNT=$(curl -s -b "$COOKIE_JAR" "$API_URL/rules/" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('data',d).get('rules',[])))" 2>/dev/null)
echo -e " Rules created: ${GREEN}$RULE_COUNT${NC}"
ALERT_COUNT=$(curl -s -b "$COOKIE_JAR" "$API_URL/alerts/" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',d).get('total',0))" 2>/dev/null)
echo -e " Alerts fired: ${GREEN}$ALERT_COUNT${NC}"
ANALYTICS=$(curl -s -b "$COOKIE_JAR" "$API_URL/analytics/topics?limit=5" | python3 -c "
import sys,json
d=json.load(sys.stdin)
topics = d.get('data',d).get('topics',[])
for t in topics[:5]:
print(f\" {t['topic']}: {t.get('rate_per_min',0):.0f}/min, {t.get('message_count',0)} msgs\")
" 2>/dev/null)
echo -e " Analytics (top 5 topics):"
echo -e "${GREEN}$ANALYTICS${NC}"
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE} Demo Data Ready! (Multi-Broker)${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "Open ${GREEN}http://localhost:8088${NC} and check:"
echo ""
echo -e " ${YELLOW}Dashboard${NC}"
echo " - Messages from BOTH brokers (look for broker name badges)"
echo " - Topic graph with home/* and warehouse/* nodes"
echo " - Use Broker dropdown in Advanced Search to filter by broker"
echo " - Use ★ Favorites Only to see bookmarked topics"
echo ""
echo -e " ${YELLOW}Brokers${NC}"
echo " - Default (mosquitto) — home automation sensors"
echo " - Warehouse (mosquitto2) — industrial sensors"
echo " - Both should show 'Connected' with green indicator"
echo ""
echo -e " ${YELLOW}Rules${NC}"
echo " - 5 rules spanning both brokers"
echo " - Home High Temp + Cold Storage + Low Battery + Emergency Door + Motion"
echo ""
echo -e " ${YELLOW}Alerts${NC}"
echo " - Alerts from rules on both brokers"
echo " - Filter by rule to see per-broker alerts"
echo ""
echo -e " ${YELLOW}Analytics${NC}"
echo " - Topics from both brokers in the same view"
echo " - Temperature histograms for home AND warehouse"
echo ""
rm -f "$COOKIE_JAR"
================================================
FILE: docker-compose.yml
================================================
services:
mosquitto:
image: eclipse-mosquitto:latest
container_name: mosquitto
ports:
- "1883:1883"
- "9002:9001"
volumes:
- ./mosquitto.conf:/mosquitto/config/mosquitto.conf
mosquitto2:
image: eclipse-mosquitto:latest
container_name: mosquitto2
ports:
- "1884:1883"
volumes:
- ./mosquitto.conf:/mosquitto/config/mosquitto.conf
web:
container_name: mqttui
build: .
ports:
- "8088:5000"
environment:
- DEBUG=False
- HOST=0.0.0.0
- PORT=5000
- MQTT_BROKER=mosquitto
- MQTT_PORT=1883
- MQTT_USERNAME=
- MQTT_PASSWORD=
- MQTT_KEEPALIVE=60
- MQTT_VERSION=3.1.1
- SECRET_KEY=mqttui-dev-secret-change-in-prod
- LOG_LEVEL=INFO
- MQTT_TOPICS=#
- DB_ENABLED=True
- DB_PATH=/app/data/mqtt_messages.db
- DB_MAX_MESSAGES=10000
- DB_CLEANUP_DAYS=30
- MQTTUI_ADMIN_USER=admin
- MQTTUI_ADMIN_PASSWORD=admin
- MQTTUI_RATE_LIMIT=30/minute
volumes:
- mqtt-data:/app/data
depends_on:
- mosquitto
volumes:
mqtt-data:
================================================
FILE: entrypoint.sh
================================================
#!/bin/sh
# Default to port 5000 if PORT is not set
PORT="${PORT:-5000}"
# Set default LOG_LEVEL if not provided
LOG_LEVEL="${LOG_LEVEL:-info}"
# Convert to lowercase for comparison (sh compatible)
LOG_LEVEL_LOWER=$(echo "$LOG_LEVEL" | tr '[:upper:]' '[:lower:]')
# Validate and normalize log level for gunicorn
case "$LOG_LEVEL_LOWER" in
debug|info|warning|warn|error|critical)
# Convert WARN to WARNING for gunicorn compatibility
if [ "$LOG_LEVEL_LOWER" = "warn" ]; then
LOG_LEVEL="warning"
else
LOG_LEVEL="$LOG_LEVEL_LOWER"
fi
;;
*)
echo "Invalid LOG_LEVEL: $LOG_LEVEL. Using default: info"
LOG_LEVEL="info"
;;
esac
if [ "$DEBUG" = "True" ] || [ "$DEBUG" = "1" ] || [ "$DEBUG" = "true" ]; then
echo "Running in DEBUG mode with log level: $LOG_LEVEL"
exec python wsgi.py
else
echo "Running in PRODUCTION mode with log level: $LOG_LEVEL"
exec gunicorn --log-level "$LOG_LEVEL" --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker -w 1 -b "0.0.0.0:$PORT" "wsgi:app"
fi
================================================
FILE: mosquitto.conf
================================================
listener 1883
allow_anonymous true
persistence false
# WebSocket support
listener 9001
protocol websockets
================================================
FILE: mqttui/__init__.py
================================================
__version__ = "2.0.0-dev"
================================================
FILE: mqttui/analytics.py
================================================
"""Per-topic analytics engine with rate counters and numeric payload histograms.
Provides real-time message rate tracking and statistical aggregation for
numeric JSON payload fields. Subscribes to mqtt_message_received signal
for automatic data collection.
"""
import json
import logging
import time
from collections import deque
from mqttui.events import mqtt_message_received
logger = logging.getLogger(__name__)
# Maximum timestamps stored per topic (automatic memory bounding)
MAX_TIMESTAMPS = 10000
class TopicAnalytics:
"""Track per-topic message rates and numeric payload histograms.
Data structures per topic:
_timestamps: dict[str, deque] -- rolling window of message timestamps
_histograms: dict[str, dict] -- per-topic numeric stats per field
Each field: {min, max, sum, count}
"""
def __init__(self):
self._timestamps: dict[str, deque] = {}
self._histograms: dict[str, dict] = {}
def record(self, topic: str, payload: str, timestamp: float):
"""Record a message for a topic.
Args:
topic: MQTT topic string
payload: Raw payload string (may be JSON)
timestamp: Unix timestamp of message receipt
"""
# Track timestamp for rate calculation
if topic not in self._timestamps:
self._timestamps[topic] = deque(maxlen=MAX_TIMESTAMPS)
# Ensure timestamp is a Unix float for rate calculations
if hasattr(timestamp, 'timestamp'):
timestamp = timestamp.timestamp()
self._timestamps[topic].append(timestamp)
# Try to extract numeric fields from JSON payload
try:
data = json.loads(payload)
if isinstance(data, dict):
for field_name, value in data.items():
if isinstance(value, (int, float)) and not isinstance(value, bool):
self._update_histogram(topic, field_name, float(value))
except (json.JSONDecodeError, TypeError, ValueError):
pass
def _update_histogram(self, topic: str, field_name: str, value: float):
"""Update histogram stats for a numeric field."""
if topic not in self._histograms:
self._histograms[topic] = {}
if field_name not in self._histograms[topic]:
self._histograms[topic][field_name] = {
"min": value,
"max": value,
"sum": value,
"count": 1,
}
else:
stats = self._histograms[topic][field_name]
stats["min"] = min(stats["min"], value)
stats["max"] = max(stats["max"], value)
stats["sum"] += value
stats["count"] += 1
def get_rate(self, topic: str, window: int = 60) -> float:
"""Count messages within the last `window` seconds.
Args:
topic: MQTT topic string
window: Time window in seconds (default 60)
Returns:
Number of messages received within the window.
"""
if topic not in self._timestamps:
return 0.0
cutoff = time.time() - window
count = sum(1 for ts in self._timestamps[topic] if ts >= cutoff)
return float(count)
def get_topic_stats(self, topic: str) -> dict:
"""Return full stats dict for a single topic.
Returns:
Dict with topic, rate_per_min, rate_per_hour, message_count, histograms
"""
ts_deque = self._timestamps.get(topic, deque())
return {
"topic": topic,
"rate_per_min": self.get_rate(topic, 60),
"rate_per_hour": self.get_rate(topic, 3600),
"message_count": len(ts_deque),
"histograms": self._histograms.get(topic, {}),
}
def get_all_stats(self, limit: int = 20) -> list:
"""Return stats for all tracked topics, sorted by rate_per_min descending.
Args:
limit: Maximum number of topics to return (default 20)
Returns:
List of topic stats dicts.
"""
all_topics = list(self._timestamps.keys())
stats_list = [self.get_topic_stats(t) for t in all_topics]
stats_list.sort(key=lambda s: s["rate_per_min"], reverse=True)
return stats_list[:limit]
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_analytics = None
def get_analytics() -> TopicAnalytics:
"""Return the module-level TopicAnalytics singleton."""
global _analytics
if _analytics is None:
_analytics = TopicAnalytics()
return _analytics
# ---------------------------------------------------------------------------
# Signal subscriber
# ---------------------------------------------------------------------------
def _on_mqtt_message(sender, **kwargs):
"""Handle mqtt_message_received signal -- record message in analytics."""
get_analytics().record(
kwargs["topic"],
kwargs["payload"],
kwargs["timestamp"],
)
================================================
FILE: mqttui/app.py
================================================
import os
import structlog
from flask import Flask
from mqttui.extensions import socketio, sa, login_manager
from mqttui.events import mqtt_message_received, rule_fired, alert_triggered
from mqttui.logging_config import configure_logging
def _on_mqtt_message(sender, **kwargs):
"""Forward MQTT messages to WebSocket clients and persist to database."""
topic = kwargs['topic']
payload = kwargs['payload']
timestamp = kwargs['timestamp']
qos = kwargs.get('qos', 0)
retain = kwargs.get('retain', False)
# Emit to connected browsers via batch emitter (100ms batching)
from mqttui.socketio_batch import get_batch_emitter
emitter = get_batch_emitter()
msg_data = {
'topic': topic,
'payload': payload,
'timestamp': timestamp.isoformat(),
'retain': retain,
'broker_id': kwargs.get('broker_id'),
'broker_name': kwargs.get('broker_name'),
}
if emitter:
emitter.enqueue(msg_data)
else:
socketio.emit('mqtt_message', msg_data)
# Persist to database
import mqttui.extensions as ext
if ext.db:
try:
ext.db.store_message(
topic=topic, payload=payload,
timestamp=timestamp, qos=qos, retain=retain,
)
except Exception as e:
structlog.get_logger(__name__).error("Failed to store message", error=str(e))
def create_app(config=None):
"""Application factory for mqttui."""
app = Flask(
__name__,
static_folder='../static',
template_folder='../templates'
)
# Load config from environment variables
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'your-secret-key')
app.config['DEBUG'] = os.getenv('DEBUG', 'False').lower() in ('true', '1', 't')
app.config['HOST'] = os.getenv('HOST', '0.0.0.0')
app.config['PORT'] = int(os.getenv('PORT', 5000))
app.config['MQTT_BROKER'] = os.getenv('MQTT_BROKER', 'localhost')
app.config['MQTT_PORT'] = int(os.getenv('MQTT_PORT', 1883))
app.config['MQTT_USERNAME'] = os.getenv('MQTT_USERNAME')
app.config['MQTT_PASSWORD'] = os.getenv('MQTT_PASSWORD')
app.config['MQTT_KEEPALIVE'] = int(os.getenv('MQTT_KEEPALIVE', 60))
app.config['MQTT_VERSION'] = os.getenv('MQTT_VERSION', '3.1.1')
app.config['MQTT_TOPICS'] = os.getenv('MQTT_TOPICS', '#')
app.config['MQTT_TLS'] = os.getenv('MQTT_TLS', 'false')
app.config['MQTT_TLS_CA_CERTS'] = os.getenv('MQTT_TLS_CA_CERTS', '')
app.config['MQTT_TLS_CERTFILE'] = os.getenv('MQTT_TLS_CERTFILE', '')
app.config['MQTT_TLS_KEYFILE'] = os.getenv('MQTT_TLS_KEYFILE', '')
app.config['MQTT_TLS_INSECURE'] = os.getenv('MQTT_TLS_INSECURE', 'false')
app.config['DB_ENABLED'] = os.getenv('DB_ENABLED', 'True').lower() in ('true', '1', 't')
app.config['DB_PATH'] = os.getenv('DB_PATH', 'mqtt_messages.db')
app.config['DB_MAX_MESSAGES'] = int(os.getenv('DB_MAX_MESSAGES', 10000))
app.config['DB_CLEANUP_DAYS'] = int(os.getenv('DB_CLEANUP_DAYS', 30))
# Override with passed config dict
if config:
app.config.update(config)
# Set up structured logging via structlog
log_level_name = os.getenv('LOG_LEVEL', 'DEBUG' if app.config['DEBUG'] else 'INFO').upper()
configure_logging(debug=app.config['DEBUG'], log_level=log_level_name)
# SECRET_KEY production guard
flask_env = os.getenv('FLASK_ENV', 'development')
insecure_keys = {'dev', 'change-me', 'your-secret-key'}
if flask_env == 'production' and app.config['SECRET_KEY'] in insecure_keys:
raise RuntimeError(
"SECRET_KEY is insecure. Set a strong SECRET_KEY environment variable for production."
)
# Initialize SocketIO with gevent async mode
socketio.init_app(app, async_mode='gevent')
# Initialize batch emitter for Socket.IO message batching (100ms windows)
from mqttui.socketio_batch import init_batch_emitter
init_batch_emitter(socketio, interval_ms=100)
# Enable CORS for API endpoints
from flask_cors import CORS
CORS(app, resources={r"/api/*": {"origins": "*"}})
# Configure and initialize SQLAlchemy for user database
db_dir = os.path.dirname(os.path.abspath(app.config.get('DB_PATH', 'mqtt_messages.db')))
app.config.setdefault('SQLALCHEMY_DATABASE_URI', f"sqlite:///{os.path.join(db_dir, 'mqttui_users.db')}")
app.config.setdefault('SQLALCHEMY_TRACK_MODIFICATIONS', False)
sa.init_app(app)
login_manager.init_app(app)
# Initialize rate limiter
from mqttui.extensions import limiter
rate_limit = os.getenv('MQTTUI_RATE_LIMIT', '30/minute')
app.config['RATELIMIT_DEFAULT'] = rate_limit
limiter.init_app(app)
with app.app_context():
from mqttui.models import User # noqa: F811
from mqttui.rules.models import Rule, AlertHistory # noqa: F401
from mqttui.plugins.models import PluginConfig # noqa: F401
sa.create_all()
# Initialize database if enabled
if app.config['DB_ENABLED']:
try:
from mqttui.database import MessageDatabase
import mqttui.extensions as ext
db_instance = MessageDatabase(
app.config['DB_PATH'],
app.config['DB_MAX_MESSAGES']
)
ext.db = db_instance
structlog.get_logger(__name__).info("Database initialized", path=app.config['DB_PATH'])
except Exception as e:
structlog.get_logger(__name__).error("Failed to initialize database", error=str(e))
# Register blueprints
from mqttui.routes.main import bp as main_bp
from mqttui.routes.api import bp as api_bp # TODO: Remove legacy /api/ routes in Phase 5 after frontend migrates to /api/v1/
from mqttui.routes.debug import bp as debug_bp
from mqttui.routes.api_v1 import api_v1_bp
from mqttui.routes.rules import rules_bp
from mqttui.routes.alerts import alerts_bp
from mqttui.routes.metrics import metrics_bp
from mqttui.routes.analytics import analytics_bp
from mqttui.routes.plugins import plugins_bp
from mqttui.routes.brokers import brokers_bp
app.register_blueprint(main_bp)
app.register_blueprint(api_bp)
app.register_blueprint(debug_bp)
app.register_blueprint(api_v1_bp)
app.register_blueprint(rules_bp)
app.register_blueprint(alerts_bp)
app.register_blueprint(metrics_bp)
app.register_blueprint(analytics_bp)
app.register_blueprint(plugins_bp)
app.register_blueprint(brokers_bp)
# Register auth blueprint and seed admin user
from mqttui.auth import auth_bp, seed_admin_user
app.register_blueprint(auth_bp)
seed_admin_user(app)
# Initialize MQTT client (paho-mqtt 2.x with event bus)
from mqttui.mqtt_client import init_mqtt
init_mqtt(app)
# Wire event bus: forward MQTT messages to SocketIO and database
mqtt_message_received.connect(_on_mqtt_message)
# Wire analytics subscriber to track per-topic rates and histograms
from mqttui.analytics import _on_mqtt_message as _on_analytics_message
mqtt_message_received.connect(_on_analytics_message)
# Wire Prometheus metric signal subscribers
from mqttui.routes.metrics import (
_on_message_for_metrics, _on_rule_fired_for_metrics, _on_alert_for_metrics
)
mqtt_message_received.connect(_on_message_for_metrics)
rule_fired.connect(_on_rule_fired_for_metrics)
alert_triggered.connect(_on_alert_for_metrics)
# Initialize rules engine (Phase 3)
from mqttui.rules.engine import RuleEngine
rule_engine = RuleEngine(app=app)
rule_engine.connect()
# Initialize plugin registry (Phase 7)
from mqttui.plugins.registry import init_plugin_registry
init_plugin_registry(app)
# Initialize plugin runner and wire to event bus (Phase 7)
from mqttui.plugins.runner import init_plugin_runner
plugin_runner = init_plugin_runner(app)
mqtt_message_received.connect(plugin_runner.on_mqtt_message)
rule_fired.connect(plugin_runner.on_rule_trigger)
return app
================================================
FILE: mqttui/auth.py
================================================
import os
import logging
from flask import Blueprint, render_template, redirect, url_for, request, flash
from flask_login import login_user, logout_user, login_required, current_user
from mqttui.models import User
from mqttui.extensions import sa, login_manager
logger = logging.getLogger(__name__)
auth_bp = Blueprint('auth', __name__)
@auth_bp.before_app_request
def load_user_from_api_key():
"""Check X-API-Key header before session auth."""
api_key = request.headers.get('X-API-Key')
if api_key:
user = User.query.filter_by(api_token=api_key).first()
if user and user.is_active:
login_user(user)
@login_manager.user_loader
def load_user(user_id):
return sa.session.get(User, int(user_id))
def seed_admin_user(app):
"""Create default admin user from environment variables if not exists."""
admin_username = os.environ.get('MQTTUI_ADMIN_USER', app.config.get('MQTTUI_ADMIN_USER', 'admin'))
admin_password = os.environ.get('MQTTUI_ADMIN_PASSWORD', app.config.get('MQTTUI_ADMIN_PASSWORD', 'admin'))
with app.app_context():
existing = User.query.filter_by(username=admin_username).first()
if existing:
logger.info("Admin user already exists")
return
user = User(username=admin_username)
user.set_password(admin_password)
user.generate_api_token()
sa.session.add(user)
sa.session.commit()
logger.info("Admin user seeded")
@auth_bp.route('/login', methods=['GET'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
return render_template('login.html')
@auth_bp.route('/login', methods=['POST'])
def login_post():
username = request.form.get('username', '')
password = request.form.get('password', '')
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
login_user(user)
next_page = request.args.get('next')
return redirect(next_page or '/')
flash('Invalid username or password', 'error')
return render_template('login.html'), 200
@auth_bp.route('/logout')
def logout():
logout_user()
return redirect(url_for('auth.login'))
================================================
FILE: mqttui/broker_manager.py
================================================
"""Multi-broker connection manager.
Manages multiple paho-mqtt client connections, each tied to a Broker model.
Replaces the single-client approach in mqtt_client.py.
"""
import os
import logging
import ssl
from datetime import datetime
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
from mqttui.events import mqtt_message_received
import mqttui.state as state
logger = logging.getLogger(__name__)
MQTT_RC_CODES = {
0: "Connection successful",
1: "Incorrect protocol version",
2: "Invalid client identifier",
3: "Server unavailable",
4: "Bad username or password",
5: "Not authorised",
128: "Unspecified error",
134: "Bad user name or password",
135: "Not authorized",
136: "Server unavailable",
}
# Module-level singleton
_broker_manager = None
def get_broker_manager():
return _broker_manager
class ManagedConnection:
"""A single broker connection with its paho-mqtt client."""
def __init__(self, broker_id, name, host, port, username=None, password=None,
mqtt_version='3.1.1', topics='#', tls_enabled=False,
tls_ca_certs=None, tls_insecure=False):
self.broker_id = broker_id
self.name = name
self.host = host
self.port = port
self.topics_str = topics
self.connected = False
self.error = None
# Create paho-mqtt client
protocol = mqtt.MQTTv5 if mqtt_version == '5' else mqtt.MQTTv311
self.client = mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
client_id=f"mqttui_{os.getpid()}_{broker_id}",
protocol=protocol,
)
if username and password:
self.client.username_pw_set(username, password)
if tls_enabled:
self.client.tls_set(
ca_certs=tls_ca_certs if tls_ca_certs else None,
cert_reqs=ssl.CERT_NONE if tls_insecure else ssl.CERT_REQUIRED,
)
if tls_insecure:
self.client.tls_insecure_set(True)
# Wire callbacks
self.client.on_connect = self._on_connect
self.client.on_disconnect = self._on_disconnect
self.client.on_message = self._on_message
def _on_connect(self, client, userdata, connect_flags, reason_code, properties=None):
rc = reason_code.value if hasattr(reason_code, 'value') else int(reason_code)
if rc == 0:
self.connected = True
self.error = None
state.connection_count += 1
for topic in [t.strip() for t in self.topics_str.split(',')]:
client.subscribe(topic)
logger.info(f"[{self.name}] Subscribed to: {topic}")
logger.info(f"[{self.name}] Connected to {self.host}:{self.port}")
else:
self.connected = False
self.error = MQTT_RC_CODES.get(rc, f"Unknown error (rc: {rc})")
logger.error(f"[{self.name}] Connection failed: {self.error}")
def _on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties=None):
rc = reason_code.value if hasattr(reason_code, 'value') else int(reason_code)
self.connected = False
state.connection_count = max(0, state.connection_count - 1)
if rc != 0:
self.error = MQTT_RC_CODES.get(rc, f"Unexpected disconnect (rc: {rc})")
logger.warning(f"[{self.name}] Disconnected: {self.error}")
def _on_message(self, client, userdata, msg):
try:
payload = msg.payload.decode()
except UnicodeDecodeError:
payload = msg.payload.hex()
timestamp = datetime.now()
# Update shared in-memory state
message = {
'topic': msg.topic,
'payload': payload,
'timestamp': timestamp.isoformat(),
'broker_id': self.broker_id,
'broker_name': self.name,
}
state.messages.append(message)
state.topics.add(msg.topic)
if len(state.messages) > 100:
state.messages.pop(0)
# Debug bar
try:
from debug_bar import debug_bar
debug_bar.record('mqtt', 'last_topic', msg.topic)
debug_bar.record('mqtt', 'last_payload', payload[:200])
debug_bar.record('mqtt', 'last_broker', f"{self.name} ({self.host}:{self.port})")
debug_bar.record('mqtt', 'last_timestamp', timestamp.isoformat())
except Exception:
pass
# Fire event bus signal with broker context
mqtt_message_received.send(
'broker_manager',
topic=msg.topic,
payload=payload,
timestamp=timestamp,
qos=msg.qos,
retain=msg.retain,
broker_id=self.broker_id,
broker_name=self.name,
)
def start(self):
try:
self.client.connect(self.host, self.port, keepalive=60)
self.client.loop_start()
logger.info(f"[{self.name}] Connecting to {self.host}:{self.port}")
except Exception as e:
self.connected = False
self.error = str(e)
logger.error(f"[{self.name}] Failed to connect: {e}")
def stop(self):
try:
self.client.loop_stop()
self.client.disconnect()
except Exception:
pass
self.connected = False
def publish(self, topic, payload, qos=0, retain=False):
if self.client and self.connected:
self.client.publish(topic, payload, qos=qos, retain=retain)
def status_dict(self):
return {
'broker_id': self.broker_id,
'name': self.name,
'host': self.host,
'port': self.port,
'connected': self.connected,
'error': self.error,
}
class BrokerManager:
"""Manages multiple MQTT broker connections."""
def __init__(self, app=None):
self.app = app
self.connections = {} # broker_id -> ManagedConnection
def add_connection(self, broker):
"""Add and start a connection from a Broker model instance or dict."""
if isinstance(broker, dict):
broker_id = broker['id']
conn = ManagedConnection(
broker_id=broker_id,
name=broker['name'],
host=broker['host'],
port=broker.get('port', 1883),
username=broker.get('username'),
password=broker.get('password'),
mqtt_version=broker.get('mqtt_version', '3.1.1'),
topics=broker.get('topics', '#'),
tls_enabled=broker.get('tls_enabled', False),
tls_ca_certs=broker.get('tls_ca_certs'),
tls_insecure=broker.get('tls_insecure', False),
)
else:
broker_id = broker.id
conn = ManagedConnection(
broker_id=broker.id,
name=broker.name,
host=broker.host,
port=broker.port,
username=broker.username,
password=broker.password,
mqtt_version=broker.mqtt_version,
topics=broker.topics,
tls_enabled=broker.tls_enabled,
tls_ca_certs=broker.tls_ca_certs,
tls_insecure=broker.tls_insecure,
)
# Stop existing connection if replacing
if broker_id in self.connections:
self.connections[broker_id].stop()
self.connections[broker_id] = conn
conn.start()
return conn
def remove_connection(self, broker_id):
conn = self.connections.pop(broker_id, None)
if conn:
conn.stop()
def get_connection(self, broker_id):
return self.connections.get(broker_id)
def get_default_connection(self):
"""Return the first connected broker, or first broker overall."""
for conn in self.connections.values():
if conn.connected:
return conn
# Fallback to first
if self.connections:
return next(iter(self.connections.values()))
return None
def publish(self, topic, payload, qos=0, retain=False, broker_id=None):
"""Publish to a specific broker or the default one."""
if broker_id and broker_id in self.connections:
self.connections[broker_id].publish(topic, payload, qos, retain)
else:
conn = self.get_default_connection()
if conn:
conn.publish(topic, payload, qos, retain)
def all_statuses(self):
return [conn.status_dict() for conn in self.connections.values()]
def start_all(self, app):
"""Load active brokers from DB and start connections."""
with app.app_context():
from mqttui.models import Broker
brokers = Broker.query.filter_by(is_active=True).all()
for broker in brokers:
self.add_connection(broker)
logger.info(f"BrokerManager started {len(brokers)} connection(s)")
def stop_all(self):
for conn in self.connections.values():
conn.stop()
self.connections.clear()
def load_env_broker(self, app):
"""Create a default broker from environment variables if no brokers in DB."""
with app.app_context():
from mqttui.models import Broker
if Broker.query.count() > 0:
return # DB already has brokers
# Seed from env vars (backward compatible with v1.x)
broker = Broker(
name='Default',
host=app.config['MQTT_BROKER'],
port=app.config['MQTT_PORT'],
username=app.config.get('MQTT_USERNAME'),
password=app.config.get('MQTT_PASSWORD'),
mqtt_version=app.config['MQTT_VERSION'],
topics=app.config['MQTT_TOPICS'],
tls_enabled=app.config.get('MQTT_TLS', 'false').lower() in ('true', '1', 'yes'),
tls_ca_certs=app.config.get('MQTT_TLS_CA_CERTS') or None,
tls_insecure=app.config.get('MQTT_TLS_INSECURE', 'false').lower() in ('true', '1', 'yes'),
is_active=True,
is_default=True,
)
from mqttui.extensions import sa
sa.session.add(broker)
sa.session.commit()
logger.info(f"Seeded default broker from env: {broker.host}:{broker.port}")
def init_broker_manager(app):
"""Initialize the global broker manager. Call from create_app()."""
global _broker_manager
_broker_manager = BrokerManager(app=app)
_broker_manager.load_env_broker(app)
_broker_manager.start_all(app)
return _broker_manager
================================================
FILE: mqttui/database.py
================================================
"""
Database module for MQTT message persistence
"""
import sqlite3
import threading
import logging
import re
import json
from datetime import datetime, timedelta
from typing import List, Dict, Optional, Union
import os
class MessageDatabase:
def __init__(self, db_path: str = "mqtt_messages.db", max_messages: int = 10000):
self.db_path = db_path
self.max_messages = max_messages
self._local = threading.local()
self.init_database()
def get_connection(self):
"""Get thread-local database connection"""
if not hasattr(self._local, 'connection'):
self._local.connection = sqlite3.connect(
self.db_path,
check_same_thread=False,
timeout=30.0
)
self._local.connection.row_factory = sqlite3.Row
# Enable WAL mode for concurrent read/write
self._local.connection.execute('PRAGMA journal_mode=WAL')
self._local.connection.execute('PRAGMA busy_timeout=5000')
# Add REGEXP function for advanced topic filtering
self._local.connection.create_function("REGEXP", 2, self._regexp)
return self._local.connection
def _regexp(self, pattern, value):
"""Custom REGEXP function for SQLite"""
try:
return re.search(pattern, value, re.IGNORECASE) is not None
except Exception:
return False
def init_database(self):
"""Initialize database schema"""
conn = self.get_connection()
try:
cursor = conn.cursor()
# Messages table
cursor.execute('''
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic TEXT NOT NULL,
payload TEXT NOT NULL,
timestamp DATETIME NOT NULL,
qos INTEGER DEFAULT 0,
retain BOOLEAN DEFAULT 0,
payload_size INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
# Topics table for faster topic queries
cursor.execute('''
CREATE TABLE IF NOT EXISTS topics (
topic TEXT PRIMARY KEY,
last_message_at DATETIME NOT NULL,
message_count INTEGER DEFAULT 1,
first_seen DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
# Filter presets table for saved searches
cursor.execute('''
CREATE TABLE IF NOT EXISTS filter_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
description TEXT,
filters TEXT NOT NULL, -- JSON string of filter parameters
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME
)
''')
# Create indexes for better performance
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_messages_topic
ON messages(topic)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_messages_timestamp
ON messages(timestamp DESC)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_messages_topic_timestamp
ON messages(topic, timestamp DESC)
''')
conn.commit()
logging.info("Database initialized successfully")
except Exception as e:
logging.error(f"Database initialization error: {e}")
conn.rollback()
raise
def store_message(self, topic: str, payload: str, timestamp: datetime = None,
qos: int = 0, retain: bool = False) -> bool:
"""Store a message in the database"""
if timestamp is None:
timestamp = datetime.now()
conn = self.get_connection()
try:
cursor = conn.cursor()
# Store message
cursor.execute('''
INSERT INTO messages (topic, payload, timestamp, qos, retain, payload_size)
VALUES (?, ?, ?, ?, ?, ?)
''', (topic, payload, timestamp, qos, retain, len(payload)))
# Update topics table
cursor.execute('''
INSERT OR REPLACE INTO topics (topic, last_message_at, message_count, first_seen)
VALUES (?, ?,
COALESCE((SELECT message_count FROM topics WHERE topic = ?) + 1, 1),
COALESCE((SELECT first_seen FROM topics WHERE topic = ?), ?)
)
''', (topic, timestamp, topic, topic, timestamp))
conn.commit()
# Clean up old messages if needed
self._cleanup_old_messages()
return True
except Exception as e:
logging.error(f"Error storing message: {e}")
conn.rollback()
return False
def get_messages(self, limit: int = 100, offset: int = 0,
topic_filter: str = None, since: datetime = None,
content_search: str = None, regex_topic: str = None,
json_path: str = None, json_value: str = None) -> List[Dict]:
"""Retrieve messages from database with enhanced filtering"""
conn = self.get_connection()
try:
cursor = conn.cursor()
query = '''
SELECT topic, payload, timestamp, qos, retain, payload_size
FROM messages
WHERE 1=1
'''
params = []
# Basic topic filtering (wildcard support)
if topic_filter:
if '%' in topic_filter or '_' in topic_filter:
query += ' AND topic LIKE ?'
else:
query += ' AND topic = ?'
params.append(topic_filter)
# Regex topic filtering (more powerful than LIKE)
if regex_topic:
query += ' AND topic REGEXP ?'
params.append(regex_topic)
# Content search (case-insensitive)
if content_search:
query += ' AND LOWER(payload) LIKE LOWER(?)'
params.append(f'%{content_search}%')
# Time filtering
if since:
query += ' AND timestamp >= ?'
params.append(since)
query += ' ORDER BY timestamp DESC LIMIT ? OFFSET ?'
params.extend([limit, offset])
cursor.execute(query, params)
rows = cursor.fetchall()
messages = [dict(row) for row in rows]
# Apply Python-based filters that can't be done in SQL
if json_path or regex_topic:
messages = self._apply_python_filters(
messages, regex_topic, json_path, json_value
)
return messages
except Exception as e:
logging.error(f"Error retrieving messages: {e}")
return []
def _apply_python_filters(self, messages: List[Dict], regex_topic: str = None,
json_path: str = None, json_value: str = None) -> List[Dict]:
"""Apply filters that require Python processing"""
filtered = []
for msg in messages:
# Regex topic filter (if not handled by SQL REGEXP)
if regex_topic and not re.search(regex_topic, msg['topic'], re.IGNORECASE):
continue
# JSON path filtering
if json_path and json_value:
try:
payload_json = json.loads(msg['payload'])
value = self._get_json_path_value(payload_json, json_path)
if value is None or str(value).lower() != json_value.lower():
continue
except (json.JSONDecodeError, KeyError):
continue
filtered.append(msg)
return filtered
def _get_json_path_value(self, json_obj: dict, path: str):
"""Extract value from JSON using simple dot notation path"""
try:
keys = path.split('.')
current = json_obj
for key in keys:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return None
return current
except Exception:
return None
def get_topics(self) -> List[Dict]:
"""Get all topics with statistics"""
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute('''
SELECT topic, message_count, last_message_at, first_seen
FROM topics
ORDER BY last_message_at DESC
''')
rows = cursor.fetchall()
return [dict(row) for row in rows]
except Exception as e:
logging.error(f"Error retrieving topics: {e}")
return []
def get_message_count(self, topic_filter: str = None, since: datetime = None) -> int:
"""Get total message count with optional filtering"""
conn = self.get_connection()
try:
cursor = conn.cursor()
query = 'SELECT COUNT(*) FROM messages WHERE 1=1'
params = []
if topic_filter:
if '%' in topic_filter or '_' in topic_filter:
query += ' AND topic LIKE ?'
else:
query += ' AND topic = ?'
params.append(topic_filter)
if since:
query += ' AND timestamp >= ?'
params.append(since)
cursor.execute(query, params)
return cursor.fetchone()[0]
except Exception as e:
logging.error(f"Error counting messages: {e}")
return 0
def _cleanup_old_messages(self):
"""Remove old messages to stay within limit"""
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute('SELECT COUNT(*) FROM messages')
count = cursor.fetchone()[0]
if count > self.max_messages:
messages_to_delete = count - self.max_messages + 1000
cursor.execute('''
DELETE FROM messages
WHERE id IN (
SELECT id FROM messages
ORDER BY timestamp ASC
LIMIT ?
)
''', (messages_to_delete,))
cursor.execute('''
UPDATE topics SET message_count = (
SELECT COUNT(*) FROM messages WHERE messages.topic = topics.topic
)
''')
conn.commit()
logging.info(f"Cleaned up {messages_to_delete} old messages")
except Exception as e:
logging.error(f"Error during cleanup: {e}")
conn.rollback()
def cleanup_old_data(self, days: int = 30):
"""Remove messages older than specified days"""
cutoff_date = datetime.now() - timedelta(days=days)
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute('DELETE FROM messages WHERE timestamp < ?', (cutoff_date,))
deleted = cursor.rowcount
cursor.execute('''
DELETE FROM topics WHERE topic NOT IN (
SELECT DISTINCT topic FROM messages
)
''')
conn.commit()
logging.info(f"Cleaned up {deleted} messages older than {days} days")
return deleted
except Exception as e:
logging.error(f"Error during data cleanup: {e}")
conn.rollback()
return 0
def get_database_size(self) -> Dict:
"""Get database size information"""
try:
size_bytes = os.path.getsize(self.db_path) if os.path.exists(self.db_path) else 0
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute('SELECT COUNT(*) FROM messages')
message_count = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM topics')
topic_count = cursor.fetchone()[0]
return {
'size_bytes': size_bytes,
'size_mb': round(size_bytes / 1024 / 1024, 2),
'message_count': message_count,
'topic_count': topic_count
}
except Exception as e:
logging.error(f"Error getting database size: {e}")
return {'size_bytes': 0, 'size_mb': 0, 'message_count': 0, 'topic_count': 0}
def save_filter_preset(self, name: str, filters: Dict, description: str = None) -> bool:
"""Save a filter preset"""
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute('''
INSERT OR REPLACE INTO filter_presets (name, description, filters, last_used)
VALUES (?, ?, ?, ?)
''', (name, description, json.dumps(filters), datetime.now()))
conn.commit()
logging.info(f"Saved filter preset: {name}")
return True
except Exception as e:
logging.error(f"Error saving filter preset: {e}")
conn.rollback()
return False
def get_filter_presets(self) -> List[Dict]:
"""Get all filter presets"""
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute('''
SELECT name, description, filters, created_at, last_used
FROM filter_presets
ORDER BY last_used DESC, created_at DESC
''')
rows = cursor.fetchall()
presets = []
for row in rows:
preset = dict(row)
preset['filters'] = json.loads(preset['filters'])
presets.append(preset)
return presets
except Exception as e:
logging.error(f"Error getting filter presets: {e}")
return []
def delete_filter_preset(self, name: str) -> bool:
"""Delete a filter preset"""
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute('DELETE FROM filter_presets WHERE name = ?', (name,))
conn.commit()
if cursor.rowcount > 0:
logging.info(f"Deleted filter preset: {name}")
return True
return False
except Exception as e:
logging.error(f"Error deleting filter preset: {e}")
conn.rollback()
return False
def use_filter_preset(self, name: str) -> Optional[Dict]:
"""Load and mark a filter preset as used"""
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute('''
SELECT filters FROM filter_presets WHERE name = ?
''', (name,))
row = cursor.fetchone()
if not row:
return None
cursor.execute('''
UPDATE filter_presets SET last_used = ? WHERE name = ?
''', (datetime.now(), name))
conn.commit()
return json.loads(row['filters'])
except Exception as e:
logging.error(f"Error using filter preset: {e}")
return None
def close(self):
"""Close database connection"""
if hasattr(self._local, 'connection'):
self._local.connection.close()
================================================
FILE: mqttui/events.py
================================================
from blinker import Namespace
mqttui_signals = Namespace()
# Fired when an MQTT message is received from the broker
# sender: mqtt_client module, kwargs: topic, payload, timestamp, qos, retain
mqtt_message_received = mqttui_signals.signal('mqtt-message-received')
# Fired when an automation rule fires (Phase 3 will use this)
# sender: rules engine, kwargs: rule_id, rule_name, topic, payload
rule_fired = mqttui_signals.signal('rule-fired')
# Fired when an alert is triggered (Phase 4 will use this)
# sender: alerting module, kwargs: alert_id, rule_id, message
alert_triggered = mqttui_signals.signal('alert-triggered')
# Fired when a rule is created, updated, or deleted (Phase 3 hot-reload)
# sender: rules API, kwargs: action ('created'|'updated'|'deleted'), rule_id
rule_changed = mqttui_signals.signal('rule-changed')
================================================
FILE: mqttui/extensions.py
================================================
from flask_socketio import SocketIO
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
socketio = SocketIO()
db = None # Will be set in create_app (MessageDatabase instance)
sa = SQLAlchemy()
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
limiter = Limiter(key_func=get_remote_address, storage_uri="memory://")
================================================
FILE: mqttui/helpers.py
================================================
"""API response helpers for consistent JSON envelope format."""
from flask import jsonify
def api_success(data=None, status_code=200):
"""Standard success envelope."""
return jsonify({"status": "success", "data": data, "error": None}), status_code
def api_error(message, code="UNKNOWN_ERROR", status_code=400):
"""Standard error envelope."""
return jsonify({
"status": "error",
"data": None,
"error": {"code": code, "message": message}
}), status_code
================================================
FILE: mqttui/logging_config.py
================================================
"""Structured logging configuration using structlog.
Provides JSON output in production and colored console output in development.
"""
import structlog
import logging
import sys
def configure_logging(debug=False, log_level='INFO'):
"""Configure structlog with JSON (prod) or console (dev) output.
Args:
debug: If True, use colored console renderer. If False, use JSON.
log_level: Standard logging level name (DEBUG, INFO, WARNING, ERROR, CRITICAL).
"""
shared_processors = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
]
if debug:
renderer = structlog.dev.ConsoleRenderer(colors=True)
else:
renderer = structlog.processors.JSONRenderer()
structlog.configure(
processors=shared_processors + [
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
formatter = structlog.stdlib.ProcessorFormatter(
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
renderer,
],
)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(getattr(logging, log_level.upper(), logging.INFO))
================================================
FILE: mqttui/models.py
================================================
from mqttui.extensions import sa
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
import secrets
class User(UserMixin, sa.Model):
__tablename__ = 'users'
id = sa.Column(sa.Integer, primary_key=True)
username = sa.Column(sa.String(80), unique=True, nullable=False)
password_hash = sa.Column(sa.String(256), nullable=False)
api_token = sa.Column(sa.String(64), unique=True, nullable=True)
is_active_user = sa.Column(sa.Boolean, default=True)
created_at = sa.Column(sa.DateTime, server_default=sa.func.now())
def set_password(self, password):
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def generate_api_token(self):
self.api_token = secrets.token_hex(32)
return self.api_token
@property
def is_active(self):
return self.is_active_user
class TopicFavorite(sa.Model):
__tablename__ = 'topic_favorites'
id = sa.Column(sa.Integer, primary_key=True)
user_id = sa.Column(sa.Integer, sa.ForeignKey('users.id'), nullable=False)
topic = sa.Column(sa.String(500), nullable=False)
created_at = sa.Column(sa.DateTime, server_default=sa.func.now())
__table_args__ = (sa.UniqueConstraint('user_id', 'topic', name='uq_user_topic'),)
def to_dict(self):
return {
'id': self.id,
'user_id': self.user_id,
'topic': self.topic,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
class Broker(sa.Model):
__tablename__ = 'brokers'
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String(200), nullable=False)
host = sa.Column(sa.String(500), nullable=False)
port = sa.Column(sa.Integer, default=1883)
username = sa.Column(sa.String(200), nullable=True)
password = sa.Column(sa.String(200), nullable=True)
mqtt_version = sa.Column(sa.String(10), default='3.1.1')
topics = sa.Column(sa.String(1000), default='#')
tls_enabled = sa.Column(sa.Boolean, default=False)
tls_ca_certs = sa.Column(sa.String(500), nullable=True)
tls_insecure = sa.Column(sa.Boolean, default=False)
is_active = sa.Column(sa.Boolean, default=True)
is_default = sa.Column(sa.Boolean, default=False)
created_at = sa.Column(sa.DateTime, server_default=sa.func.now())
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'host': self.host,
'port': self.port,
'username': self.username,
'has_password': bool(self.password),
'mqtt_version': self.mqtt_version,
'topics': self.topics,
'tls_enabled': self.tls_enabled,
'tls_insecure': self.tls_insecure,
'is_active': self.is_active,
'is_default': self.is_default,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
================================================
FILE: mqttui/mqtt_client.py
================================================
"""MQTT client compatibility layer.
Delegates to BrokerManager for multi-broker support while keeping the
same publish() API that rules engine and other modules use.
"""
import logging
logger = logging.getLogger(__name__)
def init_mqtt(app):
"""Initialize MQTT connections via BrokerManager. Call inside create_app()."""
from mqttui.broker_manager import init_broker_manager
init_broker_manager(app)
logger.info("MQTT client initialized via BrokerManager")
def get_client():
"""Get the default MQTT client instance (backward compat)."""
from mqttui.broker_manager import get_broker_manager
mgr = get_broker_manager()
if mgr:
conn = mgr.get_default_connection()
if conn:
return conn.client
return None
def publish(topic, payload, qos=0, retain=False, broker_id=None):
"""Publish a message. Routes to specific broker or default."""
from mqttui.broker_manager import get_broker_manager
mgr = get_broker_manager()
if mgr:
mgr.publish(topic, payload, qos=qos, retain=retain, broker_id=broker_id)
================================================
FILE: mqttui/plugins/__init__.py
================================================
================================================
FILE: mqttui/plugins/examples/__init__.py
================================================
================================================
FILE: mqttui/plugins/examples/json_formatter.py
================================================
#!/usr/bin/env python3
"""JSON formatter example plugin for mqttui.
Reads a JSON event from stdin, pretty-prints JSON payloads,
and writes the result to stdout following the plugin protocol.
Protocol:
stdin: {"event": "on_message", "data": {"topic": "...", "payload": "..."}}
stdout: {"actions": [{"type": "transform", "result": "..."}]}
"""
import json
import sys
def main():
try:
line = sys.stdin.readline()
if not line:
print(json.dumps({"actions": []}))
return
envelope = json.loads(line)
event = envelope.get("event", "")
data = envelope.get("data", {})
if event != "on_message":
print(json.dumps({"actions": []}))
return
payload = data.get("payload", "")
try:
parsed = json.loads(payload)
pretty = json.dumps(parsed, indent=2)
print(json.dumps({"actions": [{"type": "transform", "result": pretty}]}))
except (json.JSONDecodeError, TypeError):
print(json.dumps({"actions": []}))
except Exception:
print(json.dumps({"actions": []}))
if __name__ == "__main__":
main()
================================================
FILE: mqttui/plugins/examples/topic_logger.py
================================================
#!/usr/bin/env python3
"""Topic logger example plugin for mqttui.
Reads a JSON event from stdin, logs the topic and payload to stderr,
and writes a log action to stdout following the plugin protocol.
Protocol:
stdin: {"event": "on_message", "data": {"topic": "...", "payload": "..."}}
stdout: {"actions": [{"type": "log", "message": "Topic: ..., Payload: ..."}]}
"""
import json
import sys
def main():
try:
line = sys.stdin.readline()
if not line:
print(json.dumps({"actions": []}))
return
envelope = json.loads(line)
event = envelope.get("event", "")
data = envelope.get("data", {})
if event != "on_message":
print(json.dumps({"actions": []}))
return
topic = data.get("topic", "")
payload = data.get("payload", "")
# Log to stderr (stdout is reserved for protocol)
print(f"[topic_logger] Topic: {topic}, Payload: {payload}", file=sys.stderr)
# Return log action via protocol
message = f"Topic: {topic}, Payload: {payload}"
print(json.dumps({"actions": [{"type": "log", "message": message}]}))
except Exception:
print(json.dumps({"actions": []}))
if __name__ == "__main__":
main()
================================================
FILE: mqttui/plugins/hookspec.py
================================================
"""Plugin hook specifications for mqttui."""
from __future__ import annotations
import pluggy
PROJECT_NAME = "mqttui"
hookspec = pluggy.HookspecMarker(PROJECT_NAME)
hookimpl = pluggy.HookimplMarker(PROJECT_NAME)
class MQTTUIPlugin:
"""Hook specifications for mqttui plugins.
Plugin authors implement these hooks using the @hookimpl decorator.
"""
@hookspec
def on_message(self, topic: str, payload: str) -> dict | None:
"""Called when an MQTT message is received.
Args:
topic: The MQTT topic string.
payload: The message payload as a string.
Returns:
Optional dict with transformed/enriched data, or None.
"""
@hookspec
def on_connect(self) -> None:
"""Called when the MQTT client connects to the broker."""
@hookspec
def on_rule_trigger(self, rule_name: str, topic: str, payload: str) -> dict | None:
"""Called when an automation rule fires.
Args:
rule_name: Name of the triggered rule.
topic: The MQTT topic that triggered the rule.
payload: The message payload.
Returns:
Optional dict with additional context or modifications.
"""
================================================
FILE: mqttui/plugins/models.py
================================================
"""SQLAlchemy model for plugin configuration persistence."""
from mqttui.extensions import sa
class PluginConfig(sa.Model):
__tablename__ = 'plugin_configs'
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String(200), unique=True, nullable=False)
entry_point = sa.Column(sa.String(500), nullable=False)
enabled = sa.Column(sa.Boolean, default=False)
config_json = sa.Column(sa.Text, default='{}')
version = sa.Column(sa.String(50), nullable=True)
description = sa.Column(sa.Text, nullable=True)
installed_at = sa.Column(sa.DateTime, server_default=sa.func.now())
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'entry_point': self.entry_point,
'enabled': self.enabled,
'config_json': self.config_json,
'version': self.version,
'description': self.description,
'installed_at': self.installed_at.isoformat() if self.installed_at else None,
}
================================================
FILE: mqttui/plugins/registry.py
================================================
"""Plugin registry: discovers, loads, and manages mqttui plugins."""
from __future__ import annotations
import importlib.metadata
from importlib.metadata import entry_points
import pluggy
import structlog
from mqttui.plugins.hookspec import MQTTUIPlugin, PROJECT_NAME
from mqttui.plugins.models import PluginConfig
from mqttui.extensions import sa
logger = structlog.get_logger(__name__)
class PluginRegistry:
"""Discovers plugins via entry_points and manages their lifecycle."""
def __init__(self, app=None):
self.app = app
self.pm = pluggy.PluginManager(PROJECT_NAME)
self.pm.add_hookspecs(MQTTUIPlugin)
def discover(self) -> list[dict]:
"""Scan importlib.metadata entry_points for 'mqttui.plugins' group.
For each discovered entry point, upsert a PluginConfig row if not
already present. Returns list of discovered plugin dicts.
"""
discovered = []
try:
eps = entry_points(group='mqttui.plugins')
except TypeError:
# Python 3.9 compat: entry_points() returns a dict
eps = entry_points().get('mqttui.plugins', [])
for ep in eps:
name = ep.name
value = ep.value
version = None
description = None
try:
if ep.dist:
version = ep.dist.version
description = ep.dist.metadata.get('Summary', '')
except Exception:
pass
existing = PluginConfig.query.filter_by(name=name).first()
if not existing:
pc = PluginConfig(
name=name,
entry_point=value,
enabled=False,
version=version,
description=description,
)
sa.session.add(pc)
sa.session.commit()
discovered.append(pc.to_dict())
logger.info("Discovered new plugin", name=name, version=version)
else:
# Update version/entry_point if changed
existing.version = version
existing.entry_point = value
sa.session.commit()
discovered.append(existing.to_dict())
return discovered
def list_plugins(self) -> list[dict]:
"""Return all registered plugins as list of dicts."""
return [pc.to_dict() for pc in PluginConfig.query.all()]
def get_enabled_plugins(self) -> list[PluginConfig]:
"""Return only plugins with enabled=True."""
return PluginConfig.query.filter_by(enabled=True).all()
def enable(self, name: str) -> bool:
"""Enable a plugin by name. Returns False if not found."""
pc = PluginConfig.query.filter_by(name=name).first()
if not pc:
return False
pc.enabled = True
sa.session.commit()
logger.info("Plugin enabled", name=name)
return True
def disable(self, name: str) -> bool:
"""Disable a plugin by name. Returns False if not found."""
pc = PluginConfig.query.filter_by(name=name).first()
if not pc:
return False
pc.enabled = False
sa.session.commit()
logger.info("Plugin disabled", name=name)
return True
# Module-level singleton
_registry = None
def get_plugin_registry() -> PluginRegistry:
"""Return the singleton PluginRegistry instance."""
return _registry
def init_plugin_registry(app) -> PluginRegistry:
"""Create and initialize the PluginRegistry singleton."""
global _registry
_registry = PluginRegistry(app)
with app.app_context():
_registry.discover()
logger.info("Plugin registry initialized")
return _registry
================================================
FILE: mqttui/plugins/runner.py
================================================
"""Plugin runner: subprocess isolation layer for mqttui plugins.
Runs plugin code in separate processes communicating via newline-delimited
JSON on stdin/stdout. Plugins have no access to app internals (empty env).
"""
from __future__ import annotations
import json
import subprocess
import sys
import structlog
import mqttui.mqtt_client
from mqttui.plugins.registry import get_plugin_registry
logger = structlog.get_logger(__name__)
PLUGIN_TIMEOUT = 5 # seconds
class PluginRunner:
"""Executes plugins in isolated subprocesses with JSON protocol."""
def __init__(self, app=None):
self.app = app
self.logger = structlog.get_logger(__name__)
def call_plugin(self, plugin_config, event_type: str, data: dict) -> list[dict]:
"""Run a single plugin in a subprocess and return its actions.
Args:
plugin_config: PluginConfig model instance with entry_point.
event_type: Hook name (e.g. 'on_message', 'on_rule_trigger').
data: Event data dict to pass to the plugin.
Returns:
List of action dicts from the plugin, or empty list on error.
"""
cmd = [sys.executable, "-m", plugin_config.entry_point]
input_json = json.dumps({"event": event_type, "data": data}) + "\n"
try:
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env={},
)
stdout, stderr = proc.communicate(input=input_json, timeout=PLUGIN_TIMEOUT)
if stderr:
self.logger.debug(
"Plugin stderr output",
plugin=plugin_config.name,
stderr=stderr.strip(),
)
result = json.loads(stdout)
return result.get("actions", [])
except subprocess.TimeoutExpired:
proc.kill()
self.logger.warning(
"Plugin timed out, killed",
plugin=plugin_config.name,
timeout=PLUGIN_TIMEOUT,
)
return []
except json.JSONDecodeError:
self.logger.warning(
"Plugin returned invalid JSON",
plugin=plugin_config.name,
stdout=stdout[:200] if stdout else "",
)
return []
except Exception as exc:
self.logger.error(
"Plugin execution error",
plugin=plugin_config.name,
error=str(exc),
)
return []
def dispatch_message(self, topic: str, payload: str) -> list[dict]:
"""Run all enabled plugins for an MQTT message event.
Returns:
Aggregated list of action dicts from all plugins.
"""
registry = get_plugin_registry()
if registry is None:
return []
all_actions = []
try:
if self.app:
with self.app.app_context():
enabled = registry.get_enabled_plugins()
else:
enabled = registry.get_enabled_plugins()
except (RuntimeError, Exception):
enabled = []
for plugin in enabled:
actions = self.call_plugin(
plugin, "on_message", {"topic": topic, "payload": payload}
)
all_actions.extend(actions)
return all_actions
def dispatch_actions(self, actions: list[dict]):
"""Execute action dicts returned by plugins.
Supported action types:
- publish: Publish an MQTT message (topic, payload).
- log: Log a message via structlog.
"""
for action in actions:
action_type = action.get("type")
if action_type == "publish":
mqttui.mqtt_client.publish(action["topic"], action["payload"])
elif action_type == "log":
self.logger.info(
"Plugin log action",
message=action.get("message", ""),
)
else:
self.logger.warning(
"Unknown plugin action type",
action_type=action_type,
action=action,
)
def on_mqtt_message(self, sender, **kwargs):
"""Blinker signal handler for mqtt_message_received."""
topic = kwargs.get("topic", "")
payload = kwargs.get("payload", "")
payload_str = payload if isinstance(payload, str) else str(payload)
actions = self.dispatch_message(topic, payload_str)
if actions:
self.dispatch_actions(actions)
def on_rule_trigger(self, sender, **kwargs):
"""Blinker signal handler for rule_fired."""
rule_name = kwargs.get("rule_name", "")
topic = kwargs.get("topic", "")
payload = kwargs.get("payload", "")
payload_str = payload if isinstance(payload, str) else str(payload)
registry = get_plugin_registry()
if registry is None:
return
all_actions = []
for plugin in registry.get_enabled_plugins():
actions = self.call_plugin(
plugin,
"on_rule_trigger",
{"rule_name": rule_name, "topic": topic, "payload": payload_str},
)
all_actions.extend(actions)
if all_actions:
self.dispatch_actions(all_actions)
# Module-level singleton
_runner = None
def get_plugin_runner() -> PluginRunner | None:
"""Return the singleton PluginRunner instance."""
return _runner
def init_plugin_runner(app) -> PluginRunner:
"""Create and initialize the PluginRunner singleton."""
global _runner
_runner = PluginRunner(app)
logger.info("Plugin runner initialized")
return _runner
================================================
FILE: mqttui/routes/__init__.py
================================================
================================================
FILE: mqttui/routes/alerts.py
================================================
"""Alert history REST API blueprint.
Provides paginated, filterable listing of AlertHistory records.
All endpoints require authentication via @login_required.
"""
from flask import Blueprint, request
from flask_login import login_required
from mqttui.extensions import sa
from mqttui.helpers import api_success, api_error
from mqttui.rules.models import AlertHistory
alerts_bp = Blueprint('alerts', __name__, url_prefix='/api/v1/alerts')
@alerts_bp.route('/')
@login_required
def list_alerts():
"""List alert history with pagination and optional filters.
Query params:
page (int): Page number, default 1.
per_page (int): Items per page, default 20, max 100.
rule_id (int): Filter by rule ID.
severity (str): Filter by severity level.
Returns:
JSON envelope with alerts list, total count, page, and per_page.
"""
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 20, type=int), 100)
rule_id = request.args.get('rule_id', type=int)
severity = request.args.get('severity', type=str)
query = AlertHistory.query.order_by(AlertHistory.fired_at.desc())
if rule_id is not None:
query = query.filter(AlertHistory.rule_id == rule_id)
if severity:
query = query.filter(AlertHistory.severity == severity)
total = query.count()
alerts = query.offset((page - 1) * per_page).limit(per_page).all()
return api_success({
"alerts": [a.to_dict() for a in alerts],
"total": total,
"page": page,
"per_page": per_page,
})
================================================
FILE: mqttui/routes/analytics.py
================================================
"""Analytics REST API blueprint.
Provides per-topic message rate and numeric payload histogram data.
All endpoints return JSON envelope: {"status": "success"|"error", "data": ..., "error": ...}
"""
from flask import Blueprint, request
from flask_login import login_required
import logging
from mqttui.helpers import api_success, api_error
from mqttui.analytics import get_analytics
analytics_bp = Blueprint('analytics', __name__, url_prefix='/api/v1/analytics')
logger = logging.getLogger(__name__)
@analytics_bp.route('/topics')
@login_required
def get_topics():
"""Get top-N topic analytics sorted by message rate.
Query params:
limit (int): Maximum topics to return (default 20)
window (int): Rate window in seconds (default 60)
Returns:
JSON envelope with topics array and window_seconds.
"""
limit = request.args.get('limit', 20, type=int)
window = request.args.get('window', 60, type=int)
analytics = get_analytics()
topics = analytics.get_all_stats(limit=limit)
return api_success({
"topics": topics,
"window_seconds": window,
})
@analytics_bp.route('/topics/<path:topic>')
@login_required
def get_topic(topic):
"""Get analytics for a single topic.
Uses path converter to support MQTT topics with slashes (e.g., home/sensor/temp).
Returns:
JSON envelope with topic stats or 404 if no data.
"""
analytics = get_analytics()
stats = analytics.get_topic_stats(topic)
if stats["message_count"] == 0:
return api_error("Topic not found", "NOT_FOUND", 404)
return api_success(stats)
================================================
FILE: mqttui/routes/api.py
================================================
# TODO: Remove legacy /api/ routes in Phase 5 after frontend migrates to /api/v1/
# All new API development should use mqttui/routes/api_v1.py
from flask import Blueprint, request, jsonify
from datetime import datetime, timedelta
import logging
from mqttui import state
from mqttui import extensions as ext
bp = Blueprint('api', __name__)
@bp.route('/api/messages')
def get_message_history():
"""Get paginated message history with enhanced filtering"""
try:
limit = min(int(request.args.get('limit', 100)), 1000)
offset = int(request.args.get('offset', 0))
topic_filter = request.args.get('topic')
hours = request.args.get('hours')
content_search = request.args.get('content')
regex_topic = request.args.get('regex_topic')
json_path = request.args.get('json_path')
json_value = request.args.get('json_value')
since = None
if hours:
since = datetime.now() - timedelta(hours=int(hours))
if ext.db:
messages_list = ext.db.get_messages(
limit=limit,
offset=offset,
topic_filter=topic_filter,
since=since,
content_search=content_search,
regex_topic=regex_topic,
json_path=json_path,
json_value=json_value
)
total_count = ext.db.get_message_count(topic_filter=topic_filter, since=since)
else:
messages_list = list(reversed(state.messages))
if topic_filter:
messages_list = [m for m in messages_list if m['topic'] == topic_filter]
if content_search:
messages_list = [m for m in messages_list if content_search.lower() in m['payload'].lower()]
total_count = len(messages_list)
messages_list = messages_list[offset:offset + limit]
return jsonify({
'messages': messages_list,
'total': total_count,
'limit': limit,
'offset': offset,
'has_more': offset + len(messages_list) < total_count,
'filters_applied': {
'topic': topic_filter,
'content': content_search,
'regex_topic': regex_topic,
'json_path': json_path,
'json_value': json_value,
'hours': hours
}
})
except Exception as e:
logging.error(f"Error getting message history: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/topics')
def get_topic_list():
"""Get list of all topics with statistics"""
try:
if ext.db:
topics_list = ext.db.get_topics()
else:
topics_list = [{'topic': topic} for topic in sorted(state.topics)]
return jsonify({'topics': topics_list})
except Exception as e:
logging.error(f"Error getting topics: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/database/stats')
def get_database_stats():
"""Get database size and statistics"""
try:
if ext.db:
stats = ext.db.get_database_size()
stats['enabled'] = True
else:
stats = {
'enabled': False,
'message_count': len(state.messages),
'topic_count': len(state.topics)
}
return jsonify(stats)
except Exception as e:
logging.error(f"Error getting database stats: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/database/cleanup', methods=['POST'])
def cleanup_database():
"""Clean up old database records"""
try:
if not ext.db:
return jsonify({'error': 'Database not enabled'}), 400
from flask import current_app
days = int(request.json.get('days', current_app.config['DB_CLEANUP_DAYS']))
deleted = ext.db.cleanup_old_data(days)
return jsonify({
'success': True,
'deleted_messages': deleted,
'days': days
})
except Exception as e:
logging.error(f"Error cleaning database: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/filter-presets')
def get_filter_presets():
"""Get all saved filter presets"""
try:
if not ext.db:
return jsonify({'error': 'Database not enabled'}), 400
presets = ext.db.get_filter_presets()
return jsonify({'presets': presets})
except Exception as e:
logging.error(f"Error getting filter presets: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/filter-presets', methods=['POST'])
def save_filter_preset():
"""Save a new filter preset"""
try:
if not ext.db:
return jsonify({'error': 'Database not enabled'}), 400
data = request.json
name = data.get('name')
description = data.get('description', '')
filters = data.get('filters', {})
if not name or not filters:
return jsonify({'error': 'Name and filters are required'}), 400
success = ext.db.save_filter_preset(name, filters, description)
if success:
return jsonify({'success': True, 'message': f'Saved preset: {name}'})
else:
return jsonify({'error': 'Failed to save preset'}), 500
except Exception as e:
logging.error(f"Error saving filter preset: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/filter-presets/<name>', methods=['DELETE'])
def delete_filter_preset(name):
"""Delete a filter preset"""
try:
if not ext.db:
return jsonify({'error': 'Database not enabled'}), 400
success = ext.db.delete_filter_preset(name)
if success:
return jsonify({'success': True, 'message': f'Deleted preset: {name}'})
else:
return jsonify({'error': 'Preset not found'}), 404
except Exception as e:
logging.error(f"Error deleting filter preset: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/filter-presets/<name>/use', methods=['POST'])
def use_filter_preset(name):
"""Load and use a filter preset"""
try:
if not ext.db:
return jsonify({'error': 'Database not enabled'}), 400
filters = ext.db.use_filter_preset(name)
if filters:
return jsonify({'success': True, 'filters': filters})
else:
return jsonify({'error': 'Preset not found'}), 404
except Exception as e:
logging.error(f"Error using filter preset: {e}")
return jsonify({'error': str(e)}), 500
================================================
FILE: mqttui/routes/api_v1.py
================================================
"""Versioned API v1 endpoints for MQTTUI.
All endpoints return JSON envelope: {"status": "success"|"error", "data": ..., "error": ...}
"""
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from datetime import datetime, timedelta
import logging
from mqttui import __version__
from mqttui import state
from mqttui import extensions as ext
from mqttui.extensions import sa, limiter
from mqttui.helpers import api_success, api_error
from mqttui.models import TopicFavorite
api_v1_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1')
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Messages
# ---------------------------------------------------------------------------
@api_v1_bp.route('/messages')
@login_required
def get_messages():
"""Get paginated message history with filtering.
---
get:
summary: Retrieve MQTT messages
parameters:
- name: limit
in: query
schema: {type: integer, default: 100}
- name: offset
in: query
schema: {type: integer, default: 0}
- name: topic
in: query
schema: {type: string}
- name: hours
in: query
schema: {type: integer}
- name: content
in: query
schema: {type: string}
- name: regex_topic
in: query
schema: {type: string}
- name: json_path
in: query
schema: {type: string}
- name: json_value
in: query
schema: {type: string}
responses:
200:
description: Paginated message list
"""
try:
limit = min(int(request.args.get('limit', 100)), 1000)
offset = int(request.args.get('offset', 0))
topic_filter = request.args.get('topic')
hours = request.args.get('hours')
content_search = request.args.get('content')
regex_topic = request.args.get('regex_topic')
json_path = request.args.get('json_path')
json_value = request.args.get('json_value')
since = None
if hours:
since = datetime.now() - timedelta(hours=int(hours))
if ext.db:
messages_list = ext.db.get_messages(
limit=limit,
offset=offset,
topic_filter=topic_filter,
since=since,
content_search=content_search,
regex_topic=regex_topic,
json_path=json_path,
json_value=json_value,
)
total_count = ext.db.get_message_count(
topic_filter=topic_filter, since=since
)
else:
messages_list = list(reversed(state.messages))
if topic_filter:
messages_list = [m for m in messages_list if m['topic'] == topic_filter]
if content_search:
messages_list = [
m for m in messages_list
if content_search.lower() in m['payload'].lower()
]
total_count = len(messages_list)
messages_list = messages_list[offset:offset + limit]
return api_success({
'messages': messages_list,
'total': total_count,
'limit': limit,
'offset': offset,
'has_more': offset + len(messages_list) < total_count,
'filters_applied': {
'topic': topic_filter,
'content': content_search,
'regex_topic': regex_topic,
'json_path': json_path,
'json_value': json_value,
'hours': hours,
}
})
except Exception as e:
logger.error(f"Error getting message history: {e}")
return api_error(str(e), "MESSAGES_ERROR", 500)
# ---------------------------------------------------------------------------
# Topics
# ---------------------------------------------------------------------------
@api_v1_bp.route('/topics')
@login_required
def get_topics():
"""Get list of all topics with statistics.
---
get:
summary: List MQTT topics
responses:
200:
description: Topic list
"""
try:
if ext.db:
topics_list = ext.db.get_topics()
else:
topics_list = [{'topic': topic} for topic in sorted(state.topics)]
# Annotate with is_favorite for the current user
fav_topics = set()
if current_user.is_authenticated:
favs = TopicFavorite.query.filter_by(user_id=current_user.id).all()
fav_topics = {f.topic for f in favs}
for t in topics_list:
t['is_favorite'] = t.get('topic', '') in fav_topics
return api_success({'topics': topics_list})
except Exception as e:
logger.error(f"Error getting topics: {e}")
return api_error(str(e), "TOPICS_ERROR", 500)
@api_v1_bp.route('/topics/favorites')
@login_required
def get_favorites():
"""Get current user's bookmarked topics.
---
get:
summary: List favorite topics
responses:
200:
description: Favorites list
"""
try:
favs = TopicFavorite.query.filter_by(user_id=current_user.id).all()
return api_success({'favorites': [f.to_dict() for f in favs]})
except Exception as e:
logger.error(f"Error getting favorites: {e}")
return api_error(str(e), "FAVORITES_ERROR", 500)
@api_v1_bp.route('/topics/<path:topic>/bookmark', methods=['POST'])
@login_required
def toggle_bookmark(topic):
"""Toggle bookmark on a topic.
---
post:
summary: Toggle topic bookmark
parameters:
- name: topic
in: path
required: true
schema: {type: string}
responses:
201:
description: Bookmark created
200:
description: Bookmark removed
"""
try:
existing = TopicFavorite.query.filter_by(
user_id=current_user.id, topic=topic
).first()
if existing:
sa.session.delete(existing)
sa.session.commit()
return api_success({'bookmarked': False, 'topic': topic})
else:
fav = TopicFavorite(user_id=current_user.id, topic=topic)
sa.session.add(fav)
sa.session.commit()
return api_success({'bookmarked': True, 'topic': topic}, 201)
except Exception as e:
sa.session.rollback()
logger.error(f"Error toggling bookmark: {e}")
return api_error(str(e), "BOOKMARK_ERROR", 500)
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
@api_v1_bp.route('/database/stats')
@login_required
def get_database_stats():
"""Get database size and statistics.
---
get:
summary: Database statistics
responses:
200:
description: Database stats
"""
try:
if ext.db:
stats = ext.db.get_database_size()
stats['enabled'] = True
else:
stats = {
'enabled': False,
'message_count': len(state.messages),
'topic_count': len(state.topics),
}
return api_success(stats)
except Exception as e:
logger.error(f"Error getting database stats: {e}")
return api_error(str(e), "DB_STATS_ERROR", 500)
@api_v1_bp.route('/database/cleanup', methods=['POST'])
@login_required
def cleanup_database():
"""Clean up old database records.
---
post:
summary: Cleanup old messages
requestBody:
content:
application/json:
schema:
type: object
properties:
days:
type: integer
default: 30
responses:
200:
description: Cleanup result
"""
try:
if not ext.db:
return api_error("Database not enabled", "DB_DISABLED", 400)
days = int(request.json.get('days', current_app.config['DB_CLEANUP_DAYS']))
deleted = ext.db.cleanup_old_data(days)
return api_success({'deleted_messages': deleted, 'days': days})
except Exception as e:
logger.error(f"Error cleaning database: {e}")
return api_error(str(e), "DB_CLEANUP_ERROR", 500)
# ---------------------------------------------------------------------------
# Filter Presets
# ---------------------------------------------------------------------------
@api_v1_bp.route('/filter-presets')
@login_required
def get_filter_presets():
"""Get all saved filter presets.
---
get:
summary: List filter presets
responses:
200:
description: Preset list
"""
try:
if not ext.db:
return api_error("Database not enabled", "DB_DISABLED", 400)
presets = ext.db.get_filter_presets()
return api_success({'presets': presets})
except Exception as e:
logger.error(f"Error getting filter presets: {e}")
return api_error(str(e), "PRESETS_ERROR", 500)
@api_v1_bp.route('/filter-presets', methods=['POST'])
@login_required
def save_filter_preset():
"""Save a new filter preset.
---
post:
summary: Create filter preset
requestBody:
content:
application/json:
schema:
type: object
properties:
name:
type: string
description:
type: string
filters:
type: object
responses:
201:
description: Preset saved
"""
try:
if not ext.db:
return api_error("Database not enabled", "DB_DISABLED", 400)
data = request.json
name = data.get('name')
description = data.get('description', '')
filters = data.get('filters', {})
if not name or not filters:
return api_error("Name and filters are required", "VALIDATION_ERROR", 400)
success = ext.db.save_filter_preset(name, filters, description)
if success:
return api_success({'message': f'Saved preset: {name}'}, 201)
else:
return api_error("Failed to save preset", "PRESET_SAVE_FAILED", 500)
except Exception as e:
logger.error(f"Error saving filter preset: {e}")
return api_error(str(e), "PRESETS_ERROR", 500)
@api_v1_bp.route('/filter-presets/<name>', methods=['DELETE'])
@login_required
def delete_filter_preset(name):
"""Delete a filter preset.
---
delete:
summary: Delete a filter preset
parameters:
- name: name
in: path
required: true
schema: {type: string}
responses:
200:
description: Preset deleted
404:
description: Preset not found
"""
try:
if not ext.db:
return api_error("Database not enabled", "DB_DISABLED", 400)
success = ext.db.delete_filter_preset(name)
if success:
return api_success({'message': f'Deleted preset: {name}'})
else:
return api_error("Preset not found", "NOT_FOUND", 404)
except Exception as e:
logger.error(f"Error deleting filter preset: {e}")
return api_error(str(e), "PRESETS_ERROR", 500)
@api_v1_bp.route('/filter-presets/<name>/use', methods=['POST'])
@login_required
def use_filter_preset(name):
"""Load and use a filter preset.
---
post:
summary: Use a filter preset
parameters:
- name: name
in: path
required: true
schema: {type: string}
responses:
200:
description: Preset filters
404:
description: Preset not found
"""
try:
if not ext.db:
return api_error("Database not enabled", "DB_DISABLED", 400)
filters = ext.db.use_filter_preset(name)
if filters:
return api_success({'filters': filters})
else:
return api_error("Preset not found", "NOT_FOUND", 404)
except Exception as e:
logger.error(f"Error using filter preset: {e}")
return api_error(str(e), "PRESETS_ERROR", 500)
# ---------------------------------------------------------------------------
# Publish
# ---------------------------------------------------------------------------
@api_v1_bp.route('/publish', methods=['POST'])
@login_required
@limiter.limit("30/minute")
def publish_message():
"""Publish an MQTT message (JSON API).
---
post:
summary: Publish MQTT message
requestBody:
content:
application/json:
schema:
type: object
required: [topic, message]
properties:
topic:
type: string
message:
type: string
responses:
200:
description: Message published
"""
try:
data = request.json
if not data:
return api_error("JSON body required", "VALIDATION_ERROR", 400)
topic = data.get('topic')
message = data.get('message')
if not topic or message is None:
return api_error("topic and message are required", "VALIDATION_ERROR", 400)
from mqttui.mqtt_client import publish as mqtt_publish
mqtt_publish(topic, message)
return api_success({'published': True})
except Exception as e:
logger.error(f"Error publishing message: {e}")
return api_error(str(e), "PUBLISH_ERROR", 500)
# ---------------------------------------------------------------------------
# Stats & Version
# ---------------------------------------------------------------------------
@api_v1_bp.route('/stats')
@login_required
def get_stats():
"""Get application statistics.
---
get:
summary: Application statistics
responses:
200:
description: Stats
"""
try:
return api_success({
'connection_count': state.connection_count,
'topic_count': len(state.topics),
'message_count': len(state.messages),
'errors': state.error_log,
})
except Exception as e:
logger.error(f"Error getting stats: {e}")
return api_error(str(e), "STATS_ERROR", 500)
@api_v1_bp.route('/version')
def get_version():
"""Get application version.
---
get:
summary: App version
responses:
200:
description: Version info
"""
return api_success({'version': __version__})
# ---------------------------------------------------------------------------
# OpenAPI Documentation
# ---------------------------------------------------------------------------
@api_v1_bp.route('/docs')
def api_docs():
"""Serve Swagger UI for API documentation."""
html = """<!DOCTYPE html>
<html><head><title>MQTTUI API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
</head><body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>SwaggerUIBundle({url: '/api/v1/openapi.json', dom_id: '#swagger-ui'})</script>
</body></html>"""
return html
@api_v1_bp.route('/openapi.json')
def openapi_spec():
"""Return OpenAPI 3.0 specification."""
from apispec import APISpec
from apispec_webframeworks.flask import FlaskPlugin
spec = APISpec(
title="MQTTUI API",
version="1.0.0",
openapi_version="3.0.3",
info={"description": "MQTT monitoring and automation API"},
plugins=[FlaskPlugin()],
)
# Register paths from current app's url_map
with current_app.test_request_context():
for rule in current_app.url_map.iter_rules():
if rule.rule.startswith('/api/v1/') and rule.endpoint != 'static':
view = current_app.view_functions.get(rule.endpoint)
if view:
spec.path(view=view, app=current_app)
return jsonify(spec.to_dict())
# ---------------------------------------------------------------------------
# Token CRUD
# ---------------------------------------------------------------------------
@api_v1_bp.route('/auth/token', methods=['GET'])
@login_required
def get_token():
"""Get current user's API token.
---
get:
summary: Get API token
security: [{session: []}, {apiKey: []}]
responses:
200: {description: Current token}
"""
return api_success({"api_token": current_user.api_token})
@api_v1_bp.route('/auth/token', methods=['POST'])
@login_required
def regenerate_token():
"""Regenerate API token.
---
post:
summary: Regenerate API token
responses:
200: {description: New token generated}
"""
token = current_user.generate_api_token()
sa.session.commit()
return api_success({"api_token": token})
@api_v1_bp.route('/auth/token', methods=['DELETE'])
@login_required
def revoke_token():
"""Revoke API token.
---
delete:
summary: Revoke API token
responses:
200: {description: Token revoked}
"""
current_user.api_token = None
sa.session.commit()
return api_success({"message": "API token revoked"})
# ---------------------------------------------------------------------------
# Blueprint error handlers
# ---------------------------------------------------------------------------
@api_v1_bp.errorhandler(429)
def handle_429(e):
response = api_error("Rate limit exceeded", "RATE_LIMIT_EXCEEDED", 429)
# Flask-Limiter sets the description with retry info
resp = response[0]
resp.headers['Retry-After'] = str(60)
return resp, 429
@api_v1_bp.errorhandler(404)
def handle_404(e):
return api_error("Resource not found", "NOT_FOUND", 404)
@api_v1_bp.errorhandler(500)
def handle_500(e):
return api_error("Internal server error", "INTERNAL_ERROR", 500)
================================================
FILE: mqttui/routes/brokers.py
================================================
"""Broker management REST API endpoints."""
from flask import Blueprint, request
from flask_login import login_required
from mqttui.helpers import api_success, api_error
from mqttui.extensions import sa
from mqttui.models import Broker
from mqttui.broker_manager import get_broker_manager
brokers_bp = Blueprint('brokers', __name__, url_prefix='/api/v1/brokers')
@brokers_bp.route('/', methods=['GET'])
@login_required
def list_brokers():
"""List all configured brokers with connection status."""
brokers = Broker.query.order_by(Broker.created_at).all()
mgr = get_broker_manager()
result = []
for b in brokers:
d = b.to_dict()
if mgr:
conn = mgr.get_connection(b.id)
d['connected'] = conn.connected if conn else False
d['connection_error'] = conn.error if conn else None
else:
d['connected'] = False
d['connection_error'] = None
result.append(d)
return api_success({'brokers': result})
@brokers_bp.route('/', methods=['POST'])
@login_required
def create_broker():
"""Add a new broker connection."""
data = request.get_json()
if not data or not data.get('name') or not data.get('host'):
return api_error("name and host are required", "VALIDATION", 400)
broker = Broker(
name=data['name'],
host=data['host'],
port=data.get('port', 1883),
username=data.get('username'),
password=data.get('password'),
mqtt_version=data.get('mqtt_version', '3.1.1'),
topics=data.get('topics', '#'),
tls_enabled=data.get('tls_enabled', False),
tls_ca_certs=data.get('tls_ca_certs'),
tls_insecure=data.get('tls_insecure', False),
is_active=data.get('is_active', True),
)
sa.session.add(broker)
sa.session.commit()
# Start connection if active
if broker.is_active:
mgr = get_broker_manager()
if mgr:
mgr.add_connection(broker)
return api_success({'broker': broker.to_dict()}), 201
@brokers_bp.route('/<int:broker_id>', methods=['GET'])
@login_required
def get_broker(broker_id):
"""Get a single broker's details and connection status."""
broker = sa.session.get(Broker, broker_id)
if not broker:
return api_error("Broker not found", "NOT_FOUND", 404)
d = broker.to_dict()
mgr = get_broker_manager()
if mgr:
conn = mgr.get_connection(broker.id)
d['connected'] = conn.connected if conn else False
d['connection_error'] = conn.error if conn else None
return api_success({'broker': d})
@brokers_bp.route('/<int:broker_id>', methods=['PUT'])
@login_required
def update_broker(broker_id):
"""Update broker configuration. Reconnects if active."""
broker = sa.session.get(Broker, broker_id)
if not broker:
return api_error("Broker not found", "NOT_FOUND", 404)
data = request.get_json()
if not data:
return api_error("No data provided", "VALIDATION", 400)
for field in ['name', 'host', 'port', 'username', 'password', 'mqtt_version',
'topics', 'tls_enabled', 'tls_ca_certs', 'tls_insecure', 'is_active']:
if field in data:
setattr(broker, field, data[field])
sa.session.commit()
# Reconnect with new settings
mgr = get_broker_manager()
if mgr:
if broker.is_active:
mgr.add_connection(broker) # Stops old, starts new
else:
mgr.remove_connection(broker.id)
return api_success({'broker': broker.to_dict()})
@brokers_bp.route('/<int:broker_id>', methods=['DELETE'])
@login_required
def delete_broker(broker_id):
"""Delete a broker and disconnect."""
broker = sa.session.get(Broker, broker_id)
if not broker:
return api_error("Broker not found", "NOT_FOUND", 404)
mgr = get_broker_manager()
if mgr:
mgr.remove_connection(broker.id)
sa.session.delete(broker)
sa.session.commit()
return api_success({'deleted': broker_id})
@brokers_bp.route('/<int:broker_id>/connect', methods=['POST'])
@login_required
def connect_broker(broker_id):
"""Start/restart connection to a broker."""
broker = sa.session.get(Broker, broker_id)
if not broker:
return api_error("Broker not found", "NOT_FOUND", 404)
broker.is_active = True
sa.session.commit()
mgr = get_broker_manager()
if mgr:
mgr.add_connection(broker)
return api_success({'broker': broker.to_dict(), 'status': 'connecting'})
@brokers_bp.route('/<int:broker_id>/disconnect', methods=['POST'])
@login_required
def disconnect_broker(broker_id):
"""Disconnect from a broker."""
broker = sa.session.get(Broker, broker_id)
if not broker:
return api_error("Broker not found", "NOT_FOUND", 404)
broker.is_active = False
sa.session.commit()
mgr = get_broker_manager()
if mgr:
mgr.remove_connection(broker.id)
return api_success({'broker': broker.to_dict(), 'status': 'disconnected'})
@brokers_bp.route('/status', methods=['GET'])
@login_required
def broker_status():
"""Get live connection status for all brokers."""
mgr = get_broker_manager()
statuses = mgr.all_statuses() if mgr else []
return api_success({'connections': statuses})
================================================
FILE: mqttui/routes/debug.py
================================================
from flask import Blueprint, request, jsonify
import logging
from mqttui.extensions import limiter
bp = Blueprint('debug', __name__)
@bp.before_app_request
def debug_bar_middleware():
from debug_bar import debug_bar
debug_bar.start_request()
debug_bar.record('request', 'path', request.path)
debug_bar.record('request', 'method', request.method)
@bp.after_app_request
def after_request(response):
from debug_bar import debug_bar
debug_bar.record('request', 'status_code', response.status_code)
debug_bar.end_request()
return response
@bp.route('/debug-bar')
@limiter.exempt
def get_debug_bar_data():
from debug_bar import debug_bar
try:
data = debug_bar.get_data()
return jsonify(data)
except Exception as e:
logging.error(f"Error fetching debug bar data: {e}")
return jsonify({"error": "Failed to fetch debug bar data"}), 500
@bp.route('/toggle-debug-bar', methods=['POST'])
def toggle_debug_bar():
from debug_bar import debug_bar
if debug_bar.enabled:
debug_bar.disable()
else:
debug_bar.enable()
return jsonify(enabled=debug_bar.enabled)
@bp.route('/record-client-performance', methods=['POST'])
def record_client_performance():
from debug_bar import debug_bar
data = request.json
debug_bar.record('performance', 'page_load_time', f"{data['pageLoadTime']}ms")
debug_bar.record('performance', 'dom_ready_time', f"{data['domReadyTime']}ms")
return jsonify(success=True)
================================================
FILE: mqttui/routes/main.py
================================================
from flask import Blueprint, render_template, request, jsonify, send_from_directory
from flask_login import login_required
import logging
from mqttui import __version__
from mqttui.extensions import limiter
from mqttui import state
bp = Blueprint('main', __name__)
@bp.route('/')
@login_required
def index():
return render_template('index.html', messages=state.messages, topics=list(state.topics))
@bp.route('/publish', methods=['POST'])
@login_required
def publish_message():
from mqttui.mqtt_client import publish as mqtt_publish
topic = request.form['topic']
message = request.form['message']
mqtt_publish(topic, message)
return jsonify(success=True)
@bp.route('/stats')
@limiter.exempt
def get_stats():
return jsonify({
'connection_count': state.connection_count,
'topic_count': len(state.topics),
'message_count': len(state.messages),
'errors': state.error_log
})
@bp.route('/version')
@limiter.exempt
def get_version():
return jsonify({'version': __version__})
@bp.route('/static/<path:path>')
def send_static(path):
return send_from_directory('static', path)
# ---------------------------------------------------------------------------
# Partial routes for htmx (Alerts History UI)
# ---------------------------------------------------------------------------
@bp.route('/partials/alerts')
@login_required
def alerts_list_partial():
"""Return HTML partial of alert history with pagination and filters."""
from mqttui.rules.models import AlertHistory, Rule
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 20, type=int), 100)
rule_id = request.args.get('rule_id', type=int)
severity = request.args.get('severity', type=str)
query = AlertHistory.query.order_by(AlertHistory.fired_at.desc())
if rule_id:
query = query.filter(AlertHistory.rule_id == rule_id)
if severity:
query = query.filter(AlertHistory.severity == severity)
total = query.count()
alerts = query.offset((page - 1) * per_page).limit(per_page).all()
has_next = (page * per_page) < total
# Get all rules for filter dropdown
rules = Rule.query.order_by(Rule.name).all()
return render_template('partials/alerts_list.html',
alerts=alerts, rules=rules,
page=page, per_page=per_page,
total=total, has_next=has_next,
current_rule_id=rule_id,
current_severity=severity)
# ---------------------------------------------------------------------------
# Partial routes for htmx (Rules Editor UI)
# ---------------------------------------------------------------------------
@bp.route('/partials/rules')
@login_required
def rules_list_partial():
"""Return HTML partial of all rules for htmx swap."""
from mqttui.rules.models import Rule
rules = Rule.query.order_by(Rule.created_at.desc()).all()
return render_template('partials/rules_list.html', rules=rules)
@bp.route('/partials/rules/<int:rule_id>/row')
@login_required
def rule_row_partial(rule_id):
"""Return single rule row partial after update."""
from mqttui.rules.models import Rule
from mqttui.extensions import sa
rule = sa.session.get(Rule, rule_id)
if not rule:
return '', 404
return render_template('partials/rule_row.html', rule=rule)
@bp.route('/partials/rules/form')
@login_required
def rule_form_partial():
"""Return empty rule creation form partial."""
return render_template('partials/rule_form.html', rule=None)
@bp.route('/partials/rules/<int:rule_id>/form')
@login_required
def rule_edit_form_partial(rule_id):
"""Return pre-filled rule edit form partial."""
from mqttui.rules.models import Rule
from mqttui.extensions import sa
rule = sa.session.get(Rule, rule_id)
if not rule:
return '', 404
return render_template('partials/rule_form.html', rule=rule)
@bp.route('/partials/rules/<int:rule_id>/dry-run')
@login_required
def dry_run_form_partial(rule_id):
"""Return dry-run test form for a rule."""
return render_template('partials/dry_run_result.html', rule_id=rule_id, result=None)
@bp.route('/partials/rules/<int:rule_id>/toggle', methods=['POST'])
@login_required
def rule_toggle_partial(rule_id):
"""Toggle rule enabled/disabled and return updated row partial."""
from mqttui.rules.models import Rule
from mqttui.extensions import sa
from mqttui.events import rule_changed
rule = sa.session.get(Rule, rule_id)
if not rule:
return '', 404
rule.enabled = not rule.enabled
sa.session.commit()
rule_changed.send('ui', action='updated', rule_id=rule.id)
return render_template('partials/rule_row.html', rule=rule)
@bp.route('/partials/analytics')
@login_required
def analytics_partial():
"""Return HTML partial for analytics dashboard widget."""
return render_template('partials/analytics.html')
@bp.route('/partials/rules/<int:rule_id>/delete', methods=['DELETE'])
@login_required
def rule_delete_partial(rule_id):
"""Delete a rule and return empty response to remove DOM element."""
from mqttui.rules.models import Rule
from mqttui.extensions import sa
from mqttui.events import rule_changed
rule = sa.session.get(Rule, rule_id)
if not rule:
return '', 404
sa.session.delete(rule)
sa.session.commit()
rule_changed.send('ui', action='deleted', rule_id=rule_id)
return '' # Empty response removes the element
================================================
FILE: mqttui/routes/metrics.py
================================================
"""Prometheus-compatible /metrics endpoint and metric definitions.
Exposes counters, gauges, and histograms for MQTT, rules, webhooks,
and WebSocket activity. Designed for unauthenticated Prometheus scraping.
"""
from prometheus_client import Counter, Gauge, Histogram, generate_latest, CONTENT_TYPE_LATEST
from flask import Blueprint, Response
metrics_bp = Blueprint('metrics', __name__)
# Counters
MQTT_MESSAGES = Counter('mqtt_messages_total', 'Total MQTT messages received', ['topic'])
RULE_FIRINGS = Counter('rule_firings_total', 'Total rule firings', ['rule_id'])
WEBHOOK_DELIVERIES = Counter('webhook_deliveries_total', 'Total webhook deliveries', ['status'])
ALERTS = Counter('alerts_total', 'Total alerts triggered')
# Gauges
MQTT_CONNECTED = Gauge('mqtt_connected', 'MQTT broker connection status (0/1)')
ACTIVE_RULES = Gauge('active_rules', 'Number of active rules')
WEBSOCKET_CLIENTS = Gauge('websocket_clients', 'Number of connected WebSocket clients')
# Histograms
WEBHOOK_DURATION = Histogram(
'webhook_delivery_duration_seconds',
'Webhook delivery duration',
buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],
)
@metrics_bp.route('/metrics')
def prometheus_metrics():
"""Serve Prometheus metrics. No authentication required for scraping."""
import mqttui.state as state
MQTT_CONNECTED.set(1 if state.connection_count > 0 else 0)
WEBSOCKET_CLIENTS.set(state.active_websockets)
# Update active rules gauge from database
try:
from mqttui.rules.models import Rule
ACTIVE_RULES.set(Rule.query.filter_by(enabled=True).count())
except Exception:
pass
return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST)
# Signal subscribers for incrementing counters
def _on_message_for_metrics(sender, **kwargs):
"""Increment mqtt_messages_total counter on MQTT message received."""
MQTT_MESSAGES.labels(topic=kwargs.get('topic', 'unknown')).inc()
def _on_rule_fired_for_metrics(sender, **kwargs):
"""Increment rule_firings_total counter on rule fired."""
RULE_FIRINGS.labels(rule_id=str(kwargs.get('rule_id', 'unknown'))).inc()
def _on_alert_for_metrics(sender, **kwargs):
"""Increment alerts_total counter on alert triggered."""
ALERTS.inc()
================================================
FILE: mqttui/routes/plugins.py
================================================
"""Plugin management REST API and partials blueprint.
Provides endpoints to list, enable, and disable plugins,
plus an HTML partial for the plugins management tab.
"""
from flask import Blueprint, render_template
from flask_login import login_required
from mqttui.helpers import api_success, api_error
from mqttui.plugins.registry import get_plugin_registry
plugins_bp = Blueprint('plugins', __name__)
@plugins_bp.route('/api/v1/plugins')
@login_required
def list_plugins():
"""List all installed plugins.
Returns:
JSON envelope with plugins list.
"""
registry = get_plugin_registry()
plugins = registry.list_plugins() if registry else []
return api_success({"plugins": plugins})
@plugins_bp.route('/api/v1/plugins/<name>/enable', methods=['POST'])
@login_required
def enable_plugin(name):
"""Enable a plugin by name.
Returns:
JSON success or 404 if plugin not found.
"""
registry = get_plugin_registry()
if not registry or not registry.enable(name):
return api_error("Plugin not found", "NOT_FOUND", 404)
return api_success({"name": name, "enabled": True})
@plugins_bp.route('/api/v1/plugins/<name>/disable', methods=['POST'])
@login_required
def disable_plugin(name):
"""Disable a plugin by name.
Returns:
JSON success or 404 if plugin not found.
"""
registry = get_plugin_registry()
if not registry or not registry.disable(name):
return api_error("Plugin not found", "NOT_FOUND", 404)
return api_success({"name": name, "enabled": False})
@plugins_bp.route('/partials/plugins')
@login_required
def plugins_partial():
"""Render plugins management HTML partial.
Returns:
HTML partial with plugin list and enable/disable controls.
"""
registry = get_plugin_registry()
plugins = registry.list_plugins() if registry else []
return render_template('partials/plugins.html', plugins=plugins)
================================================
FILE: mqttui/routes/rules.py
================================================
"""Rules CRUD REST API blueprint.
All endpoints return JSON envelope: {"status": "success"|"error", "data": ..., "error": ...}
All endpoints require @login_required authentication.
"""
from flask import Blueprint, request
from flask_login import login_required
import json
import logging
from mqttui.extensions import sa
from mqttui.helpers import api_success, api_error
from mqttui.rules.models import Rule
from mqttui.rules.evaluator import evaluate, ConditionError
from mqttui.rules.ssrf import is_ssrf_safe
from mqttui.events import rule_changed
rules_bp = Blueprint('rules', __name__, url_prefix='/api/v1/rules')
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# List / Create
# ---------------------------------------------------------------------------
@rules_bp.route('/')
@login_required
def list_rules():
"""List all rules ordered by creation date (newest first)."""
rules = Rule.query.order_by(Rule.created_at.desc()).all()
return api_success({"rules": [r.to_dict() for r in rules]})
@rules_bp.route('/', methods=['POST'])
@login_required
def create_rule():
"""Create a new rule.
Required fields: name, trigger_topic, action (dict with 'type' key).
Optional: description, condition, rate_limit_per_min, schedule_cron, enabled.
"""
data = request.get_json(silent=True)
if not data:
return api_error("JSON body required", "VALIDATION_ERROR", 400)
# Validate required fields
name = data.get('name')
if not name or not isinstance(name, str) or not name.strip():
return api_error("name is required", "VALIDATION_ERROR", 400)
trigger_topic = data.get('trigger_topic')
if not trigger_topic or not isinstance(trigger_topic, str) or not trigger_topic.strip():
return api_error("trigger_topic is required", "VALIDATION_ERROR", 400)
action = data.get('action')
if not action or not isinstance(action, dict) or 'type' not in action:
return api_error("action is required and must have a 'type' key", "VALIDATION_ERROR", 400)
# SSRF validation for webhook actions
if action.get('type') == 'webhook':
webhook_url = action.get('url', '')
safe, reason = is_ssrf_safe(webhook_url)
if not safe:
return api_error(f"Webhook URL rejected: {reason}", "SSRF_BLOCKED", 400)
# Build rule
rule = Rule(
name=name.strip(),
description=data.get('description', ''),
trigger_topic=trigger_topic.strip(),
condition_json=json.dumps(data.get('condition', {})),
action_json=json.dumps(action),
enabled=data.get('enabled', True),
rate_limit_per_min=data.get('rate_limit_per_min', 10),
schedule_cron=data.get('schedule_cron'),
)
sa.session.add(rule)
sa.session.commit()
rule_changed.send('api', action='created', rule_id=rule.id)
logger.info
gitextract_wv3k4jzs/ ├── .env_example ├── .github/ │ └── workflows/ │ └── docker-publish.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.multiarch ├── LICENSE.md ├── README.md ├── app.py ├── database.py ├── debug_bar.py ├── demo.sh ├── docker-compose.yml ├── entrypoint.sh ├── mosquitto.conf ├── mqttui/ │ ├── __init__.py │ ├── analytics.py │ ├── app.py │ ├── auth.py │ ├── broker_manager.py │ ├── database.py │ ├── events.py │ ├── extensions.py │ ├── helpers.py │ ├── logging_config.py │ ├── models.py │ ├── mqtt_client.py │ ├── plugins/ │ │ ├── __init__.py │ │ ├── examples/ │ │ │ ├── __init__.py │ │ │ ├── json_formatter.py │ │ │ └── topic_logger.py │ │ ├── hookspec.py │ │ ├── models.py │ │ ├── registry.py │ │ └── runner.py │ ├── routes/ │ │ ├── __init__.py │ │ ├── alerts.py │ │ ├── analytics.py │ │ ├── api.py │ │ ├── api_v1.py │ │ ├── brokers.py │ │ ├── debug.py │ │ ├── main.py │ │ ├── metrics.py │ │ ├── plugins.py │ │ └── rules.py │ ├── rules/ │ │ ├── __init__.py │ │ ├── actions.py │ │ ├── cooldown.py │ │ ├── engine.py │ │ ├── evaluator.py │ │ ├── models.py │ │ └── ssrf.py │ ├── socketio_batch.py │ └── state.py ├── pytest.ini ├── requirements-dev.txt ├── requirements.txt ├── static/ │ ├── css/ │ │ ├── input.css │ │ └── output.css │ ├── script.js │ └── styles.css ├── templates/ │ ├── base.html │ ├── index.html │ ├── login.html │ └── partials/ │ ├── alert_row.html │ ├── alerts_list.html │ ├── analytics.html │ ├── brokers.html │ ├── dry_run_result.html │ ├── plugins.html │ ├── rule_form.html │ ├── rule_row.html │ └── rules_list.html ├── tests/ │ ├── __init__.py │ ├── conftest.py │ ├── test_alerts_api.py │ ├── test_analytics.py │ ├── test_api_v1.py │ ├── test_app_factory.py │ ├── test_auth.py │ ├── test_cooldown.py │ ├── test_database.py │ ├── test_frontend.py │ ├── test_observability.py │ ├── test_plugin_registry.py │ ├── test_plugin_runner.py │ ├── test_plugins_api.py │ ├── test_routes.py │ ├── test_rules_api.py │ ├── test_rules_engine.py │ ├── test_rules_evaluator.py │ ├── test_rules_models.py │ ├── test_ux_features.py │ └── test_webhook.py └── wsgi.py
SYMBOL INDEX (587 symbols across 56 files)
FILE: app.py
function after_request (line 110) | def after_request(response):
function handle_connect (line 139) | def handle_connect():
function handle_disconnect (line 146) | def handle_disconnect():
function on_connect (line 152) | def on_connect(client, userdata, flags, rc, properties=None):
function on_disconnect (line 178) | def on_disconnect(client, userdata, rc):
function on_message (line 191) | def on_message(client, userdata, msg):
function get_message_history (line 234) | def get_message_history():
function get_topic_list (line 301) | def get_topic_list():
function get_database_stats (line 317) | def get_database_stats():
function cleanup_database (line 337) | def cleanup_database():
function get_filter_presets (line 358) | def get_filter_presets():
function save_filter_preset (line 372) | def save_filter_preset():
function delete_filter_preset (line 398) | def delete_filter_preset(name):
function use_filter_preset (line 416) | def use_filter_preset(name):
function index (line 434) | def index():
function publish_message (line 438) | def publish_message():
function get_stats (line 446) | def get_stats():
function send_static (line 455) | def send_static(path):
function get_debug_bar_data (line 459) | def get_debug_bar_data():
function toggle_debug_bar (line 468) | def toggle_debug_bar():
function record_client_performance (line 476) | def record_client_performance():
function get_version (line 483) | def get_version():
function connect_mqtt (line 490) | def connect_mqtt():
FILE: database.py
class MessageDatabase (line 13) | class MessageDatabase:
method __init__ (line 14) | def __init__(self, db_path: str = "mqtt_messages.db", max_messages: in...
method get_connection (line 20) | def get_connection(self):
method _regexp (line 35) | def _regexp(self, pattern, value):
method init_database (line 42) | def init_database(self):
method store_message (line 108) | def store_message(self, topic: str, payload: str, timestamp: datetime ...
method get_messages (line 144) | def get_messages(self, limit: int = 100, offset: int = 0,
method _apply_python_filters (line 203) | def _apply_python_filters(self, messages: List[Dict], regex_topic: str...
method _get_json_path_value (line 228) | def _get_json_path_value(self, json_obj: dict, path: str):
method get_topics (line 242) | def get_topics(self) -> List[Dict]:
method get_message_count (line 259) | def get_message_count(self, topic_filter: str = None, since: datetime ...
method _cleanup_old_messages (line 286) | def _cleanup_old_messages(self):
method cleanup_old_data (line 322) | def cleanup_old_data(self, days: int = 30):
method get_database_size (line 347) | def get_database_size(self) -> Dict:
method save_filter_preset (line 371) | def save_filter_preset(self, name: str, filters: Dict, description: st...
method get_filter_presets (line 390) | def get_filter_presets(self) -> List[Dict]:
method delete_filter_preset (line 414) | def delete_filter_preset(self, name: str) -> bool:
method use_filter_preset (line 432) | def use_filter_preset(self, name: str) -> Optional[Dict]:
method close (line 459) | def close(self):
FILE: debug_bar.py
class DebugBarPanel (line 7) | class DebugBarPanel:
method __init__ (line 8) | def __init__(self, name):
method record (line 12) | def record(self, key, value):
method get_data (line 15) | def get_data(self):
class DebugBar (line 18) | class DebugBar:
method __init__ (line 19) | def __init__(self):
method add_panel (line 30) | def add_panel(self, name):
method record (line 35) | def record(self, panel, key, value):
method start_request (line 40) | def start_request(self):
method end_request (line 43) | def end_request(self):
method get_data (line 49) | def get_data(self):
method enable (line 54) | def enable(self):
method disable (line 57) | def disable(self):
method remove (line 60) | def remove(self, panel_name, key):
function debug_bar_middleware (line 74) | def debug_bar_middleware():
FILE: mqttui/analytics.py
class TopicAnalytics (line 20) | class TopicAnalytics:
method __init__ (line 29) | def __init__(self):
method record (line 33) | def record(self, topic: str, payload: str, timestamp: float):
method _update_histogram (line 59) | def _update_histogram(self, topic: str, field_name: str, value: float):
method get_rate (line 78) | def get_rate(self, topic: str, window: int = 60) -> float:
method get_topic_stats (line 95) | def get_topic_stats(self, topic: str) -> dict:
method get_all_stats (line 110) | def get_all_stats(self, limit: int = 20) -> list:
function get_analytics (line 132) | def get_analytics() -> TopicAnalytics:
function _on_mqtt_message (line 144) | def _on_mqtt_message(sender, **kwargs):
FILE: mqttui/app.py
function _on_mqtt_message (line 11) | def _on_mqtt_message(sender, **kwargs):
function create_app (line 47) | def create_app(config=None):
FILE: mqttui/auth.py
function load_user_from_api_key (line 16) | def load_user_from_api_key():
function load_user (line 26) | def load_user(user_id):
function seed_admin_user (line 30) | def seed_admin_user(app):
function login (line 50) | def login():
function login_post (line 57) | def login_post():
function logout (line 72) | def logout():
FILE: mqttui/broker_manager.py
function get_broker_manager (line 36) | def get_broker_manager():
class ManagedConnection (line 40) | class ManagedConnection:
method __init__ (line 43) | def __init__(self, broker_id, name, host, port, username=None, passwor...
method _on_connect (line 78) | def _on_connect(self, client, userdata, connect_flags, reason_code, pr...
method _on_disconnect (line 93) | def _on_disconnect(self, client, userdata, disconnect_flags, reason_co...
method _on_message (line 101) | def _on_message(self, client, userdata, msg):
method start (line 144) | def start(self):
method stop (line 154) | def stop(self):
method publish (line 162) | def publish(self, topic, payload, qos=0, retain=False):
method status_dict (line 166) | def status_dict(self):
class BrokerManager (line 177) | class BrokerManager:
method __init__ (line 180) | def __init__(self, app=None):
method add_connection (line 184) | def add_connection(self, broker):
method remove_connection (line 225) | def remove_connection(self, broker_id):
method get_connection (line 230) | def get_connection(self, broker_id):
method get_default_connection (line 233) | def get_default_connection(self):
method publish (line 243) | def publish(self, topic, payload, qos=0, retain=False, broker_id=None):
method all_statuses (line 252) | def all_statuses(self):
method start_all (line 255) | def start_all(self, app):
method stop_all (line 264) | def stop_all(self):
method load_env_broker (line 269) | def load_env_broker(self, app):
function init_broker_manager (line 297) | def init_broker_manager(app):
FILE: mqttui/database.py
class MessageDatabase (line 14) | class MessageDatabase:
method __init__ (line 15) | def __init__(self, db_path: str = "mqtt_messages.db", max_messages: in...
method get_connection (line 21) | def get_connection(self):
method _regexp (line 38) | def _regexp(self, pattern, value):
method init_database (line 45) | def init_database(self):
method store_message (line 111) | def store_message(self, topic: str, payload: str, timestamp: datetime ...
method get_messages (line 147) | def get_messages(self, limit: int = 100, offset: int = 0,
method _apply_python_filters (line 206) | def _apply_python_filters(self, messages: List[Dict], regex_topic: str...
method _get_json_path_value (line 230) | def _get_json_path_value(self, json_obj: dict, path: str):
method get_topics (line 244) | def get_topics(self) -> List[Dict]:
method get_message_count (line 261) | def get_message_count(self, topic_filter: str = None, since: datetime ...
method _cleanup_old_messages (line 288) | def _cleanup_old_messages(self):
method cleanup_old_data (line 321) | def cleanup_old_data(self, days: int = 30):
method get_database_size (line 345) | def get_database_size(self) -> Dict:
method save_filter_preset (line 369) | def save_filter_preset(self, name: str, filters: Dict, description: st...
method get_filter_presets (line 388) | def get_filter_presets(self) -> List[Dict]:
method delete_filter_preset (line 412) | def delete_filter_preset(self, name: str) -> bool:
method use_filter_preset (line 430) | def use_filter_preset(self, name: str) -> Optional[Dict]:
method close (line 455) | def close(self):
FILE: mqttui/helpers.py
function api_success (line 6) | def api_success(data=None, status_code=200):
function api_error (line 11) | def api_error(message, code="UNKNOWN_ERROR", status_code=400):
FILE: mqttui/logging_config.py
function configure_logging (line 10) | def configure_logging(debug=False, log_level='INFO'):
FILE: mqttui/models.py
class User (line 7) | class User(UserMixin, sa.Model):
method set_password (line 17) | def set_password(self, password):
method check_password (line 20) | def check_password(self, password):
method generate_api_token (line 23) | def generate_api_token(self):
method is_active (line 28) | def is_active(self):
class TopicFavorite (line 32) | class TopicFavorite(sa.Model):
method to_dict (line 42) | def to_dict(self):
class Broker (line 51) | class Broker(sa.Model):
method to_dict (line 69) | def to_dict(self):
FILE: mqttui/mqtt_client.py
function init_mqtt (line 11) | def init_mqtt(app):
function get_client (line 18) | def get_client():
function publish (line 29) | def publish(topic, payload, qos=0, retain=False, broker_id=None):
FILE: mqttui/plugins/examples/json_formatter.py
function main (line 15) | def main():
FILE: mqttui/plugins/examples/topic_logger.py
function main (line 15) | def main():
FILE: mqttui/plugins/hookspec.py
class MQTTUIPlugin (line 12) | class MQTTUIPlugin:
method on_message (line 19) | def on_message(self, topic: str, payload: str) -> dict | None:
method on_connect (line 31) | def on_connect(self) -> None:
method on_rule_trigger (line 35) | def on_rule_trigger(self, rule_name: str, topic: str, payload: str) ->...
FILE: mqttui/plugins/models.py
class PluginConfig (line 5) | class PluginConfig(sa.Model):
method to_dict (line 17) | def to_dict(self):
FILE: mqttui/plugins/registry.py
class PluginRegistry (line 17) | class PluginRegistry:
method __init__ (line 20) | def __init__(self, app=None):
method discover (line 25) | def discover(self) -> list[dict]:
method list_plugins (line 73) | def list_plugins(self) -> list[dict]:
method get_enabled_plugins (line 77) | def get_enabled_plugins(self) -> list[PluginConfig]:
method enable (line 81) | def enable(self, name: str) -> bool:
method disable (line 91) | def disable(self, name: str) -> bool:
function get_plugin_registry (line 106) | def get_plugin_registry() -> PluginRegistry:
function init_plugin_registry (line 111) | def init_plugin_registry(app) -> PluginRegistry:
FILE: mqttui/plugins/runner.py
class PluginRunner (line 22) | class PluginRunner:
method __init__ (line 25) | def __init__(self, app=None):
method call_plugin (line 29) | def call_plugin(self, plugin_config, event_type: str, data: dict) -> l...
method dispatch_message (line 89) | def dispatch_message(self, topic: str, payload: str) -> list[dict]:
method dispatch_actions (line 115) | def dispatch_actions(self, actions: list[dict]):
method on_mqtt_message (line 141) | def on_mqtt_message(self, sender, **kwargs):
method on_rule_trigger (line 151) | def on_rule_trigger(self, sender, **kwargs):
function get_plugin_runner (line 179) | def get_plugin_runner() -> PluginRunner | None:
function init_plugin_runner (line 184) | def init_plugin_runner(app) -> PluginRunner:
FILE: mqttui/routes/alerts.py
function list_alerts (line 18) | def list_alerts():
FILE: mqttui/routes/analytics.py
function get_topics (line 20) | def get_topics():
function get_topic (line 44) | def get_topic(topic):
FILE: mqttui/routes/api.py
function get_message_history (line 14) | def get_message_history():
function get_topic_list (line 76) | def get_topic_list():
function get_database_stats (line 92) | def get_database_stats():
function cleanup_database (line 113) | def cleanup_database():
function get_filter_presets (line 135) | def get_filter_presets():
function save_filter_preset (line 150) | def save_filter_preset():
function delete_filter_preset (line 177) | def delete_filter_preset(name):
function use_filter_preset (line 196) | def use_filter_preset(name):
FILE: mqttui/routes/api_v1.py
function get_messages (line 28) | def get_messages():
function get_topics (line 129) | def get_topics():
function get_favorites (line 160) | def get_favorites():
function toggle_bookmark (line 179) | def toggle_bookmark(topic):
function get_database_stats (line 220) | def get_database_stats():
function cleanup_database (line 247) | def cleanup_database():
function get_filter_presets (line 284) | def get_filter_presets():
function save_filter_preset (line 306) | def save_filter_preset():
function delete_filter_preset (line 352) | def delete_filter_preset(name):
function use_filter_preset (line 385) | def use_filter_preset(name):
function publish_message (line 423) | def publish_message():
function get_stats (line 469) | def get_stats():
function get_version (line 491) | def get_version():
function api_docs (line 508) | def api_docs():
function openapi_spec (line 522) | def openapi_spec():
function get_token (line 550) | def get_token():
function regenerate_token (line 564) | def regenerate_token():
function revoke_token (line 579) | def revoke_token():
function handle_429 (line 597) | def handle_429(e):
function handle_404 (line 606) | def handle_404(e):
function handle_500 (line 611) | def handle_500(e):
FILE: mqttui/routes/brokers.py
function list_brokers (line 15) | def list_brokers():
function create_broker (line 37) | def create_broker():
function get_broker (line 70) | def get_broker(broker_id):
function update_broker (line 88) | def update_broker(broker_id):
function delete_broker (line 118) | def delete_broker(broker_id):
function connect_broker (line 136) | def connect_broker(broker_id):
function disconnect_broker (line 154) | def disconnect_broker(broker_id):
function broker_status (line 172) | def broker_status():
FILE: mqttui/routes/debug.py
function debug_bar_middleware (line 10) | def debug_bar_middleware():
function after_request (line 18) | def after_request(response):
function get_debug_bar_data (line 27) | def get_debug_bar_data():
function toggle_debug_bar (line 38) | def toggle_debug_bar():
function record_client_performance (line 48) | def record_client_performance():
FILE: mqttui/routes/main.py
function index (line 14) | def index():
function publish_message (line 20) | def publish_message():
function get_stats (line 30) | def get_stats():
function get_version (line 41) | def get_version():
function send_static (line 46) | def send_static(path):
function alerts_list_partial (line 56) | def alerts_list_partial():
function rules_list_partial (line 92) | def rules_list_partial():
function rule_row_partial (line 101) | def rule_row_partial(rule_id):
function rule_form_partial (line 113) | def rule_form_partial():
function rule_edit_form_partial (line 120) | def rule_edit_form_partial(rule_id):
function dry_run_form_partial (line 132) | def dry_run_form_partial(rule_id):
function rule_toggle_partial (line 139) | def rule_toggle_partial(rule_id):
function analytics_partial (line 155) | def analytics_partial():
function rule_delete_partial (line 162) | def rule_delete_partial(rule_id):
FILE: mqttui/routes/metrics.py
function prometheus_metrics (line 31) | def prometheus_metrics():
function _on_message_for_metrics (line 47) | def _on_message_for_metrics(sender, **kwargs):
function _on_rule_fired_for_metrics (line 52) | def _on_rule_fired_for_metrics(sender, **kwargs):
function _on_alert_for_metrics (line 57) | def _on_alert_for_metrics(sender, **kwargs):
FILE: mqttui/routes/plugins.py
function list_plugins (line 17) | def list_plugins():
function enable_plugin (line 30) | def enable_plugin(name):
function disable_plugin (line 44) | def disable_plugin(name):
function plugins_partial (line 58) | def plugins_partial():
FILE: mqttui/routes/rules.py
function list_rules (line 29) | def list_rules():
function create_rule (line 37) | def create_rule():
function _get_rule_or_404 (line 92) | def _get_rule_or_404(rule_id):
function get_rule (line 102) | def get_rule(rule_id):
function update_rule (line 112) | def update_rule(rule_id):
function delete_rule (line 157) | def delete_rule(rule_id):
function enable_rule (line 178) | def enable_rule(rule_id):
function disable_rule (line 193) | def disable_rule(rule_id):
function test_rule (line 212) | def test_rule(rule_id):
function _topic_matches (line 280) | def _topic_matches(pattern, topic):
function _build_action_preview (line 301) | def _build_action_preview(action, context):
FILE: mqttui/rules/actions.py
function execute_action (line 20) | def execute_action(action_dict, context):
function _execute_publish (line 49) | def _execute_publish(action_dict, context):
function _execute_log (line 79) | def _execute_log(action_dict, context):
function _execute_webhook (line 102) | def _execute_webhook(action_dict, context):
function _build_default_payload (line 163) | def _build_default_payload(context):
function _build_webhook_payload (line 174) | def _build_webhook_payload(template, context):
function _deliver_webhook (line 203) | def _deliver_webhook(url, payload_json, rule_id, rule_name, topic,
function _execute_telegram (line 312) | def _execute_telegram(action_dict, context):
function _execute_slack (line 336) | def _execute_slack(action_dict, context):
function _substitute_template (line 355) | def _substitute_template(template, context):
function _log_suppressed_alert (line 368) | def _log_suppressed_alert(rule_id, rule_name, topic, url,
function _log_webhook_history (line 396) | def _log_webhook_history(sa, rule_id, rule_name, topic, url,
FILE: mqttui/rules/cooldown.py
class CooldownTracker (line 11) | class CooldownTracker:
method __init__ (line 19) | def __init__(self, default_seconds=300):
method check (line 29) | def check(self, rule_id, cooldown_seconds=None):
method get_suppressed_count (line 53) | def get_suppressed_count(self, rule_id):
method get_cooldown_until (line 64) | def get_cooldown_until(self, rule_id, cooldown_seconds=None):
FILE: mqttui/rules/engine.py
function _fire_scheduled_rule (line 28) | def _fire_scheduled_rule(app, rule_id):
function get_engine (line 39) | def get_engine():
class RuleEngine (line 44) | class RuleEngine:
method __init__ (line 57) | def __init__(self, app=None):
method init_scheduler (line 71) | def init_scheduler(self):
method fire_scheduled_rule (line 88) | def fire_scheduled_rule(self, rule_id):
method sync_scheduled_jobs (line 140) | def sync_scheduled_jobs(self):
method reload_cache (line 182) | def reload_cache(self):
method _check_rate_limit (line 222) | def _check_rate_limit(self, rule):
method on_mqtt_message (line 257) | def on_mqtt_message(self, sender, **kwargs):
method _on_rule_changed (line 367) | def _on_rule_changed(self, sender, **kwargs):
method connect (line 376) | def connect(self):
method disconnect (line 390) | def disconnect(self):
class _nullcontext (line 402) | class _nullcontext:
method __enter__ (line 405) | def __enter__(self):
method __exit__ (line 408) | def __exit__(self, *args):
FILE: mqttui/rules/evaluator.py
class ConditionError (line 9) | class ConditionError(Exception):
function _get_path (line 14) | def _get_path(data, path):
function _coerce_numeric (line 35) | def _coerce_numeric(actual, expected):
function evaluate (line 48) | def evaluate(condition, payload):
FILE: mqttui/rules/models.py
class Rule (line 6) | class Rule(sa.Model):
method action (line 24) | def action(self):
method condition (line 31) | def condition(self):
method to_dict (line 37) | def to_dict(self):
class AlertHistory (line 55) | class AlertHistory(sa.Model):
method to_dict (line 73) | def to_dict(self):
FILE: mqttui/rules/ssrf.py
function is_ssrf_safe (line 15) | def is_ssrf_safe(url: str) -> tuple:
function _check_ip (line 63) | def _check_ip(addr) -> tuple:
FILE: mqttui/socketio_batch.py
class BatchEmitter (line 5) | class BatchEmitter:
method __init__ (line 12) | def __init__(self, socketio: SocketIO, interval_ms: int = 100):
method start (line 20) | def start(self):
method stop (line 25) | def stop(self):
method enqueue (line 31) | def enqueue(self, message: dict):
method _schedule_flush (line 36) | def _schedule_flush(self):
method _flush (line 44) | def _flush(self):
function init_batch_emitter (line 57) | def init_batch_emitter(socketio: SocketIO, interval_ms: int = 100):
function get_batch_emitter (line 68) | def get_batch_emitter():
FILE: static/script.js
function mqttuiApp (line 24) | function mqttuiApp() {
function messageListComponent (line 85) | function messageListComponent() {
function statsComponent (line 171) | function statsComponent() {
function publishComponent (line 197) | function publishComponent() {
function initChart (line 216) | function initChart() {
function updateChart (line 288) | function updateChart() {
function initNetwork (line 312) | function initNetwork() {
function updateNetwork (line 422) | function updateNetwork(message) {
function getRandomColor (line 482) | function getRandomColor() {
function updateMessageList (line 492) | function updateMessageList(message) {
function updateStats (line 497) | function updateStats() {
function updateTopicFilter (line 501) | function updateTopicFilter(newTopic) {
function loadTopicsFromAPI (line 511) | function loadTopicsFromAPI() {
function loadFilteredMessages (line 550) | function loadFilteredMessages(customFilters = {}) {
function setupAdvancedSearchHandlers (line 579) | function setupAdvancedSearchHandlers() {
function loadFilterPresets (line 791) | function loadFilterPresets() {
function initDebugBar (line 817) | function initDebugBar() {
function toggleDebugBar (line 840) | function toggleDebugBar() {
function closeDebugBar (line 849) | function closeDebugBar() {
function trackClientPerformance (line 855) | function trackClientPerformance() {
function updateDebugBar (line 884) | function updateDebugBar() {
function ruleFormComponent (line 909) | function ruleFormComponent(existing = {}) {
function dryRunComponent (line 1008) | function dryRunComponent(ruleId) {
function brokersComponent (line 1042) | function brokersComponent() {
FILE: tests/conftest.py
function app (line 8) | def app(tmp_path):
function client (line 35) | def client(app):
function test_db (line 41) | def test_db(tmp_path):
function auth_client (line 51) | def auth_client(app):
function api_token (line 70) | def api_token(app):
function mock_mqtt (line 79) | def mock_mqtt():
FILE: tests/test_alerts_api.py
function _seed_alerts (line 15) | def _seed_alerts(app, count=25, rule_id=1, severity='info'):
function _seed_rule (line 31) | def _seed_rule(app, rule_id=None, action_type='webhook'):
class TestListAlerts (line 61) | class TestListAlerts:
method test_list_alerts_empty (line 64) | def test_list_alerts_empty(self, auth_client, app):
method test_list_alerts_paginated (line 73) | def test_list_alerts_paginated(self, auth_client, app):
method test_list_alerts_filter_rule_id (line 84) | def test_list_alerts_filter_rule_id(self, auth_client, app):
method test_list_alerts_filter_severity (line 94) | def test_list_alerts_filter_severity(self, auth_client, app):
method test_list_alerts_requires_auth (line 104) | def test_list_alerts_requires_auth(self, client):
class TestDryRunActionPreview (line 115) | class TestDryRunActionPreview:
method test_dry_run_action_preview (line 118) | def test_dry_run_action_preview(self, auth_client, app):
method test_dry_run_action_preview_publish (line 139) | def test_dry_run_action_preview_publish(self, auth_client, app):
method test_dry_run_action_preview_log (line 155) | def test_dry_run_action_preview_log(self, auth_client, app):
method test_dry_run_no_preview_when_no_match (line 171) | def test_dry_run_no_preview_when_no_match(self, auth_client, app):
FILE: tests/test_analytics.py
class TestTopicAnalytics (line 9) | class TestTopicAnalytics:
method setup_method (line 12) | def setup_method(self):
method test_record_increments_message_count (line 15) | def test_record_increments_message_count(self):
method test_get_rate_per_minute (line 22) | def test_get_rate_per_minute(self):
method test_get_rate_per_hour (line 30) | def test_get_rate_per_hour(self):
method test_expired_timestamps_not_counted (line 38) | def test_expired_timestamps_not_counted(self):
method test_numeric_payload_updates_histogram (line 48) | def test_numeric_payload_updates_histogram(self):
method test_non_numeric_payload_does_not_crash (line 60) | def test_non_numeric_payload_does_not_crash(self):
method test_get_topic_stats_returns_full_dict (line 69) | def test_get_topic_stats_returns_full_dict(self):
method test_get_all_stats_sorted_by_rate (line 81) | def test_get_all_stats_sorted_by_rate(self):
class TestAnalyticsAPI (line 100) | class TestAnalyticsAPI:
method test_get_topics_returns_200 (line 103) | def test_get_topics_returns_200(self, auth_client, app):
method test_get_single_topic_returns_200 (line 118) | def test_get_single_topic_returns_200(self, auth_client, app):
method test_get_unknown_topic_returns_404 (line 131) | def test_get_unknown_topic_returns_404(self, auth_client):
method test_unauthenticated_request_rejected (line 139) | def test_unauthenticated_request_rejected(self, client):
class TestAnalyticsPartial (line 146) | class TestAnalyticsPartial:
method test_analytics_partial_returns_200 (line 149) | def test_analytics_partial_returns_200(self, auth_client):
method test_analytics_partial_contains_rate_per_min (line 155) | def test_analytics_partial_contains_rate_per_min(self, auth_client):
method test_analytics_partial_requires_auth (line 161) | def test_analytics_partial_requires_auth(self, client):
FILE: tests/test_api_v1.py
function test_json_envelope_success (line 5) | def test_json_envelope_success(auth_client):
function test_json_envelope_error (line 16) | def test_json_envelope_error(auth_client):
function test_messages_endpoint (line 27) | def test_messages_endpoint(auth_client):
function test_topics_endpoint (line 36) | def test_topics_endpoint(auth_client):
function test_version_public (line 45) | def test_version_public(client):
function test_docs_endpoint (line 53) | def test_docs_endpoint(client):
function test_openapi_spec (line 60) | def test_openapi_spec(client):
function test_cors_headers (line 70) | def test_cors_headers(client):
function test_publish_requires_auth (line 80) | def test_publish_requires_auth(client):
function test_stats_requires_auth (line 86) | def test_stats_requires_auth(client):
function test_messages_requires_auth (line 92) | def test_messages_requires_auth(client):
function test_rate_limit_publish (line 98) | def test_rate_limit_publish(auth_client, app):
FILE: tests/test_app_factory.py
function test_create_app (line 1) | def test_create_app(app):
function test_create_app_has_blueprints (line 6) | def test_create_app_has_blueprints(app):
function test_create_app_custom_config (line 13) | def test_create_app_custom_config(tmp_path):
function test_socketio_async_mode (line 31) | def test_socketio_async_mode(app):
FILE: tests/test_auth.py
function test_login_page_loads (line 4) | def test_login_page_loads(client):
function test_unauthenticated_redirect (line 12) | def test_unauthenticated_redirect(client):
function test_login_valid_credentials (line 19) | def test_login_valid_credentials(client, app):
function test_login_invalid_credentials (line 35) | def test_login_invalid_credentials(client, app):
function test_logout (line 42) | def test_logout(auth_client):
function test_api_token_auth (line 49) | def test_api_token_auth(client, api_token):
function test_api_token_invalid (line 58) | def test_api_token_invalid(client):
function test_secret_key_guard (line 65) | def test_secret_key_guard():
function test_token_get (line 84) | def test_token_get(auth_client, app):
function test_token_regenerate (line 94) | def test_token_regenerate(auth_client, app):
function test_token_revoke (line 104) | def test_token_revoke(auth_client, app):
FILE: tests/test_cooldown.py
class TestCooldownTracker (line 14) | class TestCooldownTracker:
method test_cooldown_allows_first_alert (line 17) | def test_cooldown_allows_first_alert(self):
method test_cooldown_blocks_during_window (line 22) | def test_cooldown_blocks_during_window(self):
method test_cooldown_allows_after_expiry (line 28) | def test_cooldown_allows_after_expiry(self):
method test_cooldown_different_rules_independent (line 38) | def test_cooldown_different_rules_independent(self):
method test_cooldown_custom_window (line 48) | def test_cooldown_custom_window(self):
method test_cooldown_suppressed_count (line 63) | def test_cooldown_suppressed_count(self):
method test_cooldown_suppressed_count_resets_on_new_fire (line 75) | def test_cooldown_suppressed_count_resets_on_new_fire(self):
class TestWebhookCooldownIntegration (line 94) | class TestWebhookCooldownIntegration:
method test_webhook_skipped_during_cooldown (line 97) | def test_webhook_skipped_during_cooldown(self, app):
FILE: tests/test_database.py
function test_wal_mode (line 4) | def test_wal_mode(test_db):
function test_busy_timeout (line 10) | def test_busy_timeout(test_db):
function test_store_and_retrieve (line 16) | def test_store_and_retrieve(test_db):
function test_get_topics (line 31) | def test_get_topics(test_db):
function test_message_count (line 40) | def test_message_count(test_db):
FILE: tests/test_frontend.py
class TestPartialRoutes (line 6) | class TestPartialRoutes:
method test_rules_list_partial_returns_html (line 9) | def test_rules_list_partial_returns_html(self, auth_client):
method test_alerts_list_partial_returns_html (line 14) | def test_alerts_list_partial_returns_html(self, auth_client):
method test_rule_form_partial_returns_html (line 19) | def test_rule_form_partial_returns_html(self, auth_client):
method test_partials_require_auth (line 24) | def test_partials_require_auth(self, client):
class TestIndexTemplate (line 31) | class TestIndexTemplate:
method test_index_contains_alpine (line 34) | def test_index_contains_alpine(self, auth_client):
method test_index_contains_htmx (line 39) | def test_index_contains_htmx(self, auth_client):
method test_index_contains_tabs (line 43) | def test_index_contains_tabs(self, auth_client):
method test_index_contains_alpine_xdata (line 50) | def test_index_contains_alpine_xdata(self, auth_client):
class TestBatchEmitter (line 55) | class TestBatchEmitter:
method test_enqueue_adds_to_buffer (line 58) | def test_enqueue_adds_to_buffer(self):
method test_flush_emits_batch_and_clears (line 65) | def test_flush_emits_batch_and_clears(self):
method test_flush_noop_when_empty (line 78) | def test_flush_noop_when_empty(self):
FILE: tests/test_observability.py
class TestStructlogConfiguration (line 6) | class TestStructlogConfiguration:
method test_configure_logging_debug_uses_console_renderer (line 9) | def test_configure_logging_debug_uses_console_renderer(self):
method test_configure_logging_production_uses_json_renderer (line 29) | def test_configure_logging_production_uses_json_renderer(self):
method test_structlog_get_logger_returns_bound_logger (line 44) | def test_structlog_get_logger_returns_bound_logger(self):
class TestPrometheusMetricsEndpoint (line 56) | class TestPrometheusMetricsEndpoint:
method test_metrics_returns_200 (line 59) | def test_metrics_returns_200(self, client):
method test_metrics_contains_mqtt_messages_total (line 65) | def test_metrics_contains_mqtt_messages_total(self, client):
method test_metrics_contains_mqtt_connected_gauge (line 70) | def test_metrics_contains_mqtt_connected_gauge(self, client):
method test_metrics_contains_rule_firings_total (line 75) | def test_metrics_contains_rule_firings_total(self, client):
method test_metrics_no_auth_required (line 80) | def test_metrics_no_auth_required(self, client):
FILE: tests/test_plugin_registry.py
function _ensure_plugin_table (line 7) | def _ensure_plugin_table(app):
class TestMQTTUIPluginHookspec (line 15) | class TestMQTTUIPluginHookspec:
method test_has_on_message_hookspec (line 18) | def test_has_on_message_hookspec(self):
method test_has_on_connect_hookspec (line 22) | def test_has_on_connect_hookspec(self):
method test_has_on_rule_trigger_hookspec (line 26) | def test_has_on_rule_trigger_hookspec(self):
method test_hookspec_markers_applied (line 30) | def test_hookspec_markers_applied(self):
method test_hookimpl_marker_exported (line 37) | def test_hookimpl_marker_exported(self):
class TestPluginConfigModel (line 42) | class TestPluginConfigModel:
method test_create_plugin_config (line 45) | def test_create_plugin_config(self, app):
method test_update_enabled_state (line 66) | def test_update_enabled_state(self, app):
method test_to_dict (line 85) | def test_to_dict(self, app):
class TestPluginRegistry (line 110) | class TestPluginRegistry:
method test_discover_with_no_entry_points (line 113) | def test_discover_with_no_entry_points(self, app):
method test_discover_with_mock_entry_point (line 122) | def test_discover_with_mock_entry_point(self, app):
method test_list_plugins (line 142) | def test_list_plugins(self, app):
method test_enable_plugin (line 161) | def test_enable_plugin(self, app):
method test_disable_plugin (line 182) | def test_disable_plugin(self, app):
method test_enable_nonexistent_returns_false (line 203) | def test_enable_nonexistent_returns_false(self, app):
method test_disable_nonexistent_returns_false (line 209) | def test_disable_nonexistent_returns_false(self, app):
method test_get_enabled_plugins (line 215) | def test_get_enabled_plugins(self, app):
FILE: tests/test_plugin_runner.py
function runner (line 12) | def runner():
function mock_plugin (line 18) | def mock_plugin():
class TestCallPlugin (line 28) | class TestCallPlugin:
method test_spawns_subprocess_with_correct_args (line 31) | def test_spawns_subprocess_with_correct_args(self, runner, mock_plugin):
method test_sends_json_on_stdin (line 46) | def test_sends_json_on_stdin(self, runner, mock_plugin):
method test_parses_valid_json_response (line 62) | def test_parses_valid_json_response(self, runner, mock_plugin):
method test_timeout_kills_subprocess (line 74) | def test_timeout_kills_subprocess(self, runner, mock_plugin):
method test_invalid_json_returns_empty (line 86) | def test_invalid_json_returns_empty(self, runner, mock_plugin):
method test_empty_env_for_security_isolation (line 97) | def test_empty_env_for_security_isolation(self, runner, mock_plugin):
class TestDispatchMessage (line 111) | class TestDispatchMessage:
method test_calls_all_enabled_plugins (line 114) | def test_calls_all_enabled_plugins(self, runner):
method test_collects_all_actions (line 130) | def test_collects_all_actions(self, runner):
class TestDispatchActions (line 150) | class TestDispatchActions:
method test_publish_action (line 153) | def test_publish_action(self, runner):
method test_log_action (line 161) | def test_log_action(self, runner):
method test_unknown_action_type (line 167) | def test_unknown_action_type(self, runner):
class TestPluginTimeout (line 174) | class TestPluginTimeout:
method test_timeout_is_5_seconds (line 177) | def test_timeout_is_5_seconds(self):
FILE: tests/test_plugins_api.py
class TestPluginsAPI (line 12) | class TestPluginsAPI:
method seed_plugin (line 16) | def seed_plugin(self, app):
method test_list_plugins (line 32) | def test_list_plugins(self, auth_client):
method test_enable_plugin (line 42) | def test_enable_plugin(self, auth_client, app):
method test_disable_plugin (line 56) | def test_disable_plugin(self, auth_client, app):
method test_enable_nonexistent_returns_404 (line 72) | def test_enable_nonexistent_returns_404(self, auth_client):
method test_disable_nonexistent_returns_404 (line 79) | def test_disable_nonexistent_returns_404(self, auth_client):
method test_partials_plugins_returns_html (line 86) | def test_partials_plugins_returns_html(self, auth_client):
class TestJsonFormatterPlugin (line 96) | class TestJsonFormatterPlugin:
method test_formats_json_payload (line 99) | def test_formats_json_payload(self):
method test_non_json_payload_returns_empty_actions (line 119) | def test_non_json_payload_returns_empty_actions(self):
method test_non_message_event_returns_empty_actions (line 135) | def test_non_message_event_returns_empty_actions(self):
class TestTopicLoggerPlugin (line 152) | class TestTopicLoggerPlugin:
method test_logs_message (line 155) | def test_logs_message(self):
method test_logs_to_stderr (line 174) | def test_logs_to_stderr(self):
FILE: tests/test_routes.py
function test_index_redirects_unauthenticated (line 1) | def test_index_redirects_unauthenticated(client):
function test_index_returns_200_authenticated (line 8) | def test_index_returns_200_authenticated(client, app):
function test_stats_returns_json (line 16) | def test_stats_returns_json(client):
function test_version_returns_json (line 26) | def test_version_returns_json(client):
function test_api_messages_returns_json (line 33) | def test_api_messages_returns_json(client):
function test_api_topics_returns_json (line 40) | def test_api_topics_returns_json(client):
function test_database_stats (line 47) | def test_database_stats(client):
FILE: tests/test_rules_api.py
function _create_rule (line 9) | def _create_rule(auth_client, **overrides):
function test_list_rules_empty (line 25) | def test_list_rules_empty(auth_client):
function test_list_rules_with_data (line 34) | def test_list_rules_with_data(auth_client):
function test_create_rule (line 48) | def test_create_rule(auth_client):
function test_create_rule_missing_name (line 63) | def test_create_rule_missing_name(auth_client):
function test_create_rule_missing_action (line 75) | def test_create_rule_missing_action(auth_client):
function test_create_rule_missing_trigger_topic (line 87) | def test_create_rule_missing_trigger_topic(auth_client):
function test_get_rule (line 102) | def test_get_rule(auth_client):
function test_get_rule_not_found (line 111) | def test_get_rule_not_found(auth_client):
function test_update_rule (line 123) | def test_update_rule(auth_client):
function test_update_rule_not_found (line 140) | def test_update_rule_not_found(auth_client):
function test_delete_rule (line 150) | def test_delete_rule(auth_client):
function test_delete_rule_not_found (line 164) | def test_delete_rule_not_found(auth_client):
function test_enable_disable (line 174) | def test_enable_disable(auth_client):
function test_enable_not_found (line 190) | def test_enable_not_found(auth_client):
function test_dry_run_match (line 200) | def test_dry_run_match(auth_client):
function test_dry_run_no_match_condition (line 218) | def test_dry_run_no_match_condition(auth_client):
function test_dry_run_no_match_topic (line 235) | def test_dry_run_no_match_topic(auth_client):
function test_dry_run_dict_payload (line 250) | def test_dry_run_dict_payload(auth_client):
function test_dry_run_missing_topic (line 263) | def test_dry_run_missing_topic(auth_client):
function test_unauthenticated_access (line 278) | def test_unauthenticated_access(client):
FILE: tests/test_rules_engine.py
function _create_rule (line 17) | def _create_rule(app, **overrides):
function _send_message (line 38) | def _send_message(topic, payload_dict):
function engine (line 51) | def engine(app):
function fire_log (line 63) | def fire_log():
class TestLoopPrevention (line 79) | class TestLoopPrevention:
method test_loop_prevention_skips_automation_messages (line 82) | def test_loop_prevention_skips_automation_messages(self, app, engine, ...
class TestTopicMatching (line 95) | class TestTopicMatching:
method test_wildcard_topic_matching (line 98) | def test_wildcard_topic_matching(self, app, engine, fire_log):
method test_non_matching_topic (line 109) | def test_non_matching_topic(self, app, engine, fire_log):
class TestConditionEvaluation (line 119) | class TestConditionEvaluation:
method test_matching_condition_fires (line 122) | def test_matching_condition_fires(self, app, engine, fire_log):
method test_non_matching_condition_does_not_fire (line 136) | def test_non_matching_condition_does_not_fire(self, app, engine, fire_...
class TestDisabledRule (line 150) | class TestDisabledRule:
method test_disabled_rule_not_in_cache (line 153) | def test_disabled_rule_not_in_cache(self, app, engine, fire_log):
class TestPerRuleRateLimit (line 163) | class TestPerRuleRateLimit:
method test_rate_limit_blocks_excess (line 166) | def test_rate_limit_blocks_excess(self, app, engine, fire_log):
class TestGlobalCircuitBreaker (line 182) | class TestGlobalCircuitBreaker:
method test_global_limit_blocks_excess (line 185) | def test_global_limit_blocks_excess(self, app, engine, fire_log):
class TestPublishActionSourceMarker (line 206) | class TestPublishActionSourceMarker:
method test_publish_injects_source_marker (line 209) | def test_publish_injects_source_marker(self, app):
class TestLogActionCreatesAlert (line 226) | class TestLogActionCreatesAlert:
method test_log_creates_alert_history (line 229) | def test_log_creates_alert_history(self, app):
class TestRuleFiredSignal (line 246) | class TestRuleFiredSignal:
method test_signal_emitted_on_fire (line 249) | def test_signal_emitted_on_fire(self, app, engine, fire_log):
class TestHotReload (line 272) | class TestHotReload:
method test_hot_reload_on_rule_changed (line 275) | def test_hot_reload_on_rule_changed(self, app, engine):
class TestCronScheduleSync (line 298) | class TestCronScheduleSync:
method test_cron_schedule_sync (line 301) | def test_cron_schedule_sync(self, app, engine):
method test_removed_rule_job_cleaned_up (line 316) | def test_removed_rule_job_cleaned_up(self, app, engine):
class TestFireScheduledRule (line 331) | class TestFireScheduledRule:
method test_fire_scheduled_rule (line 334) | def test_fire_scheduled_rule(self, app, engine):
method test_fire_scheduled_rule_missing_id (line 354) | def test_fire_scheduled_rule_missing_id(self, app, engine):
class TestAppCreatesRuleEngine (line 362) | class TestAppCreatesRuleEngine:
method test_rules_blueprint_registered (line 365) | def test_rules_blueprint_registered(self, app):
FILE: tests/test_rules_evaluator.py
function test_simple_gt_true (line 6) | def test_simple_gt_true():
function test_simple_gt_false (line 11) | def test_simple_gt_false():
function test_nested_path_eq (line 16) | def test_nested_path_eq():
function test_compound_all_true (line 23) | def test_compound_all_true():
function test_compound_all_false (line 32) | def test_compound_all_false():
function test_compound_any_true (line 41) | def test_compound_any_true():
function test_compound_any_false (line 50) | def test_compound_any_false():
function test_contains (line 59) | def test_contains():
function test_not_contains (line 64) | def test_not_contains():
function test_regex_match (line 69) | def test_regex_match():
function test_regex_no_match (line 74) | def test_regex_no_match():
function test_exists_true (line 79) | def test_exists_true():
function test_exists_false (line 84) | def test_exists_false():
function test_not_exists_true (line 89) | def test_not_exists_true():
function test_not_exists_false (line 94) | def test_not_exists_false():
function test_non_json_payload_returns_false (line 99) | def test_non_json_payload_returns_false():
function test_numeric_coercion (line 104) | def test_numeric_coercion():
function test_numeric_coercion_lt (line 109) | def test_numeric_coercion_lt():
function test_empty_condition_always_true (line 114) | def test_empty_condition_always_true():
function test_none_condition_always_true (line 119) | def test_none_condition_always_true():
function test_unknown_operator_raises (line 124) | def test_unknown_operator_raises():
function test_eq_operator (line 130) | def test_eq_operator():
function test_ne_operator (line 136) | def test_ne_operator():
function test_gte_operator (line 142) | def test_gte_operator():
function test_lte_operator (line 148) | def test_lte_operator():
function test_missing_path_returns_false (line 154) | def test_missing_path_returns_false():
FILE: tests/test_rules_models.py
function test_rule_model_columns (line 6) | def test_rule_model_columns(app):
function test_rule_tablename (line 21) | def test_rule_tablename(app):
function test_rule_to_dict (line 28) | def test_rule_to_dict(app):
function test_alert_history_model_columns (line 57) | def test_alert_history_model_columns(app):
function test_alert_history_tablename (line 67) | def test_alert_history_tablename(app):
function test_alert_history_to_dict (line 74) | def test_alert_history_to_dict(app):
FILE: tests/test_ux_features.py
class TestTopicFavorites (line 5) | class TestTopicFavorites:
method test_bookmark_creates_favorite (line 8) | def test_bookmark_creates_favorite(self, auth_client, app):
method test_bookmark_toggle_removes_favorite (line 16) | def test_bookmark_toggle_removes_favorite(self, auth_client, app):
method test_get_favorites_returns_bookmarked_topics (line 27) | def test_get_favorites_returns_bookmarked_topics(self, auth_client, app):
method test_topics_includes_is_favorite (line 43) | def test_topics_includes_is_favorite(self, auth_client, app):
method test_unauthenticated_bookmark_rejected (line 57) | def test_unauthenticated_bookmark_rejected(self, client):
FILE: tests/test_webhook.py
class TestSSRFValidator (line 14) | class TestSSRFValidator:
method test_ssrf_blocks_private_10 (line 17) | def test_ssrf_blocks_private_10(self):
method test_ssrf_blocks_private_172 (line 23) | def test_ssrf_blocks_private_172(self):
method test_ssrf_blocks_private_192 (line 28) | def test_ssrf_blocks_private_192(self):
method test_ssrf_blocks_localhost (line 33) | def test_ssrf_blocks_localhost(self):
method test_ssrf_blocks_link_local (line 38) | def test_ssrf_blocks_link_local(self):
method test_ssrf_allows_public (line 46) | def test_ssrf_allows_public(self, mock_dns):
method test_ssrf_blocks_ipv6_localhost (line 51) | def test_ssrf_blocks_ipv6_localhost(self):
class TestWebhookDelivery (line 61) | class TestWebhookDelivery:
method test_webhook_success (line 65) | def test_webhook_success(self, mock_post, app):
method test_webhook_retry_on_5xx (line 93) | def test_webhook_retry_on_5xx(self, mock_post, app):
method test_webhook_no_retry_on_4xx (line 123) | def test_webhook_no_retry_on_4xx(self, mock_post, app):
method test_webhook_payload_template (line 150) | def test_webhook_payload_template(self, app):
method test_webhook_runs_in_thread (line 166) | def test_webhook_runs_in_thread(self, mock_post, app):
class TestSSRFEndpoints (line 193) | class TestSSRFEndpoints:
method test_create_rule_ssrf_blocked (line 196) | def test_create_rule_ssrf_blocked(self, auth_client, app):
method test_create_rule_ssrf_allowed (line 214) | def test_create_rule_ssrf_allowed(self, mock_dns, auth_client, app):
method test_update_rule_ssrf_blocked (line 227) | def test_update_rule_ssrf_blocked(self, auth_client, app):
Condensed preview — 96 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (477K chars).
[
{
"path": ".env_example",
"chars": 546,
"preview": "DEBUG=False\nHOST=0.0.0.0\nPORT=5000\nMQTT_BROKER=your_mqtt_broker\nMQTT_PORT=1883\nMQTT_USERNAME=your_username\nMQTT_PASSWORD"
},
{
"path": ".github/workflows/docker-publish.yml",
"chars": 1153,
"preview": "name: Docker\n\non:\n push:\n tags: [ 'v*.*.*' ]\n\nenv:\n DOCKER_HUB_REPO: terdia07/mqttui\n\njobs:\n push_to_registry:\n "
},
{
"path": ".gitignore",
"chars": 317,
"preview": "# Environment variables\n.env\n\n# Python\n__pycache__\n*.pyc\n*.pyo\n\n# Database files \ndata/\n*.db\n*.sqlite\n*.sqlite3\n\n# Logs"
},
{
"path": "CHANGELOG.md",
"chars": 11573,
"preview": "# Changelog\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changel"
},
{
"path": "Dockerfile",
"chars": 216,
"preview": "FROM python:3.11-slim\n\nWORKDIR /app\n\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\n\nCOPY . "
},
{
"path": "Dockerfile.multiarch",
"chars": 820,
"preview": "# Multi-architecture Dockerfile supporting both AMD64 and ARM64 (fixes issue #3)\nFROM --platform=$TARGETPLATFORM python:"
},
{
"path": "LICENSE.md",
"chars": 1073,
"preview": "MIT License\n\nCopyright (c) [2024] [Terry Osayawe]\n\nPermission is hereby granted, free of charge, to any person obtaining"
},
{
"path": "README.md",
"chars": 8796,
"preview": "# MQTTUI — Intelligent MQTT Web Interface\n\nAn open-source web application that monitors, visualizes, and **automates** y"
},
{
"path": "app.py",
"chars": 19792,
"preview": "__version__ = \"1.3.0\"\n\nfrom flask import Flask, render_template, request, jsonify, send_from_directory\nfrom flask_socket"
},
{
"path": "database.py",
"chars": 16968,
"preview": "\"\"\"\nDatabase module for MQTT message persistence\n\"\"\"\nimport sqlite3\nimport threading\nimport logging\nimport re\nimport jso"
},
{
"path": "debug_bar.py",
"chars": 2074,
"preview": "import time\nimport psutil\nfrom flask import request\nfrom threading import Lock\nimport logging\n\nclass DebugBarPanel:\n "
},
{
"path": "demo.sh",
"chars": 16454,
"preview": "#!/bin/bash\n# =============================================================================\n# MQTTUI v2.1 Demo Script\n# "
},
{
"path": "docker-compose.yml",
"chars": 1124,
"preview": "services:\n mosquitto:\n image: eclipse-mosquitto:latest\n container_name: mosquitto\n ports:\n - \"1883:1883\"\n"
},
{
"path": "entrypoint.sh",
"chars": 1055,
"preview": "#!/bin/sh\n\n# Default to port 5000 if PORT is not set\nPORT=\"${PORT:-5000}\"\n\n# Set default LOG_LEVEL if not provided\nLOG_L"
},
{
"path": "mosquitto.conf",
"chars": 107,
"preview": "listener 1883\nallow_anonymous true\npersistence false\n\n# WebSocket support\nlistener 9001\nprotocol websockets"
},
{
"path": "mqttui/__init__.py",
"chars": 26,
"preview": "__version__ = \"2.0.0-dev\"\n"
},
{
"path": "mqttui/analytics.py",
"chars": 5160,
"preview": "\"\"\"Per-topic analytics engine with rate counters and numeric payload histograms.\n\nProvides real-time message rate tracki"
},
{
"path": "mqttui/app.py",
"chars": 8055,
"preview": "import os\n\nimport structlog\nfrom flask import Flask\n\nfrom mqttui.extensions import socketio, sa, login_manager\nfrom mqtt"
},
{
"path": "mqttui/auth.py",
"chars": 2242,
"preview": "import os\nimport logging\n\nfrom flask import Blueprint, render_template, redirect, url_for, request, flash\nfrom flask_log"
},
{
"path": "mqttui/broker_manager.py",
"chars": 10857,
"preview": "\"\"\"Multi-broker connection manager.\n\nManages multiple paho-mqtt client connections, each tied to a Broker model.\nReplace"
},
{
"path": "mqttui/database.py",
"chars": 15956,
"preview": "\"\"\"\nDatabase module for MQTT message persistence\n\"\"\"\nimport sqlite3\nimport threading\nimport logging\nimport re\nimport jso"
},
{
"path": "mqttui/events.py",
"chars": 830,
"preview": "from blinker import Namespace\n\nmqttui_signals = Namespace()\n\n# Fired when an MQTT message is received from the broker\n# "
},
{
"path": "mqttui/extensions.py",
"chars": 449,
"preview": "from flask_socketio import SocketIO\nfrom flask_sqlalchemy import SQLAlchemy\nfrom flask_login import LoginManager\nfrom fl"
},
{
"path": "mqttui/helpers.py",
"chars": 501,
"preview": "\"\"\"API response helpers for consistent JSON envelope format.\"\"\"\n\nfrom flask import jsonify\n\n\ndef api_success(data=None, "
},
{
"path": "mqttui/logging_config.py",
"chars": 1667,
"preview": "\"\"\"Structured logging configuration using structlog.\n\nProvides JSON output in production and colored console output in d"
},
{
"path": "mqttui/models.py",
"chars": 3090,
"preview": "from mqttui.extensions import sa\nfrom flask_login import UserMixin\nfrom werkzeug.security import generate_password_hash,"
},
{
"path": "mqttui/mqtt_client.py",
"chars": 1087,
"preview": "\"\"\"MQTT client compatibility layer.\n\nDelegates to BrokerManager for multi-broker support while keeping the\nsame publish("
},
{
"path": "mqttui/plugins/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "mqttui/plugins/examples/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "mqttui/plugins/examples/json_formatter.py",
"chars": 1178,
"preview": "#!/usr/bin/env python3\n\"\"\"JSON formatter example plugin for mqttui.\n\nReads a JSON event from stdin, pretty-prints JSON p"
},
{
"path": "mqttui/plugins/examples/topic_logger.py",
"chars": 1274,
"preview": "#!/usr/bin/env python3\n\"\"\"Topic logger example plugin for mqttui.\n\nReads a JSON event from stdin, logs the topic and pay"
},
{
"path": "mqttui/plugins/hookspec.py",
"chars": 1239,
"preview": "\"\"\"Plugin hook specifications for mqttui.\"\"\"\nfrom __future__ import annotations\n\nimport pluggy\n\nPROJECT_NAME = \"mqttui\"\n"
},
{
"path": "mqttui/plugins/models.py",
"chars": 1027,
"preview": "\"\"\"SQLAlchemy model for plugin configuration persistence.\"\"\"\nfrom mqttui.extensions import sa\n\n\nclass PluginConfig(sa.Mo"
},
{
"path": "mqttui/plugins/registry.py",
"chars": 3810,
"preview": "\"\"\"Plugin registry: discovers, loads, and manages mqttui plugins.\"\"\"\nfrom __future__ import annotations\n\nimport importli"
},
{
"path": "mqttui/plugins/runner.py",
"chars": 5955,
"preview": "\"\"\"Plugin runner: subprocess isolation layer for mqttui plugins.\n\nRuns plugin code in separate processes communicating v"
},
{
"path": "mqttui/routes/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "mqttui/routes/alerts.py",
"chars": 1597,
"preview": "\"\"\"Alert history REST API blueprint.\n\nProvides paginated, filterable listing of AlertHistory records.\nAll endpoints requ"
},
{
"path": "mqttui/routes/analytics.py",
"chars": 1626,
"preview": "\"\"\"Analytics REST API blueprint.\n\nProvides per-topic message rate and numeric payload histogram data.\nAll endpoints retu"
},
{
"path": "mqttui/routes/api.py",
"chars": 6689,
"preview": "# TODO: Remove legacy /api/ routes in Phase 5 after frontend migrates to /api/v1/\n# All new API development should use m"
},
{
"path": "mqttui/routes/api_v1.py",
"chars": 18233,
"preview": "\"\"\"Versioned API v1 endpoints for MQTTUI.\n\nAll endpoints return JSON envelope: {\"status\": \"success\"|\"error\", \"data\": ..."
},
{
"path": "mqttui/routes/brokers.py",
"chars": 5310,
"preview": "\"\"\"Broker management REST API endpoints.\"\"\"\nfrom flask import Blueprint, request\nfrom flask_login import login_required\n"
},
{
"path": "mqttui/routes/debug.py",
"chars": 1511,
"preview": "from flask import Blueprint, request, jsonify\nimport logging\n\nfrom mqttui.extensions import limiter\n\nbp = Blueprint('deb"
},
{
"path": "mqttui/routes/main.py",
"chars": 5601,
"preview": "from flask import Blueprint, render_template, request, jsonify, send_from_directory\nfrom flask_login import login_requir"
},
{
"path": "mqttui/routes/metrics.py",
"chars": 2252,
"preview": "\"\"\"Prometheus-compatible /metrics endpoint and metric definitions.\n\nExposes counters, gauges, and histograms for MQTT, r"
},
{
"path": "mqttui/routes/plugins.py",
"chars": 1945,
"preview": "\"\"\"Plugin management REST API and partials blueprint.\n\nProvides endpoints to list, enable, and disable plugins,\nplus an "
},
{
"path": "mqttui/routes/rules.py",
"chars": 10974,
"preview": "\"\"\"Rules CRUD REST API blueprint.\n\nAll endpoints return JSON envelope: {\"status\": \"success\"|\"error\", \"data\": ..., \"error"
},
{
"path": "mqttui/rules/__init__.py",
"chars": 43,
"preview": "from mqttui.rules.engine import RuleEngine\n"
},
{
"path": "mqttui/rules/actions.py",
"chars": 15015,
"preview": "\"\"\"Action executor for the rules engine.\n\nHandles publish, log, and webhook action types.\nWebhook delivery uses httpx in"
},
{
"path": "mqttui/rules/cooldown.py",
"chars": 2962,
"preview": "\"\"\"Per-rule alert cooldown tracker for deduplication.\n\nPrevents alert storms from sustained conditions by enforcing a mi"
},
{
"path": "mqttui/rules/engine.py",
"chars": 15044,
"preview": "\"\"\"RuleEngine -- runtime heart of the rules automation system.\n\nSubscribes to mqtt_message_received events, evaluates ma"
},
{
"path": "mqttui/rules/evaluator.py",
"chars": 3693,
"preview": "\"\"\"Pure-function condition evaluator for the rules engine.\n\nEvaluates structured JSON conditions against message payload"
},
{
"path": "mqttui/rules/models.py",
"chars": 3485,
"preview": "from mqttui.extensions import sa\nfrom datetime import datetime\nimport json\n\n\nclass Rule(sa.Model):\n __tablename__ = '"
},
{
"path": "mqttui/rules/ssrf.py",
"chars": 2372,
"preview": "\"\"\"SSRF URL validation for webhook destinations.\n\nBlocks webhook URLs that resolve to private, loopback, or link-local a"
},
{
"path": "mqttui/socketio_batch.py",
"chars": 1997,
"preview": "import threading\nfrom flask_socketio import SocketIO\n\n\nclass BatchEmitter:\n \"\"\"Collects MQTT messages and emits them "
},
{
"path": "mqttui/state.py",
"chars": 185,
"preview": "\"\"\"\nModule-level shared state for in-memory MQTT data.\nImported by routes and MQTT handlers.\n\"\"\"\n\nmessages = []\ntopics ="
},
{
"path": "pytest.ini",
"chars": 125,
"preview": "[pytest]\ntestpaths = tests\npython_files = test_*.py\npython_classes = Test*\npython_functions = test_*\naddopts = -v --tb=s"
},
{
"path": "requirements-dev.txt",
"chars": 32,
"preview": "pytest==8.3.4\npytest-cov==6.0.0\n"
},
{
"path": "requirements.txt",
"chars": 393,
"preview": "Flask==3.1.0\nFlask-SocketIO==5.5.1\npaho-mqtt==2.1.0\nWerkzeug==3.1.3\npsutil==6.1.1\npython-dotenv==1.0.1\ngunicorn==23.0.0\n"
},
{
"path": "static/css/input.css",
"chars": 93,
"preview": "@import \"tailwindcss\";\n\n@theme {\n --color-gray-750: #2d3748;\n --color-gray-850: #1a202c;\n}\n"
},
{
"path": "static/css/output.css",
"chars": 433,
"preview": "/* Tailwind CSS v4 compiled output.\n * Build with: tailwindcss -i static/css/input.css -o static/css/output.css --minify"
},
{
"path": "static/script.js",
"chars": 41847,
"preview": "const socket = io();\nlet messageChart;\nlet network;\nlet nodes;\nlet edges;\nlet topicFilter = 'all';\nlet messageCount = 0;"
},
{
"path": "static/styles.css",
"chars": 3194,
"preview": "/* Custom scrollbar for Webkit browsers */\n#message-list::-webkit-scrollbar {\n width: 8px;\n}\n\n#message-list::-webkit-"
},
{
"path": "templates/base.html",
"chars": 1727,
"preview": "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width"
},
{
"path": "templates/index.html",
"chars": 17312,
"preview": "{% extends \"base.html\" %}\n\n{% block head %}\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.2/di"
},
{
"path": "templates/login.html",
"chars": 2264,
"preview": "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width"
},
{
"path": "templates/partials/alert_row.html",
"chars": 1861,
"preview": "<div class=\"bg-gray-700 rounded p-3 border-l-4\n {% if alert.severity == 'critical' %}border-red-500\n {% elif alert.sev"
},
{
"path": "templates/partials/alerts_list.html",
"chars": 2352,
"preview": "<div id=\"alerts-list\">\n <!-- Filters -->\n <div class=\"flex flex-wrap gap-3 mb-4 items-end\">\n <div>\n <label cla"
},
{
"path": "templates/partials/analytics.html",
"chars": 4452,
"preview": "<div x-data=\"analyticsWidget()\" x-init=\"init()\">\n <!-- Top Topics by Message Rate -->\n <div class=\"bg-gray-800 rou"
},
{
"path": "templates/partials/brokers.html",
"chars": 7038,
"preview": "<div class=\"space-y-4\" x-data=\"brokersComponent()\" x-init=\"loadBrokers()\">\n <div class=\"flex justify-between items-ce"
},
{
"path": "templates/partials/dry_run_result.html",
"chars": 2189,
"preview": "<div class=\"bg-gray-800 border border-gray-600 rounded p-4 mb-4\" x-data=\"dryRunComponent({{ rule_id }})\">\n <h4 class=\"t"
},
{
"path": "templates/partials/plugins.html",
"chars": 2288,
"preview": "<div>\n {% if plugins %}\n {% for plugin in plugins %}\n <div class=\"bg-gray-800 rounded-lg p-4 mb-3\">\n <di"
},
{
"path": "templates/partials/rule_form.html",
"chars": 7724,
"preview": "<div class=\"bg-gray-800 border border-gray-600 rounded p-4 mb-4\"\n x-data=\"ruleFormComponent(window.__ruleEditData ||"
},
{
"path": "templates/partials/rule_row.html",
"chars": 2577,
"preview": "<div id=\"rule-{{ rule.id }}\" class=\"bg-gray-700 rounded p-3 border-l-4 {{ 'border-green-500' if rule.enabled else 'borde"
},
{
"path": "templates/partials/rules_list.html",
"chars": 685,
"preview": "<div id=\"rules-list\">\n <div class=\"flex justify-between items-center mb-4\">\n <h3 class=\"text-lg font-semibold\">Autom"
},
{
"path": "tests/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tests/conftest.py",
"chars": 2551,
"preview": "import pytest\nimport tempfile\nimport os\nfrom unittest.mock import patch, MagicMock\n\n\n@pytest.fixture\ndef app(tmp_path):\n"
},
{
"path": "tests/test_alerts_api.py",
"chars": 7219,
"preview": "\"\"\"Tests for the Alerts REST API and dry-run action_preview.\"\"\"\nimport json\nfrom datetime import datetime\n\nimport pytest"
},
{
"path": "tests/test_analytics.py",
"chars": 7039,
"preview": "\"\"\"Tests for the per-topic analytics engine.\"\"\"\nimport time\nimport json\nimport pytest\n\nfrom mqttui.analytics import Topi"
},
{
"path": "tests/test_api_v1.py",
"chars": 3750,
"preview": "\"\"\"Tests for API v1 endpoints.\"\"\"\nimport json\n\n\ndef test_json_envelope_success(auth_client):\n \"\"\"API responses follow"
},
{
"path": "tests/test_app_factory.py",
"chars": 991,
"preview": "def test_create_app(app):\n assert app is not None\n assert app.testing is True\n\n\ndef test_create_app_has_blueprints"
},
{
"path": "tests/test_auth.py",
"chars": 3714,
"preview": "\"\"\"Tests for authentication flow.\"\"\"\n\n\ndef test_login_page_loads(client):\n \"\"\"GET /login returns 200 with login form."
},
{
"path": "tests/test_cooldown.py",
"chars": 5090,
"preview": "\"\"\"Tests for CooldownTracker and webhook cooldown integration.\"\"\"\nimport time\nfrom unittest.mock import patch, MagicMock"
},
{
"path": "tests/test_database.py",
"chars": 1348,
"preview": "from datetime import datetime\n\n\ndef test_wal_mode(test_db):\n conn = test_db.get_connection()\n mode = conn.execute("
},
{
"path": "tests/test_frontend.py",
"chars": 3072,
"preview": "\"\"\"Tests for Phase 5 frontend: partials, Alpine.js, htmx, batch emitter.\"\"\"\nimport pytest\nfrom unittest.mock import Magi"
},
{
"path": "tests/test_observability.py",
"chars": 3441,
"preview": "\"\"\"Tests for structured logging (structlog) and Prometheus metrics endpoint.\"\"\"\nimport pytest\nfrom unittest.mock import "
},
{
"path": "tests/test_plugin_registry.py",
"chars": 8222,
"preview": "\"\"\"Tests for plugin hookspec, model, and registry.\"\"\"\nimport pytest\nfrom unittest.mock import patch, MagicMock\n\n\n@pytest"
},
{
"path": "tests/test_plugin_runner.py",
"chars": 7512,
"preview": "\"\"\"Tests for PluginRunner subprocess isolation and JSON protocol.\"\"\"\nimport json\nimport subprocess\nfrom unittest.mock im"
},
{
"path": "tests/test_plugins_api.py",
"chars": 6798,
"preview": "\"\"\"Tests for plugin management REST API and example plugins.\"\"\"\nimport json\nimport subprocess\nimport sys\n\nimport pytest\n"
},
{
"path": "tests/test_routes.py",
"chars": 1524,
"preview": "def test_index_redirects_unauthenticated(client):\n \"\"\"Unauthenticated access to / should redirect to /login.\"\"\"\n r"
},
{
"path": "tests/test_rules_api.py",
"chars": 9648,
"preview": "\"\"\"Integration tests for rules REST API endpoints.\"\"\"\nimport json\n\n\n# --------------------------------------------------"
},
{
"path": "tests/test_rules_engine.py",
"chars": 12160,
"preview": "\"\"\"Tests for the RuleEngine class -- cache, matcher, rate limiter, loop prevention.\"\"\"\nimport json\nimport time\nfrom coll"
},
{
"path": "tests/test_rules_evaluator.py",
"chars": 5481,
"preview": "\"\"\"Tests for the condition evaluator pure function.\"\"\"\nimport pytest\nfrom mqttui.rules.evaluator import evaluate, Condit"
},
{
"path": "tests/test_rules_models.py",
"chars": 3223,
"preview": "\"\"\"Tests for Rule and AlertHistory models.\"\"\"\nimport json\nfrom datetime import datetime\n\n\ndef test_rule_model_columns(ap"
},
{
"path": "tests/test_ux_features.py",
"chars": 2648,
"preview": "\"\"\"Tests for UX features: topic favorites/bookmarks and retained message indicator.\"\"\"\nimport json\n\n\nclass TestTopicFavo"
},
{
"path": "tests/test_webhook.py",
"chars": 9765,
"preview": "\"\"\"Tests for webhook delivery system: SSRF validation, webhook execution, retry logic.\"\"\"\n\nimport json\nimport pytest\nimp"
},
{
"path": "wsgi.py",
"chars": 266,
"preview": "from dotenv import load_dotenv\nload_dotenv()\n\nfrom mqttui.app import create_app\nfrom mqttui.extensions import socketio\n\n"
}
]
About this extraction
This page contains the full source code of the terdia/mqttui GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 96 files (440.5 KB), approximately 102.2k tokens, and a symbol index with 587 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.