Support for bigger clusters.
This commit is contained in:
1 parent
be278b3c7c
commit
a6d014601d
8 files changed
+1336
-323
No files matched your search
+390
-125
@@ -80,24 +80,36 @@
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
.node-box {
|
||||
text-align: center;
|
||||
padding: 10px 20px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
min-width: 180px;
|
||||
min-width: 200px;
|
||||
flex: 1;
|
||||
max-width: 320px;
|
||||
}
|
||||
.pipe {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
min-width: 220px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.arrow {
|
||||
font-size: 1.4rem;
|
||||
color: var(--accent);
|
||||
letter-spacing: 2px;
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
.arrow { font-size: 1.5rem; color: var(--accent); letter-spacing: 2px; }
|
||||
|
||||
/* Inputs & Buttons */
|
||||
input, textarea, select {
|
||||
@@ -123,11 +135,22 @@
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
button:hover { background: #30363d; border-color: #8b949e; color: #fff; }
|
||||
button:hover:not(:disabled) { background: #30363d; border-color: #8b949e; color: #fff; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button.btn-primary { background: #238636; border-color: #2ea043; color: #fff; }
|
||||
button.btn-primary:hover { background: #2ea043; }
|
||||
button.btn-primary:hover:not(:disabled) { background: #2ea043; }
|
||||
button.btn-test { background: #1f6feb; border-color: #388bfd; color: #fff; }
|
||||
button.btn-test:hover { background: #388bfd; }
|
||||
button.btn-test:hover:not(:disabled) { background: #388bfd; }
|
||||
button.btn-switch { background: #8957e5; border-color: #a371f7; color: #fff; font-weight: 600; }
|
||||
button.btn-switch:hover:not(:disabled) { background: #9a67ea; border-color: #b388ff; }
|
||||
|
||||
.write-box {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-container { max-height: 380px; overflow-y: auto; }
|
||||
@@ -160,7 +183,7 @@
|
||||
<div>
|
||||
<h1>⚡ Records API & Replication Monitor</h1>
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem; margin-top: 4px;">
|
||||
Interactive validation and real-time visualization of SQLite append-only journals
|
||||
Interactive validation and real-time visualization of SQLite append-only journals & mTLS failover
|
||||
</p>
|
||||
</div>
|
||||
<div style="display:flex; align-items:center; gap: 10px;">
|
||||
@@ -175,12 +198,12 @@
|
||||
<div class="card" style="padding: 10px 16px;">
|
||||
<div style="display:flex; gap: 20px; align-items:center; flex-wrap:wrap; font-size:0.85rem;">
|
||||
<div style="flex:1; min-width:240px;">
|
||||
<label>Primary Base URL:</label>
|
||||
<input type="text" id="primaryUrl" value="https://127.0.0.1:3000" style="margin-bottom:0;" />
|
||||
<label>Node 1 Base URL:</label>
|
||||
<input type="text" id="node1Url" value="https://127.0.0.1:3000" style="margin-bottom:0;" />
|
||||
</div>
|
||||
<div style="flex:1; min-width:240px;">
|
||||
<label>Replica Base URL:</label>
|
||||
<input type="text" id="replicaUrl" value="https://127.0.0.1:3001" style="margin-bottom:0;" />
|
||||
<label>Node 2 Base URL:</label>
|
||||
<input type="text" id="node2Url" value="https://127.0.0.1:3001" style="margin-bottom:0;" />
|
||||
</div>
|
||||
<div style="flex:1; min-width:180px;">
|
||||
<label>Replication Bearer Token:</label>
|
||||
@@ -196,50 +219,97 @@
|
||||
|
||||
<!-- Live Replication Pipeline Banner -->
|
||||
<div class="topology">
|
||||
<div class="node-box" id="nodePrimaryBox">
|
||||
<span class="badge badge-primary">Primary Node</span>
|
||||
<div style="font-size: 1.2rem; font-weight: bold; margin-top: 5px;" id="primaryHead">Seq: --</div>
|
||||
<div style="font-size: 0.72rem; color: var(--text-muted);" id="primaryId">ID: --</div>
|
||||
<!-- Node 1 Box -->
|
||||
<div class="node-box" id="node1Box">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
|
||||
<span style="font-size:0.75rem; color:var(--text-muted); font-weight:600;">Node 1 (:3000)</span>
|
||||
<span id="node1RoleBadge" class="badge badge-primary">PRIMARY</span>
|
||||
</div>
|
||||
<div style="font-size: 1.3rem; font-weight: bold; margin-top: 5px;" id="node1Head">Seq: --</div>
|
||||
<div style="font-size: 0.72rem; color: var(--text-muted);" id="node1Id">ID: --</div>
|
||||
<div style="font-size: 0.72rem; color: var(--text-muted);" id="node1SyncState">State: --</div>
|
||||
</div>
|
||||
|
||||
<!-- Directional Pipeline & Switchover Control -->
|
||||
<div class="pipe">
|
||||
<span id="pipeState" class="badge badge-ok">Connected</span>
|
||||
<div class="arrow">━━━━ ◄─pull── ━━━━</div>
|
||||
<div id="pipeDetails">Lag: <span id="lagRecords">0</span> records</div>
|
||||
<span id="pipeState" class="badge badge-ok">CONNECTED</span>
|
||||
<div id="pipeArrow" class="arrow">━━━━ ◄─pull── ━━━━</div>
|
||||
<div id="pipeDetails" style="font-size:0.78rem;">Lag: <span id="lagRecords">0</span> records</div>
|
||||
<button id="switchoverBtn" class="btn-switch" style="margin-top:4px;" onclick="performSwitchover()">
|
||||
🔀 Graceful Switchover
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="node-box" id="nodeReplicaBox">
|
||||
<span class="badge badge-replica">Replica Node</span>
|
||||
<div style="font-size: 1.2rem; font-weight: bold; margin-top: 5px;" id="replicaHead">Seq: --</div>
|
||||
<div style="font-size: 0.72rem; color: var(--text-muted);" id="replicaId">ID: --</div>
|
||||
<div style="font-size: 0.72rem; color: var(--text-muted);" id="replicaSyncState">State: --</div>
|
||||
<!-- Node 2 Box -->
|
||||
<div class="node-box" id="node2Box">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
|
||||
<span style="font-size:0.75rem; color:var(--text-muted); font-weight:600;">Node 2 (:3001)</span>
|
||||
<span id="node2RoleBadge" class="badge badge-replica">REPLICA</span>
|
||||
</div>
|
||||
<div style="font-size: 1.3rem; font-weight: bold; margin-top: 5px;" id="node2Head">Seq: --</div>
|
||||
<div style="font-size: 0.72rem; color: var(--text-muted);" id="node2Id">ID: --</div>
|
||||
<div style="font-size: 0.72rem; color: var(--text-muted);" id="node2SyncState">State: --</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Side-by-Side Journal Tables -->
|
||||
<!-- Main Side-by-Side Journal Tables with Dedicated Write Box Per Instance -->
|
||||
<div class="grid-2">
|
||||
<!-- Primary Table -->
|
||||
<!-- Node 1 Column -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span>📖 Primary Records (`/api/records`)</span>
|
||||
<span id="primaryCount" class="badge badge-primary">0 records</span>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<span>📖 Node 1 Records</span>
|
||||
<span id="node1TableRole" class="badge badge-primary">PRIMARY</span>
|
||||
</div>
|
||||
<span id="node1Count" class="badge badge-primary">0 records</span>
|
||||
</div>
|
||||
|
||||
<!-- Write Box for Node 1 -->
|
||||
<div class="write-box">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
|
||||
<span style="font-size:0.8rem; font-weight:600;">✍️ Insert Record (Node 1)</span>
|
||||
<span style="font-size:0.72rem; color:var(--text-muted);">POST /api/records</span>
|
||||
</div>
|
||||
<textarea id="node1RecordData" rows="2" style="font-family:monospace; margin-bottom:8px;">{ "version": 1, "message": "Written to Node 1" }</textarea>
|
||||
<button class="btn-primary" style="width:100%;" onclick="submitRecordToNode('node1')">
|
||||
Send to Node 1 (:3000)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Node 1 Table -->
|
||||
<div class="table-container">
|
||||
<table id="primaryTable">
|
||||
<table id="node1Table">
|
||||
<thead><tr><th>Seq</th><th>UUID</th><th>Date (UTC)</th><th>Payload</th></tr></thead>
|
||||
<tbody><tr><td colspan="4" style="text-align:center; color:var(--text-muted)">Loading...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replica Table -->
|
||||
<!-- Node 2 Column -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span>📖 Replica Records (`/api/records`)</span>
|
||||
<span id="replicaCount" class="badge badge-replica">0 records</span>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<span>📖 Node 2 Records</span>
|
||||
<span id="node2TableRole" class="badge badge-replica">REPLICA</span>
|
||||
</div>
|
||||
<span id="node2Count" class="badge badge-replica">0 records</span>
|
||||
</div>
|
||||
|
||||
<!-- Write Box for Node 2 -->
|
||||
<div class="write-box">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
|
||||
<span style="font-size:0.8rem; font-weight:600;">✍️ Insert Record (Node 2)</span>
|
||||
<span style="font-size:0.72rem; color:var(--text-muted);">POST /api/records</span>
|
||||
</div>
|
||||
<textarea id="node2RecordData" rows="2" style="font-family:monospace; margin-bottom:8px;">{ "version": 1, "message": "Written to Node 2" }</textarea>
|
||||
<button class="btn-primary" style="width:100%;" onclick="submitRecordToNode('node2')">
|
||||
Send to Node 2 (:3001)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Node 2 Table -->
|
||||
<div class="table-container">
|
||||
<table id="replicaTable">
|
||||
<table id="node2Table">
|
||||
<thead><tr><th>Seq</th><th>UUID</th><th>Date (UTC)</th><th>Payload</th></tr></thead>
|
||||
<tbody><tr><td colspan="4" style="text-align:center; color:var(--text-muted)">Loading...</td></tr></tbody>
|
||||
</table>
|
||||
@@ -249,24 +319,24 @@
|
||||
|
||||
<!-- Action & Validation Panel -->
|
||||
<div class="grid-3">
|
||||
<!-- Insert Record Form -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>✍️ Write Record (Primary)</span></div>
|
||||
<label style="font-size:0.8rem;">JSON Payload (`data` field):</label>
|
||||
<textarea id="recordData" rows="4">{
|
||||
"version": 1,
|
||||
"message": "Testing replication!"
|
||||
}</textarea>
|
||||
<button class="btn-primary" style="width:100%;" onclick="submitRecord()">Send to Primary (POST /api/records)</button>
|
||||
</div>
|
||||
|
||||
<!-- Automated Validation Tests -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>🧪 Automated Tests</span></div>
|
||||
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||
<button class="btn-test" onclick="runSwitchoverTest()">🔀 Test Graceful Role Switchover</button>
|
||||
<button class="btn-test" onclick="runReplicaWriteGuardTest()">🛡️ Test Replica Read-Only Guard (Expect 403)</button>
|
||||
<button class="btn-test" onclick="runSchemaValidationTest()">⚠️ Test Invalid Schema Payload (Expect 400)</button>
|
||||
<button class="btn-test" onclick="runSyncLatencyTest()">⏱️ Test Insert-to-Replica Sync Latency</button>
|
||||
<button class="btn-test" onclick="runSyncLatencyTest()">⏱️ Test Insert-to-Replica Propagation</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Role Management -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>🎛️ Role Controls</span></div>
|
||||
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||
<button onclick="manualPromote('node1')">👑 Promote Node 1 to Primary</button>
|
||||
<button onclick="manualPromote('node2')">👑 Promote Node 2 to Primary</button>
|
||||
<button onclick="manualStepDown()">⬇️ Step Down Active Primary</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -276,7 +346,7 @@
|
||||
<span>⚙️ Schema Management</span>
|
||||
<button onclick="fetchSchema()" style="padding:2px 8px; font-size:0.75rem;">Inspect</button>
|
||||
</div>
|
||||
<pre id="schemaView" style="font-size:0.75rem; background:#0d1117; padding:8px; border-radius:4px; max-height:100px; overflow:auto;">Click Inspect to view</pre>
|
||||
<pre id="schemaView" style="font-size:0.75rem; background:#0d1117; padding:8px; border-radius:4px; max-height:85px; overflow:auto;">Click Inspect to view</pre>
|
||||
<button onclick="reloadSchema()" style="margin-top:8px; width:100%;">Trigger Hot-Reload (`/reload-config`)</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,8 +385,10 @@
|
||||
return nativeFetch(input, init);
|
||||
};
|
||||
|
||||
let primaryHeadSeq = 0;
|
||||
let replicaHeadSeq = 0;
|
||||
let node1HeadSeq = 0;
|
||||
let node2HeadSeq = 0;
|
||||
let currentNode1Role = 'unknown';
|
||||
let currentNode2Role = 'unknown';
|
||||
|
||||
function log(msg, type = 'info') {
|
||||
const box = document.getElementById('consoleLog');
|
||||
@@ -328,74 +400,155 @@
|
||||
}
|
||||
|
||||
function getUrls() {
|
||||
const n1 = document.getElementById('node1Url')?.value || 'https://127.0.0.1:3000';
|
||||
const n2 = document.getElementById('node2Url')?.value || 'https://127.0.0.1:3001';
|
||||
return {
|
||||
primary: document.getElementById('primaryUrl').value.replace(/\/+$/, ''),
|
||||
replica: document.getElementById('replicaUrl').value.replace(/\/+$/, ''),
|
||||
token: document.getElementById('replToken').value
|
||||
node1: n1.replace(/\/+$/, ''),
|
||||
node2: n2.replace(/\/+$/, ''),
|
||||
token: document.getElementById('replToken')?.value || 'change-me'
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAll() {
|
||||
const { primary, replica } = getUrls();
|
||||
const { node1, node2 } = getUrls();
|
||||
|
||||
// 1. Fetch Primary Records
|
||||
let node1Status = null;
|
||||
let node2Status = null;
|
||||
let node1Records = [];
|
||||
let node2Records = [];
|
||||
|
||||
// 1. Query Node 1 Status & Records
|
||||
try {
|
||||
const res = await fetch(`${primary}/api/records`);
|
||||
if (res.ok) {
|
||||
const records = await res.json();
|
||||
renderTable('primaryTable', records);
|
||||
document.getElementById('primaryCount').innerText = `${records.length} records`;
|
||||
primaryHeadSeq = records.length > 0 ? records[0].seq : 0;
|
||||
document.getElementById('primaryHead').innerText = `Seq: ${primaryHeadSeq}`;
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('primaryHead').innerText = `Offline`;
|
||||
const sRes = await fetch(`${node1}/api/replication/status`);
|
||||
if (sRes.ok) node1Status = await sRes.json();
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
const rRes = await fetch(`${node1}/api/records`);
|
||||
if (rRes.ok) node1Records = await rRes.json();
|
||||
} catch (_) {}
|
||||
|
||||
// 2. Query Node 2 Status & Records
|
||||
try {
|
||||
const sRes = await fetch(`${node2}/api/replication/status`);
|
||||
if (sRes.ok) node2Status = await sRes.json();
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
const rRes = await fetch(`${node2}/api/records`);
|
||||
if (rRes.ok) node2Records = await rRes.json();
|
||||
} catch (_) {}
|
||||
|
||||
// Update Node 1 UI elements
|
||||
if (node1Status) {
|
||||
currentNode1Role = node1Status.role || 'replica';
|
||||
node1HeadSeq = node1Status.local_head_seq ?? (node1Records.length > 0 ? node1Records[0].seq : 0);
|
||||
document.getElementById('node1Head').innerText = `Seq: #${node1HeadSeq}`;
|
||||
document.getElementById('node1Id').innerText = `ID: ${node1Status.instance_id.slice(0, 8)}…`;
|
||||
document.getElementById('node1SyncState').innerText = `State: ${node1Status.sync?.state || 'idle'}`;
|
||||
updateRoleBadge('node1RoleBadge', currentNode1Role);
|
||||
updateRoleBadge('node1TableRole', currentNode1Role);
|
||||
} else {
|
||||
currentNode1Role = 'offline';
|
||||
document.getElementById('node1Head').innerText = `Offline`;
|
||||
document.getElementById('node1Id').innerText = `ID: --`;
|
||||
document.getElementById('node1SyncState').innerText = `State: --`;
|
||||
updateRoleBadge('node1RoleBadge', 'offline');
|
||||
updateRoleBadge('node1TableRole', 'offline');
|
||||
}
|
||||
renderTable('node1Table', node1Records, Math.max(node1HeadSeq, node2HeadSeq));
|
||||
document.getElementById('node1Count').innerText = `${node1Records.length} records`;
|
||||
|
||||
// 2. Fetch Replica Replication Status
|
||||
try {
|
||||
const res = await fetch(`${replica}/api/replication/status`);
|
||||
if (res.ok) {
|
||||
const status = await res.json();
|
||||
replicaHeadSeq = status.local_head_seq ?? 0;
|
||||
document.getElementById('replicaHead').innerText = `Seq: ${replicaHeadSeq}`;
|
||||
document.getElementById('replicaId').innerText = `ID: ${status.instance_id.slice(0, 8)}...`;
|
||||
document.getElementById('primaryId').innerText = `Source: ${(status.sync?.source_instance_id || 'unlinked').slice(0, 8)}...`;
|
||||
|
||||
const state = status.sync?.state || 'unknown';
|
||||
const badge = document.getElementById('pipeState');
|
||||
badge.innerText = state.toUpperCase();
|
||||
badge.className = `badge ${state === 'idle' ? 'badge-ok' : state === 'syncing' ? 'badge-warn' : 'badge-err'}`;
|
||||
|
||||
const lag = status.lag_records ?? Math.max(0, primaryHeadSeq - replicaHeadSeq);
|
||||
document.getElementById('lagRecords').innerText = lag;
|
||||
document.getElementById('replicaSyncState').innerText = `State: ${state}`;
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('replicaHead').innerText = `Offline`;
|
||||
document.getElementById('pipeState').innerText = 'DISCONNECTED';
|
||||
document.getElementById('pipeState').className = 'badge badge-err';
|
||||
// Update Node 2 UI elements
|
||||
if (node2Status) {
|
||||
currentNode2Role = node2Status.role || 'replica';
|
||||
node2HeadSeq = node2Status.local_head_seq ?? (node2Records.length > 0 ? node2Records[0].seq : 0);
|
||||
document.getElementById('node2Head').innerText = `Seq: #${node2HeadSeq}`;
|
||||
document.getElementById('node2Id').innerText = `ID: ${node2Status.instance_id.slice(0, 8)}…`;
|
||||
document.getElementById('node2SyncState').innerText = `State: ${node2Status.sync?.state || 'idle'}`;
|
||||
updateRoleBadge('node2RoleBadge', currentNode2Role);
|
||||
updateRoleBadge('node2TableRole', currentNode2Role);
|
||||
} else {
|
||||
currentNode2Role = 'offline';
|
||||
document.getElementById('node2Head').innerText = `Offline`;
|
||||
document.getElementById('node2Id').innerText = `ID: --`;
|
||||
document.getElementById('node2SyncState').innerText = `State: --`;
|
||||
updateRoleBadge('node2RoleBadge', 'offline');
|
||||
updateRoleBadge('node2TableRole', 'offline');
|
||||
}
|
||||
renderTable('node2Table', node2Records, Math.max(node1HeadSeq, node2HeadSeq));
|
||||
document.getElementById('node2Count').innerText = `${node2Records.length} records`;
|
||||
|
||||
// 3. Fetch Replica Records
|
||||
try {
|
||||
const res = await fetch(`${replica}/api/records`);
|
||||
if (res.ok) {
|
||||
const records = await res.json();
|
||||
renderTable('replicaTable', records);
|
||||
document.getElementById('replicaCount').innerText = `${records.length} records`;
|
||||
}
|
||||
} catch (e) {}
|
||||
// 3. Update the Live Replication Pipeline Banner dynamically
|
||||
updatePipelineBanner(node1Status, node2Status);
|
||||
}
|
||||
|
||||
function renderTable(tableId, records) {
|
||||
function updateRoleBadge(badgeId, role) {
|
||||
const el = document.getElementById(badgeId);
|
||||
if (!el) return;
|
||||
if (role === 'primary') {
|
||||
el.className = 'badge badge-primary';
|
||||
el.innerText = 'PRIMARY';
|
||||
} else if (role === 'replica') {
|
||||
el.className = 'badge badge-replica';
|
||||
el.innerText = 'REPLICA';
|
||||
} else {
|
||||
el.className = 'badge badge-err';
|
||||
el.innerText = 'OFFLINE';
|
||||
}
|
||||
}
|
||||
|
||||
function updatePipelineBanner(status1, status2) {
|
||||
const arrowEl = document.getElementById('pipeArrow');
|
||||
const detailsEl = document.getElementById('pipeDetails');
|
||||
const stateBadge = document.getElementById('pipeState');
|
||||
const switchBtn = document.getElementById('switchoverBtn');
|
||||
|
||||
if (currentNode1Role === 'primary' && currentNode2Role === 'replica') {
|
||||
// Node 2 pulls from Node 1
|
||||
arrowEl.innerText = '━━━━ ◄─pull── ━━━━';
|
||||
const lag = status2?.lag_records ?? Math.max(0, node1HeadSeq - node2HeadSeq);
|
||||
detailsEl.innerHTML = `Node 2 pulling from Node 1 (Lag: <strong>${lag}</strong> records)`;
|
||||
const syncState = status2?.sync?.state || 'idle';
|
||||
stateBadge.innerText = syncState.toUpperCase();
|
||||
stateBadge.className = `badge ${syncState === 'idle' ? 'badge-ok' : syncState === 'syncing' ? 'badge-warn' : 'badge-err'}`;
|
||||
switchBtn.innerText = '🔀 Switchover: Node 1 ➔ Node 2';
|
||||
switchBtn.disabled = false;
|
||||
} else if (currentNode2Role === 'primary' && currentNode1Role === 'replica') {
|
||||
// Node 1 pulls from Node 2
|
||||
arrowEl.innerText = '━━━━ ──pull─► ━━━━';
|
||||
const lag = status1?.lag_records ?? Math.max(0, node2HeadSeq - node1HeadSeq);
|
||||
detailsEl.innerHTML = `Node 1 pulling from Node 2 (Lag: <strong>${lag}</strong> records)`;
|
||||
const syncState = status1?.sync?.state || 'idle';
|
||||
stateBadge.innerText = syncState.toUpperCase();
|
||||
stateBadge.className = `badge ${syncState === 'idle' ? 'badge-ok' : syncState === 'syncing' ? 'badge-warn' : 'badge-err'}`;
|
||||
switchBtn.innerText = '🔀 Switchover: Node 2 ➔ Node 1';
|
||||
switchBtn.disabled = false;
|
||||
} else if (currentNode1Role === 'primary' && currentNode2Role === 'primary') {
|
||||
arrowEl.innerText = '⚠️ DUAL PRIMARY ⚠️';
|
||||
detailsEl.innerText = 'Warning: Split-brain condition detected!';
|
||||
stateBadge.innerText = 'SPLIT BRAIN';
|
||||
stateBadge.className = 'badge badge-err';
|
||||
switchBtn.innerText = '🔀 Resolve Split-Brain';
|
||||
switchBtn.disabled = false;
|
||||
} else {
|
||||
arrowEl.innerText = '━━━━ ✖✖✖ ━━━━';
|
||||
detailsEl.innerText = 'No active primary/replica replication stream';
|
||||
stateBadge.innerText = 'DISCONNECTED';
|
||||
stateBadge.className = 'badge badge-err';
|
||||
switchBtn.innerText = '🔀 Graceful Switchover';
|
||||
switchBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable(tableId, records, globalHead) {
|
||||
const tbody = document.querySelector(`#${tableId} tbody`);
|
||||
if (!records || records.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center; color:var(--text-muted)">Database is empty</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = records.map(r => `
|
||||
<tr class="${r.seq === primaryHeadSeq ? 'synced' : ''}">
|
||||
<tr class="${r.seq === globalHead ? 'synced' : ''}">
|
||||
<td><strong>#${r.seq}</strong></td>
|
||||
<td style="font-family:monospace; font-size:0.75rem;" title="${r.uuid}">${r.uuid.slice(0, 8)}…</td>
|
||||
<td style="font-size:0.75rem;">${r.date.replace('T', ' ').replace('Z', '')}</td>
|
||||
@@ -404,18 +557,23 @@
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function submitRecord() {
|
||||
const { primary } = getUrls();
|
||||
// Submit a record directly to a specific node
|
||||
async function submitRecordToNode(whichNode) {
|
||||
const { node1, node2 } = getUrls();
|
||||
const targetUrl = whichNode === 'node1' ? node1 : node2;
|
||||
const nodeName = whichNode === 'node1' ? 'Node 1 (:3000)' : 'Node 2 (:3001)';
|
||||
const textarea = document.getElementById(`${whichNode}RecordData`);
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(document.getElementById('recordData').value);
|
||||
payload = JSON.parse(textarea.value);
|
||||
} catch (err) {
|
||||
log(`Invalid JSON in editor: ${err.message}`, 'fail');
|
||||
log(`Invalid JSON in ${nodeName} write box: ${err.message}`, 'fail');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${primary}/api/records`, {
|
||||
const res = await fetch(`${targetUrl}/api/records`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: payload })
|
||||
@@ -423,43 +581,140 @@
|
||||
const text = await res.text();
|
||||
if (res.status === 201) {
|
||||
const data = JSON.parse(text);
|
||||
log(`Write successful on Primary! Created seq #${data.seq} (UUID: ${data.uuid.slice(0, 8)})`, 'pass');
|
||||
log(`Write SUCCESS on ${nodeName}! Created seq #${data.seq} (UUID: ${data.uuid.slice(0, 8)})`, 'pass');
|
||||
fetchAll();
|
||||
} else if (res.status === 403) {
|
||||
log(`${nodeName} BLOCKED write [HTTP 403 Forbidden] (Read-Only Replica): ${text}`, 'fail');
|
||||
} else {
|
||||
log(`Primary rejected write [HTTP ${res.status}]: ${text}`, 'fail');
|
||||
log(`${nodeName} returned [HTTP ${res.status}]: ${text}`, 'fail');
|
||||
}
|
||||
} catch (e) {
|
||||
log(`Network failure writing to Primary: ${e.message}`, 'fail');
|
||||
log(`Network failure writing to ${nodeName}: ${e.message}`, 'fail');
|
||||
}
|
||||
}
|
||||
|
||||
// TEST 1: Replica read-only safety guard
|
||||
async function runReplicaWriteGuardTest() {
|
||||
const { replica } = getUrls();
|
||||
log(`Starting Replica Write Guard test against ${replica}/api/records...`, 'info');
|
||||
// Perform graceful switchover from the active Primary
|
||||
async function performSwitchover() {
|
||||
const { node1, node2 } = getUrls();
|
||||
let primaryUrl = null;
|
||||
let replicaUrl = null;
|
||||
let primaryName = '';
|
||||
let replicaName = '';
|
||||
|
||||
if (currentNode1Role === 'primary') {
|
||||
primaryUrl = node1;
|
||||
replicaUrl = node2;
|
||||
primaryName = 'Node 1';
|
||||
replicaName = 'Node 2';
|
||||
} else if (currentNode2Role === 'primary') {
|
||||
primaryUrl = node2;
|
||||
replicaUrl = node1;
|
||||
primaryName = 'Node 2';
|
||||
replicaName = 'Node 1';
|
||||
} else {
|
||||
log('Switchover aborted: No active primary detected', 'fail');
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Initiating graceful switchover: ${primaryName} ➔ ${replicaName}...`, 'info');
|
||||
const btn = document.getElementById('switchoverBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${replica}/api/records`, {
|
||||
const res = await fetch(`${primaryUrl}/api/admin/switchover`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ replica_url: replicaUrl, timeout_secs: 15 })
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (res.ok) {
|
||||
log(`PASS: Graceful switchover completed! ${replicaName} is now Primary; ${primaryName} is Replica.`, 'pass');
|
||||
await fetchAll();
|
||||
} else {
|
||||
log(`FAIL: Switchover rejected [HTTP ${res.status}]: ${text}`, 'fail');
|
||||
}
|
||||
} catch (e) {
|
||||
log(`Switchover execution error: ${e.message}`, 'fail');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Manual promotion helper
|
||||
async function manualPromote(whichNode) {
|
||||
const { node1, node2 } = getUrls();
|
||||
const targetUrl = whichNode === 'node1' ? node1 : node2;
|
||||
const nodeName = whichNode === 'node1' ? 'Node 1' : 'Node 2';
|
||||
log(`Promoting ${nodeName} to Primary...`, 'info');
|
||||
try {
|
||||
const res = await fetch(`${targetUrl}/api/admin/promote`, { method: 'POST' });
|
||||
const text = await res.text();
|
||||
log(`Promote result on ${nodeName} [HTTP ${res.status}]: ${text}`, res.ok ? 'pass' : 'fail');
|
||||
fetchAll();
|
||||
} catch (e) {
|
||||
log(`Promote failed: ${e.message}`, 'fail');
|
||||
}
|
||||
}
|
||||
|
||||
// Manual step-down helper
|
||||
async function manualStepDown() {
|
||||
const { node1, node2 } = getUrls();
|
||||
const primaryUrl = (currentNode1Role === 'primary') ? node1 : (currentNode2Role === 'primary' ? node2 : null);
|
||||
if (!primaryUrl) {
|
||||
log('No active primary found to step down', 'fail');
|
||||
return;
|
||||
}
|
||||
log(`Stepping down active primary (${primaryUrl})...`, 'info');
|
||||
try {
|
||||
const res = await fetch(`${primaryUrl}/api/admin/step-down`, { method: 'POST' });
|
||||
const text = await res.text();
|
||||
log(`Step-down result [HTTP ${res.status}]: ${text}`, res.ok ? 'pass' : 'fail');
|
||||
fetchAll();
|
||||
} catch (e) {
|
||||
log(`Step-down failed: ${e.message}`, 'fail');
|
||||
}
|
||||
}
|
||||
|
||||
// TEST 1: Role Switchover automated test
|
||||
async function runSwitchoverTest() {
|
||||
log('Starting Automated Role Switchover verification test...', 'info');
|
||||
await performSwitchover();
|
||||
}
|
||||
|
||||
// TEST 2: Replica read-only safety guard
|
||||
async function runReplicaWriteGuardTest() {
|
||||
const { node1, node2 } = getUrls();
|
||||
const target = (currentNode2Role === 'replica') ? node2 : (currentNode1Role === 'replica' ? node1 : node2);
|
||||
const targetName = (target === node2) ? 'Node 2 (:3001)' : 'Node 1 (:3000)';
|
||||
|
||||
log(`Testing Write Guard against ${targetName} (${target}/api/records)...`, 'info');
|
||||
try {
|
||||
const res = await fetch(`${target}/api/records`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: { message: "Direct write forbidden test" } })
|
||||
});
|
||||
if (res.status === 403) {
|
||||
const msg = await res.text();
|
||||
log(`PASS: Replica blocked write with HTTP 403 Forbidden! Response: "${msg}"`, 'pass');
|
||||
log(`PASS: ${targetName} blocked write with HTTP 403 Forbidden! Response: "${msg}"`, 'pass');
|
||||
} else {
|
||||
log(`FAIL: Replica answered with HTTP ${res.status} instead of expected 403!`, 'fail');
|
||||
log(`FAIL: ${targetName} answered with HTTP ${res.status} instead of expected 403!`, 'fail');
|
||||
}
|
||||
} catch (e) {
|
||||
log(`Test failed due to network error: ${e.message}`, 'fail');
|
||||
}
|
||||
}
|
||||
|
||||
// TEST 2: Schema validation test
|
||||
// TEST 3: Schema validation test
|
||||
async function runSchemaValidationTest() {
|
||||
const { primary } = getUrls();
|
||||
log(`Starting Schema Rejection test on Primary (sending wrong types & extra fields)...`, 'info');
|
||||
const { node1, node2 } = getUrls();
|
||||
const primaryUrl = (currentNode1Role === 'primary') ? node1 : (currentNode2Role === 'primary' ? node2 : node1);
|
||||
const primaryName = (primaryUrl === node1) ? 'Node 1' : 'Node 2';
|
||||
|
||||
log(`Starting Schema Rejection test on ${primaryName} (sending invalid payload)...`, 'info');
|
||||
try {
|
||||
const res = await fetch(`${primary}/api/records`, {
|
||||
const res = await fetch(`${primaryUrl}/api/records`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: { unknown_xyz_field: 999999, version: "not-an-integer" } })
|
||||
@@ -475,15 +730,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
// TEST 3: Sync Latency propagation benchmark
|
||||
// TEST 4: Sync Latency propagation benchmark
|
||||
async function runSyncLatencyTest() {
|
||||
const { primary, replica } = getUrls();
|
||||
const { node1, node2 } = getUrls();
|
||||
const primaryUrl = (currentNode1Role === 'primary') ? node1 : (currentNode2Role === 'primary' ? node2 : null);
|
||||
const replicaUrl = (currentNode1Role === 'primary') ? node2 : (currentNode2Role === 'primary' ? node1 : null);
|
||||
|
||||
if (!primaryUrl || !replicaUrl) {
|
||||
log('Latency test aborted: Cluster does not have a distinct Primary and Replica', 'fail');
|
||||
return;
|
||||
}
|
||||
|
||||
const testMsg = `probe-${Date.now()}`;
|
||||
log(`Triggering probe write on Primary with marker: ${testMsg}...`, 'info');
|
||||
|
||||
const startTime = performance.now();
|
||||
try {
|
||||
const res = await fetch(`${primary}/api/records`, {
|
||||
const res = await fetch(`${primaryUrl}/api/records`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: { message: testMsg, msg: testMsg, version: 1 } })
|
||||
@@ -497,7 +760,7 @@
|
||||
const interval = setInterval(async () => {
|
||||
attempts++;
|
||||
try {
|
||||
const statusRes = await fetch(`${replica}/api/replication/status`);
|
||||
const statusRes = await fetch(`${replicaUrl}/api/replication/status`);
|
||||
const status = await statusRes.json();
|
||||
if (status.local_head_seq >= targetSeq) {
|
||||
clearInterval(interval);
|
||||
@@ -519,9 +782,10 @@
|
||||
}
|
||||
|
||||
async function fetchSchema() {
|
||||
const { primary } = getUrls();
|
||||
const { node1, node2 } = getUrls();
|
||||
const primaryUrl = (currentNode1Role === 'primary') ? node1 : (currentNode2Role === 'primary' ? node2 : node1);
|
||||
try {
|
||||
const res = await fetch(`${primary}/api/admin/config`);
|
||||
const res = await fetch(`${primaryUrl}/api/admin/config`);
|
||||
const data = await res.json();
|
||||
document.getElementById('schemaView').innerText = JSON.stringify(data, null, 2);
|
||||
log(`Fetched active configuration schema.`, 'info');
|
||||
@@ -531,9 +795,10 @@
|
||||
}
|
||||
|
||||
async function reloadSchema() {
|
||||
const { primary } = getUrls();
|
||||
const { node1, node2 } = getUrls();
|
||||
const primaryUrl = (currentNode1Role === 'primary') ? node1 : (currentNode2Role === 'primary' ? node2 : node1);
|
||||
try {
|
||||
const res = await fetch(`${primary}/api/admin/reload-config`, { method: 'POST' });
|
||||
const res = await fetch(`${primaryUrl}/api/admin/reload-config`, { method: 'POST' });
|
||||
const text = await res.text();
|
||||
log(`Reload schema result [HTTP ${res.status}]: ${text}`, res.ok ? 'pass' : 'fail');
|
||||
fetchSchema();
|
||||
@@ -553,4 +818,4 @@
|
||||
fetchAll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
Reference in new issue
Block a user