import { manager, Methods } from './client/manager.js'; import { loadPeers, savePeers, clearPeers, getPeersCachePath, getLastActivePeerId, setLastActivePeerId, } from './client/peerCache.js'; import { topicIdsMatch, pickPreferredActivePeer, orderPeersForBoot, } from './client/peerActive.js'; import { startTerminal, appendTerminalOutput } from './libs/terminal.js'; import { startDockerTerminal, cleanUpDockerTerminal } from './libs/dockerTerminal.js'; import { openPopoutTerminal, handlePopoutTerminalOutput, findPopoutForContainer, focusPopoutTerminal, closeAllPopoutTerminals, } from './libs/popoutTerminals.js'; import { fetchTemplates, displayTemplateList, openDeployModal, collectDuplicateFormData, populateDuplicateForm, initTemplateDeployer, filterTemplatesByQuery, } from './libs/templateDeploy.js'; import { initAddContainerPage } from './libs/addContainer.js'; import { initRegistryManager, openPushImageModal, pullImageWithAuth } from './libs/registryManager.js'; import { initContainerFlattener, openContainerFlattenerModal } from './libs/containerFlattener.js'; import { showContainerSkeleton, createProgressBar, updateProgressBar, removeProgressBar } from './libs/loadingStates.js'; import { closeAllModals, showStatusIndicator, hideStatusIndicator, updateStatusIndicator, showAlert, jobSpinnerHtml, jobSpinnerPending, jobSpinnerLoadingBlock, } from './libs/uiUtils.js'; import notificationManager from './libs/notifications.js'; import { initOpsApp } from './ui/ops-app.js'; import { initAllTableColumnPickers } from './ui/tableColumns.js'; import { presentError, formatResponseError, isBackgroundMethod } from './client/errors.js'; import { warmSnapshot } from './client/snapshot.js'; import { fetchMergedTemplates, getTemplateListUrls, clearMergedTemplateCache, } from './client/templateLists.js'; import { loadSettingsBlob, saveSettingsBlob } from './client/settingsCache.js'; import { getTerminalCtor, getFitAddonCtor, defaultXtermOptions, decodePayload, createFitController, safeFit, waitForTerminalHostSize, applyXtermPalette, } from './libs/xtermUtils.js'; import { createInputCoalescer } from './libs/termInput.js'; // Global RPC push / response routing manager.on('message', (msg, conn) => { handleRpcMessage(msg, conn); }); /** True while boot is re-dialing saved peers (suppress welcome flash) */ let isBootRestoring = false; /** * Bind a live manager connection into the UI `connections` map and refresh peer chrome. * Used after first connect and after automatic reconnect (which previously left peer=null). * @param {import('./client/connection.js').PearDockConnection|null|undefined} conn */ function bindLivePeer(conn) { if (!conn?.id || !conn.publicKeyHex) return; const topicId = conn.id; if (!connections[topicId]) { connections[topicId] = { publicKeyHex: conn.publicKeyHex, topicHex: conn.publicKeyHex, peer: null, alias: conn.alias || null, inviteToken: null, capability: conn.capability || null, adminSeed: conn.adminSeed || null, connectedAt: null, lastHealthCheck: null, latency: null, healthStatus: 'unknown', }; } const entry = connections[topicId]; entry.peer = conn; entry.publicKeyHex = conn.publicKeyHex || entry.publicKeyHex; entry.connectedAt = conn.connectedAt || Date.now(); entry.healthStatus = conn.healthStatus || 'healthy'; entry.latency = conn.latency ?? entry.latency; if (conn.alias && !entry.alias) entry.alias = conn.alias; if (conn.capability) entry.capability = conn.capability; updateConnectionStatus(topicId, true); updateConnectionDisplay(topicId); // Sync health UI from live peer (manager health loop owns pings — no second interval) startHealthMonitoring(topicId); } manager.on('disconnect', (conn) => { const topicId = conn?.id; if (topicId && connections[topicId]) { updateConnectionStatus(topicId, false); connections[topicId].peer = null; connections[topicId].healthStatus = 'disconnected'; if (connections[topicId].healthCheckInterval) { clearInterval(connections[topicId].healthCheckInterval); connections[topicId].healthCheckInterval = null; } updateConnectionDisplay(topicId); } if (!manager.active?.connected) { updateHealthBadge(null); lastStatsInterestSent = null; lastStatsInterestPeerId = null; } refreshFleetIfVisible(); if (isBootRestoring) return; // While auto-reconnect is armed, stay in the workspace and show status — // do not bounce to the welcome page (that looked like a permanent drop). if (!hasActiveConnection()) { resetContainerList(); stopStatsInterval(); lastStatsInterestSent = null; lastStatsInterestPeerId = null; if (typeof manager.isReconnecting === 'function' && manager.isReconnecting()) { // reconnecting banner is driven by 'reconnecting' events return; } showWelcomePage(); } }); manager.on('health', (info, conn) => { if (!conn || conn === manager.active) { updateHealthBadge(info, conn || manager.active); } if (conn?.id && connections[conn.id]) { connections[conn.id].latency = info?.latency ?? connections[conn.id].latency; connections[conn.id].healthStatus = info?.status || connections[conn.id].healthStatus; connections[conn.id].lastHealthCheck = Date.now(); updateConnectionDisplay(conn.id); } }); manager.on('connect', (conn) => { if (!conn) return; bindLivePeer(conn); // Header health badge is for the active server only — never a background restore peer if (manager.active && (conn === manager.active || conn.id === manager.active.id)) { updateHealthBadge( { latency: conn.latency, docker: conn.dockerHealth, status: conn.healthStatus }, conn ); // Re-declare view interest after (re)connect so stats streams resume if needed lastStatsInterestSent = null; lastStatsInterestPeerId = null; try { syncStatsInterest({ force: true }); } catch { // ignore } } }); manager.on('active', (conn) => { // Switching fleet peer: re-send interest to the new active host lastStatsInterestSent = null; lastStatsInterestPeerId = null; if (conn?.connected) { try { syncStatsInterest({ force: true }); } catch { // ignore } } }); /** Throttle reconnect notices (standard mode only) */ let lastReconnectToastAt = 0; /** * Jobs tray + reconnect feedback prefs (Settings → Jobs tray). * Falls back to minimal defaults if ops shell is not ready. */ function jobsTrayPrefs() { try { if (typeof window !== 'undefined' && window.peardockOps?.getJobsTrayPrefs) { return window.peardockOps.getJobsTrayPrefs(); } if (typeof window !== 'undefined' && window.__peardockSettings) { const s = window.__peardockSettings; return { reconnectFeedback: s.reconnectFeedback || 'minimal', reconnectNotifySuccess: s.reconnectNotifySuccess !== false, reconnectNotifyFailure: s.reconnectNotifyFailure !== false, launchConnectNotify: s.launchConnectNotify !== false, jobActivityInTray: s.jobActivityInTray !== false, }; } } catch { // ignore } return { reconnectFeedback: 'minimal', reconnectNotifySuccess: true, reconnectNotifyFailure: true, launchConnectNotify: true, jobActivityInTray: true, }; } manager.on('reconnecting', ({ id, attempt, maxAttempts, delayMs }) => { const secs = Math.max(1, Math.round((delayMs || 5000) / 1000)); const label = (id && connections[id] && peerDisplayName(connections[id], id)) || (id ? `${String(id).slice(0, 8)}…` : 'peer'); const attemptLabel = maxAttempts != null ? `${attempt}/${maxAttempts}` : String(attempt); const prefs = jobsTrayPrefs(); const mode = prefs.reconnectFeedback || 'minimal'; // Fleet / list always update; drawer + bell depend on feedback level if (mode === 'standard') { updateStatusIndicator( `Reconnecting to ${label} (attempt ${attemptLabel})… next try in ${secs}s` ); } if (id && connections[id]) { connections[id].healthStatus = 'connecting'; updateConnectionStatus(id, false); updateConnectionDisplay(id); } refreshFleetIfVisible(); if (mode === 'silent' || typeof showAlert !== 'function') return; const now = Date.now(); if (mode === 'minimal') { // One quiet bell on first loss only — no periodic spam, no job tray panel if (attempt === 1) { showAlert( 'warning', `Connection lost — retrying ${label} in the background…`, { toast: false, badge: true } ); } return; } // standard: first attempt + at most once per minute thereafter if (attempt === 1 || now - lastReconnectToastAt > 60_000) { lastReconnectToastAt = now; showAlert( 'warning', attempt === 1 ? `Connection lost — retrying ${label} every 5 seconds${maxAttempts != null ? ` (max ${maxAttempts})` : ''}…` : `Still reconnecting to ${label} (attempt ${attemptLabel})…` ); } }); manager.on('reconnected', ({ id, connection } = {}) => { lastReconnectToastAt = 0; hideStatusIndicator(); const conn = connection || (id && manager.connections.get(id)) || manager.active; if (conn) bindLivePeer(conn); refreshFleetIfVisible(); const prefs = jobsTrayPrefs(); if ( prefs.reconnectFeedback !== 'silent' && prefs.reconnectNotifySuccess !== false && typeof showAlert === 'function' ) { const label = (id && connections[id] && peerDisplayName(connections[id], id)) || 'peardock server'; showAlert('success', `Reconnected to ${label}`, { toast: false }); } if (hasActiveConnection()) { hideWelcomePage(); hideRestoringPage(); startStatsInterval(); warmSnapshot(); applyRoleUI(); sendCommand(Methods.listContainers); } }); manager.on('reconnect-failed', ({ id, attempts, maxAttempts } = {}) => { // Fires when max tries reached (Settings → Behavior) or hard auth deny hideStatusIndicator(); if (id && connections[id]) { connections[id].healthStatus = 'error'; updateConnectionStatus(id, false); updateConnectionDisplay(id); } refreshFleetIfVisible(); const prefs = jobsTrayPrefs(); if ( prefs.reconnectFeedback !== 'silent' && prefs.reconnectNotifyFailure !== false && typeof showAlert === 'function' ) { const n = attempts != null && maxAttempts != null ? `${attempts}/${maxAttempts}` : attempts; showAlert( 'danger', n ? `Could not reconnect after ${n} tries. Host stays offline in Fleet — use Reconnect.` : 'Could not reconnect. Host stays offline in Fleet — use Reconnect.', { toast: false, badge: true } ); } updateHealthBadge(null); if (!isBootRestoring && !hasActiveConnection()) { showWelcomePage(); } }); // 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(); /** * Legacy no-ops: live container stats arrive via push:allStats from the server. * Kept as named exports so call sites stay stable without a 500ms wake timer. */ function stopStatsInterval() { if (statsInterval) { clearInterval(statsInterval); statsInterval = null; } } function startStatsInterval() { // Clear any leftover timer from older sessions; do not schedule a new one. stopStatsInterval(); } /** * Views that show live per-container CPU/memory (table or dashboard KPIs). * Server only opens Docker stats streams while ≥1 peer reports interest. */ const STATS_INTEREST_VIEWS = new Set(['containers', 'dashboard', 'container-details']); /** @type {boolean|null} last interest sent for active peer (avoid spam) */ let lastStatsInterestSent = null; /** @type {string|null} peer id we last told */ let lastStatsInterestPeerId = null; /** * Tell the active peer whether this UI needs live fleet stats. * Call on view change and after connect. Leaving Containers/Dashboard tears * down server-side Docker stats streams for this client. * @param {{ force?: boolean }} [opts] */ function syncStatsInterest(opts = {}) { const force = opts.force === true; const conn = manager.active; if (!conn?.connected) { lastStatsInterestSent = null; lastStatsInterestPeerId = null; return; } const view = typeof currentView === 'string' ? currentView : typeof window !== 'undefined' && typeof window.currentView === 'string' ? window.currentView : ''; const want = STATS_INTEREST_VIEWS.has(view); if ( !force && lastStatsInterestSent === want && lastStatsInterestPeerId === conn.id ) { return; } lastStatsInterestSent = want; lastStatsInterestPeerId = conn.id; try { conn .request(Methods.setStatsInterest || 'setStatsInterest', { active: want, view: view || undefined, }) .catch(() => { // older servers may lack the method — ignore lastStatsInterestSent = null; }); } catch { lastStatsInterestSent = null; } } if (typeof window !== 'undefined') { window.syncStatsInterest = syncStatsInterest; } // 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(); }); } }); const smoothedStats = {}; // Container-specific smoothing storage const historicalStats = {}; // Container-specific historical stats for charts const MAX_HISTORY_POINTS = 900; // ~30m at 2s broadcast interval /** Advanced container-details stats tab state */ const detailsStatsState = { timeframeSec: 60, paused: false, autoscale: true, containerId: null, wired: false, charts: { cpu: null, memory: null, net: null, disk: null }, }; function emptyHistorySeries() { return { timestamps: [], cpu: [], memory: [], memoryLimit: [], netRxRate: [], netTxRate: [], blkReadRate: [], blkWriteRate: [], }; } function ensureHistorySeries(containerId) { if (!historicalStats[containerId]) { historicalStats[containerId] = emptyHistorySeries(); } const h = historicalStats[containerId]; for (const k of Object.keys(emptyHistorySeries())) { if (!Array.isArray(h[k])) h[k] = []; } return h; } function smoothIoField(obj, key, value, factor) { const n = Number(value); if (!Number.isFinite(n)) return; if (!Number.isFinite(obj[key])) obj[key] = n; else obj[key] = obj[key] * (1 - factor) + n * factor; } function smoothStats(containerId, newStats, smoothingFactor = 0.2) { if (!smoothedStats[containerId]) { smoothedStats[containerId] = { cpu: 0, memory: 0, memoryLimit: 0, netRxRate: 0, netTxRate: 0, blkReadRate: 0, blkWriteRate: 0, ip: newStats.ip || 'No IP Assigned', }; } const s = smoothedStats[containerId]; const cpu = Number(newStats.cpu); const memory = Number(newStats.memory); if (Number.isFinite(cpu)) { s.cpu = s.cpu * (1 - smoothingFactor) + cpu * smoothingFactor; } if (Number.isFinite(memory)) { s.memory = s.memory * (1 - smoothingFactor) + memory * smoothingFactor; } s.ip = newStats.ip || s.ip; const lim = Number(newStats.memoryLimit); if (Number.isFinite(lim) && lim > 0) s.memoryLimit = lim; // Rates: light smoothing so charts stay readable smoothIoField(s, 'netRxRate', newStats.netRxRate, 0.35); smoothIoField(s, 'netTxRate', newStats.netTxRate, 0.35); smoothIoField(s, 'blkReadRate', newStats.blkReadRate, 0.35); smoothIoField(s, 'blkWriteRate', newStats.blkWriteRate, 0.35); const history = ensureHistorySeries(containerId); const now = Date.now(); history.timestamps.push(now); history.cpu.push(s.cpu); history.memory.push(s.memory); history.memoryLimit.push(s.memoryLimit || 0); history.netRxRate.push(s.netRxRate || 0); history.netTxRate.push(s.netTxRate || 0); history.blkReadRate.push(s.blkReadRate || 0); history.blkWriteRate.push(s.blkWriteRate || 0); while (history.timestamps.length > MAX_HISTORY_POINTS) { history.timestamps.shift(); history.cpu.shift(); history.memory.shift(); history.memoryLimit.shift(); history.netRxRate.shift(); history.netTxRate.shift(); history.blkReadRate.shift(); history.blkWriteRate.shift(); } if ( currentView === 'container-details' && currentContainerDetails && currentContainerDetails.Id === containerId && !detailsStatsState.paused ) { const statsPane = document.getElementById('stats-pane'); if (statsPane?.classList.contains('active') || statsPane?.classList.contains('show')) { updateContainerDetailsStatsLive(containerId); } } return s; } 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, capability: entry.capability || null, adminSeed: entry.adminSeed || 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); const adminSeed = entry.adminSeed && /^[0-9a-f]{64}$/i.test(String(entry.adminSeed)) ? String(entry.adminSeed).toLowerCase() : null; result[id] = { publicKeyHex, topicHex: publicKeyHex, alias: entry.alias || null, inviteToken: entry.inviteToken || null, capability: entry.capability || null, adminSeed, 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, capability, adminSeed } = connections[topicId]; const key = publicKeyHex || topicHex; if (!key) continue; const prev = serializableConnections[topicId] || {}; const seed = (adminSeed && /^[0-9a-f]{64}$/i.test(String(adminSeed)) ? String(adminSeed).toLowerCase() : null) || (prev.adminSeed && /^[0-9a-f]{64}$/i.test(String(prev.adminSeed)) ? String(prev.adminSeed).toLowerCase() : null); serializableConnections[topicId] = { publicKeyHex: key, topicHex: key, alias: alias || null, inviteToken: inviteToken || null, capability: capability || null, ...(seed ? { adminSeed: seed } : {}), }; } // 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; /** Default containers list filter: Running only, sorted by CPU high→low. */ const CONTAINER_FILTER_DEFAULTS = Object.freeze({ search: '', status: 'running', sort: 'cpu-desc', }); /** Legacy localStorage key — migrated into settings.json once, then removed. */ const CONTAINER_FILTER_LEGACY_LS_KEY = 'peardock.containers.filters'; const CONTAINER_FILTER_STATUSES = new Set([ 'all', 'running', 'exited', 'created', 'restarting', ]); const CONTAINER_FILTER_SORTS = new Set([ 'name-asc', 'name-desc', 'cpu-desc', 'cpu-asc', 'memory-desc', 'memory-asc', ]); /** * @param {unknown} raw * @returns {{ search: string, status: string, sort: string }} */ function normalizeContainerFilters(raw) { const src = raw && typeof raw === 'object' ? raw : {}; return { search: typeof src.search === 'string' ? src.search : CONTAINER_FILTER_DEFAULTS.search, status: CONTAINER_FILTER_STATUSES.has(src.status) ? src.status : CONTAINER_FILTER_DEFAULTS.status, sort: CONTAINER_FILTER_SORTS.has(src.sort) ? src.sort : CONTAINER_FILTER_DEFAULTS.sort, }; } /** * Read container filters from settings cache (~/.config/peardock/cache/settings.json). * One-time migrate from the old localStorage key if present. * @returns {{ search: string, status: string, sort: string }} */ function loadContainerFilters() { // One-time migrate legacy localStorage → settings.json try { if (typeof localStorage !== 'undefined') { const legacy = localStorage.getItem(CONTAINER_FILTER_LEGACY_LS_KEY); if (legacy) { let parsed = null; try { parsed = JSON.parse(legacy); } catch { parsed = null; } const normalized = normalizeContainerFilters(parsed); try { const s = loadSettingsBlob(); s.containerFilters = normalized; saveSettingsBlob(s); } catch (err) { console.warn( '[WARN] Failed to migrate container filters into settings cache', err?.message || err ); } try { localStorage.removeItem(CONTAINER_FILTER_LEGACY_LS_KEY); } catch { // ignore } return normalized; } } } catch { // ignore migration errors } try { if (typeof window !== 'undefined' && window.peardockOps?.loadSettings) { const s = window.peardockOps.loadSettings(); if (s?.containerFilters) return normalizeContainerFilters(s.containerFilters); } } catch { // fall through to file blob } try { const s = loadSettingsBlob(); return normalizeContainerFilters(s.containerFilters); } catch { return { search: CONTAINER_FILTER_DEFAULTS.search, status: CONTAINER_FILTER_DEFAULTS.status, sort: CONTAINER_FILTER_DEFAULTS.sort, }; } } /** * Persist container list filters into the settings cache file * (~/.config/peardock/cache/settings.json). Writes via settingsCache so the * value survives app restarts; does not go through peardockOps.saveSettings * (avoids re-applying theme/refresh on every keystroke). */ function saveContainerFilters() { const filters = { search: containerFilterState.search || '', status: containerFilterState.status || CONTAINER_FILTER_DEFAULTS.status, sort: containerFilterState.sort || CONTAINER_FILTER_DEFAULTS.sort, }; try { const s = loadSettingsBlob(); s.containerFilters = filters; saveSettingsBlob(s); // Keep in-memory mirror hot for other readers try { if (typeof window !== 'undefined' && window.__peardockSettings) { window.__peardockSettings.containerFilters = filters; } } catch { // ignore } } catch (err) { console.warn('[WARN] Failed to save container filters', err?.message || err); } } /** Sync toolbar controls from containerFilterState. */ function applyContainerFiltersToUI() { const searchInput = document.getElementById('container-search'); const statusFilter = document.getElementById('container-status-filter'); const sortSelect = document.getElementById('container-sort'); if (searchInput && searchInput.value !== (containerFilterState.search || '')) { searchInput.value = containerFilterState.search || ''; } if (statusFilter && statusFilter.value !== containerFilterState.status) { statusFilter.value = containerFilterState.status; } if (sortSelect && sortSelect.value !== containerFilterState.sort) { sortSelect.value = containerFilterState.sort; } } /** * Apply containers list status filter (UI + state) and re-render if data is ready. * Persists until Clear is used. * @param {string} status - all | running | exited | created | restarting */ function setContainerStatusFilter(status = CONTAINER_FILTER_DEFAULTS.status) { const next = status || CONTAINER_FILTER_DEFAULTS.status; containerFilterState.status = next; const statusFilter = document.getElementById('container-status-filter'); if (statusFilter) statusFilter.value = next; saveContainerFilters(); if (containerFilterState.allContainers.length) { renderContainers( containerFilterState.allContainers, containerVirt.topicId || manager.active?.id || '' ); } } window.setContainerStatusFilter = setContainerStatusFilter; /** * Dashboard KPI shortcuts → resource views (optional container status filter). * KPI status choices also persist until Clear. * @param {string} view * @param {{ status?: string }} [opts] */ function navigateDashboardKpi(view, opts = {}) { if (!view) return; if (view === 'containers') { // Set filter before navigate so first paint of containers view is correct if (opts.status) { setContainerStatusFilter(opts.status); } } navigateToView(view); } window.navigateDashboardKpi = navigateDashboardKpi; function initDashboardKpiShortcuts() { document.querySelectorAll('.dash-kpi[data-dash-nav]').forEach((el) => { el.addEventListener('click', () => { const view = el.getAttribute('data-dash-nav'); const status = el.getAttribute('data-container-status') || undefined; navigateDashboardKpi(view, status ? { status } : {}); }); }); } // 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'); // Restore last user choices (or defaults) into the toolbar applyContainerFiltersToUI(); const rerender = () => { if (!containerFilterState.allContainers.length) return; const tid = containerVirt.topicId || manager.active?.id || ''; renderContainers(containerFilterState.allContainers, tid); }; /** * Re-read container filters + peer roster from disk after backup restore. * Exposed for ops-app restore flow. */ window.rehydrateClientStateFromCache = function rehydrateClientStateFromCache() { try { const f = loadContainerFilters(); containerFilterState.search = f.search; containerFilterState.status = f.status; containerFilterState.sort = f.sort; applyContainerFiltersToUI(); if (containerFilterState.allContainers.length) { renderContainers( containerFilterState.allContainers, containerVirt.topicId || manager.active?.id || '' ); } } catch (err) { console.warn('[WARN] rehydrate filters failed', err?.message || err); } try { const next = loadConnections(); // Update serializable peer fields; keep live sockets on existing ids for (const id of Object.keys(connections)) { if (!next[id]) { // Peer removed from roster — drop if not live-connected if (!connections[id]?.peer?.connected) delete connections[id]; } } for (const [id, entry] of Object.entries(next)) { if (!connections[id]) { connections[id] = entry; } else { connections[id].publicKeyHex = entry.publicKeyHex; connections[id].topicHex = entry.topicHex || entry.publicKeyHex; connections[id].alias = entry.alias; connections[id].inviteToken = entry.inviteToken; connections[id].capability = entry.capability; connections[id].adminSeed = entry.adminSeed; } } if (typeof window.loadPeersView === 'function') window.loadPeersView(); } catch (err) { console.warn('[WARN] rehydrate peers failed', err?.message || err); } }; if (searchInput) { searchInput.addEventListener('input', (e) => { containerFilterState.search = e.target.value; saveContainerFilters(); rerender(); }); } if (statusFilter) { statusFilter.addEventListener('change', (e) => { containerFilterState.status = e.target.value; saveContainerFilters(); rerender(); }); } if (sortSelect) { sortSelect.addEventListener('change', (e) => { containerFilterState.sort = e.target.value; saveContainerFilters(); rerender(); }); } if (clearBtn) { clearBtn.addEventListener('click', () => { containerFilterState.search = CONTAINER_FILTER_DEFAULTS.search; containerFilterState.status = CONTAINER_FILTER_DEFAULTS.status; containerFilterState.sort = CONTAINER_FILTER_DEFAULTS.sort; applyContainerFiltersToUI(); saveContainerFilters(); rerender(); }); } } // 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() { // Only wire sidebar view links — never container/settings/swarm tab buttons const navLinks = document.querySelectorAll('#sidebar .nav-link[data-view]'); navLinks.forEach((link) => { link.addEventListener('click', (e) => { e.preventDefault(); const view = link.dataset.view; // Swarm disabled when Docker is not in swarm mode if ( view === 'swarm' && (link.classList.contains('nav-link-disabled') || link.getAttribute('aria-disabled') === 'true' || link.dataset.swarmAvailable === '0') ) { return; } if (view) { navigateToView(view); } }); }); // Only enter workspace if already connected; otherwise stay on welcome // (unless boot restore is already showing the restoring screen) if (hasActiveConnection()) { hideWelcomePage(); } else if (!isBootRestoring) { showWelcomePage(); } } function navigateToView(viewName, opts = {}) { const { skipWelcomeGate = false, replace = false, fromHistory = false } = opts; // Settings (incl. Peers subtab) is always reachable offline // Legacy hash #/peers redirects into Settings → Peers if (viewName === 'peers') { viewName = 'settings'; opts = { ...opts, settingsTab: opts.settingsTab || 'peers' }; } // Block Swarm when Docker node is not swarming if (viewName === 'swarm') { const link = document.getElementById('nav-swarm-link') || document.querySelector('#sidebar .nav-link[data-view="swarm"]'); const blocked = link && (link.classList.contains('nav-link-disabled') || link.getAttribute('aria-disabled') === 'true' || link.dataset.swarmAvailable === '0'); if (blocked) { const msg = link?.title || window.peardockOps?.SWARM_DISABLED_NAV_TITLE || 'Docker Swarm Mode is Disabled on the Node'; if (typeof showAlert === 'function') { showAlert('info', msg, { toast: true, tray: false, badge: false }); } return; } } const allowOffline = viewName === 'settings' || skipWelcomeGate; // Without an active peer, keep the welcome card and block workspace views if (!allowOffline && !hasActiveConnection()) { showWelcomePage(); return; } // Container search / status / sort persist until Clear is clicked // (including across view changes and reloads). Do not reset on leave. // 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'); } // Scope to sidebar view links only. Using `.nav-link` globally strips `active` // from Bootstrap tabs (container details, swarm, settings), leaving orphaned // `.tab-pane.show.active` panes that stack when reopening details. // Highlight Containers while on add-container (create is a sub-flow). if (viewName !== 'container-details') { document.querySelectorAll('#sidebar .nav-link[data-view]').forEach((link) => { const isActive = link.dataset.view === viewName || (viewName === 'add-container' && link.dataset.view === 'containers'); link.classList.toggle('active', isActive); }); } const prevView = currentView; const leavingDetails = prevView === 'container-details' && viewName !== 'container-details'; currentView = viewName; // Expose for auto-refresh poller if (typeof window !== 'undefined') window.currentView = viewName; // Demand-driven fleet stats: only collect while on Containers / Dashboard / details try { syncStatsInterest(); } catch { // ignore } // Drop live streams / PTYs when leaving container details if (leavingDetails) { if (typeof stopDetailsLogs === 'function') stopDetailsLogs(); if (typeof cleanupDetailsTerminal === 'function') cleanupDetailsTerminal(); if (typeof destroyDetailsStatsCharts === 'function') destroyDetailsStatsCharts(); if (typeof invalidateContainerTop === 'function') { invalidateContainerTop({ resetFilters: true, clearDom: true }); } currentContainerDetails = null; if (typeof window !== 'undefined') window.currentContainerDetails = null; } if (viewName === 'dashboard') { loadDashboard(); } else if (viewName === 'containers') { if (typeof updateBulkActionsToolbar === 'function') updateBulkActionsToolbar(); if (hasActiveConnection()) { // Keep existing rows visible while refreshing — skeleton caused stats flicker if (!containerFilterState.allContainers.length) { showListSkeleton('container-list', 6); } else { renderContainers( containerFilterState.allContainers, containerVirt.topicId || manager.active?.id || '' ); } sendCommand('listContainers'); // First open of Containers for this peer: compare digests to registries ensureInitialImageUpdateCheck(); } } else if (viewName === 'images') { loadImages(); } else if (viewName === 'registry') { window.loadRegistryView?.() || window.refreshRegistryPanel?.(); } 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 === 'add-container') { initAddContainerPage(); if (typeof applyRoleUI === 'function') applyRoleUI(); } else if (viewName === 'fleet') { loadFleetView(); } 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?.(opts.settingsTab); } // 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 } } // Track G: view enter animation + “Updated …” stamp try { window.peardockUx?.animateViewEnter?.(viewName); window.peardockUx?.markListRefreshed?.(viewName); } 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] * @param {{ force?: boolean }} [opts] - force=true wipes even if rows exist */ function showListSkeleton(listId, rows = 5, opts = {}) { const host = document.getElementById(listId); if (!host) return; // Don't wipe a populated list unless forced (causes flicker on auto-refresh) if (!opts.force) { const hasRows = host.querySelector?.('tr[data-row-key], tr[data-container-id], tr:not(.skeleton-row)') || (host.children?.length > 0 && !host.querySelector?.('.skeleton-row, .pd-skeleton-row')); // If host already has real content, keep it if (host.dataset.hasData === '1') return; if (hasRows && !host.querySelector?.('.skeleton-row, .pd-skeleton-row')) { host.dataset.hasData = '1'; 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(''); } delete host.dataset.hasData; delete host.dataset.listFp; } /** * Skip a full list rebuild when content fingerprint is unchanged (auto-refresh). * @param {HTMLElement|null} el * @param {string} fingerprint * @returns {boolean} true if caller should skip rebuild */ function skipIfUnchangedList(el, fingerprint) { if (!el) return true; if (el.dataset.listFp === fingerprint) return true; el.dataset.listFp = fingerprint; el.dataset.hasData = '1'; return false; } function listFp(parts) { return parts.join('\u0001'); } /** * Empty state HTML for tables (single full-width row). */ function emptyTableRow(colspan, title, body) { return `
${escapeHtmlLite(title)}
${body ? `
${escapeHtmlLite(body)}
` : ''}
`; } /** * Build fleet roster from configured peers + live sockets. * Offline / reconnecting hosts stay on the board (not removed when the link drops). * @returns {Array<{ * id: string, * publicKeyHex: string, * alias: string|null, * connected: boolean, * online: boolean, * reconnecting: boolean, * failed: boolean, * active: boolean, * latency: number|null, * dockerHealth: object|null, * role: string|null, * healthStatus: string, * reconnectAttempts: number, * maxAttempts: number|null, * }>} */ function buildFleetRoster() { /** @type {Map} */ const byId = new Map(); const upsert = (id, partial) => { if (!id) return; const prev = byId.get(id) || { id, publicKeyHex: '', alias: null, connected: false, online: false, reconnecting: false, failed: false, active: false, latency: null, dockerHealth: null, role: null, healthStatus: 'offline', reconnectAttempts: 0, maxAttempts: null, }; byId.set(id, { ...prev, ...partial, id }); }; // Configured peers (always keep these cards even when offline) for (const [id, entry] of Object.entries(connections || {})) { if (!entry) continue; const publicKeyHex = String(entry.publicKeyHex || entry.topicHex || '').toLowerCase(); if (!publicKeyHex && !id) continue; const live = manager.connections.get(id); const recon = typeof manager.getReconnectInfo === 'function' ? manager.getReconnectInfo(id) : null; const online = Boolean(live?.connected); const reconnecting = Boolean(recon?.reconnecting) || entry.healthStatus === 'connecting'; const failed = Boolean(recon?.failed) || entry.healthStatus === 'error'; upsert(id, { publicKeyHex: publicKeyHex || live?.publicKeyHex || '', alias: entry.alias || live?.alias || null, connected: online, online, reconnecting: !online && reconnecting, failed: !online && failed && !reconnecting, active: Boolean(manager.active?.id === id && online), latency: online ? live?.latency ?? entry.latency ?? null : null, dockerHealth: online ? live?.dockerHealth ?? null : null, role: online ? live?.role || null : entry.role || null, healthStatus: online ? live?.healthStatus || 'healthy' : reconnecting ? 'reconnecting' : failed ? 'failed' : entry.healthStatus === 'connecting' ? 'connecting' : 'offline', reconnectAttempts: recon?.attempts || 0, maxAttempts: recon && Number.isFinite(recon.maxAttempts) ? recon.maxAttempts : null, }); } // Live manager sockets not yet mirrored into `connections` for (const live of manager.list()) { const id = live.id || live.publicKeyHex?.slice(0, 12); if (!id) continue; if (byId.has(id) && byId.get(id).online) continue; const online = Boolean(live.connected); upsert(id, { publicKeyHex: live.publicKeyHex || byId.get(id)?.publicKeyHex || '', alias: live.alias || byId.get(id)?.alias || null, connected: online, online, reconnecting: false, failed: false, active: manager.active === live, latency: live.latency ?? null, dockerHealth: live.dockerHealth ?? null, role: live.role || null, healthStatus: online ? live.healthStatus || 'healthy' : 'offline', }); } // Saved peers from disk if app map is empty mid-boot if (byId.size === 0) { try { for (const p of manager.loadSaved?.() || []) { const id = p.id || String(p.publicKeyHex || '').slice(0, 12); if (!id) continue; upsert(id, { publicKeyHex: p.publicKeyHex, alias: p.alias || null, online: false, connected: false, healthStatus: 'offline', }); } } catch { // ignore } } return [...byId.values()].sort((a, b) => { // Active online first, then online, then reconnecting, then offline/failed const rank = (x) => (x.active ? 0 : x.online ? 1 : x.reconnecting ? 2 : x.failed ? 4 : 3); const d = rank(a) - rank(b); if (d !== 0) return d; return String(a.alias || a.id).localeCompare(String(b.alias || b.id)); }); } /** Multi-host fleet dashboard — all configured peers (online + offline) side-by-side */ async function loadFleetView() { const host = document.getElementById('fleet-cards'); if (!host) return; const list = buildFleetRoster(); const envMap = typeof window.peardockOps?.getPeerEnvironments === 'function' ? window.peardockOps.getPeerEnvironments() : {}; let envFilter = document.getElementById('fleet-env-filter')?.value || ''; try { const s = (typeof window !== 'undefined' && window.__peardockSettings) || (typeof window !== 'undefined' && window.peardockOps?.loadSettings?.()) || {}; 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((row) => (envMap[row.id] || '') === envFilter) : list; if (!list.length) { host.innerHTML = '
No peers configured. Add a public key under Settings → Peers.
'; return; } if (!filtered.length) { host.innerHTML = `
No peers tagged ${escapeHtmlLite(envFilter)}. Set environment on a peer card.
`; return; } host.innerHTML = filtered .map((row) => { const id = row.id; const active = row.active; const online = row.online; const reconnecting = row.reconnecting; const failed = row.failed; const lat = online && row.latency != null ? `${row.latency} ms` : '—'; const dockerOk = row.dockerHealth?.ok; const role = online ? row.role || '—' : '—'; const env = envMap[id] || ''; let badgeClass = 'secondary'; let badgeLabel = 'offline'; let border = 'secondary'; if (online) { badgeClass = 'success'; badgeLabel = 'online'; border = active ? 'success' : 'secondary'; } else if (reconnecting) { badgeClass = 'warning text-dark'; badgeLabel = row.maxAttempts != null ? `reconnecting ${row.reconnectAttempts}/${row.maxAttempts}` : `reconnecting · try ${row.reconnectAttempts || 1}`; border = 'warning'; } else if (failed) { badgeClass = 'danger'; badgeLabel = row.maxAttempts != null ? `offline · failed (${row.reconnectAttempts}/${row.maxAttempts})` : 'offline · failed'; border = 'danger'; } else { border = 'secondary'; } const healthLine = online ? escapeHtmlLite(row.healthStatus || 'healthy') : reconnecting ? 'Reconnecting…' : failed ? row.maxAttempts != null ? `Max reconnect tries reached (${row.reconnectAttempts}/${row.maxAttempts})` : 'Reconnect failed' : 'Offline'; // Action controls: online → set active; reconnecting → disabled; failed/offline → Reconnect let actionHtml = ''; if (online) { actionHtml = ` `; } else if (reconnecting) { actionHtml = ` `; } else if (failed) { // Max retries exhausted — explicit Reconnect control for the user actionHtml = `

