librelad d39852aa3d refactor(webui): reorganize into components/ + core/ taxonomy
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>
2026-05-30 07:13:52 +01:00

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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
}
window.PeersPage = PeersPage;