import { manager, Methods } from './client/manager.js'; import { loadPeers, savePeers, clearPeers, getPeersCachePath, } from './client/peerCache.js'; import { startTerminal, appendTerminalOutput } from './libs/terminal.js'; import { startDockerTerminal, cleanUpDockerTerminal } from './libs/dockerTerminal.js'; import { fetchTemplates, displayTemplateList, openDeployModal, collectDuplicateFormData, populateDuplicateForm, initTemplateDeployer, filterTemplatesByQuery, } from './libs/templateDeploy.js'; import { showContainerSkeleton, createProgressBar, updateProgressBar, removeProgressBar } from './libs/loadingStates.js'; import { closeAllModals, showStatusIndicator, hideStatusIndicator, updateStatusIndicator, showAlert } from './libs/uiUtils.js'; import notificationManager from './libs/notifications.js'; import { initOpsApp } from './ui/ops-app.js'; import { presentError, formatResponseError, isBackgroundMethod } from './client/errors.js'; import { warmSnapshot } from './client/snapshot.js'; import { fetchMergedTemplates, getTemplateListUrls, clearMergedTemplateCache, } from './client/templateLists.js'; import { getTerminalCtor, getFitAddonCtor, defaultXtermOptions, decodePayload, createFitController, safeFit, } from './libs/xtermUtils.js'; import { createInputCoalescer } from './libs/termInput.js'; // Global RPC push / response routing manager.on('message', (msg, conn) => { handleRpcMessage(msg, conn); }); manager.on('disconnect', (conn) => { const topicId = conn?.id; if (topicId && connections[topicId]) { updateConnectionStatus(topicId, false); connections[topicId].peer = null; } updateHealthBadge(null); if (!hasActiveConnection()) { if (containerList) containerList.innerHTML = ''; stopStatsInterval(); showWelcomePage(); } }); manager.on('health', (info, conn) => { if (!conn || conn === manager.active) { updateHealthBadge(info, conn || manager.active); } }); manager.on('connect', (conn) => { if (conn) { updateHealthBadge( { latency: conn.latency, docker: conn.dockerHealth, status: conn.healthStatus }, conn ); } }); manager.on('reconnecting', ({ attempt, delayMs }) => { updateStatusIndicator(`Reconnecting (attempt ${attempt}) in ${Math.round(delayMs / 1000)}s…`); if (typeof showAlert === 'function') { showAlert('warning', `Connection lost — reconnecting (attempt ${attempt})…`); } }); manager.on('reconnected', () => { hideStatusIndicator(); if (typeof showAlert === 'function') showAlert('success', 'Reconnected to peardock server'); if (hasActiveConnection()) { hideWelcomePage(); sendCommand(Methods.listContainers); } }); manager.on('reconnect-failed', () => { hideStatusIndicator(); if (typeof showAlert === 'function') { showAlert('danger', 'Could not reconnect after multiple attempts. Re-add the connection.'); } updateHealthBadge(null); }); // DOM Elements - Cache frequently accessed elements (will be initialized in DOMContentLoaded) let containerList = null; let connectionList = null; let addConnectionForm = null; let newConnectionTopic = null; let connectionTitle = null; let dashboard = null; let welcomePage = null; let sidebar = null; let collapseSidebarBtn = null; let alertContainer = null; // Modal Elements (will be initialized in DOMContentLoaded) let duplicateModalElement = null; let duplicateModal = null; let duplicateContainerForm = null; // Notification tray initialization state let notificationTrayInitialized = false; // Global variables const connections = {}; window.openTerminals = {}; let statsInterval = null; // activePeer is always the manager's active PearDockConnection (RPC), never a raw stream Object.defineProperty(window, 'activePeer', { configurable: true, enumerable: true, get() { return manager.active; }, set(value) { if (value?.id) manager.setActive(value.id); else if (value == null && manager.active) { // allow clear without dropping manager map entry } }, }); // Centralized volumes cache/store const volumesStore = { volumes: [], lastUpdate: null, loading: false, listeners: new Set(), // Listeners that want to be notified when volumes update // Get cached volumes get() { return this.volumes; }, // Set volumes and notify listeners set(volumes) { this.volumes = Array.isArray(volumes) ? volumes : []; this.lastUpdate = Date.now(); this.loading = false; this.notifyListeners(); }, // Add a listener callback subscribe(callback) { this.listeners.add(callback); // Immediately call with current data if available if (this.volumes.length > 0) { callback(this.volumes); } // Return unsubscribe function return () => this.listeners.delete(callback); }, // Notify all listeners notifyListeners() { this.listeners.forEach(callback => { try { callback(this.volumes); } catch (error) { console.error('[ERROR] Volume listener error:', error); } }); }, // Check if cache is stale (older than 30 seconds) isStale() { if (!this.lastUpdate) return true; return Date.now() - this.lastUpdate > 30000; }, // Set loading state setLoading(loading) { this.loading = loading; }, // Check if currently loading isLoading() { return this.loading; } }; // Expose volumes store to window for use by other modules window.volumesStore = volumesStore; let lastStatsUpdate = Date.now(); function stopStatsInterval() { if (statsInterval) { clearInterval(statsInterval); statsInterval = null; console.log('[INFO] Stats interval stopped.'); } } // Utility functions are now imported from uiUtils.js document.addEventListener('DOMContentLoaded', () => { const dockerTerminalModal = document.getElementById('dockerTerminalModal'); if (dockerTerminalModal) { dockerTerminalModal.addEventListener('hidden.bs.modal', () => { console.log('[INFO] Modal fully closed. Performing additional cleanup.'); cleanUpDockerTerminal(); }); } }); function startStatsInterval() { // Guard: stop existing interval before starting a new one stopStatsInterval(); // Only start if there's an active peer if (!window.activePeer) { console.warn('[WARN] No active peer; not starting stats interval.'); return; } // Increased interval to 500ms for better performance (was 100ms) statsInterval = setInterval(() => { if (window.activePeer) { const now = Date.now(); if (now - lastStatsUpdate >= 500) { // Ensure at least 500ms between updates lastStatsUpdate = now; } } else { console.warn('[WARN] No active peer; skipping stats request.'); stopStatsInterval(); // Stop interval if peer is no longer active } }, 500); // Poll every 500ms for better performance (reduced from 100ms) } const smoothedStats = {}; // Container-specific smoothing storage const historicalStats = {}; // Container-specific historical stats for charts const MAX_HISTORY_POINTS = 60; // Keep last 60 data points (5 minutes at 5s intervals) function smoothStats(containerId, newStats, smoothingFactor = 0.2) { if (!smoothedStats[containerId]) { smoothedStats[containerId] = { cpu: 0, memory: 0, ip: newStats.ip || 'No IP Assigned' }; } smoothedStats[containerId].cpu = smoothedStats[containerId].cpu * (1 - smoothingFactor) + newStats.cpu * smoothingFactor; smoothedStats[containerId].memory = smoothedStats[containerId].memory * (1 - smoothingFactor) + newStats.memory * smoothingFactor; // Preserve the latest IP address smoothedStats[containerId].ip = newStats.ip || smoothedStats[containerId].ip; // Store historical data for charts if (!historicalStats[containerId]) { historicalStats[containerId] = { timestamps: [], cpu: [], memory: [] }; } const history = historicalStats[containerId]; const now = new Date(); history.timestamps.push(now); history.cpu.push(smoothedStats[containerId].cpu); history.memory.push(smoothedStats[containerId].memory); // Keep only last MAX_HISTORY_POINTS if (history.timestamps.length > MAX_HISTORY_POINTS) { history.timestamps.shift(); history.cpu.shift(); history.memory.shift(); } // Update charts if on container details view if (currentView === 'container-details' && currentContainerDetails && currentContainerDetails.Id === containerId) { updateStatsCharts(containerId); } return smoothedStats[containerId]; } function refreshContainerStats() { if (!window.activePeer) { // Don't try to refresh if there's no active peer return; } console.log('[INFO] Refreshing container stats...'); sendCommand('listContainers'); // Request an updated container list startStatsInterval(); // Restart stats interval } /** * Wait for a success RPC whose message contains the given fragment. * Safe against success replies without a message (e.g. listVolumes). * Always restores any previous handlePeerResponse. */ function waitForPeerResponse(expectedMessageFragment, timeout = 900000) { console.log(`[DEBUG] Waiting for peer response with fragment: "${expectedMessageFragment}"`); return new Promise((resolve, reject) => { const previousHandler = window.handlePeerResponse; let settled = false; const fragment = String(expectedMessageFragment || ''); const restore = () => { if (window.handlePeerResponse === onResponse) { window.handlePeerResponse = typeof previousHandler === 'function' ? previousHandler : null; } }; const settle = (fn, value) => { if (settled) return; settled = true; clearTimeout(timer); restore(); fn(value); }; const onResponse = (response) => { try { const msg = response && typeof response.message === 'string' ? response.message : ''; if (response && response.success && msg && msg.includes(fragment)) { console.log(`[DEBUG] Expected response received: ${msg}`); settle(resolve, response); return; } // Forward unrelated messages so list/broadcast handlers still work if (typeof previousHandler === 'function' && previousHandler !== onResponse) { previousHandler(response); } } catch (err) { console.warn('[WARN] waitForPeerResponse handler error:', err?.message || err); } }; window.handlePeerResponse = onResponse; const timer = setTimeout(() => { console.warn('[WARN] Timed out waiting for response'); settle(reject, new Error('Timed out waiting for peer response')); }, timeout); }); } // Peer persistence: ~/.config/peardock/cache/peers.json (primary) // localStorage is only a mirror / migration source via client/peerCache.js function deleteCookie(name) { try { document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; } catch { // ignore } } /** * Load saved peers from disk cache (migrates legacy localStorage on first run). * @returns {Record} */ function loadConnections() { /** @type {Record} */ let parsed = {}; try { parsed = loadPeers() || {}; } catch (err) { console.warn(`[WARN] Failed to load peer cache: ${err.message}`); parsed = {}; } // Merge manager list if it has extras 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 publicKeyHex = String(entry.publicKeyHex || entry.topicHex || entry.topic || '').toLowerCase(); if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) continue; const id = publicKeyHex.substring(0, 12); result[id] = { publicKeyHex, topicHex: publicKeyHex, alias: entry.alias || null, inviteToken: entry.inviteToken || null, peer: null, connectedAt: null, lastHealthCheck: null, latency: null, healthStatus: 'unknown', }; } return result; } /** * Save configured peers to ~/.config/peardock/cache/peers.json * Merges with the on-disk roster so a transient empty in-memory map cannot * wipe saved peers (e.g. mid-boot or after socket teardown). * @param {{ removeId?: string }} [opts] */ function saveConnections(opts = {}) { /** @type {Record} */ let serializableConnections = {}; try { serializableConnections = { ...(loadPeers() || {}) }; } catch { serializableConnections = {}; } if (opts.removeId) { delete serializableConnections[opts.removeId]; } for (const topicId in connections) { 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, }; } // Refuse accidental wipe of a non-empty cache with empty memory (except explicit remove of last peer) const nextCount = Object.keys(serializableConnections).length; if (nextCount === 0 && !opts.removeId) { try { const existing = loadPeers() || {}; if (Object.keys(existing).length > 0) { console.warn( '[WARN] saveConnections: refusing to wipe', Object.keys(existing).length, 'saved peer(s) with empty in-memory state' ); return; } } catch { // proceed } } try { const result = savePeers(serializableConnections); console.log( '[INFO] Saved', result.count, 'peer(s) to', result.path || getPeersCachePath(), result.ok ? '' : '(file write failed; localStorage mirror may still apply)' ); } catch (err) { console.error(`[ERROR] Failed to save peer cache: ${err.message}`); } } /** * Prefer alias; never put the raw 12-char id in the primary label when a friendlier * short label works — full key stays on title/tooltip so the action buttons fit. * @param {{ alias?: string|null, publicKeyHex?: string }} conn * @param {string} topicId */ function peerDisplayName(conn, topicId) { const alias = (conn?.alias || '').trim(); if (alias) return alias; const key = String(conn?.publicKeyHex || topicId || ''); if (key.length >= 12) return `Peer ${key.slice(0, 6)}…`; return topicId || 'Peer'; } /** * Secondary meta line (latency / status) — never dump the full peer id here. * @param {{ latency?: number|null, healthStatus?: string }} conn */ function peerMetaText(conn) { if (conn?.latency != null && Number.isFinite(conn.latency)) { return `${conn.latency}ms`; } if (conn?.healthStatus === 'connecting') return 'Connecting…'; if (conn?.healthStatus === 'error') return 'Offline'; return ''; } /** * Build a peer list row that truncates labels and never overflows action buttons. * @param {string} topicId * @param {object} conn * @returns {HTMLLIElement} */ function createPeerListItem(topicId, conn) { const connectionItem = document.createElement('li'); connectionItem.className = 'list-group-item'; connectionItem.dataset.topicId = topicId; const displayName = peerDisplayName(conn, topicId); const meta = peerMetaText(conn); const fullKey = conn?.publicKeyHex || topicId; const connected = Boolean(conn?.peer?.connected || conn?.healthStatus === 'healthy'); connectionItem.title = conn?.alias ? `${conn.alias} · ${fullKey}` : String(fullKey); connectionItem.innerHTML = `
${escapeHtmlLite(displayName)}
${escapeHtmlLite(meta)}
`; connectionItem.querySelector('.connection-info')?.addEventListener('click', () => switchConnection(topicId)); connectionItem.querySelector('.disconnect-btn')?.addEventListener('click', (e) => { e.stopPropagation(); disconnectConnection(topicId, connectionItem); }); 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}`); } }); return connectionItem; } /** Clear all saved peers (Settings action). */ function resetAllPeers() { console.log('[INFO] Resetting connections and clearing peer cache at', getPeersCachePath()); Object.keys(connections).forEach((topicId) => { disconnectConnection(topicId); }); deleteCookie('connections'); try { clearPeers(); } catch (err) { console.warn(`[WARN] Failed to clear peer cache: ${err.message}`); } if (typeof connectionList !== 'undefined' && connectionList) { connectionList.innerHTML = ''; } updatePeersViewChrome(); showWelcomePage(); showAlert('success', 'All saved peers removed'); } /** * Peers tab chrome: empty state + count (list is #connection-list). */ function updatePeersViewChrome() { const list = document.getElementById('connection-list'); const empty = document.getElementById('peers-view-empty'); const countEl = document.getElementById('peers-view-count'); const n = list ? list.querySelectorAll('.list-group-item').length : 0; if (countEl) { countEl.textContent = n === 1 ? '1 peer' : `${n} peers`; } if (empty) { empty.classList.toggle('d-none', n > 0); } if (list) { list.classList.toggle('d-none', n === 0); } } function loadPeersView() { updatePeersViewChrome(); // Re-sync active highlight from manager const activeId = manager.active?.id; if (activeId) { document.querySelectorAll('#connection-list .list-group-item').forEach((item) => { const tid = item.dataset.topicId || ''; item.classList.toggle( 'active', tid === activeId || tid.startsWith(activeId) || activeId.startsWith(tid) ); }); } } window.loadPeersView = loadPeersView; // Initialize container filtering function initContainerFiltering() { const searchInput = document.getElementById('container-search'); const statusFilter = document.getElementById('container-status-filter'); const sortSelect = document.getElementById('container-sort'); const clearBtn = document.getElementById('clear-filters'); if (searchInput) { searchInput.addEventListener('input', (e) => { containerFilterState.search = e.target.value; if (containerFilterState.allContainers.length > 0) { renderContainers(containerFilterState.allContainers, Object.keys(connections)[0] || ''); } }); } if (statusFilter) { statusFilter.addEventListener('change', (e) => { containerFilterState.status = e.target.value; if (containerFilterState.allContainers.length > 0) { renderContainers(containerFilterState.allContainers, Object.keys(connections)[0] || ''); } }); } if (sortSelect) { sortSelect.addEventListener('change', (e) => { containerFilterState.sort = e.target.value; if (containerFilterState.allContainers.length > 0) { renderContainers(containerFilterState.allContainers, Object.keys(connections)[0] || ''); } }); } if (clearBtn) { clearBtn.addEventListener('click', () => { containerFilterState.search = ''; containerFilterState.status = 'all'; containerFilterState.sort = 'name-asc'; if (searchInput) searchInput.value = ''; if (statusFilter) statusFilter.value = 'all'; if (sortSelect) sortSelect.value = 'name-asc'; if (containerFilterState.allContainers.length > 0) { renderContainers(containerFilterState.allContainers, Object.keys(connections)[0] || ''); } }); } } // Initialize the app console.log('[INFO] Client app initialized'); // Utility functions are now imported from uiUtils.js /** * Centralized error handler for server responses * Parses errors, formats user-friendly messages, and sends to notification center * @param {Object} response - Server response object * @returns {string|null} - Formatted error message or null if no error */ /** * Handle error field on RPC responses / manager.send failures. * Suppresses protomux "REQUEST_ERROR: Request failed" spam and background poll noise. * @returns {string|null} user-facing message if shown */ function handleErrorResponse(response) { if (!response || !response.error) { return null; } // Background / silent errors: log only (never tray spam) if (response.silent || isBackgroundMethod(response.method)) { console.warn( '[RPC quiet]', response.method || 'request', typeof response.error === 'string' ? response.error : response.error?.message || response.error ); return null; } const formatted = formatResponseError(response.error, response.method); if (!formatted || formatted.silent) { console.warn('[RPC]', response.method || 'request', response.error); return null; } let formattedMessage = formatted.message; let errorType = formatted.severity === 'warning' ? 'warning' : 'danger'; // Domain-specific polish for Docker engine errors const lower = formattedMessage.toLowerCase(); if (lower.includes('volume') && lower.includes('in use')) { formattedMessage = 'Cannot remove volume: it is still in use by a container'; } else if (lower.includes('permission denied') || lower.includes('permission_denied')) { errorType = 'warning'; } formattedMessage = formattedMessage .replace(/^REQUEST_ERROR:\s*/i, '') .replace(/\(HTTP code \d+\)\s*/g, '') .replace(/\s+/g, ' ') .trim(); // Keep enough detail for operators to act (ports, image names, how-to-fix) if (formattedMessage.length > 900) { formattedMessage = formattedMessage.substring(0, 897) + '...'; } // Skip useless generic leftover if (/^request failed$/i.test(formattedMessage) || /^request_error:\s*request failed$/i.test(formattedMessage)) { console.warn('[RPC] suppressed generic Request failed', response.method || ''); return null; } // Single channel only: showAlert (no toast by default; job tray if active) if (typeof showAlert === 'function') { showAlert(errorType, formattedMessage, { autoDismiss: true, duration: errorType === 'warning' ? 6000 : 8000, key: `rpc:${response.method || ''}:${formattedMessage.slice(0, 80)}`, toast: false, }); } // Return formatted message for use with showAlert() if needed return formattedMessage; } // Navigation Management let currentView = 'dashboard'; function initNavigation() { const navLinks = document.querySelectorAll('.nav-link'); navLinks.forEach((link) => { link.addEventListener('click', (e) => { e.preventDefault(); const view = link.dataset.view; if (view) { navigateToView(view); } }); }); // Only enter workspace if already connected; otherwise stay on welcome if (hasActiveConnection()) { hideWelcomePage(); } else { showWelcomePage(); } } function navigateToView(viewName, opts = {}) { const { skipWelcomeGate = false, replace = false, fromHistory = false } = opts; // Peers list is always reachable (manage saved keys even when offline) const allowOffline = viewName === 'peers' || viewName === 'settings' || skipWelcomeGate; // Without an active peer, keep the welcome card and block workspace views if (!allowOffline && !hasActiveConnection()) { showWelcomePage(); return; } // Connected: never leave welcome stacked over the workspace setWelcomeVisible(false); document.querySelectorAll('.view').forEach((view) => { view.classList.add('hidden'); }); const targetView = document.getElementById(`${viewName}-view`); if (targetView) { targetView.classList.remove('hidden'); } if (viewName !== 'container-details') { document.querySelectorAll('.nav-link').forEach((link) => { link.classList.remove('active'); if (link.dataset.view === viewName) { link.classList.add('active'); } }); } currentView = viewName; // Expose for auto-refresh poller if (typeof window !== 'undefined') window.currentView = viewName; if (viewName === 'dashboard') { loadDashboard(); } else if (viewName === 'containers') { if (hasActiveConnection()) { showListSkeleton('container-list', 6); sendCommand('listContainers'); } } else if (viewName === 'images') { loadImages(); } else if (viewName === 'networks') { loadNetworks(); } else if (viewName === 'volumes') { loadVolumes(); } else if (viewName === 'stacks') { loadStacks(); } else if (viewName === 'swarm') { window.peardockOps?.loadSwarmView?.(); } else if (viewName === 'deploy') { loadDeployView(); } else if (viewName === 'fleet') { loadFleetView(); } else if (viewName === 'peers') { loadPeersView(); } else if (viewName === 'access') { loadAccessView(); } else if (viewName === 'events') { window.peardockOps?.loadEventsView?.(); } else if (viewName === 'host') { window.peardockOps?.loadHostView?.(); } else if (viewName === 'tunnels') { window.peardockOps?.loadTunnelsView?.(); } else if (viewName === 'settings') { window.peardockOps?.loadSettingsView?.(); } // Browser history / deep-links (skip when handling popstate) if (!fromHistory) { try { const hash = `#/${viewName}`; if (location.hash !== hash) { if (replace) history.replaceState({ view: viewName }, '', hash); else history.pushState({ view: viewName }, '', hash); } } catch { // ignore } } } /** Hash / back-forward navigation */ function initHashRouting() { const fromHash = () => { const hashView = (location.hash || '').replace(/^#\/?/, '').split('?')[0]; if (hashView && hasActiveConnection()) { navigateToView(hashView, { fromHistory: true, replace: true }); } }; window.addEventListener('popstate', () => { const view = (history.state && history.state.view) || (location.hash || '').replace(/^#\/?/, '').split('?')[0]; if (view && hasActiveConnection()) { navigateToView(view, { fromHistory: true }); } }); window.addEventListener('hashchange', fromHash); } /** * Lightweight skeleton placeholder for list tables / tbody. * @param {string} listId - element id of tbody or list host * @param {number} [rows=5] */ function showListSkeleton(listId, rows = 5) { const host = document.getElementById(listId); if (!host) return; const isTableBody = host.tagName === 'TBODY'; if (isTableBody) { const cols = host.closest('table')?.querySelectorAll('thead th')?.length || 5; host.innerHTML = Array.from({ length: rows }, () => { const cells = Array.from({ length: cols }, () => '
').join(''); return `${cells}`; }).join(''); } else { host.innerHTML = Array.from({ length: rows }, () => '
').join(''); } } /** * Empty state HTML for tables (single full-width row). */ function emptyTableRow(colspan, title, body) { return `
${escapeHtmlLite(title)}
${body ? `
${escapeHtmlLite(body)}
` : ''}
`; } /** Multi-host fleet dashboard — all live connections side-by-side */ async function loadFleetView() { const host = document.getElementById('fleet-cards'); if (!host) return; const list = manager.list(); const envMap = typeof window.peardockOps?.getPeerEnvironments === 'function' ? window.peardockOps.getPeerEnvironments() : {}; let envFilter = document.getElementById('fleet-env-filter')?.value || ''; try { const s = JSON.parse(localStorage.getItem('peardock.settings.v1') || '{}'); if (!envFilter && s.fleetEnvFilter) { envFilter = s.fleetEnvFilter; const sel = document.getElementById('fleet-env-filter'); if (sel) sel.value = envFilter; } } catch { // ignore } const filtered = envFilter ? list.filter((conn) => { const id = conn.id || conn.publicKeyHex?.slice(0, 12); return (envMap[id] || '') === envFilter; }) : list; if (!list.length) { host.innerHTML = '
No connected peers. Add a public key in the sidebar.
'; return; } if (!filtered.length) { host.innerHTML = `
No peers tagged ${escapeHtmlLite(envFilter)}. Set environment on a peer card.
`; return; } host.innerHTML = filtered .map((conn) => { const id = conn.id || conn.publicKeyHex?.slice(0, 12); const active = manager.active === conn; const lat = conn.latency != null ? `${conn.latency} ms` : '—'; const dockerOk = conn.dockerHealth?.ok; const role = conn.role || '—'; const env = envMap[id] || ''; return `
${escapeHtmlLite(conn.alias || id)}
${conn.connected ? 'online' : 'offline'}

${escapeHtmlLite((conn.publicKeyHex || '').slice(0, 24))}…

  • Latency: ${lat}
  • Docker: ${dockerOk === true ? 'ok' : dockerOk === false ? 'down' : '—'}
  • Role: ${escapeHtmlLite(role)}
  • ${escapeHtmlLite(conn.healthStatus || 'unknown')}
`; }) .join(''); host.querySelectorAll('.fleet-activate').forEach((btn) => { btn.addEventListener('click', () => { manager.setActive(btn.dataset.id); loadFleetView(); if (typeof showAlert === 'function') showAlert('success', `Active peer: ${btn.dataset.id}`); }); }); host.querySelectorAll('.fleet-env-select').forEach((sel) => { sel.addEventListener('change', () => { const id = sel.getAttribute('data-id'); window.peardockOps?.setPeerEnvironment?.(id, sel.value); if (typeof showAlert === 'function') { showAlert('info', sel.value ? `Tagged ${id} as ${sel.value}` : `Cleared env tag on ${id}`, { badge: false, }); } }); }); } window.loadFleetView = loadFleetView; async function loadAccessView() { const peersEl = document.getElementById('access-peers-list'); const invitesEl = document.getElementById('access-invites-list'); const vaultEl = document.getElementById('access-vault-list'); // One-time Hub search wiring if (!window.__accessHubWired) { window.__accessHubWired = true; document.getElementById('access-hub-search-btn')?.addEventListener('click', () => accessHubSearch()); document.getElementById('access-hub-term')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); accessHubSearch(); } }); } if (!manager.active?.connected) { if (peersEl) peersEl.textContent = 'Not connected.'; return; } try { const peersRes = await manager.request(Methods.listPeers, {}); if (peersEl) { const live = peersRes?.live || []; const known = peersRes?.peers || []; const revoked = peersRes?.revoked || []; let html = `

Allowlist enforce: ${peersRes?.enforceAllowlist ? 'on' : 'off'}

`; html += '
Live sessions
    '; for (const p of live) { html += `
  • ${p.peerId?.slice(0, 16)}… ${p.role}
  • `; } html += '
'; if (known.length) { html += '
Registered peers
    '; for (const p of known) { html += `
  • ${p.peerId?.slice(0, 16)}… ${p.role}
  • `; } html += '
'; } if (revoked.length) { html += `

Revoked: ${revoked.map((r) => r.slice(0, 12)).join(', ')}

`; } peersEl.innerHTML = html; peersEl.querySelectorAll('.access-revoke').forEach((btn) => { btn.addEventListener('click', async () => { const ok = window.peardockOps?.confirmDestructive ? await window.peardockOps.confirmDestructive('Revoke peer', 'Revoke this peer? They will be disconnected.') : true; if (!ok) return; await manager.request(Methods.revokePeer, { peerId: btn.dataset.id }); if (typeof showAlert === 'function') showAlert('warning', 'Peer revoked'); loadAccessView(); }); }); applyRoleUI(); } } catch (err) { if (peersEl) peersEl.textContent = err.message || 'Failed to list peers'; } try { const inv = await manager.request(Methods.listInvites, {}); if (invitesEl) { const data = inv?.data || []; if (!data.length) invitesEl.innerHTML = 'No active invites'; else { invitesEl.innerHTML = data .map( (i) => `
${i.token}
role=${i.role} exp=${i.expiresAt} uses=${i.uses}/${i.maxUses}
` ) .join(''); } } } catch { if (invitesEl) invitesEl.textContent = ''; } try { const vault = await manager.request(Methods.listVaultCredentials, {}); if (vaultEl) { const data = vault?.data || []; if (!data.length) vaultEl.innerHTML = 'No stored credentials'; else { vaultEl.innerHTML = data .map( (c) => `
${c.label || c.username} @ ${c.serveraddress}
` ) .join(''); vaultEl.querySelectorAll('.vault-use').forEach((btn) => { btn.addEventListener('click', async () => { await manager.request(Methods.vaultUseCredential, { id: btn.dataset.id }); if (typeof showAlert === 'function') showAlert('success', 'Vault credential applied to session'); }); }); vaultEl.querySelectorAll('.vault-del').forEach((btn) => { btn.addEventListener('click', async () => { const ok = window.peardockOps?.confirmDestructive ? await window.peardockOps.confirmDestructive('Delete credential', 'Remove this vault credential?') : true; if (!ok) return; await manager.request(Methods.vaultDeleteCredential, { id: btn.dataset.id }); loadAccessView(); }); }); } } } catch (err) { if (vaultEl) vaultEl.textContent = err.message || 'Vault unavailable'; } applyRoleUI(); } const ROLE_RANK = { viewer: 1, operator: 2, admin: 3 }; /** Hide/disable UI that exceeds the active peer role */ function applyRoleUI() { const role = manager.active?.role || 'viewer'; const rank = ROLE_RANK[role] || 0; document.body.dataset.role = role; document.querySelectorAll('[data-min-role]').forEach((el) => { const need = ROLE_RANK[el.dataset.minRole] || 99; const allowed = rank >= need; el.classList.toggle('role-hidden', !allowed); if ('disabled' in el) el.disabled = !allowed; el.setAttribute('aria-disabled', allowed ? 'false' : 'true'); if (!allowed) el.title = el.title || `Requires ${el.dataset.minRole} role`; }); } function showFirstConnectChecklist() { try { if (typeof window.peardockOps?.shouldShowFirstConnectTip === 'function') { if (!window.peardockOps.shouldShowFirstConnectTip()) return; } else if (localStorage.getItem('peardock.firstConnect.dismissed') === '1') { return; } } catch { // ignore } document.getElementById('first-connect-checklist')?.classList.remove('hidden'); } function dismissFirstConnectChecklist() { document.getElementById('first-connect-checklist')?.classList.add('hidden'); try { localStorage.setItem('peardock.firstConnect.dismissed', '1'); } catch { // ignore } } function renderActivityPanel() { const list = document.getElementById('activity-panel-list'); if (!list) return; const jobs = window.peardockOps?.listJobs?.(15) || []; if (!jobs.length) { list.innerHTML = '
No jobs yet — deploy, pull, or build to see activity here.
'; return; } list.innerHTML = jobs .map((j) => { const tone = j.status === 'success' ? 'success' : j.status === 'error' ? 'danger' : 'primary'; const when = j.createdAt ? new Date(j.createdAt).toLocaleTimeString() : ''; const steps = (j.steps || []) .map((s) => s.status) .filter(Boolean) .join(' · '); return ``; }) .join(''); list.querySelectorAll('.activity-item').forEach((btn) => { btn.addEventListener('click', () => { const job = window.peardockOps?.listJobs?.(50)?.find((x) => x.id === btn.dataset.jobId); if (job && window.peardockOps?.showJob) { window.peardockOps.showJob(job); document.getElementById('activity-panel')?.classList.add('hidden'); } }); }); } window.renderActivityPanel = renderActivityPanel; window.applyRoleUI = applyRoleUI; // Wire access view actions once document.addEventListener('DOMContentLoaded', () => { // Invite form (role / TTL / max uses + copy token) const inviteForm = document.getElementById('invite-peer-form'); if (inviteForm) { inviteForm.addEventListener('submit', async (e) => { e.preventDefault(); const role = document.getElementById('invite-role')?.value || 'operator'; const ttlHours = Number(document.getElementById('invite-ttl')?.value) || 72; const maxUses = Number(document.getElementById('invite-max-uses')?.value) || 1; try { const res = await manager.request(Methods.invitePeer, { role, ttlHours, maxUses }); const token = res?.data?.token; const resultBox = document.getElementById('invite-token-result'); const tokenInput = document.getElementById('invite-token-value'); if (token && tokenInput) { tokenInput.value = token; resultBox?.classList.remove('hidden'); showAlert('success', 'Invite created — copy the token below'); try { await navigator.clipboard?.writeText?.(token); showAlert('info', 'Token copied to clipboard'); } catch { // ignore } } loadAccessView(); } catch (err) { showAlert('danger', err.message || 'Failed to create invite'); } }); } document.getElementById('invite-token-copy')?.addEventListener('click', async () => { const token = document.getElementById('invite-token-value')?.value; if (!token) return; try { await navigator.clipboard.writeText(token); showAlert('success', 'Token copied'); } catch { showAlert('warning', 'Could not copy — select and copy manually'); } }); document.getElementById('invitePeerModal')?.addEventListener('show.bs.modal', () => { document.getElementById('invite-token-result')?.classList.add('hidden'); const tokenInput = document.getElementById('invite-token-value'); if (tokenInput) tokenInput.value = ''; }); // First-connect checklist document.getElementById('first-connect-dismiss')?.addEventListener('click', dismissFirstConnectChecklist); document.getElementById('first-connect-got-it')?.addEventListener('click', dismissFirstConnectChecklist); document.querySelectorAll('.first-connect-link').forEach((btn) => { btn.addEventListener('click', () => { const view = btn.dataset.view; dismissFirstConnectChecklist(); if (view) navigateToView(view); }); }); // Activity tray document.getElementById('activity-tray-toggle')?.addEventListener('click', (e) => { e.stopPropagation(); const panel = document.getElementById('activity-panel'); if (!panel) return; const open = panel.classList.toggle('hidden') === false; document.getElementById('activity-tray-toggle')?.setAttribute('aria-expanded', open ? 'true' : 'false'); if (open) renderActivityPanel(); }); document.getElementById('activity-panel-close')?.addEventListener('click', () => { document.getElementById('activity-panel')?.classList.add('hidden'); }); document.addEventListener('click', (e) => { const tray = document.getElementById('activity-tray'); const panel = document.getElementById('activity-panel'); if (tray && panel && !tray.contains(e.target)) panel.classList.add('hidden'); }); const vaultForm = document.getElementById('vault-store-form'); if (vaultForm) { vaultForm.addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(vaultForm); try { await manager.request(Methods.vaultStoreCredential, { username: fd.get('username'), password: fd.get('password'), serveraddress: fd.get('serveraddress') || undefined, }); vaultForm.reset(); if (typeof showAlert === 'function') showAlert('success', 'Credential stored encrypted'); loadAccessView(); } catch (err) { if (typeof showAlert === 'function') showAlert('danger', err.message); } }); } const fleetRefresh = document.getElementById('fleet-refresh-btn'); if (fleetRefresh) fleetRefresh.addEventListener('click', () => loadFleetView()); }); // Expose to window for onclick handlers window.navigateToView = navigateToView; // Dashboard Functions function loadDashboard() { if (!hasActiveConnection() && !window.activePeer) { return; } // Set up volumes subscription early to catch broadcasts if (!volumesStoreSubscription) { volumesStoreSubscription = volumesStore.subscribe((volumes) => { // Only auto-update if we're on the volumes view if (currentView === 'volumes') { renderVolumes(volumes); } }); } // Instant hydrate from host snapshot (auto-populate dashboard datapoints) import('./client/snapshot.js') .then(({ getSnapshot }) => getSnapshot()) .then((snap) => { if (!snap?.counts) return; const runningEl = document.getElementById('stat-running-containers'); const stoppedEl = document.getElementById('stat-stopped-containers'); const imagesEl = document.getElementById('stat-total-images'); const networksEl = document.getElementById('stat-total-networks'); if (runningEl) runningEl.textContent = String(snap.counts.running ?? 0); if (stoppedEl) { stoppedEl.textContent = String( Math.max(0, (snap.counts.containers || 0) - (snap.counts.running || 0)) ); } if (imagesEl) imagesEl.textContent = String(snap.counts.images ?? 0); if (networksEl) networksEl.textContent = String(snap.counts.networks ?? 0); const dockerInfoEl = document.getElementById('docker-info-content'); if (dockerInfoEl && snap.engine && !dockerInfoEl.dataset.filled) { const eng = snap.engine; const m = (label, value) => `
${label}${value}
`; dockerInfoEl.innerHTML = [ m('Host', eng.name || 'host'), m('OS', `${eng.operatingSystem || '—'} · ${eng.architecture || ''}`), m('API', eng.version?.ApiVersion || eng.version?.apiVersion || '—'), m('Swarm', eng.swarm || 'inactive'), m('CPUs', eng.ncpu ?? '—'), m('Volumes', snap.counts?.volumes ?? 0), ].join(''); dockerInfoEl.dataset.filled = '1'; } }) .catch(() => {}); // Load system info sendCommand('getSystemInfo'); sendCommand('getSystemDf'); // Load container stats for counts sendCommand('listContainers'); // Load images count sendCommand('listImages'); // Load networks count sendCommand('listNetworks'); // Load volumes sendCommand('listVolumes'); } window.loadDashboard = loadDashboard; /** @type {Array} */ const dockerEventBuffer = []; const MAX_EVENT_ROWS = 80; function formatBytes(bytes) { const n = Number(bytes) || 0; if (n === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.min(sizes.length - 1, Math.floor(Math.log(n) / Math.log(k))); return `${Math.round((n / k ** i) * 100) / 100} ${sizes[i]}`; } function refreshSystemDf() { if (!hasActiveConnection()) return; sendCommand('getSystemDf'); } window.refreshSystemDf = refreshSystemDf; function renderSystemDf(data) { const el = document.getElementById('system-df-content'); if (!el || !data) return; const layers = data.LayersSize ?? data.layersSize; const images = data.Images || []; const containers = data.Containers || []; const volumes = data.Volumes || []; const buildCache = data.BuildCache || []; const imgSize = images.reduce((s, i) => s + (i.Size || 0), 0); const volSize = volumes.reduce((s, v) => s + (v.UsageData?.Size || v.Size || 0), 0); const cacheSize = buildCache.reduce((s, c) => s + (c.Size || 0), 0); const contSize = containers.reduce((s, c) => s + (c.SizeRw || 0), 0); const layerSize = layers ?? imgSize; const total = Math.max(layerSize + volSize + cacheSize + contSize, 1); const row = (label, bytes) => { const pct = Math.min(100, Math.round((Number(bytes || 0) / total) * 100)); return `
${label}${formatBytes(bytes)}
`; }; el.innerHTML = [ row('Image layers', layerSize), row(`Images (${images.length})`, imgSize), row(`Containers (${containers.length})`, contSize), row(`Volumes (${volumes.length})`, volSize), row('Build cache', cacheSize), ].join(''); } function appendDockerEvent(event) { if (!event) return; dockerEventBuffer.unshift(event); if (dockerEventBuffer.length > MAX_EVENT_ROWS) dockerEventBuffer.pop(); renderEventTimeline(); } function clearEventTimeline() { dockerEventBuffer.length = 0; renderEventTimeline(); } window.clearEventTimeline = clearEventTimeline; function renderEventTimeline() { const el = document.getElementById('docker-events-timeline'); if (!el) return; if (dockerEventBuffer.length === 0) { el.innerHTML = '
Docker events will stream here…
'; return; } el.innerHTML = dockerEventBuffer .map((ev) => { const t = ev.time || ev.timeNano ? new Date((ev.time || 0) * 1000) : new Date(); const time = t.toLocaleTimeString(); const type = ev.Type || ev.type || '—'; const action = ev.Action || ev.status || '—'; const name = ev.Actor?.Attributes?.name || ev.from || (ev.id ? String(ev.id).slice(0, 12) : '') || ''; return `
${time}${escapeHtmlLite(type)}${escapeHtmlLite(action)} ${escapeHtmlLite(name)}
`; }) .join(''); } window.handleDockerEvent = appendDockerEvent; function updateHealthBadge(info, conn) { const dot = document.getElementById('health-dot'); const lat = document.getElementById('health-latency'); const dock = document.getElementById('health-docker'); const roleEl = document.getElementById('health-role'); if (!dot || !lat || !dock) return; if (!info && !conn?.connected) { dot.className = 'health-dot health-dot--unknown'; lat.textContent = '—'; dock.textContent = 'Docker —'; if (roleEl) roleEl.textContent = ''; return; } const latency = info?.latency ?? conn?.latency; const docker = info?.docker ?? conn?.dockerHealth; const status = info?.status ?? conn?.healthStatus ?? 'unknown'; const role = conn?.role || info?.role || null; lat.textContent = latency != null ? `${latency} ms` : '—'; if (docker?.ok === true) { dock.textContent = `Docker ${docker.apiVersion || 'ok'}`; } else if (docker?.ok === false) { dock.textContent = 'Docker down'; } else { dock.textContent = 'Docker —'; } if (roleEl) roleEl.textContent = role ? role : ''; let cls = 'health-dot--unknown'; if (status === 'healthy' || (docker?.ok && status !== 'degraded')) cls = 'health-dot--ok'; else if (status === 'degraded' || docker?.ok === false) cls = 'health-dot--degraded'; else if (status === 'disconnected' || status === 'error') cls = 'health-dot--error'; dot.className = `health-dot ${cls}`; } /** * Prune unused Docker resources (admin). * @param {'containers'|'images'|'networks'|'volumes'} kind */ async function pruneResource(kind) { const map = { containers: { method: 'pruneContainers', label: 'stopped containers' }, images: { method: 'pruneImages', label: 'unused images' }, networks: { method: 'pruneNetworks', label: 'unused networks' }, volumes: { method: 'pruneVolumes', label: 'unused volumes' }, builder: { method: 'pruneBuilder', label: 'build cache' }, }; const conf = map[kind]; if (!conf) return; if (!hasActiveConnection()) { showAlert('danger', 'Not connected'); return; } let confirmed = false; await new Promise((resolve) => { showConfirmModal(`Prune ${conf.label}? This cannot be undone.`, () => { confirmed = true; resolve(); }); const modalEl = document.getElementById('confirmModal'); if (modalEl) { modalEl.addEventListener( 'hidden.bs.modal', () => { if (!confirmed) resolve(); }, { once: true } ); } }); if (!confirmed) return; showStatusIndicator(`Pruning ${conf.label}…`); try { const response = await sendCommand(conf.method, {}); hideStatusIndicator(); if (response?.success) { showAlert('success', response.message || `Pruned ${conf.label}`); if (kind === 'containers') sendCommand('listContainers'); if (kind === 'images' || kind === 'builder') sendCommand('listImages'); if (kind === 'networks') sendCommand('listNetworks'); if (kind === 'volumes') sendCommand('listVolumes'); sendCommand('getSystemDf'); } } catch (err) { hideStatusIndicator(); showAlert('danger', err.message || 'Prune failed'); } } window.pruneResource = pruneResource; /** * Docker system prune (unused containers, networks, images; optional volumes). * @param {{ volumes?: boolean }} [opts] */ async function systemPrune(opts = {}) { if (!hasActiveConnection()) { showAlert('danger', 'Not connected'); return; } const volumes = Boolean(opts.volumes); let confirmed = false; await new Promise((resolve) => { showConfirmModal( volumes ? 'System prune including unused volumes? This cannot be undone.' : 'System prune (stopped containers, unused networks & images)? Volumes are kept unless you opt in.', () => { confirmed = true; resolve(); } ); const modalEl = document.getElementById('confirmModal'); if (modalEl) { modalEl.addEventListener( 'hidden.bs.modal', () => { if (!confirmed) resolve(); }, { once: true } ); } }); if (!confirmed) return; showStatusIndicator('Running docker system prune…'); try { const response = await sendCommand('systemPrune', { volumes, all: false }); hideStatusIndicator(); if (response?.success) { showAlert('success', response.message || 'System prune complete'); sendCommand('listContainers'); sendCommand('listImages'); sendCommand('listNetworks'); sendCommand('getSystemDf'); } } catch (err) { hideStatusIndicator(); showAlert('danger', err.message || 'System prune failed'); } } window.systemPrune = systemPrune; /** * Search Docker Hub via peardock searchImages RPC (pull modal). */ async function searchDockerHub() { const term = document.getElementById('hub-search-term')?.value?.trim(); const host = document.getElementById('hub-search-results'); if (!host) return; if (!term) { showAlert('warning', 'Enter a Hub search term'); return; } if (!hasActiveConnection()) { showAlert('danger', 'Not connected'); return; } host.innerHTML = '
Searching Hub…
'; try { const res = await sendCommand('searchImages', { term, limit: 15 }); const rows = res?.data || res?.results || []; if (!rows.length) { host.innerHTML = '
No results
'; return; } host.innerHTML = rows .map((r) => { const name = r.name || r.Name || ''; const desc = (r.description || r.Description || '').slice(0, 120); const stars = r.star_count ?? r.starCount ?? r.Stars ?? '—'; const official = r.is_official || r.isOfficial || r.Official ? ' · official' : ''; return ``; }) .join(''); host.querySelectorAll('.hub-result').forEach((btn) => { btn.addEventListener('click', () => { const n = btn.getAttribute('data-name') || ''; const input = document.getElementById('pull-image-name'); if (input && n) { input.value = n.includes(':') ? n : `${n}:latest`; input.focus(); } }); }); } catch (err) { host.innerHTML = `
${escapeHtmlLite(err.message || 'Search failed')}
`; } } window.searchDockerHub = searchDockerHub; async function accessHubSearch() { const term = document.getElementById('access-hub-term')?.value?.trim(); const host = document.getElementById('access-hub-results'); if (!host) return; if (!term) { showAlert('warning', 'Enter a search term'); return; } host.innerHTML = '
Searching…
'; try { const res = await sendCommand('searchImages', { term, limit: 12 }); const rows = res?.data || []; if (!rows.length) { host.innerHTML = '
No results
'; return; } host.innerHTML = rows .map((r) => { const name = r.name || r.Name || ''; return ` `; }) .join(''); host.querySelectorAll('.access-hub-pull').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); let n = btn.getAttribute('data-name') || ''; if (n && !n.includes(':')) n = n + ':latest'; if (n) { sendCommand('pullImage', { image: n }); showAlert('info', `Pulling ${n}…`, { badge: false }); } }); }); } catch (err) { host.innerHTML = `
${escapeHtmlLite(err.message || 'Failed')}
`; } } /** * Recreate container with same config (admin). * @param {{ Id: string, Names?: string[] }} container */ async function recreateContainerAction(container) { if (!container?.Id) return; if (!hasActiveConnection()) { showAlert('danger', 'Not connected'); return; } const name = (container.Names?.[0] || '').replace(/^\//, '') || container.Id.slice(0, 12); let confirmed = false; await new Promise((resolve) => { showConfirmModal( `Recreate container "${name}"? It will be stopped, removed, and created again with the same configuration.`, () => { confirmed = true; resolve(); } ); const modalEl = document.getElementById('confirmModal'); if (modalEl) { modalEl.addEventListener( 'hidden.bs.modal', () => { if (!confirmed) resolve(); }, { once: true } ); } }); if (!confirmed) return; showStatusIndicator(`Recreating ${name}…`); try { const response = await sendCommand('recreateContainer', { id: container.Id, start: true, }); hideStatusIndicator(); if (response?.success) { showAlert('success', response.message || `Recreated ${name}`); sendCommand('listContainers'); } } catch (err) { hideStatusIndicator(); showAlert('danger', err.message || 'Recreate failed'); } } window.recreateContainerAction = recreateContainerAction; function loadContainerTop(containerId) { if (!containerId || !hasActiveConnection()) return; const el = document.getElementById('container-top-content'); if (el) el.innerHTML = '
Loading processes…
'; sendCommand('containerTop', { id: containerId }); } window.loadContainerTop = loadContainerTop; function renderContainerTop(payload) { const el = document.getElementById('container-top-content'); if (!el) return; const data = payload?.data || payload; const titles = data?.Titles || data?.titles || []; const processes = data?.Processes || data?.processes || []; if (!processes.length) { el.innerHTML = '
No processes (is the container running?)
'; return; } const head = titles.map((t) => `${t}`).join(''); const rows = processes .map((proc) => { const cells = (Array.isArray(proc) ? proc : [proc]).map((c) => `${c}`).join(''); return `${cells}`; }) .join(''); el.innerHTML = `${head}${rows}
`; } function updateDashboardStats(containers, images, networks) { if (containers) { const running = containers.filter(c => c.State === 'running').length; const stopped = containers.filter(c => c.State !== 'running').length; const runningEl = document.getElementById('stat-running-containers'); const stoppedEl = document.getElementById('stat-stopped-containers'); if (runningEl) runningEl.textContent = running; if (stoppedEl) stoppedEl.textContent = stopped; } if (images) { const imagesEl = document.getElementById('stat-total-images'); if (imagesEl) imagesEl.textContent = images.length; } if (networks) { const networksEl = document.getElementById('stat-total-networks'); if (networksEl) networksEl.textContent = networks.length; } } function updateSystemInfo(systemInfo) { if (!systemInfo) return; const dockerInfoEl = document.getElementById('docker-info-content'); const resourcesEl = document.getElementById('system-resources-content'); if (dockerInfoEl && systemInfo.info) { const info = systemInfo.info; const metric = (label, value) => `
${label}${value}
`; dockerInfoEl.innerHTML = [ metric('Version', systemInfo.version?.Version || 'Unknown'), metric('Containers', info.Containers || 0), metric('Running', info.ContainersRunning || 0), metric('Paused', info.ContainersPaused || 0), metric('Stopped', info.ContainersStopped || 0), metric('Images', info.Images || 0), metric('Storage driver', info.Driver || 'Unknown'), ].join(''); } if (resourcesEl && systemInfo.info) { const info = systemInfo.info; const fmt = (bytes) => { if (!bytes) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; }; const metric = (label, value) => `
${label}${value}
`; resourcesEl.innerHTML = [ metric('Memory', fmt(info.MemTotal)), metric('CPU cores', info.NCPU || 'Unknown'), metric('Operating system', info.OperatingSystem || 'Unknown'), metric('Architecture', info.Architecture || 'Unknown'), metric('Kernel', info.KernelVersion || 'Unknown'), ].join(''); } } // Images Functions let allImages = []; // Store all images for filtering let currentImageFilter = 'all'; // Current filter: 'all', 'used', 'unused' function loadImages() { if (!window.activePeer && !hasActiveConnection()) { return; } showListSkeleton('images-list', 5); sendCommand('listImages'); } function filterImages(filter) { currentImageFilter = filter; // Update active button document.querySelectorAll('.image-filter-btn').forEach(btn => { btn.classList.remove('active'); if (btn.dataset.filter === filter) { btn.classList.add('active'); } }); // Re-render images with current filter renderImages(allImages); } function renderImages(images) { // Store all images allImages = images || []; // Calculate filter counts const usedCount = allImages.filter(image => image.usage && image.usage.length > 0).length; const unusedCount = allImages.filter(image => !image.usage || image.usage.length === 0).length; // Update filter badge counts const allCountEl = document.getElementById('filter-count-all'); const usedCountEl = document.getElementById('filter-count-used'); const unusedCountEl = document.getElementById('filter-count-unused'); if (allCountEl) allCountEl.textContent = allImages.length; if (usedCountEl) usedCountEl.textContent = usedCount; if (unusedCountEl) unusedCountEl.textContent = unusedCount; // Filter images based on current filter + search let filteredImages = allImages; if (currentImageFilter === 'used') { filteredImages = allImages.filter(image => image.usage && image.usage.length > 0); } else if (currentImageFilter === 'unused') { filteredImages = allImages.filter(image => !image.usage || image.usage.length === 0); } const imgSearch = document.getElementById('image-search')?.value?.trim().toLowerCase() || ''; if (imgSearch) { filteredImages = filteredImages.filter((image) => { const tag = (image.RepoTags && image.RepoTags[0]) || ''; const id = (image.Id || '').slice(0, 20); return tag.toLowerCase().includes(imgSearch) || id.toLowerCase().includes(imgSearch); }); } const imagesList = document.getElementById('images-list'); if (!imagesList) return; if (!filteredImages || filteredImages.length === 0) { const hasAny = allImages.length > 0; imagesList.innerHTML = emptyTableRow( 8, hasAny ? 'No matching images' : 'No images yet', hasAny ? 'Try another filter or search.' : 'Pull or build an image to get started.' ); return; } const formatBytes = (bytes) => { if (!bytes) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; }; imagesList.innerHTML = filteredImages.map(image => { const repoTag = image.RepoTags && image.RepoTags[0] ? image.RepoTags[0].split(':') : ['', '']; const repo = repoTag[0]; const tag = repoTag[1]; const imageId = image.Id.substring(7, 19); const size = formatBytes(image.Size); const created = image.Created ? new Date(image.Created * 1000).toLocaleDateString() : 'Unknown'; const usage = image.usage ? image.usage.length : 0; return ` ${repo} ${tag} ${imageId} ${size} ${created} ${usage} container${usage !== 1 ? 's' : ''}
`; }).join(''); // Add event listeners imagesList.querySelectorAll('.action-remove-image').forEach(btn => { btn.addEventListener('click', () => { const imageId = btn.dataset.imageId; showConfirmModal('Are you sure you want to remove this image?', () => { sendCommand('removeImage', { id: imageId, force: true }); setTimeout(() => loadImages(), 1000); }); }); }); imagesList.querySelectorAll('.action-tag-image').forEach(btn => { btn.addEventListener('click', async () => { const imageId = btn.dataset.imageId; const modal = new bootstrap.Modal(document.getElementById('tagImageModal')); const repoInput = document.getElementById('tag-repo'); const tagInput = document.getElementById('tag-tag'); const confirmBtn = document.getElementById('confirm-tag-btn'); repoInput.value = ''; tagInput.value = 'latest'; // Remove old listeners if (confirmBtn && confirmBtn.parentNode) { const newConfirmBtn = confirmBtn.cloneNode(true); confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn); newConfirmBtn.addEventListener('click', async () => { const repo = repoInput.value.trim(); if (repo) { const tag = tagInput.value.trim() || 'latest'; modal.hide(); showStatusIndicator(`Tagging image...`); sendCommand('tagImage', { id: imageId, repo, tag }); try { const response = await waitForPeerResponse('Image tagged as'); showAlert('success', response.message || 'Image tagged successfully'); loadImages(); } catch (error) { console.error('[ERROR] Failed to tag image:', error); showAlert('danger', error.message || 'Failed to tag image'); } finally { hideStatusIndicator(); } } else { showAlert('danger', 'Repository name is required'); } }); } else { console.warn('[WARNING] confirm-tag-btn not found in DOM, skipping listener setup'); } modal.show(); }); }); imagesList.querySelectorAll('.action-inspect-image').forEach(btn => { btn.addEventListener('click', () => { const imageId = btn.dataset.imageId; openImageInspectModal(imageId); }); }); } // Networks Functions function loadNetworks() { if (!window.activePeer && !hasActiveConnection()) { return; } showListSkeleton('networks-list', 4); sendCommand('listNetworks'); } /** @type {Array} */ let allNetworks = []; function renderNetworks(networks) { const networksList = document.getElementById('networks-list'); if (!networksList) return; allNetworks = networks || []; const q = document.getElementById('network-search')?.value?.trim().toLowerCase() || ''; const filtered = !q ? allNetworks : allNetworks.filter((n) => { const name = (n.Name || '').toLowerCase(); const driver = (n.Driver || '').toLowerCase(); const subnet = (n.IPAM?.Config?.[0]?.Subnet || '').toLowerCase(); return name.includes(q) || driver.includes(q) || subnet.includes(q); }); if (!filtered.length) { networksList.innerHTML = emptyTableRow( 7, allNetworks.length ? 'No matching networks' : 'No networks yet', allNetworks.length ? 'Clear search to see all networks.' : 'Create a network with smart IPAM defaults.' ); return; } networksList.innerHTML = filtered.map(network => { const subnet = network.IPAM?.Config?.[0]?.Subnet || '-'; const gateway = network.IPAM?.Config?.[0]?.Gateway || '-'; const usage = network.usage ? network.usage.length : 0; return ` ${network.Name} ${network.Driver} ${network.Scope || 'local'} ${subnet} ${gateway} ${usage} container${usage !== 1 ? 's' : ''}
${network.Name !== 'bridge' && network.Name !== 'host' && network.Name !== 'none' ? ` ` : ''}
`; }).join(''); // Add event listeners networksList.querySelectorAll('.action-remove-network').forEach(btn => { btn.addEventListener('click', () => { const networkId = btn.dataset.networkId; showConfirmModal('Are you sure you want to remove this network?', () => { sendCommand('removeNetwork', { id: networkId }); setTimeout(() => loadNetworks(), 1000); }); }); }); networksList.querySelectorAll('.action-inspect-network').forEach(btn => { btn.addEventListener('click', () => { const networkId = btn.dataset.networkId; openNetworkInspectModal(networkId); }); }); networksList.querySelectorAll('.action-connect-network').forEach(btn => { btn.addEventListener('click', async () => { const networkId = btn.dataset.networkId; const modal = new bootstrap.Modal(document.getElementById('connectNetworkModal')); const containerSelect = document.getElementById('connect-container-select'); const confirmBtn = document.getElementById('confirm-connect-btn'); // Store networkId for later use modal._networkId = networkId; // Get list of containers for selection sendCommand('listContainers'); // Wait for containers list const originalHandler = window.handlePeerResponse; window.handlePeerResponse = async (response) => { if (response.type === 'containers' && response.data) { const containers = response.data; if (containers.length === 0) { showAlert('warning', 'No containers available to connect'); if (typeof originalHandler === 'function') { window.handlePeerResponse = originalHandler; } return; } // Populate select dropdown containerSelect.innerHTML = ''; containers.forEach(container => { const name = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12); const option = document.createElement('option'); option.value = container.Id; option.textContent = `${name} (${container.State})`; containerSelect.appendChild(option); }); // Remove old listeners - re-query element to ensure it still exists in DOM const currentConfirmBtn = document.getElementById('confirm-connect-btn'); if (currentConfirmBtn && currentConfirmBtn.parentNode) { const newConfirmBtn = currentConfirmBtn.cloneNode(true); currentConfirmBtn.parentNode.replaceChild(newConfirmBtn, currentConfirmBtn); newConfirmBtn.addEventListener('click', async () => { const containerId = containerSelect.value; if (containerId) { modal.hide(); showStatusIndicator(`Connecting container to network...`); sendCommand('connectNetwork', { networkId: modal._networkId, containerId }); try { const response = await waitForPeerResponse('Container connected to network'); showAlert('success', response.message || 'Container connected to network'); loadNetworks(); } catch (error) { console.error('[ERROR] Failed to connect container:', error); showAlert('danger', error.message || 'Failed to connect container'); } finally { hideStatusIndicator(); } } else { showAlert('danger', 'Please select a container'); } }); } else { // Fallback: if element doesn't exist, add listener directly (shouldn't happen normally) console.warn('[WARNING] confirm-connect-btn not found in DOM, skipping listener setup'); } modal.show(); if (typeof originalHandler === 'function') { window.handlePeerResponse = originalHandler; } } else if (typeof originalHandler === 'function') { originalHandler(response); } }; }); }); } // Volumes Functions /** @type {Array} */ let allStacksCache = []; function loadStacks() { if (!window.activePeer && !hasActiveConnection()) { console.warn('[WARN] No active peer connection'); return; } showListSkeleton('stacks-list-body', 3); sendCommand('listStacks'); } function renderStacks(stacks) { const stacksListBody = document.getElementById('stacks-list-body'); if (!stacksListBody) return; allStacksCache = Array.isArray(stacks) ? stacks : []; const q = document.getElementById('stack-search')?.value?.trim().toLowerCase() || ''; const filtered = !q ? allStacksCache : allStacksCache.filter((s) => { const name = String(s.name || '').toLowerCase(); const services = (s.services || []).join(' ').toLowerCase(); return name.includes(q) || services.includes(q); }); if (!filtered.length) { stacksListBody.innerHTML = emptyTableRow( 5, allStacksCache.length ? 'No matching stacks' : 'No stacks yet', allStacksCache.length ? 'Clear search to see all stacks.' : 'Deploy a compose stack to get started.' ); return; } stacksListBody.innerHTML = filtered.map(stack => { const containers = stack.containers || []; const runningCount = containers.filter(c => c.state === 'running').length; const totalCount = containers.length; const statusClass = runningCount === totalCount && totalCount > 0 ? 'text-success' : runningCount > 0 ? 'text-warning' : 'text-danger'; const services = Array.isArray(stack.services) ? stack.services.join(', ') : '—'; return ` ${escapeHtmlLite(stack.name)} ${escapeHtmlLite(services)} ${totalCount} container(s) ${runningCount}/${totalCount} running
`; }).join(''); applyRoleUI(); // Add event listeners stacksListBody.querySelectorAll('.action-inspect-stack').forEach(btn => { btn.addEventListener('click', () => { const stackName = btn.dataset.stackName; openStackInspectModal(stackName); }); }); stacksListBody.querySelectorAll('.action-remove-stack').forEach(btn => { btn.addEventListener('click', async () => { const stackName = btn.dataset.stackName; let confirmed = false; await new Promise((resolve) => { showConfirmModal(`Remove stack "${stackName}"? This will remove all containers in the stack.`, () => { confirmed = true; resolve(); }); const modalEl = document.getElementById('confirmModal'); if (modalEl) { modalEl.addEventListener('hidden.bs.modal', () => { if (!confirmed) resolve(); }, { once: true }); } }); if (!confirmed) return; showStatusIndicator(`Removing stack "${stackName}"...`); sendCommand('removeStack', { stackName }); try { const response = await waitForPeerResponse(`Stack "${stackName}" removed successfully`); showAlert('success', response.message); loadStacks(); } catch (error) { console.error('[ERROR] Failed to remove stack:', error); showAlert('danger', error.message || 'Failed to remove stack'); } finally { hideStatusIndicator(); } }); }); } // Deploy stack handler function setupDeployStackHandler() { const deployStackBtn = document.getElementById('deploy-stack-btn'); const deployStackForm = document.getElementById('deploy-stack-form'); if (deployStackBtn && deployStackForm) { deployStackBtn.addEventListener('click', async () => { const stackName = document.getElementById('stack-name').value.trim(); const composeContent = document.getElementById('compose-content').value.trim(); if (!stackName || !composeContent) { showAlert('danger', 'Stack name and compose content are required'); return; } showStatusIndicator(`Deploying stack "${stackName}"...`); try { const envFileContent = document.getElementById('stack-env-file')?.value?.trim() || undefined; const response = await manager.request(Methods.deployStack, { stackName, composeContent, envFileContent: envFileContent || undefined, }); showAlert('success', response?.message || `Stack "${stackName}" deployed`); // Close modal and reset form const modal = bootstrap.Modal.getInstance(document.getElementById('deploy-stack-modal')); if (modal) modal.hide(); deployStackForm.reset(); // Load stacks view navigateToView('stacks'); loadStacks(); } catch (error) { console.error('[ERROR] Failed to deploy stack:', error); presentError(error, 'deployStack', { showAlert }); } finally { hideStatusIndicator(); } }); } document.getElementById('stack-gitops-btn')?.addEventListener('click', async () => { const stackName = document.getElementById('stack-name')?.value?.trim(); const repoUrl = document.getElementById('stack-git-url')?.value?.trim(); const ref = document.getElementById('stack-git-ref')?.value?.trim() || 'main'; const composePath = document.getElementById('stack-git-path')?.value?.trim() || 'docker-compose.yml'; if (!stackName || !repoUrl) { showAlert('warning', 'Stack name and repository URL are required for GitOps sync'); return; } showStatusIndicator(`GitOps sync ${stackName} from ${repoUrl}…`); try { const response = await manager.request(Methods.syncStackFromGit, { stackName, repoUrl, ref, composePath, }); if (response?.composeContent == null && response?.success) { // deployed server-side } showAlert( 'success', response?.message || `Stack "${stackName}" synced from git` ); const modal = bootstrap.Modal.getInstance(document.getElementById('deploy-stack-modal')); if (modal) modal.hide(); navigateToView('stacks'); loadStacks(); } catch (error) { presentError(error, 'syncStackFromGit', { showAlert }); } finally { hideStatusIndicator(); } }); } // Subscription for volumes store to auto-update UI let volumesStoreSubscription = null; function loadVolumes() { if (!window.activePeer && !hasActiveConnection()) { return; } // Set up subscription to auto-update UI when volumes change if (!volumesStoreSubscription) { volumesStoreSubscription = volumesStore.subscribe((volumes) => { // Only auto-update if we're on the volumes view if (currentView === 'volumes') { renderVolumes(volumes); } }); } // Check cache first - if fresh, use it; otherwise load from server if (!volumesStore.isStale() && volumesStore.get().length > 0) { renderVolumes(volumesStore.get()); return; } // Set loading state volumesStore.setLoading(true); showListSkeleton('volumes-list', 4); sendCommand('listVolumes'); } /** @type {Array} */ let allVolumesCache = []; function renderVolumes(volumes) { // Note: Do not call volumesStore.set() here to avoid infinite loop // The store is already updated before calling renderVolumes() in the message handler const volumesList = document.getElementById('volumes-list'); if (!volumesList) return; allVolumesCache = volumes || []; const q = document.getElementById('volume-search')?.value?.trim().toLowerCase() || ''; const filtered = !q ? allVolumesCache : allVolumesCache.filter((v) => { const name = (v.Name || '').toLowerCase(); const driver = (v.Driver || '').toLowerCase(); const mp = (v.Mountpoint || '').toLowerCase(); return name.includes(q) || driver.includes(q) || mp.includes(q); }); if (!filtered.length) { volumesList.innerHTML = emptyTableRow( 5, allVolumesCache.length ? 'No matching volumes' : 'No volumes yet', allVolumesCache.length ? 'Clear search to see all volumes.' : 'Create a volume for persistent storage.' ); return; } volumesList.innerHTML = filtered.map(volume => { const usage = volume.usage ? volume.usage.length : 0; return ` ${volume.Name} ${volume.Driver || 'local'} ${volume.Mountpoint || '-'} ${usage} container${usage !== 1 ? 's' : ''}
`; }).join(''); volumesList.querySelectorAll('.action-browse-volume').forEach((btn) => { btn.addEventListener('click', () => { const volumeName = btn.dataset.volumeName; if (window.peardockOps?.openVolumeBrowser) { window.peardockOps.openVolumeBrowser(volumeName); } else { showAlert('info', 'Volume browser not loaded'); } }); }); // Add event listeners volumesList.querySelectorAll('.action-remove-volume').forEach(btn => { btn.addEventListener('click', () => { const volumeName = btn.dataset.volumeName; showConfirmModal('Are you sure you want to remove this volume? This cannot be undone.', () => { // Set up response handler to refresh volumes list const originalHandler = window.handlePeerResponse; window.handlePeerResponse = (response) => { // Always call original handler first for volume broadcasts and other messages if (typeof originalHandler === 'function') { originalHandler(response); } // Handle remove volume success response if (response.success && response.message && response.message.includes('removed')) { showAlert('success', response.message); // Volumes will be updated via server broadcast, but refresh to be sure if (currentView === 'volumes') { loadVolumes(); } } else if (response.error) { // Error is already handled by centralized RPC handler // But we can still show alert for immediate feedback const errorMsg = handleErrorResponse(response); if (errorMsg) { showAlert('danger', errorMsg); } } // Only reset handler if this was the remove volume response (not a broadcast) if (response.success && response.message && response.message.includes('removed')) { window.handlePeerResponse = originalHandler; } }; sendCommand('removeVolume', { name: volumeName }); // Reset handler after timeout as safety measure setTimeout(() => { if (window.handlePeerResponse !== originalHandler) { window.handlePeerResponse = originalHandler; } }, 30000); }); }); }); volumesList.querySelectorAll('.action-inspect-volume').forEach(btn => { btn.addEventListener('click', () => { const volumeName = btn.dataset.volumeName; openVolumeInspectModal(volumeName); }); }); } // Modal Functions function setupBuildImageHandler() { const buildImageBtn = document.getElementById('build-image-btn'); const buildImageForm = document.getElementById('buildImageForm'); if (buildImageBtn && buildImageForm) { buildImageBtn.addEventListener('click', async () => { const tag = document.getElementById('image-tag').value.trim(); const dockerfile = document.getElementById('dockerfile-content').value.trim(); if (!tag || !dockerfile) { showAlert('danger', 'Image tag and Dockerfile content are required'); return; } showStatusIndicator(`Building image "${tag}"...`); sendCommand('buildImage', { tag, dockerfile }); try { const response = await waitForPeerResponse('Image built successfully'); showAlert('success', response.message || 'Image built successfully'); // Close modal and reset form const modal = bootstrap.Modal.getInstance(document.getElementById('buildImageModal')); if (modal) modal.hide(); buildImageForm.reset(); // Refresh images list sendCommand('listImages'); } catch (error) { console.error('[ERROR] Failed to build image:', error); showAlert('danger', error.message || 'Failed to build image'); } finally { hideStatusIndicator(); } }); } } /** Derive 0–100 from Docker pull progress event, or null if unknown. */ function pullProgressPercent(event) { const detail = event?.progressDetail; if (detail && detail.total && detail.current != null) { return Math.min(100, Math.round((detail.current / detail.total) * 100)); } if (event?.status && /complete|downloaded|pull complete/i.test(String(event.status))) { return 100; } return null; } function pullImage() { const imageName = document.getElementById('pull-image-name')?.value?.trim(); if (!imageName) { showAlert('danger', 'Please enter an image name'); return; } const modal = bootstrap.Modal.getInstance(document.getElementById('pullImageModal')); if (modal) modal.hide(); try { const host = document.getElementById('images-view') || document.getElementById('alert-container')?.parentElement || document.body; document.getElementById('progress-pull-image')?.remove(); host.prepend(createProgressBar('pull-image', `Pulling ${imageName}`)); } catch { // ignore } showStatusIndicator(`Pulling image "${imageName}"...`); sendCommand('pullImage', { image: imageName }).then((response) => { removeProgressBar('pull-image'); if (response?.success) { hideStatusIndicator(); showAlert('success', response.message || `Image "${imageName}" pulled successfully`); if (typeof loadImages === 'function') loadImages(); else sendCommand('listImages'); } else if (response?.error) { hideStatusIndicator(); const errorMsg = handleErrorResponse(response); if (errorMsg) showAlert('danger', errorMsg); } else { // Error already emitted via manager.send → handleRpcMessage hideStatusIndicator(); } }); } /** Legacy entry — always open smart network modal */ function createNetwork() { if (window.peardockOps?.openSmartNetworkModal) { window.peardockOps.openSmartNetworkModal(); return; } showAlert('info', 'Use Create network from the Networks view'); // Keep a no-op timeout cleanup path for any old callers const originalHandler = window.handlePeerResponse; setTimeout(() => { if (window.handlePeerResponse === originalHandler) { window.handlePeerResponse = originalHandler; } }, 30000); } function createVolume() { const name = document.getElementById('volume-name')?.value?.trim(); if (!name) { showAlert('danger', 'Please enter a volume name'); return; } const driver = document.getElementById('volume-driver')?.value?.trim() || null; const modal = bootstrap.Modal.getInstance(document.getElementById('createVolumeModal')); if (modal) modal.hide(); showStatusIndicator(`Creating volume "${name}"...`); sendCommand('createVolume', { name, driver }); // Store the volume name being created for later matching const volumeNameBeingCreated = name; let volumeCreated = false; // Wait for response const originalHandler = window.handlePeerResponse; window.handlePeerResponse = (response) => { // Always call original handler first for volume broadcasts and other messages if (typeof originalHandler === 'function') { originalHandler(response); } // Check if volume was already created to avoid duplicate processing if (volumeCreated) { return; } // Handle create volume success response - more flexible condition const isDirectSuccess = response.success && response.message && (response.message.includes('created successfully') || response.message.includes('created')); // Check if volumes broadcast includes the newly created volume let isVolumeInBroadcast = false; if (response.type === 'volumes' || (response.success && (response.data || response.volumes))) { const volumesArray = response.data || response.volumes || []; if (Array.isArray(volumesArray)) { isVolumeInBroadcast = volumesArray.some(vol => { const volName = typeof vol === 'string' ? vol : (vol.Name || vol.name || ''); return volName === volumeNameBeingCreated; }); } } // Handle success (either direct response or volumes broadcast with new volume) if (isDirectSuccess || isVolumeInBroadcast) { volumeCreated = true; hideStatusIndicator(); if (isDirectSuccess && response.message) { showAlert('success', response.message); } else if (isVolumeInBroadcast) { showAlert('success', `Volume "${volumeNameBeingCreated}" created successfully`); } // Volumes will be updated via server broadcast, but refresh to be sure loadVolumes(); // Reset form document.getElementById('create-volume-form')?.reset(); // Reset handler since we've handled the response window.handlePeerResponse = originalHandler; } else if (response.error) { hideStatusIndicator(); // Error is already handled by centralized handler in handleRpcMessage // But we can still show alert for immediate feedback const errorMsg = handleErrorResponse(response); if (errorMsg) { showAlert('danger', errorMsg); } // Reset handler on error window.handlePeerResponse = originalHandler; } }; // Safety timeout - hide spinner and reset handler if no response received setTimeout(() => { if (!volumeCreated && window.handlePeerResponse !== originalHandler) { console.warn(`[WARN] Volume creation timeout for "${volumeNameBeingCreated}" - hiding spinner as safety measure`); hideStatusIndicator(); window.handlePeerResponse = originalHandler; } }, 30000); } // Container Details Functions let currentContainerDetails = null; function showContainerDetails(container) { currentContainerDetails = container; navigateToView('container-details'); // Update title const titleEl = document.getElementById('container-details-title'); if (titleEl) { const name = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12); titleEl.innerHTML = `${name}`; } // Load container config sendCommand('inspectContainer', { id: container.Id }); // Set up callback for container config window.inspectContainerCallback = (config) => { populateContainerDetails(config, container); window.inspectContainerCallback = null; }; // Switch to overview tab const overviewTab = document.getElementById('overview-tab'); if (overviewTab) { const tab = new bootstrap.Tab(overviewTab); tab.show(); } } // Copy to clipboard utility function copyToClipboard(text, buttonElement) { navigator.clipboard.writeText(text).then(() => { const originalHTML = buttonElement.innerHTML; buttonElement.innerHTML = ' Copied!'; buttonElement.style.background = 'var(--accent-success)'; buttonElement.style.borderColor = 'var(--accent-success)'; setTimeout(() => { buttonElement.innerHTML = originalHTML; buttonElement.style.background = ''; buttonElement.style.borderColor = ''; }, 2000); }).catch(err => { console.error('Failed to copy:', err); showAlert('danger', 'Failed to copy to clipboard'); }); } // Format relative time function formatRelativeTime(dateString) { if (!dateString || dateString === 'Unknown' || dateString === 'Not started') return ''; const date = new Date(dateString); const now = new Date(); const diffMs = now - date; const diffSecs = Math.floor(diffMs / 1000); const diffMins = Math.floor(diffSecs / 60); const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); if (diffSecs < 60) return `${diffSecs} second${diffSecs !== 1 ? 's' : ''} ago`; if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`; if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`; if (diffDays < 30) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`; return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) !== 1 ? 's' : ''} ago`; } function populateContainerDetails(config, container) { if (!config) return; // Overview Tab populateOverviewTab(config, container); // Configuration Tab populateConfigTab(config); // Networking Tab populateNetworkingTab(config); // Stats Tab - will be updated by stats updates updateContainerDetailsStats(container); // Attach copy / tunnel button event listeners after content is populated setTimeout(() => { document.querySelectorAll('.copy-btn').forEach(btn => { btn.addEventListener('click', function() { const textToCopy = this.getAttribute('data-copy'); if (textToCopy) { copyToClipboard(textToCopy, this); } }); }); document.querySelectorAll('.action-tunnel-port').forEach((btn) => { btn.addEventListener('click', async function () { const containerId = this.getAttribute('data-container-id'); const containerPort = Number(this.getAttribute('data-container-port')); const hostPort = Number(this.getAttribute('data-host-port')); const protocol = this.getAttribute('data-protocol') || 'tcp'; const containerName = this.getAttribute('data-container-name') || ''; if (!containerId || !containerPort) { showAlert('warning', 'Missing container port info for tunnel'); return; } try { this.disabled = true; const res = await manager.request(Methods.createTunnel, { containerId, containerPort, protocol, name: containerName ? `${containerName}:${containerPort}` : `port-${hostPort || containerPort}`, secure: true, }); const url = res?.tunnel?.url; const existing = Boolean(res?.existing); if (url && navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(url); } catch { // ignore } } showAlert( existing ? 'info' : 'success', url ? existing ? `Tunnel already exists — URL copied. Connect with: npx holesail '${url.slice(0, 24)}…'` : `Holesail tunnel created — URL copied. Connect with: npx holesail '${url.slice(0, 24)}…'` : existing ? 'Tunnel already exists for this port' : 'Holesail tunnel created' ); if (typeof window.peardockOps?.loadTunnelsView === 'function') { // warm tunnels view cache } // Offer local connect via designed modal (Settings → Tunnels) const offerLocal = window.peardockOps?.loadSettings?.()?.offerLocalTunnelAfterCreate !== false; if (url && offerLocal && window.peardockOps?.connectLocalTunnel) { const open = window.peardockOps.askUserConfirm ? await window.peardockOps.askUserConfirm( 'Open local tunnel?', 'Start a local Holesail client and open this tunnel in your browser?', { confirmLabel: 'Open tunnel', icon: 'fa-network-wired' } ) : false; if (open) { await window.peardockOps.connectLocalTunnel(url); } } } catch (err) { presentError(err, 'createTunnel', { showAlert }); } finally { this.disabled = false; } }); }); }, 100); } function populateOverviewTab(config, container) { const content = document.getElementById('container-overview-content'); if (!content) return; const name = config.Name?.replace(/^\//, '') || 'Unknown'; const image = config.Config?.Image || 'Unknown'; const state = config.State?.Status || 'Unknown'; const created = config.Created ? new Date(config.Created).toLocaleString() : 'Unknown'; const createdRelative = config.Created ? formatRelativeTime(config.Created) : ''; const started = config.State?.StartedAt ? new Date(config.State.StartedAt).toLocaleString() : 'Not started'; const startedRelative = config.State?.StartedAt ? formatRelativeTime(config.State.StartedAt) : ''; const id = config.Id || 'Unknown'; const fullId = id; const shortId = id.substring(0, 12); const restartCount = config.RestartCount || 0; // Get IP address let ipAddress = 'No IP Assigned'; let primaryNetwork = null; if (config.NetworkSettings && config.NetworkSettings.Networks) { const networks = Object.values(config.NetworkSettings.Networks); if (networks.length > 0 && networks[0].IPAddress) { ipAddress = networks[0].IPAddress; primaryNetwork = networks[0]; } } // Status badge class const statusBadgeClass = state === 'running' ? 'status-running' : state === 'paused' ? 'status-paused' : state === 'restarting' ? 'status-restarting' : 'status-exited'; // Status icon const statusIcon = state === 'running' ? 'fa-circle-check' : state === 'paused' ? 'fa-pause-circle' : state === 'restarting' ? 'fa-sync-alt' : 'fa-stop-circle'; content.innerHTML = `

