feat(peers): introduce 'Peer' as a first-class concept (Phase 2)
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>
This commit is contained in:
parent
03ae556b42
commit
1014dd6e42
64
containers/libreportal/frontend/html/peers-content.html
Normal file
64
containers/libreportal/frontend/html/peers-content.html
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<div class="container peers-layout">
|
||||||
|
<div class="main">
|
||||||
|
<div class="peers-page" id="peers-page">
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-header-icon-slot">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="6" cy="7" r="3"></circle>
|
||||||
|
<circle cx="18" cy="7" r="3"></circle>
|
||||||
|
<path d="M9 17l3-3 3 3"></path>
|
||||||
|
<path d="M12 14v7"></path>
|
||||||
|
<path d="M6 10v3a3 3 0 003 3"></path>
|
||||||
|
<path d="M18 10v3a3 3 0 01-3 3"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="page-header-title">
|
||||||
|
<h1>Peers</h1>
|
||||||
|
<p>Named references to other LibrePortal instances. Use them in the Migrate tab to pull apps across without typing hostnames.</p>
|
||||||
|
</div>
|
||||||
|
<div class="page-header-actions">
|
||||||
|
<button class="backup-refresh-btn" id="peers-refresh-btn" title="Refresh">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="23 4 23 10 17 10"></polyline>
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||||||
|
</svg>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button class="backup-primary-btn" id="peers-add-btn">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
Add peer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="peers-empty" id="peers-empty" hidden>
|
||||||
|
<p>No peers yet.</p>
|
||||||
|
<p class="backup-card-hint">
|
||||||
|
Add one to give a memorable name to another LibrePortal you share a backup location with.
|
||||||
|
Direct-SSH peers (no shared backup repo needed) ship with Phase 3.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="peers-list" id="peers-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="backup-modal" id="peers-add-modal">
|
||||||
|
<div class="backup-modal-inner">
|
||||||
|
<div class="backup-modal-header">
|
||||||
|
<h3>Add a peer</h3>
|
||||||
|
<button class="backup-modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="backup-modal-body" id="peers-add-modal-body"></div>
|
||||||
|
<div class="backup-modal-footer">
|
||||||
|
<button class="backup-secondary-btn" data-close-modal>Cancel</button>
|
||||||
|
<button class="backup-primary-btn" id="peers-add-confirm">Add peer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -51,6 +51,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Backups
|
Backups
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/peers" class="nav-item" id="nav-peers">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="6" cy="7" r="3"></circle>
|
||||||
|
<circle cx="18" cy="7" r="3"></circle>
|
||||||
|
<path d="M9 17l3-3 3 3"></path>
|
||||||
|
<path d="M12 14v7"></path>
|
||||||
|
<path d="M6 10v3a3 3 0 003 3"></path>
|
||||||
|
<path d="M18 10v3a3 3 0 01-3 3"></path>
|
||||||
|
</svg>
|
||||||
|
Peers
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="mobile-drawer-page-section" id="mobile-drawer-page-section"></div>
|
<div class="mobile-drawer-page-section" id="mobile-drawer-page-section"></div>
|
||||||
<div class="topbar-controls">
|
<div class="topbar-controls">
|
||||||
|
|||||||
@ -100,6 +100,7 @@
|
|||||||
<script src="/js/components/backup/backup-page.js"></script>
|
<script src="/js/components/backup/backup-page.js"></script>
|
||||||
<script src="/js/components/backup/backup-app-card.js"></script>
|
<script src="/js/components/backup/backup-app-card.js"></script>
|
||||||
<script src="/js/components/ssh/ssh-page.js"></script>
|
<script src="/js/components/ssh/ssh-page.js"></script>
|
||||||
|
<script src="/js/components/peers/peers-page.js"></script>
|
||||||
<script src="/js/components/admin/charts.js"></script>
|
<script src="/js/components/admin/charts.js"></script>
|
||||||
<script src="/js/components/admin/admin-overview.js"></script>
|
<script src="/js/components/admin/admin-overview.js"></script>
|
||||||
<script src="/js/components/admin/admin-system.js"></script>
|
<script src="/js/components/admin/admin-system.js"></script>
|
||||||
|
|||||||
@ -0,0 +1,254 @@
|
|||||||
|
// 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;
|
||||||
@ -77,6 +77,8 @@ class LibrePortalSPAClean {
|
|||||||
this.routes.set('/tasks*', () => this.handleTasks()); // Handle /tasks with query
|
this.routes.set('/tasks*', () => this.handleTasks()); // Handle /tasks with query
|
||||||
this.routes.set('/backup', () => this.handleBackup());
|
this.routes.set('/backup', () => this.handleBackup());
|
||||||
this.routes.set('/backup*', () => this.handleBackup());
|
this.routes.set('/backup*', () => this.handleBackup());
|
||||||
|
this.routes.set('/peers', () => this.handlePeers());
|
||||||
|
this.routes.set('/peers*', () => this.handlePeers());
|
||||||
this.routes.set('/ssh', () => this.handleSsh()); // legacy → /admin/tools/ssh-access
|
this.routes.set('/ssh', () => this.handleSsh()); // legacy → /admin/tools/ssh-access
|
||||||
this.routes.set('/ssh*', () => this.handleSsh());
|
this.routes.set('/ssh*', () => this.handleSsh());
|
||||||
|
|
||||||
@ -272,6 +274,22 @@ class LibrePortalSPAClean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handlePeers() {
|
||||||
|
try {
|
||||||
|
const html = await this.fetchContent('/html/peers-content.html');
|
||||||
|
this.loadContent(html, 'Peers');
|
||||||
|
if (typeof PeersPage !== 'undefined') {
|
||||||
|
window.peersPage = new PeersPage();
|
||||||
|
await window.peersPage.init();
|
||||||
|
} else {
|
||||||
|
console.error('PeersPage class not loaded');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Peers page load error:', error);
|
||||||
|
this.showError('Failed to load peers page');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async handleSsh() {
|
async handleSsh() {
|
||||||
// Legacy /ssh → SSH Access under the Admin area.
|
// Legacy /ssh → SSH Access under the Admin area.
|
||||||
this.navigate('/admin/tools/ssh-access', true);
|
this.navigate('/admin/tools/ssh-access', true);
|
||||||
|
|||||||
45
scripts/cli/commands/peer/cli_peer_commands.sh
Normal file
45
scripts/cli/commands/peer/cli_peer_commands.sh
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
cliHandlePeerCommands()
|
||||||
|
{
|
||||||
|
local action="$initial_command2"
|
||||||
|
local arg1="$initial_command3"
|
||||||
|
local arg2="$initial_command4"
|
||||||
|
local arg3="$initial_command5"
|
||||||
|
local arg4="$initial_command6"
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
""|help)
|
||||||
|
cliShowPeerHelp
|
||||||
|
;;
|
||||||
|
list)
|
||||||
|
peerList
|
||||||
|
;;
|
||||||
|
get)
|
||||||
|
[[ -z "$arg1" ]] && { isNotice "Usage: peer get <name>"; return; }
|
||||||
|
peerGet "$arg1"
|
||||||
|
;;
|
||||||
|
add)
|
||||||
|
# peer add <name> <kind> [k=v] [k=v]
|
||||||
|
# Up to two k=v pairs from initial_command5..6 — covers backup-channel's
|
||||||
|
# hostname + loc_idx, which is the only kind that's wired today.
|
||||||
|
[[ -z "$arg1" || -z "$arg2" ]] && { isNotice "Usage: peer add <name> <kind> [key=value ...]"; return; }
|
||||||
|
peerAdd "$arg1" "$arg2" "$arg3" "$arg4"
|
||||||
|
;;
|
||||||
|
remove|rm|delete)
|
||||||
|
[[ -z "$arg1" ]] && { isNotice "Usage: peer remove <name>"; return; }
|
||||||
|
peerRemove "$arg1"
|
||||||
|
;;
|
||||||
|
check)
|
||||||
|
if [[ -z "$arg1" ]]; then
|
||||||
|
peerCheckAll
|
||||||
|
else
|
||||||
|
peerCheckReachable "$arg1"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
isNotice "Invalid peer action: $action"
|
||||||
|
cliShowPeerHelp
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
29
scripts/cli/commands/peer/cli_peer_header.sh
Normal file
29
scripts/cli/commands/peer/cli_peer_header.sh
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
cliShowPeerHelp()
|
||||||
|
{
|
||||||
|
isHeader "LibrePortal Peer Commands"
|
||||||
|
echo "Manage named references to other LibrePortal instances."
|
||||||
|
echo ""
|
||||||
|
echo "peer list"
|
||||||
|
echo " JSON dump of every peer record (id, kind, config, status)."
|
||||||
|
echo ""
|
||||||
|
echo "peer get <name>"
|
||||||
|
echo " JSON for a single peer."
|
||||||
|
echo ""
|
||||||
|
echo "peer add <name> backup-channel hostname=<host> [loc_idx=<n>]"
|
||||||
|
echo " Add a friendly label for another LibrePortal whose backups land in"
|
||||||
|
echo " a location this host can see. loc_idx defaults to 'any enabled'."
|
||||||
|
echo ""
|
||||||
|
echo "peer remove <name>"
|
||||||
|
echo " Delete the local peer record. Doesn't touch the other host or any"
|
||||||
|
echo " backups — just removes the label."
|
||||||
|
echo ""
|
||||||
|
echo "peer check [name]"
|
||||||
|
echo " Reachability probe. With <name>, checks one; without, all. Updates"
|
||||||
|
echo " the peer's status + last_seen columns."
|
||||||
|
echo ""
|
||||||
|
echo "Notes:"
|
||||||
|
echo " • Today only kind=backup-channel works. direct-ssh-direct and"
|
||||||
|
echo " direct-ssh-via-relay (Connect blind-relay) ship with Phase 3."
|
||||||
|
}
|
||||||
@ -67,6 +67,30 @@ databaseCreateTables()
|
|||||||
checkSuccess "Creating $setup_table_name table"
|
checkSuccess "Creating $setup_table_name table"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
setup_table_name=peers
|
||||||
|
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
|
||||||
|
# Named other LibrePortal instances. kind selects the transport:
|
||||||
|
# backup-channel Phase 1/2 — friendly label over a hostname
|
||||||
|
# that already shows up in a shared backup repo
|
||||||
|
# direct-ssh-direct Phase 3 — reachable peer over plain SSH
|
||||||
|
# direct-ssh-via-relay Phase 3b — peer over Connect's blind relay
|
||||||
|
# config_json carries kind-specific knobs (hostname, loc_idx, pubkey
|
||||||
|
# fingerprint, relay token, etc.) so adding new kinds doesn't need
|
||||||
|
# another schema migration.
|
||||||
|
local result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (
|
||||||
|
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
|
||||||
|
);")
|
||||||
|
checkSuccess "Creating $setup_table_name table"
|
||||||
|
local result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_peers_name ON peers(name);")
|
||||||
|
local result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_peers_kind ON peers(kind);")
|
||||||
|
fi
|
||||||
|
|
||||||
setup_table_name=network_resources
|
setup_table_name=network_resources
|
||||||
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
|
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
|
||||||
# Simple unified network resources table - replaces all complex network tables
|
# Simple unified network resources table - replaces all complex network tables
|
||||||
|
|||||||
70
scripts/peer/peer_add.sh
Normal file
70
scripts/peer/peer_add.sh
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Add a peer record. Caller provides name + kind + a key=value config blob
|
||||||
|
# that's serialised into the config_json column.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# peerAdd <name> <kind> [key1=val1] [key2=val2] ...
|
||||||
|
#
|
||||||
|
# Example (backup-channel — Phase 1/2):
|
||||||
|
# peerAdd homelab backup-channel hostname=homelab.local loc_idx=1
|
||||||
|
|
||||||
|
peerAdd()
|
||||||
|
{
|
||||||
|
local name="$1"; shift
|
||||||
|
local kind="$1"; shift
|
||||||
|
|
||||||
|
local nv; nv=$(peerValidateName "$name")
|
||||||
|
if [[ "$nv" != "ok" ]]; then
|
||||||
|
isError "Invalid peer name: $nv"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local kv; kv=$(peerValidateKind "$kind")
|
||||||
|
if [[ "$kv" != "ok" ]]; then
|
||||||
|
isError "Cannot use kind '$kind' yet: $kv"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Already exists?
|
||||||
|
local existing
|
||||||
|
existing=$(sqlite3 "$(_peerDb)" "SELECT id FROM peers WHERE name='$(peerSqlEscape "$name")';" 2>/dev/null)
|
||||||
|
if [[ -n "$existing" ]]; then
|
||||||
|
isError "Peer '$name' already exists (id=$existing). Use 'peer update' or 'peer remove' first."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build the JSON blob from key=value args. We don't pull in jq; values are
|
||||||
|
# JSON-escaped inline (only handles strings and bare numerics, which is
|
||||||
|
# what the kind-specific schemas need today).
|
||||||
|
local first=1
|
||||||
|
local cfg='{'
|
||||||
|
local kv_pair k v
|
||||||
|
for kv_pair in "$@"; do
|
||||||
|
k="${kv_pair%%=*}"
|
||||||
|
v="${kv_pair#*=}"
|
||||||
|
[[ -z "$k" || "$k" == "$kv_pair" ]] && continue # not a key=value
|
||||||
|
local rendered
|
||||||
|
if [[ "$v" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then
|
||||||
|
rendered="$v"
|
||||||
|
else
|
||||||
|
local esc="${v//\\/\\\\}"; esc="${esc//\"/\\\"}"
|
||||||
|
rendered="\"$esc\""
|
||||||
|
fi
|
||||||
|
if (( first )); then cfg+="\"$k\":$rendered"; first=0
|
||||||
|
else cfg+=",\"$k\":$rendered"; fi
|
||||||
|
done
|
||||||
|
cfg+='}'
|
||||||
|
|
||||||
|
sqlite3 "$(_peerDb)" \
|
||||||
|
"INSERT INTO peers (name, kind, config_json) VALUES ('$(peerSqlEscape "$name")', '$(peerSqlEscape "$kind")', '$(peerSqlEscape "$cfg")');" 2>/dev/null
|
||||||
|
|
||||||
|
if [[ $? -eq 0 ]]; then
|
||||||
|
isSuccessful "Peer '$name' added (kind=$kind)"
|
||||||
|
# Refresh WebUI cache if the generator is loaded.
|
||||||
|
declare -F webuiGeneratePeers >/dev/null 2>&1 && webuiGeneratePeers >/dev/null 2>&1 || true
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
isError "Failed to insert peer '$name'"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
82
scripts/peer/peer_check.sh
Normal file
82
scripts/peer/peer_check.sh
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Reachability check for a peer. The meaning of "reachable" depends on kind:
|
||||||
|
# backup-channel At least one snapshot from this peer's hostname is
|
||||||
|
# visible in the configured location within the last
|
||||||
|
# 30 days (or ever, if it's just been added).
|
||||||
|
# direct-ssh-direct SSH connect + 'peer-shell ping' (Phase 3).
|
||||||
|
# direct-ssh-via-relay Open relay session + 'peer-shell ping' (Phase 3b).
|
||||||
|
#
|
||||||
|
# Updates the peer's status + last_seen columns on success/failure so the UI
|
||||||
|
# can render a colored dot without re-running the check on every page load.
|
||||||
|
|
||||||
|
peerCheckReachable()
|
||||||
|
{
|
||||||
|
local name="$1"
|
||||||
|
if [[ -z "$name" ]]; then isError "peerCheckReachable: name required"; return 1; fi
|
||||||
|
|
||||||
|
local row
|
||||||
|
row=$(sqlite3 "$(_peerDb)" "SELECT id, kind, config_json FROM peers WHERE name='$(peerSqlEscape "$name")';" 2>/dev/null)
|
||||||
|
if [[ -z "$row" ]]; then
|
||||||
|
isError "No peer named '$name'"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local id kind cfg
|
||||||
|
IFS='|' read -r id kind cfg <<< "$row"
|
||||||
|
|
||||||
|
local new_status="unknown"
|
||||||
|
local now
|
||||||
|
now=$(date -Iseconds)
|
||||||
|
|
||||||
|
case "$kind" in
|
||||||
|
backup-channel)
|
||||||
|
local hostname loc_idx
|
||||||
|
hostname=$(printf '%s' "$cfg" | grep -o '"hostname":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||||
|
loc_idx=$(printf '%s' "$cfg" | grep -o '"loc_idx":[0-9]*' | head -1 | cut -d':' -f2)
|
||||||
|
if [[ -z "$hostname" ]]; then
|
||||||
|
new_status="config-error"
|
||||||
|
elif [[ -z "$loc_idx" ]]; then
|
||||||
|
# No preferred location — try any enabled location.
|
||||||
|
local found=""
|
||||||
|
while IFS= read -r idx; do
|
||||||
|
[[ -z "$idx" ]] && continue
|
||||||
|
if engineSnapshotsJson "$idx" "" "$hostname" 2>/dev/null | grep -q '"short_id":'; then
|
||||||
|
found="$idx"; break
|
||||||
|
fi
|
||||||
|
done < <(resticEnabledLocations)
|
||||||
|
[[ -n "$found" ]] && new_status="ok" || new_status="no-snapshots"
|
||||||
|
else
|
||||||
|
if engineSnapshotsJson "$loc_idx" "" "$hostname" 2>/dev/null | grep -q '"short_id":'; then
|
||||||
|
new_status="ok"
|
||||||
|
else
|
||||||
|
new_status="no-snapshots"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
direct-ssh-direct|direct-ssh-via-relay)
|
||||||
|
new_status="not-yet-implemented"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
new_status="unknown-kind"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
sqlite3 "$(_peerDb)" \
|
||||||
|
"UPDATE peers SET status='$(peerSqlEscape "$new_status")', last_seen='$now' WHERE id=$id;" 2>/dev/null
|
||||||
|
|
||||||
|
echo "$new_status"
|
||||||
|
[[ "$new_status" == "ok" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check every peer; useful for the WebUI's "Refresh" button.
|
||||||
|
peerCheckAll()
|
||||||
|
{
|
||||||
|
local name
|
||||||
|
while IFS= read -r name; do
|
||||||
|
[[ -z "$name" ]] && continue
|
||||||
|
local status
|
||||||
|
status=$(peerCheckReachable "$name")
|
||||||
|
isNotice " $name → $status"
|
||||||
|
done < <(sqlite3 "$(_peerDb)" "SELECT name FROM peers ORDER BY name;" 2>/dev/null)
|
||||||
|
}
|
||||||
48
scripts/peer/peer_helpers.sh
Normal file
48
scripts/peer/peer_helpers.sh
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Shared helpers for the peer subsystem. Peers are named other LibrePortal
|
||||||
|
# instances; rows live in the sqlite `peers` table (db_create_tables.sh).
|
||||||
|
#
|
||||||
|
# kind enum:
|
||||||
|
# backup-channel Friendly label over a hostname that shows up in a
|
||||||
|
# shared backup repo. No new networking — Phase 1/2.
|
||||||
|
# direct-ssh-direct Reachable peer over plain SSH (Phase 3).
|
||||||
|
# direct-ssh-via-relay Peer over Connect's blind relay (Phase 3b).
|
||||||
|
#
|
||||||
|
# config_json is kind-specific. For backup-channel:
|
||||||
|
# {"hostname":"homelab","loc_idx":1}
|
||||||
|
|
||||||
|
_peerDb() { echo "$docker_dir/$db_file"; }
|
||||||
|
|
||||||
|
# Quote a value for SQLite (escape single quotes by doubling). Stdin in,
|
||||||
|
# stdout out. Caller wraps the result in their own single quotes.
|
||||||
|
peerSqlEscape()
|
||||||
|
{
|
||||||
|
local s="$1"
|
||||||
|
printf "%s" "${s//\'/\'\'}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate that a string is a reasonable peer-name (alnum, dash, underscore,
|
||||||
|
# dot; 1..64 chars). Echo "ok" or the rejection reason; caller checks for "ok".
|
||||||
|
peerValidateName()
|
||||||
|
{
|
||||||
|
local name="$1"
|
||||||
|
if [[ -z "$name" ]]; then echo "empty"; return 1; fi
|
||||||
|
if [[ ${#name} -gt 64 ]]; then echo "too-long"; return 1; fi
|
||||||
|
if [[ ! "$name" =~ ^[A-Za-z0-9._-]+$ ]]; then echo "invalid-chars"; return 1; fi
|
||||||
|
echo "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate kind. Phase 2 only allows 'backup-channel'; the others are accepted
|
||||||
|
# at the schema level but the bash helpers reject them until Phase 3 ships
|
||||||
|
# their support to avoid users adding peers that nothing knows how to use.
|
||||||
|
peerValidateKind()
|
||||||
|
{
|
||||||
|
local kind="$1"
|
||||||
|
case "$kind" in
|
||||||
|
backup-channel) echo "ok" ;;
|
||||||
|
direct-ssh-direct|direct-ssh-via-relay)
|
||||||
|
echo "not-yet-implemented"; return 1 ;;
|
||||||
|
*) echo "unknown-kind"; return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
63
scripts/peer/peer_list.sh
Normal file
63
scripts/peer/peer_list.sh
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# List peers — JSON array, one row per peer. Used by the WebUI generator and
|
||||||
|
# the CLI's `libreportal peer list` command. Output is one line of JSON.
|
||||||
|
|
||||||
|
peerList()
|
||||||
|
{
|
||||||
|
local out='['
|
||||||
|
local first=1
|
||||||
|
local row
|
||||||
|
while IFS='|' read -r id name kind config_json status last_seen created_at; do
|
||||||
|
[[ -z "$id" ]] && continue
|
||||||
|
# Each field is sqlite-escaped (single-quote-doubled) and JSON-encoded
|
||||||
|
# on the way out — and config_json is already JSON so we paste it raw.
|
||||||
|
local name_e="${name//\\/\\\\}"; name_e="${name_e//\"/\\\"}"
|
||||||
|
local kind_e="${kind//\\/\\\\}"; kind_e="${kind_e//\"/\\\"}"
|
||||||
|
local status_e="${status//\\/\\\\}"; status_e="${status_e//\"/\\\"}"
|
||||||
|
local last_e="${last_seen//\\/\\\\}"; last_e="${last_e//\"/\\\"}"
|
||||||
|
local created_e="${created_at//\\/\\\\}"; created_e="${created_e//\"/\\\"}"
|
||||||
|
local cfg="${config_json:-{\}}"
|
||||||
|
|
||||||
|
(( first )) || out+=","
|
||||||
|
first=0
|
||||||
|
out+="{\"id\":$id,\"name\":\"$name_e\",\"kind\":\"$kind_e\",\"config\":$cfg,\"status\":\"$status_e\",\"last_seen\":\"$last_e\",\"created_at\":\"$created_e\"}"
|
||||||
|
done < <(sqlite3 "$(_peerDb)" "SELECT id, name, kind, config_json, COALESCE(status,''), COALESCE(last_seen,''), COALESCE(created_at,'') FROM peers ORDER BY name;" 2>/dev/null)
|
||||||
|
out+=']'
|
||||||
|
echo "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
peerGet()
|
||||||
|
{
|
||||||
|
local name="$1"
|
||||||
|
if [[ -z "$name" ]]; then echo "null"; return 1; fi
|
||||||
|
local row
|
||||||
|
row=$(sqlite3 "$(_peerDb)" "SELECT id, name, kind, config_json, COALESCE(status,''), COALESCE(last_seen,''), COALESCE(created_at,'') FROM peers WHERE name='$(peerSqlEscape "$name")';" 2>/dev/null)
|
||||||
|
[[ -z "$row" ]] && { echo "null"; return 1; }
|
||||||
|
|
||||||
|
local id n k cfg s last created
|
||||||
|
IFS='|' read -r id n k cfg s last created <<< "$row"
|
||||||
|
local name_e="${n//\\/\\\\}"; name_e="${name_e//\"/\\\"}"
|
||||||
|
local kind_e="${k//\\/\\\\}"; kind_e="${kind_e//\"/\\\"}"
|
||||||
|
printf '{"id":%s,"name":"%s","kind":"%s","config":%s,"status":"%s","last_seen":"%s","created_at":"%s"}\n' \
|
||||||
|
"$id" "$name_e" "$kind_e" "${cfg:-{\}}" "$s" "$last" "$created"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Lookup peer name by hostname. Walks the backup-channel peers, parses their
|
||||||
|
# config.hostname, returns the matching peer name (or empty). Cheap; small N.
|
||||||
|
peerNameForHostname()
|
||||||
|
{
|
||||||
|
local hostname="$1"
|
||||||
|
[[ -z "$hostname" ]] && return 1
|
||||||
|
local row
|
||||||
|
while IFS='|' read -r name cfg; do
|
||||||
|
[[ -z "$name" ]] && continue
|
||||||
|
local h
|
||||||
|
h=$(printf '%s' "$cfg" | grep -o '"hostname":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||||
|
if [[ "$h" == "$hostname" ]]; then
|
||||||
|
echo "$name"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done < <(sqlite3 "$(_peerDb)" "SELECT name, config_json FROM peers WHERE kind='backup-channel';" 2>/dev/null)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
27
scripts/peer/peer_remove.sh
Normal file
27
scripts/peer/peer_remove.sh
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Delete a peer by name. Doesn't touch backups, SSH keys, or any actual peer
|
||||||
|
# state on the other host — just removes the local label.
|
||||||
|
|
||||||
|
peerRemove()
|
||||||
|
{
|
||||||
|
local name="$1"
|
||||||
|
if [[ -z "$name" ]]; then isError "peerRemove: name required"; return 1; fi
|
||||||
|
|
||||||
|
local existing
|
||||||
|
existing=$(sqlite3 "$(_peerDb)" "SELECT id FROM peers WHERE name='$(peerSqlEscape "$name")';" 2>/dev/null)
|
||||||
|
if [[ -z "$existing" ]]; then
|
||||||
|
isNotice "No peer named '$name'"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
sqlite3 "$(_peerDb)" "DELETE FROM peers WHERE name='$(peerSqlEscape "$name")';" 2>/dev/null
|
||||||
|
if [[ $? -eq 0 ]]; then
|
||||||
|
isSuccessful "Peer '$name' removed"
|
||||||
|
declare -F webuiGeneratePeers >/dev/null 2>&1 && webuiGeneratePeers >/dev/null 2>&1 || true
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
isError "Failed to remove peer '$name'"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ files_libreportal_app=(
|
|||||||
"${migrate_scripts[@]}"
|
"${migrate_scripts[@]}"
|
||||||
"${network_scripts[@]}"
|
"${network_scripts[@]}"
|
||||||
"${os_scripts[@]}"
|
"${os_scripts[@]}"
|
||||||
|
"${peer_scripts[@]}"
|
||||||
"${restore_scripts[@]}"
|
"${restore_scripts[@]}"
|
||||||
"${setup_scripts[@]}"
|
"${setup_scripts[@]}"
|
||||||
"${source_scripts[@]}"
|
"${source_scripts[@]}"
|
||||||
|
|||||||
@ -24,6 +24,8 @@ cli_scripts=(
|
|||||||
"cli/commands/install/cli_install_header.sh"
|
"cli/commands/install/cli_install_header.sh"
|
||||||
"cli/commands/ip/cli_ip_commands.sh"
|
"cli/commands/ip/cli_ip_commands.sh"
|
||||||
"cli/commands/ip/cli_ip_header.sh"
|
"cli/commands/ip/cli_ip_header.sh"
|
||||||
|
"cli/commands/peer/cli_peer_commands.sh"
|
||||||
|
"cli/commands/peer/cli_peer_header.sh"
|
||||||
"cli/commands/regen/cli_regen_commands.sh"
|
"cli/commands/regen/cli_regen_commands.sh"
|
||||||
"cli/commands/regen/cli_regen_header.sh"
|
"cli/commands/regen/cli_regen_header.sh"
|
||||||
"cli/commands/reset/cli_reset_commands.sh"
|
"cli/commands/reset/cli_reset_commands.sh"
|
||||||
|
|||||||
13
scripts/source/files/arrays/files_peer.sh
Normal file
13
scripts/source/files/arrays/files_peer.sh
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# This file is auto-generated by generate_arrays.sh
|
||||||
|
# Do not edit manually - run './scripts/source/files/generate_arrays.sh run' to regenerate
|
||||||
|
|
||||||
|
peer_scripts=(
|
||||||
|
"peer/peer_add.sh"
|
||||||
|
"peer/peer_check.sh"
|
||||||
|
"peer/peer_helpers.sh"
|
||||||
|
"peer/peer_list.sh"
|
||||||
|
"peer/peer_remove.sh"
|
||||||
|
|
||||||
|
)
|
||||||
@ -20,6 +20,7 @@ source_scripts=(
|
|||||||
"source/files/arrays/files_migrate.sh"
|
"source/files/arrays/files_migrate.sh"
|
||||||
"source/files/arrays/files_network.sh"
|
"source/files/arrays/files_network.sh"
|
||||||
"source/files/arrays/files_os.sh"
|
"source/files/arrays/files_os.sh"
|
||||||
|
"source/files/arrays/files_peer.sh"
|
||||||
"source/files/arrays/files_restore.sh"
|
"source/files/arrays/files_restore.sh"
|
||||||
"source/files/arrays/files_setup.sh"
|
"source/files/arrays/files_setup.sh"
|
||||||
"source/files/arrays/files_source.sh"
|
"source/files/arrays/files_source.sh"
|
||||||
|
|||||||
@ -26,6 +26,7 @@ webui_scripts=(
|
|||||||
"webui/data/generators/config/webui_cli_config_set.sh"
|
"webui/data/generators/config/webui_cli_config_set.sh"
|
||||||
"webui/data/generators/config/webui_generate_configs.sh"
|
"webui/data/generators/config/webui_generate_configs.sh"
|
||||||
"webui/data/generators/config/webui_update_config.sh"
|
"webui/data/generators/config/webui_update_config.sh"
|
||||||
|
"webui/data/generators/peers/webui_peers.sh"
|
||||||
"webui/data/generators/system/webui_ssh_access.sh"
|
"webui/data/generators/system/webui_ssh_access.sh"
|
||||||
"webui/data/generators/system/webui_system_disk.sh"
|
"webui/data/generators/system/webui_system_disk.sh"
|
||||||
"webui/data/generators/system/webui_system_info.sh"
|
"webui/data/generators/system/webui_system_info.sh"
|
||||||
|
|||||||
@ -17,6 +17,7 @@ files_libreportal_cli=(
|
|||||||
"${migrate_scripts[@]}"
|
"${migrate_scripts[@]}"
|
||||||
"${network_scripts[@]}"
|
"${network_scripts[@]}"
|
||||||
"${os_scripts[@]}"
|
"${os_scripts[@]}"
|
||||||
|
"${peer_scripts[@]}"
|
||||||
"${restore_scripts[@]}"
|
"${restore_scripts[@]}"
|
||||||
"${setup_scripts[@]}"
|
"${setup_scripts[@]}"
|
||||||
"${source_scripts[@]}"
|
"${source_scripts[@]}"
|
||||||
|
|||||||
31
scripts/webui/data/generators/peers/webui_peers.sh
Normal file
31
scripts/webui/data/generators/peers/webui_peers.sh
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Generate data/peers/generated/peers.json — drives the /peers WebUI page and
|
||||||
|
# is also read by the /backup/migrate tab to overlay friendly names on top of
|
||||||
|
# bare hostnames.
|
||||||
|
#
|
||||||
|
# This is just peerList wrapped with a generated_at envelope; no extra logic.
|
||||||
|
|
||||||
|
webuiGeneratePeers()
|
||||||
|
{
|
||||||
|
local output_dir="$containers_dir/libreportal/frontend/data/peers/generated"
|
||||||
|
local output_file="$output_dir/peers.json"
|
||||||
|
local temp_file="${output_file}.tmp.$$"
|
||||||
|
|
||||||
|
runFileOp mkdir -p "$output_dir"
|
||||||
|
|
||||||
|
local generated_at
|
||||||
|
generated_at=$(date -Iseconds)
|
||||||
|
local peers
|
||||||
|
peers=$(peerList 2>/dev/null)
|
||||||
|
[[ -z "$peers" ]] && peers='[]'
|
||||||
|
|
||||||
|
cat > "$temp_file" <<EOF
|
||||||
|
{
|
||||||
|
"generated_at": "$generated_at",
|
||||||
|
"peers": $peers
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
runFileOp mv "$temp_file" "$output_file"
|
||||||
|
runFileOp chmod 644 "$output_file" 2>/dev/null || true
|
||||||
|
}
|
||||||
@ -89,6 +89,12 @@ webuiLibrePortalUpdate() {
|
|||||||
local result=$(webuiGenerateBackupLocations && webuiGenerateBackupDashboard && webuiGenerateBackupSnapshots all && webuiGenerateBackupAppStatus && webuiGenerateBackupEngines && webuiGenerateBackupSchema && webuiGenerateBackupPasswords && webuiGenerateBackupMigrate)
|
local result=$(webuiGenerateBackupLocations && webuiGenerateBackupDashboard && webuiGenerateBackupSnapshots all && webuiGenerateBackupAppStatus && webuiGenerateBackupEngines && webuiGenerateBackupSchema && webuiGenerateBackupPasswords && webuiGenerateBackupMigrate)
|
||||||
checkSuccess "Refreshed backup dashboard data..."
|
checkSuccess "Refreshed backup dashboard data..."
|
||||||
|
|
||||||
|
# Peers (named other LibrePortal instances) — small, cheap; lives
|
||||||
|
# in its own data/peers/generated/peers.json file consumed by
|
||||||
|
# /peers and overlay-read by the migrate tab.
|
||||||
|
local result=$(webuiGeneratePeers)
|
||||||
|
checkSuccess "Refreshed peers data..."
|
||||||
|
|
||||||
# SSH access snapshot (authorized keys + password-login state)
|
# SSH access snapshot (authorized keys + password-login state)
|
||||||
local result=$(webuiGenerateSshAccess)
|
local result=$(webuiGenerateSshAccess)
|
||||||
checkSuccess "Refreshed SSH access data..."
|
checkSuccess "Refreshed SSH access data..."
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user