Persist peers across restarts; move connect form to modal
CI / test (push) Successful in 9m55s

Always store configured connections in localStorage (with invite/alias), restore and re-dial on boot, and free the sidebar so every nav item stays reachable via a scrollable nav plus an Add peer modal.
This commit is contained in:
2026-07-10 21:53:28 -04:00
parent 2cccb8a04e
commit 5d3342fe4f
5 changed files with 512 additions and 307 deletions
+190 -99
View File
@@ -314,6 +314,7 @@ function waitForPeerResponse(expectedMessageFragment, timeout = 900000) {
} }
// Utility functions for managing cookies and localStorage // Utility functions for managing cookies and localStorage
// Pear/desktop apps often do not persist document.cookie — always prefer localStorage.
const COOKIE_SIZE_LIMIT = 4000; // 4KB limit for cookies const COOKIE_SIZE_LIMIT = 4000; // 4KB limit for cookies
const CONNECTIONS_STORAGE_KEY = 'peardock_connections'; const CONNECTIONS_STORAGE_KEY = 'peardock_connections';
const USE_LOCALSTORAGE_KEY = 'peardock_use_localstorage'; const USE_LOCALSTORAGE_KEY = 'peardock_use_localstorage';
@@ -323,66 +324,98 @@ function setCookie(name, value, days = 365) {
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`; const expires = `expires=${date.toUTCString()}`;
const cookieValue = `${name}=${encodeURIComponent(value)};${expires};path=/`; const cookieValue = `${name}=${encodeURIComponent(value)};${expires};path=/`;
// Check cookie size (approximate)
if (cookieValue.length > COOKIE_SIZE_LIMIT) {
console.warn(`[WARN] Cookie size (${cookieValue.length} bytes) exceeds limit. Using localStorage instead.`);
// Mark that we should use localStorage
try { try {
localStorage.setItem(USE_LOCALSTORAGE_KEY, 'true');
localStorage.setItem(CONNECTIONS_STORAGE_KEY, value);
return;
} catch (err) {
console.error(`[ERROR] Failed to save to localStorage: ${err.message}`);
// Fall through to try cookie anyway
}
}
document.cookie = cookieValue; document.cookie = cookieValue;
} catch (err) {
console.warn(`[WARN] Failed to set cookie ${name}: ${err.message}`);
}
} }
function getCookie(name) { function getCookie(name) {
try {
const cookies = document.cookie.split('; '); const cookies = document.cookie.split('; ');
for (let i = 0; i < cookies.length; i++) { for (let i = 0; i < cookies.length; i++) {
const [key, value] = cookies[i].split('='); const [key, value] = cookies[i].split('=');
if (key === name) return decodeURIComponent(value); if (key === name) return decodeURIComponent(value);
} }
} catch {
// ignore
}
return null; return null;
} }
function deleteCookie(name) { function deleteCookie(name) {
try {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
} catch {
// ignore
}
} }
// Load saved server public keys from cookies or localStorage /**
* Read raw saved connections JSON from localStorage (primary) then cookies (legacy).
* @returns {string|null}
*/
function readConnectionsRaw() {
try {
const fromLs = localStorage.getItem(CONNECTIONS_STORAGE_KEY);
if (fromLs) return fromLs;
} catch (err) {
console.warn(`[WARN] localStorage read failed: ${err.message}`);
}
// Legacy: cookie-only storage (and older flag values 'true' / '1')
return getCookie('connections');
}
// Load saved server public keys (localStorage first — survives Pear restarts)
function loadConnections() { function loadConnections() {
let savedConnections = null; let savedConnections = null;
try { try {
const useLocalStorage = localStorage.getItem(USE_LOCALSTORAGE_KEY); savedConnections = readConnectionsRaw();
if (useLocalStorage === 'true') {
savedConnections = localStorage.getItem(CONNECTIONS_STORAGE_KEY);
} else {
savedConnections = getCookie('connections');
}
} catch (err) { } catch (err) {
console.warn(`[WARN] localStorage not available, falling back to cookies: ${err.message}`); console.warn(`[WARN] Failed to load connections: ${err.message}`);
savedConnections = getCookie('connections');
} }
const parsed = savedConnections ? JSON.parse(savedConnections) : {}; let parsed = {};
const connections = {}; if (savedConnections) {
try {
parsed = JSON.parse(savedConnections);
} catch (err) {
console.error(`[ERROR] Corrupt connections storage: ${err.message}`);
parsed = {};
}
}
// Also merge manager-compatible list if present
try {
const fromManager = manager.loadSaved?.() || [];
for (const entry of fromManager) {
if (!entry?.publicKeyHex) continue;
const id = entry.id || entry.publicKeyHex.slice(0, 12);
if (!parsed[id]) {
parsed[id] = {
publicKeyHex: entry.publicKeyHex,
alias: entry.alias || null,
inviteToken: entry.inviteToken || null,
};
}
}
} catch {
// manager may not be ready in tests
}
const result = {};
for (const topicId in parsed) { for (const topicId in parsed) {
const entry = parsed[topicId]; const entry = parsed[topicId] || {};
// publicKeyHex is modern; topicHex is legacy hyperswarm topic storage // publicKeyHex is modern; topicHex is legacy hyperswarm topic storage
const publicKeyHex = (entry.publicKeyHex || entry.topicHex || '').toLowerCase(); const publicKeyHex = String(entry.publicKeyHex || entry.topicHex || entry.topic || '').toLowerCase();
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) continue; if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) continue;
const id = publicKeyHex.substring(0, 12); const id = publicKeyHex.substring(0, 12);
connections[id] = { result[id] = {
publicKeyHex, publicKeyHex,
topicHex: publicKeyHex, // keep field name for older UI bindings topicHex: publicKeyHex, // keep field name for older UI bindings
alias: entry.alias || null, alias: entry.alias || null,
inviteToken: entry.inviteToken || null,
peer: null, peer: null,
connectedAt: null, connectedAt: null,
lastHealthCheck: null, lastHealthCheck: null,
@@ -391,59 +424,51 @@ function loadConnections() {
}; };
} }
return connections; return result;
} }
// Save connections to cookies or localStorage // Save configured peers to localStorage (primary) + cookie backup when small
function saveConnections() { function saveConnections() {
const serializableConnections = {}; const serializableConnections = {};
for (const topicId in connections) { for (const topicId in connections) {
const { publicKeyHex, topicHex, alias } = connections[topicId]; const { publicKeyHex, topicHex, alias, inviteToken } = connections[topicId];
const key = publicKeyHex || topicHex; const key = publicKeyHex || topicHex;
if (!key) continue;
serializableConnections[topicId] = { serializableConnections[topicId] = {
publicKeyHex: key, publicKeyHex: key,
topicHex: key, topicHex: key,
alias: alias || null, alias: alias || null,
inviteToken: inviteToken || null,
}; };
} }
const serialized = JSON.stringify(serializableConnections); const serialized = JSON.stringify(serializableConnections);
// Check size and use appropriate storage
if (serialized.length > COOKIE_SIZE_LIMIT) {
// Use localStorage for large data
try { try {
localStorage.setItem(USE_LOCALSTORAGE_KEY, 'true');
localStorage.setItem(CONNECTIONS_STORAGE_KEY, serialized); localStorage.setItem(CONNECTIONS_STORAGE_KEY, serialized);
console.log('[INFO] Saved connections to localStorage (data too large for cookies)'); localStorage.setItem(USE_LOCALSTORAGE_KEY, '1');
console.log('[INFO] Saved connections to localStorage', Object.keys(serializableConnections).length);
} catch (err) { } catch (err) {
console.error(`[ERROR] Failed to save to localStorage: ${err.message}`); console.error(`[ERROR] Failed to save connections to localStorage: ${err.message}`);
// Try cookie as fallback (may fail but we try)
setCookie('connections', serialized);
}
} else {
// Use cookies for small data
try {
localStorage.removeItem(USE_LOCALSTORAGE_KEY);
localStorage.removeItem(CONNECTIONS_STORAGE_KEY);
} catch (err) {
// Ignore localStorage errors
} }
// Cookie backup for web contexts (may not work in Pear)
if (serialized.length <= COOKIE_SIZE_LIMIT) {
setCookie('connections', serialized); setCookie('connections', serialized);
} }
} }
// Add Reset Connections Button
// Toggle Reset Connections Button Visibility // Toggle Reset Connections Button Visibility
function toggleResetButtonVisibility() { function toggleResetButtonVisibility() {
const resetConnectionsBtn = document.querySelector('#sidebar .btn-danger'); const resetConnectionsBtn =
document.getElementById('reset-connections-btn') ||
document.querySelector('#sidebar .reset-connections-btn');
if (!resetConnectionsBtn) return; if (!resetConnectionsBtn) return;
// Show or hide the button based on active connections resetConnectionsBtn.style.display = Object.keys(connections).length > 0 ? '' : 'none';
resetConnectionsBtn.style.display = Object.keys(connections).length > 0 ? 'block' : 'none';
} }
@@ -5050,12 +5075,23 @@ async function addConnection(publicKeyHex, meta = {}) {
// Keep welcome visible until HyperDHT + RPC are actually connected // Keep welcome visible until HyperDHT + RPC are actually connected
const topicId = publicKeyHex.substring(0, 12); const topicId = publicKeyHex.substring(0, 12);
const alias = meta.alias || connections[topicId]?.alias || null;
const inviteToken = meta.inviteToken || connections[topicId]?.inviteToken || null;
// Already live — just activate
if (connections[topicId]?.peer?.connected) {
manager.setActive(connections[topicId].peer.id || topicId);
switchConnection(topicId);
hideWelcomePage();
return;
}
connections[topicId] = { connections[topicId] = {
publicKeyHex, publicKeyHex,
topicHex: publicKeyHex, topicHex: publicKeyHex,
peer: null, peer: null,
alias: null, alias,
inviteToken,
connectedAt: null, connectedAt: null,
lastHealthCheck: null, lastHealthCheck: null,
latency: null, latency: null,
@@ -5063,19 +5099,22 @@ async function addConnection(publicKeyHex, meta = {}) {
}; };
saveConnections(); saveConnections();
const connectionItem = document.createElement('li'); // Ensure a single list item per peer (restores / retries)
let connectionItem = connectionList?.querySelector?.(`[data-topic-id="${topicId}"]`);
if (!connectionItem) {
connectionItem = document.createElement('li');
connectionItem.className = 'list-group-item d-flex align-items-center justify-content-between'; connectionItem.className = 'list-group-item d-flex align-items-center justify-content-between';
connectionItem.dataset.topicId = topicId; connectionItem.dataset.topicId = topicId;
const displayName = connections[topicId].alias || topicId; const displayName = alias || topicId;
connectionItem.innerHTML = ` connectionItem.innerHTML = `
<div class="connection-item"> <div class="connection-item">
<div class="d-flex align-items-center justify-content-between gap-2"> <div class="d-flex align-items-center justify-content-between gap-2">
<div class="connection-info text-truncate flex-grow-1"> <div class="connection-info text-truncate flex-grow-1">
<span class="d-flex align-items-center gap-2"> <span class="d-flex align-items-center gap-2">
<span class="connection-status status-disconnected"></span> <span class="connection-status status-disconnected"></span>
<span class="connection-name text-truncate fw-semibold">${displayName}</span> <span class="connection-name text-truncate fw-semibold">${escapeHtmlLite(displayName)}</span>
</span> </span>
<small class="text-muted d-block ms-4 mt-1 font-monospace" style="font-size:10px"></small> <small class="text-muted d-block ms-4 mt-1 font-monospace" style="font-size:10px">${escapeHtmlLite(topicId)}</small>
</div> </div>
<div class="btn-group btn-group-sm flex-shrink-0"> <div class="btn-group btn-group-sm flex-shrink-0">
<button class="btn btn-outline-primary docker-terminal-btn" title="Docker CLI"> <button class="btn btn-outline-primary docker-terminal-btn" title="Docker CLI">
@@ -5108,18 +5147,26 @@ async function addConnection(publicKeyHex, meta = {}) {
e.stopPropagation(); e.stopPropagation();
disconnectConnection(topicId, connectionItem); disconnectConnection(topicId, connectionItem);
}); });
connectionList?.appendChild(connectionItem);
} else {
updateConnectionStatus(topicId, false);
}
refreshContainerStats(); refreshContainerStats();
connectionList.appendChild(connectionItem); toggleResetButtonVisibility();
try { try {
showStatusIndicator('Connecting…'); if (!meta.quiet) showStatusIndicator('Connecting…');
const conn = await manager.connect(publicKeyHex, { const conn = await manager.connect(publicKeyHex, {
inviteToken: meta.inviteToken || undefined, inviteToken: inviteToken || undefined,
alias: meta.alias || undefined, alias: alias || undefined,
}); });
connections[topicId].peer = conn; connections[topicId].peer = conn;
connections[topicId].connectedAt = Date.now(); connections[topicId].connectedAt = Date.now();
connections[topicId].healthStatus = 'healthy'; connections[topicId].healthStatus = 'healthy';
if (alias) connections[topicId].alias = alias;
if (inviteToken) connections[topicId].inviteToken = inviteToken;
saveConnections();
updateConnectionStatus(topicId, true); updateConnectionStatus(topicId, true);
startHealthMonitoring(topicId); startHealthMonitoring(topicId);
manager.setActive(conn.id); manager.setActive(conn.id);
@@ -5127,24 +5174,37 @@ async function addConnection(publicKeyHex, meta = {}) {
startStatsInterval(); startStatsInterval();
warmSnapshot(); warmSnapshot();
hideWelcomePage(); hideWelcomePage();
hideStatusIndicator(); if (!meta.quiet) hideStatusIndicator();
showAlert('success', `Connected to ${topicId}`); if (!meta.quiet) showAlert('success', `Connected to ${alias || topicId}`);
} catch (err) { } catch (err) {
console.error('[ERROR] Connection failed', err); console.error('[ERROR] Connection failed', err);
connections[topicId].healthStatus = 'error'; connections[topicId].healthStatus = 'error';
// Keep peer configured so the next restart / retry can reconnect
saveConnections();
updateConnectionStatus(topicId, false); updateConnectionStatus(topicId, false);
hideStatusIndicator(); if (!meta.quiet) hideStatusIndicator(false);
if (!meta.quiet) {
presentError(err, 'connect', { showAlert, notificationManager }); presentError(err, 'connect', { showAlert, notificationManager });
}
// Leave failed peer slot in list but keep welcome if nothing is live // Leave failed peer slot in list but keep welcome if nothing is live
if (!hasActiveConnection()) { if (!hasActiveConnection()) {
showWelcomePage(); showWelcomePage();
} }
} }
}
if (sidebar && !sidebar.classList.contains('collapsed')) { /**
sidebar.classList.add('collapsed'); * Open the Add Peer modal (sidebar button / welcome CTA).
if (collapseSidebarBtn) collapseSidebarBtn.innerHTML = '<i class="fas fa-chevron-right"></i>'; */
} function openAddConnectionModal() {
const modalEl = document.getElementById('addConnectionModal');
if (!modalEl || typeof bootstrap === 'undefined') return;
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
// Focus key field after animation
setTimeout(() => {
document.getElementById('new-connection-topic')?.focus();
}, 200);
} }
// Function to open the template deploy modal // Function to open the template deploy modal
@@ -5209,6 +5269,14 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}, 500); }, 500);
// Add peer modal: open from sidebar / welcome
document.getElementById('open-add-connection-btn')?.addEventListener('click', () => {
openAddConnectionModal();
});
document.getElementById('welcome-add-peer-btn')?.addEventListener('click', () => {
openAddConnectionModal();
});
// Set up event listeners that depend on DOM elements // Set up event listeners that depend on DOM elements
if (addConnectionForm) { if (addConnectionForm) {
addConnectionForm.addEventListener('submit', (e) => { addConnectionForm.addEventListener('submit', (e) => {
@@ -5216,11 +5284,22 @@ document.addEventListener('DOMContentLoaded', () => {
const topicHex = newConnectionTopic ? newConnectionTopic.value.trim() : ''; const topicHex = newConnectionTopic ? newConnectionTopic.value.trim() : '';
const inviteEl = document.getElementById('new-connection-invite'); const inviteEl = document.getElementById('new-connection-invite');
const aliasEl = document.getElementById('new-connection-alias');
const inviteToken = inviteEl ? inviteEl.value.trim() : ''; const inviteToken = inviteEl ? inviteEl.value.trim() : '';
const alias = aliasEl ? aliasEl.value.trim() : '';
if (topicHex) { if (topicHex) {
addConnection(topicHex, { inviteToken: inviteToken || undefined }); addConnection(topicHex, {
inviteToken: inviteToken || undefined,
alias: alias || undefined,
});
if (newConnectionTopic) newConnectionTopic.value = ''; if (newConnectionTopic) newConnectionTopic.value = '';
if (inviteEl) inviteEl.value = ''; if (inviteEl) inviteEl.value = '';
if (aliasEl) aliasEl.value = '';
// Close modal after submit
const modalEl = document.getElementById('addConnectionModal');
if (modalEl && typeof bootstrap !== 'undefined') {
bootstrap.Modal.getInstance(modalEl)?.hide();
}
} }
}); });
} }
@@ -5251,41 +5330,34 @@ document.addEventListener('DOMContentLoaded', () => {
collapseSidebarBtn.addEventListener('click', () => { collapseSidebarBtn.addEventListener('click', () => {
if (sidebar) { if (sidebar) {
sidebar.classList.toggle('collapsed'); sidebar.classList.toggle('collapsed');
collapseSidebarBtn.innerHTML = sidebar.classList.contains('collapsed') ? '<i class="fas fa-chevron-right"></i>' : '<i class="fas fa-chevron-left"></i>'; collapseSidebarBtn.innerHTML = sidebar.classList.contains('collapsed')
? '<i class="fas fa-chevron-right"></i>'
// Toggle Reset Connections Button Visibility : '<i class="fas fa-chevron-left"></i>';
const resetConnectionsBtn = sidebar.querySelector('.btn-danger');
if (resetConnectionsBtn) {
resetConnectionsBtn.style.display = sidebar.classList.contains('collapsed') ? 'none' : 'block';
}
} }
}); });
} }
// Add Reset Connections Button // Reset saved peers (static button in connections panel)
if (sidebar) { document.getElementById('reset-connections-btn')?.addEventListener('click', () => {
const resetConnectionsBtn = document.createElement('button');
resetConnectionsBtn.textContent = 'Reset Connections';
resetConnectionsBtn.className = 'btn btn-danger w-100 mt-2';
resetConnectionsBtn.addEventListener('click', () => {
console.log('[INFO] Resetting connections and clearing storage.'); console.log('[INFO] Resetting connections and clearing storage.');
Object.keys(connections).forEach((topicId) => { Object.keys(connections).forEach((topicId) => {
disconnectConnection(topicId); disconnectConnection(topicId);
}); });
deleteCookie('connections'); deleteCookie('connections');
// Also clear localStorage
try { try {
localStorage.removeItem(USE_LOCALSTORAGE_KEY); localStorage.removeItem(USE_LOCALSTORAGE_KEY);
localStorage.removeItem(CONNECTIONS_STORAGE_KEY); localStorage.removeItem(CONNECTIONS_STORAGE_KEY);
} catch (err) { } catch (err) {
console.warn(`[WARN] Failed to clear localStorage: ${err.message}`); console.warn(`[WARN] Failed to clear localStorage: ${err.message}`);
} }
resetConnectionsView(); if (typeof connectionList !== 'undefined' && connectionList) {
showWelcomePage(); connectionList.innerHTML = '';
toggleResetButtonVisibility(); // Ensure button visibility is updated
});
sidebar.appendChild(resetConnectionsBtn);
} }
if (typeof resetConnectionsView === 'function') resetConnectionsView();
showWelcomePage();
toggleResetButtonVisibility();
});
toggleResetButtonVisibility();
// Initialize container filtering (lightweight, doesn't block) // Initialize container filtering (lightweight, doesn't block)
initContainerFiltering(); initContainerFiltering();
@@ -5419,35 +5491,54 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
// Restore connections synchronously (like old version for faster boot) // Restore configured peers from localStorage and re-dial
try { try {
const savedConnections = loadConnections(); const savedConnections = loadConnections();
console.log('[INFO] Loading saved connections:', savedConnections); const keys = Object.keys(savedConnections);
console.log('[INFO] Loading saved connections:', keys.length, keys);
// Restore saved connections with error handling // Restore in parallel; keep list populated even if dial fails
Object.keys(savedConnections).forEach((topicId) => { Promise.all(
keys.map(async (topicId) => {
try { try {
const entry = savedConnections[topicId]; const entry = savedConnections[topicId];
const publicKeyHex = entry.publicKeyHex || entry.topicHex || entry.topic; const publicKeyHex = entry.publicKeyHex || entry.topicHex || entry.topic;
if (publicKeyHex) addConnection(String(publicKeyHex)); if (!publicKeyHex) return;
await addConnection(String(publicKeyHex), {
alias: entry.alias || undefined,
inviteToken: entry.inviteToken || undefined,
quiet: true,
});
} catch (err) { } catch (err) {
console.error(`[ERROR] Failed to restore connection ${topicId}: ${err.message}`); console.error(`[ERROR] Failed to restore connection ${topicId}: ${err.message}`);
} }
}); })
).then(() => {
// Connections may still be dialing; only enter workspace once a peer is live
if (hasActiveConnection()) { if (hasActiveConnection()) {
hideWelcomePage(); hideWelcomePage();
startStatsInterval(); startStatsInterval();
} else if (keys.length > 0) {
// Peers configured but none live yet — stay on welcome with list visible
showWelcomePage();
showAlert('info', `Restored ${keys.length} saved peer(s). Reconnecting…`);
} else {
showWelcomePage();
}
toggleResetButtonVisibility();
assertVisibility();
});
// Show peer slots immediately while dials are in flight
if (keys.length > 0) {
// list items created by addConnection; welcome until live
showWelcomePage();
} else { } else {
showWelcomePage(); showWelcomePage();
} }
assertVisibility(); assertVisibility();
// Notification tray is already initialized globally in DOMContentLoaded
} catch (err) { } catch (err) {
console.error(`[ERROR] Failed to initialize connections: ${err.message}`); console.error(`[ERROR] Failed to initialize connections: ${err.message}`);
showWelcomePage(); // Show welcome page on error showWelcomePage();
// Notification tray is already initialized globally in DOMContentLoaded
} }
}); });
+49 -21
View File
@@ -49,6 +49,7 @@ export class ConnectionManager extends EventEmitter {
this._reconnect.set(id, { this._reconnect.set(id, {
publicKeyHex: key, publicKeyHex: key,
alias: meta.alias ?? prev?.alias ?? null, alias: meta.alias ?? prev?.alias ?? null,
inviteToken: meta.inviteToken ?? prev?.inviteToken ?? null,
attempts: 0, attempts: 0,
timer: null, timer: null,
intentional: false, intentional: false,
@@ -57,11 +58,13 @@ export class ConnectionManager extends EventEmitter {
const entry = this._reconnect.get(id) || { const entry = this._reconnect.get(id) || {
publicKeyHex: key, publicKeyHex: key,
alias: meta.alias || null, alias: meta.alias || null,
inviteToken: meta.inviteToken || null,
attempts: 0, attempts: 0,
timer: null, timer: null,
intentional: false, intentional: false,
} }
if (meta.alias) entry.alias = meta.alias if (meta.alias) entry.alias = meta.alias
if (meta.inviteToken) entry.inviteToken = meta.inviteToken
this._reconnect.set(id, entry) this._reconnect.set(id, entry)
} }
@@ -130,7 +133,7 @@ export class ConnectionManager extends EventEmitter {
} }
if (!conn) { if (!conn) {
this._reconnect.delete(id) this._reconnect.delete(id)
this.persist() this.persist({ removeId: id })
return return
} }
if (this.active === conn) { if (this.active === conn) {
@@ -140,7 +143,7 @@ export class ConnectionManager extends EventEmitter {
this.connections.delete(id) this.connections.delete(id)
await conn.close().catch(() => {}) await conn.close().catch(() => {})
this._reconnect.delete(id) this._reconnect.delete(id)
this.persist() this.persist({ removeId: id })
this.emit('remove', id) this.emit('remove', id)
if (this.connections.size === 0) this._stopHealthLoop() if (this.connections.size === 0) this._stopHealthLoop()
} }
@@ -190,6 +193,7 @@ export class ConnectionManager extends EventEmitter {
try { try {
await this.connect(entry.publicKeyHex, { await this.connect(entry.publicKeyHex, {
alias: entry.alias || undefined, alias: entry.alias || undefined,
inviteToken: entry.inviteToken || undefined,
skipReconnectReset: true, skipReconnectReset: true,
}) })
this.emit('reconnected', { id, publicKeyHex: entry.publicKeyHex }) this.emit('reconnected', { id, publicKeyHex: entry.publicKeyHex })
@@ -258,55 +262,78 @@ export class ConnectionManager extends EventEmitter {
}) })
} }
/** Persist connection keys (not live sockets). */ /**
persist() { * Persist connection keys (not live sockets).
const serializable = {} * Always writes localStorage (Pear/desktop does not reliably keep cookies).
* Merges with any existing stored peers so a transient disconnect does not
* wipe the configured roster — full remove goes through disconnect().
* @param {{ removeId?: string }} [opts]
*/
persist(opts = {}) {
let existing = {}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) existing = JSON.parse(raw) || {}
} catch {
existing = {}
}
// Start from existing roster, drop intentional removals
const serializable = { ...existing }
if (opts.removeId) delete serializable[opts.removeId]
for (const [id, conn] of this.connections) { for (const [id, conn] of this.connections) {
const recon = this._reconnect.get(id)
serializable[id] = { serializable[id] = {
publicKeyHex: conn.publicKeyHex, publicKeyHex: conn.publicKeyHex,
alias: conn.alias || null, alias: conn.alias || recon?.alias || serializable[id]?.alias || null,
inviteToken: recon?.inviteToken || serializable[id]?.inviteToken || null,
} }
} }
// Keep reconnect roster entries that are not currently live (saved peers)
for (const [id, recon] of this._reconnect) {
if (serializable[id] || !recon?.publicKeyHex) continue
serializable[id] = {
publicKeyHex: recon.publicKeyHex,
alias: recon.alias || null,
inviteToken: recon.inviteToken || null,
}
}
const json = JSON.stringify(serializable) const json = JSON.stringify(serializable)
try { try {
if (json.length > CONFIG.STORAGE.COOKIE_SIZE_LIMIT) {
localStorage.setItem(STORAGE_KEY, json) localStorage.setItem(STORAGE_KEY, json)
localStorage.setItem(USE_LS_KEY, '1') localStorage.setItem(USE_LS_KEY, '1')
} else {
document.cookie = `connections=${encodeURIComponent(json)};path=/;max-age=31536000`
localStorage.removeItem(USE_LS_KEY)
}
} catch (err) { } catch (err) {
console.error('[ERROR] Failed to persist connections', err) console.error('[ERROR] Failed to persist connections', err)
}
// Optional cookie backup for browser contexts
try { try {
localStorage.setItem(STORAGE_KEY, json) if (json.length <= CONFIG.STORAGE.COOKIE_SIZE_LIMIT) {
localStorage.setItem(USE_LS_KEY, '1') document.cookie = `connections=${encodeURIComponent(json)};path=/;max-age=31536000`
}
} catch { } catch {
// ignore // ignore
} }
} }
}
/** /**
* Load saved public keys (does not auto-connect). * Load saved public keys (does not auto-connect).
* @returns {Array<{ id: string, publicKeyHex: string, alias: string|null }>} * @returns {Array<{ id: string, publicKeyHex: string, alias: string|null, inviteToken?: string|null }>}
*/ */
loadSaved() { loadSaved() {
let raw = null let raw = null
try { try {
if (localStorage.getItem(USE_LS_KEY) === '1') {
raw = localStorage.getItem(STORAGE_KEY) raw = localStorage.getItem(STORAGE_KEY)
}
} catch { } catch {
// ignore // ignore
} }
if (!raw) { if (!raw) {
try {
const match = document.cookie.match(/(?:^|; )connections=([^;]*)/) const match = document.cookie.match(/(?:^|; )connections=([^;]*)/)
if (match) raw = decodeURIComponent(match[1]) if (match) raw = decodeURIComponent(match[1])
}
if (!raw) {
try {
raw = localStorage.getItem(STORAGE_KEY)
} catch { } catch {
// ignore // ignore
} }
@@ -327,6 +354,7 @@ export class ConnectionManager extends EventEmitter {
id: id || publicKeyHex.slice(0, 12), id: id || publicKeyHex.slice(0, 12),
publicKeyHex, publicKeyHex,
alias: value.alias || null, alias: value.alias || null,
inviteToken: value.inviteToken || null,
} }
}).filter((e) => /^[0-9a-f]{64}$/.test(e.publicKeyHex)) }).filter((e) => /^[0-9a-f]{64}$/.test(e.publicKeyHex))
} catch { } catch {
+55 -9
View File
@@ -89,6 +89,7 @@
<i class="fas fa-chevron-left"></i> <i class="fas fa-chevron-left"></i>
</button> </button>
<div class="content"> <div class="content">
<div class="sidebar-nav-scroll">
<div class="sidebar-section-label">Navigate <kbd class="text-muted" style="font-size:0.65rem">Ctrl+K</kbd></div> <div class="sidebar-section-label">Navigate <kbd class="text-muted" style="font-size:0.65rem">Ctrl+K</kbd></div>
<nav id="main-nav"> <nav id="main-nav">
<ul class="nav-menu"> <ul class="nav-menu">
@@ -170,19 +171,22 @@
</li> </li>
</ul> </ul>
</nav> </nav>
</div>
<hr class="sidebar-divider"> <hr class="sidebar-divider">
<div class="connections-panel"> <div class="connections-panel">
<div class="sidebar-section-label">Peers</div> <div class="connections-panel-header">
<ul id="connection-list" class="list-group mb-3"></ul> <div class="sidebar-section-label mb-0">Peers</div>
<form id="add-connection-form" autocomplete="off"> <button type="button" id="open-add-connection-btn" class="btn btn-sm btn-primary open-add-connection-btn" title="Add peer" aria-label="Add peer">
<input type="text" id="new-connection-topic" class="form-control mb-2" placeholder="Server public key…" spellcheck="false" required> <i class="fas fa-plus"></i>
<input type="text" id="new-connection-invite" class="form-control mb-2" placeholder="Invite token (optional)" spellcheck="false"> <span class="open-add-connection-label">Add</span>
<button type="submit" class="btn btn-primary"> </button>
<i class="fas fa-plug me-1"></i> Connect </div>
<ul id="connection-list" class="list-group"></ul>
<button type="button" id="reset-connections-btn" class="btn btn-outline-danger btn-sm w-100 mt-2 reset-connections-btn" title="Remove all saved peers">
<i class="fas fa-trash-can me-1"></i><span class="reset-connections-label">Reset peers</span>
</button> </button>
</form>
</div> </div>
</div> </div>
</div> </div>
@@ -211,10 +215,13 @@
<div class="welcome-step-num">3</div> <div class="welcome-step-num">3</div>
<div> <div>
<strong>Connect from here</strong> <strong>Connect from here</strong>
<span>Paste the key under <em>Peers</em> in the sidebar and manage containers, images, stacks, and terminals.</span> <span>Use <em>Add peer</em> to paste the public key (and optional invite token), then manage containers, images, stacks, and terminals.</span>
</div> </div>
</div> </div>
</div> </div>
<button type="button" id="welcome-add-peer-btn" class="btn btn-primary btn-lg mt-4">
<i class="fas fa-plug me-2"></i>Add peer
</button>
</div> </div>
<!-- Dashboard View --> <!-- Dashboard View -->
<div id="dashboard-view" class="view hidden"> <div id="dashboard-view" class="view hidden">
@@ -2118,6 +2125,45 @@ services:
</div> </div>
</div> </div>
<!-- Add Peer / Connection Modal -->
<div class="modal fade" id="addConnectionModal" tabindex="-1" aria-labelledby="add-connection-modal-title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-white">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="add-connection-modal-title">
<i class="fas fa-plug me-2"></i>Add peer
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="add-connection-form" autocomplete="off">
<div class="modal-body">
<p class="text-muted small mb-3">
Paste the server public key printed when you run <code>npm run server</code>. Optionally include an invite token if the host requires one.
</p>
<div class="mb-3">
<label for="new-connection-topic" class="form-label">Server public key</label>
<input type="text" id="new-connection-topic" class="form-control font-monospace" placeholder="64 hex characters…" spellcheck="false" required autocomplete="off">
</div>
<div class="mb-3">
<label for="new-connection-invite" class="form-label">Invite token <span class="text-muted">(optional)</span></label>
<input type="text" id="new-connection-invite" class="form-control font-monospace" placeholder="Invite token" spellcheck="false" autocomplete="off">
</div>
<div class="mb-0">
<label for="new-connection-alias" class="form-label">Alias <span class="text-muted">(optional)</span></label>
<input type="text" id="new-connection-alias" class="form-control" placeholder="e.g. prod-host" spellcheck="false" maxlength="64" autocomplete="off">
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-plug me-1"></i> Connect
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Docker Terminal Modal --> <!-- Docker Terminal Modal -->
<div class="modal fade" id="dockerTerminalModal" tabindex="-1" aria-labelledby="docker-terminal-title" aria-hidden="true"> <div class="modal fade" id="dockerTerminalModal" tabindex="-1" aria-labelledby="docker-terminal-title" aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
+66 -25
View File
@@ -200,11 +200,13 @@ body {
} }
#sidebar.collapsed .sidebar-section-label, #sidebar.collapsed .sidebar-section-label,
#sidebar.collapsed #add-connection-form,
#sidebar.collapsed .connection-name, #sidebar.collapsed .connection-name,
#sidebar.collapsed .connection-item small, #sidebar.collapsed .connection-item small,
#sidebar.collapsed .docker-terminal-btn, #sidebar.collapsed .docker-terminal-btn,
#sidebar.collapsed .disconnect-btn span { #sidebar.collapsed .disconnect-btn span,
#sidebar.collapsed .open-add-connection-label,
#sidebar.collapsed .reset-connections-label,
#sidebar.collapsed .reset-connections-btn {
display: none !important; display: none !important;
} }
@@ -212,6 +214,12 @@ body {
overflow: hidden; overflow: hidden;
} }
#sidebar.collapsed .open-add-connection-btn {
width: 100%;
justify-content: center;
padding: 8px;
}
#sidebar.collapsed .nav-link { #sidebar.collapsed .nav-link {
justify-content: center; justify-content: center;
padding: 12px; padding: 12px;
@@ -266,13 +274,24 @@ body {
} }
#sidebar .content { #sidebar .content {
padding: 16px 14px 20px; padding: 16px 14px 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0;
overflow: hidden; overflow: hidden;
} }
/* Nav scrolls independently so every item stays reachable */
.sidebar-nav-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding-right: 2px;
scrollbar-gutter: stable;
}
.sidebar-section-label { .sidebar-section-label {
font-size: 11px; font-size: 11px;
font-weight: 650; font-weight: 650;
@@ -321,20 +340,48 @@ body {
opacity: 1; opacity: 1;
} }
/* Connections panel */ /* Connections panel — fixed footprint at bottom; list scrolls if many peers */
.connections-panel { .connections-panel {
flex: 1; flex: 0 0 auto;
max-height: 38%;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-top: 4px; margin-top: 4px;
padding-top: 2px;
}
.connections-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
padding: 0 2px;
}
.connections-panel-header .sidebar-section-label {
margin: 0;
}
.open-add-connection-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 8px;
font-weight: 600;
font-size: 12px;
padding: 4px 10px;
flex-shrink: 0;
} }
#connection-list { #connection-list {
flex: 1; flex: 1 1 auto;
min-height: 0;
max-height: 160px;
overflow-y: auto; overflow-y: auto;
padding: 0 2px 8px; padding: 0 2px 4px;
margin-bottom: 10px !important; margin-bottom: 0 !important;
} }
#connection-list .list-group-item { #connection-list .list-group-item {
@@ -363,27 +410,19 @@ body {
background: transparent !important; background: transparent !important;
} }
#add-connection-form { .reset-connections-btn {
display: flex; border-radius: 8px;
flex-direction: column; font-size: 12px;
gap: 8px;
padding: 0 2px !important;
} }
#add-connection-form .form-control { #addConnectionModal .form-control {
width: 100%; font-size: 13px;
margin: 0 !important; }
font-family: var(--font-mono);
font-size: 11.5px; #addConnectionModal .font-monospace {
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
#add-connection-form .btn {
width: 100%;
border-radius: 10px;
font-weight: 600;
}
/* ─── Content / views ─── */ /* ─── Content / views ─── */
.view { .view {
margin-top: 0 !important; margin-top: 0 !important;
@@ -1174,7 +1213,9 @@ option {
} }
#sidebar .nav-label, #sidebar .nav-label,
#sidebar .sidebar-section-label, #sidebar .sidebar-section-label,
#sidebar #add-connection-form, #sidebar .open-add-connection-label,
#sidebar .reset-connections-label,
#sidebar .reset-connections-btn,
#sidebar .connections-panel > h4 { #sidebar .connections-panel > h4 {
display: none; display: none;
} }
+13 -14
View File
@@ -181,9 +181,7 @@
width: 60px; width: 60px;
} }
#sidebar.collapsed .content { /* Keep collapsed nav icons reachable (do not hide entire .content) */
display: none;
}
#collapse-sidebar-btn { #collapse-sidebar-btn {
position: absolute; position: absolute;
@@ -1449,13 +1447,7 @@
margin-bottom: var(--spacing-xs); margin-bottom: var(--spacing-xs);
} }
/* Add Connection Form */ /* Add peer modal form */
#add-connection-form {
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border-color);
}
#add-connection-form .form-control { #add-connection-form .form-control {
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -1471,10 +1463,17 @@
box-shadow: 0 0 0 3px rgba(45, 212, 191, 0.1); box-shadow: 0 0 0 3px rgba(45, 212, 191, 0.1);
} }
#add-connection-form .btn-primary { .connections-panel-header {
padding: var(--spacing-sm) var(--spacing-md); display: flex;
font-size: 0.875rem; align-items: center;
white-space: nowrap; justify-content: space-between;
gap: 8px;
}
.sidebar-nav-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
} }
/* Filter and Search Bar Styling */ /* Filter and Search Bar Styling */