// 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 `
${this.escape(peer.name)} (${this.escape(peer.kind)}) ${this.escape(statusLabel)}
${cfgSummary}
${peer.last_seen ? `
Checked ${this.escape(this.formatRelativeTime(peer.last_seen))}
` : ''}
${peer.kind === 'direct-ssh-direct' ? ` ` : ''}
`; } /* --- pairing wizard --- */ async openTokenModal() { const modal = document.getElementById('peers-token-modal'); const body = document.getElementById('peers-token-modal-body'); if (!modal || !body) return; body.innerHTML = `

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 = `
Only set this if the token's name would collide with an existing peer here.

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 = `
${parsed.apps.map(a => `
${this.escape(a.slug)} ${this.formatBytes((a.size_kb || 0) * 1024)}
`).join('')}
`; } openPullModal(peer, app) { const modal = document.getElementById('peers-pull-modal'); const body = document.getElementById('peers-pull-modal-body'); if (!modal || !body) return; body.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.

`; modal.dataset.peer = peer; modal.dataset.app = app; modal.classList.add('open'); } async confirmPull() { const modal = document.getElementById('peers-pull-modal'); if (!modal) return; const { peer, app } = modal.dataset; const preBackup = document.getElementById('peer-pull-opt-pre-backup')?.checked; const rewrite = document.getElementById('peer-pull-opt-rewrite')?.checked; const opts = []; if (preBackup === false) opts.push('--no-pre-backup'); if (rewrite === false) opts.push('--keep-urls'); const optStr = opts.length ? ' ' + opts.join(' ') : ''; this.closeAllModals(); await this.runTask(`libreportal peer pull ${peer} ${app}${optStr}`, 'peer', app); } /* --- task-capture helpers (used by token/apps which need the output) --- */ async runTaskCapture(command, type, app) { // Same as runTask but returns the task id so the caller can read its log. if (!this.taskManager) { this.notify('Task system unavailable', 'error'); return null; } try { const t = await this.taskManager.createTask(command, type, app); return t?.id || null; } catch (err) { this.notify(`Failed to queue task: ${err.message || err}`, 'error'); return null; } } async readTaskOutput(taskId, maxWaitMs = 8000) { // Poll the task's log until it completes or we hit a cap. Fast verbs // (token, apps) finish in well under a second; this is the cheapest // way to surface their stdout without subscribing to SSE. if (!taskId) return ''; const start = Date.now(); while (Date.now() - start < maxWaitMs) { try { const r = await fetch(`/api/tasks/${taskId}`); if (r.ok) { const t = await r.json(); if (t.status === 'completed' || t.status === 'failed') { const lr = await fetch(`/api/tasks/${taskId}/log`); if (lr.ok) return await lr.text(); return ''; } } } catch { /* network blip — retry */ } await new Promise(res => setTimeout(res, 250)); } return ''; } formatBytes(b) { if (!b || b < 1024) return `${b || 0} B`; const u = ['KB','MB','GB','TB']; let i = -1; let v = b; do { v /= 1024; i++; } while (v >= 1024 && i < u.length - 1); return `${v.toFixed(v >= 10 ? 0 : 1)} ${u[i]}`; } 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;