librelad cfdd39386c feat(admin): move Peers into Admin/Tools; lift System next to Overview
Two related UI tidies — both removing surface area from the topbar / Tools
group rather than adding new pages.

Peers → /admin/tools/peers
  Was a top-level /peers route with its own topbar nav item, which doubled
  the navigation surface for what's really an admin tool (same shape as
  SSH Access). Now lives under the Admin sidebar's Tools group alongside
  SSH Access. /peers is kept as a legacy redirect → /admin/tools/peers.

  Plumbing:
  - config-sidebar.js gains a Peers entry under the Tools label.
  - config-manager.js gains a 'peers' branch that fetches
    peers-content.html into config-section, then inits PeersPage.
  - window.adminPath() learns 'peers' → /admin/tools/peers.
  - spa.js handlePeers() is now a redirect (mirrors handleSsh).
  - topbar.html drops the Peers nav item.
  - peers-content.html slimmed to a config-section template (no
    standalone page wrapper) so it embeds cleanly under the admin shell.
  - PeersPage gains a rootId constructor arg for symmetry with SshPage
    (queries still work globally — IDs are unique).

System lifted out of the Tools group
  User feedback: 'overview/system are kinda like, the same thing'. Moved
  System to sit right under Overview at the top of the sidebar, before
  the 'Config' label. Both surfaces are admin-landing pages (Overview =
  ops/health summary, System = live host + per-app stats) — distinct from
  config form pages or the Tools utilities.

  config-sidebar.js: System block moved to the top section (right after
  Overview's click handler). Original Tools-group instance removed.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 20:16:45 +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;