Auto-reconnect stopped. You can try again manually.

`; } else { actionHtml = ` `; } return `
${escapeHtmlLite(row.alias || id)}
${escapeHtmlLite(badgeLabel)}

${escapeHtmlLite((row.publicKeyHex || id).slice(0, 24))}…

  • Latency: ${lat}
  • Docker: ${online ? (dockerOk === true ? 'ok' : dockerOk === false ? 'down' : '—') : '—'}
  • Role: ${escapeHtmlLite(String(role))}
  • ${healthLine}
${actionHtml}
`; }) .join(''); /** * Manual reconnect from Fleet — resets auto-retry budget and dials again. * @param {HTMLButtonElement} btn */ const fleetManualReconnect = async (btn) => { const id = btn.dataset.id; if (!id) return; const entry = connections[id]; const publicKeyHex = entry?.publicKeyHex || entry?.topicHex; if (!publicKeyHex) { if (typeof showAlert === 'function') showAlert('warning', 'No public key for this peer'); return; } btn.disabled = true; const prevHtml = btn.innerHTML; btn.innerHTML = 'Connecting…'; // Clear error health so card leaves "failed" while dialing if (connections[id]) connections[id].healthStatus = 'connecting'; try { // skipReconnectReset omitted → connect() zeros attempts and re-arms the budget await addConnection(publicKeyHex, { alias: entry?.alias || undefined, inviteToken: entry?.inviteToken || undefined, capability: entry?.capability || undefined, adminSeed: entry?.adminSeed || undefined, quiet: false, skipActivate: false, }); } catch (err) { if (typeof showAlert === 'function') { showAlert('danger', err?.message || 'Reconnect failed'); } if (connections[id] && !connections[id]?.peer?.connected) { connections[id].healthStatus = 'error'; } } if (btn.isConnected) { btn.disabled = false; btn.innerHTML = prevHtml; } loadFleetView(); }; host.querySelectorAll('.fleet-activate').forEach((btn) => { btn.addEventListener('click', async () => { const id = btn.dataset.id; if (!id) return; if (btn.dataset.online === '1') { manager.setActive(id); if (connections[id]?.peer) { switchConnection(id); } loadFleetView(); if (typeof showAlert === 'function') showAlert('success', `Active peer: ${id}`); return; } await fleetManualReconnect(btn); }); }); host.querySelectorAll('.fleet-reconnect').forEach((btn) => { btn.addEventListener('click', () => fleetManualReconnect(btn)); }); 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; /** Refresh fleet cards when peer connectivity changes (if that view is open). */ function refreshFleetIfVisible() { try { if (typeof window.currentView === 'string' && window.currentView === 'fleet') { loadFleetView(); } else if (document.getElementById('fleet-view') && !document.getElementById('fleet-view').classList.contains('hidden')) { loadFleetView(); } } catch { // ignore } } async function loadAccessView() { const peersEl = document.getElementById('access-peers-list'); const invitesEl = document.getElementById('access-invites-list'); const revokedEl = document.getElementById('access-revoked-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(); } }); document.getElementById('access-clear-revoked-btn')?.addEventListener('click', async () => { if ((manager.active?.role || 'viewer') !== 'admin') { showAlert('danger', 'Only admins can manage revokes'); return; } const ok = window.peardockOps?.confirmDestructive ? await window.peardockOps.confirmDestructive( 'Clear all revokes', 'Remove every peer from the revoke list? They will be able to connect again (as viewer unless re-invited).' ) : typeof confirm === 'function' ? confirm('Clear all revoked peers?') : true; if (!ok) return; try { const res = await manager.request(Methods.clearRevokedPeers, {}); showAlert('success', `Cleared ${res?.cleared ?? 0} revoke(s)`); loadAccessView(); } catch (err) { showAlert('danger', err.message || 'Failed to clear revokes'); } }); } if (!manager.active?.connected) { if (peersEl) peersEl.textContent = 'Not connected.'; if (revokedEl) revokedEl.textContent = 'Not connected.'; return; } try { const peersRes = await manager.request(Methods.listPeers, {}); if (peersEl) { const live = peersRes?.live || []; const known = peersRes?.peers || []; let html = `

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

