// Peers page controller. List + add + remove peer records. The data behind // it is generated by scripts/webui/data/generators/peers/webui_peers.sh and // served at /data/peers/generated/peers.json — Phase 2 only knows about // kind=backup-channel; the other kinds light up in Phase 3. class PeersPage { constructor(rootId = 'config-section') { // rootId is the container the page renders into when embedded under // /admin/tools/peers. The peers-* element IDs are unique enough that // global document.getElementById queries still find them inside the // injected template — but keeping the ref for future scoped queries. this.rootId = rootId; this.peers = []; this.backupLocations = []; // populated for the loc_idx dropdown this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; this.eventBound = false; } async init() { this.bindEvents(); await this.refreshAll(); this.render(); } async refreshAll() { const ts = Date.now(); const [peersData, backupLocs] = await Promise.all([ this.fetchJson(`/data/peers/generated/peers.json?t=${ts}`), this.fetchJson(`/data/backup/generated/locations.json?t=${ts}`) ]); this.peers = peersData?.peers || []; this.backupLocations = (backupLocs?.locations || []).filter(l => l.enabled); } async fetchJson(url) { try { const r = await fetch(url); if (!r.ok) return null; return await r.json(); } catch { return null; } } bindEvents() { if (this.eventBound) return; this.eventBound = true; document.addEventListener('click', (e) => { if (e.target.closest('#peers-refresh-btn')) { this.runTask('libreportal regen webui --force', 'webui', null); setTimeout(() => this.refreshAll().then(() => this.render()), 2000); return; } if (e.target.closest('#peers-add-btn')) { this.openAddModal(); return; } if (e.target.closest('#peers-add-confirm')) { this.confirmAdd(); return; } if (e.target.closest('#peers-token-btn')) { this.openTokenModal(); return; } if (e.target.closest('#peers-pair-btn')) { this.openPairModal(); return; } if (e.target.closest('#peers-pair-confirm')){ this.confirmPair(); return; } if (e.target.closest('#peers-pull-confirm')){ this.confirmPull(); return; } const removeBtn = e.target.closest('[data-action="peer-remove"]'); if (removeBtn) { this.removePeer(removeBtn.dataset.name); return; } const checkBtn = e.target.closest('[data-action="peer-check"]'); if (checkBtn) { this.checkPeer(checkBtn.dataset.name); return; } const pullBtn = e.target.closest('[data-action="peer-pull"]'); if (pullBtn) { this.openPullModal(pullBtn.dataset.peer, pullBtn.dataset.app); return; } const appsBtn = e.target.closest('[data-action="peer-apps"]'); if (appsBtn) { this.fetchAndShowPeerApps(appsBtn.dataset.name); return; } if (e.target.closest('[data-close-modal]') || e.target.matches('.backup-modal')) { this.closeAllModals(); return; } }); } render() { const list = document.getElementById('peers-list'); const empty = document.getElementById('peers-empty'); if (!list || !empty) return; if (!this.peers.length) { list.innerHTML = ''; empty.hidden = false; return; } empty.hidden = true; list.innerHTML = this.peers.map(p => this.renderPeerCard(p)).join(''); } renderPeerCard(peer) { const cfg = peer.config || {}; const cfgSummary = this.summariseConfig(peer.kind, cfg); const statusClass = { ok: 'ok', 'no-snapshots': 'warn', 'config-error': 'fail', 'not-yet-implemented': 'warn', 'unknown-kind': 'fail', unknown: 'none' }[peer.status] || 'none'; const statusLabel = { ok: 'Reachable', 'no-snapshots': 'No recent snapshots', 'config-error': 'Config error', 'not-yet-implemented': 'Not yet supported', 'unknown-kind': 'Unknown kind', unknown: 'Not checked' }[peer.status] || peer.status; return `
Fetching token…
`; modal.classList.add('open'); // Run via the task system so the keypair generation (first-time only) // surfaces in the task log if it fails. const id = await this.runTaskCapture('libreportal peer token', 'peer', null); const token = await this.readTaskOutput(id); if (!token || !token.trim().startsWith('lp-peer:')) { body.innerHTML = `Could not generate token. Check the task log.
`; return; } const trimmed = token.trim(); body.innerHTML = `Copy this token and paste it into the other LibrePortal's Pair with token dialog. Symmetric — you'll need to do the same with theirs.
Anyone with this token can SSH in as the manager user with a forced-command (only the safe peer-shell verbs). Treat it like a strong password — share only over a channel you trust (encrypted chat, password manager).
`; } openPairModal() { const modal = document.getElementById('peers-pair-modal'); const body = document.getElementById('peers-pair-modal-body'); if (!modal || !body) return; body.innerHTML = `Accepting authorizes the originator to call peer-shell as the manager user (locked-down: no shell, no forwarding, only the whitelisted verbs). A peer record is also created so this host can pull from them.
`; modal.classList.add('open'); } async confirmPair() { const modal = document.getElementById('peers-pair-modal'); if (!modal) return; const token = modal.querySelector('#peer-pair-token')?.value?.trim(); const override = modal.querySelector('#peer-pair-name')?.value?.trim(); if (!token || !token.startsWith('lp-peer:')) { this.notify('That doesn\'t look like a pairing token (should start with lp-peer:).', 'error'); return; } this.closeAllModals(); const cmd = override ? `libreportal peer pair '${token.replace(/'/g, "'\\''")}' ${override}` : `libreportal peer pair '${token.replace(/'/g, "'\\''")}'`; await this.runTask(cmd, 'peer', null); setTimeout(() => this.refreshAll().then(() => this.render()), 2000); } /* --- direct-ssh peer: list-apps + pull --- */ async fetchAndShowPeerApps(peerName) { const zone = document.querySelector(`.peers-apps-zone[data-peer="${CSS.escape(peerName)}"]`); if (!zone) return; zone.hidden = false; zone.innerHTML = `Fetching app list…
`; const id = await this.runTaskCapture(`libreportal peer apps ${peerName}`, 'peer', null); const out = await this.readTaskOutput(id); let parsed; try { parsed = JSON.parse(out); } catch { parsed = null; } if (!parsed || !Array.isArray(parsed.apps)) { zone.innerHTML = `No app list — peer unreachable or returned malformed JSON. See task log.
`; return; } if (!parsed.apps.length) { zone.innerHTML = `${this.escape(peerName)} has no apps installed.
`; return; } zone.innerHTML = `Pull ${this.escape(app)} from peer ${this.escape(peer)}?
Streams the app's live data over SSH (via the peer-shell forced-command) and replaces this host's copy.
${this.escape(cfg.hostname)}` : 'no hostname set';
const loc = cfg.loc_idx != null ? `location ${this.escape(String(cfg.loc_idx))}` : 'any enabled location';
return `${host} · ${loc}`;
}
// direct-ssh kinds: minimal placeholder until Phase 3.
return Object.keys(cfg).length
? Object.entries(cfg).map(([k, v]) => `${this.escape(k)}=${this.escape(String(v))}`).join(' · ')
: '(no config)';
}
openAddModal() {
const modal = document.getElementById('peers-add-modal');
const body = document.getElementById('peers-add-modal-body');
if (!modal || !body) return;
const locOptions = ['']
.concat(this.backupLocations.map(l =>
``))
.join('');
body.innerHTML = `