Basic Information

Container Name
${name}
Container ID
${shortId}
Image
${image}

Status & Health

Status
${state}
Restart Count
${restartCount}
Restarts

Network Information

IP Address
${ipAddress !== 'No IP Assigned' ? ` ${ipAddress} ` : 'No IP Assigned'}
${primaryNetwork && primaryNetwork.Gateway ? `
Gateway
${primaryNetwork.Gateway}
` : ''} ${primaryNetwork && primaryNetwork.MacAddress ? `
MAC Address
${primaryNetwork.MacAddress}
` : ''}

Timeline

Created
${created} ${createdRelative ? `
${createdRelative}
` : ''}
Started
${started} ${startedRelative ? `
${startedRelative}
` : ''}
`; } function populateConfigTab(config) { const content = document.getElementById('container-config-content'); if (!content) return; const cmd = config.Config?.Cmd || []; const entrypoint = config.Config?.Entrypoint || []; const workingDir = config.Config?.WorkingDir || ''; const user = config.Config?.User || ''; const env = config.Config?.Env || []; const exposedPorts = config.Config?.ExposedPorts ? Object.keys(config.Config.ExposedPorts) : []; const labels = config.Config?.Labels || {}; const hostname = config.Config?.Hostname || ''; const domainname = config.Config?.Domainname || ''; const tty = config.Config?.Tty || false; const openStdin = config.Config?.OpenStdin || false; // Format command and entrypoint const cmdStr = cmd.length > 0 ? cmd.join(' ') : null; const entrypointStr = entrypoint.length > 0 ? entrypoint.join(' ') : null; // Parse environment variables into key-value pairs const envVars = env.map(e => { const idx = e.indexOf('='); if (idx === -1) return { key: e, value: '' }; return { key: e.substring(0, idx), value: e.substring(idx + 1) }; }); content.innerHTML = `

