End-to-end direct-ssh-direct: two LibrePortal instances exchange pairing
tokens, each authorizes the other to call a locked-down peer-shell dispatcher
via SSH forced-command, then either side can pull live app data from the
other without needing a shared backup repo.
Push and Connect-via-relay are deferred — push is symmetric to pull (same
forced-command, opposite verb), and the relay variant waits for Connect to
actually exist (config_json + kind enum already future-proofed in Phase 2).
Key generation (peer_key.sh):
One ed25519 keypair per install at ~<manager>/.ssh/libreportal-peer{,.pub}.
Generated lazily on the first peer-related call. Used as our outbound
SSH identity AND as the pubkey other instances authorize.
Forced-command dispatcher (peer_shell.sh):
Standalone script, deployed by peerInstallShell() to
~<manager>/.local/bin/peer-shell. authorized_keys entries look like:
command="~/.local/bin/peer-shell <peer-name>",no-pty,no-port-forwarding,
no-X11-forwarding,no-agent-forwarding,no-user-rc ssh-ed25519 AAAA… peer:<name>
sshd hands us $SSH_ORIGINAL_COMMAND; we parse, whitelist the verb, and
refuse anything else. Verbs:
ping Liveness probe (JSON ok:true).
list-apps JSON {peer, apps:[{slug, size_kb}]}.
stream-app tar of containers_dir/<slug> to stdout (slug strictly
validated — lowercase alnum+dash; rejects path traversal).
Audit log appended to ~/.local/state/libreportal/peer-shell.log. Excluded
from the generated source arrays (would crash any sourcing shell on empty
SSH_ORIGINAL_COMMAND); generate_arrays.sh skip-list extended.
Pairing token (peer_pairing.sh):
Format: lp-peer|v1|<name>|<user>|<host>|<port>|<base64-pubkey>|<fingerprint>
Pipe-delimited because the SHA256 fingerprint and base64 pubkey both
contain ':'. peerPairingParse decodes + re-derives the fingerprint from
the actual key, refusing tokens with mismatched fingerprints (catches
truncation / tampering). peerPairingAccept:
1. Installs peer-shell (peerInstallShell).
2. Appends to authorized_keys with the lockdown options above.
3. Inserts a peers row (kind=direct-ssh-direct, config carries host,
port, user, fingerprint).
Symmetric — user runs accept on BOTH sides with the other's token to
enable bidirectional calls.
Outbound SSH (peer_remote.sh):
peerExec <name> <verb> [args] — looks up the peer's connection config and
ssh's in with the right key, BatchMode + ConnectTimeout + accept-new for
the host key. peerPing wraps it and updates peers.status + last_seen.
Pull-an-app (peer_pull.sh):
peerPullApp <peer> <app> [--no-pre-backup] [--keep-urls]
1. peerPing (refuse if unreachable).
2. migratePreBackupDestination (reuses the Phase 0 safety wrapper —
same restic-tagged pre-migrate snapshot as the backup-channel flow).
3. Stop + wipe destination's app folder.
4. peerExec stream-app | tar -x (pipefail; bails on partial transfers).
5. migrateApplyUrlRewrite + dockerComposeUpdateAndStartApp install
(URL repointing, idempotent install path).
6. dockerComposeUp + post-restore hooks.
Identical Stage-2..6 to migrateApplyApp; only the data source differs
(tar-over-SSH instead of restic-restore).
CLI (cli_peer_commands.sh + header):
libreportal peer token — emit this host's pairing token
libreportal peer pair <token> [name] — accept a token (override name)
libreportal peer apps <peer> — live peer-shell list-apps
libreportal peer pull <peer> <app> [--no-pre-backup] [--keep-urls]
WebUI (/peers):
Header gains 'Show my token' and 'Pair with token' buttons (both open
modals around the matching CLI verbs). Token modal warns the user that
the token is credentials. Pair modal accepts a free-form override name.
Direct-SSH peer cards gain a 'List apps' button that opens an inline
drawer showing the peer's live app inventory (via peer apps) with per-
app 'Pull' buttons. Pull modal has the same two safety toggles as the
Migrate tab (pre-backup ON, URL rewrite ON by default).
Backup-channel manual-add modal kept; direct-SSH must use the token flow.
Smoke-tested:
- All 16 peer-subsystem functions register without crashing the shell.
- peer-shell ping ⇒ {ok:true}; unknown-verb refused; path-traversal slug
refused; valid-slug streams.
- Token emit→parse round-trip preserves every field; garbage rejected
with not-a-token; v99 rejected with unsupported-version.
Signed-off-by: librelad <librelad@digitalangels.vip>
451 lines
22 KiB
JavaScript
451 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() {
|
|
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;
|