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) ────────────────────────────────────────