Command & Entrypoint

Command
${cmdStr ? `
${cmdStr}
` : `
Not set
`}
Entrypoint
${entrypointStr ? `
${entrypointStr}
` : `
Not set
`}

Runtime Settings

Working Directory
${workingDir ? `${workingDir}` : 'Not set'}
User
${user || 'Default'}
Hostname
${hostname ? `${hostname}` : 'Not set'}
Domain Name
${domainname ? `${domainname}` : 'Not set'}
TTY
${tty ? 'Enabled' : 'Disabled'}
Open STDIN
${openStdin ? 'Enabled' : 'Disabled'}

Environment Variables

${envVars.length > 0 ? `
${envVars.map(envVar => ` `).join('')}
Variable Value Action
${envVar.key} ${envVar.value || '(empty)'}
` : `
No environment variables set
`}
${exposedPorts.length > 0 ? `

Exposed Ports

${exposedPorts.map(port => { const [portNum, protocol] = port.split('/'); return `
${protocol || 'tcp'} ${portNum}
`; }).join('')}
` : ''} ${Object.keys(labels).length > 0 ? `

Labels

${Object.entries(labels).map(([key, value]) => ` `).join('')}
Key Value Action
${key} ${value}
` : ''} `; } function populateNetworkingTab(config) { const content = document.getElementById('container-networking-content'); if (!content) return; const networkMode = config.HostConfig?.NetworkMode || 'default'; const networks = config.NetworkSettings?.Networks || {}; const ports = config.NetworkSettings?.Ports || {}; const dns = config.HostConfig?.Dns || []; const extraHosts = config.HostConfig?.ExtraHosts || []; const links = config.HostConfig?.Links || []; // Format port bindings with detailed info const portBindings = []; if (ports) { Object.keys(ports).forEach(port => { const [portNum, protocol] = port.split('/'); const bindings = ports[port]; if (bindings && bindings.length > 0) { bindings.forEach(binding => { portBindings.push({ containerPort: portNum, protocol: protocol || 'tcp', hostIp: binding.HostIp || '0.0.0.0', hostPort: binding.HostPort, display: `${binding.HostIp || '0.0.0.0'}:${binding.HostPort} → ${portNum}/${protocol || 'tcp'}` }); }); } else { // Exposed but not bound portBindings.push({ containerPort: portNum, protocol: protocol || 'tcp', hostIp: null, hostPort: null, display: `${portNum}/${protocol || 'tcp'} (exposed, not bound)` }); } }); } // Network cards data const networkCards = []; Object.keys(networks).forEach(netName => { const net = networks[netName]; networkCards.push({ name: netName, ipAddress: net.IPAddress || 'Not assigned', gateway: net.Gateway || 'Not set', macAddress: net.MacAddress || 'Not set', networkId: net.NetworkID || 'Not set', endpointId: net.EndpointID || 'Not set', ipPrefixLen: net.IPPrefixLen || null, globalIPv6Address: net.GlobalIPv6Address || null, globalIPv6PrefixLen: net.GlobalIPv6PrefixLen || null, ipv6Gateway: net.IPv6Gateway || null }); }); content.innerHTML = `

Network Mode

Mode
${networkMode}
${networkCards.length > 0 ? `

Connected Networks

${networkCards.map(net => `
${net.name}
${net.name !== 'host' && net.name !== 'none' && net.networkId && net.networkId !== 'Not set' ? ` ` : ''}
IP Address
${net.ipAddress !== 'Not assigned' ? ` ${net.ipAddress} ${net.ipPrefixLen ? `/${net.ipPrefixLen}` : ''} ` : 'Not assigned'}
Gateway
${net.gateway !== 'Not set' ? `${net.gateway}` : 'Not set'}
MAC Address
${net.macAddress !== 'Not set' ? `${net.macAddress}` : 'Not set'}
${net.globalIPv6Address ? `
IPv6 Address
${net.globalIPv6Address} ${net.globalIPv6PrefixLen ? `/${net.globalIPv6PrefixLen}` : ''}
` : ''} ${net.ipv6Gateway ? `
IPv6 Gateway
${net.ipv6Gateway}
` : ''} ${net.networkId && net.networkId !== 'Not set' ? `
Network ID
${net.networkId.length > 12 ? net.networkId.substring(0, 12) + '...' : net.networkId}
` : ''} ${net.endpointId && net.endpointId !== 'Not set' ? `
Endpoint ID
${net.endpointId.length > 12 ? net.endpointId.substring(0, 12) + '...' : net.endpointId}
` : ''}
`).join('')}
` : `

Connected Networks

No networks connected
`}