`; html += '
Live sessions
'; if (!live.length) { html += '

No live sessions

'; } else { html += '
    '; for (const p of live) { const pid = p.peerId || ''; const auth = p.authMode ? ` · ${p.authMode}` : ''; html += `
  • ${escapeHtmlLite(pid)} ${escapeHtmlLite(p.role || '—')}${escapeHtmlLite(auth)}
  • `; } html += '
'; } if (known.length) { html += '
Registered peers
    '; for (const p of known) { const pid = p.peerId || ''; html += `
  • ${escapeHtmlLite(pid)} ${escapeHtmlLite(p.role || '—')}
  • `; } html += '
'; } else { html += '

No registered peers yet (connect via invite or seed).

'; } peersEl.innerHTML = html; peersEl.querySelectorAll('.access-revoke').forEach((btn) => { btn.addEventListener('click', async () => { const peerId = btn.dataset.id; if (!peerId) return; const ok = window.peardockOps?.confirmDestructive ? await window.peardockOps.confirmDestructive( 'Revoke peer', 'Revoke this peer? They will be disconnected and cannot reconnect until unrevoked.' ) : typeof confirm === 'function' ? confirm('Revoke this peer?') : true; if (!ok) return; try { await manager.request(Methods.revokePeer, { peerId }); if (typeof showAlert === 'function') showAlert('warning', 'Peer revoked'); loadAccessView(); } catch (err) { if (typeof showAlert === 'function') { showAlert('danger', err.message || 'Failed to revoke peer'); } } }); }); applyRoleUI(); } // Revoke list management (admin) if (revokedEl) { const role = manager.active?.role || 'viewer'; if (role !== 'admin') { revokedEl.innerHTML = 'Revoke list requires admin.'; } else { const revoked = peersRes?.revoked || []; if (!revoked.length) { revokedEl.innerHTML = 'No revoked peers.'; } else { revokedEl.innerHTML = revoked .map((peerId) => { const id = String(peerId || ''); return `
${escapeHtmlLite(id)}
`; }) .join(''); revokedEl.querySelectorAll('.access-unrevoke').forEach((btn) => { btn.addEventListener('click', async () => { const peerId = btn.dataset.id; if (!peerId) return; const ok = window.peardockOps?.confirmDestructive ? await window.peardockOps.confirmDestructive( 'Unrevoke peer', 'Remove this peer from the revoke list? They may connect again (as viewer unless re-invited).' ) : true; if (!ok) return; try { btn.disabled = true; await manager.request(Methods.unrevokePeer, { peerId }); if (typeof showAlert === 'function') showAlert('success', 'Peer unrevoked'); loadAccessView(); } catch (err) { if (typeof showAlert === 'function') { showAlert('danger', err.message || 'Failed to unrevoke'); } btn.disabled = false; } }); }); revokedEl.querySelectorAll('.access-copy-peerid').forEach((btn) => { btn.addEventListener('click', async () => { const peerId = btn.dataset.id || ''; if (!peerId) return; try { await navigator.clipboard.writeText(peerId); if (typeof showAlert === 'function') { showAlert('success', 'Peer id copied'); } } catch { if (typeof showAlert === 'function') { showAlert('warning', 'Could not copy peer id'); } } }); }); } applyRoleUI(); } } } catch (err) { if (peersEl) peersEl.textContent = err.message || 'Failed to list peers'; if (revokedEl) revokedEl.textContent = err.message || 'Failed to list revokes'; } // Invites are admin-only secrets — never fetch or render for viewer/operator if (invitesEl) { const role = manager.active?.role || 'viewer'; if (role !== 'admin') { invitesEl.innerHTML = 'Invites require admin. Connect with SERVER_SEED to manage invites.'; } else { try { const inv = await manager.request(Methods.listInvites, {}); const data = inv?.data || []; if (!data.length) invitesEl.innerHTML = 'No active invites'; else { invitesEl.innerHTML = data .map((i, idx) => { // One card = one invite: pd1 share string + linked capability grant const kind = i.kind || 'invite'; const role = i.role || i.grant?.role || '—'; const jti = String(i.jti || i.grant?.jti || ''); const share = String(i.share || i.invite || (kind !== 'capability' ? i.token : '') || ''); const canCopyShare = Boolean(share) && !share.startsWith('(capability ') && (share.startsWith('pd1.') || share.length > 8); const maxUses = i.maxUses ?? i.grant?.maxUses ?? 0; const uses = i.uses ?? i.grant?.uses ?? 0; const exp = i.expiresAt ?? i.grant?.expiresAt; const persistent = i.persistent || i.grant?.persistent || (!exp && maxUses === 0); const grantLabel = kind === 'pd1' ? 'Capability grant (embedded in pd1 invite)' : kind === 'capability' ? 'Capability grant (share server public key + this grant)' : 'Legacy invite token'; const grantMeta = [ `role=${role}`, jti ? `jti=${jti.slice(0, 12)}…` : null, persistent ? 'never expires · unlimited reconnects' : [ exp ? `exp=${exp}` : null, maxUses > 0 ? `uses=${uses}/${maxUses}` : 'unlimited uses', ] .filter(Boolean) .join(' · '), ] .filter(Boolean) .join(' · '); return `
${escapeHtmlLite(String(role))} ${escapeHtmlLite(kind)} ${persistent ? 'persistent' : ''}
${ canCopyShare ? `
1. Share with operator — peardock invite pd1.… (paste full string in Add peer)
${escapeHtmlLite(share)}
${share.length} characters · never truncate
` : '' }
2. ${escapeHtmlLite(grantLabel)}
${escapeHtmlLite(grantMeta)}
${ kind === 'pd1' ? 'Operators only need the pd1. string above — public key + capability are embedded (no separate steps).' : 'Recipient connects with server public key and presents this grant at handshake.' }
`; }) .join(''); invitesEl.querySelectorAll('.access-invite-copy').forEach((btn) => { btn.addEventListener('click', async () => { const card = btn.closest('.access-invite-card'); const full = card?.dataset?.token || card?.querySelector('.access-invite-share')?.textContent || ''; if (!full || full === '—') return; try { await navigator.clipboard.writeText(full); if (typeof showAlert === 'function') { showAlert('success', `Invite copied (${full.length} characters)`); } } catch { const pre = card?.querySelector('.access-invite-share'); if (pre && window.getSelection) { const range = document.createRange(); range.selectNodeContents(pre); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } if (typeof showAlert === 'function') { showAlert( 'warning', 'Could not copy automatically — invite selected for manual copy' ); } } }); }); invitesEl.querySelectorAll('.access-invite-delete').forEach((btn) => { btn.addEventListener('click', async () => { const card = btn.closest('.access-invite-card'); if (!card) return; const kind = card.dataset.kind || undefined; const jti = card.dataset.jti || undefined; const token = card.dataset.token || undefined; const ok = window.peardockOps?.confirmDestructive ? await window.peardockOps.confirmDestructive( 'Delete invite', 'Delete this invite and its capability grant? Anyone still holding the string can no longer elevate. Already-connected peers keep their role until revoked.' ) : typeof confirm === 'function' ? confirm('Delete this invite and its capability?') : true; if (!ok) return; try { btn.disabled = true; await manager.request(Methods.deleteInvite, { kind: kind || undefined, jti: jti || undefined, token: token || undefined, }); if (typeof showAlert === 'function') showAlert('success', 'Invite deleted'); loadAccessView(); } catch (err) { if (typeof showAlert === 'function') { showAlert('danger', err.message || 'Failed to delete invite'); } btn.disabled = false; } }); }); applyRoleUI(); } } catch (err) { invitesEl.textContent = err?.code === 'PERMISSION_DENIED' ? 'Invites require admin role.' : err?.message || ''; } } } 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'); if (typeof window.refreshRegistryPanel === 'function') window.refreshRegistryPanel(); }); }); 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(); if (typeof window.refreshRegistryPanel === 'function') window.refreshRegistryPanel(); }); }); } } } 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); // Only force-disable for insufficient role. Never force-enable — that // would wipe container lifecycle state (e.g. Start disabled while running). // Also never clear data-in-use locks (images used by containers). if ('disabled' in el) { if (!allowed) { el.disabled = true; el.dataset.roleDisabled = '1'; } else if (el.dataset.roleDisabled === '1') { if (el.dataset.inUse !== '1') { el.disabled = false; } delete el.dataset.roleDisabled; } } const lockedInUse = el.dataset.inUse === '1'; el.setAttribute('aria-disabled', allowed && !lockedInUse ? 'false' : 'true'); if (!allowed) el.title = el.title || `Requires ${el.dataset.minRole} role`; }); // Re-apply container start/stop/terminal enablement (role pass must not re-enable them) try { if (typeof refreshAllContainerRowActions === 'function') { refreshAllContainerRowActions(); } } catch { // list may not exist yet } // Invite UI is admin-only (read-only / operator must not create or list invites) const invitesAllowed = rank >= ROLE_RANK.admin; document.querySelectorAll('[data-admin-invites]').forEach((el) => { el.classList.toggle('role-hidden', !invitesAllowed); el.setAttribute('aria-hidden', invitesAllowed ? 'false' : 'true'); }); } 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'); // Persist in settings.json (showFirstConnectTip: false) try { if (window.peardockOps?.saveSettings) { window.peardockOps.saveSettings({ showFirstConnectTip: false }); } else { const s = loadSettingsBlob(); s.showFirstConnectTip = false; saveSettingsBlob(s); } } 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) — result lives in Active invites list, not a second panel const inviteForm = document.getElementById('invite-peer-form'); function hideInvitePeerModal() { const modalEl = document.getElementById('invitePeerModal'); if (!modalEl || typeof bootstrap === 'undefined') return; const inst = bootstrap.Modal.getInstance(modalEl); if (inst) inst.hide(); else new bootstrap.Modal(modalEl).hide(); } async function copyInviteText(token, { quiet = false } = {}) { const full = String(token || ''); if (!full) { if (!quiet) showAlert('warning', 'No invite to copy'); return false; } try { await navigator.clipboard.writeText(full); if (!quiet) { showAlert('success', `Invite copied (${full.length} characters, full string)`); } return true; } catch { if (!quiet) { showAlert( 'warning', 'Could not auto-copy — use Copy invite on the card under Active invites' ); } return false; } } if (inviteForm) { inviteForm.addEventListener('submit', async (e) => { e.preventDefault(); if ((manager.active?.role || 'viewer') !== 'admin') { showAlert('danger', 'Only admins can create invites (read-only / operator cannot)'); return; } const role = document.getElementById('invite-role')?.value || 'operator'; // 0 = forever / unlimited (server defaults); empty field → 0 const ttlRaw = document.getElementById('invite-ttl')?.value; const maxRaw = document.getElementById('invite-max-uses')?.value; const ttlHours = ttlRaw === '' || ttlRaw == null ? 0 : Number(ttlRaw); const maxUses = maxRaw === '' || maxRaw == null ? 0 : Number(maxRaw); const submitBtn = document.getElementById('invite-peer-submit'); if (submitBtn) submitBtn.disabled = true; try { const res = await manager.request(Methods.invitePeer, { role, ttlHours, maxUses }); const token = res?.data?.token || res?.data?.invite || res?.data?.capability; const kind = res?.data?.kind || 'invite'; const grantRole = res?.data?.role || role; if (token) { // Close create form — full string + Copy live under Active invites hideInvitePeerModal(); const copied = await copyInviteText(token, { quiet: true }); const base = kind === 'pd1' ? `Invite created for role “${grantRole}” — share the full pd1. string` : `Invite created for role “${grantRole}”`; showAlert( 'success', copied ? `${base} (copied · ${String(token).length} characters)` : `${base}. Use Copy invite on the Active invites card.` ); } else { showAlert('danger', 'Server returned an empty invite — try again'); } await loadAccessView(); } catch (err) { showAlert('danger', err.message || 'Failed to create invite'); } finally { if (submitBtn) submitBtn.disabled = false; } }); } // 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; const settingsTab = btn.dataset.settingsTab; dismissFirstConnectChecklist(); if (view) navigateToView(view, settingsTab ? { settingsTab } : {}); }); }); // 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, label: fd.get('label') || undefined, }); vaultForm.reset(); if (typeof showAlert === 'function') showAlert('success', 'Credential stored encrypted'); loadAccessView(); if (typeof window.refreshRegistryPanel === 'function') window.refreshRegistryPanel(); } 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 RPC TTL cache — avoids re-hitting the Docker socket on every * navigation to the dashboard when nothing has changed. * Invalidated on peer switch and on relevant list/event responses. */ const DASHBOARD_TTL = { systemInfo: 30_000, systemDf: 30_000, containers: 10_000, images: 20_000, networks: 20_000, volumes: 20_000, }; /** @type {{ peerId: string, at: Record }} */ const dashboardRpcCache = { peerId: '', at: {} }; function dashboardCachePeerId() { return manager.active?.id || window.activePeer?.id || ''; } function touchDashboardCache(key) { const peerId = dashboardCachePeerId(); if (!peerId) return; if (dashboardRpcCache.peerId !== peerId) { dashboardRpcCache.peerId = peerId; dashboardRpcCache.at = {}; } dashboardRpcCache.at[key] = Date.now(); } /** * @param {string} key * @param {number} [ttlMs] */ function isDashboardCacheFresh(key, ttlMs) { const peerId = dashboardCachePeerId(); if (!peerId || dashboardRpcCache.peerId !== peerId) return false; const at = dashboardRpcCache.at[key]; if (!at) return false; const ttl = ttlMs ?? DASHBOARD_TTL[key] ?? 15_000; return Date.now() - at < ttl; } /** * @param {string|string[]|'all'} [keys] */ function invalidateDashboardCache(keys = 'all') { if (keys === 'all') { dashboardRpcCache.at = {}; return; } const list = Array.isArray(keys) ? keys : [keys]; for (const k of list) delete dashboardRpcCache.at[k]; } window.invalidateDashboardCache = invalidateDashboardCache; // Dashboard Functions function loadDashboard() { if (!hasActiveConnection() && !window.activePeer) { return; } const peerId = dashboardCachePeerId(); if (peerId && dashboardRpcCache.peerId !== peerId) { dashboardRpcCache.peerId = peerId; dashboardRpcCache.at = {}; } // 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(() => {}); // Only re-fetch slices that are stale — same data when fresh, less Docker socket load. // When fresh, rely on event pushes + auto-refresh for updates. if (!isDashboardCacheFresh('systemInfo')) { sendCommand('getSystemInfo'); } if (!isDashboardCacheFresh('systemDf')) { sendCommand('getSystemDf'); } if (!isDashboardCacheFresh('containers')) { sendCommand('listContainers'); } if (!isDashboardCacheFresh('images')) { sendCommand('listImages'); } if (!isDashboardCacheFresh('networks')) { sendCommand('listNetworks'); } if (!isDashboardCacheFresh('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; invalidateDashboardCache('systemDf'); 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; const pending = conn?.connected || status === 'connecting' || status === 'unknown'; if (latency != null) { lat.textContent = `${latency} ms`; } else if (pending) { lat.innerHTML = jobSpinnerPending(); } else { lat.textContent = '—'; } if (docker?.ok === true) { dock.textContent = `Docker ${docker.apiVersion || 'ok'}`; } else if (docker?.ok === false) { dock.textContent = 'Docker down'; } else if (pending) { dock.innerHTML = `Docker ${jobSpinnerPending()}`; } 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; if (typeof window.peardockOps?.pruneJob === 'function') { try { await window.peardockOps.pruneJob({ kind, label: conf.label, method: conf.method, }); 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) { if (!err?.viaJob) showAlert('danger', err.message || 'Prune failed'); } 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; if (typeof window.peardockOps?.pruneJob === 'function') { try { await window.peardockOps.pruneJob({ kind: 'system', label: volumes ? 'system (+ volumes)' : 'system', method: Methods.systemPrune || 'systemPrune', args: { volumes, all: false }, }); sendCommand('listContainers'); sendCommand('listImages'); sendCommand('listNetworks'); sendCommand('getSystemDf'); } catch (err) { if (!err?.viaJob) showAlert('danger', err.message || 'System prune failed'); } 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) { if (typeof pullImageWithAuth === 'function') { pullImageWithAuth({ image: n }).catch(() => {}); } else { 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; if (typeof window.peardockOps?.recreateContainersJob === 'function') { try { await window.peardockOps.recreateContainersJob({ ids: [container.Id], names: [name], start: true, }); sendCommand('listContainers'); } catch (err) { if (!err?.viaJob) showAlert('danger', err.message || 'Recreate failed'); } 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; /** Processes tab: cached docker top + client-side debounced filters */ const containerTopState = { containerId: null, /** Bumped to ignore late containerTop responses after leave / container switch */ gen: 0, titles: [], processes: [], /** Column indexes resolved from Titles (best-effort) */ cols: { user: -1, pid: -1, ppid: -1, cmd: -1 }, filter: { query: '', user: '', pid: '', ppid: '', cmd: '', sortCol: '', sortDir: 'asc', caseInsensitive: true, }, debounceTimer: null, debounceMs: 220, advancedOpen: false, wired: false, }; /** * Invalidate in-flight process list fetches (tab leave / container change). * @param {{ resetFilters?: boolean, clearDom?: boolean }} [opts] */ function invalidateContainerTop(opts = {}) { containerTopState.gen += 1; containerTopState.containerId = null; containerTopState.titles = []; containerTopState.processes = []; containerTopState.cols = { user: -1, pid: -1, ppid: -1, cmd: -1 }; if (containerTopState.debounceTimer) { clearTimeout(containerTopState.debounceTimer); containerTopState.debounceTimer = null; } if (opts.resetFilters) resetProcsFilters(); if (opts.clearDom !== false) { const el = document.getElementById('container-top-content'); if (el) el.innerHTML = ''; updateProcsResultCount(null, null); } } function loadContainerTop(containerId) { if (!containerId || !hasActiveConnection()) return; containerTopState.gen += 1; containerTopState.containerId = containerId; const el = document.getElementById('container-top-content'); if (el) { el.innerHTML = `
${jobSpinnerHtml('Loading processes…')}
`; } updateProcsResultCount(null, null); sendCommand('containerTop', { id: containerId }); } window.loadContainerTop = loadContainerTop; window.invalidateContainerTop = invalidateContainerTop; function resolveTopColumnIndexes(titles) { const norm = titles.map((t) => String(t || '').toLowerCase().trim()); const find = (...candidates) => { for (const c of candidates) { const i = norm.findIndex((t) => t === c || t.includes(c)); if (i >= 0) return i; } return -1; }; return { user: find('uid', 'user', 'username'), pid: find('pid'), ppid: find('ppid'), // CMD / COMMAND usually last; prefer exact then contains cmd: (() => { const exact = norm.findIndex((t) => t === 'cmd' || t === 'command' || t === 'args'); if (exact >= 0) return exact; return find('cmd', 'command', 'args'); })(), }; } /** * Classify docker-top columns so compact fields (UID/PID/…) stay narrow * and CMD can claim remaining width. */ function procsColumnKind(title, index, cmdIdx) { const t = String(title || '') .toLowerCase() .trim(); if ( index === cmdIdx || t === 'cmd' || t === 'command' || t === 'args' || t === 'command line' ) { return 'cmd'; } if ( t === 'uid' || t === 'user' || t === 'username' || t === 'pid' || t === 'ppid' || t === 'c' || t === 'ni' || t === 'pri' || t === 'tty' || t === 'stat' || t === 's' || t === '%cpu' || t === '%mem' || t === 'cpu' || t === 'rss' || t === 'vsz' ) { return 'compact'; } if ( t === 'stime' || t === 'start' || t === 'time' || t === 'etime' || t === 'wchan' || t === 'lstart' ) { return 'time'; } return 'mid'; } /** Place CMD/COMMAND immediately after PPID for a more readable process table. */ function reorderTopColumnsCmdAfterPpid(titles, processes) { const cols = resolveTopColumnIndexes(titles); if (cols.cmd < 0 || cols.ppid < 0) return { titles, processes }; if (cols.cmd === cols.ppid + 1) return { titles, processes }; const order = titles.map((_, i) => i).filter((i) => i !== cols.cmd); const ppidPos = order.indexOf(cols.ppid); if (ppidPos < 0) return { titles, processes }; order.splice(ppidPos + 1, 0, cols.cmd); return { titles: order.map((i) => titles[i]), processes: processes.map((row) => order.map((i) => row[i] ?? '')), }; } function renderContainerTop(payload) { const data = payload?.data || payload; const responseId = payload?.id || data?.id; // Ignore late replies after tab leave / container switch if (!containerTopState.containerId) return; if ( responseId && !containerIdsMatch(responseId, containerTopState.containerId) ) { return; } const rawTitles = Array.isArray(data?.Titles || data?.titles) ? data.Titles || data.titles : []; const rawProcesses = Array.isArray(data?.Processes || data?.processes) ? (data.Processes || data.processes).map((p) => Array.isArray(p) ? p.map((c) => String(c ?? '')) : [String(p ?? '')] ) : []; const { titles, processes } = reorderTopColumnsCmdAfterPpid( rawTitles, rawProcesses ); containerTopState.titles = titles; containerTopState.processes = processes; containerTopState.cols = resolveTopColumnIndexes(containerTopState.titles); populateProcsSortOptions(containerTopState.titles); ensureProcsFilterWired(); paintContainerTopTable(); } function populateProcsSortOptions(titles) { const sel = document.getElementById('procs-filter-sort'); if (!sel) return; const current = containerTopState.filter.sortCol; const opts = ['']; titles.forEach((t, i) => { opts.push(``); }); sel.innerHTML = opts.join(''); if (current !== '' && current != null && Number(current) < titles.length) { sel.value = String(current); } else { sel.value = ''; containerTopState.filter.sortCol = ''; } } function readProcsFilterFromForm() { const f = containerTopState.filter; const q = document.getElementById('procs-filter-query'); const user = document.getElementById('procs-filter-user'); const pid = document.getElementById('procs-filter-pid'); const ppid = document.getElementById('procs-filter-ppid'); const cmd = document.getElementById('procs-filter-cmd'); const sort = document.getElementById('procs-filter-sort'); const dir = document.getElementById('procs-filter-dir'); const ci = document.getElementById('procs-filter-case'); f.query = q?.value?.trim() || ''; f.user = user?.value?.trim() || ''; f.pid = pid?.value?.trim() || ''; f.ppid = ppid?.value?.trim() || ''; f.cmd = cmd?.value?.trim() || ''; f.sortCol = sort?.value ?? ''; f.sortDir = dir?.value === 'desc' ? 'desc' : 'asc'; f.caseInsensitive = ci ? ci.checked : true; const clearBtn = document.getElementById('procs-filter-query-clear'); if (clearBtn) clearBtn.hidden = !f.query; updateProcsResetEnabled(); } function procsFilterActive() { const f = containerTopState.filter; return Boolean( f.query || f.user || f.pid || f.ppid || f.cmd || f.sortCol !== '' ); } function updateProcsResetEnabled() { const btn = document.getElementById('procs-filter-reset-btn'); if (btn) btn.disabled = !procsFilterActive(); } function matchField(haystack, needle, caseInsensitive) { if (!needle) return true; const h = caseInsensitive ? String(haystack).toLowerCase() : String(haystack); const n = caseInsensitive ? needle.toLowerCase() : needle; return h.includes(n); } function filterAndSortProcesses() { const { titles, processes, cols, filter: f } = containerTopState; const ci = f.caseInsensitive; let rows = processes.map((cells, idx) => ({ cells, idx })); const colVal = (cells, colIdx) => colIdx >= 0 && colIdx < cells.length ? cells[colIdx] : ''; if (f.query) { rows = rows.filter(({ cells }) => cells.some((c) => matchField(c, f.query, ci)) ); } if (f.user) { rows = rows.filter(({ cells }) => matchField(colVal(cells, cols.user), f.user, ci) ); } if (f.pid) { rows = rows.filter(({ cells }) => matchField(colVal(cells, cols.pid), f.pid, false) ); } if (f.ppid) { rows = rows.filter(({ cells }) => matchField(colVal(cells, cols.ppid), f.ppid, false) ); } if (f.cmd) { // Prefer CMD column; fall back to last cell / full-row join rows = rows.filter(({ cells }) => { const cmdCell = cols.cmd >= 0 ? colVal(cells, cols.cmd) : cells[cells.length - 1] || cells.join(' '); return matchField(cmdCell, f.cmd, ci); }); } if (f.sortCol !== '' && f.sortCol != null) { const col = Number(f.sortCol); if (!Number.isNaN(col) && col >= 0 && col < titles.length) { const dir = f.sortDir === 'desc' ? -1 : 1; rows.sort((a, b) => { const av = a.cells[col] ?? ''; const bv = b.cells[col] ?? ''; const an = Number(av); const bn = Number(bv); if (!Number.isNaN(an) && !Number.isNaN(bn) && av !== '' && bv !== '') { return (an - bn) * dir; } return ( String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: ci ? 'base' : 'variant', }) * dir ); }); } } return rows; } function updateProcsResultCount(shown, total) { const el = document.getElementById('procs-result-count'); if (!el) return; if (shown == null || total == null) { el.textContent = ''; el.removeAttribute('data-active'); return; } if (total === 0) { el.textContent = 'No processes'; el.setAttribute('data-active', '0'); return; } if (shown === total && !procsFilterActive()) { el.textContent = `${total} process${total === 1 ? '' : 'es'}`; el.setAttribute('data-active', '0'); } else { el.textContent = `${shown} of ${total}`; el.setAttribute('data-active', shown === 0 ? 'empty' : '1'); } } function paintContainerTopTable() { const el = document.getElementById('container-top-content'); if (!el) return; const titles = containerTopState.titles; const total = containerTopState.processes.length; if (!total) { el.innerHTML = '
No processes (is the container running?)
'; updateProcsResultCount(0, 0); return; } const filtered = filterAndSortProcesses(); updateProcsResultCount(filtered.length, total); if (!filtered.length) { el.innerHTML = `
No processes match your filters
`; document.getElementById('procs-empty-clear')?.addEventListener('click', resetProcsFilters); return; } const esc = typeof escapeHtml === 'function' ? escapeHtml : (t) => String(t ?? ''); const cmdIdx = containerTopState.cols.cmd; const colKinds = titles.map((t, i) => procsColumnKind(t, i, cmdIdx)); const colgroup = colKinds .map((kind) => ``) .join(''); const head = titles .map((t, i) => { const label = esc(String(t || `Col ${i + 1}`)); const kind = colKinds[i]; const sorted = containerTopState.filter.sortCol !== '' && Number(containerTopState.filter.sortCol) === i; const arrow = sorted ? containerTopState.filter.sortDir === 'desc' ? ' ' : ' ' : ''; return `${label}${arrow}`; }) .join(''); const q = containerTopState.filter.query; const highlight = (text) => { const raw = String(text ?? ''); if (!q) return esc(raw); try { const flags = containerTopState.filter.caseInsensitive ? 'gi' : 'g'; const re = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags); let out = ''; let last = 0; let m; re.lastIndex = 0; while ((m = re.exec(raw)) !== null) { out += esc(raw.slice(last, m.index)); out += `${esc(m[0])}`; last = m.index + m[0].length; if (m[0].length === 0) { re.lastIndex++; if (re.lastIndex > raw.length) break; } } out += esc(raw.slice(last)); return out; } catch { return esc(raw); } }; const rows = filtered .map(({ cells }) => { const tds = cells .map((c, i) => { const kind = colKinds[i] || 'mid'; const body = q ? highlight(c) : esc(c); const titleAttr = kind === 'cmd' ? '' : ` title="${esc(String(c ?? ''))}"`; return `${body}`; }) .join(''); return `${tds}`; }) .join(''); el.innerHTML = `${colgroup}${head}${rows}
`; el.querySelectorAll('th.procs-th').forEach((th) => { th.addEventListener('click', () => { const col = th.getAttribute('data-col'); const sortSel = document.getElementById('procs-filter-sort'); const dirSel = document.getElementById('procs-filter-dir'); if ( containerTopState.filter.sortCol === col && containerTopState.filter.sortDir === 'asc' ) { containerTopState.filter.sortDir = 'desc'; if (dirSel) dirSel.value = 'desc'; } else if ( containerTopState.filter.sortCol === col && containerTopState.filter.sortDir === 'desc' ) { containerTopState.filter.sortCol = ''; containerTopState.filter.sortDir = 'asc'; if (sortSel) sortSel.value = ''; if (dirSel) dirSel.value = 'asc'; } else { containerTopState.filter.sortCol = col; containerTopState.filter.sortDir = 'asc'; if (sortSel) sortSel.value = col; if (dirSel) dirSel.value = 'asc'; } updateProcsResetEnabled(); paintContainerTopTable(); }); }); } function scheduleProcsFilterApply() { const hint = document.getElementById('procs-debounce-hint'); if (hint) hint.hidden = false; if (containerTopState.debounceTimer) { clearTimeout(containerTopState.debounceTimer); } containerTopState.debounceTimer = setTimeout(() => { containerTopState.debounceTimer = null; if (hint) hint.hidden = true; readProcsFilterFromForm(); paintContainerTopTable(); }, containerTopState.debounceMs); } function resetProcsFilters() { if (containerTopState.debounceTimer) { clearTimeout(containerTopState.debounceTimer); containerTopState.debounceTimer = null; } const hint = document.getElementById('procs-debounce-hint'); if (hint) hint.hidden = true; containerTopState.filter = { query: '', user: '', pid: '', ppid: '', cmd: '', sortCol: '', sortDir: 'asc', caseInsensitive: true, }; const ids = [ 'procs-filter-query', 'procs-filter-user', 'procs-filter-pid', 'procs-filter-ppid', 'procs-filter-cmd', ]; ids.forEach((id) => { const el = document.getElementById(id); if (el) el.value = ''; }); const sort = document.getElementById('procs-filter-sort'); const dir = document.getElementById('procs-filter-dir'); const ci = document.getElementById('procs-filter-case'); if (sort) sort.value = ''; if (dir) dir.value = 'asc'; if (ci) ci.checked = true; const clearBtn = document.getElementById('procs-filter-query-clear'); if (clearBtn) clearBtn.hidden = true; updateProcsResetEnabled(); paintContainerTopTable(); } function ensureProcsFilterWired() { if (containerTopState.wired) return; const form = document.getElementById('procs-filter-form'); if (!form) return; containerTopState.wired = true; const debouncedIds = [ 'procs-filter-query', 'procs-filter-user', 'procs-filter-pid', 'procs-filter-ppid', 'procs-filter-cmd', ]; debouncedIds.forEach((id) => { const el = document.getElementById(id); if (!el) return; el.addEventListener('input', () => { if (id === 'procs-filter-query') { const clearBtn = document.getElementById('procs-filter-query-clear'); if (clearBtn) clearBtn.hidden = !el.value; } scheduleProcsFilterApply(); }); el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { el.value = ''; if (id === 'procs-filter-query') { const clearBtn = document.getElementById('procs-filter-query-clear'); if (clearBtn) clearBtn.hidden = true; } scheduleProcsFilterApply(); } }); }); // Immediate apply for discrete controls ['procs-filter-sort', 'procs-filter-dir', 'procs-filter-case'].forEach((id) => { const el = document.getElementById(id); if (!el) return; el.addEventListener('change', () => { readProcsFilterFromForm(); paintContainerTopTable(); }); }); document.getElementById('procs-filter-query-clear')?.addEventListener('click', () => { const q = document.getElementById('procs-filter-query'); if (q) q.value = ''; const clearBtn = document.getElementById('procs-filter-query-clear'); if (clearBtn) clearBtn.hidden = true; scheduleProcsFilterApply(); q?.focus(); }); document.getElementById('procs-filter-reset-btn')?.addEventListener('click', resetProcsFilters); const advToggle = document.getElementById('procs-filter-advanced-toggle'); const advPanel = document.getElementById('procs-filter-advanced'); advToggle?.addEventListener('click', () => { containerTopState.advancedOpen = !containerTopState.advancedOpen; if (advPanel) advPanel.hidden = !containerTopState.advancedOpen; advToggle.setAttribute('aria-expanded', containerTopState.advancedOpen ? 'true' : 'false'); advToggle.classList.toggle('is-open', containerTopState.advancedOpen); }); } function updateDashboardStats(containers, images, networks) { if (containers) { // Honor global hide-by-label settings so KPIs match the Containers list const visible = applyGlobalContainerVisibility(containers); const running = visible.filter(c => c.State === 'running').length; const stopped = visible.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 /** Expose for registry push modal catalog */ Object.defineProperty(window, 'allImages', { get() { return allImages; }, set(v) { allImages = v || []; }, configurable: true, }); /** * Image update status by image ref and container id (style digest check). * @type {Map} */ const imageUpdateByImage = new Map(); /** @type {Map} */ const imageUpdateByContainer = new Map(); let imageUpdateCheckInFlight = false; let imageUpdateCheckTimer = null; /** Peer id for which we already kicked off the first Containers-tab update check */ let imageUpdateInitialPeerId = ''; let currentImageFilter = 'all'; // Current filter: 'all', 'used', 'unused' function loadImages() { if (!window.activePeer && !hasActiveConnection()) { return; } if (!allImages?.length) 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 isImageInUse(image) { return Array.isArray(image?.usage) && image.usage.length > 0; } function isImageUnused(image) { return Array.isArray(image?.usage) && image.usage.length === 0; } function renderImages(images) { // Preserve usage when a push omits it so Used/Unused filters stay stable mid-delete const prevUsageById = new Map((allImages || []).map((img) => [img.Id, img.usage])); allImages = (images || []).map((image) => { if (Array.isArray(image.usage)) return image; const prev = prevUsageById.get(image.Id); return prev !== undefined ? { ...image, usage: prev } : image; }); // Calculate filter counts const usedCount = allImages.filter(isImageInUse).length; const unusedCount = allImages.filter(isImageUnused).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(isImageInUse); } else if (currentImageFilter === 'unused') { filteredImages = allImages.filter(isImageUnused); } 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; const emptyFp = listFp(['empty', hasAny ? 'filtered' : 'none']); if (!skipIfUnchangedList(imagesList, emptyFp)) { 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]; }; const fp = listFp( filteredImages.map( (image) => `${image.Id}|${(image.RepoTags && image.RepoTags[0]) || ''}|${image.Size || 0}|${image.usage?.length || 0}` ) ); if (skipIfUnchangedList(imagesList, fp)) return; imagesList.innerHTML = filteredImages.map(image => { const tags = (image.RepoTags || []).filter(Boolean); const primary = tags[0] || ':'; const repoTag = primary.includes(':') ? [primary.slice(0, primary.lastIndexOf(':')), primary.slice(primary.lastIndexOf(':') + 1)] : [primary, '']; const repo = repoTag[0]; 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; const inUse = isImageInUse(image); const tagBadges = tags.length > 0 ? tags .map((t) => { const short = t.includes(':') ? t.slice(t.lastIndexOf(':') + 1) : t; return `${short}`; }) .join('') : '<none>'; const tagsJson = encodeURIComponent(JSON.stringify(tags)); const canPull = tags.length > 0 && tags[0] !== ':'; const selectDisabled = inUse ? 'disabled data-in-use="1" title="In use by a container — cannot select for removal"' : ''; const removeDisabled = inUse ? 'disabled data-in-use="1" aria-disabled="true" title="In use by a container — stop or remove containers using this image first"' : 'title="Remove"'; return ` ${repo} ${tagBadges} ${imageId} ${size} ${created} ${usage} container${usage !== 1 ? 's' : ''}
${ canPull ? `` : '' }
`; }).join(''); // Add event listeners applyRoleUI(); // Re-lock in-use controls — applyRoleUI must not re-enable them after a role change imagesList.querySelectorAll('[data-in-use="1"]').forEach((el) => { el.disabled = true; el.setAttribute('aria-disabled', 'true'); }); const selectAllImages = document.getElementById('select-all-images'); const selectableCheckboxes = imagesList.querySelectorAll('.image-checkbox:not(:disabled)'); if (selectAllImages) { selectAllImages.disabled = selectableCheckboxes.length === 0; selectAllImages.checked = false; selectAllImages.indeterminate = false; } imagesList.querySelectorAll('.action-remove-image').forEach(btn => { btn.addEventListener('click', () => { if (btn.disabled || btn.dataset.inUse === '1') return; const imageId = btn.dataset.imageId; const image = allImages.find((img) => img.Id === imageId); if (isImageInUse(image)) { showAlert('warning', 'Cannot remove an image that is in use by a container'); return; } 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); }); }); imagesList.querySelectorAll('.action-push-image').forEach((btn) => { btn.addEventListener('click', () => { let repoTags = []; try { repoTags = JSON.parse(decodeURIComponent(btn.dataset.tags || '%5B%5D')); } catch { repoTags = []; } const defaultRef = decodeURIComponent(btn.dataset.defaultRef || ''); if (typeof openPushImageModal === 'function') { openPushImageModal({ id: btn.dataset.imageId, defaultRef, repoTags, }); } }); }); imagesList.querySelectorAll('.action-pull-image').forEach((btn) => { btn.addEventListener('click', () => { const ref = decodeURIComponent(btn.dataset.ref || ''); if (!ref) return; if (typeof pullImageWithAuth === 'function') { pullImageWithAuth({ image: ref }).catch(() => {}); } else { sendCommand('pullImage', { image: ref }); } }); }); applyRoleUI(); } // Networks Functions function loadNetworks() { if (!window.activePeer && !hasActiveConnection()) { return; } if (!allNetworks?.length) 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) { const emptyFp = listFp(['empty-net', allNetworks.length ? 'filtered' : 'none']); if (!skipIfUnchangedList(networksList, emptyFp)) { 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; } const netFp = listFp( filtered.map( (n) => `${n.Id || n.Name}|${n.Name}|${n.Driver}|${n.IPAM?.Config?.[0]?.Subnet || ''}|${n.usage?.length || 0}` ) ); if (skipIfUnchangedList(networksList, netFp)) 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 applyRoleUI(); 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); } }; }); }); applyRoleUI(); } // Volumes Functions /** @type {Array} */ let allStacksCache = []; function loadStacks() { if (!window.activePeer && !hasActiveConnection()) { console.warn('[WARN] No active peer connection'); return; } if (!allStacksCache?.length) 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) { const emptyFp = listFp(['empty-stack', allStacksCache.length ? 'filtered' : 'none']); if (!skipIfUnchangedList(stacksListBody, emptyFp)) { 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; } const stackFp = listFp( filtered.map((s) => { const containers = s.containers || []; const running = containers.filter((c) => c.state === 'running').length; return `${s.name}|${(s.services || []).join(',')}|${containers.length}|${running}`; }) ); if (skipIfUnchangedList(stacksListBody, stackFp)) 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; if (typeof window.peardockOps?.removeStackJob === 'function') { try { await window.peardockOps.removeStackJob({ stackName }); loadStacks(); } catch (error) { if (!error?.viaJob) { showAlert('danger', error.message || 'Failed to remove stack'); } } 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'); const gitOpsBtn = document.getElementById('stack-gitops-btn'); if (deployStackBtn && deployStackForm && deployStackBtn.dataset.handlerBound !== '1') { deployStackBtn.dataset.handlerBound = '1'; 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; } const envFileContent = document.getElementById('stack-env-file')?.value?.trim() || undefined; const deployArgs = { stackName, composeContent, envFileContent: envFileContent || undefined, }; // Prefer multi-step job tray (validate → pull images → compose up) if (typeof window.peardockOps?.deployStackWithSteps === 'function') { try { const modal = bootstrap.Modal.getInstance(document.getElementById('deploy-stack-modal')); if (modal) modal.hide(); await window.peardockOps.deployStackWithSteps(deployArgs); deployStackForm.reset(); navigateToView('stacks'); loadStacks(); } catch (error) { if (!error?.viaJob) { presentError(error, 'deployStack', { showAlert }); } } return; } showStatusIndicator(`Deploying stack "${stackName}"...`); try { const response = await manager.request(Methods.deployStack, deployArgs); showAlert('success', response?.message || `Stack "${stackName}" deployed`); const modal = bootstrap.Modal.getInstance(document.getElementById('deploy-stack-modal')); if (modal) modal.hide(); deployStackForm.reset(); navigateToView('stacks'); loadStacks(); } catch (error) { console.error('[ERROR] Failed to deploy stack:', error); presentError(error, 'deployStack', { showAlert }); } finally { hideStatusIndicator(); } }); } if (gitOpsBtn && gitOpsBtn.dataset.handlerBound !== '1') { gitOpsBtn.dataset.handlerBound = '1'; gitOpsBtn.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; } if (typeof window.peardockOps?.syncStackFromGitWithSteps === 'function') { try { const modal = bootstrap.Modal.getInstance(document.getElementById('deploy-stack-modal')); if (modal) modal.hide(); await window.peardockOps.syncStackFromGitWithSteps({ stackName, repoUrl, ref, composePath, }); navigateToView('stacks'); loadStacks(); } catch (error) { if (!error?.viaJob) { presentError(error, 'syncStackFromGit', { showAlert }); } } return; } showStatusIndicator(`GitOps sync ${stackName} from ${repoUrl}…`); try { const response = await manager.request(Methods.syncStackFromGit, { stackName, repoUrl, ref, composePath, }); 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); if (!allVolumesCache?.length && !volumesStore.get()?.length) { 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) { const emptyFp = listFp(['empty-vol', allVolumesCache.length ? 'filtered' : 'none']); if (!skipIfUnchangedList(volumesList, emptyFp)) { 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; } const volFp = listFp( filtered.map((v) => `${v.Name}|${v.Driver}|${v.Mountpoint || ''}|${v.usage?.length || 0}`) ); if (skipIfUnchangedList(volumesList, volFp)) 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(''); applyRoleUI(); 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); }); }); applyRoleUI(); } // 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; } if (typeof window.peardockOps?.buildImageJob === 'function') { try { const modal = bootstrap.Modal.getInstance(document.getElementById('buildImageModal')); if (modal) modal.hide(); await window.peardockOps.buildImageJob({ tag, dockerfile, buildFn: async () => { sendCommand('buildImage', { tag, dockerfile }); return waitForPeerResponse('Image built successfully'); }, }); buildImageForm.reset(); sendCommand('listImages'); } catch (error) { if (!error?.viaJob) { showAlert('danger', error.message || 'Failed to build image'); } } 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; } /** * Prefer hybrid job-tray pull card; fall back to legacy status only when no pull job exists. * Never call updateStatusIndicator while hybrid pull UI is active — that creates a second * minified activity job ("Pulling image: N%") and replaces the layer progress card. * @param {object} response * @param {'pull'|'push'} kind */ function handleImageTransferProgress(response, kind = 'pull') { if (window.peardockOps?.handlePullProgressEvent?.(response)) { return; } // No active hybrid pull job — edge-case lightweight status only const pct = pullProgressPercent(response); const label = response.image || 'image'; const msg = [response.status, response.progress].filter(Boolean).join(' '); const barId = kind === 'push' ? 'push-image' : 'pull-image'; if (response.error) { updateStatusIndicator(`${kind === 'push' ? 'Push' : 'Pull'} error: ${response.error}`); return; } // Prefer in-page progress bar if present; avoid job-tray activity hijack when possible if (pct != null) { updateProgressBar(barId, pct, msg || `${kind === 'push' ? 'Pushing' : 'Pulling'} ${label}`); // Only use status-indicator (activity tray) when drawer is idle if (!window.peardockOps?.isJobDrawerActive?.()) { updateStatusIndicator( `${kind === 'push' ? 'Pushing' : 'Pulling'} ${label}: ${pct}%` ); } } else if (msg && !window.peardockOps?.isJobDrawerActive?.()) { updateStatusIndicator(`${kind === 'push' ? 'Push' : 'Pull'}: ${msg}`); } } function pullImage() { const imageName = document.getElementById('pull-image-name')?.value?.trim(); if (!imageName) { showAlert('danger', 'Please enter an image name'); return; } const credVal = document.getElementById('pull-image-credential')?.value; const credentialId = credVal && credVal !== '__session__' ? credVal : undefined; const modal = bootstrap.Modal.getInstance(document.getElementById('pullImageModal')); if (modal) modal.hide(); if (typeof pullImageWithAuth === 'function') { pullImageWithAuth({ image: imageName, credentialId }).catch(() => {}); return; } showStatusIndicator(`Pulling image "${imageName}"...`); sendCommand('pullImage', { image: imageName, credentialId }).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 { 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(); if (typeof window.peardockOps?.createVolumeJob === 'function') { window.peardockOps .createVolumeJob({ name, driver: driver || undefined }) .then(() => { if (typeof loadVolumes === 'function') loadVolumes(); else sendCommand('listVolumes'); }) .catch((err) => { if (!err?.viaJob) showAlert('danger', err.message || 'Failed to create volume'); }); return; } 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; /** * Force exactly one container-details tab visible. * Bootstrap Tab.show() fails to hide the previous pane when its trigger lost * `active` (e.g. after a global nav-link reset), so panes stack. * * Programmatic class toggles do NOT fire Bootstrap shown/hidden events — every * live tab (logs / terminal / stats / processes) must start/stop here as well * as via the BS event listeners. */ function isContainerDetailsTabActive(tabButtonId, paneId) { return Boolean( document.getElementById(tabButtonId)?.classList.contains('active') || document.getElementById(paneId)?.classList.contains('active') ); } function activateContainerDetailsTab(tabButtonId = 'overview-tab') { const tabList = document.getElementById('container-details-tabs'); const tabContent = document.getElementById('container-details-tab-content'); if (!tabList || !tabContent) return; const targetBtn = document.getElementById(tabButtonId); if (!targetBtn) return; const targetSelector = targetBtn.getAttribute('data-bs-target'); const targetPane = targetSelector ? tabContent.querySelector(targetSelector) : null; const wasLogs = isContainerDetailsTabActive('logs-tab', 'logs-pane'); const wasTerminal = isContainerDetailsTabActive('terminal-tab', 'terminal-pane'); const wasStats = isContainerDetailsTabActive('stats-tab', 'stats-pane'); const wasProcesses = isContainerDetailsTabActive('processes-tab', 'processes-pane'); const willBeLogs = tabButtonId === 'logs-tab'; const willBeTerminal = tabButtonId === 'terminal-tab'; const willBeStats = tabButtonId === 'stats-tab'; const willBeProcesses = tabButtonId === 'processes-tab'; // Leave handlers (before pane class flip) — only when actually leaving that tab if (wasLogs && !willBeLogs && typeof stopDetailsLogs === 'function') { stopDetailsLogs(); } if (wasTerminal && !willBeTerminal && typeof cleanupDetailsTerminal === 'function') { cleanupDetailsTerminal(); } if (wasStats && !willBeStats && typeof destroyDetailsStatsCharts === 'function') { destroyDetailsStatsCharts(); } if (wasProcesses && !willBeProcesses && typeof invalidateContainerTop === 'function') { invalidateContainerTop({ clearDom: true }); } tabList.querySelectorAll('.nav-link').forEach((link) => { const active = link === targetBtn; link.classList.toggle('active', active); link.setAttribute('aria-selected', active ? 'true' : 'false'); link.setAttribute('tabindex', active ? '0' : '-1'); }); tabContent.querySelectorAll('.tab-pane').forEach((pane) => { const active = pane === targetPane; pane.classList.toggle('show', active); pane.classList.toggle('active', active); }); // Enter handlers only when newly entering (avoid double-init on re-activate) const cid = currentContainerDetails?.Id; if (willBeLogs && !wasLogs && cid && typeof startDetailsLogs === 'function') { startDetailsLogs(cid); } if (willBeTerminal && !wasTerminal && cid && typeof initDetailsTerminal === 'function') { initDetailsTerminal(cid); } if ( willBeStats && !wasStats && currentContainerDetails && typeof updateContainerDetailsStats === 'function' ) { updateContainerDetailsStats(currentContainerDetails); } if (willBeProcesses && !wasProcesses && cid && typeof loadContainerTop === 'function') { loadContainerTop(cid); } } /** * Open container details, optionally landing on a specific tab. * @param {object} container * @param {{ tab?: string }} [opts] */ function showContainerDetails(container, opts = {}) { const prevId = currentContainerDetails?.Id; const switchingContainer = prevId && container?.Id && !containerIdsMatch(prevId, container.Id); // Stop streams/PTY from a previous container / tab before switching if (typeof stopDetailsLogs === 'function') stopDetailsLogs(); if (typeof cleanupDetailsTerminal === 'function') cleanupDetailsTerminal(); if (typeof destroyDetailsStatsCharts === 'function') destroyDetailsStatsCharts(); if (typeof invalidateContainerTop === 'function') { invalidateContainerTop({ resetFilters: Boolean(switchingContainer || !prevId), clearDom: true, }); } // Fresh stats UI when opening details (or switching containers) detailsStatsState.paused = false; detailsStatsState.containerId = container?.Id || null; const pauseBtn = document.getElementById('detail-stats-pause-btn'); const live = document.getElementById('detail-stats-live'); if (pauseBtn) pauseBtn.innerHTML = ' Pause'; if (live) { live.classList.remove('is-paused'); live.innerHTML = ' Live'; } currentContainerDetails = container; if (typeof window !== 'undefined') window.currentContainerDetails = container; navigateToView('container-details'); // Update title + lifecycle action bar const titleEl = document.getElementById('container-details-title'); if (titleEl) { const name = container.Names?.[0]?.replace(/^\//, '') || container.Id.substring(0, 12); const safe = typeof escapeHtmlLite === 'function' ? escapeHtmlLite(name) : String(name) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); titleEl.innerHTML = `${safe}`; titleEl.title = name; } updateContainerDetailsActions(container); ensureContainerDetailsActionsWired(); // Load container config const expectedInspectId = container.Id; sendCommand('inspectContainer', { id: container.Id }); // Set up callback for container config (ignore stale replies after switch) window.inspectContainerCallback = (config) => { const configId = config?.Id || config?.ID; if ( !currentContainerDetails || !containerIdsMatch(currentContainerDetails.Id, expectedInspectId) || (configId && !containerIdsMatch(configId, expectedInspectId)) ) { return; } populateContainerDetails(config, container); // Prefer inspect State for accurate action enablement if (config?.State?.Status) { const merged = { ...container, State: config.State.Status, Status: config.State.Status, }; currentContainerDetails = merged; if (typeof window !== 'undefined') window.currentContainerDetails = merged; updateContainerDetailsActions(merged); } window.inspectContainerCallback = null; }; // Land on requested tab with a single visible pane (no stacked content) const tabId = opts.tab || 'overview-tab'; activateContainerDetailsTab(tabId); } /** * Enable/disable container detail lifecycle actions from runtime state. * @param {{ Id?: string, State?: string|object, Status?: string, Names?: string[] }} container */ function updateContainerDetailsActions(container) { const bar = document.getElementById('container-details-actions'); const badge = document.getElementById('container-details-state-badge'); if (!bar) return; const raw = typeof container?.State === 'object' ? container.State?.Status : container?.State || container?.Status || ''; const state = String(raw || '').toLowerCase(); const running = state === 'running'; const paused = state === 'paused'; const stopped = state === 'exited' || state === 'created' || state === 'dead' || state === '' || (!running && !paused && state !== 'restarting'); if (badge) { badge.hidden = !state; badge.textContent = state || ''; badge.className = 'detail-badge container-details-state-badge'; if (running) badge.classList.add('status-running'); else if (paused) badge.classList.add('status-paused'); else if (state === 'restarting') badge.classList.add('status-restarting'); else badge.classList.add('status-exited'); } /** @type {Record} */ const enable = { start: stopped || state === 'exited' || state === 'created' || state === 'dead', stop: running, kill: running || paused || state === 'restarting', restart: running || paused, pause: running, resume: paused, recreate: true, duplicate: true, remove: true, }; bar.querySelectorAll('[data-detail-action]').forEach((btn) => { const action = btn.getAttribute('data-detail-action'); btn.disabled = !enable[action]; }); } let containerDetailsActionsWired = false; function ensureContainerDetailsActionsWired() { if (containerDetailsActionsWired) return; const bar = document.getElementById('container-details-actions'); if (!bar) return; containerDetailsActionsWired = true; bar.addEventListener('click', async (e) => { const btn = e.target.closest('[data-detail-action]'); if (!btn || btn.disabled) return; const action = btn.getAttribute('data-detail-action'); const container = currentContainerDetails; if (!container?.Id) { showAlert('warning', 'No container selected'); return; } await runContainerDetailAction(action, container, btn); }); } /** * Single-container lifecycle action from the details header toolbar. * @param {string} action * @param {{ Id: string, Names?: string[], State?: string }} container * @param {HTMLElement} [btn] */ async function runContainerDetailAction(action, container, btn) { const id = container.Id; const name = (container.Names?.[0] || '').replace(/^\//, '') || id.slice(0, 12); if (!hasActiveConnection()) { showAlert('danger', 'Not connected'); return; } const needConfirm = new Set(['stop', 'kill', 'remove', 'recreate']); if (needConfirm.has(action)) { const labels = { stop: 'Stop', kill: 'Kill', remove: 'Remove', recreate: 'Recreate', }; const bodies = { stop: `Stop container "${name}"?`, kill: `Force-kill container "${name}"?`, remove: `Permanently remove container "${name}"? This cannot be undone.`, recreate: `Recreate "${name}" with the same configuration? It will be stopped and replaced.`, }; let ok = true; if (window.peardockOps?.askUserConfirm) { ok = await window.peardockOps.askUserConfirm( `${labels[action]} container?`, bodies[action], { confirmLabel: labels[action], danger: action === 'remove' || action === 'kill' || action === 'recreate', icon: action === 'remove' ? 'fa-trash' : action === 'kill' ? 'fa-skull' : action === 'recreate' ? 'fa-redo' : 'fa-stop', } ); } if (!ok) return; } if (action === 'duplicate') { if (typeof openDuplicateModal === 'function') { openDuplicateModal(container); } else { showAlert('danger', 'Duplicate is unavailable'); } return; } if (action === 'recreate') { if (typeof recreateContainerAction === 'function') { // recreateContainerAction has its own confirm — skip double confirm by calling RPC showStatusIndicator(`Recreating ${name}…`); try { const res = await manager.request(Methods.recreateContainer, { id, start: true, }); showAlert('success', res?.message || `Recreated ${name}`); sendCommand('listContainers'); // Re-open details for new id if returned if (res?.id) { showContainerDetails({ Id: res.id, Names: [`/${res.name || name}`], State: 'running', }); } else { navigateToView('containers'); } } catch (err) { presentError(err, 'recreateContainer', { showAlert }); } finally { hideStatusIndicator(); } } return; } /** @type {Record} */ const map = { start: { method: Methods.startContainer, label: 'Start' }, stop: { method: Methods.stopContainer, label: 'Stop' }, kill: { method: Methods.killContainer, label: 'Kill' }, restart: { method: Methods.restartContainer, label: 'Restart' }, pause: { method: Methods.pauseContainer, label: 'Pause' }, resume: { method: Methods.unpauseContainer, label: 'Resume' }, remove: { method: Methods.removeContainer, label: 'Remove' }, }; const spec = map[action]; if (!spec) return; if (btn) btn.disabled = true; if (typeof window.peardockOps?.containerActionJob === 'function') { try { await window.peardockOps.containerActionJob({ action, id, name, force: true, }); sendCommand('listContainers'); if (action === 'remove') { currentContainerDetails = null; navigateToView('containers'); return; } const nextState = action === 'start' || action === 'resume' || action === 'restart' ? 'running' : action === 'stop' || action === 'kill' ? 'exited' : action === 'pause' ? 'paused' : container.State; const updated = { ...container, State: nextState, Status: nextState }; currentContainerDetails = updated; if (typeof window !== 'undefined') window.currentContainerDetails = updated; updateContainerDetailsActions(updated); } catch (err) { if (!err?.viaJob) presentError(err, spec.method, { showAlert }); } finally { if (btn) btn.disabled = false; } return; } showStatusIndicator(`${spec.label}ing ${name}…`); try { const payload = action === 'remove' ? { id, force: true } : { id }; const res = await manager.request(spec.method, payload); showAlert('success', res?.message || `${spec.label}ed ${name}`); sendCommand('listContainers'); if (action === 'remove') { currentContainerDetails = null; navigateToView('containers'); return; } // Refresh details state from list when available const nextState = action === 'start' || action === 'resume' || action === 'restart' ? 'running' : action === 'stop' || action === 'kill' ? 'exited' : action === 'pause' ? 'paused' : container.State; const updated = { ...container, State: nextState, Status: nextState }; currentContainerDetails = updated; if (typeof window !== 'undefined') window.currentContainerDetails = updated; updateContainerDetailsActions(updated); // Re-inspect for accurate overview sendCommand('inspectContainer', { id }); window.inspectContainerCallback = (config) => { const configId = config?.Id || config?.ID; if ( !currentContainerDetails || !containerIdsMatch(currentContainerDetails.Id, id) || (configId && !containerIdsMatch(configId, id)) ) { return; } populateContainerDetails(config, updated); if (config?.State?.Status) { const merged = { ...updated, State: config.State.Status }; currentContainerDetails = merged; updateContainerDetailsActions(merged); } window.inspectContainerCallback = null; }; } catch (err) { presentError(err, spec.method, { showAlert }); updateContainerDetailsActions(container); } finally { hideStatusIndicator(); } } window.runContainerDetailAction = runContainerDetailAction; window.updateContainerDetailsActions = updateContainerDetailsActions; // 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; if ( currentContainerDetails?.Id && container?.Id && !containerIdsMatch(currentContainerDetails.Id, container.Id) ) { return; } // Overview Tab populateOverviewTab(config, container); // Configuration Tab populateConfigTab(config); // Networking Tab populateNetworkingTab(config); // Stats KPIs / history (charts only materialize when Stats tab is visible) updateContainerDetailsStats(container); // Attach copy / tunnel button event listeners after content is populated setTimeout(() => { if ( currentContainerDetails?.Id && container?.Id && !containerIdsMatch(currentContainerDetails.Id, container.Id) ) { return; } 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 esc = typeof escapeHtml === 'function' ? escapeHtml : (t) => String(t ?? ''); const name = (config.Name || '').replace(/^\//, '') || 'Unknown'; const image = config.Config?.Image || container?.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 startedAtRaw = config.State?.StartedAt; const startedValid = startedAtRaw && !String(startedAtRaw).startsWith('0001-01-01'); const started = startedValid ? new Date(startedAtRaw).toLocaleString() : 'Not started'; const startedRelative = startedValid ? formatRelativeTime(startedAtRaw) : ''; const finishedAtRaw = config.State?.FinishedAt; const finishedValid = finishedAtRaw && !String(finishedAtRaw).startsWith('0001-01-01'); const finished = finishedValid ? new Date(finishedAtRaw).toLocaleString() : ''; const finishedRelative = finishedValid ? formatRelativeTime(finishedAtRaw) : ''; const fullId = config.Id || container?.Id || ''; const shortId = fullId ? fullId.substring(0, 12) : 'Unknown'; const restartCount = config.RestartCount || 0; const pid = config.State?.Pid || 0; const exitCode = config.State?.ExitCode; const errorMsg = config.State?.Error || ''; const oomKilled = Boolean(config.State?.OOMKilled); const privileged = Boolean(config.HostConfig?.Privileged); const readonlyRootfs = Boolean(config.HostConfig?.ReadonlyRootfs); const hostname = config.Config?.Hostname || ''; const user = config.Config?.User || ''; const workingDir = config.Config?.WorkingDir || ''; const networkMode = config.HostConfig?.NetworkMode || 'default'; const restartPolicy = config.HostConfig?.RestartPolicy?.Name || 'no'; const restartMax = config.HostConfig?.RestartPolicy?.MaximumRetryCount || 0; const platform = config.Platform || [config.Os, config.Architecture].filter(Boolean).join('/') || ''; const labels = config.Config?.Labels || {}; const composeProject = labels['com.docker.compose.project'] || labels['com.docker.compose.project.working_dir'] || ''; const composeService = labels['com.docker.compose.service'] || ''; const composeWorkdir = labels['com.docker.compose.project.working_dir'] || ''; const logDriver = config.HostConfig?.LogConfig?.Type || 'json-file'; const healthStatus = config.State?.Health?.Status || ''; const healthFailing = config.State?.Health?.FailingStreak; const cmd = config.Config?.Cmd || []; const entrypoint = config.Config?.Entrypoint || []; const cmdStr = Array.isArray(cmd) ? cmd.join(' ') : String(cmd || ''); const entryStr = Array.isArray(entrypoint) ? entrypoint.join(' ') : String(entrypoint || ''); const envCount = Array.isArray(config.Config?.Env) ? config.Config.Env.length : 0; // Networks const networksMap = config.NetworkSettings?.Networks || {}; const networkNames = Object.keys(networksMap); let ipAddress = ''; let primaryNetwork = null; let primaryNetName = ''; for (const [n, net] of Object.entries(networksMap)) { if (net?.IPAddress) { ipAddress = net.IPAddress; primaryNetwork = net; primaryNetName = n; break; } } if (!primaryNetwork && networkNames.length) { primaryNetName = networkNames[0]; primaryNetwork = networksMap[primaryNetName]; } // Ports (compact) const ports = config.NetworkSettings?.Ports || {}; const portChips = []; Object.keys(ports).forEach((portKey) => { const bindings = ports[portKey]; if (bindings && bindings.length) { bindings.forEach((b) => { portChips.push(`${b.HostPort || '·'}→${portKey}`); }); } else { portChips.push(portKey); } }); const portShow = portChips.slice(0, 8); const portExtra = Math.max(0, portChips.length - portShow.length); // Mounts (compact) const mounts = config.Mounts || []; const mountShow = mounts.slice(0, 5); const mountExtra = Math.max(0, mounts.length - mountShow.length); // Resources const memoryBytes = config.HostConfig?.Memory || 0; const nanoCpus = config.HostConfig?.NanoCpus || 0; const cpuShares = config.HostConfig?.CpuShares || 0; const cpuQuota = config.HostConfig?.CpuQuota || 0; const cpuPeriod = config.HostConfig?.CpuPeriod || 0; let cpuLimit = 'Unlimited'; if (nanoCpus > 0) cpuLimit = `${(nanoCpus / 1e9).toFixed(2)} CPUs`; else if (cpuQuota > 0 && cpuPeriod > 0) cpuLimit = `${(cpuQuota / cpuPeriod).toFixed(2)} CPUs`; else if (cpuShares > 0) cpuLimit = `${cpuShares} shares`; const memLimit = memoryBytes > 0 ? formatBytes(memoryBytes) : 'Unlimited'; // Uptime when running let uptimeLabel = ''; if (state === 'running' && startedValid) { const ms = Date.now() - new Date(startedAtRaw).getTime(); if (ms > 0) { const s = Math.floor(ms / 1000); const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60); if (d > 0) uptimeLabel = `${d}d ${h}h`; else if (h > 0) uptimeLabel = `${h}h ${m}m`; else uptimeLabel = `${m}m`; } } const statusBadgeClass = state === 'running' ? 'status-running' : state === 'paused' ? 'status-paused' : state === 'restarting' ? 'status-restarting' : 'status-exited'; const statusIcon = state === 'running' ? 'fa-circle-check' : state === 'paused' ? 'fa-pause-circle' : state === 'restarting' ? 'fa-sync-alt' : 'fa-stop-circle'; const healthBadgeClass = healthStatus === 'healthy' ? 'status-running' : healthStatus === 'unhealthy' ? 'status-exited' : 'status-paused'; const kv = (icon, label, valueHtml) => `
${label} ${valueHtml}
`; const muted = (t) => `${esc(t)}`; const codeCopy = (display, full = display) => `${esc(display)}` + (full ? ` ` : ''); const chip = (text, title = '') => `${esc(text)}`; const section = (title, icon, body) => `

