librelad 1014dd6e42 feat(peers): introduce 'Peer' as a first-class concept (Phase 2)
A peer is a named reference to another LibrePortal instance. Phase 2 only
implements kind=backup-channel (friendly label over a hostname that shows
up in a shared backup repo); direct-ssh-direct and direct-ssh-via-relay
(Connect's blind-relay) are reserved enum values for Phase 3.

DB schema (db_create_tables.sh):
  CREATE TABLE peers (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    name         TEXT UNIQUE NOT NULL,
    kind         TEXT NOT NULL DEFAULT 'backup-channel',
    config_json  TEXT NOT NULL DEFAULT '{}',
    status       TEXT DEFAULT 'unknown',
    last_seen    TEXT,
    created_at   TEXT DEFAULT CURRENT_TIMESTAMP
  );
  + indexes on name and kind.

  config_json is kind-specific so new transports don't need a schema
  migration. For backup-channel it carries {"hostname":"","loc_idx":N}.

Bash module (scripts/peer/):
  peer_helpers.sh   _peerDb, peerSqlEscape, peerValidateName/Kind.
  peer_add.sh       peerAdd <name> <kind> [k=v ...] → INSERT, refresh
                    generator. Rejects unimplemented kinds early so users
                    don't create dead-end peer records.
  peer_remove.sh    peerRemove <name> → DELETE.
  peer_list.sh      peerList → JSON array; peerGet, peerNameForHostname
                    (reverse-lookup for the migrate-tab overlay).
  peer_check.sh     peerCheckReachable, peerCheckAll. For backup-channel
                    'reachable' = at least one snapshot from that hostname
                    visible in (preferred|any enabled) location. Updates
                    status + last_seen so UI dots render without re-probing.

CLI (scripts/cli/commands/peer/):
  libreportal peer list
  libreportal peer get <name>
  libreportal peer add <name> backup-channel hostname=<host> [loc_idx=<n>]
  libreportal peer remove <name>
  libreportal peer check [name]

  Auto-routed by cli_initialize.sh's category-discovery.

WebUI data generator (scripts/webui/data/generators/peers/webui_peers.sh):
  Emits data/peers/generated/peers.json with the peerList output and a
  generated_at envelope. Hooked into webuiLibrePortalUpdate alongside the
  backup generators.

Frontend:
  - New top-level /peers route in spa.js (PeersPage class, peers-content.html).
  - 'Peers' nav item in the topbar between Backups and the right-side controls.
  - Add-peer modal with friendly-name + kind + hostname + preferred-location
    selector (populated from the existing backup-locations data).
  - Per-peer card with status dot, last-checked time, Check + Remove buttons.
  - Phase 3 kinds appear in the kind dropdown as disabled options so users
    can see what's coming.

Source-array wiring:
  - generate_arrays.sh auto-created files_peer.sh from the new peer/ dir.
  - cli_files.sh + app_files.sh include ${peer_scripts[@]} alphabetically.
  - files_webui.sh auto-picked-up the new peers/ generator subfolder.

The migrate-tab friendly-name overlay (use peer names in /backup/migrate
when a peer record exists for a hostname) is intentionally deferred — it's
a 5-line frontend lookup once peers.json is loaded; cleaner to add after
Phase 3 ships its peer-detail view.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 17:43:56 +01:00

255 lines
11 KiB
JavaScript

// 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 `
<div class="backup-location-row" style="margin-bottom:10px; padding:14px 18px; background:var(--surface-2, #1a1a1a); border-radius:8px">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:14px">
<div style="min-width:0; flex:1">
<div style="display:flex; align-items:center; gap:10px; margin-bottom:4px">
<strong style="font-size:1.1em">${this.escape(peer.name)}</strong>
<span class="backup-card-hint" style="opacity:.7">(${this.escape(peer.kind)})</span>
<span class="backup-status-dot ${statusClass}" title="${this.escape(statusLabel)}"></span>
<span class="backup-card-hint" style="font-size:.8em">${this.escape(statusLabel)}</span>
</div>
<div class="backup-card-hint" style="font-size:.9em">${cfgSummary}</div>
${peer.last_seen ? `<div class="backup-card-hint" style="font-size:.78em; margin-top:4px">Checked ${this.escape(this.formatRelativeTime(peer.last_seen))}</div>` : ''}
</div>
<div style="display:flex; gap:8px; flex-shrink:0">
<button class="backup-secondary-btn" data-action="peer-check" data-name="${this.escape(peer.name)}">Check</button>
<button class="backup-danger-btn" data-action="peer-remove" data-name="${this.escape(peer.name)}">Remove</button>
</div>
</div>
</div>
`;
}
summariseConfig(kind, cfg) {
if (kind === 'backup-channel') {
const host = cfg.hostname ? `hostname=<code>${this.escape(cfg.hostname)}</code>` : '<em>no hostname set</em>';
const loc = cfg.loc_idx != null ? `location <code>${this.escape(String(cfg.loc_idx))}</code>` : '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)}=<code>${this.escape(String(v))}</code>`).join(' · ')
: '<em>(no config)</em>';
}
openAddModal() {
const modal = document.getElementById('peers-add-modal');
const body = document.getElementById('peers-add-modal-body');
if (!modal || !body) return;
const locOptions = ['<option value="">Any enabled location</option>']
.concat(this.backupLocations.map(l =>
`<option value="${l.idx}">${this.escape(l.name || `Location ${l.idx}`)} (idx ${l.idx})</option>`))
.join('');
body.innerHTML = `
<div class="backup-form-grid">
<div>
<label for="peer-add-name">Name</label>
<input type="text" class="form-control" id="peer-add-name" placeholder="e.g. homelab">
<span class="backup-card-hint">Used as the label everywhere — letters, digits, ._- only.</span>
</div>
<div>
<label for="peer-add-kind">Kind</label>
<select class="form-control" id="peer-add-kind">
<option value="backup-channel" selected>Backup channel (shared backup location)</option>
<option value="direct-ssh-direct" disabled>Direct SSH (Phase 3)</option>
<option value="direct-ssh-via-relay" disabled>Via Connect relay (Phase 3b)</option>
</select>
</div>
<div>
<label for="peer-add-hostname">Source hostname</label>
<input type="text" class="form-control" id="peer-add-hostname" placeholder="Hostname as it appears in restic snapshots">
<span class="backup-card-hint">Match the source LibrePortal's CFG_INSTALL_NAME (or hostname if that's unset).</span>
</div>
<div>
<label for="peer-add-loc">Preferred location</label>
<select class="form-control" id="peer-add-loc">${locOptions}</select>
<span class="backup-card-hint">Pinning a location skips probing the others for snapshots.</span>
</div>
</div>
`;
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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
}
window.PeersPage = PeersPage;