Port Mappings

${portBindings.length > 0 ? `
${portBindings.map(binding => ` `).join('')}
Host IP Host Port Container Port Protocol Action
${binding.hostIp ? `${binding.hostIp}` : '-'} ${binding.hostPort ? `${binding.hostPort}` : '-'} ${binding.containerPort} ${binding.protocol} ${binding.hostPort ? `
` : '-'}
` : `
No port mappings configured
`}
${dns.length > 0 ? `

DNS Servers

${dns.map(dnsServer => `
${dnsServer}
`).join('')}
` : ''} ${extraHosts.length > 0 ? `

Extra Hosts

${extraHosts.map(host => { const [hostname, ip] = host.split(':'); return ` `; content.querySelectorAll('.network-disconnect-btn').forEach((btn) => { btn.addEventListener('click', () => { const containerId = btn.getAttribute('data-container-id'); const networkId = btn.getAttribute('data-network-id'); const networkName = btn.getAttribute('data-network-name'); if (window.peardockOps?.disconnectContainerNetwork) { window.peardockOps.disconnectContainerNetwork(containerId, networkId, networkName); } else { showAlert('info', 'Disconnect not available'); } }); }); }).join('')}
Hostname IP Address Action
${hostname} ${ip}
` : ''} ${links.length > 0 ? `

Container Links

${links.map(link => `
${link}
`).join('')}
` : ''} `; } let cpuChart = null; let memoryChart = null; function updateContainerDetailsStats(container) { // This will be called by the stats update function // For now, just show current stats if available if (smoothedStats[container.Id]) { const stats = smoothedStats[container.Id]; const cpuEl = document.getElementById('detail-cpu'); const memoryEl = document.getElementById('detail-memory'); if (cpuEl) cpuEl.textContent = `${stats.cpu.toFixed(2)}%`; if (memoryEl) memoryEl.textContent = `${(stats.memory / (1024 * 1024)).toFixed(2)} MB`; } // Merge server-side history ring buffer into client charts (road-map stats history) if (manager.active?.connected) { manager .request(Methods.getStatsHistory, { id: container.Id, limit: 120 }) .then((res) => { if (!res?.data?.length) return; if (!historicalStats[container.Id]) { historicalStats[container.Id] = { timestamps: [], cpu: [], memory: [] }; } const h = historicalStats[container.Id]; // Prefer server series when longer if (res.data.length >= h.timestamps.length) { h.timestamps = res.data.map((p) => p.t); h.cpu = res.data.map((p) => p.cpu); h.memory = res.data.map((p) => p.memory); updateStatsCharts(container.Id); } }) .catch(() => {}); } // Initialize charts updateStatsCharts(container.Id); } function updateStatsCharts(containerId) { if (!historicalStats[containerId] || historicalStats[containerId].timestamps.length === 0) { return; } const history = historicalStats[containerId]; const container = document.getElementById('stats-charts-container'); if (!container) return; // Format timestamps for display const labels = history.timestamps.map(ts => { const date = new Date(ts); return `${date.getMinutes()}:${date.getSeconds().toString().padStart(2, '0')}`; }); // Create or update CPU chart const cpuCtx = document.getElementById('cpu-chart-canvas'); if (!cpuCtx) { // Create canvas if it doesn't exist container.innerHTML = `
CPU Usage
Memory Usage
`; } const cpuCanvas = document.getElementById('cpu-chart-canvas'); const memoryCanvas = document.getElementById('memory-chart-canvas'); if (cpuCanvas && typeof Chart !== 'undefined') { if (!cpuChart) { cpuChart = new Chart(cpuCanvas, { type: 'line', data: { labels: labels, datasets: [{ label: 'CPU %', data: history.cpu, borderColor: 'rgb(16, 185, 129)', backgroundColor: 'rgba(16, 185, 129, 0.1)', tension: 0.4, fill: true }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, max: 100, ticks: { color: 'rgba(255, 255, 255, 0.7)' }, grid: { color: 'rgba(255, 255, 255, 0.1)' } }, x: { ticks: { color: 'rgba(255, 255, 255, 0.7)' }, grid: { color: 'rgba(255, 255, 255, 0.1)' } } } } }); } else { cpuChart.data.labels = labels; cpuChart.data.datasets[0].data = history.cpu; cpuChart.update('none'); } } if (memoryCanvas && typeof Chart !== 'undefined') { // Convert memory to MB const memoryMB = history.memory.map(m => m / (1024 * 1024)); if (!memoryChart) { memoryChart = new Chart(memoryCanvas, { type: 'line', data: { labels: labels, datasets: [{ label: 'Memory (MB)', data: memoryMB, borderColor: 'rgb(59, 130, 246)', backgroundColor: 'rgba(59, 130, 246, 0.1)', tension: 0.4, fill: true }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { color: 'rgba(255, 255, 255, 0.7)' }, grid: { color: 'rgba(255, 255, 255, 0.1)' } }, x: { ticks: { color: 'rgba(255, 255, 255, 0.7)' }, grid: { color: 'rgba(255, 255, 255, 0.1)' } } } } }); } else { memoryChart.data.labels = labels; memoryChart.data.datasets[0].data = memoryMB; memoryChart.update('none'); } } } // Logs state management let logsState = { paused: false, autoScroll: true, currentFilter: 'all', searchTerm: '', allLogs: [], containerId: null, }; // Detect log level from log line function detectLogLevel(line) { const lowerLine = line.toLowerCase(); if (/\b(error|exception|fatal|panic|crit|critical)\b/.test(lowerLine)) { return 'error'; } else if (/\b(warn|warning)\b/.test(lowerLine)) { return 'warn'; } else if (/\b(debug|trace)\b/.test(lowerLine)) { return 'debug'; } else if (/\b(info|notice)\b/.test(lowerLine)) { return 'info'; } return null; } // Format log line with timestamp and level detection function formatLogLine(rawLine) { // Preserve leading spaces; only drop pure empty lines const line = String(rawLine ?? '').replace(/\r$/, ''); if (!line.trim()) return null; const level = detectLogLevel(line); // Docker --timestamps and common ISO / RFC3339 / syslog prefixes let logTimestamp = null; let formatted = line; const tsMatch = line.match( /^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\s+(.*)$/ ); if (tsMatch) { logTimestamp = tsMatch[1]; formatted = tsMatch[2] ?? line; } return { raw: line, level, timestamp: logTimestamp || null, formatted, }; } /** Cap DOM log lines to avoid memory blow-up */ const MAX_LOG_DOM_LINES = 5000; /** Partial line buffer when chunks split mid-line */ let logsLineBuffer = ''; // Apply filters and search to logs function applyLogFilters() { const logsContent = document.getElementById('container-logs-content'); if (!logsContent) return; const logLines = logsContent.querySelectorAll('.log-line'); let visibleCount = 0; logLines.forEach(line => { const logData = line.dataset; let shouldShow = true; // Apply level filter if (logsState.currentFilter !== 'all') { const lineLevel = logData.level || ''; if (lineLevel !== logsState.currentFilter) { shouldShow = false; } } // Apply search filter if (shouldShow && logsState.searchTerm) { const searchLower = logsState.searchTerm.toLowerCase(); const lineText = line.textContent.toLowerCase(); if (!lineText.includes(searchLower)) { shouldShow = false; } else { // Highlight search matches highlightSearchMatches(line); } } else { // Remove highlight if no search removeSearchHighlights(line); } if (shouldShow) { line.classList.remove('hidden'); visibleCount++; } else { line.classList.add('hidden'); } }); // Update filter button states document.querySelectorAll('.logs-filter-btn').forEach(btn => { if (btn.dataset.filter === logsState.currentFilter) { btn.classList.add('active'); } else { btn.classList.remove('active'); } }); } // Highlight search matches in log line function highlightSearchMatches(lineElement) { if (!logsState.searchTerm) return; const contentSpan = lineElement.querySelector('.log-content'); if (!contentSpan) return; const originalText = lineElement.dataset.originalText || contentSpan.textContent; const searchTerm = logsState.searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`(${searchTerm})`, 'gi'); if (regex.test(originalText)) { lineElement.classList.add('highlight'); const highlighted = escapeHtml(originalText).replace(regex, '$1'); contentSpan.innerHTML = highlighted; } } // Remove search highlights function removeSearchHighlights(lineElement) { lineElement.classList.remove('highlight'); const contentSpan = lineElement.querySelector('.log-content'); if (contentSpan) { // Restore original text (escape HTML) const originalText = lineElement.dataset.originalText || contentSpan.textContent; contentSpan.innerHTML = escapeHtml(originalText); } } // Scroll to bottom if auto-scroll is enabled function scrollLogsToBottom() { if (logsState.autoScroll && !logsState.paused) { const logsContent = document.getElementById('container-logs-content'); if (logsContent) { logsContent.scrollTop = logsContent.scrollHeight; } } } // Set up logs tab document.addEventListener('DOMContentLoaded', () => { const processesTab = document.getElementById('processes-tab'); if (processesTab) { processesTab.addEventListener('shown.bs.tab', () => { if (currentContainerDetails?.Id) { loadContainerTop(currentContainerDetails.Id); } }); } const refreshTopBtn = document.getElementById('refresh-container-top-btn'); if (refreshTopBtn) { refreshTopBtn.addEventListener('click', () => { if (currentContainerDetails?.Id) { loadContainerTop(currentContainerDetails.Id); } }); } const logsTab = document.getElementById('logs-tab'); if (logsTab) { logsTab.addEventListener('shown.bs.tab', () => { if (currentContainerDetails) { const logsContent = document.getElementById('container-logs-content'); if (logsContent) { // Reset state logsState = { paused: false, autoScroll: true, currentFilter: 'all', searchTerm: '', allLogs: [] }; // Reset UI controls const pauseBtn = document.getElementById('logs-pause-btn'); if (pauseBtn) { pauseBtn.innerHTML = ' Pause'; pauseBtn.title = 'Pause'; } const searchInput = document.getElementById('logs-search-input'); if (searchInput) { searchInput.value = ''; } const clearSearchBtn = document.getElementById('logs-clear-search'); if (clearSearchBtn) { clearSearchBtn.style.display = 'none'; } const autoScrollCheckbox = document.getElementById('logs-auto-scroll'); if (autoScrollCheckbox) { autoScrollCheckbox.checked = true; } // Reset filter buttons document.querySelectorAll('.logs-filter-btn').forEach(btn => { if (btn.dataset.filter === 'all') { btn.classList.add('active'); } else { btn.classList.remove('active'); } }); logsContent.innerHTML = '
Loading logs...
'; logsLineBuffer = ''; logsState.containerId = currentContainerDetails.Id; logsState.allLogs = []; sendCommand('logs', { id: currentContainerDetails.Id, tail: 200, timestamps: true, follow: true, }); // Set up enhanced log handler (server demuxes docker frames → utf8 base64) window.handleLogOutput = (logData) => { if (logsState.paused) return; if ( logData.containerId && logsState.containerId && logData.containerId !== logsState.containerId ) { return; } const chunk = decodePayload(logData.data, logData.encoding || 'base64'); if (!chunk) return; const loadingEl = logsContent.querySelector('.logs-loading'); if (loadingEl) logsContent.innerHTML = ''; // Split on newlines; keep incomplete trailing line in buffer const combined = logsLineBuffer + chunk; const parts = combined.split(/\r?\n/); logsLineBuffer = parts.pop() ?? ''; const frag = document.createDocumentFragment(); for (const rawLine of parts) { const formattedLog = formatLogLine(rawLine); if (!formattedLog) continue; logsState.allLogs.push(formattedLog); if (logsState.allLogs.length > MAX_LOG_DOM_LINES) { logsState.allLogs.splice(0, logsState.allLogs.length - MAX_LOG_DOM_LINES); } const logElement = document.createElement('div'); logElement.className = 'log-line'; if (formattedLog.level) { logElement.classList.add(formattedLog.level); logElement.dataset.level = formattedLog.level; } else { logElement.dataset.level = ''; } let logHTML = ''; if (formattedLog.timestamp) { logHTML += `${escapeHtml(formattedLog.timestamp)} `; } if (formattedLog.level) { logHTML += `${formattedLog.level}`; } logHTML += `${escapeHtml(formattedLog.formatted)}`; logElement.innerHTML = logHTML; logElement.dataset.originalText = formattedLog.formatted; frag.appendChild(logElement); } if (frag.childNodes.length) { logsContent.appendChild(frag); // Cap DOM nodes while (logsContent.children.length > MAX_LOG_DOM_LINES) { logsContent.removeChild(logsContent.firstChild); } applyLogFilters(); scrollLogsToBottom(); } }; } } }); } // Set up logs controls const pauseBtn = document.getElementById('logs-pause-btn'); if (pauseBtn) { pauseBtn.addEventListener('click', () => { logsState.paused = !logsState.paused; pauseBtn.innerHTML = logsState.paused ? ' Resume' : ' Pause'; pauseBtn.title = logsState.paused ? 'Resume' : 'Pause'; }); } const clearBtn = document.getElementById('logs-clear-btn'); if (clearBtn) { clearBtn.addEventListener('click', () => { const logsContent = document.getElementById('container-logs-content'); if (logsContent) { showConfirmModal('Clear all logs?', () => { logsContent.innerHTML = ''; logsState.allLogs = []; }); } }); } const copyBtn = document.getElementById('logs-copy-btn'); if (copyBtn) { copyBtn.addEventListener('click', () => { const logsContent = document.getElementById('container-logs-content'); if (logsContent) { const allText = Array.from(logsContent.querySelectorAll('.log-line:not(.hidden)')) .map(line => line.textContent) .join('\n'); copyToClipboard(allText, copyBtn); } }); } const downloadBtn = document.getElementById('logs-download-btn'); if (downloadBtn) { downloadBtn.addEventListener('click', async () => { if (!currentContainerDetails) return; let allText = ''; // Prefer server-side full log fetch (with current filters) for complete download try { if (manager.active?.connected) { const res = await manager.request(Methods.getContainerLogs, { id: currentContainerDetails.Id, tail: 5000, timestamps: true, search: logsState.searchTerm || undefined, level: logsState.currentFilter !== 'all' ? logsState.currentFilter : undefined, }); if (res?.data) allText = res.data; } } catch { // fall through to DOM export } if (!allText) { const logsContent = document.getElementById('container-logs-content'); if (logsContent) { allText = Array.from(logsContent.querySelectorAll('.log-line:not(.hidden)')) .map((line) => line.textContent) .join('\n'); } } if (!allText) return; const blob = new Blob([allText], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `container-${currentContainerDetails.Id.substring(0, 12)}-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }); } // Auto-scroll toggle const autoScrollCheckbox = document.getElementById('logs-auto-scroll'); if (autoScrollCheckbox) { autoScrollCheckbox.addEventListener('change', (e) => { logsState.autoScroll = e.target.checked; if (logsState.autoScroll) { scrollLogsToBottom(); } }); } // Search input const searchInput = document.getElementById('logs-search-input'); const clearSearchBtn = document.getElementById('logs-clear-search'); if (searchInput) { searchInput.addEventListener('input', (e) => { logsState.searchTerm = e.target.value; if (logsState.searchTerm) { clearSearchBtn.style.display = 'block'; } else { clearSearchBtn.style.display = 'none'; } applyLogFilters(); }); } if (clearSearchBtn) { clearSearchBtn.addEventListener('click', () => { searchInput.value = ''; logsState.searchTerm = ''; clearSearchBtn.style.display = 'none'; applyLogFilters(); }); } // Filter buttons document.querySelectorAll('.logs-filter-btn').forEach(btn => { btn.addEventListener('click', () => { logsState.currentFilter = btn.dataset.filter; applyLogFilters(); }); }); // Set up stats tab const statsTab = document.getElementById('stats-tab'); if (statsTab) { statsTab.addEventListener('shown.bs.tab', () => { if (currentContainerDetails) { updateContainerDetailsStats(currentContainerDetails); } }); statsTab.addEventListener('hidden.bs.tab', () => { // Clean up charts when leaving stats tab if (cpuChart) { cpuChart.destroy(); cpuChart = null; } if (memoryChart) { memoryChart.destroy(); memoryChart = null; } }); } // Terminal state for details tab let detailsTerminalSession = null; let terminalFontSize = 14; let terminalTheme = 'dark'; // Terminal theme configurations const terminalThemes = { dark: { background: '#000000', foreground: '#ffffff', cursor: '#ffffff', selectionBackground: '#4d4d4d' }, light: { background: '#ffffff', foreground: '#000000', cursor: '#000000', selectionBackground: '#b3d4fc' }, 'solarized-dark': { background: '#002b36', foreground: '#839496', cursor: '#93a1a1', selectionBackground: '#073642' }, 'solarized-light': { background: '#fdf6e3', foreground: '#657b83', cursor: '#586e75', selectionBackground: '#eee8d5' }, monokai: { background: '#272822', foreground: '#f8f8f2', cursor: '#f8f8f0', selectionBackground: '#49483e' } }; // Initialize terminal for details tab function initDetailsTerminal(containerId) { let TerminalCtor; let FitAddonCtor; try { TerminalCtor = getTerminalCtor(); FitAddonCtor = getFitAddonCtor(); } catch (err) { console.error('[ERROR] Terminal libraries not loaded', err); return; } const terminalContainer = document.getElementById('container-terminal-xterm'); const wrap = document.getElementById('container-terminal-content'); if (!terminalContainer) return; if (detailsTerminalSession) { cleanupDetailsTerminal(); } // Ensure host has size for FitAddon if (wrap) { wrap.style.minHeight = wrap.style.minHeight || '360px'; wrap.style.height = wrap.style.height || '100%'; } terminalContainer.style.width = '100%'; terminalContainer.style.height = '100%'; terminalContainer.style.minHeight = '320px'; const theme = terminalThemes[terminalTheme] || defaultXtermOptions().theme; const xterm = new TerminalCtor( defaultXtermOptions({ fontSize: terminalFontSize, theme, }) ); const fitAddon = new FitAddonCtor(); xterm.loadAddon(fitAddon); terminalContainer.innerHTML = ''; xterm.open(terminalContainer); const sendResize = (cols, rows) => { if (!manager.active?.connected || !cols || !rows) return; manager.event(Methods.terminalResize, { containerId, cols, rows }); }; const fitController = createFitController(fitAddon, xterm, sendResize); fitController.observe(terminalContainer); if (wrap) fitController.observe(wrap); const inputCoalescer = createInputCoalescer(({ data, encoding }) => { if (!manager.active?.connected) return; manager.event(Methods.terminalInput, { containerId, data, encoding: encoding || 'utf8', }); }); const onDataDisposable = xterm.onData((data) => { if (!manager.active?.connected) return; inputCoalescer.push(data); }); detailsTerminalSession = { xterm, fitAddon, fitController, inputCoalescer, onDataDisposable, containerId, }; // Fit when tab is visible, then start PTY with size requestAnimationFrame(() => { const dims = safeFit(fitAddon, xterm) || { cols: xterm.cols, rows: xterm.rows }; if (manager.active?.connected) { manager .request(Methods.startTerminal, { containerId, cols: dims.cols, rows: dims.rows, tty: true, }) .catch((err) => { xterm.writeln(`\r\n\x1b[31m[ERROR] ${err.message}\x1b[0m`); }); sendResize(dims.cols, dims.rows); } xterm.focus(); }); updateTerminalFontSizeDisplay(); } // Cleanup terminal for details tab function cleanupDetailsTerminal() { if (!detailsTerminalSession) return; const sid = detailsTerminalSession.containerId; try { detailsTerminalSession.inputCoalescer?.flush(); detailsTerminalSession.inputCoalescer?.destroy(); if (sid && manager.active?.connected) { manager.request(Methods.killTerminal, { containerId: sid }).catch(() => {}); } detailsTerminalSession.onDataDisposable?.dispose(); detailsTerminalSession.fitController?.disconnect(); detailsTerminalSession.xterm?.dispose(); } catch { // ignore } detailsTerminalSession = null; const el = document.getElementById('container-terminal-xterm'); if (el) el.innerHTML = ''; } // Append terminal output function appendDetailsTerminalOutput(data, encoding = 'base64') { if (!detailsTerminalSession?.xterm) return; const text = decodePayload(data, encoding); if (text) detailsTerminalSession.xterm.write(text); } // Update font size display function updateTerminalFontSizeDisplay() { const display = document.getElementById('terminal-font-size-display'); if (display) { display.textContent = terminalFontSize; } } // Apply terminal theme function applyTerminalTheme(theme) { terminalTheme = theme; if (detailsTerminalSession && detailsTerminalSession.xterm) { detailsTerminalSession.xterm.options.theme = terminalThemes[theme]; } } // Set up terminal tab const terminalTab = document.getElementById('terminal-tab'); if (terminalTab) { terminalTab.addEventListener('shown.bs.tab', () => { if (currentContainerDetails) { initDetailsTerminal(currentContainerDetails.Id); } }); terminalTab.addEventListener('hidden.bs.tab', () => { cleanupDetailsTerminal(); }); } // Terminal controls const terminalFontDecreaseBtn = document.getElementById('terminal-font-decrease'); const terminalFontIncreaseBtn = document.getElementById('terminal-font-increase'); const terminalFontResetBtn = document.getElementById('terminal-font-reset'); const terminalCopyBtn = document.getElementById('terminal-copy-btn'); const terminalClearBtn = document.getElementById('terminal-clear-btn'); const terminalThemeSelect = document.getElementById('terminal-theme-select'); const applyDetailsFont = (size) => { terminalFontSize = size; if (detailsTerminalSession?.xterm) { detailsTerminalSession.xterm.options.fontSize = size; detailsTerminalSession.fitController?.fitNow(); } updateTerminalFontSizeDisplay(); }; if (terminalFontDecreaseBtn) { terminalFontDecreaseBtn.addEventListener('click', () => { if (terminalFontSize > 8) applyDetailsFont(terminalFontSize - 1); }); } if (terminalFontIncreaseBtn) { terminalFontIncreaseBtn.addEventListener('click', () => { if (terminalFontSize < 28) applyDetailsFont(terminalFontSize + 1); }); } if (terminalFontResetBtn) { terminalFontResetBtn.addEventListener('click', () => applyDetailsFont(14)); } if (terminalCopyBtn) { terminalCopyBtn.addEventListener('click', () => { if (detailsTerminalSession && detailsTerminalSession.xterm) { const selection = detailsTerminalSession.xterm.getSelection(); if (selection) { copyToClipboard(selection, terminalCopyBtn); } else { showAlert('info', 'No text selected'); } } }); } if (terminalClearBtn) { terminalClearBtn.addEventListener('click', async () => { if (!detailsTerminalSession?.xterm) return; const ok = window.peardockOps?.askUserConfirm ? await window.peardockOps.askUserConfirm( 'Clear terminal?', 'Clear the visible terminal buffer for this session?', { confirmLabel: 'Clear', icon: 'fa-eraser' } ) : true; if (ok) detailsTerminalSession.xterm.clear(); }); } if (terminalThemeSelect) { terminalThemeSelect.value = terminalTheme; terminalThemeSelect.addEventListener('change', (e) => { applyTerminalTheme(e.target.value); }); } // Handle terminal output for details tab window.handleDetailsTerminalOutput = (data, containerId, encoding) => { if (detailsTerminalSession && detailsTerminalSession.containerId === containerId) { appendDetailsTerminalOutput(data, encoding); } }; }); /** * Confirmation helper — designed modal only (never native window.confirm). * Respects Settings → confirm destructive actions when opts.destructive !== false. * @param {string} message * @param {Function} onConfirm * @param {{ title?: string, requireText?: string, danger?: boolean, info?: boolean, confirmLabel?: string, destructive?: boolean }} [opts] */ function showConfirmModal(message, onConfirm, opts = {}) { const run = () => { if (typeof onConfirm === 'function') onConfirm(); }; const destructive = opts.destructive !== false && opts.info !== true; // Settings gate: skip dialog when user disabled destructive confirms if ( destructive && window.peardockOps?.shouldConfirmDestructive && !window.peardockOps.shouldConfirmDestructive() ) { run(); return; } const title = opts.title || (destructive ? 'Confirm' : 'Continue'); if (window.peardockOps?.confirmDialog) { window.peardockOps .confirmDialog({ title, body: message, danger: opts.danger ?? destructive, info: opts.info, requireText: opts.requireText, confirmLabel: opts.confirmLabel || (destructive ? 'Confirm' : 'Continue'), }) .then((ok) => { if (ok) run(); }) .catch(() => showBootstrapConfirm(message, onConfirm, opts)); return; } if (window.peardockOps?.askUserConfirm) { window.peardockOps .askUserConfirm(title, message, { danger: opts.danger ?? destructive, info: opts.info, confirmLabel: opts.confirmLabel, }) .then((ok) => { if (ok) run(); }) .catch(() => showBootstrapConfirm(message, onConfirm, opts)); return; } showBootstrapConfirm(message, onConfirm, opts); } function showBootstrapConfirm(message, onConfirm, opts = {}) { const modalEl = document.getElementById('confirmModal'); if (!modalEl || typeof bootstrap === 'undefined') { // Last resort: still avoid ugly defaults when possible console.warn('[WARN] confirm modal unavailable; action cancelled for safety'); return; } const modal = bootstrap.Modal.getOrCreateInstance(modalEl); const messageEl = document.getElementById('confirmModalMessage'); const titleEl = document.getElementById('confirmModalLabel'); const confirmBtn = document.getElementById('confirmModalBtn'); if (titleEl && opts.title) titleEl.textContent = opts.title; if (messageEl) messageEl.textContent = message; if (confirmBtn && confirmBtn.parentNode) { const newConfirmBtn = confirmBtn.cloneNode(true); confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn); if (opts.confirmLabel) newConfirmBtn.textContent = opts.confirmLabel; newConfirmBtn.className = opts.danger === false && opts.info ? 'btn btn-primary' : 'btn btn-danger'; newConfirmBtn.addEventListener('click', () => { modal.hide(); if (typeof onConfirm === 'function') onConfirm(); }); } modal.show(); } // Bulk Operations Functions function getSelectedContainers() { const checkboxes = document.querySelectorAll('.container-checkbox:checked'); const selected = Array.from(checkboxes).map(cb => { // Try dataset first, fallback to getAttribute for compatibility const id = cb.dataset.containerId || cb.getAttribute('data-container-id'); if (!id) { console.warn('[WARN] Checkbox missing container ID:', cb); } return id; }).filter(id => id); // Filter out any undefined/null values console.log('[DEBUG] getSelectedContainers - found', selected.length, 'selected containers'); return selected; } function getSelectedImages() { const checkboxes = document.querySelectorAll('.image-checkbox:checked'); return Array.from(checkboxes).map(cb => cb.dataset.imageId); } function updateBulkActionsToolbar() { const selected = getSelectedContainers(); const toolbar = document.getElementById('bulk-actions-toolbar'); const countEl = document.getElementById('selected-count'); if (toolbar && countEl) { if (selected.length > 0) { toolbar.style.display = 'block'; countEl.textContent = selected.length; } else { toolbar.style.display = 'none'; } } } function updateBulkActionsImagesToolbar() { const selected = getSelectedImages(); const toolbar = document.getElementById('bulk-actions-images-toolbar'); const countEl = document.getElementById('selected-images-count'); if (toolbar && countEl) { if (selected.length > 0) { toolbar.style.display = 'block'; countEl.textContent = selected.length; } else { toolbar.style.display = 'none'; } } } function toggleSelectAllContainers(checkbox) { const checkboxes = document.querySelectorAll('.container-checkbox'); checkboxes.forEach(cb => { cb.checked = checkbox.checked; }); updateBulkActionsToolbar(); } function toggleSelectAllImages(checkbox) { const checkboxes = document.querySelectorAll('.image-checkbox'); checkboxes.forEach(cb => { cb.checked = checkbox.checked; }); updateBulkActionsImagesToolbar(); } function clearContainerSelection() { document.querySelectorAll('.container-checkbox').forEach(cb => cb.checked = false); document.getElementById('select-all-containers').checked = false; updateBulkActionsToolbar(); } function clearImageSelection() { document.querySelectorAll('.image-checkbox').forEach(cb => cb.checked = false); document.getElementById('select-all-images').checked = false; updateBulkActionsImagesToolbar(); } async function bulkStartContainers() { const selected = getSelectedContainers(); console.log('[DEBUG] bulkStartContainers - selected containers:', selected); if (selected.length === 0) { console.warn('[WARN] No containers selected'); return; } await new Promise((resolve) => { showConfirmModal(`Start ${selected.length} container(s)?`, resolve); }); showStatusIndicator(`Starting ${selected.length} container(s)...`); try { sendCommand('bulkContainerOperation', { containerIds: selected, operation: 'start' }); const response = await waitForPeerResponse('Bulk operation completed'); const successCount = response.results.filter(r => r.success).length; const failCount = response.results.filter(r => !r.success).length; showAlert('success', `Started ${successCount} container(s)${failCount > 0 ? `, ${failCount} failed` : ''}`); clearContainerSelection(); setTimeout(() => sendCommand('listContainers'), 1000); } catch (error) { console.error('[ERROR] Bulk start failed:', error); showAlert('danger', error.message || 'Failed to start containers'); } finally { hideStatusIndicator(); } } async function bulkKillContainers() { const selected = getSelectedContainers(); if (selected.length === 0) return; let confirmed = false; await new Promise((resolve) => { showConfirmModal(`Kill ${selected.length} container(s)?`, () => { confirmed = true; resolve(); }); const modalEl = document.getElementById('confirmModal'); if (modalEl) { modalEl.addEventListener( 'hidden.bs.modal', () => { if (!confirmed) resolve(); }, { once: true } ); } }); if (!confirmed) return; showStatusIndicator(`Killing ${selected.length} container(s)...`); try { sendCommand('bulkContainerOperation', { containerIds: selected, operation: 'kill' }); const response = await waitForPeerResponse('Bulk operation completed'); const successCount = response.results.filter((r) => r.success).length; const failCount = response.results.filter((r) => !r.success).length; showAlert( 'success', `Killed ${successCount} container(s)${failCount > 0 ? `, ${failCount} failed` : ''}` ); clearContainerSelection(); setTimeout(() => sendCommand('listContainers'), 1000); } catch (error) { showAlert('danger', error.message || 'Failed to kill containers'); } finally { hideStatusIndicator(); } } window.bulkKillContainers = bulkKillContainers; async function bulkStopContainers() { const selected = getSelectedContainers(); console.log('[DEBUG] bulkStopContainers - selected containers:', selected); if (selected.length === 0) { console.warn('[WARN] No containers selected'); return; } let confirmed = false; await new Promise((resolve) => { showConfirmModal(`Stop ${selected.length} container(s)?`, () => { confirmed = true; resolve(); }); const modalEl = document.getElementById('confirmModal'); if (modalEl) { modalEl.addEventListener('hidden.bs.modal', () => { if (!confirmed) resolve(); }, { once: true }); } }); if (!confirmed) return; showStatusIndicator(`Stopping ${selected.length} container(s)...`); try { sendCommand('bulkContainerOperation', { containerIds: selected, operation: 'stop' }); const response = await waitForPeerResponse('Bulk operation completed'); const successCount = response.results.filter(r => r.success).length; const failCount = response.results.filter(r => !r.success).length; showAlert('success', `Stopped ${successCount} container(s)${failCount > 0 ? `, ${failCount} failed` : ''}`); clearContainerSelection(); setTimeout(() => sendCommand('listContainers'), 1000); } catch (error) { console.error('[ERROR] Bulk stop failed:', error); showAlert('danger', error.message || 'Failed to stop containers'); } finally { hideStatusIndicator(); } } async function bulkRemoveContainers() { const selected = getSelectedContainers(); console.log('[DEBUG] bulkRemoveContainers - selected containers:', selected); if (selected.length === 0) { console.warn('[WARN] No containers selected'); return; } let confirmed = false; await new Promise((resolve) => { showConfirmModal(`Remove ${selected.length} container(s)? This action cannot be undone.`, () => { confirmed = true; resolve(); }); const modalEl = document.getElementById('confirmModal'); if (modalEl) { modalEl.addEventListener('hidden.bs.modal', () => { if (!confirmed) resolve(); }, { once: true }); } }); if (!confirmed) return; showStatusIndicator(`Removing ${selected.length} container(s)...`); try { sendCommand('bulkContainerOperation', { containerIds: selected, operation: 'remove' }); const response = await waitForPeerResponse('Bulk operation completed'); const successCount = response.results.filter(r => r.success).length; const failCount = response.results.filter(r => !r.success).length; showAlert('success', `Removed ${successCount} container(s)${failCount > 0 ? `, ${failCount} failed` : ''}`); clearContainerSelection(); setTimeout(() => sendCommand('listContainers'), 1000); } catch (error) { console.error('[ERROR] Bulk remove failed:', error); showAlert('danger', error.message || 'Failed to remove containers'); } finally { hideStatusIndicator(); } } // Exec terminal function - uses regular terminal infrastructure function startExecTerminal(containerId, execId) { if (!window.activePeer) { console.error('[ERROR] No active peer connection.'); return; } // Reuse terminal infrastructure for exec // The server already handles exec output streaming via execOutput/execErrorOutput startTerminal(containerId, `Exec: ${containerId.substring(0, 12)}`); // Store exec ID for input handling if (window.openTerminals[containerId]) { window.openTerminals[containerId].execId = execId; } } async function bulkRemoveImages() { const selected = getSelectedImages(); if (selected.length === 0) return; let confirmed = false; await new Promise((resolve) => { showConfirmModal(`Remove ${selected.length} image(s)? This action cannot be undone.`, () => { confirmed = true; resolve(); }); const modalEl = document.getElementById('confirmModal'); if (modalEl) { modalEl.addEventListener('hidden.bs.modal', () => { if (!confirmed) resolve(); }, { once: true }); } }); if (!confirmed) return; showStatusIndicator(`Removing ${selected.length} image(s)...`); let completed = 0; let failed = 0; for (const imageId of selected) { try { sendCommand('removeImage', { id: imageId, force: true }); await new Promise(resolve => setTimeout(resolve, 500)); completed++; } catch (error) { failed++; console.error(`Failed to remove image ${imageId}:`, error); } } hideStatusIndicator(); showAlert('success', `Removed ${completed} image(s)${failed > 0 ? `, ${failed} failed` : ''}`); clearImageSelection(); setTimeout(() => loadImages(), 1000); } // Deploy View Functions let deployViewTemplates = []; /** @type {{ sources?: number, unique?: number, duplicates?: number, fetched?: number }|null} */ let deployViewTemplateStats = null; let deployTemplateSearchSetup = false; const DEPLOY_SEARCH_DEBOUNCE_MS = 280; function clearDeployTemplateCache() { deployViewTemplates = []; deployViewTemplateStats = null; clearMergedTemplateCache(); } window.__peardockClearDeployTemplateCache = clearDeployTemplateCache; /** * @param {object[]} templates * @param {object} [stats] */ function setDeployTemplates(templates, stats) { deployViewTemplates = Array.isArray(templates) ? templates : []; deployViewTemplateStats = stats || null; // Keep modal catalog in sync if open path uses fetchTemplates separately try { fetchTemplates(); } catch { // ignore } if (typeof currentView !== 'undefined' && currentView === 'deploy') { const searchInput = document.getElementById('deploy-template-search-input'); const query = searchInput?.value || ''; displayDeployTemplateList(filterTemplatesByQuery(deployViewTemplates, query)); updateDeployTemplateCount(query); } } window.__peardockSetDeployTemplates = setDeployTemplates; function updateDeployTemplateCount(query = '') { const countEl = document.getElementById('deploy-template-count'); if (!countEl) return; const total = deployViewTemplates.length; const q = String(query || '').trim(); const filtered = q ? filterTemplatesByQuery(deployViewTemplates, q).length : total; const st = deployViewTemplateStats; let base = q ? `${filtered} of ${total} templates` : `${total} templates`; if (st?.sources != null) { base += ` · ${st.sources} list${st.sources === 1 ? '' : 's'}`; } if (st?.duplicates) { base += ` · ${st.duplicates} dupes removed`; } countEl.textContent = base; } function setupDeployTemplateSearch() { const searchInput = document.getElementById('deploy-template-search-input'); if (!searchInput || deployTemplateSearchSetup) return; deployTemplateSearchSetup = true; const runFilter = debounce(() => { const query = searchInput.value; const filtered = filterTemplatesByQuery(deployViewTemplates, query); displayDeployTemplateList(filtered); updateDeployTemplateCount(query); }, DEPLOY_SEARCH_DEBOUNCE_MS); searchInput.addEventListener('input', runFilter); searchInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') { searchInput.value = ''; runFilter(); searchInput.blur(); } }); } async function loadDeployView() { const templateListContainer = document.getElementById('deploy-template-list-container'); if (!templateListContainer) return; initTemplateDeployer(); setupDeployTemplateSearch(); setupDeployViewFormHandler(); setupDeployResourceSliders(); // Network mode change handler const networkMode = document.getElementById('deploy-network-mode'); const customNetworkContainer = document.getElementById('deploy-custom-network-container'); if (networkMode && customNetworkContainer && !networkMode.dataset.bound) { networkMode.dataset.bound = '1'; networkMode.addEventListener('change', (e) => { if (e.target.value === 'container') { customNetworkContainer.style.display = 'block'; const input = customNetworkContainer.querySelector('input'); if (input) input.placeholder = 'container-name'; } else if (e.target.value !== 'host' && e.target.value !== 'none' && e.target.value !== 'bridge') { customNetworkContainer.style.display = 'block'; const input = customNetworkContainer.querySelector('input'); if (input) input.placeholder = 'network-name'; } else { customNetworkContainer.style.display = 'none'; } }); } // Show loading state only when we don't have cache if (!deployViewTemplates.length) { const n = getTemplateListUrls().length; templateListContainer.innerHTML = `
Loading templates from ${n} list${n === 1 ? '' : 's'}…
`; } try { if (!deployViewTemplates.length) { const result = await fetchMergedTemplates(getTemplateListUrls()); deployViewTemplates = result.templates || []; deployViewTemplateStats = result.stats || null; // Sync modal list from the same merged set (avoid second multi-fetch when possible) try { await fetchTemplates(); } catch { // ignore modal fetch failures } } const searchInput = document.getElementById('deploy-template-search-input'); const query = searchInput?.value || ''; displayDeployTemplateList(filterTemplatesByQuery(deployViewTemplates, query)); updateDeployTemplateCount(query); } catch (error) { console.error('[ERROR] Failed to fetch templates:', error.message); templateListContainer.innerHTML = '
Failed to load templates. Check network access and Settings → Deploy template lists.
'; } } function displayDeployTemplateList(templates) { const templateListContainer = document.getElementById('deploy-template-list-container'); if (!templateListContainer) return; if (!templates || templates.length === 0) { templateListContainer.innerHTML = '
No templates match your search
'; return; } const list = document.createElement('ul'); list.className = 'list-group deploy-template-list'; list.id = 'deploy-template-list-ul'; templates.forEach((template) => { const listItem = document.createElement('li'); listItem.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center bg-dark text-white border-secondary'; listItem.style.cursor = 'pointer'; const title = template.title || template.name || 'Untitled'; const desc = template.description || 'No description'; const logo = template.logo ? `` : ''; listItem.innerHTML = `
${logo}
${escapeHtmlLite(title)}
${escapeHtmlLite(desc)}
`; const activate = (e) => { e?.stopPropagation?.(); selectTemplateForDeploy(template); }; listItem.querySelector('.deploy-template-btn-view')?.addEventListener('click', activate); listItem.addEventListener('click', activate); list.appendChild(listItem); }); templateListContainer.innerHTML = ''; templateListContainer.appendChild(list); } function escapeHtmlLite(s) { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function setupDeployResourceSliders() { const pairs = [ ['deploy-cpu-limit', 'deploy-cpu-limit-val', (v) => (Number(v) <= 0 ? 'Unlimited' : `${v} cores`)], ['deploy-cpu-reservation', 'deploy-cpu-reservation-val', (v) => (Number(v) <= 0 ? 'None' : `${v} cores`)], ['deploy-memory-limit', 'deploy-memory-limit-val', (v) => (Number(v) <= 0 ? 'Unlimited' : `${v} MB`)], ['deploy-memory-reservation', 'deploy-memory-reservation-val', (v) => (Number(v) <= 0 ? 'None' : `${v} MB`)], ['duplicate-cpu-limit', 'duplicate-cpu-limit-val', (v) => (Number(v) <= 0 ? 'Unlimited' : `${v} cores`)], ['duplicate-memory-limit', 'duplicate-memory-limit-val', (v) => (Number(v) <= 0 ? 'Unlimited' : `${v} MB`)], ]; for (const [inputId, labelId, fmt] of pairs) { const input = document.getElementById(inputId); const label = document.getElementById(labelId); if (!input || input.dataset.sliderBound) continue; input.dataset.sliderBound = '1'; const sync = () => { if (label) label.textContent = fmt(input.value); }; input.addEventListener('input', sync); sync(); } } window.setupDeployResourceSliders = setupDeployResourceSliders; function selectTemplateForDeploy(template) { if (!template || typeof template !== 'object') { showAlert('danger', 'Invalid template'); return; } const formSection = document.getElementById('deploy-form-section'); if (formSection) { formSection.style.display = 'block'; formSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } initTemplateDeployer(); setupDeployViewFormHandler(); setupDeployResourceSliders(); const form = document.getElementById('deploy-view-form'); if (form) form.reset(); [ 'deploy-ports-container', 'deploy-volumes-container', 'deploy-env', 'deploy-labels-container', 'deploy-dns-container', 'deploy-extra-hosts-container', 'deploy-devices-container', 'deploy-capabilities-container', 'deploy-security-opts-container', 'deploy-log-opts-container', 'deploy-sysctls-container', 'deploy-ulimits-container', 'deploy-tmpfs-container', ].forEach((id) => { const container = document.getElementById(id); if (container) container.innerHTML = ''; }); // Suggested container name from template title const nameEl = document.getElementById('deploy-container-name'); if (nameEl) { const base = String(template.title || template.name || template.image || 'app') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 40) || 'app'; nameEl.value = base; } const deployImage = document.getElementById('deploy-image'); if (deployImage && template.image) deployImage.value = String(template.image); if (template.command) { const cmdEl = document.getElementById('deploy-command'); if (cmdEl) { cmdEl.value = Array.isArray(template.command) ? template.command.join(' ') : String(template.command); } } if (template.restart_policy) { const rp = document.getElementById('deploy-restart-policy'); if (rp) rp.value = String(template.restart_policy); } else { const rp = document.getElementById('deploy-restart-policy'); if (rp) rp.value = 'unless-stopped'; } if (template.interactive) { const tty = document.getElementById('deploy-tty'); const stdin = document.getElementById('deploy-stdin-open'); if (tty) tty.checked = true; if (stdin) stdin.checked = true; } // Ports if (Array.isArray(template.ports) && typeof window.addPortMapping === 'function') { template.ports.forEach((port) => { if (port == null) return; let portValue; if (typeof port === 'string' || typeof port === 'number') { portValue = String(port); } else if (typeof port === 'object') { const containerPort = port.container || port.target || port.containerPort; if (!containerPort) return; const protocol = port.protocol || 'tcp'; const hostPort = port.host || port.published || port.hostPort || ''; portValue = hostPort ? `${hostPort}:${containerPort}/${protocol}` : `${containerPort}/${protocol}`; } else { return; } window.addPortMapping(portValue); }); } // Volumes — support bind/container object and string formats if (Array.isArray(template.volumes) && typeof window.addVolumeMount === 'function') { template.volumes.forEach((volume) => { if (typeof volume === 'string') { window.addVolumeMount(volume); return; } if (volume && typeof volume === 'object') { const host = volume.bind || volume.host || volume.source || ''; const container = volume.container || volume.target || volume.containerPath || ''; const mode = volume.mode || (volume.read_only ? 'ro' : ''); if (container) { const str = host ? `${host}:${container}${mode ? ':' + mode : ''}` : container; window.addVolumeMount(str); } else { window.addVolumeMount(); } } }); } // Env — use template-aware addEnvVar when possible if (Array.isArray(template.env) && typeof window.addEnvVar === 'function') { template.env.forEach((env) => { if (!env) return; // Portainer-style template env: { name, label, default, set, preset } if (typeof env === 'object' && env.name) { window.addEnvVar(env); } else if (typeof env === 'string') { window.addEnvVar(); const container = document.getElementById('deploy-env'); const last = container?.lastElementChild; const keyInput = last?.querySelector('[data-env-key]'); const valInput = last?.querySelector('[data-env-value]'); const i = env.indexOf('='); if (keyInput) keyInput.value = i >= 0 ? env.slice(0, i) : env; if (valInput && i >= 0) valInput.value = env.slice(i + 1); } }); } // Labels if (template.labels && typeof window.addLabel === 'function') { const entries = Array.isArray(template.labels) ? template.labels : Object.entries(template.labels).map(([k, v]) => `${k}=${v}`); entries.forEach((label) => { window.addLabel(); const container = document.getElementById('deploy-labels-container'); const last = container?.lastElementChild?.querySelector('input'); if (last) last.value = typeof label === 'string' ? label : `${label}`; }); } if (typeof window.updatePreview === 'function') window.updatePreview(); showAlert('info', `Loaded template: ${template.title || template.name || template.image || 'selected'}`); } function resetDeployView() { const formSection = document.getElementById('deploy-form-section'); if (formSection) { formSection.style.display = 'none'; } const viewForm = document.getElementById('deploy-view-form'); if (viewForm) { viewForm.reset(); // Clear all array containers (same as modal reset) ['deploy-ports-container', 'deploy-volumes-container', 'deploy-env', 'deploy-labels-container', 'deploy-dns-container', 'deploy-extra-hosts-container', 'deploy-devices-container', 'deploy-capabilities-container', 'deploy-security-opts-container', 'deploy-log-opts-container', 'deploy-sysctls-container', 'deploy-ulimits-container', 'deploy-tmpfs-container'].forEach(id => { const container = document.getElementById(id); if (container) container.innerHTML = ''; }); } const searchInput = document.getElementById('deploy-template-search-input'); if (searchInput) { searchInput.value = ''; // Re-display all templates if (deployViewTemplates.length > 0) { displayDeployTemplateList(deployViewTemplates); } } } // Set up deploy view form submit handler let deployViewFormHandlerSetup = false; function setupDeployViewFormHandler() { const deployViewForm = document.getElementById('deploy-view-form'); if (!deployViewForm) { console.warn('[WARN] Deploy view form not found, will retry when form is visible'); return; } // Check if handler is already attached by looking for a data attribute if (deployViewForm.dataset.handlerAttached === 'true') { console.log('[INFO] Form handler already attached'); return; } console.log('[INFO] Setting up deploy view form handler'); attachFormHandler(deployViewForm); deployViewForm.dataset.handlerAttached = 'true'; deployViewFormHandlerSetup = true; } function attachFormHandler(form) { if (!form) return; form.addEventListener('submit', async (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); console.log('[INFO] Deploy view form submitted'); const collect = typeof window.collectFormData === 'function' ? window.collectFormData : null; const validate = typeof window.validateFormData === 'function' ? window.validateFormData : null; const deploy = typeof window.deployDockerContainer === 'function' ? window.deployDockerContainer : null; if (!collect || !deploy) { showAlert('danger', 'Form collection function not available. Please refresh the page.'); return false; } let formData; try { formData = collect(); } catch (collectError) { console.error('[ERROR] Failed to collect form data:', collectError); showAlert('danger', 'Failed to collect form data. Check console for details.'); return false; } // Normalize slider zeros → omit (unlimited) if (formData.cpuLimit === 0) delete formData.cpuLimit; if (formData.cpuReservation === 0) delete formData.cpuReservation; if (formData.memoryLimit === 0) delete formData.memoryLimit; if (formData.memoryReservation === 0) delete formData.memoryReservation; // Validate if (validate) { try { const errors = validate(formData); if (errors.length > 0) { showAlert('danger', errors.join(' ')); return false; } } catch (validateError) { console.error('[ERROR] Validation error:', validateError); } } if (!formData.containerName || !formData.image) { showAlert('danger', 'Container name and image are required.'); return false; } const containerName = formData.containerName; // Live job drawer handles progress — no top toast / tray spam try { const successResponse = await deploy(formData); if (successResponse && successResponse.success !== false) { // Job tray already shows success; only toast when deploy had no live log UI if (!successResponse.viaJob) { showAlert('success', successResponse.message || 'Container deployed successfully!'); } resetDeployView(); if (window.sendCommand) { window.sendCommand('listContainers'); setTimeout(() => navigateToNewContainer(containerName), 800); } } else { throw new Error(successResponse?.error || successResponse?.message || 'Deployment failed'); } } catch (error) { console.error('[ERROR] Failed to deploy container:', error); // Job drawer already has multi-line error + how-to-fix; skip top toast if (!error?.viaJob) { presentError(error, 'deployContainer', { showAlert }); } } return false; }); console.log('[INFO] Deploy view form handler attached'); } function navigateToNewContainer(containerName) { console.log(`[INFO] Looking for newly created container: ${containerName}`); // Set up a handler to catch the container list response and find the new container const originalHandler = window.handlePeerResponse; let containerFound = false; const findContainerHandler = (response) => { if (response.type === 'containers' && response.data && !containerFound) { const newContainer = response.data.find(c => { const name = c.Names?.[0]?.replace(/^\//, '') || ''; return name === containerName; }); if (newContainer) { console.log(`[INFO] Found new container, navigating to details: ${containerName}`); containerFound = true; // Restore original handler first window.handlePeerResponse = originalHandler; // Navigate to container details if (typeof showContainerDetails === 'function') { showContainerDetails(newContainer); } else { console.error('[ERROR] showContainerDetails function not available'); } return; } } // Not the response we're looking for, pass to original handler if (typeof originalHandler === 'function') { originalHandler(response); } }; window.handlePeerResponse = findContainerHandler; // Request container list if (window.activePeer && typeof sendCommand === 'function') { sendCommand('listContainers'); } // Timeout after 10 seconds setTimeout(() => { if (window.handlePeerResponse === findContainerHandler && !containerFound) { window.handlePeerResponse = originalHandler; console.warn('[WARN] Timeout waiting for new container to appear. Navigating to containers view.'); navigateToView('containers'); } }, 10000); } // Expose to window window.pullImage = pullImage; // Hub search Enter key in pull modal document.getElementById('hub-search-term')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); searchDockerHub(); } }); window.createNetwork = createNetwork; window.createVolume = createVolume; window.showContainerDetails = showContainerDetails; window.resetDeployView = resetDeployView; window.navigateToNewContainer = navigateToNewContainer; window.updateBulkActionsToolbar = updateBulkActionsToolbar; window.updateBulkActionsImagesToolbar = updateBulkActionsImagesToolbar; window.toggleSelectAllContainers = toggleSelectAllContainers; window.toggleSelectAllImages = toggleSelectAllImages; window.clearContainerSelection = clearContainerSelection; window.clearImageSelection = clearImageSelection; window.bulkStartContainers = bulkStartContainers; window.bulkStopContainers = bulkStopContainers; window.bulkRemoveContainers = bulkRemoveContainers; window.bulkRemoveImages = bulkRemoveImages; window.filterImages = filterImages; // Collapse Sidebar Functionality - set up in DOMContentLoaded function handleRpcMessage(response, conn) { const topicId = conn?.id || manager.activeId; const peer = conn || manager.active; try { console.log(`[DEBUG] RPC message (${topicId}):`, response); if (response && response.message) console.log(response.message) // Handle errors first - check for error responses before processing if (response.error) { const errorMessage = handleErrorResponse(response); // Error has been sent to notification center, but continue processing // in case there are other handlers that need to see the error } if (response.success && response.message && typeof response.message === 'string' && response.message.includes('deployed successfully')) { console.log(`[INFO] Template deployed successfully: ${response.message}`); closeAllModals(); // Close all modals after successful deployment hideStatusIndicator(); startStatsInterval(); // Restart stats polling showAlert('success', response.message); hideStatusIndicator(); } // Ensure the data is for a known connection (pushes always have a conn id) if (topicId && !connections[topicId] && peer) { // Still process if this is the active manager connection if (manager.active && peer.id !== manager.active.id) { console.warn(`[WARN] No connection found for topic: ${topicId}. Ignoring data.`); return; } } if ( topicId && connections[topicId]?.peer && peer && peer !== connections[topicId].peer && peer.id !== connections[topicId].peer.id ) { console.warn(`[WARN] Ignoring data from a non-active peer for topic: ${topicId}`); return; } // Delegate handling based on the response type switch (response.type) { case 'allStats': console.log('[INFO] Received aggregated stats for all containers.'); response.data.forEach((stats) => updateContainerStats(stats)); break; case 'containers': console.log('[INFO] Processing container list...'); renderContainers(response.data, topicId); // Render containers specific to this topic // Update dashboard stats if on dashboard view if (currentView === 'dashboard') { updateDashboardStats(response.data, null, null); } break; case 'terminalOutput': case 'terminalErrorOutput': appendTerminalOutput(response.data, response.containerId, response.encoding); if (window.handleDetailsTerminalOutput) { window.handleDetailsTerminalOutput(response.data, response.containerId, response.encoding); } break; case 'execOutput': console.log('[INFO] Appending exec output...'); appendTerminalOutput(response.data, response.containerId, response.encoding); break; case 'execErrorOutput': console.log('[INFO] Appending exec error output...'); appendTerminalOutput(response.data, response.containerId, response.encoding); break; case 'stacks': console.log('[INFO] Handling stacks list...'); window.currentStacksData = response.data; renderStacks(response.data); // Check if we have a pending stack inspect if (window.pendingStackInspect) { const { stackName } = window.pendingStackInspect; delete window.pendingStackInspect; hideStatusIndicator(); const stack = response.data.find(s => s.name === stackName); if (stack) { formatAndPopulateStackModal(stack); const modalTitle = document.getElementById('stackInspectModalLabel'); if (modalTitle) { modalTitle.innerHTML = `Stack Information: ${stackName}`; } const modal = new bootstrap.Modal(document.getElementById('stackInspectModal')); modal.show(); document.getElementById('stack-inspect-formatted-view').style.display = 'block'; document.getElementById('stack-inspect-json-view').style.display = 'none'; } } break; case 'containerConfig': console.log('[INFO] Handling container configuration...'); if (window.inspectContainerCallback) { window.inspectContainerCallback(response.data); window.inspectContainerCallback = null; // Reset the callback } break; case 'logs': console.log('[INFO] Handling logs output...'); if (window.handleLogOutput) { window.handleLogOutput(response); } break; case 'systemInfo': console.log('[INFO] Handling system information...'); updateSystemInfo(response.data); break; case 'images': console.log('[INFO] Handling images list...'); renderImages(response.data); // Update dashboard stats if on dashboard view if (currentView === 'dashboard') { updateDashboardStats(null, response.data, null); } break; case 'networks': console.log('[INFO] Handling networks list...'); renderNetworks(response.data); // Update dashboard stats if on dashboard view if (currentView === 'dashboard') { updateDashboardStats(null, null, response.data); } break; case 'volumes': console.log('[INFO] Handling volumes list...'); // Store in cache and render let volumesToRender = null; if (response.data && Array.isArray(response.data)) { volumesToRender = response.data; } else if (response.volumes && Array.isArray(response.volumes)) { // Fallback for old format volumesToRender = response.volumes; } if (volumesToRender !== null) { // Always update store first (this will trigger subscriptions) volumesStore.set(volumesToRender); // Always render if on volumes view to ensure UI is updated if (currentView === 'volumes') { renderVolumes(volumesToRender); } } else { console.warn('[WARN] Received volumes message but no valid volumes data found'); } break; case 'pullProgress': { const pct = pullProgressPercent(response); const label = response.image || 'image'; const msg = [response.status, response.progress].filter(Boolean).join(' '); if (pct != null) { updateProgressBar('pull-image', pct, msg || `Pulling ${label}`); updateStatusIndicator(`Pulling ${label}: ${pct}%`); } else if (msg) { updateStatusIndicator(`Pull: ${msg}`); } break; } case 'buildProgress': { const line = (response.stream || response.status || response.error || '').trim(); if (line) { updateStatusIndicator(`Build: ${line.slice(0, 120)}`); // Append to build output area if present const out = document.getElementById('build-output') || document.getElementById('buildImageOutput'); if (out) { out.textContent = (out.textContent || '') + line; out.scrollTop = out.scrollHeight; } } break; } case 'dockerEvent': { if (window.handleDockerEvent) { window.handleDockerEvent(response.data); } if (window.peardockOps?.appendLiveEvent) { window.peardockOps.appendLiveEvent(response.data || response); } else if (typeof window !== 'undefined') { import('./ui/ops-app.js').then((m) => m.appendLiveEvent?.(response.data || response)).catch(() => {}); } break; } case 'systemDf': renderSystemDf(response.data); break; case 'containerTop': renderContainerTop(response); if (window.pendingRpcCallback) { window.pendingRpcCallback(response); window.pendingRpcCallback = null; } break; case 'imageHistory': { if (response.data) { populateImageHistoryFromApi(response.data); } if (window.pendingRpcCallback) { window.pendingRpcCallback(response); window.pendingRpcCallback = null; } break; } case 'containerStats': case 'imageSearch': case 'stackPs': case 'stackLogs': if (window.pendingRpcCallback) { window.pendingRpcCallback(response); window.pendingRpcCallback = null; } break; case 'imageConfig': console.log(`[INFO] Handling imageConfig...`); if (response.data && window.pendingImageInspect) { const { imageId } = window.pendingImageInspect; delete window.pendingImageInspect; hideStatusIndicator(); const modalTitle = document.getElementById('imageInspectModalLabel'); if (modalTitle) { const repoTag = response.data.RepoTags?.[0] || imageId.substring(0, 12); modalTitle.innerHTML = `Image Information: ${repoTag}`; } formatAndPopulateImageModal(response.data); window.currentImageInspectConfig = response.data; const modal = new bootstrap.Modal(document.getElementById('imageInspectModal')); modal.show(); document.getElementById('image-inspect-formatted-view').style.display = 'block'; document.getElementById('image-inspect-json-view').style.display = 'none'; } break; case 'networkConfig': console.log(`[INFO] Handling networkConfig...`); if (response.data && window.pendingNetworkInspect) { const { networkId } = window.pendingNetworkInspect; delete window.pendingNetworkInspect; hideStatusIndicator(); const modalTitle = document.getElementById('networkInspectModalLabel'); if (modalTitle) { const networkName = response.data.Name || networkId.substring(0, 12); modalTitle.innerHTML = `Network Information: ${networkName}`; } formatAndPopulateNetworkModal(response.data); window.currentNetworkInspectConfig = response.data; const modal = new bootstrap.Modal(document.getElementById('networkInspectModal')); modal.show(); document.getElementById('network-inspect-formatted-view').style.display = 'block'; document.getElementById('network-inspect-json-view').style.display = 'none'; } break; case 'volumeConfig': console.log(`[INFO] Handling volumeConfig...`); if (response.data && window.pendingVolumeInspect) { const { volumeName } = window.pendingVolumeInspect; delete window.pendingVolumeInspect; hideStatusIndicator(); const modalTitle = document.getElementById('volumeInspectModalLabel'); if (modalTitle) { modalTitle.innerHTML = `Volume Information: ${volumeName}`; } formatAndPopulateVolumeModal(response.data); window.currentVolumeInspectConfig = response.data; const modal = new bootstrap.Modal(document.getElementById('volumeInspectModal')); modal.show(); document.getElementById('volume-inspect-formatted-view').style.display = 'block'; document.getElementById('volume-inspect-json-view').style.display = 'none'; } break; default: // Success-only RPC replies (start/stop/etc.) have no type — that is normal if (response.success && Array.isArray(response.contents) && response.path !== undefined) { // directory browser } else if (response.success && Array.isArray(response.volumes)) { // volumes list without type } else if (response.success || response.error) { // command acknowledgement } else if (response.type) { console.warn(`[WARN] Unhandled response type: ${response.type}`); } break; } // Handle volumes responses - update cache and route to handlers if needed // Check for volumes in response (both new format with type and old format) // Note: This is a fallback for responses that weren't handled in the switch statement above // The 'volumes' case in the switch should have already handled type: 'volumes' responses let volumesArray = null; if (response && response.type !== 'volumes' && response.success === true && Array.isArray(response.volumes)) { // Old format volumes response that wasn't caught by switch volumesArray = response.volumes; } if (volumesArray !== null) { // Always update the cache first (this will trigger subscriptions) volumesStore.set(volumesArray); // Render if on volumes view if (currentView === 'volumes') { renderVolumes(volumesArray); } // Route to active volume selectors if they exist (for deploy modal) if (window.activeVolumeHandlers && window.activeVolumeHandlers.size > 0) { for (const [volumeId, handlerInfo] of window.activeVolumeHandlers.entries()) { const state = handlerInfo?.state; // Process if not received yet if (state && !state.volumesReceived) { if (handlerInfo.protectedHandler && typeof handlerInfo.protectedHandler === 'function') { handlerInfo.protectedHandler(response); } else if (handlerInfo.handler && typeof handlerInfo.handler === 'function') { handlerInfo.handler(response); } } } } } // Handle peer response callback if defined // This allows custom handlers (like directory browser) to process responses if (typeof window.handlePeerResponse === 'function') { try { window.handlePeerResponse(response); } catch (handlerErr) { // Never let a stale temporary handler break listVolumes / dashboard loads console.warn( '[WARN] handlePeerResponse failed:', handlerErr?.message || handlerErr, response?.type || response ); } } } catch (err) { console.error(`[ERROR] Failed to process RPC message: ${err.message}`, response); // Avoid alarming the user for non-critical handler bugs on successful lists if (!(response && response.success && response.type)) { showAlert('danger', 'Failed to process server message. Check the console for details.'); } } } // Add a new connection - event listener set up in DOMContentLoaded async function addConnection(publicKeyHex, meta = {}) { console.log(`[DEBUG] Adding connection with public key: ${publicKeyHex}`); publicKeyHex = (publicKeyHex || '').trim().toLowerCase(); if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) { showAlert('danger', 'Invalid server public key. Expected 64 hex characters.'); return; } // 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, inviteToken, connectedAt: null, lastHealthCheck: null, latency: null, healthStatus: 'connecting', }; saveConnections(); // Ensure a single list item per peer (restores / retries) let connectionItem = connectionList?.querySelector?.(`[data-topic-id="${topicId}"]`); if (!connectionItem) { connectionItem = createPeerListItem(topicId, connections[topicId]); connectionList?.appendChild(connectionItem); updatePeersViewChrome(); } else { updateConnectionDisplay(topicId); updateConnectionStatus(topicId, false); } refreshContainerStats(); try { if (!meta.quiet) showStatusIndicator('Connecting…'); const conn = await manager.connect(publicKeyHex, { 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; // Prefer server-provided alias if we did not set one if (!connections[topicId].alias && conn.alias) { connections[topicId].alias = conn.alias; } saveConnections(); updateConnectionStatus(topicId, true); updateConnectionDisplay(topicId); startHealthMonitoring(topicId); manager.setActive(conn.id); switchConnection(topicId); startStatsInterval(); warmSnapshot(); hideWelcomePage(); applyRoleUI(); if (!meta.quiet) { hideStatusIndicator(); showAlert('success', `Connected to ${peerDisplayName(connections[topicId], topicId)}`); showFirstConnectChecklist(); } } 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); updateConnectionDisplay(topicId); 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(); } } } /** * 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 function openTemplateDeployModal(topicId) { // Pass the topic ID or other connection-specific info if needed console.log(`[INFO] Preparing template deploy modal for topic: ${topicId}`); // Ensure the modal fetches templates fetchTemplates(); // Refresh template list // Show the modal const templateDeployModal = new bootstrap.Modal(document.getElementById('templateDeployModal')); templateDeployModal.show(); } // Initialize connections from cookies on page load document.addEventListener('DOMContentLoaded', () => { // Initialize DOM elements immediately containerList = document.getElementById('container-list'); connectionList = document.getElementById('connection-list'); addConnectionForm = document.getElementById('add-connection-form'); newConnectionTopic = document.getElementById('new-connection-topic'); connectionTitle = document.getElementById('connection-title'); dashboard = document.getElementById('dashboard-view') || document.getElementById('dashboard'); welcomePage = document.getElementById('welcome-page'); sidebar = document.getElementById('sidebar'); collapseSidebarBtn = document.getElementById('collapse-sidebar-btn'); alertContainer = document.getElementById('alert-container'); // Always start on welcome until a peer is connected showWelcomePage(); // Initialize modal elements duplicateModalElement = document.getElementById('duplicateModal'); if (duplicateModalElement && typeof bootstrap !== 'undefined') { duplicateModal = new bootstrap.Modal(duplicateModalElement); } duplicateContainerForm = document.getElementById('duplicate-container-form'); hideStatusIndicator(); // Initialize notification tray early - available globally regardless of connection status initNotificationTray(); // Set up deploy view form handler on DOMContentLoaded setTimeout(() => { setupDeployViewFormHandler(); setupDeployStackHandler(); setupBuildImageHandler(); // Also add a direct button click handler as fallback const deployButton = document.querySelector('#deploy-view-form button[type="submit"]'); if (deployButton) { deployButton.addEventListener('click', (e) => { e.preventDefault(); const form = document.getElementById('deploy-view-form'); if (form) { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); } }); } }, 500); // Add peer modal: sidebar quick-add / Peers view / welcome document.getElementById('open-add-connection-btn')?.addEventListener('click', () => { openAddConnectionModal(); }); document.getElementById('peers-view-add-btn')?.addEventListener('click', () => { openAddConnectionModal(); }); document.getElementById('peers-view-refresh-btn')?.addEventListener('click', () => { loadPeersView(); }); document.getElementById('welcome-add-peer-btn')?.addEventListener('click', () => { openAddConnectionModal(); }); // Set up event listeners that depend on DOM elements if (addConnectionForm) { addConnectionForm.addEventListener('submit', (e) => { e.preventDefault(); 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, 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(); } } }); } // Track B ops shell (command palette, smart network, host/events/settings, jobs) initOpsApp({ navigateToView, sendCommand, }); // Prefer smart network modal for create buttons document.querySelectorAll('[data-bs-target="#createNetworkModal"]').forEach((btn) => { btn.setAttribute('data-bs-target', '#createNetworkSmartModal'); btn.addEventListener('click', (e) => { e.preventDefault(); window.peardockOps?.openSmartNetworkModal?.(); }); }); // Browser history + deep links initHashRouting(); const hashView = (location.hash || '').replace(/^#\/?/, '').split('?')[0]; if (hashView && hasActiveConnection()) { navigateToView(hashView, { replace: true, fromHistory: true }); } // List search inputs (images / networks / volumes) document.getElementById('image-search')?.addEventListener('input', () => { if (typeof allImages !== 'undefined') renderImages(allImages); }); document.getElementById('network-search')?.addEventListener('input', () => { if (typeof allNetworks !== 'undefined') renderNetworks(allNetworks); }); document.getElementById('volume-search')?.addEventListener('input', () => { if (typeof allVolumesCache !== 'undefined') renderVolumes(allVolumesCache); }); document.getElementById('stack-search')?.addEventListener('input', () => { if (typeof allStacksCache !== 'undefined') renderStacks(allStacksCache); }); applyRoleUI(); // Set up sidebar collapse functionality if (collapseSidebarBtn) { const syncSidebarCollapseUi = () => { const collapsed = sidebar?.classList.contains('collapsed'); collapseSidebarBtn.innerHTML = collapsed ? '' : ''; collapseSidebarBtn.title = collapsed ? 'Expand sidebar' : 'Collapse sidebar'; collapseSidebarBtn.setAttribute( 'aria-label', collapsed ? 'Expand sidebar' : 'Collapse sidebar' ); collapseSidebarBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); }; collapseSidebarBtn.addEventListener('click', () => { if (sidebar) { sidebar.classList.toggle('collapsed'); try { localStorage.setItem( 'peardock.sidebar.collapsed', sidebar.classList.contains('collapsed') ? '1' : '0' ); } catch { // ignore } syncSidebarCollapseUi(); } }); // Restore last collapsed state try { if (localStorage.getItem('peardock.sidebar.collapsed') === '1' && sidebar) { sidebar.classList.add('collapsed'); } } catch { // ignore } syncSidebarCollapseUi(); } // Reset all peers lives in Settings (not the sidebar) document.getElementById('settings-reset-peers-btn')?.addEventListener('click', async () => { const count = Object.keys(connections).length; if (count === 0) { showAlert('info', 'No saved peers to reset'); return; } const ok = window.peardockOps?.confirmDestructive ? await window.peardockOps.confirmDestructive( 'Reset all peers?', `Remove all ${count} saved peer(s) from this client? You will need to re-add their public keys.` ) : false; if (ok) resetAllPeers(); }); // Initialize container filtering (lightweight, doesn't block) initContainerFiltering(); // Initialize navigation (respects connection state / welcome) initNavigation(); // Notification tray will be initialized after connections are restored if (duplicateContainerForm) { duplicateContainerForm.addEventListener('submit', async (e) => { e.preventDefault(); let formData; try { formData = collectDuplicateFormData(); } catch (collectError) { console.error('[ERROR] Failed to collect duplicate form data:', collectError); showAlert('danger', 'Failed to collect form data. Check console for details.'); return; } // Validate required fields if (!formData.containerName || !formData.image) { showAlert('danger', 'Container name and image are required.'); return; } // Get container name for notifications const containerName = formData.containerName || 'container'; // Close modal immediately before async operation if (duplicateModal) { duplicateModal.hide(); } closeAllModals(); // Add notification for container creation notificationManager.add('info', `Creating container "${containerName}"...`, { autoDismiss: false }); showStatusIndicator('Preparing container configuration...'); try { // Use deployContainer command with the collected form data // This reuses the same deployment logic const originalHandler = window.handlePeerResponse; let timeoutId = null; let isResolved = false; const duplicateHandler = (response) => { if (isResolved) { if (typeof originalHandler === 'function') { originalHandler(response); } return; } const isDuplicateResponse = (response.success && response.message && typeof response.message === 'string' && response.message.includes('deployed successfully')) || (response.error && ( (response.message && typeof response.message === 'string' && response.message.includes('deploy')) || (typeof response.error === 'string' && (response.error.includes('deploy') || response.error.includes('Container'))) )); if (isDuplicateResponse) { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } window.handlePeerResponse = originalHandler; isResolved = true; if (response.success && response.message && response.message.includes('deployed successfully')) { // Update message to indicate we're transferring updateStatusIndicator('Transferring you to the container'); // Update notification to success notificationManager.add('success', `Container "${formData.containerName}" created successfully!`); showAlert('success', `Container "${formData.containerName}" duplicated successfully!`); sendCommand('listContainers'); // Hide spinner after a delay to allow navigation setTimeout(() => { hideStatusIndicator(); }, 1500); } else if (response.error) { hideStatusIndicator(); const errorMessage = typeof response.error === 'string' ? response.error : (response.error?.message || response.error?.toString() || 'Unknown error'); // Update notification to error notificationManager.add('danger', `Failed to create container "${formData.containerName}"`); showAlert('danger', errorMessage); } } else { if (typeof originalHandler === 'function') { originalHandler(response); } } }; window.handlePeerResponse = duplicateHandler; // Update message when starting deployment updateStatusIndicator('Creating container...'); if (typeof window.sendCommand === 'function') { window.sendCommand('deployContainer', formData); } else { window.handlePeerResponse = originalHandler; hideStatusIndicator(); showAlert('danger', 'sendCommand is not available. Please ensure app.js is loaded.'); return; } timeoutId = setTimeout(() => { if (!isResolved) { window.handlePeerResponse = originalHandler; isResolved = true; hideStatusIndicator(); // Update notification to timeout error notificationManager.add('danger', `Failed to create container "${containerName}" (timeout)`); showAlert('danger', 'Duplication timed out. No response from server.'); } }, 60000); } catch (error) { hideStatusIndicator(); console.error('[ERROR] Failed to duplicate container:', error); // Update notification to error notificationManager.add('danger', `Failed to create container "${containerName}"`); showAlert('danger', error.message || 'Failed to duplicate container. Check console for details.'); } }); } // Restore configured peers from ~/.config/peardock/cache/peers.json and re-dial try { console.log('[INFO] Peer cache path:', getPeersCachePath()); const savedConnections = loadConnections(); const keys = Object.keys(savedConnections); console.log('[INFO] Loading saved connections:', keys.length, keys); // 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(); } assertVisibility(); }); // Show peer slots immediately while dials are in flight showWelcomePage(); assertVisibility(); } catch (err) { console.error(`[ERROR] Failed to initialize connections: ${err.message}`); showWelcomePage(); } }); function disconnectConnection(topicId, connectionItem) { const connection = connections[topicId]; if (!connection) { console.error(`[ERROR] No connection found for topicId: ${topicId}`); return; } // Clean up terminals if (window.openTerminals[topicId]) { console.log(`[INFO] Closing terminals for topic: ${topicId}`); window.openTerminals[topicId].forEach((terminalId) => { try { cleanUpTerminal(terminalId); } catch (err) { console.error(`[ERROR] Failed to clean up terminal ${terminalId}: ${err.message}`); } }); delete window.openTerminals[topicId]; } // Stop health monitoring if (connection.healthCheckInterval) { clearInterval(connection.healthCheckInterval); } // Close HyperDHT / protomux-rpc connection and forget from disk roster if (connection.peer) { manager.disconnect(topicId, { forget: true }).catch(() => {}); connection.peer.close?.().catch?.(() => {}); } else { // Slot existed without a live socket — still drop from cache manager.disconnect(topicId, { forget: true }).catch(() => {}); } // Remove from global connections delete connections[topicId]; // Persist remaining peers (merge + removeId keeps other saved peers) saveConnections({ removeId: topicId }); // Remove the connection item from the UI if (connectionItem && connectionList?.contains(connectionItem)) { connectionList.removeChild(connectionItem); } updatePeersViewChrome(); // Reset UI if this was the active connection if (manager.active?.id === topicId || manager.active === connection.peer) { const connectionTitleEl = document.getElementById('connection-title'); if (connectionTitleEl) { connectionTitleEl.textContent = 'Choose a Connection'; } const dashboardEl = document.getElementById('dashboard'); if (dashboardEl) { dashboardEl.classList.add('hidden'); } resetContainerList(); stopStatsInterval(); } // Welcome when nothing live remains if (Object.keys(connections).length === 0 || !hasActiveConnection()) { showWelcomePage(); } console.log(`[INFO] Disconnected and removed connection: ${topicId}`); } // Function to reset the container list function resetContainerList() { containerList.innerHTML = ''; // Clear the existing list // Clean up smoothedStats for all containers when list is reset Object.keys(smoothedStats).forEach(containerId => { delete smoothedStats[containerId]; }); console.log('[INFO] Container list cleared.'); } // Function to reset the connections view function resetConnectionsView() { if (!connectionList) connectionList = document.getElementById('connection-list'); if (connectionList) connectionList.innerHTML = ''; Object.keys(connections).forEach((topicId) => { const conn = connections[topicId]; const connectionItem = createPeerListItem(topicId, conn); connectionList?.appendChild(connectionItem); }); updatePeersViewChrome(); console.log('[INFO] Connections view reset.'); } // Update connection status function updateConnectionStatus(topicId, isConnected) { const connectionItem = document.querySelector(`[data-topic-id="${topicId}"]`); if (connectionItem) { const statusElement = connectionItem.querySelector('.connection-status'); if (statusElement) { statusElement.className = `connection-status ${isConnected ? 'status-connected' : 'status-disconnected'}`; } } } // Update connection display with alias and latency (never overflows action buttons) function updateConnectionDisplay(topicId) { const connectionItem = document.querySelector(`[data-topic-id="${topicId}"]`); if (!connectionItem) return; const connection = connections[topicId]; if (!connection) return; const nameElement = connectionItem.querySelector('.connection-name'); const metaElement = connectionItem.querySelector('.connection-meta'); if (nameElement) { nameElement.textContent = peerDisplayName(connection, topicId); } if (metaElement) { metaElement.textContent = peerMetaText(connection); } const fullKey = connection.publicKeyHex || topicId; connectionItem.title = connection.alias ? `${connection.alias} · ${fullKey}` : String(fullKey); } // Health monitoring for connections function startHealthMonitoring(topicId) { const connection = connections[topicId]; if (!connection) return; const healthCheckInterval = setInterval(async () => { const entry = connections[topicId]; if (!entry?.peer?.connected) { clearInterval(healthCheckInterval); return; } try { const ms = await entry.peer.ping(); entry.latency = ms; entry.lastHealthCheck = Date.now(); entry.healthStatus = ms < 5000 ? 'healthy' : 'slow'; updateConnectionDisplay(topicId); } catch (err) { entry.healthStatus = 'unhealthy'; updateConnectionStatus(topicId, false); clearInterval(healthCheckInterval); } }, 10000); connections[topicId].healthCheckInterval = healthCheckInterval; } // Switch between connections function switchConnection(topicId) { const connection = connections[topicId]; if (!connection || !connection.peer?.connected) { console.error('[ERROR] No connection found or no active peer.'); if (!hasActiveConnection()) { showWelcomePage(); stopStatsInterval(); } return; } if (connection.peer?.id) { manager.setActive(connection.peer.id); } // Mark active row in peer list document.querySelectorAll('#connection-list .list-group-item').forEach((item) => { item.classList.toggle('active', item.dataset.topicId === topicId); }); hideWelcomePage(); resetContainerList(); console.log(`[INFO] Switched to connection: ${topicId}`); startStatsInterval(); sendCommand(Methods.listContainers); } // Attach switchConnection to the global window object window.switchConnection = switchConnection; // Send a command to the active peer via protomux-rpc // opts.silent: suppress user-facing error toast (background polls) function sendCommand(command, args = {}, opts = {}) { if (!manager.active?.connected) { console.debug('[DEBUG] No active peer to send command (this is normal during initialization).'); return Promise.resolve(null); } const silent = opts.silent === true; if (!silent) console.log(`[DEBUG] RPC ${command}`, args); return manager .send(command, args, { silent }) .then((response) => { if (response) { // Route request responses through the same UI pipeline as pushes handleRpcMessage(response, manager.active); } return response; }) .catch((err) => { // manager.send normally swallows; keep presentError for unexpected throws console.error(`[ERROR] RPC ${command} failed:`, err?.message || err); presentError(err, command, { showAlert, silent }); return null; }); } // Attach sendCommand to the global window object window.sendCommand = sendCommand; // Cache for DOM queries const domCache = { containerList: null, connectionList: null, dashboard: null, welcomePage: null, }; // Initialize DOM cache function initDOMCache() { domCache.containerList = document.getElementById('container-list'); domCache.connectionList = document.getElementById('connection-list'); domCache.dashboard = document.getElementById('dashboard'); domCache.welcomePage = document.getElementById('welcome-page'); } // Initialize cache on load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initDOMCache); } else { initDOMCache(); } // Debounce utility function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } // Container filtering and sorting state let containerFilterState = { search: '', status: 'all', sort: 'name-asc', allContainers: [] }; // Filter and sort containers function filterAndSortContainers(containers) { let filtered = [...containers]; // Apply search filter if (containerFilterState.search) { const searchLower = containerFilterState.search.toLowerCase(); filtered = filtered.filter(container => { const name = container.Names[0]?.replace(/^\//, '') || ''; const image = container.Image || ''; return name.toLowerCase().includes(searchLower) || image.toLowerCase().includes(searchLower); }); } // Apply status filter if (containerFilterState.status !== 'all') { filtered = filtered.filter(container => { const state = container.State?.toLowerCase() || ''; return state === containerFilterState.status.toLowerCase(); }); } // Apply sorting const [sortField, sortOrder] = containerFilterState.sort.split('-'); filtered.sort((a, b) => { let aVal, bVal; switch (sortField) { case 'name': aVal = (a.Names[0]?.replace(/^\//, '') || '').toLowerCase(); bVal = (b.Names[0]?.replace(/^\//, '') || '').toLowerCase(); break; case 'cpu': aVal = smoothedStats[a.Id]?.cpu || 0; bVal = smoothedStats[b.Id]?.cpu || 0; break; case 'memory': aVal = smoothedStats[a.Id]?.memory || 0; bVal = smoothedStats[b.Id]?.memory || 0; break; default: return 0; } if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1; if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1; return 0; }); return filtered; } // Helper function to truncate sha256 hash image names for display function formatImageName(imageName) { if (!imageName || imageName === '-') { return imageName; } // Check if image name starts with sha256: and is a hash if (imageName.startsWith('sha256:')) { const hash = imageName.substring(7); // Remove 'sha256:' prefix // Check if it's a valid hex hash (64 characters for full SHA256) if (/^[a-f0-9]{64}$/i.test(hash)) { // Truncate to first 12 characters (standard Docker short hash) + '...' return `sha256:${hash.substring(0, 12)}...`; } } return imageName; } // Render the container list with optimized DOM manipulation /** Virtualized container table state (windowed rows for 500+ containers) */ const containerVirt = { rows: [], topicId: null, scrollEl: null, bound: false, rowHeight: 52, overscan: 8, }; function buildContainerRow(container) { const name = container.Names[0]?.replace(/^\//, '') || 'Unknown'; const image = formatImageName(container.Image || '-'); const containerId = container.Id; const ipAddress = container.ipAddress || 'No IP Assigned'; if (ipAddress === 'No IP Assigned') { sendCommand('inspectContainer', { id: container.Id }); } const row = document.createElement('tr'); row.dataset.containerId = containerId; const state = container.State || 'Unknown'; const stateLower = state.toLowerCase(); const statusClass = stateLower === 'running' ? 'status-running' : stateLower === 'exited' || stateLower === 'stopped' ? 'status-exited' : stateLower === 'created' ? 'status-created' : stateLower === 'restarting' ? 'status-restarting' : ''; row.innerHTML = `
${name} ${name}
${image} ${state}
0.00%
0.00 MB
${ipAddress}
`; const checkbox = row.querySelector('.container-checkbox'); if (checkbox) checkbox.addEventListener('change', () => updateBulkActionsToolbar()); const duplicateBtn = row.querySelector('.action-duplicate'); if (duplicateBtn) duplicateBtn.addEventListener('click', () => openDuplicateModal(container)); const recreateBtn = row.querySelector('.action-recreate'); if (recreateBtn) { recreateBtn.addEventListener('click', () => recreateContainerAction(container)); } const resourcesBtn = row.querySelector('.action-resources'); if (resourcesBtn) { resourcesBtn.addEventListener('click', () => { const name = (container.Names?.[0] || '').replace(/^\//, ''); window.peardockOps?.openResourceEditor?.(container.Id, { name }); }); } const nameLink = row.querySelector('.container-name-link'); if (nameLink) { nameLink.addEventListener('click', (e) => { e.preventDefault(); showContainerDetails(container); }); } const nameDisplay = row.querySelector('.container-name-display'); if (nameDisplay) { nameDisplay.addEventListener('click', (e) => { e.preventDefault(); showContainerDetails(container); }); } addActionListeners(row, container); return row; } function paintVirtualContainers() { const listElement = domCache.containerList || containerList; if (!listElement) return; const rows = containerVirt.rows; if (!rows.length) return; const scrollParent = listElement.closest('.table-responsive') || listElement.closest('.view') || listElement.parentElement; const scrollTop = scrollParent?.scrollTop || 0; const viewH = scrollParent?.clientHeight || 600; const rh = containerVirt.rowHeight; const total = rows.length; const start = Math.max(0, Math.floor(scrollTop / rh) - containerVirt.overscan); const visible = Math.ceil(viewH / rh) + containerVirt.overscan * 2; const end = Math.min(total, start + visible); const fragment = document.createDocumentFragment(); if (start > 0) { const topPad = document.createElement('tr'); topPad.className = 'virt-pad-top'; topPad.innerHTML = ``; fragment.appendChild(topPad); } for (let i = start; i < end; i++) { fragment.appendChild(buildContainerRow(rows[i])); } if (end < total) { const botPad = document.createElement('tr'); botPad.className = 'virt-pad-bot'; botPad.innerHTML = ``; fragment.appendChild(botPad); } listElement.innerHTML = ''; listElement.appendChild(fragment); } function bindContainerVirtualScroll() { if (containerVirt.bound) return; const listElement = domCache.containerList || containerList; const scrollParent = listElement?.closest('.table-responsive') || listElement?.closest('.view') || listElement?.parentElement; if (!scrollParent) return; containerVirt.scrollEl = scrollParent; let ticking = false; scrollParent.addEventListener( 'scroll', () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { ticking = false; if (containerVirt.rows.length > 80) paintVirtualContainers(); }); }, { passive: true } ); containerVirt.bound = true; } function renderContainers(containers, topicId) { if (!window.activePeer || !connections[topicId] || window.activePeer !== connections[topicId].peer) { console.warn('[WARN] Active peer mismatch or invalid connection. Skipping container rendering.'); return; } console.log(`[INFO] Rendering ${containers.length} containers for topic: ${topicId}`); const currentContainerIds = new Set(containers.map((c) => c.Id)); Object.keys(smoothedStats).forEach((containerId) => { if (!currentContainerIds.has(containerId)) delete smoothedStats[containerId]; }); const filteredContainers = filterAndSortContainers(containers); const listElement = domCache.containerList || containerList; if (!filteredContainers.length) { const hasAny = (containers || []).length > 0; listElement.innerHTML = emptyTableRow( 8, hasAny ? 'No matching containers' : 'No containers yet', hasAny ? 'Clear filters or search to see more.' : 'Deploy a container from the Deploy view.' ); containerVirt.rows = []; return; } containerVirt.rows = filteredContainers; containerVirt.topicId = topicId; bindContainerVirtualScroll(); // Virtualize only for large lists; small lists render fully for simplicity if (filteredContainers.length > 80) { paintVirtualContainers(); return; } const fragment = document.createDocumentFragment(); filteredContainers.forEach((container) => { fragment.appendChild(buildContainerRow(container)); }); listElement.innerHTML = ''; listElement.appendChild(fragment); } function addActionListeners(row, container) { const startBtn = row.querySelector('.action-start'); const stopBtn = row.querySelector('.action-stop'); const pauseBtn = row.querySelector('.action-pause'); const removeBtn = row.querySelector('.action-remove'); const terminalBtn = row.querySelector('.action-terminal'); const restartBtn = row.querySelector('.action-restart'); const inspectBtn = row.querySelector('.action-inspect'); const renameBtn = row.querySelector('.action-rename'); const commitBtn = row.querySelector('.action-commit'); const execBtn = row.querySelector('.action-exec'); const killBtn = row.querySelector('.action-kill'); const topBtn = row.querySelector('.action-top'); // Start Button startBtn.addEventListener('click', async () => { showStatusIndicator(`Starting container "${container.Names[0]}"...`); sendCommand('startContainer', { id: container.Id }); const expectedMessageFragment = `Container ${container.Id} started`; try { const response = await waitForPeerResponse(expectedMessageFragment); console.log('[DEBUG] Start container response:', response); showAlert('success', response.message); // Refresh the container list to update states sendCommand('listContainers'); // Restart stats interval startStatsInterval(); } catch (error) { console.error('[ERROR] Failed to start container:', error.message); showAlert('danger', error.message || 'Failed to start container.'); } finally { console.log('[DEBUG] Hiding status indicator in startBtn finally block'); hideStatusIndicator(); } }); stopBtn.addEventListener('click', async () => { showStatusIndicator(`Stopping container "${container.Names[0]}"...`); sendCommand('stopContainer', { id: container.Id }); const expectedMessageFragment = `Container ${container.Id} stopped`; try { const response = await waitForPeerResponse(expectedMessageFragment); console.log('[DEBUG] Stop container response:', response); showAlert('success', response.message); // Refresh the container list to update states sendCommand('listContainers'); // Restart stats interval startStatsInterval(); } catch (error) { console.error('[ERROR] Failed to stop container:', error.message); showAlert('danger', error.message || 'Failed to stop container.'); } finally { console.log('[DEBUG] Hiding status indicator in stopBtn finally block'); hideStatusIndicator(); } }); // Restart Button restartBtn.addEventListener('click', async () => { showStatusIndicator(`Restarting container "${container.Names[0]}"...`); sendCommand('restartContainer', { id: container.Id }); const expectedMessageFragment = `Container ${container.Id} restarted`; try { const response = await waitForPeerResponse(expectedMessageFragment); console.log('[DEBUG] Restart container response:', response); showAlert('success', response.message); // Refresh the container list to update states sendCommand('listContainers'); } catch (error) { console.error('[ERROR] Failed to restart container:', error.message); showAlert('danger', error.message || 'Failed to restart container.'); } finally { console.log('[DEBUG] Hiding status indicator in restartBtn finally block'); hideStatusIndicator(); } }); if (killBtn) { killBtn.addEventListener('click', async () => { showStatusIndicator(`Killing container "${container.Names[0]}"...`); try { const response = await sendCommand('killContainer', { id: container.Id }); if (response?.success) { showAlert('success', response.message || 'Container killed'); sendCommand('listContainers'); } } catch (error) { showAlert('danger', error.message || 'Failed to kill container.'); } finally { hideStatusIndicator(); } }); } if (topBtn) { topBtn.addEventListener('click', () => { showContainerDetails(container); // Switch to processes tab after details load setTimeout(() => { const tab = document.getElementById('processes-tab'); if (tab) tab.click(); loadContainerTop(container.Id); }, 200); }); } // Pause Button if (pauseBtn) { pauseBtn.addEventListener('click', async () => { showStatusIndicator(`Pausing container "${container.Names[0]}"...`); sendCommand('pauseContainer', { id: container.Id }); const expectedMessageFragment = `Container ${container.Id} paused`; try { const response = await waitForPeerResponse(expectedMessageFragment); showAlert('success', response.message); sendCommand('listContainers'); } catch (error) { console.error('[ERROR] Failed to pause container:', error.message); showAlert('danger', error.message || 'Failed to pause container.'); } finally { hideStatusIndicator(); } }); } // Rename Button - Inline Edit if (renameBtn) { renameBtn.addEventListener('click', () => { const nameDisplay = row.querySelector('.container-name-display'); if (!nameDisplay) return; const currentName = nameDisplay.textContent.trim(); const originalName = currentName; // Create input field const input = document.createElement('input'); input.type = 'text'; input.value = currentName; input.className = 'form-control form-control-sm bg-dark text-white'; input.style.width = '200px'; input.style.display = 'inline-block'; // Replace display with input const parent = nameDisplay.parentElement; const nameDisplayClone = nameDisplay.cloneNode(true); nameDisplay.style.display = 'none'; parent.insertBefore(input, nameDisplay); // Focus and select input.focus(); input.select(); // Track rename state to prevent multiple attempts let isRenaming = false; let checkInterval = null; let originalHandler = null; // Save function const saveName = async () => { // Prevent multiple calls if (isRenaming) { return; } const newName = input.value.trim(); // Early return if name is empty if (!newName) { showAlert('danger', 'Container name cannot be empty'); parent.removeChild(input); nameDisplay.textContent = originalName; nameDisplay.style.display = ''; return; } // Early return if name unchanged - no need to rename if (newName === originalName) { parent.removeChild(input); nameDisplay.style.display = ''; return; } // Set renaming flag isRenaming = true; sendCommand('renameContainer', { id: container.Id, name: newName }); const expectedMessageFragment = `Container renamed to "${newName}"`; let renameHandled = false; // Set up response handler originalHandler = window.handlePeerResponse; const responseHandler = (response) => { if (!renameHandled && response && response.success && response.message && response.message.includes(expectedMessageFragment)) { renameHandled = true; // Clear interval if it exists if (checkInterval) { clearInterval(checkInterval); checkInterval = null; } showAlert('success', response.message); sendCommand('listContainers'); // Restore original handler if (typeof originalHandler === 'function') { window.handlePeerResponse = originalHandler; } else { window.handlePeerResponse = null; } isRenaming = false; } else if (typeof originalHandler === 'function') { originalHandler(response); } }; window.handlePeerResponse = responseHandler; // Also listen for container list updates as confirmation (backup) checkInterval = setInterval(() => { const nameDisplays = document.querySelectorAll('.container-name-display'); nameDisplays.forEach(display => { if (!renameHandled && display.textContent.trim() === newName && display.dataset.containerId === container.Id) { renameHandled = true; clearInterval(checkInterval); checkInterval = null; showAlert('success', `Container renamed to "${newName}"`); // Restore original handler if (typeof originalHandler === 'function') { window.handlePeerResponse = originalHandler; } else { window.handlePeerResponse = null; } isRenaming = false; } }); }, 500); // Clean up interval after 10 seconds setTimeout(() => { if (checkInterval) { clearInterval(checkInterval); checkInterval = null; } if (isRenaming) { isRenaming = false; } }, 10000); // Restore display immediately (input will be removed) parent.removeChild(input); nameDisplay.style.display = ''; }; // Cancel function const cancelEdit = () => { parent.removeChild(input); nameDisplay.style.display = ''; }; // Handle Enter key input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); if (!isRenaming && input.parentElement) { saveName(); } } else if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); } }); // Handle blur (click outside) - use setTimeout to allow other events to process first input.addEventListener('blur', () => { setTimeout(() => { // Only save if input is still in DOM and we're not already renaming if (!isRenaming && input.parentElement) { saveName(); } }, 150); }); }); } const logsBtn = row.querySelector('.action-logs'); logsBtn.addEventListener('click', () => openLogModal(container.Id)); function openLogModal(containerId) { console.log(`[INFO] Opening logs modal for container: ${containerId}`); const modal = new bootstrap.Modal(document.getElementById('logsModal')); const logContainer = document.getElementById('logs-container'); // Clear any existing logs logContainer.innerHTML = ''; // Request previous logs sendCommand('logs', { id: containerId }); // Listen for logs window.handleLogOutput = (logData) => { const logLine = decodePayload(logData.data, logData.encoding || 'base64'); if (!logLine) return; const logElement = document.createElement('pre'); logElement.className = 'mb-0'; logElement.style.whiteSpace = 'pre-wrap'; logElement.style.wordBreak = 'break-word'; logElement.textContent = logLine; logContainer.appendChild(logElement); logContainer.scrollTop = logContainer.scrollHeight; }; // Show the modal modal.show(); } // Remove Button removeBtn.addEventListener('click', async () => { const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal')); deleteModal.show(); const confirmDeleteBtn = document.getElementById('confirm-delete-btn'); confirmDeleteBtn.onclick = async () => { // Close modal immediately before async operation deleteModal.hide(); closeAllModals(); const containerName = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12); // Add notification for container deletion notificationManager.add('info', `Deleting container "${containerName}"...`, { autoDismiss: false }); showStatusIndicator(`Deleting container "${container.Names[0]}"...`); // Check if the container has active terminals if (window.openTerminals[container.Id]) { console.log(`[INFO] Closing active terminals for container: ${container.Id}`); window.openTerminals[container.Id].forEach((terminalId) => { try { cleanUpTerminal(terminalId); } catch (err) { console.error(`[ERROR] Failed to clean up terminal ${terminalId}: ${err.message}`); } }); delete window.openTerminals[container.Id]; } // Hide the terminal modal if it is active const terminalModal = document.getElementById('terminal-modal'); if (terminalModal.style.display === 'flex') { console.log(`[INFO] Hiding terminal modal for container: ${container.Id}`); terminalModal.style.display = 'none'; } terminalModal.addEventListener('shown.bs.modal', () => { terminal.focus(); }); sendCommand('removeContainer', { id: container.Id }); const expectedMessageFragment = `Container ${container.Id} removed`; try { const response = await waitForPeerResponse(expectedMessageFragment); console.log('[DEBUG] Remove container response:', response); // Update notification to success notificationManager.add('success', `Container "${containerName}" deleted successfully`); showAlert('success', response.message); // Refresh the container list to update states sendCommand('listContainers'); } catch (error) { console.error('[ERROR] Failed to delete container:', error.message); // Update notification to error notificationManager.add('danger', `Failed to delete container "${containerName}"`); showAlert('danger', error.message || `Failed to delete container "${container.Names[0]}".`); } finally { console.log('[DEBUG] Hiding status indicator in removeBtn finally block'); hideStatusIndicator(); } }; }); terminalBtn.addEventListener('click', () => { console.log(`[DEBUG] Opening terminal for container ID: ${container.Id}`); try { startTerminal(container.Id, container.Names[0] || container.Id); } catch (error) { console.error(`[ERROR] Failed to start terminal for container ${container.Id}: ${error.message}`); showAlert('danger', `Failed to start terminal: ${error.message}`); } }); // Inspect Button if (inspectBtn) { inspectBtn.addEventListener('click', () => { openInspectModal(container); }); } } function updateContainerStats(stats) { if (!stats || !stats.id || typeof stats.cpu === 'undefined' || typeof stats.memory === 'undefined') { console.error('[ERROR] Invalid stats object:', stats); return; } console.log(`[DEBUG] Updating stats for container ID: ${stats.id}`); const row = containerList?.querySelector(`tr[data-container-id="${stats.id}"]`); if (row) { // Ensure the IP address is added or retained from existing row const existingIpAddress = row.querySelector('.ip-address')?.textContent || 'No IP Assigned'; stats.ip = stats.ip || existingIpAddress; const smoothed = smoothStats(stats.id, stats); updateStatsUI(row, smoothed); } // Update container details stats if we're on that view if (currentView === 'container-details' && currentContainerDetails && currentContainerDetails.Id === stats.id) { const cpuEl = document.getElementById('detail-cpu'); const memoryEl = document.getElementById('detail-memory'); const smoothed = smoothStats(stats.id, stats); if (cpuEl) cpuEl.textContent = `${smoothed.cpu.toFixed(2)}%`; if (memoryEl) memoryEl.textContent = `${(smoothed.memory / (1024 * 1024)).toFixed(2)} MB`; } } // Batch stats updates let pendingStatsUpdates = []; // Debounced stats update function with visualization const debouncedStatsUpdate = debounce((updates) => { requestAnimationFrame(() => { for (const { row, stats } of updates) { const cpuEl = row.querySelector('.cpu .stats-value'); const cpuBar = row.querySelector('.cpu-bar'); const memoryEl = row.querySelector('.memory .stats-value'); const memoryBar = row.querySelector('.memory-bar'); const ipEl = row.querySelector('.ip-address'); if (cpuEl) { const cpuPercent = stats.cpu.toFixed(2) || '0.00'; cpuEl.textContent = `${cpuPercent}%`; if (cpuBar) { // Cap at 100% for visualization const width = Math.min(100, parseFloat(cpuPercent)); cpuBar.style.width = `${width}%`; } } if (memoryEl) { const memoryMB = (stats.memory / (1024 * 1024)).toFixed(2) || '0.00'; memoryEl.textContent = `${memoryMB} MB`; if (memoryBar) { // Calculate memory percentage (assuming reasonable max of 8GB for visualization) const maxMemory = 8 * 1024 * 1024 * 1024; // 8GB const memoryPercent = Math.min(100, (stats.memory / maxMemory) * 100); memoryBar.style.width = `${memoryPercent}%`; } } if (ipEl) ipEl.textContent = stats.ip; } }); }, 100); // Debounce to 100ms function updateStatsUI(row, stats) { pendingStatsUpdates.push({ row, stats }); // Trigger debounced update debouncedStatsUpdate(pendingStatsUpdates); // Clear pending updates after processing (they're processed in the debounced function) setTimeout(() => { if (pendingStatsUpdates.length > 0) { pendingStatsUpdates = []; } }, 200); } // Function to open the Duplicate Modal with container configurations function openDuplicateModal(container) { console.log(`[INFO] Opening Duplicate Modal for container: ${container.Id}`); showStatusIndicator('Fetching container configuration...'); // Send a command to inspect the container sendCommand('inspectContainer', { id: container.Id }); // Listen for the inspectContainer response window.inspectContainerCallback = (config) => { hideStatusIndicator(); if (!config) { console.error('[ERROR] Failed to retrieve container configuration.'); showAlert('danger', 'Failed to retrieve container configuration.'); return; } console.log(`[DEBUG] Retrieved container configuration: ${JSON.stringify(config)}`); // Parse configuration and populate the accordion form try { // Clear the form first const form = document.getElementById('duplicate-container-form'); if (form) form.reset(); // Populate all fields using the helper function populateDuplicateForm(config); setupDeployResourceSliders(); // Set up network mode change handler for duplicate modal const networkMode = document.getElementById('duplicate-network-mode'); const customNetworkContainer = document.getElementById('duplicate-custom-network-container'); if (networkMode && customNetworkContainer && networkMode.parentNode) { // Remove existing listeners by cloning and replacing const newNetworkMode = networkMode.cloneNode(true); networkMode.parentNode.replaceChild(newNetworkMode, networkMode); newNetworkMode.addEventListener('change', (e) => { if (e.target.value === 'container') { customNetworkContainer.style.display = 'block'; const input = customNetworkContainer.querySelector('input'); if (input) input.placeholder = 'container-name'; } else if (e.target.value !== 'host' && e.target.value !== 'none' && e.target.value !== 'bridge') { customNetworkContainer.style.display = 'block'; const input = customNetworkContainer.querySelector('input'); if (input) input.placeholder = 'network-name'; } else { customNetworkContainer.style.display = 'none'; } }); } // Show the duplicate modal if (duplicateModal) { duplicateModal.show(); } } catch (error) { console.error(`[ERROR] Failed to populate modal fields: ${error.message}`); showAlert('danger', 'Failed to populate container configuration fields.'); } }; } // Function to open the Inspect Modal with container information function openInspectModal(container) { console.log(`[INFO] Opening Inspect Modal for container: ${container.Id}`); showStatusIndicator('Fetching container information...'); // Store the original callback if it exists const originalCallback = window.inspectContainerCallback; // Send a command to inspect the container sendCommand('inspectContainer', { id: container.Id }); // Listen for the inspectContainer response window.inspectContainerCallback = (config) => { hideStatusIndicator(); // Restore original callback window.inspectContainerCallback = originalCallback; if (!config) { console.error('[ERROR] Failed to retrieve container configuration.'); showAlert('danger', 'Failed to retrieve container configuration.'); return; } try { // Update modal title with container name const modalTitle = document.getElementById('containerInspectModalLabel'); const containerName = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12); if (modalTitle) { modalTitle.innerHTML = `Container Information: ${containerName}`; } // Format and populate the modal formatAndPopulateInspectModal(config); // Store config for JSON view window.currentInspectConfig = config; // Show the modal const inspectModal = new bootstrap.Modal(document.getElementById('containerInspectModal')); inspectModal.show(); // Reset view to formatted view document.getElementById('inspect-formatted-view').style.display = 'block'; document.getElementById('inspect-json-view').style.display = 'none'; document.getElementById('toggle-json-view').innerHTML = ' Raw JSON'; } catch (error) { console.error(`[ERROR] Failed to populate inspect modal: ${error.message}`); showAlert('danger', 'Failed to populate container information.'); } }; } // Format and populate the inspect modal with container configuration function formatAndPopulateInspectModal(config) { // Overview Section populateOverviewSection(config); // Configuration Section populateConfigSection(config); // Networking Section populateNetworkingSection(config); // Storage Section populateStorageSection(config); // Resources Section populateResourcesSection(config); // Security Section populateSecuritySection(config); // Runtime Section populateRuntimeSection(config); // Health & Logging Section populateHealthSection(config); // Labels & Metadata Section populateLabelsSection(config); } // Helper function to render key-value pairs function renderKeyValue(key, value) { if (value === null || value === undefined || value === '') { return ''; } return `
${escapeHtml(key)}
${formatValue(value)}
`; } // Helper function to format values function formatValue(value) { if (value === null || value === undefined) { return 'Not set'; } if (typeof value === 'boolean') { const badgeClass = value ? 'inspect-badge-success' : 'inspect-badge-danger'; const text = value ? 'Yes' : 'No'; return `${text}`; } if (Array.isArray(value)) { if (value.length === 0) { return 'None'; } return `
    ${value.map(item => `
  • ${escapeHtml(String(item))}
  • `).join('')}
