// 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() { 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; } 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; } 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 `
${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 = `