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
|
// 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=/`;
|
||||||
|
try {
|
||||||
// Check cookie size (approximate)
|
document.cookie = cookieValue;
|
||||||
if (cookieValue.length > COOKIE_SIZE_LIMIT) {
|
} catch (err) {
|
||||||
console.warn(`[WARN] Cookie size (${cookieValue.length} bytes) exceeds limit. Using localStorage instead.`);
|
console.warn(`[WARN] Failed to set cookie ${name}: ${err.message}`);
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
const cookies = document.cookie.split('; ');
|
try {
|
||||||
for (let i = 0; i < cookies.length; i++) {
|
const cookies = document.cookie.split('; ');
|
||||||
const [key, value] = cookies[i].split('=');
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
if (key === name) return decodeURIComponent(value);
|
const [key, value] = cookies[i].split('=');
|
||||||
|
if (key === name) return decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteCookie(name) {
|
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() {
|
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
|
try {
|
||||||
if (serialized.length > COOKIE_SIZE_LIMIT) {
|
localStorage.setItem(CONNECTIONS_STORAGE_KEY, serialized);
|
||||||
// Use localStorage for large data
|
localStorage.setItem(USE_LOCALSTORAGE_KEY, '1');
|
||||||
try {
|
console.log('[INFO] Saved connections to localStorage', Object.keys(serializableConnections).length);
|
||||||
localStorage.setItem(USE_LOCALSTORAGE_KEY, 'true');
|
} catch (err) {
|
||||||
localStorage.setItem(CONNECTIONS_STORAGE_KEY, serialized);
|
console.error(`[ERROR] Failed to save connections to localStorage: ${err.message}`);
|
||||||
console.log('[INFO] Saved connections to localStorage (data too large for cookies)');
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error(`[ERROR] Failed to save to localStorage: ${err.message}`);
|
// Cookie backup for web contexts (may not work in Pear)
|
||||||
// Try cookie as fallback (may fail but we try)
|
if (serialized.length <= COOKIE_SIZE_LIMIT) {
|
||||||
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
|
|
||||||
}
|
|
||||||
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)
|
||||||
connectionItem.className = 'list-group-item d-flex align-items-center justify-content-between';
|
let connectionItem = connectionList?.querySelector?.(`[data-topic-id="${topicId}"]`);
|
||||||
connectionItem.dataset.topicId = topicId;
|
if (!connectionItem) {
|
||||||
const displayName = connections[topicId].alias || topicId;
|
connectionItem = document.createElement('li');
|
||||||
connectionItem.innerHTML = `
|
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="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">
|
||||||
@@ -5089,37 +5128,45 @@ async function addConnection(publicKeyHex, meta = {}) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
connectionItem.querySelector('.docker-terminal-btn')?.addEventListener('click', (event) => {
|
connectionItem.querySelector('.docker-terminal-btn')?.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const connection = connections[topicId];
|
const connection = connections[topicId];
|
||||||
if (connection?.peer) {
|
if (connection?.peer) {
|
||||||
startDockerTerminal(topicId, connection.peer);
|
startDockerTerminal(topicId, connection.peer);
|
||||||
const dockerTerminalModal = document.getElementById('dockerTerminalModal');
|
const dockerTerminalModal = document.getElementById('dockerTerminalModal');
|
||||||
if (dockerTerminalModal) {
|
if (dockerTerminalModal) {
|
||||||
new bootstrap.Modal(dockerTerminalModal).show();
|
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();
|
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);
|
||||||
presentError(err, 'connect', { showAlert, notificationManager });
|
if (!meta.quiet) {
|
||||||
|
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');
|
console.log('[INFO] Resetting connections and clearing storage.');
|
||||||
resetConnectionsBtn.textContent = 'Reset Connections';
|
Object.keys(connections).forEach((topicId) => {
|
||||||
resetConnectionsBtn.className = 'btn btn-danger w-100 mt-2';
|
disconnectConnection(topicId);
|
||||||
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)
|
// 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(
|
||||||
try {
|
keys.map(async (topicId) => {
|
||||||
const entry = savedConnections[topicId];
|
try {
|
||||||
const publicKeyHex = entry.publicKeyHex || entry.topicHex || entry.topic;
|
const entry = savedConnections[topicId];
|
||||||
if (publicKeyHex) addConnection(String(publicKeyHex));
|
const publicKeyHex = entry.publicKeyHex || entry.topicHex || entry.topic;
|
||||||
} catch (err) {
|
if (!publicKeyHex) return;
|
||||||
console.error(`[ERROR] Failed to restore connection ${topicId}: ${err.message}`);
|
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
|
// Show peer slots immediately while dials are in flight
|
||||||
if (hasActiveConnection()) {
|
if (keys.length > 0) {
|
||||||
hideWelcomePage();
|
// list items created by addConnection; welcome until live
|
||||||
startStatsInterval();
|
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
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+55
-27
@@ -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)
|
||||||
try {
|
}
|
||||||
localStorage.setItem(STORAGE_KEY, json)
|
|
||||||
localStorage.setItem(USE_LS_KEY, '1')
|
// Optional cookie backup for browser contexts
|
||||||
} catch {
|
try {
|
||||||
// ignore
|
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).
|
* 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) {
|
|
||||||
const match = document.cookie.match(/(?:^|; )connections=([^;]*)/)
|
|
||||||
if (match) raw = decodeURIComponent(match[1])
|
|
||||||
}
|
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
try {
|
try {
|
||||||
raw = localStorage.getItem(STORAGE_KEY)
|
const match = document.cookie.match(/(?:^|; )connections=([^;]*)/)
|
||||||
|
if (match) raw = decodeURIComponent(match[1])
|
||||||
} 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 {
|
||||||
|
|||||||
+136
-90
@@ -89,100 +89,104 @@
|
|||||||
<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-section-label">Navigate <kbd class="text-muted" style="font-size:0.65rem">Ctrl+K</kbd></div>
|
<div class="sidebar-nav-scroll">
|
||||||
<nav id="main-nav">
|
<div class="sidebar-section-label">Navigate <kbd class="text-muted" style="font-size:0.65rem">Ctrl+K</kbd></div>
|
||||||
<ul class="nav-menu">
|
<nav id="main-nav">
|
||||||
<li class="nav-group-label">Overview</li>
|
<ul class="nav-menu">
|
||||||
<li class="nav-item">
|
<li class="nav-group-label">Overview</li>
|
||||||
<a href="#" class="nav-link active" data-view="dashboard" title="Dashboard">
|
<li class="nav-item">
|
||||||
<i class="fas fa-gauge-high"></i>
|
<a href="#" class="nav-link active" data-view="dashboard" title="Dashboard">
|
||||||
<span class="nav-label">Dashboard</span>
|
<i class="fas fa-gauge-high"></i>
|
||||||
</a>
|
<span class="nav-label">Dashboard</span>
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a href="#" class="nav-link" data-view="fleet" title="Fleet">
|
<li class="nav-item">
|
||||||
<i class="fas fa-server"></i>
|
<a href="#" class="nav-link" data-view="fleet" title="Fleet">
|
||||||
<span class="nav-label">Fleet</span>
|
<i class="fas fa-server"></i>
|
||||||
</a>
|
<span class="nav-label">Fleet</span>
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a href="#" class="nav-link" data-view="events" title="Events">
|
<li class="nav-item">
|
||||||
<i class="fas fa-bolt"></i>
|
<a href="#" class="nav-link" data-view="events" title="Events">
|
||||||
<span class="nav-label">Events</span>
|
<i class="fas fa-bolt"></i>
|
||||||
</a>
|
<span class="nav-label">Events</span>
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-group-label">Workloads</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-group-label">Workloads</li>
|
||||||
<a href="#" class="nav-link" data-view="deploy" title="Deploy">
|
<li class="nav-item">
|
||||||
<i class="fas fa-rocket"></i>
|
<a href="#" class="nav-link" data-view="deploy" title="Deploy">
|
||||||
<span class="nav-label">Deploy</span>
|
<i class="fas fa-rocket"></i>
|
||||||
</a>
|
<span class="nav-label">Deploy</span>
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a href="#" class="nav-link" data-view="containers" title="Containers">
|
<li class="nav-item">
|
||||||
<i class="fas fa-cube"></i>
|
<a href="#" class="nav-link" data-view="containers" title="Containers">
|
||||||
<span class="nav-label">Containers</span>
|
<i class="fas fa-cube"></i>
|
||||||
</a>
|
<span class="nav-label">Containers</span>
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a href="#" class="nav-link" data-view="stacks" title="Stacks">
|
<li class="nav-item">
|
||||||
<i class="fas fa-boxes-stacked"></i>
|
<a href="#" class="nav-link" data-view="stacks" title="Stacks">
|
||||||
<span class="nav-label">Stacks</span>
|
<i class="fas fa-boxes-stacked"></i>
|
||||||
</a>
|
<span class="nav-label">Stacks</span>
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-group-label">Build & storage</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-group-label">Build & storage</li>
|
||||||
<a href="#" class="nav-link" data-view="images" title="Images">
|
<li class="nav-item">
|
||||||
<i class="fas fa-layer-group"></i>
|
<a href="#" class="nav-link" data-view="images" title="Images">
|
||||||
<span class="nav-label">Images</span>
|
<i class="fas fa-layer-group"></i>
|
||||||
</a>
|
<span class="nav-label">Images</span>
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a href="#" class="nav-link" data-view="volumes" title="Volumes">
|
<li class="nav-item">
|
||||||
<i class="fas fa-hard-drive"></i>
|
<a href="#" class="nav-link" data-view="volumes" title="Volumes">
|
||||||
<span class="nav-label">Volumes</span>
|
<i class="fas fa-hard-drive"></i>
|
||||||
</a>
|
<span class="nav-label">Volumes</span>
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a href="#" class="nav-link" data-view="networks" title="Networks">
|
<li class="nav-item">
|
||||||
<i class="fas fa-diagram-project"></i>
|
<a href="#" class="nav-link" data-view="networks" title="Networks">
|
||||||
<span class="nav-label">Networks</span>
|
<i class="fas fa-diagram-project"></i>
|
||||||
</a>
|
<span class="nav-label">Networks</span>
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-group-label">Host & access</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-group-label">Host & access</li>
|
||||||
<a href="#" class="nav-link" data-view="host" title="Host">
|
<li class="nav-item">
|
||||||
<i class="fas fa-microchip"></i>
|
<a href="#" class="nav-link" data-view="host" title="Host">
|
||||||
<span class="nav-label">Host</span>
|
<i class="fas fa-microchip"></i>
|
||||||
</a>
|
<span class="nav-label">Host</span>
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a href="#" class="nav-link" data-view="access" title="Access">
|
<li class="nav-item">
|
||||||
<i class="fas fa-user-shield"></i>
|
<a href="#" class="nav-link" data-view="access" title="Access">
|
||||||
<span class="nav-label">Access</span>
|
<i class="fas fa-user-shield"></i>
|
||||||
</a>
|
<span class="nav-label">Access</span>
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a href="#" class="nav-link" data-view="settings" title="Settings">
|
<li class="nav-item">
|
||||||
<i class="fas fa-gear"></i>
|
<a href="#" class="nav-link" data-view="settings" title="Settings">
|
||||||
<span class="nav-label">Settings</span>
|
<i class="fas fa-gear"></i>
|
||||||
</a>
|
<span class="nav-label">Settings</span>
|
||||||
</li>
|
</a>
|
||||||
</ul>
|
</li>
|
||||||
</nav>
|
</ul>
|
||||||
|
</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">
|
|
||||||
<i class="fas fa-plug me-1"></i> Connect
|
|
||||||
</button>
|
</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>
|
</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
@@ -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
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user