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>
255 lines
11 KiB
JavaScript
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 => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
}[c]));
|
|
}
|
|
}
|
|
|
|
window.PeersPage = PeersPage;
|