`; } if (typeof value === 'object') { return `
${escapeHtml(JSON.stringify(value, null, 2))}
`; } return escapeHtml(String(value)); } // Helper function to escape HTML function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Populate Overview Section function populateOverviewSection(config) { const content = document.getElementById('inspect-overview-content'); if (!content) return; const name = config.Name?.replace(/^\//, '') || 'Unknown'; const image = config.Config?.Image || 'Unknown'; const state = config.State?.Status || 'Unknown'; const created = config.Created ? new Date(config.Created).toLocaleString() : 'Unknown'; const started = config.State?.StartedAt ? new Date(config.State.StartedAt).toLocaleString() : 'Not started'; const id = config.Id || 'Unknown'; const restartCount = config.RestartCount || 0; content.innerHTML = ` ${renderKeyValue('Name', name)} ${renderKeyValue('ID', id.substring(0, 12))} ${renderKeyValue('Image', image)} ${renderKeyValue('Status', state)} ${renderKeyValue('Created', created)} ${renderKeyValue('Started', started)} ${renderKeyValue('Restart Count', restartCount)} `; } // Populate Configuration Section function populateConfigSection(config) { const content = document.getElementById('inspect-config-content'); if (!content) return; const cmd = config.Config?.Cmd || []; const entrypoint = config.Config?.Entrypoint || []; const workingDir = config.Config?.WorkingDir || ''; const user = config.Config?.User || ''; const env = config.Config?.Env || []; const exposedPorts = config.Config?.ExposedPorts ? Object.keys(config.Config.ExposedPorts) : []; let html = ''; html += renderKeyValue('Command', cmd.length > 0 ? cmd.join(' ') : 'Not set'); html += renderKeyValue('Entrypoint', entrypoint.length > 0 ? entrypoint.join(' ') : 'Not set'); html += renderKeyValue('Working Directory', workingDir); html += renderKeyValue('User', user); html += renderKeyValue('Environment Variables', env); html += renderKeyValue('Exposed Ports', exposedPorts); content.innerHTML = html || '
No configuration data available
'; } // Populate Networking Section function populateNetworkingSection(config) { const content = document.getElementById('inspect-networking-content'); if (!content) return; const networkMode = config.HostConfig?.NetworkMode || 'default'; const networks = config.NetworkSettings?.Networks || {}; const ports = config.NetworkSettings?.Ports || {}; const dns = config.HostConfig?.Dns || []; const extraHosts = config.HostConfig?.ExtraHosts || []; // Format port bindings const portBindings = []; if (ports) { Object.keys(ports).forEach(port => { const bindings = ports[port]; if (bindings && bindings.length > 0) { bindings.forEach(binding => { portBindings.push(`${binding.HostIp || '0.0.0.0'}:${binding.HostPort} -> ${port}`); }); } }); } // Format network IPs const networkIPs = []; Object.keys(networks).forEach(netName => { const net = networks[netName]; if (net.IPAddress) { networkIPs.push(`${netName}: ${net.IPAddress}`); } }); let html = ''; html += renderKeyValue('Network Mode', networkMode); html += renderKeyValue('IP Addresses', networkIPs.length > 0 ? networkIPs : ['No IP assigned']); html += renderKeyValue('Port Mappings', portBindings.length > 0 ? portBindings : ['No port mappings']); html += renderKeyValue('DNS Servers', dns); html += renderKeyValue('Extra Hosts', extraHosts); content.innerHTML = html || '
No networking data available
'; } // Populate Storage Section function populateStorageSection(config) { const content = document.getElementById('inspect-storage-content'); if (!content) return; const mounts = config.Mounts || []; const binds = config.HostConfig?.Binds || []; const tmpfs = config.HostConfig?.Tmpfs || {}; // Format mounts const mountList = mounts.map(mount => { return `${mount.Source} -> ${mount.Destination} (${mount.Type}${mount.Mode ? ', ' + mount.Mode : ''})`; }); // Format tmpfs const tmpfsList = Object.keys(tmpfs).map(path => { return `${path}: ${tmpfs[path]}`; }); let html = ''; html += renderKeyValue('Volume Mounts', mountList.length > 0 ? mountList : binds); html += renderKeyValue('Tmpfs Mounts', tmpfsList.length > 0 ? tmpfsList : ['None']); content.innerHTML = html || '
No storage data available
'; } // Populate Resources Section function populateResourcesSection(config) { const content = document.getElementById('inspect-resources-content'); if (!content) return; const cpuShares = config.HostConfig?.CpuShares || 0; const cpuQuota = config.HostConfig?.CpuQuota || 0; const cpuPeriod = config.HostConfig?.CpuPeriod || 0; const memory = config.HostConfig?.Memory || 0; const memorySwap = config.HostConfig?.MemorySwap || 0; const memoryReservation = config.HostConfig?.MemoryReservation || 0; const devices = config.HostConfig?.Devices || []; const cpusetCpus = config.HostConfig?.CpusetCpus || ''; const cpusetMems = config.HostConfig?.CpusetMems || ''; // Format CPU let cpuInfo = ''; if (cpuQuota > 0 && cpuPeriod > 0) { cpuInfo = `${(cpuQuota / cpuPeriod).toFixed(2)} cores`; } else if (cpuShares > 0) { cpuInfo = `${cpuShares} shares`; } else { cpuInfo = 'Unlimited'; } // Format memory const formatMemory = (bytes) => { if (bytes === 0) return 'Unlimited'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; }; // Format devices const deviceList = devices.map(device => { return `${device.PathOnHost} -> ${device.PathInContainer}${device.CgroupPermissions ? ' (' + device.CgroupPermissions + ')' : ''}`; }); let html = ''; html += renderKeyValue('CPU Limit', cpuInfo); html += renderKeyValue('CPU Shares', cpuShares > 0 ? cpuShares : 'Default'); html += renderKeyValue('CPU Set CPUs', cpusetCpus || 'All'); html += renderKeyValue('CPU Set Memory', cpusetMems || 'All'); html += renderKeyValue('Memory Limit', formatMemory(memory)); html += renderKeyValue('Memory Reservation', formatMemory(memoryReservation)); html += renderKeyValue('Memory Swap', formatMemory(memorySwap)); html += renderKeyValue('Device Mappings', deviceList.length > 0 ? deviceList : ['None']); content.innerHTML = html || '
No resource data available
'; } // Populate Security Section function populateSecuritySection(config) { const content = document.getElementById('inspect-security-content'); if (!content) return; const privileged = config.HostConfig?.Privileged || false; const readonlyRootfs = config.HostConfig?.ReadonlyRootfs || false; const capabilities = config.HostConfig?.CapAdd || []; const securityOpts = config.HostConfig?.SecurityOpt || []; const user = config.Config?.User || ''; let html = ''; html += renderKeyValue('Privileged Mode', privileged); html += renderKeyValue('Read-only Root Filesystem', readonlyRootfs); html += renderKeyValue('User', user || 'Default'); html += renderKeyValue('Added Capabilities', capabilities.length > 0 ? capabilities : ['None']); html += renderKeyValue('Security Options', securityOpts.length > 0 ? securityOpts : ['None']); content.innerHTML = html || '
No security data available
'; } // Populate Runtime Section function populateRuntimeSection(config) { const content = document.getElementById('inspect-runtime-content'); if (!content) return; const restartPolicy = config.HostConfig?.RestartPolicy?.Name || 'no'; const restartMaxRetries = config.HostConfig?.RestartPolicy?.MaximumRetryCount || 0; const autoRemove = config.HostConfig?.AutoRemove || false; const tty = config.Config?.Tty || false; const stdinOpen = config.Config?.OpenStdin || false; const attachStdin = config.Config?.AttachStdin || false; const attachStdout = config.Config?.AttachStdout || false; const attachStderr = config.Config?.AttachStderr || false; const init = config.HostConfig?.Init || false; let html = ''; html += renderKeyValue('Restart Policy', restartPolicy + (restartMaxRetries > 0 ? ` (max retries: ${restartMaxRetries})` : '')); html += renderKeyValue('Auto Remove', autoRemove); html += renderKeyValue('TTY', tty); html += renderKeyValue('Interactive (Stdin Open)', stdinOpen); html += renderKeyValue('Init Process', init); html += renderKeyValue('Attach Stdin', attachStdin); html += renderKeyValue('Attach Stdout', attachStdout); html += renderKeyValue('Attach Stderr', attachStderr); content.innerHTML = html || '
No runtime data available
'; } // Populate Health & Logging Section function populateHealthSection(config) { const content = document.getElementById('inspect-health-content'); if (!content) return; const healthcheck = config.Config?.Healthcheck || null; const healthStatus = config.State?.Health || null; const logDriver = config.HostConfig?.LogConfig?.Type || 'default'; const logOpts = config.HostConfig?.LogConfig?.Config || {}; let html = ''; // Live healthcheck status from Engine if (healthStatus) { const status = healthStatus.Status || 'unknown'; const failing = healthStatus.FailingStreak ?? 0; const badgeClass = status === 'healthy' ? 'detail-badge-success' : status === 'unhealthy' ? 'detail-badge-danger' : 'detail-badge-warning'; html += `
${status} failing streak: ${failing}
`; const logs = healthStatus.Log || []; if (logs.length) { const last = logs[logs.length - 1]; html += renderKeyValue( 'Last health probe', `exit ${last.ExitCode ?? '?'} — ${(last.Output || '').toString().slice(0, 200)}` ); } } else { html += renderKeyValue('Health Status', 'No healthcheck results (not configured or not started)'); } if (healthcheck) { const test = healthcheck.Test || []; const interval = healthcheck.Interval || 0; const timeout = healthcheck.Timeout || 0; const retries = healthcheck.Retries || 0; const startPeriod = healthcheck.StartPeriod || 0; html += renderKeyValue('Health Check Command', test.length > 0 ? test.join(' ') : 'Not set'); html += renderKeyValue('Health Check Interval', interval > 0 ? `${interval / 1000000000}s` : 'Not set'); html += renderKeyValue('Health Check Timeout', timeout > 0 ? `${timeout / 1000000000}s` : 'Not set'); html += renderKeyValue('Health Check Retries', retries); html += renderKeyValue('Health Check Start Period', startPeriod > 0 ? `${startPeriod / 1000000000}s` : 'Not set'); } else { html += renderKeyValue('Health Check', 'Not configured'); } html += renderKeyValue('Log Driver', logDriver); html += renderKeyValue('Log Options', Object.keys(logOpts).length > 0 ? logOpts : {}); content.innerHTML = html || '
No health or logging data available
'; } // Populate Labels & Metadata Section function populateLabelsSection(config) { const content = document.getElementById('inspect-labels-content'); if (!content) return; const labels = config.Config?.Labels || {}; const created = config.Created ? new Date(config.Created).toLocaleString() : 'Unknown'; const path = config.Path || ''; const args = config.Args || []; const driver = config.Driver || ''; // Format labels const labelList = Object.keys(labels).map(key => `${key}=${labels[key]}`); let html = ''; html += renderKeyValue('Labels', labelList.length > 0 ? labelList : ['None']); html += renderKeyValue('Created', created); html += renderKeyValue('Path', path); html += renderKeyValue('Arguments', args.length > 0 ? args : ['None']); html += renderKeyValue('Driver', driver); content.innerHTML = html || '
No labels or metadata available
'; } // ============ Image Inspect Functions ============ function openImageInspectModal(imageId) { console.log(`[INFO] Opening Image Inspect Modal for image: ${imageId}`); showStatusIndicator('Fetching image information...'); sendCommand('inspectImage', { id: imageId }); // Real layer history from Engine History API sendCommand('imageHistory', { id: imageId }); window.pendingImageInspect = { imageId }; } function formatAndPopulateImageModal(config) { if (!config) return; // Store config for JSON view window.currentImageInspectConfig = config; // Populate Overview populateImageOverviewSection(config); // Populate Configuration populateImageConfigSection(config); // Populate History & Layers (RootFS from inspect; Engine history may arrive async) populateImageHistorySection(config); } /** Populate history accordion from imageHistory RPC (docker history). */ function populateImageHistoryFromApi(history) { const content = document.getElementById('image-history-content'); if (!content || !Array.isArray(history)) return; let html = '
Image history
'; html += '
    '; history.forEach((layer, index) => { const created = layer.Created ? new Date(typeof layer.Created === 'number' ? layer.Created * 1000 : layer.Created).toLocaleString() : ''; const createdBy = layer.CreatedBy || layer.created_by || ''; const size = layer.Size != null ? formatBytes(layer.Size) : ''; const tags = layer.Tags?.length ? layer.Tags.join(', ') : ''; html += `
  • #${index} ${created} ${size ? `· ${size}` : ''}
    ${tags ? `
    ${tags}
    ` : ''}
    ${createdBy || '—'}
  • `; }); html += '
'; content.innerHTML = html; } function populateImageOverviewSection(config) { const content = document.getElementById('image-overview-content'); if (!content) return; const id = config.Id || 'Unknown'; const tags = config.RepoTags || []; const size = config.Size || 0; const virtualSize = config.VirtualSize || 0; const created = config.Created ? new Date(config.Created).toLocaleString() : 'Unknown'; const architecture = config.Architecture || 'Unknown'; const os = config.Os || 'Unknown'; const formatSize = (bytes) => { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; }; let html = ''; html += renderKeyValue('ID', id.substring(0, 12)); html += renderKeyValue('Tags', tags.length > 0 ? tags : ['']); html += renderKeyValue('Size', formatSize(size)); html += renderKeyValue('Virtual Size', formatSize(virtualSize)); html += renderKeyValue('Created', created); html += renderKeyValue('Architecture', architecture); html += renderKeyValue('OS', os); content.innerHTML = html || '
No overview data available
'; } function populateImageConfigSection(config) { const content = document.getElementById('image-config-content'); if (!content) return; const cmd = config.Config?.Cmd || []; const entrypoint = config.Config?.Entrypoint || []; const env = config.Config?.Env || []; const exposedPorts = config.Config?.ExposedPorts ? Object.keys(config.Config.ExposedPorts) : []; const workingDir = config.Config?.WorkingDir || ''; const user = config.Config?.User || ''; const labels = config.Config?.Labels || {}; let html = ''; html += renderKeyValue('Command', cmd.length > 0 ? cmd.join(' ') : 'Not set'); html += renderKeyValue('Entrypoint', entrypoint.length > 0 ? entrypoint.join(' ') : 'Not set'); html += renderKeyValue('Working Directory', workingDir || 'Not set'); html += renderKeyValue('User', user || 'Default'); html += renderKeyValue('Environment Variables', env.length > 0 ? env : ['None']); html += renderKeyValue('Exposed Ports', exposedPorts.length > 0 ? exposedPorts : ['None']); const labelList = Object.keys(labels).map(key => `${key}=${labels[key]}`); html += renderKeyValue('Labels', labelList.length > 0 ? labelList : ['None']); content.innerHTML = html || '
No configuration data available
'; } function populateImageHistorySection(config) { const content = document.getElementById('image-history-content'); if (!content) return; const rootfs = config.RootFS || {}; const layers = rootfs.Layers || []; const history = config.History || []; let html = ''; if (layers.length > 0) { html += '
Layers:
'; html += '
    '; layers.forEach((layer, index) => { html += `
  • ${layer.substring(0, 20)}...
  • `; }); html += '
'; } if (history.length > 0) { html += '
History:
'; history.forEach((h, index) => { if (h.created) { html += `
${new Date(h.created * 1000).toLocaleString()}:
`; html += `
${h.created_by || 'Unknown command'}
`; } }); } content.innerHTML = html || '
No history or layer data available
'; } // ============ Network Inspect Functions ============ function openNetworkInspectModal(networkId) { console.log(`[INFO] Opening Network Inspect Modal for network: ${networkId}`); showStatusIndicator('Fetching network information...'); sendCommand('inspectNetwork', { id: networkId }); window.pendingNetworkInspect = { networkId }; } function formatAndPopulateNetworkModal(config) { if (!config) return; // Store config for JSON view window.currentNetworkInspectConfig = config; // Populate Overview populateNetworkOverviewSection(config); // Populate Configuration populateNetworkConfigSection(config); // Populate Containers populateNetworkContainersSection(config); } function populateNetworkOverviewSection(config) { const content = document.getElementById('network-overview-content'); if (!content) return; const id = config.Id || 'Unknown'; const name = config.Name || 'Unknown'; const driver = config.Driver || 'Unknown'; const scope = config.Scope || 'local'; const created = config.Created ? new Date(config.Created).toLocaleString() : 'Unknown'; const internal = config.Internal || false; const attachable = config.Attachable || false; let html = ''; html += renderKeyValue('ID', id.substring(0, 12)); html += renderKeyValue('Name', name); html += renderKeyValue('Driver', driver); html += renderKeyValue('Scope', scope); html += renderKeyValue('Created', created); html += renderKeyValue('Internal', internal); html += renderKeyValue('Attachable', attachable); content.innerHTML = html || '
No overview data available
'; } function populateNetworkConfigSection(config) { const content = document.getElementById('network-config-content'); if (!content) return; const ipam = config.IPAM || {}; const ipamConfig = ipam.Config || []; const options = config.Options || {}; const labels = config.Labels || {}; const enableIPv6 = config.EnableIPv6 || false; let html = ''; if (ipamConfig.length > 0) { html += '
IPAM Configuration:
'; ipamConfig.forEach((ipam, index) => { if (ipam.Subnet) html += renderKeyValue(`Subnet ${index + 1}`, ipam.Subnet); if (ipam.Gateway) html += renderKeyValue(`Gateway ${index + 1}`, ipam.Gateway); if (ipam.IPRange) html += renderKeyValue(`IP Range ${index + 1}`, ipam.IPRange); }); } html += renderKeyValue('Enable IPv6', enableIPv6); html += renderKeyValue('Options', Object.keys(options).length > 0 ? options : {}); const labelList = Object.keys(labels).map(key => `${key}=${labels[key]}`); html += renderKeyValue('Labels', labelList.length > 0 ? labelList : ['None']); content.innerHTML = html || '
No configuration data available
'; } function populateNetworkContainersSection(config) { const content = document.getElementById('network-containers-content'); if (!content) return; const containers = config.Containers || {}; const containerList = Object.keys(containers).map(key => { const container = containers[key]; return `${container.Name || key}: ${container.IPv4Address || 'No IP'}`; }); let html = ''; html += renderKeyValue('Connected Containers', containerList.length > 0 ? containerList : ['None']); content.innerHTML = html || '
No container data available
'; } // ============ Volume Inspect Functions ============ function openVolumeInspectModal(volumeName) { console.log(`[INFO] Opening Volume Inspect Modal for volume: ${volumeName}`); showStatusIndicator('Fetching volume information...'); sendCommand('inspectVolume', { name: volumeName }); window.pendingVolumeInspect = { volumeName }; } function formatAndPopulateVolumeModal(config) { if (!config) return; // Store config for JSON view window.currentVolumeInspectConfig = config; // Populate Overview populateVolumeOverviewSection(config); // Populate Configuration populateVolumeConfigSection(config); // Populate Usage (if available from volumes store) populateVolumeUsageSection(config); } function populateVolumeOverviewSection(config) { const content = document.getElementById('volume-overview-content'); if (!content) return; const name = config.Name || 'Unknown'; const driver = config.Driver || 'Unknown'; const mountpoint = config.Mountpoint || 'Unknown'; const created = config.CreatedAt ? new Date(config.CreatedAt).toLocaleString() : 'Unknown'; const scope = config.Scope || 'local'; let html = ''; html += renderKeyValue('Name', name); html += renderKeyValue('Driver', driver); html += renderKeyValue('Mountpoint', mountpoint); html += renderKeyValue('Created', created); html += renderKeyValue('Scope', scope); content.innerHTML = html || '
No overview data available
'; } function populateVolumeConfigSection(config) { const content = document.getElementById('volume-config-content'); if (!content) return; const options = config.Options || {}; const labels = config.Labels || {}; let html = ''; html += renderKeyValue('Driver Options', Object.keys(options).length > 0 ? options : {}); const labelList = Object.keys(labels).map(key => `${key}=${labels[key]}`); html += renderKeyValue('Labels', labelList.length > 0 ? labelList : ['None']); content.innerHTML = html || '
No configuration data available
'; } function populateVolumeUsageSection(config) { const content = document.getElementById('volume-usage-content'); if (!content) return; // Try to get usage from volumes store const volumesStore = window.volumesStore; let usage = []; if (volumesStore && volumesStore.data) { const volume = volumesStore.data.find(v => v.Name === config.Name); if (volume && volume.Usage) { usage = volume.Usage.map(u => `${u.containerName} (${u.mountPoint})`); } } let html = ''; html += renderKeyValue('Used By Containers', usage.length > 0 ? usage : ['Not in use']); content.innerHTML = html || '
No usage data available
'; } // ============ Stack Inspect Functions ============ function openStackInspectModal(stackName) { console.log(`[INFO] Opening Stack Inspect Modal for stack: ${stackName}`); showStatusIndicator('Fetching stack information...'); // Get stack data from current stacks list if (window.currentStacksData) { const stack = window.currentStacksData.find(s => s.name === stackName); if (stack) { formatAndPopulateStackModal(stack); const modal = new bootstrap.Modal(document.getElementById('stackInspectModal')); modal.show(); hideStatusIndicator(); return; } } // If not in cache, reload stacks sendCommand('listStacks'); window.pendingStackInspect = { stackName }; } function formatAndPopulateStackModal(stackData) { if (!stackData) return; // Store data for JSON view window.currentStackInspectConfig = stackData; // Populate Overview populateStackOverviewSection(stackData); // Populate Services populateStackServicesSection(stackData); // Populate Containers populateStackContainersSection(stackData); } function populateStackOverviewSection(stackData) { const content = document.getElementById('stack-overview-content'); if (!content) return; const name = stackData.name || 'Unknown'; const services = stackData.services || []; const containers = stackData.containers || []; const runningCount = containers.filter(c => c.state === 'running').length; const totalCount = containers.length; let html = ''; html += renderKeyValue('Stack Name', name); html += renderKeyValue('Services', services.length > 0 ? services.join(', ') : 'None'); html += renderKeyValue('Total Containers', totalCount); html += renderKeyValue('Running Containers', runningCount); html += renderKeyValue('Status', runningCount === totalCount && totalCount > 0 ? 'All running' : `${runningCount}/${totalCount} running`); content.innerHTML = html || '
No overview data available
'; } function populateStackServicesSection(stackData) { const content = document.getElementById('stack-services-content'); if (!content) return; const services = stackData.services || []; let html = ''; if (services.length > 0) { html += '
    '; services.forEach(service => { html += `
  • ${service}
  • `; }); html += '
'; } else { html = '
No services defined
'; } content.innerHTML = html; } function populateStackContainersSection(stackData) { const content = document.getElementById('stack-containers-content'); if (!content) return; const containers = stackData.containers || []; let html = ''; if (containers.length > 0) { html += '
    '; containers.forEach(container => { const stateBadge = container.state === 'running' ? 'text-success' : 'text-danger'; html += `
  • ${container.name} (${container.state})
    Image: ${container.image}
  • `; }); html += '
'; } else { html = '
No containers in stack
'; } content.innerHTML = html; } /** * App chrome mode: welcome (no active peer) vs workspace (connected). * Welcome is NOT a .view — it must be toggled explicitly or it stacks over content. */ function setWelcomeVisible(visible) { const el = welcomePage || document.getElementById('welcome-page'); if (!el) { console.error('[ERROR] Welcome page element not found'); return; } welcomePage = el; if (visible) { el.classList.remove('hidden', 'is-hidden'); el.removeAttribute('hidden'); el.style.display = ''; } else { el.classList.add('hidden', 'is-hidden'); el.setAttribute('hidden', ''); el.style.display = 'none'; } } function hasActiveConnection() { return Boolean(manager.active?.connected || window.activePeer?.connected); } function showWelcomePage() { setWelcomeVisible(true); // Hide all workspace views while onboarding document.querySelectorAll('.view').forEach((view) => { view.classList.add('hidden'); }); if (connectionTitle) { connectionTitle.textContent = ''; } } function hideWelcomePage() { setWelcomeVisible(false); // Enter workspace on dashboard (or keep current view if already set) const target = currentView && currentView !== 'welcome' ? currentView : 'dashboard'; navigateToView(target, { skipWelcomeGate: true }); } function assertVisibility() { const el = welcomePage || document.getElementById('welcome-page'); const dash = document.getElementById('dashboard-view'); if (!el) { console.warn('[WARN] Cannot assert visibility: welcome page missing'); return; } const welcomeHidden = el.classList.contains('hidden') || el.hasAttribute('hidden') || el.style.display === 'none'; if (!hasActiveConnection()) { console.assert(!welcomeHidden, '[ASSERTION FAILED] Welcome page should be visible when disconnected.'); } else { console.assert(welcomeHidden, '[ASSERTION FAILED] Welcome page should be hidden when connected.'); if (dash) { console.assert( !dash.classList.contains('hidden') || currentView !== 'dashboard', '[ASSERTION FAILED] Dashboard should be visible when connected on dashboard view.' ); } } } // Attach startTerminal to the global window object window.startTerminal = startTerminal; // Close live sockets on quit — do NOT forget peers (that was wiping peers.json) window.addEventListener('beforeunload', () => { manager.disconnectAll({ forget: false }).catch(() => {}); }); // Pear / Electron may fire pagehide when the window is destroyed window.addEventListener('pagehide', () => { manager.disconnectAll({ forget: false }).catch(() => {}); }); // Initialize Inspect Modal Event Listeners document.addEventListener('DOMContentLoaded', () => { // Container Inspect Modal - Toggle JSON View const toggleJsonViewBtn = document.getElementById('toggle-json-view'); if (toggleJsonViewBtn) { toggleJsonViewBtn.addEventListener('click', () => { const formattedView = document.getElementById('inspect-formatted-view'); const jsonView = document.getElementById('inspect-json-view'); const jsonContent = document.getElementById('inspect-json-content'); if (formattedView.style.display === 'none') { formattedView.style.display = 'block'; jsonView.style.display = 'none'; toggleJsonViewBtn.innerHTML = ' Raw JSON'; } else { formattedView.style.display = 'none'; jsonView.style.display = 'block'; toggleJsonViewBtn.innerHTML = ' Formatted View'; if (window.currentInspectConfig && jsonContent.textContent === '') { jsonContent.textContent = JSON.stringify(window.currentInspectConfig, null, 2); } } }); } // Container Inspect Modal - Copy JSON to Clipboard const copyJsonBtn = document.getElementById('copy-json-btn'); if (copyJsonBtn) { copyJsonBtn.addEventListener('click', async () => { if (!window.currentInspectConfig) { showAlert('warning', 'No container configuration available to copy.'); return; } try { const jsonString = JSON.stringify(window.currentInspectConfig, null, 2); await navigator.clipboard.writeText(jsonString); showAlert('success', 'Container configuration copied to clipboard!'); } catch (error) { console.error('[ERROR] Failed to copy to clipboard:', error); showAlert('danger', 'Failed to copy to clipboard.'); } }); } // Image Inspect Modal - Toggle JSON View const toggleImageJsonViewBtn = document.getElementById('toggle-image-json-view'); if (toggleImageJsonViewBtn) { toggleImageJsonViewBtn.addEventListener('click', () => { const formattedView = document.getElementById('image-inspect-formatted-view'); const jsonView = document.getElementById('image-inspect-json-view'); const jsonContent = document.getElementById('image-inspect-json-content'); if (formattedView.style.display === 'none') { formattedView.style.display = 'block'; jsonView.style.display = 'none'; toggleImageJsonViewBtn.innerHTML = ' Raw JSON'; } else { formattedView.style.display = 'none'; jsonView.style.display = 'block'; toggleImageJsonViewBtn.innerHTML = ' Formatted View'; if (window.currentImageInspectConfig && jsonContent.textContent === '') { jsonContent.textContent = JSON.stringify(window.currentImageInspectConfig, null, 2); } } }); } // Image Inspect Modal - Copy JSON const copyImageJsonBtn = document.getElementById('copy-image-json-btn'); if (copyImageJsonBtn) { copyImageJsonBtn.addEventListener('click', async () => { if (!window.currentImageInspectConfig) { showAlert('warning', 'No image configuration available to copy.'); return; } try { await navigator.clipboard.writeText(JSON.stringify(window.currentImageInspectConfig, null, 2)); showAlert('success', 'Image configuration copied to clipboard!'); } catch (err) { console.error('Failed to copy image configuration:', err); showAlert('danger', 'Failed to copy image configuration.'); } }); } // Network Inspect Modal - Toggle JSON View const toggleNetworkJsonViewBtn = document.getElementById('toggle-network-json-view'); if (toggleNetworkJsonViewBtn) { toggleNetworkJsonViewBtn.addEventListener('click', () => { const formattedView = document.getElementById('network-inspect-formatted-view'); const jsonView = document.getElementById('network-inspect-json-view'); const jsonContent = document.getElementById('network-inspect-json-content'); if (formattedView.style.display === 'none') { formattedView.style.display = 'block'; jsonView.style.display = 'none'; toggleNetworkJsonViewBtn.innerHTML = ' Raw JSON'; } else { formattedView.style.display = 'none'; jsonView.style.display = 'block'; toggleNetworkJsonViewBtn.innerHTML = ' Formatted View'; if (window.currentNetworkInspectConfig && jsonContent.textContent === '') { jsonContent.textContent = JSON.stringify(window.currentNetworkInspectConfig, null, 2); } } }); } // Network Inspect Modal - Copy JSON const copyNetworkJsonBtn = document.getElementById('copy-network-json-btn'); if (copyNetworkJsonBtn) { copyNetworkJsonBtn.addEventListener('click', async () => { if (!window.currentNetworkInspectConfig) { showAlert('warning', 'No network configuration available to copy.'); return; } try { await navigator.clipboard.writeText(JSON.stringify(window.currentNetworkInspectConfig, null, 2)); showAlert('success', 'Network configuration copied to clipboard!'); } catch (err) { console.error('Failed to copy network configuration:', err); showAlert('danger', 'Failed to copy network configuration.'); } }); } // Volume Inspect Modal - Toggle JSON View const toggleVolumeJsonViewBtn = document.getElementById('toggle-volume-json-view'); if (toggleVolumeJsonViewBtn) { toggleVolumeJsonViewBtn.addEventListener('click', () => { const formattedView = document.getElementById('volume-inspect-formatted-view'); const jsonView = document.getElementById('volume-inspect-json-view'); const jsonContent = document.getElementById('volume-inspect-json-content'); if (formattedView.style.display === 'none') { formattedView.style.display = 'block'; jsonView.style.display = 'none'; toggleVolumeJsonViewBtn.innerHTML = ' Raw JSON'; } else { formattedView.style.display = 'none'; jsonView.style.display = 'block'; toggleVolumeJsonViewBtn.innerHTML = ' Formatted View'; if (window.currentVolumeInspectConfig && jsonContent.textContent === '') { jsonContent.textContent = JSON.stringify(window.currentVolumeInspectConfig, null, 2); } } }); } // Volume Inspect Modal - Copy JSON const copyVolumeJsonBtn = document.getElementById('copy-volume-json-btn'); if (copyVolumeJsonBtn) { copyVolumeJsonBtn.addEventListener('click', async () => { if (!window.currentVolumeInspectConfig) { showAlert('warning', 'No volume configuration available to copy.'); return; } try { await navigator.clipboard.writeText(JSON.stringify(window.currentVolumeInspectConfig, null, 2)); showAlert('success', 'Volume configuration copied to clipboard!'); } catch (err) { console.error('Failed to copy volume configuration:', err); showAlert('danger', 'Failed to copy volume configuration.'); } }); } // Stack Inspect Modal - Toggle JSON View const toggleStackJsonViewBtn = document.getElementById('toggle-stack-json-view'); if (toggleStackJsonViewBtn) { toggleStackJsonViewBtn.addEventListener('click', () => { const formattedView = document.getElementById('stack-inspect-formatted-view'); const jsonView = document.getElementById('stack-inspect-json-view'); const jsonContent = document.getElementById('stack-inspect-json-content'); if (formattedView.style.display === 'none') { formattedView.style.display = 'block'; jsonView.style.display = 'none'; toggleStackJsonViewBtn.innerHTML = ' Raw JSON'; } else { formattedView.style.display = 'none'; jsonView.style.display = 'block'; toggleStackJsonViewBtn.innerHTML = ' Formatted View'; if (window.currentStackInspectConfig && jsonContent.textContent === '') { jsonContent.textContent = JSON.stringify(window.currentStackInspectConfig, null, 2); } } }); } // Stack Inspect Modal - Copy JSON const copyStackJsonBtn = document.getElementById('copy-stack-json-btn'); if (copyStackJsonBtn) { copyStackJsonBtn.addEventListener('click', async () => { if (!window.currentStackInspectConfig) { showAlert('warning', 'No stack configuration available to copy.'); return; } try { await navigator.clipboard.writeText(JSON.stringify(window.currentStackInspectConfig, null, 2)); showAlert('success', 'Stack configuration copied to clipboard!'); } catch (err) { console.error('Failed to copy stack configuration:', err); showAlert('danger', 'Failed to copy stack configuration.'); } }); } }); /** * Initialize notification tray */ function initNotificationTray() { // Guard: prevent multiple initializations if (notificationTrayInitialized) { return; } const trayToggle = document.getElementById('notification-tray-toggle'); const notificationPanel = document.getElementById('notification-panel'); const closePanelBtn = document.getElementById('close-panel-btn'); const markAllReadBtn = document.getElementById('mark-all-read-btn'); const clearAllBtn = document.getElementById('clear-all-btn'); const notificationList = document.getElementById('notification-list'); const notificationBadge = document.getElementById('notification-badge'); const filterButtons = document.querySelectorAll('.notification-filter-btn'); if (!trayToggle || !notificationPanel) { console.warn('[WARN] Notification tray elements not found.'); return; } // Mark as initialized before setting up event listeners notificationTrayInitialized = true; let currentFilter = 'all'; // Format timestamp for display function formatTimestamp(timestamp) { const now = new Date(); const diff = now - timestamp; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) return `${days}d ago`; if (hours > 0) return `${hours}h ago`; if (minutes > 0) return `${minutes}m ago`; return 'Just now'; } // Get icon for notification type function getNotificationIcon(type) { const icons = { success: 'fa-check-circle', danger: 'fa-exclamation-circle', warning: 'fa-exclamation-triangle', info: 'fa-info-circle' }; return icons[type] || 'fa-bell'; } // Render notifications function renderNotifications() { const notifications = notificationManager.getNotifications({ type: currentFilter }); if (notifications.length === 0) { notificationList.innerHTML = `

