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