${title}

${body}
`; const pills = []; pills.push( ` ${restartCount} restart${restartCount === 1 ? '' : 's'}` ); if (uptimeLabel) { pills.push( ` up ${esc(uptimeLabel)}` ); } if (healthStatus) { pills.push( ` ${esc(healthStatus)}` ); } if (privileged) { pills.push( ` privileged` ); } if (readonlyRootfs) { pills.push( ` ro rootfs` ); } if (composeProject) { pills.push( ` ${esc(composeProject)}${composeService ? ` · ${esc(composeService)}` : ''}` ); } if (oomKilled) { pills.push( ` OOM` ); } const metricChips = [ { icon: 'fa-microchip', label: 'CPU', value: cpuLimit }, { icon: 'fa-memory', label: 'Memory', value: memLimit }, { icon: 'fa-project-diagram', label: 'Networks', value: String(networkNames.length) }, { icon: 'fa-plug', label: 'Ports', value: String(portChips.length) }, { icon: 'fa-hdd', label: 'Mounts', value: String(mounts.length) }, { icon: 'fa-list', label: 'Env', value: String(envCount) }, ] .map( (m) => `
${esc(m.label)} ${esc(m.value)}
` ) .join(''); const identityBody = [ kv( 'fa-fingerprint', 'ID', codeCopy(shortId, fullId) ), kv( 'fa-box', 'Image', `${esc(image.length > 64 ? image.slice(0, 61) + '…' : image)}` + (image ? ` ` : '') ), platform ? kv('fa-server', 'OS', esc(platform)) : '', hostname ? kv('fa-desktop', 'Host', `${esc(hostname)}`) : '', user ? kv('fa-user', 'User', esc(user)) : kv('fa-user', 'User', muted('default')), workingDir ? kv('fa-folder', 'CWD', `${esc(workingDir)}`) : '', ].join(''); const runtimeBody = [ kv( 'fa-redo', 'Restart', `${esc(restartPolicy)}${restartMax > 0 ? ` ×${restartMax}` : ''}` ), kv('fa-sitemap', 'Mode', `${esc(networkMode)}`), kv('fa-scroll', 'Log', `${esc(logDriver)}`), pid > 0 ? kv('fa-hashtag', 'PID', `${pid}`) : '', state !== 'running' && exitCode != null ? kv('fa-door-open', 'Exit', `${exitCode}`) : '', finished && state !== 'running' ? kv( 'fa-stop-circle', 'Ended', `${esc(finished)}${finishedRelative ? ` ${esc(finishedRelative)}` : ''}` ) : '', errorMsg ? kv('fa-exclamation-triangle', 'Error', `${esc(errorMsg.slice(0, 120))}`) : '', ].join(''); const networkBody = [ kv( 'fa-network-wired', 'IP', ipAddress ? codeCopy(ipAddress) : muted('None') ), primaryNetwork?.Gateway ? kv('fa-route', 'GW', `${esc(primaryNetwork.Gateway)}`) : '', primaryNetwork?.MacAddress ? kv('fa-ethernet', 'MAC', `${esc(primaryNetwork.MacAddress)}`) : '', networkNames.length ? kv( 'fa-project-diagram', 'Nets', networkNames .slice(0, 4) .map((n) => chip(n)) .join('') + (networkNames.length > 4 ? chip(`+${networkNames.length - 4}`) : '') ) : kv('fa-project-diagram', 'Nets', muted('None')), ].join(''); const emptyState = (icon, text) => `
${esc(text)}
`; const portsBody = portShow.length > 0 ? `
${portShow .map((p) => chip(p)) .join('')}${portExtra ? chip(`+${portExtra} more`) : ''}
` : emptyState('fa-plug', 'No published ports'); const storageBody = mountShow.length > 0 ? `
${mountShow .map((m) => { const src = m.Source || m.Name || '?'; const dst = m.Destination || '?'; const ro = m.RW === false || m.Mode === 'ro'; const full = `${src} → ${dst} (${m.Type || 'bind'}${ro ? ', ro' : ''})`; return ` ${esc(src)} ${esc(dst)} ${ro ? 'ro' : ''} `; }) .join('')}${ mountExtra ? `+${mountExtra} more` : '' }
` : emptyState('fa-hdd', 'No mounts'); const timelineBody = `
Created ${esc(created)} ${createdRelative ? `${esc(createdRelative)}` : ''}
Started ${esc(started)} ${startedRelative ? `${esc(startedRelative)}` : ''}
${ composeWorkdir ? `
Compose ${esc(composeWorkdir)}
` : '' }
`; const commandLine = [entryStr, cmdStr].filter(Boolean).join(' ').trim(); const commandDisplay = commandLine; content.innerHTML = `
${esc(state)}
${esc(name)}
${pills.join('')}
${metricChips}
${section('Identity', 'fa-id-card', identityBody)} ${section('Runtime', 'fa-cog', runtimeBody)} ${section('Network', 'fa-network-wired', networkBody)}

Ports

${portsBody}

Mounts

${storageBody}

Timeline

${timelineBody}
${ commandLine ? `
Command ${esc(commandDisplay)}
` : '' }
`; } 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 || !config) return; const esc = typeof escapeHtml === 'function' ? escapeHtml : (t) => String(t ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); const containerId = config.Id || currentContainerDetails?.Id || ''; const containerName = (config.Name || '').replace(/^\//, '') || containerId.slice(0, 12); const networkMode = config.HostConfig?.NetworkMode || 'default'; const networks = config.NetworkSettings?.Networks || {}; const ports = config.NetworkSettings?.Ports || config.HostConfig?.PortBindings || {}; const dns = config.HostConfig?.Dns || []; const extraHosts = config.HostConfig?.ExtraHosts || []; const links = config.HostConfig?.Links || []; const isHostOrNone = networkMode === 'host' || networkMode === 'none'; const portBindings = []; 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, }); }); } else { portBindings.push({ containerPort: portNum, protocol: protocol || 'tcp', hostIp: null, hostPort: null, }); } }); const networkCards = Object.keys(networks).map((netName) => { const net = networks[netName] || {}; return { name: netName, ipAddress: net.IPAddress || '', gateway: net.Gateway || '', macAddress: net.MacAddress || '', networkId: net.NetworkID || '', endpointId: net.EndpointID || '', ipPrefixLen: net.IPPrefixLen || null, globalIPv6Address: net.GlobalIPv6Address || '', globalIPv6PrefixLen: net.GlobalIPv6PrefixLen || null, ipv6Gateway: net.IPv6Gateway || '', aliases: Array.isArray(net.Aliases) ? net.Aliases : [], }; }); const canJoin = !isHostOrNone && Boolean(containerId); content.innerHTML = `
Network manager

Mode ${esc(networkMode)} · ${networkCards.length} connected network${networkCards.length === 1 ? '' : 's'}

Connected networks

${ networkCards.length ? `
${networkCards .map((net) => { const canDisconnect = net.name !== 'host' && net.name !== 'none' && net.networkId && !isHostOrNone; const ip = net.ipAddress ? `${esc(net.ipAddress)}${net.ipPrefixLen ? `/${net.ipPrefixLen}` : ''}` : ''; return ``; }) .join('')}
Network IPv4 Gateway MAC Aliases
${esc(net.name)} ${net.networkId ? `
${esc(net.networkId.slice(0, 12))}
` : ''}
${net.ipAddress ? `${ip}` : ip} ${net.gateway ? `${esc(net.gateway)}` : ''} ${net.macAddress ? `${esc(net.macAddress)}` : ''} ${ net.aliases.length ? net.aliases.map((a) => `${esc(a)}`).join(' ') : '' } ${ canDisconnect ? `` : '' }
` : `
No networks connected
` }

Published ports

