Implement master promotion feature
All checks were successful
Deploy / deploy (push) Successful in 33s
All checks were successful
Deploy / deploy (push) Successful in 33s
Allows promoting any replica to master with zero document re-downloads.
The sync_map rebuild uses existing DB data only — pure in-memory join.
Changes:
- app/sync/promote.py: preflight() checks (doc count, sync lock, ack
warnings) and promote() transaction (pause scheduler, rebuild all
sync_maps, create old-master replica, swap settings, resume scheduler)
- app/api/master.py: GET /api/master/promote/{id}/preflight (dry run)
and POST /api/master/promote/{id} (execute)
- app/models.py: add promoted_from_master bool field to Replica
- app/database.py: idempotent ALTER TABLE migration for new column
- app/main.py: register master router
- app/templates/replica_detail.html: "Promote to Master" button +
dialog with pre-flight summary, 3-card stats, ack checkboxes, spinner
- app/ui/routes.py: flash query param on dashboard route
- app/templates/dashboard.html: blue info banner for post-promotion flash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:1rem;">
|
||||
<h2 style="margin:0;">{{ replica.name }}</h2>
|
||||
<div style="display:flex; gap:0.5rem;">
|
||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
||||
<button class="secondary outline"
|
||||
hx-post="/api/replicas/{{ replica.id }}/test"
|
||||
hx-target="#conn-result" hx-swap="innerHTML">
|
||||
@@ -30,6 +30,11 @@
|
||||
Re-enable
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if replica.enabled and not replica.suspended_at %}
|
||||
<button class="outline" style="margin-left:auto;" onclick="openPromoteDialog()">
|
||||
Promote to Master
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,4 +154,153 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Promote to Master dialog -->
|
||||
<dialog id="promote-dialog">
|
||||
<article>
|
||||
<header>
|
||||
<button aria-label="Close" rel="prev" onclick="document.getElementById('promote-dialog').close()"></button>
|
||||
<h3 style="margin:0;">Promote <strong>{{ replica.name }}</strong> to Master</h3>
|
||||
<p style="margin:0.25rem 0 0; font-size:0.875em; color:var(--pico-muted-color);">
|
||||
{{ replica.url }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div id="promote-preflight-content" style="min-height:6rem;">
|
||||
<progress></progress>
|
||||
<p class="muted">Checking pre-flight conditions…</p>
|
||||
</div>
|
||||
|
||||
<footer style="display:flex; justify-content:flex-end; gap:0.5rem; padding:0; margin-top:1.25rem;">
|
||||
<button class="secondary outline" onclick="document.getElementById('promote-dialog').close()">Cancel</button>
|
||||
<button id="promote-confirm-btn" disabled onclick="doPromote()">Promote to Master</button>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
const REPLICA_ID = {{ replica.id }};
|
||||
const REPLICA_NAME = {{ replica.name | tojson }};
|
||||
let _preflightData = null;
|
||||
|
||||
async function openPromoteDialog() {
|
||||
const dialog = document.getElementById('promote-dialog');
|
||||
const content = document.getElementById('promote-preflight-content');
|
||||
const btn = document.getElementById('promote-confirm-btn');
|
||||
content.innerHTML = '<progress></progress><p class="muted">Checking pre-flight conditions\u2026</p>';
|
||||
btn.disabled = true;
|
||||
_preflightData = null;
|
||||
dialog.showModal();
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/master/promote/' + REPLICA_ID + '/preflight');
|
||||
_preflightData = await resp.json();
|
||||
renderPreflight(_preflightData);
|
||||
} catch (err) {
|
||||
content.innerHTML = '<p><span class="badge badge-error">Error</span> ' + err + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderPreflight(data) {
|
||||
const content = document.getElementById('promote-preflight-content');
|
||||
const btn = document.getElementById('promote-confirm-btn');
|
||||
let html = '';
|
||||
|
||||
if (!data.can_promote) {
|
||||
html += '<div style="padding:0.75rem 1rem; background:#fff5f5; border-radius:0.4rem; border-left:3px solid var(--pico-del-color);">';
|
||||
html += '<strong>' + (data.error || 'Cannot promote') + '</strong><br>';
|
||||
html += '<small>' + (data.detail || '') + '</small>';
|
||||
html += '</div>';
|
||||
content.innerHTML = html;
|
||||
btn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Summary cards
|
||||
html += '<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:0.75rem; margin-bottom:1rem;">';
|
||||
html += '<div style="padding:0.6rem 0.75rem; background:var(--pico-card-sectionning-background-color); border-radius:0.4rem; text-align:center;">';
|
||||
html += '<small class="muted">Master docs</small><br><strong>' + (data.master_doc_count ?? '?') + '</strong></div>';
|
||||
html += '<div style="padding:0.6rem 0.75rem; background:var(--pico-card-sectionning-background-color); border-radius:0.4rem; text-align:center;">';
|
||||
html += '<small class="muted">Replica docs</small><br><strong>' + (data.replica_doc_count ?? '?') + '</strong></div>';
|
||||
html += '<div style="padding:0.6rem 0.75rem; background:var(--pico-card-sectionning-background-color); border-radius:0.4rem; text-align:center;">';
|
||||
html += '<small class="muted">Mappings to rebuild</small><br><strong>' + data.ok_entries + '</strong></div>';
|
||||
html += '</div>';
|
||||
|
||||
// Old master name field
|
||||
html += '<label style="margin-bottom:0.75rem; display:block;">Name for the current master (it becomes a new replica)<input type="text" id="old-master-name" value="old-master" required oninput="updateConfirmBtn()" style="margin-top:0.25rem;"></label>';
|
||||
|
||||
// Warnings with ack checkboxes
|
||||
if (data.pending_entries > 0) {
|
||||
html += '<label style="display:flex; gap:0.5rem; align-items:flex-start; margin-bottom:0.5rem; cursor:pointer;">';
|
||||
html += '<input type="checkbox" id="ack-pending" onchange="updateConfirmBtn()" style="margin-top:0.1rem; flex-shrink:0;">';
|
||||
html += '<span><strong>' + data.pending_entries + ' pending entries</strong> \u2014 these documents may be re-uploaded after promotion.</span>';
|
||||
html += '</label>';
|
||||
}
|
||||
if (data.error_entries > 0) {
|
||||
html += '<label style="display:flex; gap:0.5rem; align-items:flex-start; margin-bottom:0.5rem; cursor:pointer;">';
|
||||
html += '<input type="checkbox" id="ack-errors" onchange="updateConfirmBtn()" style="margin-top:0.1rem; flex-shrink:0;">';
|
||||
html += '<span><strong>' + data.error_entries + ' error entries</strong> \u2014 these documents will be re-synced after promotion.</span>';
|
||||
html += '</label>';
|
||||
}
|
||||
|
||||
html += '<p style="margin:0.75rem 0 0; font-size:0.875em; color:var(--pico-muted-color);">The current master will be added as a new replica and will sync from <strong>' + REPLICA_NAME + '</strong> going forward.</p>';
|
||||
|
||||
content.innerHTML = html;
|
||||
updateConfirmBtn();
|
||||
}
|
||||
|
||||
function updateConfirmBtn() {
|
||||
if (!_preflightData || !_preflightData.can_promote) return;
|
||||
const btn = document.getElementById('promote-confirm-btn');
|
||||
const nameOk = (document.getElementById('old-master-name')?.value || '').trim().length > 0;
|
||||
const pendingOk = _preflightData.pending_entries === 0 || document.getElementById('ack-pending')?.checked;
|
||||
const errorsOk = _preflightData.error_entries === 0 || document.getElementById('ack-errors')?.checked;
|
||||
btn.disabled = !(nameOk && pendingOk && errorsOk);
|
||||
}
|
||||
|
||||
async function doPromote() {
|
||||
const btn = document.getElementById('promote-confirm-btn');
|
||||
const oldMasterName = (document.getElementById('old-master-name')?.value || '').trim();
|
||||
const ackPending = !!document.getElementById('ack-pending')?.checked;
|
||||
const ackErrors = !!document.getElementById('ack-errors')?.checked;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.setAttribute('aria-busy', 'true');
|
||||
btn.textContent = 'Promoting\u2026';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/master/promote/' + REPLICA_ID, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
old_master_name: oldMasterName,
|
||||
acknowledge_pending: ackPending,
|
||||
acknowledge_errors: ackErrors,
|
||||
}),
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const result = await resp.json();
|
||||
const msg = 'Promoted\u00a0' + (result.new_master?.name || REPLICA_NAME) +
|
||||
'\u00a0to master.\u00a0' + oldMasterName + '\u00a0added as replica.\u00a0' +
|
||||
result.sync_map_rebuilt.entries_mapped + '\u00a0document mappings rebuilt.';
|
||||
window.location.href = '/?flash=' + encodeURIComponent(msg);
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
const detail = err.detail?.detail || err.detail || 'Unknown error';
|
||||
const content = document.getElementById('promote-preflight-content');
|
||||
content.innerHTML += '<p style="margin-top:0.75rem;"><span class="badge badge-error">Error</span> ' + detail + '</p>';
|
||||
btn.disabled = false;
|
||||
btn.removeAttribute('aria-busy');
|
||||
btn.textContent = 'Promote to Master';
|
||||
}
|
||||
} catch (err) {
|
||||
const content = document.getElementById('promote-preflight-content');
|
||||
content.innerHTML += '<p style="margin-top:0.75rem;"><span class="badge badge-error">Network error</span> ' + err + '</p>';
|
||||
btn.disabled = false;
|
||||
btn.removeAttribute('aria-busy');
|
||||
btn.textContent = 'Promote to Master';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user