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
This commit is contained in:
administrator 2026-06-24 11:16:16 +00:00
commit c4c7dd3f05
18 changed files with 2158 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
venv/
__pycache__/
*.pyc
.env
cluster-config/maxscale.cnf

129
README.md Normal file
View file

@ -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), 33073312 (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 |

378
app.py Normal file
View file

@ -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/<path:subpath>")
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)

13
cluster-config/server1.cnf Executable file
View file

@ -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

13
cluster-config/server2.cnf Executable file
View file

@ -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

13
cluster-config/server3.cnf Executable file
View file

@ -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

13
cluster-config/server4.cnf Executable file
View file

@ -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

13
cluster-config/server5.cnf Executable file
View file

@ -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

13
cluster-config/server6.cnf Executable file
View file

@ -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

93
deploy.sh Normal file
View file

@ -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"

197
engine.py Normal file
View file

@ -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

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
flask>=3.0,<4.0
pymysql>=1.1,<2.0
gunicorn>=22.0,<23.0

397
scripts/cluster.sh Executable file
View file

@ -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

View file

@ -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

View file

@ -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

329
static/dashboard.css Normal file
View file

@ -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; }
}

393
static/dashboard.js Normal file
View file

@ -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 = '<span class="term-line dim">MariaDB HA Monitor v2.0 — Ready</span>';
}
// ── 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 <span class="dc-badge cluster2">CL2</span>'
: 'Cluster 2 <span class="dc-badge cluster2">CL2</span>';
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();
});
})();

118
templates/dashboard.html Normal file
View file

@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MariaDB HA — Live Demo</title>
<link rel="stylesheet" href="/static/dashboard.css">
</head>
<body>
<div class="dash">
<!-- HEADER -->
<header class="dash-header">
<div class="header-left">
<h1 class="dash-title">MariaDB Enterprise</h1>
<p class="dash-subtitle">High Availability Live Demo</p>
</div>
<div class="header-right" id="header-stats">
<select class="mode-select" id="mode-select" title="Demo architecture mode">
<option value="dr">Primary + DR Site</option>
<option value="standalone">2 Independent Clusters</option>
</select>
<span class="stat-pill" id="pill-availability">100.00%</span>
<span class="stat-pill" id="pill-transactions">0 / 0 txn</span>
</div>
</header>
<!-- MAIN GRID -->
<main class="dash-grid">
<!-- LEFT: Application Monitor -->
<section class="panel panel-app">
<h2 class="panel-title">Application Monitor</h2>
<div class="app-status-row">
<div id="status-circle" class="circle-mini gray"></div>
<div class="app-status-info">
<p id="status-text" class="status-label-mini">Ready</p>
<span class="avail-inline" id="avail-inline"></span>
</div>
</div>
<!-- Terminal log -->
<div class="terminal" id="terminal">
<div class="terminal-inner" id="terminal-log">
<span class="term-line dim">MariaDB HA Monitor v2.0 — Ready</span>
<span class="term-line dim">Waiting for demo to start...</span>
</div>
</div>
</section>
<!-- CENTER: Cluster 1 -->
<section class="panel panel-dc">
<h2 class="panel-title">Cluster 1 <span class="dc-badge cluster1">CL1</span></h2>
<div class="server-grid" id="cluster1-servers">
<div class="server-card loading" data-node="mariadb1">
<span class="server-name">mariadb1</span>
<span class="server-role"></span>
<button class="server-toggle" data-node="mariadb1" title="Toggle ON/OFF"></button>
</div>
<div class="server-card loading" data-node="mariadb2">
<span class="server-name">mariadb2</span>
<span class="server-role"></span>
<button class="server-toggle" data-node="mariadb2" title="Toggle ON/OFF"></button>
</div>
<div class="server-card loading" data-node="mariadb3">
<span class="server-name">mariadb3</span>
<span class="server-role"></span>
<button class="server-toggle" data-node="mariadb3" title="Toggle ON/OFF"></button>
</div>
</div>
<div class="mx-status" id="mx1-status">
<span class="mx-label">MaxScale :4000</span>
<span class="mx-dot"></span>
</div>
</section>
<!-- RIGHT: Cluster 2 -->
<section class="panel panel-dc">
<h2 class="panel-title" id="cluster2-title">Cluster 2 <span class="dc-badge cluster2">CL2</span></h2>
<div class="server-grid" id="cluster2-servers">
<div class="server-card loading" data-node="mariadb4">
<span class="server-name">mariadb4</span>
<span class="server-role"></span>
<button class="server-toggle" data-node="mariadb4" title="Toggle ON/OFF"></button>
</div>
<div class="server-card loading" data-node="mariadb5">
<span class="server-name">mariadb5</span>
<span class="server-role"></span>
<button class="server-toggle" data-node="mariadb5" title="Toggle ON/OFF"></button>
</div>
<div class="server-card loading" data-node="mariadb6">
<span class="server-name">mariadb6</span>
<span class="server-role"></span>
<button class="server-toggle" data-node="mariadb6" title="Toggle ON/OFF"></button>
</div>
</div>
<div class="mx-status" id="mx2-status">
<span class="mx-label">MaxScale :4000</span>
<span class="mx-dot"></span>
</div>
</section>
</main>
<!-- ACTION BAR -->
<footer class="action-bar">
<button class="btn-action btn-init" id="btn-init" title="Create test database">
⚡ Initialize DB
</button>
<button class="btn-action btn-start" id="btn-toggle" disabled title="Begin transaction monitoring">
▶ Start Demo
</button>
<button class="btn-action btn-recover" id="btn-recover-all" title="Restart all nodes">
♻ Recover All
</button>
<span class="action-msg" id="action-msg"></span>
</footer>
</div>
<script src="/static/dashboard.js"></script>
</body>
</html>