${ portBindings.length ? `
${portBindings .map((b) => { const host = b.hostPort ? `${b.hostIp || '0.0.0.0'}:${b.hostPort}` : '—'; return ``; }) .join('')}
Host Container Protocol
${b.hostPort ? `${esc(host)}` : ''} ${esc(b.containerPort)} ${esc(b.protocol)} ${ b.hostPort ? ` ` : '' }
` : `
No published ports
` }
${ dns.length ? `

DNS

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

Extra hosts

${extraHosts.map((h) => `${esc(h)}`).join('')}
` : '' } ${ links.length ? `

Links

${links.map((l) => `${esc(l)}`).join('')}
` : '' }
`; // Wire actions content.querySelectorAll('.copy-btn').forEach((btn) => { btn.addEventListener('click', () => { const text = btn.getAttribute('data-copy'); if (text) copyToClipboard(text, btn); }); }); content.querySelectorAll('.network-disconnect-btn').forEach((btn) => { btn.addEventListener('click', async () => { const cId = btn.getAttribute('data-container-id'); const nId = btn.getAttribute('data-network-id'); const nName = btn.getAttribute('data-network-name'); if (window.peardockOps?.disconnectContainerNetwork) { await window.peardockOps.disconnectContainerNetwork(cId, nId, nName); refreshContainerNetworking(cId); } else if (manager.active?.connected) { try { await manager.request(Methods.disconnectNetwork, { containerId: cId, networkId: nId, }); showAlert('success', `Disconnected from ${nName || nId}`); refreshContainerNetworking(cId); } catch (err) { presentError(err, 'disconnectNetwork', { showAlert }); } } }); }); // Tunnel buttons re-bound by populateContainerDetails setTimeout in most cases; // bind here for networking-only refresh. content.querySelectorAll('.action-tunnel-port').forEach((btn) => { if (btn._tunnelBound) return; btn._tunnelBound = true; btn.addEventListener('click', async function () { const cid = 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 cname = this.getAttribute('data-container-name') || ''; if (!cid || !containerPort) return; try { this.disabled = true; const res = await manager.request(Methods.createTunnel, { containerId: cid, containerPort, protocol, name: cname ? `${cname}:${containerPort}` : `port-${hostPort || containerPort}`, secure: true, }); const url = res?.tunnel?.url; if (url && navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(url); } catch { // ignore } } showAlert( res?.existing ? 'info' : 'success', url ? res?.existing ? 'Tunnel already exists — URL copied' : 'Holesail tunnel created — URL copied' : 'Tunnel updated' ); } catch (err) { presentError(err, 'createTunnel', { showAlert }); } finally { this.disabled = false; } }); }); document.getElementById('net-mgr-refresh-btn')?.addEventListener('click', () => { if (containerId) refreshContainerNetworking(containerId); }); const joinPanel = document.getElementById('net-mgr-join-panel'); const joinBtn = document.getElementById('net-mgr-join-btn'); const joinCancel = document.getElementById('net-mgr-join-cancel'); const joinConfirm = document.getElementById('net-mgr-join-confirm'); const joinSelect = document.getElementById('net-mgr-join-select'); joinBtn?.addEventListener('click', async () => { if (!joinPanel) return; joinPanel.hidden = false; if (joinSelect) { joinSelect.innerHTML = ''; try { const res = await manager.request(Methods.listNetworks, {}); const list = res?.data || res?.networks || []; const connected = new Set(networkCards.map((n) => n.name)); const opts = list .filter((n) => n && n.Name && !connected.has(n.Name) && n.Name !== 'host' && n.Name !== 'none') .map( (n) => `` ); joinSelect.innerHTML = '' + (opts.length ? opts.join('') : ''); } catch (err) { joinSelect.innerHTML = ''; presentError(err, 'listNetworks', { showAlert }); } } }); joinCancel?.addEventListener('click', () => { if (joinPanel) joinPanel.hidden = true; }); joinConfirm?.addEventListener('click', async () => { const networkId = joinSelect?.value; if (!networkId || !containerId) { showAlert('warning', 'Select a network to join'); return; } const aliasesRaw = document.getElementById('net-mgr-join-aliases')?.value || ''; const aliases = aliasesRaw .split(',') .map((s) => s.trim()) .filter(Boolean); const ipv4Address = document.getElementById('net-mgr-join-ipv4')?.value?.trim() || ''; const ipv6Address = document.getElementById('net-mgr-join-ipv6')?.value?.trim() || ''; try { joinConfirm.disabled = true; await manager.request(Methods.connectNetwork, { networkId, containerId, aliases: aliases.length ? aliases : undefined, ipv4Address: ipv4Address || undefined, ipv6Address: ipv6Address || undefined, }); showAlert('success', 'Connected to network'); if (joinPanel) joinPanel.hidden = true; refreshContainerNetworking(containerId); } catch (err) { presentError(err, 'connectNetwork', { showAlert }); } finally { joinConfirm.disabled = false; } }); } /** Re-inspect container and refresh Networking tab (and cache details). */ async function refreshContainerNetworking(containerId) { if (!containerId || !manager.active?.connected) return; try { const res = await manager.request(Methods.inspectContainer, { id: containerId }); const config = res?.data || res; if (!config) return; if (currentContainerDetails?.Id === containerId) { // Keep list object; merge inspect for networking paint populateNetworkingTab(config); } } catch (err) { console.warn('[WARN] refresh networking failed', err?.message || err); } } window.refreshContainerNetworking = refreshContainerNetworking; function formatStatsBytes(n) { const v = Number(n) || 0; if (v < 1024) return `${v.toFixed(0)} B`; if (v < 1024 ** 2) return `${(v / 1024).toFixed(1)} KB`; if (v < 1024 ** 3) return `${(v / 1024 ** 2).toFixed(1)} MB`; return `${(v / 1024 ** 3).toFixed(2)} GB`; } function formatStatsRate(n) { return `${formatStatsBytes(n)}/s`; } function formatStatsMem(bytes) { const b = Number(bytes) || 0; if (b >= 1024 ** 3) return `${(b / 1024 ** 3).toFixed(2)} GB`; return `${(b / 1024 ** 2).toFixed(1)} MB`; } function percentile(sortedAsc, p) { if (!sortedAsc.length) return 0; const idx = Math.min( sortedAsc.length - 1, Math.max(0, Math.ceil((p / 100) * sortedAsc.length) - 1) ); return sortedAsc[idx]; } function sliceHistoryByTimeframe(history, timeframeSec) { const n = history.timestamps.length; if (!n) { return { timestamps: [], cpu: [], memory: [], memoryLimit: [], netRxRate: [], netTxRate: [], blkReadRate: [], blkWriteRate: [], }; } let start = 0; if (timeframeSec > 0) { const cutoff = Date.now() - timeframeSec * 1000; start = history.timestamps.findIndex((t) => Number(t) >= cutoff); if (start < 0) start = n; } const slice = (arr) => (Array.isArray(arr) ? arr.slice(start) : []); return { timestamps: slice(history.timestamps), cpu: slice(history.cpu), memory: slice(history.memory), memoryLimit: slice(history.memoryLimit), netRxRate: slice(history.netRxRate), netTxRate: slice(history.netTxRate), blkReadRate: slice(history.blkReadRate), blkWriteRate: slice(history.blkWriteRate), }; } function formatChartLabels(timestamps) { const span = timestamps.length > 1 ? Number(timestamps[timestamps.length - 1]) - Number(timestamps[0]) : 0; const showDate = span > 3600 * 1000; return timestamps.map((ts) => { const d = new Date(ts); const t = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; if (!showDate) return t; return `${d.getMonth() + 1}/${d.getDate()} ${t}`; }); } function chartThemeOptions(yLabel, yMax) { const autoscale = detailsStatsState.autoscale; const y = { beginAtZero: true, ticks: { color: 'rgba(255, 255, 255, 0.55)', font: { size: 10 }, maxTicksLimit: 6, }, grid: { color: 'rgba(255, 255, 255, 0.06)' }, border: { color: 'rgba(255, 255, 255, 0.08)' }, title: yLabel ? { display: true, text: yLabel, color: 'rgba(255, 255, 255, 0.4)', font: { size: 10 }, } : undefined, }; if (!autoscale && yMax != null) y.max = yMax; else if (autoscale && yMax != null) y.suggestedMax = yMax; return { responsive: true, maintainAspectRatio: false, animation: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: true, position: 'top', align: 'end', labels: { color: 'rgba(255, 255, 255, 0.65)', boxWidth: 10, boxHeight: 10, font: { size: 10 }, padding: 8, }, }, tooltip: { backgroundColor: 'rgba(15, 19, 26, 0.95)', borderColor: 'rgba(255, 255, 255, 0.1)', borderWidth: 1, titleFont: { size: 11 }, bodyFont: { size: 11 }, padding: 8, }, }, scales: { y, x: { ticks: { color: 'rgba(255, 255, 255, 0.45)', font: { size: 9 }, maxTicksLimit: 8, maxRotation: 0, }, grid: { color: 'rgba(255, 255, 255, 0.04)' }, border: { color: 'rgba(255, 255, 255, 0.08)' }, }, }, elements: { point: { radius: 0, hoverRadius: 3 }, line: { borderWidth: 1.75, tension: 0.3 }, }, }; } function upsertLineChart(key, canvasId, labels, datasets, yLabel, yMax) { if (typeof Chart === 'undefined') return; // Charts measure layout; skip create/update while the Stats pane is hidden if (!isContainerDetailsTabActive('stats-tab', 'stats-pane')) return; const canvas = document.getElementById(canvasId); if (!canvas) return; const chart = detailsStatsState.charts[key]; if (!chart) { // Avoid Chart.js init on a 0×0 canvas (first paint / hidden pane) if ((canvas.clientWidth || 0) < 2 || (canvas.clientHeight || 0) < 2) return; detailsStatsState.charts[key] = new Chart(canvas, { type: 'line', data: { labels, datasets }, options: chartThemeOptions(yLabel, yMax), }); } else { chart.data.labels = labels; chart.data.datasets = datasets; chart.options = chartThemeOptions(yLabel, yMax); chart.update('none'); } } function destroyDetailsStatsCharts() { for (const key of Object.keys(detailsStatsState.charts)) { try { detailsStatsState.charts[key]?.destroy(); } catch { // ignore } detailsStatsState.charts[key] = null; } } function ensureDetailsStatsWired() { if (detailsStatsState.wired) return; const root = document.getElementById('container-stats-content'); if (!root) return; detailsStatsState.wired = true; root.querySelectorAll('.detail-stats-tf').forEach((btn) => { btn.addEventListener('click', () => { root.querySelectorAll('.detail-stats-tf').forEach((b) => b.classList.remove('active')); btn.classList.add('active'); detailsStatsState.timeframeSec = Number(btn.dataset.tf) || 0; if (detailsStatsState.containerId) { updateStatsCharts(detailsStatsState.containerId); } }); }); document.getElementById('detail-stats-autoscale')?.addEventListener('change', (e) => { detailsStatsState.autoscale = Boolean(e.target.checked); if (detailsStatsState.containerId) updateStatsCharts(detailsStatsState.containerId); }); document.getElementById('detail-stats-pause-btn')?.addEventListener('click', () => { detailsStatsState.paused = !detailsStatsState.paused; const btn = document.getElementById('detail-stats-pause-btn'); const live = document.getElementById('detail-stats-live'); if (btn) { btn.innerHTML = detailsStatsState.paused ? ' Resume' : ' Pause'; } if (live) { live.classList.toggle('is-paused', detailsStatsState.paused); live.innerHTML = detailsStatsState.paused ? ' Paused' : ' Live'; } }); document.getElementById('detail-stats-export-btn')?.addEventListener('click', () => { if (detailsStatsState.containerId) exportDetailsStatsCsv(detailsStatsState.containerId); }); document.getElementById('detail-stats-refresh-btn')?.addEventListener('click', () => { if (currentContainerDetails) { fetchServerStatsHistory(currentContainerDetails.Id, true); } }); } function mergeServerHistory(containerId, points) { if (!Array.isArray(points) || !points.length) return; const h = ensureHistorySeries(containerId); // Prefer longer server series; keep live tail if client is ahead if (points.length >= h.timestamps.length) { h.timestamps = points.map((p) => p.t); h.cpu = points.map((p) => Number(p.cpu) || 0); h.memory = points.map((p) => Number(p.memory) || 0); h.memoryLimit = points.map((p) => Number(p.memoryLimit) || 0); h.netRxRate = points.map((p) => Number(p.netRxRate) || 0); h.netTxRate = points.map((p) => Number(p.netTxRate) || 0); h.blkReadRate = points.map((p) => Number(p.blkReadRate) || 0); h.blkWriteRate = points.map((p) => Number(p.blkWriteRate) || 0); } } function fetchServerStatsHistory(containerId, forceCharts = false) { if (!manager.active?.connected || !containerId) return; const limit = Math.min(MAX_HISTORY_POINTS, 900); const since = detailsStatsState.timeframeSec > 0 ? Date.now() - detailsStatsState.timeframeSec * 1000 : undefined; manager .request(Methods.getStatsHistory, { id: containerId, limit, since }) .then((res) => { if (res?.data?.length) { mergeServerHistory(containerId, res.data); if (forceCharts || !detailsStatsState.paused) updateStatsCharts(containerId); } else if (forceCharts) { updateStatsCharts(containerId); } }) .catch(() => { if (forceCharts) updateStatsCharts(containerId); }); } function updateContainerDetailsStats(container) { if (!container?.Id) return; ensureDetailsStatsWired(); detailsStatsState.containerId = container.Id; ensureHistorySeries(container.Id); // Pending summary chips until history/live samples arrive for (const id of [ 'detail-stats-window', 'detail-stats-cpu-p95', 'detail-stats-mem-peak', 'detail-stats-updated', ]) { const el = document.getElementById(id); if (el && (!el.textContent || el.textContent === '—' || el.querySelector?.('.job-spinner'))) { el.innerHTML = jobSpinnerPending(); } } updateContainerDetailsStatsLive(container.Id); fetchServerStatsHistory(container.Id, true); } /** Live KPI + charts refresh (skips network fetch). */ function updateContainerDetailsStatsLive(containerId) { const stats = smoothedStats[containerId]; const cpuEl = document.getElementById('detail-cpu'); const memEl = document.getElementById('detail-memory'); const netEl = document.getElementById('detail-net'); const diskEl = document.getElementById('detail-disk'); const cpuBar = document.getElementById('detail-cpu-bar'); const memBar = document.getElementById('detail-memory-bar'); const memSub = document.getElementById('detail-memory-sub'); const updatedEl = document.getElementById('detail-stats-updated'); if (stats) { if (cpuEl) cpuEl.textContent = `${stats.cpu.toFixed(2)}%`; if (memEl) memEl.textContent = formatStatsMem(stats.memory); if (netEl) { netEl.textContent = `↓${formatStatsBytes(stats.netRxRate || 0)} · ↑${formatStatsBytes(stats.netTxRate || 0)}`; } if (diskEl) { diskEl.textContent = `R${formatStatsBytes(stats.blkReadRate || 0)} · W${formatStatsBytes(stats.blkWriteRate || 0)}`; } if (cpuBar) { const pct = Math.min(100, Math.max(0, stats.cpu)); cpuBar.style.width = `${pct}%`; } const lim = Number(stats.memoryLimit) || 0; if (memBar && lim > 0) { memBar.style.width = `${Math.min(100, (stats.memory / lim) * 100)}%`; } else if (memBar) { memBar.style.width = '0%'; } if (memSub) { memSub.textContent = lim > 0 ? `of ${formatStatsMem(lim)} · ${((stats.memory / lim) * 100).toFixed(1)}%` : 'limit unlimited'; } } if (updatedEl) { updatedEl.textContent = new Date().toLocaleTimeString(); } if (!detailsStatsState.paused) { updateStatsCharts(containerId); } } function updateStatsCharts(containerId) { const full = historicalStats[containerId]; if (!full?.timestamps?.length) { const samplesEl = document.getElementById('detail-stats-samples'); if (samplesEl) samplesEl.textContent = '0'; return; } const win = sliceHistoryByTimeframe(full, detailsStatsState.timeframeSec); const labels = formatChartLabels(win.timestamps); const memMB = win.memory.map((m) => (Number(m) || 0) / (1024 * 1024)); // KPI window stats const cpuSorted = [...win.cpu].sort((a, b) => a - b); const avg = (arr) => arr.length ? arr.reduce((s, v) => s + (Number(v) || 0), 0) / arr.length : 0; const peak = (arr) => (arr.length ? Math.max(...arr.map((v) => Number(v) || 0)) : 0); const cpuAvg = avg(win.cpu); const cpuPeak = peak(win.cpu); const cpuP95 = percentile(cpuSorted, 95); const memPeak = peak(win.memory); const lastLim = win.memoryLimit?.length ? Number(win.memoryLimit[win.memoryLimit.length - 1]) || 0 : smoothedStats[containerId]?.memoryLimit || 0; const cpuSub = document.getElementById('detail-cpu-sub'); if (cpuSub) { cpuSub.textContent = `avg ${cpuAvg.toFixed(1)}% · peak ${cpuPeak.toFixed(1)}%`; } const netSub = document.getElementById('detail-net-sub'); if (netSub) { netSub.textContent = `avg ↓${formatStatsRate(avg(win.netRxRate))} · ↑${formatStatsRate(avg(win.netTxRate))}`; } const diskSub = document.getElementById('detail-disk-sub'); if (diskSub) { diskSub.textContent = `avg R${formatStatsRate(avg(win.blkReadRate))} · W${formatStatsRate(avg(win.blkWriteRate))}`; } const samplesEl = document.getElementById('detail-stats-samples'); if (samplesEl) samplesEl.textContent = String(win.timestamps.length); const windowEl = document.getElementById('detail-stats-window'); if (windowEl) { windowEl.textContent = detailsStatsState.timeframeSec > 0 ? detailsStatsState.timeframeSec >= 60 ? `${detailsStatsState.timeframeSec / 60}m` : `${detailsStatsState.timeframeSec}s` : 'all retained'; } const p95El = document.getElementById('detail-stats-cpu-p95'); if (p95El) p95El.textContent = `${cpuP95.toFixed(1)}%`; const memPeakEl = document.getElementById('detail-stats-mem-peak'); if (memPeakEl) memPeakEl.textContent = formatStatsMem(memPeak); const cpuMax = detailsStatsState.autoscale ? Math.max(10, Math.ceil(cpuPeak * 1.15) || 10) : Math.max(100, Math.ceil(cpuPeak)); const memMax = detailsStatsState.autoscale ? Math.max(16, Math.ceil(Math.max(...memMB, lastLim / (1024 * 1024) || 0) * 1.1) || 16) : undefined; upsertLineChart( 'cpu', 'cpu-chart-canvas', labels, [ { label: 'CPU %', data: win.cpu, borderColor: 'rgb(52, 211, 153)', backgroundColor: 'rgba(52, 211, 153, 0.12)', fill: true, }, ], '%', cpuMax ); upsertLineChart( 'memory', 'memory-chart-canvas', labels, [ { label: 'Working set', data: memMB, borderColor: 'rgb(56, 189, 248)', backgroundColor: 'rgba(56, 189, 248, 0.12)', fill: true, }, ...(lastLim > 0 ? [ { label: 'Limit', data: win.memory.map(() => lastLim / (1024 * 1024)), borderColor: 'rgba(248, 113, 113, 0.65)', backgroundColor: 'transparent', borderDash: [4, 4], fill: false, pointRadius: 0, }, ] : []), ], 'MB', memMax ); const netPeak = Math.max(peak(win.netRxRate), peak(win.netTxRate), 1); upsertLineChart( 'net', 'net-chart-canvas', labels, [ { label: 'RX', data: win.netRxRate, borderColor: 'rgb(167, 139, 250)', backgroundColor: 'rgba(167, 139, 250, 0.1)', fill: true, }, { label: 'TX', data: win.netTxRate, borderColor: 'rgb(251, 191, 36)', backgroundColor: 'rgba(251, 191, 36, 0.08)', fill: true, }, ], 'B/s', detailsStatsState.autoscale ? netPeak * 1.2 : undefined ); const diskPeak = Math.max(peak(win.blkReadRate), peak(win.blkWriteRate), 1); upsertLineChart( 'disk', 'disk-chart-canvas', labels, [ { label: 'Read', data: win.blkReadRate, borderColor: 'rgb(45, 212, 191)', backgroundColor: 'rgba(45, 212, 191, 0.1)', fill: true, }, { label: 'Write', data: win.blkWriteRate, borderColor: 'rgb(244, 114, 182)', backgroundColor: 'rgba(244, 114, 182, 0.08)', fill: true, }, ], 'B/s', detailsStatsState.autoscale ? diskPeak * 1.2 : undefined ); } function exportDetailsStatsCsv(containerId) { const full = historicalStats[containerId]; if (!full?.timestamps?.length) { showAlert('info', 'No stats samples to export yet'); return; } const win = sliceHistoryByTimeframe(full, detailsStatsState.timeframeSec); const rows = [ [ 'timestamp_iso', 'cpu_percent', 'memory_bytes', 'memory_limit_bytes', 'net_rx_Bps', 'net_tx_Bps', 'blk_read_Bps', 'blk_write_Bps', ].join(','), ]; for (let i = 0; i < win.timestamps.length; i++) { rows.push( [ new Date(win.timestamps[i]).toISOString(), (win.cpu[i] ?? 0).toFixed(4), Math.round(win.memory[i] ?? 0), Math.round(win.memoryLimit[i] ?? 0), Math.round(win.netRxRate[i] ?? 0), Math.round(win.netTxRate[i] ?? 0), Math.round(win.blkReadRate[i] ?? 0), Math.round(win.blkWriteRate[i] ?? 0), ].join(',') ); } const blob = new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8' }); const a = document.createElement('a'); const short = String(containerId).slice(0, 12); a.href = URL.createObjectURL(blob); a.download = `peardock-stats-${short}-${Date.now()}.csv`; a.click(); URL.revokeObjectURL(a.href); showAlert('success', `Exported ${win.timestamps.length} samples`); } // Logs state management let logsState = { paused: false, autoScroll: true, currentFilter: 'all', searchTerm: '', allLogs: [], containerId: null, /** Monotonic generation so stale handlers ignore late chunks */ gen: 0, streaming: false, }; /** 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 = ''; /** @type {ReturnType|null} */ let logsLoadingFallbackTimer = null; /** * Whether two Docker container ids refer to the same container (full or prefix). * @param {string} a * @param {string} b */ function containerIdsMatch(a, b) { if (!a || !b) return false; const x = String(a); const y = String(b); if (x === y) return true; if (x.length >= 12 && y.length >= 12) { return x.startsWith(y) || y.startsWith(x); } return false; } /** * Append decoded log chunk into the details logs pane. * @param {object} logData * @param {HTMLElement} logsContent * @param {number} gen */ function appendDetailsLogChunk(logData, logsContent, gen) { if (!logsContent || logsState.gen !== gen) return; if (logsState.paused) return; if ( logData.containerId && logsState.containerId && !containerIdsMatch(logData.containerId, logsState.containerId) ) { return; } const chunk = decodePayload(logData.data, logData.encoding || 'base64'); if (!chunk) return; const loadingEl = logsContent.querySelector('.logs-loading'); if (loadingEl) loadingEl.remove(); // 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); while (logsContent.children.length > MAX_LOG_DOM_LINES) { logsContent.removeChild(logsContent.firstChild); } applyLogFilters(); scrollLogsToBottom(); } } /** * Stop details-tab log stream (when leaving Logs tab or details view). */ function stopDetailsLogs() { if (logsLoadingFallbackTimer) { clearTimeout(logsLoadingFallbackTimer); logsLoadingFallbackTimer = null; } const id = logsState.containerId; logsState.streaming = false; logsState.gen += 1; // invalidate any in-flight handlers logsLineBuffer = ''; if (id && manager.active?.connected) { manager.send(Methods.stopLogs, { id }, { silent: true }).catch(() => {}); } // Keep window.handleLogOutput only if modal logs still need it — clear details path if (window.handleLogOutput && window.handleLogOutput._pdDetailsLogs) { window.handleLogOutput = null; } } /** * Start (or restart) live logs for the container details Logs tab. * Always stops any previous stream first so re-entry never hangs on "Loading…". * @param {string} containerId */ async function startDetailsLogs(containerId) { const logsContent = document.getElementById('container-logs-content'); if (!logsContent || !containerId) return; // Tear down previous stream before starting a new one stopDetailsLogs(); const gen = logsState.gen + 1; logsState = { paused: false, autoScroll: true, currentFilter: 'all', searchTerm: '', allLogs: [], containerId, gen, streaming: true, }; logsLineBuffer = ''; // 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; document.querySelectorAll('.logs-filter-btn').forEach((btn) => { btn.classList.toggle('active', btn.dataset.filter === 'all'); }); logsContent.innerHTML = `
${jobSpinnerHtml('Loading logs…')}
`; const handler = (logData) => appendDetailsLogChunk(logData, logsContent, gen); handler._pdDetailsLogs = true; window.handleLogOutput = handler; if (!manager.active?.connected) { logsContent.innerHTML = '
Not connected — cannot load logs.
'; logsState.streaming = false; return; } try { // Prefer startLogs; also accept 'logs' alias on older peers await manager.request(Methods.startLogs, { id: containerId, tail: 200, timestamps: true, follow: true, }); } catch (err) { // Fallback method name try { await manager.request(Methods.logs, { id: containerId, tail: 200, timestamps: true, follow: true, }); } catch (err2) { if (logsState.gen !== gen) return; console.warn('[logs] start failed', err2?.message || err?.message); logsContent.innerHTML = `
Failed to start log stream: ${escapeHtml(err2?.message || err?.message || 'unknown error')}
`; logsState.streaming = false; return; } } if (logsState.gen !== gen) return; // Left the Logs tab while start was in flight if ( typeof isContainerDetailsTabActive === 'function' && !isContainerDetailsTabActive('logs-tab', 'logs-pane') ) { stopDetailsLogs(); return; } if ( !currentContainerDetails?.Id || !containerIdsMatch(currentContainerDetails.Id, containerId) ) { stopDetailsLogs(); return; } // If still only the spinner after a short wait, pull a one-shot snapshot so // the pane is never stuck on "Loading logs…" (quiet containers / stream race). if (logsLoadingFallbackTimer) clearTimeout(logsLoadingFallbackTimer); logsLoadingFallbackTimer = setTimeout(async () => { logsLoadingFallbackTimer = null; if (logsState.gen !== gen) return; const stillLoading = logsContent.querySelector('.logs-loading'); const hasLines = logsContent.querySelector('.log-line'); if (!stillLoading || hasLines) return; try { const res = await manager.request(Methods.getContainerLogs, { id: containerId, tail: 200, timestamps: true, }); if (logsState.gen !== gen) return; const text = res?.data || res?.logs || ''; if (!text) { stillLoading.textContent = 'No log output yet.'; return; } stillLoading.remove(); // Feed through the same line parser as the live stream appendDetailsLogChunk( { containerId, data: btoa(unescape(encodeURIComponent(String(text)))), encoding: 'base64', }, logsContent, gen ); if (!logsContent.querySelector('.log-line')) { logsContent.innerHTML = '
No log output yet.
'; } } catch { if (logsState.gen === gen && stillLoading) { stillLoading.textContent = 'Waiting for log output…'; } } }, 1500); } window.startDetailsLogs = startDetailsLogs; window.stopDetailsLogs = stopDetailsLogs; // ─── Container-details Terminal tab ─────────────────────────────────────── // Leave: invalidate local UI immediately; fire-and-forget remote kill. // Enter: open xterm immediately and start PTY — never wait on prior kill. // Unique sessionIds + server kill-by-sessionId prevent late kills from // destroying a newly started session. startTerminal also clears leftovers. /** @type {null | { * xterm: import('@xterm/xterm').Terminal, * fitAddon: object, * fitController: object, * inputCoalescer: object, * onDataDisposable: { dispose?: () => void }, * containerId: string, * gen: number, * }} */ let detailsTerminalSession = null; /** Bumped on every stop/start to abandon stale async work */ let detailsTerminalGen = 0; /** Serializes concurrent inits so two shown events don't double-mount xterm */ let detailsTerminalChain = Promise.resolve(); /** * Desire token for the Terminal tab. Bumped on every leave/enter so a queued * init from a previous visit is skipped even when the container id matches. */ let detailsTerminalDesireGen = 0; /** Set while the Terminal tab wants a live PTY; cleared on leave */ let detailsTerminalDesiredId = null; /** Container id / session id whose remote PTY kill is still pending on the chain */ let detailsTerminalPendingKill = null; // { containerId, sessionId } | null let detailsTerminalFontSize = 14; let detailsTerminalTheme = 'dark'; const DETAILS_TERMINAL_THEMES = { dark: { background: '#0b0f14', foreground: '#e6edf3', cursor: '#34d399', cursorAccent: '#0b0f14', selectionBackground: 'rgba(52, 211, 153, 0.35)', selectionForeground: '#f4f7fb', black: '#0b0f14', red: '#f87171', green: '#4ade80', yellow: '#fbbf24', blue: '#38bdf8', magenta: '#c084fc', cyan: '#2dd4bf', white: '#e6edf3', brightBlack: '#64748b', brightRed: '#fca5a5', brightGreen: '#86efac', brightYellow: '#fde68a', brightBlue: '#7dd3fc', brightMagenta: '#d8b4fe', brightCyan: '#5eead4', brightWhite: '#f8fafc', }, light: { background: '#ffffff', foreground: '#0f172a', cursor: '#0f766e', cursorAccent: '#ffffff', selectionBackground: 'rgba(15, 118, 110, 0.22)', selectionForeground: '#0f172a', black: '#0f172a', red: '#b91c1c', green: '#15803d', yellow: '#b45309', blue: '#1d4ed8', magenta: '#6d28d9', cyan: '#0e7490', white: '#e2e8f0', brightBlack: '#64748b', brightRed: '#dc2626', brightGreen: '#16a34a', brightYellow: '#d97706', brightBlue: '#2563eb', brightMagenta: '#7c3aed', brightCyan: '#0891b2', brightWhite: '#0f172a', }, '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', }, }; /** * @param {() => Promise | void} fn * @returns {Promise} */ function enqueueDetailsTerminalOp(fn) { const run = detailsTerminalChain.then( () => fn(), () => fn() ); detailsTerminalChain = run.catch((err) => { console.warn('[terminal] op failed', err?.message || err); }); return run; } function disposeDetailsTerminalLocal(session) { if (!session) return; try { session.inputCoalescer?.flush?.(); session.inputCoalescer?.destroy?.(); session.onDataDisposable?.dispose?.(); session.fitController?.disconnect?.(); session.xterm?.dispose?.(); } catch { // ignore } } /** * Synchronously abandon local terminal UI + in-flight init (bump gen). * Does not await remote kill — that is fire-and-forget so re-entry stays instant. * @param {{ clearDom?: boolean }} [opts] * @returns {{ containerId: string, sessionId?: string }|null} */ function invalidateDetailsTerminal(opts = {}) { detailsTerminalGen += 1; detailsTerminalDesireGen += 1; detailsTerminalDesiredId = null; const session = detailsTerminalSession; detailsTerminalSession = null; disposeDetailsTerminalLocal(session); if (opts.clearDom !== false) { const el = document.getElementById('container-terminal-xterm'); if (el) el.innerHTML = ''; } if (session?.containerId) { return { containerId: session.containerId, sessionId: session.sessionId || null, }; } return detailsTerminalPendingKill; } /** * Best-effort remote PTY kill by sessionId (safe against late kill of a newer PTY). * @param {{ containerId?: string, sessionId?: string }|string|null} target * @returns {Promise} */ async function killDetailsTerminalRemote(target) { const spec = typeof target === 'string' ? { containerId: target, sessionId: null } : target; if (!spec || !manager.active?.connected) return; const { containerId, sessionId } = spec; if (!containerId && !sessionId) return; try { const args = sessionId ? { sessionId } : { containerId }; await manager.request(Methods.killTerminal, args, { timeout: 5000 }); } catch { // already gone / timed out — startTerminal clears leftovers on next open } finally { if ( detailsTerminalPendingKill && ((sessionId && detailsTerminalPendingKill.sessionId === sessionId) || (!sessionId && detailsTerminalPendingKill.containerId === containerId && !detailsTerminalPendingKill.sessionId)) ) { detailsTerminalPendingKill = null; } } } /** * Public: tear down details terminal on tab leave / leave details view. * Local UI is cleared immediately; remote kill does not block the next start. * @returns {Promise} */ function cleanupDetailsTerminal() { const target = invalidateDetailsTerminal({ clearDom: true }); if (target) { detailsTerminalPendingKill = target; void killDetailsTerminalRemote(target); } // Banner is only meaningful while the Terminal tab is active; drop it on leave // so a later open starts clean (pop-out itself is unaffected). hideDetailsTerminalPopoutBanner(); return Promise.resolve(); } /** * Open a fresh details-tab PTY. Starts immediately — does not wait on prior kill * (server startTerminal clears leftover PTYs; unique sessionIds protect against late kill). * @param {string} containerId * @returns {Promise} */ function initDetailsTerminal(containerId) { if (!containerId) return Promise.resolve(); detailsTerminalDesireGen += 1; const desireGen = detailsTerminalDesireGen; detailsTerminalDesiredId = containerId; // Serialize concurrent inits only (kills are fire-and-forget off-chain) return enqueueDetailsTerminalOp(() => initDetailsTerminalNow(containerId, desireGen) ); } /** * Drop a leftover local session without bumping desireGen (re-init path). * @returns {{ containerId: string, sessionId?: string }|null} */ function takeDetailsTerminalSessionForReplace() { const session = detailsTerminalSession; if (!session) return null; detailsTerminalSession = null; disposeDetailsTerminalLocal(session); return { containerId: session.containerId, sessionId: session.sessionId || null, }; } /** * @param {string} containerId * @param {number} desireGen * @returns {Promise} */ async function initDetailsTerminalNow(containerId, desireGen) { // User left / re-entered while we were queued — only the latest desire wins if (desireGen !== detailsTerminalDesireGen) return; if (detailsTerminalDesiredId !== containerId) return; if (!isContainerDetailsTabActive('terminal-tab', 'terminal-pane')) return; if ( !currentContainerDetails?.Id || !containerIdsMatch(currentContainerDetails.Id, containerId) ) { return; } // If this container already has a pop-out on the active peer, keep the PTY // in the external window instead of starting a second session. const activePeerId = manager.active?.id || null; const existingPopout = findPopoutForContainer(containerId, activePeerId); if (existingPopout) { showDetailsTerminalPopoutBanner(existingPopout); return; } hideDetailsTerminalPopoutBanner(); // Replace any leftover local session without invalidating this desire token const prev = takeDetailsTerminalSessionForReplace(); if (prev) void killDetailsTerminalRemote(prev); detailsTerminalGen += 1; const myGen = detailsTerminalGen; const sessionId = `details-${String(containerId).slice(0, 12)}-${myGen}-${Date.now().toString(36)}`; 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 (desireGen !== detailsTerminalDesireGen || myGen !== detailsTerminalGen) return; if (!isContainerDetailsTabActive('terminal-tab', 'terminal-pane')) return; if (wrap) { wrap.style.minHeight = wrap.style.minHeight || '200px'; wrap.style.height = '100%'; wrap.style.flex = wrap.style.flex || '1 1 auto'; } terminalContainer.style.width = '100%'; terminalContainer.style.height = '100%'; terminalContainer.style.minHeight = '160px'; terminalContainer.style.flex = '1 1 auto'; terminalContainer.innerHTML = ''; const themeKey = resolveDetailsTerminalThemeKey(detailsTerminalTheme); detailsTerminalTheme = themeKey; const theme = DETAILS_TERMINAL_THEMES[themeKey] || DETAILS_TERMINAL_THEMES.light || defaultXtermOptions().theme; const xterm = new TerminalCtor( defaultXtermOptions({ fontSize: detailsTerminalFontSize, theme, }) ); const fitAddon = new FitAddonCtor(); xterm.loadAddon(fitAddon); // Open immediately so the tab never sits on a spinner / blank host xterm.open(terminalContainer); applyXtermPalette(xterm, theme); try { applyDetailsTerminalTheme(themeKey); } catch { // ignore } const sendResize = (cols, rows) => { if (!manager.active?.connected || !cols || !rows) return; if (myGen !== detailsTerminalGen) return; manager.event(Methods.terminalResize, { containerId, sessionId, 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; if (myGen !== detailsTerminalGen) return; manager.event(Methods.terminalInput, { containerId, sessionId, data, encoding: encoding || 'utf8', }); }); const onDataDisposable = xterm.onData((data) => { if (!manager.active?.connected) return; if (myGen !== detailsTerminalGen) return; inputCoalescer.push(data); }); const abandonLocal = (alsoKillRemote) => { disposeDetailsTerminalLocal({ xterm, fitController, inputCoalescer, onDataDisposable, }); if (alsoKillRemote) { void killDetailsTerminalRemote({ containerId, sessionId }); } }; const stillWanted = () => desireGen === detailsTerminalDesireGen && detailsTerminalDesiredId === containerId && myGen === detailsTerminalGen && isContainerDetailsTabActive('terminal-tab', 'terminal-pane') && Boolean(currentContainerDetails?.Id) && containerIdsMatch(currentContainerDetails.Id, containerId); // Brief layout wait only — fall through quickly with defaults if still 0×0 await waitForTerminalHostSize(terminalContainer, { minW: 40, minH: 40, maxFrames: 12, }); if (!stillWanted()) { abandonLocal(false); return; } let dims = safeFit(fitAddon, xterm) || { cols: xterm.cols, rows: xterm.rows }; await new Promise((r) => requestAnimationFrame(r)); if (!stillWanted()) { abandonLocal(false); return; } dims = safeFit(fitAddon, xterm) || dims; if (!dims?.cols || !dims?.rows || dims.cols < 2 || dims.rows < 1) { dims = { cols: 80, rows: 24 }; } if (!manager.active?.connected) { xterm.writeln('\r\n\x1b[31m[ERROR] Not connected\x1b[0m'); detailsTerminalSession = { xterm, fitAddon, fitController, inputCoalescer, onDataDisposable, containerId, sessionId, gen: myGen, failed: true, }; return; } try { await manager.request(Methods.startTerminal, { containerId, sessionId, cols: dims.cols, rows: dims.rows, tty: true, }); } catch (err) { if (stillWanted()) { xterm.writeln(`\r\n\x1b[31m[ERROR] ${err?.message || err}\x1b[0m`); detailsTerminalSession = { xterm, fitAddon, fitController, inputCoalescer, onDataDisposable, containerId, sessionId, gen: myGen, failed: true, }; return; } abandonLocal(true); return; } if (!stillWanted()) { abandonLocal(true); return; } detailsTerminalSession = { xterm, fitAddon, fitController, inputCoalescer, onDataDisposable, containerId, sessionId, gen: myGen, }; detailsTerminalPendingKill = null; applyXtermPalette( xterm, DETAILS_TERMINAL_THEMES[detailsTerminalTheme] || DETAILS_TERMINAL_THEMES.light || theme ); sendResize(dims.cols, dims.rows); xterm.focus(); updateDetailsTerminalFontSizeDisplay(); requestAnimationFrame(() => { if (myGen !== detailsTerminalGen || detailsTerminalSession?.gen !== myGen) return; fitController.fitNow(); setTimeout(() => { if (myGen !== detailsTerminalGen || detailsTerminalSession?.gen !== myGen) return; fitController.fitNow(); applyXtermPalette( detailsTerminalSession.xterm, DETAILS_TERMINAL_THEMES[detailsTerminalTheme] || DETAILS_TERMINAL_THEMES.light ); }, 80); }); } function appendDetailsTerminalOutput(data, encoding = 'base64') { if (!detailsTerminalSession?.xterm || detailsTerminalSession.failed) return; const text = decodePayload(data, encoding); if (text) detailsTerminalSession.xterm.write(text); } function updateDetailsTerminalFontSizeDisplay() { const display = document.getElementById('terminal-font-size-display'); if (display) display.textContent = String(detailsTerminalFontSize); } /** * Resolve a concrete xterm palette key for the details terminal. * When UI is light and the saved pref is auto/dark, use light so text stays readable. * @param {string} [theme] * @returns {string} */ function resolveDetailsTerminalThemeKey(theme) { const uiLight = document.documentElement?.dataset?.theme === 'light'; let key = theme; if (!key || key === 'auto') { try { const pref = window.__peardockSettings?.terminalTheme; if (pref && pref !== 'auto' && DETAILS_TERMINAL_THEMES[pref]) key = pref; else key = uiLight ? 'light' : 'dark'; } catch { key = uiLight ? 'light' : 'dark'; } } // UI light + dark palette = invisible light-on-white; force light unless a specialty theme if ( uiLight && (key === 'dark' || key === 'auto') && DETAILS_TERMINAL_THEMES.light ) { key = 'light'; } if (!DETAILS_TERMINAL_THEMES[key]) key = uiLight ? 'light' : 'dark'; return key; } function applyDetailsTerminalTheme(theme) { const key = resolveDetailsTerminalThemeKey(theme); detailsTerminalTheme = key; const palette = DETAILS_TERMINAL_THEMES[key] || DETAILS_TERMINAL_THEMES.dark; if (detailsTerminalSession?.xterm) { applyXtermPalette(detailsTerminalSession.xterm, palette); // Theme flips change CSS chrome size; re-fit so PTY cols/rows match the host try { detailsTerminalSession.fitController?.fitNow?.(); } catch { // ignore } } const terminalThemeSelect = document.getElementById('terminal-theme-select'); if (terminalThemeSelect && [...terminalThemeSelect.options].some((o) => o.value === key)) { terminalThemeSelect.value = key; } } function applyDetailsTerminalFont(size) { detailsTerminalFontSize = size; if (detailsTerminalSession?.xterm) { detailsTerminalSession.xterm.options.fontSize = size; detailsTerminalSession.fitController?.fitNow?.(); } updateDetailsTerminalFontSizeDisplay(); } /** * Banner when the shell for this container lives in a pop-out window. * @param {{ windowId: string, peerLabel?: string, title?: string }|null} popout */ function showDetailsTerminalPopoutBanner(popout) { const terminalContainer = document.getElementById('container-terminal-xterm'); if (!terminalContainer) return; // Tear down any local xterm so we do not fight the pop-out session const prev = takeDetailsTerminalSessionForReplace(); if (prev) void killDetailsTerminalRemote(prev); terminalContainer.innerHTML = ''; const wrap = document.createElement('div'); wrap.className = 'terminal-popout-banner'; wrap.id = 'details-terminal-popout-banner'; wrap.innerHTML = `
Terminal is open in a separate window
Session stays alive when you switch servers until you close the window.
`; terminalContainer.appendChild(wrap); document.getElementById('details-terminal-focus-popout')?.addEventListener('click', () => { if (popout?.windowId) focusPopoutTerminal(popout.windowId); }); } function hideDetailsTerminalPopoutBanner() { document.getElementById('details-terminal-popout-banner')?.remove(); } /** * Pop the details-tab (or current container) shell into a dedicated window. * Pinned to the peer that is active when opened; survives active-node switches. * @returns {Promise} */ async function popOutDetailsTerminal() { const containerId = detailsTerminalSession?.containerId || currentContainerDetails?.Id || detailsTerminalDesiredId; if (!containerId) { showAlert('warning', 'No container selected for terminal'); return; } if (!manager.active?.connected) { showAlert('error', 'Not connected'); return; } const peerId = manager.active.id; const existing = findPopoutForContainer(containerId, peerId); if (existing) { focusPopoutTerminal(existing.windowId); showDetailsTerminalPopoutBanner(existing); return; } const name = currentContainerDetails?.Names?.[0] || currentContainerDetails?.Name || containerId.slice(0, 12); const theme = detailsTerminalTheme || 'dark'; const fontSize = detailsTerminalFontSize || 14; // Release the in-pane PTY first so the pop-out owns the shell cleanly if (typeof cleanupDetailsTerminal === 'function') { cleanupDetailsTerminal(); } try { const opened = await openPopoutTerminal({ containerId, containerName: String(name).replace(/^\//, ''), peerId, theme, fontSize, }); showDetailsTerminalPopoutBanner(opened); showAlert('success', 'Terminal opened in a new window'); } catch (err) { showAlert('error', err?.message || 'Failed to pop out terminal'); // Restore in-pane terminal if still on the tab if ( isContainerDetailsTabActive('terminal-tab', 'terminal-pane') && currentContainerDetails?.Id && containerIdsMatch(currentContainerDetails.Id, containerId) ) { void initDetailsTerminal(containerId); } } } window.initDetailsTerminal = initDetailsTerminal; window.cleanupDetailsTerminal = cleanupDetailsTerminal; window.popOutDetailsTerminal = popOutDetailsTerminal; window.handleDetailsTerminalOutput = (data, containerId, encoding, sessionId) => { if ( !detailsTerminalSession || detailsTerminalSession.failed || detailsTerminalSession.gen !== detailsTerminalGen ) { return; } if ( sessionId && detailsTerminalSession.sessionId && sessionId !== detailsTerminalSession.sessionId ) { return; } if (!containerIdsMatch(detailsTerminalSession.containerId, containerId)) return; appendDetailsTerminalOutput(data, encoding); }; /** * Re-apply container list / dashboard after settings change (e.g. hide-label filters). */ window.__peardockOnSettingsChanged = function peardockOnSettingsChanged() { try { if (containerFilterState?.allContainers?.length) { scheduleContainerPaint( containerVirt.topicId || containerStore.topicId || manager.active?.id || '' ); if (typeof currentView !== 'undefined' && currentView === 'dashboard') { updateDashboardStats(containerFilterState.allContainers, null, null); } } } catch (err) { console.warn('[settings] container re-filter failed', err?.message || err); } }; /** * Strip ANSI / VT100 escape sequences from container log text. * Apps like PM2 emit colors as ESC[…m; browsers hide ESC so you see "[32m" junk. */ function stripAnsi(input) { let s = String(input ?? ''); // CSI: ESC [ ... final byte (@-~) s = s.replace(/\u001b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, ''); // OSC: ESC ] ... BEL or ST (ESC \) s = s.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, ''); // 2-byte escapes: ESC + final s = s.replace(/\u001b[@-Z\\-_]/g, ''); // 7-bit CSI without ESC (rare) and C1 CSI (0x9b) s = s.replace(/\u009b[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, ''); // Orphaned SGR fragments if ESC was already lost: [1;32m or [39m s = s.replace(/\x1b/g, ''); s = s.replace(/(?:^|[^\d])\[(?:\d{1,3};)*\d{0,3}[mK]/g, (m) => m.startsWith('[') ? '' : m[0] ); // Clean leftover pure SGR tokens at line start / after space s = s.replace(/(?:^|\s)\[(?:\d{1,3};)*\d{0,3}m/g, (m) => (m[0] === '[' ? '' : m[0])); s = s.replace(/\[(?:\d{1,3};)*\d{0,3}m/g, ''); // Other C0 controls except tab/newline s = s.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ''); return s; } // 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) { // Strip ANSI first so colors/tables from PM2 etc. don't leak as "[32m" let line = stripAnsi(String(rawLine ?? '')); // Normalize CR-only progress lines / CRLF line = line.replace(/\r/g, ''); 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, }; } // 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', () => { ensureProcsFilterWired(); if (currentContainerDetails?.Id) { loadContainerTop(currentContainerDetails.Id); } }); processesTab.addEventListener('hidden.bs.tab', () => { if (typeof invalidateContainerTop === 'function') { invalidateContainerTop({ clearDom: false }); } }); } const refreshTopBtn = document.getElementById('refresh-container-top-btn'); if (refreshTopBtn) { refreshTopBtn.addEventListener('click', () => { if (currentContainerDetails?.Id) { loadContainerTop(currentContainerDetails.Id); } }); } ensureProcsFilterWired(); const logsTab = document.getElementById('logs-tab'); if (logsTab) { logsTab.addEventListener('shown.bs.tab', () => { if (currentContainerDetails?.Id) { startDetailsLogs(currentContainerDetails.Id); } }); // Always stop the stream when leaving Logs so re-entry can start cleanly logsTab.addEventListener('hidden.bs.tab', () => { stopDetailsLogs(); }); } // 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 (advanced KPIs + timeframe charts) ensureDetailsStatsWired(); const statsTab = document.getElementById('stats-tab'); if (statsTab) { statsTab.addEventListener('shown.bs.tab', () => { if (currentContainerDetails) { updateContainerDetailsStats(currentContainerDetails); } }); statsTab.addEventListener('hidden.bs.tab', () => { destroyDetailsStatsCharts(); }); } // Terminal tab — lifecycle is module-level (serialized start/stop). // Bootstrap events fire on user clicks; activateContainerDetailsTab covers // programmatic switches that do not emit BS events. const terminalTab = document.getElementById('terminal-tab'); if (terminalTab) { terminalTab.addEventListener('shown.bs.tab', () => { if (currentContainerDetails?.Id) { initDetailsTerminal(currentContainerDetails.Id); } }); terminalTab.addEventListener('hidden.bs.tab', () => { cleanupDetailsTerminal(); }); } 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 terminalPopoutBtn = document.getElementById('terminal-popout-btn'); if (terminalPopoutBtn) { terminalPopoutBtn.addEventListener('click', () => { void popOutDetailsTerminal(); }); } // Refresh details banner when pop-outs open/close window.addEventListener('peardock-popout-terminal', (ev) => { const detail = ev?.detail; const containerId = currentContainerDetails?.Id; if (!containerId || !isContainerDetailsTabActive('terminal-tab', 'terminal-pane')) return; if (detail?.reason === 'closed') { hideDetailsTerminalPopoutBanner(); // Restore in-pane terminal if still viewing this container if ( detail?.session?.containerId && containerIdsMatch(detail.session.containerId, containerId) ) { void initDetailsTerminal(containerId); } return; } if (detail?.reason === 'opened' && detail?.session) { if (containerIdsMatch(detail.session.containerId, containerId)) { showDetailsTerminalPopoutBanner(detail.session); } } }); if (terminalFontDecreaseBtn) { terminalFontDecreaseBtn.addEventListener('click', () => { if (detailsTerminalFontSize > 8) { applyDetailsTerminalFont(detailsTerminalFontSize - 1); } }); } if (terminalFontIncreaseBtn) { terminalFontIncreaseBtn.addEventListener('click', () => { if (detailsTerminalFontSize < 28) { applyDetailsTerminalFont(detailsTerminalFontSize + 1); } }); } if (terminalFontResetBtn) { terminalFontResetBtn.addEventListener('click', () => applyDetailsTerminalFont(14)); } if (terminalCopyBtn) { terminalCopyBtn.addEventListener('click', () => { if (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) { // Seed from settings (auto follows UI color mode) try { const s = window.__peardockSettings || {}; const ui = document.documentElement?.dataset?.theme || 'dark'; let pref = s.terminalTheme; if (pref === 'auto' || !pref) pref = ui === 'light' ? 'light' : 'dark'; if (pref && DETAILS_TERMINAL_THEMES[pref]) { applyDetailsTerminalTheme(pref); } } catch { // ignore } if ([...terminalThemeSelect.options].some((o) => o.value === detailsTerminalTheme)) { terminalThemeSelect.value = detailsTerminalTheme; } terminalThemeSelect.addEventListener('change', (e) => { applyDetailsTerminalTheme(e.target.value); }); } updateDetailsTerminalFontSizeDisplay(); // Chain details-terminal into global theme apply (Settings save / UI color mode) try { const prev = window.__peardockApplyTerminalThemes; window.__peardockApplyTerminalThemes = (termTheme, dockerTheme) => { if (typeof prev === 'function') prev(termTheme, dockerTheme); if (termTheme) applyDetailsTerminalTheme(termTheme); // After data-theme CSS swaps, wait a frame then re-fit (fixes blank light init) requestAnimationFrame(() => { detailsTerminalSession?.fitController?.fitNow?.(); if (detailsTerminalSession?.xterm) { applyXtermPalette( detailsTerminalSession.xterm, DETAILS_TERMINAL_THEMES[detailsTerminalTheme] || DETAILS_TERMINAL_THEMES.dark ); } }); }; } catch { // ignore } }); /** * 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:not(:disabled)'); return Array.from(checkboxes) .map((cb) => cb.dataset.imageId) .filter((id) => { const image = allImages.find((img) => img.Id === id); return image && !isImageInUse(image); }); } function updateBulkActionsToolbar() { const selected = getSelectedContainers(); const toolbar = document.getElementById('bulk-actions-toolbar'); const countEl = document.getElementById('selected-count'); const hint = document.getElementById('containers-action-hint'); const n = selected.length; if (countEl) countEl.textContent = String(n); if (hint) { hint.textContent = n ? `${n} container${n === 1 ? '' : 's'} ready for bulk actions` : 'Select containers to enable actions'; } if (toolbar) { toolbar.classList.toggle('has-selection', n > 0); toolbar.querySelectorAll('.containers-action-btn').forEach((btn) => { const op = btn.getAttribute('data-bulk-op'); if (op === 'clear') btn.disabled = n === 0; else btn.disabled = n === 0; }); } } /** * Run a bulk lifecycle op via typed RPC (containers action bar). * @param {string} operation * @param {{ label: string, confirm?: boolean, danger?: boolean }} meta */ async function runBulkContainerOperation(operation, meta) { const selected = getSelectedContainers(); if (!selected.length) { showAlert('warning', 'Select one or more containers first'); return; } if (!hasActiveConnection()) { showAlert('danger', 'Not connected'); return; } if (meta.confirm !== false) { const title = `${meta.label} ${selected.length} container(s)?`; let ok = true; if (window.peardockOps?.askUserConfirm) { ok = await window.peardockOps.askUserConfirm(title, meta.body || title, { confirmLabel: meta.label, danger: Boolean(meta.danger), icon: meta.icon || 'fa-cube', }); } else { ok = await new Promise((resolve) => { let confirmed = false; showConfirmModal(title, () => { confirmed = true; resolve(true); }); document.getElementById('confirmModal')?.addEventListener( 'hidden.bs.modal', () => { if (!confirmed) resolve(false); }, { once: true } ); }); } if (!ok) return; } if (typeof window.peardockOps?.bulkContainerJob === 'function') { try { const names = selected.map((id) => { const row = document.querySelector(`.container-checkbox[value="${id}"]`); const tr = row?.closest('tr'); const nameCell = tr?.querySelector('[data-container-name], .container-name, td:nth-child(2)'); return (nameCell?.textContent || '').trim().replace(/^\//, '') || id.slice(0, 12); }); await window.peardockOps.bulkContainerJob({ operation, label: meta.label, containerIds: selected, names, force: true, }); clearContainerSelection(); sendCommand('listContainers'); } catch (err) { if (!err?.viaJob) presentError(err, 'bulkContainerOperation', { showAlert }); } return; } showStatusIndicator(`${meta.label}ing ${selected.length} container(s)…`); try { const res = await manager.request(Methods.bulkContainerOperation, { containerIds: selected, operation, }); const results = res?.results || []; const successCount = results.filter((r) => r.success).length; const failCount = results.filter((r) => !r.success).length; showAlert( failCount && !successCount ? 'danger' : 'success', `${meta.label}: ${successCount} ok${failCount ? `, ${failCount} failed` : ''}` ); clearContainerSelection(); sendCommand('listContainers'); } catch (err) { presentError(err, 'bulkContainerOperation', { showAlert }); } finally { hideStatusIndicator(); } } 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) { // Never select images that are in use by a container const checkboxes = document.querySelectorAll('.image-checkbox:not(:disabled)'); 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() { return runBulkContainerOperation('start', { label: 'Start', confirm: true, icon: 'fa-play', body: 'Start the selected stopped containers?', }); } async function bulkStopContainers() { return runBulkContainerOperation('stop', { label: 'Stop', confirm: true, danger: false, icon: 'fa-stop', body: 'Gracefully stop the selected running containers?', }); } async function bulkKillContainers() { return runBulkContainerOperation('kill', { label: 'Kill', confirm: true, danger: true, icon: 'fa-skull', body: 'Force-kill the selected containers immediately?', }); } window.bulkKillContainers = bulkKillContainers; async function bulkRestartContainers() { return runBulkContainerOperation('restart', { label: 'Restart', confirm: true, icon: 'fa-sync-alt', body: 'Restart the selected containers?', }); } window.bulkRestartContainers = bulkRestartContainers; async function bulkPauseContainers() { return runBulkContainerOperation('pause', { label: 'Pause', confirm: true, icon: 'fa-pause', body: 'Pause (freeze processes in) the selected running containers?', }); } window.bulkPauseContainers = bulkPauseContainers; async function bulkResumeContainers() { return runBulkContainerOperation('unpause', { label: 'Resume', confirm: true, icon: 'fa-play-circle', body: 'Resume (unpause) the selected containers?', }); } window.bulkResumeContainers = bulkResumeContainers; async function bulkRemoveContainers() { return runBulkContainerOperation('remove', { label: 'Remove', confirm: true, danger: true, icon: 'fa-trash', body: 'Permanently remove the selected containers? This cannot be undone.', }); } async function bulkRecreateContainers() { const selected = getSelectedContainers(); if (!selected.length) { showAlert('warning', 'Select one or more containers first'); return; } if (!hasActiveConnection()) { showAlert('danger', 'Not connected'); return; } let ok = true; if (window.peardockOps?.askUserConfirm) { ok = await window.peardockOps.askUserConfirm( `Recreate ${selected.length} container(s)?`, 'Each container will be stopped, removed, and created again with the same configuration.', { confirmLabel: 'Recreate', danger: true, icon: 'fa-redo' } ); } if (!ok) return; if (typeof window.peardockOps?.recreateContainersJob === 'function') { try { await window.peardockOps.recreateContainersJob({ ids: selected, start: true, }); clearContainerSelection(); sendCommand('listContainers'); } catch (err) { if (!err?.viaJob) presentError(err, 'recreateContainer', { showAlert }); } return; } showStatusIndicator(`Recreating ${selected.length} container(s)…`); let successCount = 0; let failCount = 0; try { for (const id of selected) { try { await manager.request(Methods.recreateContainer, { id, start: true }); successCount += 1; } catch { failCount += 1; } } showAlert( failCount && !successCount ? 'danger' : 'success', `Recreate: ${successCount} ok${failCount ? `, ${failCount} failed` : ''}` ); clearContainerSelection(); sendCommand('listContainers'); } catch (err) { presentError(err, 'recreateContainer', { showAlert }); } finally { hideStatusIndicator(); } } window.bulkRecreateContainers = bulkRecreateContainers; // 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) { showAlert('warning', 'No removable images selected (in-use images cannot be removed)'); 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; if (typeof window.peardockOps?.removeImagesJob === 'function') { try { await window.peardockOps.removeImagesJob({ ids: selected, force: true, }); clearImageSelection(); setTimeout(() => loadImages(), 400); } catch (err) { if (!err?.viaJob) showAlert('danger', err.message || 'Failed to remove images'); } 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; /** Show tray-style spinner in the template count line while lists load. */ function setDeployTemplateCountLoading(message = 'Loading templates…') { const countEl = document.getElementById('deploy-template-count'); if (!countEl) return; countEl.classList.add('d-flex', 'align-items-center', 'gap-2', 'deploy-template-count--loading'); countEl.innerHTML = jobSpinnerHtml(message); } function updateDeployTemplateCount(query = '') { const countEl = document.getElementById('deploy-template-count'); if (!countEl) return; countEl.classList.remove('deploy-template-count--loading'); 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 tray-style loading state only when we don't have cache if (!deployViewTemplates.length) { const n = getTemplateListUrls().length; const msg = `Loading templates from ${n} list${n === 1 ? '' : 's'}…`; setDeployTemplateCountLoading(msg); templateListContainer.innerHTML = jobSpinnerLoadingBlock(msg); } 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); const countEl = document.getElementById('deploy-template-count'); if (countEl) { countEl.classList.remove('deploy-template-count--loading'); countEl.textContent = 'Failed to load templates'; } 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 deploy-template-item d-flex align-items-center bg-dark text-white border-secondary'; listItem.style.cursor = 'pointer'; const title = template.title || template.name || 'Untitled'; // Full text stays in title attr (capped); display is CSS-truncated so Deploy stays in-row const desc = String(template.description || 'No description').replace(/\s+/g, ' ').trim(); const descTip = desc.length > 280 ? `${desc.slice(0, 277)}…` : desc; const logo = template.logo ? `` : ''; const typeNum = Number(template.type); const img = String(template.image || '').trim(); const isStack = typeNum === 2 || typeNum === 3 || (!img && (template.repository?.url || template.repository)); const typeBadge = isStack ? 'stack' : 'container'; const imageLine = img ? `${escapeHtmlLite(img)}` : isStack ? 'Compose · image resolved on deploy' : ''; listItem.innerHTML = `
${logo}
${escapeHtmlLite(title)}
${typeBadge}
${escapeHtmlLite(desc)} ${imageLine}
`; 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; /** * Open the Stacks deploy modal pre-filled from a Portainer type 2/3 template. * @param {object} resolved — after resolveTemplateForDeploy * @returns {boolean} */ function openStackDeployFromTemplate(resolved) { const build = typeof window.buildStackDeployPayload === 'function' ? window.buildStackDeployPayload : null; if (!build) return false; const payload = build(resolved); if (!payload?.ok) { showAlert('warning', payload?.error || 'Could not build stack deploy payload'); return false; } setupDeployStackHandler(); const nameEl = document.getElementById('stack-name'); const composeEl = document.getElementById('compose-content'); const envEl = document.getElementById('stack-env-file'); const gitUrlEl = document.getElementById('stack-git-url'); const gitRefEl = document.getElementById('stack-git-ref'); const gitPathEl = document.getElementById('stack-git-path'); if (nameEl) nameEl.value = payload.stackName; if (composeEl) composeEl.value = payload.composeContent; if (envEl) envEl.value = payload.envFileContent || ''; if (gitUrlEl) gitUrlEl.value = payload.repoUrl || ''; if (gitRefEl) gitRefEl.value = payload.ref || 'main'; if (gitPathEl) gitPathEl.value = payload.composePath || 'docker-compose.yml'; // Optional note strip at top of modal body const modalBody = document.querySelector('#deploy-stack-modal .modal-body'); if (modalBody) { modalBody.querySelector('.stack-template-note')?.remove(); if (payload.note) { const note = document.createElement('div'); note.className = 'stack-template-note alert alert-info py-2 px-3 mb-3 small'; note.innerHTML = `
${escapeHtmlLite(resolved.title || payload.stackName)} notes
${payload.note}
`; modalBody.insertBefore(note, modalBody.firstChild); } } navigateToView('stacks'); const modalEl = document.getElementById('deploy-stack-modal'); if (modalEl && typeof bootstrap !== 'undefined') { const modal = bootstrap.Modal.getOrCreateInstance(modalEl); modal.show(); } const svcN = payload.serviceCount || 0; showAlert( 'info', `Loaded stack template “${resolved.title || payload.stackName}”${svcN ? ` (${svcN} services)` : ''} — review compose and deploy.` ); return true; } window.openStackDeployFromTemplate = openStackDeployFromTemplate; async function selectTemplateForDeploy(template) { if (!template || typeof template !== 'object') { showAlert('danger', 'Invalid template'); return; } initTemplateDeployer(); setupDeployViewFormHandler(); setupDeployResourceSliders(); const form = document.getElementById('deploy-view-form'); // Scope lookups to the deploy *view* form (modal reuses the same control ids) if (typeof window.setDeployFormScope === 'function') { window.setDeployFormScope(form); } const populate = typeof window.populateDeployFormFromTemplate === 'function' ? window.populateDeployFormFromTemplate : null; const resolve = typeof window.resolveTemplateForDeploy === 'function' ? window.resolveTemplateForDeploy : null; if (!populate) { showAlert('danger', 'Template loader unavailable. Reload the app and try again.'); return; } const typeNum = Number(template.type); const isCatalogStack = typeNum === 2 || typeNum === 3 || (!String(template.image || '').trim() && (template.repository?.url || template.repository)); let resolved = template; // Always resolve stack catalogs (and type-1 never needs compose) if (isCatalogStack && resolve) { showStatusIndicator( `Loading compose for ${template.title || template.name || 'template'}…` ); try { resolved = await resolve(template); } finally { hideStatusIndicator(); } } // Type 2/3 (and multi-service compose) → full stack deploy path (Portainer parity) if (isCatalogStack && resolved._composeText) { if (openStackDeployFromTemplate(resolved)) return; } // Container path (type 1, or stack fallback when compose missing but image resolved) const formSection = document.getElementById('deploy-form-section'); if (formSection) { formSection.style.display = 'block'; formSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } if (form) form.reset(); const result = populate(resolved); if (result?.isStack) { showAlert( 'warning', result.warnings?.[0] || `“${template.title || template.name || 'Template'}” is a Compose/stack template. Could not load its compose file — check the repository URL or deploy manually under Stacks.` ); } else if (result?.warnings?.length) { showAlert(resolved._composeResolved ? 'info' : 'warning', result.warnings.join(' ')); } else { const bits = []; if (Array.isArray(resolved.ports)) bits.push(`${resolved.ports.length} port(s)`); if (Array.isArray(resolved.volumes)) bits.push(`${resolved.volumes.length} volume(s)`); if (Array.isArray(resolved.env)) bits.push(`${resolved.env.length} env`); if (resolved.image) bits.push(resolved.image); const detail = bits.length ? ` (${bits.join(', ')})` : ''; showAlert( 'info', `Loaded template: ${resolved.title || resolved.name || resolved.image || 'selected'}${detail}` ); } } 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'); // Ensure collect/validate read the view form (ids are duplicated on the modal) if (typeof window.setDeployFormScope === 'function') { window.setDeployFormScope(form); } 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; } // Async networking precheck before deploy (empty host ports, peer conflicts) try { const { precheckDeployNetworking, formatNetworkingPrecheckMessage } = await import('./client/deployNetworkPrecheck.js'); const netCheck = await precheckDeployNetworking(formData); if (!netCheck.ok) { showAlert( 'danger', formatNetworkingPrecheckMessage(netCheck) || 'Networking configuration is invalid.' ); return false; } if (netCheck.warnings?.length) { showAlert('warning', netCheck.warnings.join(' '), { toast: true, tray: false }); } formData._networkingPrechecked = true; } catch (netErr) { console.warn('[deploy] networking precheck failed', netErr); } 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) { if (error?.code === 'DEPLOY_CANCELLED') { return false; } 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 /** * True when this connection is the UI-selected active server. * Multi-peer pushes still arrive for every connection; only the active * peer may update the containers table and other host-scoped lists. */ function isActiveUiConnection(conn, topicId) { const active = manager.active; if (!active?.id) return false; if (conn && (conn === active || conn.id === active.id)) return true; if (topicId) { const a = String(active.id); const t = String(topicId); if (t === a || a.startsWith(t) || t.startsWith(a)) return true; } return false; } /** Host-scoped UI types — must not mix data from background peers */ const ACTIVE_SERVER_ONLY_TYPES = new Set([ 'containers', 'allStats', 'images', 'networks', 'volumes', 'stacks', 'systemInfo', 'systemDf', 'dockerEvent', 'containerConfig', 'containerTop', 'imageConfig', 'networkConfig', 'volumeConfig', 'imageHistory', 'containerStats', ]); 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; } const fromActive = isActiveUiConnection(peer, topicId); // Drop host-scoped list/stats from non-selected servers (keeps table = active only) if ( response?.type && ACTIVE_SERVER_ONLY_TYPES.has(response.type) && !fromActive ) { return; } // Delegate handling based on the response type switch (response.type) { case 'allStats': if (Array.isArray(response.data)) { response.data.forEach((stats) => updateContainerStats(stats)); } break; case 'containers': { // Stable merge store — never blank the table on refresh races const rows = Array.isArray(response.data) ? response.data : Array.isArray(response.data?.containers) ? response.data.containers : null; if (rows) { applyContainerSnapshot(rows, topicId, { source: 'rpc' }); touchDashboardCache('containers'); if (currentView === 'dashboard') { updateDashboardStats(containerFilterState.allContainers, null, null); } } break; } case 'terminalOutput': case 'terminalErrorOutput': // Pop-out windows are pinned to a peer and must receive I/O even when // that peer is not the UI-active node. handlePopoutTerminalOutput( { data: response.data, containerId: response.containerId, sessionId: response.sessionId, encoding: response.encoding, }, peer ); appendTerminalOutput(response.data, response.containerId, response.encoding); if (window.handleDetailsTerminalOutput) { window.handleDetailsTerminalOutput( response.data, response.containerId, response.encoding, response.sessionId ); } 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) { const cb = window.inspectContainerCallback; cb(response.data); // Callbacks clear themselves on accept; only force-clear if unchanged // so a stale reply cannot wipe a newer container's pending callback. if (window.inspectContainerCallback === cb) { window.inspectContainerCallback = null; } } // Opportunistic IP merge into stable store (list NetworkSettings can be empty) if (response.data) { mergeInspectIpIntoStore(response.data); } 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); touchDashboardCache('systemInfo'); break; case 'images': console.log('[INFO] Handling images list...'); renderImages(response.data); touchDashboardCache('images'); // 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); touchDashboardCache('networks'); // 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); touchDashboardCache('volumes'); // 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': { handleImageTransferProgress(response, 'pull'); break; } case 'pushProgress': { handleImageTransferProgress(response, 'push'); break; } case 'flattenProgress': { // Job tray listens via manager message; keep status indicator lightly in sync if (response?.message) { updateStatusIndicator(String(response.message).slice(0, 140)); } 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': { // Fleet-changing events: force dashboard slices to re-fetch on next visit const ev = response.data || response; const t = String(ev?.Type || ev?.type || '').toLowerCase(); if (t === 'container') invalidateDashboardCache('containers'); else if (t === 'image') invalidateDashboardCache(['images', 'systemDf']); else if (t === 'volume') invalidateDashboardCache(['volumes', 'systemDf']); else if (t === 'network') invalidateDashboardCache('networks'); 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 'alert': { // Server webhook alert also surfaces in the local bell const a = response.data || response; const sev = String(a.severity || 'info').toLowerCase(); const type = sev === 'critical' || sev === 'error' ? 'danger' : sev === 'warning' ? 'warning' : 'info'; try { notificationManager?.add?.( type, a.title ? `${a.title}${a.message ? ' — ' + a.message : ''}` : a.message || 'Server alert', { badge: sev === 'critical' || sev === 'warning', key: a.id || a.ruleId } ); } catch { // ignore } try { window.peardockAlerts?.onServerAlert?.(a); } catch { // ignore } break; } case 'session': case 'roleUpdate': { // Live ACL change from server (setPeerRole) — no reconnect required const payload = response.data || response; const newRole = payload.role || response.role; if (newRole && peer) { peer.role = newRole; if (manager.active && (peer.id === manager.active.id || !manager.active.id)) { manager.active.role = newRole; } try { if (typeof applyRoleUI === 'function') applyRoleUI(); } catch { // ignore } try { notificationManager?.add?.( 'info', `Server role updated to ${newRole}`, { badge: false, key: 'role-update' } ); } catch { // ignore } } break; } case 'systemDf': renderSystemDf(response.data); touchDashboardCache('systemDf'); 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 /** * Connect by public key, or decode a pd1 invite then connect. * @param {string} input - 64-hex public key OR pd1. invite * @param {{ alias?: string, inviteToken?: string, capability?: string, adminSeed?: string, quiet?: boolean, skipActivate?: boolean, restore?: boolean }} [meta] */ async function addConnection(input, meta = {}) { // Collapse whitespace so multi-line paste of long pd1 invites still works const raw = String(input || '') .replace(/\s+/g, '') .trim(); let publicKeyHex = raw.toLowerCase(); let capability = meta.capability || null; let inviteToken = meta.inviteToken || null; let adminSeed = meta.adminSeed || null; let alias = meta.alias || null; // Invite path: pd1. embeds pubkey + capability (no external vault) let fromInvite = false; if (raw && !/^[0-9a-f]{64}$/i.test(raw)) { try { const { classifyConnectionInput, decodePeardockInvite } = await import( './shared/crypto-auth.js' ); const kind = classifyConnectionInput(raw); if (kind === 'legacyAutopassInvite' || kind === 'autopassInvite') { showAlert( 'danger', 'Old AutoPass invites are no longer supported. Ask an admin for a new peardock invite starting with pd1.' ); return; } if (kind !== 'peardockInvite') { showAlert( 'danger', 'Unrecognized invite. Ask an admin for a peardock invite starting with pd1.' ); return; } const pkg = decodePeardockInvite(raw); if (!pkg) { showAlert('danger', 'Invalid peardock invite string (pd1.…). Ask for a new invite.'); return; } publicKeyHex = pkg.publicKeyHex; capability = pkg.capability; alias = alias || pkg.alias || null; inviteToken = null; fromInvite = true; if (!capability || !capability.includes('.')) { showAlert('danger', 'Invite missing capability grant. Ask the admin for a new pd1. invite.'); return; } if (!meta.quiet) { const roleHint = pkg.role ? ` as ${pkg.role}` : ''; showAlert('success', `Invite decoded${roleHint} — connecting with capability grant…`); } } catch (err) { console.error('[ERROR] Invite redeem failed', err); hideStatusIndicator(); showAlert('danger', err?.message || 'Invite redeem failed'); return; } } 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 or a pd1. invite.'); return; } if (adminSeed) { adminSeed = String(adminSeed).trim().toLowerCase(); if (!/^[0-9a-f]{64}$/.test(adminSeed)) { showAlert('danger', 'Admin private key (SERVER_SEED) must be 64 hex characters.'); return; } } // Keep welcome visible until HyperDHT + RPC are actually connected const topicId = publicKeyHex.substring(0, 12); alias = alias || connections[topicId]?.alias || null; // After invite redeem: never fall back to a stale spent capability for this server if (!fromInvite) { capability = capability || connections[topicId]?.capability || null; inviteToken = inviteToken || connections[topicId]?.inviteToken || null; } // Restore saved admin seed for auto-reconnect as admin adminSeed = adminSeed || connections[topicId]?.adminSeed || null; if (adminSeed && !/^[0-9a-f]{64}$/i.test(String(adminSeed))) adminSeed = null; else if (adminSeed) adminSeed = String(adminSeed).toLowerCase(); // Boot restore dials every peer without flipping active / workspace const skipActivate = meta.skipActivate === true || meta.restore === true; // Already live — activate only when this is an intentional select (not a fresh invite) if (connections[topicId]?.peer?.connected && !fromInvite) { if (!skipActivate) { manager.setActive(connections[topicId].peer.id || topicId); switchConnection(topicId); hideRestoringPage(); hideWelcomePage(); } return connections[topicId].peer; } connections[topicId] = { publicKeyHex, topicHex: publicKeyHex, peer: null, alias, inviteToken: fromInvite ? null : inviteToken, capability: capability || null, adminSeed: adminSeed || connections[topicId]?.adminSeed || null, 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); } if (!skipActivate) refreshContainerStats(); try { if (!meta.quiet) { showStatusIndicator( adminSeed ? 'Connecting as admin…' : capability ? 'Connecting with invite grant…' : 'Connecting…' ); } const conn = await manager.connect(publicKeyHex, { inviteToken: fromInvite ? undefined : inviteToken || undefined, capability: capability || undefined, adminSeed: adminSeed || undefined, alias: alias || undefined, setActive: !skipActivate, forceAuth: fromInvite, }); 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 live capability on conn (may have been cleared after CAPABILITY_SPENT retry) if (conn.capability) connections[topicId].capability = conn.capability; else if (capability && conn.authMode !== 'registered') { connections[topicId].capability = capability; } else if (!conn.capability && connections[topicId].capability && conn.authMode === 'registered') { // Spent grant dropped — keep reconnect working via registration only connections[topicId].capability = null; connections[topicId].inviteToken = null; } // Persist seed only after successful admin proof so reconnect stays admin if (adminSeed && (conn.role === 'admin' || conn.authMode === 'seed')) { connections[topicId].adminSeed = adminSeed; } // 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); if (!skipActivate) { manager.setActive(conn.id); switchConnection(topicId); startStatsInterval(); warmSnapshot(); hideRestoringPage(); hideWelcomePage(); applyRoleUI(); } if (!meta.quiet) { hideStatusIndicator(); const role = conn.role || 'viewer'; const mode = conn.authMode || 'viewer'; const roleLabel = role === 'viewer' && capability ? ` as viewer (capability not elevated — try a fresh invite)` : ` as ${role}${mode && mode !== role ? ` · ${mode}` : ''}`; showAlert( role === 'viewer' && capability ? 'warning' : 'success', `Connected to ${peerDisplayName(connections[topicId], topicId)}${roleLabel}` ); showFirstConnectChecklist(); } return conn; } catch (err) { console.error('[ERROR] Connection failed', err); connections[topicId].healthStatus = 'error'; // Spent/deleted invite: drop cached grant so reconnect does not spam CAPABILITY_SPENT const code = err?.code || err?.cause?.code; if ( code === 'CAPABILITY_SPENT' || code === 'CAPABILITY_EXPIRED' || code === 'CAPABILITY_INVALID' || /already used or revoked/i.test(String(err?.message || '')) ) { connections[topicId].capability = null; connections[topicId].inviteToken = null; if (typeof showAlert === 'function' && !meta.quiet) { showAlert( 'warning', 'Invite/capability is no longer valid. Ask an admin for a new pd1. invite, or reconnect if you were already registered.' ); } } // 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 (!skipActivate && !isBootRestoring && !hasActiveConnection()) { showWelcomePage(); } return null; } } /** * 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'); // Boot chrome: if we have saved peers, show restoring immediately (no welcome flash) // Full dial happens later after listeners are wired. try { const earlySaved = loadConnections(); const earlyKeys = Object.keys(earlySaved || {}); if (earlyKeys.length > 0) { isBootRestoring = true; hideWelcomePageContentOnly(); showRestoringPage(earlyKeys.length, getLastActivePeerId()); } else { showWelcomePage(); } } catch { 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 seedEl = document.getElementById('new-connection-admin-seed'); const aliasEl = document.getElementById('new-connection-alias'); const adminSeed = seedEl ? seedEl.value.trim() : ''; const alias = aliasEl ? aliasEl.value.trim() : ''; if (!topicHex) return; const submitBtn = addConnectionForm.querySelector('button[type="submit"]'); if (submitBtn) submitBtn.disabled = true; // Keep modal open during invite decode so status is visible; close after dial starts const isInvite = !/^[0-9a-f]{64}$/i.test(topicHex.replace(/\s+/g, '')); ;(async () => { try { await addConnection(topicHex, { adminSeed: adminSeed || undefined, alias: alias || undefined, }); if (newConnectionTopic) newConnectionTopic.value = ''; if (seedEl) seedEl.value = ''; if (aliasEl) aliasEl.value = ''; const modalEl = document.getElementById('addConnectionModal'); if (modalEl && typeof bootstrap !== 'undefined') { bootstrap.Modal.getInstance(modalEl)?.hide(); } } finally { if (submitBtn) submitBtn.disabled = false; // If invite pairing failed, leave modal open with fields intact for retry if (!isInvite) { // pubkey path: clear was already done on success only } } })(); }); } // Track B ops shell (command palette, smart network, host/events/settings, jobs) initOpsApp({ navigateToView, sendCommand, }); // Show / Hide Columns pickers for resource tables (persisted) try { initAllTableColumnPickers(); } catch (err) { console.warn('[tableColumns] init failed', err); } // Add container page (blank create form) initAddContainerPage(); // Images registry manager (vault, push/pull auth) initRegistryManager(); initContainerFlattener(); document.getElementById('registry-refresh-btn')?.addEventListener('click', () => { if (typeof window.refreshRegistryPanel === 'function') window.refreshRegistryPanel(); }); document.getElementById('check-image-updates-btn')?.addEventListener('click', () => { scheduleImageUpdateCheck({ force: true, clearCache: true, immediate: true, notify: true, }); }); // 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'); const collapsed = sidebar.classList.contains('collapsed'); // Persist in settings.json (survives restarts) try { if (window.peardockOps?.saveSettings) { window.peardockOps.saveSettings({ sidebarCollapsed: collapsed }); } else { const s = loadSettingsBlob(); s.sidebarCollapsed = collapsed; saveSettingsBlob(s); } } catch { // ignore } syncSidebarCollapseUi(); } }); // Restore last collapsed state from settings cache (legacy LS fallback) try { let collapsed = false; if (window.peardockOps?.loadSettings) { collapsed = Boolean(window.peardockOps.loadSettings().sidebarCollapsed); } else { const s = loadSettingsBlob(); collapsed = Boolean(s.sidebarCollapsed); if (!collapsed && localStorage.getItem('peardock.sidebar.collapsed') === '1') { collapsed = true; } } if (collapsed && 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(); initDashboardKpiShortcuts(); // 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; } if (!formData.containerName || !formData.image) { showAlert('danger', 'Container name and image are required.'); return; } const containerName = formData.containerName; const deploy = typeof window.deployDockerContainer === 'function' ? window.deployDockerContainer : null; if (!deploy) { showAlert('danger', 'Deploy function unavailable. Reload the app and try again.'); return; } // Close modal first so the replace confirm (if any) is visible if (duplicateModal) duplicateModal.hide(); closeAllModals(); try { const result = await deploy(formData); if (result?.code === 'DEPLOY_CANCELLED' || !result) return; if (!result.viaJob) { showAlert( 'success', result.message || (result.replaced ? `Container "${containerName}" replaced successfully` : `Container "${containerName}" duplicated successfully`) ); } if (typeof sendCommand === 'function') { sendCommand('listContainers'); setTimeout(() => { if (typeof navigateToNewContainer === 'function') { navigateToNewContainer(containerName); } }, 800); } } catch (error) { if (error?.code === 'DEPLOY_CANCELLED') return; console.error('[ERROR] Failed to duplicate container:', error); if (!error?.viaJob) { presentError(error, 'deployContainer', { showAlert }); } } }); } // 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); if (keys.length === 0) { isBootRestoring = false; hideRestoringPage(); showWelcomePage(); assertVisibility(); return; } // Prefer last-known active, otherwise first key in roster order const lastActiveId = getLastActivePeerId(); const orderedKeys = orderPeersForBoot(keys, lastActiveId); isBootRestoring = true; hideWelcomePageContentOnly(); const preferredEntry = lastActiveId ? savedConnections[lastActiveId] : null; // Alias may live under a key that only prefix-matches lastActiveId let preferredAlias = preferredEntry?.alias || null; if (!preferredAlias && lastActiveId) { for (const [k, e] of Object.entries(savedConnections)) { if (topicIdsMatch(k, lastActiveId) && e?.alias) { preferredAlias = e.alias; break; } } } showRestoringPage(orderedKeys.length, lastActiveId, preferredAlias); // Titlebar: show last-used server while dials race (not a random online peer) try { window.peardockOps?.updateActivePeerChip?.(null, { pending: { id: lastActiveId || '', alias: preferredAlias }, connecting: true, }); } catch { // ignore } // Populate peer list slots immediately (no welcome flash) for (const topicId of orderedKeys) { const entry = savedConnections[topicId]; if (!entry) continue; if (!connections[topicId]) { connections[topicId] = { publicKeyHex: entry.publicKeyHex || entry.topicHex, topicHex: entry.publicKeyHex || entry.topicHex, peer: null, alias: entry.alias || null, inviteToken: entry.inviteToken || null, capability: entry.capability || null, adminSeed: entry.adminSeed || null, connectedAt: null, lastHealthCheck: null, latency: null, healthStatus: 'connecting', }; } // Seed manager reconnect alias so resolvePeerLabel works before connect try { if (!manager._reconnect.has(topicId)) { manager._reconnect.set(topicId, { publicKeyHex: String(entry.publicKeyHex || entry.topicHex || '').toLowerCase(), alias: entry.alias || null, inviteToken: entry.inviteToken || null, capability: entry.capability || null, adminSeed: entry.adminSeed || null, attempts: 0, timer: null, intentional: false, }); } else if (entry.alias) { const r = manager._reconnect.get(topicId); if (r && !r.alias) r.alias = entry.alias; } } catch { // ignore } let item = connectionList?.querySelector?.(`[data-topic-id="${topicId}"]`); if (!item && connectionList) { item = createPeerListItem(topicId, connections[topicId]); connectionList.appendChild(item); } updateConnectionStatus(topicId, false); // Highlight last-active row even before it is online if (lastActiveId && topicIdsMatch(topicId, lastActiveId) && item) { item.classList.add('active'); } } updatePeersViewChrome(); let done = 0; const total = orderedKeys.length; let bootFinished = false; let pendingDials = total; /** @type {ReturnType|null} */ let preferredWaitTimer = null; /** Max wait for last-used peer after some other host is already online */ const PREFERRED_BOOT_WAIT_MS = 2800; const clearPreferredWait = () => { if (preferredWaitTimer) { clearTimeout(preferredWaitTimer); preferredWaitTimer = null; } }; const preferredIsOnline = () => { if (!lastActiveId) return false; return orderedKeys.some( (k) => topicIdsMatch(k, lastActiveId) && connections[k]?.peer?.connected ); }; const anyPeerOnline = () => orderedKeys.some((k) => connections[k]?.peer?.connected); const bumpRestoring = (label) => { done += 1; updateRestoringStatus(`${label}`, { done, total }); }; /** * Leave the restoring screen once the last-used peer is online, all dials * settled, or a short grace after another peer came up first. * @param {{ reason?: string }} [opts] */ const finishBootRestore = (opts = {}) => { if (bootFinished) return; bootFinished = true; clearPreferredWait(); isBootRestoring = false; hideRestoringPage(); const preferred = pickPreferredActivePeer(lastActiveId, orderedKeys, connections); if (preferred && connections[preferred]?.peer?.connected) { const isLastUsed = !lastActiveId || topicIdsMatch(preferred, lastActiveId); // Only persist last-active when we selected the remembered peer (or none was set) switchConnection(preferred, { persistLastActive: isLastUsed }); if (isLastUsed) { try { setLastActivePeerId(manager.active?.id || preferred); } catch { // ignore } } startStatsInterval(); warmSnapshot(); applyRoleUI(); hideWelcomePage(); console.log( '[INFO] Active peer after restore:', preferred, isLastUsed ? '(last-used)' : '(fallback)', opts.reason || '' ); } else if (!hasActiveConnection()) { // No peer online yet — welcome; offline hosts keep retrying in the background. try { window.peardockOps?.updateActivePeerChip?.(null); } catch { // ignore } showWelcomePage(); } assertVisibility(); }; /** * Decide whether we can leave the restoring screen now. * @param {string} [reason] * @param {string} [justConnectedId] */ const tryFinishBoot = (reason, justConnectedId) => { if (bootFinished) return; // Preferred last-used is up → activate immediately if (preferredIsOnline()) { finishBootRestore({ reason: reason || 'preferred-online' }); return; } // All dials done → best available (or welcome) if (pendingDials <= 0) { finishBootRestore({ reason: reason || 'all-settled' }); return; } // Some other peer came online first: wait briefly for last-used before fallback if (anyPeerOnline() && lastActiveId) { if (reason === 'preferred-timeout') { finishBootRestore({ reason: 'preferred-timeout' }); return; } if (!preferredWaitTimer) { const label = preferredAlias || (lastActiveId ? `Peer ${String(lastActiveId).slice(0, 6)}…` : 'last server'); updateRestoringStatus(`Waiting for ${label}…`, { done, total }); preferredWaitTimer = setTimeout(() => { preferredWaitTimer = null; tryFinishBoot('preferred-timeout', justConnectedId); }, PREFERRED_BOOT_WAIT_MS); } return; } // No preferred remembered: first online peer is fine if (anyPeerOnline() && !lastActiveId) { finishBootRestore({ reason: reason || 'peer-online' }); } }; /** * Launch failure → notification tray (not a blocking boot wait). * @param {string} topicId * @param {object} [entry] * @param {Error|null} [err] */ const notifyBootConnectFailure = (topicId, entry, err) => { const label = (entry && peerDisplayName(entry, topicId)) || (connections[topicId] && peerDisplayName(connections[topicId], topicId)) || `${String(topicId).slice(0, 8)}…`; const detail = err?.message ? `: ${err.message}` : ''; const msg = `Launch: could not connect to ${label}${detail}`; console.warn(`[WARN] ${msg}`); const prefs = jobsTrayPrefs(); if (prefs.launchConnectNotify === false || typeof showAlert !== 'function') return; // Bell only — do not hold boot or open the Jobs tray showAlert('warning', msg, { toast: false, badge: true }); }; /** * After boot already left restoring, promote a late-arriving preferred peer * (or any first peer if still on welcome). * @param {string} topicId */ const onLateBootConnect = (topicId) => { if (!connections[topicId]?.peer?.connected) return; if (!hasActiveConnection()) { const isLastUsed = !lastActiveId || topicIdsMatch(topicId, lastActiveId); switchConnection(topicId, { persistLastActive: isLastUsed || !lastActiveId }); startStatsInterval(); warmSnapshot(); applyRoleUI(); hideWelcomePage(); console.log('[INFO] Late boot connect activated:', topicId); return; } // Prefer last-active when it comes online after a fallback peer was used if ( lastActiveId && topicIdsMatch(topicId, lastActiveId) && manager.active?.id && !topicIdsMatch(manager.active.id, lastActiveId) ) { switchConnection(topicId, { persistLastActive: true }); startStatsInterval(); warmSnapshot(); applyRoleUI(); hideWelcomePage(); console.log('[INFO] Promoted preferred peer after late connect:', topicId); } }; // Dial every peer in parallel. Preferred last-used ends restoring first; // other peers only end it after a short wait or when all dials settle. for (const topicId of orderedKeys) { const entry = savedConnections[topicId]; ;(async () => { try { const publicKeyHex = entry?.publicKeyHex || entry?.topicHex || entry?.topic; if (!publicKeyHex) { bumpRestoring(`${topicId.slice(0, 8)}… skipped`); notifyBootConnectFailure(topicId, entry, new Error('Missing public key')); return; } if (!bootFinished) { updateRestoringStatus(`Connecting ${entry.alias || topicId.slice(0, 8)}…`, { done, total, }); } await addConnection(String(publicKeyHex), { alias: entry.alias || undefined, inviteToken: entry.inviteToken || undefined, capability: entry.capability || undefined, adminSeed: entry.adminSeed || undefined, quiet: true, skipActivate: true, restore: true, }); const ok = Boolean(connections[topicId]?.peer?.connected); bumpRestoring( `${entry.alias || topicId.slice(0, 8)} ${ok ? 'online' : 'offline'}` ); if (ok) { if (!bootFinished) { tryFinishBoot( topicIdsMatch(topicId, lastActiveId) ? 'preferred-online' : 'peer-online', topicId ); } else { onLateBootConnect(topicId); } } else { // addConnection returned without a live socket (caught internally) notifyBootConnectFailure(topicId, entry, null); } } catch (err) { console.error(`[ERROR] Failed to restore connection ${topicId}:`, err?.message || err); bumpRestoring(`${topicId.slice(0, 8)}… failed`); notifyBootConnectFailure(topicId, entry, err); } finally { pendingDials -= 1; if (pendingDials <= 0 && !bootFinished) { tryFinishBoot('all-settled'); } } })(); } } catch (err) { isBootRestoring = false; hideRestoringPage(); 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() { if (containerList) { containerList.innerHTML = ''; delete containerList.dataset.listFp; delete containerList.dataset.structFp; delete containerList.dataset.hasData; delete containerList.dataset.orderFp; } // Clean up smoothedStats for all containers when list is reset Object.keys(smoothedStats).forEach(containerId => { delete smoothedStats[containerId]; }); inspectIpRequested.clear(); clearContainerStore(); containerVirt.rows = []; 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); } /** * Sync connection-row health UI from the live peer. * Ping / dead-link detection is owned solely by ConnectionManager's health loop * (manager emits 'health' → updateConnectionDisplay). A second interval here used * to double docker.version() load on the server every 10s. */ function startHealthMonitoring(topicId) { const connection = connections[topicId]; if (!connection) return; if (connection.healthCheckInterval) { clearInterval(connection.healthCheckInterval); connection.healthCheckInterval = null; } const peer = connection.peer?.connected ? connection.peer : manager.connections.get(topicId)?.connected ? manager.connections.get(topicId) : null; if (!peer) return; if (connection.peer !== peer) connection.peer = peer; connection.latency = peer.latency ?? connection.latency; connection.healthStatus = peer.healthStatus || connection.healthStatus || 'healthy'; connection.lastHealthCheck = peer.lastHealthCheck || connection.lastHealthCheck || Date.now(); updateConnectionStatus(topicId, true); updateConnectionDisplay(topicId); } // Switch between connections /** * @param {string} topicId * @param {{ persistLastActive?: boolean }} [opts] * persistLastActive (default true): write peers.json activePeerId. * Boot may pass false when activating a temporary fallback host. */ function switchConnection(topicId, opts = {}) { const connection = connections[topicId]; if (!connection || !connection.peer?.connected) { console.error('[ERROR] No connection found or no active peer.'); if (!isBootRestoring && !hasActiveConnection()) { showWelcomePage(); stopStatsInterval(); } return; } const persistLastActive = opts.persistLastActive !== false; const activeId = connection.peer?.id || topicId; if (activeId) { manager.setActive(activeId, { persist: persistLastActive }); } if (persistLastActive) { try { setLastActivePeerId(manager.active?.id || topicId); } catch { // ignore } } // Mark active row in peer list (match short / full ids) document.querySelectorAll('#connection-list .list-group-item').forEach((item) => { const rowId = item.dataset.topicId; item.classList.toggle( 'active', Boolean(rowId && (rowId === topicId || topicIdsMatch(rowId, topicId))) ); }); hideRestoringPage(); hideWelcomePage(); // Drop previous host's containers immediately so the table never mixes peers resetContainerList(); containerStore.topicId = manager.active?.id || topicId || ''; invalidateDashboardCache('all'); console.log(`[INFO] Switched to connection: ${topicId}`); startStatsInterval(); sendCommand(Methods.listContainers); // Refresh other host-scoped lists for the newly active server if (currentView === 'images') sendCommand(Methods.listImages); else if (currentView === 'networks') sendCommand(Methods.listNetworks); else if (currentView === 'volumes') sendCommand(Methods.listVolumes); else if (currentView === 'stacks') sendCommand(Methods.listStacks); else if (currentView === 'dashboard') { sendCommand(Methods.listImages); sendCommand(Methods.listNetworks); sendCommand('getSystemInfo'); } } // 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 (filters loaded from settings cache) const _savedContainerFilters = loadContainerFilters(); let containerFilterState = { search: _savedContainerFilters.search, status: _savedContainerFilters.status, sort: _savedContainerFilters.sort, allContainers: [] }; /** * Stable container store — merges snapshots by Id so auto-refresh / docker-event * races never wipe rows. Removals require consecutive misses (or long grace). */ const containerStore = { /** @type {Map} */ byId: new Map(), /** @type {Map} consecutive snapshots missing this id */ missCount: new Map(), /** Monotonic apply counter (ignore out-of-order empty-ish updates) */ gen: 0, paintScheduled: false, topicId: '', }; function isUsefulIp(ip) { return Boolean(ip) && ip !== 'No IP Assigned' && ip !== 'Error Retrieving IP'; } /** * Merge a list snapshot into the stable store and schedule one paint. * Only accepts data for the currently active server. * @param {object[]} incoming * @param {string} [topicId] * @param {{ source?: string, force?: boolean }} [opts] */ function applyContainerSnapshot(incoming, topicId, opts = {}) { if (!Array.isArray(incoming)) return; const activeId = manager.active?.id || ''; // Never mix another peer's containers into the active table if (topicId && activeId && !topicIdsMatch(topicId, activeId)) { return; } // Snapshot belongs to a different host than the store → hard reset first if ( topicId && containerStore.topicId && !topicIdsMatch(topicId, containerStore.topicId) ) { if (activeId && !topicIdsMatch(topicId, activeId)) return; clearContainerStore(); } // Never replace a populated store with a transient empty payload if (incoming.length === 0) { if (containerStore.byId.size > 0) { return; } containerFilterState.allContainers = []; scheduleContainerPaint(topicId); return; } // Reject suspiciously small snapshots while we already have a full fleet // (partial race / mid-reconnect). Allow shrinks of 1–2 (normal stop/rm). const prevSize = containerStore.byId.size; if ( !opts.force && prevSize >= 3 && incoming.length < Math.max(1, Math.floor(prevSize * 0.5)) ) { console.warn( `[WARN] Ignoring suspect container snapshot (${incoming.length} vs ${prevSize} cached)` ); return; } containerStore.gen += 1; if (topicId) containerStore.topicId = topicId; else if (activeId && !containerStore.topicId) containerStore.topicId = activeId; const seen = new Set(); for (const c of incoming) { if (!c?.Id) continue; seen.add(c.Id); const prev = containerStore.byId.get(c.Id); const merged = prev ? { ...prev, ...c } : { ...c }; // Keep a good IP when the new snapshot omits / fails it (event pushes vs list) if (!isUsefulIp(c.ipAddress) && isUsefulIp(prev?.ipAddress)) { merged.ipAddress = prev.ipAddress; } if ((!c.Names || !c.Names.length) && prev?.Names?.length) { merged.Names = prev.Names; } if (!c.Image && prev?.Image) merged.Image = prev.Image; containerStore.byId.set(c.Id, merged); containerStore.missCount.delete(c.Id); } // Defer removals: one missed snapshot is not enough (refresh race) for (const id of [...containerStore.byId.keys()]) { if (seen.has(id)) continue; const misses = (containerStore.missCount.get(id) || 0) + 1; containerStore.missCount.set(id, misses); if (misses >= 2) { containerStore.byId.delete(id); containerStore.missCount.delete(id); delete smoothedStats[id]; inspectIpRequested.delete(id); } } containerFilterState.allContainers = [...containerStore.byId.values()]; scheduleContainerPaint(topicId || containerStore.topicId); } function scheduleContainerPaint(topicId) { if (containerStore.paintScheduled) return; containerStore.paintScheduled = true; requestAnimationFrame(() => { containerStore.paintScheduled = false; renderContainers( containerFilterState.allContainers, topicId || containerStore.topicId || manager.active?.id || '' ); }); } function clearContainerStore() { containerStore.byId.clear(); containerStore.missCount.clear(); containerStore.gen = 0; containerStore.topicId = ''; containerFilterState.allContainers = []; // New peer / reset — allow Containers tab to run an initial update check again imageUpdateInitialPeerId = ''; imageUpdateByImage.clear(); imageUpdateByContainer.clear(); } /** Merge IP from inspect payload without reordering / wiping the table */ function mergeInspectIpIntoStore(config) { const id = config?.Id || config?.id; if (!id) return; const nets = config?.NetworkSettings?.Networks; let ip = null; if (nets && typeof nets === 'object') { for (const net of Object.values(nets)) { if (net?.IPAddress) { ip = net.IPAddress; break; } } } if (!isUsefulIp(ip)) return; const prev = containerStore.byId.get(id); if (prev) { if (prev.ipAddress === ip) { // still update DOM if row shows placeholder } else { containerStore.byId.set(id, { ...prev, ipAddress: ip }); containerFilterState.allContainers = [...containerStore.byId.values()]; } } if (smoothedStats[id]) smoothedStats[id].ip = ip; const listElement = domCache.containerList || containerList; const row = listElement?.querySelector(`tr[data-container-id="${id}"]`); const ipEl = row?.querySelector('.ip-address'); if (ipEl && ipEl.textContent !== ip) ipEl.textContent = ip; } /** * Global hide-by-label filters from Settings (client-side). * @returns {Array<{ name: string, value: string }>} */ function getHiddenContainerLabelFilters() { try { const s = (typeof window !== 'undefined' && window.__peardockSettings) || (typeof window !== 'undefined' && window.peardockOps?.loadSettings?.()) || {}; const raw = s?.hiddenContainerLabels; if (!Array.isArray(raw)) return []; return raw .map((f) => ({ name: String(f?.name || f?.key || '').trim(), value: String(f?.value ?? '').trim(), })) .filter((f) => f.name); } catch { return []; } } /** * True if container should be hidden by a global label filter. * Empty filter value matches any value for that label key. * @param {object} container * @param {Array<{ name: string, value: string }>} [filters] */ function isContainerHiddenByLabelFilters(container, filters = getHiddenContainerLabelFilters()) { if (!filters.length || !container) return false; const labels = container.Labels || container.labels || {}; if (!labels || typeof labels !== 'object') return false; for (const f of filters) { if (!f.name) continue; if (!Object.prototype.hasOwnProperty.call(labels, f.name)) continue; if (f.value === '' || f.value == null) return true; if (String(labels[f.name]) === f.value) return true; } return false; } /** * Apply global settings hide-filters (labels) — used by list, dashboard, bulk ops. * @param {object[]} containers * @returns {object[]} */ function applyGlobalContainerVisibility(containers) { const filters = getHiddenContainerLabelFilters(); if (!filters.length) return containers; return containers.filter((c) => !isContainerHiddenByLabelFilters(c, filters)); } // Filter and sort containers function filterAndSortContainers(containers) { // Global label hide rules first (Settings → Behavior → Hidden containers) let filtered = applyGlobalContainerVisibility(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 (stable secondary key = name so order does not thrash) const [sortField, sortOrder] = containerFilterState.sort.split('-'); filtered.sort((a, b) => { let aVal, bVal; const aName = (a.Names?.[0]?.replace(/^\//, '') || a.Id || '').toLowerCase(); const bName = (b.Names?.[0]?.replace(/^\//, '') || b.Id || '').toLowerCase(); switch (sortField) { case 'name': aVal = aName; bVal = bName; 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 aName < bName ? -1 : aName > bName ? 1 : 0; } if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1; if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1; // Stable tie-break by name if (aName < bName) return -1; if (aName > bName) return 1; return String(a.Id).localeCompare(String(b.Id)); }); 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, /** When true, skip repaint so open action menus are not destroyed / clipped */ menuOpen: false, }; /** Pending stats paints keyed by container id — one rAF flush, no flicker */ const pendingStatsMap = new Map(); let statsFlushRaf = 0; /** Avoid spamming inspectContainer for the same id */ const inspectIpRequested = new Set(); function containerStatusClass(state) { const stateLower = String(state || 'unknown').toLowerCase(); if (stateLower === 'running') return 'status-running'; if (stateLower === 'exited' || stateLower === 'stopped') return 'status-exited'; if (stateLower === 'created') return 'status-created'; if (stateLower === 'restarting') return 'status-restarting'; return ''; } function formatCpuDisplay(cpu) { if (cpu == null || Number.isNaN(Number(cpu))) return '—'; const n = Number(cpu); if (n < 0.01 && n > 0) return '<0.01%'; return `${n.toFixed(2)}%`; } function formatMemDisplay(memoryBytes) { if (memoryBytes == null || Number.isNaN(Number(memoryBytes))) return '—'; const bytes = Number(memoryBytes); if (bytes <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let v = bytes; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; } const digits = i === 0 ? 0 : v >= 10 ? 1 : 2; return `${v.toFixed(digits)} ${units[i]}`; } /** Memory bar % — prefer container limit from Engine stats when present */ function memoryBarPercent(usageBytes, limitBytes) { const usage = Number(usageBytes) || 0; let limit = Number(limitBytes) || 0; // Docker often reports host total RAM as limit when unlimited — still OK for bar if (limit <= 0) limit = 8 * 1024 * 1024 * 1024; // If "limit" is huge host RAM and usage is tiny, bar still scales correctly return Math.min(100, Math.max(0, (usage / limit) * 100)); } /** Apply CPU/memory/IP to a row only when values actually change */ function applyStatsToRow(row, stats) { if (!row || !stats) return; const cpuEl = row.querySelector('.cpu .stats-value') || row.querySelector('td.cpu .stats-value'); const cpuBar = row.querySelector('.cpu-bar') || row.querySelector('td.cpu .stats-bar'); const memoryEl = row.querySelector('.memory .stats-value') || row.querySelector('td.memory .stats-value'); const memoryBar = row.querySelector('.memory-bar') || row.querySelector('td.memory .stats-bar'); const ipEl = row.querySelector('.ip-address'); const cpuText = formatCpuDisplay(stats.cpu); if (cpuEl) { // Clear pending spinner HTML when real stats arrive if (cpuEl.querySelector?.('.job-spinner') || cpuEl.textContent !== cpuText) { cpuEl.textContent = cpuText; cpuEl.title = cpuText; } } if (cpuBar) { // Bar is % of one full core-equivalent up to 100% (cap display) const width = Math.min(100, Math.max(0, Number(stats.cpu) || 0)); const w = `${width}%`; if (cpuBar.style.width !== w) cpuBar.style.width = w; cpuBar.setAttribute('aria-valuenow', String(Math.round(width))); } const memText = formatMemDisplay(stats.memory); if (memoryEl) { if (memoryEl.querySelector?.('.job-spinner') || memoryEl.textContent !== memText) { memoryEl.textContent = memText; const lim = stats.memoryLimit ? ` / ${formatMemDisplay(stats.memoryLimit)}` : ''; memoryEl.title = `${memText}${lim}`; } } if (memoryBar) { const memoryPercent = memoryBarPercent(stats.memory, stats.memoryLimit); const w = `${memoryPercent}%`; if (memoryBar.style.width !== w) memoryBar.style.width = w; memoryBar.setAttribute('aria-valuenow', String(Math.round(memoryPercent))); } if (ipEl && stats.ip && stats.ip !== 'No IP Assigned') { if (ipEl.textContent !== stats.ip) ipEl.textContent = stats.ip; } } /** Clear CPU/memory cells (no spinner) — used for stopped / non-running rows. */ function clearStatsOnRow(row) { if (!row) return; const cpuEl = row.querySelector('.cpu .stats-value') || row.querySelector('td.cpu .stats-value'); const cpuBar = row.querySelector('.cpu-bar') || row.querySelector('td.cpu .stats-bar'); const memoryEl = row.querySelector('.memory .stats-value') || row.querySelector('td.memory .stats-value'); const memoryBar = row.querySelector('.memory-bar') || row.querySelector('td.memory .stats-bar'); if (cpuEl && (cpuEl.querySelector?.('.job-spinner') || cpuEl.textContent !== '—')) { cpuEl.textContent = '—'; cpuEl.removeAttribute('title'); } if (cpuBar) { if (cpuBar.style.width !== '0%') cpuBar.style.width = '0%'; cpuBar.setAttribute('aria-valuenow', '0'); } if (memoryEl && (memoryEl.querySelector?.('.job-spinner') || memoryEl.textContent !== '—')) { memoryEl.textContent = '—'; memoryEl.removeAttribute('title'); } if (memoryBar) { if (memoryBar.style.width !== '0%') memoryBar.style.width = '0%'; memoryBar.setAttribute('aria-valuenow', '0'); } } /** * @param {HTMLElement} row * @param {string} containerId * @param {boolean} [running=true] */ function seedStatsIntoRow(row, containerId, running = true) { if (!running) { clearStatsOnRow(row); return; } const s = smoothedStats[containerId]; if (s) { applyStatsToRow(row, s); return; } // Just started / no sample yet — show pending spinners (not stale "—") const cpuEl = row.querySelector('.cpu .stats-value') || row.querySelector('td.cpu .stats-value'); const memoryEl = row.querySelector('.memory .stats-value') || row.querySelector('td.memory .stats-value'); if (cpuEl && !cpuEl.querySelector?.('.job-spinner')) { cpuEl.innerHTML = jobSpinnerPending(); cpuEl.removeAttribute('title'); } if (memoryEl && !memoryEl.querySelector?.('.job-spinner')) { memoryEl.innerHTML = jobSpinnerPending(); memoryEl.removeAttribute('title'); } } function maybeRequestContainerIp(containerId, ipAddress) { if (ipAddress && ipAddress !== 'No IP Assigned') return; if (inspectIpRequested.has(containerId)) return; inspectIpRequested.add(containerId); sendCommand('inspectContainer', { id: containerId }); } /** * Enable/disable container table action buttons from runtime state. * - Start: only when not running * - Stop / terminal / restart / kill / pause / top: only when running * Respects role-based disable (does not re-enable if role forbids). * @param {HTMLElement} row * @param {object|string} [containerOrState] */ function applyContainerRowActionState(row, containerOrState) { if (!row) return; let state = ''; if (typeof containerOrState === 'string') { state = containerOrState; } else if (containerOrState && typeof containerOrState === 'object') { const raw = typeof containerOrState.State === 'object' ? containerOrState.State?.Status : containerOrState.State || containerOrState.Status || ''; state = String(raw || ''); } else if (row._pdContainer) { state = String(row._pdContainer.State || ''); } const stateLower = state.toLowerCase(); const running = stateLower === 'running'; const paused = stateLower === 'paused'; // Actions that require a live process tree const canRunOps = running; // Kill/restart also useful while paused or restarting const canStopish = running || paused || stateLower === 'restarting'; const setBtn = (sel, enabled) => { const btn = row.querySelector(sel); if (!btn) return; // Role system owns disable when insufficient role if (btn.dataset.roleDisabled === '1') { btn.disabled = true; return; } btn.disabled = !enabled; btn.setAttribute('aria-disabled', enabled ? 'false' : 'true'); }; setBtn('.action-start', !running && !paused && stateLower !== 'restarting'); setBtn('.action-stop', running); setBtn('.action-terminal', canRunOps); setBtn('.action-restart', canStopish); setBtn('.action-kill', canStopish); setBtn('.action-pause', running); setBtn('.action-top', canRunOps); setBtn('.action-exec', canRunOps); // Resume only when paused (if present on row) setBtn('.action-resume', paused); setBtn('.action-unpause', paused); } /** * Re-apply action enablement on every visible container row (after role UI). */ function refreshAllContainerRowActions() { const listElement = domCache.containerList || containerList; if (!listElement) return; listElement.querySelectorAll('tr[data-container-id]').forEach((row) => { applyContainerRowActionState(row, row._pdContainer); }); } /** * Update only structural fields on an existing row (not live stats). * Preserves CPU/memory DOM values between list refreshes. */ function patchContainerRow(row, container) { const name = container.Names?.[0]?.replace(/^\//, '') || 'Unknown'; const image = formatImageName(container.Image || '-'); const state = container.State || 'Unknown'; const statusClass = containerStatusClass(state); const running = String(state).toLowerCase() === 'running'; const ipFromData = container.ipAddress || null; const nameDisplay = row.querySelector('.container-name-display'); if (nameDisplay && nameDisplay.textContent !== name) nameDisplay.textContent = name; const nameLink = row.querySelector('.container-name-link'); if (nameLink && nameLink.textContent !== name) nameLink.textContent = name; // Image cell const imageTd = row.querySelector('.container-image-cell') || row.children[2]; if (imageTd && imageTd.textContent !== image) imageTd.textContent = image; applyImageUpdateToRow(row, container); const badge = row.querySelector('td .badge'); if (badge) { if (badge.textContent !== state) badge.textContent = state; const want = `badge ${statusClass}`.trim(); if (badge.className !== want) badge.className = want; } applyContainerRowActionState(row, container); if (ipFromData && ipFromData !== 'No IP Assigned') { const ipEl = row.querySelector('.ip-address'); if (ipEl && ipEl.textContent !== ipFromData) ipEl.textContent = ipFromData; } row._pdContainer = container; seedStatsIntoRow(row, container.Id, running); } /** * @param {string} status * @param {{ image?: string, localDigest?: string|null, remoteDigest?: string|null, error?: string|null }} [meta] */ function imageUpdateIndicatorHtml(status, meta = {}) { const st = status || 'unknown'; const titleParts = []; if (st === 'updated') titleParts.push('Image is up to date with the registry'); else if (st === 'outdated') titleParts.push('Newer image available for this tag at the registry'); else if (st === 'checking') titleParts.push('Checking registry…'); else if (st === 'skipped') titleParts.push('Skipped (local / digests not applicable)'); else titleParts.push('Could not determine update status'); if (meta.image) titleParts.push(`Image: ${meta.image}`); if (meta.localDigest) titleParts.push(`Local: ${String(meta.localDigest).slice(0, 19)}…`); if (meta.remoteDigest) titleParts.push(`Remote: ${String(meta.remoteDigest).slice(0, 19)}…`); if (meta.error) titleParts.push(meta.error); const title = titleParts.join('\n').replace(/"/g, '"'); let icon = 'fa-minus'; let cls = 'is-unknown'; if (st === 'updated') { icon = 'fa-check'; cls = 'is-updated'; } else if (st === 'outdated') { icon = 'fa-arrow-up'; cls = 'is-outdated'; } else if (st === 'checking') { icon = 'fa-circle-notch fa-spin'; cls = 'is-checking'; } else if (st === 'skipped') { icon = 'fa-minus'; cls = 'is-skipped'; } return ``; } function getImageUpdateMeta(container) { const id = container?.Id || ''; if (id) { const byC = imageUpdateByContainer.get(id); if (byC) return byC; // Match short ids / prefix (Docker sometimes shortens client-side copies) if (id.length >= 12) { for (const [cid, info] of imageUpdateByContainer) { if (cid === id || cid.startsWith(id) || id.startsWith(cid)) return info; } } } const img = container?.Image || ''; if (img) { const byI = imageUpdateByImage.get(img); if (byI) return { ...byI, image: img }; // Resolved name may differ slightly (e.g. library/ prefix) — substring match last resort for (const [name, info] of imageUpdateByImage) { if (name === img || name.endsWith(`/${img}`) || img.endsWith(`/${name}`)) { return { ...info, image: name }; } } } return { status: 'unknown', image: img }; } function applyImageUpdateToRow(row, container) { const cell = row.querySelector('.image-update-cell'); if (!cell) return; const meta = getImageUpdateMeta(container || row._pdContainer || {}); cell.innerHTML = imageUpdateIndicatorHtml(meta.status, meta); const btn = cell.querySelector('.image-update-indicator'); if (btn && meta.status === 'outdated') { btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const image = meta.image || container?.Image; if (!image) return; if (typeof pullImageWithAuth === 'function') { pullImageWithAuth({ image }).then(() => { scheduleImageUpdateCheck({ force: true }); }).catch(() => {}); } else { sendCommand('pullImage', { image }); } }); } } function scheduleImageUpdateCheck(opts = {}) { if (imageUpdateCheckTimer) clearTimeout(imageUpdateCheckTimer); const delay = opts.immediate ? 0 : 400; imageUpdateCheckTimer = setTimeout(() => { imageUpdateCheckTimer = null; runImageUpdateCheck(opts); }, delay); } /** * Run image update check once when Containers tab first loads for the active peer. * List reconcile often skips this (structFp unchanged / check deferred while off-tab). */ function ensureInitialImageUpdateCheck() { if (!manager?.active?.connected) return; if (currentView && currentView !== 'containers') return; const peerId = manager.active.id || ''; if (!peerId) return; if (imageUpdateInitialPeerId === peerId) return; imageUpdateInitialPeerId = peerId; // Silent: table indicators only — no tray spam on Containers tab open scheduleImageUpdateCheck({ force: false, immediate: true, initial: true, silent: true }); } async function runImageUpdateCheck(opts = {}) { if (!manager.active?.connected) return; if (currentView && currentView !== 'containers' && !opts.force) return; if (imageUpdateCheckInFlight && !opts.force) return; imageUpdateCheckInFlight = true; // Tray only for explicit user-triggered checks — suppress on page-load / background const notify = opts.notify === true || (opts.force === true && opts.initial !== true && opts.silent !== true); const silent = opts.silent === true || opts.initial === true || !notify; const btn = document.getElementById('check-image-updates-btn'); if (btn) { btn.disabled = true; btn.innerHTML = 'Checking…'; } // Mark visible rows as checking on force or first tab load (no client cache yet) const listElement = domCache.containerList || containerList; const showChecking = Boolean(opts.force || opts.initial || imageUpdateByImage.size === 0); if (listElement && showChecking) { listElement.querySelectorAll('tr[data-container-id]').forEach((row) => { const c = row._pdContainer; if (!c) return; const existing = imageUpdateByContainer.get(c.Id) || imageUpdateByImage.get(c.Image); if (!existing || opts.force || opts.initial) { imageUpdateByContainer.set(c.Id, { status: 'checking', image: c.Image }); applyImageUpdateToRow(row, c); } }); } try { const res = await manager.request(Methods.checkImageUpdates, { force: Boolean(opts.force), all: true, clearCache: Boolean(opts.clearCache), }); imageUpdateByImage.clear(); imageUpdateByContainer.clear(); const byImage = res?.byImage || {}; const byContainer = res?.byContainer || {}; for (const [img, info] of Object.entries(byImage)) { imageUpdateByImage.set(img, info); } for (const [id, info] of Object.entries(byContainer)) { imageUpdateByContainer.set(id, info); } // Paint all visible rows if (listElement) { listElement.querySelectorAll('tr[data-container-id]').forEach((row) => { applyImageUpdateToRow(row, row._pdContainer); }); } if (!silent) { const statuses = Object.values(byImage); const outdated = statuses.filter((s) => s.status === 'outdated').length; const updated = statuses.filter((s) => s.status === 'updated').length; const unknown = statuses.filter((s) => s.status === 'unknown' || s.status === 'skipped').length; const withErr = statuses.filter((s) => s.error).slice(0, 2); let text = outdated > 0 ? `${outdated} image(s) have updates · ${updated} up to date · ${unknown} unknown/skipped` : statuses.length ? updated > 0 && unknown === 0 ? `All checked images up to date (${updated})` : `${updated} up to date · ${unknown} unknown/skipped` : 'No images to check'; if (withErr.length) { text += ` — ${withErr.map((s) => s.error).join('; ')}`; } const trayType = outdated > 0 ? 'warning' : withErr.length ? 'warning' : 'info'; if (typeof notificationManager?.add === 'function') { notificationManager.add(trayType, text, { badge: outdated > 0 }); } else if (typeof showAlert === 'function') { showAlert(trayType, text); } } } catch (err) { console.warn('[image-updates]', err); if (!silent) { const msg = err?.message || 'Update check failed'; if (typeof notificationManager?.add === 'function') { notificationManager.add('danger', msg, { badge: true }); } else if (typeof showAlert === 'function') { showAlert('danger', msg); } } } finally { imageUpdateCheckInFlight = false; if (btn) { btn.disabled = false; btn.innerHTML = 'Check updates'; } } } window.scheduleImageUpdateCheck = scheduleImageUpdateCheck; window.runImageUpdateCheck = runImageUpdateCheck; function buildContainerRow(container) { const name = container.Names[0]?.replace(/^\//, '') || 'Unknown'; const image = formatImageName(container.Image || '-'); const containerId = container.Id; const ipAddress = container.ipAddress || smoothedStats[containerId]?.ip || 'No IP Assigned'; maybeRequestContainerIp(containerId, ipAddress); const row = document.createElement('tr'); row.dataset.containerId = containerId; row._pdContainer = container; const state = container.State || 'Unknown'; const statusClass = containerStatusClass(state); const running = String(state).toLowerCase() === 'running'; const prior = running ? smoothedStats[containerId] : null; // Running + no sample yet → tray spinner. Stopped/exited never spin (no stats stream). const cpuText = running ? prior ? formatCpuDisplay(prior.cpu) : jobSpinnerPending() : '—'; const memText = running ? prior ? formatMemDisplay(prior.memory) : jobSpinnerPending() : '—'; const cpuWidth = prior ? Math.min(100, Math.max(0, prior.cpu || 0)) : 0; const memWidth = prior ? memoryBarPercent(prior.memory, prior.memoryLimit) : 0; const updateMeta = getImageUpdateMeta(container); row.innerHTML = `
${name} ${name}
${image} ${imageUpdateIndicatorHtml(updateMeta.status, updateMeta)} ${state}
${cpuText}
${memText}
${ipAddress}
`; applyContainerRowActionState(row, container); const checkbox = row.querySelector('.container-checkbox'); if (checkbox) checkbox.addEventListener('change', () => updateBulkActionsToolbar()); 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); }); } const moreBtn = row.querySelector('.action-more'); if (moreBtn) { moreBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openContainerActionsModal(container); }); } applyImageUpdateToRow(row, container); addActionListeners(row, container); return row; } /** * Container "More actions" modal (replaces fragile ⋮ dropdown menus). * @type {{ container: object|null }} */ const containerActionsModalState = { container: null }; /** No-op: kept so list reconcile still has a safe close hook. */ function closeContainerActionMenus() { containerVirt.menuOpen = false; } /** * Open the designed more-actions modal for a container. * @param {object} container */ function openContainerActionsModal(container) { if (!container) return; const modalEl = document.getElementById('containerActionsModal'); if (!modalEl || typeof bootstrap === 'undefined') { console.warn('[WARN] containerActionsModal missing'); return; } containerActionsModalState.container = container; containerVirt.menuOpen = true; const name = (container.Names?.[0] || '').replace(/^\//, '') || container.Id?.slice(0, 12) || 'Container'; const state = container.State || 'unknown'; const running = String(state).toLowerCase() === 'running'; const image = formatImageName(container.Image || '—'); const shortId = (container.Id || '').slice(0, 12); const titleEl = document.getElementById('containerActionsModalLabel'); if (titleEl) titleEl.textContent = name; const subtitle = document.getElementById('cam-subtitle'); if (subtitle) { subtitle.innerHTML = ` ${escapeHtmlLite(shortId)} · ${escapeHtmlLite(state)} · ${escapeHtmlLite(image)} `; } // Enable/disable state-sensitive actions (restart/pause/kill/processes) const paused = String(state).toLowerCase() === 'paused'; const canStopish = running || paused || String(state).toLowerCase() === 'restarting'; modalEl.querySelectorAll('[data-cam-need-running]').forEach((btn) => { if (btn.dataset.roleDisabled === '1') { btn.disabled = true; return; } const action = btn.getAttribute('data-cam-action'); // kill/restart also ok when paused; pure "need running" stay strict if (action === 'kill' || action === 'restart') { btn.disabled = !canStopish; } else { btn.disabled = !running; } btn.setAttribute('aria-disabled', btn.disabled ? 'true' : 'false'); }); // Start-only actions if present modalEl.querySelectorAll('[data-cam-need-stopped]').forEach((btn) => { if (btn.dataset.roleDisabled === '1') { btn.disabled = true; return; } btn.disabled = running || paused; btn.setAttribute('aria-disabled', btn.disabled ? 'true' : 'false'); }); const modal = bootstrap.Modal.getOrCreateInstance(modalEl); modal.show(); } function hideContainerActionsModal() { const modalEl = document.getElementById('containerActionsModal'); if (!modalEl || typeof bootstrap === 'undefined') return; bootstrap.Modal.getInstance(modalEl)?.hide(); } function bindContainerActionsModalOnce() { const modalEl = document.getElementById('containerActionsModal'); if (!modalEl || modalEl.dataset.bound === '1') return; modalEl.dataset.bound = '1'; modalEl.addEventListener('hidden.bs.modal', () => { containerActionsModalState.container = null; containerVirt.menuOpen = false; if (containerVirt.rows.length > 80) { requestAnimationFrame(() => paintVirtualContainers()); } }); modalEl.addEventListener('click', async (e) => { const btn = e.target?.closest?.('[data-cam-action]'); if (!btn || btn.disabled) return; const action = btn.getAttribute('data-cam-action'); const container = containerActionsModalState.container; if (!container || !action) return; const name = (container.Names?.[0] || '').replace(/^\//, '') || container.Id; const hide = () => hideContainerActionsModal(); try { switch (action) { case 'restart': { hide(); showStatusIndicator(`Restarting container "${name}"...`); sendCommand('restartContainer', { id: container.Id }); try { const response = await waitForPeerResponse(`Container ${container.Id} restarted`); showAlert('success', response.message); sendCommand('listContainers'); } catch (err) { showAlert('danger', err.message || 'Failed to restart container.'); } finally { hideStatusIndicator(); } break; } case 'kill': { hide(); showStatusIndicator(`Killing container "${name}"...`); try { const response = await sendCommand('killContainer', { id: container.Id }); if (response?.success) { showAlert('success', response.message || 'Container killed'); sendCommand('listContainers'); } } catch (err) { showAlert('danger', err.message || 'Failed to kill container.'); } finally { hideStatusIndicator(); } break; } case 'pause': { hide(); showStatusIndicator(`Pausing container "${name}"...`); sendCommand('pauseContainer', { id: container.Id }); try { const response = await waitForPeerResponse(`Container ${container.Id} paused`); showAlert('success', response.message); sendCommand('listContainers'); } catch (err) { showAlert('danger', err.message || 'Failed to pause container.'); } finally { hideStatusIndicator(); } break; } case 'processes': { hide(); showContainerDetails(container, { tab: 'processes-tab' }); break; } case 'inspect': { hide(); showContainerDetails(container); break; } case 'duplicate': { hide(); openDuplicateModal(container); break; } case 'rename': { hide(); // Prefer dedicated rename modal when present const renameModalEl = document.getElementById('renameContainerModal'); const input = document.getElementById('new-container-name'); if (renameModalEl && input && typeof bootstrap !== 'undefined') { input.value = name; input.dataset.containerId = container.Id; bootstrap.Modal.getOrCreateInstance(renameModalEl).show(); const confirmBtn = document.getElementById('confirm-rename-btn'); if (confirmBtn) { confirmBtn.onclick = async () => { const newName = input.value.trim(); if (!newName) { showAlert('danger', 'Container name cannot be empty'); return; } if (newName === name) { bootstrap.Modal.getInstance(renameModalEl)?.hide(); return; } sendCommand('renameContainer', { id: container.Id, name: newName }); bootstrap.Modal.getInstance(renameModalEl)?.hide(); showStatusIndicator(`Renaming to "${newName}"...`); try { const response = await waitForPeerResponse(`Container renamed to "${newName}"`); showAlert('success', response.message || `Renamed to ${newName}`); sendCommand('listContainers'); } catch (err) { showAlert('danger', err.message || 'Rename failed'); } finally { hideStatusIndicator(); } }; } } else { showAlert('info', 'Rename is unavailable in this build.'); } break; } case 'recreate': { hide(); if (typeof recreateContainerAction === 'function') recreateContainerAction(container); break; } case 'resources': { hide(); window.peardockOps?.openResourceEditor?.(container.Id, { name }); break; } case 'flatten': { hide(); if (typeof openContainerFlattenerModal === 'function') { openContainerFlattenerModal(container); } else { showAlert('info', 'Container Flattener is unavailable in this build.'); } break; } case 'remove': { hide(); const deleteModalEl = document.getElementById('deleteModal'); if (deleteModalEl && typeof bootstrap !== 'undefined') { const deleteModal = bootstrap.Modal.getOrCreateInstance(deleteModalEl); deleteModal.show(); const confirmDeleteBtn = document.getElementById('confirm-delete-btn'); if (confirmDeleteBtn) { confirmDeleteBtn.onclick = async () => { deleteModal.hide(); if (typeof closeAllModals === 'function') closeAllModals(); notificationManager?.add?.('info', `Deleting container "${name}"...`, { autoDismiss: false, }); showStatusIndicator(`Deleting container "${name}"...`); if (window.openTerminals?.[container.Id]) { window.openTerminals[container.Id].forEach((terminalId) => { try { cleanUpTerminal(terminalId); } catch { // ignore } }); delete window.openTerminals[container.Id]; } try { const response = await manager.request(Methods.removeContainer, { id: container.Id, force: true, }); showAlert('success', response?.message || 'Container removed'); sendCommand('listContainers'); } catch (err) { presentError(err, Methods.removeContainer, { showAlert }); } finally { hideStatusIndicator(); } }; } } break; } default: break; } } catch (err) { showAlert('danger', err.message || 'Action failed'); hideStatusIndicator(); } }); } // Bind modal once DOM is ready (module scripts often load after DOMContentLoaded) if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bindContainerActionsModalOnce); } else { bindContainerActionsModalOnce(); } /** * In-place DOM reconcile — never clears tbody (no blank frame / disappear flash). * Only removes rows for containers that are truly gone; patches the rest. * Reorders only when the id sequence actually changed. */ function reconcileContainerRows(listElement, containers) { if (!listElement) return; // Close any open more-actions UI before DOM moves closeContainerActionMenus(); // Drop skeleton / empty-state rows when we have real data if (containers.length) { for (const tr of [...listElement.querySelectorAll('tr.skeleton-row, tr:not([data-container-id])')]) { tr.remove(); } } const byId = new Map(); for (const tr of listElement.querySelectorAll('tr[data-container-id]')) { byId.set(tr.dataset.containerId, tr); } const nextIds = new Set(containers.map((c) => c.Id)); for (const [id, tr] of byId) { if (!nextIds.has(id)) { tr.remove(); byId.delete(id); // smoothedStats / inspectIp cleared by containerStore when removal is confirmed } } // Patch or create without touching order first for (const container of containers) { let row = byId.get(container.Id); if (row) { patchContainerRow(row, container); } else { row = buildContainerRow(container); byId.set(container.Id, row); // Append new rows; order pass below places them correctly listElement.appendChild(row); } } // Reorder only when needed (avoids thrashing insertBefore every refresh) const orderFp = containers.map((c) => c.Id).join('\u0001'); if (listElement.dataset.orderFp === orderFp) return; listElement.dataset.orderFp = orderFp; let prev = null; for (const container of containers) { const row = byId.get(container.Id); if (!row) continue; if (prev == null) { if (listElement.firstChild !== row) { listElement.insertBefore(row, listElement.firstChild); } } else if (prev.nextSibling !== row) { listElement.insertBefore(row, prev.nextSibling); } prev = row; } } function paintVirtualContainers() { // Prefer full in-place reconcile — virtualization replaceChildren caused flash. // Only window when list is huge AND we already have stable rows. const listElement = domCache.containerList || containerList; if (!listElement) return; // Don't rebuild DOM while a row actions menu is open (would kill menu / overlap rows) if (containerVirt.menuOpen) return; if (listElement.querySelector('.dropdown-menu.show, .dropdown.show')) { containerVirt.menuOpen = true; return; } const rows = containerVirt.rows; if (!rows.length) return; // For stability, always in-place reconcile up to 250 rows (no pad/window flash) if (rows.length <= 250) { reconcileContainerRows(listElement, rows); 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 windowRows = rows.slice(start, end); // Keep non-visible rows in DOM but collapsed height? Simpler: full reconcile for correctness // Huge lists still patch in place for the full set (browser handles 500 tr fine if not recreated) reconcileContainerRows(listElement, rows); } 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', () => { // Hide menus while scrolling so they never freeze mid-viewport closeContainerActionMenus(listElement); if (ticking) return; ticking = true; requestAnimationFrame(() => { ticking = false; if (containerVirt.rows.length > 80) paintVirtualContainers(); }); }, { passive: true } ); containerVirt.bound = true; } /** * Paint the containers table from an already-merged snapshot. * Does not own the source of truth — applyContainerSnapshot does. * Only paints data for the active selected server. */ function renderContainers(containers, topicId) { const listElement = domCache.containerList || containerList; if (!listElement) return; const activeId = manager.active?.id || ''; let resolvedTopic = topicId || containerVirt.topicId || containerStore.topicId || activeId || ''; // Refuse to paint another server's snapshot into the active table if ( resolvedTopic && activeId && !topicIdsMatch(resolvedTopic, activeId) ) { return; } // Store is for a different host (stale after switch race) — do not paint it if ( containerStore.topicId && activeId && !topicIdsMatch(containerStore.topicId, activeId) ) { return; } const hasRows = Boolean(listElement.querySelector('tr[data-container-id]')); if (!Array.isArray(containers)) return; // Prefer store as source of truth when available (filters re-render pass the same array) const source = containerStore.byId.size > 0 && (!containerStore.topicId || !activeId || topicIdsMatch(containerStore.topicId, activeId)) ? [...containerStore.byId.values()] : containers; // STABILITY: never wipe a populated table for empty data if (source.length === 0) { if (hasRows || containerFilterState.allContainers.length > 0 || containerStore.byId.size > 0) { return; } listElement.innerHTML = emptyTableRow( 9, 'No containers yet', 'Deploy a container from the Deploy view.' ); delete listElement.dataset.structFp; delete listElement.dataset.orderFp; containerVirt.rows = []; return; } if (resolvedTopic) containerVirt.topicId = resolvedTopic; const filteredContainers = filterAndSortContainers(source); if (!filteredContainers.length) { // Filters exclude everything — only swap empty state when filters are active const filtersActive = Boolean(containerFilterState.search) || containerFilterState.status !== 'all'; if (!filtersActive && hasRows) { // Should not happen; keep existing rows rather than flash empty return; } const emptyFp = listFp(['filtered-empty', containerFilterState.search, containerFilterState.status]); if (listElement.dataset.structFp === emptyFp) return; listElement.dataset.structFp = emptyFp; listElement.innerHTML = emptyTableRow( 9, 'No matching containers', 'Clear filters or search to see more.' ); delete listElement.dataset.orderFp; containerVirt.rows = []; return; } containerVirt.rows = filteredContainers; bindContainerVirtualScroll(); listElement.dataset.hasData = '1'; // Structural fingerprint — exclude volatile IP (filled async / event vs list races) const structFp = listFp( filteredContainers.map( (c) => `${c.Id}|${c.State}|${c.Image}|${(c.Names && c.Names[0]) || ''}` ) ); // Skip DOM work when identity/state/image/name unchanged (auto-refresh) if (listElement.dataset.structFp === structFp && hasRows) { // Still patch IP cells if we gained better addresses without structural change for (const c of filteredContainers) { if (!isUsefulIp(c.ipAddress)) continue; const row = listElement.querySelector(`tr[data-container-id="${c.Id}"]`); if (!row) continue; const ipEl = row.querySelector('.ip-address'); if (ipEl && ipEl.textContent !== c.ipAddress) ipEl.textContent = c.ipAddress; } return; } listElement.dataset.structFp = structFp; // In-place patch only — no tbody clear, no replaceChildren reconcileContainerRows(listElement, filteredContainers); applyRoleUI(); // Role UI must not leave Start enabled on running rows — re-apply lifecycle state refreshAllContainerRowActions(); // Digest check (cached server-side; cheap when warm) scheduleImageUpdateCheck({ force: false }); // Ensure first Containers-tab open for this peer always kicks a check // (schedule above is skipped when currentView was not containers when the timer fired) if (currentView === 'containers') { ensureInitialImageUpdateCheck(); } } 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'); const cname = (container.Names?.[0] || '').replace(/^\//, '') || container.Id.slice(0, 12); /** @param {string} action */ async function runRowContainerAction(action) { if (typeof window.peardockOps?.containerActionJob === 'function') { try { await window.peardockOps.containerActionJob({ action, id: container.Id, name: cname, force: true, }); sendCommand('listContainers'); startStatsInterval(); } catch (error) { if (!error?.viaJob) { showAlert('danger', error.message || `Failed to ${action} container.`); } } return; } // Legacy fallback const methodMap = { start: 'startContainer', stop: 'stopContainer', restart: 'restartContainer', kill: 'killContainer', pause: 'pauseContainer', resume: 'unpauseContainer', }; const method = methodMap[action]; if (!method) return; showStatusIndicator(`${action} container "${cname}"…`); sendCommand(method, { id: container.Id }); try { const response = await waitForPeerResponse(`Container ${container.Id}`); showAlert('success', response.message); sendCommand('listContainers'); startStatsInterval(); } catch (error) { showAlert('danger', error.message || `Failed to ${action} container.`); } finally { hideStatusIndicator(); } } // Primary row actions — honor disabled (state + role) if (startBtn) { startBtn.addEventListener('click', () => { if (startBtn.disabled) return; runRowContainerAction('start'); }); } if (stopBtn) { stopBtn.addEventListener('click', () => { if (stopBtn.disabled) return; runRowContainerAction('stop'); }); } // Restart / kill / pause / etc. live in the more-actions modal when not on the row if (restartBtn) { restartBtn.addEventListener('click', async () => { if (restartBtn.disabled) return; if (typeof window.peardockOps?.containerActionJob === 'function') { await runRowContainerAction('restart'); return; } 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 () => { if (killBtn.disabled) return; 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', () => { if (topBtn.disabled) return; showContainerDetails(container, { tab: 'processes-tab' }); }); } // Pause Button if (pauseBtn) { pauseBtn.addEventListener('click', async () => { if (pauseBtn.disabled) return; 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'); if (logsBtn) 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 (optional — often only on more-actions modal) if (removeBtn) 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(); }); try { const response = await manager.request(Methods.removeContainer, { id: container.Id, force: true, }); console.log('[DEBUG] Remove container response:', response); notificationManager.add('success', `Container "${containerName}" deleted successfully`); showAlert('success', response?.message || `Container "${containerName}" removed`); sendCommand('listContainers'); } catch (error) { console.error('[ERROR] Failed to delete container:', error.message); notificationManager.add('danger', `Failed to delete container "${containerName}"`); presentError(error, Methods.removeContainer, { showAlert }); } finally { console.log('[DEBUG] Hiding status indicator in removeBtn finally block'); hideStatusIndicator(); } }; }); if (terminalBtn) { terminalBtn.addEventListener('click', () => { if (terminalBtn.disabled) return; 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 findContainerRowById(containerId) { const list = domCache.containerList || containerList; if (!list || !containerId) return null; const id = String(containerId); // Exact attribute match (escape for CSS selectors when available) try { const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'); const row = list.querySelector(`tr[data-container-id="${esc}"]`); if (row) return row; } catch { // fall through to scan } // Prefix match (short ids / truncated attrs) const short = id.slice(0, 12); for (const tr of list.querySelectorAll('tr[data-container-id]')) { const rid = tr.dataset.containerId || ''; if (rid === id || rid.startsWith(short) || id.startsWith(rid.slice(0, 12))) { return tr; } } return null; } function updateContainerStats(stats) { if (!stats || !stats.id) return; // Coerce numeric fields (encodings may deliver strings) const cpu = Number(stats.cpu); const memory = Number(stats.memory); if (!Number.isFinite(cpu) || !Number.isFinite(memory)) return; const normalized = { ...stats, id: stats.id, cpu, memory, memoryLimit: Number(stats.memoryLimit) || smoothedStats[stats.id]?.memoryLimit || 0, ip: stats.ip, }; const row = findContainerRowById(normalized.id); // Preserve IP from row / prior sample so we never flash "No IP Assigned" const existingIp = row?.querySelector('.ip-address')?.textContent || smoothedStats[normalized.id]?.ip || null; if (!normalized.ip || normalized.ip === 'No IP Assigned') { if (existingIp && existingIp !== 'No IP Assigned') normalized.ip = existingIp; } const smoothed = smoothStats(normalized.id, normalized); // Keep limit for bar scaling if (normalized.memoryLimit) smoothed.memoryLimit = normalized.memoryLimit; if (row) updateStatsUI(row, smoothed); // Detail pane (only when this container is open) if ( currentView === 'container-details' && currentContainerDetails && currentContainerDetails.Id === stats.id ) { const cpuEl = document.getElementById('detail-cpu'); const memoryEl = document.getElementById('detail-memory'); if (cpuEl) { const t = formatCpuDisplay(smoothed.cpu); if (cpuEl.textContent !== t) cpuEl.textContent = t; } if (memoryEl) { const t = formatMemDisplay(smoothed.memory); if (memoryEl.textContent !== t) memoryEl.textContent = t; } } } function flushPendingStats() { statsFlushRaf = 0; if (!pendingStatsMap.size) return; const batch = [...pendingStatsMap.values()]; pendingStatsMap.clear(); for (const { row, stats } of batch) { if (row?.isConnected) applyStatsToRow(row, stats); } } function updateStatsUI(row, stats) { if (!row?.dataset?.containerId) return; pendingStatsMap.set(row.dataset.containerId, { row, stats }); if (!statsFlushRaf) { statsFlushRaf = requestAnimationFrame(flushPendingStats); } } // Function to open the Duplicate Modal with container configurations async function openDuplicateModal(container) { if (!container?.Id) return; console.log(`[INFO] Opening Duplicate Modal for container: ${container.Id}`); showStatusIndicator('Fetching container configuration...'); try { let config = null; if (manager.active?.connected) { const res = await manager.request(Methods.inspectContainer, { id: container.Id }); config = res?.data || res?.config || res; } else { // Offline fallback via push callback path config = await new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error('Inspect timed out')), 20000); window.inspectContainerCallback = (cfg) => { clearTimeout(t); window.inspectContainerCallback = null; resolve(cfg); }; sendCommand('inspectContainer', { id: container.Id }); }); } hideStatusIndicator(); if (!config || (!config.Config && !config.Id && !config.Name)) { showAlert('danger', 'Failed to retrieve container configuration.'); return; } const form = document.getElementById('duplicate-container-form'); if (form) form.reset(); // Existing names so we can suggest a free duplicate name const existingNames = new Set(); try { const list = containerFilterState?.allContainers || []; for (const c of list) { for (const n of c.Names || []) { existingNames.add(String(n).replace(/^\//, '')); } } } catch { // ignore } populateDuplicateForm(config, { existingNames, sourceContainerId: container.Id || config.Id || '', }); setupDeployResourceSliders(); const networkMode = document.getElementById('duplicate-network-mode'); const customNetworkContainer = document.getElementById('duplicate-custom-network-container'); if (networkMode && customNetworkContainer && networkMode.parentNode) { const newNetworkMode = networkMode.cloneNode(true); networkMode.parentNode.replaceChild(newNetworkMode, networkMode); const syncCustomNet = (value) => { const input = customNetworkContainer.querySelector('input'); if (value === 'host' || value === 'none') { customNetworkContainer.style.display = 'none'; return; } customNetworkContainer.style.display = 'block'; if (input) { input.placeholder = value === 'container' ? 'container-name' : 'user network name (optional)'; } }; newNetworkMode.addEventListener('change', (e) => syncCustomNet(e.target.value)); syncCustomNet(newNetworkMode.value); } if (duplicateModal) duplicateModal.show(); } catch (error) { hideStatusIndicator(); console.error('[ERROR] Failed to open duplicate modal:', error); presentError(error, 'inspectContainer', { showAlert }); } } window.openDuplicateModal = openDuplicateModal; // 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 setRestoringVisible(visible) { const el = document.getElementById('restoring-page'); if (!el) return; if (visible) { el.classList.remove('hidden', 'is-hidden'); el.removeAttribute('hidden'); el.style.display = ''; el.setAttribute('aria-busy', 'true'); } else { el.classList.add('hidden', 'is-hidden'); el.setAttribute('hidden', ''); el.style.display = 'none'; el.setAttribute('aria-busy', 'false'); } } /** * Boot-only: hide welcome without navigating into the workspace yet. */ function hideWelcomePageContentOnly() { setWelcomeVisible(false); } /** * Show restoring chrome while re-dialing saved peers (not the empty-state welcome). * @param {number} peerCount * @param {string|null} [preferredId] */ /** * @param {number} peerCount * @param {string|null|undefined} preferredId * @param {string|null|undefined} [preferredAlias] */ function showRestoringPage(peerCount, preferredId, preferredAlias) { setWelcomeVisible(false); setRestoringVisible(true); document.querySelectorAll('.view').forEach((view) => { view.classList.add('hidden'); }); if (connectionTitle) { connectionTitle.textContent = 'Restoring…'; } const total = Math.max(0, Number(peerCount) || 0); const page = document.getElementById('restoring-page'); if (page) { page.dataset.restoreTotal = String(total); page.dataset.restoreDone = '0'; } const preferLabel = (preferredAlias && String(preferredAlias).trim()) || (preferredId ? `Peer ${String(preferredId).slice(0, 6)}…` : ''); const prefer = preferLabel ? ` · last used ${preferLabel}` : ''; updateRestoringStatus( peerCount === 1 ? `Reconnecting to 1 saved server${prefer}` : `Reconnecting to ${peerCount} saved servers${prefer}`, { done: 0, total } ); } /** * @param {string} text * @param {{ done?: number, total?: number }} [progress] */ function updateRestoringStatus(text, progress = {}) { const el = document.getElementById('restoring-status'); if (el && text != null) el.textContent = text; const page = document.getElementById('restoring-page'); const total = progress.total != null ? Number(progress.total) : Number(page?.dataset?.restoreTotal || 0); const done = progress.done != null ? Number(progress.done) : Number(page?.dataset?.restoreDone || 0); if (page && progress.total != null) page.dataset.restoreTotal = String(total); if (page && progress.done != null) page.dataset.restoreDone = String(done); const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0; const bar = document.getElementById('restoring-progress-bar'); const track = document.getElementById('restoring-progress'); const count = document.getElementById('restoring-progress-count'); if (bar) bar.style.width = `${pct}%`; if (track) { track.setAttribute('aria-valuenow', String(pct)); track.setAttribute('aria-valuemax', '100'); } if (count) count.textContent = total > 0 ? `${done} of ${total}` : '0 of 0'; } function hideRestoringPage() { setRestoringVisible(false); } function hasActiveConnection() { return Boolean(manager.active?.connected || window.activePeer?.connected); } function showWelcomePage() { if (isBootRestoring) { // Never flash welcome over the restoring screen mid-boot return; } hideRestoringPage(); 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); hideRestoringPage(); // 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 restoring = document.getElementById('restoring-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'; const restoringVisible = restoring && !restoring.classList.contains('hidden') && !restoring.hasAttribute('hidden') && restoring.style.display !== 'none'; if (isBootRestoring || restoringVisible) { // Boot path — welcome may be hidden while restoring return; } 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', () => { void closeAllPopoutTerminals().catch(() => {}); manager.disconnectAll({ forget: false }).catch(() => {}); }); // Pear / Electron may fire pagehide when the window is destroyed window.addEventListener('pagehide', () => { void closeAllPopoutTerminals().catch(() => {}); 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); }