commit c4c7dd3f05899c99f9991431936e636b6bd476bc Author: administrator Date: Wed Jun 24 11:16:16 2026 +0000 chore: initial release — MariaDB HA Demo - 6-node MariaDB cluster with GTID replication - MaxScale 24.02 proxy with automatic failover - Flask dashboard with SSE transaction monitor - Per-server toggle controls + mode selector - Systemd services for auto-start on boot - One-command deploy.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a3bcc1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +venv/ +__pycache__/ +*.pyc +.env +cluster-config/maxscale.cnf diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb59455 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# MariaDB HA Demo + +Real-time high availability demonstration with a 6-node MariaDB cluster, MaxScale proxy, and live dashboard. + +--- + +## Quick Start + +```bash +git clone https://git.sechpoint.app/customer-engineering/mariadb-demo.git +cd mariadb-demo +bash deploy.sh +``` + +Open **http://{host}:5000** in your browser. + +--- + +## Architecture + +``` +┌─ Cluster 1 ─────────────┐ ┌─ Cluster 2 ─────────────┐ +│ mariadb1 PRIMARY │ │ mariadb4 SECONDARY │ +│ mariadb2 SECONDARY │ │ mariadb5 SECONDARY │ +│ mariadb3 SECONDARY │ │ mariadb6 SECONDARY │ +└─────────────────────────┘ └──────────────────────────┘ + │ │ + └─────── MaxScale ──────────┘ + (port 4000) + │ + ┌──────┴──────┐ + │ Dashboard │ + │ (port 5000)│ + └─────────────┘ +``` + +- **6 MariaDB nodes**: 2 clusters with GTID-based async replication +- **MaxScale 24.02**: read-write splitting, automatic failover +- **Dashboard**: real-time SSE transaction monitor with per-server toggle controls + +--- + +## Dashboard Controls + +| Action | How | +|--------|-----| +| Start transactions | Click **⚡ Initialize DB** then **▶ Start Demo** | +| Kill a server | Click the green/red circle on any server card | +| Kill entire cluster | Toggle all 3 servers in a cluster OFF | +| Recover | Click the circle again (red → green) | +| Switch mode | Use dropdown: **Primary + DR** or **2 Independent Clusters** | + +### What Visitors See +- **Terminal feed**: live INSERT→SELECT→DELETE transactions +- **Status circle**: green = healthy, red = transaction lost +- **Server cards**: PRIMARY (green), SECONDARY (blue), DOWN (red) +- **Availability %**: drops during failover, recovers automatically + +--- + +## Demo Scenarios + +### 1. Standard Failover +Kill the PRIMARY server — MaxScale promotes a SECONDARY. Availability drops briefly, then recovers. + +### 2. Cross-Cluster Failover (DR Mode) +Switch to **Primary + DR** mode. Kill all 3 servers in Cluster 1 — Cluster 2 takes over as PRIMARY. + +### 3. Independent Clusters +Switch to **2 Independent Clusters**. Each cluster has its own PRIMARY — kill one without affecting the other. + +### 4. MaxScale GUI +Open **http://{host}:5000/maxscale** — full MaxScale admin interface. Login: `admin` / `mariadb` + +--- + +## Systemd Services + +```bash +sudo systemctl start mariadb-cluster # Start cluster +sudo systemctl stop mariadb-cluster # Stop everything +sudo systemctl start mariadb-monitor # Start dashboard +bash scripts/cluster.sh status # Health check +``` + +Services auto-start on boot when installed via `deploy.sh`. + +--- + +## Requirements + +- Ubuntu 24.04+ / Debian 12+ +- 8 GB RAM, 4 CPU cores +- Docker installed (`deploy.sh` handles this automatically) +- Ports: 5000 (dashboard), 4000 (MaxScale), 8989 (MaxScale API), 3307–3312 (MariaDB) + +--- + +## Manual Deployment (without deploy.sh) + +```bash +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Start cluster +bash scripts/cluster.sh start + +# Start dashboard +python3 app.py +``` + +--- + +## Files + +| File | Purpose | +|------|---------| +| `engine.py` | Transaction engine (INSERT→SELECT→DELETE every 1s) | +| `app.py` | Flask app: routes, SSE endpoint, MaxScale proxy | +| `scripts/cluster.sh` | Docker cluster control (start/stop/status) | +| `scripts/mariadb-cluster.service` | Systemd unit for cluster | +| `scripts/mariadb-monitor.service` | Systemd unit for dashboard | +| `cluster-config/` | MariaDB config files (server1-6.cnf) | +| `templates/dashboard.html` | Single-page dashboard | +| `static/dashboard.js` | Dashboard logic + SSE consumer | +| `static/dashboard.css` | Dark theme styles | +| `deploy.sh` | One-command deployment | +| `requirements.txt` | Python dependencies | diff --git a/app.py b/app.py new file mode 100644 index 0000000..a1076f4 --- /dev/null +++ b/app.py @@ -0,0 +1,378 @@ +""" +Flask application — MariaDB HA connection monitor. + +Serves the web dashboard and bridges engine.py events to the browser +via Server-Sent Events (SSE). Single global DatabaseEngine instance. +""" +import json +import queue +import subprocess +import urllib.request +import urllib.error +import base64 +from flask import Flask, render_template, request, jsonify, Response + +from engine import DatabaseEngine + +app = Flask(__name__) +engine = DatabaseEngine() + +# ── Page Routes ────────────────────────────────────────── + +@app.route("/") +def index(): + """Serve the main dashboard — MariaDB HA Live Demo.""" + return render_template("dashboard.html") + + +# ── API Routes ─────────────────────────────────────────── + +@app.route("/api/initialize", methods=["POST"]) +def api_initialize(): + """Create the test database and table.""" + config = request.get_json(silent=True) or {} + _apply_config(config) + result = engine.initialize() + if "error" in result: + return jsonify(result), 500 + return jsonify({"status": "initialized"}) + +@app.route("/api/start", methods=["POST"]) +def api_start(): + """Begin the transaction monitoring loop.""" + config = request.get_json(silent=True) or {} + _apply_config(config) + if not engine.is_running: + engine.start() + return jsonify({"status": "started"}) + +@app.route("/api/stop", methods=["POST"]) +def api_stop(): + """Stop the monitoring loop.""" + engine.stop() + return jsonify({"status": "stopped"}) + +@app.route("/api/state", methods=["GET"]) +def api_state(): + """Return current engine state (for polling fallback).""" + return jsonify(engine.get_state()) + + +# ── Demo Dashboard API ─────────────────────────────────── + +@app.route("/api/maxscale/servers") +def api_maxscale_servers(): + """Proxy MaxScale REST API — returns server status for dashboard.""" + try: + req = urllib.request.Request("http://127.0.0.1:8989/v1/servers") + auth = base64.b64encode(b"monitor:monitor123").decode() + req.add_header("Authorization", f"Basic {auth}") + with urllib.request.urlopen(req, timeout=3) as resp: + data = json.loads(resp.read()) + servers = [] + for s in data.get("data", []): + attr = s["attributes"] + state = attr["state"] + # Enhance: if state is just "Running" but server has slave GTIDs, mark as Slave + if state.strip() == "Running": + gtid = attr.get("gtid_current_pos") or "" + # If GTID has multiple domains (e.g., "1-1-5,2-4-3"), it was a slave + if "," in gtid: + state = "Slave, Running" + servers.append({ + "id": s["id"], + "state": state, + "connections": attr["parameters"].get("connections", 0), + "address": attr["parameters"]["address"], + "port": attr["parameters"]["port"], + }) + return jsonify({"servers": servers}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/docker/status") +def api_docker_status(): + """Return running Docker container status for dashboard.""" + try: + result = subprocess.run( + ["docker", "ps", "--format", "{{.Names}}||{{.Status}}", + "--filter", "name=mariadb", "--filter", "name=maxscale"], + capture_output=True, text=True, timeout=5, + ) + containers = [] + for line in result.stdout.strip().split("\n"): + if "||" in line: + name, status = line.split("||", 1) + running = "Up" in status + containers.append({"name": name, "running": running, "status": status}) + return jsonify({"containers": containers}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/demo/action", methods=["POST"]) +def api_demo_action(): + """Execute a demo action: kill or recover a node.""" + data = request.get_json(silent=True) or {} + action = data.get("action", "") + node = data.get("node", "") + + allowed_nodes = ["mariadb1", "mariadb2", "mariadb3", + "mariadb4", "mariadb5", "mariadb6", + "maxscale1", "maxscale2"] + + if node and node not in allowed_nodes: + return jsonify({"error": f"Unknown node: {node}"}), 400 + + def _find_dc_master(dc_servers): + """Query MaxScale REST API to find the current master for a DC. + dc_servers: list of server IDs like ['server1','server2','server3']""" + try: + req = urllib.request.Request("http://127.0.0.1:8989/v1/servers") + auth = base64.b64encode(b"monitor:monitor123").decode() + req.add_header("Authorization", f"Basic {auth}") + with urllib.request.urlopen(req, timeout=3) as resp: + data = json.loads(resp.read()) + for s in data.get("data", []): + if s["id"] in dc_servers and "Master" in s["attributes"]["state"]: + return s["id"].replace("server", "mariadb") + except Exception: + pass + return None + + try: + if action == "kill" and node: + subprocess.run( + ["docker", "stop", "-t", "1", node], + capture_output=True, text=True, timeout=5, + ) + return jsonify({"status": "killed", "node": node}) + elif action == "recover" and node: + subprocess.run( + ["docker", "start", node], + capture_output=True, text=True, timeout=10, + ) + return jsonify({"status": "recovered", "node": node}) + elif action == "kill-dc1": + master = _find_dc_master(["server1", "server2", "server3"]) + target = master if master else "mariadb1" + subprocess.run( + ["docker", "stop", "-t", "1", target], + capture_output=True, text=True, timeout=5, + ) + return jsonify({"status": "killed", "node": target}) + elif action == "kill-dc2": + master = _find_dc_master(["server4", "server5", "server6"]) + target = master if master else "mariadb4" + subprocess.run( + ["docker", "stop", "-t", "1", target], + capture_output=True, text=True, timeout=5, + ) + return jsonify({"status": "killed", "node": target}) + elif action == "recover-dc1": + subprocess.run( + ["docker", "start", "mariadb1"], + capture_output=True, text=True, timeout=10, + ) + return jsonify({"status": "recovered", "node": "mariadb1"}) + elif action == "recover-dc2": + subprocess.run( + ["docker", "start", "mariadb4"], + capture_output=True, text=True, timeout=10, + ) + return jsonify({"status": "recovered", "node": "mariadb4"}) + elif action == "recover-all": + for n in allowed_nodes: + subprocess.run( + ["docker", "start", n], + capture_output=True, text=True, timeout=5, + ) + return jsonify({"status": "recovered", "node": "all"}) + else: + return jsonify({"error": f"Unknown action: {action}"}), 400 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/demo/mode", methods=["POST"]) +def api_demo_mode(): + """Switch demo architecture mode: 'dr' (DC2 replicas of DC1) or 'standalone' (independent DCs).""" + data = request.get_json(silent=True) or {} + mode = data.get("mode", "dr") + if mode not in ("dr", "standalone"): + return jsonify({"error": f"Unknown mode: {mode}"}), 400 + + try: + if mode == "dr": + # DC2 replicas follow DC1 master (mariadb1) — DR site + master_ip = subprocess.run( + ["docker", "inspect", "-f", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", "mariadb1"], + capture_output=True, text=True, timeout=5, + ).stdout.strip() + for port in [3310, 3311, 3312]: + subprocess.run( + ["mariadb", "-h", "127.0.0.1", "-P", str(port), "-u", "root", "-pmonitor123", "--skip-ssl", + "-e", f"STOP SLAVE; SET GLOBAL read_only=1; CHANGE MASTER TO MASTER_HOST='{master_ip}', MASTER_PORT=3306, MASTER_USER='repl', MASTER_PASSWORD='repl123', MASTER_USE_GTID=slave_pos; START SLAVE;"], + capture_output=True, text=True, timeout=10, + ) + else: + # standalone: DC2 has its own master (mariadb4 becomes independent master) + dc2_master_ip = subprocess.run( + ["docker", "inspect", "-f", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", "mariadb4"], + capture_output=True, text=True, timeout=5, + ).stdout.strip() + # mariadb4: stop replication, become standalone master (writable) + subprocess.run( + ["mariadb", "-h", "127.0.0.1", "-P", "3310", "-u", "root", "-pmonitor123", "--skip-ssl", + "-e", "STOP SLAVE; RESET SLAVE ALL; SET GLOBAL read_only=0; CREATE USER IF NOT EXISTS 'repl'@'%' IDENTIFIED BY 'repl123'; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%'; FLUSH PRIVILEGES;"], + capture_output=True, text=True, timeout=10, + ) + # mariadb5-6: replicate from mariadb4 + for port in [3311, 3312]: + subprocess.run( + ["mariadb", "-h", "127.0.0.1", "-P", str(port), "-u", "root", "-pmonitor123", "--skip-ssl", + "-e", f"STOP SLAVE; CHANGE MASTER TO MASTER_HOST='{dc2_master_ip}', MASTER_PORT=3306, MASTER_USER='repl', MASTER_PASSWORD='repl123', MASTER_USE_GTID=slave_pos; START SLAVE;"], + capture_output=True, text=True, timeout=10, + ) + return jsonify({"mode": mode, "status": "ok"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +# ── MaxScale GUI Reverse Proxy ──────────────────────────── +# Proxies /maxscale/* → MaxScale REST API + GUI on port 8989 +# Rewrites paths in HTML, JS, and CSS so the Vue.js GUI works behind /maxscale + +@app.route("/maxscale", defaults={"subpath": ""}) +@app.route("/maxscale/", defaults={"subpath": ""}) +@app.route("/maxscale/") +def proxy_maxscale(subpath): + """Reverse proxy to MaxScale with content path rewriting.""" + target_url = f"http://127.0.0.1:8989/{subpath}" + if request.query_string: + target_url += "?" + request.query_string.decode("utf-8") + + body = request.get_data() + headers = {} + for key, value in request.headers: + if key.lower() not in ("host", "connection"): + headers[key] = value + + try: + headers["Accept-Encoding"] = "identity" # prevent gzip so we can rewrite + req = urllib.request.Request(target_url, data=body, headers=headers, method=request.method) + with urllib.request.urlopen(req, timeout=30) as resp: + content = resp.read() + content_type = resp.headers.get("Content-Type", "") + + # Rewrite paths in text-based responses (and any JS/CSS regardless of content-type) + is_text = any(t in content_type for t in ("text/html", "application/javascript", + "text/css", "text/javascript")) + ext = subpath.rsplit(".", 1)[-1].lower() if "." in subpath else "" + if is_text or ext in ("js", "css", "html"): + text = content.decode("utf-8", errors="replace") + # Rewrite webpack public path to be relative + if '.p="/"' in text: + text = text.replace('.p="/"', '.p="./"') + # Rewrite axios base URL for API calls + if 'baseURL:"/"' in text: + text = text.replace('baseURL:"/"', 'baseURL:"/maxscale/"') + # Fix paths that already contain "maxscale" (would double with baseURL) + text = text.replace('"/maxscale?fields[maxscale]=version"', '"/?fields[maxscale]=version"') + text = text.replace('"/maxscale/', '"/maxscale/') # no-op, just for clarity + # Rewrite paths in HTML only (JS/CSS use baseURL and .p) + if "text/html" in content_type: + text = text.replace('"/js/', '"/maxscale/js/') + text = text.replace("'/js/", "'/maxscale/js/") + text = text.replace('"/css/', '"/maxscale/css/') + text = text.replace("'/css/", "'/maxscale/css/") + text = text.replace('"/img/', '"/maxscale/img/') + text = text.replace("'/img/", "'/maxscale/img/") + text = text.replace('"/fonts/', '"/maxscale/fonts/') + text = text.replace("'/fonts/", "'/maxscale/fonts/") + text = text.replace('"/v1/', '"/maxscale/v1/') + text = text.replace("'/v1/", "'/maxscale/v1/") + text = text.replace('href="/favicon', 'href="/maxscale/favicon') + text = text.replace('href="/apple', 'href="/maxscale/apple') + text = text.replace('href="/safari', 'href="/maxscale/safari') + content = text.encode("utf-8") + + response_headers = {} + for key, value in resp.headers.items(): + if key.lower() not in ("transfer-encoding", "connection", "content-length"): + response_headers[key] = value + + return Response(content, status=resp.status, headers=response_headers) + except urllib.error.HTTPError as e: + return Response(e.read(), status=e.code) + except Exception as e: + return jsonify({"error": str(e)}), 502 + + +# ── SSE Stream ─────────────────────────────────────────── + +@app.route("/api/events") +def api_events(): + """Server-Sent Events stream — pushes transaction results to browser.""" + event_queue = queue.Queue() + + def on_event(event): + event_queue.put(event) + + engine.on_event(on_event) + + def generate(): + try: + # Send initial state + yield _sse_event("connected", engine.get_state()) + while engine.is_running or not event_queue.empty(): + try: + event = event_queue.get(timeout=0.5) + yield _sse_event(event["type"], event) + except queue.Empty: + # Keep-alive comment to prevent proxy timeouts + yield ": keepalive\n\n" + yield _sse_event("stopped", {"status": "stopped"}) + except GeneratorExit: + pass + finally: + engine.remove_callback(on_event) + + return Response( + generate(), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +# ── Helpers ────────────────────────────────────────────── + +def _apply_config(config: dict) -> None: + """Apply connection settings from request body to engine.""" + if "host" in config: + engine.host = config["host"] + if "port" in config: + engine.port = int(config["port"]) + if "user" in config: + engine.user = config["user"] + if "password" in config: + engine.password = config["password"] + if "table_name" in config: + engine.table_name = config["table_name"] + + +def _sse_event(event_type: str, data: dict) -> str: + """Format a dictionary as an SSE event.""" + payload = json.dumps(data) + return f"event: {event_type}\ndata: {payload}\n\n" + + +# ── Entry Point ────────────────────────────────────────── + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, debug=True, threaded=True) diff --git a/cluster-config/server1.cnf b/cluster-config/server1.cnf new file mode 100755 index 0000000..5eb30b7 --- /dev/null +++ b/cluster-config/server1.cnf @@ -0,0 +1,13 @@ +[mariadb] +# Identifiant unique du serveur (obligatoire) +server_id = 1 +gtid-domain-id = 1 + +# Activation du journal binaire (nécessaire pour la réplication) +log_bin = mysql-bin + +# S'assurer que le serveur écoute sur l'IP de votre réseau (et non juste localhost) +bind-address = 0.0.0.0 +log_slave_updates +auto_increment_offset = 1 +auto_increment_increment = 2 diff --git a/cluster-config/server2.cnf b/cluster-config/server2.cnf new file mode 100755 index 0000000..9d3ca45 --- /dev/null +++ b/cluster-config/server2.cnf @@ -0,0 +1,13 @@ +[mariadb] +# Identifiant unique du serveur (obligatoire) +server_id = 2 +gtid-domain-id = 1 + +# Activation du journal binaire (nécessaire pour la réplication) +log_bin = mysql-bin + +# S'assurer que le serveur écoute sur l'IP de votre réseau (et non juste localhost) +bind-address = 0.0.0.0 +log_slave_updates +auto_increment_offset = 1 +auto_increment_increment = 2 diff --git a/cluster-config/server3.cnf b/cluster-config/server3.cnf new file mode 100755 index 0000000..2100e81 --- /dev/null +++ b/cluster-config/server3.cnf @@ -0,0 +1,13 @@ +[mariadb] +# Identifiant unique du serveur (obligatoire) +server_id = 3 +gtid-domain-id = 1 + +# Activation du journal binaire (nécessaire pour la réplication) +log_bin = mysql-bin + +# S'assurer que le serveur écoute sur l'IP de votre réseau (et non juste localhost) +bind-address = 0.0.0.0 +log_slave_updates +auto_increment_offset = 1 +auto_increment_increment = 2 diff --git a/cluster-config/server4.cnf b/cluster-config/server4.cnf new file mode 100755 index 0000000..b590177 --- /dev/null +++ b/cluster-config/server4.cnf @@ -0,0 +1,13 @@ +[mariadb] +# Identifiant unique du serveur (obligatoire) +server_id = 4 +gtid-domain-id = 2 + +# Activation du journal binaire (nécessaire pour la réplication) +log_bin = mysql-bin + +# S'assurer que le serveur écoute sur l'IP de votre réseau (et non juste localhost) +bind-address = 0.0.0.0 +log_slave_updates +auto_increment_offset = 2 +auto_increment_increment = 2 diff --git a/cluster-config/server5.cnf b/cluster-config/server5.cnf new file mode 100755 index 0000000..9c4e007 --- /dev/null +++ b/cluster-config/server5.cnf @@ -0,0 +1,13 @@ +[mariadb] +# Identifiant unique du serveur (obligatoire) +server_id = 5 +gtid-domain-id = 2 + +# Activation du journal binaire (nécessaire pour la réplication) +log_bin = mysql-bin + +# S'assurer que le serveur écoute sur l'IP de votre réseau (et non juste localhost) +bind-address = 0.0.0.0 +log_slave_updates +auto_increment_offset = 2 +auto_increment_increment = 2 diff --git a/cluster-config/server6.cnf b/cluster-config/server6.cnf new file mode 100755 index 0000000..a266434 --- /dev/null +++ b/cluster-config/server6.cnf @@ -0,0 +1,13 @@ +[mariadb] +# Identifiant unique du serveur (obligatoire) +server_id = 6 +gtid-domain-id = 2 + +# Activation du journal binaire (nécessaire pour la réplication) +log_bin = mysql-bin + +# S'assurer que le serveur écoute sur l'IP de votre réseau (et non juste localhost) +bind-address = 0.0.0.0 +log_slave_updates +auto_increment_offset = 2 +auto_increment_increment = 2 diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..0a0e75b --- /dev/null +++ b/deploy.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# ── MariaDB HA Demo — One-Command Deploy ──────────────── +# Installs dependencies, deploys MariaDB cluster, starts the dashboard. +# Run as: bash deploy.sh +set -euo pipefail + +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly DEMO_HOME="${SCRIPT_DIR}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' + +log() { echo -e "${GREEN}[✓]${NC} $1"; } +warn() { echo -e "${RED}[!]${NC} $1"; } +info() { echo -e "${BLUE}[i]${NC} $1"; } + +# ── Check prerequisites ────────────────────────────────── +command -v docker >/dev/null 2>&1 || { + warn "Docker not found. Installing..." + sudo apt-get update -qq && sudo apt-get install -y -qq docker.io docker-compose-v2 +} +command -v python3 >/dev/null 2>&1 || { + warn "Python3 not found. Installing..." + sudo apt-get install -y -qq python3 python3-pip python3-venv +} +command -v mariadb >/dev/null 2>&1 || { + warn "MariaDB client not found. Installing..." + sudo apt-get install -y -qq mariadb-client +} + +# ── Docker ─────────────────────────────────────────────── +if ! systemctl is-active --quiet docker; then + sudo systemctl enable --now docker + log "Docker started" +fi + +# ── Python venv ────────────────────────────────────────── +if [ ! -d "${DEMO_HOME}/venv" ]; then + info "Creating Python virtual environment..." + python3 -m venv "${DEMO_HOME}/venv" + "${DEMO_HOME}/venv/bin/pip" install -q -r "${DEMO_HOME}/requirements.txt" + log "Python dependencies installed" +fi + +# ── Docker images ──────────────────────────────────────── +info "Pulling Docker images..." +docker pull mariadb:latest -q 2>/dev/null & +docker pull mariadb/maxscale:latest -q 2>/dev/null & +wait +log "Docker images ready" + +# ── Cluster ────────────────────────────────────────────── +chmod +x "${DEMO_HOME}/scripts/cluster.sh" +"${DEMO_HOME}/scripts/cluster.sh" start + +# ── Systemd ────────────────────────────────────────────── +if [ -d /etc/systemd/system ]; then + sudo cp "${DEMO_HOME}/scripts/mariadb-cluster.service" /etc/systemd/system/ + sudo cp "${DEMO_HOME}/scripts/mariadb-monitor.service" /etc/systemd/system/ + # Update paths in service files + sudo sed -i "s|/home/engineer/mariadb-monitor|${DEMO_HOME}|g" \ + /etc/systemd/system/mariadb-cluster.service \ + /etc/systemd/system/mariadb-monitor.service + sudo systemctl daemon-reload + sudo systemctl enable mariadb-cluster.service mariadb-monitor.service 2>/dev/null || true + log "Systemd services installed (auto-start on boot)" +fi + +# ── Dashboard ──────────────────────────────────────────── +"${DEMO_HOME}/venv/bin/python3" "${DEMO_HOME}/app.py" & +DASHBOARD_PID=$! +sleep 3 + +echo "" +echo "╔══════════════════════════════════════════════════════════╗" +echo "║ MariaDB HA Demo — Ready ║" +echo "╠══════════════════════════════════════════════════════════╣" +echo "║ Dashboard: http://$(hostname -I | awk '{print $1}'):5000 ║" +echo "║ MaxScale GUI: http://$(hostname -I | awk '{print $1}'):5000/maxscale ║" +echo "║ MaxScale API: http://127.0.0.1:8989 ║" +echo "╠══════════════════════════════════════════════════════════╣" +echo "║ Cluster: 6 nodes (Cluster 1 + Cluster 2) ║" +echo "║ Proxy: MaxScale 24.02 (port 4000) ║" +echo "║ Monitor: HA-monitor + readwritesplit ║" +echo "╚══════════════════════════════════════════════════════════╝" +echo "" +echo " Stop: sudo systemctl stop mariadb-cluster" +echo " Start: sudo systemctl start mariadb-cluster && sudo systemctl start mariadb-monitor" +echo "" +echo " Dashboard PID: ${DASHBOARD_PID}" +echo " Logs: journalctl -u mariadb-cluster -f" diff --git a/engine.py b/engine.py new file mode 100644 index 0000000..05e5845 --- /dev/null +++ b/engine.py @@ -0,0 +1,197 @@ +""" +DatabaseEngine — pure Python transaction monitor for MariaDB HA demos. + +Mirrors the Swift DatabaseEngine behavior exactly: +- Fresh connection per transaction (matching MySQLStoreCoordinator pattern) +- INSERT → SELECT → DELETE cycle every 1 second +- Thread-safe state with SSE callback pattern + +Design: No Flask dependency — testable in isolation. +""" +import threading +import time +import pymysql + + +class DatabaseEngine: + """Manages the transaction monitoring loop and exposes events via callbacks.""" + + def __init__(self): + # Connection settings + self.host = "127.0.0.1" + self.port = 4000 + self.user = "root" + self.password = "monitor123" + self.table_name = "test_io" + self.database = "db_monitor_app" + + # State + self._lock = threading.Lock() + self._running = False + self._thread = None + self.total_tests = 0 + self.successful_tests = 0 + self._callbacks = [] + + # ── Public API ────────────────────────────────────────── + + def initialize(self) -> dict: + """Create database and table. Returns {'ok': True} or {'error': str}.""" + try: + conn = self._connect(database="") + with conn.cursor() as cur: + cur.execute(f"CREATE DATABASE IF NOT EXISTS {self.database}") + cur.execute(f"USE {self.database}") + cur.execute( + f"CREATE TABLE IF NOT EXISTS {self.table_name} " + f"(id INT AUTO_INCREMENT PRIMARY KEY, data_string VARCHAR(50))" + ) + conn.commit() + conn.close() + return {"ok": True} + except pymysql.Error as e: + return {"error": str(e)} + + def start(self) -> None: + """Begin the transaction monitoring loop in a background thread.""" + if self._running: + return + with self._lock: + self._running = True + self.total_tests = 0 + self.successful_tests = 0 + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop the monitoring loop.""" + self._running = False + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=2) + self._thread = None + + def on_event(self, callback) -> None: + """Register a callback for SSE events. Called as callback(event_dict).""" + self._callbacks.append(callback) + + def remove_callback(self, callback) -> None: + """Remove a previously registered callback.""" + if callback in self._callbacks: + self._callbacks.remove(callback) + + @property + def is_running(self) -> bool: + return self._running + + def get_state(self) -> dict: + """Snapshot of current state for polling clients.""" + with self._lock: + return { + "running": self._running, + "total": self.total_tests, + "successful": self.successful_tests, + "availability": self._availability(), + } + + # ── Internal: Connection ──────────────────────────────── + + def _connect(self, database: str | None = None) -> pymysql.Connection: + """Create a fresh MariaDB connection — matches Swift's per-transaction pattern.""" + db = database if database is not None else self.database + return pymysql.connect( + host=self.host, + port=int(self.port), + user=self.user, + password=self.password, + database=db, + connect_timeout=3, + read_timeout=5, + write_timeout=5, + autocommit=False, + ) + + # ── Internal: Transaction Loop ────────────────────────── + + def _run_loop(self) -> None: + """Background thread loop: run one transaction per second.""" + while self._running: + success = self._run_transaction() + self._update_stats(success) + time.sleep(1) + + def _run_transaction(self) -> bool: + """Execute INSERT → SELECT → DELETE as a single atomic cycle. + Returns True on success, False on any failure. + Creates a fresh connection each time (matching Swift behavior). + """ + conn = None + try: + conn = self._connect() + test_id = self.total_tests + 1 + + with conn.cursor() as cur: + # 1. INSERT + cur.execute( + f"INSERT INTO {self.table_name} (data_string) VALUES ('ping-{test_id}')" + ) + last_id = cur.lastrowid + + # 2. SELECT (verify the row exists) + cur.execute( + f"SELECT * FROM {self.table_name} WHERE id = {last_id}" + ) + cur.fetchall() + + # 3. DELETE (clean up, prevent table bloat) + cur.execute( + f"DELETE FROM {self.table_name} WHERE id = {last_id}" + ) + + conn.commit() + return True + + except pymysql.Error: + if conn: + try: + conn.rollback() + except Exception: + pass + return False + + finally: + if conn: + try: + conn.close() + except Exception: + pass + + # ── Internal: State & Notifications ───────────────────── + + def _update_stats(self, success: bool) -> None: + """Thread-safe stats update and SSE notification.""" + with self._lock: + self.total_tests += 1 + if success: + self.successful_tests += 1 + event = { + "type": "status", + "success": success, + "total": self.total_tests, + "successful": self.successful_tests, + "availability": self._availability(), + } + self._notify(event) + + def _availability(self) -> float: + """Calculate availability percentage. Returns 100.0 if no tests yet.""" + if self.total_tests == 0: + return 100.0 + return (self.successful_tests / self.total_tests) * 100.0 + + def _notify(self, event: dict) -> None: + """Push event to all registered SSE callbacks.""" + for cb in self._callbacks: + try: + cb(event) + except Exception: + pass diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b982223 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0,<4.0 +pymysql>=1.1,<2.0 +gunicorn>=22.0,<23.0 diff --git a/scripts/cluster.sh b/scripts/cluster.sh new file mode 100755 index 0000000..088466f --- /dev/null +++ b/scripts/cluster.sh @@ -0,0 +1,397 @@ +#!/usr/bin/env bash +# ── MariaDB HA Cluster Control ─────────────────────────── +# Manages 6 MariaDB nodes (DC1+DC2) + 2 MaxScale proxies. +# Idempotent: create if missing, start if stopped, skip if running. +# +# Usage: cluster.sh {start|stop|status} +# +# Persists across reboots — all volumes under $HOME, not /tmp. +set -euo pipefail + +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly CONFIG_DIR="${SCRIPT_DIR}/../cluster-config" + +# ── Container definitions ──────────────────────────────── +# Format: name|image|host_port|container_port|config_file|admin_port(optional) +readonly NODES_DC1=( + "mariadb1|mariadb:latest|3307|3306|${CONFIG_DIR}/server1.cnf" + "mariadb2|mariadb:latest|3308|3306|${CONFIG_DIR}/server2.cnf" + "mariadb3|mariadb:latest|3309|3306|${CONFIG_DIR}/server3.cnf" +) +readonly NODES_DC2=( + "mariadb4|mariadb:latest|3310|3306|${CONFIG_DIR}/server4.cnf" + "mariadb5|mariadb:latest|3311|3306|${CONFIG_DIR}/server5.cnf" + "mariadb6|mariadb:latest|3312|3306|${CONFIG_DIR}/server6.cnf" +) + +readonly PROXIES=( + "maxscale1|mariadb/maxscale:latest|4000|4000|${CONFIG_DIR}/maxscale.cnf|8989" +) + +readonly MARIADB_USER="root" +readonly MARIADB_PASS="monitor123" +readonly MAXSCALE_PORT=4000 +readonly MARIADB_TEST_PORT=3307 +readonly MAX_POLL_SECONDS=60 +readonly POLL_INTERVAL=2 + +# ── Pure helpers ───────────────────────────────────────── + +_container_exists() { + local name="$1" + docker ps -a --format '{{.Names}}' --filter "name=^${name}$" | grep -q . +} + +_container_running() { + local name="$1" + docker ps --format '{{.Names}}' --filter "name=^${name}$" | grep -q . +} + +_mariadb_healthy() { + mariadb -h 127.0.0.1 -P "${MARIADB_TEST_PORT}" \ + -u "${MARIADB_USER}" -p"${MARIADB_PASS}" \ + --skip-ssl -e "SELECT 1" &>/dev/null +} + +_maxscale_healthy() { + mariadb -h 127.0.0.1 -P "${MAXSCALE_PORT}" \ + -u "${MARIADB_USER}" -p"${MARIADB_PASS}" \ + --skip-ssl -e "SELECT 1" &>/dev/null +} + +_poll_health() { + local label="$1" + local check_fn="$2" + local elapsed=0 + + while (( elapsed < MAX_POLL_SECONDS )); do + if ${check_fn}; then + echo " [OK] ${label} ready (${elapsed}s)" + return 0 + fi + sleep "${POLL_INTERVAL}" + elapsed=$(( elapsed + POLL_INTERVAL )) + done + echo " [FAIL] ${label} not ready after ${MAX_POLL_SECONDS}s" >&2 + return 1 +} + +_create_node() { + local name="$1" image="$2" host_port="$3" cont_port="$4" config="$5" + + if _container_exists "${name}"; then + if _container_running "${name}"; then + echo " [SKIP] ${name} already running" + return 0 + fi + echo " [START] ${name} (existing container)" + docker start "${name}" >/dev/null + return 0 + fi + + echo " [CREATE] ${name} (port ${host_port})" + docker run -d --name "${name}" \ + -e MYSQL_ROOT_PASSWORD="${MARIADB_PASS}" \ + -p "${host_port}:${cont_port}" \ + -v "${config}:/etc/mysql/conf.d/seb.cnf:ro" \ + "${image}" >/dev/null +} + +_create_proxy() { + local name="$1" image="$2" host_port="$3" cont_port="$4" config="$5" admin_port="$6" + + if _container_exists "${name}"; then + if _container_running "${name}"; then + echo " [SKIP] ${name} already running" + return 0 + fi + echo " [START] ${name} (existing container)" + docker start "${name}" >/dev/null + return 0 + fi + + echo " [CREATE] ${name} (port ${host_port}, admin :${admin_port})" + docker run -d --name "${name}" \ + -p "${host_port}:${cont_port}" \ + -p "${admin_port}:8989" \ + -v "${config}:/etc/maxscale.cnf:ro" \ + "${image}" >/dev/null + + # Create REST API user after container is ready (only for first container) + if [[ "${name}" == "maxscale1" ]]; then + sleep 3 + docker exec "${name}" maxctrl create user monitor monitor123 --type=admin 2>/dev/null || true + fi +} + +# ── Commands ───────────────────────────────────────────── + +cmd_start() { + echo "=== MariaDB Cluster: Starting ===" + + # 1. Start all MariaDB nodes (DC1 + DC2) + for entry in "${NODES_DC1[@]}" "${NODES_DC2[@]}"; do + IFS='|' read -r name image host_port cont_port config <<< "${entry}" + _create_node "${name}" "${image}" "${host_port}" "${cont_port}" "${config}" + done + + # 2. Wait for at least one MariaDB node + echo " [WAIT] Polling MariaDB on :${MARIADB_TEST_PORT} ..." + _poll_health "MariaDB" _mariadb_healthy || exit 1 + + # 3. Generate MaxScale config (after all node IPs are known) + local mx_config="${CONFIG_DIR}/maxscale.cnf" + if ! _container_exists "maxscale1"; then + echo " [CONFIG] Generating ${mx_config}" + _generate_maxscale_config "${mx_config}" + fi + + # 4. Start MaxScale proxies + for entry in "${PROXIES[@]}"; do + IFS='|' read -r name image host_port cont_port config admin_port <<< "${entry}" + _create_proxy "${name}" "${image}" "${host_port}" "${cont_port}" "${config}" "${admin_port}" + done + + # 5. Wait for MaxScale + echo " [WAIT] Polling MaxScale on :${MAXSCALE_PORT} ..." + _poll_health "MaxScale" _maxscale_healthy || exit 1 + + # 6. Setup replication if needed (idempotent) + _setup_replication + + echo "=== Cluster ready ===" +} + +cmd_stop() { + echo "=== MariaDB Cluster: Stopping ===" + + # Stop proxies first, then nodes + local containers=() + for entry in "${PROXIES[@]}"; do + IFS='|' read -r name _ <<< "${entry}" + containers+=("${name}") + done + for entry in "${NODES_DC1[@]}" "${NODES_DC2[@]}"; do + IFS='|' read -r name _ <<< "${entry}" + containers+=("${name}") + done + + for name in "${containers[@]}"; do + if _container_running "${name}"; then + echo " [STOP] ${name}" + docker stop "${name}" >/dev/null + else + echo " [SKIP] ${name} not running" + fi + done + + echo "=== Cluster stopped ===" +} + +cmd_status() { + echo "=== MariaDB Cluster: Status ===" + echo "" + + echo "Cluster 1 (ports 3307-3309):" + for entry in "${NODES_DC1[@]}"; do + IFS='|' read -r name _ host_port _ _ <<< "${entry}" + if _container_running "${name}"; then + echo " ${name} :${host_port} [RUNNING]" + elif _container_exists "${name}"; then + echo " ${name} :${host_port} [STOPPED]" + else + echo " ${name} :${host_port} [MISSING]" + fi + done + + echo "" + echo "Cluster 2 (ports 3310-3312):" + for entry in "${NODES_DC2[@]}"; do + IFS='|' read -r name _ host_port _ _ <<< "${entry}" + if _container_running "${name}"; then + echo " ${name} :${host_port} [RUNNING]" + elif _container_exists "${name}"; then + echo " ${name} :${host_port} [STOPPED]" + else + echo " ${name} :${host_port} [MISSING]" + fi + done + + echo "" + echo "MaxScale:" + for entry in "${PROXIES[@]}"; do + IFS='|' read -r name _ host_port _ _ admin_port <<< "${entry}" + if _container_running "${name}"; then + echo " ${name} :${host_port} (admin :${admin_port}) [RUNNING]" + elif _container_exists "${name}"; then + echo " ${name} :${host_port} (admin :${admin_port}) [STOPPED]" + else + echo " ${name} :${host_port} (admin :${admin_port}) [MISSING]" + fi + done + + echo "" + if _maxscale_healthy; then + echo "Health: ALL HEALTHY" + elif _mariadb_healthy; then + echo "Health: MariaDB UP, MaxScale DOWN" + else + echo "Health: DOWN" + fi +} + +# ── MaxScale config generator ───────────────────────────── +# Unified config with DC1 (servers 1-3) + DC2 (servers 4-6) + +_generate_maxscale_config() { + local out="$1" + local ip1 ip2 ip3 ip4 ip5 ip6 + + ip1=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}' mariadb1 2>/dev/null || echo "172.17.0.2") + ip2=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}' mariadb2 2>/dev/null || echo "172.17.0.3") + ip3=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}' mariadb3 2>/dev/null || echo "172.17.0.4") + ip4=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}' mariadb4 2>/dev/null || echo "172.17.0.5") + ip5=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}' mariadb5 2>/dev/null || echo "172.17.0.6") + ip6=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}' mariadb6 2>/dev/null || echo "172.17.0.7") + + cat > "${out}" << EOF +[maxscale] +threads=auto +admin_host=0.0.0.0 +admin_port=8989 +admin_gui=true +admin_secure_gui=false + +[server1] +type=server +address=${ip1} +port=3306 +protocol=MariaDBBackend + +[server2] +type=server +address=${ip2} +port=3306 +protocol=MariaDBBackend + +[server3] +type=server +address=${ip3} +port=3306 +protocol=MariaDBBackend + +[server4] +type=server +address=${ip4} +port=3306 +protocol=MariaDBBackend + +[server5] +type=server +address=${ip5} +port=3306 +protocol=MariaDBBackend + +[server6] +type=server +address=${ip6} +port=3306 +protocol=MariaDBBackend + +[HA-monitor] +type=monitor +module=mariadbmon +servers=server1,server2,server3,server4,server5,server6 +user=${MARIADB_USER} +password=${MARIADB_PASS} +replication_user=repl +replication_password=repl123 +monitor_interval=500ms +backend_connect_timeout=1s +backend_read_timeout=1s +failcount=1 +verify_master_failure=false +master_failure_timeout=3000ms +auto_failover=true +auto_rejoin=true + +[HA-RWS] +type=service +router=readwritesplit +servers=server1,server2,server3,server4,server5,server6 +user=${MARIADB_USER} +password=${MARIADB_PASS} +enable_root_user=true + +[HA-port] +type=listener +service=HA-RWS +protocol=MariaDBClient +port=4000 +ssl=false +EOF +} + +# ── Replication setup ───────────────────────────────────── +# Idempotent: skips if replication already running + +_setup_replication() { + echo " [REPL] Setting up GTID replication..." + + local ip1 ip2 ip3 ip4 ip5 ip6 + ip1=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mariadb1 2>/dev/null || echo "") + ip2=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mariadb2 2>/dev/null || echo "") + ip3=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mariadb3 2>/dev/null || echo "") + ip4=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mariadb4 2>/dev/null || echo "") + ip5=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mariadb5 2>/dev/null || echo "") + ip6=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mariadb6 2>/dev/null || echo "") + + _mariadb_exec() { + local port="$1" sql="$2" + mariadb -h 127.0.0.1 -P "${port}" -u root -p"${MARIADB_PASS}" --skip-ssl -e "${sql}" 2>/dev/null + } + + _replication_running() { + local port="$1" + local status + status=$(_mariadb_exec "${port}" "SHOW SLAVE STATUS\G" 2>/dev/null) + echo "${status}" | grep -q "Slave_IO_Running: Yes" && \ + echo "${status}" | grep -q "Slave_SQL_Running: Yes" + } + + # DC1: mariadb1 → mariadb2, mariadb3 + _mariadb_exec 3307 "CREATE USER IF NOT EXISTS 'repl'@'%' IDENTIFIED BY 'repl123'; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%'; FLUSH PRIVILEGES;" + for port in 3308 3309; do + if _replication_running "${port}"; then + echo " [SKIP] Replication already running on :${port}" + continue + fi + _mariadb_exec "${port}" "STOP SLAVE;" + _mariadb_exec "${port}" "CHANGE MASTER TO MASTER_HOST='${ip1}', MASTER_PORT=3306, MASTER_USER='repl', MASTER_PASSWORD='repl123', MASTER_USE_GTID=slave_pos; START SLAVE;" + echo " [OK] Replication set up on :${port} → mariadb1" + done + + # DC2: mariadb1 → mariadb4, mariadb5, mariadb6 (cross-DC DR replicas) + # Replication user already created on mariadb1 above + for port in 3310 3311 3312; do + if _replication_running "${port}"; then + echo " [SKIP] Replication already running on :${port}" + continue + fi + _mariadb_exec "${port}" "STOP SLAVE;" + _mariadb_exec "${port}" "CHANGE MASTER TO MASTER_HOST='${ip1}', MASTER_PORT=3306, MASTER_USER='repl', MASTER_PASSWORD='repl123', MASTER_USE_GTID=slave_pos; START SLAVE;" + echo " [OK] Replication set up on :${port} → mariadb1 (DC1 master)" + done +} + +# ── Dispatch ────────────────────────────────────────────── + +case "${1:-}" in + start) cmd_start ;; + stop) cmd_stop ;; + status) cmd_status ;; + *) + echo "Usage: $0 {start|stop|status}" >&2 + exit 1 + ;; +esac diff --git a/scripts/mariadb-cluster.service b/scripts/mariadb-cluster.service new file mode 100644 index 0000000..d98f392 --- /dev/null +++ b/scripts/mariadb-cluster.service @@ -0,0 +1,18 @@ +[Unit] +Description=MariaDB HA Demo Cluster (6 nodes + MaxScale proxy) +Documentation=https://github.com/giraudseb/MariaDB_geocluster_docker_demo +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +User=engineer +ExecStart=/home/engineer/mariadb-monitor/scripts/cluster.sh start +ExecStop=/home/engineer/mariadb-monitor/scripts/cluster.sh stop +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/scripts/mariadb-monitor.service b/scripts/mariadb-monitor.service new file mode 100644 index 0000000..7452ecd --- /dev/null +++ b/scripts/mariadb-monitor.service @@ -0,0 +1,20 @@ +[Unit] +Description=MariaDB HA Monitor Web Dashboard +Documentation=https://github.com/engineer/mariadb-monitor +Requires=mariadb-cluster.service +After=mariadb-cluster.service + +[Service] +Type=simple +User=engineer +WorkingDirectory=/home/engineer/mariadb-monitor +ExecStart=/home/engineer/mariadb-monitor/venv/bin/python3 /home/engineer/mariadb-monitor/app.py +ExecStop=/bin/kill -TERM $MAINPID +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal +Environment=FLASK_DEBUG=0 + +[Install] +WantedBy=multi-user.target diff --git a/static/dashboard.css b/static/dashboard.css new file mode 100644 index 0000000..d8d068d --- /dev/null +++ b/static/dashboard.css @@ -0,0 +1,329 @@ +/* ── Dashboard: MariaDB HA Demo ──────────────────────── */ +:root { + --bg: #0a0c10; + --panel-bg: #13161f; + --border: #1e2332; + --text: #c9d1d9; + --text-dim: #5a6270; + --green: #3fb950; + --green-glow: rgba(63, 185, 80, 0.3); + --red: #f85149; + --red-glow: rgba(248, 81, 73, 0.3); + --blue: #58a6ff; + --orange: #d2991d; + --radius: 12px; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + min-height: 100vh; + overflow-x: hidden; +} + +.dash { + display: flex; + flex-direction: column; + height: 100vh; + padding: 12px 16px; + gap: 10px; +} + +/* ── Header ────────────────────────────────────────── */ +.dash-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 20px; + background: var(--panel-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + flex-shrink: 0; +} + +.header-left { display: flex; flex-direction: column; gap: 2px; } +.dash-title { font-size: 1.35rem; color: #fff; font-weight: 700; letter-spacing: 0.5px; } +.dash-subtitle { font-size: 0.78rem; color: var(--text-dim); } + +.header-right { display: flex; gap: 12px; align-items: center; } +.stat-pill { + background: #1a1f2e; + border: 1px solid var(--border); + padding: 8px 18px; + border-radius: 20px; + font-size: 1rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + min-width: 110px; + text-align: center; +} +#pill-availability { color: var(--blue); } +#pill-availability.warning { color: var(--red); } +#pill-transactions { color: var(--text); } + +/* ── Mode Selector ──────────────────────────────────── */ +.mode-select { + background: #1a1f2e; + border: 1px solid var(--border); + color: var(--text); + padding: 8px 14px; + border-radius: 20px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + outline: none; + min-width: 160px; +} +.mode-select:focus { border-color: var(--blue); } +.mode-select:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ── Main Grid ──────────────────────────────────────── */ +.dash-grid { + display: grid; + grid-template-columns: 1fr 1.3fr 1.3fr; + gap: 10px; + flex: 1; + min-height: 0; +} + +/* ── Panels ─────────────────────────────────────────── */ +.panel { + background: var(--panel-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + display: flex; + flex-direction: column; + overflow: hidden; +} +.panel-title { + font-size: 0.9rem; + font-weight: 600; + color: #fff; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} +.dc-badge { + font-size: 0.7rem; + padding: 2px 10px; + border-radius: 10px; + font-weight: 700; + letter-spacing: 1px; +} +.cluster1 { background: rgba(88,166,255,0.2); color: var(--blue); } +.cluster2 { background: rgba(210,153,29,0.2); color: var(--orange); } + +/* ── App Panel (compact header + terminal) ──────────── */ +.panel-app { align-items: stretch; } +.app-status-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 10px; +} +.circle-mini { + width: 48px; height: 48px; + border-radius: 50%; + flex-shrink: 0; + transition: background 0.25s, box-shadow 0.25s; +} +.circle-mini.gray { background: #2a2f3a; box-shadow: 0 0 12px rgba(42,47,58,0.3); } +.circle-mini.green { + background: var(--green); + box-shadow: 0 0 24px var(--green-glow); + animation: pulse-green 1.5s ease-in-out infinite; +} +.circle-mini.red { + background: var(--red); + box-shadow: 0 0 24px var(--red-glow); + animation: pulse-red 0.8s ease-in-out infinite; +} +.app-status-info { + display: flex; + flex-direction: column; + gap: 2px; +} +.status-label-mini { font-size: 0.85rem; font-weight: 600; } +.status-label-mini.error { color: var(--red); } +.avail-inline { + font-size: 1.4rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--blue); +} +.avail-inline.warning { color: var(--red); } + +/* ── Server Cards ───────────────────────────────────── */ +.server-grid { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; +} +.server-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #181c26; + border: 1px solid var(--border); + border-radius: 8px; + transition: border-color 0.3s, background 0.3s; + position: relative; +} +.server-card.primary { border-color: var(--green); border-left: 3px solid var(--green); } +.server-card.secondary { border-color: var(--blue); border-left: 3px solid var(--blue); } +.server-card.down { border-color: var(--red); border-left: 3px solid var(--red); background: rgba(248,81,73,0.08); } +.server-card.loading { opacity: 0.5; } + +.server-name { font-size: 0.9rem; font-weight: 600; } +.server-role { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; +} +.server-card.primary .server-role { color: var(--green); } +.server-card.secondary .server-role { color: var(--blue); } +.server-card.down .server-role { color: var(--red); } + +.server-dot { + width: 10px; height: 10px; + border-radius: 50%; + transition: background 0.3s; +} +.server-card.primary .server-dot { background: var(--green); box-shadow: 0 0 8px var(--green-glow); } +.server-card.secondary .server-dot { background: var(--blue); box-shadow: 0 0 8px rgba(88,166,255,0.3); } +.server-card.down .server-dot { background: var(--red); box-shadow: 0 0 8px var(--red-glow); animation: pulse-red 1s infinite; } +.server-card.loading .server-dot { background: var(--text-dim); } + +/* ── Server Toggle Buttons ──────────────────────────── */ +.server-toggle { + width: 28px; height: 28px; + border-radius: 50%; + border: 2px solid var(--border); + background: var(--text-dim); + cursor: pointer; + transition: background 0.25s, border-color 0.25s, box-shadow 0.25s; + padding: 0; + outline: none; + flex-shrink: 0; +} +.server-toggle.on { + background: var(--green); + border-color: var(--green); + box-shadow: 0 0 10px var(--green-glow); +} +.server-toggle.off { + background: var(--red); + border-color: var(--red); + box-shadow: 0 0 10px var(--red-glow); + animation: pulse-red 1.5s ease-in-out infinite; +} +.server-toggle:disabled { opacity: 0.5; cursor: wait; } +.server-toggle:hover:not(:disabled) { transform: scale(1.15); } + +/* ── Terminal ────────────────────────────────────────── */ +.terminal { + flex: 1; + min-height: 0; + background: #05070a; + border: 1px solid #141822; + border-radius: 6px; + padding: 10px 14px; + overflow-y: auto; + font-family: "SF Mono", "Cascadia Code", "Fira Code", "Consolas", monospace; + font-size: 0.78rem; + line-height: 1.55; +} +.terminal::-webkit-scrollbar { width: 6px; } +.terminal::-webkit-scrollbar-thumb { background: #1a1f2e; border-radius: 3px; } +.terminal-inner { display: flex; flex-direction: column; } +.term-line { white-space: nowrap; padding: 1px 0; } +.term-line.dim { color: #3a4050; } +.term-line.ok { color: #4ade80; } +.term-line.fail { color: #f87171; font-weight: 700; } + +/* ── Animations ──────────────────────────────────────── */ +@keyframes pulse-green { + 0%, 100% { box-shadow: 0 0 20px var(--green-glow); } + 50% { box-shadow: 0 0 40px rgba(63, 185, 80, 0.35); } +} +@keyframes pulse-red { + 0%, 100% { box-shadow: 0 0 20px var(--red-glow); } + 50% { box-shadow: 0 0 40px rgba(248, 81, 73, 0.4); } +} + +/* ── MaxScale Status ────────────────────────────────── */ +.mx-status { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + margin-top: 10px; + background: #181c26; + border: 1px solid var(--border); + border-radius: 8px; +} +.mx-label { font-size: 0.8rem; color: var(--text-dim); } +.mx-dot { + width: 10px; height: 10px; + border-radius: 50%; + background: var(--green); + box-shadow: 0 0 8px var(--green-glow); +} +.mx-dot.offline { background: var(--red); box-shadow: 0 0 8px var(--red-glow); } + +/* ── Action Bar ─────────────────────────────────────── */ +.action-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + background: var(--panel-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + flex-shrink: 0; + flex-wrap: wrap; +} +.btn-action { + padding: 10px 20px; + border: none; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 700; + cursor: pointer; + transition: transform 0.15s, opacity 0.2s, background 0.2s; + white-space: nowrap; + letter-spacing: 0.3px; +} +.btn-action:hover:not(:disabled) { transform: scale(1.03); } +.btn-action:disabled { opacity: 0.35; cursor: not-allowed; transform: none; } +.btn-action:active:not(:disabled) { transform: scale(0.97); } + +.btn-init { background: #1e2332; color: var(--blue); border: 1px solid var(--blue); } +.btn-start { background: var(--green); color: #000; } +.btn-stop { background: var(--red); color: #fff; } +.btn-kill { background: #3d1a1a; color: var(--red); border: 1px solid var(--red); } +.btn-recover { background: #1a2e1a; color: var(--green); border: 1px solid var(--green); } + +.action-msg { + font-size: 0.8rem; + color: var(--text-dim); + margin-left: auto; + min-width: 160px; + text-align: right; +} + +/* ── Responsive: stack on small screens ─────────────── */ +@media (max-width: 1100px) { + .dash-grid { grid-template-columns: 1fr; } + .dash { height: auto; } + .action-bar { justify-content: center; } +} diff --git a/static/dashboard.js b/static/dashboard.js new file mode 100644 index 0000000..fa2d854 --- /dev/null +++ b/static/dashboard.js @@ -0,0 +1,393 @@ +/** + * MariaDB HA Demo Dashboard + * SSE-driven transaction monitor + per-server toggle buttons + MaxScale polling. + */ +(function () { + "use strict"; + + const $ = (sel) => document.querySelector(sel); + const $$ = (sel) => document.querySelectorAll(sel); + + // ── DOM refs ──────────────────────────────────────── + const statusCircle = $("#status-circle"); + const statusText = $("#status-text"); + const availInline = $("#avail-inline"); + const terminalLog = $("#terminal-log"); + const terminal = $("#terminal"); + const pillAvail = $("#pill-availability"); + const pillTxn = $("#pill-transactions"); + const actionMsg = $("#action-msg"); + const btnInit = $("#btn-init"); + const btnToggle = $("#btn-toggle"); + const modeSelect = $("#mode-select"); + const cluster2Title = $("#cluster2-title"); + const allToggles = $$(".server-toggle"); + + // ── State ─────────────────────────────────────────── + let eventSource = null; + let isRunning = false; + let isInitialized = false; + let maxscalePollTimer = null; + let serverStates = {}; // { mariadb1: "Primary", mariadb2: "Secondary", ... } + + // ── Config (matching demo cluster) ────────────────── + const config = { + host: "127.0.0.1", + port: "4000", + user: "root", + password: "monitor123", + table_name: "test_io", + }; + + // ── Helpers ────────────────────────────────────────── + function postJSON(url, body) { + return fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } + + function showMsg(msg, duration) { + actionMsg.textContent = msg; + if (duration) setTimeout(() => { if (actionMsg.textContent === msg) actionMsg.textContent = ""; }, duration); + } + + // ── UI Updates ────────────────────────────────────── + function updateHeader(data) { + pillAvail.textContent = data.availability.toFixed(2) + "%"; + pillTxn.textContent = data.successful + " / " + data.total + " txn"; + pillAvail.classList.toggle("warning", data.availability < 99.0); + } + + function updateAppPanel(data) { + if (data.type === "status") { + if (data.success) { + statusCircle.className = "circle-mini green"; + statusText.className = "status-label-mini"; + statusText.textContent = "Transaction OK"; + _appendTerminal("TX #" + data.total + " ✓ OK", "ok"); + } else { + statusCircle.className = "circle-mini red"; + statusText.className = "status-label-mini error"; + statusText.textContent = "TRANSACTION LOST!"; + _appendTerminal("TX #" + data.total + " ✗ LOST", "fail"); + } + availInline.textContent = data.availability.toFixed(2) + "%"; + availInline.classList.toggle("warning", data.availability < 99.0); + updateHeader(data); + } + } + + function resetAppPanel() { + statusCircle.className = "circle-mini gray"; + statusText.className = "status-label-mini"; + statusText.textContent = "Ready"; + availInline.textContent = "—"; + availInline.classList.remove("warning"); + pillAvail.textContent = "100.00%"; + pillAvail.classList.remove("warning"); + pillTxn.textContent = "0 / 0 txn"; + terminalLog.innerHTML = 'MariaDB HA Monitor v2.0 — Ready'; + } + + // ── Terminal Log ──────────────────────────────────── + const MAX_TERM_LINES = 40; + + function _appendTerminal(msg, cls) { + const now = new Date(); + const ts = now.toTimeString().slice(0, 8); + const line = document.createElement("span"); + line.className = "term-line " + cls; + line.textContent = "[" + ts + "] " + msg; + terminalLog.appendChild(line); + + // Trim old lines + while (terminalLog.children.length > MAX_TERM_LINES + 2) { + terminalLog.removeChild(terminalLog.children[1]); // keep header + } + // Auto-scroll + terminal.scrollTop = terminal.scrollHeight; + } + + // ── Server Cards & Toggles ────────────────────────── + function updateServerCards(servers) { + servers.forEach((s) => { + const nodeName = s.id.replace("server", "mariadb"); + const card = document.querySelector(`.server-card[data-node="${nodeName}"]`); + if (!card) return; + + card.classList.remove("loading", "primary", "secondary", "down"); + const roleEl = card.querySelector(".server-role"); + const toggle = card.querySelector(".server-toggle"); + + if (s.state.includes("Down")) { + card.classList.add("down"); + roleEl.textContent = "DOWN"; + serverStates[nodeName] = "Down"; + if (toggle) { + toggle.className = "server-toggle off"; + toggle.title = "Click to reconnect"; + } + } else if (s.state.includes("Master")) { + card.classList.add("primary"); + roleEl.textContent = "PRIMARY"; + serverStates[nodeName] = "Primary"; + if (toggle) { + toggle.className = "server-toggle on"; + toggle.title = "Click to disconnect"; + } + } else if (s.state.includes("Slave")) { + card.classList.add("secondary"); + roleEl.textContent = "SECONDARY"; + serverStates[nodeName] = "Secondary"; + if (toggle) { + toggle.className = "server-toggle on"; + toggle.title = "Click to disconnect"; + } + } else { + // Fallback: if state is ambiguous "Running", preserve last known role + const prev = serverStates[nodeName]; + if (prev === "Primary") { + card.classList.add("primary"); + roleEl.textContent = "PRIMARY"; + } else if (prev === "Secondary") { + card.classList.add("secondary"); + roleEl.textContent = "SECONDARY"; + } else if (prev === "Down") { + card.classList.add("down"); + roleEl.textContent = "DOWN"; + } else { + roleEl.textContent = s.state; + } + serverStates[nodeName] = s.state; + if (toggle) { + toggle.className = "server-toggle on"; + toggle.title = "Click to disconnect"; + } + } + }); + + // MaxScale status dots + const cluster1Alive = servers.some(s => ["server1","server2","server3"].includes(s.id) && !s.state.includes("Down")); + const cluster2Alive = servers.some(s => ["server4","server5","server6"].includes(s.id) && !s.state.includes("Down")); + const mx1Dot = document.querySelector("#mx1-status .mx-dot"); + const mx2Dot = document.querySelector("#mx2-status .mx-dot"); + if (mx1Dot) mx1Dot.classList.toggle("offline", !cluster1Alive); + if (mx2Dot) mx2Dot.classList.toggle("offline", !cluster2Alive); + } + + // ── Toggle Handler ────────────────────────────────── + async function toggleServer(nodeName) { + const isDown = serverStates[nodeName] === "Down"; + const action = isDown ? "recover" : "kill"; + const label = isDown ? "Reconnecting " + nodeName : "Disconnecting " + nodeName; + + // Update toggle immediately for responsive feel + const toggle = document.querySelector(`.server-toggle[data-node="${nodeName}"]`); + if (toggle) { + toggle.className = isDown ? "server-toggle on" : "server-toggle off"; + toggle.disabled = true; + } + showMsg(label + "..."); + + try { + const resp = await postJSON("/api/demo/action", { action, node: nodeName }); + if (resp.ok) { + showMsg(nodeName + " " + (isDown ? "reconnected" : "disconnected"), 3000); + // Poll MaxScale immediately for updated state + setTimeout(pollMaxScale, 800); + setTimeout(pollMaxScale, 2000); + setTimeout(pollMaxScale, 4000); + } else { + showMsg("Error toggling " + nodeName); + if (toggle) toggle.disabled = false; + } + } catch (err) { + showMsg("Error: " + err.message); + if (toggle) toggle.disabled = false; + } + } + + // ── MaxScale Polling ───────────────────────────────── + async function pollMaxScale() { + try { + const resp = await fetch("/api/maxscale/servers"); + if (resp.ok) { + const data = await resp.json(); + if (data.servers) updateServerCards(data.servers); + } + } catch (e) { /* silent */ } + } + + // ── SSE ────────────────────────────────────────────── + function connectSSE() { + if (eventSource) eventSource.close(); + eventSource = new EventSource("/api/events"); + + eventSource.addEventListener("connected", (e) => { + const data = JSON.parse(e.data); + updateHeader(data); + isRunning = true; + updateUIForRunning(); + }); + + eventSource.addEventListener("status", (e) => { + updateAppPanel(JSON.parse(e.data)); + }); + + eventSource.addEventListener("stopped", () => { + disconnectSSE(); + isRunning = false; + updateUIForStopped(); + }); + + eventSource.onerror = () => { + if (!isRunning) disconnectSSE(); + }; + } + + function disconnectSSE() { + if (eventSource) { eventSource.close(); eventSource = null; } + } + + // ── UI State ───────────────────────────────────────── + function updateUIForRunning() { + btnToggle.textContent = "⏸ Stop Demo"; + btnToggle.className = "btn-action btn-stop"; + btnToggle.disabled = false; + btnInit.disabled = true; + } + + function updateUIForStopped() { + btnToggle.textContent = "▶ Start Demo"; + btnToggle.className = "btn-action btn-start"; + btnToggle.disabled = !isInitialized; + btnInit.disabled = isRunning; + resetAppPanel(); + } + + function updateUIForInitialized() { + btnInit.disabled = true; + btnToggle.disabled = false; + btnToggle.textContent = "▶ Start Demo"; + btnToggle.className = "btn-action btn-start"; + } + + // ── Button Handlers ────────────────────────────────── + btnInit.addEventListener("click", async () => { + if (isRunning) return; + btnInit.disabled = true; + showMsg("Initializing database..."); + try { + const resp = await postJSON("/api/initialize", config); + if (resp.ok) { + isInitialized = true; + updateUIForInitialized(); + showMsg("Database ready!", 3000); + } else { + const data = await resp.json(); + showMsg("Error: " + (data.error || "init failed")); + btnInit.disabled = false; + } + } catch (err) { + showMsg("Error: " + err.message); + btnInit.disabled = false; + } + }); + + btnToggle.addEventListener("click", async () => { + if (isRunning) { + btnToggle.disabled = true; + isRunning = false; + showMsg("Stopping..."); + await postJSON("/api/stop", {}); + disconnectSSE(); + updateUIForStopped(); + showMsg("Demo stopped", 3000); + } else { + if (!isInitialized) return; + btnToggle.disabled = true; + showMsg("Starting demo...", 2000); + const resp = await postJSON("/api/start", config); + if (resp.ok) { + connectSSE(); + } else { + btnToggle.disabled = false; + showMsg("Failed to start"); + } + } + }); + + // Recover all button + const btnRecoverAll = $("#btn-recover-all"); + btnRecoverAll.addEventListener("click", async () => { + showMsg("Recovering all nodes..."); + try { + const resp = await postJSON("/api/demo/action", { action: "recover-all", node: "" }); + if (resp.ok) { + showMsg("All nodes recovered!", 3000); + setTimeout(pollMaxScale, 1000); + setTimeout(pollMaxScale, 3000); + } else { + showMsg("Error recovering"); + } + } catch (err) { + showMsg("Error: " + err.message); + } + }); + + // ── Per-server toggle listeners ───────────────────── + allToggles.forEach((toggle) => { + toggle.addEventListener("click", () => { + const node = toggle.dataset.node; + if (node) toggleServer(node); + }); + }); + + // ── Polling ────────────────────────────────────────── + function startPolling() { + pollMaxScale(); + maxscalePollTimer = setInterval(pollMaxScale, 2500); + } + + function stopPolling() { + if (maxscalePollTimer) clearInterval(maxscalePollTimer); + } + + // ── Mode Selector ──────────────────────────────────── + modeSelect.addEventListener("change", async () => { + const mode = modeSelect.value; + showMsg("Switching to " + (mode === "dr" ? "Primary + DR" : "2 Independent Clusters") + "..."); + modeSelect.disabled = true; + + try { + const resp = await postJSON("/api/demo/mode", { mode }); + if (resp.ok) { + // Update Cluster 2 title + cluster2Title.innerHTML = mode === "dr" + ? 'DR Site CL2' + : 'Cluster 2 CL2'; + showMsg("Mode switched!", 3000); + // Poll MaxScale for updated topology + setTimeout(pollMaxScale, 1500); + setTimeout(pollMaxScale, 4000); + } else { + const data = await resp.json(); + showMsg("Error: " + (data.error || "switch failed")); + } + } catch (err) { + showMsg("Error: " + err.message); + } finally { + modeSelect.disabled = false; + } + }); + updateUIForStopped(); + startPolling(); + showMsg("Ready — click ⚡ Initialize DB to begin", 5000); + + window.addEventListener("beforeunload", () => { + disconnectSSE(); + stopPolling(); + }); +})(); diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..aa688f9 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,118 @@ + + + + + + MariaDB HA — Live Demo + + + +
+ +
+
+

MariaDB Enterprise

+

High Availability Live Demo

+
+
+ + 100.00% + 0 / 0 txn +
+
+ + +
+ +
+

Application Monitor

+
+
+
+

Ready

+ +
+
+ +
+
+ MariaDB HA Monitor v2.0 — Ready + Waiting for demo to start... +
+
+
+ + +
+

Cluster 1 CL1

+
+
+ mariadb1 + + +
+
+ mariadb2 + + +
+
+ mariadb3 + + +
+
+
+ MaxScale :4000 + +
+
+ + +
+

Cluster 2 CL2

+
+
+ mariadb4 + + +
+
+ mariadb5 + + +
+
+ mariadb6 + + +
+
+
+ MaxScale :4000 + +
+
+
+ + +
+ + + + +
+
+ + + +