- 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
393 lines
16 KiB
JavaScript
393 lines
16 KiB
JavaScript
/**
|
|
* 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();
|
|
});
|
|
})();
|