Final modularization layout (user-chosen): every page is a self-contained folder under components/<id>/ (controllers + CSS + its html fragment), and all shared/framework code folds into core/: core/kernel (feature-registry, lifecycle, services, spa) core/boot (auth, system-loader/orchestrator, setup, loaders) core/lib (data-loader, router, helpers, the task kernel, shared modules) core/ui (topbar, modal, notifications, … + topbar.html) core/css (all shared stylesheets) core/icons Top level is now just: components/, core/, themes/, index.html (+ runtime data/). Every path reference rewritten (index.html, scripts arrays, fetch()/ loadFragment()/loadScript() literals, system-loader + config-manager controller paths, kernel manifest URL, feature.json, backend FEATURES_DIR). The /api/features/list endpoint NAME is unchanged (it now scans components/). Deleted 3 dead files (app-content.html, apps-content.html, html-cache.js). Verified: 0 stale prefixes, 0 double-rewrites, all JS/JSON valid. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
456 lines
22 KiB
JavaScript
456 lines
22 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(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 `
|
|
<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>
|
|
${peer.kind === 'direct-ssh-direct' ? `
|
|
<button class="backup-secondary-btn" data-action="peer-apps" data-name="${this.escape(peer.name)}">List apps</button>
|
|
` : ''}
|
|
<button class="backup-danger-btn" data-action="peer-remove" data-name="${this.escape(peer.name)}">Remove</button>
|
|
</div>
|
|
</div>
|
|
<div class="peers-apps-zone" data-peer="${this.escape(peer.name)}" hidden style="margin-top:12px"></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/* --- 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 = `<p class="backup-card-hint">Fetching token…</p>`;
|
|
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 = `<p style="color:var(--danger,#dc2626)">Could not generate token. Check the task log.</p>`;
|
|
return;
|
|
}
|
|
const trimmed = token.trim();
|
|
body.innerHTML = `
|
|
<p>Copy this token and paste it into the other LibrePortal's <strong>Pair with token</strong> dialog. Symmetric — you'll need to do the same with theirs.</p>
|
|
<textarea class="form-control" readonly style="width:100%; min-height:140px; font-family:monospace; font-size:.85em; word-break:break-all">${this.escape(trimmed)}</textarea>
|
|
<p class="backup-card-hint" style="margin-top:8px">
|
|
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).
|
|
</p>
|
|
`;
|
|
}
|
|
|
|
openPairModal() {
|
|
const modal = document.getElementById('peers-pair-modal');
|
|
const body = document.getElementById('peers-pair-modal-body');
|
|
if (!modal || !body) return;
|
|
body.innerHTML = `
|
|
<div class="backup-form-grid">
|
|
<div>
|
|
<label for="peer-pair-token">Pairing token from the other LibrePortal</label>
|
|
<textarea class="form-control" id="peer-pair-token" placeholder="lp-peer:v1:…" style="min-height:120px; font-family:monospace; font-size:.85em"></textarea>
|
|
</div>
|
|
<div>
|
|
<label for="peer-pair-name">Local label (optional)</label>
|
|
<input type="text" class="form-control" id="peer-pair-name" placeholder="Override the name in the token">
|
|
<span class="backup-card-hint">Only set this if the token's name would collide with an existing peer here.</span>
|
|
</div>
|
|
</div>
|
|
<p class="backup-card-hint" style="margin-top:8px">
|
|
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.
|
|
</p>
|
|
`;
|
|
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 = `<p class="backup-card-hint">Fetching app list…</p>`;
|
|
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 = `<p style="color:var(--danger,#dc2626)">No app list — peer unreachable or returned malformed JSON. See task log.</p>`;
|
|
return;
|
|
}
|
|
if (!parsed.apps.length) {
|
|
zone.innerHTML = `<p class="backup-card-hint">${this.escape(peerName)} has no apps installed.</p>`;
|
|
return;
|
|
}
|
|
zone.innerHTML = `
|
|
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:6px">
|
|
${parsed.apps.map(a => `
|
|
<div style="display:flex; justify-content:space-between; align-items:center; padding:6px 10px; background:var(--surface-2, #1a1a1a); border-radius:6px">
|
|
<div>
|
|
<strong>${this.escape(a.slug)}</strong>
|
|
<span class="backup-card-hint" style="font-size:.78em; display:block">${this.formatBytes((a.size_kb || 0) * 1024)}</span>
|
|
</div>
|
|
<button class="backup-primary-btn" data-action="peer-pull" data-peer="${this.escape(peerName)}" data-app="${this.escape(a.slug)}">Pull</button>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
openPullModal(peer, app) {
|
|
const modal = document.getElementById('peers-pull-modal');
|
|
const body = document.getElementById('peers-pull-modal-body');
|
|
if (!modal || !body) return;
|
|
body.innerHTML = `
|
|
<p>Pull <strong>${this.escape(app)}</strong> from peer <strong>${this.escape(peer)}</strong>?</p>
|
|
<p class="backup-card-hint">Streams the app's live data over SSH (via the peer-shell forced-command) and replaces this host's copy.</p>
|
|
<div style="margin-top:14px; display:flex; flex-direction:column; gap:8px">
|
|
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer">
|
|
<input type="checkbox" id="peer-pull-opt-pre-backup" checked>
|
|
<span>Back up the destination's existing copy first
|
|
<span class="backup-card-hint" style="display:block; font-size:.85em">Safety net — snapshots ${this.escape(app)} into your first enabled backup location, tagged <code>pre-migrate</code>.</span>
|
|
</span>
|
|
</label>
|
|
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer">
|
|
<input type="checkbox" id="peer-pull-opt-rewrite" checked>
|
|
<span>Rewrite host-bound URLs to this host
|
|
<span class="backup-card-hint" style="display:block; font-size:.85em">Replaces CFG_*_URL / *_DOMAIN / *_HOSTNAME with this host's values after the transfer.</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
`;
|
|
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=<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;
|