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
+242 -151
View File
@@ -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
}
try {
document.cookie = cookieValue;
} catch (err) {
console.warn(`[WARN] Failed to set cookie ${name}: ${err.message}`);
}
document.cookie = cookieValue;
}
function getCookie(name) {
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);
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) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
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)');
} 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
}
try {
localStorage.setItem(CONNECTIONS_STORAGE_KEY, serialized);
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 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');
connectionItem.className = 'list-group-item d-flex align-items-center justify-content-between';
connectionItem.dataset.topicId = topicId;
const displayName = connections[topicId].alias || topicId;
connectionItem.innerHTML = `
// 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 = 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">
@@ -5089,37 +5128,45 @@ async function addConnection(publicKeyHex, meta = {}) {
</div>
`;
connectionItem.querySelector('.docker-terminal-btn')?.addEventListener('click', (event) => {
event.stopPropagation();
const connection = connections[topicId];
if (connection?.peer) {
startDockerTerminal(topicId, connection.peer);
const dockerTerminalModal = document.getElementById('dockerTerminalModal');
if (dockerTerminalModal) {
new bootstrap.Modal(dockerTerminalModal).show();
connectionItem.querySelector('.docker-terminal-btn')?.addEventListener('click', (event) => {
event.stopPropagation();
const connection = connections[topicId];
if (connection?.peer) {
startDockerTerminal(topicId, connection.peer);
const dockerTerminalModal = document.getElementById('dockerTerminalModal');
if (dockerTerminalModal) {
new bootstrap.Modal(dockerTerminalModal).show();
}
} else {
console.warn(`[WARNING] No active connection for ${topicId}`);
}
} else {
console.warn(`[WARNING] No active connection for ${topicId}`);
}
});
});
connectionItem.querySelector('.connection-info')?.addEventListener('click', () => switchConnection(topicId));
connectionItem.querySelector('.disconnect-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
disconnectConnection(topicId, connectionItem);
});
connectionList?.appendChild(connectionItem);
} else {
updateConnectionStatus(topicId, false);
}
connectionItem.querySelector('.connection-info')?.addEventListener('click', () => switchConnection(topicId));
connectionItem.querySelector('.disconnect-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
disconnectConnection(topicId, connectionItem);
});
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();
presentError(err, 'connect', { showAlert, notificationManager });
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>';
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';
}
}
// 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);
});
}
// 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', () => {
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);
}
deleteCookie('connections');
try {
localStorage.removeItem(USE_LOCALSTORAGE_KEY);
localStorage.removeItem(CONNECTIONS_STORAGE_KEY);
} catch (err) {
console.warn(`[WARN] Failed to clear localStorage: ${err.message}`);
}
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) => {
try {
const entry = savedConnections[topicId];
const publicKeyHex = entry.publicKeyHex || entry.topicHex || entry.topic;
if (publicKeyHex) addConnection(String(publicKeyHex));
} catch (err) {
console.error(`[ERROR] Failed to restore connection ${topicId}: ${err.message}`);
// 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) 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}`);
}
})
).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();
});
// Connections may still be dialing; only enter workspace once a peer is live
if (hasActiveConnection()) {
hideWelcomePage();
startStatsInterval();
// 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();
}
});
+55 -27
View File
@@ -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)
}
localStorage.setItem(STORAGE_KEY, json)
localStorage.setItem(USE_LS_KEY, '1')
} catch (err) {
console.error('[ERROR] Failed to persist connections', err)
try {
localStorage.setItem(STORAGE_KEY, json)
localStorage.setItem(USE_LS_KEY, '1')
} catch {
// ignore
}
// Optional cookie backup for browser contexts
try {
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)
}
raw = localStorage.getItem(STORAGE_KEY)
} catch {
// ignore
}
if (!raw) {
const match = document.cookie.match(/(?:^|; )connections=([^;]*)/)
if (match) raw = decodeURIComponent(match[1])
}
if (!raw) {
try {
raw = localStorage.getItem(STORAGE_KEY)
const match = document.cookie.match(/(?:^|; )connections=([^;]*)/)
if (match) raw = decodeURIComponent(match[1])
} 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 {
+136 -90
View File
@@ -89,100 +89,104 @@
<i class="fas fa-chevron-left"></i>
</button>
<div class="content">
<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">
<li class="nav-group-label">Overview</li>
<li class="nav-item">
<a href="#" class="nav-link active" data-view="dashboard" title="Dashboard">
<i class="fas fa-gauge-high"></i>
<span class="nav-label">Dashboard</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="fleet" title="Fleet">
<i class="fas fa-server"></i>
<span class="nav-label">Fleet</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="events" title="Events">
<i class="fas fa-bolt"></i>
<span class="nav-label">Events</span>
</a>
</li>
<li class="nav-group-label">Workloads</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="deploy" title="Deploy">
<i class="fas fa-rocket"></i>
<span class="nav-label">Deploy</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="containers" title="Containers">
<i class="fas fa-cube"></i>
<span class="nav-label">Containers</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="stacks" title="Stacks">
<i class="fas fa-boxes-stacked"></i>
<span class="nav-label">Stacks</span>
</a>
</li>
<li class="nav-group-label">Build & storage</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="images" title="Images">
<i class="fas fa-layer-group"></i>
<span class="nav-label">Images</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="volumes" title="Volumes">
<i class="fas fa-hard-drive"></i>
<span class="nav-label">Volumes</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="networks" title="Networks">
<i class="fas fa-diagram-project"></i>
<span class="nav-label">Networks</span>
</a>
</li>
<li class="nav-group-label">Host & access</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="host" title="Host">
<i class="fas fa-microchip"></i>
<span class="nav-label">Host</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="access" title="Access">
<i class="fas fa-user-shield"></i>
<span class="nav-label">Access</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="settings" title="Settings">
<i class="fas fa-gear"></i>
<span class="nav-label">Settings</span>
</a>
</li>
</ul>
</nav>
<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">
<li class="nav-group-label">Overview</li>
<li class="nav-item">
<a href="#" class="nav-link active" data-view="dashboard" title="Dashboard">
<i class="fas fa-gauge-high"></i>
<span class="nav-label">Dashboard</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="fleet" title="Fleet">
<i class="fas fa-server"></i>
<span class="nav-label">Fleet</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="events" title="Events">
<i class="fas fa-bolt"></i>
<span class="nav-label">Events</span>
</a>
</li>
<li class="nav-group-label">Workloads</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="deploy" title="Deploy">
<i class="fas fa-rocket"></i>
<span class="nav-label">Deploy</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="containers" title="Containers">
<i class="fas fa-cube"></i>
<span class="nav-label">Containers</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="stacks" title="Stacks">
<i class="fas fa-boxes-stacked"></i>
<span class="nav-label">Stacks</span>
</a>
</li>
<li class="nav-group-label">Build & storage</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="images" title="Images">
<i class="fas fa-layer-group"></i>
<span class="nav-label">Images</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="volumes" title="Volumes">
<i class="fas fa-hard-drive"></i>
<span class="nav-label">Volumes</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="networks" title="Networks">
<i class="fas fa-diagram-project"></i>
<span class="nav-label">Networks</span>
</a>
</li>
<li class="nav-group-label">Host & access</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="host" title="Host">
<i class="fas fa-microchip"></i>
<span class="nav-label">Host</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="access" title="Access">
<i class="fas fa-user-shield"></i>
<span class="nav-label">Access</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="settings" title="Settings">
<i class="fas fa-gear"></i>
<span class="nav-label">Settings</span>
</a>
</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>
</form>
</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>
</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
View File
@@ -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
View File
@@ -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 */