mariadb-demo/static/dashboard.js
administrator c4c7dd3f05 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
2026-06-24 11:16:16 +00:00

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();
});
})();