[
  {
    "path": ".env_example",
    "content": "DEBUG=False\nHOST=0.0.0.0\nPORT=5000\nMQTT_BROKER=your_mqtt_broker\nMQTT_PORT=1883\nMQTT_USERNAME=your_username\nMQTT_PASSWORD=your_password\nMQTT_KEEPALIVE=60\nMQTT_VERSION=3.1.1\nMQTT_TOPICS=#\nSECRET_KEY=your-secret-key\nLOG_LEVEL=INFO\nDB_ENABLED=True\nDB_PATH=mqtt_messages.db\nDB_MAX_MESSAGES=10000\nDB_CLEANUP_DAYS=30\n\n# TLS/SSL (for MQTTS connections)\nMQTT_TLS=false\nMQTT_TLS_CA_CERTS=\nMQTT_TLS_CERTFILE=\nMQTT_TLS_KEYFILE=\nMQTT_TLS_INSECURE=false\n\n# v2.0 Authentication\nMQTTUI_ADMIN_USER=admin\nMQTTUI_ADMIN_PASSWORD=changeme\nMQTTUI_RATE_LIMIT=30/minute\n"
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "content": "name: Docker\n\non:\n  push:\n    tags: [ 'v*.*.*' ]\n\nenv:\n  DOCKER_HUB_REPO: terdia07/mqttui\n\njobs:\n  push_to_registry:\n    name: Push Docker image to Docker Hub\n    runs-on: ubuntu-latest\n    steps:\n      - name: Check out the repo\n        uses: actions/checkout@v4\n      \n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n      \n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n      \n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n      \n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ env.DOCKER_HUB_REPO }}\n      \n      - name: Build and push Docker image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile.multiarch\n          platforms: linux/amd64,linux/arm64,linux/arm/v7\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}"
  },
  {
    "path": ".gitignore",
    "content": "# 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\n*.log\n*.log.*\n\n# macOS\n.DS_Store\n\n# Static assets (if needed)\nstatic/mqttui-logo-svg.svg\nstatic/mqttui-logo.png\n\n# SQLite WAL/SHM temp files\n*.db-shm\n*.db-wal\n\n# GSD planning artifacts\n.planning/\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [2.1.0] - 2026-03-24\n\n### Added\n- **Multi-broker support**: Connect to multiple MQTT brokers simultaneously from the Brokers tab\n- **Broker management API**: Full CRUD at `/api/v1/brokers/` with connect/disconnect endpoints\n- **Broker management UI**: Add, edit, remove, connect, disconnect brokers with live status indicators\n- **Broker filtering**: Filter messages by source broker in Advanced Search dropdown\n- **Broker name badges**: Each message shows which broker it came from\n- **Demo script updated**: `./demo.sh` populates both brokers with different data profiles (home vs warehouse)\n- **Backward compatible**: Existing env var config auto-seeds a \"Default\" broker on first run\n\n### Fixed\n- Debug bar performance tab now uses modern Performance API (deprecated `window.performance.timing` replaced)\n\n## [2.0.0] - 2026-03-24\n\n### Added\n\n#### Automation Rules Engine\n- **IF/THEN automation rules**: Create rules that evaluate conditions against incoming MQTT messages and fire actions automatically\n- **11-operator condition evaluator**: eq, ne, gt, lt, gte, lte, contains, not_contains, regex, exists, not_exists\n- **Compound conditions**: `all` (AND) and `any` (OR) for complex logic\n- **JSON path support**: Dot-notation paths like `sensors.outdoor.temp` for nested payload fields\n- **Action types**: Publish to topic, webhook HTTP POST, log to alert history\n- **Loop detection**: `__source` marker prevents feedback loops, per-rule rate limiting (10/min default), global circuit breaker (100/min)\n- **Time-based rules**: APScheduler with cron expressions (e.g., \"every 5 minutes\")\n- **Hot-reload**: Rule changes take effect immediately without app restart\n- **Dry-run testing**: Test rules against sample payloads before activating\n- **Rules REST API**: Full CRUD at `/api/v1/rules/` with enable/disable and dry-run endpoints\n\n#### Alerting & Notifications\n- **Telegram alerts**: First-class action type — enter bot token + chat ID, get alerts in Telegram with Markdown formatting\n- **Slack alerts**: First-class action type — enter incoming webhook URL, get alerts in Slack with mrkdwn formatting\n- **HTTP webhook delivery**: Generic webhook with customizable payload templates using `{{topic}}`, `{{payload}}`, `{{timestamp}}` variables\n- **Retry with exponential backoff**: 3 retries at 1s, 5s, 25s on server errors\n- **SSRF protection**: Blocks webhook URLs pointing to private/reserved addresses (RFC-1918, localhost, link-local)\n- **Alert deduplication**: Configurable cooldown window (5 min default) prevents alert storms\n- **Alert history**: Persistent record of all alerts with delivery status, viewable via API and UI\n\n#### Modern Frontend\n- **Alpine.js component architecture**: Reactive UI components replacing vanilla JS\n- **htmx interactions**: Form submissions, CRUD operations, and pagination without full page reloads\n- **Socket.IO batching**: Server-side 100ms batch window handles 1000+ msg/sec without browser flooding\n- **Rules Editor UI**: Create, edit, delete, enable/disable, and dry-run test rules inline\n- **Alert History panel**: Filterable by rule, severity, and time range with pagination\n- **Tab navigation**: Dashboard / Rules / Alerts / Analytics / Plugins tabs\n- **Favorites toggle**: Filter message list to show only bookmarked topics\n- **Advanced Search**: Filter messages by topic, content, regex, JSON path, and time range\n- **Redesigned message rate chart**: Gradient area chart with 30-second rolling window and live msg/s counter\n- **Demo script**: `./demo.sh` populates realistic test data across all features\n\n#### REST API & Authentication\n- **Versioned API**: All endpoints under `/api/v1/` prefix with consistent JSON envelope responses\n- **OpenAPI documentation**: Swagger UI at `/api/v1/docs` with full endpoint documentation\n- **User authentication**: Flask-Login with username/password, session cookies\n- **API tokens**: `X-API-Key` header for programmatic access, token CRUD endpoints\n- **Rate limiting**: Configurable per-IP limit on publish endpoint (30/min default)\n- **CORS support**: Cross-origin API access for external consumers\n- **SECRET_KEY guard**: Application refuses to start with insecure key in production\n\n#### Analytics & Observability\n- **Per-topic analytics**: Message rate counters and numeric payload histograms\n- **Analytics dashboard**: Real-time top-topics-by-rate widget with histogram drill-down\n- **Structured logging**: structlog with JSON output (production) and colored console (development)\n- **Prometheus metrics**: `/metrics` endpoint with message counters, rule firing rates, connection gauges\n- **Topic favorites**: Star/bookmark topics for quick access\n- **Retained message indicator**: Visual \"R\" badge on retained messages\n\n#### Plugin Architecture\n- **Plugin hook specification**: `MQTTUIPlugin` base class with `on_message`, `on_connect`, `on_rule_trigger` hooks via pluggy\n- **Subprocess isolation**: Plugins run in separate processes with empty environment — no access to app, database, or MQTT client\n- **Entry-point discovery**: Standard Python packaging via `importlib.metadata` entry_points\n- **Plugin management UI**: View installed plugins, enable/disable via toggle\n- **Bundled examples**: JSON Formatter and Topic Logger plugins included\n\n### Changed\n\n#### Architecture Overhaul\n- **Flask application factory**: Monolithic `app.py` refactored into `mqttui/` package with blueprints\n- **gevent async mode**: Replaced unmaintained eventlet with gevent for WebSocket support\n- **paho-mqtt 2.x**: Upgraded to modern `CallbackAPIVersion.VERSION2` callback API\n- **Flask 3.1.x**: Upgraded from Flask 2.0.1 with compatible Werkzeug\n- **SQLite WAL mode**: Write-ahead logging with `busy_timeout` on all connections for concurrent access\n- **Blinker event bus**: Internal signals (`mqtt_message_received`, `rule_fired`, `alert_triggered`) decouple all components\n- **Python 3.11**: Docker base image upgraded from 3.9\n- **Tailwind CSS v4**: Standalone CLI replacing CDN v2.x (no Node.js dependency)\n\n#### Testing\n- **218+ automated tests**: pytest infrastructure with shared fixtures across all 7 phases\n- **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\n\n### New Environment Variables\n- `MQTTUI_ADMIN_USER`: Admin username (default: admin)\n- `MQTTUI_ADMIN_PASSWORD`: Admin password (required for first run)\n- `MQTTUI_RATE_LIMIT`: Publish rate limit (default: 30/minute)\n- `SECRET_KEY`: Flask secret key (required in production, must not be \"dev\" or \"change-me\")\n\n## [1.3.2] - 2025-08-24\n### Added\n- **Complete UI Redesign**: Collapsible sidebar layout for maximum screen real estate utilization\n- **Advanced Search Panel**: Comprehensive message filtering with regex patterns, JSON path queries, content search, and time-based filters\n- **Message Persistence**: SQLite database storage with automatic cleanup and configurable message limits\n- **Filter Presets**: Save and load frequently used search filter combinations\n- **Network Visualization Enhancements**:\n  - Node pinning functionality (double-click to pin/unpin nodes, red color indicates pinned)\n  - Improved physics engine with overlap prevention\n  - Fullscreen support for Message Flow diagram\n  - Right-click context menu for node management\n- **Collapsible Sidebar**: Hide/show controls panel to maximize Message Flow and Messages viewing area\n- **Enhanced API Endpoints**:\n  - `/api/messages` with advanced filtering support\n  - `/api/topics` with statistics\n  - `/api/filter-presets` for preset management\n\n### Changed\n- **Layout Architecture**: Complete redesign from grid-based to flexbox sidebar layout\n- **Message Flow Area**: Now takes up significantly more screen space (up to 100% width when sidebar hidden)\n- **Message Rate Chart**: Moved to compact sidebar position for better space utilization\n- **Topic Filtering**: Now actually filters displayed messages instead of just clearing the list\n- **Advanced Search**: Collapsible panel, closed by default to reduce visual clutter\n- **Screen Space Optimization**: Removed unnecessary padding, maximized content area utilization\n\n### Fixed\n- **Topic Dropdown Functionality**: Fixed issue where topic selection only cleared messages instead of filtering\n- **Message Stacking**: Resolved overlapping div issue in the main content area\n- **Layout Responsiveness**: Proper flexbox implementation ensures consistent 50/50 split between Message Flow and Messages\n- **Database Thread Safety**: Implemented thread-local connections for concurrent message storage\n\n### Technical Improvements\n- **Database Schema**: Enhanced with indexes for better query performance\n- **Message Filtering**: Support for regex topic patterns, JSON path queries, and content search\n- **Network Physics**: Improved node positioning with `avoidOverlap` and better stabilization\n- **JavaScript Architecture**: Modular functions for filter management and UI interactions\n- **CSS Layout**: Modern flexbox implementation with smooth transitions and animations\n\n## [1.3.1] - 2024-08-24\n### Added\n- LOG_LEVEL environment variable support for controlling application logging verbosity (#9)\n- Topic subscription filtering via MQTT_TOPICS environment variable (#6)\n- Multi-architecture Docker support (AMD64, ARM64, ARMv7) with new Dockerfile.multiarch (#3)\n- GitHub Actions workflow for automated multi-platform Docker builds\n- Enhanced documentation for MQTT broker address format (#7)\n- Proper Flask-SocketIO server startup when running app.py directly (#12)\n\n### Fixed\n- MQTT v5 connection error by adding properties parameter to on_connect callback (#8)\n- Application no longer exits prematurely when run outside Docker (#12)\n- Improved LOG_LEVEL validation in entrypoint.sh with gunicorn support\n\n### Changed\n- Enhanced logging configuration with better formatting and level control\n- Updated README with comprehensive configuration documentation\n- Improved error handling for MQTT v5 connections\n\n## [1.3.0] - 2024-08-27\n### Added\n- Improved logging for MQTT connection attempts and status\n- Client ID specification for MQTT connection\n- Support for different MQTT protocol versions\n\n### Changed\n- Modified app.py to ensure MQTT connection is attempted when run in a container\n- Refactored server startup process for better compatibility with both development and production environments\n\n### Fixed\n- Resolved issues with MQTT connection not being established\n- Fixed problems related to username/password authentication for MQTT brokers\n- Improved error handling for non-UTF-8 encoded MQTT messages\n\n## [1.2.0] - 2024-08-24\n### Added\n  - Debug Bar feature for enhanced developer insights\n  - Real-time websocket connection/disconnect status\n  - MQTT connection status and last message details\n  - Request duration tracking\n  - Toggle functionality to show/hide the Debug Bar\n\n## [1.0.0] - 2024-08-19\n### Added\n- Initial release of MQTT Web Interface\n- Real-time visualization of MQTT topic hierarchy and message flow\n- Ability to publish messages to MQTT topics\n- Display of message statistics (connection count, topic count, message count)\n- Interactive network graph showing topic relationships\n- Docker support for easy deployment\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM python:3.11-slim\n\nWORKDIR /app\n\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\n\nCOPY . .\n\nCOPY entrypoint.sh /entrypoint.sh\nRUN chmod +x /entrypoint.sh\n\nENTRYPOINT [\"/entrypoint.sh\"]\n"
  },
  {
    "path": "Dockerfile.multiarch",
    "content": "# Multi-architecture Dockerfile supporting both AMD64 and ARM64 (fixes issue #3)\nFROM --platform=$TARGETPLATFORM python:3.11-slim\n\n# Set build arguments for multi-platform builds\nARG TARGETPLATFORM\nARG BUILDPLATFORM\nRUN echo \"Building on $BUILDPLATFORM for $TARGETPLATFORM\"\n\nWORKDIR /app\n\n# Install build dependencies for compiling packages on ARM platforms\nRUN apt-get update && apt-get install -y \\\n    build-essential \\\n    gcc \\\n    g++ \\\n    libc6-dev \\\n    libffi-dev \\\n    && rm -rf /var/lib/apt/lists/*\n\nCOPY requirements.txt .\nRUN pip install --no-cache-dir --upgrade pip setuptools wheel && \\\n    pip install --no-cache-dir -r requirements.txt\n\nCOPY . .\n\n# Use an entrypoint script to allow for variable substitution\nCOPY entrypoint.sh /entrypoint.sh\nRUN chmod +x /entrypoint.sh\n\nENTRYPOINT [\"/entrypoint.sh\"]\n"
  },
  {
    "path": "LICENSE.md",
    "content": "MIT License\n\nCopyright (c) [2024] [Terry Osayawe]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "README.md",
    "content": "# MQTTUI — Intelligent MQTT Web Interface\n\nAn 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.\n\n![Message Flow Screenshot](static/screenshot.png)\n\n## What's New in v2.1\n\n- **Multi-Broker Support** — Connect to multiple MQTT brokers simultaneously, manage from the Brokers tab, filter messages by broker\n- **Telegram & Slack Alerts** — First-class action types in the rules editor, no raw webhook URLs needed\n\n## What's New in v2.0\n\n- **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\n- **Alerting** — Telegram, Slack, and HTTP webhook notifications with retry/backoff, SSRF protection, and alert deduplication\n- **Modern UI** — Alpine.js + htmx component architecture with tabbed navigation (Dashboard / Rules / Alerts / Analytics / Plugins / Brokers)\n- **REST API** — Versioned `/api/v1/` endpoints with OpenAPI docs at `/api/v1/docs`\n- **User Authentication** — Login with username/password, API tokens for programmatic access\n- **Analytics** — Per-topic message rates, payload histograms, Prometheus `/metrics` endpoint\n- **Plugin Architecture** — Extend with custom handlers running in subprocess isolation\n- **218+ Automated Tests** — Full pytest suite across all features\n\n## Features\n\n### Core\n- **Multi-broker** — connect to multiple MQTT brokers simultaneously, filter messages by broker\n- Real-time MQTT message streaming via Socket.IO (handles 1000+ msg/sec with server-side batching)\n- Interactive topic hierarchy network graph (Vis.js)\n- Publish messages to any topic\n- SQLite message persistence with advanced search (regex, JSON path, time range)\n- Filter presets — save and load search combinations\n- MQTT v3.1.1 and v5 protocol support\n- Docker deployment with multi-arch support (AMD64, ARM64, ARMv7)\n\n### Automation\n- **Rules Engine**: 11 condition operators (eq, gt, contains, regex, exists, etc.) with compound all/any logic\n- **JSON Path Conditions**: Match nested payload fields like `sensors.outdoor.temp > 30`\n- **Actions**: Publish to topic, HTTP webhook, log alert\n- **Loop Detection**: `__source` marker + per-rule rate limiting + global circuit breaker\n- **Time-Based Rules**: Cron schedules via APScheduler (e.g., publish heartbeat every 5 minutes)\n- **Dry-Run Testing**: Test rules against sample payloads before activating\n- **Hot-Reload**: Rule changes take effect immediately, no restart needed\n\n### Alerting & Notifications\n- **Telegram**: Enter bot token + chat ID — get alerts directly in Telegram\n- **Slack**: Enter incoming webhook URL — get alerts in your Slack channel\n- **HTTP Webhooks**: Generic webhook with customizable payload templates (`{{topic}}`, `{{payload}}`, `{{timestamp}}`)\n- Retry with exponential backoff (1s / 5s / 25s) on server errors\n- SSRF protection — blocks private/reserved addresses\n- Alert deduplication with configurable cooldown (5 min default)\n- Persistent alert history with delivery status tracking\n\n### Analytics & Observability\n- Per-topic message rate counters and numeric payload histograms\n- Structured JSON logging (structlog) for production\n- Prometheus-compatible `/metrics` endpoint\n- Topic favorites/bookmarks for quick access\n- Retained message indicator\n\n### Plugin System\n- `MQTTUIPlugin` base class with `on_message`, `on_connect`, `on_rule_trigger` hooks\n- Subprocess isolation — plugins cannot access app internals\n- Python entry-point discovery (`pip install` your plugin)\n- Management UI with enable/disable toggles\n- Bundled examples: JSON Formatter, Topic Logger\n\n## Quick Start\n\n### Docker Compose (Recommended)\n\n```bash\ngit clone https://github.com/terdia/mqttui.git\ncd mqttui\ndocker compose up -d\n```\n\nThis starts:\n- Mosquitto MQTT broker on port 1883\n- MQTTUI on port 8088\n\nOpen `http://localhost:8088` and log in with the admin credentials you set.\n\n### Docker\n\n```bash\ndocker pull terdia07/mqttui:v2.0\ndocker run -p 8088:5000 \\\n  -e MQTT_BROKER=your_broker \\\n  -e MQTTUI_ADMIN_USER=admin \\\n  -e MQTTUI_ADMIN_PASSWORD=changeme \\\n  -e SECRET_KEY=your-secret-key \\\n  terdia07/mqttui:v2.0\n```\n\n### Manual Installation\n\n```bash\ngit clone https://github.com/terdia/mqttui.git\ncd mqttui\npip install -r requirements.txt\n\n# Set required environment variables\nexport MQTTUI_ADMIN_USER=admin\nexport MQTTUI_ADMIN_PASSWORD=changeme\nexport SECRET_KEY=your-secret-key\nexport MQTT_BROKER=localhost\n\npython wsgi.py\n```\n\n### Running Tests\n\n```bash\npip install -r requirements.txt\npytest tests/ -q\n```\n\n### Demo Data\n\nPopulate realistic test data across all features (requires Docker Compose running):\n\n```bash\n./demo.sh\n```\n\nThis creates 200+ messages, 4 automation rules, triggers alerts, sets up filter presets, and bookmarks topics. Great for evaluating all tabs and features.\n\n## Configuration\n\n### Required Environment Variables\n\n| Variable | Description | Default |\n|----------|-------------|---------|\n| `MQTTUI_ADMIN_USER` | Admin username | `admin` |\n| `MQTTUI_ADMIN_PASSWORD` | Admin password | *(required)* |\n| `SECRET_KEY` | Flask secret key (must not be \"dev\" in production) | *(required)* |\n\n### MQTT Connection\n\n| Variable | Description | Default |\n|----------|-------------|---------|\n| `MQTT_BROKER` | Broker address (IP or hostname, no protocol prefix) | `localhost` |\n| `MQTT_PORT` | Broker port | `1883` |\n| `MQTT_USERNAME` | Broker username | *(optional)* |\n| `MQTT_PASSWORD` | Broker password | *(optional)* |\n| `MQTT_KEEPALIVE` | Keep-alive interval (seconds) | `60` |\n| `MQTT_VERSION` | Protocol version (`3.1.1` or `5`) | `3.1.1` |\n| `MQTT_TOPICS` | Topics to subscribe (comma-separated) | `#` |\n\n### TLS/SSL (MQTTS)\n\n| Variable | Description | Default |\n|----------|-------------|---------|\n| `MQTT_TLS` | Enable TLS connection | `false` |\n| `MQTT_TLS_CA_CERTS` | Path to CA certificate file | *(optional)* |\n| `MQTT_TLS_CERTFILE` | Path to client certificate | *(optional)* |\n| `MQTT_TLS_KEYFILE` | Path to client private key | *(optional)* |\n| `MQTT_TLS_INSECURE` | Skip certificate verification (not recommended) | `false` |\n\n**Example — connect to a TLS broker:**\n```bash\ndocker run -p 8088:5000 \\\n  -e MQTT_BROKER=broker.hivemq.com \\\n  -e MQTT_PORT=8883 \\\n  -e MQTT_TLS=true \\\n  terdia07/mqttui:v2.0.0\n```\n\n### Application\n\n| Variable | Description | Default |\n|----------|-------------|---------|\n| `DEBUG` | Enable development mode | `False` |\n| `PORT` | Application port | `5000` |\n| `LOG_LEVEL` | Logging level (DEBUG/INFO/WARNING/ERROR) | `INFO` |\n| `MQTTUI_RATE_LIMIT` | Publish endpoint rate limit | `30/minute` |\n\n### Database\n\n| Variable | Description | Default |\n|----------|-------------|---------|\n| `DB_ENABLED` | Enable message persistence | `True` |\n| `DB_PATH` | SQLite database path | `./data/mqtt_messages.db` |\n| `DB_MAX_MESSAGES` | Max stored messages | `10000` |\n| `DB_CLEANUP_DAYS` | Auto-delete messages older than X days | `30` |\n\n## API Documentation\n\nAll API endpoints are under `/api/v1/` with OpenAPI documentation at `/api/v1/docs`.\n\n### Key Endpoints\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `GET` | `/api/v1/messages` | Message history with filtering |\n| `POST` | `/api/v1/publish` | Publish MQTT message |\n| `GET` | `/api/v1/topics` | Topic list with stats |\n| `GET/POST` | `/api/v1/rules/` | List / create automation rules |\n| `PUT/DELETE` | `/api/v1/rules/<id>` | Update / delete rule |\n| `POST` | `/api/v1/rules/<id>/test` | Dry-run test a rule |\n| `POST` | `/api/v1/rules/<id>/enable` | Enable rule |\n| `GET` | `/api/v1/alerts/` | Alert history |\n| `GET` | `/api/v1/analytics/topics` | Per-topic analytics |\n| `GET` | `/api/v1/plugins/` | List plugins |\n| `GET` | `/metrics` | Prometheus metrics |\n\nAll data endpoints require authentication (session cookie or `X-API-Key` header).\n\n## Tech Stack\n\n- **Backend**: Python 3.11, Flask 3.1.x, Flask-SocketIO + gevent, paho-mqtt 2.1.0\n- **Database**: SQLite with WAL mode\n- **Frontend**: Alpine.js 3.x, htmx 2.x, Tailwind CSS v4, Vis.js, Chart.js\n- **Real-time**: Socket.IO with server-side 100ms batching\n- **Scheduling**: APScheduler with GeventScheduler\n- **Plugins**: pluggy + subprocess isolation\n- **Observability**: structlog + prometheus_client\n\n## Contributing\n\n1. Fork the repository\n2. Create a feature branch (`git checkout -b feature/amazing-feature`)\n3. Run tests (`pytest tests/ -q`)\n4. Commit your changes\n5. Push and open a Pull Request\n\n## License\n\nMIT License — see [LICENSE.md](LICENSE.md) for details.\n\n## Links\n\n- [GitHub Repository](https://github.com/terdia/mqttui)\n- [Docker Hub](https://hub.docker.com/r/terdia07/mqttui)\n- [Changelog](CHANGELOG.md)\n"
  },
  {
    "path": "app.py",
    "content": "__version__ = \"1.3.0\"\n\nfrom flask import Flask, render_template, request, jsonify, send_from_directory\nfrom flask_socketio import SocketIO, emit\nimport paho.mqtt.client as mqtt\nfrom datetime import datetime, timedelta\nimport os\nfrom debug_bar import debug_bar, debug_bar_middleware\nimport logging\nimport time\nfrom dotenv import load_dotenv\nfrom logging.handlers import RotatingFileHandler\nfrom werkzeug.serving import run_simple\nfrom database import MessageDatabase\n\n# Load environment variables\nload_dotenv()\n\n# Configuration\nDEBUG = os.getenv('DEBUG', 'False').lower() in ('true', '1', 't')\nHOST = os.getenv('HOST', '0.0.0.0')\nPORT = int(os.getenv('PORT', 5000))\nMQTT_BROKER = os.getenv('MQTT_BROKER', 'localhost')\nMQTT_PORT = int(os.getenv('MQTT_PORT', 1883))\nMQTT_USERNAME = os.getenv('MQTT_USERNAME')\nMQTT_PASSWORD = os.getenv('MQTT_PASSWORD')\nMQTT_KEEPALIVE = int(os.getenv('MQTT_KEEPALIVE', 60))\nMQTT_VERSION = os.getenv('MQTT_VERSION', '3.1.1')\n# Support for topic filtering (issue #6)\nMQTT_TOPICS = os.getenv('MQTT_TOPICS', '#')  # Comma-separated list of topics to subscribe to\n\n# Database configuration for message persistence\nDB_ENABLED = os.getenv('DB_ENABLED', 'True').lower() in ('true', '1', 't')\nDB_PATH = os.getenv('DB_PATH', 'mqtt_messages.db')\nDB_MAX_MESSAGES = int(os.getenv('DB_MAX_MESSAGES', 10000))\nDB_CLEANUP_DAYS = int(os.getenv('DB_CLEANUP_DAYS', 30))\n\n# Set up logging with LOG_LEVEL environment variable support (fixes issue #9)\nLOG_LEVEL = os.getenv('LOG_LEVEL', 'DEBUG' if DEBUG else 'INFO').upper()\nlog_levels = {\n    'DEBUG': logging.DEBUG,\n    'INFO': logging.INFO,\n    'WARNING': logging.WARNING,\n    'WARN': logging.WARNING,  # Support both WARN and WARNING\n    'ERROR': logging.ERROR,\n    'CRITICAL': logging.CRITICAL\n}\nlog_level = log_levels.get(LOG_LEVEL, logging.INFO)\nif LOG_LEVEL not in log_levels:\n    print(f\"Invalid LOG_LEVEL: {LOG_LEVEL}. Using INFO level.\")\n    \nlogging.basicConfig(level=log_level, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')\nif not DEBUG:\n    handler = RotatingFileHandler('mqttui.log', maxBytes=10000, backupCount=1)\n    handler.setLevel(log_level)\n    handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))\n    logging.getLogger('').addHandler(handler)\n\napp = Flask(__name__, static_url_path='/static')\napp.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'your-secret-key')\nsocketio = SocketIO(app, async_mode='threading')\n\n# Initialize database for message persistence\ndb = None\nif DB_ENABLED:\n    try:\n        db = MessageDatabase(DB_PATH, DB_MAX_MESSAGES)\n        logging.info(f\"Database initialized: {DB_PATH}\")\n    except Exception as e:\n        logging.error(f\"Failed to initialize database: {e}\")\n        db = None\n\nMQTT_RC_CODES = {\n    0: \"Connection successful\",\n    1: \"Connection refused - incorrect protocol version\",\n    2: \"Connection refused - invalid client identifier\",\n    3: \"Connection refused - server unavailable\",\n    4: \"Connection refused - bad username or password\",\n    5: \"Connection refused - not authorised\",\n    # MQTT v5 specific codes\n    16: \"Connection refused - no matching subscribers\",\n    17: \"Connection refused - no subscription existed\",\n    128: \"Connection refused - unspecified error\",\n    129: \"Connection refused - malformed packet\",\n    130: \"Connection refused - protocol error\",\n    131: \"Connection refused - implementation specific error\",\n    132: \"Connection refused - unsupported protocol version\",\n    133: \"Connection refused - client identifier not valid\",\n    134: \"Connection refused - bad user name or password\",\n    135: \"Connection refused - not authorized\",\n    136: \"Connection refused - server unavailable\",\n    137: \"Connection refused - server busy\",\n    138: \"Connection refused - banned\",\n    139: \"Connection refused - server shutting down\",\n    140: \"Connection refused - bad authentication method\",\n    141: \"Connection refused - topic name invalid\",\n    142: \"Connection refused - packet too large\",\n    143: \"Connection refused - quota exceeded\",\n    144: \"Connection refused - payload format invalid\",\n    145: \"Connection refused - retain not supported\",\n    146: \"Connection refused - QoS not supported\",\n    147: \"Connection refused - use another server\",\n    148: \"Connection refused - server moved\",\n    149: \"Connection refused - connection rate exceeded\"\n}\n\napp.before_request(debug_bar_middleware)\n\n@app.after_request\ndef after_request(response):\n    debug_bar.record('request', 'status_code', response.status_code)\n    debug_bar.end_request()\n    return response\n\n# MQTT setup\nmqtt_version = os.getenv('MQTT_VERSION', '3.1.1')\nif mqtt_version == '5':\n    mqtt_client = mqtt.Client(client_id=f\"mqttui_{os.getpid()}\", protocol=mqtt.MQTTv5)\n    logging.info(\"Using MQTT v5\")\nelse:\n    mqtt_client = mqtt.Client(client_id=f\"mqttui_{os.getpid()}\", clean_session=True, protocol=mqtt.MQTTv311)\n    logging.info(\"Using MQTT v3.1.1\")\n\nmqtt_broker = os.getenv('MQTT_BROKER', 'localhost')\nmqtt_port = int(os.getenv('MQTT_PORT', 1883))\nmqtt_username = os.getenv('MQTT_USERNAME')\nmqtt_password = os.getenv('MQTT_PASSWORD')\nmqtt_keepalive = int(os.getenv('MQTT_KEEPALIVE', 60))\n\nlogging.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}\")\n\nmessages = []\ntopics = set()\nconnection_count = 0\nactive_websockets = 0\nerror_log = []\n\n@socketio.on('connect')\ndef handle_connect():\n    global active_websockets\n    active_websockets += 1\n    debug_bar.record('performance', 'active_websockets', active_websockets)\n    logging.info(f\"WebSocket connected. Total active: {active_websockets}\")\n\n@socketio.on('disconnect')\ndef handle_disconnect():\n    global active_websockets\n    active_websockets -= 1\n    debug_bar.record('performance', 'active_websockets', active_websockets)\n    logging.info(f\"WebSocket disconnected. Total active: {active_websockets}\")\n\ndef on_connect(client, userdata, flags, rc, properties=None):\n    # MQTT v5 includes 'properties' parameter, v3.1.1 doesn't (fixes issue #8)\n    global connection_count\n    error_message = MQTT_RC_CODES.get(rc, f\"Unknown error (rc: {rc})\")\n    connection_status = 'Connected' if rc == 0 else f'Failed: {error_message}'\n    debug_bar.record('mqtt', 'connection_status', connection_status)\n    \n    logging.info(f\"MQTT Connection attempt - Result: {connection_status}\")\n    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}\")\n    \n    if rc == 0:\n        connection_count += 1\n        # Subscribe to specified topics (supports filtering - issue #6)\n        topics_to_subscribe = [topic.strip() for topic in MQTT_TOPICS.split(',')]\n        for topic in topics_to_subscribe:\n            client.subscribe(topic)\n            logging.info(f\"Subscribed to topic: {topic}\")\n        logging.info(f\"Connected to MQTT broker at {mqtt_broker}:{mqtt_port}. Total connections: {connection_count}\")\n        debug_bar.remove('mqtt', 'connection_attempt')  # Remove connection attempt entry\n    else:\n        error_log.append(error_message)\n        debug_bar.record('mqtt', 'last_error', error_message)\n        logging.error(f\"Connection failed: {error_message}\")\n        time.sleep(5)\n        connect_mqtt()  # Retry connection\n\ndef on_disconnect(client, userdata, rc):\n    global connection_count\n    connection_count = max(0, connection_count - 1)\n    error_message = MQTT_RC_CODES.get(rc, f\"Unknown error (rc: {rc})\")\n    disconnect_reason = 'Clean disconnect' if rc == 0 else f'Unexpected disconnect: {error_message}'\n    debug_bar.record('mqtt', 'last_disconnect', disconnect_reason)\n    error_log.append(f\"Disconnected: {disconnect_reason}\")\n    logging.warning(f\"Disconnected from MQTT broker: {disconnect_reason}\")\n    \n    if rc != 0:\n        logging.info(\"Attempting to reconnect...\")\n        client.connect_async(mqtt_broker, mqtt_port, mqtt_keepalive)\n\ndef on_message(client, userdata, msg):\n    try:\n        payload = msg.payload.decode()\n    except UnicodeDecodeError:\n        payload = msg.payload.hex()\n\n    timestamp = datetime.now()\n    message = {\n        'topic': msg.topic,\n        'payload': payload,\n        'timestamp': timestamp.isoformat()\n    }\n    \n    # Store in memory for backward compatibility\n    messages.append(message)\n    topics.add(msg.topic)\n    if len(messages) > 100:\n        messages.pop(0)\n    \n    # Store in database if enabled\n    if db:\n        try:\n            db.store_message(\n                topic=msg.topic,\n                payload=payload,\n                timestamp=timestamp,\n                qos=msg.qos,\n                retain=msg.retain\n            )\n        except Exception as e:\n            logging.error(f\"Failed to store message in database: {e}\")\n    \n    # Emit to connected clients\n    socketio.emit('mqtt_message', message)\n    debug_bar.record('mqtt', 'last_message', message)\n    logging.debug(f\"MQTT message received: {message}\")\n\nmqtt_client.on_connect = on_connect\nmqtt_client.on_message = on_message\nmqtt_client.on_disconnect = on_disconnect\n\n# API endpoints for message history\n@app.route('/api/messages')\ndef get_message_history():\n    \"\"\"Get paginated message history with enhanced filtering\"\"\"\n    try:\n        # Get query parameters\n        limit = min(int(request.args.get('limit', 100)), 1000)  # Max 1000 messages\n        offset = int(request.args.get('offset', 0))\n        \n        # Basic filters\n        topic_filter = request.args.get('topic')\n        hours = request.args.get('hours')  # Messages from last N hours\n        \n        # Enhanced filters\n        content_search = request.args.get('content')  # Search in message content\n        regex_topic = request.args.get('regex_topic')  # Regex pattern for topic\n        json_path = request.args.get('json_path')  # JSON path (e.g., \"temperature\")\n        json_value = request.args.get('json_value')  # Expected value at JSON path\n        \n        since = None\n        if hours:\n            since = datetime.now() - timedelta(hours=int(hours))\n        \n        if db:\n            # Get from database with enhanced filtering\n            messages_list = db.get_messages(\n                limit=limit,\n                offset=offset, \n                topic_filter=topic_filter,\n                since=since,\n                content_search=content_search,\n                regex_topic=regex_topic,\n                json_path=json_path,\n                json_value=json_value\n            )\n            # Note: total_count doesn't account for Python filters, so it's approximate\n            total_count = db.get_message_count(topic_filter=topic_filter, since=since)\n        else:\n            # Fallback to in-memory messages (basic filtering only)\n            messages_list = list(reversed(messages))  # Most recent first\n            if topic_filter:\n                messages_list = [m for m in messages_list if m['topic'] == topic_filter]\n            if content_search:\n                messages_list = [m for m in messages_list if content_search.lower() in m['payload'].lower()]\n            \n            total_count = len(messages_list)\n            messages_list = messages_list[offset:offset+limit]\n        \n        return jsonify({\n            'messages': messages_list,\n            'total': total_count,\n            'limit': limit,\n            'offset': offset,\n            'has_more': offset + len(messages_list) < total_count,\n            'filters_applied': {\n                'topic': topic_filter,\n                'content': content_search,\n                'regex_topic': regex_topic,\n                'json_path': json_path,\n                'json_value': json_value,\n                'hours': hours\n            }\n        })\n        \n    except Exception as e:\n        logging.error(f\"Error getting message history: {e}\")\n        return jsonify({'error': str(e)}), 500\n\n@app.route('/api/topics')\ndef get_topic_list():\n    \"\"\"Get list of all topics with statistics\"\"\"\n    try:\n        if db:\n            topics_list = db.get_topics()\n        else:\n            # Fallback to in-memory topics\n            topics_list = [{'topic': topic} for topic in sorted(topics)]\n        \n        return jsonify({'topics': topics_list})\n        \n    except Exception as e:\n        logging.error(f\"Error getting topics: {e}\")\n        return jsonify({'error': str(e)}), 500\n\n@app.route('/api/database/stats')\ndef get_database_stats():\n    \"\"\"Get database size and statistics\"\"\"\n    try:\n        if db:\n            stats = db.get_database_size()\n            stats['enabled'] = True\n        else:\n            stats = {\n                'enabled': False,\n                'message_count': len(messages),\n                'topic_count': len(topics)\n            }\n        \n        return jsonify(stats)\n        \n    except Exception as e:\n        logging.error(f\"Error getting database stats: {e}\")\n        return jsonify({'error': str(e)}), 500\n\n@app.route('/api/database/cleanup', methods=['POST'])\ndef cleanup_database():\n    \"\"\"Clean up old database records\"\"\"\n    try:\n        if not db:\n            return jsonify({'error': 'Database not enabled'}), 400\n            \n        days = int(request.json.get('days', DB_CLEANUP_DAYS))\n        deleted = db.cleanup_old_data(days)\n        \n        return jsonify({\n            'success': True,\n            'deleted_messages': deleted,\n            'days': days\n        })\n        \n    except Exception as e:\n        logging.error(f\"Error cleaning database: {e}\")\n        return jsonify({'error': str(e)}), 500\n\n# Filter presets API endpoints\n@app.route('/api/filter-presets')\ndef get_filter_presets():\n    \"\"\"Get all saved filter presets\"\"\"\n    try:\n        if not db:\n            return jsonify({'error': 'Database not enabled'}), 400\n        \n        presets = db.get_filter_presets()\n        return jsonify({'presets': presets})\n        \n    except Exception as e:\n        logging.error(f\"Error getting filter presets: {e}\")\n        return jsonify({'error': str(e)}), 500\n\n@app.route('/api/filter-presets', methods=['POST'])\ndef save_filter_preset():\n    \"\"\"Save a new filter preset\"\"\"\n    try:\n        if not db:\n            return jsonify({'error': 'Database not enabled'}), 400\n        \n        data = request.json\n        name = data.get('name')\n        description = data.get('description', '')\n        filters = data.get('filters', {})\n        \n        if not name or not filters:\n            return jsonify({'error': 'Name and filters are required'}), 400\n        \n        success = db.save_filter_preset(name, filters, description)\n        \n        if success:\n            return jsonify({'success': True, 'message': f'Saved preset: {name}'})\n        else:\n            return jsonify({'error': 'Failed to save preset'}), 500\n        \n    except Exception as e:\n        logging.error(f\"Error saving filter preset: {e}\")\n        return jsonify({'error': str(e)}), 500\n\n@app.route('/api/filter-presets/<name>', methods=['DELETE'])\ndef delete_filter_preset(name):\n    \"\"\"Delete a filter preset\"\"\"\n    try:\n        if not db:\n            return jsonify({'error': 'Database not enabled'}), 400\n        \n        success = db.delete_filter_preset(name)\n        \n        if success:\n            return jsonify({'success': True, 'message': f'Deleted preset: {name}'})\n        else:\n            return jsonify({'error': 'Preset not found'}), 404\n        \n    except Exception as e:\n        logging.error(f\"Error deleting filter preset: {e}\")\n        return jsonify({'error': str(e)}), 500\n\n@app.route('/api/filter-presets/<name>/use', methods=['POST'])\ndef use_filter_preset(name):\n    \"\"\"Load and use a filter preset\"\"\"\n    try:\n        if not db:\n            return jsonify({'error': 'Database not enabled'}), 400\n        \n        filters = db.use_filter_preset(name)\n        \n        if filters:\n            return jsonify({'success': True, 'filters': filters})\n        else:\n            return jsonify({'error': 'Preset not found'}), 404\n        \n    except Exception as e:\n        logging.error(f\"Error using filter preset: {e}\")\n        return jsonify({'error': str(e)}), 500\n\n@app.route('/')\ndef index():\n    return render_template('index.html', messages=messages, topics=list(topics))\n\n@app.route('/publish', methods=['POST'])\ndef publish_message():\n    topic = request.form['topic']\n    message = request.form['message']\n    mqtt_client.publish(topic, message)\n    debug_bar.record('mqtt', 'last_publish', {'topic': topic, 'message': message})\n    return jsonify(success=True)\n\n@app.route('/stats')\ndef get_stats():\n    return jsonify({\n        'connection_count': connection_count,\n        'topic_count': len(topics),\n        'message_count': len(messages),\n        'errors': error_log\n    })\n\n@app.route('/static/<path:path>')\ndef send_static(path):\n    return send_from_directory('static', path)\n\n@app.route('/debug-bar')\ndef get_debug_bar_data():\n    try:\n        data = debug_bar.get_data()\n        return jsonify(data)\n    except Exception as e:\n        logging.error(f\"Error fetching debug bar data: {e}\")\n        return jsonify({\"error\": \"Failed to fetch debug bar data\"}), 500\n\n@app.route('/toggle-debug-bar', methods=['POST'])\ndef toggle_debug_bar():\n    if debug_bar.enabled:\n        debug_bar.disable()\n    else:\n        debug_bar.enable()\n    return jsonify(enabled=debug_bar.enabled)\n\n@app.route('/record-client-performance', methods=['POST'])\ndef record_client_performance():\n    data = request.json\n    debug_bar.record('performance', 'page_load_time', f\"{data['pageLoadTime']}ms\")\n    debug_bar.record('performance', 'dom_ready_time', f\"{data['domReadyTime']}ms\")\n    return jsonify(success=True)\n\n@app.route('/version')\ndef get_version():\n    return jsonify({'version': __version__})\n\nif __name__ == '__main__' or __name__ == 'app':\n    if mqtt_username and mqtt_password:\n        mqtt_client.username_pw_set(mqtt_username, mqtt_password)\n    \n    def connect_mqtt():\n        if mqtt_username and mqtt_password:\n            mqtt_client.username_pw_set(mqtt_username, mqtt_password)\n            logging.info(\"MQTT credentials set\")\n        else:\n            logging.info(\"No MQTT credentials provided\")\n        \n        debug_bar.record('mqtt', 'connection_attempt', f\"Connecting to {mqtt_broker}:{mqtt_port}\")\n        debug_bar.record('mqtt', 'broker', mqtt_broker)\n        debug_bar.record('mqtt', 'port', mqtt_port)\n        debug_bar.record('mqtt', 'username', mqtt_username if mqtt_username else 'Not set')\n        debug_bar.record('mqtt', 'password', 'Set' if mqtt_password else 'Not set')\n        debug_bar.record('mqtt', 'protocol', f'MQTT v{mqtt_version}')\n        debug_bar.record('mqtt', 'subscribed_topics', MQTT_TOPICS)\n        \n        logging.info(f\"Attempting to connect to MQTT broker at {mqtt_broker}:{mqtt_port}\")\n        \n        try:\n            mqtt_client.connect(mqtt_broker, mqtt_port, mqtt_keepalive)\n            mqtt_client.loop_start()\n        except Exception as e:\n            error_message = f\"Failed to connect to MQTT broker: {str(e)}\"\n            debug_bar.record('mqtt', 'connection_error', error_message)\n            debug_bar.record('mqtt', 'connection_status', 'Failed')\n            error_log.append(error_message)\n            logging.error(error_message)\n            time.sleep(5)\n            connect_mqtt()  # Retry connection\n            debug_bar.record('mqtt', 'connection_status', 'Failed')\n    \n    connect_mqtt()\n    \n    # Start the Flask-SocketIO server when running directly\n    # This prevents the app from exiting prematurely (fixes issue #12)\n    if __name__ == '__main__':\n        socketio.run(app, host=HOST, port=PORT, debug=DEBUG)"
  },
  {
    "path": "database.py",
    "content": "\"\"\"\nDatabase module for MQTT message persistence\n\"\"\"\nimport sqlite3\nimport threading\nimport logging\nimport re\nimport json\nfrom datetime import datetime, timedelta\nfrom typing import List, Dict, Optional, Union\nimport os\n\nclass MessageDatabase:\n    def __init__(self, db_path: str = \"mqtt_messages.db\", max_messages: int = 10000):\n        self.db_path = db_path\n        self.max_messages = max_messages\n        self._local = threading.local()\n        self.init_database()\n        \n    def get_connection(self):\n        \"\"\"Get thread-local database connection\"\"\"\n        if not hasattr(self._local, 'connection'):\n            self._local.connection = sqlite3.connect(\n                self.db_path, \n                check_same_thread=False,\n                timeout=30.0\n            )\n            self._local.connection.row_factory = sqlite3.Row\n            \n            # Add REGEXP function for advanced topic filtering\n            self._local.connection.create_function(\"REGEXP\", 2, self._regexp)\n            \n        return self._local.connection\n    \n    def _regexp(self, pattern, value):\n        \"\"\"Custom REGEXP function for SQLite\"\"\"\n        try:\n            return re.search(pattern, value, re.IGNORECASE) is not None\n        except Exception:\n            return False\n    \n    def init_database(self):\n        \"\"\"Initialize database schema\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            \n            # Messages table\n            cursor.execute('''\n                CREATE TABLE IF NOT EXISTS messages (\n                    id INTEGER PRIMARY KEY AUTOINCREMENT,\n                    topic TEXT NOT NULL,\n                    payload TEXT NOT NULL,\n                    timestamp DATETIME NOT NULL,\n                    qos INTEGER DEFAULT 0,\n                    retain BOOLEAN DEFAULT 0,\n                    payload_size INTEGER DEFAULT 0,\n                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n                )\n            ''')\n            \n            # Topics table for faster topic queries\n            cursor.execute('''\n                CREATE TABLE IF NOT EXISTS topics (\n                    topic TEXT PRIMARY KEY,\n                    last_message_at DATETIME NOT NULL,\n                    message_count INTEGER DEFAULT 1,\n                    first_seen DATETIME DEFAULT CURRENT_TIMESTAMP\n                )\n            ''')\n            \n            # Filter presets table for saved searches\n            cursor.execute('''\n                CREATE TABLE IF NOT EXISTS filter_presets (\n                    id INTEGER PRIMARY KEY AUTOINCREMENT,\n                    name TEXT UNIQUE NOT NULL,\n                    description TEXT,\n                    filters TEXT NOT NULL,  -- JSON string of filter parameters\n                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n                    last_used DATETIME\n                )\n            ''')\n            \n            # Create indexes for better performance\n            cursor.execute('''\n                CREATE INDEX IF NOT EXISTS idx_messages_topic \n                ON messages(topic)\n            ''')\n            \n            cursor.execute('''\n                CREATE INDEX IF NOT EXISTS idx_messages_timestamp \n                ON messages(timestamp DESC)\n            ''')\n            \n            cursor.execute('''\n                CREATE INDEX IF NOT EXISTS idx_messages_topic_timestamp \n                ON messages(topic, timestamp DESC)\n            ''')\n            \n            conn.commit()\n            logging.info(\"Database initialized successfully\")\n            \n        except Exception as e:\n            logging.error(f\"Database initialization error: {e}\")\n            conn.rollback()\n            raise\n    \n    def store_message(self, topic: str, payload: str, timestamp: datetime = None, \n                     qos: int = 0, retain: bool = False) -> bool:\n        \"\"\"Store a message in the database\"\"\"\n        if timestamp is None:\n            timestamp = datetime.now()\n            \n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            \n            # Store message\n            cursor.execute('''\n                INSERT INTO messages (topic, payload, timestamp, qos, retain, payload_size)\n                VALUES (?, ?, ?, ?, ?, ?)\n            ''', (topic, payload, timestamp, qos, retain, len(payload)))\n            \n            # Update topics table\n            cursor.execute('''\n                INSERT OR REPLACE INTO topics (topic, last_message_at, message_count, first_seen)\n                VALUES (?, ?, \n                    COALESCE((SELECT message_count FROM topics WHERE topic = ?) + 1, 1),\n                    COALESCE((SELECT first_seen FROM topics WHERE topic = ?), ?)\n                )\n            ''', (topic, timestamp, topic, topic, timestamp))\n            \n            conn.commit()\n            \n            # Clean up old messages if needed\n            self._cleanup_old_messages()\n            return True\n            \n        except Exception as e:\n            logging.error(f\"Error storing message: {e}\")\n            conn.rollback()\n            return False\n    \n    def get_messages(self, limit: int = 100, offset: int = 0, \n                    topic_filter: str = None, since: datetime = None,\n                    content_search: str = None, regex_topic: str = None,\n                    json_path: str = None, json_value: str = None) -> List[Dict]:\n        \"\"\"Retrieve messages from database with enhanced filtering\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            \n            query = '''\n                SELECT topic, payload, timestamp, qos, retain, payload_size\n                FROM messages \n                WHERE 1=1\n            '''\n            params = []\n            \n            # Basic topic filtering (wildcard support)\n            if topic_filter:\n                if '%' in topic_filter or '_' in topic_filter:\n                    query += ' AND topic LIKE ?'\n                else:\n                    query += ' AND topic = ?'\n                params.append(topic_filter)\n            \n            # Regex topic filtering (more powerful than LIKE)\n            if regex_topic:\n                query += ' AND topic REGEXP ?'\n                params.append(regex_topic)\n            \n            # Content search (case-insensitive)\n            if content_search:\n                query += ' AND LOWER(payload) LIKE LOWER(?)'\n                params.append(f'%{content_search}%')\n            \n            # Time filtering\n            if since:\n                query += ' AND timestamp >= ?'\n                params.append(since)\n                \n            query += ' ORDER BY timestamp DESC LIMIT ? OFFSET ?'\n            params.extend([limit, offset])\n            \n            cursor.execute(query, params)\n            rows = cursor.fetchall()\n            \n            messages = [dict(row) for row in rows]\n            \n            # Apply Python-based filters that can't be done in SQL\n            if json_path or regex_topic:\n                messages = self._apply_python_filters(\n                    messages, regex_topic, json_path, json_value\n                )\n            \n            return messages\n            \n        except Exception as e:\n            logging.error(f\"Error retrieving messages: {e}\")\n            return []\n    \n    def _apply_python_filters(self, messages: List[Dict], regex_topic: str = None,\n                            json_path: str = None, json_value: str = None) -> List[Dict]:\n        \"\"\"Apply filters that require Python processing\"\"\"\n        filtered = []\n        \n        for msg in messages:\n            # Regex topic filter (if not handled by SQL REGEXP)\n            if regex_topic and not re.search(regex_topic, msg['topic'], re.IGNORECASE):\n                continue\n                \n            # JSON path filtering\n            if json_path and json_value:\n                try:\n                    payload_json = json.loads(msg['payload'])\n                    # Simple JSON path support (e.g., \"temperature\", \"sensors.temp\")\n                    value = self._get_json_path_value(payload_json, json_path)\n                    if value is None or str(value).lower() != json_value.lower():\n                        continue\n                except (json.JSONDecodeError, KeyError):\n                    continue\n            \n            filtered.append(msg)\n        \n        return filtered\n    \n    def _get_json_path_value(self, json_obj: dict, path: str):\n        \"\"\"Extract value from JSON using simple dot notation path\"\"\"\n        try:\n            keys = path.split('.')\n            current = json_obj\n            for key in keys:\n                if isinstance(current, dict) and key in current:\n                    current = current[key]\n                else:\n                    return None\n            return current\n        except Exception:\n            return None\n    \n    def get_topics(self) -> List[Dict]:\n        \"\"\"Get all topics with statistics\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            cursor.execute('''\n                SELECT topic, message_count, last_message_at, first_seen\n                FROM topics\n                ORDER BY last_message_at DESC\n            ''')\n            rows = cursor.fetchall()\n            return [dict(row) for row in rows]\n            \n        except Exception as e:\n            logging.error(f\"Error retrieving topics: {e}\")\n            return []\n    \n    def get_message_count(self, topic_filter: str = None, since: datetime = None) -> int:\n        \"\"\"Get total message count with optional filtering\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            \n            query = 'SELECT COUNT(*) FROM messages WHERE 1=1'\n            params = []\n            \n            if topic_filter:\n                if '%' in topic_filter or '_' in topic_filter:\n                    query += ' AND topic LIKE ?'\n                else:\n                    query += ' AND topic = ?'\n                params.append(topic_filter)\n            \n            if since:\n                query += ' AND timestamp >= ?'\n                params.append(since)\n                \n            cursor.execute(query, params)\n            return cursor.fetchone()[0]\n            \n        except Exception as e:\n            logging.error(f\"Error counting messages: {e}\")\n            return 0\n    \n    def _cleanup_old_messages(self):\n        \"\"\"Remove old messages to stay within limit\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            \n            # Check if we need cleanup\n            cursor.execute('SELECT COUNT(*) FROM messages')\n            count = cursor.fetchone()[0]\n            \n            if count > self.max_messages:\n                # Delete oldest messages\n                messages_to_delete = count - self.max_messages + 1000  # Delete extra to avoid frequent cleanups\n                cursor.execute('''\n                    DELETE FROM messages \n                    WHERE id IN (\n                        SELECT id FROM messages \n                        ORDER BY timestamp ASC \n                        LIMIT ?\n                    )\n                ''', (messages_to_delete,))\n                \n                # Update topic counts (this is approximate)\n                cursor.execute('''\n                    UPDATE topics SET message_count = (\n                        SELECT COUNT(*) FROM messages WHERE messages.topic = topics.topic\n                    )\n                ''')\n                \n                conn.commit()\n                logging.info(f\"Cleaned up {messages_to_delete} old messages\")\n                \n        except Exception as e:\n            logging.error(f\"Error during cleanup: {e}\")\n            conn.rollback()\n    \n    def cleanup_old_data(self, days: int = 30):\n        \"\"\"Remove messages older than specified days\"\"\"\n        cutoff_date = datetime.now() - timedelta(days=days)\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            cursor.execute('DELETE FROM messages WHERE timestamp < ?', (cutoff_date,))\n            deleted = cursor.rowcount\n            \n            # Clean up topics with no messages\n            cursor.execute('''\n                DELETE FROM topics WHERE topic NOT IN (\n                    SELECT DISTINCT topic FROM messages\n                )\n            ''')\n            \n            conn.commit()\n            logging.info(f\"Cleaned up {deleted} messages older than {days} days\")\n            return deleted\n            \n        except Exception as e:\n            logging.error(f\"Error during data cleanup: {e}\")\n            conn.rollback()\n            return 0\n    \n    def get_database_size(self) -> Dict:\n        \"\"\"Get database size information\"\"\"\n        try:\n            size_bytes = os.path.getsize(self.db_path) if os.path.exists(self.db_path) else 0\n            conn = self.get_connection()\n            cursor = conn.cursor()\n            \n            cursor.execute('SELECT COUNT(*) FROM messages')\n            message_count = cursor.fetchone()[0]\n            \n            cursor.execute('SELECT COUNT(*) FROM topics')\n            topic_count = cursor.fetchone()[0]\n            \n            return {\n                'size_bytes': size_bytes,\n                'size_mb': round(size_bytes / 1024 / 1024, 2),\n                'message_count': message_count,\n                'topic_count': topic_count\n            }\n            \n        except Exception as e:\n            logging.error(f\"Error getting database size: {e}\")\n            return {'size_bytes': 0, 'size_mb': 0, 'message_count': 0, 'topic_count': 0}\n    \n    def save_filter_preset(self, name: str, filters: Dict, description: str = None) -> bool:\n        \"\"\"Save a filter preset\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            cursor.execute('''\n                INSERT OR REPLACE INTO filter_presets (name, description, filters, last_used)\n                VALUES (?, ?, ?, ?)\n            ''', (name, description, json.dumps(filters), datetime.now()))\n            \n            conn.commit()\n            logging.info(f\"Saved filter preset: {name}\")\n            return True\n            \n        except Exception as e:\n            logging.error(f\"Error saving filter preset: {e}\")\n            conn.rollback()\n            return False\n    \n    def get_filter_presets(self) -> List[Dict]:\n        \"\"\"Get all filter presets\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            cursor.execute('''\n                SELECT name, description, filters, created_at, last_used\n                FROM filter_presets\n                ORDER BY last_used DESC, created_at DESC\n            ''')\n            rows = cursor.fetchall()\n            \n            presets = []\n            for row in rows:\n                preset = dict(row)\n                preset['filters'] = json.loads(preset['filters'])\n                presets.append(preset)\n            \n            return presets\n            \n        except Exception as e:\n            logging.error(f\"Error getting filter presets: {e}\")\n            return []\n    \n    def delete_filter_preset(self, name: str) -> bool:\n        \"\"\"Delete a filter preset\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            cursor.execute('DELETE FROM filter_presets WHERE name = ?', (name,))\n            conn.commit()\n            \n            if cursor.rowcount > 0:\n                logging.info(f\"Deleted filter preset: {name}\")\n                return True\n            return False\n            \n        except Exception as e:\n            logging.error(f\"Error deleting filter preset: {e}\")\n            conn.rollback()\n            return False\n    \n    def use_filter_preset(self, name: str) -> Optional[Dict]:\n        \"\"\"Load and mark a filter preset as used\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            \n            # Get the preset\n            cursor.execute('''\n                SELECT filters FROM filter_presets WHERE name = ?\n            ''', (name,))\n            row = cursor.fetchone()\n            \n            if not row:\n                return None\n                \n            # Update last_used timestamp\n            cursor.execute('''\n                UPDATE filter_presets SET last_used = ? WHERE name = ?\n            ''', (datetime.now(), name))\n            \n            conn.commit()\n            return json.loads(row['filters'])\n            \n        except Exception as e:\n            logging.error(f\"Error using filter preset: {e}\")\n            return None\n\n    def close(self):\n        \"\"\"Close database connection\"\"\"\n        if hasattr(self._local, 'connection'):\n            self._local.connection.close()"
  },
  {
    "path": "debug_bar.py",
    "content": "import time\nimport psutil\nfrom flask import request\nfrom threading import Lock\nimport logging\n\nclass DebugBarPanel:\n    def __init__(self, name):\n        self.name = name\n        self.data = {}\n\n    def record(self, key, value):\n        self.data[key] = value\n\n    def get_data(self):\n        return self.data\n\nclass DebugBar:\n    def __init__(self):\n        self.panels = {}\n        self.enabled = False\n        self.start_time = None\n        self.lock = Lock()\n        try:\n            self.process = psutil.Process()\n        except Exception as e:\n            logging.error(f\"Failed to initialize psutil Process: {e}\")\n            self.process = None\n\n    def add_panel(self, name):\n        with self.lock:\n            if name not in self.panels:\n                self.panels[name] = DebugBarPanel(name)\n\n    def record(self, panel, key, value):\n        with self.lock:\n            if panel in self.panels:\n                self.panels[panel].record(key, value)\n\n    def start_request(self):\n        self.start_time = time.time()\n\n    def end_request(self):\n        if self.start_time:\n            duration = time.time() - self.start_time\n            self.record('request', 'duration', f\"{duration:.2f}s\")\n            self.start_time = None\n\n    def get_data(self):\n        with self.lock:\n            #self.update_performance_metrics()\n            return {name: panel.get_data() for name, panel in self.panels.items()}\n\n    def enable(self):\n        self.enabled = True\n\n    def disable(self):\n        self.enabled = False\n\n    def remove(self, panel_name, key):\n        with self.lock:\n            if panel_name in self.panels:\n                panel = self.panels[panel_name]\n                if key in panel.data:\n                    del panel.data[key]\n\ndebug_bar = DebugBar()\n\n# Initialize default panels\ndebug_bar.add_panel('mqtt')\ndebug_bar.add_panel('request')\ndebug_bar.add_panel('performance')\n\ndef debug_bar_middleware():\n    debug_bar.start_request()\n    debug_bar.record('request', 'path', request.path)\n    debug_bar.record('request', 'method', request.method)"
  },
  {
    "path": "demo.sh",
    "content": "#!/bin/bash\n# =============================================================================\n# MQTTUI v2.1 Demo Script\n# Populates realistic MQTT data across multiple brokers to test all features\n# Usage: ./demo.sh\n# Requires: docker compose running (mosquitto + mosquitto2 + mqttui containers)\n# =============================================================================\n\nset -e\n\nBROKER1=\"mosquitto\"\nBROKER2=\"mosquitto2\"\nAPI_URL=\"http://localhost:8088/api/v1\"\nPUB1=\"docker exec $BROKER1 mosquitto_pub\"\nPUB2=\"docker exec $BROKER2 mosquitto_pub\"\n\n# Colors\nGREEN='\\033[0;32m'\nBLUE='\\033[0;34m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m'\n\necho -e \"${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\"\necho -e \"${BLUE} MQTTUI v2.1 Demo Data Generator (Multi-Broker)${NC}\"\necho -e \"${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\"\necho \"\"\n\n# Check containers are running\nfor c in $BROKER1 $BROKER2 mqttui; do\n    if ! docker ps --format '{{.Names}}' | grep -q \"^${c}$\"; then\n        echo \"Error: $c container not running. Run: docker compose up -d\"\n        exit 1\n    fi\ndone\n\n# ---------------------------------------------------------------------------\n# 1. Login and get session cookie\n# ---------------------------------------------------------------------------\necho -e \"${YELLOW}[1/8] Authenticating...${NC}\"\nCOOKIE_JAR=\"/tmp/mqttui-demo-cookies.txt\"\nLOGIN_RESP=$(curl -s -c \"$COOKIE_JAR\" -X POST \"http://localhost:8088/login\" \\\n    -d \"username=admin&password=admin\" \\\n    -o /dev/null -w \"%{http_code}\")\n\nif [ \"$LOGIN_RESP\" != \"302\" ] && [ \"$LOGIN_RESP\" != \"200\" ]; then\n    echo \"  Login failed (HTTP $LOGIN_RESP). Check MQTTUI_ADMIN_USER/PASSWORD.\"\n    exit 1\nfi\necho -e \"  ${GREEN}✓ Logged in as admin${NC}\"\n\n# ---------------------------------------------------------------------------\n# 2. Set up second broker connection\n# ---------------------------------------------------------------------------\necho -e \"${YELLOW}[2/8] Setting up second broker...${NC}\"\n\n# Check if Broker 2 already exists\nEXISTING=$(curl -s -b \"$COOKIE_JAR\" \"$API_URL/brokers/\" | python3 -c \"\nimport sys,json\nd=json.load(sys.stdin)\nbrokers = (d.get('data') or d).get('brokers',[])\nprint(len([b for b in brokers if b.get('host') == '$BROKER2']))\n\" 2>/dev/null)\n\nif [ \"$EXISTING\" = \"0\" ]; then\n    curl -s -b \"$COOKIE_JAR\" -X POST \"$API_URL/brokers/\" \\\n        -H \"Content-Type: application/json\" \\\n        -d \"{\n            \\\"name\\\": \\\"Warehouse\\\",\n            \\\"host\\\": \\\"$BROKER2\\\",\n            \\\"port\\\": 1883,\n            \\\"topics\\\": \\\"#\\\",\n            \\\"is_active\\\": true\n        }\" > /dev/null\n    echo -e \"  ${GREEN}✓ Added 'Warehouse' broker ($BROKER2:1883)${NC}\"\n    sleep 2  # Wait for connection\nelse\n    echo -e \"  ${GREEN}✓ Second broker already exists${NC}\"\nfi\n\nBROKER_COUNT=$(curl -s -b \"$COOKIE_JAR\" \"$API_URL/brokers/\" | python3 -c \"\nimport sys,json; d=json.load(sys.stdin)\nbrokers = (d.get('data') or d).get('brokers',[])\nconnected = sum(1 for b in brokers if b.get('connected'))\nprint(f'{connected}/{len(brokers)} connected')\n\" 2>/dev/null)\necho -e \"  Brokers: ${GREEN}$BROKER_COUNT${NC}\"\n\n# ---------------------------------------------------------------------------\n# 3. Publish to Broker 1 — Home automation sensors\n# ---------------------------------------------------------------------------\necho -e \"${YELLOW}[3/8] Publishing to Broker 1 (Default) — Home sensors...${NC}\"\n\n# Temperature sensors\nfor i in $(seq 1 40); do\n    temp=$(echo \"scale=1; 18 + ($RANDOM % 200) / 10\" | bc)\n    $PUB1 -t \"home/living-room/temperature\" -m \"{\\\"value\\\": $temp, \\\"unit\\\": \\\"C\\\", \\\"device\\\": \\\"DHT22\\\"}\"\n    $PUB1 -t \"home/bedroom/temperature\" -m \"{\\\"value\\\": $temp, \\\"unit\\\": \\\"C\\\", \\\"device\\\": \\\"DHT22\\\"}\"\ndone\necho -e \"  ${GREEN}✓ 80 temperature readings${NC}\"\n\n# Humidity\nfor i in $(seq 1 20); do\n    humidity=$((40 + RANDOM % 40))\n    $PUB1 -t \"home/living-room/humidity\" -m \"{\\\"value\\\": $humidity, \\\"unit\\\": \\\"%\\\"}\"\ndone\necho -e \"  ${GREEN}✓ 20 humidity readings${NC}\"\n\n# Motion sensors\nfor i in $(seq 1 15); do\n    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]}')\n    $PUB1 -t \"home/$room/motion\" -m \"{\\\"detected\\\": true, \\\"confidence\\\": $((70 + RANDOM % 30))}\"\ndone\necho -e \"  ${GREEN}✓ 15 motion events${NC}\"\n\n# Battery levels\nfor i in $(seq 1 10); do\n    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]}')\n    battery=$((5 + RANDOM % 95))\n    $PUB1 -t \"home/$device/battery\" -m \"{\\\"level\\\": $battery, \\\"charging\\\": false}\"\ndone\necho -e \"  ${GREEN}✓ 10 battery levels${NC}\"\n\n# Retained status\n$PUB1 -t \"system/home/status\" -m '{\"status\": \"online\", \"version\": \"2.1.0\"}' -r\necho -e \"  ${GREEN}✓ 1 retained status message${NC}\"\n\necho -e \"  ${GREEN}Broker 1 total: 126 messages${NC}\"\n\n# ---------------------------------------------------------------------------\n# 4. Publish to Broker 2 — Warehouse / industrial sensors\n# ---------------------------------------------------------------------------\necho -e \"${YELLOW}[4/8] Publishing to Broker 2 (Warehouse) — Industrial sensors...${NC}\"\n\n# Warehouse temperature (cold storage monitoring)\nfor i in $(seq 1 40); do\n    temp=$(echo \"scale=1; -5 + ($RANDOM % 150) / 10\" | bc)\n    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]}')\n    $PUB2 -t \"warehouse/$zone/temperature\" -m \"{\\\"value\\\": $temp, \\\"unit\\\": \\\"C\\\", \\\"sensor\\\": \\\"PT100\\\"}\"\ndone\necho -e \"  ${GREEN}✓ 40 cold storage temperature readings${NC}\"\n\n# Conveyor belt speed\nfor i in $(seq 1 20); do\n    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]}')\n    speed=$(echo \"scale=1; 1 + ($RANDOM % 50) / 10\" | bc)\n    $PUB2 -t \"warehouse/$line/conveyor/speed\" -m \"{\\\"value\\\": $speed, \\\"unit\\\": \\\"m/s\\\"}\"\ndone\necho -e \"  ${GREEN}✓ 20 conveyor speed readings${NC}\"\n\n# Door access events\nfor i in $(seq 1 15); do\n    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]}')\n    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]}')\n    $PUB2 -t \"warehouse/doors/$door\" -m \"{\\\"action\\\": \\\"$action\\\", \\\"badge_id\\\": \\\"EMP-$((100 + RANDOM % 900))\\\"}\"\ndone\necho -e \"  ${GREEN}✓ 15 door access events${NC}\"\n\n# Power meters\nfor i in $(seq 1 10); do\n    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]}')\n    watts=$((500 + RANDOM % 9500))\n    $PUB2 -t \"warehouse/power/$meter\" -m \"{\\\"watts\\\": $watts, \\\"voltage\\\": 240}\"\ndone\necho -e \"  ${GREEN}✓ 10 power meter readings${NC}\"\n\n# Retained status\n$PUB2 -t \"system/warehouse/status\" -m '{\"status\": \"online\", \"zones\": 3, \"conveyors\": 3}' -r\necho -e \"  ${GREEN}✓ 1 retained status message${NC}\"\n\necho -e \"  ${GREEN}Broker 2 total: 86 messages${NC}\"\n\n# ---------------------------------------------------------------------------\n# 5. Create automation rules (spanning both brokers)\n# ---------------------------------------------------------------------------\necho -e \"${YELLOW}[5/8] Creating automation rules...${NC}\"\n\n# Rule 1: High temperature alert (home)\ncurl -s -b \"$COOKIE_JAR\" -X POST \"$API_URL/rules/\" \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\n        \"name\": \"Home High Temp Alert\",\n        \"description\": \"Alert when home temperature exceeds 35°C\",\n        \"trigger_topic\": \"home/+/temperature\",\n        \"condition\": {\"path\": \"value\", \"op\": \"gt\", \"value\": 35},\n        \"action\": {\"type\": \"publish\", \"topic\": \"alerts/high-temp\", \"payload\": \"{\\\"alert\\\": \\\"Home temperature exceeded 35°C\\\"}\"},\n        \"rate_limit_per_min\": 5\n    }' > /dev/null\necho -e \"  ${GREEN}✓ Home High Temp Alert${NC}\"\n\n# Rule 2: Cold storage warning (warehouse — temp too high for cold storage)\ncurl -s -b \"$COOKIE_JAR\" -X POST \"$API_URL/rules/\" \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\n        \"name\": \"Cold Storage Warning\",\n        \"description\": \"Alert when warehouse temp rises above 5°C\",\n        \"trigger_topic\": \"warehouse/+/temperature\",\n        \"condition\": {\"path\": \"value\", \"op\": \"gt\", \"value\": 5},\n        \"action\": {\"type\": \"log\", \"severity\": \"critical\", \"message\": \"Cold storage temperature exceeded threshold\"},\n        \"rate_limit_per_min\": 10\n    }' > /dev/null\necho -e \"  ${GREEN}✓ Cold Storage Warning (warehouse temp > 5°C)${NC}\"\n\n# Rule 3: Low battery warning\ncurl -s -b \"$COOKIE_JAR\" -X POST \"$API_URL/rules/\" \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\n        \"name\": \"Low Battery Warning\",\n        \"description\": \"Log when any device battery drops below 20%\",\n        \"trigger_topic\": \"home/+/battery\",\n        \"condition\": {\"path\": \"level\", \"op\": \"lt\", \"value\": 20},\n        \"action\": {\"type\": \"log\", \"severity\": \"warning\", \"message\": \"Low battery detected\"},\n        \"rate_limit_per_min\": 10\n    }' > /dev/null\necho -e \"  ${GREEN}✓ Low Battery Warning${NC}\"\n\n# Rule 4: Emergency door monitor (warehouse)\ncurl -s -b \"$COOKIE_JAR\" -X POST \"$API_URL/rules/\" \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\n        \"name\": \"Emergency Door Monitor\",\n        \"description\": \"Log when emergency door is opened\",\n        \"trigger_topic\": \"warehouse/doors/emergency\",\n        \"condition\": {\"path\": \"action\", \"op\": \"eq\", \"value\": \"opened\"},\n        \"action\": {\"type\": \"log\", \"severity\": \"critical\", \"message\": \"Emergency door opened!\"},\n        \"rate_limit_per_min\": 30\n    }' > /dev/null\necho -e \"  ${GREEN}✓ Emergency Door Monitor${NC}\"\n\n# Rule 5: Motion logger\ncurl -s -b \"$COOKIE_JAR\" -X POST \"$API_URL/rules/\" \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\n        \"name\": \"Motion Event Logger\",\n        \"description\": \"Log all motion detection events\",\n        \"trigger_topic\": \"home/+/motion\",\n        \"condition\": {},\n        \"action\": {\"type\": \"log\", \"severity\": \"info\", \"message\": \"Motion detected\"},\n        \"rate_limit_per_min\": 30\n    }' > /dev/null\necho -e \"  ${GREEN}✓ Motion Event Logger${NC}\"\n\n# ---------------------------------------------------------------------------\n# 6. Trigger rules with matching messages\n# ---------------------------------------------------------------------------\necho -e \"${YELLOW}[6/8] Triggering rules with matching messages...${NC}\"\n\n# Trigger home high temp\nfor i in $(seq 1 5); do\n    temp=$(echo \"scale=1; 36 + ($RANDOM % 50) / 10\" | bc)\n    $PUB1 -t \"home/living-room/temperature\" -m \"{\\\"value\\\": $temp, \\\"unit\\\": \\\"C\\\", \\\"device\\\": \\\"DHT22\\\"}\"\n    sleep 0.3\ndone\necho -e \"  ${GREEN}✓ 5 high-temp messages on Broker 1${NC}\"\n\n# Trigger cold storage warnings\nfor i in $(seq 1 5); do\n    temp=$(echo \"scale=1; 6 + ($RANDOM % 40) / 10\" | bc)\n    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]}')\n    $PUB2 -t \"warehouse/$zone/temperature\" -m \"{\\\"value\\\": $temp, \\\"unit\\\": \\\"C\\\", \\\"sensor\\\": \\\"PT100\\\"}\"\n    sleep 0.3\ndone\necho -e \"  ${GREEN}✓ 5 cold storage warnings on Broker 2${NC}\"\n\n# Trigger low battery\nfor i in $(seq 1 3); do\n    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]}')\n    battery=$((3 + RANDOM % 15))\n    $PUB1 -t \"home/$device/battery\" -m \"{\\\"level\\\": $battery, \\\"charging\\\": false}\"\n    sleep 0.3\ndone\necho -e \"  ${GREEN}✓ 3 low-battery messages${NC}\"\n\n# Trigger emergency door\n$PUB2 -t \"warehouse/doors/emergency\" -m '{\"action\": \"opened\", \"badge_id\": \"EMP-999\"}'\necho -e \"  ${GREEN}✓ 1 emergency door event on Broker 2${NC}\"\n\nsleep 3\n\n# ---------------------------------------------------------------------------\n# 7. Create filter presets + bookmarks\n# ---------------------------------------------------------------------------\necho -e \"${YELLOW}[7/8] Creating filter presets and bookmarks...${NC}\"\n\ncurl -s -b \"$COOKIE_JAR\" -X POST \"$API_URL/filter-presets\" \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"name\": \"Home Sensors\", \"description\": \"All home sensor data\", \"filters\": {\"regex_topic\": \"home/.*\"}}' > /dev/null\necho -e \"  ${GREEN}✓ 'Home Sensors' preset${NC}\"\n\ncurl -s -b \"$COOKIE_JAR\" -X POST \"$API_URL/filter-presets\" \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"name\": \"Warehouse Only\", \"description\": \"All warehouse data\", \"filters\": {\"regex_topic\": \"warehouse/.*\"}}' > /dev/null\necho -e \"  ${GREEN}✓ 'Warehouse Only' preset${NC}\"\n\ncurl -s -b \"$COOKIE_JAR\" -X POST \"$API_URL/filter-presets\" \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"name\": \"Alerts\", \"description\": \"Alert topics only\", \"filters\": {\"regex_topic\": \"alerts/.*\"}}' > /dev/null\necho -e \"  ${GREEN}✓ 'Alerts' preset${NC}\"\n\nfor topic in \"home/living-room/temperature\" \"warehouse/zone-A/temperature\" \"warehouse/doors/emergency\"; do\n    curl -s -b \"$COOKIE_JAR\" -X POST \"$API_URL/topics/$(echo $topic | sed 's/\\//%2F/g')/bookmark\" > /dev/null 2>&1\n    echo -e \"  ${GREEN}✓ Bookmarked: $topic${NC}\"\ndone\n\n# ---------------------------------------------------------------------------\n# 8. Verify data via API\n# ---------------------------------------------------------------------------\necho -e \"${YELLOW}[8/8] Verifying data via API...${NC}\"\n\nMSG_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)\necho -e \"  Messages stored: ${GREEN}$MSG_COUNT${NC}\"\n\nBROKER_STATUS=$(curl -s -b \"$COOKIE_JAR\" \"$API_URL/brokers/\" | python3 -c \"\nimport sys,json\nd=json.load(sys.stdin)\nbrokers = (d.get('data') or d).get('brokers',[])\nfor b in brokers:\n    status = '✓ Connected' if b.get('connected') else '✗ Disconnected'\n    print(f\\\"    {b['name']} ({b['host']}:{b['port']}): {status}\\\")\n\" 2>/dev/null)\necho -e \"  Brokers:\"\necho -e \"${GREEN}$BROKER_STATUS${NC}\"\n\nRULE_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)\necho -e \"  Rules created: ${GREEN}$RULE_COUNT${NC}\"\n\nALERT_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)\necho -e \"  Alerts fired: ${GREEN}$ALERT_COUNT${NC}\"\n\nANALYTICS=$(curl -s -b \"$COOKIE_JAR\" \"$API_URL/analytics/topics?limit=5\" | python3 -c \"\nimport sys,json\nd=json.load(sys.stdin)\ntopics = d.get('data',d).get('topics',[])\nfor t in topics[:5]:\n    print(f\\\"    {t['topic']}: {t.get('rate_per_min',0):.0f}/min, {t.get('message_count',0)} msgs\\\")\n\" 2>/dev/null)\necho -e \"  Analytics (top 5 topics):\"\necho -e \"${GREEN}$ANALYTICS${NC}\"\n\n# ---------------------------------------------------------------------------\n# Summary\n# ---------------------------------------------------------------------------\necho \"\"\necho -e \"${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\"\necho -e \"${BLUE} Demo Data Ready! (Multi-Broker)${NC}\"\necho -e \"${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\"\necho \"\"\necho -e \"Open ${GREEN}http://localhost:8088${NC} and check:\"\necho \"\"\necho -e \"  ${YELLOW}Dashboard${NC}\"\necho \"    - Messages from BOTH brokers (look for broker name badges)\"\necho \"    - Topic graph with home/* and warehouse/* nodes\"\necho \"    - Use Broker dropdown in Advanced Search to filter by broker\"\necho \"    - Use ★ Favorites Only to see bookmarked topics\"\necho \"\"\necho -e \"  ${YELLOW}Brokers${NC}\"\necho \"    - Default (mosquitto) — home automation sensors\"\necho \"    - Warehouse (mosquitto2) — industrial sensors\"\necho \"    - Both should show 'Connected' with green indicator\"\necho \"\"\necho -e \"  ${YELLOW}Rules${NC}\"\necho \"    - 5 rules spanning both brokers\"\necho \"    - Home High Temp + Cold Storage + Low Battery + Emergency Door + Motion\"\necho \"\"\necho -e \"  ${YELLOW}Alerts${NC}\"\necho \"    - Alerts from rules on both brokers\"\necho \"    - Filter by rule to see per-broker alerts\"\necho \"\"\necho -e \"  ${YELLOW}Analytics${NC}\"\necho \"    - Topics from both brokers in the same view\"\necho \"    - Temperature histograms for home AND warehouse\"\necho \"\"\n\nrm -f \"$COOKIE_JAR\"\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  mosquitto:\n    image: eclipse-mosquitto:latest\n    container_name: mosquitto\n    ports:\n      - \"1883:1883\"\n      - \"9002:9001\"\n    volumes:\n      - ./mosquitto.conf:/mosquitto/config/mosquitto.conf\n\n  mosquitto2:\n    image: eclipse-mosquitto:latest\n    container_name: mosquitto2\n    ports:\n      - \"1884:1883\"\n    volumes:\n      - ./mosquitto.conf:/mosquitto/config/mosquitto.conf\n\n  web:\n    container_name: mqttui\n    build: .\n    ports:\n      - \"8088:5000\"\n    environment:\n      - DEBUG=False\n      - HOST=0.0.0.0\n      - PORT=5000\n      - MQTT_BROKER=mosquitto\n      - MQTT_PORT=1883\n      - MQTT_USERNAME=\n      - MQTT_PASSWORD=\n      - MQTT_KEEPALIVE=60\n      - MQTT_VERSION=3.1.1\n      - SECRET_KEY=mqttui-dev-secret-change-in-prod\n      - LOG_LEVEL=INFO\n      - MQTT_TOPICS=#\n      - DB_ENABLED=True\n      - DB_PATH=/app/data/mqtt_messages.db\n      - DB_MAX_MESSAGES=10000\n      - DB_CLEANUP_DAYS=30\n      - MQTTUI_ADMIN_USER=admin\n      - MQTTUI_ADMIN_PASSWORD=admin\n      - MQTTUI_RATE_LIMIT=30/minute\n    volumes:\n      - mqtt-data:/app/data\n    depends_on:\n      - mosquitto\n\nvolumes:\n  mqtt-data:"
  },
  {
    "path": "entrypoint.sh",
    "content": "#!/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_LEVEL=\"${LOG_LEVEL:-info}\"\n\n# Convert to lowercase for comparison (sh compatible)\nLOG_LEVEL_LOWER=$(echo \"$LOG_LEVEL\" | tr '[:upper:]' '[:lower:]')\n\n# Validate and normalize log level for gunicorn\ncase \"$LOG_LEVEL_LOWER\" in\n  debug|info|warning|warn|error|critical)\n    # Convert WARN to WARNING for gunicorn compatibility\n    if [ \"$LOG_LEVEL_LOWER\" = \"warn\" ]; then\n      LOG_LEVEL=\"warning\"\n    else\n      LOG_LEVEL=\"$LOG_LEVEL_LOWER\"\n    fi\n    ;;\n  *)\n    echo \"Invalid LOG_LEVEL: $LOG_LEVEL. Using default: info\"\n    LOG_LEVEL=\"info\"\n    ;;\nesac\n\nif [ \"$DEBUG\" = \"True\" ] || [ \"$DEBUG\" = \"1\" ] || [ \"$DEBUG\" = \"true\" ]; then\n    echo \"Running in DEBUG mode with log level: $LOG_LEVEL\"\n    exec python wsgi.py\nelse\n    echo \"Running in PRODUCTION mode with log level: $LOG_LEVEL\"\n    exec gunicorn --log-level \"$LOG_LEVEL\" --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker -w 1 -b \"0.0.0.0:$PORT\" \"wsgi:app\"\nfi\n"
  },
  {
    "path": "mosquitto.conf",
    "content": "listener 1883\nallow_anonymous true\npersistence false\n\n# WebSocket support\nlistener 9001\nprotocol websockets"
  },
  {
    "path": "mqttui/__init__.py",
    "content": "__version__ = \"2.0.0-dev\"\n"
  },
  {
    "path": "mqttui/analytics.py",
    "content": "\"\"\"Per-topic analytics engine with rate counters and numeric payload histograms.\n\nProvides real-time message rate tracking and statistical aggregation for\nnumeric JSON payload fields. Subscribes to mqtt_message_received signal\nfor automatic data collection.\n\"\"\"\nimport json\nimport logging\nimport time\nfrom collections import deque\n\nfrom mqttui.events import mqtt_message_received\n\nlogger = logging.getLogger(__name__)\n\n# Maximum timestamps stored per topic (automatic memory bounding)\nMAX_TIMESTAMPS = 10000\n\n\nclass TopicAnalytics:\n    \"\"\"Track per-topic message rates and numeric payload histograms.\n\n    Data structures per topic:\n        _timestamps: dict[str, deque] -- rolling window of message timestamps\n        _histograms: dict[str, dict] -- per-topic numeric stats per field\n            Each field: {min, max, sum, count}\n    \"\"\"\n\n    def __init__(self):\n        self._timestamps: dict[str, deque] = {}\n        self._histograms: dict[str, dict] = {}\n\n    def record(self, topic: str, payload: str, timestamp: float):\n        \"\"\"Record a message for a topic.\n\n        Args:\n            topic: MQTT topic string\n            payload: Raw payload string (may be JSON)\n            timestamp: Unix timestamp of message receipt\n        \"\"\"\n        # Track timestamp for rate calculation\n        if topic not in self._timestamps:\n            self._timestamps[topic] = deque(maxlen=MAX_TIMESTAMPS)\n        # Ensure timestamp is a Unix float for rate calculations\n        if hasattr(timestamp, 'timestamp'):\n            timestamp = timestamp.timestamp()\n        self._timestamps[topic].append(timestamp)\n\n        # Try to extract numeric fields from JSON payload\n        try:\n            data = json.loads(payload)\n            if isinstance(data, dict):\n                for field_name, value in data.items():\n                    if isinstance(value, (int, float)) and not isinstance(value, bool):\n                        self._update_histogram(topic, field_name, float(value))\n        except (json.JSONDecodeError, TypeError, ValueError):\n            pass\n\n    def _update_histogram(self, topic: str, field_name: str, value: float):\n        \"\"\"Update histogram stats for a numeric field.\"\"\"\n        if topic not in self._histograms:\n            self._histograms[topic] = {}\n\n        if field_name not in self._histograms[topic]:\n            self._histograms[topic][field_name] = {\n                \"min\": value,\n                \"max\": value,\n                \"sum\": value,\n                \"count\": 1,\n            }\n        else:\n            stats = self._histograms[topic][field_name]\n            stats[\"min\"] = min(stats[\"min\"], value)\n            stats[\"max\"] = max(stats[\"max\"], value)\n            stats[\"sum\"] += value\n            stats[\"count\"] += 1\n\n    def get_rate(self, topic: str, window: int = 60) -> float:\n        \"\"\"Count messages within the last `window` seconds.\n\n        Args:\n            topic: MQTT topic string\n            window: Time window in seconds (default 60)\n\n        Returns:\n            Number of messages received within the window.\n        \"\"\"\n        if topic not in self._timestamps:\n            return 0.0\n\n        cutoff = time.time() - window\n        count = sum(1 for ts in self._timestamps[topic] if ts >= cutoff)\n        return float(count)\n\n    def get_topic_stats(self, topic: str) -> dict:\n        \"\"\"Return full stats dict for a single topic.\n\n        Returns:\n            Dict with topic, rate_per_min, rate_per_hour, message_count, histograms\n        \"\"\"\n        ts_deque = self._timestamps.get(topic, deque())\n        return {\n            \"topic\": topic,\n            \"rate_per_min\": self.get_rate(topic, 60),\n            \"rate_per_hour\": self.get_rate(topic, 3600),\n            \"message_count\": len(ts_deque),\n            \"histograms\": self._histograms.get(topic, {}),\n        }\n\n    def get_all_stats(self, limit: int = 20) -> list:\n        \"\"\"Return stats for all tracked topics, sorted by rate_per_min descending.\n\n        Args:\n            limit: Maximum number of topics to return (default 20)\n\n        Returns:\n            List of topic stats dicts.\n        \"\"\"\n        all_topics = list(self._timestamps.keys())\n        stats_list = [self.get_topic_stats(t) for t in all_topics]\n        stats_list.sort(key=lambda s: s[\"rate_per_min\"], reverse=True)\n        return stats_list[:limit]\n\n\n# ---------------------------------------------------------------------------\n# Module-level singleton\n# ---------------------------------------------------------------------------\n\n_analytics = None\n\n\ndef get_analytics() -> TopicAnalytics:\n    \"\"\"Return the module-level TopicAnalytics singleton.\"\"\"\n    global _analytics\n    if _analytics is None:\n        _analytics = TopicAnalytics()\n    return _analytics\n\n\n# ---------------------------------------------------------------------------\n# Signal subscriber\n# ---------------------------------------------------------------------------\n\ndef _on_mqtt_message(sender, **kwargs):\n    \"\"\"Handle mqtt_message_received signal -- record message in analytics.\"\"\"\n    get_analytics().record(\n        kwargs[\"topic\"],\n        kwargs[\"payload\"],\n        kwargs[\"timestamp\"],\n    )\n"
  },
  {
    "path": "mqttui/app.py",
    "content": "import os\n\nimport structlog\nfrom flask import Flask\n\nfrom mqttui.extensions import socketio, sa, login_manager\nfrom mqttui.events import mqtt_message_received, rule_fired, alert_triggered\nfrom mqttui.logging_config import configure_logging\n\n\ndef _on_mqtt_message(sender, **kwargs):\n    \"\"\"Forward MQTT messages to WebSocket clients and persist to database.\"\"\"\n    topic = kwargs['topic']\n    payload = kwargs['payload']\n    timestamp = kwargs['timestamp']\n    qos = kwargs.get('qos', 0)\n    retain = kwargs.get('retain', False)\n\n    # Emit to connected browsers via batch emitter (100ms batching)\n    from mqttui.socketio_batch import get_batch_emitter\n    emitter = get_batch_emitter()\n    msg_data = {\n        'topic': topic,\n        'payload': payload,\n        'timestamp': timestamp.isoformat(),\n        'retain': retain,\n        'broker_id': kwargs.get('broker_id'),\n        'broker_name': kwargs.get('broker_name'),\n    }\n    if emitter:\n        emitter.enqueue(msg_data)\n    else:\n        socketio.emit('mqtt_message', msg_data)\n\n    # Persist to database\n    import mqttui.extensions as ext\n    if ext.db:\n        try:\n            ext.db.store_message(\n                topic=topic, payload=payload,\n                timestamp=timestamp, qos=qos, retain=retain,\n            )\n        except Exception as e:\n            structlog.get_logger(__name__).error(\"Failed to store message\", error=str(e))\n\n\ndef create_app(config=None):\n    \"\"\"Application factory for mqttui.\"\"\"\n    app = Flask(\n        __name__,\n        static_folder='../static',\n        template_folder='../templates'\n    )\n\n    # Load config from environment variables\n    app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'your-secret-key')\n    app.config['DEBUG'] = os.getenv('DEBUG', 'False').lower() in ('true', '1', 't')\n    app.config['HOST'] = os.getenv('HOST', '0.0.0.0')\n    app.config['PORT'] = int(os.getenv('PORT', 5000))\n    app.config['MQTT_BROKER'] = os.getenv('MQTT_BROKER', 'localhost')\n    app.config['MQTT_PORT'] = int(os.getenv('MQTT_PORT', 1883))\n    app.config['MQTT_USERNAME'] = os.getenv('MQTT_USERNAME')\n    app.config['MQTT_PASSWORD'] = os.getenv('MQTT_PASSWORD')\n    app.config['MQTT_KEEPALIVE'] = int(os.getenv('MQTT_KEEPALIVE', 60))\n    app.config['MQTT_VERSION'] = os.getenv('MQTT_VERSION', '3.1.1')\n    app.config['MQTT_TOPICS'] = os.getenv('MQTT_TOPICS', '#')\n    app.config['MQTT_TLS'] = os.getenv('MQTT_TLS', 'false')\n    app.config['MQTT_TLS_CA_CERTS'] = os.getenv('MQTT_TLS_CA_CERTS', '')\n    app.config['MQTT_TLS_CERTFILE'] = os.getenv('MQTT_TLS_CERTFILE', '')\n    app.config['MQTT_TLS_KEYFILE'] = os.getenv('MQTT_TLS_KEYFILE', '')\n    app.config['MQTT_TLS_INSECURE'] = os.getenv('MQTT_TLS_INSECURE', 'false')\n    app.config['DB_ENABLED'] = os.getenv('DB_ENABLED', 'True').lower() in ('true', '1', 't')\n    app.config['DB_PATH'] = os.getenv('DB_PATH', 'mqtt_messages.db')\n    app.config['DB_MAX_MESSAGES'] = int(os.getenv('DB_MAX_MESSAGES', 10000))\n    app.config['DB_CLEANUP_DAYS'] = int(os.getenv('DB_CLEANUP_DAYS', 30))\n\n    # Override with passed config dict\n    if config:\n        app.config.update(config)\n\n    # Set up structured logging via structlog\n    log_level_name = os.getenv('LOG_LEVEL', 'DEBUG' if app.config['DEBUG'] else 'INFO').upper()\n    configure_logging(debug=app.config['DEBUG'], log_level=log_level_name)\n\n    # SECRET_KEY production guard\n    flask_env = os.getenv('FLASK_ENV', 'development')\n    insecure_keys = {'dev', 'change-me', 'your-secret-key'}\n    if flask_env == 'production' and app.config['SECRET_KEY'] in insecure_keys:\n        raise RuntimeError(\n            \"SECRET_KEY is insecure. Set a strong SECRET_KEY environment variable for production.\"\n        )\n\n    # Initialize SocketIO with gevent async mode\n    socketio.init_app(app, async_mode='gevent')\n\n    # Initialize batch emitter for Socket.IO message batching (100ms windows)\n    from mqttui.socketio_batch import init_batch_emitter\n    init_batch_emitter(socketio, interval_ms=100)\n\n    # Enable CORS for API endpoints\n    from flask_cors import CORS\n    CORS(app, resources={r\"/api/*\": {\"origins\": \"*\"}})\n\n    # Configure and initialize SQLAlchemy for user database\n    db_dir = os.path.dirname(os.path.abspath(app.config.get('DB_PATH', 'mqtt_messages.db')))\n    app.config.setdefault('SQLALCHEMY_DATABASE_URI', f\"sqlite:///{os.path.join(db_dir, 'mqttui_users.db')}\")\n    app.config.setdefault('SQLALCHEMY_TRACK_MODIFICATIONS', False)\n    sa.init_app(app)\n    login_manager.init_app(app)\n\n    # Initialize rate limiter\n    from mqttui.extensions import limiter\n    rate_limit = os.getenv('MQTTUI_RATE_LIMIT', '30/minute')\n    app.config['RATELIMIT_DEFAULT'] = rate_limit\n    limiter.init_app(app)\n\n    with app.app_context():\n        from mqttui.models import User  # noqa: F811\n        from mqttui.rules.models import Rule, AlertHistory  # noqa: F401\n        from mqttui.plugins.models import PluginConfig  # noqa: F401\n        sa.create_all()\n\n    # Initialize database if enabled\n    if app.config['DB_ENABLED']:\n        try:\n            from mqttui.database import MessageDatabase\n            import mqttui.extensions as ext\n            db_instance = MessageDatabase(\n                app.config['DB_PATH'],\n                app.config['DB_MAX_MESSAGES']\n            )\n            ext.db = db_instance\n            structlog.get_logger(__name__).info(\"Database initialized\", path=app.config['DB_PATH'])\n        except Exception as e:\n            structlog.get_logger(__name__).error(\"Failed to initialize database\", error=str(e))\n\n    # Register blueprints\n    from mqttui.routes.main import bp as main_bp\n    from mqttui.routes.api import bp as api_bp  # TODO: Remove legacy /api/ routes in Phase 5 after frontend migrates to /api/v1/\n    from mqttui.routes.debug import bp as debug_bp\n    from mqttui.routes.api_v1 import api_v1_bp\n    from mqttui.routes.rules import rules_bp\n    from mqttui.routes.alerts import alerts_bp\n    from mqttui.routes.metrics import metrics_bp\n    from mqttui.routes.analytics import analytics_bp\n    from mqttui.routes.plugins import plugins_bp\n    from mqttui.routes.brokers import brokers_bp\n\n    app.register_blueprint(main_bp)\n    app.register_blueprint(api_bp)\n    app.register_blueprint(debug_bp)\n    app.register_blueprint(api_v1_bp)\n    app.register_blueprint(rules_bp)\n    app.register_blueprint(alerts_bp)\n    app.register_blueprint(metrics_bp)\n    app.register_blueprint(analytics_bp)\n    app.register_blueprint(plugins_bp)\n    app.register_blueprint(brokers_bp)\n\n    # Register auth blueprint and seed admin user\n    from mqttui.auth import auth_bp, seed_admin_user\n    app.register_blueprint(auth_bp)\n    seed_admin_user(app)\n\n    # Initialize MQTT client (paho-mqtt 2.x with event bus)\n    from mqttui.mqtt_client import init_mqtt\n    init_mqtt(app)\n\n    # Wire event bus: forward MQTT messages to SocketIO and database\n    mqtt_message_received.connect(_on_mqtt_message)\n\n    # Wire analytics subscriber to track per-topic rates and histograms\n    from mqttui.analytics import _on_mqtt_message as _on_analytics_message\n    mqtt_message_received.connect(_on_analytics_message)\n\n    # Wire Prometheus metric signal subscribers\n    from mqttui.routes.metrics import (\n        _on_message_for_metrics, _on_rule_fired_for_metrics, _on_alert_for_metrics\n    )\n    mqtt_message_received.connect(_on_message_for_metrics)\n    rule_fired.connect(_on_rule_fired_for_metrics)\n    alert_triggered.connect(_on_alert_for_metrics)\n\n    # Initialize rules engine (Phase 3)\n    from mqttui.rules.engine import RuleEngine\n    rule_engine = RuleEngine(app=app)\n    rule_engine.connect()\n\n    # Initialize plugin registry (Phase 7)\n    from mqttui.plugins.registry import init_plugin_registry\n    init_plugin_registry(app)\n\n    # Initialize plugin runner and wire to event bus (Phase 7)\n    from mqttui.plugins.runner import init_plugin_runner\n    plugin_runner = init_plugin_runner(app)\n    mqtt_message_received.connect(plugin_runner.on_mqtt_message)\n    rule_fired.connect(plugin_runner.on_rule_trigger)\n\n    return app\n"
  },
  {
    "path": "mqttui/auth.py",
    "content": "import os\nimport logging\n\nfrom flask import Blueprint, render_template, redirect, url_for, request, flash\nfrom flask_login import login_user, logout_user, login_required, current_user\n\nfrom mqttui.models import User\nfrom mqttui.extensions import sa, login_manager\n\nlogger = logging.getLogger(__name__)\n\nauth_bp = Blueprint('auth', __name__)\n\n\n@auth_bp.before_app_request\ndef load_user_from_api_key():\n    \"\"\"Check X-API-Key header before session auth.\"\"\"\n    api_key = request.headers.get('X-API-Key')\n    if api_key:\n        user = User.query.filter_by(api_token=api_key).first()\n        if user and user.is_active:\n            login_user(user)\n\n\n@login_manager.user_loader\ndef load_user(user_id):\n    return sa.session.get(User, int(user_id))\n\n\ndef seed_admin_user(app):\n    \"\"\"Create default admin user from environment variables if not exists.\"\"\"\n    admin_username = os.environ.get('MQTTUI_ADMIN_USER', app.config.get('MQTTUI_ADMIN_USER', 'admin'))\n    admin_password = os.environ.get('MQTTUI_ADMIN_PASSWORD', app.config.get('MQTTUI_ADMIN_PASSWORD', 'admin'))\n\n    with app.app_context():\n        existing = User.query.filter_by(username=admin_username).first()\n        if existing:\n            logger.info(\"Admin user already exists\")\n            return\n\n        user = User(username=admin_username)\n        user.set_password(admin_password)\n        user.generate_api_token()\n        sa.session.add(user)\n        sa.session.commit()\n        logger.info(\"Admin user seeded\")\n\n\n@auth_bp.route('/login', methods=['GET'])\ndef login():\n    if current_user.is_authenticated:\n        return redirect(url_for('main.index'))\n    return render_template('login.html')\n\n\n@auth_bp.route('/login', methods=['POST'])\ndef login_post():\n    username = request.form.get('username', '')\n    password = request.form.get('password', '')\n\n    user = User.query.filter_by(username=username).first()\n    if user and user.check_password(password):\n        login_user(user)\n        next_page = request.args.get('next')\n        return redirect(next_page or '/')\n\n    flash('Invalid username or password', 'error')\n    return render_template('login.html'), 200\n\n\n@auth_bp.route('/logout')\ndef logout():\n    logout_user()\n    return redirect(url_for('auth.login'))\n"
  },
  {
    "path": "mqttui/broker_manager.py",
    "content": "\"\"\"Multi-broker connection manager.\n\nManages multiple paho-mqtt client connections, each tied to a Broker model.\nReplaces the single-client approach in mqtt_client.py.\n\"\"\"\nimport os\nimport logging\nimport ssl\nfrom datetime import datetime\n\nimport paho.mqtt.client as mqtt\nfrom paho.mqtt.enums import CallbackAPIVersion\n\nfrom mqttui.events import mqtt_message_received\nimport mqttui.state as state\n\nlogger = logging.getLogger(__name__)\n\nMQTT_RC_CODES = {\n    0: \"Connection successful\",\n    1: \"Incorrect protocol version\",\n    2: \"Invalid client identifier\",\n    3: \"Server unavailable\",\n    4: \"Bad username or password\",\n    5: \"Not authorised\",\n    128: \"Unspecified error\",\n    134: \"Bad user name or password\",\n    135: \"Not authorized\",\n    136: \"Server unavailable\",\n}\n\n# Module-level singleton\n_broker_manager = None\n\n\ndef get_broker_manager():\n    return _broker_manager\n\n\nclass ManagedConnection:\n    \"\"\"A single broker connection with its paho-mqtt client.\"\"\"\n\n    def __init__(self, broker_id, name, host, port, username=None, password=None,\n                 mqtt_version='3.1.1', topics='#', tls_enabled=False,\n                 tls_ca_certs=None, tls_insecure=False):\n        self.broker_id = broker_id\n        self.name = name\n        self.host = host\n        self.port = port\n        self.topics_str = topics\n        self.connected = False\n        self.error = None\n\n        # Create paho-mqtt client\n        protocol = mqtt.MQTTv5 if mqtt_version == '5' else mqtt.MQTTv311\n        self.client = mqtt.Client(\n            callback_api_version=CallbackAPIVersion.VERSION2,\n            client_id=f\"mqttui_{os.getpid()}_{broker_id}\",\n            protocol=protocol,\n        )\n\n        if username and password:\n            self.client.username_pw_set(username, password)\n\n        if tls_enabled:\n            self.client.tls_set(\n                ca_certs=tls_ca_certs if tls_ca_certs else None,\n                cert_reqs=ssl.CERT_NONE if tls_insecure else ssl.CERT_REQUIRED,\n            )\n            if tls_insecure:\n                self.client.tls_insecure_set(True)\n\n        # Wire callbacks\n        self.client.on_connect = self._on_connect\n        self.client.on_disconnect = self._on_disconnect\n        self.client.on_message = self._on_message\n\n    def _on_connect(self, client, userdata, connect_flags, reason_code, properties=None):\n        rc = reason_code.value if hasattr(reason_code, 'value') else int(reason_code)\n        if rc == 0:\n            self.connected = True\n            self.error = None\n            state.connection_count += 1\n            for topic in [t.strip() for t in self.topics_str.split(',')]:\n                client.subscribe(topic)\n                logger.info(f\"[{self.name}] Subscribed to: {topic}\")\n            logger.info(f\"[{self.name}] Connected to {self.host}:{self.port}\")\n        else:\n            self.connected = False\n            self.error = MQTT_RC_CODES.get(rc, f\"Unknown error (rc: {rc})\")\n            logger.error(f\"[{self.name}] Connection failed: {self.error}\")\n\n    def _on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties=None):\n        rc = reason_code.value if hasattr(reason_code, 'value') else int(reason_code)\n        self.connected = False\n        state.connection_count = max(0, state.connection_count - 1)\n        if rc != 0:\n            self.error = MQTT_RC_CODES.get(rc, f\"Unexpected disconnect (rc: {rc})\")\n            logger.warning(f\"[{self.name}] Disconnected: {self.error}\")\n\n    def _on_message(self, client, userdata, msg):\n        try:\n            payload = msg.payload.decode()\n        except UnicodeDecodeError:\n            payload = msg.payload.hex()\n\n        timestamp = datetime.now()\n\n        # Update shared in-memory state\n        message = {\n            'topic': msg.topic,\n            'payload': payload,\n            'timestamp': timestamp.isoformat(),\n            'broker_id': self.broker_id,\n            'broker_name': self.name,\n        }\n        state.messages.append(message)\n        state.topics.add(msg.topic)\n        if len(state.messages) > 100:\n            state.messages.pop(0)\n\n        # Debug bar\n        try:\n            from debug_bar import debug_bar\n            debug_bar.record('mqtt', 'last_topic', msg.topic)\n            debug_bar.record('mqtt', 'last_payload', payload[:200])\n            debug_bar.record('mqtt', 'last_broker', f\"{self.name} ({self.host}:{self.port})\")\n            debug_bar.record('mqtt', 'last_timestamp', timestamp.isoformat())\n        except Exception:\n            pass\n\n        # Fire event bus signal with broker context\n        mqtt_message_received.send(\n            'broker_manager',\n            topic=msg.topic,\n            payload=payload,\n            timestamp=timestamp,\n            qos=msg.qos,\n            retain=msg.retain,\n            broker_id=self.broker_id,\n            broker_name=self.name,\n        )\n\n    def start(self):\n        try:\n            self.client.connect(self.host, self.port, keepalive=60)\n            self.client.loop_start()\n            logger.info(f\"[{self.name}] Connecting to {self.host}:{self.port}\")\n        except Exception as e:\n            self.connected = False\n            self.error = str(e)\n            logger.error(f\"[{self.name}] Failed to connect: {e}\")\n\n    def stop(self):\n        try:\n            self.client.loop_stop()\n            self.client.disconnect()\n        except Exception:\n            pass\n        self.connected = False\n\n    def publish(self, topic, payload, qos=0, retain=False):\n        if self.client and self.connected:\n            self.client.publish(topic, payload, qos=qos, retain=retain)\n\n    def status_dict(self):\n        return {\n            'broker_id': self.broker_id,\n            'name': self.name,\n            'host': self.host,\n            'port': self.port,\n            'connected': self.connected,\n            'error': self.error,\n        }\n\n\nclass BrokerManager:\n    \"\"\"Manages multiple MQTT broker connections.\"\"\"\n\n    def __init__(self, app=None):\n        self.app = app\n        self.connections = {}  # broker_id -> ManagedConnection\n\n    def add_connection(self, broker):\n        \"\"\"Add and start a connection from a Broker model instance or dict.\"\"\"\n        if isinstance(broker, dict):\n            broker_id = broker['id']\n            conn = ManagedConnection(\n                broker_id=broker_id,\n                name=broker['name'],\n                host=broker['host'],\n                port=broker.get('port', 1883),\n                username=broker.get('username'),\n                password=broker.get('password'),\n                mqtt_version=broker.get('mqtt_version', '3.1.1'),\n                topics=broker.get('topics', '#'),\n                tls_enabled=broker.get('tls_enabled', False),\n                tls_ca_certs=broker.get('tls_ca_certs'),\n                tls_insecure=broker.get('tls_insecure', False),\n            )\n        else:\n            broker_id = broker.id\n            conn = ManagedConnection(\n                broker_id=broker.id,\n                name=broker.name,\n                host=broker.host,\n                port=broker.port,\n                username=broker.username,\n                password=broker.password,\n                mqtt_version=broker.mqtt_version,\n                topics=broker.topics,\n                tls_enabled=broker.tls_enabled,\n                tls_ca_certs=broker.tls_ca_certs,\n                tls_insecure=broker.tls_insecure,\n            )\n\n        # Stop existing connection if replacing\n        if broker_id in self.connections:\n            self.connections[broker_id].stop()\n\n        self.connections[broker_id] = conn\n        conn.start()\n        return conn\n\n    def remove_connection(self, broker_id):\n        conn = self.connections.pop(broker_id, None)\n        if conn:\n            conn.stop()\n\n    def get_connection(self, broker_id):\n        return self.connections.get(broker_id)\n\n    def get_default_connection(self):\n        \"\"\"Return the first connected broker, or first broker overall.\"\"\"\n        for conn in self.connections.values():\n            if conn.connected:\n                return conn\n        # Fallback to first\n        if self.connections:\n            return next(iter(self.connections.values()))\n        return None\n\n    def publish(self, topic, payload, qos=0, retain=False, broker_id=None):\n        \"\"\"Publish to a specific broker or the default one.\"\"\"\n        if broker_id and broker_id in self.connections:\n            self.connections[broker_id].publish(topic, payload, qos, retain)\n        else:\n            conn = self.get_default_connection()\n            if conn:\n                conn.publish(topic, payload, qos, retain)\n\n    def all_statuses(self):\n        return [conn.status_dict() for conn in self.connections.values()]\n\n    def start_all(self, app):\n        \"\"\"Load active brokers from DB and start connections.\"\"\"\n        with app.app_context():\n            from mqttui.models import Broker\n            brokers = Broker.query.filter_by(is_active=True).all()\n            for broker in brokers:\n                self.add_connection(broker)\n            logger.info(f\"BrokerManager started {len(brokers)} connection(s)\")\n\n    def stop_all(self):\n        for conn in self.connections.values():\n            conn.stop()\n        self.connections.clear()\n\n    def load_env_broker(self, app):\n        \"\"\"Create a default broker from environment variables if no brokers in DB.\"\"\"\n        with app.app_context():\n            from mqttui.models import Broker\n            if Broker.query.count() > 0:\n                return  # DB already has brokers\n\n            # Seed from env vars (backward compatible with v1.x)\n            broker = Broker(\n                name='Default',\n                host=app.config['MQTT_BROKER'],\n                port=app.config['MQTT_PORT'],\n                username=app.config.get('MQTT_USERNAME'),\n                password=app.config.get('MQTT_PASSWORD'),\n                mqtt_version=app.config['MQTT_VERSION'],\n                topics=app.config['MQTT_TOPICS'],\n                tls_enabled=app.config.get('MQTT_TLS', 'false').lower() in ('true', '1', 'yes'),\n                tls_ca_certs=app.config.get('MQTT_TLS_CA_CERTS') or None,\n                tls_insecure=app.config.get('MQTT_TLS_INSECURE', 'false').lower() in ('true', '1', 'yes'),\n                is_active=True,\n                is_default=True,\n            )\n            from mqttui.extensions import sa\n            sa.session.add(broker)\n            sa.session.commit()\n            logger.info(f\"Seeded default broker from env: {broker.host}:{broker.port}\")\n\n\ndef init_broker_manager(app):\n    \"\"\"Initialize the global broker manager. Call from create_app().\"\"\"\n    global _broker_manager\n    _broker_manager = BrokerManager(app=app)\n    _broker_manager.load_env_broker(app)\n    _broker_manager.start_all(app)\n    return _broker_manager\n"
  },
  {
    "path": "mqttui/database.py",
    "content": "\"\"\"\nDatabase module for MQTT message persistence\n\"\"\"\nimport sqlite3\nimport threading\nimport logging\nimport re\nimport json\nfrom datetime import datetime, timedelta\nfrom typing import List, Dict, Optional, Union\nimport os\n\n\nclass MessageDatabase:\n    def __init__(self, db_path: str = \"mqtt_messages.db\", max_messages: int = 10000):\n        self.db_path = db_path\n        self.max_messages = max_messages\n        self._local = threading.local()\n        self.init_database()\n\n    def get_connection(self):\n        \"\"\"Get thread-local database connection\"\"\"\n        if not hasattr(self._local, 'connection'):\n            self._local.connection = sqlite3.connect(\n                self.db_path,\n                check_same_thread=False,\n                timeout=30.0\n            )\n            self._local.connection.row_factory = sqlite3.Row\n            # Enable WAL mode for concurrent read/write\n            self._local.connection.execute('PRAGMA journal_mode=WAL')\n            self._local.connection.execute('PRAGMA busy_timeout=5000')\n            # Add REGEXP function for advanced topic filtering\n            self._local.connection.create_function(\"REGEXP\", 2, self._regexp)\n\n        return self._local.connection\n\n    def _regexp(self, pattern, value):\n        \"\"\"Custom REGEXP function for SQLite\"\"\"\n        try:\n            return re.search(pattern, value, re.IGNORECASE) is not None\n        except Exception:\n            return False\n\n    def init_database(self):\n        \"\"\"Initialize database schema\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n\n            # Messages table\n            cursor.execute('''\n                CREATE TABLE IF NOT EXISTS messages (\n                    id INTEGER PRIMARY KEY AUTOINCREMENT,\n                    topic TEXT NOT NULL,\n                    payload TEXT NOT NULL,\n                    timestamp DATETIME NOT NULL,\n                    qos INTEGER DEFAULT 0,\n                    retain BOOLEAN DEFAULT 0,\n                    payload_size INTEGER DEFAULT 0,\n                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n                )\n            ''')\n\n            # Topics table for faster topic queries\n            cursor.execute('''\n                CREATE TABLE IF NOT EXISTS topics (\n                    topic TEXT PRIMARY KEY,\n                    last_message_at DATETIME NOT NULL,\n                    message_count INTEGER DEFAULT 1,\n                    first_seen DATETIME DEFAULT CURRENT_TIMESTAMP\n                )\n            ''')\n\n            # Filter presets table for saved searches\n            cursor.execute('''\n                CREATE TABLE IF NOT EXISTS filter_presets (\n                    id INTEGER PRIMARY KEY AUTOINCREMENT,\n                    name TEXT UNIQUE NOT NULL,\n                    description TEXT,\n                    filters TEXT NOT NULL,  -- JSON string of filter parameters\n                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n                    last_used DATETIME\n                )\n            ''')\n\n            # Create indexes for better performance\n            cursor.execute('''\n                CREATE INDEX IF NOT EXISTS idx_messages_topic\n                ON messages(topic)\n            ''')\n\n            cursor.execute('''\n                CREATE INDEX IF NOT EXISTS idx_messages_timestamp\n                ON messages(timestamp DESC)\n            ''')\n\n            cursor.execute('''\n                CREATE INDEX IF NOT EXISTS idx_messages_topic_timestamp\n                ON messages(topic, timestamp DESC)\n            ''')\n\n            conn.commit()\n            logging.info(\"Database initialized successfully\")\n\n        except Exception as e:\n            logging.error(f\"Database initialization error: {e}\")\n            conn.rollback()\n            raise\n\n    def store_message(self, topic: str, payload: str, timestamp: datetime = None,\n                      qos: int = 0, retain: bool = False) -> bool:\n        \"\"\"Store a message in the database\"\"\"\n        if timestamp is None:\n            timestamp = datetime.now()\n\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n\n            # Store message\n            cursor.execute('''\n                INSERT INTO messages (topic, payload, timestamp, qos, retain, payload_size)\n                VALUES (?, ?, ?, ?, ?, ?)\n            ''', (topic, payload, timestamp, qos, retain, len(payload)))\n\n            # Update topics table\n            cursor.execute('''\n                INSERT OR REPLACE INTO topics (topic, last_message_at, message_count, first_seen)\n                VALUES (?, ?,\n                    COALESCE((SELECT message_count FROM topics WHERE topic = ?) + 1, 1),\n                    COALESCE((SELECT first_seen FROM topics WHERE topic = ?), ?)\n                )\n            ''', (topic, timestamp, topic, topic, timestamp))\n\n            conn.commit()\n\n            # Clean up old messages if needed\n            self._cleanup_old_messages()\n            return True\n\n        except Exception as e:\n            logging.error(f\"Error storing message: {e}\")\n            conn.rollback()\n            return False\n\n    def get_messages(self, limit: int = 100, offset: int = 0,\n                     topic_filter: str = None, since: datetime = None,\n                     content_search: str = None, regex_topic: str = None,\n                     json_path: str = None, json_value: str = None) -> List[Dict]:\n        \"\"\"Retrieve messages from database with enhanced filtering\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n\n            query = '''\n                SELECT topic, payload, timestamp, qos, retain, payload_size\n                FROM messages\n                WHERE 1=1\n            '''\n            params = []\n\n            # Basic topic filtering (wildcard support)\n            if topic_filter:\n                if '%' in topic_filter or '_' in topic_filter:\n                    query += ' AND topic LIKE ?'\n                else:\n                    query += ' AND topic = ?'\n                params.append(topic_filter)\n\n            # Regex topic filtering (more powerful than LIKE)\n            if regex_topic:\n                query += ' AND topic REGEXP ?'\n                params.append(regex_topic)\n\n            # Content search (case-insensitive)\n            if content_search:\n                query += ' AND LOWER(payload) LIKE LOWER(?)'\n                params.append(f'%{content_search}%')\n\n            # Time filtering\n            if since:\n                query += ' AND timestamp >= ?'\n                params.append(since)\n\n            query += ' ORDER BY timestamp DESC LIMIT ? OFFSET ?'\n            params.extend([limit, offset])\n\n            cursor.execute(query, params)\n            rows = cursor.fetchall()\n\n            messages = [dict(row) for row in rows]\n\n            # Apply Python-based filters that can't be done in SQL\n            if json_path or regex_topic:\n                messages = self._apply_python_filters(\n                    messages, regex_topic, json_path, json_value\n                )\n\n            return messages\n\n        except Exception as e:\n            logging.error(f\"Error retrieving messages: {e}\")\n            return []\n\n    def _apply_python_filters(self, messages: List[Dict], regex_topic: str = None,\n                              json_path: str = None, json_value: str = None) -> List[Dict]:\n        \"\"\"Apply filters that require Python processing\"\"\"\n        filtered = []\n\n        for msg in messages:\n            # Regex topic filter (if not handled by SQL REGEXP)\n            if regex_topic and not re.search(regex_topic, msg['topic'], re.IGNORECASE):\n                continue\n\n            # JSON path filtering\n            if json_path and json_value:\n                try:\n                    payload_json = json.loads(msg['payload'])\n                    value = self._get_json_path_value(payload_json, json_path)\n                    if value is None or str(value).lower() != json_value.lower():\n                        continue\n                except (json.JSONDecodeError, KeyError):\n                    continue\n\n            filtered.append(msg)\n\n        return filtered\n\n    def _get_json_path_value(self, json_obj: dict, path: str):\n        \"\"\"Extract value from JSON using simple dot notation path\"\"\"\n        try:\n            keys = path.split('.')\n            current = json_obj\n            for key in keys:\n                if isinstance(current, dict) and key in current:\n                    current = current[key]\n                else:\n                    return None\n            return current\n        except Exception:\n            return None\n\n    def get_topics(self) -> List[Dict]:\n        \"\"\"Get all topics with statistics\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            cursor.execute('''\n                SELECT topic, message_count, last_message_at, first_seen\n                FROM topics\n                ORDER BY last_message_at DESC\n            ''')\n            rows = cursor.fetchall()\n            return [dict(row) for row in rows]\n\n        except Exception as e:\n            logging.error(f\"Error retrieving topics: {e}\")\n            return []\n\n    def get_message_count(self, topic_filter: str = None, since: datetime = None) -> int:\n        \"\"\"Get total message count with optional filtering\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n\n            query = 'SELECT COUNT(*) FROM messages WHERE 1=1'\n            params = []\n\n            if topic_filter:\n                if '%' in topic_filter or '_' in topic_filter:\n                    query += ' AND topic LIKE ?'\n                else:\n                    query += ' AND topic = ?'\n                params.append(topic_filter)\n\n            if since:\n                query += ' AND timestamp >= ?'\n                params.append(since)\n\n            cursor.execute(query, params)\n            return cursor.fetchone()[0]\n\n        except Exception as e:\n            logging.error(f\"Error counting messages: {e}\")\n            return 0\n\n    def _cleanup_old_messages(self):\n        \"\"\"Remove old messages to stay within limit\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n\n            cursor.execute('SELECT COUNT(*) FROM messages')\n            count = cursor.fetchone()[0]\n\n            if count > self.max_messages:\n                messages_to_delete = count - self.max_messages + 1000\n                cursor.execute('''\n                    DELETE FROM messages\n                    WHERE id IN (\n                        SELECT id FROM messages\n                        ORDER BY timestamp ASC\n                        LIMIT ?\n                    )\n                ''', (messages_to_delete,))\n\n                cursor.execute('''\n                    UPDATE topics SET message_count = (\n                        SELECT COUNT(*) FROM messages WHERE messages.topic = topics.topic\n                    )\n                ''')\n\n                conn.commit()\n                logging.info(f\"Cleaned up {messages_to_delete} old messages\")\n\n        except Exception as e:\n            logging.error(f\"Error during cleanup: {e}\")\n            conn.rollback()\n\n    def cleanup_old_data(self, days: int = 30):\n        \"\"\"Remove messages older than specified days\"\"\"\n        cutoff_date = datetime.now() - timedelta(days=days)\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            cursor.execute('DELETE FROM messages WHERE timestamp < ?', (cutoff_date,))\n            deleted = cursor.rowcount\n\n            cursor.execute('''\n                DELETE FROM topics WHERE topic NOT IN (\n                    SELECT DISTINCT topic FROM messages\n                )\n            ''')\n\n            conn.commit()\n            logging.info(f\"Cleaned up {deleted} messages older than {days} days\")\n            return deleted\n\n        except Exception as e:\n            logging.error(f\"Error during data cleanup: {e}\")\n            conn.rollback()\n            return 0\n\n    def get_database_size(self) -> Dict:\n        \"\"\"Get database size information\"\"\"\n        try:\n            size_bytes = os.path.getsize(self.db_path) if os.path.exists(self.db_path) else 0\n            conn = self.get_connection()\n            cursor = conn.cursor()\n\n            cursor.execute('SELECT COUNT(*) FROM messages')\n            message_count = cursor.fetchone()[0]\n\n            cursor.execute('SELECT COUNT(*) FROM topics')\n            topic_count = cursor.fetchone()[0]\n\n            return {\n                'size_bytes': size_bytes,\n                'size_mb': round(size_bytes / 1024 / 1024, 2),\n                'message_count': message_count,\n                'topic_count': topic_count\n            }\n\n        except Exception as e:\n            logging.error(f\"Error getting database size: {e}\")\n            return {'size_bytes': 0, 'size_mb': 0, 'message_count': 0, 'topic_count': 0}\n\n    def save_filter_preset(self, name: str, filters: Dict, description: str = None) -> bool:\n        \"\"\"Save a filter preset\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            cursor.execute('''\n                INSERT OR REPLACE INTO filter_presets (name, description, filters, last_used)\n                VALUES (?, ?, ?, ?)\n            ''', (name, description, json.dumps(filters), datetime.now()))\n\n            conn.commit()\n            logging.info(f\"Saved filter preset: {name}\")\n            return True\n\n        except Exception as e:\n            logging.error(f\"Error saving filter preset: {e}\")\n            conn.rollback()\n            return False\n\n    def get_filter_presets(self) -> List[Dict]:\n        \"\"\"Get all filter presets\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            cursor.execute('''\n                SELECT name, description, filters, created_at, last_used\n                FROM filter_presets\n                ORDER BY last_used DESC, created_at DESC\n            ''')\n            rows = cursor.fetchall()\n\n            presets = []\n            for row in rows:\n                preset = dict(row)\n                preset['filters'] = json.loads(preset['filters'])\n                presets.append(preset)\n\n            return presets\n\n        except Exception as e:\n            logging.error(f\"Error getting filter presets: {e}\")\n            return []\n\n    def delete_filter_preset(self, name: str) -> bool:\n        \"\"\"Delete a filter preset\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n            cursor.execute('DELETE FROM filter_presets WHERE name = ?', (name,))\n            conn.commit()\n\n            if cursor.rowcount > 0:\n                logging.info(f\"Deleted filter preset: {name}\")\n                return True\n            return False\n\n        except Exception as e:\n            logging.error(f\"Error deleting filter preset: {e}\")\n            conn.rollback()\n            return False\n\n    def use_filter_preset(self, name: str) -> Optional[Dict]:\n        \"\"\"Load and mark a filter preset as used\"\"\"\n        conn = self.get_connection()\n        try:\n            cursor = conn.cursor()\n\n            cursor.execute('''\n                SELECT filters FROM filter_presets WHERE name = ?\n            ''', (name,))\n            row = cursor.fetchone()\n\n            if not row:\n                return None\n\n            cursor.execute('''\n                UPDATE filter_presets SET last_used = ? WHERE name = ?\n            ''', (datetime.now(), name))\n\n            conn.commit()\n            return json.loads(row['filters'])\n\n        except Exception as e:\n            logging.error(f\"Error using filter preset: {e}\")\n            return None\n\n    def close(self):\n        \"\"\"Close database connection\"\"\"\n        if hasattr(self._local, 'connection'):\n            self._local.connection.close()\n"
  },
  {
    "path": "mqttui/events.py",
    "content": "from blinker import Namespace\n\nmqttui_signals = Namespace()\n\n# Fired when an MQTT message is received from the broker\n# sender: mqtt_client module, kwargs: topic, payload, timestamp, qos, retain\nmqtt_message_received = mqttui_signals.signal('mqtt-message-received')\n\n# Fired when an automation rule fires (Phase 3 will use this)\n# sender: rules engine, kwargs: rule_id, rule_name, topic, payload\nrule_fired = mqttui_signals.signal('rule-fired')\n\n# Fired when an alert is triggered (Phase 4 will use this)\n# sender: alerting module, kwargs: alert_id, rule_id, message\nalert_triggered = mqttui_signals.signal('alert-triggered')\n\n# Fired when a rule is created, updated, or deleted (Phase 3 hot-reload)\n# sender: rules API, kwargs: action ('created'|'updated'|'deleted'), rule_id\nrule_changed = mqttui_signals.signal('rule-changed')\n"
  },
  {
    "path": "mqttui/extensions.py",
    "content": "from flask_socketio import SocketIO\nfrom flask_sqlalchemy import SQLAlchemy\nfrom flask_login import LoginManager\nfrom flask_limiter import Limiter\nfrom flask_limiter.util import get_remote_address\n\nsocketio = SocketIO()\ndb = None  # Will be set in create_app (MessageDatabase instance)\n\nsa = SQLAlchemy()\nlogin_manager = LoginManager()\nlogin_manager.login_view = 'auth.login'\n\nlimiter = Limiter(key_func=get_remote_address, storage_uri=\"memory://\")\n"
  },
  {
    "path": "mqttui/helpers.py",
    "content": "\"\"\"API response helpers for consistent JSON envelope format.\"\"\"\n\nfrom flask import jsonify\n\n\ndef api_success(data=None, status_code=200):\n    \"\"\"Standard success envelope.\"\"\"\n    return jsonify({\"status\": \"success\", \"data\": data, \"error\": None}), status_code\n\n\ndef api_error(message, code=\"UNKNOWN_ERROR\", status_code=400):\n    \"\"\"Standard error envelope.\"\"\"\n    return jsonify({\n        \"status\": \"error\",\n        \"data\": None,\n        \"error\": {\"code\": code, \"message\": message}\n    }), status_code\n"
  },
  {
    "path": "mqttui/logging_config.py",
    "content": "\"\"\"Structured logging configuration using structlog.\n\nProvides JSON output in production and colored console output in development.\n\"\"\"\nimport structlog\nimport logging\nimport sys\n\n\ndef configure_logging(debug=False, log_level='INFO'):\n    \"\"\"Configure structlog with JSON (prod) or console (dev) output.\n\n    Args:\n        debug: If True, use colored console renderer. If False, use JSON.\n        log_level: Standard logging level name (DEBUG, INFO, WARNING, ERROR, CRITICAL).\n    \"\"\"\n    shared_processors = [\n        structlog.contextvars.merge_contextvars,\n        structlog.stdlib.add_log_level,\n        structlog.stdlib.add_logger_name,\n        structlog.processors.TimeStamper(fmt=\"iso\"),\n        structlog.processors.StackInfoRenderer(),\n        structlog.processors.format_exc_info,\n    ]\n\n    if debug:\n        renderer = structlog.dev.ConsoleRenderer(colors=True)\n    else:\n        renderer = structlog.processors.JSONRenderer()\n\n    structlog.configure(\n        processors=shared_processors + [\n            structlog.stdlib.ProcessorFormatter.wrap_for_formatter,\n        ],\n        logger_factory=structlog.stdlib.LoggerFactory(),\n        wrapper_class=structlog.stdlib.BoundLogger,\n        cache_logger_on_first_use=True,\n    )\n\n    formatter = structlog.stdlib.ProcessorFormatter(\n        processors=[\n            structlog.stdlib.ProcessorFormatter.remove_processors_meta,\n            renderer,\n        ],\n    )\n\n    handler = logging.StreamHandler(sys.stdout)\n    handler.setFormatter(formatter)\n\n    root = logging.getLogger()\n    root.handlers.clear()\n    root.addHandler(handler)\n    root.setLevel(getattr(logging, log_level.upper(), logging.INFO))\n"
  },
  {
    "path": "mqttui/models.py",
    "content": "from mqttui.extensions import sa\nfrom flask_login import UserMixin\nfrom werkzeug.security import generate_password_hash, check_password_hash\nimport secrets\n\n\nclass User(UserMixin, sa.Model):\n    __tablename__ = 'users'\n\n    id = sa.Column(sa.Integer, primary_key=True)\n    username = sa.Column(sa.String(80), unique=True, nullable=False)\n    password_hash = sa.Column(sa.String(256), nullable=False)\n    api_token = sa.Column(sa.String(64), unique=True, nullable=True)\n    is_active_user = sa.Column(sa.Boolean, default=True)\n    created_at = sa.Column(sa.DateTime, server_default=sa.func.now())\n\n    def set_password(self, password):\n        self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')\n\n    def check_password(self, password):\n        return check_password_hash(self.password_hash, password)\n\n    def generate_api_token(self):\n        self.api_token = secrets.token_hex(32)\n        return self.api_token\n\n    @property\n    def is_active(self):\n        return self.is_active_user\n\n\nclass TopicFavorite(sa.Model):\n    __tablename__ = 'topic_favorites'\n\n    id = sa.Column(sa.Integer, primary_key=True)\n    user_id = sa.Column(sa.Integer, sa.ForeignKey('users.id'), nullable=False)\n    topic = sa.Column(sa.String(500), nullable=False)\n    created_at = sa.Column(sa.DateTime, server_default=sa.func.now())\n\n    __table_args__ = (sa.UniqueConstraint('user_id', 'topic', name='uq_user_topic'),)\n\n    def to_dict(self):\n        return {\n            'id': self.id,\n            'user_id': self.user_id,\n            'topic': self.topic,\n            'created_at': self.created_at.isoformat() if self.created_at else None,\n        }\n\n\nclass Broker(sa.Model):\n    __tablename__ = 'brokers'\n\n    id = sa.Column(sa.Integer, primary_key=True)\n    name = sa.Column(sa.String(200), nullable=False)\n    host = sa.Column(sa.String(500), nullable=False)\n    port = sa.Column(sa.Integer, default=1883)\n    username = sa.Column(sa.String(200), nullable=True)\n    password = sa.Column(sa.String(200), nullable=True)\n    mqtt_version = sa.Column(sa.String(10), default='3.1.1')\n    topics = sa.Column(sa.String(1000), default='#')\n    tls_enabled = sa.Column(sa.Boolean, default=False)\n    tls_ca_certs = sa.Column(sa.String(500), nullable=True)\n    tls_insecure = sa.Column(sa.Boolean, default=False)\n    is_active = sa.Column(sa.Boolean, default=True)\n    is_default = sa.Column(sa.Boolean, default=False)\n    created_at = sa.Column(sa.DateTime, server_default=sa.func.now())\n\n    def to_dict(self):\n        return {\n            'id': self.id,\n            'name': self.name,\n            'host': self.host,\n            'port': self.port,\n            'username': self.username,\n            'has_password': bool(self.password),\n            'mqtt_version': self.mqtt_version,\n            'topics': self.topics,\n            'tls_enabled': self.tls_enabled,\n            'tls_insecure': self.tls_insecure,\n            'is_active': self.is_active,\n            'is_default': self.is_default,\n            'created_at': self.created_at.isoformat() if self.created_at else None,\n        }\n"
  },
  {
    "path": "mqttui/mqtt_client.py",
    "content": "\"\"\"MQTT client compatibility layer.\n\nDelegates to BrokerManager for multi-broker support while keeping the\nsame publish() API that rules engine and other modules use.\n\"\"\"\nimport logging\n\nlogger = logging.getLogger(__name__)\n\n\ndef init_mqtt(app):\n    \"\"\"Initialize MQTT connections via BrokerManager. Call inside create_app().\"\"\"\n    from mqttui.broker_manager import init_broker_manager\n    init_broker_manager(app)\n    logger.info(\"MQTT client initialized via BrokerManager\")\n\n\ndef get_client():\n    \"\"\"Get the default MQTT client instance (backward compat).\"\"\"\n    from mqttui.broker_manager import get_broker_manager\n    mgr = get_broker_manager()\n    if mgr:\n        conn = mgr.get_default_connection()\n        if conn:\n            return conn.client\n    return None\n\n\ndef publish(topic, payload, qos=0, retain=False, broker_id=None):\n    \"\"\"Publish a message. Routes to specific broker or default.\"\"\"\n    from mqttui.broker_manager import get_broker_manager\n    mgr = get_broker_manager()\n    if mgr:\n        mgr.publish(topic, payload, qos=qos, retain=retain, broker_id=broker_id)\n"
  },
  {
    "path": "mqttui/plugins/__init__.py",
    "content": ""
  },
  {
    "path": "mqttui/plugins/examples/__init__.py",
    "content": ""
  },
  {
    "path": "mqttui/plugins/examples/json_formatter.py",
    "content": "#!/usr/bin/env python3\n\"\"\"JSON formatter example plugin for mqttui.\n\nReads a JSON event from stdin, pretty-prints JSON payloads,\nand writes the result to stdout following the plugin protocol.\n\nProtocol:\n    stdin:  {\"event\": \"on_message\", \"data\": {\"topic\": \"...\", \"payload\": \"...\"}}\n    stdout: {\"actions\": [{\"type\": \"transform\", \"result\": \"...\"}]}\n\"\"\"\nimport json\nimport sys\n\n\ndef main():\n    try:\n        line = sys.stdin.readline()\n        if not line:\n            print(json.dumps({\"actions\": []}))\n            return\n\n        envelope = json.loads(line)\n        event = envelope.get(\"event\", \"\")\n        data = envelope.get(\"data\", {})\n\n        if event != \"on_message\":\n            print(json.dumps({\"actions\": []}))\n            return\n\n        payload = data.get(\"payload\", \"\")\n        try:\n            parsed = json.loads(payload)\n            pretty = json.dumps(parsed, indent=2)\n            print(json.dumps({\"actions\": [{\"type\": \"transform\", \"result\": pretty}]}))\n        except (json.JSONDecodeError, TypeError):\n            print(json.dumps({\"actions\": []}))\n\n    except Exception:\n        print(json.dumps({\"actions\": []}))\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "mqttui/plugins/examples/topic_logger.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Topic logger example plugin for mqttui.\n\nReads a JSON event from stdin, logs the topic and payload to stderr,\nand writes a log action to stdout following the plugin protocol.\n\nProtocol:\n    stdin:  {\"event\": \"on_message\", \"data\": {\"topic\": \"...\", \"payload\": \"...\"}}\n    stdout: {\"actions\": [{\"type\": \"log\", \"message\": \"Topic: ..., Payload: ...\"}]}\n\"\"\"\nimport json\nimport sys\n\n\ndef main():\n    try:\n        line = sys.stdin.readline()\n        if not line:\n            print(json.dumps({\"actions\": []}))\n            return\n\n        envelope = json.loads(line)\n        event = envelope.get(\"event\", \"\")\n        data = envelope.get(\"data\", {})\n\n        if event != \"on_message\":\n            print(json.dumps({\"actions\": []}))\n            return\n\n        topic = data.get(\"topic\", \"\")\n        payload = data.get(\"payload\", \"\")\n\n        # Log to stderr (stdout is reserved for protocol)\n        print(f\"[topic_logger] Topic: {topic}, Payload: {payload}\", file=sys.stderr)\n\n        # Return log action via protocol\n        message = f\"Topic: {topic}, Payload: {payload}\"\n        print(json.dumps({\"actions\": [{\"type\": \"log\", \"message\": message}]}))\n\n    except Exception:\n        print(json.dumps({\"actions\": []}))\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "mqttui/plugins/hookspec.py",
    "content": "\"\"\"Plugin hook specifications for mqttui.\"\"\"\nfrom __future__ import annotations\n\nimport pluggy\n\nPROJECT_NAME = \"mqttui\"\n\nhookspec = pluggy.HookspecMarker(PROJECT_NAME)\nhookimpl = pluggy.HookimplMarker(PROJECT_NAME)\n\n\nclass MQTTUIPlugin:\n    \"\"\"Hook specifications for mqttui plugins.\n\n    Plugin authors implement these hooks using the @hookimpl decorator.\n    \"\"\"\n\n    @hookspec\n    def on_message(self, topic: str, payload: str) -> dict | None:\n        \"\"\"Called when an MQTT message is received.\n\n        Args:\n            topic: The MQTT topic string.\n            payload: The message payload as a string.\n\n        Returns:\n            Optional dict with transformed/enriched data, or None.\n        \"\"\"\n\n    @hookspec\n    def on_connect(self) -> None:\n        \"\"\"Called when the MQTT client connects to the broker.\"\"\"\n\n    @hookspec\n    def on_rule_trigger(self, rule_name: str, topic: str, payload: str) -> dict | None:\n        \"\"\"Called when an automation rule fires.\n\n        Args:\n            rule_name: Name of the triggered rule.\n            topic: The MQTT topic that triggered the rule.\n            payload: The message payload.\n\n        Returns:\n            Optional dict with additional context or modifications.\n        \"\"\"\n"
  },
  {
    "path": "mqttui/plugins/models.py",
    "content": "\"\"\"SQLAlchemy model for plugin configuration persistence.\"\"\"\nfrom mqttui.extensions import sa\n\n\nclass PluginConfig(sa.Model):\n    __tablename__ = 'plugin_configs'\n\n    id = sa.Column(sa.Integer, primary_key=True)\n    name = sa.Column(sa.String(200), unique=True, nullable=False)\n    entry_point = sa.Column(sa.String(500), nullable=False)\n    enabled = sa.Column(sa.Boolean, default=False)\n    config_json = sa.Column(sa.Text, default='{}')\n    version = sa.Column(sa.String(50), nullable=True)\n    description = sa.Column(sa.Text, nullable=True)\n    installed_at = sa.Column(sa.DateTime, server_default=sa.func.now())\n\n    def to_dict(self):\n        return {\n            'id': self.id,\n            'name': self.name,\n            'entry_point': self.entry_point,\n            'enabled': self.enabled,\n            'config_json': self.config_json,\n            'version': self.version,\n            'description': self.description,\n            'installed_at': self.installed_at.isoformat() if self.installed_at else None,\n        }\n"
  },
  {
    "path": "mqttui/plugins/registry.py",
    "content": "\"\"\"Plugin registry: discovers, loads, and manages mqttui plugins.\"\"\"\nfrom __future__ import annotations\n\nimport importlib.metadata\nfrom importlib.metadata import entry_points\n\nimport pluggy\nimport structlog\n\nfrom mqttui.plugins.hookspec import MQTTUIPlugin, PROJECT_NAME\nfrom mqttui.plugins.models import PluginConfig\nfrom mqttui.extensions import sa\n\nlogger = structlog.get_logger(__name__)\n\n\nclass PluginRegistry:\n    \"\"\"Discovers plugins via entry_points and manages their lifecycle.\"\"\"\n\n    def __init__(self, app=None):\n        self.app = app\n        self.pm = pluggy.PluginManager(PROJECT_NAME)\n        self.pm.add_hookspecs(MQTTUIPlugin)\n\n    def discover(self) -> list[dict]:\n        \"\"\"Scan importlib.metadata entry_points for 'mqttui.plugins' group.\n\n        For each discovered entry point, upsert a PluginConfig row if not\n        already present. Returns list of discovered plugin dicts.\n        \"\"\"\n        discovered = []\n        try:\n            eps = entry_points(group='mqttui.plugins')\n        except TypeError:\n            # Python 3.9 compat: entry_points() returns a dict\n            eps = entry_points().get('mqttui.plugins', [])\n\n        for ep in eps:\n            name = ep.name\n            value = ep.value\n            version = None\n            description = None\n\n            try:\n                if ep.dist:\n                    version = ep.dist.version\n                    description = ep.dist.metadata.get('Summary', '')\n            except Exception:\n                pass\n\n            existing = PluginConfig.query.filter_by(name=name).first()\n            if not existing:\n                pc = PluginConfig(\n                    name=name,\n                    entry_point=value,\n                    enabled=False,\n                    version=version,\n                    description=description,\n                )\n                sa.session.add(pc)\n                sa.session.commit()\n                discovered.append(pc.to_dict())\n                logger.info(\"Discovered new plugin\", name=name, version=version)\n            else:\n                # Update version/entry_point if changed\n                existing.version = version\n                existing.entry_point = value\n                sa.session.commit()\n                discovered.append(existing.to_dict())\n\n        return discovered\n\n    def list_plugins(self) -> list[dict]:\n        \"\"\"Return all registered plugins as list of dicts.\"\"\"\n        return [pc.to_dict() for pc in PluginConfig.query.all()]\n\n    def get_enabled_plugins(self) -> list[PluginConfig]:\n        \"\"\"Return only plugins with enabled=True.\"\"\"\n        return PluginConfig.query.filter_by(enabled=True).all()\n\n    def enable(self, name: str) -> bool:\n        \"\"\"Enable a plugin by name. Returns False if not found.\"\"\"\n        pc = PluginConfig.query.filter_by(name=name).first()\n        if not pc:\n            return False\n        pc.enabled = True\n        sa.session.commit()\n        logger.info(\"Plugin enabled\", name=name)\n        return True\n\n    def disable(self, name: str) -> bool:\n        \"\"\"Disable a plugin by name. Returns False if not found.\"\"\"\n        pc = PluginConfig.query.filter_by(name=name).first()\n        if not pc:\n            return False\n        pc.enabled = False\n        sa.session.commit()\n        logger.info(\"Plugin disabled\", name=name)\n        return True\n\n\n# Module-level singleton\n_registry = None\n\n\ndef get_plugin_registry() -> PluginRegistry:\n    \"\"\"Return the singleton PluginRegistry instance.\"\"\"\n    return _registry\n\n\ndef init_plugin_registry(app) -> PluginRegistry:\n    \"\"\"Create and initialize the PluginRegistry singleton.\"\"\"\n    global _registry\n    _registry = PluginRegistry(app)\n    with app.app_context():\n        _registry.discover()\n    logger.info(\"Plugin registry initialized\")\n    return _registry\n"
  },
  {
    "path": "mqttui/plugins/runner.py",
    "content": "\"\"\"Plugin runner: subprocess isolation layer for mqttui plugins.\n\nRuns plugin code in separate processes communicating via newline-delimited\nJSON on stdin/stdout. Plugins have no access to app internals (empty env).\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport subprocess\nimport sys\n\nimport structlog\n\nimport mqttui.mqtt_client\nfrom mqttui.plugins.registry import get_plugin_registry\n\nlogger = structlog.get_logger(__name__)\n\nPLUGIN_TIMEOUT = 5  # seconds\n\n\nclass PluginRunner:\n    \"\"\"Executes plugins in isolated subprocesses with JSON protocol.\"\"\"\n\n    def __init__(self, app=None):\n        self.app = app\n        self.logger = structlog.get_logger(__name__)\n\n    def call_plugin(self, plugin_config, event_type: str, data: dict) -> list[dict]:\n        \"\"\"Run a single plugin in a subprocess and return its actions.\n\n        Args:\n            plugin_config: PluginConfig model instance with entry_point.\n            event_type: Hook name (e.g. 'on_message', 'on_rule_trigger').\n            data: Event data dict to pass to the plugin.\n\n        Returns:\n            List of action dicts from the plugin, or empty list on error.\n        \"\"\"\n        cmd = [sys.executable, \"-m\", plugin_config.entry_point]\n        input_json = json.dumps({\"event\": event_type, \"data\": data}) + \"\\n\"\n\n        try:\n            proc = subprocess.Popen(\n                cmd,\n                stdin=subprocess.PIPE,\n                stdout=subprocess.PIPE,\n                stderr=subprocess.PIPE,\n                text=True,\n                env={},\n            )\n            stdout, stderr = proc.communicate(input=input_json, timeout=PLUGIN_TIMEOUT)\n\n            if stderr:\n                self.logger.debug(\n                    \"Plugin stderr output\",\n                    plugin=plugin_config.name,\n                    stderr=stderr.strip(),\n                )\n\n            result = json.loads(stdout)\n            return result.get(\"actions\", [])\n\n        except subprocess.TimeoutExpired:\n            proc.kill()\n            self.logger.warning(\n                \"Plugin timed out, killed\",\n                plugin=plugin_config.name,\n                timeout=PLUGIN_TIMEOUT,\n            )\n            return []\n\n        except json.JSONDecodeError:\n            self.logger.warning(\n                \"Plugin returned invalid JSON\",\n                plugin=plugin_config.name,\n                stdout=stdout[:200] if stdout else \"\",\n            )\n            return []\n\n        except Exception as exc:\n            self.logger.error(\n                \"Plugin execution error\",\n                plugin=plugin_config.name,\n                error=str(exc),\n            )\n            return []\n\n    def dispatch_message(self, topic: str, payload: str) -> list[dict]:\n        \"\"\"Run all enabled plugins for an MQTT message event.\n\n        Returns:\n            Aggregated list of action dicts from all plugins.\n        \"\"\"\n        registry = get_plugin_registry()\n        if registry is None:\n            return []\n\n        all_actions = []\n        try:\n            if self.app:\n                with self.app.app_context():\n                    enabled = registry.get_enabled_plugins()\n            else:\n                enabled = registry.get_enabled_plugins()\n        except (RuntimeError, Exception):\n            enabled = []\n        for plugin in enabled:\n            actions = self.call_plugin(\n                plugin, \"on_message\", {\"topic\": topic, \"payload\": payload}\n            )\n            all_actions.extend(actions)\n        return all_actions\n\n    def dispatch_actions(self, actions: list[dict]):\n        \"\"\"Execute action dicts returned by plugins.\n\n        Supported action types:\n            - publish: Publish an MQTT message (topic, payload).\n            - log: Log a message via structlog.\n        \"\"\"\n        for action in actions:\n            action_type = action.get(\"type\")\n\n            if action_type == \"publish\":\n                mqttui.mqtt_client.publish(action[\"topic\"], action[\"payload\"])\n\n            elif action_type == \"log\":\n                self.logger.info(\n                    \"Plugin log action\",\n                    message=action.get(\"message\", \"\"),\n                )\n\n            else:\n                self.logger.warning(\n                    \"Unknown plugin action type\",\n                    action_type=action_type,\n                    action=action,\n                )\n\n    def on_mqtt_message(self, sender, **kwargs):\n        \"\"\"Blinker signal handler for mqtt_message_received.\"\"\"\n        topic = kwargs.get(\"topic\", \"\")\n        payload = kwargs.get(\"payload\", \"\")\n        payload_str = payload if isinstance(payload, str) else str(payload)\n\n        actions = self.dispatch_message(topic, payload_str)\n        if actions:\n            self.dispatch_actions(actions)\n\n    def on_rule_trigger(self, sender, **kwargs):\n        \"\"\"Blinker signal handler for rule_fired.\"\"\"\n        rule_name = kwargs.get(\"rule_name\", \"\")\n        topic = kwargs.get(\"topic\", \"\")\n        payload = kwargs.get(\"payload\", \"\")\n        payload_str = payload if isinstance(payload, str) else str(payload)\n\n        registry = get_plugin_registry()\n        if registry is None:\n            return\n\n        all_actions = []\n        for plugin in registry.get_enabled_plugins():\n            actions = self.call_plugin(\n                plugin,\n                \"on_rule_trigger\",\n                {\"rule_name\": rule_name, \"topic\": topic, \"payload\": payload_str},\n            )\n            all_actions.extend(actions)\n\n        if all_actions:\n            self.dispatch_actions(all_actions)\n\n\n# Module-level singleton\n_runner = None\n\n\ndef get_plugin_runner() -> PluginRunner | None:\n    \"\"\"Return the singleton PluginRunner instance.\"\"\"\n    return _runner\n\n\ndef init_plugin_runner(app) -> PluginRunner:\n    \"\"\"Create and initialize the PluginRunner singleton.\"\"\"\n    global _runner\n    _runner = PluginRunner(app)\n    logger.info(\"Plugin runner initialized\")\n    return _runner\n"
  },
  {
    "path": "mqttui/routes/__init__.py",
    "content": ""
  },
  {
    "path": "mqttui/routes/alerts.py",
    "content": "\"\"\"Alert history REST API blueprint.\n\nProvides paginated, filterable listing of AlertHistory records.\nAll endpoints require authentication via @login_required.\n\"\"\"\nfrom flask import Blueprint, request\nfrom flask_login import login_required\n\nfrom mqttui.extensions import sa\nfrom mqttui.helpers import api_success, api_error\nfrom mqttui.rules.models import AlertHistory\n\nalerts_bp = Blueprint('alerts', __name__, url_prefix='/api/v1/alerts')\n\n\n@alerts_bp.route('/')\n@login_required\ndef list_alerts():\n    \"\"\"List alert history with pagination and optional filters.\n\n    Query params:\n        page (int): Page number, default 1.\n        per_page (int): Items per page, default 20, max 100.\n        rule_id (int): Filter by rule ID.\n        severity (str): Filter by severity level.\n\n    Returns:\n        JSON envelope with alerts list, total count, page, and per_page.\n    \"\"\"\n    page = request.args.get('page', 1, type=int)\n    per_page = min(request.args.get('per_page', 20, type=int), 100)\n    rule_id = request.args.get('rule_id', type=int)\n    severity = request.args.get('severity', type=str)\n\n    query = AlertHistory.query.order_by(AlertHistory.fired_at.desc())\n\n    if rule_id is not None:\n        query = query.filter(AlertHistory.rule_id == rule_id)\n    if severity:\n        query = query.filter(AlertHistory.severity == severity)\n\n    total = query.count()\n    alerts = query.offset((page - 1) * per_page).limit(per_page).all()\n\n    return api_success({\n        \"alerts\": [a.to_dict() for a in alerts],\n        \"total\": total,\n        \"page\": page,\n        \"per_page\": per_page,\n    })\n"
  },
  {
    "path": "mqttui/routes/analytics.py",
    "content": "\"\"\"Analytics REST API blueprint.\n\nProvides per-topic message rate and numeric payload histogram data.\nAll endpoints return JSON envelope: {\"status\": \"success\"|\"error\", \"data\": ..., \"error\": ...}\n\"\"\"\n\nfrom flask import Blueprint, request\nfrom flask_login import login_required\nimport logging\n\nfrom mqttui.helpers import api_success, api_error\nfrom mqttui.analytics import get_analytics\n\nanalytics_bp = Blueprint('analytics', __name__, url_prefix='/api/v1/analytics')\nlogger = logging.getLogger(__name__)\n\n\n@analytics_bp.route('/topics')\n@login_required\ndef get_topics():\n    \"\"\"Get top-N topic analytics sorted by message rate.\n\n    Query params:\n        limit (int): Maximum topics to return (default 20)\n        window (int): Rate window in seconds (default 60)\n\n    Returns:\n        JSON envelope with topics array and window_seconds.\n    \"\"\"\n    limit = request.args.get('limit', 20, type=int)\n    window = request.args.get('window', 60, type=int)\n\n    analytics = get_analytics()\n    topics = analytics.get_all_stats(limit=limit)\n\n    return api_success({\n        \"topics\": topics,\n        \"window_seconds\": window,\n    })\n\n\n@analytics_bp.route('/topics/<path:topic>')\n@login_required\ndef get_topic(topic):\n    \"\"\"Get analytics for a single topic.\n\n    Uses path converter to support MQTT topics with slashes (e.g., home/sensor/temp).\n\n    Returns:\n        JSON envelope with topic stats or 404 if no data.\n    \"\"\"\n    analytics = get_analytics()\n    stats = analytics.get_topic_stats(topic)\n\n    if stats[\"message_count\"] == 0:\n        return api_error(\"Topic not found\", \"NOT_FOUND\", 404)\n\n    return api_success(stats)\n"
  },
  {
    "path": "mqttui/routes/api.py",
    "content": "# TODO: Remove legacy /api/ routes in Phase 5 after frontend migrates to /api/v1/\n# All new API development should use mqttui/routes/api_v1.py\nfrom flask import Blueprint, request, jsonify\nfrom datetime import datetime, timedelta\nimport logging\n\nfrom mqttui import state\nfrom mqttui import extensions as ext\n\nbp = Blueprint('api', __name__)\n\n\n@bp.route('/api/messages')\ndef get_message_history():\n    \"\"\"Get paginated message history with enhanced filtering\"\"\"\n    try:\n        limit = min(int(request.args.get('limit', 100)), 1000)\n        offset = int(request.args.get('offset', 0))\n\n        topic_filter = request.args.get('topic')\n        hours = request.args.get('hours')\n\n        content_search = request.args.get('content')\n        regex_topic = request.args.get('regex_topic')\n        json_path = request.args.get('json_path')\n        json_value = request.args.get('json_value')\n\n        since = None\n        if hours:\n            since = datetime.now() - timedelta(hours=int(hours))\n\n        if ext.db:\n            messages_list = ext.db.get_messages(\n                limit=limit,\n                offset=offset,\n                topic_filter=topic_filter,\n                since=since,\n                content_search=content_search,\n                regex_topic=regex_topic,\n                json_path=json_path,\n                json_value=json_value\n            )\n            total_count = ext.db.get_message_count(topic_filter=topic_filter, since=since)\n        else:\n            messages_list = list(reversed(state.messages))\n            if topic_filter:\n                messages_list = [m for m in messages_list if m['topic'] == topic_filter]\n            if content_search:\n                messages_list = [m for m in messages_list if content_search.lower() in m['payload'].lower()]\n\n            total_count = len(messages_list)\n            messages_list = messages_list[offset:offset + limit]\n\n        return jsonify({\n            'messages': messages_list,\n            'total': total_count,\n            'limit': limit,\n            'offset': offset,\n            'has_more': offset + len(messages_list) < total_count,\n            'filters_applied': {\n                'topic': topic_filter,\n                'content': content_search,\n                'regex_topic': regex_topic,\n                'json_path': json_path,\n                'json_value': json_value,\n                'hours': hours\n            }\n        })\n\n    except Exception as e:\n        logging.error(f\"Error getting message history: {e}\")\n        return jsonify({'error': str(e)}), 500\n\n\n@bp.route('/api/topics')\ndef get_topic_list():\n    \"\"\"Get list of all topics with statistics\"\"\"\n    try:\n        if ext.db:\n            topics_list = ext.db.get_topics()\n        else:\n            topics_list = [{'topic': topic} for topic in sorted(state.topics)]\n\n        return jsonify({'topics': topics_list})\n\n    except Exception as e:\n        logging.error(f\"Error getting topics: {e}\")\n        return jsonify({'error': str(e)}), 500\n\n\n@bp.route('/api/database/stats')\ndef get_database_stats():\n    \"\"\"Get database size and statistics\"\"\"\n    try:\n        if ext.db:\n            stats = ext.db.get_database_size()\n            stats['enabled'] = True\n        else:\n            stats = {\n                'enabled': False,\n                'message_count': len(state.messages),\n                'topic_count': len(state.topics)\n            }\n\n        return jsonify(stats)\n\n    except Exception as e:\n        logging.error(f\"Error getting database stats: {e}\")\n        return jsonify({'error': str(e)}), 500\n\n\n@bp.route('/api/database/cleanup', methods=['POST'])\ndef cleanup_database():\n    \"\"\"Clean up old database records\"\"\"\n    try:\n        if not ext.db:\n            return jsonify({'error': 'Database not enabled'}), 400\n\n        from flask import current_app\n        days = int(request.json.get('days', current_app.config['DB_CLEANUP_DAYS']))\n        deleted = ext.db.cleanup_old_data(days)\n\n        return jsonify({\n            'success': True,\n            'deleted_messages': deleted,\n            'days': days\n        })\n\n    except Exception as e:\n        logging.error(f\"Error cleaning database: {e}\")\n        return jsonify({'error': str(e)}), 500\n\n\n@bp.route('/api/filter-presets')\ndef get_filter_presets():\n    \"\"\"Get all saved filter presets\"\"\"\n    try:\n        if not ext.db:\n            return jsonify({'error': 'Database not enabled'}), 400\n\n        presets = ext.db.get_filter_presets()\n        return jsonify({'presets': presets})\n\n    except Exception as e:\n        logging.error(f\"Error getting filter presets: {e}\")\n        return jsonify({'error': str(e)}), 500\n\n\n@bp.route('/api/filter-presets', methods=['POST'])\ndef save_filter_preset():\n    \"\"\"Save a new filter preset\"\"\"\n    try:\n        if not ext.db:\n            return jsonify({'error': 'Database not enabled'}), 400\n\n        data = request.json\n        name = data.get('name')\n        description = data.get('description', '')\n        filters = data.get('filters', {})\n\n        if not name or not filters:\n            return jsonify({'error': 'Name and filters are required'}), 400\n\n        success = ext.db.save_filter_preset(name, filters, description)\n\n        if success:\n            return jsonify({'success': True, 'message': f'Saved preset: {name}'})\n        else:\n            return jsonify({'error': 'Failed to save preset'}), 500\n\n    except Exception as e:\n        logging.error(f\"Error saving filter preset: {e}\")\n        return jsonify({'error': str(e)}), 500\n\n\n@bp.route('/api/filter-presets/<name>', methods=['DELETE'])\ndef delete_filter_preset(name):\n    \"\"\"Delete a filter preset\"\"\"\n    try:\n        if not ext.db:\n            return jsonify({'error': 'Database not enabled'}), 400\n\n        success = ext.db.delete_filter_preset(name)\n\n        if success:\n            return jsonify({'success': True, 'message': f'Deleted preset: {name}'})\n        else:\n            return jsonify({'error': 'Preset not found'}), 404\n\n    except Exception as e:\n        logging.error(f\"Error deleting filter preset: {e}\")\n        return jsonify({'error': str(e)}), 500\n\n\n@bp.route('/api/filter-presets/<name>/use', methods=['POST'])\ndef use_filter_preset(name):\n    \"\"\"Load and use a filter preset\"\"\"\n    try:\n        if not ext.db:\n            return jsonify({'error': 'Database not enabled'}), 400\n\n        filters = ext.db.use_filter_preset(name)\n\n        if filters:\n            return jsonify({'success': True, 'filters': filters})\n        else:\n            return jsonify({'error': 'Preset not found'}), 404\n\n    except Exception as e:\n        logging.error(f\"Error using filter preset: {e}\")\n        return jsonify({'error': str(e)}), 500\n"
  },
  {
    "path": "mqttui/routes/api_v1.py",
    "content": "\"\"\"Versioned API v1 endpoints for MQTTUI.\n\nAll endpoints return JSON envelope: {\"status\": \"success\"|\"error\", \"data\": ..., \"error\": ...}\n\"\"\"\n\nfrom flask import Blueprint, request, jsonify, current_app\nfrom flask_login import login_required, current_user\nfrom datetime import datetime, timedelta\nimport logging\n\nfrom mqttui import __version__\nfrom mqttui import state\nfrom mqttui import extensions as ext\nfrom mqttui.extensions import sa, limiter\nfrom mqttui.helpers import api_success, api_error\nfrom mqttui.models import TopicFavorite\n\napi_v1_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1')\nlogger = logging.getLogger(__name__)\n\n\n# ---------------------------------------------------------------------------\n# Messages\n# ---------------------------------------------------------------------------\n\n@api_v1_bp.route('/messages')\n@login_required\ndef get_messages():\n    \"\"\"Get paginated message history with filtering.\n    ---\n    get:\n      summary: Retrieve MQTT messages\n      parameters:\n        - name: limit\n          in: query\n          schema: {type: integer, default: 100}\n        - name: offset\n          in: query\n          schema: {type: integer, default: 0}\n        - name: topic\n          in: query\n          schema: {type: string}\n        - name: hours\n          in: query\n          schema: {type: integer}\n        - name: content\n          in: query\n          schema: {type: string}\n        - name: regex_topic\n          in: query\n          schema: {type: string}\n        - name: json_path\n          in: query\n          schema: {type: string}\n        - name: json_value\n          in: query\n          schema: {type: string}\n      responses:\n        200:\n          description: Paginated message list\n    \"\"\"\n    try:\n        limit = min(int(request.args.get('limit', 100)), 1000)\n        offset = int(request.args.get('offset', 0))\n\n        topic_filter = request.args.get('topic')\n        hours = request.args.get('hours')\n        content_search = request.args.get('content')\n        regex_topic = request.args.get('regex_topic')\n        json_path = request.args.get('json_path')\n        json_value = request.args.get('json_value')\n\n        since = None\n        if hours:\n            since = datetime.now() - timedelta(hours=int(hours))\n\n        if ext.db:\n            messages_list = ext.db.get_messages(\n                limit=limit,\n                offset=offset,\n                topic_filter=topic_filter,\n                since=since,\n                content_search=content_search,\n                regex_topic=regex_topic,\n                json_path=json_path,\n                json_value=json_value,\n            )\n            total_count = ext.db.get_message_count(\n                topic_filter=topic_filter, since=since\n            )\n        else:\n            messages_list = list(reversed(state.messages))\n            if topic_filter:\n                messages_list = [m for m in messages_list if m['topic'] == topic_filter]\n            if content_search:\n                messages_list = [\n                    m for m in messages_list\n                    if content_search.lower() in m['payload'].lower()\n                ]\n            total_count = len(messages_list)\n            messages_list = messages_list[offset:offset + limit]\n\n        return api_success({\n            'messages': messages_list,\n            'total': total_count,\n            'limit': limit,\n            'offset': offset,\n            'has_more': offset + len(messages_list) < total_count,\n            'filters_applied': {\n                'topic': topic_filter,\n                'content': content_search,\n                'regex_topic': regex_topic,\n                'json_path': json_path,\n                'json_value': json_value,\n                'hours': hours,\n            }\n        })\n    except Exception as e:\n        logger.error(f\"Error getting message history: {e}\")\n        return api_error(str(e), \"MESSAGES_ERROR\", 500)\n\n\n# ---------------------------------------------------------------------------\n# Topics\n# ---------------------------------------------------------------------------\n\n@api_v1_bp.route('/topics')\n@login_required\ndef get_topics():\n    \"\"\"Get list of all topics with statistics.\n    ---\n    get:\n      summary: List MQTT topics\n      responses:\n        200:\n          description: Topic list\n    \"\"\"\n    try:\n        if ext.db:\n            topics_list = ext.db.get_topics()\n        else:\n            topics_list = [{'topic': topic} for topic in sorted(state.topics)]\n\n        # Annotate with is_favorite for the current user\n        fav_topics = set()\n        if current_user.is_authenticated:\n            favs = TopicFavorite.query.filter_by(user_id=current_user.id).all()\n            fav_topics = {f.topic for f in favs}\n        for t in topics_list:\n            t['is_favorite'] = t.get('topic', '') in fav_topics\n\n        return api_success({'topics': topics_list})\n    except Exception as e:\n        logger.error(f\"Error getting topics: {e}\")\n        return api_error(str(e), \"TOPICS_ERROR\", 500)\n\n\n@api_v1_bp.route('/topics/favorites')\n@login_required\ndef get_favorites():\n    \"\"\"Get current user's bookmarked topics.\n    ---\n    get:\n      summary: List favorite topics\n      responses:\n        200:\n          description: Favorites list\n    \"\"\"\n    try:\n        favs = TopicFavorite.query.filter_by(user_id=current_user.id).all()\n        return api_success({'favorites': [f.to_dict() for f in favs]})\n    except Exception as e:\n        logger.error(f\"Error getting favorites: {e}\")\n        return api_error(str(e), \"FAVORITES_ERROR\", 500)\n\n\n@api_v1_bp.route('/topics/<path:topic>/bookmark', methods=['POST'])\n@login_required\ndef toggle_bookmark(topic):\n    \"\"\"Toggle bookmark on a topic.\n    ---\n    post:\n      summary: Toggle topic bookmark\n      parameters:\n        - name: topic\n          in: path\n          required: true\n          schema: {type: string}\n      responses:\n        201:\n          description: Bookmark created\n        200:\n          description: Bookmark removed\n    \"\"\"\n    try:\n        existing = TopicFavorite.query.filter_by(\n            user_id=current_user.id, topic=topic\n        ).first()\n        if existing:\n            sa.session.delete(existing)\n            sa.session.commit()\n            return api_success({'bookmarked': False, 'topic': topic})\n        else:\n            fav = TopicFavorite(user_id=current_user.id, topic=topic)\n            sa.session.add(fav)\n            sa.session.commit()\n            return api_success({'bookmarked': True, 'topic': topic}, 201)\n    except Exception as e:\n        sa.session.rollback()\n        logger.error(f\"Error toggling bookmark: {e}\")\n        return api_error(str(e), \"BOOKMARK_ERROR\", 500)\n\n\n# ---------------------------------------------------------------------------\n# Database\n# ---------------------------------------------------------------------------\n\n@api_v1_bp.route('/database/stats')\n@login_required\ndef get_database_stats():\n    \"\"\"Get database size and statistics.\n    ---\n    get:\n      summary: Database statistics\n      responses:\n        200:\n          description: Database stats\n    \"\"\"\n    try:\n        if ext.db:\n            stats = ext.db.get_database_size()\n            stats['enabled'] = True\n        else:\n            stats = {\n                'enabled': False,\n                'message_count': len(state.messages),\n                'topic_count': len(state.topics),\n            }\n        return api_success(stats)\n    except Exception as e:\n        logger.error(f\"Error getting database stats: {e}\")\n        return api_error(str(e), \"DB_STATS_ERROR\", 500)\n\n\n@api_v1_bp.route('/database/cleanup', methods=['POST'])\n@login_required\ndef cleanup_database():\n    \"\"\"Clean up old database records.\n    ---\n    post:\n      summary: Cleanup old messages\n      requestBody:\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                days:\n                  type: integer\n                  default: 30\n      responses:\n        200:\n          description: Cleanup result\n    \"\"\"\n    try:\n        if not ext.db:\n            return api_error(\"Database not enabled\", \"DB_DISABLED\", 400)\n\n        days = int(request.json.get('days', current_app.config['DB_CLEANUP_DAYS']))\n        deleted = ext.db.cleanup_old_data(days)\n\n        return api_success({'deleted_messages': deleted, 'days': days})\n    except Exception as e:\n        logger.error(f\"Error cleaning database: {e}\")\n        return api_error(str(e), \"DB_CLEANUP_ERROR\", 500)\n\n\n# ---------------------------------------------------------------------------\n# Filter Presets\n# ---------------------------------------------------------------------------\n\n@api_v1_bp.route('/filter-presets')\n@login_required\ndef get_filter_presets():\n    \"\"\"Get all saved filter presets.\n    ---\n    get:\n      summary: List filter presets\n      responses:\n        200:\n          description: Preset list\n    \"\"\"\n    try:\n        if not ext.db:\n            return api_error(\"Database not enabled\", \"DB_DISABLED\", 400)\n\n        presets = ext.db.get_filter_presets()\n        return api_success({'presets': presets})\n    except Exception as e:\n        logger.error(f\"Error getting filter presets: {e}\")\n        return api_error(str(e), \"PRESETS_ERROR\", 500)\n\n\n@api_v1_bp.route('/filter-presets', methods=['POST'])\n@login_required\ndef save_filter_preset():\n    \"\"\"Save a new filter preset.\n    ---\n    post:\n      summary: Create filter preset\n      requestBody:\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                name:\n                  type: string\n                description:\n                  type: string\n                filters:\n                  type: object\n      responses:\n        201:\n          description: Preset saved\n    \"\"\"\n    try:\n        if not ext.db:\n            return api_error(\"Database not enabled\", \"DB_DISABLED\", 400)\n\n        data = request.json\n        name = data.get('name')\n        description = data.get('description', '')\n        filters = data.get('filters', {})\n\n        if not name or not filters:\n            return api_error(\"Name and filters are required\", \"VALIDATION_ERROR\", 400)\n\n        success = ext.db.save_filter_preset(name, filters, description)\n\n        if success:\n            return api_success({'message': f'Saved preset: {name}'}, 201)\n        else:\n            return api_error(\"Failed to save preset\", \"PRESET_SAVE_FAILED\", 500)\n    except Exception as e:\n        logger.error(f\"Error saving filter preset: {e}\")\n        return api_error(str(e), \"PRESETS_ERROR\", 500)\n\n\n@api_v1_bp.route('/filter-presets/<name>', methods=['DELETE'])\n@login_required\ndef delete_filter_preset(name):\n    \"\"\"Delete a filter preset.\n    ---\n    delete:\n      summary: Delete a filter preset\n      parameters:\n        - name: name\n          in: path\n          required: true\n          schema: {type: string}\n      responses:\n        200:\n          description: Preset deleted\n        404:\n          description: Preset not found\n    \"\"\"\n    try:\n        if not ext.db:\n            return api_error(\"Database not enabled\", \"DB_DISABLED\", 400)\n\n        success = ext.db.delete_filter_preset(name)\n\n        if success:\n            return api_success({'message': f'Deleted preset: {name}'})\n        else:\n            return api_error(\"Preset not found\", \"NOT_FOUND\", 404)\n    except Exception as e:\n        logger.error(f\"Error deleting filter preset: {e}\")\n        return api_error(str(e), \"PRESETS_ERROR\", 500)\n\n\n@api_v1_bp.route('/filter-presets/<name>/use', methods=['POST'])\n@login_required\ndef use_filter_preset(name):\n    \"\"\"Load and use a filter preset.\n    ---\n    post:\n      summary: Use a filter preset\n      parameters:\n        - name: name\n          in: path\n          required: true\n          schema: {type: string}\n      responses:\n        200:\n          description: Preset filters\n        404:\n          description: Preset not found\n    \"\"\"\n    try:\n        if not ext.db:\n            return api_error(\"Database not enabled\", \"DB_DISABLED\", 400)\n\n        filters = ext.db.use_filter_preset(name)\n\n        if filters:\n            return api_success({'filters': filters})\n        else:\n            return api_error(\"Preset not found\", \"NOT_FOUND\", 404)\n    except Exception as e:\n        logger.error(f\"Error using filter preset: {e}\")\n        return api_error(str(e), \"PRESETS_ERROR\", 500)\n\n\n# ---------------------------------------------------------------------------\n# Publish\n# ---------------------------------------------------------------------------\n\n@api_v1_bp.route('/publish', methods=['POST'])\n@login_required\n@limiter.limit(\"30/minute\")\ndef publish_message():\n    \"\"\"Publish an MQTT message (JSON API).\n    ---\n    post:\n      summary: Publish MQTT message\n      requestBody:\n        content:\n          application/json:\n            schema:\n              type: object\n              required: [topic, message]\n              properties:\n                topic:\n                  type: string\n                message:\n                  type: string\n      responses:\n        200:\n          description: Message published\n    \"\"\"\n    try:\n        data = request.json\n        if not data:\n            return api_error(\"JSON body required\", \"VALIDATION_ERROR\", 400)\n\n        topic = data.get('topic')\n        message = data.get('message')\n\n        if not topic or message is None:\n            return api_error(\"topic and message are required\", \"VALIDATION_ERROR\", 400)\n\n        from mqttui.mqtt_client import publish as mqtt_publish\n        mqtt_publish(topic, message)\n\n        return api_success({'published': True})\n    except Exception as e:\n        logger.error(f\"Error publishing message: {e}\")\n        return api_error(str(e), \"PUBLISH_ERROR\", 500)\n\n\n# ---------------------------------------------------------------------------\n# Stats & Version\n# ---------------------------------------------------------------------------\n\n@api_v1_bp.route('/stats')\n@login_required\ndef get_stats():\n    \"\"\"Get application statistics.\n    ---\n    get:\n      summary: Application statistics\n      responses:\n        200:\n          description: Stats\n    \"\"\"\n    try:\n        return api_success({\n            'connection_count': state.connection_count,\n            'topic_count': len(state.topics),\n            'message_count': len(state.messages),\n            'errors': state.error_log,\n        })\n    except Exception as e:\n        logger.error(f\"Error getting stats: {e}\")\n        return api_error(str(e), \"STATS_ERROR\", 500)\n\n\n@api_v1_bp.route('/version')\ndef get_version():\n    \"\"\"Get application version.\n    ---\n    get:\n      summary: App version\n      responses:\n        200:\n          description: Version info\n    \"\"\"\n    return api_success({'version': __version__})\n\n\n# ---------------------------------------------------------------------------\n# OpenAPI Documentation\n# ---------------------------------------------------------------------------\n\n@api_v1_bp.route('/docs')\ndef api_docs():\n    \"\"\"Serve Swagger UI for API documentation.\"\"\"\n    html = \"\"\"<!DOCTYPE html>\n<html><head><title>MQTTUI API Docs</title>\n<link rel=\"stylesheet\" href=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui.css\">\n</head><body>\n<div id=\"swagger-ui\"></div>\n<script src=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js\"></script>\n<script>SwaggerUIBundle({url: '/api/v1/openapi.json', dom_id: '#swagger-ui'})</script>\n</body></html>\"\"\"\n    return html\n\n\n@api_v1_bp.route('/openapi.json')\ndef openapi_spec():\n    \"\"\"Return OpenAPI 3.0 specification.\"\"\"\n    from apispec import APISpec\n    from apispec_webframeworks.flask import FlaskPlugin\n\n    spec = APISpec(\n        title=\"MQTTUI API\",\n        version=\"1.0.0\",\n        openapi_version=\"3.0.3\",\n        info={\"description\": \"MQTT monitoring and automation API\"},\n        plugins=[FlaskPlugin()],\n    )\n    # Register paths from current app's url_map\n    with current_app.test_request_context():\n        for rule in current_app.url_map.iter_rules():\n            if rule.rule.startswith('/api/v1/') and rule.endpoint != 'static':\n                view = current_app.view_functions.get(rule.endpoint)\n                if view:\n                    spec.path(view=view, app=current_app)\n    return jsonify(spec.to_dict())\n\n\n# ---------------------------------------------------------------------------\n# Token CRUD\n# ---------------------------------------------------------------------------\n\n@api_v1_bp.route('/auth/token', methods=['GET'])\n@login_required\ndef get_token():\n    \"\"\"Get current user's API token.\n    ---\n    get:\n      summary: Get API token\n      security: [{session: []}, {apiKey: []}]\n      responses:\n        200: {description: Current token}\n    \"\"\"\n    return api_success({\"api_token\": current_user.api_token})\n\n\n@api_v1_bp.route('/auth/token', methods=['POST'])\n@login_required\ndef regenerate_token():\n    \"\"\"Regenerate API token.\n    ---\n    post:\n      summary: Regenerate API token\n      responses:\n        200: {description: New token generated}\n    \"\"\"\n    token = current_user.generate_api_token()\n    sa.session.commit()\n    return api_success({\"api_token\": token})\n\n\n@api_v1_bp.route('/auth/token', methods=['DELETE'])\n@login_required\ndef revoke_token():\n    \"\"\"Revoke API token.\n    ---\n    delete:\n      summary: Revoke API token\n      responses:\n        200: {description: Token revoked}\n    \"\"\"\n    current_user.api_token = None\n    sa.session.commit()\n    return api_success({\"message\": \"API token revoked\"})\n\n\n# ---------------------------------------------------------------------------\n# Blueprint error handlers\n# ---------------------------------------------------------------------------\n\n@api_v1_bp.errorhandler(429)\ndef handle_429(e):\n    response = api_error(\"Rate limit exceeded\", \"RATE_LIMIT_EXCEEDED\", 429)\n    # Flask-Limiter sets the description with retry info\n    resp = response[0]\n    resp.headers['Retry-After'] = str(60)\n    return resp, 429\n\n\n@api_v1_bp.errorhandler(404)\ndef handle_404(e):\n    return api_error(\"Resource not found\", \"NOT_FOUND\", 404)\n\n\n@api_v1_bp.errorhandler(500)\ndef handle_500(e):\n    return api_error(\"Internal server error\", \"INTERNAL_ERROR\", 500)\n"
  },
  {
    "path": "mqttui/routes/brokers.py",
    "content": "\"\"\"Broker management REST API endpoints.\"\"\"\nfrom flask import Blueprint, request\nfrom flask_login import login_required\n\nfrom mqttui.helpers import api_success, api_error\nfrom mqttui.extensions import sa\nfrom mqttui.models import Broker\nfrom mqttui.broker_manager import get_broker_manager\n\nbrokers_bp = Blueprint('brokers', __name__, url_prefix='/api/v1/brokers')\n\n\n@brokers_bp.route('/', methods=['GET'])\n@login_required\ndef list_brokers():\n    \"\"\"List all configured brokers with connection status.\"\"\"\n    brokers = Broker.query.order_by(Broker.created_at).all()\n    mgr = get_broker_manager()\n\n    result = []\n    for b in brokers:\n        d = b.to_dict()\n        if mgr:\n            conn = mgr.get_connection(b.id)\n            d['connected'] = conn.connected if conn else False\n            d['connection_error'] = conn.error if conn else None\n        else:\n            d['connected'] = False\n            d['connection_error'] = None\n        result.append(d)\n\n    return api_success({'brokers': result})\n\n\n@brokers_bp.route('/', methods=['POST'])\n@login_required\ndef create_broker():\n    \"\"\"Add a new broker connection.\"\"\"\n    data = request.get_json()\n    if not data or not data.get('name') or not data.get('host'):\n        return api_error(\"name and host are required\", \"VALIDATION\", 400)\n\n    broker = Broker(\n        name=data['name'],\n        host=data['host'],\n        port=data.get('port', 1883),\n        username=data.get('username'),\n        password=data.get('password'),\n        mqtt_version=data.get('mqtt_version', '3.1.1'),\n        topics=data.get('topics', '#'),\n        tls_enabled=data.get('tls_enabled', False),\n        tls_ca_certs=data.get('tls_ca_certs'),\n        tls_insecure=data.get('tls_insecure', False),\n        is_active=data.get('is_active', True),\n    )\n    sa.session.add(broker)\n    sa.session.commit()\n\n    # Start connection if active\n    if broker.is_active:\n        mgr = get_broker_manager()\n        if mgr:\n            mgr.add_connection(broker)\n\n    return api_success({'broker': broker.to_dict()}), 201\n\n\n@brokers_bp.route('/<int:broker_id>', methods=['GET'])\n@login_required\ndef get_broker(broker_id):\n    \"\"\"Get a single broker's details and connection status.\"\"\"\n    broker = sa.session.get(Broker, broker_id)\n    if not broker:\n        return api_error(\"Broker not found\", \"NOT_FOUND\", 404)\n\n    d = broker.to_dict()\n    mgr = get_broker_manager()\n    if mgr:\n        conn = mgr.get_connection(broker.id)\n        d['connected'] = conn.connected if conn else False\n        d['connection_error'] = conn.error if conn else None\n\n    return api_success({'broker': d})\n\n\n@brokers_bp.route('/<int:broker_id>', methods=['PUT'])\n@login_required\ndef update_broker(broker_id):\n    \"\"\"Update broker configuration. Reconnects if active.\"\"\"\n    broker = sa.session.get(Broker, broker_id)\n    if not broker:\n        return api_error(\"Broker not found\", \"NOT_FOUND\", 404)\n\n    data = request.get_json()\n    if not data:\n        return api_error(\"No data provided\", \"VALIDATION\", 400)\n\n    for field in ['name', 'host', 'port', 'username', 'password', 'mqtt_version',\n                  'topics', 'tls_enabled', 'tls_ca_certs', 'tls_insecure', 'is_active']:\n        if field in data:\n            setattr(broker, field, data[field])\n\n    sa.session.commit()\n\n    # Reconnect with new settings\n    mgr = get_broker_manager()\n    if mgr:\n        if broker.is_active:\n            mgr.add_connection(broker)  # Stops old, starts new\n        else:\n            mgr.remove_connection(broker.id)\n\n    return api_success({'broker': broker.to_dict()})\n\n\n@brokers_bp.route('/<int:broker_id>', methods=['DELETE'])\n@login_required\ndef delete_broker(broker_id):\n    \"\"\"Delete a broker and disconnect.\"\"\"\n    broker = sa.session.get(Broker, broker_id)\n    if not broker:\n        return api_error(\"Broker not found\", \"NOT_FOUND\", 404)\n\n    mgr = get_broker_manager()\n    if mgr:\n        mgr.remove_connection(broker.id)\n\n    sa.session.delete(broker)\n    sa.session.commit()\n\n    return api_success({'deleted': broker_id})\n\n\n@brokers_bp.route('/<int:broker_id>/connect', methods=['POST'])\n@login_required\ndef connect_broker(broker_id):\n    \"\"\"Start/restart connection to a broker.\"\"\"\n    broker = sa.session.get(Broker, broker_id)\n    if not broker:\n        return api_error(\"Broker not found\", \"NOT_FOUND\", 404)\n\n    broker.is_active = True\n    sa.session.commit()\n\n    mgr = get_broker_manager()\n    if mgr:\n        mgr.add_connection(broker)\n\n    return api_success({'broker': broker.to_dict(), 'status': 'connecting'})\n\n\n@brokers_bp.route('/<int:broker_id>/disconnect', methods=['POST'])\n@login_required\ndef disconnect_broker(broker_id):\n    \"\"\"Disconnect from a broker.\"\"\"\n    broker = sa.session.get(Broker, broker_id)\n    if not broker:\n        return api_error(\"Broker not found\", \"NOT_FOUND\", 404)\n\n    broker.is_active = False\n    sa.session.commit()\n\n    mgr = get_broker_manager()\n    if mgr:\n        mgr.remove_connection(broker.id)\n\n    return api_success({'broker': broker.to_dict(), 'status': 'disconnected'})\n\n\n@brokers_bp.route('/status', methods=['GET'])\n@login_required\ndef broker_status():\n    \"\"\"Get live connection status for all brokers.\"\"\"\n    mgr = get_broker_manager()\n    statuses = mgr.all_statuses() if mgr else []\n    return api_success({'connections': statuses})\n"
  },
  {
    "path": "mqttui/routes/debug.py",
    "content": "from flask import Blueprint, request, jsonify\nimport logging\n\nfrom mqttui.extensions import limiter\n\nbp = Blueprint('debug', __name__)\n\n\n@bp.before_app_request\ndef debug_bar_middleware():\n    from debug_bar import debug_bar\n    debug_bar.start_request()\n    debug_bar.record('request', 'path', request.path)\n    debug_bar.record('request', 'method', request.method)\n\n\n@bp.after_app_request\ndef after_request(response):\n    from debug_bar import debug_bar\n    debug_bar.record('request', 'status_code', response.status_code)\n    debug_bar.end_request()\n    return response\n\n\n@bp.route('/debug-bar')\n@limiter.exempt\ndef get_debug_bar_data():\n    from debug_bar import debug_bar\n    try:\n        data = debug_bar.get_data()\n        return jsonify(data)\n    except Exception as e:\n        logging.error(f\"Error fetching debug bar data: {e}\")\n        return jsonify({\"error\": \"Failed to fetch debug bar data\"}), 500\n\n\n@bp.route('/toggle-debug-bar', methods=['POST'])\ndef toggle_debug_bar():\n    from debug_bar import debug_bar\n    if debug_bar.enabled:\n        debug_bar.disable()\n    else:\n        debug_bar.enable()\n    return jsonify(enabled=debug_bar.enabled)\n\n\n@bp.route('/record-client-performance', methods=['POST'])\ndef record_client_performance():\n    from debug_bar import debug_bar\n    data = request.json\n    debug_bar.record('performance', 'page_load_time', f\"{data['pageLoadTime']}ms\")\n    debug_bar.record('performance', 'dom_ready_time', f\"{data['domReadyTime']}ms\")\n    return jsonify(success=True)\n"
  },
  {
    "path": "mqttui/routes/main.py",
    "content": "from flask import Blueprint, render_template, request, jsonify, send_from_directory\nfrom flask_login import login_required\nimport logging\n\nfrom mqttui import __version__\nfrom mqttui.extensions import limiter\nfrom mqttui import state\n\nbp = Blueprint('main', __name__)\n\n\n@bp.route('/')\n@login_required\ndef index():\n    return render_template('index.html', messages=state.messages, topics=list(state.topics))\n\n\n@bp.route('/publish', methods=['POST'])\n@login_required\ndef publish_message():\n    from mqttui.mqtt_client import publish as mqtt_publish\n    topic = request.form['topic']\n    message = request.form['message']\n    mqtt_publish(topic, message)\n    return jsonify(success=True)\n\n\n@bp.route('/stats')\n@limiter.exempt\ndef get_stats():\n    return jsonify({\n        'connection_count': state.connection_count,\n        'topic_count': len(state.topics),\n        'message_count': len(state.messages),\n        'errors': state.error_log\n    })\n\n\n@bp.route('/version')\n@limiter.exempt\ndef get_version():\n    return jsonify({'version': __version__})\n\n\n@bp.route('/static/<path:path>')\ndef send_static(path):\n    return send_from_directory('static', path)\n\n\n# ---------------------------------------------------------------------------\n# Partial routes for htmx (Alerts History UI)\n# ---------------------------------------------------------------------------\n\n@bp.route('/partials/alerts')\n@login_required\ndef alerts_list_partial():\n    \"\"\"Return HTML partial of alert history with pagination and filters.\"\"\"\n    from mqttui.rules.models import AlertHistory, Rule\n\n    page = request.args.get('page', 1, type=int)\n    per_page = min(request.args.get('per_page', 20, type=int), 100)\n    rule_id = request.args.get('rule_id', type=int)\n    severity = request.args.get('severity', type=str)\n\n    query = AlertHistory.query.order_by(AlertHistory.fired_at.desc())\n    if rule_id:\n        query = query.filter(AlertHistory.rule_id == rule_id)\n    if severity:\n        query = query.filter(AlertHistory.severity == severity)\n\n    total = query.count()\n    alerts = query.offset((page - 1) * per_page).limit(per_page).all()\n    has_next = (page * per_page) < total\n\n    # Get all rules for filter dropdown\n    rules = Rule.query.order_by(Rule.name).all()\n\n    return render_template('partials/alerts_list.html',\n                           alerts=alerts, rules=rules,\n                           page=page, per_page=per_page,\n                           total=total, has_next=has_next,\n                           current_rule_id=rule_id,\n                           current_severity=severity)\n\n\n# ---------------------------------------------------------------------------\n# Partial routes for htmx (Rules Editor UI)\n# ---------------------------------------------------------------------------\n\n@bp.route('/partials/rules')\n@login_required\ndef rules_list_partial():\n    \"\"\"Return HTML partial of all rules for htmx swap.\"\"\"\n    from mqttui.rules.models import Rule\n    rules = Rule.query.order_by(Rule.created_at.desc()).all()\n    return render_template('partials/rules_list.html', rules=rules)\n\n\n@bp.route('/partials/rules/<int:rule_id>/row')\n@login_required\ndef rule_row_partial(rule_id):\n    \"\"\"Return single rule row partial after update.\"\"\"\n    from mqttui.rules.models import Rule\n    from mqttui.extensions import sa\n    rule = sa.session.get(Rule, rule_id)\n    if not rule:\n        return '', 404\n    return render_template('partials/rule_row.html', rule=rule)\n\n\n@bp.route('/partials/rules/form')\n@login_required\ndef rule_form_partial():\n    \"\"\"Return empty rule creation form partial.\"\"\"\n    return render_template('partials/rule_form.html', rule=None)\n\n\n@bp.route('/partials/rules/<int:rule_id>/form')\n@login_required\ndef rule_edit_form_partial(rule_id):\n    \"\"\"Return pre-filled rule edit form partial.\"\"\"\n    from mqttui.rules.models import Rule\n    from mqttui.extensions import sa\n    rule = sa.session.get(Rule, rule_id)\n    if not rule:\n        return '', 404\n    return render_template('partials/rule_form.html', rule=rule)\n\n\n@bp.route('/partials/rules/<int:rule_id>/dry-run')\n@login_required\ndef dry_run_form_partial(rule_id):\n    \"\"\"Return dry-run test form for a rule.\"\"\"\n    return render_template('partials/dry_run_result.html', rule_id=rule_id, result=None)\n\n\n@bp.route('/partials/rules/<int:rule_id>/toggle', methods=['POST'])\n@login_required\ndef rule_toggle_partial(rule_id):\n    \"\"\"Toggle rule enabled/disabled and return updated row partial.\"\"\"\n    from mqttui.rules.models import Rule\n    from mqttui.extensions import sa\n    from mqttui.events import rule_changed\n    rule = sa.session.get(Rule, rule_id)\n    if not rule:\n        return '', 404\n    rule.enabled = not rule.enabled\n    sa.session.commit()\n    rule_changed.send('ui', action='updated', rule_id=rule.id)\n    return render_template('partials/rule_row.html', rule=rule)\n\n\n@bp.route('/partials/analytics')\n@login_required\ndef analytics_partial():\n    \"\"\"Return HTML partial for analytics dashboard widget.\"\"\"\n    return render_template('partials/analytics.html')\n\n\n@bp.route('/partials/rules/<int:rule_id>/delete', methods=['DELETE'])\n@login_required\ndef rule_delete_partial(rule_id):\n    \"\"\"Delete a rule and return empty response to remove DOM element.\"\"\"\n    from mqttui.rules.models import Rule\n    from mqttui.extensions import sa\n    from mqttui.events import rule_changed\n    rule = sa.session.get(Rule, rule_id)\n    if not rule:\n        return '', 404\n    sa.session.delete(rule)\n    sa.session.commit()\n    rule_changed.send('ui', action='deleted', rule_id=rule_id)\n    return ''  # Empty response removes the element\n"
  },
  {
    "path": "mqttui/routes/metrics.py",
    "content": "\"\"\"Prometheus-compatible /metrics endpoint and metric definitions.\n\nExposes counters, gauges, and histograms for MQTT, rules, webhooks,\nand WebSocket activity. Designed for unauthenticated Prometheus scraping.\n\"\"\"\nfrom prometheus_client import Counter, Gauge, Histogram, generate_latest, CONTENT_TYPE_LATEST\nfrom flask import Blueprint, Response\n\nmetrics_bp = Blueprint('metrics', __name__)\n\n# Counters\nMQTT_MESSAGES = Counter('mqtt_messages_total', 'Total MQTT messages received', ['topic'])\nRULE_FIRINGS = Counter('rule_firings_total', 'Total rule firings', ['rule_id'])\nWEBHOOK_DELIVERIES = Counter('webhook_deliveries_total', 'Total webhook deliveries', ['status'])\nALERTS = Counter('alerts_total', 'Total alerts triggered')\n\n# Gauges\nMQTT_CONNECTED = Gauge('mqtt_connected', 'MQTT broker connection status (0/1)')\nACTIVE_RULES = Gauge('active_rules', 'Number of active rules')\nWEBSOCKET_CLIENTS = Gauge('websocket_clients', 'Number of connected WebSocket clients')\n\n# Histograms\nWEBHOOK_DURATION = Histogram(\n    'webhook_delivery_duration_seconds',\n    'Webhook delivery duration',\n    buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],\n)\n\n\n@metrics_bp.route('/metrics')\ndef prometheus_metrics():\n    \"\"\"Serve Prometheus metrics. No authentication required for scraping.\"\"\"\n    import mqttui.state as state\n    MQTT_CONNECTED.set(1 if state.connection_count > 0 else 0)\n    WEBSOCKET_CLIENTS.set(state.active_websockets)\n    # Update active rules gauge from database\n    try:\n        from mqttui.rules.models import Rule\n        ACTIVE_RULES.set(Rule.query.filter_by(enabled=True).count())\n    except Exception:\n        pass\n    return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST)\n\n\n# Signal subscribers for incrementing counters\n\ndef _on_message_for_metrics(sender, **kwargs):\n    \"\"\"Increment mqtt_messages_total counter on MQTT message received.\"\"\"\n    MQTT_MESSAGES.labels(topic=kwargs.get('topic', 'unknown')).inc()\n\n\ndef _on_rule_fired_for_metrics(sender, **kwargs):\n    \"\"\"Increment rule_firings_total counter on rule fired.\"\"\"\n    RULE_FIRINGS.labels(rule_id=str(kwargs.get('rule_id', 'unknown'))).inc()\n\n\ndef _on_alert_for_metrics(sender, **kwargs):\n    \"\"\"Increment alerts_total counter on alert triggered.\"\"\"\n    ALERTS.inc()\n"
  },
  {
    "path": "mqttui/routes/plugins.py",
    "content": "\"\"\"Plugin management REST API and partials blueprint.\n\nProvides endpoints to list, enable, and disable plugins,\nplus an HTML partial for the plugins management tab.\n\"\"\"\nfrom flask import Blueprint, render_template\nfrom flask_login import login_required\n\nfrom mqttui.helpers import api_success, api_error\nfrom mqttui.plugins.registry import get_plugin_registry\n\nplugins_bp = Blueprint('plugins', __name__)\n\n\n@plugins_bp.route('/api/v1/plugins')\n@login_required\ndef list_plugins():\n    \"\"\"List all installed plugins.\n\n    Returns:\n        JSON envelope with plugins list.\n    \"\"\"\n    registry = get_plugin_registry()\n    plugins = registry.list_plugins() if registry else []\n    return api_success({\"plugins\": plugins})\n\n\n@plugins_bp.route('/api/v1/plugins/<name>/enable', methods=['POST'])\n@login_required\ndef enable_plugin(name):\n    \"\"\"Enable a plugin by name.\n\n    Returns:\n        JSON success or 404 if plugin not found.\n    \"\"\"\n    registry = get_plugin_registry()\n    if not registry or not registry.enable(name):\n        return api_error(\"Plugin not found\", \"NOT_FOUND\", 404)\n    return api_success({\"name\": name, \"enabled\": True})\n\n\n@plugins_bp.route('/api/v1/plugins/<name>/disable', methods=['POST'])\n@login_required\ndef disable_plugin(name):\n    \"\"\"Disable a plugin by name.\n\n    Returns:\n        JSON success or 404 if plugin not found.\n    \"\"\"\n    registry = get_plugin_registry()\n    if not registry or not registry.disable(name):\n        return api_error(\"Plugin not found\", \"NOT_FOUND\", 404)\n    return api_success({\"name\": name, \"enabled\": False})\n\n\n@plugins_bp.route('/partials/plugins')\n@login_required\ndef plugins_partial():\n    \"\"\"Render plugins management HTML partial.\n\n    Returns:\n        HTML partial with plugin list and enable/disable controls.\n    \"\"\"\n    registry = get_plugin_registry()\n    plugins = registry.list_plugins() if registry else []\n    return render_template('partials/plugins.html', plugins=plugins)\n"
  },
  {
    "path": "mqttui/routes/rules.py",
    "content": "\"\"\"Rules CRUD REST API blueprint.\n\nAll endpoints return JSON envelope: {\"status\": \"success\"|\"error\", \"data\": ..., \"error\": ...}\nAll endpoints require @login_required authentication.\n\"\"\"\n\nfrom flask import Blueprint, request\nfrom flask_login import login_required\nimport json\nimport logging\n\nfrom mqttui.extensions import sa\nfrom mqttui.helpers import api_success, api_error\nfrom mqttui.rules.models import Rule\nfrom mqttui.rules.evaluator import evaluate, ConditionError\nfrom mqttui.rules.ssrf import is_ssrf_safe\nfrom mqttui.events import rule_changed\n\nrules_bp = Blueprint('rules', __name__, url_prefix='/api/v1/rules')\nlogger = logging.getLogger(__name__)\n\n\n# ---------------------------------------------------------------------------\n# List / Create\n# ---------------------------------------------------------------------------\n\n@rules_bp.route('/')\n@login_required\ndef list_rules():\n    \"\"\"List all rules ordered by creation date (newest first).\"\"\"\n    rules = Rule.query.order_by(Rule.created_at.desc()).all()\n    return api_success({\"rules\": [r.to_dict() for r in rules]})\n\n\n@rules_bp.route('/', methods=['POST'])\n@login_required\ndef create_rule():\n    \"\"\"Create a new rule.\n\n    Required fields: name, trigger_topic, action (dict with 'type' key).\n    Optional: description, condition, rate_limit_per_min, schedule_cron, enabled.\n    \"\"\"\n    data = request.get_json(silent=True)\n    if not data:\n        return api_error(\"JSON body required\", \"VALIDATION_ERROR\", 400)\n\n    # Validate required fields\n    name = data.get('name')\n    if not name or not isinstance(name, str) or not name.strip():\n        return api_error(\"name is required\", \"VALIDATION_ERROR\", 400)\n\n    trigger_topic = data.get('trigger_topic')\n    if not trigger_topic or not isinstance(trigger_topic, str) or not trigger_topic.strip():\n        return api_error(\"trigger_topic is required\", \"VALIDATION_ERROR\", 400)\n\n    action = data.get('action')\n    if not action or not isinstance(action, dict) or 'type' not in action:\n        return api_error(\"action is required and must have a 'type' key\", \"VALIDATION_ERROR\", 400)\n\n    # SSRF validation for webhook actions\n    if action.get('type') == 'webhook':\n        webhook_url = action.get('url', '')\n        safe, reason = is_ssrf_safe(webhook_url)\n        if not safe:\n            return api_error(f\"Webhook URL rejected: {reason}\", \"SSRF_BLOCKED\", 400)\n\n    # Build rule\n    rule = Rule(\n        name=name.strip(),\n        description=data.get('description', ''),\n        trigger_topic=trigger_topic.strip(),\n        condition_json=json.dumps(data.get('condition', {})),\n        action_json=json.dumps(action),\n        enabled=data.get('enabled', True),\n        rate_limit_per_min=data.get('rate_limit_per_min', 10),\n        schedule_cron=data.get('schedule_cron'),\n    )\n\n    sa.session.add(rule)\n    sa.session.commit()\n\n    rule_changed.send('api', action='created', rule_id=rule.id)\n    logger.info(f\"Rule created: id={rule.id} name={rule.name}\")\n\n    return api_success(rule.to_dict(), 201)\n\n\n# ---------------------------------------------------------------------------\n# Single rule CRUD\n# ---------------------------------------------------------------------------\n\ndef _get_rule_or_404(rule_id):\n    \"\"\"Helper to fetch a rule by ID or return a 404 error response.\"\"\"\n    rule = sa.session.get(Rule, rule_id)\n    if not rule:\n        return None, api_error(\"Rule not found\", \"NOT_FOUND\", 404)\n    return rule, None\n\n\n@rules_bp.route('/<int:rule_id>')\n@login_required\ndef get_rule(rule_id):\n    \"\"\"Get a single rule by ID.\"\"\"\n    rule, err = _get_rule_or_404(rule_id)\n    if err:\n        return err\n    return api_success(rule.to_dict())\n\n\n@rules_bp.route('/<int:rule_id>', methods=['PUT'])\n@login_required\ndef update_rule(rule_id):\n    \"\"\"Update a rule. Only updates fields present in the request body.\"\"\"\n    rule, err = _get_rule_or_404(rule_id)\n    if err:\n        return err\n\n    data = request.get_json(silent=True)\n    if not data:\n        return api_error(\"JSON body required\", \"VALIDATION_ERROR\", 400)\n\n    # SSRF validation for webhook actions\n    if 'action' in data and isinstance(data['action'], dict) and data['action'].get('type') == 'webhook':\n        webhook_url = data['action'].get('url', '')\n        safe, reason = is_ssrf_safe(webhook_url)\n        if not safe:\n            return api_error(f\"Webhook URL rejected: {reason}\", \"SSRF_BLOCKED\", 400)\n\n    # Update only fields present in request\n    if 'name' in data:\n        rule.name = data['name']\n    if 'description' in data:\n        rule.description = data['description']\n    if 'trigger_topic' in data:\n        rule.trigger_topic = data['trigger_topic']\n    if 'condition' in data:\n        rule.condition_json = json.dumps(data['condition'])\n    if 'action' in data:\n        rule.action_json = json.dumps(data['action'])\n    if 'enabled' in data:\n        rule.enabled = data['enabled']\n    if 'rate_limit_per_min' in data:\n        rule.rate_limit_per_min = data['rate_limit_per_min']\n    if 'schedule_cron' in data:\n        rule.schedule_cron = data['schedule_cron']\n\n    sa.session.commit()\n\n    rule_changed.send('api', action='updated', rule_id=rule.id)\n    logger.info(f\"Rule updated: id={rule.id}\")\n\n    return api_success(rule.to_dict())\n\n\n@rules_bp.route('/<int:rule_id>', methods=['DELETE'])\n@login_required\ndef delete_rule(rule_id):\n    \"\"\"Delete a rule by ID.\"\"\"\n    rule, err = _get_rule_or_404(rule_id)\n    if err:\n        return err\n\n    sa.session.delete(rule)\n    sa.session.commit()\n\n    rule_changed.send('api', action='deleted', rule_id=rule_id)\n    logger.info(f\"Rule deleted: id={rule_id}\")\n\n    return api_success({\"message\": f\"Rule {rule_id} deleted\"})\n\n\n# ---------------------------------------------------------------------------\n# Enable / Disable\n# ---------------------------------------------------------------------------\n\n@rules_bp.route('/<int:rule_id>/enable', methods=['POST'])\n@login_required\ndef enable_rule(rule_id):\n    \"\"\"Enable a rule.\"\"\"\n    rule, err = _get_rule_or_404(rule_id)\n    if err:\n        return err\n\n    rule.enabled = True\n    sa.session.commit()\n\n    rule_changed.send('api', action='updated', rule_id=rule.id)\n    return api_success(rule.to_dict())\n\n\n@rules_bp.route('/<int:rule_id>/disable', methods=['POST'])\n@login_required\ndef disable_rule(rule_id):\n    \"\"\"Disable a rule.\"\"\"\n    rule, err = _get_rule_or_404(rule_id)\n    if err:\n        return err\n\n    rule.enabled = False\n    sa.session.commit()\n\n    rule_changed.send('api', action='updated', rule_id=rule.id)\n    return api_success(rule.to_dict())\n\n\n# ---------------------------------------------------------------------------\n# Dry-run / Test\n# ---------------------------------------------------------------------------\n\n@rules_bp.route('/<int:rule_id>/test', methods=['POST'])\n@login_required\ndef test_rule(rule_id):\n    \"\"\"Dry-run a rule against a sample topic + payload.\n\n    Checks topic matching (MQTT wildcard) and condition evaluation.\n    Does NOT fire the rule's action -- purely for testing.\n    \"\"\"\n    rule, err = _get_rule_or_404(rule_id)\n    if err:\n        return err\n\n    data = request.get_json(silent=True)\n    if not data:\n        return api_error(\"JSON body required\", \"VALIDATION_ERROR\", 400)\n\n    topic = data.get('topic')\n    payload_str = data.get('payload')\n\n    if not topic:\n        return api_error(\"topic is required\", \"VALIDATION_ERROR\", 400)\n    if payload_str is None:\n        return api_error(\"payload is required\", \"VALIDATION_ERROR\", 400)\n\n    # Parse payload as JSON if possible\n    payload_dict = None\n    if isinstance(payload_str, dict):\n        payload_dict = payload_str\n    elif isinstance(payload_str, str):\n        try:\n            payload_dict = json.loads(payload_str)\n        except (json.JSONDecodeError, ValueError):\n            payload_dict = None\n\n    # Check MQTT topic matching (supports wildcards + and #)\n    topic_matches = _topic_matches(rule.trigger_topic, topic)\n\n    # Check condition evaluation\n    condition = json.loads(rule.condition_json) if rule.condition_json else {}\n    condition_matches = False\n    try:\n        condition_matches = evaluate(condition, payload_dict)\n    except ConditionError as e:\n        return api_error(f\"Condition error: {e}\", \"CONDITION_ERROR\", 400)\n\n    matched = topic_matches and condition_matches\n    actions = [json.loads(rule.action_json)] if matched else []\n\n    # Build action_preview when rule matches\n    action_preview = []\n    if matched and actions:\n        for action in actions:\n            preview = _build_action_preview(action, {\n                'rule_id': rule.id,\n                'rule_name': rule.name,\n                'topic': topic,\n                'payload': payload_str,\n            })\n            if preview:\n                action_preview.append(preview)\n\n    return api_success({\n        \"match\": matched,\n        \"topic_match\": topic_matches,\n        \"condition_match\": condition_matches,\n        \"actions\": actions,\n        \"action_preview\": action_preview,\n    })\n\n\ndef _topic_matches(pattern, topic):\n    \"\"\"Check if an MQTT topic matches a subscription pattern.\n\n    Supports MQTT wildcards:\n    - '+' matches a single level\n    - '#' matches any remaining levels (must be last)\n    \"\"\"\n    pattern_parts = pattern.split('/')\n    topic_parts = topic.split('/')\n\n    for i, p in enumerate(pattern_parts):\n        if p == '#':\n            return True  # '#' matches everything from here\n        if i >= len(topic_parts):\n            return False\n        if p != '+' and p != topic_parts[i]:\n            return False\n\n    return len(pattern_parts) == len(topic_parts)\n\n\ndef _build_action_preview(action, context):\n    \"\"\"Build a preview of what an action would produce.\n\n    Args:\n        action: Parsed action dict with 'type' key.\n        context: dict with rule_id, rule_name, topic, payload.\n\n    Returns:\n        Preview dict with type-specific fields, or None.\n    \"\"\"\n    action_type = action.get('type')\n\n    if action_type == 'webhook':\n        from mqttui.rules.actions import _build_webhook_payload, _build_default_payload\n        template = action.get('payload_template')\n        if template:\n            payload = _build_webhook_payload(template, context)\n        else:\n            payload = _build_default_payload(context)\n        return {\n            'type': 'webhook',\n            'url': action.get('url', ''),\n            'payload': payload,\n        }\n\n    elif action_type == 'publish':\n        return {\n            'type': 'publish',\n            'topic': action.get('topic', ''),\n            'payload': action.get('payload', context.get('payload', '')),\n        }\n\n    elif action_type == 'log':\n        message = action.get(\n            'message',\n            f\"Rule {context.get('rule_name', '')} fired on {context.get('topic', '')}\",\n        )\n        return {\n            'type': 'log',\n            'severity': action.get('severity', 'info'),\n            'message': message,\n        }\n\n    return None\n"
  },
  {
    "path": "mqttui/rules/__init__.py",
    "content": "from mqttui.rules.engine import RuleEngine\n"
  },
  {
    "path": "mqttui/rules/actions.py",
    "content": "\"\"\"Action executor for the rules engine.\n\nHandles publish, log, and webhook action types.\nWebhook delivery uses httpx in a thread pool for non-blocking HTTP POST.\n\"\"\"\nimport json\nimport logging\nimport time\nfrom concurrent.futures import ThreadPoolExecutor\nfrom datetime import datetime\n\nimport httpx\n\nlogger = logging.getLogger(__name__)\n\n# Module-level thread pool for webhook delivery (non-blocking)\n_webhook_executor = ThreadPoolExecutor(max_workers=4)\n\n\ndef execute_action(action_dict, context):\n    \"\"\"Execute a rule action.\n\n    Args:\n        action_dict: parsed action JSON with 'type' key.\n            - type 'publish': publishes MQTT message with __source marker\n            - type 'log': creates AlertHistory record in database\n            - type 'webhook': HTTP POST with retry in thread pool\n        context: dict with 'rule_id', 'rule_name', 'topic', 'payload' keys.\n\n    Returns:\n        dict with 'success' bool and 'detail' string.\n    \"\"\"\n    action_type = action_dict.get('type')\n\n    if action_type == 'publish':\n        return _execute_publish(action_dict, context)\n    elif action_type == 'log':\n        return _execute_log(action_dict, context)\n    elif action_type == 'webhook':\n        return _execute_webhook(action_dict, context)\n    elif action_type == 'telegram':\n        return _execute_telegram(action_dict, context)\n    elif action_type == 'slack':\n        return _execute_slack(action_dict, context)\n    else:\n        return {\"success\": False, \"detail\": f\"Unknown action type: {action_type}\"}\n\n\ndef _execute_publish(action_dict, context):\n    \"\"\"Publish an MQTT message with __source marker for loop prevention.\"\"\"\n    from mqttui.mqtt_client import publish as mqtt_publish\n\n    # Use action payload if specified, otherwise use original context payload\n    outgoing = action_dict.get('payload', context.get('payload'))\n\n    # Parse as JSON if possible, inject __source marker\n    if isinstance(outgoing, str):\n        try:\n            outgoing_dict = json.loads(outgoing)\n        except (json.JSONDecodeError, TypeError):\n            # Raw string -- wrap in JSON envelope\n            outgoing_dict = {\"payload\": outgoing}\n    elif isinstance(outgoing, dict):\n        outgoing_dict = outgoing\n    else:\n        outgoing_dict = {\"payload\": str(outgoing)}\n\n    outgoing_dict[\"__source\"] = \"mqttui-automation\"\n\n    mqtt_publish(\n        action_dict['topic'],\n        json.dumps(outgoing_dict),\n        qos=action_dict.get('qos', 0),\n        retain=action_dict.get('retain', False),\n    )\n    return {\"success\": True, \"detail\": f\"Published to {action_dict['topic']}\"}\n\n\ndef _execute_log(action_dict, context):\n    \"\"\"Create an AlertHistory record in the database.\"\"\"\n    from mqttui.rules.models import AlertHistory\n    from mqttui.extensions import sa\n\n    message = action_dict.get(\n        'message',\n        f\"Rule {context['rule_name']} fired on {context['topic']}\",\n    )\n\n    record = AlertHistory(\n        rule_id=context['rule_id'],\n        rule_name=context['rule_name'],\n        topic=context['topic'],\n        severity=action_dict.get('severity', 'info'),\n        message=message,\n        fired_at=datetime.utcnow(),\n    )\n    sa.session.add(record)\n    sa.session.commit()\n    return {\"success\": True, \"detail\": \"Alert logged\"}\n\n\ndef _execute_webhook(action_dict, context):\n    \"\"\"Submit webhook delivery to thread pool for async execution.\n\n    Checks per-rule cooldown before submitting. If the rule is within its\n    cooldown window the webhook is suppressed and an AlertHistory record\n    is created with the incremented suppressed_count.\n\n    Returns immediately with submission confirmation. Actual HTTP delivery\n    happens in background thread with retry logic.\n    \"\"\"\n    from mqttui.rules.cooldown import cooldown_tracker\n\n    rule_id = context['rule_id']\n\n    # Check cooldown before submitting\n    if not cooldown_tracker.check(rule_id):\n        suppressed_count = cooldown_tracker.get_suppressed_count(rule_id)\n        cooldown_until = cooldown_tracker.get_cooldown_until(rule_id)\n\n        # Log suppressed alert to history\n        _log_suppressed_alert(\n            rule_id=rule_id,\n            rule_name=context['rule_name'],\n            topic=context['topic'],\n            url=action_dict.get('url', ''),\n            suppressed_count=suppressed_count,\n            cooldown_until=cooldown_until,\n        )\n\n        return {\"success\": True, \"detail\": f\"Alert suppressed (cooldown), {suppressed_count} suppressed\"}\n\n    url = action_dict.get('url', '')\n    payload_template = action_dict.get('payload_template')\n\n    # Build payload\n    if payload_template:\n        payload_json = _build_webhook_payload(payload_template, context)\n    else:\n        payload_json = _build_default_payload(context)\n\n    # Get Flask app for thread pool context\n    try:\n        from flask import current_app\n        app = current_app._get_current_object()\n    except RuntimeError:\n        app = None\n\n    # Submit to thread pool -- non-blocking\n    _webhook_executor.submit(\n        _deliver_webhook,\n        url=url,\n        payload_json=payload_json,\n        rule_id=context['rule_id'],\n        rule_name=context['rule_name'],\n        topic=context['topic'],\n        app=app,\n    )\n\n    return {\"success\": True, \"detail\": \"Webhook delivery submitted\"}\n\n\ndef _build_default_payload(context):\n    \"\"\"Build the default webhook JSON payload.\"\"\"\n    return {\n        \"topic\": context.get('topic', ''),\n        \"payload\": context.get('payload', ''),\n        \"rule_name\": context.get('rule_name', ''),\n        \"timestamp\": datetime.utcnow().isoformat(),\n        \"mqttui_source\": True,\n    }\n\n\ndef _build_webhook_payload(template, context):\n    \"\"\"Build webhook payload from a template string with {{variable}} substitution.\n\n    Supports: {{topic}}, {{payload}}, {{rule_name}}, {{timestamp}}\n\n    Args:\n        template: JSON string with {{variable}} placeholders.\n        context: dict with rule_id, rule_name, topic, payload.\n\n    Returns:\n        Parsed dict from the substituted template.\n    \"\"\"\n    substitutions = {\n        '{{topic}}': str(context.get('topic', '')),\n        '{{payload}}': str(context.get('payload', '')),\n        '{{rule_name}}': str(context.get('rule_name', '')),\n        '{{timestamp}}': datetime.utcnow().isoformat(),\n    }\n\n    result = template\n    for placeholder, value in substitutions.items():\n        result = result.replace(placeholder, value)\n\n    try:\n        return json.loads(result)\n    except (json.JSONDecodeError, TypeError):\n        return {\"raw\": result}\n\n\ndef _deliver_webhook(url, payload_json, rule_id, rule_name, topic,\n                     max_retries=3, _sleep_fn=None, app=None):\n    \"\"\"Deliver webhook HTTP POST with retry logic.\n\n    Args:\n        url: Destination URL for the POST request.\n        payload_json: Dict payload to send as JSON.\n        rule_id: Rule ID for AlertHistory logging.\n        rule_name: Rule name for AlertHistory logging.\n        topic: MQTT topic that triggered the rule.\n        max_retries: Maximum retry attempts on 5xx/connection errors.\n        _sleep_fn: Override sleep function for testing (default: time.sleep).\n        app: Flask app instance for creating app context in thread pool.\n\n    Returns:\n        dict with 'success' bool and 'detail' string.\n    \"\"\"\n    from mqttui.rules.models import AlertHistory\n    from mqttui.extensions import sa\n    from mqttui.events import alert_triggered\n\n    # Push app context for this thread (thread pool workers have none)\n    ctx = None\n    if app is not None:\n        ctx = app.app_context()\n        ctx.push()\n\n    sleep_fn = _sleep_fn or time.sleep\n    last_status = None\n    last_error = None\n    retries = 0\n\n    for attempt in range(1 + max_retries):\n        try:\n            response = httpx.post(url, json=payload_json, timeout=10.0)\n            last_status = response.status_code\n\n            if 200 <= response.status_code < 300:\n                # Success\n                _log_webhook_history(\n                    sa, rule_id, rule_name, topic, url,\n                    http_status=response.status_code,\n                    retry_count=attempt,\n                )\n                alert_triggered.send(\n                    'webhook',\n                    alert_id=None,\n                    rule_id=rule_id,\n                    message=f\"Webhook delivered to {url}\",\n                )\n                try:\n                    from mqttui.routes.metrics import WEBHOOK_DELIVERIES\n                    WEBHOOK_DELIVERIES.labels(status='success').inc()\n                except Exception:\n                    pass\n                logger.info(f\"Webhook delivered: rule={rule_name} url={url} status={response.status_code}\")\n                if ctx is not None:\n                    ctx.pop()\n                return {\"success\": True, \"detail\": f\"Webhook delivered (HTTP {response.status_code})\"}\n\n            elif 400 <= response.status_code < 500:\n                # Client error -- no retry\n                last_error = f\"HTTP {response.status_code}: {response.text[:200]}\"\n                logger.warning(f\"Webhook client error: rule={rule_name} url={url} status={response.status_code}\")\n                _log_webhook_history(\n                    sa, rule_id, rule_name, topic, url,\n                    http_status=response.status_code,\n                    retry_count=0,\n                    error_detail=last_error,\n                )\n                if ctx is not None:\n                    ctx.pop()\n                return {\"success\": False, \"detail\": last_error}\n\n            else:\n                # 5xx or other -- retry\n                last_error = f\"HTTP {response.status_code}: {response.text[:200]}\"\n                retries = attempt\n                if attempt < max_retries:\n                    backoff = 5 ** attempt  # 1s, 5s, 25s\n                    logger.info(f\"Webhook retry {attempt + 1}/{max_retries}: rule={rule_name} backoff={backoff}s\")\n                    sleep_fn(backoff)\n\n        except (httpx.ConnectError, httpx.TimeoutException, OSError) as e:\n            last_error = str(e)\n            retries = attempt\n            if attempt < max_retries:\n                backoff = 5 ** attempt\n                logger.info(f\"Webhook retry {attempt + 1}/{max_retries}: rule={rule_name} error={e}\")\n                sleep_fn(backoff)\n\n    # Exhausted retries\n    _log_webhook_history(\n        sa, rule_id, rule_name, topic, url,\n        http_status=last_status,\n        retry_count=retries,\n        error_detail=last_error,\n    )\n    try:\n        from mqttui.routes.metrics import WEBHOOK_DELIVERIES\n        WEBHOOK_DELIVERIES.labels(status='failure').inc()\n    except Exception:\n        pass\n    logger.error(f\"Webhook failed after {retries} retries: rule={rule_name} url={url}\")\n    if ctx is not None:\n        ctx.pop()\n    return {\"success\": False, \"detail\": f\"Webhook failed after {retries} retries: {last_error}\"}\n\n\ndef _execute_telegram(action_dict, context):\n    \"\"\"Send a Telegram message via Bot API.\n\n    Converts to a webhook action targeting the Telegram Bot API, then delegates\n    to _execute_webhook for cooldown, retry, alert history, and metrics.\n    \"\"\"\n    bot_token = action_dict.get('bot_token', '')\n    chat_id = action_dict.get('chat_id', '')\n    template = action_dict.get('message_template',\n                               '🔔 *MQTT Alert*\\n*Rule:* {{rule_name}}\\n*Topic:* `{{topic}}`\\n*Payload:* `{{payload}}`')\n\n    if not bot_token or not chat_id:\n        return {\"success\": False, \"detail\": \"Telegram requires bot_token and chat_id\"}\n\n    # Substitute placeholders\n    message = _substitute_template(template, context)\n\n    url = f\"https://api.telegram.org/bot{bot_token}/sendMessage\"\n    payload_template = json.dumps({\"chat_id\": chat_id, \"text\": message, \"parse_mode\": \"Markdown\"})\n\n    # Delegate to webhook with full cooldown + retry + alert logging\n    return _execute_webhook({'url': url, 'payload_template': payload_template}, context)\n\n\ndef _execute_slack(action_dict, context):\n    \"\"\"Send a Slack message via incoming webhook URL.\n\n    Converts to a webhook action, then delegates to _execute_webhook for\n    cooldown, retry, alert history, and metrics.\n    \"\"\"\n    webhook_url = action_dict.get('webhook_url', '')\n    template = action_dict.get('message_template',\n                               '🔔 *MQTT Alert*\\n>*Rule:* {{rule_name}}\\n>*Topic:* `{{topic}}`\\n>*Payload:* `{{payload}}`')\n\n    if not webhook_url:\n        return {\"success\": False, \"detail\": \"Slack requires webhook_url\"}\n\n    message = _substitute_template(template, context)\n    payload_template = json.dumps({\"text\": message})\n\n    return _execute_webhook({'url': webhook_url, 'payload_template': payload_template}, context)\n\n\ndef _substitute_template(template, context):\n    \"\"\"Replace {{variable}} placeholders in a template string.\"\"\"\n    result = template\n    for placeholder, value in {\n        '{{topic}}': str(context.get('topic', '')),\n        '{{payload}}': str(context.get('payload', '')),\n        '{{rule_name}}': str(context.get('rule_name', '')),\n        '{{timestamp}}': datetime.utcnow().isoformat(),\n    }.items():\n        result = result.replace(placeholder, value)\n    return result\n\n\ndef _log_suppressed_alert(rule_id, rule_name, topic, url,\n                          suppressed_count, cooldown_until):\n    \"\"\"Create an AlertHistory record for a suppressed (cooldown) alert.\"\"\"\n    from mqttui.rules.models import AlertHistory\n    from mqttui.extensions import sa\n\n    try:\n        record = AlertHistory(\n            rule_id=rule_id,\n            rule_name=rule_name,\n            topic=topic,\n            severity='info',\n            message=f\"Webhook to {url} suppressed (cooldown)\",\n            fired_at=datetime.utcnow(),\n            webhook_url=url,\n            suppressed_count=suppressed_count,\n            cooldown_until=cooldown_until,\n        )\n        sa.session.add(record)\n        sa.session.commit()\n    except Exception as e:\n        logger.error(f\"Failed to log suppressed alert: {e}\")\n        try:\n            sa.session.rollback()\n        except Exception:\n            pass\n\n\ndef _log_webhook_history(sa, rule_id, rule_name, topic, url,\n                         http_status=None, retry_count=0, error_detail=None):\n    \"\"\"Create an AlertHistory record for webhook delivery.\"\"\"\n    from mqttui.rules.models import AlertHistory\n\n    try:\n        record = AlertHistory(\n            rule_id=rule_id,\n            rule_name=rule_name,\n            topic=topic,\n            severity='info' if http_status and http_status < 400 else 'error',\n            message=f\"Webhook to {url}\",\n            fired_at=datetime.utcnow(),\n            webhook_url=url,\n            http_status=http_status,\n            retry_count=retry_count,\n            error_detail=error_detail,\n        )\n        sa.session.add(record)\n        sa.session.commit()\n    except Exception as e:\n        logger.error(f\"Failed to log webhook history: {e}\")\n        try:\n            sa.session.rollback()\n        except Exception:\n            pass\n"
  },
  {
    "path": "mqttui/rules/cooldown.py",
    "content": "\"\"\"Per-rule alert cooldown tracker for deduplication.\n\nPrevents alert storms from sustained conditions by enforcing a minimum\ninterval between alert firings for each rule. Suppressed alerts are\ncounted so the suppression can be reported in AlertHistory.\n\"\"\"\nimport time\nfrom datetime import datetime, timedelta\n\n\nclass CooldownTracker:\n    \"\"\"In-memory per-rule cooldown tracker.\n\n    Tracks the last time each rule fired and blocks subsequent firings\n    within the cooldown window. Thread-safe for use from the webhook\n    thread pool (GIL protects dict operations).\n    \"\"\"\n\n    def __init__(self, default_seconds=300):\n        \"\"\"Initialize with a default cooldown window.\n\n        Args:\n            default_seconds: Default cooldown window in seconds (default 5 min).\n        \"\"\"\n        self._last_fired = {}   # rule_id -> monotonic timestamp\n        self._suppressed = {}   # rule_id -> suppressed count since last fire\n        self._default = default_seconds\n\n    def check(self, rule_id, cooldown_seconds=None):\n        \"\"\"Check if a rule is allowed to fire.\n\n        Args:\n            rule_id: The rule identifier.\n            cooldown_seconds: Optional per-rule cooldown override.\n\n        Returns:\n            True if the rule is allowed to fire, False if suppressed.\n        \"\"\"\n        window = cooldown_seconds if cooldown_seconds is not None else self._default\n        now = time.monotonic()\n\n        last = self._last_fired.get(rule_id)\n        if last is None or (now - last) > window:\n            # Allowed -- record firing and reset suppressed count\n            self._last_fired[rule_id] = now\n            self._suppressed[rule_id] = 0\n            return True\n\n        # Suppressed\n        self._suppressed[rule_id] = self._suppressed.get(rule_id, 0) + 1\n        return False\n\n    def get_suppressed_count(self, rule_id):\n        \"\"\"Return the number of suppressed alerts since the last firing.\n\n        Args:\n            rule_id: The rule identifier.\n\n        Returns:\n            Number of suppressed alerts (0 if rule has never been suppressed).\n        \"\"\"\n        return self._suppressed.get(rule_id, 0)\n\n    def get_cooldown_until(self, rule_id, cooldown_seconds=None):\n        \"\"\"Return the datetime when the cooldown expires for a rule.\n\n        Args:\n            rule_id: The rule identifier.\n            cooldown_seconds: Optional per-rule cooldown override.\n\n        Returns:\n            datetime when cooldown expires, or None if no active cooldown.\n        \"\"\"\n        window = cooldown_seconds if cooldown_seconds is not None else self._default\n        last = self._last_fired.get(rule_id)\n        if last is None:\n            return None\n\n        elapsed = time.monotonic() - last\n        remaining = window - elapsed\n        if remaining <= 0:\n            return None\n\n        return datetime.utcnow() + timedelta(seconds=remaining)\n\n\n# Module-level singleton used by the action executor\ncooldown_tracker = CooldownTracker()\n"
  },
  {
    "path": "mqttui/rules/engine.py",
    "content": "\"\"\"RuleEngine -- runtime heart of the rules automation system.\n\nSubscribes to mqtt_message_received events, evaluates matching rules,\nexecutes actions, and enforces safety mechanisms (loop prevention,\nper-rule rate limiting, global circuit breaker).\n\nAlso manages time-based rules via APScheduler and hot-reloads rule cache\nwhen rules are created, updated, or deleted.\n\"\"\"\nimport json\nimport logging\nimport time\nfrom collections import deque\nfrom datetime import datetime\n\nfrom paho.mqtt.matcher import MQTTMatcher\n\nfrom mqttui.events import mqtt_message_received, rule_fired, rule_changed\nfrom mqttui.rules.evaluator import evaluate, ConditionError\nfrom mqttui.rules.actions import execute_action\n\nlogger = logging.getLogger(__name__)\n\n# Module-level singleton reference for APScheduler pickle compatibility\n_engine = None\n\n\ndef _fire_scheduled_rule(app, rule_id):\n    \"\"\"Module-level function for APScheduler pickle compatibility.\n\n    APScheduler serialises job targets; lambdas and bound methods cannot be\n    pickled, so we use a plain module-level function with explicit args.\n    \"\"\"\n    with app.app_context():\n        if _engine:\n            _engine.fire_scheduled_rule(rule_id)\n\n\ndef get_engine():\n    \"\"\"Return the module-level RuleEngine singleton (or None).\"\"\"\n    return _engine\n\n\nclass RuleEngine:\n    \"\"\"Evaluate automation rules against live MQTT messages.\n\n    Attributes:\n        _rules: dict mapping rule_id -> Rule ORM instance (enabled only)\n        _matcher: MQTTMatcher mapping subscription patterns to rule_ids\n        _rule_timestamps: dict mapping rule_id -> deque of firing timestamps\n        _global_timestamps: deque of all firing timestamps (circuit breaker)\n        _GLOBAL_LIMIT: max total firings per window (default 100)\n        _WINDOW: sliding window duration in seconds (default 60)\n        _scheduler: GeventScheduler instance (or None before init_scheduler)\n    \"\"\"\n\n    def __init__(self, app=None):\n        self._rules = {}\n        self._matcher = MQTTMatcher()\n        self._rule_timestamps = {}\n        self._global_timestamps = deque()\n        self._GLOBAL_LIMIT = 100\n        self._WINDOW = 60.0\n        self._app = app\n        self._scheduler = None\n\n    # ------------------------------------------------------------------\n    # Scheduler\n    # ------------------------------------------------------------------\n\n    def init_scheduler(self):\n        \"\"\"Start the APScheduler GeventScheduler with SQLAlchemy job store.\"\"\"\n        from apscheduler.schedulers.gevent import GeventScheduler\n        from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore\n\n        uri = self._app.config['SQLALCHEMY_DATABASE_URI']\n        self._scheduler = GeventScheduler(\n            jobstores={'default': SQLAlchemyJobStore(url=uri)},\n            job_defaults={\n                'misfire_grace_time': 30,\n                'max_instances': 1,\n                'coalesce': True,\n            },\n        )\n        self._scheduler.start()\n        logger.info(\"APScheduler GeventScheduler started\")\n\n    def fire_scheduled_rule(self, rule_id):\n        \"\"\"Execute a time-based rule by id (called from scheduler job).\"\"\"\n        rule = self._rules.get(rule_id)\n        if rule is None or not rule.enabled:\n            logger.debug(f\"Scheduled rule {rule_id} skipped (not found or disabled)\")\n            return\n\n        # Rate limit check\n        if not self._check_rate_limit(rule):\n            logger.debug(f\"Scheduled rule {rule_id} rate-limited\")\n            return\n\n        # Build action and context\n        try:\n            action_dict = json.loads(rule.action_json) if rule.action_json else {}\n        except json.JSONDecodeError:\n            logger.error(f\"Invalid action JSON for scheduled rule {rule_id}\")\n            return\n\n        context = {\n            'rule_id': rule.id,\n            'rule_name': rule.name,\n            'topic': '__scheduled__',\n            'payload': '',\n        }\n\n        try:\n            result = execute_action(action_dict, context)\n            if not result.get('success'):\n                logger.warning(f\"Scheduled action failed for rule {rule_id}: {result.get('detail')}\")\n        except Exception as e:\n            logger.error(f\"Scheduled action error for rule {rule_id}: {e}\")\n            return\n\n        # Update rule stats\n        try:\n            from mqttui.extensions import sa\n            rule.fire_count = (rule.fire_count or 0) + 1\n            rule.last_fired = datetime.utcnow()\n            sa.session.commit()\n        except Exception as e:\n            logger.error(f\"Failed to update stats for scheduled rule {rule_id}: {e}\")\n\n        # Fire signal\n        rule_fired.send(\n            self,\n            rule_id=rule.id,\n            rule_name=rule.name,\n            topic='__scheduled__',\n            payload='',\n        )\n\n    def sync_scheduled_jobs(self):\n        \"\"\"Synchronise APScheduler jobs with the current rule cache.\n\n        - New cron rules get jobs added.\n        - Deleted rules get jobs removed.\n        - Changed cron expressions are updated (replace_existing=True).\n        \"\"\"\n        if self._scheduler is None:\n            return\n\n        from apscheduler.triggers.cron import CronTrigger\n\n        existing_jobs = {job.id for job in self._scheduler.get_jobs()}\n\n        for rule in self._rules.values():\n            job_id = f'rule_{rule.id}'\n            if rule.schedule_cron and rule.schedule_cron.strip():\n                self._scheduler.add_job(\n                    _fire_scheduled_rule,\n                    trigger=CronTrigger.from_crontab(rule.schedule_cron),\n                    args=[self._app, rule.id],\n                    id=job_id,\n                    replace_existing=True,\n                )\n                existing_jobs.discard(job_id)\n            else:\n                # Rule has no cron -- remove stale job if present\n                if job_id in existing_jobs:\n                    self._scheduler.remove_job(job_id)\n                    existing_jobs.discard(job_id)\n\n        # Cleanup jobs for rules that were deleted\n        for job_id in existing_jobs:\n            if job_id.startswith('rule_'):\n                self._scheduler.remove_job(job_id)\n\n        logger.debug(\"Scheduler jobs synced with rule cache\")\n\n    # ------------------------------------------------------------------\n    # Cache\n    # ------------------------------------------------------------------\n\n    def reload_cache(self):\n        \"\"\"Load enabled rules from DB into in-memory cache and rebuild matcher.\"\"\"\n        from mqttui.rules.models import Rule\n\n        ctx = self._app.app_context() if self._app else _nullcontext()\n        with ctx:\n            enabled_rules = Rule.query.filter_by(enabled=True).all()\n\n            new_rules = {}\n            new_matcher = MQTTMatcher()\n\n            for rule in enabled_rules:\n                new_rules[rule.id] = rule\n                # MQTTMatcher stores value at subscription key\n                # Multiple rules can share a topic, so store list\n                try:\n                    existing = new_matcher[rule.trigger_topic]\n                    existing.append(rule.id)\n                except KeyError:\n                    new_matcher[rule.trigger_topic] = [rule.id]\n\n            # Atomic swap\n            self._rules = new_rules\n            self._matcher = new_matcher\n\n            # Clean stale rate-limit entries\n            active_ids = set(new_rules.keys())\n            stale_ids = set(self._rule_timestamps.keys()) - active_ids\n            for rid in stale_ids:\n                del self._rule_timestamps[rid]\n\n        logger.info(f\"RuleEngine cache reloaded: {len(new_rules)} enabled rules\")\n\n        # Sync scheduler jobs after cache reload\n        self.sync_scheduled_jobs()\n\n    # ------------------------------------------------------------------\n    # Rate limiting\n    # ------------------------------------------------------------------\n\n    def _check_rate_limit(self, rule):\n        \"\"\"Check per-rule and global rate limits using sliding window.\n\n        Returns True if the rule is allowed to fire, False if blocked.\n        On allow, records the timestamp in both deques.\n        \"\"\"\n        now = time.monotonic()\n        cutoff = now - self._WINDOW\n\n        # --- Global circuit breaker ---\n        while self._global_timestamps and self._global_timestamps[0] < cutoff:\n            self._global_timestamps.popleft()\n        if len(self._global_timestamps) >= self._GLOBAL_LIMIT:\n            logger.warning(\"Global circuit breaker triggered\")\n            return False\n\n        # --- Per-rule rate limit ---\n        if rule.id not in self._rule_timestamps:\n            self._rule_timestamps[rule.id] = deque()\n        rule_deque = self._rule_timestamps[rule.id]\n        while rule_deque and rule_deque[0] < cutoff:\n            rule_deque.popleft()\n        if len(rule_deque) >= rule.rate_limit_per_min:\n            logger.debug(f\"Rate limit hit for rule {rule.id} ({rule.name})\")\n            return False\n\n        # Allowed -- record timestamp\n        rule_deque.append(now)\n        self._global_timestamps.append(now)\n        return True\n\n    # ------------------------------------------------------------------\n    # MQTT message handler\n    # ------------------------------------------------------------------\n\n    def on_mqtt_message(self, sender, **kwargs):\n        \"\"\"Handle an incoming MQTT message from the event bus.\n\n        1. Parse payload, check for loop marker\n        2. Match topic against cached rules\n        3. For each match: check rate limit, evaluate condition, execute action\n        4. Fire rule_fired signal on success\n        \"\"\"\n        topic = kwargs.get('topic', '')\n        payload_raw = kwargs.get('payload', '')\n\n        # Parse payload\n        try:\n            payload_dict = json.loads(payload_raw)\n        except (json.JSONDecodeError, TypeError):\n            payload_dict = {}\n\n        # Loop prevention: skip messages from our own automation\n        if isinstance(payload_dict, dict) and payload_dict.get('__source') == 'mqttui-automation':\n            logger.debug(f\"Skipping automation message on {topic}\")\n            return\n\n        # Find matching rules via MQTTMatcher\n        matched_rule_ids = []\n        try:\n            for rule_id_list in self._matcher.iter_match(topic):\n                if isinstance(rule_id_list, list):\n                    matched_rule_ids.extend(rule_id_list)\n                else:\n                    matched_rule_ids.append(rule_id_list)\n        except (StopIteration, KeyError):\n            return\n\n        if not matched_rule_ids:\n            return\n\n        ctx = self._app.app_context() if self._app else _nullcontext()\n        with ctx:\n            for rule_id in matched_rule_ids:\n                rule = self._rules.get(rule_id)\n                if rule is None:\n                    continue\n\n                # Rate limit check\n                if not self._check_rate_limit(rule):\n                    continue\n\n                # Evaluate condition\n                try:\n                    condition = json.loads(rule.condition_json) if rule.condition_json else {}\n                except json.JSONDecodeError:\n                    logger.error(f\"Invalid condition JSON for rule {rule.id}\")\n                    continue\n\n                try:\n                    if not evaluate(condition, payload_dict):\n                        # Condition did not match -- undo rate limit recording\n                        if rule.id in self._rule_timestamps and self._rule_timestamps[rule.id]:\n                            self._rule_timestamps[rule.id].pop()\n                        if self._global_timestamps:\n                            self._global_timestamps.pop()\n                        continue\n                except ConditionError as e:\n                    logger.error(f\"Condition error for rule {rule.id}: {e}\")\n                    continue\n\n                # Execute action\n                try:\n                    action = json.loads(rule.action_json) if rule.action_json else {}\n                except json.JSONDecodeError:\n                    logger.error(f\"Invalid action JSON for rule {rule.id}\")\n                    continue\n\n                context = {\n                    'rule_id': rule.id,\n                    'rule_name': rule.name,\n                    'topic': topic,\n                    'payload': payload_raw,\n                }\n\n                try:\n                    result = execute_action(action, context)\n                    if not result.get('success'):\n                        logger.warning(f\"Action failed for rule {rule.id}: {result.get('detail')}\")\n                except Exception as e:\n                    logger.error(f\"Action execution error for rule {rule.id}: {e}\")\n                    continue\n\n                # Update rule stats\n                try:\n                    from mqttui.extensions import sa\n                    rule.fire_count = (rule.fire_count or 0) + 1\n                    rule.last_fired = datetime.utcnow()\n                    sa.session.commit()\n                except Exception as e:\n                    logger.error(f\"Failed to update rule stats for {rule.id}: {e}\")\n\n                # Fire signal\n                rule_fired.send(\n                    self,\n                    rule_id=rule.id,\n                    rule_name=rule.name,\n                    topic=topic,\n                    payload=payload_raw,\n                )\n\n    # ------------------------------------------------------------------\n    # Hot-reload\n    # ------------------------------------------------------------------\n\n    def _on_rule_changed(self, sender, **kwargs):\n        \"\"\"Handle rule_changed signal by reloading the cache.\"\"\"\n        logger.info(f\"Rule changed ({kwargs.get('action', 'unknown')}), reloading cache\")\n        self.reload_cache()\n\n    # ------------------------------------------------------------------\n    # Lifecycle\n    # ------------------------------------------------------------------\n\n    def connect(self):\n        \"\"\"Connect to the event bus, load rule cache, and start scheduler.\"\"\"\n        global _engine\n        _engine = self\n\n        mqtt_message_received.connect(self.on_mqtt_message)\n        rule_changed.connect(self._on_rule_changed)\n        self.reload_cache()\n\n        if self._scheduler is None:\n            self.init_scheduler()\n\n        logger.info(\"RuleEngine connected to event bus\")\n\n    def disconnect(self):\n        \"\"\"Disconnect from the event bus and shut down scheduler.\"\"\"\n        mqtt_message_received.disconnect(self.on_mqtt_message)\n        try:\n            rule_changed.disconnect(self._on_rule_changed)\n        except Exception:\n            pass  # May not be connected\n        if self._scheduler and self._scheduler.running:\n            self._scheduler.shutdown(wait=False)\n        logger.info(\"RuleEngine disconnected from event bus\")\n\n\nclass _nullcontext:\n    \"\"\"Minimal context manager for when no app context is needed.\"\"\"\n\n    def __enter__(self):\n        return self\n\n    def __exit__(self, *args):\n        pass\n"
  },
  {
    "path": "mqttui/rules/evaluator.py",
    "content": "\"\"\"Pure-function condition evaluator for the rules engine.\n\nEvaluates structured JSON conditions against message payloads.\nNo side effects, no database access -- purely functional.\n\"\"\"\nimport re\n\n\nclass ConditionError(Exception):\n    \"\"\"Raised when a condition is malformed or uses an unknown operator.\"\"\"\n    pass\n\n\ndef _get_path(data, path):\n    \"\"\"Resolve a dot-notation path in a nested dict.\n\n    Args:\n        data: dict to traverse\n        path: dot-separated path string (e.g. \"sensors.outdoor.temp\")\n\n    Returns:\n        The value at the path.\n\n    Raises:\n        KeyError: if any segment is missing\n        TypeError: if a non-dict is encountered mid-path\n    \"\"\"\n    segments = path.split('.')\n    current = data\n    for segment in segments:\n        current = current[segment]\n    return current\n\n\ndef _coerce_numeric(actual, expected):\n    \"\"\"Coerce actual to numeric type if expected is numeric and actual is a string.\n\n    Handles sensor payloads like {\"temp\": \"28\"} where values arrive as strings.\n    \"\"\"\n    if isinstance(expected, (int, float)) and isinstance(actual, str):\n        try:\n            return float(actual)\n        except (ValueError, TypeError):\n            return actual\n    return actual\n\n\ndef evaluate(condition, payload):\n    \"\"\"Evaluate a condition dict against a payload dict.\n\n    Args:\n        condition: dict describing the condition. Supports:\n            - Simple: {\"path\": \"temp\", \"op\": \"gt\", \"value\": 30}\n            - Compound: {\"all\": [...conditions]} or {\"any\": [...conditions]}\n            - Empty/None: always matches (returns True)\n        payload: dict of the parsed message payload, or None for non-JSON.\n\n    Returns:\n        bool: True if condition matches, False otherwise.\n\n    Raises:\n        ConditionError: if an unknown operator is used.\n    \"\"\"\n    # Empty or None condition = always match\n    if not condition:\n        return True\n\n    # Compound conditions\n    if 'all' in condition:\n        return all(evaluate(c, payload) for c in condition['all'])\n    if 'any' in condition:\n        return any(evaluate(c, payload) for c in condition['any'])\n\n    path = condition.get('path')\n    op = condition.get('op')\n    value = condition.get('value')\n\n    # Existence checks don't need the actual value resolved first\n    if op == 'exists':\n        if not isinstance(payload, dict):\n            return False\n        try:\n            _get_path(payload, path)\n            return True\n        except (KeyError, TypeError):\n            return False\n\n    if op == 'not_exists':\n        if not isinstance(payload, dict):\n            return True\n        try:\n            _get_path(payload, path)\n            return False\n        except (KeyError, TypeError):\n            return True\n\n    # All other ops require resolving the path value\n    if not isinstance(payload, dict):\n        return False\n\n    try:\n        actual = _get_path(payload, path)\n    except (KeyError, TypeError):\n        return False\n\n    # Numeric coercion for comparison operators\n    if op in ('gt', 'lt', 'gte', 'lte'):\n        actual = _coerce_numeric(actual, value)\n\n    if op == 'eq':\n        return actual == value\n    elif op == 'ne':\n        return actual != value\n    elif op == 'gt':\n        return actual > value\n    elif op == 'lt':\n        return actual < value\n    elif op == 'gte':\n        return actual >= value\n    elif op == 'lte':\n        return actual <= value\n    elif op == 'contains':\n        return str(value) in str(actual)\n    elif op == 'not_contains':\n        return str(value) not in str(actual)\n    elif op == 'regex':\n        return bool(re.search(str(value), str(actual)))\n    else:\n        raise ConditionError(f\"Unknown operator: {op}\")\n"
  },
  {
    "path": "mqttui/rules/models.py",
    "content": "from mqttui.extensions import sa\nfrom datetime import datetime\nimport json\n\n\nclass Rule(sa.Model):\n    __tablename__ = 'rules'\n\n    id = sa.Column(sa.Integer, primary_key=True)\n    name = sa.Column(sa.String(200), nullable=False)\n    description = sa.Column(sa.Text, default='')\n    trigger_topic = sa.Column(sa.String(500), nullable=False)\n    condition_json = sa.Column(sa.Text, nullable=False, default='{}')\n    action_json = sa.Column(sa.Text, nullable=False)\n    enabled = sa.Column(sa.Boolean, default=True)\n    rate_limit_per_min = sa.Column(sa.Integer, default=10)\n    schedule_cron = sa.Column(sa.String(100), nullable=True)\n    last_fired = sa.Column(sa.DateTime, nullable=True)\n    fire_count = sa.Column(sa.Integer, default=0)\n    created_at = sa.Column(sa.DateTime, server_default=sa.func.now())\n    updated_at = sa.Column(sa.DateTime, server_default=sa.func.now(), onupdate=datetime.utcnow)\n\n    @property\n    def action(self):\n        try:\n            return json.loads(self.action_json) if self.action_json else {}\n        except (json.JSONDecodeError, TypeError):\n            return {}\n\n    @property\n    def condition(self):\n        try:\n            return json.loads(self.condition_json) if self.condition_json else {}\n        except (json.JSONDecodeError, TypeError):\n            return {}\n\n    def to_dict(self):\n        return {\n            'id': self.id,\n            'name': self.name,\n            'description': self.description,\n            'trigger_topic': self.trigger_topic,\n            'condition': self.condition,\n            'action': self.action,\n            'enabled': self.enabled,\n            'rate_limit_per_min': self.rate_limit_per_min,\n            'schedule_cron': self.schedule_cron,\n            'last_fired': self.last_fired.isoformat() if self.last_fired else None,\n            'fire_count': self.fire_count,\n            'created_at': self.created_at.isoformat() if self.created_at else None,\n            'updated_at': self.updated_at.isoformat() if self.updated_at else None,\n        }\n\n\nclass AlertHistory(sa.Model):\n    __tablename__ = 'alert_history'\n\n    id = sa.Column(sa.Integer, primary_key=True)\n    rule_id = sa.Column(sa.Integer, nullable=True)\n    rule_name = sa.Column(sa.String(200))\n    topic = sa.Column(sa.String(500))\n    severity = sa.Column(sa.String(20), default='info')\n    message = sa.Column(sa.Text)\n    fired_at = sa.Column(sa.DateTime, server_default=sa.func.now())\n    # Webhook delivery fields (Phase 4)\n    webhook_url = sa.Column(sa.String(1000), nullable=True)\n    http_status = sa.Column(sa.Integer, nullable=True)\n    retry_count = sa.Column(sa.Integer, default=0)\n    error_detail = sa.Column(sa.Text, nullable=True)\n    suppressed_count = sa.Column(sa.Integer, default=0)\n    cooldown_until = sa.Column(sa.DateTime, nullable=True)\n\n    def to_dict(self):\n        return {\n            'id': self.id,\n            'rule_id': self.rule_id,\n            'rule_name': self.rule_name,\n            'topic': self.topic,\n            'severity': self.severity,\n            'message': self.message,\n            'fired_at': self.fired_at.isoformat() if self.fired_at else None,\n            'webhook_url': self.webhook_url,\n            'http_status': self.http_status,\n            'retry_count': self.retry_count,\n            'error_detail': self.error_detail,\n            'suppressed_count': self.suppressed_count,\n            'cooldown_until': self.cooldown_until.isoformat() if self.cooldown_until else None,\n        }\n"
  },
  {
    "path": "mqttui/rules/ssrf.py",
    "content": "\"\"\"SSRF URL validation for webhook destinations.\n\nBlocks webhook URLs that resolve to private, loopback, or link-local addresses\nto prevent Server-Side Request Forgery attacks.\n\"\"\"\n\nimport ipaddress\nimport logging\nimport socket\nfrom urllib.parse import urlparse\n\nlogger = logging.getLogger(__name__)\n\n\ndef is_ssrf_safe(url: str) -> tuple:\n    \"\"\"Check if a URL is safe from SSRF by resolving and validating the IP.\n\n    Args:\n        url: The webhook destination URL to validate.\n\n    Returns:\n        Tuple of (safe: bool, reason: str). If safe is False, reason explains why.\n    \"\"\"\n    try:\n        parsed = urlparse(url)\n    except Exception:\n        return False, \"Invalid URL\"\n\n    hostname = parsed.hostname\n    if not hostname:\n        return False, \"No hostname in URL\"\n\n    # Try to parse hostname directly as IP address first\n    try:\n        addr = ipaddress.ip_address(hostname)\n        return _check_ip(addr)\n    except ValueError:\n        pass\n\n    # Resolve hostname via DNS\n    try:\n        results = socket.getaddrinfo(hostname, parsed.port or 80, proto=socket.IPPROTO_TCP)\n    except socket.gaierror as e:\n        return False, f\"DNS resolution failed: {e}\"\n\n    if not results:\n        return False, \"DNS resolution returned no results\"\n\n    # Check ALL resolved addresses -- block if any are private\n    for family, _, _, _, sockaddr in results:\n        ip_str = sockaddr[0]\n        try:\n            addr = ipaddress.ip_address(ip_str)\n            safe, reason = _check_ip(addr)\n            if not safe:\n                return False, reason\n        except ValueError:\n            continue\n\n    return True, \"URL is safe\"\n\n\ndef _check_ip(addr) -> tuple:\n    \"\"\"Check a single IP address against blocked ranges.\n\n    Returns:\n        Tuple of (safe: bool, reason: str).\n    \"\"\"\n    if addr.is_private:\n        return False, f\"Blocked: {addr} is a private address\"\n    if addr.is_loopback:\n        return False, f\"Blocked: {addr} is a loopback address\"\n    if addr.is_link_local:\n        return False, f\"Blocked: {addr} is a link-local address\"\n    if addr.is_reserved:\n        return False, f\"Blocked: {addr} is a reserved address\"\n    if addr.is_multicast:\n        return False, f\"Blocked: {addr} is a multicast address\"\n    if addr.is_unspecified:\n        return False, f\"Blocked: {addr} is an unspecified address\"\n\n    return True, \"Address is safe\"\n"
  },
  {
    "path": "mqttui/socketio_batch.py",
    "content": "import threading\nfrom flask_socketio import SocketIO\n\n\nclass BatchEmitter:\n    \"\"\"Collects MQTT messages and emits them in batches every 100ms.\n\n    This prevents UI flooding at high throughput by buffering messages\n    server-side and sending them in consolidated batches.\n    \"\"\"\n\n    def __init__(self, socketio: SocketIO, interval_ms: int = 100):\n        self._socketio = socketio\n        self._interval = interval_ms / 1000.0\n        self._buffer = []\n        self._lock = threading.Lock()\n        self._timer = None\n        self._running = False\n\n    def start(self):\n        \"\"\"Start the periodic flush timer.\"\"\"\n        self._running = True\n        self._schedule_flush()\n\n    def stop(self):\n        \"\"\"Stop the periodic flush timer.\"\"\"\n        self._running = False\n        if self._timer:\n            self._timer.cancel()\n\n    def enqueue(self, message: dict):\n        \"\"\"Add a message to the batch buffer (thread-safe).\"\"\"\n        with self._lock:\n            self._buffer.append(message)\n\n    def _schedule_flush(self):\n        \"\"\"Schedule the next flush cycle.\"\"\"\n        if not self._running:\n            return\n        self._timer = threading.Timer(self._interval, self._flush)\n        self._timer.daemon = True\n        self._timer.start()\n\n    def _flush(self):\n        \"\"\"Emit buffered messages as a batch and reschedule.\"\"\"\n        with self._lock:\n            batch = self._buffer\n            self._buffer = []\n        if batch:\n            self._socketio.emit('mqtt_messages_batch', batch)\n        self._schedule_flush()\n\n\n_batch_emitter = None\n\n\ndef init_batch_emitter(socketio: SocketIO, interval_ms: int = 100):\n    \"\"\"Initialize and start the global batch emitter.\n\n    Called once in create_app after socketio.init_app().\n    \"\"\"\n    global _batch_emitter\n    _batch_emitter = BatchEmitter(socketio, interval_ms)\n    _batch_emitter.start()\n    return _batch_emitter\n\n\ndef get_batch_emitter():\n    \"\"\"Return the global batch emitter instance.\"\"\"\n    return _batch_emitter\n"
  },
  {
    "path": "mqttui/state.py",
    "content": "\"\"\"\nModule-level shared state for in-memory MQTT data.\nImported by routes and MQTT handlers.\n\"\"\"\n\nmessages = []\ntopics = set()\nconnection_count = 0\nactive_websockets = 0\nerror_log = []\n"
  },
  {
    "path": "pytest.ini",
    "content": "[pytest]\ntestpaths = tests\npython_files = test_*.py\npython_classes = Test*\npython_functions = test_*\naddopts = -v --tb=short\n"
  },
  {
    "path": "requirements-dev.txt",
    "content": "pytest==8.3.4\npytest-cov==6.0.0\n"
  },
  {
    "path": "requirements.txt",
    "content": "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\ngevent==24.11.1\ngevent-websocket==0.10.1\nblinker==1.9.0\nFlask-SQLAlchemy==3.1.1\nFlask-Login==0.6.3\nFlask-CORS==5.0.1\napispec==6.8.1\napispec-webframeworks==1.2.0\nFlask-Limiter==3.11.0\nAPScheduler==3.11.2\nhttpx>=0.27\nstructlog>=24.1.0\nprometheus_client>=0.21.0\npluggy>=1.5.0\n"
  },
  {
    "path": "static/css/input.css",
    "content": "@import \"tailwindcss\";\n\n@theme {\n  --color-gray-750: #2d3748;\n  --color-gray-850: #1a202c;\n}\n"
  },
  {
    "path": "static/css/output.css",
    "content": "/* Tailwind CSS v4 compiled output.\n * Build with: tailwindcss -i static/css/input.css -o static/css/output.css --minify\n * Watch with: tailwindcss -i static/css/input.css -o static/css/output.css --watch\n *\n * This file is a placeholder for local development without the Tailwind CLI.\n * The CDN fallback in base.html provides Tailwind utilities during development.\n * The Dockerfile compiles this file during the Docker build.\n */\n"
  },
  {
    "path": "static/script.js",
    "content": "const socket = io();\nlet messageChart;\nlet network;\nlet nodes;\nlet edges;\nlet topicFilter = 'all';\nlet messageCount = 0;\nlet pinnedNodes = new Set();\n\n// ============================================================\n// Alpine.js global store for shared state\n// ============================================================\ndocument.addEventListener('alpine:init', () => {\n    Alpine.store('mqtt', {\n        connected: false,\n        selectedTopic: 'all',\n        messageCount: 0,\n    });\n});\n\n// ============================================================\n// Alpine.js root app component (bound to <body>)\n// ============================================================\nfunction mqttuiApp() {\n    return {\n        sidebarOpen: true,\n        activeTab: 'dashboard',\n        alertsLoaded: false,\n        rulesLoaded: false,\n        analyticsLoaded: false,\n        pluginsLoaded: false,\n        brokersLoaded: false,\n        init() {\n            // Initialize Chart.js and Vis.js network\n            initChart();\n            initNetwork();\n            setInterval(updateChart, 1000);\n            setInterval(updateStats, 5000);\n            initDebugBar();\n            trackClientPerformance();\n\n            // Load existing topics from API\n            loadTopicsFromAPI();\n            setInterval(loadTopicsFromAPI, 30000);\n\n            // Setup advanced search UI\n            setupAdvancedSearchHandlers();\n\n            // Handle batch messages from server (100ms batched)\n            socket.on('mqtt_messages_batch', (batch) => {\n                batch.forEach(msg => {\n                    window.dispatchEvent(new CustomEvent('mqtt-message', { detail: msg }));\n                    Alpine.store('mqtt').messageCount++;\n                    messageCount++;\n                    updateNetwork(msg);\n                    updateTopicFilter(msg.topic);\n                });\n            });\n\n            // Fallback for single messages (backward compat)\n            socket.on('mqtt_message', (data) => {\n                window.dispatchEvent(new CustomEvent('mqtt-message', { detail: data }));\n                Alpine.store('mqtt').messageCount++;\n                messageCount++;\n                updateMessageList(data);\n                updateNetwork(data);\n                updateTopicFilter(data.topic);\n            });\n        },\n        toggleSidebar() {\n            this.sidebarOpen = !this.sidebarOpen;\n            // Toggle body class for CSS max-width adjustment\n            if (this.sidebarOpen) {\n                document.body.classList.remove('sidebar-hidden');\n            } else {\n                document.body.classList.add('sidebar-hidden');\n            }\n        },\n    };\n}\n\n// ============================================================\n// Alpine.js message list component\n// ============================================================\nfunction messageListComponent() {\n    return {\n        messages: [],\n        favorites: [],\n        init() {\n            this.loadMessages();\n            this.loadFavorites();\n            window.addEventListener('mqtt-message', (e) => {\n                const msg = e.detail;\n                const topicFilter = Alpine.store('mqtt').selectedTopic;\n                const brokerEl = document.getElementById('broker-filter');\n                const brokerFilter = brokerEl ? brokerEl.value : 'all';\n                const topicMatch = topicFilter === 'all' || msg.topic === topicFilter;\n                const brokerMatch = brokerFilter === 'all' || String(msg.broker_id) === brokerFilter;\n                if (topicMatch && brokerMatch) {\n                    this.messages.unshift(msg);\n                    if (this.messages.length > 100) this.messages.pop();\n                }\n            });\n        },\n        async loadMessages(extraFilters = {}) {\n            const params = new URLSearchParams({ limit: '50' });\n            const topic = Alpine.store('mqtt').selectedTopic;\n            if (topic && topic !== 'all') params.append('topic', topic);\n            // Apply extra filters (from Advanced Search)\n            Object.entries(extraFilters).forEach(([key, value]) => {\n                if (value) params.append(key, value);\n            });\n            try {\n                const resp = await fetch(`/api/v1/messages?${params}`);\n                const data = await resp.json();\n                const payload = data.data || data;\n                this.messages = payload.messages || [];\n            } catch (e) {\n                console.error('Error loading messages:', e);\n            }\n        },\n        showFavoritesOnly: false,\n        get filteredMessages() {\n            if (!this.showFavoritesOnly) return this.messages;\n            return this.messages.filter(m => this.favorites.includes(m.topic));\n        },\n        async loadFavorites() {\n            try {\n                const resp = await fetch('/api/v1/topics/favorites');\n                const data = await resp.json();\n                if (data.status === 'success') {\n                    this.favorites = data.data.favorites.map(f => f.topic);\n                }\n            } catch (e) {\n                console.error('Error loading favorites:', e);\n            }\n        },\n        isFavorite(topic) {\n            return this.favorites.includes(topic);\n        },\n        async toggleFavorite(topic) {\n            try {\n                const resp = await fetch(`/api/v1/topics/${encodeURIComponent(topic)}/bookmark`, {\n                    method: 'POST',\n                });\n                const data = await resp.json();\n                if (data.status === 'success') {\n                    if (data.data.bookmarked) {\n                        this.favorites.push(topic);\n                    } else {\n                        this.favorites = this.favorites.filter(t => t !== topic);\n                    }\n                }\n            } catch (e) {\n                console.error('Error toggling favorite:', e);\n            }\n        },\n        formatPayload(payload) {\n            try { return JSON.stringify(JSON.parse(payload), null, 2); }\n            catch { return payload; }\n        },\n        formatTime(ts) {\n            return new Date(ts).toLocaleTimeString();\n        },\n    };\n}\n\n// ============================================================\n// Alpine.js stats component\n// ============================================================\nfunction statsComponent() {\n    return {\n        connections: 0,\n        topics: 0,\n        messageTotal: 0,\n        init() {\n            this.fetchStats();\n            setInterval(() => this.fetchStats(), 5000);\n        },\n        async fetchStats() {\n            try {\n                const resp = await fetch('/stats');\n                const data = await resp.json();\n                this.connections = data.connection_count;\n                this.topics = data.topic_count;\n                this.messageTotal = data.message_count;\n            } catch (e) {\n                console.error('Error fetching stats:', e);\n            }\n        },\n    };\n}\n\n// ============================================================\n// Alpine.js publish component\n// ============================================================\nfunction publishComponent() {\n    return {\n        topic: '',\n        message: '',\n        async submit() {\n            await fetch('/publish', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n                body: `topic=${encodeURIComponent(this.topic)}&message=${encodeURIComponent(this.message)}`\n            });\n            this.topic = '';\n            this.message = '';\n        },\n    };\n}\n\n// ============================================================\n// Chart.js (kept as-is per plan)\n// ============================================================\nfunction initChart() {\n    if (messageChart) return;\n    const canvas = document.getElementById('messageChart');\n    if (!canvas) return;\n    const ctx = canvas.getContext('2d');\n\n    // Create gradient fill\n    const gradient = ctx.createLinearGradient(0, 0, 0, canvas.parentElement.clientHeight || 160);\n    gradient.addColorStop(0, 'rgba(59, 130, 246, 0.3)');\n    gradient.addColorStop(1, 'rgba(59, 130, 246, 0.0)');\n\n    // Pre-fill with zeros for smooth start\n    const emptyLabels = Array(30).fill('');\n    const emptyData = Array(30).fill(0);\n\n    messageChart = new Chart(ctx, {\n        type: 'line',\n        data: {\n            labels: emptyLabels,\n            datasets: [{\n                label: 'msg/s',\n                data: emptyData,\n                borderColor: 'rgb(59, 130, 246)',\n                backgroundColor: gradient,\n                borderWidth: 2,\n                fill: true,\n                tension: 0.4,\n                pointRadius: 0,\n                pointHoverRadius: 4,\n                pointHoverBackgroundColor: 'rgb(59, 130, 246)',\n            }]\n        },\n        options: {\n            responsive: true,\n            maintainAspectRatio: false,\n            animation: { duration: 300 },\n            interaction: { intersect: false, mode: 'index' },\n            scales: {\n                y: {\n                    beginAtZero: true,\n                    grid: { color: 'rgba(75, 85, 99, 0.3)', drawBorder: false },\n                    ticks: {\n                        color: 'rgb(156, 163, 175)',\n                        font: { size: 10 },\n                        maxTicksLimit: 4,\n                        callback: v => Number.isInteger(v) ? v : '',\n                    },\n                    border: { display: false },\n                },\n                x: {\n                    display: false,\n                }\n            },\n            plugins: {\n                legend: { display: false },\n                tooltip: {\n                    backgroundColor: 'rgba(17, 24, 39, 0.9)',\n                    titleColor: 'rgb(156, 163, 175)',\n                    bodyColor: 'rgb(59, 130, 246)',\n                    bodyFont: { weight: 'bold', size: 14 },\n                    padding: 8,\n                    displayColors: false,\n                    callbacks: {\n                        title: (items) => items[0]?.label || '',\n                        label: (item) => `${item.raw} msg/s`,\n                    }\n                }\n            }\n        }\n    });\n}\n\nfunction updateChart() {\n    if (!messageChart) return;\n    const now = new Date();\n    const timeLabel = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });\n\n    messageChart.data.labels.push(timeLabel);\n    messageChart.data.datasets[0].data.push(messageCount);\n\n    if (messageChart.data.labels.length > 30) {\n        messageChart.data.labels.shift();\n        messageChart.data.datasets[0].data.shift();\n    }\n\n    // Update the big rate number\n    const rateDisplay = document.getElementById('rate-display');\n    if (rateDisplay) rateDisplay.textContent = messageCount;\n\n    messageChart.update('none'); // skip animation for smoother feel at 1s interval\n    messageCount = 0;\n}\n\n// ============================================================\n// Vis.js network (kept as-is per plan)\n// ============================================================\nfunction initNetwork() {\n    if (network) return; // Already initialized\n    nodes = new vis.DataSet([\n        { id: 'broker', label: 'MQTT Broker', shape: 'hexagon', color: '#FFA500', size: 30 }\n    ]);\n    edges = new vis.DataSet();\n\n    const container = document.getElementById('network-visualization');\n    const data = { nodes, edges };\n    const options = {\n        physics: {\n            enabled: true,\n            stabilization: {\n                enabled: true,\n                iterations: 150,\n                updateInterval: 25,\n                fit: true\n            },\n            barnesHut: {\n                gravitationalConstant: -8000,\n                centralGravity: 0.3,\n                springLength: 200,\n                springConstant: 0.04,\n                damping: 0.09,\n                avoidOverlap: 0.1\n            }\n        },\n        nodes: {\n            font: { color: '#FFFFFF', size: 14 },\n            borderWidth: 2,\n            shadow: true,\n            margin: 10\n        },\n        edges: {\n            width: 2,\n            color: { inherit: 'from' },\n            smooth: { type: 'continuous' },\n            arrows: { to: { enabled: true, scaleFactor: 0.5 } }\n        },\n        interaction: {\n            dragNodes: true,\n            dragView: true,\n            zoomView: true\n        },\n        layout: {\n            improvedLayout: true,\n            clusterThreshold: 150\n        }\n    };\n\n    network = new vis.Network(container, data, options);\n\n    // Auto-fit network after stabilization\n    network.on('stabilizationIterationsDone', function() {\n        network.fit({\n            animation: { duration: 1000, easingFunction: 'easeInOutQuad' }\n        });\n    });\n\n    // Double-click to pin/unpin nodes\n    network.on('doubleClick', function(params) {\n        if (params.nodes.length > 0) {\n            const nodeId = params.nodes[0];\n            if (pinnedNodes.has(nodeId)) {\n                pinnedNodes.delete(nodeId);\n                nodes.update({\n                    id: nodeId,\n                    fixed: false,\n                    color: nodes.get(nodeId).color || '#97C2FC'\n                });\n            } else {\n                pinnedNodes.add(nodeId);\n                nodes.update({\n                    id: nodeId,\n                    fixed: true,\n                    color: '#FF6B6B'\n                });\n            }\n        }\n    });\n\n    // Right-click context menu for pinning\n    network.on('oncontext', function(params) {\n        params.event.preventDefault();\n        if (params.nodes.length > 0) {\n            const nodeId = params.nodes[0];\n            const isPinned = pinnedNodes.has(nodeId);\n            const action = isPinned ? 'Unpin' : 'Pin';\n\n            if (confirm(`${action} node \"${nodes.get(nodeId).label}\"?`)) {\n                if (isPinned) {\n                    pinnedNodes.delete(nodeId);\n                    nodes.update({\n                        id: nodeId,\n                        fixed: false,\n                        color: nodes.get(nodeId).color || '#97C2FC'\n                    });\n                } else {\n                    pinnedNodes.add(nodeId);\n                    nodes.update({\n                        id: nodeId,\n                        fixed: true,\n                        color: '#FF6B6B'\n                    });\n                }\n            }\n        }\n    });\n}\n\nfunction updateNetwork(message) {\n    if (!nodes || !edges || !network) {\n        console.error('Network not initialized');\n        return;\n    }\n\n    const topicParts = message.topic.split('/');\n    let parentId = 'broker';\n\n    topicParts.forEach((part, index) => {\n        const nodeId = topicParts.slice(0, index + 1).join('/');\n        if (!nodes.get(nodeId)) {\n            nodes.add({\n                id: nodeId,\n                label: part,\n                color: getRandomColor(),\n                shape: 'dot',\n                size: 20 - index * 2\n            });\n\n            clearTimeout(window.autoFitTimeout);\n            window.autoFitTimeout = setTimeout(() => {\n                if (network) {\n                    network.fit({\n                        animation: { duration: 800, easingFunction: 'easeInOutQuad' }\n                    });\n                }\n            }, 2000);\n        }\n        if (parentId !== nodeId) {\n            const edgeId = `${parentId}-${nodeId}`;\n            if (!edges.get(edgeId)) {\n                edges.add({ id: edgeId, from: parentId, to: nodeId });\n            }\n        }\n        parentId = nodeId;\n    });\n\n    // Animate message flow\n    const edgeIds = edges.getIds();\n    edgeIds.forEach(edgeId => {\n        edges.update({ id: edgeId, color: { color: '#00ff00' }, width: 4 });\n        setTimeout(() => {\n            edges.update({ id: edgeId, color: { inherit: 'from' }, width: 2 });\n        }, 1000);\n    });\n\n    // Pulse the final topic node\n    const finalNodeId = topicParts.join('/');\n    const finalNode = nodes.get(finalNodeId);\n    nodes.update({ id: finalNodeId, size: finalNode.size + 5 });\n    setTimeout(() => {\n        nodes.update({ id: finalNodeId, size: finalNode.size });\n    }, 1000);\n}\n\n// ============================================================\n// Standalone utility functions (kept as-is per plan)\n// ============================================================\n\nfunction getRandomColor() {\n    const letters = '0123456789ABCDEF';\n    let color = '#';\n    for (let i = 0; i < 6; i++) {\n        color += letters[Math.floor(Math.random() * 16)];\n    }\n    return color;\n}\n\n// Legacy updateMessageList for backward-compat single-message handler\nfunction updateMessageList(message) {\n    // Now handled by Alpine.js messageListComponent via CustomEvent\n    // This function is kept for any non-Alpine fallback paths\n}\n\nfunction updateStats() {\n    // Stats are now handled by Alpine.js statsComponent — this is a no-op\n}\n\nfunction updateTopicFilter(newTopic) {\n    const topicFilterEl = document.getElementById('topic-filter');\n    if (topicFilterEl && !Array.from(topicFilterEl.options).some(option => option.value === newTopic)) {\n        const option = document.createElement('option');\n        option.value = newTopic;\n        option.textContent = newTopic;\n        topicFilterEl.appendChild(option);\n    }\n}\n\nfunction loadTopicsFromAPI() {\n    fetch('/api/v1/topics')\n        .then(response => response.json())\n        .then(envelope => {\n            const data = envelope.data || envelope;\n            const topicFilterEl = document.getElementById('topic-filter');\n            if (!topicFilterEl) return;\n\n            const allTopicsOption = topicFilterEl.querySelector('option[value=\"all\"]');\n            topicFilterEl.innerHTML = '';\n            if (allTopicsOption) {\n                topicFilterEl.appendChild(allTopicsOption);\n            } else {\n                const option = document.createElement('option');\n                option.value = 'all';\n                option.textContent = 'All Topics';\n                topicFilterEl.appendChild(option);\n            }\n\n            // Sort favorites to top\n            const topics = data.topics || [];\n            topics.sort((a, b) => {\n                if (a.is_favorite && !b.is_favorite) return -1;\n                if (!a.is_favorite && b.is_favorite) return 1;\n                return 0;\n            });\n\n            topics.forEach(topic => {\n                const option = document.createElement('option');\n                option.value = topic.topic;\n                const star = topic.is_favorite ? '\\u2605 ' : '';\n                const count = topic.message_count != null ? ` (${topic.message_count})` : '';\n                option.textContent = `${star}${topic.topic}${count}`;\n                topicFilterEl.appendChild(option);\n            });\n        })\n        .catch(error => console.error('Error loading topics:', error));\n}\n\nfunction loadFilteredMessages(customFilters = {}) {\n    // Find the Alpine message list component and call loadMessages with filters\n    const messageList = document.getElementById('message-list');\n    if (!messageList) return;\n\n    // Read topic from dropdown (not stale global)\n    const topicEl = document.getElementById('topic-filter');\n    const selectedTopic = topicEl ? topicEl.value : 'all';\n    if (selectedTopic !== 'all') {\n        Alpine.store('mqtt').selectedTopic = selectedTopic;\n    }\n\n    // Get the Alpine component on the parent element\n    const alpineEl = messageList.closest('[x-data]');\n    if (alpineEl && alpineEl._x_dataStack) {\n        const component = alpineEl._x_dataStack[0];\n        if (component && component.loadMessages) {\n            component.loadMessages(customFilters);\n            return;\n        }\n    }\n\n    // Fallback: dispatch event with filters\n    window.dispatchEvent(new CustomEvent('apply-filters', { detail: customFilters }));\n}\n\n// ============================================================\n// Advanced search handlers (kept as-is per plan)\n// ============================================================\nfunction setupAdvancedSearchHandlers() {\n    const applyBtn = document.getElementById('apply-filters-btn');\n    if (applyBtn) {\n        applyBtn.addEventListener('click', function() {\n            const filters = {\n                content: document.getElementById('content-search').value,\n                regex_topic: document.getElementById('regex-topic').value,\n                json_path: document.getElementById('json-path').value,\n                json_value: document.getElementById('json-value').value,\n                hours: document.getElementById('time-filter').value\n            };\n\n            Object.keys(filters).forEach(key => {\n                if (!filters[key]) delete filters[key];\n            });\n\n            loadFilteredMessages(filters);\n        });\n    }\n\n    const clearBtn = document.getElementById('clear-filters-btn');\n    if (clearBtn) {\n        clearBtn.addEventListener('click', function() {\n            const topicFilterEl = document.getElementById('topic-filter');\n            if (topicFilterEl) topicFilterEl.value = 'all';\n            const contentSearch = document.getElementById('content-search');\n            if (contentSearch) contentSearch.value = '';\n            const regexTopic = document.getElementById('regex-topic');\n            if (regexTopic) regexTopic.value = '';\n            const jsonPath = document.getElementById('json-path');\n            if (jsonPath) jsonPath.value = '';\n            const jsonValue = document.getElementById('json-value');\n            if (jsonValue) jsonValue.value = '';\n            const timeFilter = document.getElementById('time-filter');\n            if (timeFilter) timeFilter.value = '';\n            topicFilter = 'all';\n            if (window.Alpine && Alpine.store('mqtt')) {\n                Alpine.store('mqtt').selectedTopic = 'all';\n            }\n            loadFilteredMessages();\n        });\n    }\n\n    loadFilterPresets();\n\n    const loadPresetBtn = document.getElementById('load-preset-btn');\n    if (loadPresetBtn) {\n        loadPresetBtn.addEventListener('click', function() {\n            const presetName = document.getElementById('preset-select').value;\n            if (!presetName) {\n                alert('Please select a preset to load');\n                return;\n            }\n\n            fetch(`/api/v1/filter-presets/${encodeURIComponent(presetName)}/use`, {\n                method: 'POST'\n            })\n            .then(response => response.json())\n            .then(data => {\n                const inner = data.data || data;\n                if (data.status === 'success' || inner.filters) {\n                    const filters = inner.filters;\n                    const contentSearch = document.getElementById('content-search');\n                    if (contentSearch) contentSearch.value = filters.content || '';\n                    const regexTopic = document.getElementById('regex-topic');\n                    if (regexTopic) regexTopic.value = filters.regex_topic || '';\n                    const jsonPath = document.getElementById('json-path');\n                    if (jsonPath) jsonPath.value = filters.json_path || '';\n                    const jsonValue = document.getElementById('json-value');\n                    if (jsonValue) jsonValue.value = filters.json_value || '';\n                    const timeFilter = document.getElementById('time-filter');\n                    if (timeFilter) timeFilter.value = filters.hours || '';\n\n                    if (filters.topic) {\n                        const topicFilterEl = document.getElementById('topic-filter');\n                        if (topicFilterEl) topicFilterEl.value = filters.topic;\n                        topicFilter = filters.topic;\n                    }\n\n                    loadFilteredMessages(filters);\n                } else {\n                    alert('Error loading preset: ' + data.error);\n                }\n            })\n            .catch(error => {\n                console.error('Error loading preset:', error);\n                alert('Error loading preset');\n            });\n        });\n    }\n\n    const savePresetBtn = document.getElementById('save-preset-btn');\n    if (savePresetBtn) {\n        savePresetBtn.addEventListener('click', function() {\n            const name = prompt('Enter a name for this filter preset:');\n            if (!name) return;\n\n            const description = prompt('Enter a description (optional):') || '';\n\n            const filters = {\n                content: document.getElementById('content-search').value,\n                regex_topic: document.getElementById('regex-topic').value,\n                json_path: document.getElementById('json-path').value,\n                json_value: document.getElementById('json-value').value,\n                hours: document.getElementById('time-filter').value\n            };\n\n            if (topicFilter && topicFilter !== 'all') {\n                filters.topic = topicFilter;\n            }\n\n            Object.keys(filters).forEach(key => {\n                if (!filters[key]) delete filters[key];\n            });\n\n            if (Object.keys(filters).length === 0) {\n                alert('No filters to save');\n                return;\n            }\n\n            fetch('/api/v1/filter-presets', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    name: name,\n                    description: description,\n                    filters: filters\n                })\n            })\n            .then(response => response.json())\n            .then(data => {\n                if (data.status === 'success') {\n                    alert('Filter preset saved successfully!');\n                    loadFilterPresets();\n                } else {\n                    alert('Error saving preset: ' + (data.error?.message || data.error));\n                }\n            })\n            .catch(error => {\n                console.error('Error saving preset:', error);\n                alert('Error saving preset');\n            });\n        });\n    }\n\n    // Fullscreen and reset buttons for network\n    const fullscreenBtn = document.getElementById('fullscreen-btn');\n    if (fullscreenBtn) {\n        fullscreenBtn.addEventListener('click', function() {\n            const networkDiv = document.getElementById('network-visualization');\n            if (networkDiv.requestFullscreen) {\n                networkDiv.requestFullscreen();\n            } else if (networkDiv.webkitRequestFullscreen) {\n                networkDiv.webkitRequestFullscreen();\n            } else if (networkDiv.msRequestFullscreen) {\n                networkDiv.msRequestFullscreen();\n            }\n        });\n    }\n\n    const resetBtn = document.getElementById('reset-nodes-btn');\n    if (resetBtn) {\n        resetBtn.addEventListener('click', function() {\n            if (network) {\n                pinnedNodes.forEach(nodeId => {\n                    const node = nodes.get(nodeId);\n                    if (node) {\n                        nodes.update({\n                            id: nodeId,\n                            fixed: false,\n                            color: node.originalColor || '#97C2FC'\n                        });\n                    }\n                });\n                pinnedNodes.clear();\n\n                network.setOptions({\n                    physics: {\n                        enabled: true,\n                        stabilization: {\n                            enabled: true,\n                            iterations: 150,\n                            updateInterval: 25,\n                            fit: true\n                        }\n                    }\n                });\n\n                setTimeout(() => {\n                    network.fit({\n                        animation: { duration: 1000, easingFunction: 'easeInOutQuad' }\n                    });\n                }, 500);\n            }\n        });\n    }\n}\n\n// Topic filter change handler\ndocument.addEventListener('DOMContentLoaded', function() {\n    const topicFilterEl = document.getElementById('topic-filter');\n    if (topicFilterEl) {\n        topicFilterEl.addEventListener('change', function(e) {\n            topicFilter = e.target.value;\n            if (window.Alpine && Alpine.store('mqtt')) {\n                Alpine.store('mqtt').selectedTopic = topicFilter;\n            }\n            loadFilteredMessages();\n        });\n    }\n});\n\nfunction loadFilterPresets() {\n    fetch('/api/v1/filter-presets')\n        .then(response => response.json())\n        .then(data => {\n            const presetSelect = document.getElementById('preset-select');\n            if (!presetSelect) return;\n\n            presetSelect.innerHTML = '<option value=\"\">Select a preset...</option>';\n\n            const presets = (data.data || data).presets || [];\n            presets.forEach(preset => {\n                const option = document.createElement('option');\n                option.value = preset.name;\n                option.textContent = `${preset.name}${preset.description ? ' - ' + preset.description : ''}`;\n                presetSelect.appendChild(option);\n            });\n        })\n        .catch(error => console.error('Error loading presets:', error));\n}\n\n// ============================================================\n// Debug bar (kept as-is per plan)\n// ============================================================\nlet debugBar;\nlet debugBarToggle;\n\nfunction initDebugBar() {\n    if (debugBar) return; // Already initialized\n    debugBar = document.createElement('div');\n    debugBar.id = 'debug-bar';\n    debugBar.style.display = 'none';\n    document.body.appendChild(debugBar);\n\n    debugBarToggle = document.createElement('button');\n    debugBarToggle.id = 'debug-bar-toggle';\n    debugBarToggle.innerHTML = 'Debug';\n    debugBarToggle.onclick = toggleDebugBar;\n    document.body.appendChild(debugBarToggle);\n\n    const closeButton = document.createElement('button');\n    closeButton.id = 'debug-bar-close';\n    closeButton.innerHTML = '&times;';\n    closeButton.onclick = closeDebugBar;\n    debugBar.appendChild(closeButton);\n\n    updateDebugBar();\n    setInterval(updateDebugBar, 1000);\n}\n\nfunction toggleDebugBar() {\n    fetch('/toggle-debug-bar', { method: 'POST' })\n        .then(response => response.json())\n        .then(data => {\n            debugBar.style.display = data.enabled ? 'block' : 'none';\n            debugBarToggle.classList.toggle('active', data.enabled);\n        });\n}\n\nfunction closeDebugBar() {\n    debugBar.style.display = 'none';\n    fetch('/toggle-debug-bar', { method: 'POST' });\n    debugBarToggle.classList.remove('active');\n}\n\nfunction trackClientPerformance() {\n    // Delay to ensure timing data is available after full page load\n    setTimeout(() => {\n        let pageLoadTime = 0;\n        let domReadyTime = 0;\n\n        // Prefer modern Navigation Timing API\n        const entries = performance.getEntriesByType('navigation');\n        if (entries.length > 0) {\n            const nav = entries[0];\n            pageLoadTime = Math.round(nav.loadEventEnd);\n            domReadyTime = Math.round(nav.domContentLoadedEventEnd);\n        } else if (performance.timing) {\n            // Fallback to deprecated API\n            const t = performance.timing;\n            pageLoadTime = t.loadEventEnd - t.navigationStart;\n            domReadyTime = t.domContentLoadedEventEnd - t.navigationStart;\n        }\n\n        if (pageLoadTime > 0) {\n            fetch('/record-client-performance', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ pageLoadTime, domReadyTime }),\n            });\n        }\n    }, 2000);\n}\n\nfunction updateDebugBar() {\n    fetch('/debug-bar')\n        .then(response => response.json())\n        .then(data => {\n            let content = '<button id=\"debug-bar-close\" onclick=\"closeDebugBar()\">&times;</button>';\n            content += '<div class=\"debug-content\">';\n            for (const [panelName, panelData] of Object.entries(data)) {\n                content += `<div class=\"debug-panel\"><h3>${panelName}</h3><ul>`;\n                for (const [key, value] of Object.entries(panelData)) {\n                    let displayValue = value;\n                    if (typeof value === 'object' && value !== null) {\n                        displayValue = '<pre>' + JSON.stringify(value, null, 2) + '</pre>';\n                    }\n                    content += `<li><strong>${key}:</strong> ${displayValue}</li>`;\n                }\n                content += '</ul></div>';\n            }\n            content += '</div>';\n            debugBar.innerHTML = content;\n        });\n}\n\n// ============================================================\n// Alpine.js rule form component (create/edit)\n// ============================================================\nfunction ruleFormComponent(existing = {}) {\n    const condition = existing.condition || {};\n    const action = existing.action || {};\n    return {\n        form: {\n            id: existing.id || null,\n            name: existing.name || '',\n            description: existing.description || '',\n            trigger_topic: existing.trigger_topic || '',\n            condition_path: condition.path || '',\n            condition_op: condition.op || '',\n            condition_value: condition.value !== undefined ? String(condition.value) : '',\n            action_type: action.type || 'publish',\n            action_topic: action.topic || '',\n            action_payload: action.payload || '',\n            action_url: action.url || '',\n            action_template: action.payload_template || '',\n            action_severity: action.severity || 'info',\n            action_message: action.message || '',\n            action_bot_token: action.bot_token || '',\n            action_chat_id: action.chat_id || '',\n            action_message_template: action.message_template || '',\n            action_slack_url: action.webhook_url || '',\n            rate_limit_per_min: existing.rate_limit_per_min || 10,\n        },\n        error: '',\n        success: '',\n        buildPayload() {\n            const payload = {\n                name: this.form.name,\n                description: this.form.description,\n                trigger_topic: this.form.trigger_topic,\n                rate_limit_per_min: this.form.rate_limit_per_min,\n            };\n            // Build condition\n            if (this.form.condition_op && this.form.condition_path) {\n                const cond = { path: this.form.condition_path, op: this.form.condition_op };\n                if (!['exists', 'not_exists'].includes(this.form.condition_op)) {\n                    let val = this.form.condition_value;\n                    const num = Number(val);\n                    if (!isNaN(num) && val.trim() !== '') val = num;\n                    cond.value = val;\n                }\n                payload.condition = cond;\n            } else {\n                payload.condition = {};\n            }\n            // Build action\n            const act = { type: this.form.action_type };\n            if (this.form.action_type === 'publish') {\n                act.topic = this.form.action_topic;\n                act.payload = this.form.action_payload;\n            } else if (this.form.action_type === 'telegram') {\n                act.bot_token = this.form.action_bot_token;\n                act.chat_id = this.form.action_chat_id;\n                if (this.form.action_message_template) act.message_template = this.form.action_message_template;\n            } else if (this.form.action_type === 'slack') {\n                act.webhook_url = this.form.action_slack_url;\n                if (this.form.action_message_template) act.message_template = this.form.action_message_template;\n            } else if (this.form.action_type === 'webhook') {\n                act.url = this.form.action_url;\n                if (this.form.action_template) act.payload_template = this.form.action_template;\n            } else if (this.form.action_type === 'log') {\n                act.severity = this.form.action_severity;\n                act.message = this.form.action_message;\n            }\n            payload.action = act;\n            return payload;\n        },\n        async submitRule() {\n            this.error = '';\n            this.success = '';\n            const payload = this.buildPayload();\n            const url = this.form.id ? `/api/v1/rules/${this.form.id}` : '/api/v1/rules/';\n            const method = this.form.id ? 'PUT' : 'POST';\n            try {\n                const resp = await fetch(url, {\n                    method,\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                });\n                const data = await resp.json();\n                if (data.status === 'success') {\n                    this.success = this.form.id ? 'Rule updated' : 'Rule created';\n                    // Reload rules list via htmx\n                    htmx.ajax('GET', '/partials/rules', { target: '#rules-panel', swap: 'innerHTML' });\n                } else {\n                    this.error = data.error?.message || 'Unknown error';\n                }\n            } catch (e) {\n                this.error = 'Network error: ' + e.message;\n            }\n        },\n    };\n}\n\n// ============================================================\n// Alpine.js dry-run test component\n// ============================================================\nfunction dryRunComponent(ruleId) {\n    return {\n        ruleId: ruleId,\n        topic: '',\n        payload: '',\n        result: null,\n        error: '',\n        async runTest() {\n            this.error = '';\n            this.result = null;\n            let payloadVal = this.payload;\n            try { payloadVal = JSON.parse(this.payload); } catch (e) { /* use raw string */ }\n            try {\n                const resp = await fetch(`/api/v1/rules/${this.ruleId}/test`, {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ topic: this.topic, payload: payloadVal }),\n                });\n                const data = await resp.json();\n                if (data.status === 'success') {\n                    this.result = data.data;\n                } else {\n                    this.error = data.error?.message || 'Test failed';\n                }\n            } catch (e) {\n                this.error = 'Network error: ' + e.message;\n            }\n        },\n    };\n}\n\n// ============================================================\n// Broker management component\n// ============================================================\nfunction brokersComponent() {\n    return {\n        brokers: [],\n        showForm: false,\n        editId: null,\n        error: '',\n        form: { name: '', host: '', port: 1883, username: '', password: '', mqtt_version: '3.1.1', topics: '#', tls_enabled: false, tls_insecure: false },\n        async loadBrokers() {\n            try {\n                const resp = await fetch('/api/v1/brokers/');\n                if (resp.redirected || !resp.ok) return; // Not logged in or error\n                const ct = resp.headers.get('content-type') || '';\n                if (!ct.includes('json')) return; // Got HTML redirect\n                const data = await resp.json();\n                this.brokers = (data.data || data).brokers || [];\n            } catch (e) { console.error('Error loading brokers:', e); }\n        },\n        resetForm() {\n            this.form = { name: '', host: '', port: 1883, username: '', password: '', mqtt_version: '3.1.1', topics: '#', tls_enabled: false, tls_insecure: false };\n            this.editId = null;\n            this.showForm = false;\n            this.error = '';\n        },\n        editBroker(broker) {\n            this.form = { name: broker.name, host: broker.host, port: broker.port, username: broker.username || '', password: '', mqtt_version: broker.mqtt_version, topics: broker.topics, tls_enabled: broker.tls_enabled, tls_insecure: broker.tls_insecure };\n            this.editId = broker.id;\n            this.showForm = true;\n        },\n        async saveBroker() {\n            this.error = '';\n            const url = this.editId ? `/api/v1/brokers/${this.editId}` : '/api/v1/brokers/';\n            const method = this.editId ? 'PUT' : 'POST';\n            try {\n                const resp = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.form) });\n                if (!resp.ok && resp.status !== 201) { this.error = `Server error (${resp.status})`; return; }\n                const data = await resp.json();\n                if (data.status === 'success') {\n                    this.resetForm();\n                    await this.loadBrokers();\n                } else {\n                    this.error = data.error?.message || 'Failed to save broker';\n                }\n            } catch (e) { this.error = 'Network error: ' + e.message; }\n        },\n        async deleteBroker(id) {\n            try {\n                await fetch(`/api/v1/brokers/${id}`, { method: 'DELETE' });\n                await this.loadBrokers();\n            } catch (e) { console.error('Error deleting broker:', e); }\n        },\n        async connectBroker(id) {\n            await fetch(`/api/v1/brokers/${id}/connect`, { method: 'POST' });\n            setTimeout(() => this.loadBrokers(), 1500);\n        },\n        async disconnectBroker(id) {\n            await fetch(`/api/v1/brokers/${id}/disconnect`, { method: 'POST' });\n            await this.loadBrokers();\n        },\n    };\n}\n"
  },
  {
    "path": "static/styles.css",
    "content": "/* Custom scrollbar for Webkit browsers */\n#message-list::-webkit-scrollbar {\n    width: 8px;\n}\n\n#message-list::-webkit-scrollbar-track {\n    background: #1F2937;\n}\n\n#message-list::-webkit-scrollbar-thumb {\n    background: #4B5563;\n    border-radius: 4px;\n}\n\n#message-list::-webkit-scrollbar-thumb:hover {\n    background: #6B7280;\n}\n\n/* Smooth transitions for dark mode */\nbody {\n    transition: background-color 0.3s, color 0.3s;\n}\n\n/* Allow the content to fill available space minus sidebar. */\n.main-content-max {\n    max-width: calc(100vw - 20rem);\n}\n\n/* When sidebar is hidden, allow full width */\n.sidebar-hidden .main-content-max {\n    max-width: 100vw;\n}\n\n/* Additional styles for better readability */\n#message-list div {\n    background-color: rgba(55, 65, 81, 0.5);\n    padding: 0.5rem;\n    border-radius: 0.25rem;\n}\n\n#network-visualization {\n    border: 1px solid #4B5563;\n    border-radius: 0.25rem;\n}\n\n/* Improve form input styling */\ninput[type=\"text\"], textarea, select {\n    width: 100%;\n    padding: 0.5rem;\n    border-radius: 0.25rem;\n    background-color: #374151;\n    border: 1px solid #4B5563;\n    color: #F3F4F6;\n}\n\ninput[type=\"text\"]:focus, textarea:focus, select:focus {\n    outline: none;\n    border-color: #60A5FA;\n    box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);\n}\n\n/* Debug bar styles */\n#debug-bar {\n    position: fixed;\n    bottom: 0;\n    left: 0;\n    right: 0;\n    background-color: rgba(0, 0, 0, 0.95);\n    color: #fff;\n    padding: 15px;\n    font-family: 'Courier New', monospace;\n    font-size: 13px;\n    max-height: 60%;\n    overflow-y: auto;\n    z-index: 1000;\n    border-top: 2px solid #3b82f6;\n    box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.5);\n}\n\n#debug-bar h2 {\n    margin-top: 0;\n    font-size: 18px;\n    color: #3b82f6;\n}\n\n#debug-bar h3 {\n    margin-top: 10px;\n    font-size: 16px;\n    color: #60a5fa;\n    border-bottom: 1px solid #3b82f6;\n    padding-bottom: 5px;\n}\n\n#debug-bar ul {\n    list-style-type: none;\n    padding-left: 0;\n}\n\n#debug-bar li {\n    margin-bottom: 8px;\n    word-break: break-all;\n}\n\n#debug-bar pre {\n    background-color: rgba(255, 255, 255, 0.1);\n    padding: 5px;\n    border-radius: 3px;\n    overflow-x: auto;\n}\n\n#debug-bar-toggle {\n    position: fixed;\n    bottom: 10px;\n    right: 10px;\n    background-color: #3b82f6;\n    color: white;\n    border: none;\n    padding: 8px 12px;\n    border-radius: 4px;\n    cursor: pointer;\n    z-index: 1001;\n    font-weight: bold;\n    transition: background-color 0.3s;\n}\n\n#debug-bar-toggle:hover {\n    background-color: #2563eb;\n}\n\n#debug-bar-toggle.active {\n    background-color: #1e40af;\n}\n\n#debug-bar-close {\n    position: absolute;\n    top: 5px;\n    right: 5px;\n    background: none;\n    border: none;\n    color: #fff;\n    font-size: 20px;\n    cursor: pointer;\n}\n\n.debug-content {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 10px;\n}\n\n.debug-panel {\n    background-color: rgba(59, 130, 246, 0.1);\n    border-radius: 4px;\n    padding: 10px;\n    flex: 1 1 calc(33% - 10px);\n    min-width: 250px;\n    margin-bottom: 10px;\n}\n\n.debug-panel h3 {\n    margin-top: 0;\n}\n\n/* Responsive design for smaller screens */\n@media (max-width: 768px) {\n    .debug-panel {\n        flex: 1 1 100%;\n    }\n}\n"
  },
  {
    "path": "templates/base.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>{% block title %}MQTT Web Interface{% endblock %}</title>\n    <!-- Tailwind v4 compiled CSS (built by CLI), CDN fallback for dev -->\n    <link rel=\"stylesheet\" href=\"{{ url_for('static', filename='css/output.css') }}\">\n    <link href=\"https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css\" rel=\"stylesheet\">\n    <link rel=\"stylesheet\" href=\"{{ url_for('static', filename='styles.css') }}\">\n    <!-- Alpine.js 3.x -->\n    <script defer src=\"https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js\"></script>\n    <!-- htmx 2.x -->\n    <script src=\"https://unpkg.com/htmx.org@2.0.4\"></script>\n    <!-- Tell Alpine to initialize new x-data elements after htmx swaps -->\n    <script>\n      document.addEventListener('htmx:afterSwap', function(event) {\n        if (window.Alpine) {\n          // Only init new x-data elements inside the swap target, not the target itself\n          // This prevents re-initializing the root app component (which breaks Chart.js)\n          event.detail.target.querySelectorAll('[x-data]:not([x-data-initialized])').forEach(function(el) {\n            Alpine.initTree(el);\n            el.setAttribute('x-data-initialized', '');\n          });\n        }\n      });\n    </script>\n    <!-- Socket.IO -->\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js\"></script>\n    {% block head %}{% endblock %}\n</head>\n<body class=\"bg-gray-900 text-white overflow-hidden\" x-data=\"mqttuiApp()\" x-init=\"init()\">\n    {% block body %}{% endblock %}\n    {% block scripts %}{% endblock %}\n</body>\n</html>\n"
  },
  {
    "path": "templates/index.html",
    "content": "{% extends \"base.html\" %}\n\n{% block head %}\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.2/dist/vis-network.min.js\"></script>\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.7.0/chart.min.js\"></script>\n{% endblock %}\n\n{% block body %}\n    <div class=\"flex h-screen\">\n        <!-- Collapsible Sidebar -->\n        <div id=\"sidebar\" class=\"w-80 bg-gray-900 border-r border-gray-700 transition-all duration-300 overflow-y-auto\"\n             x-show=\"sidebarOpen\"\n             x-bind:class=\"sidebarOpen ? 'w-80' : 'w-0 overflow-hidden'\">\n            <div class=\"p-4\">\n                <div class=\"flex justify-between items-center mb-6\">\n                    <h2 class=\"text-xl font-bold\">Controls</h2>\n                    <button x-on:click=\"toggleSidebar()\" class=\"p-2 bg-gray-700 hover:bg-gray-600 rounded\">&#9664;</button>\n                </div>\n\n                <!-- Advanced Search Panel -->\n                <div class=\"bg-gray-800 rounded-lg shadow-md p-4 mb-4\" x-data=\"{ searchOpen: false }\">\n                    <div class=\"flex justify-between items-center mb-4 cursor-pointer\" x-on:click=\"searchOpen = !searchOpen\">\n                        <h3 class=\"text-lg font-semibold\">Advanced Search</h3>\n                        <span class=\"text-lg\" x-text=\"searchOpen ? '&#9660;' : '&#9654;'\"></span>\n                    </div>\n                    <div x-show=\"searchOpen\" x-transition>\n\n                    <!-- Broker Filter -->\n                    <div class=\"mb-4\" x-data=\"{ brokers: [] }\" x-init=\"\n                        fetch('/api/v1/brokers/').then(r => { if (r.ok && (r.headers.get('content-type')||'').includes('json')) return r.json(); }).then(d => { if (d) brokers = (d.data||d).brokers || [] })\n                    \">\n                        <label class=\"block text-sm font-medium mb-2\">Broker</label>\n                        <select id=\"broker-filter\" class=\"block w-full rounded-md bg-gray-700 border-gray-600 text-white\">\n                            <option value=\"all\">All Brokers</option>\n                            <template x-for=\"b in brokers\" :key=\"b.id\">\n                                <option :value=\"b.id\" x-text=\"b.name + ' (' + b.host + ')'\"></option>\n                            </template>\n                        </select>\n                    </div>\n\n                    <!-- Basic Topic Filter -->\n                    <div class=\"mb-4\">\n                        <label class=\"block text-sm font-medium mb-2\">Topic Filter</label>\n                        <select id=\"topic-filter\" class=\"block w-full rounded-md bg-gray-700 border-gray-600 text-white\">\n                            <option value=\"all\">All Topics</option>\n                        </select>\n                    </div>\n\n                    <!-- Content Search -->\n                    <div class=\"mb-4\">\n                        <label class=\"block text-sm font-medium mb-2\">Content Search</label>\n                        <input type=\"text\" id=\"content-search\" placeholder=\"Search message content...\" class=\"block w-full rounded-md bg-gray-700 border-gray-600 text-white\">\n                    </div>\n\n                    <!-- Regex Topic Search -->\n                    <div class=\"mb-4\">\n                        <label class=\"block text-sm font-medium mb-2\">Regex Topic Pattern</label>\n                        <input type=\"text\" id=\"regex-topic\" placeholder=\"sensors/.* or home/+/temp\" class=\"block w-full rounded-md bg-gray-700 border-gray-600 text-white\">\n                    </div>\n\n                    <!-- JSON Path Search -->\n                    <div class=\"mb-4\">\n                        <label class=\"block text-sm font-medium mb-2\">JSON Path & Value</label>\n                        <input type=\"text\" id=\"json-path\" placeholder=\"temperature or sensors.temp\" class=\"block w-full mb-2 rounded-md bg-gray-700 border-gray-600 text-white\">\n                        <input type=\"text\" id=\"json-value\" placeholder=\"Expected value\" class=\"block w-full rounded-md bg-gray-700 border-gray-600 text-white\">\n                    </div>\n\n                    <!-- Time Filter -->\n                    <div class=\"mb-4\">\n                        <label class=\"block text-sm font-medium mb-2\">Time Range (hours)</label>\n                        <select id=\"time-filter\" class=\"block w-full rounded-md bg-gray-700 border-gray-600 text-white\">\n                            <option value=\"\">All Time</option>\n                            <option value=\"1\">Last Hour</option>\n                            <option value=\"6\">Last 6 Hours</option>\n                            <option value=\"24\">Last 24 Hours</option>\n                            <option value=\"168\">Last Week</option>\n                        </select>\n                    </div>\n\n                    <!-- Search Actions -->\n                    <div class=\"space-y-2\">\n                        <button id=\"apply-filters-btn\" class=\"w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded\">Apply Filters</button>\n                        <button id=\"clear-filters-btn\" class=\"w-full bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded\">Clear All</button>\n                    </div>\n\n                    <!-- Filter Presets -->\n                    <div class=\"mt-4 pt-4 border-t border-gray-600\">\n                        <label class=\"block text-sm font-medium mb-2\">Filter Presets</label>\n                        <select id=\"preset-select\" class=\"block w-full mb-2 rounded-md bg-gray-700 border-gray-600 text-white\">\n                            <option value=\"\">Select a preset...</option>\n                        </select>\n                        <div class=\"flex space-x-2\">\n                            <button id=\"load-preset-btn\" class=\"flex-1 bg-green-600 hover:bg-green-700 text-white py-1 px-2 rounded text-sm\">Load</button>\n                            <button id=\"save-preset-btn\" class=\"flex-1 bg-yellow-600 hover:bg-yellow-700 text-white py-1 px-2 rounded text-sm\">Save</button>\n                        </div>\n                    </div>\n                    </div>\n                </div>\n\n                <!-- Stats -->\n                <div class=\"bg-gray-800 rounded-lg shadow-md p-4 mb-4\" x-data=\"statsComponent()\">\n                    <h3 class=\"text-lg font-semibold mb-3\">Stats</h3>\n                    <div class=\"space-y-1 text-sm\">\n                        <p>Connections: <span class=\"font-bold text-green-400\" x-text=\"connections\">0</span></p>\n                        <p>Topics: <span class=\"font-bold text-blue-400\" x-text=\"topics\">0</span></p>\n                        <p>Messages: <span class=\"font-bold text-yellow-400\" x-text=\"messageTotal\">0</span></p>\n                    </div>\n                </div>\n\n                <!-- Message Rate Chart -->\n                <div class=\"bg-gray-800 rounded-lg shadow-md p-4 mb-4\">\n                    <div class=\"flex justify-between items-center mb-2\">\n                        <h3 class=\"text-lg font-semibold\">Message Rate</h3>\n                        <span class=\"text-2xl font-bold text-blue-400\" id=\"rate-display\">0</span>\n                        <span class=\"text-xs text-gray-500\">msg/s</span>\n                    </div>\n                    <div class=\"h-40\">\n                        <canvas id=\"messageChart\"></canvas>\n                    </div>\n                </div>\n\n                <!-- Publish -->\n                <div class=\"bg-gray-800 rounded-lg shadow-md p-4\" x-data=\"publishComponent()\">\n                    <h3 class=\"text-lg font-semibold mb-3\">Publish</h3>\n                    <form @submit.prevent=\"submit()\" class=\"space-y-2\">\n                        <input type=\"text\" x-model=\"topic\" placeholder=\"Topic\" required class=\"block w-full rounded-md bg-gray-700 border-gray-600 text-white text-sm\">\n                        <textarea x-model=\"message\" placeholder=\"Message\" required class=\"block w-full rounded-md bg-gray-700 border-gray-600 text-white text-sm\" rows=\"2\"></textarea>\n                        <button type=\"submit\" class=\"w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded text-sm\">Publish</button>\n                    </form>\n                </div>\n            </div>\n        </div>\n\n        <!-- Main Content Area -->\n        <div class=\"flex-1 flex flex-col w-full min-h-0 mx-auto main-content-max\">\n            <!-- Header -->\n            <div class=\"p-4 border-b border-gray-700\">\n                <div class=\"flex justify-between items-center\">\n                    <h1 class=\"text-2xl font-bold\">MQTT Web Interface</h1>\n                    <button x-show=\"!sidebarOpen\" x-on:click=\"toggleSidebar()\" class=\"p-2 bg-gray-700 hover:bg-gray-600 rounded\">&#9654; Show Controls</button>\n                </div>\n            </div>\n\n            <!-- Tab Bar -->\n            <div class=\"flex border-b border-gray-700 px-4\">\n                <button @click=\"activeTab = 'dashboard'\"\n                        :class=\"activeTab === 'dashboard' ? 'border-blue-500 text-blue-400' : 'border-transparent text-gray-400 hover:text-gray-200'\"\n                        class=\"px-4 py-2 text-sm font-medium border-b-2 transition-colors\">\n                    Dashboard\n                </button>\n                <button @click=\"activeTab = 'rules'; if (!rulesLoaded) { htmx.ajax('GET', '/partials/rules', {target: '#rules-panel', swap: 'innerHTML'}); rulesLoaded = true; }\"\n                        :class=\"activeTab === 'rules' ? 'border-blue-500 text-blue-400' : 'border-transparent text-gray-400 hover:text-gray-200'\"\n                        class=\"px-4 py-2 text-sm font-medium border-b-2 transition-colors\">\n                    Rules\n                </button>\n                <button @click=\"activeTab = 'alerts'; if (!alertsLoaded) { htmx.ajax('GET', '/partials/alerts', {target: '#alerts-panel', swap: 'innerHTML'}); alertsLoaded = true; }\"\n                        :class=\"activeTab === 'alerts' ? 'border-blue-500 text-blue-400' : 'border-transparent text-gray-400 hover:text-gray-200'\"\n                        class=\"px-4 py-2 text-sm font-medium border-b-2 transition-colors\">\n                    Alerts\n                </button>\n                <button @click=\"activeTab = 'analytics'; if (!analyticsLoaded) { htmx.ajax('GET', '/partials/analytics', {target: '#analytics-panel', swap: 'innerHTML'}); analyticsLoaded = true; }\"\n                        :class=\"activeTab === 'analytics' ? 'border-blue-500 text-blue-400' : 'border-transparent text-gray-400 hover:text-gray-200'\"\n                        class=\"px-4 py-2 text-sm font-medium border-b-2 transition-colors\">\n                    Analytics\n                </button>\n                <button @click=\"activeTab = 'plugins'; if (!pluginsLoaded) { htmx.ajax('GET', '/partials/plugins', {target: '#plugins-panel', swap: 'innerHTML'}); pluginsLoaded = true; }\"\n                        :class=\"activeTab === 'plugins' ? 'border-blue-500 text-blue-400' : 'border-transparent text-gray-400 hover:text-gray-200'\"\n                        class=\"px-4 py-2 text-sm font-medium border-b-2 transition-colors\">\n                    Plugins\n                </button>\n                <button @click=\"activeTab = 'brokers'; if (!brokersLoaded) { brokersLoaded = true; }\"\n                        :class=\"activeTab === 'brokers' ? 'border-blue-500 text-blue-400' : 'border-transparent text-gray-400 hover:text-gray-200'\"\n                        class=\"px-4 py-2 text-sm font-medium border-b-2 transition-colors\">\n                    Brokers\n                </button>\n            </div>\n\n            <!-- Dashboard Tab -->\n            <div x-show=\"activeTab === 'dashboard'\" class=\"flex-1 p-4 flex flex-col gap-4 overflow-hidden\">\n                <!-- Message Flow -->\n                <div class=\"bg-gray-800 rounded-lg shadow-md p-4 flex-1 flex flex-col\">\n                    <div class=\"flex justify-between items-center mb-4\">\n                        <h2 class=\"text-xl font-semibold\">Message Flow</h2>\n                        <div class=\"flex space-x-2\">\n                            <button id=\"fullscreen-btn\" class=\"px-3 py-1 bg-blue-600 hover:bg-blue-700 rounded text-sm\">Fullscreen</button>\n                            <button id=\"reset-nodes-btn\" class=\"px-3 py-1 bg-green-600 hover:bg-green-700 rounded text-sm\">Reset</button>\n                        </div>\n                    </div>\n                    <div id=\"network-visualization\" class=\"flex-1 border border-gray-600 rounded\"></div>\n                </div>\n\n                <!-- Messages -->\n                <div class=\"bg-gray-800 rounded-lg shadow-md p-4 flex-1 flex flex-col min-h-0\"\n                     x-data=\"messageListComponent()\">\n                    <div class=\"flex justify-between items-center mb-4\">\n                        <h3 class=\"text-xl font-semibold\">Messages</h3>\n                        <button @click=\"showFavoritesOnly = !showFavoritesOnly\"\n                                :class=\"showFavoritesOnly ? 'bg-yellow-600' : 'bg-gray-600'\"\n                                class=\"px-3 py-1 rounded text-xs hover:opacity-80 transition-colors\">\n                            <span x-text=\"showFavoritesOnly ? '★ Favorites Only' : '☆ All Messages'\"></span>\n                        </button>\n                    </div>\n                    <div class=\"flex-1 overflow-y-auto space-y-2\" id=\"message-list\">\n                        <template x-for=\"(msg, idx) in filteredMessages\" :key=\"idx\">\n                            <div class=\"bg-gray-700 p-3 rounded hover:bg-gray-600 transition-colors\">\n                                <div class=\"flex justify-between items-start mb-2\">\n                                    <div class=\"flex items-center gap-2\">\n                                        <span class=\"text-blue-400 font-medium\" x-text=\"msg.topic\"></span>\n                                        <span x-show=\"msg.broker_name\"\n                                              class=\"px-1.5 py-0.5 text-xs bg-gray-600 text-gray-300 rounded\"\n                                              x-text=\"msg.broker_name\"></span>\n                                        <span x-show=\"msg.retain\"\n                                              class=\"px-1.5 py-0.5 text-xs font-bold bg-amber-600 text-white rounded\"\n                                              title=\"Retained message\">R</span>\n                                        <button @click=\"toggleFavorite(msg.topic)\"\n                                                :class=\"isFavorite(msg.topic) ? 'text-yellow-400' : 'text-gray-500'\"\n                                                class=\"hover:text-yellow-300 text-sm\" title=\"Toggle favorite\">\n                                            <span x-html=\"isFavorite(msg.topic) ? '&#9733;' : '&#9734;'\"></span>\n                                        </button>\n                                    </div>\n                                    <span class=\"text-gray-400 text-sm\" x-text=\"formatTime(msg.timestamp)\"></span>\n                                </div>\n                                <div class=\"text-gray-200 text-sm whitespace-pre-wrap\" x-text=\"formatPayload(msg.payload)\"></div>\n                            </div>\n                        </template>\n                        <div x-show=\"messages.length === 0\" class=\"text-center p-4 text-gray-400\">No messages</div>\n                    </div>\n                </div>\n            </div>\n\n            <!-- Rules Tab -->\n            <div x-show=\"activeTab === 'rules'\" class=\"flex-1 flex flex-col overflow-hidden p-4\">\n                <div id=\"rules-panel\"\n                     hx-get=\"/partials/rules\"\n                     hx-trigger=\"intersect once\"\n                     hx-swap=\"innerHTML\"\n                     class=\"flex-1 overflow-y-auto\">\n                    <div class=\"text-center p-4 text-gray-400\">Loading rules...</div>\n                </div>\n            </div>\n\n            <!-- Alerts Tab -->\n            <div x-show=\"activeTab === 'alerts'\" class=\"flex-1 flex flex-col overflow-hidden p-4\">\n                <div id=\"alerts-panel\" class=\"flex-1 overflow-y-auto\">\n                    <div class=\"text-center p-4 text-gray-400\">Loading alerts...</div>\n                </div>\n            </div>\n\n            <!-- Analytics Tab -->\n            <div x-show=\"activeTab === 'analytics'\" class=\"flex-1 flex flex-col overflow-hidden p-4\">\n                <div id=\"analytics-panel\" class=\"flex-1 overflow-y-auto\">\n                    <div class=\"text-center p-4 text-gray-400\">Loading analytics...</div>\n                </div>\n            </div>\n\n            <!-- Plugins Tab -->\n            <div x-show=\"activeTab === 'plugins'\" class=\"flex-1 flex flex-col overflow-hidden p-4\">\n                <div id=\"plugins-panel\" class=\"flex-1 overflow-y-auto\">\n                    <div class=\"text-center p-4 text-gray-400\">Loading plugins...</div>\n                </div>\n            </div>\n\n            <!-- Brokers Tab -->\n            <div x-show=\"activeTab === 'brokers'\" class=\"flex-1 flex flex-col overflow-hidden p-4\">\n                <div id=\"brokers-panel\" class=\"flex-1 overflow-y-auto\">\n                    {% include 'partials/brokers.html' %}\n                </div>\n            </div>\n        </div>\n    </div>\n{% endblock %}\n\n{% block scripts %}\n    <script src=\"{{ url_for('static', filename='script.js') }}\"></script>\n{% endblock %}\n"
  },
  {
    "path": "templates/login.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>MQTTUI - Login</title>\n    <link href=\"https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css\" rel=\"stylesheet\">\n</head>\n<body class=\"bg-gray-900 text-white min-h-screen flex items-center justify-center\">\n    <div class=\"w-full max-w-md\">\n        <div class=\"bg-gray-800 rounded-lg shadow-lg p-8\">\n            <h1 class=\"text-3xl font-bold text-center mb-8\">MQTTUI</h1>\n\n            {% with messages = get_flashed_messages(with_categories=true) %}\n                {% if messages %}\n                    {% for category, message in messages %}\n                        <div class=\"mb-4 p-3 rounded bg-red-900 text-red-200 text-sm\">\n                            {{ message }}\n                        </div>\n                    {% endfor %}\n                {% endif %}\n            {% endwith %}\n\n            <form action=\"/login\" method=\"POST\">\n                <div class=\"mb-4\">\n                    <label for=\"username\" class=\"block text-sm font-medium text-gray-300 mb-2\">Username</label>\n                    <input type=\"text\" id=\"username\" name=\"username\" required\n                           class=\"w-full px-4 py-2 rounded bg-gray-700 border border-gray-600 text-white focus:outline-none focus:border-blue-500\"\n                           placeholder=\"Enter username\" autocomplete=\"username\">\n                </div>\n\n                <div class=\"mb-6\">\n                    <label for=\"password\" class=\"block text-sm font-medium text-gray-300 mb-2\">Password</label>\n                    <input type=\"password\" id=\"password\" name=\"password\" required\n                           class=\"w-full px-4 py-2 rounded bg-gray-700 border border-gray-600 text-white focus:outline-none focus:border-blue-500\"\n                           placeholder=\"Enter password\" autocomplete=\"current-password\">\n                </div>\n\n                <button type=\"submit\"\n                        class=\"w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 rounded font-medium transition duration-200\">\n                    Sign In\n                </button>\n            </form>\n        </div>\n    </div>\n</body>\n</html>\n"
  },
  {
    "path": "templates/partials/alert_row.html",
    "content": "<div class=\"bg-gray-700 rounded p-3 border-l-4\n  {% if alert.severity == 'critical' %}border-red-500\n  {% elif alert.severity == 'warning' %}border-yellow-500\n  {% else %}border-blue-500{% endif %}\">\n  <div class=\"flex justify-between items-start\">\n    <div class=\"flex-1\">\n      <div class=\"flex items-center gap-2 mb-1\">\n        <span class=\"text-xs px-2 py-0.5 rounded\n          {% if alert.severity == 'critical' %}bg-red-600\n          {% elif alert.severity == 'warning' %}bg-yellow-600\n          {% else %}bg-blue-600{% endif %}\">\n          {{ alert.severity }}\n        </span>\n        <span class=\"font-medium text-sm\">{{ alert.rule_name or 'Unknown Rule' }}</span>\n        {% if alert.webhook_url %}\n        <span class=\"text-xs px-2 py-0.5 rounded\n          {% if alert.http_status and alert.http_status < 400 %}bg-green-600\n          {% elif alert.http_status %}bg-red-600\n          {% else %}bg-gray-600{% endif %}\">\n          {% if alert.http_status %}HTTP {{ alert.http_status }}{% else %}Pending{% endif %}\n        </span>\n        {% endif %}\n        {% if alert.suppressed_count > 0 %}\n        <span class=\"text-xs text-gray-400\">({{ alert.suppressed_count }} suppressed)</span>\n        {% endif %}\n      </div>\n      <div class=\"text-sm text-gray-300\">{{ alert.message or '' }}</div>\n      <div class=\"text-xs text-gray-500 mt-1\">\n        Topic: <span class=\"text-blue-400\">{{ alert.topic or 'N/A' }}</span>\n        {% if alert.retry_count > 0 %}\n        | Retries: {{ alert.retry_count }}\n        {% endif %}\n        {% if alert.error_detail %}\n        | Error: <span class=\"text-red-400\">{{ alert.error_detail[:100] }}</span>\n        {% endif %}\n      </div>\n    </div>\n    <div class=\"text-xs text-gray-400 whitespace-nowrap ml-2\">\n      {{ alert.fired_at.strftime('%Y-%m-%d %H:%M:%S') if alert.fired_at else '' }}\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "templates/partials/alerts_list.html",
    "content": "<div id=\"alerts-list\">\n  <!-- Filters -->\n  <div class=\"flex flex-wrap gap-3 mb-4 items-end\">\n    <div>\n      <label class=\"block text-xs text-gray-400 mb-1\">Rule</label>\n      <select name=\"rule_id\" id=\"alert-filter-rule\"\n              hx-get=\"/partials/alerts\"\n              hx-target=\"#alerts-list\"\n              hx-swap=\"outerHTML\"\n              hx-include=\"#alert-filter-severity\"\n              class=\"rounded bg-gray-700 border-gray-600 text-white text-sm p-1.5\">\n        <option value=\"\">All Rules</option>\n        {% for rule in rules %}\n        <option value=\"{{ rule.id }}\" {{ 'selected' if current_rule_id == rule.id else '' }}>{{ rule.name }}</option>\n        {% endfor %}\n      </select>\n    </div>\n    <div>\n      <label class=\"block text-xs text-gray-400 mb-1\">Severity</label>\n      <select name=\"severity\" id=\"alert-filter-severity\"\n              hx-get=\"/partials/alerts\"\n              hx-target=\"#alerts-list\"\n              hx-swap=\"outerHTML\"\n              hx-include=\"#alert-filter-rule\"\n              class=\"rounded bg-gray-700 border-gray-600 text-white text-sm p-1.5\">\n        <option value=\"\">All</option>\n        <option value=\"info\" {{ 'selected' if current_severity == 'info' else '' }}>Info</option>\n        <option value=\"warning\" {{ 'selected' if current_severity == 'warning' else '' }}>Warning</option>\n        <option value=\"critical\" {{ 'selected' if current_severity == 'critical' else '' }}>Critical</option>\n      </select>\n    </div>\n    <div class=\"text-sm text-gray-400\">\n      Showing {{ alerts|length }} of {{ total }} alerts\n    </div>\n  </div>\n\n  <!-- Alert rows -->\n  {% if alerts %}\n  <div class=\"space-y-2\">\n    {% for alert in alerts %}\n      {% include 'partials/alert_row.html' %}\n    {% endfor %}\n  </div>\n  {% else %}\n  <p class=\"text-gray-400 text-center py-8\">No alerts recorded yet.</p>\n  {% endif %}\n\n  <!-- Pagination -->\n  {% if has_next %}\n  <div class=\"text-center mt-4\">\n    <button hx-get=\"/partials/alerts?page={{ page + 1 }}{% if current_rule_id %}&rule_id={{ current_rule_id }}{% endif %}{% if current_severity %}&severity={{ current_severity }}{% endif %}\"\n            hx-target=\"#alerts-list\"\n            hx-swap=\"outerHTML\"\n            class=\"px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm\">\n      Load More (page {{ page + 1 }})\n    </button>\n  </div>\n  {% endif %}\n</div>\n"
  },
  {
    "path": "templates/partials/analytics.html",
    "content": "<div x-data=\"analyticsWidget()\" x-init=\"init()\">\n    <!-- Top Topics by Message Rate -->\n    <div class=\"bg-gray-800 rounded-lg p-4 mb-4\">\n        <div class=\"flex justify-between items-center mb-3\">\n            <h3 class=\"text-lg font-semibold\">Top Topics by Rate</h3>\n            <span class=\"text-xs text-gray-400\" x-text=\"'Updated ' + lastUpdated\"></span>\n        </div>\n\n        <div class=\"space-y-2\">\n            <template x-for=\"t in topics\" :key=\"t.topic\">\n                <div class=\"flex items-center justify-between bg-gray-700 rounded p-2 cursor-pointer hover:bg-gray-600\"\n                     @click=\"selectedTopic = (selectedTopic === t.topic ? null : t.topic)\">\n                    <span class=\"text-sm text-blue-400 truncate flex-1\" x-text=\"t.topic\"></span>\n                    <div class=\"flex gap-3 text-xs text-gray-300 ml-2\">\n                        <span x-text=\"t.rate_per_min.toFixed(1) + '/min'\"></span>\n                        <span x-text=\"t.rate_per_hour.toFixed(0) + '/hr'\"></span>\n                        <span class=\"text-gray-500\" x-text=\"t.message_count + ' total'\"></span>\n                    </div>\n                </div>\n            </template>\n            <div x-show=\"topics.length === 0\" class=\"text-center text-gray-400 text-sm py-4\">\n                No analytics data yet\n            </div>\n        </div>\n    </div>\n\n    <!-- Histogram Detail (shown when topic selected) -->\n    <div x-show=\"selectedTopic\" class=\"bg-gray-800 rounded-lg p-4\">\n        <h3 class=\"text-lg font-semibold mb-3\">\n            Payload Stats: <span class=\"text-blue-400\" x-text=\"selectedTopic\"></span>\n        </h3>\n        <div class=\"space-y-2\">\n            <template x-for=\"(stats, field) in selectedHistograms\" :key=\"field\">\n                <div class=\"bg-gray-700 rounded p-3\">\n                    <div class=\"text-sm font-medium text-gray-200 mb-1\" x-text=\"field\"></div>\n                    <div class=\"grid grid-cols-4 gap-2 text-xs\">\n                        <div>\n                            <span class=\"text-gray-400\">Min:</span>\n                            <span class=\"text-green-400\" x-text=\"stats.min.toFixed(2)\"></span>\n                        </div>\n                        <div>\n                            <span class=\"text-gray-400\">Max:</span>\n                            <span class=\"text-red-400\" x-text=\"stats.max.toFixed(2)\"></span>\n                        </div>\n                        <div>\n                            <span class=\"text-gray-400\">Avg:</span>\n                            <span class=\"text-yellow-400\" x-text=\"(stats.sum / stats.count).toFixed(2)\"></span>\n                        </div>\n                        <div>\n                            <span class=\"text-gray-400\">Count:</span>\n                            <span class=\"text-blue-400\" x-text=\"stats.count\"></span>\n                        </div>\n                    </div>\n                </div>\n            </template>\n            <div x-show=\"Object.keys(selectedHistograms).length === 0\" class=\"text-sm text-gray-400\">\n                No numeric fields detected in payloads\n            </div>\n        </div>\n    </div>\n</div>\n\n<script>\nfunction analyticsWidget() {\n    return {\n        topics: [],\n        selectedTopic: null,\n        selectedHistograms: {},\n        lastUpdated: 'never',\n        refreshInterval: null,\n\n        init() {\n            this.fetchAnalytics();\n            // Auto-refresh every 5 seconds\n            this.refreshInterval = setInterval(() => this.fetchAnalytics(), 5000);\n        },\n\n        async fetchAnalytics() {\n            try {\n                const resp = await fetch('/api/v1/analytics/topics?limit=10');\n                const json = await resp.json();\n                if (json.status === 'success') {\n                    this.topics = json.data.topics;\n                    this.lastUpdated = new Date().toLocaleTimeString();\n                    // Update selected topic histogram if still selected\n                    if (this.selectedTopic) {\n                        const found = this.topics.find(t => t.topic === this.selectedTopic);\n                        this.selectedHistograms = found ? found.histograms : {};\n                    }\n                }\n            } catch (e) {\n                console.error('Analytics fetch failed:', e);\n            }\n        },\n\n        destroy() {\n            if (this.refreshInterval) clearInterval(this.refreshInterval);\n        }\n    };\n}\n</script>\n"
  },
  {
    "path": "templates/partials/brokers.html",
    "content": "<div class=\"space-y-4\" x-data=\"brokersComponent()\" x-init=\"loadBrokers()\">\n    <div class=\"flex justify-between items-center\">\n        <h3 class=\"text-xl font-semibold\">Broker Connections</h3>\n        <button @click=\"showForm = !showForm\"\n                class=\"px-4 py-2 bg-green-600 hover:bg-green-700 rounded text-sm\">\n            + Add Broker\n        </button>\n    </div>\n\n    <!-- Add/Edit Form -->\n    <div x-show=\"showForm\" x-transition class=\"bg-gray-800 border border-gray-600 rounded p-4\">\n        <h4 class=\"text-md font-semibold mb-3\" x-text=\"editId ? 'Edit Broker' : 'Add Broker'\"></h4>\n        <form @submit.prevent=\"saveBroker()\" class=\"space-y-3\">\n            <div class=\"grid grid-cols-2 gap-3\">\n                <div>\n                    <label class=\"block text-sm font-medium mb-1\">Name *</label>\n                    <input type=\"text\" x-model=\"form.name\" required placeholder=\"Production Broker\"\n                           class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n                </div>\n                <div>\n                    <label class=\"block text-sm font-medium mb-1\">Host *</label>\n                    <input type=\"text\" x-model=\"form.host\" required placeholder=\"broker.example.com\"\n                           class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n                </div>\n                <div>\n                    <label class=\"block text-sm font-medium mb-1\">Port</label>\n                    <input type=\"number\" x-model.number=\"form.port\" placeholder=\"1883\"\n                           class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n                </div>\n                <div>\n                    <label class=\"block text-sm font-medium mb-1\">MQTT Version</label>\n                    <select x-model=\"form.mqtt_version\"\n                            class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n                        <option value=\"3.1.1\">v3.1.1</option>\n                        <option value=\"5\">v5</option>\n                    </select>\n                </div>\n                <div>\n                    <label class=\"block text-sm font-medium mb-1\">Username</label>\n                    <input type=\"text\" x-model=\"form.username\" placeholder=\"(optional)\"\n                           class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n                </div>\n                <div>\n                    <label class=\"block text-sm font-medium mb-1\">Password</label>\n                    <input type=\"password\" x-model=\"form.password\" placeholder=\"(optional)\"\n                           class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n                </div>\n            </div>\n            <div>\n                <label class=\"block text-sm font-medium mb-1\">Topics</label>\n                <input type=\"text\" x-model=\"form.topics\" placeholder=\"# (all topics)\"\n                       class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n                <span class=\"text-xs text-gray-500\">Comma-separated. Supports wildcards: +, #</span>\n            </div>\n            <div class=\"flex items-center gap-4\">\n                <label class=\"flex items-center gap-2 text-sm\">\n                    <input type=\"checkbox\" x-model=\"form.tls_enabled\" class=\"rounded\">\n                    TLS/SSL\n                </label>\n                <label x-show=\"form.tls_enabled\" class=\"flex items-center gap-2 text-sm\">\n                    <input type=\"checkbox\" x-model=\"form.tls_insecure\" class=\"rounded\">\n                    Skip cert verification\n                </label>\n            </div>\n            <div class=\"flex gap-2\">\n                <button type=\"submit\" class=\"px-4 py-2 bg-green-600 hover:bg-green-700 rounded text-sm\"\n                        x-text=\"editId ? 'Update' : 'Add'\"></button>\n                <button type=\"button\" @click=\"resetForm()\"\n                        class=\"px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded text-sm\">Cancel</button>\n            </div>\n            <div x-show=\"error\" x-text=\"error\" class=\"text-red-400 text-sm\"></div>\n        </form>\n    </div>\n\n    <!-- Broker List -->\n    <div class=\"space-y-2\">\n        <template x-for=\"broker in brokers\" :key=\"broker.id\">\n            <div class=\"bg-gray-700 rounded p-3 border-l-4\"\n                 :class=\"broker.connected ? 'border-green-500' : 'border-red-500'\">\n                <div class=\"flex justify-between items-start\">\n                    <div class=\"flex-1\">\n                        <div class=\"flex items-center gap-2 mb-1\">\n                            <span class=\"font-medium\" x-text=\"broker.name\"></span>\n                            <span class=\"text-xs px-2 py-0.5 rounded\"\n                                  :class=\"broker.connected ? 'bg-green-600' : 'bg-red-600'\"\n                                  x-text=\"broker.connected ? 'Connected' : 'Disconnected'\"></span>\n                            <span x-show=\"broker.is_default\" class=\"text-xs px-2 py-0.5 rounded bg-blue-600\">Default</span>\n                            <span x-show=\"broker.tls_enabled\" class=\"text-xs px-2 py-0.5 rounded bg-purple-600\">TLS</span>\n                        </div>\n                        <div class=\"text-sm text-gray-400\">\n                            <span x-text=\"broker.host + ':' + broker.port\"></span>\n                            | Topics: <span class=\"text-blue-400\" x-text=\"broker.topics || '#'\"></span>\n                        </div>\n                        <div x-show=\"broker.connection_error\" class=\"text-xs text-red-400 mt-1\"\n                             x-text=\"broker.connection_error\"></div>\n                    </div>\n                    <div class=\"flex gap-1 ml-2\">\n                        <template x-if=\"broker.connected\">\n                            <button @click=\"disconnectBroker(broker.id)\"\n                                    class=\"px-2 py-1 bg-yellow-600 hover:bg-yellow-700 rounded text-xs\">Disconnect</button>\n                        </template>\n                        <template x-if=\"!broker.connected\">\n                            <button @click=\"connectBroker(broker.id)\"\n                                    class=\"px-2 py-1 bg-green-600 hover:bg-green-700 rounded text-xs\">Connect</button>\n                        </template>\n                        <button @click=\"editBroker(broker)\"\n                                class=\"px-2 py-1 bg-blue-600 hover:bg-blue-700 rounded text-xs\">Edit</button>\n                        <button @click=\"if(confirm('Delete broker ' + broker.name + '?')) deleteBroker(broker.id)\"\n                                class=\"px-2 py-1 bg-red-600 hover:bg-red-700 rounded text-xs\">Del</button>\n                    </div>\n                </div>\n            </div>\n        </template>\n        <div x-show=\"brokers.length === 0\" class=\"text-gray-500 text-center py-4\">\n            No brokers configured. Add one above.\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "templates/partials/dry_run_result.html",
    "content": "<div class=\"bg-gray-800 border border-gray-600 rounded p-4 mb-4\" x-data=\"dryRunComponent({{ rule_id }})\">\n  <h4 class=\"text-md font-semibold mb-3\">Dry-Run Test</h4>\n  <div class=\"space-y-3\">\n    <div>\n      <label class=\"block text-sm font-medium mb-1\">Test Topic</label>\n      <input type=\"text\" x-model=\"topic\" placeholder=\"sensors/room1/temperature\"\n             class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n    </div>\n    <div>\n      <label class=\"block text-sm font-medium mb-1\">Test Payload</label>\n      <textarea x-model=\"payload\" placeholder='{\"temperature\": 35, \"unit\": \"C\"}'\n                class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\" rows=\"3\"></textarea>\n    </div>\n    <div class=\"flex gap-2\">\n      <button @click=\"runTest()\" class=\"px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded text-sm\">Run Test</button>\n      <button @click=\"$el.closest('#rule-form-container').innerHTML=''\"\n              class=\"px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded text-sm\">Close</button>\n    </div>\n    <!-- Results -->\n    <div x-show=\"result !== null\" class=\"mt-3 p-3 rounded\" :class=\"result?.match ? 'bg-green-900/30 border border-green-600' : 'bg-red-900/30 border border-red-600'\">\n      <div class=\"font-medium mb-2\" x-text=\"result?.match ? 'Rule WOULD fire' : 'Rule would NOT fire'\"></div>\n      <div class=\"text-sm space-y-1\">\n        <div>Topic match: <span :class=\"result?.topic_match ? 'text-green-400' : 'text-red-400'\" x-text=\"result?.topic_match ? 'Yes' : 'No'\"></span></div>\n        <div>Condition match: <span :class=\"result?.condition_match ? 'text-green-400' : 'text-red-400'\" x-text=\"result?.condition_match ? 'Yes' : 'No'\"></span></div>\n        <template x-if=\"result?.action_preview?.length > 0\">\n          <div class=\"mt-2\">\n            <div class=\"text-xs text-gray-400 mb-1\">Action Preview:</div>\n            <pre class=\"text-xs bg-gray-800 p-2 rounded overflow-x-auto\" x-text=\"JSON.stringify(result.action_preview, null, 2)\"></pre>\n          </div>\n        </template>\n      </div>\n    </div>\n    <div x-show=\"error\" class=\"mt-2 text-red-400 text-sm\" x-text=\"error\"></div>\n  </div>\n</div>\n"
  },
  {
    "path": "templates/partials/plugins.html",
    "content": "<div>\n    {% if plugins %}\n    {% for plugin in plugins %}\n    <div class=\"bg-gray-800 rounded-lg p-4 mb-3\">\n        <div class=\"flex justify-between items-center\">\n            <div>\n                <span class=\"font-bold text-gray-200\">{{ plugin.name }}</span>\n                {% if plugin.version %}\n                <span class=\"text-xs text-gray-400 ml-2\">v{{ plugin.version }}</span>\n                {% endif %}\n            </div>\n            <div class=\"flex items-center gap-2\">\n                {% if plugin.enabled %}\n                <span class=\"px-2 py-0.5 text-xs rounded bg-green-600 text-white\">Enabled</span>\n                <button class=\"px-3 py-1 text-xs bg-red-600 hover:bg-red-700 rounded text-white\"\n                        hx-post=\"/api/v1/plugins/{{ plugin.name }}/disable\"\n                        hx-swap=\"none\"\n                        hx-on::after-request=\"htmx.ajax('GET', '/partials/plugins', {target: '#plugins-panel', swap: 'innerHTML'})\">\n                    Disable\n                </button>\n                {% else %}\n                <span class=\"px-2 py-0.5 text-xs rounded bg-gray-600 text-gray-300\">Disabled</span>\n                <button class=\"px-3 py-1 text-xs bg-green-600 hover:bg-green-700 rounded text-white\"\n                        hx-post=\"/api/v1/plugins/{{ plugin.name }}/enable\"\n                        hx-swap=\"none\"\n                        hx-on::after-request=\"htmx.ajax('GET', '/partials/plugins', {target: '#plugins-panel', swap: 'innerHTML'})\">\n                    Enable\n                </button>\n                {% endif %}\n            </div>\n        </div>\n        {% if plugin.description %}\n        <p class=\"text-sm text-gray-400 mt-1\">{{ plugin.description }}</p>\n        {% endif %}\n        <div class=\"text-xs text-gray-500 mt-1\">\n            <span class=\"font-mono\">{{ plugin.entry_point }}</span>\n            {% if plugin.installed_at %}\n            <span class=\"ml-2\">Installed: {{ plugin.installed_at }}</span>\n            {% endif %}\n        </div>\n    </div>\n    {% endfor %}\n    {% else %}\n    <div class=\"text-center p-8 text-gray-400\">\n        <p class=\"text-lg mb-2\">No plugins installed</p>\n        <p class=\"text-sm\">Install plugins via pip and restart MQTTUI to discover them.</p>\n    </div>\n    {% endif %}\n</div>\n"
  },
  {
    "path": "templates/partials/rule_form.html",
    "content": "<div class=\"bg-gray-800 border border-gray-600 rounded p-4 mb-4\"\n     x-data=\"ruleFormComponent(window.__ruleEditData || {})\"\n     x-init=\"delete window.__ruleEditData\">\n  {% if rule %}\n  <script>window.__ruleEditData = {{ rule.to_dict()|tojson }};</script>\n  {% endif %}\n  <h4 class=\"text-md font-semibold mb-3\">{{ 'Edit Rule' if rule else 'New Rule' }}</h4>\n  <form @submit.prevent=\"submitRule()\">\n    <!-- Name -->\n    <div class=\"mb-3\">\n      <label class=\"block text-sm font-medium mb-1\">Name *</label>\n      <input type=\"text\" x-model=\"form.name\" required\n             class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n    </div>\n    <!-- Description -->\n    <div class=\"mb-3\">\n      <label class=\"block text-sm font-medium mb-1\">Description</label>\n      <input type=\"text\" x-model=\"form.description\"\n             class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n    </div>\n    <!-- Trigger Topic -->\n    <div class=\"mb-3\">\n      <label class=\"block text-sm font-medium mb-1\">Trigger Topic *</label>\n      <input type=\"text\" x-model=\"form.trigger_topic\" required placeholder=\"sensors/+/temperature\"\n             class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n      <span class=\"text-xs text-gray-500\">Supports MQTT wildcards: + (single level), # (multi level)</span>\n    </div>\n    <!-- Condition -->\n    <div class=\"mb-3\">\n      <label class=\"block text-sm font-medium mb-1\">Condition (optional)</label>\n      <div class=\"flex gap-2\">\n        <input type=\"text\" x-model=\"form.condition_path\" placeholder=\"payload.temperature\"\n               class=\"flex-1 rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n        <select x-model=\"form.condition_op\"\n                class=\"rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n          <option value=\"\">No condition</option>\n          <option value=\"gt\">></option>\n          <option value=\"gte\">>=</option>\n          <option value=\"lt\"><</option>\n          <option value=\"lte\"><=</option>\n          <option value=\"eq\">==</option>\n          <option value=\"ne\">!=</option>\n          <option value=\"contains\">contains</option>\n          <option value=\"regex\">regex</option>\n          <option value=\"exists\">exists</option>\n        </select>\n        <input type=\"text\" x-model=\"form.condition_value\" placeholder=\"30\"\n               x-show=\"!['exists', 'not_exists', ''].includes(form.condition_op)\"\n               class=\"w-24 rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n      </div>\n    </div>\n    <!-- Action Type -->\n    <div class=\"mb-3\">\n      <label class=\"block text-sm font-medium mb-1\">Action *</label>\n      <select x-model=\"form.action_type\"\n              class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2 mb-2\">\n        <option value=\"publish\">Publish to Topic</option>\n        <option value=\"telegram\">Telegram</option>\n        <option value=\"slack\">Slack</option>\n        <option value=\"webhook\">Webhook (HTTP POST)</option>\n        <option value=\"log\">Log Alert</option>\n      </select>\n      <!-- Publish fields -->\n      <div x-show=\"form.action_type === 'publish'\" class=\"space-y-2\">\n        <input type=\"text\" x-model=\"form.action_topic\" placeholder=\"alerts/high-temp\"\n               class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n        <textarea x-model=\"form.action_payload\" placeholder='{\"alert\": \"temperature exceeded\"}'\n                  class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\" rows=\"2\"></textarea>\n      </div>\n      <!-- Telegram fields -->\n      <div x-show=\"form.action_type === 'telegram'\" class=\"space-y-2\">\n        <input type=\"text\" x-model=\"form.action_bot_token\" placeholder=\"Bot token (from @BotFather)\" required\n               class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n        <input type=\"text\" x-model=\"form.action_chat_id\" placeholder=\"Chat ID (e.g. 123456789)\" required\n               class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n        <details class=\"text-sm\">\n          <summary class=\"text-gray-400 cursor-pointer hover:text-gray-300\">Custom message template (optional — has a good default)</summary>\n          <textarea x-model=\"form.action_message_template\"\n                    placeholder=\"Leave blank for default. Variables: {{topic}}, {{payload}}, {{rule_name}}, {{timestamp}}\"\n                    class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2 mt-2\" rows=\"3\"></textarea>\n          <span class=\"text-xs text-gray-500\">Supports Markdown formatting</span>\n        </details>\n      </div>\n      <!-- Slack fields -->\n      <div x-show=\"form.action_type === 'slack'\" class=\"space-y-2\">\n        <input type=\"text\" x-model=\"form.action_slack_url\" placeholder=\"https://hooks.slack.com/services/T.../B.../xxx\" required\n               class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n        <details class=\"text-sm\">\n          <summary class=\"text-gray-400 cursor-pointer hover:text-gray-300\">Custom message template (optional — has a good default)</summary>\n          <textarea x-model=\"form.action_message_template\"\n                    placeholder=\"Leave blank for default. Variables: {{topic}}, {{payload}}, {{rule_name}}, {{timestamp}}\"\n                    class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2 mt-2\" rows=\"3\"></textarea>\n          <span class=\"text-xs text-gray-500\">Uses Slack mrkdwn formatting</span>\n        </details>\n      </div>\n      <!-- Webhook fields -->\n      <div x-show=\"form.action_type === 'webhook'\" class=\"space-y-2\">\n        <input type=\"text\" x-model=\"form.action_url\" placeholder=\"https://hooks.example.com/notify\"\n               class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n        <textarea x-model=\"form.action_template\" placeholder=\"Optional: payload template with {{topic}}, {{payload}}\"\n                  class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\" rows=\"2\"></textarea>\n      </div>\n      <!-- Log fields -->\n      <div x-show=\"form.action_type === 'log'\" class=\"space-y-2\">\n        <select x-model=\"form.action_severity\"\n                class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n          <option value=\"info\">Info</option>\n          <option value=\"warning\">Warning</option>\n          <option value=\"critical\">Critical</option>\n        </select>\n        <input type=\"text\" x-model=\"form.action_message\" placeholder=\"Alert message\"\n               class=\"block w-full rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n      </div>\n    </div>\n    <!-- Rate Limit -->\n    <div class=\"mb-3\">\n      <label class=\"block text-sm font-medium mb-1\">Rate Limit (per minute)</label>\n      <input type=\"number\" x-model.number=\"form.rate_limit_per_min\" min=\"1\" max=\"1000\"\n             class=\"w-24 rounded bg-gray-700 border-gray-600 text-white text-sm p-2\">\n    </div>\n    <!-- Buttons -->\n    <div class=\"flex gap-2\">\n      <button type=\"submit\" class=\"px-4 py-2 bg-green-600 hover:bg-green-700 rounded text-sm\" x-text=\"form.id ? 'Update' : 'Create'\"></button>\n      <button type=\"button\" @click=\"$el.closest('#rule-form-container').innerHTML=''\"\n              class=\"px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded text-sm\">Cancel</button>\n    </div>\n    <!-- Error/Success messages -->\n    <div x-show=\"error\" x-text=\"error\" class=\"mt-2 text-red-400 text-sm\"></div>\n    <div x-show=\"success\" x-text=\"success\" class=\"mt-2 text-green-400 text-sm\"></div>\n  </form>\n</div>\n"
  },
  {
    "path": "templates/partials/rule_row.html",
    "content": "<div id=\"rule-{{ rule.id }}\" class=\"bg-gray-700 rounded p-3 border-l-4 {{ 'border-green-500' if rule.enabled else 'border-gray-500' }}\">\n  <div class=\"flex justify-between items-start\">\n    <div class=\"flex-1\">\n      <div class=\"flex items-center gap-2 mb-1\">\n        <span class=\"font-medium\">{{ rule.name }}</span>\n        <span class=\"text-xs px-2 py-0.5 rounded {{ 'bg-green-600' if rule.enabled else 'bg-gray-600' }}\">\n          {{ 'Active' if rule.enabled else 'Disabled' }}\n        </span>\n        {% if rule.schedule_cron %}\n        <span class=\"text-xs px-2 py-0.5 rounded bg-purple-600\">Scheduled</span>\n        {% endif %}\n      </div>\n      <div class=\"text-sm text-gray-400\">\n        Topic: <span class=\"text-blue-400\">{{ rule.trigger_topic }}</span>\n        | Action: <span class=\"text-yellow-400\">{{ rule.action.type if rule.action else 'none' }}</span>\n        | Fired: {{ rule.fire_count }}x\n      </div>\n      {% if rule.description %}\n      <div class=\"text-xs text-gray-500 mt-1\">{{ rule.description }}</div>\n      {% endif %}\n    </div>\n    <div class=\"flex gap-1 ml-2\">\n      <!-- Enable/Disable toggle -->\n      {% if rule.enabled %}\n      <button hx-post=\"/partials/rules/{{ rule.id }}/toggle\"\n              hx-target=\"#rule-{{ rule.id }}\"\n              hx-swap=\"outerHTML\"\n              class=\"px-2 py-1 bg-yellow-600 hover:bg-yellow-700 rounded text-xs\"\n              title=\"Disable\">Pause</button>\n      {% else %}\n      <button hx-post=\"/partials/rules/{{ rule.id }}/toggle\"\n              hx-target=\"#rule-{{ rule.id }}\"\n              hx-swap=\"outerHTML\"\n              class=\"px-2 py-1 bg-green-600 hover:bg-green-700 rounded text-xs\"\n              title=\"Enable\">Enable</button>\n      {% endif %}\n      <!-- Edit -->\n      <button hx-get=\"/partials/rules/{{ rule.id }}/form\"\n              hx-target=\"#rule-form-container\"\n              hx-swap=\"innerHTML\"\n              class=\"px-2 py-1 bg-blue-600 hover:bg-blue-700 rounded text-xs\">Edit</button>\n      <!-- Dry Run -->\n      <button hx-get=\"/partials/rules/{{ rule.id }}/dry-run\"\n              hx-target=\"#rule-form-container\"\n              hx-swap=\"innerHTML\"\n              class=\"px-2 py-1 bg-purple-600 hover:bg-purple-700 rounded text-xs\">Test</button>\n      <!-- Delete -->\n      <button hx-delete=\"/partials/rules/{{ rule.id }}/delete\"\n              hx-confirm=\"Delete rule '{{ rule.name }}'?\"\n              hx-target=\"#rule-{{ rule.id }}\"\n              hx-swap=\"outerHTML\"\n              class=\"px-2 py-1 bg-red-600 hover:bg-red-700 rounded text-xs\">Del</button>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "templates/partials/rules_list.html",
    "content": "<div id=\"rules-list\">\n  <div class=\"flex justify-between items-center mb-4\">\n    <h3 class=\"text-lg font-semibold\">Automation Rules</h3>\n    <button hx-get=\"/partials/rules/form\"\n            hx-target=\"#rule-form-container\"\n            hx-swap=\"innerHTML\"\n            class=\"px-3 py-1 bg-blue-600 hover:bg-blue-700 rounded text-sm\">\n      + New Rule\n    </button>\n  </div>\n  <div id=\"rule-form-container\"></div>\n  {% if rules %}\n  <div class=\"space-y-2\">\n    {% for rule in rules %}\n      {% include 'partials/rule_row.html' %}\n    {% endfor %}\n  </div>\n  {% else %}\n  <p class=\"text-gray-400 text-center py-4\">No rules defined yet. Create one to get started.</p>\n  {% endif %}\n</div>\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/conftest.py",
    "content": "import pytest\nimport tempfile\nimport os\nfrom unittest.mock import patch, MagicMock\n\n\n@pytest.fixture\ndef app(tmp_path):\n    \"\"\"Create application for testing with mocked MQTT.\"\"\"\n    db_path = str(tmp_path / \"test.db\")\n\n    # Patch init_mqtt to prevent real MQTT broker connection\n    with patch('mqttui.mqtt_client.init_mqtt') as mock_init:\n        from mqttui.app import create_app\n        app = create_app({\n            'TESTING': True,\n            'SECRET_KEY': 'test-secret-key',\n            'SQLALCHEMY_DATABASE_URI': f'sqlite:///{tmp_path}/test_users.db',\n            'SQLALCHEMY_TRACK_MODIFICATIONS': False,\n            'DB_ENABLED': True,\n            'DB_PATH': db_path,\n            'DB_MAX_MESSAGES': 1000,\n            'MQTT_BROKER': '127.0.0.1',\n            'MQTT_PORT': 1883,\n            'MQTT_VERSION': '3.1.1',\n            'MQTT_TOPICS': '#',\n            'MQTT_USERNAME': None,\n            'MQTT_PASSWORD': None,\n            'MQTT_KEEPALIVE': 60,\n        })\n    yield app\n\n\n@pytest.fixture\ndef client(app):\n    \"\"\"Flask test client.\"\"\"\n    return app.test_client()\n\n\n@pytest.fixture\ndef test_db(tmp_path):\n    \"\"\"Isolated MessageDatabase for testing.\"\"\"\n    db_path = str(tmp_path / \"test_messages.db\")\n    from mqttui.database import MessageDatabase\n    db = MessageDatabase(db_path, max_messages=100)\n    yield db\n    db.close()\n\n\n@pytest.fixture\ndef auth_client(app):\n    \"\"\"Flask test client with authenticated session.\"\"\"\n    client = app.test_client()\n    with app.app_context():\n        from mqttui.models import User\n        from mqttui.extensions import sa\n        user = User.query.filter_by(username='admin').first()\n        if not user:\n            user = User(username='admin')\n            user.set_password('admin')\n            user.generate_api_token()\n            sa.session.add(user)\n            sa.session.commit()\n    # Log in via test client\n    client.post('/login', data={'username': 'admin', 'password': 'admin'}, follow_redirects=False)\n    return client\n\n\n@pytest.fixture\ndef api_token(app):\n    \"\"\"Return a valid API token for testing.\"\"\"\n    with app.app_context():\n        from mqttui.models import User\n        user = User.query.filter_by(username='admin').first()\n        return user.api_token\n\n\n@pytest.fixture\ndef mock_mqtt():\n    \"\"\"Mock MQTT client to prevent broker connections.\"\"\"\n    with patch('mqttui.mqtt_client._mqtt_client') as mock_client:\n        mock_client.publish = MagicMock()\n        mock_client.connect = MagicMock()\n        mock_client.subscribe = MagicMock()\n        yield mock_client\n"
  },
  {
    "path": "tests/test_alerts_api.py",
    "content": "\"\"\"Tests for the Alerts REST API and dry-run action_preview.\"\"\"\nimport json\nfrom datetime import datetime\n\nimport pytest\n\nfrom mqttui.extensions import sa\nfrom mqttui.rules.models import AlertHistory, Rule\n\n\n# ---------------------------------------------------------------------------\n# Helper to seed alerts\n# ---------------------------------------------------------------------------\n\ndef _seed_alerts(app, count=25, rule_id=1, severity='info'):\n    \"\"\"Seed AlertHistory records for testing.\"\"\"\n    with app.app_context():\n        for i in range(count):\n            record = AlertHistory(\n                rule_id=rule_id,\n                rule_name=f'rule-{rule_id}',\n                topic=f'sensors/temp/{i}',\n                severity=severity,\n                message=f'Test alert {i}',\n                fired_at=datetime(2026, 1, 1, 0, i % 60),\n            )\n            sa.session.add(record)\n        sa.session.commit()\n\n\ndef _seed_rule(app, rule_id=None, action_type='webhook'):\n    \"\"\"Seed a Rule record and return its ID.\"\"\"\n    with app.app_context():\n        action = {'type': action_type}\n        if action_type == 'webhook':\n            action['url'] = 'https://example.com/hook'\n            action['payload_template'] = '{\"topic\": \"{{topic}}\", \"rule\": \"{{rule_name}}\"}'\n        elif action_type == 'publish':\n            action['topic'] = 'output/topic'\n            action['payload'] = '{\"forwarded\": true}'\n        elif action_type == 'log':\n            action['message'] = 'Rule fired: {{rule_name}}'\n            action['severity'] = 'warning'\n\n        rule = Rule(\n            name='test-rule',\n            trigger_topic='sensors/+',\n            condition_json='{\"op\": \"gt\", \"path\": \"temp\", \"value\": 30}',\n            action_json=json.dumps(action),\n            enabled=True,\n        )\n        sa.session.add(rule)\n        sa.session.commit()\n        return rule.id\n\n\n# ---------------------------------------------------------------------------\n# GET /api/v1/alerts tests\n# ---------------------------------------------------------------------------\n\nclass TestListAlerts:\n    \"\"\"Tests for the alert history listing endpoint.\"\"\"\n\n    def test_list_alerts_empty(self, auth_client, app):\n        \"\"\"Empty database returns empty list with total 0.\"\"\"\n        resp = auth_client.get('/api/v1/alerts/')\n        assert resp.status_code == 200\n        data = resp.get_json()\n        assert data['status'] == 'success'\n        assert data['data']['alerts'] == []\n        assert data['data']['total'] == 0\n\n    def test_list_alerts_paginated(self, auth_client, app):\n        \"\"\"With 25 alerts, page=1&per_page=10 returns 10 items and total=25.\"\"\"\n        _seed_alerts(app, count=25)\n        resp = auth_client.get('/api/v1/alerts/?page=1&per_page=10')\n        assert resp.status_code == 200\n        data = resp.get_json()['data']\n        assert len(data['alerts']) == 10\n        assert data['total'] == 25\n        assert data['page'] == 1\n        assert data['per_page'] == 10\n\n    def test_list_alerts_filter_rule_id(self, auth_client, app):\n        \"\"\"Filter by rule_id returns only matching alerts.\"\"\"\n        _seed_alerts(app, count=5, rule_id=1)\n        _seed_alerts(app, count=3, rule_id=2)\n        resp = auth_client.get('/api/v1/alerts/?rule_id=1')\n        data = resp.get_json()['data']\n        assert data['total'] == 5\n        for alert in data['alerts']:\n            assert alert['rule_id'] == 1\n\n    def test_list_alerts_filter_severity(self, auth_client, app):\n        \"\"\"Filter by severity returns only matching alerts.\"\"\"\n        _seed_alerts(app, count=4, severity='info')\n        _seed_alerts(app, count=2, severity='error')\n        resp = auth_client.get('/api/v1/alerts/?severity=error')\n        data = resp.get_json()['data']\n        assert data['total'] == 2\n        for alert in data['alerts']:\n            assert alert['severity'] == 'error'\n\n    def test_list_alerts_requires_auth(self, client):\n        \"\"\"Unauthenticated request should be rejected.\"\"\"\n        resp = client.get('/api/v1/alerts/')\n        # Flask-Login redirects to login page (302) or returns 401\n        assert resp.status_code in (302, 401)\n\n\n# ---------------------------------------------------------------------------\n# Dry-run action_preview test\n# ---------------------------------------------------------------------------\n\nclass TestDryRunActionPreview:\n    \"\"\"Test that dry-run test endpoint returns action_preview.\"\"\"\n\n    def test_dry_run_action_preview(self, auth_client, app):\n        \"\"\"POST test with matching payload returns action_preview with rendered webhook payload.\"\"\"\n        rule_id = _seed_rule(app, action_type='webhook')\n        resp = auth_client.post(\n            f'/api/v1/rules/{rule_id}/test',\n            json={'topic': 'sensors/temp', 'payload': '{\"temp\": 42}'},\n            content_type='application/json',\n        )\n        assert resp.status_code == 200\n        data = resp.get_json()['data']\n        assert data['match'] is True\n        assert 'action_preview' in data\n\n        preview = data['action_preview']\n        assert len(preview) > 0\n        # Webhook preview should contain the URL and rendered payload\n        wp = preview[0]\n        assert wp['type'] == 'webhook'\n        assert 'url' in wp\n        assert 'payload' in wp\n\n    def test_dry_run_action_preview_publish(self, auth_client, app):\n        \"\"\"Publish action preview shows topic and payload.\"\"\"\n        rule_id = _seed_rule(app, action_type='publish')\n        resp = auth_client.post(\n            f'/api/v1/rules/{rule_id}/test',\n            json={'topic': 'sensors/temp', 'payload': '{\"temp\": 42}'},\n            content_type='application/json',\n        )\n        assert resp.status_code == 200\n        data = resp.get_json()['data']\n        if data['match']:\n            assert 'action_preview' in data\n            wp = data['action_preview']\n            assert len(wp) > 0\n            assert wp[0]['type'] == 'publish'\n\n    def test_dry_run_action_preview_log(self, auth_client, app):\n        \"\"\"Log action preview shows message.\"\"\"\n        rule_id = _seed_rule(app, action_type='log')\n        resp = auth_client.post(\n            f'/api/v1/rules/{rule_id}/test',\n            json={'topic': 'sensors/temp', 'payload': '{\"temp\": 42}'},\n            content_type='application/json',\n        )\n        assert resp.status_code == 200\n        data = resp.get_json()['data']\n        if data['match']:\n            assert 'action_preview' in data\n            wp = data['action_preview']\n            assert len(wp) > 0\n            assert wp[0]['type'] == 'log'\n\n    def test_dry_run_no_preview_when_no_match(self, auth_client, app):\n        \"\"\"No action_preview when rule doesn't match.\"\"\"\n        rule_id = _seed_rule(app, action_type='webhook')\n        resp = auth_client.post(\n            f'/api/v1/rules/{rule_id}/test',\n            json={'topic': 'sensors/temp', 'payload': '{\"temp\": 10}'},\n            content_type='application/json',\n        )\n        assert resp.status_code == 200\n        data = resp.get_json()['data']\n        assert data['match'] is False\n        # action_preview should be empty or absent when no match\n        preview = data.get('action_preview', [])\n        assert len(preview) == 0\n"
  },
  {
    "path": "tests/test_analytics.py",
    "content": "\"\"\"Tests for the per-topic analytics engine.\"\"\"\nimport time\nimport json\nimport pytest\n\nfrom mqttui.analytics import TopicAnalytics, get_analytics\n\n\nclass TestTopicAnalytics:\n    \"\"\"Unit tests for TopicAnalytics engine.\"\"\"\n\n    def setup_method(self):\n        self.analytics = TopicAnalytics()\n\n    def test_record_increments_message_count(self):\n        \"\"\"Test 1: record() increments message count for topic.\"\"\"\n        self.analytics.record(\"home/temp\", '{\"temperature\": 22.5}', time.time())\n        self.analytics.record(\"home/temp\", '{\"temperature\": 23.0}', time.time())\n        stats = self.analytics.get_topic_stats(\"home/temp\")\n        assert stats[\"message_count\"] == 2\n\n    def test_get_rate_per_minute(self):\n        \"\"\"Test 2: get_rate(topic, window=60) returns msgs in last 60s.\"\"\"\n        now = time.time()\n        for i in range(5):\n            self.analytics.record(\"sensor/a\", \"payload\", now - i)\n        rate = self.analytics.get_rate(\"sensor/a\", window=60)\n        assert rate == 5.0\n\n    def test_get_rate_per_hour(self):\n        \"\"\"Test 3: get_rate(topic, window=3600) returns msgs in last hour.\"\"\"\n        now = time.time()\n        for i in range(10):\n            self.analytics.record(\"sensor/b\", \"payload\", now - i * 60)\n        rate = self.analytics.get_rate(\"sensor/b\", window=3600)\n        assert rate == 10.0\n\n    def test_expired_timestamps_not_counted(self):\n        \"\"\"Test 4: Timestamps outside window are not counted.\"\"\"\n        now = time.time()\n        # Record 3 messages: 2 within window, 1 expired\n        self.analytics.record(\"sensor/c\", \"x\", now - 120)  # 2 min ago (outside 60s)\n        self.analytics.record(\"sensor/c\", \"x\", now - 30)   # 30s ago (inside 60s)\n        self.analytics.record(\"sensor/c\", \"x\", now - 10)   # 10s ago (inside 60s)\n        rate = self.analytics.get_rate(\"sensor/c\", window=60)\n        assert rate == 2.0\n\n    def test_numeric_payload_updates_histogram(self):\n        \"\"\"Test 5: Numeric JSON payload updates histogram stats.\"\"\"\n        self.analytics.record(\"sensor/d\", '{\"temperature\": 22.5}', time.time())\n        self.analytics.record(\"sensor/d\", '{\"temperature\": 25.0}', time.time())\n        self.analytics.record(\"sensor/d\", '{\"temperature\": 20.0}', time.time())\n        stats = self.analytics.get_topic_stats(\"sensor/d\")\n        hist = stats[\"histograms\"][\"temperature\"]\n        assert hist[\"min\"] == 20.0\n        assert hist[\"max\"] == 25.0\n        assert hist[\"count\"] == 3\n        assert abs(hist[\"sum\"] - 67.5) < 0.01\n\n    def test_non_numeric_payload_does_not_crash(self):\n        \"\"\"Test 6: Non-numeric/non-JSON payloads are handled gracefully.\"\"\"\n        self.analytics.record(\"sensor/e\", \"not json\", time.time())\n        self.analytics.record(\"sensor/e\", '{\"status\": \"online\"}', time.time())\n        self.analytics.record(\"sensor/e\", \"\", time.time())\n        stats = self.analytics.get_topic_stats(\"sensor/e\")\n        assert stats[\"message_count\"] == 3\n        assert stats[\"histograms\"] == {}\n\n    def test_get_topic_stats_returns_full_dict(self):\n        \"\"\"Test 7: get_topic_stats() returns dict with rate and histogram data.\"\"\"\n        now = time.time()\n        self.analytics.record(\"sensor/f\", '{\"humidity\": 60}', now)\n        stats = self.analytics.get_topic_stats(\"sensor/f\")\n        assert stats[\"topic\"] == \"sensor/f\"\n        assert \"rate_per_min\" in stats\n        assert \"rate_per_hour\" in stats\n        assert \"message_count\" in stats\n        assert \"histograms\" in stats\n        assert \"humidity\" in stats[\"histograms\"]\n\n    def test_get_all_stats_sorted_by_rate(self):\n        \"\"\"Test 8: get_all_stats() returns topics sorted by rate descending.\"\"\"\n        now = time.time()\n        # Topic A: 1 message\n        self.analytics.record(\"topic/a\", \"x\", now)\n        # Topic B: 3 messages (highest rate)\n        for _ in range(3):\n            self.analytics.record(\"topic/b\", \"x\", now)\n        # Topic C: 2 messages\n        for _ in range(2):\n            self.analytics.record(\"topic/c\", \"x\", now)\n\n        all_stats = self.analytics.get_all_stats(limit=20)\n        assert len(all_stats) == 3\n        assert all_stats[0][\"topic\"] == \"topic/b\"\n        assert all_stats[1][\"topic\"] == \"topic/c\"\n        assert all_stats[2][\"topic\"] == \"topic/a\"\n\n\nclass TestAnalyticsAPI:\n    \"\"\"Integration tests for analytics REST API endpoints.\"\"\"\n\n    def test_get_topics_returns_200(self, auth_client, app):\n        \"\"\"GET /api/v1/analytics/topics returns 200 with topics array.\"\"\"\n        # Seed some analytics data\n        from mqttui.analytics import get_analytics\n        analytics = get_analytics()\n        now = time.time()\n        analytics.record(\"test/topic\", '{\"value\": 42}', now)\n\n        resp = auth_client.get('/api/v1/analytics/topics')\n        assert resp.status_code == 200\n        data = resp.get_json()\n        assert data[\"status\"] == \"success\"\n        assert \"topics\" in data[\"data\"]\n        assert isinstance(data[\"data\"][\"topics\"], list)\n\n    def test_get_single_topic_returns_200(self, auth_client, app):\n        \"\"\"GET /api/v1/analytics/topics/<topic> returns 200 with stats.\"\"\"\n        from mqttui.analytics import get_analytics\n        analytics = get_analytics()\n        now = time.time()\n        analytics.record(\"home/sensor\", '{\"temp\": 22}', now)\n\n        resp = auth_client.get('/api/v1/analytics/topics/home/sensor')\n        assert resp.status_code == 200\n        data = resp.get_json()\n        assert data[\"status\"] == \"success\"\n        assert data[\"data\"][\"topic\"] == \"home/sensor\"\n\n    def test_get_unknown_topic_returns_404(self, auth_client):\n        \"\"\"GET /api/v1/analytics/topics/<unknown> returns 404.\"\"\"\n        resp = auth_client.get('/api/v1/analytics/topics/nonexistent/topic')\n        assert resp.status_code == 404\n        data = resp.get_json()\n        assert data[\"status\"] == \"error\"\n        assert data[\"error\"][\"code\"] == \"NOT_FOUND\"\n\n    def test_unauthenticated_request_rejected(self, client):\n        \"\"\"Unauthenticated request to analytics API is rejected.\"\"\"\n        resp = client.get('/api/v1/analytics/topics')\n        # Flask-Login redirects to login page (302) or returns 401\n        assert resp.status_code in (302, 401)\n\n\nclass TestAnalyticsPartial:\n    \"\"\"Tests for analytics partial template route.\"\"\"\n\n    def test_analytics_partial_returns_200(self, auth_client):\n        \"\"\"GET /partials/analytics returns 200 with analyticsWidget component.\"\"\"\n        resp = auth_client.get('/partials/analytics')\n        assert resp.status_code == 200\n        assert b'analyticsWidget' in resp.data\n\n    def test_analytics_partial_contains_rate_per_min(self, auth_client):\n        \"\"\"Analytics partial contains rate_per_min display binding.\"\"\"\n        resp = auth_client.get('/partials/analytics')\n        assert resp.status_code == 200\n        assert b'rate_per_min' in resp.data\n\n    def test_analytics_partial_requires_auth(self, client):\n        \"\"\"Unauthenticated request to analytics partial is rejected.\"\"\"\n        resp = client.get('/partials/analytics')\n        assert resp.status_code in (302, 401)\n"
  },
  {
    "path": "tests/test_api_v1.py",
    "content": "\"\"\"Tests for API v1 endpoints.\"\"\"\nimport json\n\n\ndef test_json_envelope_success(auth_client):\n    \"\"\"API responses follow success envelope format.\"\"\"\n    r = auth_client.get('/api/v1/version')\n    data = json.loads(r.data)\n    assert 'status' in data\n    assert 'data' in data\n    assert 'error' in data\n    assert data['status'] == 'success'\n    assert data['error'] is None\n\n\ndef test_json_envelope_error(auth_client):\n    \"\"\"API error responses follow error envelope format.\"\"\"\n    r = auth_client.post('/api/v1/publish', json={})\n    if r.status_code >= 400:\n        data = json.loads(r.data)\n        assert data['status'] == 'error'\n        assert data['error'] is not None\n        assert 'code' in data['error']\n        assert 'message' in data['error']\n\n\ndef test_messages_endpoint(auth_client):\n    \"\"\"GET /api/v1/messages returns envelope with messages array.\"\"\"\n    r = auth_client.get('/api/v1/messages')\n    assert r.status_code == 200\n    data = json.loads(r.data)\n    assert data['status'] == 'success'\n    assert 'messages' in data['data']\n\n\ndef test_topics_endpoint(auth_client):\n    \"\"\"GET /api/v1/topics returns envelope with topics array.\"\"\"\n    r = auth_client.get('/api/v1/topics')\n    assert r.status_code == 200\n    data = json.loads(r.data)\n    assert data['status'] == 'success'\n    assert 'topics' in data['data']\n\n\ndef test_version_public(client):\n    \"\"\"GET /api/v1/version is public (no auth required).\"\"\"\n    r = client.get('/api/v1/version')\n    assert r.status_code == 200\n    data = json.loads(r.data)\n    assert data['status'] == 'success'\n\n\ndef test_docs_endpoint(client):\n    \"\"\"GET /api/v1/docs returns Swagger UI.\"\"\"\n    r = client.get('/api/v1/docs')\n    assert r.status_code == 200\n    assert b'swagger-ui' in r.data\n\n\ndef test_openapi_spec(client):\n    \"\"\"GET /api/v1/openapi.json returns valid OpenAPI spec.\"\"\"\n    r = client.get('/api/v1/openapi.json')\n    assert r.status_code == 200\n    data = json.loads(r.data)\n    assert 'openapi' in data or 'swagger' in data\n    assert 'info' in data\n    assert 'paths' in data\n\n\ndef test_cors_headers(client):\n    \"\"\"API responses include CORS headers.\"\"\"\n    r = client.options('/api/v1/version', headers={\n        'Origin': 'http://example.com',\n        'Access-Control-Request-Method': 'GET'\n    })\n    # Flask-CORS should add Access-Control-Allow-Origin\n    assert r.status_code in (200, 204)\n\n\ndef test_publish_requires_auth(client):\n    \"\"\"POST /api/v1/publish without auth is rejected.\"\"\"\n    r = client.post('/api/v1/publish', json={'topic': 'test', 'message': 'hello'})\n    assert r.status_code in (302, 401)\n\n\ndef test_stats_requires_auth(client):\n    \"\"\"GET /api/v1/stats without auth is rejected.\"\"\"\n    r = client.get('/api/v1/stats')\n    assert r.status_code in (302, 401)\n\n\ndef test_messages_requires_auth(client):\n    \"\"\"GET /api/v1/messages without auth is rejected.\"\"\"\n    r = client.get('/api/v1/messages')\n    assert r.status_code in (302, 401)\n\n\ndef test_rate_limit_publish(auth_client, app):\n    \"\"\"POST /api/v1/publish is rate limited to 30/minute with Retry-After header.\"\"\"\n    from unittest.mock import patch\n    with patch('mqttui.mqtt_client.publish'):\n        hit_429 = False\n        for i in range(35):\n            r = auth_client.post('/api/v1/publish', json={'topic': 'test/rate', 'message': f'msg-{i}'})\n            if r.status_code == 429:\n                hit_429 = True\n                assert 'Retry-After' in r.headers\n                data = json.loads(r.data)\n                assert data['status'] == 'error'\n                assert data['error']['code'] == 'RATE_LIMIT_EXCEEDED'\n                break\n        # Rate limiter should have kicked in after 30 requests\n        assert hit_429, \"Expected 429 after exceeding rate limit\"\n"
  },
  {
    "path": "tests/test_app_factory.py",
    "content": "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(app):\n    blueprint_names = list(app.blueprints.keys())\n    assert 'main' in blueprint_names\n    assert 'api' in blueprint_names\n    assert 'debug' in blueprint_names\n\n\ndef test_create_app_custom_config(tmp_path):\n    from unittest.mock import patch\n    with patch('mqttui.mqtt_client.init_mqtt'):\n        from mqttui.app import create_app\n        app = create_app({\n            'SECRET_KEY': 'custom-key',\n            'DB_ENABLED': False,\n            'MQTT_BROKER': '127.0.0.1',\n            'MQTT_PORT': 1883,\n            'MQTT_VERSION': '3.1.1',\n            'MQTT_TOPICS': '#',\n            'MQTT_USERNAME': None,\n            'MQTT_PASSWORD': None,\n            'MQTT_KEEPALIVE': 60,\n        })\n    assert app.config['SECRET_KEY'] == 'custom-key'\n\n\ndef test_socketio_async_mode(app):\n    from mqttui.extensions import socketio\n    assert socketio.async_mode == 'gevent'\n"
  },
  {
    "path": "tests/test_auth.py",
    "content": "\"\"\"Tests for authentication flow.\"\"\"\n\n\ndef test_login_page_loads(client):\n    \"\"\"GET /login returns 200 with login form.\"\"\"\n    r = client.get('/login')\n    assert r.status_code == 200\n    assert b'username' in r.data\n    assert b'password' in r.data\n\n\ndef test_unauthenticated_redirect(client):\n    \"\"\"Unauthenticated GET / redirects to /login.\"\"\"\n    r = client.get('/')\n    assert r.status_code == 302\n    assert '/login' in r.headers['Location']\n\n\ndef test_login_valid_credentials(client, app):\n    \"\"\"POST /login with valid creds redirects to /.\"\"\"\n    with app.app_context():\n        from mqttui.models import User\n        from mqttui.extensions import sa\n        user = User.query.filter_by(username='admin').first()\n        if not user:\n            user = User(username='admin')\n            user.set_password('testpass')\n            sa.session.add(user)\n            sa.session.commit()\n    r = client.post('/login', data={'username': 'admin', 'password': 'admin'}, follow_redirects=False)\n    assert r.status_code == 302\n    assert r.headers['Location'] in ('/', 'http://localhost/')\n\n\ndef test_login_invalid_credentials(client, app):\n    \"\"\"POST /login with bad creds returns login page with error.\"\"\"\n    r = client.post('/login', data={'username': 'admin', 'password': 'wrong'})\n    assert r.status_code == 200\n    assert b'Invalid' in r.data or b'invalid' in r.data\n\n\ndef test_logout(auth_client):\n    \"\"\"GET /logout clears session and redirects.\"\"\"\n    r = auth_client.get('/logout', follow_redirects=False)\n    assert r.status_code == 302\n    assert '/login' in r.headers['Location']\n\n\ndef test_api_token_auth(client, api_token):\n    \"\"\"X-API-Key header authenticates API requests.\"\"\"\n    r = client.get('/api/v1/stats', headers={'X-API-Key': api_token})\n    assert r.status_code == 200\n    import json\n    data = json.loads(r.data)\n    assert data['status'] == 'success'\n\n\ndef test_api_token_invalid(client):\n    \"\"\"Invalid X-API-Key returns 401/redirect.\"\"\"\n    r = client.get('/api/v1/stats', headers={'X-API-Key': 'bad-token'})\n    # Should redirect to login or return 401\n    assert r.status_code in (302, 401)\n\n\ndef test_secret_key_guard():\n    \"\"\"App refuses to start with insecure SECRET_KEY in production.\"\"\"\n    import os\n    old_env = os.environ.get('FLASK_ENV')\n    os.environ['FLASK_ENV'] = 'production'\n    try:\n        from unittest.mock import patch\n        import pytest\n        with patch('mqttui.mqtt_client.init_mqtt'):\n            from mqttui.app import create_app\n            with pytest.raises(RuntimeError, match=\"SECRET_KEY\"):\n                create_app({'SECRET_KEY': 'dev', 'DB_ENABLED': False, 'SQLALCHEMY_DATABASE_URI': 'sqlite://'})\n    finally:\n        if old_env is None:\n            os.environ.pop('FLASK_ENV', None)\n        else:\n            os.environ['FLASK_ENV'] = old_env\n\n\ndef test_token_get(auth_client, app):\n    \"\"\"GET /api/v1/auth/token returns current token.\"\"\"\n    r = auth_client.get('/api/v1/auth/token')\n    assert r.status_code == 200\n    import json\n    data = json.loads(r.data)\n    assert data['status'] == 'success'\n    assert 'api_token' in data['data']\n\n\ndef test_token_regenerate(auth_client, app):\n    \"\"\"POST /api/v1/auth/token regenerates token.\"\"\"\n    r = auth_client.post('/api/v1/auth/token')\n    assert r.status_code == 200\n    import json\n    data = json.loads(r.data)\n    assert data['status'] == 'success'\n    assert data['data']['api_token'] is not None\n\n\ndef test_token_revoke(auth_client, app):\n    \"\"\"DELETE /api/v1/auth/token revokes token.\"\"\"\n    r = auth_client.delete('/api/v1/auth/token')\n    assert r.status_code == 200\n    import json\n    data = json.loads(r.data)\n    assert data['data']['message'] == 'API token revoked'\n"
  },
  {
    "path": "tests/test_cooldown.py",
    "content": "\"\"\"Tests for CooldownTracker and webhook cooldown integration.\"\"\"\nimport time\nfrom unittest.mock import patch, MagicMock\n\nimport pytest\n\nfrom mqttui.rules.cooldown import CooldownTracker, cooldown_tracker\n\n\n# ---------------------------------------------------------------------------\n# CooldownTracker unit tests\n# ---------------------------------------------------------------------------\n\nclass TestCooldownTracker:\n    \"\"\"Tests for the per-rule in-memory cooldown tracker.\"\"\"\n\n    def test_cooldown_allows_first_alert(self):\n        \"\"\"First call for a rule_id should always be allowed.\"\"\"\n        tracker = CooldownTracker()\n        assert tracker.check(rule_id=1) is True\n\n    def test_cooldown_blocks_during_window(self):\n        \"\"\"Subsequent calls within the cooldown window should be blocked.\"\"\"\n        tracker = CooldownTracker()\n        assert tracker.check(rule_id=1) is True\n        assert tracker.check(rule_id=1) is False\n\n    def test_cooldown_allows_after_expiry(self):\n        \"\"\"After cooldown expires, alert should be allowed again.\"\"\"\n        tracker = CooldownTracker(default_seconds=1)\n        assert tracker.check(rule_id=1) is True\n\n        # Mock time to advance past cooldown window\n        with patch('mqttui.rules.cooldown.time') as mock_time:\n            mock_time.monotonic.return_value = time.monotonic() + 2\n            assert tracker.check(rule_id=1) is True\n\n    def test_cooldown_different_rules_independent(self):\n        \"\"\"Cooldown for one rule should not affect another.\"\"\"\n        tracker = CooldownTracker()\n        assert tracker.check(rule_id=1) is True\n        assert tracker.check(rule_id=2) is True\n        # rule_id=1 still blocked\n        assert tracker.check(rule_id=1) is False\n        # rule_id=2 also blocked\n        assert tracker.check(rule_id=2) is False\n\n    def test_cooldown_custom_window(self):\n        \"\"\"CooldownTracker should respect custom default_seconds.\"\"\"\n        tracker = CooldownTracker(default_seconds=60)\n        assert tracker.check(rule_id=1) is True\n\n        # Even 50 seconds later, still blocked (60s window)\n        with patch('mqttui.rules.cooldown.time') as mock_time:\n            mock_time.monotonic.return_value = time.monotonic() + 50\n            assert tracker.check(rule_id=1) is False\n\n        # After 61 seconds, allowed again\n        with patch('mqttui.rules.cooldown.time') as mock_time:\n            mock_time.monotonic.return_value = time.monotonic() + 61\n            assert tracker.check(rule_id=1) is True\n\n    def test_cooldown_suppressed_count(self):\n        \"\"\"Suppressed count should track how many alerts were blocked.\"\"\"\n        tracker = CooldownTracker()\n        assert tracker.check(rule_id=1) is True\n        assert tracker.get_suppressed_count(rule_id=1) == 0\n\n        # Suppress 3 alerts\n        tracker.check(rule_id=1)\n        tracker.check(rule_id=1)\n        tracker.check(rule_id=1)\n        assert tracker.get_suppressed_count(rule_id=1) == 3\n\n    def test_cooldown_suppressed_count_resets_on_new_fire(self):\n        \"\"\"Suppressed count resets when a new alert is allowed.\"\"\"\n        tracker = CooldownTracker(default_seconds=1)\n        assert tracker.check(rule_id=1) is True\n        tracker.check(rule_id=1)  # suppressed\n        tracker.check(rule_id=1)  # suppressed\n        assert tracker.get_suppressed_count(rule_id=1) == 2\n\n        # After cooldown expires, count resets\n        with patch('mqttui.rules.cooldown.time') as mock_time:\n            mock_time.monotonic.return_value = time.monotonic() + 2\n            assert tracker.check(rule_id=1) is True\n        assert tracker.get_suppressed_count(rule_id=1) == 0\n\n\n# ---------------------------------------------------------------------------\n# Webhook cooldown integration test\n# ---------------------------------------------------------------------------\n\nclass TestWebhookCooldownIntegration:\n    \"\"\"Test that _execute_webhook respects cooldown.\"\"\"\n\n    def test_webhook_skipped_during_cooldown(self, app):\n        \"\"\"Webhook should return cooldown detail when blocked.\"\"\"\n        from mqttui.rules.cooldown import cooldown_tracker as ct\n        from mqttui.rules.actions import _execute_webhook\n\n        with app.app_context():\n            context = {\n                'rule_id': 99,\n                'rule_name': 'test-rule',\n                'topic': 'sensors/temp',\n                'payload': '{\"temp\": 42}',\n            }\n            action = {'type': 'webhook', 'url': 'https://example.com/hook'}\n\n            # Reset the module-level tracker for clean test\n            ct._last_fired.clear()\n            ct._suppressed.clear()\n\n            # First call -- allowed (submitted to thread pool)\n            result1 = _execute_webhook(action, context)\n            assert result1['success'] is True\n            assert 'cooldown' not in result1['detail'].lower()\n\n            # Second call -- blocked by cooldown\n            result2 = _execute_webhook(action, context)\n            assert result2['success'] is True\n            assert 'cooldown' in result2['detail'].lower() or 'suppressed' in result2['detail'].lower()\n"
  },
  {
    "path": "tests/test_database.py",
    "content": "from datetime import datetime\n\n\ndef test_wal_mode(test_db):\n    conn = test_db.get_connection()\n    mode = conn.execute('PRAGMA journal_mode').fetchone()[0]\n    assert mode == 'wal'\n\n\ndef test_busy_timeout(test_db):\n    conn = test_db.get_connection()\n    timeout = conn.execute('PRAGMA busy_timeout').fetchone()[0]\n    assert timeout == 5000\n\n\ndef test_store_and_retrieve(test_db):\n    now = datetime.now()\n    test_db.store_message(\n        topic='test/topic',\n        payload='hello world',\n        timestamp=now,\n        qos=0,\n        retain=False\n    )\n    messages = test_db.get_messages(limit=10)\n    assert len(messages) >= 1\n    assert messages[0]['topic'] == 'test/topic'\n    assert messages[0]['payload'] == 'hello world'\n\n\ndef test_get_topics(test_db):\n    test_db.store_message(topic='sensor/temp', payload='22.5', timestamp=datetime.now())\n    test_db.store_message(topic='sensor/humidity', payload='45', timestamp=datetime.now())\n    topics = test_db.get_topics()\n    topic_names = [t['topic'] for t in topics]\n    assert 'sensor/temp' in topic_names\n    assert 'sensor/humidity' in topic_names\n\n\ndef test_message_count(test_db):\n    for i in range(5):\n        test_db.store_message(topic='count/test', payload=str(i), timestamp=datetime.now())\n    count = test_db.get_message_count(topic_filter='count/test')\n    assert count == 5\n"
  },
  {
    "path": "tests/test_frontend.py",
    "content": "\"\"\"Tests for Phase 5 frontend: partials, Alpine.js, htmx, batch emitter.\"\"\"\nimport pytest\nfrom unittest.mock import MagicMock, patch\n\n\nclass TestPartialRoutes:\n    \"\"\"Test htmx partial template routes.\"\"\"\n\n    def test_rules_list_partial_returns_html(self, auth_client):\n        resp = auth_client.get('/partials/rules')\n        assert resp.status_code == 200\n        assert b'rules-list' in resp.data\n\n    def test_alerts_list_partial_returns_html(self, auth_client):\n        resp = auth_client.get('/partials/alerts')\n        assert resp.status_code == 200\n        assert b'alerts-list' in resp.data\n\n    def test_rule_form_partial_returns_html(self, auth_client):\n        resp = auth_client.get('/partials/rules/form')\n        assert resp.status_code == 200\n        assert b'name' in resp.data\n\n    def test_partials_require_auth(self, client):\n        \"\"\"Unauthenticated requests should redirect to login.\"\"\"\n        for url in ['/partials/rules', '/partials/alerts', '/partials/rules/form']:\n            resp = client.get(url)\n            assert resp.status_code in (302, 401), f\"{url} should require auth\"\n\n\nclass TestIndexTemplate:\n    \"\"\"Test main index template includes Alpine.js, htmx, and tabs.\"\"\"\n\n    def test_index_contains_alpine(self, auth_client):\n        resp = auth_client.get('/')\n        assert resp.status_code == 200\n        assert b'alpinejs' in resp.data or b'alpine' in resp.data.lower()\n\n    def test_index_contains_htmx(self, auth_client):\n        resp = auth_client.get('/')\n        assert b'htmx' in resp.data\n\n    def test_index_contains_tabs(self, auth_client):\n        resp = auth_client.get('/')\n        html = resp.data.decode()\n        assert 'Messages' in html\n        assert 'Rules' in html\n        assert 'Alerts' in html\n\n    def test_index_contains_alpine_xdata(self, auth_client):\n        resp = auth_client.get('/')\n        assert b'x-data' in resp.data\n\n\nclass TestBatchEmitter:\n    \"\"\"Test server-side Socket.IO batch emitter.\"\"\"\n\n    def test_enqueue_adds_to_buffer(self):\n        from mqttui.socketio_batch import BatchEmitter\n        mock_sio = MagicMock()\n        emitter = BatchEmitter(mock_sio, interval_ms=100)\n        emitter.enqueue({'topic': 'test', 'payload': 'hello'})\n        assert len(emitter._buffer) == 1\n\n    def test_flush_emits_batch_and_clears(self):\n        from mqttui.socketio_batch import BatchEmitter\n        mock_sio = MagicMock()\n        emitter = BatchEmitter(mock_sio, interval_ms=100)\n        emitter.enqueue({'topic': 't1', 'payload': 'p1'})\n        emitter.enqueue({'topic': 't2', 'payload': 'p2'})\n        emitter._flush()\n        mock_sio.emit.assert_called_once_with('mqtt_messages_batch', [\n            {'topic': 't1', 'payload': 'p1'},\n            {'topic': 't2', 'payload': 'p2'},\n        ])\n        assert len(emitter._buffer) == 0\n\n    def test_flush_noop_when_empty(self):\n        from mqttui.socketio_batch import BatchEmitter\n        mock_sio = MagicMock()\n        emitter = BatchEmitter(mock_sio, interval_ms=100)\n        emitter._flush()\n        mock_sio.emit.assert_not_called()\n"
  },
  {
    "path": "tests/test_observability.py",
    "content": "\"\"\"Tests for structured logging (structlog) and Prometheus metrics endpoint.\"\"\"\nimport pytest\nfrom unittest.mock import patch\n\n\nclass TestStructlogConfiguration:\n    \"\"\"Tests for mqttui.logging_config.configure_logging.\"\"\"\n\n    def test_configure_logging_debug_uses_console_renderer(self):\n        \"\"\"configure_logging(debug=True) sets up colored console renderer.\"\"\"\n        import structlog\n        from mqttui.logging_config import configure_logging\n\n        configure_logging(debug=True)\n\n        # structlog should be configured -- get a logger and verify it works\n        logger = structlog.get_logger(\"test\")\n        assert logger is not None\n        # In debug mode, the formatter should use ConsoleRenderer (not JSON)\n        import logging\n        root = logging.getLogger()\n        assert len(root.handlers) > 0\n        formatter = root.handlers[0].formatter\n        # ProcessorFormatter stores processors list\n        assert hasattr(formatter, 'processors')\n        processor_names = [type(p).__name__ for p in formatter.processors]\n        assert 'ConsoleRenderer' in processor_names\n\n    def test_configure_logging_production_uses_json_renderer(self):\n        \"\"\"configure_logging(debug=False) sets up JSON renderer.\"\"\"\n        import structlog\n        from mqttui.logging_config import configure_logging\n\n        configure_logging(debug=False)\n\n        import logging\n        root = logging.getLogger()\n        assert len(root.handlers) > 0\n        formatter = root.handlers[0].formatter\n        assert hasattr(formatter, 'processors')\n        processor_names = [type(p).__name__ for p in formatter.processors]\n        assert 'JSONRenderer' in processor_names\n\n    def test_structlog_get_logger_returns_bound_logger(self):\n        \"\"\"structlog.get_logger() returns a logger with .info() and .error().\"\"\"\n        import structlog\n        from mqttui.logging_config import configure_logging\n\n        configure_logging(debug=True)\n        logger = structlog.get_logger(\"test.bound\")\n\n        assert callable(getattr(logger, 'info', None))\n        assert callable(getattr(logger, 'error', None))\n\n\nclass TestPrometheusMetricsEndpoint:\n    \"\"\"Tests for the /metrics Prometheus scrape endpoint.\"\"\"\n\n    def test_metrics_returns_200(self, client):\n        \"\"\"GET /metrics returns 200 with text/plain content type.\"\"\"\n        response = client.get('/metrics')\n        assert response.status_code == 200\n        assert 'text/plain' in response.content_type\n\n    def test_metrics_contains_mqtt_messages_total(self, client):\n        \"\"\"The /metrics response contains mqtt_messages_total counter.\"\"\"\n        response = client.get('/metrics')\n        assert b'mqtt_messages_total' in response.data\n\n    def test_metrics_contains_mqtt_connected_gauge(self, client):\n        \"\"\"The /metrics response contains mqtt_connected gauge.\"\"\"\n        response = client.get('/metrics')\n        assert b'mqtt_connected' in response.data\n\n    def test_metrics_contains_rule_firings_total(self, client):\n        \"\"\"The /metrics response contains rule_firings_total counter.\"\"\"\n        response = client.get('/metrics')\n        assert b'rule_firings_total' in response.data\n\n    def test_metrics_no_auth_required(self, client):\n        \"\"\"The /metrics endpoint does NOT require authentication.\"\"\"\n        # client is unauthenticated -- should still get 200, not 302/401\n        response = client.get('/metrics')\n        assert response.status_code == 200\n"
  },
  {
    "path": "tests/test_plugin_registry.py",
    "content": "\"\"\"Tests for plugin hookspec, model, and registry.\"\"\"\nimport pytest\nfrom unittest.mock import patch, MagicMock\n\n\n@pytest.fixture(autouse=True)\ndef _ensure_plugin_table(app):\n    \"\"\"Ensure plugin_configs table exists (before Task 2 wires it into create_app).\"\"\"\n    with app.app_context():\n        from mqttui.plugins.models import PluginConfig  # noqa: F401\n        from mqttui.extensions import sa\n        sa.create_all()\n\n\nclass TestMQTTUIPluginHookspec:\n    \"\"\"Test that MQTTUIPlugin defines the expected hookspec methods.\"\"\"\n\n    def test_has_on_message_hookspec(self):\n        from mqttui.plugins.hookspec import MQTTUIPlugin\n        assert hasattr(MQTTUIPlugin, 'on_message')\n\n    def test_has_on_connect_hookspec(self):\n        from mqttui.plugins.hookspec import MQTTUIPlugin\n        assert hasattr(MQTTUIPlugin, 'on_connect')\n\n    def test_has_on_rule_trigger_hookspec(self):\n        from mqttui.plugins.hookspec import MQTTUIPlugin\n        assert hasattr(MQTTUIPlugin, 'on_rule_trigger')\n\n    def test_hookspec_markers_applied(self):\n        from mqttui.plugins.hookspec import MQTTUIPlugin\n        # pluggy marks hookspec methods with a special attribute\n        assert hasattr(MQTTUIPlugin.on_message, 'mqttui_spec')\n        assert hasattr(MQTTUIPlugin.on_connect, 'mqttui_spec')\n        assert hasattr(MQTTUIPlugin.on_rule_trigger, 'mqttui_spec')\n\n    def test_hookimpl_marker_exported(self):\n        from mqttui.plugins.hookspec import hookimpl\n        assert hookimpl is not None\n\n\nclass TestPluginConfigModel:\n    \"\"\"Test PluginConfig SQLAlchemy model CRUD.\"\"\"\n\n    def test_create_plugin_config(self, app):\n        with app.app_context():\n            from mqttui.plugins.models import PluginConfig\n            from mqttui.extensions import sa\n\n            pc = PluginConfig(\n                name='test-plugin',\n                entry_point='test_plugin:TestPlugin',\n                enabled=False,\n                config_json='{\"key\": \"value\"}',\n            )\n            sa.session.add(pc)\n            sa.session.commit()\n\n            found = PluginConfig.query.filter_by(name='test-plugin').first()\n            assert found is not None\n            assert found.name == 'test-plugin'\n            assert found.entry_point == 'test_plugin:TestPlugin'\n            assert found.enabled is False\n            assert found.config_json == '{\"key\": \"value\"}'\n\n    def test_update_enabled_state(self, app):\n        with app.app_context():\n            from mqttui.plugins.models import PluginConfig\n            from mqttui.extensions import sa\n\n            pc = PluginConfig(\n                name='toggle-plugin',\n                entry_point='toggle:Plugin',\n                enabled=False,\n            )\n            sa.session.add(pc)\n            sa.session.commit()\n\n            pc.enabled = True\n            sa.session.commit()\n\n            found = PluginConfig.query.filter_by(name='toggle-plugin').first()\n            assert found.enabled is True\n\n    def test_to_dict(self, app):\n        with app.app_context():\n            from mqttui.plugins.models import PluginConfig\n            from mqttui.extensions import sa\n\n            pc = PluginConfig(\n                name='dict-plugin',\n                entry_point='dict:Plugin',\n                enabled=True,\n                config_json='{}',\n                version='1.0.0',\n                description='A test plugin',\n            )\n            sa.session.add(pc)\n            sa.session.commit()\n\n            d = pc.to_dict()\n            assert d['name'] == 'dict-plugin'\n            assert d['entry_point'] == 'dict:Plugin'\n            assert d['enabled'] is True\n            assert d['version'] == '1.0.0'\n            assert d['description'] == 'A test plugin'\n            assert 'installed_at' in d\n\n\nclass TestPluginRegistry:\n    \"\"\"Test PluginRegistry discover, list, enable, disable.\"\"\"\n\n    def test_discover_with_no_entry_points(self, app):\n        with app.app_context():\n            from mqttui.plugins.registry import PluginRegistry\n            registry = PluginRegistry(app)\n\n            with patch('mqttui.plugins.registry.entry_points', return_value=[]):\n                result = registry.discover()\n            assert result == []\n\n    def test_discover_with_mock_entry_point(self, app):\n        with app.app_context():\n            from mqttui.plugins.registry import PluginRegistry\n            from mqttui.extensions import sa\n\n            registry = PluginRegistry(app)\n\n            mock_ep = MagicMock()\n            mock_ep.name = 'sample-plugin'\n            mock_ep.value = 'sample_plugin:SamplePlugin'\n            mock_ep.dist = MagicMock()\n            mock_ep.dist.version = '0.1.0'\n            mock_ep.dist.metadata = {'Summary': 'A sample plugin'}\n\n            with patch('mqttui.plugins.registry.entry_points', return_value=[mock_ep]):\n                result = registry.discover()\n\n            assert len(result) == 1\n            assert result[0]['name'] == 'sample-plugin'\n\n    def test_list_plugins(self, app):\n        with app.app_context():\n            from mqttui.plugins.models import PluginConfig\n            from mqttui.plugins.registry import PluginRegistry\n            from mqttui.extensions import sa\n\n            pc = PluginConfig(\n                name='list-plugin',\n                entry_point='list:Plugin',\n                enabled=True,\n            )\n            sa.session.add(pc)\n            sa.session.commit()\n\n            registry = PluginRegistry(app)\n            plugins = registry.list_plugins()\n            names = [p['name'] for p in plugins]\n            assert 'list-plugin' in names\n\n    def test_enable_plugin(self, app):\n        with app.app_context():\n            from mqttui.plugins.models import PluginConfig\n            from mqttui.plugins.registry import PluginRegistry\n            from mqttui.extensions import sa\n\n            pc = PluginConfig(\n                name='enable-me',\n                entry_point='enable:Plugin',\n                enabled=False,\n            )\n            sa.session.add(pc)\n            sa.session.commit()\n\n            registry = PluginRegistry(app)\n            result = registry.enable('enable-me')\n            assert result is True\n\n            found = PluginConfig.query.filter_by(name='enable-me').first()\n            assert found.enabled is True\n\n    def test_disable_plugin(self, app):\n        with app.app_context():\n            from mqttui.plugins.models import PluginConfig\n            from mqttui.plugins.registry import PluginRegistry\n            from mqttui.extensions import sa\n\n            pc = PluginConfig(\n                name='disable-me',\n                entry_point='disable:Plugin',\n                enabled=True,\n            )\n            sa.session.add(pc)\n            sa.session.commit()\n\n            registry = PluginRegistry(app)\n            result = registry.disable('disable-me')\n            assert result is True\n\n            found = PluginConfig.query.filter_by(name='disable-me').first()\n            assert found.enabled is False\n\n    def test_enable_nonexistent_returns_false(self, app):\n        with app.app_context():\n            from mqttui.plugins.registry import PluginRegistry\n            registry = PluginRegistry(app)\n            assert registry.enable('no-such-plugin') is False\n\n    def test_disable_nonexistent_returns_false(self, app):\n        with app.app_context():\n            from mqttui.plugins.registry import PluginRegistry\n            registry = PluginRegistry(app)\n            assert registry.disable('no-such-plugin') is False\n\n    def test_get_enabled_plugins(self, app):\n        with app.app_context():\n            from mqttui.plugins.models import PluginConfig\n            from mqttui.plugins.registry import PluginRegistry\n            from mqttui.extensions import sa\n\n            sa.session.add(PluginConfig(name='on-plugin', entry_point='on:P', enabled=True))\n            sa.session.add(PluginConfig(name='off-plugin', entry_point='off:P', enabled=False))\n            sa.session.commit()\n\n            registry = PluginRegistry(app)\n            enabled = registry.get_enabled_plugins()\n            names = [p.name for p in enabled]\n            assert 'on-plugin' in names\n            assert 'off-plugin' not in names\n"
  },
  {
    "path": "tests/test_plugin_runner.py",
    "content": "\"\"\"Tests for PluginRunner subprocess isolation and JSON protocol.\"\"\"\nimport json\nimport subprocess\nfrom unittest.mock import patch, MagicMock, PropertyMock\n\nimport pytest\n\nfrom mqttui.plugins.runner import PluginRunner, PLUGIN_TIMEOUT\n\n\n@pytest.fixture\ndef runner():\n    \"\"\"Create a PluginRunner instance without app context.\"\"\"\n    return PluginRunner(app=None)\n\n\n@pytest.fixture\ndef mock_plugin():\n    \"\"\"Create a mock PluginConfig.\"\"\"\n    plugin = MagicMock()\n    plugin.name = \"test-plugin\"\n    plugin.entry_point = \"test_plugin.main\"\n    plugin.enabled = True\n    plugin.config_json = \"{}\"\n    return plugin\n\n\nclass TestCallPlugin:\n    \"\"\"Tests for call_plugin subprocess execution.\"\"\"\n\n    def test_spawns_subprocess_with_correct_args(self, runner, mock_plugin):\n        \"\"\"call_plugin spawns subprocess with python -m entry_point.\"\"\"\n        with patch(\"mqttui.plugins.runner.subprocess.Popen\") as mock_popen:\n            mock_proc = MagicMock()\n            mock_proc.communicate.return_value = ('{\"actions\": []}', \"\")\n            mock_proc.returncode = 0\n            mock_popen.return_value = mock_proc\n\n            runner.call_plugin(mock_plugin, \"on_message\", {\"topic\": \"t\", \"payload\": \"p\"})\n\n            mock_popen.assert_called_once()\n            call_args = mock_popen.call_args\n            import sys\n            assert call_args[0][0] == [sys.executable, \"-m\", \"test_plugin.main\"]\n\n    def test_sends_json_on_stdin(self, runner, mock_plugin):\n        \"\"\"Subprocess receives JSON with event type and data on stdin.\"\"\"\n        with patch(\"mqttui.plugins.runner.subprocess.Popen\") as mock_popen:\n            mock_proc = MagicMock()\n            mock_proc.communicate.return_value = ('{\"actions\": []}', \"\")\n            mock_proc.returncode = 0\n            mock_popen.return_value = mock_proc\n\n            data = {\"topic\": \"home/temp\", \"payload\": \"22.5\"}\n            runner.call_plugin(mock_plugin, \"on_message\", data)\n\n            input_json = mock_proc.communicate.call_args[1].get(\"input\") or mock_proc.communicate.call_args[0][0]\n            parsed = json.loads(input_json)\n            assert parsed[\"event\"] == \"on_message\"\n            assert parsed[\"data\"] == data\n\n    def test_parses_valid_json_response(self, runner, mock_plugin):\n        \"\"\"Subprocess response with actions list is parsed and returned.\"\"\"\n        actions = [{\"type\": \"publish\", \"topic\": \"out/t\", \"payload\": \"hello\"}]\n        with patch(\"mqttui.plugins.runner.subprocess.Popen\") as mock_popen:\n            mock_proc = MagicMock()\n            mock_proc.communicate.return_value = (json.dumps({\"actions\": actions}), \"\")\n            mock_proc.returncode = 0\n            mock_popen.return_value = mock_proc\n\n            result = runner.call_plugin(mock_plugin, \"on_message\", {\"topic\": \"t\", \"payload\": \"p\"})\n            assert result == actions\n\n    def test_timeout_kills_subprocess(self, runner, mock_plugin):\n        \"\"\"Subprocess exceeding timeout is killed and returns empty actions.\"\"\"\n        with patch(\"mqttui.plugins.runner.subprocess.Popen\") as mock_popen:\n            mock_proc = MagicMock()\n            mock_proc.communicate.side_effect = subprocess.TimeoutExpired(cmd=\"test\", timeout=5)\n            mock_popen.return_value = mock_proc\n\n            result = runner.call_plugin(mock_plugin, \"on_message\", {\"topic\": \"t\", \"payload\": \"p\"})\n\n            mock_proc.kill.assert_called_once()\n            assert result == []\n\n    def test_invalid_json_returns_empty(self, runner, mock_plugin):\n        \"\"\"Invalid JSON from subprocess is handled gracefully.\"\"\"\n        with patch(\"mqttui.plugins.runner.subprocess.Popen\") as mock_popen:\n            mock_proc = MagicMock()\n            mock_proc.communicate.return_value = (\"not valid json!\", \"\")\n            mock_proc.returncode = 0\n            mock_popen.return_value = mock_proc\n\n            result = runner.call_plugin(mock_plugin, \"on_message\", {\"topic\": \"t\", \"payload\": \"p\"})\n            assert result == []\n\n    def test_empty_env_for_security_isolation(self, runner, mock_plugin):\n        \"\"\"Subprocess is spawned with empty env dict for isolation.\"\"\"\n        with patch(\"mqttui.plugins.runner.subprocess.Popen\") as mock_popen:\n            mock_proc = MagicMock()\n            mock_proc.communicate.return_value = ('{\"actions\": []}', \"\")\n            mock_proc.returncode = 0\n            mock_popen.return_value = mock_proc\n\n            runner.call_plugin(mock_plugin, \"on_message\", {\"topic\": \"t\", \"payload\": \"p\"})\n\n            call_kwargs = mock_popen.call_args[1]\n            assert call_kwargs[\"env\"] == {}\n\n\nclass TestDispatchMessage:\n    \"\"\"Tests for dispatch_message calling all enabled plugins.\"\"\"\n\n    def test_calls_all_enabled_plugins(self, runner):\n        \"\"\"dispatch_message calls call_plugin for each enabled plugin.\"\"\"\n        plugin1 = MagicMock(name=\"p1\", entry_point=\"p1.main\", enabled=True)\n        plugin2 = MagicMock(name=\"p2\", entry_point=\"p2.main\", enabled=True)\n\n        mock_registry = MagicMock()\n        mock_registry.get_enabled_plugins.return_value = [plugin1, plugin2]\n\n        with patch(\"mqttui.plugins.runner.get_plugin_registry\", return_value=mock_registry):\n            with patch.object(runner, \"call_plugin\", return_value=[]) as mock_call:\n                runner.dispatch_message(\"test/topic\", \"payload\")\n\n                assert mock_call.call_count == 2\n                mock_call.assert_any_call(plugin1, \"on_message\", {\"topic\": \"test/topic\", \"payload\": \"payload\"})\n                mock_call.assert_any_call(plugin2, \"on_message\", {\"topic\": \"test/topic\", \"payload\": \"payload\"})\n\n    def test_collects_all_actions(self, runner):\n        \"\"\"dispatch_message aggregates actions from all plugins.\"\"\"\n        plugin1 = MagicMock()\n        plugin2 = MagicMock()\n\n        mock_registry = MagicMock()\n        mock_registry.get_enabled_plugins.return_value = [plugin1, plugin2]\n\n        actions1 = [{\"type\": \"publish\", \"topic\": \"a\", \"payload\": \"1\"}]\n        actions2 = [{\"type\": \"log\", \"message\": \"hello\"}]\n\n        with patch(\"mqttui.plugins.runner.get_plugin_registry\", return_value=mock_registry):\n            with patch.object(runner, \"call_plugin\", side_effect=[actions1, actions2]):\n                result = runner.dispatch_message(\"t\", \"p\")\n\n                assert len(result) == 2\n                assert actions1[0] in result\n                assert actions2[0] in result\n\n\nclass TestDispatchActions:\n    \"\"\"Tests for dispatch_actions handling different action types.\"\"\"\n\n    def test_publish_action(self, runner):\n        \"\"\"Publish actions call mqtt_client.publish.\"\"\"\n        actions = [{\"type\": \"publish\", \"topic\": \"out/topic\", \"payload\": \"hello\"}]\n\n        with patch(\"mqttui.plugins.runner.mqttui.mqtt_client\") as mock_mqtt:\n            runner.dispatch_actions(actions)\n            mock_mqtt.publish.assert_called_once_with(\"out/topic\", \"hello\")\n\n    def test_log_action(self, runner):\n        \"\"\"Log actions are logged without errors.\"\"\"\n        actions = [{\"type\": \"log\", \"message\": \"test log message\"}]\n        # Should not raise\n        runner.dispatch_actions(actions)\n\n    def test_unknown_action_type(self, runner):\n        \"\"\"Unknown action types are logged and skipped without crash.\"\"\"\n        actions = [{\"type\": \"unknown_action\", \"data\": \"something\"}]\n        # Should not raise\n        runner.dispatch_actions(actions)\n\n\nclass TestPluginTimeout:\n    \"\"\"Tests for timeout configuration.\"\"\"\n\n    def test_timeout_is_5_seconds(self):\n        \"\"\"PLUGIN_TIMEOUT constant is 5 seconds.\"\"\"\n        assert PLUGIN_TIMEOUT == 5\n"
  },
  {
    "path": "tests/test_plugins_api.py",
    "content": "\"\"\"Tests for plugin management REST API and example plugins.\"\"\"\nimport json\nimport subprocess\nimport sys\n\nimport pytest\n\n\n# ── API Tests ──────────────────────────────────────────────────────\n\n\nclass TestPluginsAPI:\n    \"\"\"Test plugin management REST API endpoints.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def seed_plugin(self, app):\n        \"\"\"Seed a test plugin into the database.\"\"\"\n        with app.app_context():\n            from mqttui.plugins.models import PluginConfig\n            from mqttui.extensions import sa\n\n            pc = PluginConfig(\n                name=\"test-plugin\",\n                entry_point=\"mqttui.plugins.examples.json_formatter\",\n                enabled=False,\n                version=\"1.0.0\",\n                description=\"A test plugin\",\n            )\n            sa.session.add(pc)\n            sa.session.commit()\n\n    def test_list_plugins(self, auth_client):\n        \"\"\"GET /api/v1/plugins returns JSON list of plugins.\"\"\"\n        resp = auth_client.get(\"/api/v1/plugins\")\n        assert resp.status_code == 200\n        data = resp.get_json()\n        assert data[\"status\"] == \"success\"\n        plugins = data[\"data\"][\"plugins\"]\n        assert isinstance(plugins, list)\n        assert any(p[\"name\"] == \"test-plugin\" for p in plugins)\n\n    def test_enable_plugin(self, auth_client, app):\n        \"\"\"POST /api/v1/plugins/test-plugin/enable enables the plugin.\"\"\"\n        resp = auth_client.post(\"/api/v1/plugins/test-plugin/enable\")\n        assert resp.status_code == 200\n        data = resp.get_json()\n        assert data[\"status\"] == \"success\"\n\n        # Verify plugin is now enabled\n        with app.app_context():\n            from mqttui.plugins.models import PluginConfig\n\n            pc = PluginConfig.query.filter_by(name=\"test-plugin\").first()\n            assert pc.enabled is True\n\n    def test_disable_plugin(self, auth_client, app):\n        \"\"\"POST /api/v1/plugins/test-plugin/disable disables the plugin.\"\"\"\n        # Enable first\n        auth_client.post(\"/api/v1/plugins/test-plugin/enable\")\n        # Then disable\n        resp = auth_client.post(\"/api/v1/plugins/test-plugin/disable\")\n        assert resp.status_code == 200\n        data = resp.get_json()\n        assert data[\"status\"] == \"success\"\n\n        with app.app_context():\n            from mqttui.plugins.models import PluginConfig\n\n            pc = PluginConfig.query.filter_by(name=\"test-plugin\").first()\n            assert pc.enabled is False\n\n    def test_enable_nonexistent_returns_404(self, auth_client):\n        \"\"\"POST /api/v1/plugins/nonexistent/enable returns 404.\"\"\"\n        resp = auth_client.post(\"/api/v1/plugins/nonexistent/enable\")\n        assert resp.status_code == 404\n        data = resp.get_json()\n        assert data[\"status\"] == \"error\"\n\n    def test_disable_nonexistent_returns_404(self, auth_client):\n        \"\"\"POST /api/v1/plugins/nonexistent/disable returns 404.\"\"\"\n        resp = auth_client.post(\"/api/v1/plugins/nonexistent/disable\")\n        assert resp.status_code == 404\n        data = resp.get_json()\n        assert data[\"status\"] == \"error\"\n\n    def test_partials_plugins_returns_html(self, auth_client):\n        \"\"\"GET /partials/plugins returns HTML partial.\"\"\"\n        resp = auth_client.get(\"/partials/plugins\")\n        assert resp.status_code == 200\n        assert b\"test-plugin\" in resp.data\n\n\n# ── Example Plugin Tests ───────────────────────────────────────────\n\n\nclass TestJsonFormatterPlugin:\n    \"\"\"Test the json_formatter example plugin via subprocess.\"\"\"\n\n    def test_formats_json_payload(self):\n        \"\"\"JSON payload is pretty-printed.\"\"\"\n        input_data = {\n            \"event\": \"on_message\",\n            \"data\": {\"topic\": \"test/topic\", \"payload\": '{\"a\": 1, \"b\": 2}'},\n        }\n        result = subprocess.run(\n            [sys.executable, \"-m\", \"mqttui.plugins.examples.json_formatter\"],\n            input=json.dumps(input_data) + \"\\n\",\n            capture_output=True,\n            text=True,\n            timeout=5,\n        )\n        output = json.loads(result.stdout)\n        assert \"actions\" in output\n        assert len(output[\"actions\"]) == 1\n        assert output[\"actions\"][0][\"type\"] == \"transform\"\n        # Verify it's pretty-printed (has newlines)\n        assert \"\\n\" in output[\"actions\"][0][\"result\"]\n\n    def test_non_json_payload_returns_empty_actions(self):\n        \"\"\"Non-JSON payload returns empty actions list.\"\"\"\n        input_data = {\n            \"event\": \"on_message\",\n            \"data\": {\"topic\": \"test/topic\", \"payload\": \"not json\"},\n        }\n        result = subprocess.run(\n            [sys.executable, \"-m\", \"mqttui.plugins.examples.json_formatter\"],\n            input=json.dumps(input_data) + \"\\n\",\n            capture_output=True,\n            text=True,\n            timeout=5,\n        )\n        output = json.loads(result.stdout)\n        assert output[\"actions\"] == []\n\n    def test_non_message_event_returns_empty_actions(self):\n        \"\"\"Non on_message events return empty actions.\"\"\"\n        input_data = {\n            \"event\": \"on_rule_trigger\",\n            \"data\": {\"rule_name\": \"test\"},\n        }\n        result = subprocess.run(\n            [sys.executable, \"-m\", \"mqttui.plugins.examples.json_formatter\"],\n            input=json.dumps(input_data) + \"\\n\",\n            capture_output=True,\n            text=True,\n            timeout=5,\n        )\n        output = json.loads(result.stdout)\n        assert output[\"actions\"] == []\n\n\nclass TestTopicLoggerPlugin:\n    \"\"\"Test the topic_logger example plugin via subprocess.\"\"\"\n\n    def test_logs_message(self):\n        \"\"\"on_message event produces a log action.\"\"\"\n        input_data = {\n            \"event\": \"on_message\",\n            \"data\": {\"topic\": \"home/sensor/temp\", \"payload\": \"22.5\"},\n        }\n        result = subprocess.run(\n            [sys.executable, \"-m\", \"mqttui.plugins.examples.topic_logger\"],\n            input=json.dumps(input_data) + \"\\n\",\n            capture_output=True,\n            text=True,\n            timeout=5,\n        )\n        output = json.loads(result.stdout)\n        assert len(output[\"actions\"]) == 1\n        assert output[\"actions\"][0][\"type\"] == \"log\"\n        assert \"home/sensor/temp\" in output[\"actions\"][0][\"message\"]\n        assert \"22.5\" in output[\"actions\"][0][\"message\"]\n\n    def test_logs_to_stderr(self):\n        \"\"\"Plugin logs to stderr (not stdout which is protocol).\"\"\"\n        input_data = {\n            \"event\": \"on_message\",\n            \"data\": {\"topic\": \"home/sensor/temp\", \"payload\": \"22.5\"},\n        }\n        result = subprocess.run(\n            [sys.executable, \"-m\", \"mqttui.plugins.examples.topic_logger\"],\n            input=json.dumps(input_data) + \"\\n\",\n            capture_output=True,\n            text=True,\n            timeout=5,\n        )\n        assert \"home/sensor/temp\" in result.stderr\n"
  },
  {
    "path": "tests/test_routes.py",
    "content": "def test_index_redirects_unauthenticated(client):\n    \"\"\"Unauthenticated access to / should redirect to /login.\"\"\"\n    response = client.get('/')\n    assert response.status_code == 302\n    assert '/login' in response.headers['Location']\n\n\ndef test_index_returns_200_authenticated(client, app):\n    \"\"\"Authenticated access to / should return 200.\"\"\"\n    # Login first\n    client.post('/login', data={'username': 'admin', 'password': 'admin'})\n    response = client.get('/')\n    assert response.status_code == 200\n\n\ndef test_stats_returns_json(client):\n    response = client.get('/stats')\n    assert response.status_code == 200\n    data = response.get_json()\n    assert 'connection_count' in data\n    assert 'topic_count' in data\n    assert 'message_count' in data\n    assert 'errors' in data\n\n\ndef test_version_returns_json(client):\n    response = client.get('/version')\n    assert response.status_code == 200\n    data = response.get_json()\n    assert 'version' in data\n\n\ndef test_api_messages_returns_json(client):\n    response = client.get('/api/messages')\n    assert response.status_code == 200\n    data = response.get_json()\n    assert 'messages' in data\n\n\ndef test_api_topics_returns_json(client):\n    response = client.get('/api/topics')\n    assert response.status_code == 200\n    data = response.get_json()\n    assert 'topics' in data\n\n\ndef test_database_stats(client):\n    response = client.get('/api/database/stats')\n    assert response.status_code == 200\n    data = response.get_json()\n    assert 'enabled' in data\n"
  },
  {
    "path": "tests/test_rules_api.py",
    "content": "\"\"\"Integration tests for rules REST API endpoints.\"\"\"\nimport json\n\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\ndef _create_rule(auth_client, **overrides):\n    \"\"\"POST a new rule with sensible defaults. Returns response object.\"\"\"\n    data = {\n        \"name\": \"Test Rule\",\n        \"trigger_topic\": \"sensors/+/temp\",\n        \"condition\": {\"path\": \"temp\", \"op\": \"gt\", \"value\": 30},\n        \"action\": {\"type\": \"publish\", \"topic\": \"alerts/temp\", \"payload\": '{\"alert\": true}'},\n    }\n    data.update(overrides)\n    return auth_client.post('/api/v1/rules/', json=data)\n\n\n# ---------------------------------------------------------------------------\n# List\n# ---------------------------------------------------------------------------\n\ndef test_list_rules_empty(auth_client):\n    \"\"\"GET /api/v1/rules/ returns empty list when no rules exist.\"\"\"\n    r = auth_client.get('/api/v1/rules/')\n    assert r.status_code == 200\n    body = r.get_json()\n    assert body['status'] == 'success'\n    assert body['data']['rules'] == []\n\n\ndef test_list_rules_with_data(auth_client):\n    \"\"\"GET /api/v1/rules/ returns created rules.\"\"\"\n    _create_rule(auth_client, name=\"Rule A\")\n    _create_rule(auth_client, name=\"Rule B\")\n    r = auth_client.get('/api/v1/rules/')\n    assert r.status_code == 200\n    rules = r.get_json()['data']['rules']\n    assert len(rules) == 2\n\n\n# ---------------------------------------------------------------------------\n# Create\n# ---------------------------------------------------------------------------\n\ndef test_create_rule(auth_client):\n    \"\"\"POST /api/v1/rules/ with valid data returns 201 with rule data.\"\"\"\n    r = _create_rule(auth_client)\n    assert r.status_code == 201\n    body = r.get_json()\n    assert body['status'] == 'success'\n    rule = body['data']\n    assert rule['name'] == 'Test Rule'\n    assert rule['trigger_topic'] == 'sensors/+/temp'\n    assert rule['enabled'] is True\n    assert rule['id'] is not None\n    assert rule['condition'] == {\"path\": \"temp\", \"op\": \"gt\", \"value\": 30}\n    assert rule['action']['type'] == 'publish'\n\n\ndef test_create_rule_missing_name(auth_client):\n    \"\"\"POST without name returns 400 VALIDATION_ERROR.\"\"\"\n    r = auth_client.post('/api/v1/rules/', json={\n        \"trigger_topic\": \"test/+\",\n        \"action\": {\"type\": \"publish\", \"topic\": \"out\", \"payload\": \"x\"},\n    })\n    assert r.status_code == 400\n    body = r.get_json()\n    assert body['status'] == 'error'\n    assert body['error']['code'] == 'VALIDATION_ERROR'\n\n\ndef test_create_rule_missing_action(auth_client):\n    \"\"\"POST without action returns 400 VALIDATION_ERROR.\"\"\"\n    r = auth_client.post('/api/v1/rules/', json={\n        \"name\": \"No Action\",\n        \"trigger_topic\": \"test/+\",\n    })\n    assert r.status_code == 400\n    body = r.get_json()\n    assert body['status'] == 'error'\n    assert body['error']['code'] == 'VALIDATION_ERROR'\n\n\ndef test_create_rule_missing_trigger_topic(auth_client):\n    \"\"\"POST without trigger_topic returns 400 VALIDATION_ERROR.\"\"\"\n    r = auth_client.post('/api/v1/rules/', json={\n        \"name\": \"No Topic\",\n        \"action\": {\"type\": \"publish\", \"topic\": \"out\", \"payload\": \"x\"},\n    })\n    assert r.status_code == 400\n    body = r.get_json()\n    assert body['error']['code'] == 'VALIDATION_ERROR'\n\n\n# ---------------------------------------------------------------------------\n# Get single\n# ---------------------------------------------------------------------------\n\ndef test_get_rule(auth_client):\n    \"\"\"GET /api/v1/rules/<id> returns rule.\"\"\"\n    cr = _create_rule(auth_client)\n    rule_id = cr.get_json()['data']['id']\n    r = auth_client.get(f'/api/v1/rules/{rule_id}')\n    assert r.status_code == 200\n    assert r.get_json()['data']['id'] == rule_id\n\n\ndef test_get_rule_not_found(auth_client):\n    \"\"\"GET /api/v1/rules/999 returns 404 NOT_FOUND.\"\"\"\n    r = auth_client.get('/api/v1/rules/999')\n    assert r.status_code == 404\n    body = r.get_json()\n    assert body['error']['code'] == 'NOT_FOUND'\n\n\n# ---------------------------------------------------------------------------\n# Update\n# ---------------------------------------------------------------------------\n\ndef test_update_rule(auth_client):\n    \"\"\"PUT /api/v1/rules/<id> updates specified fields only.\"\"\"\n    cr = _create_rule(auth_client)\n    rule_id = cr.get_json()['data']['id']\n\n    r = auth_client.put(f'/api/v1/rules/{rule_id}', json={\n        \"name\": \"Updated Rule\",\n        \"description\": \"Now with description\",\n    })\n    assert r.status_code == 200\n    rule = r.get_json()['data']\n    assert rule['name'] == 'Updated Rule'\n    assert rule['description'] == 'Now with description'\n    # Unchanged fields preserved\n    assert rule['trigger_topic'] == 'sensors/+/temp'\n\n\ndef test_update_rule_not_found(auth_client):\n    \"\"\"PUT /api/v1/rules/999 returns 404.\"\"\"\n    r = auth_client.put('/api/v1/rules/999', json={\"name\": \"x\"})\n    assert r.status_code == 404\n\n\n# ---------------------------------------------------------------------------\n# Delete\n# ---------------------------------------------------------------------------\n\ndef test_delete_rule(auth_client):\n    \"\"\"DELETE /api/v1/rules/<id> returns 200, subsequent GET returns 404.\"\"\"\n    cr = _create_rule(auth_client)\n    rule_id = cr.get_json()['data']['id']\n\n    r = auth_client.delete(f'/api/v1/rules/{rule_id}')\n    assert r.status_code == 200\n    assert r.get_json()['status'] == 'success'\n\n    # Verify deleted\n    r2 = auth_client.get(f'/api/v1/rules/{rule_id}')\n    assert r2.status_code == 404\n\n\ndef test_delete_rule_not_found(auth_client):\n    \"\"\"DELETE /api/v1/rules/999 returns 404.\"\"\"\n    r = auth_client.delete('/api/v1/rules/999')\n    assert r.status_code == 404\n\n\n# ---------------------------------------------------------------------------\n# Enable / Disable\n# ---------------------------------------------------------------------------\n\ndef test_enable_disable(auth_client):\n    \"\"\"POST enable sets enabled=True, POST disable sets enabled=False.\"\"\"\n    cr = _create_rule(auth_client, enabled=True)\n    rule_id = cr.get_json()['data']['id']\n\n    # Disable\n    r = auth_client.post(f'/api/v1/rules/{rule_id}/disable')\n    assert r.status_code == 200\n    assert r.get_json()['data']['enabled'] is False\n\n    # Enable\n    r = auth_client.post(f'/api/v1/rules/{rule_id}/enable')\n    assert r.status_code == 200\n    assert r.get_json()['data']['enabled'] is True\n\n\ndef test_enable_not_found(auth_client):\n    \"\"\"POST /api/v1/rules/999/enable returns 404.\"\"\"\n    r = auth_client.post('/api/v1/rules/999/enable')\n    assert r.status_code == 404\n\n\n# ---------------------------------------------------------------------------\n# Dry-run / Test\n# ---------------------------------------------------------------------------\n\ndef test_dry_run_match(auth_client):\n    \"\"\"POST /api/v1/rules/<id>/test with matching topic+payload returns match=True.\"\"\"\n    cr = _create_rule(auth_client)\n    rule_id = cr.get_json()['data']['id']\n\n    r = auth_client.post(f'/api/v1/rules/{rule_id}/test', json={\n        \"topic\": \"sensors/living-room/temp\",\n        \"payload\": '{\"temp\": 35}',\n    })\n    assert r.status_code == 200\n    body = r.get_json()['data']\n    assert body['match'] is True\n    assert body['topic_match'] is True\n    assert body['condition_match'] is True\n    assert len(body['actions']) == 1\n    assert body['actions'][0]['type'] == 'publish'\n\n\ndef test_dry_run_no_match_condition(auth_client):\n    \"\"\"POST /test with non-matching payload returns match=False.\"\"\"\n    cr = _create_rule(auth_client)\n    rule_id = cr.get_json()['data']['id']\n\n    r = auth_client.post(f'/api/v1/rules/{rule_id}/test', json={\n        \"topic\": \"sensors/living-room/temp\",\n        \"payload\": '{\"temp\": 20}',\n    })\n    assert r.status_code == 200\n    body = r.get_json()['data']\n    assert body['match'] is False\n    assert body['topic_match'] is True\n    assert body['condition_match'] is False\n    assert body['actions'] == []\n\n\ndef test_dry_run_no_match_topic(auth_client):\n    \"\"\"POST /test with non-matching topic returns match=False.\"\"\"\n    cr = _create_rule(auth_client)\n    rule_id = cr.get_json()['data']['id']\n\n    r = auth_client.post(f'/api/v1/rules/{rule_id}/test', json={\n        \"topic\": \"other/topic\",\n        \"payload\": '{\"temp\": 35}',\n    })\n    assert r.status_code == 200\n    body = r.get_json()['data']\n    assert body['match'] is False\n    assert body['topic_match'] is False\n\n\ndef test_dry_run_dict_payload(auth_client):\n    \"\"\"POST /test with dict payload (not string) also works.\"\"\"\n    cr = _create_rule(auth_client)\n    rule_id = cr.get_json()['data']['id']\n\n    r = auth_client.post(f'/api/v1/rules/{rule_id}/test', json={\n        \"topic\": \"sensors/bedroom/temp\",\n        \"payload\": {\"temp\": 40},\n    })\n    assert r.status_code == 200\n    assert r.get_json()['data']['match'] is True\n\n\ndef test_dry_run_missing_topic(auth_client):\n    \"\"\"POST /test without topic returns 400.\"\"\"\n    cr = _create_rule(auth_client)\n    rule_id = cr.get_json()['data']['id']\n\n    r = auth_client.post(f'/api/v1/rules/{rule_id}/test', json={\n        \"payload\": '{\"temp\": 35}',\n    })\n    assert r.status_code == 400\n\n\n# ---------------------------------------------------------------------------\n# Authentication\n# ---------------------------------------------------------------------------\n\ndef test_unauthenticated_access(client):\n    \"\"\"GET /api/v1/rules/ without auth returns 401 or redirect to login.\"\"\"\n    r = client.get('/api/v1/rules/')\n    # Flask-Login redirects to /login (302) or returns 401\n    assert r.status_code in (302, 401)\n"
  },
  {
    "path": "tests/test_rules_engine.py",
    "content": "\"\"\"Tests for the RuleEngine class -- cache, matcher, rate limiter, loop prevention.\"\"\"\nimport json\nimport time\nfrom collections import deque\nfrom datetime import datetime\nfrom unittest.mock import patch, MagicMock, call\n\nimport pytest\n\nfrom mqttui.events import mqtt_message_received, rule_fired, rule_changed\n\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\ndef _create_rule(app, **overrides):\n    \"\"\"Create a Rule record inside app context and return its id.\"\"\"\n    with app.app_context():\n        from mqttui.rules.models import Rule\n        from mqttui.extensions import sa\n\n        defaults = {\n            'name': 'Test Rule',\n            'trigger_topic': 'sensors/+/temp',\n            'condition_json': '{}',\n            'action_json': json.dumps({'type': 'log'}),\n            'enabled': True,\n            'rate_limit_per_min': 10,\n        }\n        defaults.update(overrides)\n        rule = Rule(**defaults)\n        sa.session.add(rule)\n        sa.session.commit()\n        return rule.id\n\n\ndef _send_message(topic, payload_dict):\n    \"\"\"Fire mqtt_message_received signal with given topic and JSON payload.\"\"\"\n    mqtt_message_received.send(\n        'mqtt_client',\n        topic=topic,\n        payload=json.dumps(payload_dict),\n        timestamp=datetime.utcnow(),\n        qos=0,\n        retain=False,\n    )\n\n\n@pytest.fixture\ndef engine(app):\n    \"\"\"Create a RuleEngine with mocked scheduler, disconnect after test.\"\"\"\n    from mqttui.rules.engine import RuleEngine\n    eng = RuleEngine(app)\n    # Prevent real GeventScheduler from starting in tests\n    eng._scheduler = MagicMock()\n    eng._scheduler.get_jobs.return_value = []\n    yield eng\n    eng.disconnect()\n\n\n@pytest.fixture\ndef fire_log():\n    \"\"\"Collect rule_fired signals. Auto-disconnects after test.\"\"\"\n    fired = []\n\n    def _handler(sender, **kw):\n        fired.append(kw)\n\n    rule_fired.connect(_handler)\n    yield fired\n    rule_fired.disconnect(_handler)\n\n\n# ---------------------------------------------------------------------------\n# Tests\n# ---------------------------------------------------------------------------\n\nclass TestLoopPrevention:\n    \"\"\"Messages with __source: mqttui-automation must be skipped.\"\"\"\n\n    def test_loop_prevention_skips_automation_messages(self, app, engine, fire_log):\n        _create_rule(app, trigger_topic='sensors/#')\n        engine.connect()\n\n        with app.app_context():\n            _send_message('sensors/outdoor/temp', {\n                '__source': 'mqttui-automation',\n                'temp': 35,\n            })\n\n        assert len(fire_log) == 0, \"Rule must not fire on automation messages\"\n\n\nclass TestTopicMatching:\n    \"\"\"Rules should match based on MQTT wildcard patterns.\"\"\"\n\n    def test_wildcard_topic_matching(self, app, engine, fire_log):\n        _create_rule(app, trigger_topic='sensors/+/temp',\n                     action_json=json.dumps({'type': 'log'}))\n        engine.connect()\n\n        with app.app_context():\n            _send_message('sensors/outdoor/temp', {'temp': 25})\n\n        assert len(fire_log) == 1\n        assert fire_log[0]['topic'] == 'sensors/outdoor/temp'\n\n    def test_non_matching_topic(self, app, engine, fire_log):\n        _create_rule(app, trigger_topic='sensors/+/temp')\n        engine.connect()\n\n        with app.app_context():\n            _send_message('actuators/fan/speed', {'speed': 50})\n\n        assert len(fire_log) == 0\n\n\nclass TestConditionEvaluation:\n    \"\"\"Rules should evaluate conditions before firing.\"\"\"\n\n    def test_matching_condition_fires(self, app, engine, fire_log):\n        _create_rule(\n            app,\n            trigger_topic='sensors/+/temp',\n            condition_json=json.dumps({'path': 'temp', 'op': 'gt', 'value': 30}),\n            action_json=json.dumps({'type': 'log'}),\n        )\n        engine.connect()\n\n        with app.app_context():\n            _send_message('sensors/outdoor/temp', {'temp': 35})\n\n        assert len(fire_log) == 1\n\n    def test_non_matching_condition_does_not_fire(self, app, engine, fire_log):\n        _create_rule(\n            app,\n            trigger_topic='sensors/+/temp',\n            condition_json=json.dumps({'path': 'temp', 'op': 'gt', 'value': 30}),\n        )\n        engine.connect()\n\n        with app.app_context():\n            _send_message('sensors/outdoor/temp', {'temp': 20})\n\n        assert len(fire_log) == 0\n\n\nclass TestDisabledRule:\n    \"\"\"Disabled rules must not be in cache and never fire.\"\"\"\n\n    def test_disabled_rule_not_in_cache(self, app, engine, fire_log):\n        _create_rule(app, trigger_topic='sensors/#', enabled=False)\n        engine.connect()\n\n        with app.app_context():\n            _send_message('sensors/outdoor/temp', {'temp': 35})\n\n        assert len(fire_log) == 0\n\n\nclass TestPerRuleRateLimit:\n    \"\"\"Per-rule rate limit blocks excessive firings within the window.\"\"\"\n\n    def test_rate_limit_blocks_excess(self, app, engine, fire_log):\n        _create_rule(\n            app,\n            trigger_topic='sensors/#',\n            rate_limit_per_min=2,\n            action_json=json.dumps({'type': 'log'}),\n        )\n        engine.connect()\n\n        with app.app_context():\n            for _ in range(3):\n                _send_message('sensors/outdoor/temp', {'temp': 35})\n\n        assert len(fire_log) == 2, \"Third firing should be rate-limited\"\n\n\nclass TestGlobalCircuitBreaker:\n    \"\"\"Global circuit breaker blocks all firings after threshold.\"\"\"\n\n    def test_global_limit_blocks_excess(self, app, engine, fire_log):\n        # Create rules that will all fire\n        for i in range(5):\n            _create_rule(\n                app,\n                name=f'Rule {i}',\n                trigger_topic='sensors/#',\n                rate_limit_per_min=100,\n                action_json=json.dumps({'type': 'log'}),\n            )\n\n        engine._GLOBAL_LIMIT = 3  # Low limit for testing\n        engine.connect()\n\n        with app.app_context():\n            _send_message('sensors/outdoor/temp', {'temp': 35})\n\n        # 5 rules match but global limit is 3\n        assert len(fire_log) == 3, \"Global circuit breaker should limit to 3 firings\"\n\n\nclass TestPublishActionSourceMarker:\n    \"\"\"Publish action must inject __source marker.\"\"\"\n\n    def test_publish_injects_source_marker(self, app):\n        from mqttui.rules.actions import execute_action\n\n        with app.app_context():\n            with patch('mqttui.mqtt_client.publish') as mock_pub:\n                result = execute_action(\n                    {'type': 'publish', 'topic': 'output/fan', 'payload': '{\"speed\": 100}'},\n                    {'rule_id': 1, 'rule_name': 'Test', 'topic': 'sensors/temp', 'payload': '{}'},\n                )\n\n                assert result['success'] is True\n                call_args = mock_pub.call_args\n                published_payload = json.loads(call_args[0][1])\n                assert published_payload['__source'] == 'mqttui-automation'\n                assert published_payload['speed'] == 100\n\n\nclass TestLogActionCreatesAlert:\n    \"\"\"Log action must create an AlertHistory record.\"\"\"\n\n    def test_log_creates_alert_history(self, app):\n        from mqttui.rules.actions import execute_action\n        from mqttui.rules.models import AlertHistory\n\n        with app.app_context():\n            result = execute_action(\n                {'type': 'log', 'severity': 'warning', 'message': 'Temperature high'},\n                {'rule_id': 1, 'rule_name': 'Temp Alert', 'topic': 'sensors/temp', 'payload': '{}'},\n            )\n\n            assert result['success'] is True\n            alerts = AlertHistory.query.all()\n            assert len(alerts) == 1\n            assert alerts[0].severity == 'warning'\n            assert alerts[0].rule_name == 'Temp Alert'\n\n\nclass TestRuleFiredSignal:\n    \"\"\"rule_fired signal must be emitted with correct kwargs.\"\"\"\n\n    def test_signal_emitted_on_fire(self, app, engine, fire_log):\n        rule_id = _create_rule(\n            app,\n            name='Signal Test Rule',\n            trigger_topic='sensors/#',\n            action_json=json.dumps({'type': 'log'}),\n        )\n        engine.connect()\n\n        with app.app_context():\n            _send_message('sensors/outdoor/temp', {'temp': 25})\n\n        assert len(fire_log) == 1\n        sig = fire_log[0]\n        assert sig['rule_id'] == rule_id\n        assert sig['rule_name'] == 'Signal Test Rule'\n        assert sig['topic'] == 'sensors/outdoor/temp'\n\n\n# ---------------------------------------------------------------------------\n# Scheduler & Hot-Reload Tests (Plan 03-04)\n# ---------------------------------------------------------------------------\n\nclass TestHotReload:\n    \"\"\"rule_changed signal should trigger cache reload.\"\"\"\n\n    def test_hot_reload_on_rule_changed(self, app, engine):\n        \"\"\"Cache updates when rule_changed signal fires.\"\"\"\n        rule_id = _create_rule(app, trigger_topic='sensors/#')\n        engine.connect()\n\n        # Rule should be in cache\n        assert rule_id in engine._rules\n\n        # Delete the rule from DB\n        with app.app_context():\n            from mqttui.rules.models import Rule\n            from mqttui.extensions import sa\n            rule = sa.session.get(Rule, rule_id)\n            sa.session.delete(rule)\n            sa.session.commit()\n\n        # Fire rule_changed signal -- should trigger cache reload\n        rule_changed.send('test', action='deleted', rule_id=rule_id)\n\n        # Rule should no longer be in cache\n        assert rule_id not in engine._rules\n\n\nclass TestCronScheduleSync:\n    \"\"\"sync_scheduled_jobs should manage APScheduler jobs for cron rules.\"\"\"\n\n    def test_cron_schedule_sync(self, app, engine):\n        \"\"\"A rule with schedule_cron gets a scheduler job with replace_existing.\"\"\"\n        rule_id = _create_rule(\n            app,\n            trigger_topic='sensors/#',\n            schedule_cron='*/5 * * * *',\n        )\n        engine.connect()\n\n        # Verify add_job was called with correct arguments\n        engine._scheduler.add_job.assert_called()\n        call_kwargs = engine._scheduler.add_job.call_args\n        assert call_kwargs[1]['replace_existing'] is True\n        assert call_kwargs[1]['id'] == f'rule_{rule_id}'\n\n    def test_removed_rule_job_cleaned_up(self, app, engine):\n        \"\"\"Jobs for deleted rules are removed from scheduler.\"\"\"\n        # Simulate a stale job from a previously deleted rule\n        stale_job = MagicMock()\n        stale_job.id = 'rule_999'\n        engine._scheduler.get_jobs.return_value = [stale_job]\n\n        # Create a rule without cron (no new job expected)\n        _create_rule(app, trigger_topic='sensors/#')\n        engine.connect()\n\n        # Stale job should be removed\n        engine._scheduler.remove_job.assert_any_call('rule_999')\n\n\nclass TestFireScheduledRule:\n    \"\"\"fire_scheduled_rule should execute the action and log to AlertHistory.\"\"\"\n\n    def test_fire_scheduled_rule(self, app, engine):\n        \"\"\"Calling fire_scheduled_rule creates an AlertHistory record.\"\"\"\n        from mqttui.rules.models import AlertHistory\n\n        rule_id = _create_rule(\n            app,\n            name='Heartbeat Rule',\n            trigger_topic='__scheduled__',\n            action_json=json.dumps({'type': 'log', 'message': 'heartbeat'}),\n        )\n        engine.connect()\n\n        with app.app_context():\n            engine.fire_scheduled_rule(rule_id)\n\n            alerts = AlertHistory.query.all()\n            assert len(alerts) == 1\n            assert alerts[0].message == 'heartbeat'\n            assert alerts[0].rule_name == 'Heartbeat Rule'\n\n    def test_fire_scheduled_rule_missing_id(self, app, engine):\n        \"\"\"fire_scheduled_rule silently returns for non-existent rule.\"\"\"\n        engine.connect()\n        with app.app_context():\n            # Should not raise\n            engine.fire_scheduled_rule(99999)\n\n\nclass TestAppCreatesRuleEngine:\n    \"\"\"create_app should register the rules blueprint.\"\"\"\n\n    def test_rules_blueprint_registered(self, app):\n        \"\"\"The rules blueprint should be in app.blueprints.\"\"\"\n        assert 'rules' in app.blueprints\n"
  },
  {
    "path": "tests/test_rules_evaluator.py",
    "content": "\"\"\"Tests for the condition evaluator pure function.\"\"\"\nimport pytest\nfrom mqttui.rules.evaluator import evaluate, ConditionError\n\n\ndef test_simple_gt_true():\n    \"\"\"Greater-than returns True when actual > value.\"\"\"\n    assert evaluate({\"path\": \"temperature\", \"op\": \"gt\", \"value\": 30}, {\"temperature\": 35}) is True\n\n\ndef test_simple_gt_false():\n    \"\"\"Greater-than returns False when actual < value.\"\"\"\n    assert evaluate({\"path\": \"temperature\", \"op\": \"gt\", \"value\": 30}, {\"temperature\": 25}) is False\n\n\ndef test_nested_path_eq():\n    \"\"\"Dot-notation path resolves nested dicts.\"\"\"\n    condition = {\"path\": \"sensors.outdoor.temp\", \"op\": \"eq\", \"value\": 22}\n    payload = {\"sensors\": {\"outdoor\": {\"temp\": 22}}}\n    assert evaluate(condition, payload) is True\n\n\ndef test_compound_all_true():\n    \"\"\"Compound 'all' returns True when all sub-conditions match.\"\"\"\n    condition = {\"all\": [\n        {\"path\": \"temp\", \"op\": \"gt\", \"value\": 20},\n        {\"path\": \"temp\", \"op\": \"lt\", \"value\": 40},\n    ]}\n    assert evaluate(condition, {\"temp\": 30}) is True\n\n\ndef test_compound_all_false():\n    \"\"\"Compound 'all' returns False when any sub-condition fails.\"\"\"\n    condition = {\"all\": [\n        {\"path\": \"temp\", \"op\": \"gt\", \"value\": 20},\n        {\"path\": \"temp\", \"op\": \"lt\", \"value\": 25},\n    ]}\n    assert evaluate(condition, {\"temp\": 30}) is False\n\n\ndef test_compound_any_true():\n    \"\"\"Compound 'any' returns True when at least one sub-condition matches.\"\"\"\n    condition = {\"any\": [\n        {\"path\": \"status\", \"op\": \"eq\", \"value\": \"on\"},\n        {\"path\": \"status\", \"op\": \"eq\", \"value\": \"active\"},\n    ]}\n    assert evaluate(condition, {\"status\": \"active\"}) is True\n\n\ndef test_compound_any_false():\n    \"\"\"Compound 'any' returns False when no sub-condition matches.\"\"\"\n    condition = {\"any\": [\n        {\"path\": \"status\", \"op\": \"eq\", \"value\": \"on\"},\n        {\"path\": \"status\", \"op\": \"eq\", \"value\": \"active\"},\n    ]}\n    assert evaluate(condition, {\"status\": \"off\"}) is False\n\n\ndef test_contains():\n    \"\"\"Contains checks substring in string value.\"\"\"\n    assert evaluate({\"path\": \"name\", \"op\": \"contains\", \"value\": \"sensor\"}, {\"name\": \"temp_sensor_1\"}) is True\n\n\ndef test_not_contains():\n    \"\"\"Not-contains checks absence of substring.\"\"\"\n    assert evaluate({\"path\": \"name\", \"op\": \"not_contains\", \"value\": \"xyz\"}, {\"name\": \"abc\"}) is True\n\n\ndef test_regex_match():\n    \"\"\"Regex operator matches patterns.\"\"\"\n    assert evaluate({\"path\": \"topic\", \"op\": \"regex\", \"value\": r\"^sensor_\\d+\"}, {\"topic\": \"sensor_42\"}) is True\n\n\ndef test_regex_no_match():\n    \"\"\"Regex returns False when pattern doesn't match.\"\"\"\n    assert evaluate({\"path\": \"topic\", \"op\": \"regex\", \"value\": r\"^sensor_\\d+\"}, {\"topic\": \"device_42\"}) is False\n\n\ndef test_exists_true():\n    \"\"\"Exists returns True when path is present (even if value is falsy).\"\"\"\n    assert evaluate({\"path\": \"temp\", \"op\": \"exists\"}, {\"temp\": 0}) is True\n\n\ndef test_exists_false():\n    \"\"\"Exists returns False when path is missing.\"\"\"\n    assert evaluate({\"path\": \"missing\", \"op\": \"exists\"}, {\"temp\": 0}) is False\n\n\ndef test_not_exists_true():\n    \"\"\"Not-exists returns True when path is missing.\"\"\"\n    assert evaluate({\"path\": \"missing\", \"op\": \"not_exists\"}, {\"temp\": 0}) is True\n\n\ndef test_not_exists_false():\n    \"\"\"Not-exists returns False when path is present.\"\"\"\n    assert evaluate({\"path\": \"temp\", \"op\": \"not_exists\"}, {\"temp\": 0}) is False\n\n\ndef test_non_json_payload_returns_false():\n    \"\"\"Non-dict payload returns False for path-based conditions.\"\"\"\n    assert evaluate({\"path\": \"temp\", \"op\": \"gt\", \"value\": 30}, None) is False\n\n\ndef test_numeric_coercion():\n    \"\"\"String numbers are coerced for numeric comparisons.\"\"\"\n    assert evaluate({\"path\": \"temp\", \"op\": \"gt\", \"value\": 30}, {\"temp\": \"35\"}) is True\n\n\ndef test_numeric_coercion_lt():\n    \"\"\"String numbers coerced for less-than.\"\"\"\n    assert evaluate({\"path\": \"temp\", \"op\": \"lt\", \"value\": 30}, {\"temp\": \"25\"}) is True\n\n\ndef test_empty_condition_always_true():\n    \"\"\"Empty condition dict means always match.\"\"\"\n    assert evaluate({}, {\"temp\": 30}) is True\n\n\ndef test_none_condition_always_true():\n    \"\"\"None condition means always match.\"\"\"\n    assert evaluate(None, {\"temp\": 30}) is True\n\n\ndef test_unknown_operator_raises():\n    \"\"\"Unknown operator raises ConditionError.\"\"\"\n    with pytest.raises(ConditionError, match=\"Unknown operator\"):\n        evaluate({\"path\": \"x\", \"op\": \"unknown_op\", \"value\": 1}, {\"x\": 1})\n\n\ndef test_eq_operator():\n    \"\"\"Equality check.\"\"\"\n    assert evaluate({\"path\": \"x\", \"op\": \"eq\", \"value\": 5}, {\"x\": 5}) is True\n    assert evaluate({\"path\": \"x\", \"op\": \"eq\", \"value\": 5}, {\"x\": 6}) is False\n\n\ndef test_ne_operator():\n    \"\"\"Not-equal check.\"\"\"\n    assert evaluate({\"path\": \"x\", \"op\": \"ne\", \"value\": 5}, {\"x\": 6}) is True\n    assert evaluate({\"path\": \"x\", \"op\": \"ne\", \"value\": 5}, {\"x\": 5}) is False\n\n\ndef test_gte_operator():\n    \"\"\"Greater-than-or-equal check.\"\"\"\n    assert evaluate({\"path\": \"x\", \"op\": \"gte\", \"value\": 5}, {\"x\": 5}) is True\n    assert evaluate({\"path\": \"x\", \"op\": \"gte\", \"value\": 5}, {\"x\": 4}) is False\n\n\ndef test_lte_operator():\n    \"\"\"Less-than-or-equal check.\"\"\"\n    assert evaluate({\"path\": \"x\", \"op\": \"lte\", \"value\": 5}, {\"x\": 5}) is True\n    assert evaluate({\"path\": \"x\", \"op\": \"lte\", \"value\": 5}, {\"x\": 6}) is False\n\n\ndef test_missing_path_returns_false():\n    \"\"\"Missing path in payload returns False (not an error).\"\"\"\n    assert evaluate({\"path\": \"missing.deep.path\", \"op\": \"eq\", \"value\": 1}, {\"x\": 1}) is False\n"
  },
  {
    "path": "tests/test_rules_models.py",
    "content": "\"\"\"Tests for Rule and AlertHistory models.\"\"\"\nimport json\nfrom datetime import datetime\n\n\ndef test_rule_model_columns(app):\n    \"\"\"Rule model has all required columns.\"\"\"\n    with app.app_context():\n        from mqttui.rules.models import Rule\n        cols = [c.name for c in Rule.__table__.columns]\n        expected = [\n            'id', 'name', 'description', 'trigger_topic',\n            'condition_json', 'action_json', 'enabled',\n            'rate_limit_per_min', 'schedule_cron', 'last_fired',\n            'fire_count', 'created_at', 'updated_at',\n        ]\n        for col in expected:\n            assert col in cols, f\"Missing column: {col}\"\n\n\ndef test_rule_tablename(app):\n    \"\"\"Rule model uses 'rules' table.\"\"\"\n    with app.app_context():\n        from mqttui.rules.models import Rule\n        assert Rule.__tablename__ == 'rules'\n\n\ndef test_rule_to_dict(app):\n    \"\"\"Rule.to_dict() returns dict with parsed JSON fields.\"\"\"\n    with app.app_context():\n        from mqttui.rules.models import Rule\n        from mqttui.extensions import sa\n\n        rule = Rule(\n            name='Test Rule',\n            description='A test rule',\n            trigger_topic='sensors/temp',\n            condition_json=json.dumps({\"path\": \"temp\", \"op\": \"gt\", \"value\": 30}),\n            action_json=json.dumps({\"type\": \"log\", \"message\": \"Hot!\"}),\n            enabled=True,\n            rate_limit_per_min=5,\n        )\n        sa.session.add(rule)\n        sa.session.commit()\n\n        d = rule.to_dict()\n        assert d['name'] == 'Test Rule'\n        assert d['trigger_topic'] == 'sensors/temp'\n        assert d['condition'] == {\"path\": \"temp\", \"op\": \"gt\", \"value\": 30}\n        assert d['action'] == {\"type\": \"log\", \"message\": \"Hot!\"}\n        assert d['enabled'] is True\n        assert d['rate_limit_per_min'] == 5\n        assert d['fire_count'] == 0\n        assert d['id'] is not None\n\n\ndef test_alert_history_model_columns(app):\n    \"\"\"AlertHistory model has all required columns.\"\"\"\n    with app.app_context():\n        from mqttui.rules.models import AlertHistory\n        cols = [c.name for c in AlertHistory.__table__.columns]\n        expected = ['id', 'rule_id', 'rule_name', 'topic', 'severity', 'message', 'fired_at']\n        for col in expected:\n            assert col in cols, f\"Missing column: {col}\"\n\n\ndef test_alert_history_tablename(app):\n    \"\"\"AlertHistory model uses 'alert_history' table.\"\"\"\n    with app.app_context():\n        from mqttui.rules.models import AlertHistory\n        assert AlertHistory.__tablename__ == 'alert_history'\n\n\ndef test_alert_history_to_dict(app):\n    \"\"\"AlertHistory.to_dict() returns dict with ISO datetime.\"\"\"\n    with app.app_context():\n        from mqttui.rules.models import AlertHistory\n        from mqttui.extensions import sa\n\n        ah = AlertHistory(\n            rule_id=1,\n            rule_name='Test Rule',\n            topic='sensors/temp',\n            severity='warning',\n            message='Temperature too high',\n        )\n        sa.session.add(ah)\n        sa.session.commit()\n\n        d = ah.to_dict()\n        assert d['rule_name'] == 'Test Rule'\n        assert d['severity'] == 'warning'\n        assert d['topic'] == 'sensors/temp'\n        assert d['fired_at'] is not None\n"
  },
  {
    "path": "tests/test_ux_features.py",
    "content": "\"\"\"Tests for UX features: topic favorites/bookmarks and retained message indicator.\"\"\"\nimport json\n\n\nclass TestTopicFavorites:\n    \"\"\"Tests for topic bookmark/favorite functionality.\"\"\"\n\n    def test_bookmark_creates_favorite(self, auth_client, app):\n        \"\"\"POST /api/v1/topics/<topic>/bookmark creates a favorite (201).\"\"\"\n        r = auth_client.post('/api/v1/topics/home%2Fsensor/bookmark')\n        assert r.status_code == 201\n        data = json.loads(r.data)\n        assert data['status'] == 'success'\n        assert data['data']['bookmarked'] is True\n\n    def test_bookmark_toggle_removes_favorite(self, auth_client, app):\n        \"\"\"POST /api/v1/topics/<topic>/bookmark again removes the favorite (200).\"\"\"\n        # First call creates\n        auth_client.post('/api/v1/topics/home%2Fsensor/bookmark')\n        # Second call removes (toggle)\n        r = auth_client.post('/api/v1/topics/home%2Fsensor/bookmark')\n        assert r.status_code == 200\n        data = json.loads(r.data)\n        assert data['status'] == 'success'\n        assert data['data']['bookmarked'] is False\n\n    def test_get_favorites_returns_bookmarked_topics(self, auth_client, app):\n        \"\"\"GET /api/v1/topics/favorites returns list of bookmarked topics.\"\"\"\n        # Bookmark two topics\n        auth_client.post('/api/v1/topics/home%2Fsensor/bookmark')\n        auth_client.post('/api/v1/topics/home%2Flight/bookmark')\n\n        r = auth_client.get('/api/v1/topics/favorites')\n        assert r.status_code == 200\n        data = json.loads(r.data)\n        assert data['status'] == 'success'\n        favorites = data['data']['favorites']\n        assert len(favorites) == 2\n        topics = [f['topic'] for f in favorites]\n        assert 'home/sensor' in topics\n        assert 'home/light' in topics\n\n    def test_topics_includes_is_favorite(self, auth_client, app):\n        \"\"\"GET /api/v1/topics includes is_favorite=true for bookmarked topics.\"\"\"\n        # Bookmark a topic\n        auth_client.post('/api/v1/topics/home%2Fsensor/bookmark')\n\n        r = auth_client.get('/api/v1/topics')\n        assert r.status_code == 200\n        data = json.loads(r.data)\n        assert data['status'] == 'success'\n        # The topics list should have is_favorite field\n        topics = data['data']['topics']\n        for t in topics:\n            assert 'is_favorite' in t\n\n    def test_unauthenticated_bookmark_rejected(self, client):\n        \"\"\"Unauthenticated bookmark request returns 401 or redirect.\"\"\"\n        r = client.post('/api/v1/topics/home%2Fsensor/bookmark')\n        # Flask-Login redirects to login page (302) by default\n        assert r.status_code in (302, 401)\n"
  },
  {
    "path": "tests/test_webhook.py",
    "content": "\"\"\"Tests for webhook delivery system: SSRF validation, webhook execution, retry logic.\"\"\"\n\nimport json\nimport pytest\nimport socket\nfrom unittest.mock import patch, MagicMock, call\nfrom datetime import datetime\n\n\n# ---------------------------------------------------------------------------\n# SSRF Validator Tests\n# ---------------------------------------------------------------------------\n\nclass TestSSRFValidator:\n    \"\"\"Test SSRF URL validation blocks private/reserved IPs.\"\"\"\n\n    def test_ssrf_blocks_private_10(self):\n        from mqttui.rules.ssrf import is_ssrf_safe\n        safe, reason = is_ssrf_safe(\"http://10.0.0.1/hook\")\n        assert safe is False\n        assert \"private\" in reason.lower() or \"blocked\" in reason.lower()\n\n    def test_ssrf_blocks_private_172(self):\n        from mqttui.rules.ssrf import is_ssrf_safe\n        safe, reason = is_ssrf_safe(\"http://172.16.0.1/hook\")\n        assert safe is False\n\n    def test_ssrf_blocks_private_192(self):\n        from mqttui.rules.ssrf import is_ssrf_safe\n        safe, reason = is_ssrf_safe(\"http://192.168.1.1/hook\")\n        assert safe is False\n\n    def test_ssrf_blocks_localhost(self):\n        from mqttui.rules.ssrf import is_ssrf_safe\n        safe, reason = is_ssrf_safe(\"http://127.0.0.1/hook\")\n        assert safe is False\n\n    def test_ssrf_blocks_link_local(self):\n        from mqttui.rules.ssrf import is_ssrf_safe\n        safe, reason = is_ssrf_safe(\"http://169.254.1.1/hook\")\n        assert safe is False\n\n    @patch('socket.getaddrinfo', return_value=[\n        (socket.AF_INET, socket.SOCK_STREAM, 0, '', ('93.184.216.34', 443))\n    ])\n    def test_ssrf_allows_public(self, mock_dns):\n        from mqttui.rules.ssrf import is_ssrf_safe\n        safe, reason = is_ssrf_safe(\"https://hooks.example.com/hook\")\n        assert safe is True\n\n    def test_ssrf_blocks_ipv6_localhost(self):\n        from mqttui.rules.ssrf import is_ssrf_safe\n        safe, reason = is_ssrf_safe(\"http://[::1]/hook\")\n        assert safe is False\n\n\n# ---------------------------------------------------------------------------\n# Webhook Delivery Tests\n# ---------------------------------------------------------------------------\n\nclass TestWebhookDelivery:\n    \"\"\"Test webhook HTTP delivery, retry logic, and AlertHistory integration.\"\"\"\n\n    @patch('httpx.post')\n    def test_webhook_success(self, mock_post, app):\n        \"\"\"Successful webhook creates AlertHistory record with http_status=200.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.text = \"OK\"\n        mock_post.return_value = mock_response\n\n        from mqttui.rules.actions import _deliver_webhook\n\n        with app.app_context():\n            from mqttui.rules.models import AlertHistory\n            from mqttui.extensions import sa\n\n            result = _deliver_webhook(\n                url=\"https://hooks.example.com/hook\",\n                payload_json={\"topic\": \"test/topic\", \"payload\": \"hello\"},\n                rule_id=1,\n                rule_name=\"Test Rule\",\n                topic=\"test/topic\",\n            )\n            assert result[\"success\"] is True\n\n            record = AlertHistory.query.filter_by(rule_id=1).order_by(AlertHistory.id.desc()).first()\n            assert record is not None\n            assert record.http_status == 200\n            assert record.webhook_url == \"https://hooks.example.com/hook\"\n\n    @patch('httpx.post')\n    def test_webhook_retry_on_5xx(self, mock_post, app):\n        \"\"\"5xx responses trigger up to 3 retries.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 500\n        mock_response.text = \"Internal Server Error\"\n        mock_post.return_value = mock_response\n\n        from mqttui.rules.actions import _deliver_webhook\n\n        with app.app_context():\n            from mqttui.rules.models import AlertHistory\n\n            result = _deliver_webhook(\n                url=\"https://hooks.example.com/hook\",\n                payload_json={\"topic\": \"test/topic\"},\n                rule_id=2,\n                rule_name=\"Retry Rule\",\n                topic=\"test/topic\",\n                _sleep_fn=lambda x: None,  # Skip actual sleep in tests\n            )\n            assert result[\"success\"] is False\n            # 1 initial + 3 retries = 4 total calls\n            assert mock_post.call_count == 4\n\n            record = AlertHistory.query.filter_by(rule_id=2).order_by(AlertHistory.id.desc()).first()\n            assert record is not None\n            assert record.retry_count == 3\n            assert record.http_status == 500\n\n    @patch('httpx.post')\n    def test_webhook_no_retry_on_4xx(self, mock_post, app):\n        \"\"\"4xx responses fail immediately without retry.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 400\n        mock_response.text = \"Bad Request\"\n        mock_post.return_value = mock_response\n\n        from mqttui.rules.actions import _deliver_webhook\n\n        with app.app_context():\n            from mqttui.rules.models import AlertHistory\n\n            result = _deliver_webhook(\n                url=\"https://hooks.example.com/hook\",\n                payload_json={\"topic\": \"test/topic\"},\n                rule_id=3,\n                rule_name=\"NoRetry Rule\",\n                topic=\"test/topic\",\n            )\n            assert result[\"success\"] is False\n            assert mock_post.call_count == 1\n\n            record = AlertHistory.query.filter_by(rule_id=3).order_by(AlertHistory.id.desc()).first()\n            assert record is not None\n            assert record.retry_count == 0\n            assert record.http_status == 400\n\n    def test_webhook_payload_template(self, app):\n        \"\"\"Webhook with template substitutes {{topic}}, {{rule_name}} context values.\"\"\"\n        from mqttui.rules.actions import _build_webhook_payload\n\n        context = {\n            'rule_id': 1,\n            'rule_name': 'Temp Alert',\n            'topic': 'sensors/temp',\n            'payload': '{\"temp\": 42}',\n        }\n        template = '{\"alert\": \"{{rule_name}}\", \"on\": \"{{topic}}\"}'\n        result = _build_webhook_payload(template, context)\n        assert result[\"alert\"] == \"Temp Alert\"\n        assert result[\"on\"] == \"sensors/temp\"\n\n    @patch('httpx.post')\n    def test_webhook_runs_in_thread(self, mock_post, app):\n        \"\"\"Webhook delivery via execute_action returns immediately (async via thread pool).\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.text = \"OK\"\n        mock_post.return_value = mock_response\n\n        from mqttui.rules.actions import execute_action\n\n        with app.app_context():\n            action = {\"type\": \"webhook\", \"url\": \"https://hooks.example.com/hook\"}\n            context = {\n                \"rule_id\": 1,\n                \"rule_name\": \"Thread Rule\",\n                \"topic\": \"test/topic\",\n                \"payload\": \"hello\",\n            }\n            result = execute_action(action, context)\n            # Should return immediately with submission confirmation\n            assert result[\"success\"] is True\n            assert \"submitted\" in result[\"detail\"].lower() or \"delivery\" in result[\"detail\"].lower()\n\n\n# ---------------------------------------------------------------------------\n# SSRF Validation in Rule Endpoints\n# ---------------------------------------------------------------------------\n\nclass TestSSRFEndpoints:\n    \"\"\"Test SSRF validation in rule create/update API endpoints.\"\"\"\n\n    def test_create_rule_ssrf_blocked(self, auth_client, app):\n        \"\"\"POST /api/v1/rules/ with private webhook URL returns 400 SSRF_BLOCKED.\"\"\"\n        with app.app_context():\n            resp = auth_client.post('/api/v1/rules/', json={\n                'name': 'SSRF Test Rule',\n                'trigger_topic': 'test/#',\n                'action': {\n                    'type': 'webhook',\n                    'url': 'http://192.168.1.1/hook',\n                },\n            })\n            assert resp.status_code == 400\n            data = resp.get_json()\n            assert data['error']['code'] == 'SSRF_BLOCKED'\n\n    @patch('socket.getaddrinfo', return_value=[\n        (socket.AF_INET, socket.SOCK_STREAM, 0, '', ('93.184.216.34', 443))\n    ])\n    def test_create_rule_ssrf_allowed(self, mock_dns, auth_client, app):\n        \"\"\"POST /api/v1/rules/ with public webhook URL succeeds.\"\"\"\n        with app.app_context():\n            resp = auth_client.post('/api/v1/rules/', json={\n                'name': 'Public Webhook Rule',\n                'trigger_topic': 'test/#',\n                'action': {\n                    'type': 'webhook',\n                    'url': 'https://hooks.example.com/hook',\n                },\n            })\n            assert resp.status_code == 201\n\n    def test_update_rule_ssrf_blocked(self, auth_client, app):\n        \"\"\"PUT /api/v1/rules/<id> with private webhook URL returns 400.\"\"\"\n        with app.app_context():\n            from mqttui.rules.models import Rule\n            from mqttui.extensions import sa\n            import json\n\n            # Create a rule first\n            rule = Rule(\n                name='Updatable Rule',\n                trigger_topic='test/#',\n                action_json=json.dumps({'type': 'log', 'severity': 'info'}),\n            )\n            sa.session.add(rule)\n            sa.session.commit()\n            rule_id = rule.id\n\n            # Try to update with private webhook URL\n            resp = auth_client.put(f'/api/v1/rules/{rule_id}', json={\n                'action': {\n                    'type': 'webhook',\n                    'url': 'http://10.0.0.1/hook',\n                },\n            })\n            assert resp.status_code == 400\n            data = resp.get_json()\n            assert data['error']['code'] == 'SSRF_BLOCKED'\n"
  },
  {
    "path": "wsgi.py",
    "content": "from dotenv import load_dotenv\nload_dotenv()\n\nfrom mqttui.app import create_app\nfrom mqttui.extensions import socketio\n\napp = create_app()\n\nif __name__ == '__main__':\n    socketio.run(app, host=app.config['HOST'], port=app.config['PORT'], debug=app.config['DEBUG'])\n"
  }
]