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:
@@ -314,6 +314,7 @@ function waitForPeerResponse(expectedMessageFragment, timeout = 900000) {
|
||||
}
|
||||
|
||||
// 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 CONNECTIONS_STORAGE_KEY = 'peardock_connections';
|
||||
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);
|
||||
const expires = `expires=${date.toUTCString()}`;
|
||||
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 {
|
||||
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;
|
||||
} catch (err) {
|
||||
console.warn(`[WARN] Failed to set cookie ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
try {
|
||||
const cookies = document.cookie.split('; ');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const [key, value] = cookies[i].split('=');
|
||||
if (key === name) return decodeURIComponent(value);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deleteCookie(name) {
|
||||
try {
|
||||
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() {
|
||||
let savedConnections = null;
|
||||
|
||||
try {
|
||||
const useLocalStorage = localStorage.getItem(USE_LOCALSTORAGE_KEY);
|
||||
if (useLocalStorage === 'true') {
|
||||
savedConnections = localStorage.getItem(CONNECTIONS_STORAGE_KEY);
|
||||
} else {
|
||||
savedConnections = getCookie('connections');
|
||||
}
|
||||
savedConnections = readConnectionsRaw();
|
||||
} catch (err) {
|
||||
console.warn(`[WARN] localStorage not available, falling back to cookies: ${err.message}`);
|
||||
savedConnections = getCookie('connections');
|
||||
console.warn(`[WARN] Failed to load connections: ${err.message}`);
|
||||
}
|
||||
|
||||
const parsed = savedConnections ? JSON.parse(savedConnections) : {};
|
||||
const connections = {};
|
||||
let parsed = {};
|
||||
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) {
|
||||
const entry = parsed[topicId];
|
||||
const entry = parsed[topicId] || {};
|
||||
// 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;
|
||||
const id = publicKeyHex.substring(0, 12);
|
||||
connections[id] = {
|
||||
result[id] = {
|
||||
publicKeyHex,
|
||||
topicHex: publicKeyHex, // keep field name for older UI bindings
|
||||
alias: entry.alias || null,
|
||||
inviteToken: entry.inviteToken || null,
|
||||
peer: null,
|
||||
connectedAt: 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() {
|
||||
const serializableConnections = {};
|
||||
|
||||
for (const topicId in connections) {
|
||||
const { publicKeyHex, topicHex, alias } = connections[topicId];
|
||||
const { publicKeyHex, topicHex, alias, inviteToken } = connections[topicId];
|
||||
const key = publicKeyHex || topicHex;
|
||||
if (!key) continue;
|
||||
serializableConnections[topicId] = {
|
||||
publicKeyHex: key,
|
||||
topicHex: key,
|
||||
alias: alias || null,
|
||||
inviteToken: inviteToken || null,
|
||||
};
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(serializableConnections);
|
||||
|
||||
// Check size and use appropriate storage
|
||||
if (serialized.length > COOKIE_SIZE_LIMIT) {
|
||||
// Use localStorage for large data
|
||||
try {
|
||||
localStorage.setItem(USE_LOCALSTORAGE_KEY, 'true');
|
||||
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) {
|
||||
console.error(`[ERROR] Failed to save 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
|
||||
console.error(`[ERROR] Failed to save connections to localStorage: ${err.message}`);
|
||||
}
|
||||
|
||||
// Cookie backup for web contexts (may not work in Pear)
|
||||
if (serialized.length <= COOKIE_SIZE_LIMIT) {
|
||||
setCookie('connections', serialized);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add Reset Connections Button
|
||||
// Toggle Reset Connections Button Visibility
|
||||
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;
|
||||
|
||||
// Show or hide the button based on active connections
|
||||
resetConnectionsBtn.style.display = Object.keys(connections).length > 0 ? 'block' : 'none';
|
||||
resetConnectionsBtn.style.display = Object.keys(connections).length > 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
|
||||
@@ -5050,12 +5075,23 @@ async function addConnection(publicKeyHex, meta = {}) {
|
||||
|
||||
// Keep welcome visible until HyperDHT + RPC are actually connected
|
||||
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] = {
|
||||
publicKeyHex,
|
||||
topicHex: publicKeyHex,
|
||||
peer: null,
|
||||
alias: null,
|
||||
alias,
|
||||
inviteToken,
|
||||
connectedAt: null,
|
||||
lastHealthCheck: null,
|
||||
latency: null,
|
||||
@@ -5063,19 +5099,22 @@ async function addConnection(publicKeyHex, meta = {}) {
|
||||
};
|
||||
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.dataset.topicId = topicId;
|
||||
const displayName = connections[topicId].alias || topicId;
|
||||
const displayName = alias || topicId;
|
||||
connectionItem.innerHTML = `
|
||||
<div class="connection-item">
|
||||
<div class="d-flex align-items-center justify-content-between gap-2">
|
||||
<div class="connection-info text-truncate flex-grow-1">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<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>
|
||||
<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 class="btn-group btn-group-sm flex-shrink-0">
|
||||
<button class="btn btn-outline-primary docker-terminal-btn" title="Docker CLI">
|
||||
@@ -5108,18 +5147,26 @@ async function addConnection(publicKeyHex, meta = {}) {
|
||||
e.stopPropagation();
|
||||
disconnectConnection(topicId, connectionItem);
|
||||
});
|
||||
connectionList?.appendChild(connectionItem);
|
||||
} else {
|
||||
updateConnectionStatus(topicId, false);
|
||||
}
|
||||
|
||||
refreshContainerStats();
|
||||
connectionList.appendChild(connectionItem);
|
||||
toggleResetButtonVisibility();
|
||||
|
||||
try {
|
||||
showStatusIndicator('Connecting…');
|
||||
if (!meta.quiet) showStatusIndicator('Connecting…');
|
||||
const conn = await manager.connect(publicKeyHex, {
|
||||
inviteToken: meta.inviteToken || undefined,
|
||||
alias: meta.alias || undefined,
|
||||
inviteToken: inviteToken || undefined,
|
||||
alias: alias || undefined,
|
||||
});
|
||||
connections[topicId].peer = conn;
|
||||
connections[topicId].connectedAt = Date.now();
|
||||
connections[topicId].healthStatus = 'healthy';
|
||||
if (alias) connections[topicId].alias = alias;
|
||||
if (inviteToken) connections[topicId].inviteToken = inviteToken;
|
||||
saveConnections();
|
||||
updateConnectionStatus(topicId, true);
|
||||
startHealthMonitoring(topicId);
|
||||
manager.setActive(conn.id);
|
||||
@@ -5127,24 +5174,37 @@ async function addConnection(publicKeyHex, meta = {}) {
|
||||
startStatsInterval();
|
||||
warmSnapshot();
|
||||
hideWelcomePage();
|
||||
hideStatusIndicator();
|
||||
showAlert('success', `Connected to ${topicId}`);
|
||||
if (!meta.quiet) hideStatusIndicator();
|
||||
if (!meta.quiet) showAlert('success', `Connected to ${alias || topicId}`);
|
||||
} catch (err) {
|
||||
console.error('[ERROR] Connection failed', err);
|
||||
connections[topicId].healthStatus = 'error';
|
||||
// Keep peer configured so the next restart / retry can reconnect
|
||||
saveConnections();
|
||||
updateConnectionStatus(topicId, false);
|
||||
hideStatusIndicator();
|
||||
if (!meta.quiet) hideStatusIndicator(false);
|
||||
if (!meta.quiet) {
|
||||
presentError(err, 'connect', { showAlert, notificationManager });
|
||||
}
|
||||
// Leave failed peer slot in list but keep welcome if nothing is live
|
||||
if (!hasActiveConnection()) {
|
||||
showWelcomePage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sidebar && !sidebar.classList.contains('collapsed')) {
|
||||
sidebar.classList.add('collapsed');
|
||||
if (collapseSidebarBtn) collapseSidebarBtn.innerHTML = '<i class="fas fa-chevron-right"></i>';
|
||||
}
|
||||
/**
|
||||
* Open the Add Peer modal (sidebar button / welcome CTA).
|
||||
*/
|
||||
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
|
||||
@@ -5209,6 +5269,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}, 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
|
||||
if (addConnectionForm) {
|
||||
addConnectionForm.addEventListener('submit', (e) => {
|
||||
@@ -5216,11 +5284,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const topicHex = newConnectionTopic ? newConnectionTopic.value.trim() : '';
|
||||
const inviteEl = document.getElementById('new-connection-invite');
|
||||
const aliasEl = document.getElementById('new-connection-alias');
|
||||
const inviteToken = inviteEl ? inviteEl.value.trim() : '';
|
||||
const alias = aliasEl ? aliasEl.value.trim() : '';
|
||||
if (topicHex) {
|
||||
addConnection(topicHex, { inviteToken: inviteToken || undefined });
|
||||
addConnection(topicHex, {
|
||||
inviteToken: inviteToken || undefined,
|
||||
alias: alias || undefined,
|
||||
});
|
||||
if (newConnectionTopic) newConnectionTopic.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', () => {
|
||||
if (sidebar) {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
collapseSidebarBtn.innerHTML = sidebar.classList.contains('collapsed') ? '<i class="fas fa-chevron-right"></i>' : '<i class="fas fa-chevron-left"></i>';
|
||||
|
||||
// Toggle Reset Connections Button Visibility
|
||||
const resetConnectionsBtn = sidebar.querySelector('.btn-danger');
|
||||
if (resetConnectionsBtn) {
|
||||
resetConnectionsBtn.style.display = sidebar.classList.contains('collapsed') ? 'none' : 'block';
|
||||
}
|
||||
collapseSidebarBtn.innerHTML = sidebar.classList.contains('collapsed')
|
||||
? '<i class="fas fa-chevron-right"></i>'
|
||||
: '<i class="fas fa-chevron-left"></i>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add Reset Connections Button
|
||||
if (sidebar) {
|
||||
const resetConnectionsBtn = document.createElement('button');
|
||||
resetConnectionsBtn.textContent = 'Reset Connections';
|
||||
resetConnectionsBtn.className = 'btn btn-danger w-100 mt-2';
|
||||
resetConnectionsBtn.addEventListener('click', () => {
|
||||
// Reset saved peers (static button in connections panel)
|
||||
document.getElementById('reset-connections-btn')?.addEventListener('click', () => {
|
||||
console.log('[INFO] Resetting connections and clearing storage.');
|
||||
Object.keys(connections).forEach((topicId) => {
|
||||
disconnectConnection(topicId);
|
||||
});
|
||||
deleteCookie('connections');
|
||||
// Also clear localStorage
|
||||
try {
|
||||
localStorage.removeItem(USE_LOCALSTORAGE_KEY);
|
||||
localStorage.removeItem(CONNECTIONS_STORAGE_KEY);
|
||||
} catch (err) {
|
||||
console.warn(`[WARN] Failed to clear localStorage: ${err.message}`);
|
||||
}
|
||||
resetConnectionsView();
|
||||
showWelcomePage();
|
||||
toggleResetButtonVisibility(); // Ensure button visibility is updated
|
||||
});
|
||||
sidebar.appendChild(resetConnectionsBtn);
|
||||
if (typeof connectionList !== 'undefined' && connectionList) {
|
||||
connectionList.innerHTML = '';
|
||||
}
|
||||
if (typeof resetConnectionsView === 'function') resetConnectionsView();
|
||||
showWelcomePage();
|
||||
toggleResetButtonVisibility();
|
||||
});
|
||||
toggleResetButtonVisibility();
|
||||
|
||||
// Initialize container filtering (lightweight, doesn't block)
|
||||
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 {
|
||||
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
|
||||
Object.keys(savedConnections).forEach((topicId) => {
|
||||
// Restore in parallel; keep list populated even if dial fails
|
||||
Promise.all(
|
||||
keys.map(async (topicId) => {
|
||||
try {
|
||||
const entry = savedConnections[topicId];
|
||||
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) {
|
||||
console.error(`[ERROR] Failed to restore connection ${topicId}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Connections may still be dialing; only enter workspace once a peer is live
|
||||
})
|
||||
).then(() => {
|
||||
if (hasActiveConnection()) {
|
||||
hideWelcomePage();
|
||||
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 {
|
||||
showWelcomePage();
|
||||
}
|
||||
assertVisibility();
|
||||
// Notification tray is already initialized globally in DOMContentLoaded
|
||||
} catch (err) {
|
||||
console.error(`[ERROR] Failed to initialize connections: ${err.message}`);
|
||||
showWelcomePage(); // Show welcome page on error
|
||||
// Notification tray is already initialized globally in DOMContentLoaded
|
||||
showWelcomePage();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+49
-21
@@ -49,6 +49,7 @@ export class ConnectionManager extends EventEmitter {
|
||||
this._reconnect.set(id, {
|
||||
publicKeyHex: key,
|
||||
alias: meta.alias ?? prev?.alias ?? null,
|
||||
inviteToken: meta.inviteToken ?? prev?.inviteToken ?? null,
|
||||
attempts: 0,
|
||||
timer: null,
|
||||
intentional: false,
|
||||
@@ -57,11 +58,13 @@ export class ConnectionManager extends EventEmitter {
|
||||
const entry = this._reconnect.get(id) || {
|
||||
publicKeyHex: key,
|
||||
alias: meta.alias || null,
|
||||
inviteToken: meta.inviteToken || null,
|
||||
attempts: 0,
|
||||
timer: null,
|
||||
intentional: false,
|
||||
}
|
||||
if (meta.alias) entry.alias = meta.alias
|
||||
if (meta.inviteToken) entry.inviteToken = meta.inviteToken
|
||||
this._reconnect.set(id, entry)
|
||||
}
|
||||
|
||||
@@ -130,7 +133,7 @@ export class ConnectionManager extends EventEmitter {
|
||||
}
|
||||
if (!conn) {
|
||||
this._reconnect.delete(id)
|
||||
this.persist()
|
||||
this.persist({ removeId: id })
|
||||
return
|
||||
}
|
||||
if (this.active === conn) {
|
||||
@@ -140,7 +143,7 @@ export class ConnectionManager extends EventEmitter {
|
||||
this.connections.delete(id)
|
||||
await conn.close().catch(() => {})
|
||||
this._reconnect.delete(id)
|
||||
this.persist()
|
||||
this.persist({ removeId: id })
|
||||
this.emit('remove', id)
|
||||
if (this.connections.size === 0) this._stopHealthLoop()
|
||||
}
|
||||
@@ -190,6 +193,7 @@ export class ConnectionManager extends EventEmitter {
|
||||
try {
|
||||
await this.connect(entry.publicKeyHex, {
|
||||
alias: entry.alias || undefined,
|
||||
inviteToken: entry.inviteToken || undefined,
|
||||
skipReconnectReset: true,
|
||||
})
|
||||
this.emit('reconnected', { id, publicKeyHex: entry.publicKeyHex })
|
||||
@@ -258,55 +262,78 @@ export class ConnectionManager extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
/** Persist connection keys (not live sockets). */
|
||||
persist() {
|
||||
const serializable = {}
|
||||
/**
|
||||
* Persist connection keys (not live sockets).
|
||||
* 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) {
|
||||
const recon = this._reconnect.get(id)
|
||||
serializable[id] = {
|
||||
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)
|
||||
try {
|
||||
if (json.length > CONFIG.STORAGE.COOKIE_SIZE_LIMIT) {
|
||||
localStorage.setItem(STORAGE_KEY, json)
|
||||
localStorage.setItem(USE_LS_KEY, '1')
|
||||
} else {
|
||||
document.cookie = `connections=${encodeURIComponent(json)};path=/;max-age=31536000`
|
||||
localStorage.removeItem(USE_LS_KEY)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ERROR] Failed to persist connections', err)
|
||||
}
|
||||
|
||||
// Optional cookie backup for browser contexts
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, json)
|
||||
localStorage.setItem(USE_LS_KEY, '1')
|
||||
if (json.length <= CONFIG.STORAGE.COOKIE_SIZE_LIMIT) {
|
||||
document.cookie = `connections=${encodeURIComponent(json)};path=/;max-age=31536000`
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
let raw = null
|
||||
try {
|
||||
if (localStorage.getItem(USE_LS_KEY) === '1') {
|
||||
raw = localStorage.getItem(STORAGE_KEY)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!raw) {
|
||||
try {
|
||||
const match = document.cookie.match(/(?:^|; )connections=([^;]*)/)
|
||||
if (match) raw = decodeURIComponent(match[1])
|
||||
}
|
||||
if (!raw) {
|
||||
try {
|
||||
raw = localStorage.getItem(STORAGE_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -327,6 +354,7 @@ export class ConnectionManager extends EventEmitter {
|
||||
id: id || publicKeyHex.slice(0, 12),
|
||||
publicKeyHex,
|
||||
alias: value.alias || null,
|
||||
inviteToken: value.inviteToken || null,
|
||||
}
|
||||
}).filter((e) => /^[0-9a-f]{64}$/.test(e.publicKeyHex))
|
||||
} catch {
|
||||
|
||||
+55
-9
@@ -89,6 +89,7 @@
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<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>
|
||||
<nav id="main-nav">
|
||||
<ul class="nav-menu">
|
||||
@@ -170,19 +171,22 @@
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<hr class="sidebar-divider">
|
||||
|
||||
<div class="connections-panel">
|
||||
<div class="sidebar-section-label">Peers</div>
|
||||
<ul id="connection-list" class="list-group mb-3"></ul>
|
||||
<form id="add-connection-form" autocomplete="off">
|
||||
<input type="text" id="new-connection-topic" class="form-control mb-2" placeholder="Server public key…" spellcheck="false" required>
|
||||
<input type="text" id="new-connection-invite" class="form-control mb-2" placeholder="Invite token (optional)" spellcheck="false">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-plug me-1"></i> Connect
|
||||
<div class="connections-panel-header">
|
||||
<div class="sidebar-section-label mb-0">Peers</div>
|
||||
<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">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span class="open-add-connection-label">Add</span>
|
||||
</button>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,10 +215,13 @@
|
||||
<div class="welcome-step-num">3</div>
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<!-- Dashboard View -->
|
||||
<div id="dashboard-view" class="view hidden">
|
||||
@@ -2118,6 +2125,45 @@ services:
|
||||
</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 -->
|
||||
<div class="modal fade" id="dockerTerminalModal" tabindex="-1" aria-labelledby="docker-terminal-title" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
|
||||
+66
-25
@@ -200,11 +200,13 @@ body {
|
||||
}
|
||||
|
||||
#sidebar.collapsed .sidebar-section-label,
|
||||
#sidebar.collapsed #add-connection-form,
|
||||
#sidebar.collapsed .connection-name,
|
||||
#sidebar.collapsed .connection-item small,
|
||||
#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;
|
||||
}
|
||||
|
||||
@@ -212,6 +214,12 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#sidebar.collapsed .open-add-connection-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#sidebar.collapsed .nav-link {
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
@@ -266,13 +274,24 @@ body {
|
||||
}
|
||||
|
||||
#sidebar .content {
|
||||
padding: 16px 14px 20px;
|
||||
padding: 16px 14px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
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 {
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
@@ -321,20 +340,48 @@ body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Connections panel */
|
||||
/* Connections panel — fixed footprint at bottom; list scrolls if many peers */
|
||||
.connections-panel {
|
||||
flex: 1;
|
||||
flex: 0 0 auto;
|
||||
max-height: 38%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
padding: 0 2px 8px;
|
||||
margin-bottom: 10px !important;
|
||||
padding: 0 2px 4px;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
#connection-list .list-group-item {
|
||||
@@ -363,27 +410,19 @@ body {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
#add-connection-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 2px !important;
|
||||
.reset-connections-btn {
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#add-connection-form .form-control {
|
||||
width: 100%;
|
||||
margin: 0 !important;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
#addConnectionModal .form-control {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#addConnectionModal .font-monospace {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
#add-connection-form .btn {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ─── Content / views ─── */
|
||||
.view {
|
||||
margin-top: 0 !important;
|
||||
@@ -1174,7 +1213,9 @@ option {
|
||||
}
|
||||
#sidebar .nav-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 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+13
-14
@@ -181,9 +181,7 @@
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
#sidebar.collapsed .content {
|
||||
display: none;
|
||||
}
|
||||
/* Keep collapsed nav icons reachable (do not hide entire .content) */
|
||||
|
||||
#collapse-sidebar-btn {
|
||||
position: absolute;
|
||||
@@ -1449,13 +1447,7 @@
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Add Connection Form */
|
||||
#add-connection-form {
|
||||
margin-top: var(--spacing-lg);
|
||||
padding-top: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Add peer modal form */
|
||||
#add-connection-form .form-control {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -1471,10 +1463,17 @@
|
||||
box-shadow: 0 0 0 3px rgba(45, 212, 191, 0.1);
|
||||
}
|
||||
|
||||
#add-connection-form .btn-primary {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
.connections-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-nav-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Filter and Search Bar Styling */
|
||||
|
||||
Reference in New Issue
Block a user