No notifications

`; return; } notificationList.innerHTML = notifications.map(notification => { const icon = getNotificationIcon(notification.type); const timestamp = formatTimestamp(notification.timestamp); const unreadClass = notification.read ? '' : 'unread'; return `

${escapeHtml(notification.message)}

${timestamp}

${!notification.read ? ` ` : ''}
`; }).join(''); } // Escape HTML to prevent XSS function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Update badge — only badge-worthy unread (danger/warning / explicit badge:true) function updateBadge() { const badgeCount = typeof notificationManager.getBadgeCount === 'function' ? notificationManager.getBadgeCount() : notificationManager.getUnreadCount(); if (badgeCount > 0) { notificationBadge.textContent = badgeCount > 99 ? '99+' : badgeCount; notificationBadge.style.display = 'flex'; } else { notificationBadge.textContent = ''; notificationBadge.style.display = 'none'; } } // Toggle panel — do not auto-mark-all-read (preserve unread until explicit action) function togglePanel() { notificationPanel.classList.toggle('hidden'); if (!notificationPanel.classList.contains('hidden')) { renderNotifications(); updateBadge(); } } // Handle notification actions using event delegation notificationList.addEventListener('click', (e) => { const target = e.target.closest('.notification-action'); if (!target) return; const notificationId = target.dataset.id; if (!notificationId) return; if (target.classList.contains('mark-read-btn')) { notificationManager.markAsRead(notificationId); renderNotifications(); updateBadge(); } else if (target.classList.contains('dismiss-btn')) { notificationManager.remove(notificationId); renderNotifications(); updateBadge(); } }); // Event listeners trayToggle.addEventListener('click', (e) => { e.stopPropagation(); togglePanel(); }); if (closePanelBtn) { closePanelBtn.addEventListener('click', () => { notificationPanel.classList.add('hidden'); }); } if (markAllReadBtn) { markAllReadBtn.addEventListener('click', () => { notificationManager.markAllAsRead(); renderNotifications(); updateBadge(); }); } if (clearAllBtn) { clearAllBtn.addEventListener('click', () => { showConfirmModal('Are you sure you want to clear all notifications?', () => { notificationManager.clearAll(); renderNotifications(); updateBadge(); }); }); } // Filter buttons filterButtons.forEach(btn => { btn.addEventListener('click', () => { filterButtons.forEach(b => b.classList.remove('active')); btn.classList.add('active'); currentFilter = btn.dataset.filter; renderNotifications(); }); }); // Close panel when clicking outside document.addEventListener('click', (e) => { if (!notificationPanel.classList.contains('hidden') && !notificationPanel.contains(e.target) && !trayToggle.contains(e.target)) { notificationPanel.classList.add('hidden'); } }); // Subscribe to notification changes notificationManager.subscribe((notifications, unreadCount) => { updateBadge(); if (!notificationPanel.classList.contains('hidden')) { renderNotifications(); } }); // Initial render (will trigger storage load if needed) // Use a small delay to ensure DOM is ready setTimeout(() => { updateBadge(); renderNotifications(); }, 0); }