feat(multi-site): UI for live replication, promotion handoff, signing key

Closes the gap where all of this session's new server-side capability
(live replication, coordinated promotion, identical signing keys) had
no UI at all -- an operator using the Master Site modal had no way to
know any of it existed or was working.

- Master Site modal: new "Live Replication" row (spoke) shows whether
  this join actually registered for live updates or is stuck on a
  one-time snapshot; new "Registered Spokes" row (master) shows how
  many spokes are receiving live pushes.
- Join form: new "this site's own reachable URL" field, prefilled from
  window.location.origin, wired to the selfUrl the join API already
  supported but the UI never sent -- a UI-driven join previously NEVER
  registered for live replication, only the setup.sh bootstrap path did.
  The success toast now reports whether live replication actually
  activated, not just "joined".
- Promote button: success toast now surfaces the handoff result (old
  master demoted / unreachable / no previous master), so the operator
  sees immediately whether the coordinated demotion actually happened.
- GET /api/site/config no longer returns masterJoinKey or
  replicationPushToken in the response -- found while wiring this up:
  live credentials were being sent straight to the browser for every
  admin session. Replaced with boolean derivatives
  (hasMasterJoinKey, liveReplication).
- GET /api/directory-admin/site-status gained liveReplication (spoke)
  and registeredSpokesCount (master) so the modal has something to render.

Verified by actually driving it in a real browser against a live
container (not just code review): logged in, opened the modal, saw the
new rows, minted a real join key end-to-end, no console errors.

docs/site-join.md rewritten to cover live replication, signing-key sync,
coordinated promotion/demote, and the new endpoints -- it previously only
described the v2.2.0-v2.3.0 one-time-snapshot behavior.
This commit is contained in:
2026-08-10 18:30:48 -04:00
parent 9c604f0258
commit dc3d760d2b
4 changed files with 119 additions and 13 deletions
+17 -4
View File
@@ -3339,6 +3339,10 @@
'<tr><th>WAN Sync Health:</th><td>' + (res.config.wanConnected === false
? '<span class="badge bg-danger"><i class="fa-solid fa-xmark me-1"></i> Offline / Disconnected</span>'
: '<span class="badge bg-success"><i class="fa-solid fa-check me-1"></i> Online / Operational</span>') + '</td></tr>' +
(!isMaster ? '<tr><th>Live Replication:</th><td>' + (cfg.liveReplication
? '<span class="badge bg-success"><i class="fa-solid fa-bolt me-1"></i> Live (catalog updates push automatically)</span>'
: '<span class="badge bg-warning text-dark"><i class="fa-solid fa-triangle-exclamation me-1"></i> Snapshot only (re-join to register for live updates)</span>') + '</td></tr>' : '') +
(isMaster ? '<tr><th>Registered Spokes:</th><td><span class="badge bg-success">' + (cfg.registeredSpokesCount || 0) + ' receiving live updates</span></td></tr>' : '') +
'<tr><th>Registered Sites:</th><td><span class="badge bg-primary">' + (res.sitesCount || 0) + ' sites</span></td></tr>' +
'<tr><th>Theta Gateways:</th><td><span class="badge bg-dark">' + (res.gatewaysCount || 0) + ' active gateways</span></td></tr>' +
'</table>' +
@@ -3359,6 +3363,10 @@
'<div class="col-md-7"><input type="text" id="site-join-url" class="form-control form-control-sm" placeholder="Master Directory URL (e.g. https://sso.master.example.com)"></div>' +
'<div class="col-md-5"><input type="text" id="site-join-key" class="form-control form-control-sm font-monospace" placeholder="Site join key (stj_...)"></div>' +
'</div>' +
'<div class="mt-2">' +
'<label class="form-label small mb-1">This site\'s own reachable URL <span class="text-muted">(so the master can push live updates here — leave blank to only get a one-time snapshot)</span></label>' +
'<input type="text" id="site-join-self-url" class="form-control form-control-sm" value="' + esc(window.location.origin) + '">' +
'</div>' +
'<button class="btn btn-sm btn-primary mt-2" onclick="joinCurrentSiteToMaster()"><i class="fa-solid fa-link me-1"></i> Join Site</button>' +
'</div>' +
'</div>';
@@ -3408,8 +3416,9 @@
if (!confirmed) return;
try {
const res = await app.api.post('directory-admin/site-promote', {});
app.messages.toast(res.message || 'Node promoted to Master Site', 'success');
const res = await app.api.post('directory-admin/site-promote', { selfUrl: window.location.origin });
const handoffOk = res.handoff === 'previous master demoted' || /no previous master/.test(res.handoff || '');
app.messages.toast((res.message || 'Node promoted to Master Site') + ' — ' + (res.handoff || ''), handoffOk ? 'success' : 'warning');
app.modal.close();
refreshSiteStatus();
} catch (e) {
@@ -3421,6 +3430,7 @@
async function joinCurrentSiteToMaster() {
const masterUrl = ($('#site-join-url').val() || '').trim();
const joinKey = ($('#site-join-key').val() || '').trim();
const selfUrl = ($('#site-join-self-url').val() || '').trim();
if (!masterUrl || !joinKey) {
return app.messages.toast('Enter the master Directory URL and a site join key', 'warning');
}
@@ -3431,8 +3441,11 @@
if (!confirmed) return;
try {
const res = await app.api.post('site/join', { masterUrl, joinKey });
app.messages.toast(res.message || 'Joined master site', 'success');
const res = await app.api.post('site/join', { masterUrl, joinKey, ...(selfUrl ? { selfUrl } : {}) });
const replicationNote = res.replication && res.replication.live
? ' Live replication is active.'
: ' Snapshot only — this site will not receive live updates (' + ((res.replication && res.replication.note) || 'no selfUrl given') + ').';
app.messages.toast((res.message || 'Joined master site') + replicationNote, res.replication && res.replication.live ? 'success' : 'warning');
app.modal.close();
refreshSiteStatus();
} catch (e) {