/** * 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 = 'MariaDB HA Monitor v2.0 — Ready'; } // ── 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 CL2' : 'Cluster 2 CL2'; 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(); }); })();