// 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(peer.name)} (${this.escape(peer.kind)}) ${this.escape(statusLabel)}
${cfgSummary}
${peer.last_seen ? `
Checked ${this.escape(this.formatRelativeTime(peer.last_seen))}
` : ''}
`; } summariseConfig(kind, cfg) { if (kind === 'backup-channel') { const host = cfg.hostname ? `hostname=${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 = `
Used as the label everywhere — letters, digits, ._- only.
Match the source LibrePortal's CFG_INSTALL_NAME (or hostname if that's unset).
Pinning a location skips probing the others for snapshots.
`; modal.classList.add('open'); } async confirmAdd() { const modal = document.getElementById('peers-add-modal'); if (!modal) return; const name = modal.querySelector('#peer-add-name')?.value?.trim(); const kind = modal.querySelector('#peer-add-kind')?.value || 'backup-channel'; const host = modal.querySelector('#peer-add-hostname')?.value?.trim(); const loc = modal.querySelector('#peer-add-loc')?.value; if (!name) { this.notify('Name is required.', 'error'); return; } if (!host) { this.notify('Hostname is required for backup-channel peers.', 'error'); return; } if (!/^[A-Za-z0-9._-]+$/.test(name)) { this.notify('Name must use letters, digits, dot, underscore or dash.', 'error'); return; } this.closeAllModals(); const cfgPairs = [`hostname=${host}`]; if (loc) cfgPairs.push(`loc_idx=${loc}`); const cmd = `libreportal peer add ${name} ${kind} ${cfgPairs.join(' ')}`; await this.runTask(cmd, 'peer', null); setTimeout(() => this.refreshAll().then(() => this.render()), 1500); } async removePeer(name) { if (!confirm(`Remove peer "${name}"?\n\nThis only removes the local label — backups and the other host are untouched.`)) return; await this.runTask(`libreportal peer remove ${name}`, 'peer', null); setTimeout(() => this.refreshAll().then(() => this.render()), 1500); } async checkPeer(name) { await this.runTask(`libreportal peer check ${name}`, 'peer', null); setTimeout(() => this.refreshAll().then(() => this.render()), 2000); } closeAllModals() { document.querySelectorAll('.backup-modal.open').forEach(m => m.classList.remove('open')); } async runTask(command, type, app) { if (!this.taskManager) { this.notify('Task system unavailable', 'error'); return; } try { await this.taskManager.createTask(command, type, app); } catch (err) { this.notify(`Failed to queue task: ${err.message || err}`, 'error'); } } notify(message, kind) { if (typeof window.showNotification === 'function') window.showNotification(message, kind); else if (kind === 'error') console.error(message); else console.log(message); } formatRelativeTime(iso) { if (!iso) return 'never'; const t = Date.parse(iso); if (!t) return iso; const diff = Date.now() - t; const minute = 60_000, hour = 60 * minute, day = 24 * hour; if (diff < hour) return `${Math.max(1, Math.round(diff / minute))} min ago`; if (diff < day) return `${Math.round(diff / hour)} h ago`; if (diff < 7 * day) return `${Math.round(diff / day)} d ago`; return new Date(t).toISOString().slice(0, 10); } escape(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } } window.PeersPage = PeersPage;