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
+11 -1
View File
@@ -927,6 +927,7 @@ router.post('/discovered/merge', async (req, res, next) => {
const siteConfig = require('../utils/site_config');
const { siteIsFresh } = require('../utils/site_join');
const { Agent } = require('../models/agent');
const { SiteSpoke } = require('../models/site_spoke');
// probeMasterHealth checks whether this (spoke) node can reach its master over
// the site join key. The master's /api/site/ping is deliberately lightweight.
@@ -962,6 +963,13 @@ router.get('/site-status', async (req, res, next) => {
if (cfg.isMaster) {
canJoin = await siteIsFresh({ User, Agent }).catch(() => false);
}
// registeredSpokesCount (master) / liveReplication (spoke): surfaces
// whether live replication is actually wired up, not just whether the
// join itself succeeded -- a spoke that joined without `selfUrl` (e.g.
// via an older bootstrap, or the UI form before it grew the field) is
// fully joined but silently stuck on the one-time snapshot, which was
// otherwise invisible anywhere in the UI.
const registeredSpokesCount = cfg.isMaster ? await SiteSpoke.list().then(l => l.length).catch(() => 0) : 0;
res.json({
status: 'ok',
config: {
@@ -970,7 +978,9 @@ router.get('/site-status', async (req, res, next) => {
siteSlug: cfg.siteSlug,
wanConnected,
siteMode: cfg.isMaster ? 'master' : 'spoke',
canJoin
canJoin,
liveReplication: !cfg.isMaster ? !!cfg.replicationPushToken : undefined,
registeredSpokesCount
},
sitesCount: sites.length,
sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
+16 -2
View File
@@ -219,9 +219,23 @@ router.use(async (req, res, next) => {
});
// Current multi-site role (master/spoke, site slug, master URL).
// Never sent to the client: masterJoinKey and replicationPushToken are live
// credentials, not display data. Callers get boolean derivatives instead
// (hasMasterJoinKey, liveReplication) -- enough to render UI state without
// putting a secret in a browser response.
router.get('/config', async (req, res, next) => {
try { res.json({ status: 'ok', config: siteConfig.get() }); }
catch (e) { next(e); }
try {
const cfg = siteConfig.get();
const { masterJoinKey, replicationPushToken, ...safe } = cfg;
res.json({
status: 'ok',
config: {
...safe,
hasMasterJoinKey: !!masterJoinKey,
liveReplication: !!replicationPushToken
}
});
} catch (e) { next(e); }
});
// ── Site join key management (admin) ────────────────────────────────────────
+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) {