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:
commit
c4c7dd3f05
18 changed files with 2158 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
cluster-config/maxscale.cnf
|
||||||
129
README.md
Normal file
129
README.md
Normal 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), 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 |
|
||||||
378
app.py
Normal file
378
app.py
Normal 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
13
cluster-config/server1.cnf
Executable 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
13
cluster-config/server2.cnf
Executable 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
13
cluster-config/server3.cnf
Executable 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
13
cluster-config/server4.cnf
Executable 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
13
cluster-config/server5.cnf
Executable 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
13
cluster-config/server6.cnf
Executable 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
93
deploy.sh
Normal 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
197
engine.py
Normal 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
3
requirements.txt
Normal 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
397
scripts/cluster.sh
Executable 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
|
||||||
18
scripts/mariadb-cluster.service
Normal file
18
scripts/mariadb-cluster.service
Normal 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
|
||||||
20
scripts/mariadb-monitor.service
Normal file
20
scripts/mariadb-monitor.service
Normal 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
329
static/dashboard.css
Normal 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
393
static/dashboard.js
Normal 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
118
templates/dashboard.html
Normal 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>
|
||||||
Loading…
Reference in a new issue