Files
peardock/app.js
T
snxraven d87e6fe486 Remove redundant Peers shortcut from sidebar footer
Keep only the Add peer quick action; the Peers nav item is enough.
2026-07-10 23:56:47 -04:00

8939 lines
313 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { manager, Methods } from './client/manager.js';
import {
loadPeers,
savePeers,
clearPeers,
getPeersCachePath,
} from './client/peerCache.js';
import { startTerminal, appendTerminalOutput } from './libs/terminal.js';
import { startDockerTerminal, cleanUpDockerTerminal } from './libs/dockerTerminal.js';
import {
fetchTemplates,
displayTemplateList,
openDeployModal,
collectDuplicateFormData,
populateDuplicateForm,
initTemplateDeployer,
filterTemplatesByQuery,
} from './libs/templateDeploy.js';
import { showContainerSkeleton, createProgressBar, updateProgressBar, removeProgressBar } from './libs/loadingStates.js';
import { closeAllModals, showStatusIndicator, hideStatusIndicator, updateStatusIndicator, showAlert } from './libs/uiUtils.js';
import notificationManager from './libs/notifications.js';
import { initOpsApp } from './ui/ops-app.js';
import { presentError, formatResponseError, isBackgroundMethod } from './client/errors.js';
import { warmSnapshot } from './client/snapshot.js';
import {
fetchMergedTemplates,
getTemplateListUrls,
clearMergedTemplateCache,
} from './client/templateLists.js';
import {
getTerminalCtor,
getFitAddonCtor,
defaultXtermOptions,
decodePayload,
createFitController,
safeFit,
} from './libs/xtermUtils.js';
import { createInputCoalescer } from './libs/termInput.js';
// Global RPC push / response routing
manager.on('message', (msg, conn) => {
handleRpcMessage(msg, conn);
});
manager.on('disconnect', (conn) => {
const topicId = conn?.id;
if (topicId && connections[topicId]) {
updateConnectionStatus(topicId, false);
connections[topicId].peer = null;
}
updateHealthBadge(null);
if (!hasActiveConnection()) {
if (containerList) containerList.innerHTML = '';
stopStatsInterval();
showWelcomePage();
}
});
manager.on('health', (info, conn) => {
if (!conn || conn === manager.active) {
updateHealthBadge(info, conn || manager.active);
}
});
manager.on('connect', (conn) => {
if (conn) {
updateHealthBadge(
{ latency: conn.latency, docker: conn.dockerHealth, status: conn.healthStatus },
conn
);
}
});
manager.on('reconnecting', ({ attempt, delayMs }) => {
updateStatusIndicator(`Reconnecting (attempt ${attempt}) in ${Math.round(delayMs / 1000)}s…`);
if (typeof showAlert === 'function') {
showAlert('warning', `Connection lost — reconnecting (attempt ${attempt})…`);
}
});
manager.on('reconnected', () => {
hideStatusIndicator();
if (typeof showAlert === 'function') showAlert('success', 'Reconnected to peardock server');
if (hasActiveConnection()) {
hideWelcomePage();
sendCommand(Methods.listContainers);
}
});
manager.on('reconnect-failed', () => {
hideStatusIndicator();
if (typeof showAlert === 'function') {
showAlert('danger', 'Could not reconnect after multiple attempts. Re-add the connection.');
}
updateHealthBadge(null);
});
// DOM Elements - Cache frequently accessed elements (will be initialized in DOMContentLoaded)
let containerList = null;
let connectionList = null;
let addConnectionForm = null;
let newConnectionTopic = null;
let connectionTitle = null;
let dashboard = null;
let welcomePage = null;
let sidebar = null;
let collapseSidebarBtn = null;
let alertContainer = null;
// Modal Elements (will be initialized in DOMContentLoaded)
let duplicateModalElement = null;
let duplicateModal = null;
let duplicateContainerForm = null;
// Notification tray initialization state
let notificationTrayInitialized = false;
// Global variables
const connections = {};
window.openTerminals = {};
let statsInterval = null;
// activePeer is always the manager's active PearDockConnection (RPC), never a raw stream
Object.defineProperty(window, 'activePeer', {
configurable: true,
enumerable: true,
get() {
return manager.active;
},
set(value) {
if (value?.id) manager.setActive(value.id);
else if (value == null && manager.active) {
// allow clear without dropping manager map entry
}
},
});
// Centralized volumes cache/store
const volumesStore = {
volumes: [],
lastUpdate: null,
loading: false,
listeners: new Set(), // Listeners that want to be notified when volumes update
// Get cached volumes
get() {
return this.volumes;
},
// Set volumes and notify listeners
set(volumes) {
this.volumes = Array.isArray(volumes) ? volumes : [];
this.lastUpdate = Date.now();
this.loading = false;
this.notifyListeners();
},
// Add a listener callback
subscribe(callback) {
this.listeners.add(callback);
// Immediately call with current data if available
if (this.volumes.length > 0) {
callback(this.volumes);
}
// Return unsubscribe function
return () => this.listeners.delete(callback);
},
// Notify all listeners
notifyListeners() {
this.listeners.forEach(callback => {
try {
callback(this.volumes);
} catch (error) {
console.error('[ERROR] Volume listener error:', error);
}
});
},
// Check if cache is stale (older than 30 seconds)
isStale() {
if (!this.lastUpdate) return true;
return Date.now() - this.lastUpdate > 30000;
},
// Set loading state
setLoading(loading) {
this.loading = loading;
},
// Check if currently loading
isLoading() {
return this.loading;
}
};
// Expose volumes store to window for use by other modules
window.volumesStore = volumesStore;
let lastStatsUpdate = Date.now();
function stopStatsInterval() {
if (statsInterval) {
clearInterval(statsInterval);
statsInterval = null;
console.log('[INFO] Stats interval stopped.');
}
}
// Utility functions are now imported from uiUtils.js
document.addEventListener('DOMContentLoaded', () => {
const dockerTerminalModal = document.getElementById('dockerTerminalModal');
if (dockerTerminalModal) {
dockerTerminalModal.addEventListener('hidden.bs.modal', () => {
console.log('[INFO] Modal fully closed. Performing additional cleanup.');
cleanUpDockerTerminal();
});
}
});
function startStatsInterval() {
// Guard: stop existing interval before starting a new one
stopStatsInterval();
// Only start if there's an active peer
if (!window.activePeer) {
console.warn('[WARN] No active peer; not starting stats interval.');
return;
}
// Increased interval to 500ms for better performance (was 100ms)
statsInterval = setInterval(() => {
if (window.activePeer) {
const now = Date.now();
if (now - lastStatsUpdate >= 500) { // Ensure at least 500ms between updates
lastStatsUpdate = now;
}
} else {
console.warn('[WARN] No active peer; skipping stats request.');
stopStatsInterval(); // Stop interval if peer is no longer active
}
}, 500); // Poll every 500ms for better performance (reduced from 100ms)
}
const smoothedStats = {}; // Container-specific smoothing storage
const historicalStats = {}; // Container-specific historical stats for charts
const MAX_HISTORY_POINTS = 60; // Keep last 60 data points (5 minutes at 5s intervals)
function smoothStats(containerId, newStats, smoothingFactor = 0.2) {
if (!smoothedStats[containerId]) {
smoothedStats[containerId] = { cpu: 0, memory: 0, ip: newStats.ip || 'No IP Assigned' };
}
smoothedStats[containerId].cpu =
smoothedStats[containerId].cpu * (1 - smoothingFactor) +
newStats.cpu * smoothingFactor;
smoothedStats[containerId].memory =
smoothedStats[containerId].memory * (1 - smoothingFactor) +
newStats.memory * smoothingFactor;
// Preserve the latest IP address
smoothedStats[containerId].ip = newStats.ip || smoothedStats[containerId].ip;
// Store historical data for charts
if (!historicalStats[containerId]) {
historicalStats[containerId] = {
timestamps: [],
cpu: [],
memory: []
};
}
const history = historicalStats[containerId];
const now = new Date();
history.timestamps.push(now);
history.cpu.push(smoothedStats[containerId].cpu);
history.memory.push(smoothedStats[containerId].memory);
// Keep only last MAX_HISTORY_POINTS
if (history.timestamps.length > MAX_HISTORY_POINTS) {
history.timestamps.shift();
history.cpu.shift();
history.memory.shift();
}
// Update charts if on container details view
if (currentView === 'container-details' && currentContainerDetails && currentContainerDetails.Id === containerId) {
updateStatsCharts(containerId);
}
return smoothedStats[containerId];
}
function refreshContainerStats() {
if (!window.activePeer) {
// Don't try to refresh if there's no active peer
return;
}
console.log('[INFO] Refreshing container stats...');
sendCommand('listContainers'); // Request an updated container list
startStatsInterval(); // Restart stats interval
}
/**
* Wait for a success RPC whose message contains the given fragment.
* Safe against success replies without a message (e.g. listVolumes).
* Always restores any previous handlePeerResponse.
*/
function waitForPeerResponse(expectedMessageFragment, timeout = 900000) {
console.log(`[DEBUG] Waiting for peer response with fragment: "${expectedMessageFragment}"`);
return new Promise((resolve, reject) => {
const previousHandler = window.handlePeerResponse;
let settled = false;
const fragment = String(expectedMessageFragment || '');
const restore = () => {
if (window.handlePeerResponse === onResponse) {
window.handlePeerResponse =
typeof previousHandler === 'function' ? previousHandler : null;
}
};
const settle = (fn, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
restore();
fn(value);
};
const onResponse = (response) => {
try {
const msg =
response && typeof response.message === 'string' ? response.message : '';
if (response && response.success && msg && msg.includes(fragment)) {
console.log(`[DEBUG] Expected response received: ${msg}`);
settle(resolve, response);
return;
}
// Forward unrelated messages so list/broadcast handlers still work
if (typeof previousHandler === 'function' && previousHandler !== onResponse) {
previousHandler(response);
}
} catch (err) {
console.warn('[WARN] waitForPeerResponse handler error:', err?.message || err);
}
};
window.handlePeerResponse = onResponse;
const timer = setTimeout(() => {
console.warn('[WARN] Timed out waiting for response');
settle(reject, new Error('Timed out waiting for peer response'));
}, timeout);
});
}
// Peer persistence: ~/.config/peardock/cache/peers.json (primary)
// localStorage is only a mirror / migration source via client/peerCache.js
function deleteCookie(name) {
try {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
} catch {
// ignore
}
}
/**
* Load saved peers from disk cache (migrates legacy localStorage on first run).
* @returns {Record<string, object>}
*/
function loadConnections() {
/** @type {Record<string, object>} */
let parsed = {};
try {
parsed = loadPeers() || {};
} catch (err) {
console.warn(`[WARN] Failed to load peer cache: ${err.message}`);
parsed = {};
}
// Merge manager list if it has extras
try {
const fromManager = manager.loadSaved?.() || [];
for (const entry of fromManager) {
if (!entry?.publicKeyHex) continue;
const id = entry.id || entry.publicKeyHex.slice(0, 12);
if (!parsed[id]) {
parsed[id] = {
publicKeyHex: entry.publicKeyHex,
alias: entry.alias || null,
inviteToken: entry.inviteToken || null,
};
}
}
} catch {
// manager may not be ready in tests
}
const result = {};
for (const topicId in parsed) {
const entry = parsed[topicId] || {};
const publicKeyHex = String(entry.publicKeyHex || entry.topicHex || entry.topic || '').toLowerCase();
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) continue;
const id = publicKeyHex.substring(0, 12);
result[id] = {
publicKeyHex,
topicHex: publicKeyHex,
alias: entry.alias || null,
inviteToken: entry.inviteToken || null,
peer: null,
connectedAt: null,
lastHealthCheck: null,
latency: null,
healthStatus: 'unknown',
};
}
return result;
}
/**
* Save configured peers to ~/.config/peardock/cache/peers.json
* Merges with the on-disk roster so a transient empty in-memory map cannot
* wipe saved peers (e.g. mid-boot or after socket teardown).
* @param {{ removeId?: string }} [opts]
*/
function saveConnections(opts = {}) {
/** @type {Record<string, object>} */
let serializableConnections = {};
try {
serializableConnections = { ...(loadPeers() || {}) };
} catch {
serializableConnections = {};
}
if (opts.removeId) {
delete serializableConnections[opts.removeId];
}
for (const topicId in connections) {
const { publicKeyHex, topicHex, alias, inviteToken } = connections[topicId];
const key = publicKeyHex || topicHex;
if (!key) continue;
serializableConnections[topicId] = {
publicKeyHex: key,
topicHex: key,
alias: alias || null,
inviteToken: inviteToken || null,
};
}
// Refuse accidental wipe of a non-empty cache with empty memory (except explicit remove of last peer)
const nextCount = Object.keys(serializableConnections).length;
if (nextCount === 0 && !opts.removeId) {
try {
const existing = loadPeers() || {};
if (Object.keys(existing).length > 0) {
console.warn(
'[WARN] saveConnections: refusing to wipe',
Object.keys(existing).length,
'saved peer(s) with empty in-memory state'
);
return;
}
} catch {
// proceed
}
}
try {
const result = savePeers(serializableConnections);
console.log(
'[INFO] Saved',
result.count,
'peer(s) to',
result.path || getPeersCachePath(),
result.ok ? '' : '(file write failed; localStorage mirror may still apply)'
);
} catch (err) {
console.error(`[ERROR] Failed to save peer cache: ${err.message}`);
}
}
/**
* Prefer alias; never put the raw 12-char id in the primary label when a friendlier
* short label works — full key stays on title/tooltip so the action buttons fit.
* @param {{ alias?: string|null, publicKeyHex?: string }} conn
* @param {string} topicId
*/
function peerDisplayName(conn, topicId) {
const alias = (conn?.alias || '').trim();
if (alias) return alias;
const key = String(conn?.publicKeyHex || topicId || '');
if (key.length >= 12) return `Peer ${key.slice(0, 6)}…`;
return topicId || 'Peer';
}
/**
* Secondary meta line (latency / status) — never dump the full peer id here.
* @param {{ latency?: number|null, healthStatus?: string }} conn
*/
function peerMetaText(conn) {
if (conn?.latency != null && Number.isFinite(conn.latency)) {
return `${conn.latency}ms`;
}
if (conn?.healthStatus === 'connecting') return 'Connecting…';
if (conn?.healthStatus === 'error') return 'Offline';
return '';
}
/**
* Build a peer list row that truncates labels and never overflows action buttons.
* @param {string} topicId
* @param {object} conn
* @returns {HTMLLIElement}
*/
function createPeerListItem(topicId, conn) {
const connectionItem = document.createElement('li');
connectionItem.className = 'list-group-item';
connectionItem.dataset.topicId = topicId;
const displayName = peerDisplayName(conn, topicId);
const meta = peerMetaText(conn);
const fullKey = conn?.publicKeyHex || topicId;
const connected = Boolean(conn?.peer?.connected || conn?.healthStatus === 'healthy');
connectionItem.title = conn?.alias
? `${conn.alias} · ${fullKey}`
: String(fullKey);
connectionItem.innerHTML = `
<div class="connection-item">
<div class="connection-info">
<div class="connection-title-row">
<span class="connection-status ${connected ? 'status-connected' : 'status-disconnected'}"></span>
<span class="connection-name">${escapeHtmlLite(displayName)}</span>
</div>
<small class="connection-meta text-muted">${escapeHtmlLite(meta)}</small>
</div>
<div class="connection-actions btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary docker-terminal-btn" title="Docker CLI" aria-label="Docker CLI">
<i class="fas fa-terminal"></i>
</button>
<button type="button" class="btn btn-outline-danger disconnect-btn" title="Remove peer" aria-label="Remove peer">
<i class="fas fa-xmark"></i>
</button>
</div>
</div>`;
connectionItem.querySelector('.connection-info')?.addEventListener('click', () => switchConnection(topicId));
connectionItem.querySelector('.disconnect-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
disconnectConnection(topicId, connectionItem);
});
connectionItem.querySelector('.docker-terminal-btn')?.addEventListener('click', (event) => {
event.stopPropagation();
const connection = connections[topicId];
if (connection?.peer) {
startDockerTerminal(topicId, connection.peer);
const dockerTerminalModal = document.getElementById('dockerTerminalModal');
if (dockerTerminalModal) {
new bootstrap.Modal(dockerTerminalModal).show();
}
} else {
console.warn(`[WARNING] No active connection for ${topicId}`);
}
});
return connectionItem;
}
/** Clear all saved peers (Settings action). */
function resetAllPeers() {
console.log('[INFO] Resetting connections and clearing peer cache at', getPeersCachePath());
Object.keys(connections).forEach((topicId) => {
disconnectConnection(topicId);
});
deleteCookie('connections');
try {
clearPeers();
} catch (err) {
console.warn(`[WARN] Failed to clear peer cache: ${err.message}`);
}
if (typeof connectionList !== 'undefined' && connectionList) {
connectionList.innerHTML = '';
}
updatePeersViewChrome();
showWelcomePage();
showAlert('success', 'All saved peers removed');
}
/**
* Peers tab chrome: empty state + count (list is #connection-list).
*/
function updatePeersViewChrome() {
const list = document.getElementById('connection-list');
const empty = document.getElementById('peers-view-empty');
const countEl = document.getElementById('peers-view-count');
const n = list ? list.querySelectorAll('.list-group-item').length : 0;
if (countEl) {
countEl.textContent = n === 1 ? '1 peer' : `${n} peers`;
}
if (empty) {
empty.classList.toggle('d-none', n > 0);
}
if (list) {
list.classList.toggle('d-none', n === 0);
}
}
function loadPeersView() {
updatePeersViewChrome();
// Re-sync active highlight from manager
const activeId = manager.active?.id;
if (activeId) {
document.querySelectorAll('#connection-list .list-group-item').forEach((item) => {
const tid = item.dataset.topicId || '';
item.classList.toggle(
'active',
tid === activeId || tid.startsWith(activeId) || activeId.startsWith(tid)
);
});
}
}
window.loadPeersView = loadPeersView;
// Initialize container filtering
function initContainerFiltering() {
const searchInput = document.getElementById('container-search');
const statusFilter = document.getElementById('container-status-filter');
const sortSelect = document.getElementById('container-sort');
const clearBtn = document.getElementById('clear-filters');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
containerFilterState.search = e.target.value;
if (containerFilterState.allContainers.length > 0) {
renderContainers(containerFilterState.allContainers, Object.keys(connections)[0] || '');
}
});
}
if (statusFilter) {
statusFilter.addEventListener('change', (e) => {
containerFilterState.status = e.target.value;
if (containerFilterState.allContainers.length > 0) {
renderContainers(containerFilterState.allContainers, Object.keys(connections)[0] || '');
}
});
}
if (sortSelect) {
sortSelect.addEventListener('change', (e) => {
containerFilterState.sort = e.target.value;
if (containerFilterState.allContainers.length > 0) {
renderContainers(containerFilterState.allContainers, Object.keys(connections)[0] || '');
}
});
}
if (clearBtn) {
clearBtn.addEventListener('click', () => {
containerFilterState.search = '';
containerFilterState.status = 'all';
containerFilterState.sort = 'name-asc';
if (searchInput) searchInput.value = '';
if (statusFilter) statusFilter.value = 'all';
if (sortSelect) sortSelect.value = 'name-asc';
if (containerFilterState.allContainers.length > 0) {
renderContainers(containerFilterState.allContainers, Object.keys(connections)[0] || '');
}
});
}
}
// Initialize the app
console.log('[INFO] Client app initialized');
// Utility functions are now imported from uiUtils.js
/**
* Centralized error handler for server responses
* Parses errors, formats user-friendly messages, and sends to notification center
* @param {Object} response - Server response object
* @returns {string|null} - Formatted error message or null if no error
*/
/**
* Handle error field on RPC responses / manager.send failures.
* Suppresses protomux "REQUEST_ERROR: Request failed" spam and background poll noise.
* @returns {string|null} user-facing message if shown
*/
function handleErrorResponse(response) {
if (!response || !response.error) {
return null;
}
// Background / silent errors: log only (never tray spam)
if (response.silent || isBackgroundMethod(response.method)) {
console.warn(
'[RPC quiet]',
response.method || 'request',
typeof response.error === 'string' ? response.error : response.error?.message || response.error
);
return null;
}
const formatted = formatResponseError(response.error, response.method);
if (!formatted || formatted.silent) {
console.warn('[RPC]', response.method || 'request', response.error);
return null;
}
let formattedMessage = formatted.message;
let errorType = formatted.severity === 'warning' ? 'warning' : 'danger';
// Domain-specific polish for Docker engine errors
const lower = formattedMessage.toLowerCase();
if (lower.includes('volume') && lower.includes('in use')) {
formattedMessage = 'Cannot remove volume: it is still in use by a container';
} else if (lower.includes('permission denied') || lower.includes('permission_denied')) {
errorType = 'warning';
}
formattedMessage = formattedMessage
.replace(/^REQUEST_ERROR:\s*/i, '')
.replace(/\(HTTP code \d+\)\s*/g, '')
.replace(/\s+/g, ' ')
.trim();
// Keep enough detail for operators to act (ports, image names, how-to-fix)
if (formattedMessage.length > 900) {
formattedMessage = formattedMessage.substring(0, 897) + '...';
}
// Skip useless generic leftover
if (/^request failed$/i.test(formattedMessage) || /^request_error:\s*request failed$/i.test(formattedMessage)) {
console.warn('[RPC] suppressed generic Request failed', response.method || '');
return null;
}
// Single channel only: showAlert (no toast by default; job tray if active)
if (typeof showAlert === 'function') {
showAlert(errorType, formattedMessage, {
autoDismiss: true,
duration: errorType === 'warning' ? 6000 : 8000,
key: `rpc:${response.method || ''}:${formattedMessage.slice(0, 80)}`,
toast: false,
});
}
// Return formatted message for use with showAlert() if needed
return formattedMessage;
}
// Navigation Management
let currentView = 'dashboard';
function initNavigation() {
const navLinks = document.querySelectorAll('.nav-link');
navLinks.forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
const view = link.dataset.view;
if (view) {
navigateToView(view);
}
});
});
// Only enter workspace if already connected; otherwise stay on welcome
if (hasActiveConnection()) {
hideWelcomePage();
} else {
showWelcomePage();
}
}
function navigateToView(viewName, opts = {}) {
const { skipWelcomeGate = false, replace = false, fromHistory = false } = opts;
// Peers list is always reachable (manage saved keys even when offline)
const allowOffline =
viewName === 'peers' || viewName === 'settings' || skipWelcomeGate;
// Without an active peer, keep the welcome card and block workspace views
if (!allowOffline && !hasActiveConnection()) {
showWelcomePage();
return;
}
// Connected: never leave welcome stacked over the workspace
setWelcomeVisible(false);
document.querySelectorAll('.view').forEach((view) => {
view.classList.add('hidden');
});
const targetView = document.getElementById(`${viewName}-view`);
if (targetView) {
targetView.classList.remove('hidden');
}
if (viewName !== 'container-details') {
document.querySelectorAll('.nav-link').forEach((link) => {
link.classList.remove('active');
if (link.dataset.view === viewName) {
link.classList.add('active');
}
});
}
currentView = viewName;
// Expose for auto-refresh poller
if (typeof window !== 'undefined') window.currentView = viewName;
if (viewName === 'dashboard') {
loadDashboard();
} else if (viewName === 'containers') {
if (hasActiveConnection()) {
showListSkeleton('container-list', 6);
sendCommand('listContainers');
}
} else if (viewName === 'images') {
loadImages();
} else if (viewName === 'networks') {
loadNetworks();
} else if (viewName === 'volumes') {
loadVolumes();
} else if (viewName === 'stacks') {
loadStacks();
} else if (viewName === 'swarm') {
window.peardockOps?.loadSwarmView?.();
} else if (viewName === 'deploy') {
loadDeployView();
} else if (viewName === 'fleet') {
loadFleetView();
} else if (viewName === 'peers') {
loadPeersView();
} else if (viewName === 'access') {
loadAccessView();
} else if (viewName === 'events') {
window.peardockOps?.loadEventsView?.();
} else if (viewName === 'host') {
window.peardockOps?.loadHostView?.();
} else if (viewName === 'tunnels') {
window.peardockOps?.loadTunnelsView?.();
} else if (viewName === 'settings') {
window.peardockOps?.loadSettingsView?.();
}
// Browser history / deep-links (skip when handling popstate)
if (!fromHistory) {
try {
const hash = `#/${viewName}`;
if (location.hash !== hash) {
if (replace) history.replaceState({ view: viewName }, '', hash);
else history.pushState({ view: viewName }, '', hash);
}
} catch {
// ignore
}
}
}
/** Hash / back-forward navigation */
function initHashRouting() {
const fromHash = () => {
const hashView = (location.hash || '').replace(/^#\/?/, '').split('?')[0];
if (hashView && hasActiveConnection()) {
navigateToView(hashView, { fromHistory: true, replace: true });
}
};
window.addEventListener('popstate', () => {
const view =
(history.state && history.state.view) ||
(location.hash || '').replace(/^#\/?/, '').split('?')[0];
if (view && hasActiveConnection()) {
navigateToView(view, { fromHistory: true });
}
});
window.addEventListener('hashchange', fromHash);
}
/**
* Lightweight skeleton placeholder for list tables / tbody.
* @param {string} listId - element id of tbody or list host
* @param {number} [rows=5]
*/
function showListSkeleton(listId, rows = 5) {
const host = document.getElementById(listId);
if (!host) return;
const isTableBody = host.tagName === 'TBODY';
if (isTableBody) {
const cols = host.closest('table')?.querySelectorAll('thead th')?.length || 5;
host.innerHTML = Array.from({ length: rows }, () => {
const cells = Array.from({ length: cols }, () => '<td><div class="skeleton skeleton-text"></div></td>').join('');
return `<tr class="skeleton-row">${cells}</tr>`;
}).join('');
} else {
host.innerHTML = Array.from({ length: rows }, () => '<div class="pd-skeleton-row"></div>').join('');
}
}
/**
* Empty state HTML for tables (single full-width row).
*/
function emptyTableRow(colspan, title, body) {
return `<tr><td colspan="${colspan}" class="text-center py-4">
<div class="pd-empty pd-empty--inline">
<div class="pd-empty-icon"><i class="fas fa-inbox"></i></div>
<div class="pd-empty-title">${escapeHtmlLite(title)}</div>
${body ? `<div class="pd-empty-body text-muted small">${escapeHtmlLite(body)}</div>` : ''}
</div>
</td></tr>`;
}
/** Multi-host fleet dashboard — all live connections side-by-side */
async function loadFleetView() {
const host = document.getElementById('fleet-cards');
if (!host) return;
const list = manager.list();
const envMap =
typeof window.peardockOps?.getPeerEnvironments === 'function'
? window.peardockOps.getPeerEnvironments()
: {};
let envFilter = document.getElementById('fleet-env-filter')?.value || '';
try {
const s = JSON.parse(localStorage.getItem('peardock.settings.v1') || '{}');
if (!envFilter && s.fleetEnvFilter) {
envFilter = s.fleetEnvFilter;
const sel = document.getElementById('fleet-env-filter');
if (sel) sel.value = envFilter;
}
} catch {
// ignore
}
const filtered = envFilter
? list.filter((conn) => {
const id = conn.id || conn.publicKeyHex?.slice(0, 12);
return (envMap[id] || '') === envFilter;
})
: list;
if (!list.length) {
host.innerHTML = '<div class="col-12 text-muted">No connected peers. Add a public key in the sidebar.</div>';
return;
}
if (!filtered.length) {
host.innerHTML = `<div class="col-12 text-muted">No peers tagged <strong>${escapeHtmlLite(envFilter)}</strong>. Set environment on a peer card.</div>`;
return;
}
host.innerHTML = filtered
.map((conn) => {
const id = conn.id || conn.publicKeyHex?.slice(0, 12);
const active = manager.active === conn;
const lat = conn.latency != null ? `${conn.latency} ms` : '—';
const dockerOk = conn.dockerHealth?.ok;
const role = conn.role || '—';
const env = envMap[id] || '';
return `
<div class="col-md-6 col-xl-4">
<div class="card bg-dark border-${active ? 'success' : 'secondary'} h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">${escapeHtmlLite(conn.alias || id)}</h5>
<span class="badge bg-${conn.connected ? 'success' : 'secondary'}">${conn.connected ? 'online' : 'offline'}</span>
</div>
<p class="small text-muted mb-2 font-monospace">${escapeHtmlLite((conn.publicKeyHex || '').slice(0, 24))}…</p>
<ul class="list-unstyled small mb-2">
<li><i class="fas fa-gauge-high me-1"></i> Latency: ${lat}</li>
<li><i class="fab fa-docker me-1"></i> Docker: ${dockerOk === true ? 'ok' : dockerOk === false ? 'down' : '—'}</li>
<li><i class="fas fa-user-shield me-1"></i> Role: ${escapeHtmlLite(role)}</li>
<li><i class="fas fa-heart me-1"></i> ${escapeHtmlLite(conn.healthStatus || 'unknown')}</li>
</ul>
<div class="mb-2">
<label class="form-label small text-muted mb-0">Environment</label>
<select class="form-select form-select-sm bg-dark text-white fleet-env-select" data-id="${escapeHtmlLite(id)}">
<option value="">(none)</option>
<option value="prod" ${env === 'prod' ? 'selected' : ''}>prod</option>
<option value="staging" ${env === 'staging' ? 'selected' : ''}>staging</option>
<option value="dev" ${env === 'dev' ? 'selected' : ''}>dev</option>
<option value="lab" ${env === 'lab' ? 'selected' : ''}>lab</option>
</select>
</div>
<button class="btn btn-sm btn-outline-primary fleet-activate" data-id="${escapeHtmlLite(id)}" ${active ? 'disabled' : ''}>
${active ? 'Active' : 'Set active'}
</button>
</div>
</div>
</div>`;
})
.join('');
host.querySelectorAll('.fleet-activate').forEach((btn) => {
btn.addEventListener('click', () => {
manager.setActive(btn.dataset.id);
loadFleetView();
if (typeof showAlert === 'function') showAlert('success', `Active peer: ${btn.dataset.id}`);
});
});
host.querySelectorAll('.fleet-env-select').forEach((sel) => {
sel.addEventListener('change', () => {
const id = sel.getAttribute('data-id');
window.peardockOps?.setPeerEnvironment?.(id, sel.value);
if (typeof showAlert === 'function') {
showAlert('info', sel.value ? `Tagged ${id} as ${sel.value}` : `Cleared env tag on ${id}`, {
badge: false,
});
}
});
});
}
window.loadFleetView = loadFleetView;
async function loadAccessView() {
const peersEl = document.getElementById('access-peers-list');
const invitesEl = document.getElementById('access-invites-list');
const vaultEl = document.getElementById('access-vault-list');
// One-time Hub search wiring
if (!window.__accessHubWired) {
window.__accessHubWired = true;
document.getElementById('access-hub-search-btn')?.addEventListener('click', () => accessHubSearch());
document.getElementById('access-hub-term')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
accessHubSearch();
}
});
}
if (!manager.active?.connected) {
if (peersEl) peersEl.textContent = 'Not connected.';
return;
}
try {
const peersRes = await manager.request(Methods.listPeers, {});
if (peersEl) {
const live = peersRes?.live || [];
const known = peersRes?.peers || [];
const revoked = peersRes?.revoked || [];
let html = `<p class="mb-2">Allowlist enforce: <strong>${peersRes?.enforceAllowlist ? 'on' : 'off'}</strong></p>`;
html += '<h6 class="text-muted">Live sessions</h6><ul class="list-unstyled">';
for (const p of live) {
html += `<li class="mb-1 font-monospace">${p.peerId?.slice(0, 16)}… <span class="badge bg-secondary">${p.role}</span>
<button class="btn btn-xs btn-outline-danger btn-sm ms-1 access-revoke" data-id="${p.peerId}">Revoke</button></li>`;
}
html += '</ul>';
if (known.length) {
html += '<h6 class="text-muted mt-2">Registered peers</h6><ul class="list-unstyled">';
for (const p of known) {
html += `<li class="mb-1 font-monospace">${p.peerId?.slice(0, 16)}… <span class="badge bg-info">${p.role}</span>
<button class="btn btn-sm btn-outline-danger ms-1 access-revoke" data-id="${p.peerId}">Revoke</button></li>`;
}
html += '</ul>';
}
if (revoked.length) {
html += `<p class="text-warning mt-2">Revoked: ${revoked.map((r) => r.slice(0, 12)).join(', ')}</p>`;
}
peersEl.innerHTML = html;
peersEl.querySelectorAll('.access-revoke').forEach((btn) => {
btn.addEventListener('click', async () => {
const ok = window.peardockOps?.confirmDestructive
? await window.peardockOps.confirmDestructive('Revoke peer', 'Revoke this peer? They will be disconnected.')
: true;
if (!ok) return;
await manager.request(Methods.revokePeer, { peerId: btn.dataset.id });
if (typeof showAlert === 'function') showAlert('warning', 'Peer revoked');
loadAccessView();
});
});
applyRoleUI();
}
} catch (err) {
if (peersEl) peersEl.textContent = err.message || 'Failed to list peers';
}
try {
const inv = await manager.request(Methods.listInvites, {});
if (invitesEl) {
const data = inv?.data || [];
if (!data.length) invitesEl.innerHTML = '<span class="text-muted">No active invites</span>';
else {
invitesEl.innerHTML = data
.map(
(i) =>
`<div class="mb-2 font-monospace small"><code>${i.token}</code><br>role=${i.role} exp=${i.expiresAt} uses=${i.uses}/${i.maxUses}</div>`
)
.join('');
}
}
} catch {
if (invitesEl) invitesEl.textContent = '';
}
try {
const vault = await manager.request(Methods.listVaultCredentials, {});
if (vaultEl) {
const data = vault?.data || [];
if (!data.length) vaultEl.innerHTML = '<span class="text-muted">No stored credentials</span>';
else {
vaultEl.innerHTML = data
.map(
(c) =>
`<div class="d-flex justify-content-between align-items-center mb-2">
<span>${c.label || c.username} <span class="text-muted">@ ${c.serveraddress}</span></span>
<span>
<button class="btn btn-sm btn-outline-primary vault-use" data-id="${c.id}">Use</button>
<button class="btn btn-sm btn-outline-danger vault-del" data-id="${c.id}">Delete</button>
</span>
</div>`
)
.join('');
vaultEl.querySelectorAll('.vault-use').forEach((btn) => {
btn.addEventListener('click', async () => {
await manager.request(Methods.vaultUseCredential, { id: btn.dataset.id });
if (typeof showAlert === 'function') showAlert('success', 'Vault credential applied to session');
});
});
vaultEl.querySelectorAll('.vault-del').forEach((btn) => {
btn.addEventListener('click', async () => {
const ok = window.peardockOps?.confirmDestructive
? await window.peardockOps.confirmDestructive('Delete credential', 'Remove this vault credential?')
: true;
if (!ok) return;
await manager.request(Methods.vaultDeleteCredential, { id: btn.dataset.id });
loadAccessView();
});
});
}
}
} catch (err) {
if (vaultEl) vaultEl.textContent = err.message || 'Vault unavailable';
}
applyRoleUI();
}
const ROLE_RANK = { viewer: 1, operator: 2, admin: 3 };
/** Hide/disable UI that exceeds the active peer role */
function applyRoleUI() {
const role = manager.active?.role || 'viewer';
const rank = ROLE_RANK[role] || 0;
document.body.dataset.role = role;
document.querySelectorAll('[data-min-role]').forEach((el) => {
const need = ROLE_RANK[el.dataset.minRole] || 99;
const allowed = rank >= need;
el.classList.toggle('role-hidden', !allowed);
if ('disabled' in el) el.disabled = !allowed;
el.setAttribute('aria-disabled', allowed ? 'false' : 'true');
if (!allowed) el.title = el.title || `Requires ${el.dataset.minRole} role`;
});
}
function showFirstConnectChecklist() {
try {
if (typeof window.peardockOps?.shouldShowFirstConnectTip === 'function') {
if (!window.peardockOps.shouldShowFirstConnectTip()) return;
} else if (localStorage.getItem('peardock.firstConnect.dismissed') === '1') {
return;
}
} catch {
// ignore
}
document.getElementById('first-connect-checklist')?.classList.remove('hidden');
}
function dismissFirstConnectChecklist() {
document.getElementById('first-connect-checklist')?.classList.add('hidden');
try {
localStorage.setItem('peardock.firstConnect.dismissed', '1');
} catch {
// ignore
}
}
function renderActivityPanel() {
const list = document.getElementById('activity-panel-list');
if (!list) return;
const jobs = window.peardockOps?.listJobs?.(15) || [];
if (!jobs.length) {
list.innerHTML = '<div class="text-muted small p-3">No jobs yet — deploy, pull, or build to see activity here.</div>';
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 `<button type="button" class="activity-item" data-job-id="${escapeHtmlLite(j.id)}">
<span class="activity-item-kind">${escapeHtmlLite(j.kind || 'Job')}</span>
<span class="badge bg-${tone}">${escapeHtmlLite(j.status || '—')}</span>
<span class="activity-item-meta text-muted">${escapeHtmlLite(when)}${steps ? ` · ${escapeHtmlLite(steps)}` : ''}</span>
</button>`;
})
.join('');
list.querySelectorAll('.activity-item').forEach((btn) => {
btn.addEventListener('click', () => {
const job = window.peardockOps?.listJobs?.(50)?.find((x) => x.id === btn.dataset.jobId);
if (job && window.peardockOps?.showJob) {
window.peardockOps.showJob(job);
document.getElementById('activity-panel')?.classList.add('hidden');
}
});
});
}
window.renderActivityPanel = renderActivityPanel;
window.applyRoleUI = applyRoleUI;
// Wire access view actions once
document.addEventListener('DOMContentLoaded', () => {
// Invite form (role / TTL / max uses + copy token)
const inviteForm = document.getElementById('invite-peer-form');
if (inviteForm) {
inviteForm.addEventListener('submit', async (e) => {
e.preventDefault();
const role = document.getElementById('invite-role')?.value || 'operator';
const ttlHours = Number(document.getElementById('invite-ttl')?.value) || 72;
const maxUses = Number(document.getElementById('invite-max-uses')?.value) || 1;
try {
const res = await manager.request(Methods.invitePeer, { role, ttlHours, maxUses });
const token = res?.data?.token;
const resultBox = document.getElementById('invite-token-result');
const tokenInput = document.getElementById('invite-token-value');
if (token && tokenInput) {
tokenInput.value = token;
resultBox?.classList.remove('hidden');
showAlert('success', 'Invite created — copy the token below');
try {
await navigator.clipboard?.writeText?.(token);
showAlert('info', 'Token copied to clipboard');
} catch {
// ignore
}
}
loadAccessView();
} catch (err) {
showAlert('danger', err.message || 'Failed to create invite');
}
});
}
document.getElementById('invite-token-copy')?.addEventListener('click', async () => {
const token = document.getElementById('invite-token-value')?.value;
if (!token) return;
try {
await navigator.clipboard.writeText(token);
showAlert('success', 'Token copied');
} catch {
showAlert('warning', 'Could not copy — select and copy manually');
}
});
document.getElementById('invitePeerModal')?.addEventListener('show.bs.modal', () => {
document.getElementById('invite-token-result')?.classList.add('hidden');
const tokenInput = document.getElementById('invite-token-value');
if (tokenInput) tokenInput.value = '';
});
// First-connect checklist
document.getElementById('first-connect-dismiss')?.addEventListener('click', dismissFirstConnectChecklist);
document.getElementById('first-connect-got-it')?.addEventListener('click', dismissFirstConnectChecklist);
document.querySelectorAll('.first-connect-link').forEach((btn) => {
btn.addEventListener('click', () => {
const view = btn.dataset.view;
dismissFirstConnectChecklist();
if (view) navigateToView(view);
});
});
// Activity tray
document.getElementById('activity-tray-toggle')?.addEventListener('click', (e) => {
e.stopPropagation();
const panel = document.getElementById('activity-panel');
if (!panel) return;
const open = panel.classList.toggle('hidden') === false;
document.getElementById('activity-tray-toggle')?.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) renderActivityPanel();
});
document.getElementById('activity-panel-close')?.addEventListener('click', () => {
document.getElementById('activity-panel')?.classList.add('hidden');
});
document.addEventListener('click', (e) => {
const tray = document.getElementById('activity-tray');
const panel = document.getElementById('activity-panel');
if (tray && panel && !tray.contains(e.target)) panel.classList.add('hidden');
});
const vaultForm = document.getElementById('vault-store-form');
if (vaultForm) {
vaultForm.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(vaultForm);
try {
await manager.request(Methods.vaultStoreCredential, {
username: fd.get('username'),
password: fd.get('password'),
serveraddress: fd.get('serveraddress') || undefined,
});
vaultForm.reset();
if (typeof showAlert === 'function') showAlert('success', 'Credential stored encrypted');
loadAccessView();
} catch (err) {
if (typeof showAlert === 'function') showAlert('danger', err.message);
}
});
}
const fleetRefresh = document.getElementById('fleet-refresh-btn');
if (fleetRefresh) fleetRefresh.addEventListener('click', () => loadFleetView());
});
// Expose to window for onclick handlers
window.navigateToView = navigateToView;
// Dashboard Functions
function loadDashboard() {
if (!hasActiveConnection() && !window.activePeer) {
return;
}
// Set up volumes subscription early to catch broadcasts
if (!volumesStoreSubscription) {
volumesStoreSubscription = volumesStore.subscribe((volumes) => {
// Only auto-update if we're on the volumes view
if (currentView === 'volumes') {
renderVolumes(volumes);
}
});
}
// Instant hydrate from host snapshot (auto-populate dashboard datapoints)
import('./client/snapshot.js')
.then(({ getSnapshot }) => getSnapshot())
.then((snap) => {
if (!snap?.counts) return;
const runningEl = document.getElementById('stat-running-containers');
const stoppedEl = document.getElementById('stat-stopped-containers');
const imagesEl = document.getElementById('stat-total-images');
const networksEl = document.getElementById('stat-total-networks');
if (runningEl) runningEl.textContent = String(snap.counts.running ?? 0);
if (stoppedEl) {
stoppedEl.textContent = String(
Math.max(0, (snap.counts.containers || 0) - (snap.counts.running || 0))
);
}
if (imagesEl) imagesEl.textContent = String(snap.counts.images ?? 0);
if (networksEl) networksEl.textContent = String(snap.counts.networks ?? 0);
const dockerInfoEl = document.getElementById('docker-info-content');
if (dockerInfoEl && snap.engine && !dockerInfoEl.dataset.filled) {
const eng = snap.engine;
const m = (label, value) =>
`<div class="dash-metric"><span class="dash-metric-label">${label}</span><span class="dash-metric-value">${value}</span></div>`;
dockerInfoEl.innerHTML = [
m('Host', eng.name || 'host'),
m('OS', `${eng.operatingSystem || '—'} · ${eng.architecture || ''}`),
m('API', eng.version?.ApiVersion || eng.version?.apiVersion || '—'),
m('Swarm', eng.swarm || 'inactive'),
m('CPUs', eng.ncpu ?? '—'),
m('Volumes', snap.counts?.volumes ?? 0),
].join('');
dockerInfoEl.dataset.filled = '1';
}
})
.catch(() => {});
// Load system info
sendCommand('getSystemInfo');
sendCommand('getSystemDf');
// Load container stats for counts
sendCommand('listContainers');
// Load images count
sendCommand('listImages');
// Load networks count
sendCommand('listNetworks');
// Load volumes
sendCommand('listVolumes');
}
window.loadDashboard = loadDashboard;
/** @type {Array<object>} */
const dockerEventBuffer = [];
const MAX_EVENT_ROWS = 80;
function formatBytes(bytes) {
const n = Number(bytes) || 0;
if (n === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(sizes.length - 1, Math.floor(Math.log(n) / Math.log(k)));
return `${Math.round((n / k ** i) * 100) / 100} ${sizes[i]}`;
}
function refreshSystemDf() {
if (!hasActiveConnection()) return;
sendCommand('getSystemDf');
}
window.refreshSystemDf = refreshSystemDf;
function renderSystemDf(data) {
const el = document.getElementById('system-df-content');
if (!el || !data) return;
const layers = data.LayersSize ?? data.layersSize;
const images = data.Images || [];
const containers = data.Containers || [];
const volumes = data.Volumes || [];
const buildCache = data.BuildCache || [];
const imgSize = images.reduce((s, i) => s + (i.Size || 0), 0);
const volSize = volumes.reduce((s, v) => s + (v.UsageData?.Size || v.Size || 0), 0);
const cacheSize = buildCache.reduce((s, c) => s + (c.Size || 0), 0);
const contSize = containers.reduce((s, c) => s + (c.SizeRw || 0), 0);
const layerSize = layers ?? imgSize;
const total = Math.max(layerSize + volSize + cacheSize + contSize, 1);
const row = (label, bytes) => {
const pct = Math.min(100, Math.round((Number(bytes || 0) / total) * 100));
return `<div class="dash-disk-row">
<div class="dash-disk-top"><span>${label}</span><strong>${formatBytes(bytes)}</strong></div>
<div class="dash-disk-bar"><i style="width:${pct}%"></i></div>
</div>`;
};
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 = '<div class="dash-empty">Docker events will stream here…</div>';
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 `<div class="event-row"><span>${time}</span><span class="event-type">${escapeHtmlLite(type)}</span><span class="event-action">${escapeHtmlLite(action)} ${escapeHtmlLite(name)}</span></div>`;
})
.join('');
}
window.handleDockerEvent = appendDockerEvent;
function updateHealthBadge(info, conn) {
const dot = document.getElementById('health-dot');
const lat = document.getElementById('health-latency');
const dock = document.getElementById('health-docker');
const roleEl = document.getElementById('health-role');
if (!dot || !lat || !dock) return;
if (!info && !conn?.connected) {
dot.className = 'health-dot health-dot--unknown';
lat.textContent = '—';
dock.textContent = 'Docker —';
if (roleEl) roleEl.textContent = '';
return;
}
const latency = info?.latency ?? conn?.latency;
const docker = info?.docker ?? conn?.dockerHealth;
const status = info?.status ?? conn?.healthStatus ?? 'unknown';
const role = conn?.role || info?.role || null;
lat.textContent = latency != null ? `${latency} ms` : '—';
if (docker?.ok === true) {
dock.textContent = `Docker ${docker.apiVersion || 'ok'}`;
} else if (docker?.ok === false) {
dock.textContent = 'Docker down';
} else {
dock.textContent = 'Docker —';
}
if (roleEl) roleEl.textContent = role ? role : '';
let cls = 'health-dot--unknown';
if (status === 'healthy' || (docker?.ok && status !== 'degraded')) cls = 'health-dot--ok';
else if (status === 'degraded' || docker?.ok === false) cls = 'health-dot--degraded';
else if (status === 'disconnected' || status === 'error') cls = 'health-dot--error';
dot.className = `health-dot ${cls}`;
}
/**
* Prune unused Docker resources (admin).
* @param {'containers'|'images'|'networks'|'volumes'} kind
*/
async function pruneResource(kind) {
const map = {
containers: { method: 'pruneContainers', label: 'stopped containers' },
images: { method: 'pruneImages', label: 'unused images' },
networks: { method: 'pruneNetworks', label: 'unused networks' },
volumes: { method: 'pruneVolumes', label: 'unused volumes' },
builder: { method: 'pruneBuilder', label: 'build cache' },
};
const conf = map[kind];
if (!conf) return;
if (!hasActiveConnection()) {
showAlert('danger', 'Not connected');
return;
}
let confirmed = false;
await new Promise((resolve) => {
showConfirmModal(`Prune ${conf.label}? This cannot be undone.`, () => {
confirmed = true;
resolve();
});
const modalEl = document.getElementById('confirmModal');
if (modalEl) {
modalEl.addEventListener(
'hidden.bs.modal',
() => {
if (!confirmed) resolve();
},
{ once: true }
);
}
});
if (!confirmed) return;
showStatusIndicator(`Pruning ${conf.label}…`);
try {
const response = await sendCommand(conf.method, {});
hideStatusIndicator();
if (response?.success) {
showAlert('success', response.message || `Pruned ${conf.label}`);
if (kind === 'containers') sendCommand('listContainers');
if (kind === 'images' || kind === 'builder') sendCommand('listImages');
if (kind === 'networks') sendCommand('listNetworks');
if (kind === 'volumes') sendCommand('listVolumes');
sendCommand('getSystemDf');
}
} catch (err) {
hideStatusIndicator();
showAlert('danger', err.message || 'Prune failed');
}
}
window.pruneResource = pruneResource;
/**
* Docker system prune (unused containers, networks, images; optional volumes).
* @param {{ volumes?: boolean }} [opts]
*/
async function systemPrune(opts = {}) {
if (!hasActiveConnection()) {
showAlert('danger', 'Not connected');
return;
}
const volumes = Boolean(opts.volumes);
let confirmed = false;
await new Promise((resolve) => {
showConfirmModal(
volumes
? 'System prune including unused volumes? This cannot be undone.'
: 'System prune (stopped containers, unused networks & images)? Volumes are kept unless you opt in.',
() => {
confirmed = true;
resolve();
}
);
const modalEl = document.getElementById('confirmModal');
if (modalEl) {
modalEl.addEventListener(
'hidden.bs.modal',
() => {
if (!confirmed) resolve();
},
{ once: true }
);
}
});
if (!confirmed) return;
showStatusIndicator('Running docker system prune…');
try {
const response = await sendCommand('systemPrune', { volumes, all: false });
hideStatusIndicator();
if (response?.success) {
showAlert('success', response.message || 'System prune complete');
sendCommand('listContainers');
sendCommand('listImages');
sendCommand('listNetworks');
sendCommand('getSystemDf');
}
} catch (err) {
hideStatusIndicator();
showAlert('danger', err.message || 'System prune failed');
}
}
window.systemPrune = systemPrune;
/**
* Search Docker Hub via peardock searchImages RPC (pull modal).
*/
async function searchDockerHub() {
const term = document.getElementById('hub-search-term')?.value?.trim();
const host = document.getElementById('hub-search-results');
if (!host) return;
if (!term) {
showAlert('warning', 'Enter a Hub search term');
return;
}
if (!hasActiveConnection()) {
showAlert('danger', 'Not connected');
return;
}
host.innerHTML = '<div class="list-group-item text-muted">Searching Hub…</div>';
try {
const res = await sendCommand('searchImages', { term, limit: 15 });
const rows = res?.data || res?.results || [];
if (!rows.length) {
host.innerHTML = '<div class="list-group-item text-muted">No results</div>';
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 `<button type="button" class="list-group-item list-group-item-action list-group-item-dark hub-result" data-name="${escapeHtmlLite(name)}">
<div class="d-flex justify-content-between gap-2">
<strong class="font-monospace">${escapeHtmlLite(name)}</strong>
<span class="text-muted">★ ${escapeHtmlLite(String(stars))}${official}</span>
</div>
<div class="text-muted small text-truncate">${escapeHtmlLite(desc)}</div>
</button>`;
})
.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 = `<div class="list-group-item text-danger">${escapeHtmlLite(err.message || 'Search failed')}</div>`;
}
}
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 = '<div class="list-group-item text-muted">Searching…</div>';
try {
const res = await sendCommand('searchImages', { term, limit: 12 });
const rows = res?.data || [];
if (!rows.length) {
host.innerHTML = '<div class="list-group-item text-muted">No results</div>';
return;
}
host.innerHTML = rows
.map((r) => {
const name = r.name || r.Name || '';
return `<button type="button" class="list-group-item list-group-item-action list-group-item-dark access-hub-hit" data-name="${escapeHtmlLite(name)}">
<strong class="font-monospace">${escapeHtmlLite(name)}</strong>
<button type="button" class="btn btn-sm btn-outline-primary float-end access-hub-pull" data-name="${escapeHtmlLite(name)}">Pull</button>
</button>`;
})
.join('');
host.querySelectorAll('.access-hub-pull').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
let n = btn.getAttribute('data-name') || '';
if (n && !n.includes(':')) n = n + ':latest';
if (n) {
sendCommand('pullImage', { image: n });
showAlert('info', `Pulling ${n}…`, { badge: false });
}
});
});
} catch (err) {
host.innerHTML = `<div class="list-group-item text-danger">${escapeHtmlLite(err.message || 'Failed')}</div>`;
}
}
/**
* Recreate container with same config (admin).
* @param {{ Id: string, Names?: string[] }} container
*/
async function recreateContainerAction(container) {
if (!container?.Id) return;
if (!hasActiveConnection()) {
showAlert('danger', 'Not connected');
return;
}
const name = (container.Names?.[0] || '').replace(/^\//, '') || container.Id.slice(0, 12);
let confirmed = false;
await new Promise((resolve) => {
showConfirmModal(
`Recreate container "${name}"? It will be stopped, removed, and created again with the same configuration.`,
() => {
confirmed = true;
resolve();
}
);
const modalEl = document.getElementById('confirmModal');
if (modalEl) {
modalEl.addEventListener(
'hidden.bs.modal',
() => {
if (!confirmed) resolve();
},
{ once: true }
);
}
});
if (!confirmed) return;
showStatusIndicator(`Recreating ${name}…`);
try {
const response = await sendCommand('recreateContainer', {
id: container.Id,
start: true,
});
hideStatusIndicator();
if (response?.success) {
showAlert('success', response.message || `Recreated ${name}`);
sendCommand('listContainers');
}
} catch (err) {
hideStatusIndicator();
showAlert('danger', err.message || 'Recreate failed');
}
}
window.recreateContainerAction = recreateContainerAction;
function loadContainerTop(containerId) {
if (!containerId || !hasActiveConnection()) return;
const el = document.getElementById('container-top-content');
if (el) el.innerHTML = '<div class="text-muted small">Loading processes…</div>';
sendCommand('containerTop', { id: containerId });
}
window.loadContainerTop = loadContainerTop;
function renderContainerTop(payload) {
const el = document.getElementById('container-top-content');
if (!el) return;
const data = payload?.data || payload;
const titles = data?.Titles || data?.titles || [];
const processes = data?.Processes || data?.processes || [];
if (!processes.length) {
el.innerHTML = '<div class="text-muted small">No processes (is the container running?)</div>';
return;
}
const head = titles.map((t) => `<th>${t}</th>`).join('');
const rows = processes
.map((proc) => {
const cells = (Array.isArray(proc) ? proc : [proc]).map((c) => `<td><code>${c}</code></td>`).join('');
return `<tr>${cells}</tr>`;
})
.join('');
el.innerHTML = `<table class="table table-dark table-sm table-striped"><thead><tr>${head}</tr></thead><tbody>${rows}</tbody></table>`;
}
function updateDashboardStats(containers, images, networks) {
if (containers) {
const running = containers.filter(c => c.State === 'running').length;
const stopped = containers.filter(c => c.State !== 'running').length;
const runningEl = document.getElementById('stat-running-containers');
const stoppedEl = document.getElementById('stat-stopped-containers');
if (runningEl) runningEl.textContent = running;
if (stoppedEl) stoppedEl.textContent = stopped;
}
if (images) {
const imagesEl = document.getElementById('stat-total-images');
if (imagesEl) imagesEl.textContent = images.length;
}
if (networks) {
const networksEl = document.getElementById('stat-total-networks');
if (networksEl) networksEl.textContent = networks.length;
}
}
function updateSystemInfo(systemInfo) {
if (!systemInfo) return;
const dockerInfoEl = document.getElementById('docker-info-content');
const resourcesEl = document.getElementById('system-resources-content');
if (dockerInfoEl && systemInfo.info) {
const info = systemInfo.info;
const metric = (label, value) =>
`<div class="dash-metric"><span class="dash-metric-label">${label}</span><span class="dash-metric-value">${value}</span></div>`;
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) =>
`<div class="dash-metric"><span class="dash-metric-label">${label}</span><span class="dash-metric-value">${value}</span></div>`;
resourcesEl.innerHTML = [
metric('Memory', fmt(info.MemTotal)),
metric('CPU cores', info.NCPU || 'Unknown'),
metric('Operating system', info.OperatingSystem || 'Unknown'),
metric('Architecture', info.Architecture || 'Unknown'),
metric('Kernel', info.KernelVersion || 'Unknown'),
].join('');
}
}
// Images Functions
let allImages = []; // Store all images for filtering
let currentImageFilter = 'all'; // Current filter: 'all', 'used', 'unused'
function loadImages() {
if (!window.activePeer && !hasActiveConnection()) {
return;
}
showListSkeleton('images-list', 5);
sendCommand('listImages');
}
function filterImages(filter) {
currentImageFilter = filter;
// Update active button
document.querySelectorAll('.image-filter-btn').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.filter === filter) {
btn.classList.add('active');
}
});
// Re-render images with current filter
renderImages(allImages);
}
function renderImages(images) {
// Store all images
allImages = images || [];
// Calculate filter counts
const usedCount = allImages.filter(image => image.usage && image.usage.length > 0).length;
const unusedCount = allImages.filter(image => !image.usage || image.usage.length === 0).length;
// Update filter badge counts
const allCountEl = document.getElementById('filter-count-all');
const usedCountEl = document.getElementById('filter-count-used');
const unusedCountEl = document.getElementById('filter-count-unused');
if (allCountEl) allCountEl.textContent = allImages.length;
if (usedCountEl) usedCountEl.textContent = usedCount;
if (unusedCountEl) unusedCountEl.textContent = unusedCount;
// Filter images based on current filter + search
let filteredImages = allImages;
if (currentImageFilter === 'used') {
filteredImages = allImages.filter(image => image.usage && image.usage.length > 0);
} else if (currentImageFilter === 'unused') {
filteredImages = allImages.filter(image => !image.usage || image.usage.length === 0);
}
const imgSearch = document.getElementById('image-search')?.value?.trim().toLowerCase() || '';
if (imgSearch) {
filteredImages = filteredImages.filter((image) => {
const tag = (image.RepoTags && image.RepoTags[0]) || '';
const id = (image.Id || '').slice(0, 20);
return tag.toLowerCase().includes(imgSearch) || id.toLowerCase().includes(imgSearch);
});
}
const imagesList = document.getElementById('images-list');
if (!imagesList) return;
if (!filteredImages || filteredImages.length === 0) {
const hasAny = allImages.length > 0;
imagesList.innerHTML = emptyTableRow(
8,
hasAny ? 'No matching images' : 'No images yet',
hasAny ? 'Try another filter or search.' : 'Pull or build an image to get started.'
);
return;
}
const formatBytes = (bytes) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
imagesList.innerHTML = filteredImages.map(image => {
const repoTag = image.RepoTags && image.RepoTags[0] ? image.RepoTags[0].split(':') : ['<none>', '<none>'];
const repo = repoTag[0];
const tag = repoTag[1];
const imageId = image.Id.substring(7, 19);
const size = formatBytes(image.Size);
const created = image.Created ? new Date(image.Created * 1000).toLocaleDateString() : 'Unknown';
const usage = image.usage ? image.usage.length : 0;
return `
<tr>
<td>
<input type="checkbox" class="image-checkbox" data-image-id="${image.Id}" onchange="updateBulkActionsImagesToolbar()">
</td>
<td>${repo}</td>
<td><span class="badge bg-secondary">${tag}</span></td>
<td><code>${imageId}</code></td>
<td>${size}</td>
<td>${created}</td>
<td>${usage} container${usage !== 1 ? 's' : ''}</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-info action-inspect-image" data-image-id="${image.Id}" title="Inspect">
<i class="fas fa-info-circle"></i>
</button>
<button class="btn btn-outline-success action-tag-image" data-image-id="${image.Id}" title="Tag Image">
<i class="fas fa-tag"></i>
</button>
<button class="btn btn-outline-danger action-remove-image" data-image-id="${image.Id}" title="Remove">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
// Add event listeners
imagesList.querySelectorAll('.action-remove-image').forEach(btn => {
btn.addEventListener('click', () => {
const imageId = btn.dataset.imageId;
showConfirmModal('Are you sure you want to remove this image?', () => {
sendCommand('removeImage', { id: imageId, force: true });
setTimeout(() => loadImages(), 1000);
});
});
});
imagesList.querySelectorAll('.action-tag-image').forEach(btn => {
btn.addEventListener('click', async () => {
const imageId = btn.dataset.imageId;
const modal = new bootstrap.Modal(document.getElementById('tagImageModal'));
const repoInput = document.getElementById('tag-repo');
const tagInput = document.getElementById('tag-tag');
const confirmBtn = document.getElementById('confirm-tag-btn');
repoInput.value = '';
tagInput.value = 'latest';
// Remove old listeners
if (confirmBtn && confirmBtn.parentNode) {
const newConfirmBtn = confirmBtn.cloneNode(true);
confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn);
newConfirmBtn.addEventListener('click', async () => {
const repo = repoInput.value.trim();
if (repo) {
const tag = tagInput.value.trim() || 'latest';
modal.hide();
showStatusIndicator(`Tagging image...`);
sendCommand('tagImage', { id: imageId, repo, tag });
try {
const response = await waitForPeerResponse('Image tagged as');
showAlert('success', response.message || 'Image tagged successfully');
loadImages();
} catch (error) {
console.error('[ERROR] Failed to tag image:', error);
showAlert('danger', error.message || 'Failed to tag image');
} finally {
hideStatusIndicator();
}
} else {
showAlert('danger', 'Repository name is required');
}
});
} else {
console.warn('[WARNING] confirm-tag-btn not found in DOM, skipping listener setup');
}
modal.show();
});
});
imagesList.querySelectorAll('.action-inspect-image').forEach(btn => {
btn.addEventListener('click', () => {
const imageId = btn.dataset.imageId;
openImageInspectModal(imageId);
});
});
}
// Networks Functions
function loadNetworks() {
if (!window.activePeer && !hasActiveConnection()) {
return;
}
showListSkeleton('networks-list', 4);
sendCommand('listNetworks');
}
/** @type {Array<object>} */
let allNetworks = [];
function renderNetworks(networks) {
const networksList = document.getElementById('networks-list');
if (!networksList) return;
allNetworks = networks || [];
const q = document.getElementById('network-search')?.value?.trim().toLowerCase() || '';
const filtered = !q
? allNetworks
: allNetworks.filter((n) => {
const name = (n.Name || '').toLowerCase();
const driver = (n.Driver || '').toLowerCase();
const subnet = (n.IPAM?.Config?.[0]?.Subnet || '').toLowerCase();
return name.includes(q) || driver.includes(q) || subnet.includes(q);
});
if (!filtered.length) {
networksList.innerHTML = emptyTableRow(
7,
allNetworks.length ? 'No matching networks' : 'No networks yet',
allNetworks.length ? 'Clear search to see all networks.' : 'Create a network with smart IPAM defaults.'
);
return;
}
networksList.innerHTML = filtered.map(network => {
const subnet = network.IPAM?.Config?.[0]?.Subnet || '-';
const gateway = network.IPAM?.Config?.[0]?.Gateway || '-';
const usage = network.usage ? network.usage.length : 0;
return `
<tr>
<td><strong>${network.Name}</strong></td>
<td><span class="badge bg-info">${network.Driver}</span></td>
<td>${network.Scope || 'local'}</td>
<td><code>${subnet}</code></td>
<td><code>${gateway}</code></td>
<td>${usage} container${usage !== 1 ? 's' : ''}</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-info action-inspect-network" data-network-id="${network.Id}" title="Inspect">
<i class="fas fa-info-circle"></i>
</button>
<button class="btn btn-outline-success action-connect-network" data-network-id="${network.Id}" title="Connect Container">
<i class="fas fa-plug"></i>
</button>
${network.Name !== 'bridge' && network.Name !== 'host' && network.Name !== 'none' ? `
<button class="btn btn-outline-danger action-remove-network" data-network-id="${network.Id}" title="Remove">
<i class="fas fa-trash"></i>
</button>
` : ''}
</div>
</td>
</tr>
`;
}).join('');
// Add event listeners
networksList.querySelectorAll('.action-remove-network').forEach(btn => {
btn.addEventListener('click', () => {
const networkId = btn.dataset.networkId;
showConfirmModal('Are you sure you want to remove this network?', () => {
sendCommand('removeNetwork', { id: networkId });
setTimeout(() => loadNetworks(), 1000);
});
});
});
networksList.querySelectorAll('.action-inspect-network').forEach(btn => {
btn.addEventListener('click', () => {
const networkId = btn.dataset.networkId;
openNetworkInspectModal(networkId);
});
});
networksList.querySelectorAll('.action-connect-network').forEach(btn => {
btn.addEventListener('click', async () => {
const networkId = btn.dataset.networkId;
const modal = new bootstrap.Modal(document.getElementById('connectNetworkModal'));
const containerSelect = document.getElementById('connect-container-select');
const confirmBtn = document.getElementById('confirm-connect-btn');
// Store networkId for later use
modal._networkId = networkId;
// Get list of containers for selection
sendCommand('listContainers');
// Wait for containers list
const originalHandler = window.handlePeerResponse;
window.handlePeerResponse = async (response) => {
if (response.type === 'containers' && response.data) {
const containers = response.data;
if (containers.length === 0) {
showAlert('warning', 'No containers available to connect');
if (typeof originalHandler === 'function') {
window.handlePeerResponse = originalHandler;
}
return;
}
// Populate select dropdown
containerSelect.innerHTML = '<option value="">Select a container...</option>';
containers.forEach(container => {
const name = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12);
const option = document.createElement('option');
option.value = container.Id;
option.textContent = `${name} (${container.State})`;
containerSelect.appendChild(option);
});
// Remove old listeners - re-query element to ensure it still exists in DOM
const currentConfirmBtn = document.getElementById('confirm-connect-btn');
if (currentConfirmBtn && currentConfirmBtn.parentNode) {
const newConfirmBtn = currentConfirmBtn.cloneNode(true);
currentConfirmBtn.parentNode.replaceChild(newConfirmBtn, currentConfirmBtn);
newConfirmBtn.addEventListener('click', async () => {
const containerId = containerSelect.value;
if (containerId) {
modal.hide();
showStatusIndicator(`Connecting container to network...`);
sendCommand('connectNetwork', { networkId: modal._networkId, containerId });
try {
const response = await waitForPeerResponse('Container connected to network');
showAlert('success', response.message || 'Container connected to network');
loadNetworks();
} catch (error) {
console.error('[ERROR] Failed to connect container:', error);
showAlert('danger', error.message || 'Failed to connect container');
} finally {
hideStatusIndicator();
}
} else {
showAlert('danger', 'Please select a container');
}
});
} else {
// Fallback: if element doesn't exist, add listener directly (shouldn't happen normally)
console.warn('[WARNING] confirm-connect-btn not found in DOM, skipping listener setup');
}
modal.show();
if (typeof originalHandler === 'function') {
window.handlePeerResponse = originalHandler;
}
} else if (typeof originalHandler === 'function') {
originalHandler(response);
}
};
});
});
}
// Volumes Functions
/** @type {Array<object>} */
let allStacksCache = [];
function loadStacks() {
if (!window.activePeer && !hasActiveConnection()) {
console.warn('[WARN] No active peer connection');
return;
}
showListSkeleton('stacks-list-body', 3);
sendCommand('listStacks');
}
function renderStacks(stacks) {
const stacksListBody = document.getElementById('stacks-list-body');
if (!stacksListBody) return;
allStacksCache = Array.isArray(stacks) ? stacks : [];
const q = document.getElementById('stack-search')?.value?.trim().toLowerCase() || '';
const filtered = !q
? allStacksCache
: allStacksCache.filter((s) => {
const name = String(s.name || '').toLowerCase();
const services = (s.services || []).join(' ').toLowerCase();
return name.includes(q) || services.includes(q);
});
if (!filtered.length) {
stacksListBody.innerHTML = emptyTableRow(
5,
allStacksCache.length ? 'No matching stacks' : 'No stacks yet',
allStacksCache.length
? 'Clear search to see all stacks.'
: 'Deploy a compose stack to get started.'
);
return;
}
stacksListBody.innerHTML = filtered.map(stack => {
const containers = stack.containers || [];
const runningCount = containers.filter(c => c.state === 'running').length;
const totalCount = containers.length;
const statusClass = runningCount === totalCount && totalCount > 0 ? 'text-success' :
runningCount > 0 ? 'text-warning' : 'text-danger';
const services = Array.isArray(stack.services) ? stack.services.join(', ') : '—';
return `
<tr>
<td><strong>${escapeHtmlLite(stack.name)}</strong></td>
<td>${escapeHtmlLite(services)}</td>
<td>${totalCount} container(s)</td>
<td><span class="${statusClass}">${runningCount}/${totalCount} running</span></td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-info action-inspect-stack" data-stack-name="${escapeHtmlLite(stack.name)}" title="Inspect">
<i class="fas fa-info-circle"></i>
</button>
<button class="btn btn-outline-danger action-remove-stack" data-stack-name="${escapeHtmlLite(stack.name)}" title="Remove Stack" data-min-role="operator">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
applyRoleUI();
// Add event listeners
stacksListBody.querySelectorAll('.action-inspect-stack').forEach(btn => {
btn.addEventListener('click', () => {
const stackName = btn.dataset.stackName;
openStackInspectModal(stackName);
});
});
stacksListBody.querySelectorAll('.action-remove-stack').forEach(btn => {
btn.addEventListener('click', async () => {
const stackName = btn.dataset.stackName;
let confirmed = false;
await new Promise((resolve) => {
showConfirmModal(`Remove stack "${stackName}"? This will remove all containers in the stack.`, () => {
confirmed = true;
resolve();
});
const modalEl = document.getElementById('confirmModal');
if (modalEl) {
modalEl.addEventListener('hidden.bs.modal', () => {
if (!confirmed) resolve();
}, { once: true });
}
});
if (!confirmed) return;
showStatusIndicator(`Removing stack "${stackName}"...`);
sendCommand('removeStack', { stackName });
try {
const response = await waitForPeerResponse(`Stack "${stackName}" removed successfully`);
showAlert('success', response.message);
loadStacks();
} catch (error) {
console.error('[ERROR] Failed to remove stack:', error);
showAlert('danger', error.message || 'Failed to remove stack');
} finally {
hideStatusIndicator();
}
});
});
}
// Deploy stack handler
function setupDeployStackHandler() {
const deployStackBtn = document.getElementById('deploy-stack-btn');
const deployStackForm = document.getElementById('deploy-stack-form');
if (deployStackBtn && deployStackForm) {
deployStackBtn.addEventListener('click', async () => {
const stackName = document.getElementById('stack-name').value.trim();
const composeContent = document.getElementById('compose-content').value.trim();
if (!stackName || !composeContent) {
showAlert('danger', 'Stack name and compose content are required');
return;
}
showStatusIndicator(`Deploying stack "${stackName}"...`);
try {
const envFileContent =
document.getElementById('stack-env-file')?.value?.trim() || undefined;
const response = await manager.request(Methods.deployStack, {
stackName,
composeContent,
envFileContent: envFileContent || undefined,
});
showAlert('success', response?.message || `Stack "${stackName}" deployed`);
// Close modal and reset form
const modal = bootstrap.Modal.getInstance(document.getElementById('deploy-stack-modal'));
if (modal) modal.hide();
deployStackForm.reset();
// Load stacks view
navigateToView('stacks');
loadStacks();
} catch (error) {
console.error('[ERROR] Failed to deploy stack:', error);
presentError(error, 'deployStack', { showAlert });
} finally {
hideStatusIndicator();
}
});
}
document.getElementById('stack-gitops-btn')?.addEventListener('click', async () => {
const stackName = document.getElementById('stack-name')?.value?.trim();
const repoUrl = document.getElementById('stack-git-url')?.value?.trim();
const ref = document.getElementById('stack-git-ref')?.value?.trim() || 'main';
const composePath =
document.getElementById('stack-git-path')?.value?.trim() || 'docker-compose.yml';
if (!stackName || !repoUrl) {
showAlert('warning', 'Stack name and repository URL are required for GitOps sync');
return;
}
showStatusIndicator(`GitOps sync ${stackName} from ${repoUrl}…`);
try {
const response = await manager.request(Methods.syncStackFromGit, {
stackName,
repoUrl,
ref,
composePath,
});
if (response?.composeContent == null && response?.success) {
// deployed server-side
}
showAlert(
'success',
response?.message || `Stack "${stackName}" synced from git`
);
const modal = bootstrap.Modal.getInstance(document.getElementById('deploy-stack-modal'));
if (modal) modal.hide();
navigateToView('stacks');
loadStacks();
} catch (error) {
presentError(error, 'syncStackFromGit', { showAlert });
} finally {
hideStatusIndicator();
}
});
}
// Subscription for volumes store to auto-update UI
let volumesStoreSubscription = null;
function loadVolumes() {
if (!window.activePeer && !hasActiveConnection()) {
return;
}
// Set up subscription to auto-update UI when volumes change
if (!volumesStoreSubscription) {
volumesStoreSubscription = volumesStore.subscribe((volumes) => {
// Only auto-update if we're on the volumes view
if (currentView === 'volumes') {
renderVolumes(volumes);
}
});
}
// Check cache first - if fresh, use it; otherwise load from server
if (!volumesStore.isStale() && volumesStore.get().length > 0) {
renderVolumes(volumesStore.get());
return;
}
// Set loading state
volumesStore.setLoading(true);
showListSkeleton('volumes-list', 4);
sendCommand('listVolumes');
}
/** @type {Array<object>} */
let allVolumesCache = [];
function renderVolumes(volumes) {
// Note: Do not call volumesStore.set() here to avoid infinite loop
// The store is already updated before calling renderVolumes() in the message handler
const volumesList = document.getElementById('volumes-list');
if (!volumesList) return;
allVolumesCache = volumes || [];
const q = document.getElementById('volume-search')?.value?.trim().toLowerCase() || '';
const filtered = !q
? allVolumesCache
: allVolumesCache.filter((v) => {
const name = (v.Name || '').toLowerCase();
const driver = (v.Driver || '').toLowerCase();
const mp = (v.Mountpoint || '').toLowerCase();
return name.includes(q) || driver.includes(q) || mp.includes(q);
});
if (!filtered.length) {
volumesList.innerHTML = emptyTableRow(
5,
allVolumesCache.length ? 'No matching volumes' : 'No volumes yet',
allVolumesCache.length ? 'Clear search to see all volumes.' : 'Create a volume for persistent storage.'
);
return;
}
volumesList.innerHTML = filtered.map(volume => {
const usage = volume.usage ? volume.usage.length : 0;
return `
<tr>
<td><strong>${volume.Name}</strong></td>
<td><span class="badge bg-info">${volume.Driver || 'local'}</span></td>
<td><code>${volume.Mountpoint || '-'}</code></td>
<td>${usage} container${usage !== 1 ? 's' : ''}</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary action-browse-volume" data-volume-name="${volume.Name}" title="Browse files">
<i class="fas fa-folder-open"></i>
</button>
<button class="btn btn-outline-info action-inspect-volume" data-volume-name="${volume.Name}" title="Inspect">
<i class="fas fa-info-circle"></i>
</button>
<button class="btn btn-outline-danger action-remove-volume" data-volume-name="${volume.Name}" title="Remove">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
volumesList.querySelectorAll('.action-browse-volume').forEach((btn) => {
btn.addEventListener('click', () => {
const volumeName = btn.dataset.volumeName;
if (window.peardockOps?.openVolumeBrowser) {
window.peardockOps.openVolumeBrowser(volumeName);
} else {
showAlert('info', 'Volume browser not loaded');
}
});
});
// Add event listeners
volumesList.querySelectorAll('.action-remove-volume').forEach(btn => {
btn.addEventListener('click', () => {
const volumeName = btn.dataset.volumeName;
showConfirmModal('Are you sure you want to remove this volume? This cannot be undone.', () => {
// Set up response handler to refresh volumes list
const originalHandler = window.handlePeerResponse;
window.handlePeerResponse = (response) => {
// Always call original handler first for volume broadcasts and other messages
if (typeof originalHandler === 'function') {
originalHandler(response);
}
// Handle remove volume success response
if (response.success && response.message && response.message.includes('removed')) {
showAlert('success', response.message);
// Volumes will be updated via server broadcast, but refresh to be sure
if (currentView === 'volumes') {
loadVolumes();
}
} else if (response.error) {
// Error is already handled by centralized RPC handler
// But we can still show alert for immediate feedback
const errorMsg = handleErrorResponse(response);
if (errorMsg) {
showAlert('danger', errorMsg);
}
}
// Only reset handler if this was the remove volume response (not a broadcast)
if (response.success && response.message && response.message.includes('removed')) {
window.handlePeerResponse = originalHandler;
}
};
sendCommand('removeVolume', { name: volumeName });
// Reset handler after timeout as safety measure
setTimeout(() => {
if (window.handlePeerResponse !== originalHandler) {
window.handlePeerResponse = originalHandler;
}
}, 30000);
});
});
});
volumesList.querySelectorAll('.action-inspect-volume').forEach(btn => {
btn.addEventListener('click', () => {
const volumeName = btn.dataset.volumeName;
openVolumeInspectModal(volumeName);
});
});
}
// Modal Functions
function setupBuildImageHandler() {
const buildImageBtn = document.getElementById('build-image-btn');
const buildImageForm = document.getElementById('buildImageForm');
if (buildImageBtn && buildImageForm) {
buildImageBtn.addEventListener('click', async () => {
const tag = document.getElementById('image-tag').value.trim();
const dockerfile = document.getElementById('dockerfile-content').value.trim();
if (!tag || !dockerfile) {
showAlert('danger', 'Image tag and Dockerfile content are required');
return;
}
showStatusIndicator(`Building image "${tag}"...`);
sendCommand('buildImage', { tag, dockerfile });
try {
const response = await waitForPeerResponse('Image built successfully');
showAlert('success', response.message || 'Image built successfully');
// Close modal and reset form
const modal = bootstrap.Modal.getInstance(document.getElementById('buildImageModal'));
if (modal) modal.hide();
buildImageForm.reset();
// Refresh images list
sendCommand('listImages');
} catch (error) {
console.error('[ERROR] Failed to build image:', error);
showAlert('danger', error.message || 'Failed to build image');
} finally {
hideStatusIndicator();
}
});
}
}
/** Derive 0100 from Docker pull progress event, or null if unknown. */
function pullProgressPercent(event) {
const detail = event?.progressDetail;
if (detail && detail.total && detail.current != null) {
return Math.min(100, Math.round((detail.current / detail.total) * 100));
}
if (event?.status && /complete|downloaded|pull complete/i.test(String(event.status))) {
return 100;
}
return null;
}
function pullImage() {
const imageName = document.getElementById('pull-image-name')?.value?.trim();
if (!imageName) {
showAlert('danger', 'Please enter an image name');
return;
}
const modal = bootstrap.Modal.getInstance(document.getElementById('pullImageModal'));
if (modal) modal.hide();
try {
const host =
document.getElementById('images-view') ||
document.getElementById('alert-container')?.parentElement ||
document.body;
document.getElementById('progress-pull-image')?.remove();
host.prepend(createProgressBar('pull-image', `Pulling ${imageName}`));
} catch {
// ignore
}
showStatusIndicator(`Pulling image "${imageName}"...`);
sendCommand('pullImage', { image: imageName }).then((response) => {
removeProgressBar('pull-image');
if (response?.success) {
hideStatusIndicator();
showAlert('success', response.message || `Image "${imageName}" pulled successfully`);
if (typeof loadImages === 'function') loadImages();
else sendCommand('listImages');
} else if (response?.error) {
hideStatusIndicator();
const errorMsg = handleErrorResponse(response);
if (errorMsg) showAlert('danger', errorMsg);
} else {
// Error already emitted via manager.send → handleRpcMessage
hideStatusIndicator();
}
});
}
/** Legacy entry — always open smart network modal */
function createNetwork() {
if (window.peardockOps?.openSmartNetworkModal) {
window.peardockOps.openSmartNetworkModal();
return;
}
showAlert('info', 'Use Create network from the Networks view');
// Keep a no-op timeout cleanup path for any old callers
const originalHandler = window.handlePeerResponse;
setTimeout(() => {
if (window.handlePeerResponse === originalHandler) {
window.handlePeerResponse = originalHandler;
}
}, 30000);
}
function createVolume() {
const name = document.getElementById('volume-name')?.value?.trim();
if (!name) {
showAlert('danger', 'Please enter a volume name');
return;
}
const driver = document.getElementById('volume-driver')?.value?.trim() || null;
const modal = bootstrap.Modal.getInstance(document.getElementById('createVolumeModal'));
if (modal) modal.hide();
showStatusIndicator(`Creating volume "${name}"...`);
sendCommand('createVolume', { name, driver });
// Store the volume name being created for later matching
const volumeNameBeingCreated = name;
let volumeCreated = false;
// Wait for response
const originalHandler = window.handlePeerResponse;
window.handlePeerResponse = (response) => {
// Always call original handler first for volume broadcasts and other messages
if (typeof originalHandler === 'function') {
originalHandler(response);
}
// Check if volume was already created to avoid duplicate processing
if (volumeCreated) {
return;
}
// Handle create volume success response - more flexible condition
const isDirectSuccess = response.success &&
response.message &&
(response.message.includes('created successfully') ||
response.message.includes('created'));
// Check if volumes broadcast includes the newly created volume
let isVolumeInBroadcast = false;
if (response.type === 'volumes' || (response.success && (response.data || response.volumes))) {
const volumesArray = response.data || response.volumes || [];
if (Array.isArray(volumesArray)) {
isVolumeInBroadcast = volumesArray.some(vol => {
const volName = typeof vol === 'string' ? vol : (vol.Name || vol.name || '');
return volName === volumeNameBeingCreated;
});
}
}
// Handle success (either direct response or volumes broadcast with new volume)
if (isDirectSuccess || isVolumeInBroadcast) {
volumeCreated = true;
hideStatusIndicator();
if (isDirectSuccess && response.message) {
showAlert('success', response.message);
} else if (isVolumeInBroadcast) {
showAlert('success', `Volume "${volumeNameBeingCreated}" created successfully`);
}
// Volumes will be updated via server broadcast, but refresh to be sure
loadVolumes();
// Reset form
document.getElementById('create-volume-form')?.reset();
// Reset handler since we've handled the response
window.handlePeerResponse = originalHandler;
} else if (response.error) {
hideStatusIndicator();
// Error is already handled by centralized handler in handleRpcMessage
// But we can still show alert for immediate feedback
const errorMsg = handleErrorResponse(response);
if (errorMsg) {
showAlert('danger', errorMsg);
}
// Reset handler on error
window.handlePeerResponse = originalHandler;
}
};
// Safety timeout - hide spinner and reset handler if no response received
setTimeout(() => {
if (!volumeCreated && window.handlePeerResponse !== originalHandler) {
console.warn(`[WARN] Volume creation timeout for "${volumeNameBeingCreated}" - hiding spinner as safety measure`);
hideStatusIndicator();
window.handlePeerResponse = originalHandler;
}
}, 30000);
}
// Container Details Functions
let currentContainerDetails = null;
function showContainerDetails(container) {
currentContainerDetails = container;
navigateToView('container-details');
// Update title
const titleEl = document.getElementById('container-details-title');
if (titleEl) {
const name = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12);
titleEl.innerHTML = `<i class="fas fa-box me-2"></i>${name}`;
}
// Load container config
sendCommand('inspectContainer', { id: container.Id });
// Set up callback for container config
window.inspectContainerCallback = (config) => {
populateContainerDetails(config, container);
window.inspectContainerCallback = null;
};
// Switch to overview tab
const overviewTab = document.getElementById('overview-tab');
if (overviewTab) {
const tab = new bootstrap.Tab(overviewTab);
tab.show();
}
}
// Copy to clipboard utility
function copyToClipboard(text, buttonElement) {
navigator.clipboard.writeText(text).then(() => {
const originalHTML = buttonElement.innerHTML;
buttonElement.innerHTML = '<i class="fas fa-check"></i> Copied!';
buttonElement.style.background = 'var(--accent-success)';
buttonElement.style.borderColor = 'var(--accent-success)';
setTimeout(() => {
buttonElement.innerHTML = originalHTML;
buttonElement.style.background = '';
buttonElement.style.borderColor = '';
}, 2000);
}).catch(err => {
console.error('Failed to copy:', err);
showAlert('danger', 'Failed to copy to clipboard');
});
}
// Format relative time
function formatRelativeTime(dateString) {
if (!dateString || dateString === 'Unknown' || dateString === 'Not started') return '';
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return `${diffSecs} second${diffSecs !== 1 ? 's' : ''} ago`;
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
if (diffDays < 30) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) !== 1 ? 's' : ''} ago`;
}
function populateContainerDetails(config, container) {
if (!config) return;
// Overview Tab
populateOverviewTab(config, container);
// Configuration Tab
populateConfigTab(config);
// Networking Tab
populateNetworkingTab(config);
// Stats Tab - will be updated by stats updates
updateContainerDetailsStats(container);
// Attach copy / tunnel button event listeners after content is populated
setTimeout(() => {
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', function() {
const textToCopy = this.getAttribute('data-copy');
if (textToCopy) {
copyToClipboard(textToCopy, this);
}
});
});
document.querySelectorAll('.action-tunnel-port').forEach((btn) => {
btn.addEventListener('click', async function () {
const containerId = this.getAttribute('data-container-id');
const containerPort = Number(this.getAttribute('data-container-port'));
const hostPort = Number(this.getAttribute('data-host-port'));
const protocol = this.getAttribute('data-protocol') || 'tcp';
const containerName = this.getAttribute('data-container-name') || '';
if (!containerId || !containerPort) {
showAlert('warning', 'Missing container port info for tunnel');
return;
}
try {
this.disabled = true;
const res = await manager.request(Methods.createTunnel, {
containerId,
containerPort,
protocol,
name: containerName
? `${containerName}:${containerPort}`
: `port-${hostPort || containerPort}`,
secure: true,
});
const url = res?.tunnel?.url;
const existing = Boolean(res?.existing);
if (url && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(url);
} catch {
// ignore
}
}
showAlert(
existing ? 'info' : 'success',
url
? existing
? `Tunnel already exists — URL copied. Connect with: npx holesail '${url.slice(0, 24)}…'`
: `Holesail tunnel created — URL copied. Connect with: npx holesail '${url.slice(0, 24)}…'`
: existing
? 'Tunnel already exists for this port'
: 'Holesail tunnel created'
);
if (typeof window.peardockOps?.loadTunnelsView === 'function') {
// warm tunnels view cache
}
// Offer local connect via designed modal (Settings → Tunnels)
const offerLocal =
window.peardockOps?.loadSettings?.()?.offerLocalTunnelAfterCreate !== false;
if (url && offerLocal && window.peardockOps?.connectLocalTunnel) {
const open = window.peardockOps.askUserConfirm
? await window.peardockOps.askUserConfirm(
'Open local tunnel?',
'Start a local Holesail client and open this tunnel in your browser?',
{ confirmLabel: 'Open tunnel', icon: 'fa-network-wired' }
)
: false;
if (open) {
await window.peardockOps.connectLocalTunnel(url);
}
}
} catch (err) {
presentError(err, 'createTunnel', { showAlert });
} finally {
this.disabled = false;
}
});
});
}, 100);
}
function populateOverviewTab(config, container) {
const content = document.getElementById('container-overview-content');
if (!content) return;
const name = config.Name?.replace(/^\//, '') || 'Unknown';
const image = config.Config?.Image || 'Unknown';
const state = config.State?.Status || 'Unknown';
const created = config.Created ? new Date(config.Created).toLocaleString() : 'Unknown';
const createdRelative = config.Created ? formatRelativeTime(config.Created) : '';
const started = config.State?.StartedAt ? new Date(config.State.StartedAt).toLocaleString() : 'Not started';
const startedRelative = config.State?.StartedAt ? formatRelativeTime(config.State.StartedAt) : '';
const id = config.Id || 'Unknown';
const fullId = id;
const shortId = id.substring(0, 12);
const restartCount = config.RestartCount || 0;
// Get IP address
let ipAddress = 'No IP Assigned';
let primaryNetwork = null;
if (config.NetworkSettings && config.NetworkSettings.Networks) {
const networks = Object.values(config.NetworkSettings.Networks);
if (networks.length > 0 && networks[0].IPAddress) {
ipAddress = networks[0].IPAddress;
primaryNetwork = networks[0];
}
}
// Status badge class
const statusBadgeClass = state === 'running' ? 'status-running' :
state === 'paused' ? 'status-paused' :
state === 'restarting' ? 'status-restarting' : 'status-exited';
// Status icon
const statusIcon = state === 'running' ? 'fa-circle-check' :
state === 'paused' ? 'fa-pause-circle' :
state === 'restarting' ? 'fa-sync-alt' : 'fa-stop-circle';
content.innerHTML = `
<!-- Basic Information Section -->
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-info-circle"></i>
<h3 class="detail-section-title">Basic Information</h3>
</div>
<div class="detail-info-grid">
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-tag"></i>
Container Name
</div>
<div class="detail-info-value">${name}</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-fingerprint"></i>
Container ID
</div>
<div class="detail-info-value">
<code>${shortId}</code>
<button class="copy-btn" data-copy="${fullId}" title="Copy full ID">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-box"></i>
Image
</div>
<div class="detail-info-value">${image}</div>
</div>
</div>
</div>
<!-- Status & Health Section -->
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-heartbeat"></i>
<h3 class="detail-section-title">Status & Health</h3>
</div>
<div class="detail-info-grid">
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-circle"></i>
Status
</div>
<div class="detail-info-value">
<span class="detail-badge ${statusBadgeClass}">
<i class="fas ${statusIcon}"></i>
${state}
</span>
</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-redo"></i>
Restart Count
</div>
<div class="detail-info-value">
<div class="detail-stat-card" style="padding: var(--spacing-sm); margin-top: var(--spacing-xs);">
<div class="detail-stat-icon" style="background: rgba(45, 212, 191, 0.2); color: var(--accent-primary);">
<i class="fas fa-redo"></i>
</div>
<div class="detail-stat-content">
<div class="detail-stat-value">${restartCount}</div>
<div class="detail-stat-label">Restarts</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Network Information Section -->
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-network-wired"></i>
<h3 class="detail-section-title">Network Information</h3>
</div>
<div class="detail-info-grid">
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-ip-address"></i>
IP Address
</div>
<div class="detail-info-value">
${ipAddress !== 'No IP Assigned' ? `
<code>${ipAddress}</code>
<button class="copy-btn" data-copy="${ipAddress}" title="Copy IP address">
<i class="fas fa-copy"></i>
</button>
` : '<span style="color: var(--text-muted);">No IP Assigned</span>'}
</div>
</div>
${primaryNetwork && primaryNetwork.Gateway ? `
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-network-wired"></i>
Gateway
</div>
<div class="detail-info-value"><code>${primaryNetwork.Gateway}</code></div>
</div>
` : ''}
${primaryNetwork && primaryNetwork.MacAddress ? `
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-ethernet"></i>
MAC Address
</div>
<div class="detail-info-value"><code>${primaryNetwork.MacAddress}</code></div>
</div>
` : ''}
</div>
</div>
<!-- Timeline Section -->
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-clock"></i>
<h3 class="detail-section-title">Timeline</h3>
</div>
<div class="detail-info-grid">
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-calendar-plus"></i>
Created
</div>
<div class="detail-info-value">
${created}
${createdRelative ? `<div class="relative-time">${createdRelative}</div>` : ''}
</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-play-circle"></i>
Started
</div>
<div class="detail-info-value">
${started}
${startedRelative ? `<div class="relative-time">${startedRelative}</div>` : ''}
</div>
</div>
</div>
</div>
`;
}
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 Section -->
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-terminal"></i>
<h3 class="detail-section-title">Command & Entrypoint</h3>
</div>
<div class="detail-info-grid">
<div class="detail-info-item" style="grid-column: 1 / -1;">
<div class="detail-info-label">
<i class="fas fa-play"></i>
Command
</div>
${cmdStr ? `
<div class="detail-code-block">
<pre><code>${cmdStr}</code></pre>
<button class="copy-btn" data-copy="${cmdStr}" title="Copy command" style="position: absolute; top: var(--spacing-sm); right: var(--spacing-sm);">
<i class="fas fa-copy"></i>
</button>
</div>
` : `
<div class="empty-state" style="padding: var(--spacing-md); text-align: left;">
<span style="color: var(--text-muted);">Not set</span>
</div>
`}
</div>
<div class="detail-info-item" style="grid-column: 1 / -1;">
<div class="detail-info-label">
<i class="fas fa-sign-in-alt"></i>
Entrypoint
</div>
${entrypointStr ? `
<div class="detail-code-block">
<pre><code>${entrypointStr}</code></pre>
<button class="copy-btn" data-copy="${entrypointStr}" title="Copy entrypoint" style="position: absolute; top: var(--spacing-sm); right: var(--spacing-sm);">
<i class="fas fa-copy"></i>
</button>
</div>
` : `
<div class="empty-state" style="padding: var(--spacing-md); text-align: left;">
<span style="color: var(--text-muted);">Not set</span>
</div>
`}
</div>
</div>
</div>
<!-- Runtime Settings Section -->
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-cog"></i>
<h3 class="detail-section-title">Runtime Settings</h3>
</div>
<div class="detail-info-grid">
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-folder"></i>
Working Directory
</div>
<div class="detail-info-value">
${workingDir ? `<code>${workingDir}</code>` : '<span style="color: var(--text-muted);">Not set</span>'}
</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-user"></i>
User
</div>
<div class="detail-info-value">${user || '<span style="color: var(--text-muted);">Default</span>'}</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-server"></i>
Hostname
</div>
<div class="detail-info-value">
${hostname ? `<code>${hostname}</code>` : '<span style="color: var(--text-muted);">Not set</span>'}
</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-globe"></i>
Domain Name
</div>
<div class="detail-info-value">
${domainname ? `<code>${domainname}</code>` : '<span style="color: var(--text-muted);">Not set</span>'}
</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-toggle-on"></i>
TTY
</div>
<div class="detail-info-value">
<span class="detail-badge ${tty ? 'status-running' : 'status-exited'}">
${tty ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-keyboard"></i>
Open STDIN
</div>
<div class="detail-info-value">
<span class="detail-badge ${openStdin ? 'status-running' : 'status-exited'}">
${openStdin ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
</div>
</div>
<!-- Environment Variables Section -->
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-list"></i>
<h3 class="detail-section-title">Environment Variables</h3>
</div>
${envVars.length > 0 ? `
<div style="overflow-x: auto;">
<table class="detail-env-table">
<thead>
<tr>
<th>Variable</th>
<th>Value</th>
<th style="width: 80px;">Action</th>
</tr>
</thead>
<tbody>
${envVars.map(envVar => `
<tr>
<td><code>${envVar.key}</code></td>
<td><code>${envVar.value || '<span style="color: var(--text-muted);">(empty)</span>'}</code></td>
<td>
<button class="copy-btn" data-copy="${envVar.key}=${envVar.value}" title="Copy variable">
<i class="fas fa-copy"></i>
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
` : `
<div class="empty-state">
<i class="fas fa-inbox"></i>
<div>No environment variables set</div>
</div>
`}
</div>
<!-- Exposed Ports Section -->
${exposedPorts.length > 0 ? `
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-plug"></i>
<h3 class="detail-section-title">Exposed Ports</h3>
</div>
<div class="detail-info-grid">
${exposedPorts.map(port => {
const [portNum, protocol] = port.split('/');
return `
<div class="detail-info-item">
<div class="detail-info-value">
<span class="protocol-badge ${protocol || 'tcp'}">${protocol || 'tcp'}</span>
<code style="margin-left: var(--spacing-sm);">${portNum}</code>
</div>
</div>
`;
}).join('')}
</div>
</div>
` : ''}
<!-- Labels Section -->
${Object.keys(labels).length > 0 ? `
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-tags"></i>
<h3 class="detail-section-title">Labels</h3>
</div>
<div style="overflow-x: auto;">
<table class="detail-env-table">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th style="width: 80px;">Action</th>
</tr>
</thead>
<tbody>
${Object.entries(labels).map(([key, value]) => `
<tr>
<td><code>${key}</code></td>
<td><code>${value}</code></td>
<td>
<button class="copy-btn" data-copy="${key}=${value}" title="Copy label">
<i class="fas fa-copy"></i>
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
` : ''}
`;
}
function populateNetworkingTab(config) {
const content = document.getElementById('container-networking-content');
if (!content) return;
const networkMode = config.HostConfig?.NetworkMode || 'default';
const networks = config.NetworkSettings?.Networks || {};
const ports = config.NetworkSettings?.Ports || {};
const dns = config.HostConfig?.Dns || [];
const extraHosts = config.HostConfig?.ExtraHosts || [];
const links = config.HostConfig?.Links || [];
// Format port bindings with detailed info
const portBindings = [];
if (ports) {
Object.keys(ports).forEach(port => {
const [portNum, protocol] = port.split('/');
const bindings = ports[port];
if (bindings && bindings.length > 0) {
bindings.forEach(binding => {
portBindings.push({
containerPort: portNum,
protocol: protocol || 'tcp',
hostIp: binding.HostIp || '0.0.0.0',
hostPort: binding.HostPort,
display: `${binding.HostIp || '0.0.0.0'}:${binding.HostPort}${portNum}/${protocol || 'tcp'}`
});
});
} else {
// Exposed but not bound
portBindings.push({
containerPort: portNum,
protocol: protocol || 'tcp',
hostIp: null,
hostPort: null,
display: `${portNum}/${protocol || 'tcp'} (exposed, not bound)`
});
}
});
}
// Network cards data
const networkCards = [];
Object.keys(networks).forEach(netName => {
const net = networks[netName];
networkCards.push({
name: netName,
ipAddress: net.IPAddress || 'Not assigned',
gateway: net.Gateway || 'Not set',
macAddress: net.MacAddress || 'Not set',
networkId: net.NetworkID || 'Not set',
endpointId: net.EndpointID || 'Not set',
ipPrefixLen: net.IPPrefixLen || null,
globalIPv6Address: net.GlobalIPv6Address || null,
globalIPv6PrefixLen: net.GlobalIPv6PrefixLen || null,
ipv6Gateway: net.IPv6Gateway || null
});
});
content.innerHTML = `
<!-- Network Mode Section -->
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-network-wired"></i>
<h3 class="detail-section-title">Network Mode</h3>
</div>
<div class="detail-info-grid">
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-sitemap"></i>
Mode
</div>
<div class="detail-info-value">
<span class="detail-badge status-running">
<i class="fas fa-network-wired"></i>
${networkMode}
</span>
</div>
</div>
</div>
</div>
<!-- Networks Section -->
${networkCards.length > 0 ? `
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-project-diagram"></i>
<h3 class="detail-section-title">Connected Networks</h3>
</div>
${networkCards.map(net => `
<div class="network-card">
<div class="network-card-header">
<div class="network-card-title">
<i class="fas fa-network-wired"></i>
${net.name}
</div>
${net.name !== 'host' && net.name !== 'none' && net.networkId && net.networkId !== 'Not set' ? `
<button type="button" class="btn btn-sm btn-outline-danger network-disconnect-btn"
data-network-id="${net.networkId}" data-network-name="${net.name}"
data-container-id="${config.Id || ''}" data-min-role="operator" title="Disconnect">
<i class="fas fa-unlink"></i>
</button>` : ''}
</div>
<div class="detail-info-grid">
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-ip-address"></i>
IP Address
</div>
<div class="detail-info-value">
${net.ipAddress !== 'Not assigned' ? `
<code>${net.ipAddress}</code>
${net.ipPrefixLen ? `<span style="color: var(--text-muted); margin-left: var(--spacing-xs);">/${net.ipPrefixLen}</span>` : ''}
<button class="copy-btn" data-copy="${net.ipAddress}" title="Copy IP address">
<i class="fas fa-copy"></i>
</button>
` : '<span style="color: var(--text-muted);">Not assigned</span>'}
</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-route"></i>
Gateway
</div>
<div class="detail-info-value">
${net.gateway !== 'Not set' ? `<code>${net.gateway}</code>` : '<span style="color: var(--text-muted);">Not set</span>'}
</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-ethernet"></i>
MAC Address
</div>
<div class="detail-info-value">
${net.macAddress !== 'Not set' ? `<code>${net.macAddress}</code>` : '<span style="color: var(--text-muted);">Not set</span>'}
</div>
</div>
${net.globalIPv6Address ? `
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-ip-address"></i>
IPv6 Address
</div>
<div class="detail-info-value">
<code>${net.globalIPv6Address}</code>
${net.globalIPv6PrefixLen ? `<span style="color: var(--text-muted); margin-left: var(--spacing-xs);">/${net.globalIPv6PrefixLen}</span>` : ''}
<button class="copy-btn" data-copy="${net.globalIPv6Address}" title="Copy IPv6 address">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
` : ''}
${net.ipv6Gateway ? `
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-route"></i>
IPv6 Gateway
</div>
<div class="detail-info-value"><code>${net.ipv6Gateway}</code></div>
</div>
` : ''}
${net.networkId && net.networkId !== 'Not set' ? `
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-fingerprint"></i>
Network ID
</div>
<div class="detail-info-value">
<code style="font-size: 0.75rem;">${net.networkId.length > 12 ? net.networkId.substring(0, 12) + '...' : net.networkId}</code>
<button class="copy-btn" data-copy="${net.networkId}" title="Copy network ID">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
` : ''}
${net.endpointId && net.endpointId !== 'Not set' ? `
<div class="detail-info-item">
<div class="detail-info-label">
<i class="fas fa-link"></i>
Endpoint ID
</div>
<div class="detail-info-value">
<code style="font-size: 0.75rem;">${net.endpointId.length > 12 ? net.endpointId.substring(0, 12) + '...' : net.endpointId}</code>
<button class="copy-btn" data-copy="${net.endpointId}" title="Copy endpoint ID">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
` : ''}
</div>
</div>
`).join('')}
</div>
` : `
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-project-diagram"></i>
<h3 class="detail-section-title">Connected Networks</h3>
</div>
<div class="empty-state">
<i class="fas fa-network-wired"></i>
<div>No networks connected</div>
</div>
</div>
`}
<!-- Port Mappings Section -->
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-plug"></i>
<h3 class="detail-section-title">Port Mappings</h3>
</div>
${portBindings.length > 0 ? `
<div style="overflow-x: auto;">
<table class="port-mapping-table">
<thead>
<tr>
<th>Host IP</th>
<th>Host Port</th>
<th>Container Port</th>
<th>Protocol</th>
<th style="width: 80px;">Action</th>
</tr>
</thead>
<tbody>
${portBindings.map(binding => `
<tr>
<td>${binding.hostIp ? `<code>${binding.hostIp}</code>` : '<span style="color: var(--text-muted);">-</span>'}</td>
<td>${binding.hostPort ? `<code>${binding.hostPort}</code>` : '<span style="color: var(--text-muted);">-</span>'}</td>
<td><code>${binding.containerPort}</code></td>
<td><span class="protocol-badge ${binding.protocol}">${binding.protocol}</span></td>
<td>
${binding.hostPort ? `
<div class="btn-group btn-group-sm">
<button class="copy-btn btn btn-sm btn-outline-secondary" data-copy="${binding.hostIp || '0.0.0.0'}:${binding.hostPort}" title="Copy host address">
<i class="fas fa-copy"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-primary action-tunnel-port"
data-min-role="operator"
data-container-id="${config.Id || ''}"
data-container-name="${(config.Name || '').replace(/^\//, '')}"
data-container-port="${binding.containerPort}"
data-host-port="${binding.hostPort}"
data-protocol="${binding.protocol || 'tcp'}"
title="Tunnel this port via Holesail">
<i class="fas fa-network-wired"></i>
</button>
</div>
` : '<span style="color: var(--text-muted);">-</span>'}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
` : `
<div class="empty-state">
<i class="fas fa-plug"></i>
<div>No port mappings configured</div>
</div>
`}
</div>
<!-- DNS Configuration Section -->
${dns.length > 0 ? `
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-server"></i>
<h3 class="detail-section-title">DNS Servers</h3>
</div>
<div class="detail-info-grid">
${dns.map(dnsServer => `
<div class="detail-info-item">
<div class="detail-info-value">
<i class="fas fa-server" style="color: var(--accent-info); margin-right: var(--spacing-xs);"></i>
<code>${dnsServer}</code>
<button class="copy-btn" data-copy="${dnsServer}" title="Copy DNS server">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
`).join('')}
</div>
</div>
` : ''}
<!-- Extra Hosts Section -->
${extraHosts.length > 0 ? `
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-list"></i>
<h3 class="detail-section-title">Extra Hosts</h3>
</div>
<div style="overflow-x: auto;">
<table class="detail-env-table">
<thead>
<tr>
<th>Hostname</th>
<th>IP Address</th>
<th style="width: 80px;">Action</th>
</tr>
</thead>
<tbody>
${extraHosts.map(host => {
const [hostname, ip] = host.split(':');
return `
<tr>
<td><code>${hostname}</code></td>
<td><code>${ip}</code></td>
<td>
<button class="copy-btn" data-copy="${host}" title="Copy host entry">
<i class="fas fa-copy"></i>
</button>
</td>
</tr>
`;
content.querySelectorAll('.network-disconnect-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const containerId = btn.getAttribute('data-container-id');
const networkId = btn.getAttribute('data-network-id');
const networkName = btn.getAttribute('data-network-name');
if (window.peardockOps?.disconnectContainerNetwork) {
window.peardockOps.disconnectContainerNetwork(containerId, networkId, networkName);
} else {
showAlert('info', 'Disconnect not available');
}
});
});
}).join('')}
</tbody>
</table>
</div>
</div>
` : ''}
<!-- Container Links Section -->
${links.length > 0 ? `
<div class="detail-section-card">
<div class="detail-section-header">
<i class="fas fa-link"></i>
<h3 class="detail-section-title">Container Links</h3>
</div>
<div class="detail-info-grid">
${links.map(link => `
<div class="detail-info-item">
<div class="detail-info-value">
<i class="fas fa-link" style="color: var(--accent-primary); margin-right: var(--spacing-xs);"></i>
<code>${link}</code>
</div>
</div>
`).join('')}
</div>
</div>
` : ''}
`;
}
let cpuChart = null;
let memoryChart = null;
function updateContainerDetailsStats(container) {
// This will be called by the stats update function
// For now, just show current stats if available
if (smoothedStats[container.Id]) {
const stats = smoothedStats[container.Id];
const cpuEl = document.getElementById('detail-cpu');
const memoryEl = document.getElementById('detail-memory');
if (cpuEl) cpuEl.textContent = `${stats.cpu.toFixed(2)}%`;
if (memoryEl) memoryEl.textContent = `${(stats.memory / (1024 * 1024)).toFixed(2)} MB`;
}
// Merge server-side history ring buffer into client charts (road-map stats history)
if (manager.active?.connected) {
manager
.request(Methods.getStatsHistory, { id: container.Id, limit: 120 })
.then((res) => {
if (!res?.data?.length) return;
if (!historicalStats[container.Id]) {
historicalStats[container.Id] = { timestamps: [], cpu: [], memory: [] };
}
const h = historicalStats[container.Id];
// Prefer server series when longer
if (res.data.length >= h.timestamps.length) {
h.timestamps = res.data.map((p) => p.t);
h.cpu = res.data.map((p) => p.cpu);
h.memory = res.data.map((p) => p.memory);
updateStatsCharts(container.Id);
}
})
.catch(() => {});
}
// Initialize charts
updateStatsCharts(container.Id);
}
function updateStatsCharts(containerId) {
if (!historicalStats[containerId] || historicalStats[containerId].timestamps.length === 0) {
return;
}
const history = historicalStats[containerId];
const container = document.getElementById('stats-charts-container');
if (!container) return;
// Format timestamps for display
const labels = history.timestamps.map(ts => {
const date = new Date(ts);
return `${date.getMinutes()}:${date.getSeconds().toString().padStart(2, '0')}`;
});
// Create or update CPU chart
const cpuCtx = document.getElementById('cpu-chart-canvas');
if (!cpuCtx) {
// Create canvas if it doesn't exist
container.innerHTML = `
<div class="row g-3">
<div class="col-12">
<h6 class="mb-3">CPU Usage</h6>
<div class="chart-wrapper" style="height: 250px; position: relative;">
<canvas id="cpu-chart-canvas"></canvas>
</div>
</div>
<div class="col-12">
<h6 class="mb-3">Memory Usage</h6>
<div class="chart-wrapper" style="height: 250px; position: relative;">
<canvas id="memory-chart-canvas"></canvas>
</div>
</div>
</div>
`;
}
const cpuCanvas = document.getElementById('cpu-chart-canvas');
const memoryCanvas = document.getElementById('memory-chart-canvas');
if (cpuCanvas && typeof Chart !== 'undefined') {
if (!cpuChart) {
cpuChart = new Chart(cpuCanvas, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'CPU %',
data: history.cpu,
borderColor: 'rgb(16, 185, 129)',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
color: 'rgba(255, 255, 255, 0.7)'
},
grid: {
color: 'rgba(255, 255, 255, 0.1)'
}
},
x: {
ticks: {
color: 'rgba(255, 255, 255, 0.7)'
},
grid: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
}
}
});
} else {
cpuChart.data.labels = labels;
cpuChart.data.datasets[0].data = history.cpu;
cpuChart.update('none');
}
}
if (memoryCanvas && typeof Chart !== 'undefined') {
// Convert memory to MB
const memoryMB = history.memory.map(m => m / (1024 * 1024));
if (!memoryChart) {
memoryChart = new Chart(memoryCanvas, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Memory (MB)',
data: memoryMB,
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: 'rgba(255, 255, 255, 0.7)'
},
grid: {
color: 'rgba(255, 255, 255, 0.1)'
}
},
x: {
ticks: {
color: 'rgba(255, 255, 255, 0.7)'
},
grid: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
}
}
});
} else {
memoryChart.data.labels = labels;
memoryChart.data.datasets[0].data = memoryMB;
memoryChart.update('none');
}
}
}
// Logs state management
let logsState = {
paused: false,
autoScroll: true,
currentFilter: 'all',
searchTerm: '',
allLogs: [],
containerId: null,
};
// Detect log level from log line
function detectLogLevel(line) {
const lowerLine = line.toLowerCase();
if (/\b(error|exception|fatal|panic|crit|critical)\b/.test(lowerLine)) {
return 'error';
} else if (/\b(warn|warning)\b/.test(lowerLine)) {
return 'warn';
} else if (/\b(debug|trace)\b/.test(lowerLine)) {
return 'debug';
} else if (/\b(info|notice)\b/.test(lowerLine)) {
return 'info';
}
return null;
}
// Format log line with timestamp and level detection
function formatLogLine(rawLine) {
// Preserve leading spaces; only drop pure empty lines
const line = String(rawLine ?? '').replace(/\r$/, '');
if (!line.trim()) return null;
const level = detectLogLevel(line);
// Docker --timestamps and common ISO / RFC3339 / syslog prefixes
let logTimestamp = null;
let formatted = line;
const tsMatch = line.match(
/^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\s+(.*)$/
);
if (tsMatch) {
logTimestamp = tsMatch[1];
formatted = tsMatch[2] ?? line;
}
return {
raw: line,
level,
timestamp: logTimestamp || null,
formatted,
};
}
/** Cap DOM log lines to avoid memory blow-up */
const MAX_LOG_DOM_LINES = 5000;
/** Partial line buffer when chunks split mid-line */
let logsLineBuffer = '';
// Apply filters and search to logs
function applyLogFilters() {
const logsContent = document.getElementById('container-logs-content');
if (!logsContent) return;
const logLines = logsContent.querySelectorAll('.log-line');
let visibleCount = 0;
logLines.forEach(line => {
const logData = line.dataset;
let shouldShow = true;
// Apply level filter
if (logsState.currentFilter !== 'all') {
const lineLevel = logData.level || '';
if (lineLevel !== logsState.currentFilter) {
shouldShow = false;
}
}
// Apply search filter
if (shouldShow && logsState.searchTerm) {
const searchLower = logsState.searchTerm.toLowerCase();
const lineText = line.textContent.toLowerCase();
if (!lineText.includes(searchLower)) {
shouldShow = false;
} else {
// Highlight search matches
highlightSearchMatches(line);
}
} else {
// Remove highlight if no search
removeSearchHighlights(line);
}
if (shouldShow) {
line.classList.remove('hidden');
visibleCount++;
} else {
line.classList.add('hidden');
}
});
// Update filter button states
document.querySelectorAll('.logs-filter-btn').forEach(btn => {
if (btn.dataset.filter === logsState.currentFilter) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
// Highlight search matches in log line
function highlightSearchMatches(lineElement) {
if (!logsState.searchTerm) return;
const contentSpan = lineElement.querySelector('.log-content');
if (!contentSpan) return;
const originalText = lineElement.dataset.originalText || contentSpan.textContent;
const searchTerm = logsState.searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${searchTerm})`, 'gi');
if (regex.test(originalText)) {
lineElement.classList.add('highlight');
const highlighted = escapeHtml(originalText).replace(regex, '<mark style="background: rgba(255, 212, 59, 0.4); color: var(--text-primary);">$1</mark>');
contentSpan.innerHTML = highlighted;
}
}
// Remove search highlights
function removeSearchHighlights(lineElement) {
lineElement.classList.remove('highlight');
const contentSpan = lineElement.querySelector('.log-content');
if (contentSpan) {
// Restore original text (escape HTML)
const originalText = lineElement.dataset.originalText || contentSpan.textContent;
contentSpan.innerHTML = escapeHtml(originalText);
}
}
// Scroll to bottom if auto-scroll is enabled
function scrollLogsToBottom() {
if (logsState.autoScroll && !logsState.paused) {
const logsContent = document.getElementById('container-logs-content');
if (logsContent) {
logsContent.scrollTop = logsContent.scrollHeight;
}
}
}
// Set up logs tab
document.addEventListener('DOMContentLoaded', () => {
const processesTab = document.getElementById('processes-tab');
if (processesTab) {
processesTab.addEventListener('shown.bs.tab', () => {
if (currentContainerDetails?.Id) {
loadContainerTop(currentContainerDetails.Id);
}
});
}
const refreshTopBtn = document.getElementById('refresh-container-top-btn');
if (refreshTopBtn) {
refreshTopBtn.addEventListener('click', () => {
if (currentContainerDetails?.Id) {
loadContainerTop(currentContainerDetails.Id);
}
});
}
const logsTab = document.getElementById('logs-tab');
if (logsTab) {
logsTab.addEventListener('shown.bs.tab', () => {
if (currentContainerDetails) {
const logsContent = document.getElementById('container-logs-content');
if (logsContent) {
// Reset state
logsState = {
paused: false,
autoScroll: true,
currentFilter: 'all',
searchTerm: '',
allLogs: []
};
// Reset UI controls
const pauseBtn = document.getElementById('logs-pause-btn');
if (pauseBtn) {
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
pauseBtn.title = 'Pause';
}
const searchInput = document.getElementById('logs-search-input');
if (searchInput) {
searchInput.value = '';
}
const clearSearchBtn = document.getElementById('logs-clear-search');
if (clearSearchBtn) {
clearSearchBtn.style.display = 'none';
}
const autoScrollCheckbox = document.getElementById('logs-auto-scroll');
if (autoScrollCheckbox) {
autoScrollCheckbox.checked = true;
}
// Reset filter buttons
document.querySelectorAll('.logs-filter-btn').forEach(btn => {
if (btn.dataset.filter === 'all') {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
logsContent.innerHTML = '<div class="text-muted text-center logs-loading">Loading logs...</div>';
logsLineBuffer = '';
logsState.containerId = currentContainerDetails.Id;
logsState.allLogs = [];
sendCommand('logs', {
id: currentContainerDetails.Id,
tail: 200,
timestamps: true,
follow: true,
});
// Set up enhanced log handler (server demuxes docker frames → utf8 base64)
window.handleLogOutput = (logData) => {
if (logsState.paused) return;
if (
logData.containerId &&
logsState.containerId &&
logData.containerId !== logsState.containerId
) {
return;
}
const chunk = decodePayload(logData.data, logData.encoding || 'base64');
if (!chunk) return;
const loadingEl = logsContent.querySelector('.logs-loading');
if (loadingEl) logsContent.innerHTML = '';
// Split on newlines; keep incomplete trailing line in buffer
const combined = logsLineBuffer + chunk;
const parts = combined.split(/\r?\n/);
logsLineBuffer = parts.pop() ?? '';
const frag = document.createDocumentFragment();
for (const rawLine of parts) {
const formattedLog = formatLogLine(rawLine);
if (!formattedLog) continue;
logsState.allLogs.push(formattedLog);
if (logsState.allLogs.length > MAX_LOG_DOM_LINES) {
logsState.allLogs.splice(0, logsState.allLogs.length - MAX_LOG_DOM_LINES);
}
const logElement = document.createElement('div');
logElement.className = 'log-line';
if (formattedLog.level) {
logElement.classList.add(formattedLog.level);
logElement.dataset.level = formattedLog.level;
} else {
logElement.dataset.level = '';
}
let logHTML = '';
if (formattedLog.timestamp) {
logHTML += `<span class="log-timestamp">${escapeHtml(formattedLog.timestamp)}</span> `;
}
if (formattedLog.level) {
logHTML += `<span class="log-level-badge ${formattedLog.level}">${formattedLog.level}</span>`;
}
logHTML += `<span class="log-content">${escapeHtml(formattedLog.formatted)}</span>`;
logElement.innerHTML = logHTML;
logElement.dataset.originalText = formattedLog.formatted;
frag.appendChild(logElement);
}
if (frag.childNodes.length) {
logsContent.appendChild(frag);
// Cap DOM nodes
while (logsContent.children.length > MAX_LOG_DOM_LINES) {
logsContent.removeChild(logsContent.firstChild);
}
applyLogFilters();
scrollLogsToBottom();
}
};
}
}
});
}
// Set up logs controls
const pauseBtn = document.getElementById('logs-pause-btn');
if (pauseBtn) {
pauseBtn.addEventListener('click', () => {
logsState.paused = !logsState.paused;
pauseBtn.innerHTML = logsState.paused
? '<i class="fas fa-play"></i> Resume'
: '<i class="fas fa-pause"></i> Pause';
pauseBtn.title = logsState.paused ? 'Resume' : 'Pause';
});
}
const clearBtn = document.getElementById('logs-clear-btn');
if (clearBtn) {
clearBtn.addEventListener('click', () => {
const logsContent = document.getElementById('container-logs-content');
if (logsContent) {
showConfirmModal('Clear all logs?', () => {
logsContent.innerHTML = '';
logsState.allLogs = [];
});
}
});
}
const copyBtn = document.getElementById('logs-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
const logsContent = document.getElementById('container-logs-content');
if (logsContent) {
const allText = Array.from(logsContent.querySelectorAll('.log-line:not(.hidden)'))
.map(line => line.textContent)
.join('\n');
copyToClipboard(allText, copyBtn);
}
});
}
const downloadBtn = document.getElementById('logs-download-btn');
if (downloadBtn) {
downloadBtn.addEventListener('click', async () => {
if (!currentContainerDetails) return;
let allText = '';
// Prefer server-side full log fetch (with current filters) for complete download
try {
if (manager.active?.connected) {
const res = await manager.request(Methods.getContainerLogs, {
id: currentContainerDetails.Id,
tail: 5000,
timestamps: true,
search: logsState.searchTerm || undefined,
level: logsState.currentFilter !== 'all' ? logsState.currentFilter : undefined,
});
if (res?.data) allText = res.data;
}
} catch {
// fall through to DOM export
}
if (!allText) {
const logsContent = document.getElementById('container-logs-content');
if (logsContent) {
allText = Array.from(logsContent.querySelectorAll('.log-line:not(.hidden)'))
.map((line) => line.textContent)
.join('\n');
}
}
if (!allText) return;
const blob = new Blob([allText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `container-${currentContainerDetails.Id.substring(0, 12)}-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
}
// Auto-scroll toggle
const autoScrollCheckbox = document.getElementById('logs-auto-scroll');
if (autoScrollCheckbox) {
autoScrollCheckbox.addEventListener('change', (e) => {
logsState.autoScroll = e.target.checked;
if (logsState.autoScroll) {
scrollLogsToBottom();
}
});
}
// Search input
const searchInput = document.getElementById('logs-search-input');
const clearSearchBtn = document.getElementById('logs-clear-search');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
logsState.searchTerm = e.target.value;
if (logsState.searchTerm) {
clearSearchBtn.style.display = 'block';
} else {
clearSearchBtn.style.display = 'none';
}
applyLogFilters();
});
}
if (clearSearchBtn) {
clearSearchBtn.addEventListener('click', () => {
searchInput.value = '';
logsState.searchTerm = '';
clearSearchBtn.style.display = 'none';
applyLogFilters();
});
}
// Filter buttons
document.querySelectorAll('.logs-filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
logsState.currentFilter = btn.dataset.filter;
applyLogFilters();
});
});
// Set up stats tab
const statsTab = document.getElementById('stats-tab');
if (statsTab) {
statsTab.addEventListener('shown.bs.tab', () => {
if (currentContainerDetails) {
updateContainerDetailsStats(currentContainerDetails);
}
});
statsTab.addEventListener('hidden.bs.tab', () => {
// Clean up charts when leaving stats tab
if (cpuChart) {
cpuChart.destroy();
cpuChart = null;
}
if (memoryChart) {
memoryChart.destroy();
memoryChart = null;
}
});
}
// Terminal state for details tab
let detailsTerminalSession = null;
let terminalFontSize = 14;
let terminalTheme = 'dark';
// Terminal theme configurations
const terminalThemes = {
dark: {
background: '#000000',
foreground: '#ffffff',
cursor: '#ffffff',
selectionBackground: '#4d4d4d'
},
light: {
background: '#ffffff',
foreground: '#000000',
cursor: '#000000',
selectionBackground: '#b3d4fc'
},
'solarized-dark': {
background: '#002b36',
foreground: '#839496',
cursor: '#93a1a1',
selectionBackground: '#073642'
},
'solarized-light': {
background: '#fdf6e3',
foreground: '#657b83',
cursor: '#586e75',
selectionBackground: '#eee8d5'
},
monokai: {
background: '#272822',
foreground: '#f8f8f2',
cursor: '#f8f8f0',
selectionBackground: '#49483e'
}
};
// Initialize terminal for details tab
function initDetailsTerminal(containerId) {
let TerminalCtor;
let FitAddonCtor;
try {
TerminalCtor = getTerminalCtor();
FitAddonCtor = getFitAddonCtor();
} catch (err) {
console.error('[ERROR] Terminal libraries not loaded', err);
return;
}
const terminalContainer = document.getElementById('container-terminal-xterm');
const wrap = document.getElementById('container-terminal-content');
if (!terminalContainer) return;
if (detailsTerminalSession) {
cleanupDetailsTerminal();
}
// Ensure host has size for FitAddon
if (wrap) {
wrap.style.minHeight = wrap.style.minHeight || '360px';
wrap.style.height = wrap.style.height || '100%';
}
terminalContainer.style.width = '100%';
terminalContainer.style.height = '100%';
terminalContainer.style.minHeight = '320px';
const theme =
terminalThemes[terminalTheme] ||
defaultXtermOptions().theme;
const xterm = new TerminalCtor(
defaultXtermOptions({
fontSize: terminalFontSize,
theme,
})
);
const fitAddon = new FitAddonCtor();
xterm.loadAddon(fitAddon);
terminalContainer.innerHTML = '';
xterm.open(terminalContainer);
const sendResize = (cols, rows) => {
if (!manager.active?.connected || !cols || !rows) return;
manager.event(Methods.terminalResize, { containerId, cols, rows });
};
const fitController = createFitController(fitAddon, xterm, sendResize);
fitController.observe(terminalContainer);
if (wrap) fitController.observe(wrap);
const inputCoalescer = createInputCoalescer(({ data, encoding }) => {
if (!manager.active?.connected) return;
manager.event(Methods.terminalInput, {
containerId,
data,
encoding: encoding || 'utf8',
});
});
const onDataDisposable = xterm.onData((data) => {
if (!manager.active?.connected) return;
inputCoalescer.push(data);
});
detailsTerminalSession = {
xterm,
fitAddon,
fitController,
inputCoalescer,
onDataDisposable,
containerId,
};
// Fit when tab is visible, then start PTY with size
requestAnimationFrame(() => {
const dims = safeFit(fitAddon, xterm) || { cols: xterm.cols, rows: xterm.rows };
if (manager.active?.connected) {
manager
.request(Methods.startTerminal, {
containerId,
cols: dims.cols,
rows: dims.rows,
tty: true,
})
.catch((err) => {
xterm.writeln(`\r\n\x1b[31m[ERROR] ${err.message}\x1b[0m`);
});
sendResize(dims.cols, dims.rows);
}
xterm.focus();
});
updateTerminalFontSizeDisplay();
}
// Cleanup terminal for details tab
function cleanupDetailsTerminal() {
if (!detailsTerminalSession) return;
const sid = detailsTerminalSession.containerId;
try {
detailsTerminalSession.inputCoalescer?.flush();
detailsTerminalSession.inputCoalescer?.destroy();
if (sid && manager.active?.connected) {
manager.request(Methods.killTerminal, { containerId: sid }).catch(() => {});
}
detailsTerminalSession.onDataDisposable?.dispose();
detailsTerminalSession.fitController?.disconnect();
detailsTerminalSession.xterm?.dispose();
} catch {
// ignore
}
detailsTerminalSession = null;
const el = document.getElementById('container-terminal-xterm');
if (el) el.innerHTML = '';
}
// Append terminal output
function appendDetailsTerminalOutput(data, encoding = 'base64') {
if (!detailsTerminalSession?.xterm) return;
const text = decodePayload(data, encoding);
if (text) detailsTerminalSession.xterm.write(text);
}
// Update font size display
function updateTerminalFontSizeDisplay() {
const display = document.getElementById('terminal-font-size-display');
if (display) {
display.textContent = terminalFontSize;
}
}
// Apply terminal theme
function applyTerminalTheme(theme) {
terminalTheme = theme;
if (detailsTerminalSession && detailsTerminalSession.xterm) {
detailsTerminalSession.xterm.options.theme = terminalThemes[theme];
}
}
// Set up terminal tab
const terminalTab = document.getElementById('terminal-tab');
if (terminalTab) {
terminalTab.addEventListener('shown.bs.tab', () => {
if (currentContainerDetails) {
initDetailsTerminal(currentContainerDetails.Id);
}
});
terminalTab.addEventListener('hidden.bs.tab', () => {
cleanupDetailsTerminal();
});
}
// Terminal controls
const terminalFontDecreaseBtn = document.getElementById('terminal-font-decrease');
const terminalFontIncreaseBtn = document.getElementById('terminal-font-increase');
const terminalFontResetBtn = document.getElementById('terminal-font-reset');
const terminalCopyBtn = document.getElementById('terminal-copy-btn');
const terminalClearBtn = document.getElementById('terminal-clear-btn');
const terminalThemeSelect = document.getElementById('terminal-theme-select');
const applyDetailsFont = (size) => {
terminalFontSize = size;
if (detailsTerminalSession?.xterm) {
detailsTerminalSession.xterm.options.fontSize = size;
detailsTerminalSession.fitController?.fitNow();
}
updateTerminalFontSizeDisplay();
};
if (terminalFontDecreaseBtn) {
terminalFontDecreaseBtn.addEventListener('click', () => {
if (terminalFontSize > 8) applyDetailsFont(terminalFontSize - 1);
});
}
if (terminalFontIncreaseBtn) {
terminalFontIncreaseBtn.addEventListener('click', () => {
if (terminalFontSize < 28) applyDetailsFont(terminalFontSize + 1);
});
}
if (terminalFontResetBtn) {
terminalFontResetBtn.addEventListener('click', () => applyDetailsFont(14));
}
if (terminalCopyBtn) {
terminalCopyBtn.addEventListener('click', () => {
if (detailsTerminalSession && detailsTerminalSession.xterm) {
const selection = detailsTerminalSession.xterm.getSelection();
if (selection) {
copyToClipboard(selection, terminalCopyBtn);
} else {
showAlert('info', 'No text selected');
}
}
});
}
if (terminalClearBtn) {
terminalClearBtn.addEventListener('click', async () => {
if (!detailsTerminalSession?.xterm) return;
const ok = window.peardockOps?.askUserConfirm
? await window.peardockOps.askUserConfirm(
'Clear terminal?',
'Clear the visible terminal buffer for this session?',
{ confirmLabel: 'Clear', icon: 'fa-eraser' }
)
: true;
if (ok) detailsTerminalSession.xterm.clear();
});
}
if (terminalThemeSelect) {
terminalThemeSelect.value = terminalTheme;
terminalThemeSelect.addEventListener('change', (e) => {
applyTerminalTheme(e.target.value);
});
}
// Handle terminal output for details tab
window.handleDetailsTerminalOutput = (data, containerId, encoding) => {
if (detailsTerminalSession && detailsTerminalSession.containerId === containerId) {
appendDetailsTerminalOutput(data, encoding);
}
};
});
/**
* Confirmation helper — designed modal only (never native window.confirm).
* Respects Settings → confirm destructive actions when opts.destructive !== false.
* @param {string} message
* @param {Function} onConfirm
* @param {{ title?: string, requireText?: string, danger?: boolean, info?: boolean, confirmLabel?: string, destructive?: boolean }} [opts]
*/
function showConfirmModal(message, onConfirm, opts = {}) {
const run = () => {
if (typeof onConfirm === 'function') onConfirm();
};
const destructive = opts.destructive !== false && opts.info !== true;
// Settings gate: skip dialog when user disabled destructive confirms
if (
destructive &&
window.peardockOps?.shouldConfirmDestructive &&
!window.peardockOps.shouldConfirmDestructive()
) {
run();
return;
}
const title = opts.title || (destructive ? 'Confirm' : 'Continue');
if (window.peardockOps?.confirmDialog) {
window.peardockOps
.confirmDialog({
title,
body: message,
danger: opts.danger ?? destructive,
info: opts.info,
requireText: opts.requireText,
confirmLabel: opts.confirmLabel || (destructive ? 'Confirm' : 'Continue'),
})
.then((ok) => {
if (ok) run();
})
.catch(() => showBootstrapConfirm(message, onConfirm, opts));
return;
}
if (window.peardockOps?.askUserConfirm) {
window.peardockOps
.askUserConfirm(title, message, {
danger: opts.danger ?? destructive,
info: opts.info,
confirmLabel: opts.confirmLabel,
})
.then((ok) => {
if (ok) run();
})
.catch(() => showBootstrapConfirm(message, onConfirm, opts));
return;
}
showBootstrapConfirm(message, onConfirm, opts);
}
function showBootstrapConfirm(message, onConfirm, opts = {}) {
const modalEl = document.getElementById('confirmModal');
if (!modalEl || typeof bootstrap === 'undefined') {
// Last resort: still avoid ugly defaults when possible
console.warn('[WARN] confirm modal unavailable; action cancelled for safety');
return;
}
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
const messageEl = document.getElementById('confirmModalMessage');
const titleEl = document.getElementById('confirmModalLabel');
const confirmBtn = document.getElementById('confirmModalBtn');
if (titleEl && opts.title) titleEl.textContent = opts.title;
if (messageEl) messageEl.textContent = message;
if (confirmBtn && confirmBtn.parentNode) {
const newConfirmBtn = confirmBtn.cloneNode(true);
confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn);
if (opts.confirmLabel) newConfirmBtn.textContent = opts.confirmLabel;
newConfirmBtn.className = opts.danger === false && opts.info
? 'btn btn-primary'
: 'btn btn-danger';
newConfirmBtn.addEventListener('click', () => {
modal.hide();
if (typeof onConfirm === 'function') onConfirm();
});
}
modal.show();
}
// Bulk Operations Functions
function getSelectedContainers() {
const checkboxes = document.querySelectorAll('.container-checkbox:checked');
const selected = Array.from(checkboxes).map(cb => {
// Try dataset first, fallback to getAttribute for compatibility
const id = cb.dataset.containerId || cb.getAttribute('data-container-id');
if (!id) {
console.warn('[WARN] Checkbox missing container ID:', cb);
}
return id;
}).filter(id => id); // Filter out any undefined/null values
console.log('[DEBUG] getSelectedContainers - found', selected.length, 'selected containers');
return selected;
}
function getSelectedImages() {
const checkboxes = document.querySelectorAll('.image-checkbox:checked');
return Array.from(checkboxes).map(cb => cb.dataset.imageId);
}
function updateBulkActionsToolbar() {
const selected = getSelectedContainers();
const toolbar = document.getElementById('bulk-actions-toolbar');
const countEl = document.getElementById('selected-count');
if (toolbar && countEl) {
if (selected.length > 0) {
toolbar.style.display = 'block';
countEl.textContent = selected.length;
} else {
toolbar.style.display = 'none';
}
}
}
function updateBulkActionsImagesToolbar() {
const selected = getSelectedImages();
const toolbar = document.getElementById('bulk-actions-images-toolbar');
const countEl = document.getElementById('selected-images-count');
if (toolbar && countEl) {
if (selected.length > 0) {
toolbar.style.display = 'block';
countEl.textContent = selected.length;
} else {
toolbar.style.display = 'none';
}
}
}
function toggleSelectAllContainers(checkbox) {
const checkboxes = document.querySelectorAll('.container-checkbox');
checkboxes.forEach(cb => {
cb.checked = checkbox.checked;
});
updateBulkActionsToolbar();
}
function toggleSelectAllImages(checkbox) {
const checkboxes = document.querySelectorAll('.image-checkbox');
checkboxes.forEach(cb => {
cb.checked = checkbox.checked;
});
updateBulkActionsImagesToolbar();
}
function clearContainerSelection() {
document.querySelectorAll('.container-checkbox').forEach(cb => cb.checked = false);
document.getElementById('select-all-containers').checked = false;
updateBulkActionsToolbar();
}
function clearImageSelection() {
document.querySelectorAll('.image-checkbox').forEach(cb => cb.checked = false);
document.getElementById('select-all-images').checked = false;
updateBulkActionsImagesToolbar();
}
async function bulkStartContainers() {
const selected = getSelectedContainers();
console.log('[DEBUG] bulkStartContainers - selected containers:', selected);
if (selected.length === 0) {
console.warn('[WARN] No containers selected');
return;
}
await new Promise((resolve) => {
showConfirmModal(`Start ${selected.length} container(s)?`, resolve);
});
showStatusIndicator(`Starting ${selected.length} container(s)...`);
try {
sendCommand('bulkContainerOperation', { containerIds: selected, operation: 'start' });
const response = await waitForPeerResponse('Bulk operation completed');
const successCount = response.results.filter(r => r.success).length;
const failCount = response.results.filter(r => !r.success).length;
showAlert('success', `Started ${successCount} container(s)${failCount > 0 ? `, ${failCount} failed` : ''}`);
clearContainerSelection();
setTimeout(() => sendCommand('listContainers'), 1000);
} catch (error) {
console.error('[ERROR] Bulk start failed:', error);
showAlert('danger', error.message || 'Failed to start containers');
} finally {
hideStatusIndicator();
}
}
async function bulkKillContainers() {
const selected = getSelectedContainers();
if (selected.length === 0) return;
let confirmed = false;
await new Promise((resolve) => {
showConfirmModal(`Kill ${selected.length} container(s)?`, () => {
confirmed = true;
resolve();
});
const modalEl = document.getElementById('confirmModal');
if (modalEl) {
modalEl.addEventListener(
'hidden.bs.modal',
() => {
if (!confirmed) resolve();
},
{ once: true }
);
}
});
if (!confirmed) return;
showStatusIndicator(`Killing ${selected.length} container(s)...`);
try {
sendCommand('bulkContainerOperation', { containerIds: selected, operation: 'kill' });
const response = await waitForPeerResponse('Bulk operation completed');
const successCount = response.results.filter((r) => r.success).length;
const failCount = response.results.filter((r) => !r.success).length;
showAlert(
'success',
`Killed ${successCount} container(s)${failCount > 0 ? `, ${failCount} failed` : ''}`
);
clearContainerSelection();
setTimeout(() => sendCommand('listContainers'), 1000);
} catch (error) {
showAlert('danger', error.message || 'Failed to kill containers');
} finally {
hideStatusIndicator();
}
}
window.bulkKillContainers = bulkKillContainers;
async function bulkStopContainers() {
const selected = getSelectedContainers();
console.log('[DEBUG] bulkStopContainers - selected containers:', selected);
if (selected.length === 0) {
console.warn('[WARN] No containers selected');
return;
}
let confirmed = false;
await new Promise((resolve) => {
showConfirmModal(`Stop ${selected.length} container(s)?`, () => {
confirmed = true;
resolve();
});
const modalEl = document.getElementById('confirmModal');
if (modalEl) {
modalEl.addEventListener('hidden.bs.modal', () => {
if (!confirmed) resolve();
}, { once: true });
}
});
if (!confirmed) return;
showStatusIndicator(`Stopping ${selected.length} container(s)...`);
try {
sendCommand('bulkContainerOperation', { containerIds: selected, operation: 'stop' });
const response = await waitForPeerResponse('Bulk operation completed');
const successCount = response.results.filter(r => r.success).length;
const failCount = response.results.filter(r => !r.success).length;
showAlert('success', `Stopped ${successCount} container(s)${failCount > 0 ? `, ${failCount} failed` : ''}`);
clearContainerSelection();
setTimeout(() => sendCommand('listContainers'), 1000);
} catch (error) {
console.error('[ERROR] Bulk stop failed:', error);
showAlert('danger', error.message || 'Failed to stop containers');
} finally {
hideStatusIndicator();
}
}
async function bulkRemoveContainers() {
const selected = getSelectedContainers();
console.log('[DEBUG] bulkRemoveContainers - selected containers:', selected);
if (selected.length === 0) {
console.warn('[WARN] No containers selected');
return;
}
let confirmed = false;
await new Promise((resolve) => {
showConfirmModal(`Remove ${selected.length} container(s)? This action cannot be undone.`, () => {
confirmed = true;
resolve();
});
const modalEl = document.getElementById('confirmModal');
if (modalEl) {
modalEl.addEventListener('hidden.bs.modal', () => {
if (!confirmed) resolve();
}, { once: true });
}
});
if (!confirmed) return;
showStatusIndicator(`Removing ${selected.length} container(s)...`);
try {
sendCommand('bulkContainerOperation', { containerIds: selected, operation: 'remove' });
const response = await waitForPeerResponse('Bulk operation completed');
const successCount = response.results.filter(r => r.success).length;
const failCount = response.results.filter(r => !r.success).length;
showAlert('success', `Removed ${successCount} container(s)${failCount > 0 ? `, ${failCount} failed` : ''}`);
clearContainerSelection();
setTimeout(() => sendCommand('listContainers'), 1000);
} catch (error) {
console.error('[ERROR] Bulk remove failed:', error);
showAlert('danger', error.message || 'Failed to remove containers');
} finally {
hideStatusIndicator();
}
}
// Exec terminal function - uses regular terminal infrastructure
function startExecTerminal(containerId, execId) {
if (!window.activePeer) {
console.error('[ERROR] No active peer connection.');
return;
}
// Reuse terminal infrastructure for exec
// The server already handles exec output streaming via execOutput/execErrorOutput
startTerminal(containerId, `Exec: ${containerId.substring(0, 12)}`);
// Store exec ID for input handling
if (window.openTerminals[containerId]) {
window.openTerminals[containerId].execId = execId;
}
}
async function bulkRemoveImages() {
const selected = getSelectedImages();
if (selected.length === 0) return;
let confirmed = false;
await new Promise((resolve) => {
showConfirmModal(`Remove ${selected.length} image(s)? This action cannot be undone.`, () => {
confirmed = true;
resolve();
});
const modalEl = document.getElementById('confirmModal');
if (modalEl) {
modalEl.addEventListener('hidden.bs.modal', () => {
if (!confirmed) resolve();
}, { once: true });
}
});
if (!confirmed) return;
showStatusIndicator(`Removing ${selected.length} image(s)...`);
let completed = 0;
let failed = 0;
for (const imageId of selected) {
try {
sendCommand('removeImage', { id: imageId, force: true });
await new Promise(resolve => setTimeout(resolve, 500));
completed++;
} catch (error) {
failed++;
console.error(`Failed to remove image ${imageId}:`, error);
}
}
hideStatusIndicator();
showAlert('success', `Removed ${completed} image(s)${failed > 0 ? `, ${failed} failed` : ''}`);
clearImageSelection();
setTimeout(() => loadImages(), 1000);
}
// Deploy View Functions
let deployViewTemplates = [];
/** @type {{ sources?: number, unique?: number, duplicates?: number, fetched?: number }|null} */
let deployViewTemplateStats = null;
let deployTemplateSearchSetup = false;
const DEPLOY_SEARCH_DEBOUNCE_MS = 280;
function clearDeployTemplateCache() {
deployViewTemplates = [];
deployViewTemplateStats = null;
clearMergedTemplateCache();
}
window.__peardockClearDeployTemplateCache = clearDeployTemplateCache;
/**
* @param {object[]} templates
* @param {object} [stats]
*/
function setDeployTemplates(templates, stats) {
deployViewTemplates = Array.isArray(templates) ? templates : [];
deployViewTemplateStats = stats || null;
// Keep modal catalog in sync if open path uses fetchTemplates separately
try {
fetchTemplates();
} catch {
// ignore
}
if (typeof currentView !== 'undefined' && currentView === 'deploy') {
const searchInput = document.getElementById('deploy-template-search-input');
const query = searchInput?.value || '';
displayDeployTemplateList(filterTemplatesByQuery(deployViewTemplates, query));
updateDeployTemplateCount(query);
}
}
window.__peardockSetDeployTemplates = setDeployTemplates;
function updateDeployTemplateCount(query = '') {
const countEl = document.getElementById('deploy-template-count');
if (!countEl) return;
const total = deployViewTemplates.length;
const q = String(query || '').trim();
const filtered = q ? filterTemplatesByQuery(deployViewTemplates, q).length : total;
const st = deployViewTemplateStats;
let base = q ? `${filtered} of ${total} templates` : `${total} templates`;
if (st?.sources != null) {
base += ` · ${st.sources} list${st.sources === 1 ? '' : 's'}`;
}
if (st?.duplicates) {
base += ` · ${st.duplicates} dupes removed`;
}
countEl.textContent = base;
}
function setupDeployTemplateSearch() {
const searchInput = document.getElementById('deploy-template-search-input');
if (!searchInput || deployTemplateSearchSetup) return;
deployTemplateSearchSetup = true;
const runFilter = debounce(() => {
const query = searchInput.value;
const filtered = filterTemplatesByQuery(deployViewTemplates, query);
displayDeployTemplateList(filtered);
updateDeployTemplateCount(query);
}, DEPLOY_SEARCH_DEBOUNCE_MS);
searchInput.addEventListener('input', runFilter);
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
searchInput.value = '';
runFilter();
searchInput.blur();
}
});
}
async function loadDeployView() {
const templateListContainer = document.getElementById('deploy-template-list-container');
if (!templateListContainer) return;
initTemplateDeployer();
setupDeployTemplateSearch();
setupDeployViewFormHandler();
setupDeployResourceSliders();
// Network mode change handler
const networkMode = document.getElementById('deploy-network-mode');
const customNetworkContainer = document.getElementById('deploy-custom-network-container');
if (networkMode && customNetworkContainer && !networkMode.dataset.bound) {
networkMode.dataset.bound = '1';
networkMode.addEventListener('change', (e) => {
if (e.target.value === 'container') {
customNetworkContainer.style.display = 'block';
const input = customNetworkContainer.querySelector('input');
if (input) input.placeholder = 'container-name';
} else if (e.target.value !== 'host' && e.target.value !== 'none' && e.target.value !== 'bridge') {
customNetworkContainer.style.display = 'block';
const input = customNetworkContainer.querySelector('input');
if (input) input.placeholder = 'network-name';
} else {
customNetworkContainer.style.display = 'none';
}
});
}
// Show loading state only when we don't have cache
if (!deployViewTemplates.length) {
const n = getTemplateListUrls().length;
templateListContainer.innerHTML =
`<div class="text-center text-muted py-4"><i class="fas fa-spinner fa-spin me-2"></i>Loading templates from ${n} list${n === 1 ? '' : 's'}…</div>`;
}
try {
if (!deployViewTemplates.length) {
const result = await fetchMergedTemplates(getTemplateListUrls());
deployViewTemplates = result.templates || [];
deployViewTemplateStats = result.stats || null;
// Sync modal list from the same merged set (avoid second multi-fetch when possible)
try {
await fetchTemplates();
} catch {
// ignore modal fetch failures
}
}
const searchInput = document.getElementById('deploy-template-search-input');
const query = searchInput?.value || '';
displayDeployTemplateList(filterTemplatesByQuery(deployViewTemplates, query));
updateDeployTemplateCount(query);
} catch (error) {
console.error('[ERROR] Failed to fetch templates:', error.message);
templateListContainer.innerHTML =
'<div class="text-center text-danger py-4"><i class="fas fa-exclamation-triangle me-2"></i>Failed to load templates. Check network access and Settings → Deploy template lists.</div>';
}
}
function displayDeployTemplateList(templates) {
const templateListContainer = document.getElementById('deploy-template-list-container');
if (!templateListContainer) return;
if (!templates || templates.length === 0) {
templateListContainer.innerHTML =
'<div class="text-center text-muted py-4">No templates match your search</div>';
return;
}
const list = document.createElement('ul');
list.className = 'list-group deploy-template-list';
list.id = 'deploy-template-list-ul';
templates.forEach((template) => {
const listItem = document.createElement('li');
listItem.className =
'list-group-item list-group-item-action d-flex justify-content-between align-items-center bg-dark text-white border-secondary';
listItem.style.cursor = 'pointer';
const title = template.title || template.name || 'Untitled';
const desc = template.description || 'No description';
const logo = template.logo
? `<img src="${template.logo}" alt="" class="me-3" style="width:32px;height:32px;object-fit:contain;" onerror="this.style.display='none'">`
: '<i class="fas fa-cube me-3 text-muted"></i>';
listItem.innerHTML = `
<div class="d-flex align-items-center min-w-0">
${logo}
<div class="min-w-0">
<div class="fw-bold text-truncate">${escapeHtmlLite(title)}</div>
<small class="text-muted d-block text-truncate">${escapeHtmlLite(desc)}</small>
</div>
</div>
<button type="button" class="btn btn-primary btn-sm deploy-template-btn-view flex-shrink-0 ms-2">
<i class="fas fa-rocket me-1"></i>Deploy
</button>
`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function setupDeployResourceSliders() {
const pairs = [
['deploy-cpu-limit', 'deploy-cpu-limit-val', (v) => (Number(v) <= 0 ? 'Unlimited' : `${v} cores`)],
['deploy-cpu-reservation', 'deploy-cpu-reservation-val', (v) => (Number(v) <= 0 ? 'None' : `${v} cores`)],
['deploy-memory-limit', 'deploy-memory-limit-val', (v) => (Number(v) <= 0 ? 'Unlimited' : `${v} MB`)],
['deploy-memory-reservation', 'deploy-memory-reservation-val', (v) => (Number(v) <= 0 ? 'None' : `${v} MB`)],
['duplicate-cpu-limit', 'duplicate-cpu-limit-val', (v) => (Number(v) <= 0 ? 'Unlimited' : `${v} cores`)],
['duplicate-memory-limit', 'duplicate-memory-limit-val', (v) => (Number(v) <= 0 ? 'Unlimited' : `${v} MB`)],
];
for (const [inputId, labelId, fmt] of pairs) {
const input = document.getElementById(inputId);
const label = document.getElementById(labelId);
if (!input || input.dataset.sliderBound) continue;
input.dataset.sliderBound = '1';
const sync = () => {
if (label) label.textContent = fmt(input.value);
};
input.addEventListener('input', sync);
sync();
}
}
window.setupDeployResourceSliders = setupDeployResourceSliders;
function selectTemplateForDeploy(template) {
if (!template || typeof template !== 'object') {
showAlert('danger', 'Invalid template');
return;
}
const formSection = document.getElementById('deploy-form-section');
if (formSection) {
formSection.style.display = 'block';
formSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
initTemplateDeployer();
setupDeployViewFormHandler();
setupDeployResourceSliders();
const form = document.getElementById('deploy-view-form');
if (form) form.reset();
[
'deploy-ports-container',
'deploy-volumes-container',
'deploy-env',
'deploy-labels-container',
'deploy-dns-container',
'deploy-extra-hosts-container',
'deploy-devices-container',
'deploy-capabilities-container',
'deploy-security-opts-container',
'deploy-log-opts-container',
'deploy-sysctls-container',
'deploy-ulimits-container',
'deploy-tmpfs-container',
].forEach((id) => {
const container = document.getElementById(id);
if (container) container.innerHTML = '';
});
// Suggested container name from template title
const nameEl = document.getElementById('deploy-container-name');
if (nameEl) {
const base = String(template.title || template.name || template.image || 'app')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40) || 'app';
nameEl.value = base;
}
const deployImage = document.getElementById('deploy-image');
if (deployImage && template.image) deployImage.value = String(template.image);
if (template.command) {
const cmdEl = document.getElementById('deploy-command');
if (cmdEl) {
cmdEl.value = Array.isArray(template.command)
? template.command.join(' ')
: String(template.command);
}
}
if (template.restart_policy) {
const rp = document.getElementById('deploy-restart-policy');
if (rp) rp.value = String(template.restart_policy);
} else {
const rp = document.getElementById('deploy-restart-policy');
if (rp) rp.value = 'unless-stopped';
}
if (template.interactive) {
const tty = document.getElementById('deploy-tty');
const stdin = document.getElementById('deploy-stdin-open');
if (tty) tty.checked = true;
if (stdin) stdin.checked = true;
}
// Ports
if (Array.isArray(template.ports) && typeof window.addPortMapping === 'function') {
template.ports.forEach((port) => {
if (port == null) return;
let portValue;
if (typeof port === 'string' || typeof port === 'number') {
portValue = String(port);
} else if (typeof port === 'object') {
const containerPort = port.container || port.target || port.containerPort;
if (!containerPort) return;
const protocol = port.protocol || 'tcp';
const hostPort = port.host || port.published || port.hostPort || '';
portValue = hostPort
? `${hostPort}:${containerPort}/${protocol}`
: `${containerPort}/${protocol}`;
} else {
return;
}
window.addPortMapping(portValue);
});
}
// Volumes — support bind/container object and string formats
if (Array.isArray(template.volumes) && typeof window.addVolumeMount === 'function') {
template.volumes.forEach((volume) => {
if (typeof volume === 'string') {
window.addVolumeMount(volume);
return;
}
if (volume && typeof volume === 'object') {
const host = volume.bind || volume.host || volume.source || '';
const container = volume.container || volume.target || volume.containerPath || '';
const mode = volume.mode || (volume.read_only ? 'ro' : '');
if (container) {
const str = host
? `${host}:${container}${mode ? ':' + mode : ''}`
: container;
window.addVolumeMount(str);
} else {
window.addVolumeMount();
}
}
});
}
// Env — use template-aware addEnvVar when possible
if (Array.isArray(template.env) && typeof window.addEnvVar === 'function') {
template.env.forEach((env) => {
if (!env) return;
// Portainer-style template env: { name, label, default, set, preset }
if (typeof env === 'object' && env.name) {
window.addEnvVar(env);
} else if (typeof env === 'string') {
window.addEnvVar();
const container = document.getElementById('deploy-env');
const last = container?.lastElementChild;
const keyInput = last?.querySelector('[data-env-key]');
const valInput = last?.querySelector('[data-env-value]');
const i = env.indexOf('=');
if (keyInput) keyInput.value = i >= 0 ? env.slice(0, i) : env;
if (valInput && i >= 0) valInput.value = env.slice(i + 1);
}
});
}
// Labels
if (template.labels && typeof window.addLabel === 'function') {
const entries = Array.isArray(template.labels)
? template.labels
: Object.entries(template.labels).map(([k, v]) => `${k}=${v}`);
entries.forEach((label) => {
window.addLabel();
const container = document.getElementById('deploy-labels-container');
const last = container?.lastElementChild?.querySelector('input');
if (last) last.value = typeof label === 'string' ? label : `${label}`;
});
}
if (typeof window.updatePreview === 'function') window.updatePreview();
showAlert('info', `Loaded template: ${template.title || template.name || template.image || 'selected'}`);
}
function resetDeployView() {
const formSection = document.getElementById('deploy-form-section');
if (formSection) {
formSection.style.display = 'none';
}
const viewForm = document.getElementById('deploy-view-form');
if (viewForm) {
viewForm.reset();
// Clear all array containers (same as modal reset)
['deploy-ports-container', 'deploy-volumes-container', 'deploy-env', 'deploy-labels-container',
'deploy-dns-container', 'deploy-extra-hosts-container', 'deploy-devices-container',
'deploy-capabilities-container', 'deploy-security-opts-container', 'deploy-log-opts-container',
'deploy-sysctls-container', 'deploy-ulimits-container', 'deploy-tmpfs-container'].forEach(id => {
const container = document.getElementById(id);
if (container) container.innerHTML = '';
});
}
const searchInput = document.getElementById('deploy-template-search-input');
if (searchInput) {
searchInput.value = '';
// Re-display all templates
if (deployViewTemplates.length > 0) {
displayDeployTemplateList(deployViewTemplates);
}
}
}
// Set up deploy view form submit handler
let deployViewFormHandlerSetup = false;
function setupDeployViewFormHandler() {
const deployViewForm = document.getElementById('deploy-view-form');
if (!deployViewForm) {
console.warn('[WARN] Deploy view form not found, will retry when form is visible');
return;
}
// Check if handler is already attached by looking for a data attribute
if (deployViewForm.dataset.handlerAttached === 'true') {
console.log('[INFO] Form handler already attached');
return;
}
console.log('[INFO] Setting up deploy view form handler');
attachFormHandler(deployViewForm);
deployViewForm.dataset.handlerAttached = 'true';
deployViewFormHandlerSetup = true;
}
function attachFormHandler(form) {
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
console.log('[INFO] Deploy view form submitted');
const collect = typeof window.collectFormData === 'function' ? window.collectFormData : null;
const validate = typeof window.validateFormData === 'function' ? window.validateFormData : null;
const deploy = typeof window.deployDockerContainer === 'function' ? window.deployDockerContainer : null;
if (!collect || !deploy) {
showAlert('danger', 'Form collection function not available. Please refresh the page.');
return false;
}
let formData;
try {
formData = collect();
} catch (collectError) {
console.error('[ERROR] Failed to collect form data:', collectError);
showAlert('danger', 'Failed to collect form data. Check console for details.');
return false;
}
// Normalize slider zeros → omit (unlimited)
if (formData.cpuLimit === 0) delete formData.cpuLimit;
if (formData.cpuReservation === 0) delete formData.cpuReservation;
if (formData.memoryLimit === 0) delete formData.memoryLimit;
if (formData.memoryReservation === 0) delete formData.memoryReservation;
// Validate
if (validate) {
try {
const errors = validate(formData);
if (errors.length > 0) {
showAlert('danger', errors.join(' '));
return false;
}
} catch (validateError) {
console.error('[ERROR] Validation error:', validateError);
}
}
if (!formData.containerName || !formData.image) {
showAlert('danger', 'Container name and image are required.');
return false;
}
const containerName = formData.containerName;
// Live job drawer handles progress — no top toast / tray spam
try {
const successResponse = await deploy(formData);
if (successResponse && successResponse.success !== false) {
// Job tray already shows success; only toast when deploy had no live log UI
if (!successResponse.viaJob) {
showAlert('success', successResponse.message || 'Container deployed successfully!');
}
resetDeployView();
if (window.sendCommand) {
window.sendCommand('listContainers');
setTimeout(() => navigateToNewContainer(containerName), 800);
}
} else {
throw new Error(successResponse?.error || successResponse?.message || 'Deployment failed');
}
} catch (error) {
console.error('[ERROR] Failed to deploy container:', error);
// Job drawer already has multi-line error + how-to-fix; skip top toast
if (!error?.viaJob) {
presentError(error, 'deployContainer', { showAlert });
}
}
return false;
});
console.log('[INFO] Deploy view form handler attached');
}
function navigateToNewContainer(containerName) {
console.log(`[INFO] Looking for newly created container: ${containerName}`);
// Set up a handler to catch the container list response and find the new container
const originalHandler = window.handlePeerResponse;
let containerFound = false;
const findContainerHandler = (response) => {
if (response.type === 'containers' && response.data && !containerFound) {
const newContainer = response.data.find(c => {
const name = c.Names?.[0]?.replace(/^\//, '') || '';
return name === containerName;
});
if (newContainer) {
console.log(`[INFO] Found new container, navigating to details: ${containerName}`);
containerFound = true;
// Restore original handler first
window.handlePeerResponse = originalHandler;
// Navigate to container details
if (typeof showContainerDetails === 'function') {
showContainerDetails(newContainer);
} else {
console.error('[ERROR] showContainerDetails function not available');
}
return;
}
}
// Not the response we're looking for, pass to original handler
if (typeof originalHandler === 'function') {
originalHandler(response);
}
};
window.handlePeerResponse = findContainerHandler;
// Request container list
if (window.activePeer && typeof sendCommand === 'function') {
sendCommand('listContainers');
}
// Timeout after 10 seconds
setTimeout(() => {
if (window.handlePeerResponse === findContainerHandler && !containerFound) {
window.handlePeerResponse = originalHandler;
console.warn('[WARN] Timeout waiting for new container to appear. Navigating to containers view.');
navigateToView('containers');
}
}, 10000);
}
// Expose to window
window.pullImage = pullImage;
// Hub search Enter key in pull modal
document.getElementById('hub-search-term')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
searchDockerHub();
}
});
window.createNetwork = createNetwork;
window.createVolume = createVolume;
window.showContainerDetails = showContainerDetails;
window.resetDeployView = resetDeployView;
window.navigateToNewContainer = navigateToNewContainer;
window.updateBulkActionsToolbar = updateBulkActionsToolbar;
window.updateBulkActionsImagesToolbar = updateBulkActionsImagesToolbar;
window.toggleSelectAllContainers = toggleSelectAllContainers;
window.toggleSelectAllImages = toggleSelectAllImages;
window.clearContainerSelection = clearContainerSelection;
window.clearImageSelection = clearImageSelection;
window.bulkStartContainers = bulkStartContainers;
window.bulkStopContainers = bulkStopContainers;
window.bulkRemoveContainers = bulkRemoveContainers;
window.bulkRemoveImages = bulkRemoveImages;
window.filterImages = filterImages;
// Collapse Sidebar Functionality - set up in DOMContentLoaded
function handleRpcMessage(response, conn) {
const topicId = conn?.id || manager.activeId;
const peer = conn || manager.active;
try {
console.log(`[DEBUG] RPC message (${topicId}):`, response);
if (response && response.message) console.log(response.message)
// Handle errors first - check for error responses before processing
if (response.error) {
const errorMessage = handleErrorResponse(response);
// Error has been sent to notification center, but continue processing
// in case there are other handlers that need to see the error
}
if (response.success && response.message && typeof response.message === 'string' && response.message.includes('deployed successfully')) {
console.log(`[INFO] Template deployed successfully: ${response.message}`);
closeAllModals(); // Close all modals after successful deployment
hideStatusIndicator();
startStatsInterval(); // Restart stats polling
showAlert('success', response.message);
hideStatusIndicator();
}
// Ensure the data is for a known connection (pushes always have a conn id)
if (topicId && !connections[topicId] && peer) {
// Still process if this is the active manager connection
if (manager.active && peer.id !== manager.active.id) {
console.warn(`[WARN] No connection found for topic: ${topicId}. Ignoring data.`);
return;
}
}
if (
topicId &&
connections[topicId]?.peer &&
peer &&
peer !== connections[topicId].peer &&
peer.id !== connections[topicId].peer.id
) {
console.warn(`[WARN] Ignoring data from a non-active peer for topic: ${topicId}`);
return;
}
// Delegate handling based on the response type
switch (response.type) {
case 'allStats':
console.log('[INFO] Received aggregated stats for all containers.');
response.data.forEach((stats) => updateContainerStats(stats));
break;
case 'containers':
console.log('[INFO] Processing container list...');
renderContainers(response.data, topicId); // Render containers specific to this topic
// Update dashboard stats if on dashboard view
if (currentView === 'dashboard') {
updateDashboardStats(response.data, null, null);
}
break;
case 'terminalOutput':
case 'terminalErrorOutput':
appendTerminalOutput(response.data, response.containerId, response.encoding);
if (window.handleDetailsTerminalOutput) {
window.handleDetailsTerminalOutput(response.data, response.containerId, response.encoding);
}
break;
case 'execOutput':
console.log('[INFO] Appending exec output...');
appendTerminalOutput(response.data, response.containerId, response.encoding);
break;
case 'execErrorOutput':
console.log('[INFO] Appending exec error output...');
appendTerminalOutput(response.data, response.containerId, response.encoding);
break;
case 'stacks':
console.log('[INFO] Handling stacks list...');
window.currentStacksData = response.data;
renderStacks(response.data);
// Check if we have a pending stack inspect
if (window.pendingStackInspect) {
const { stackName } = window.pendingStackInspect;
delete window.pendingStackInspect;
hideStatusIndicator();
const stack = response.data.find(s => s.name === stackName);
if (stack) {
formatAndPopulateStackModal(stack);
const modalTitle = document.getElementById('stackInspectModalLabel');
if (modalTitle) {
modalTitle.innerHTML = `<i class="fas fa-info-circle me-2"></i>Stack Information: ${stackName}`;
}
const modal = new bootstrap.Modal(document.getElementById('stackInspectModal'));
modal.show();
document.getElementById('stack-inspect-formatted-view').style.display = 'block';
document.getElementById('stack-inspect-json-view').style.display = 'none';
}
}
break;
case 'containerConfig':
console.log('[INFO] Handling container configuration...');
if (window.inspectContainerCallback) {
window.inspectContainerCallback(response.data);
window.inspectContainerCallback = null; // Reset the callback
}
break;
case 'logs':
console.log('[INFO] Handling logs output...');
if (window.handleLogOutput) {
window.handleLogOutput(response);
}
break;
case 'systemInfo':
console.log('[INFO] Handling system information...');
updateSystemInfo(response.data);
break;
case 'images':
console.log('[INFO] Handling images list...');
renderImages(response.data);
// Update dashboard stats if on dashboard view
if (currentView === 'dashboard') {
updateDashboardStats(null, response.data, null);
}
break;
case 'networks':
console.log('[INFO] Handling networks list...');
renderNetworks(response.data);
// Update dashboard stats if on dashboard view
if (currentView === 'dashboard') {
updateDashboardStats(null, null, response.data);
}
break;
case 'volumes':
console.log('[INFO] Handling volumes list...');
// Store in cache and render
let volumesToRender = null;
if (response.data && Array.isArray(response.data)) {
volumesToRender = response.data;
} else if (response.volumes && Array.isArray(response.volumes)) {
// Fallback for old format
volumesToRender = response.volumes;
}
if (volumesToRender !== null) {
// Always update store first (this will trigger subscriptions)
volumesStore.set(volumesToRender);
// Always render if on volumes view to ensure UI is updated
if (currentView === 'volumes') {
renderVolumes(volumesToRender);
}
} else {
console.warn('[WARN] Received volumes message but no valid volumes data found');
}
break;
case 'pullProgress': {
const pct = pullProgressPercent(response);
const label = response.image || 'image';
const msg = [response.status, response.progress].filter(Boolean).join(' ');
if (pct != null) {
updateProgressBar('pull-image', pct, msg || `Pulling ${label}`);
updateStatusIndicator(`Pulling ${label}: ${pct}%`);
} else if (msg) {
updateStatusIndicator(`Pull: ${msg}`);
}
break;
}
case 'buildProgress': {
const line = (response.stream || response.status || response.error || '').trim();
if (line) {
updateStatusIndicator(`Build: ${line.slice(0, 120)}`);
// Append to build output area if present
const out = document.getElementById('build-output') || document.getElementById('buildImageOutput');
if (out) {
out.textContent = (out.textContent || '') + line;
out.scrollTop = out.scrollHeight;
}
}
break;
}
case 'dockerEvent': {
if (window.handleDockerEvent) {
window.handleDockerEvent(response.data);
}
if (window.peardockOps?.appendLiveEvent) {
window.peardockOps.appendLiveEvent(response.data || response);
} else if (typeof window !== 'undefined') {
import('./ui/ops-app.js').then((m) => m.appendLiveEvent?.(response.data || response)).catch(() => {});
}
break;
}
case 'systemDf':
renderSystemDf(response.data);
break;
case 'containerTop':
renderContainerTop(response);
if (window.pendingRpcCallback) {
window.pendingRpcCallback(response);
window.pendingRpcCallback = null;
}
break;
case 'imageHistory': {
if (response.data) {
populateImageHistoryFromApi(response.data);
}
if (window.pendingRpcCallback) {
window.pendingRpcCallback(response);
window.pendingRpcCallback = null;
}
break;
}
case 'containerStats':
case 'imageSearch':
case 'stackPs':
case 'stackLogs':
if (window.pendingRpcCallback) {
window.pendingRpcCallback(response);
window.pendingRpcCallback = null;
}
break;
case 'imageConfig':
console.log(`[INFO] Handling imageConfig...`);
if (response.data && window.pendingImageInspect) {
const { imageId } = window.pendingImageInspect;
delete window.pendingImageInspect;
hideStatusIndicator();
const modalTitle = document.getElementById('imageInspectModalLabel');
if (modalTitle) {
const repoTag = response.data.RepoTags?.[0] || imageId.substring(0, 12);
modalTitle.innerHTML = `<i class="fas fa-info-circle me-2"></i>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 = `<i class="fas fa-info-circle me-2"></i>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 = `<i class="fas fa-info-circle me-2"></i>Volume Information: ${volumeName}`;
}
formatAndPopulateVolumeModal(response.data);
window.currentVolumeInspectConfig = response.data;
const modal = new bootstrap.Modal(document.getElementById('volumeInspectModal'));
modal.show();
document.getElementById('volume-inspect-formatted-view').style.display = 'block';
document.getElementById('volume-inspect-json-view').style.display = 'none';
}
break;
default:
// Success-only RPC replies (start/stop/etc.) have no type — that is normal
if (response.success && Array.isArray(response.contents) && response.path !== undefined) {
// directory browser
} else if (response.success && Array.isArray(response.volumes)) {
// volumes list without type
} else if (response.success || response.error) {
// command acknowledgement
} else if (response.type) {
console.warn(`[WARN] Unhandled response type: ${response.type}`);
}
break;
}
// Handle volumes responses - update cache and route to handlers if needed
// Check for volumes in response (both new format with type and old format)
// Note: This is a fallback for responses that weren't handled in the switch statement above
// The 'volumes' case in the switch should have already handled type: 'volumes' responses
let volumesArray = null;
if (response && response.type !== 'volumes' && response.success === true && Array.isArray(response.volumes)) {
// Old format volumes response that wasn't caught by switch
volumesArray = response.volumes;
}
if (volumesArray !== null) {
// Always update the cache first (this will trigger subscriptions)
volumesStore.set(volumesArray);
// Render if on volumes view
if (currentView === 'volumes') {
renderVolumes(volumesArray);
}
// Route to active volume selectors if they exist (for deploy modal)
if (window.activeVolumeHandlers && window.activeVolumeHandlers.size > 0) {
for (const [volumeId, handlerInfo] of window.activeVolumeHandlers.entries()) {
const state = handlerInfo?.state;
// Process if not received yet
if (state && !state.volumesReceived) {
if (handlerInfo.protectedHandler && typeof handlerInfo.protectedHandler === 'function') {
handlerInfo.protectedHandler(response);
} else if (handlerInfo.handler && typeof handlerInfo.handler === 'function') {
handlerInfo.handler(response);
}
}
}
}
}
// Handle peer response callback if defined
// This allows custom handlers (like directory browser) to process responses
if (typeof window.handlePeerResponse === 'function') {
try {
window.handlePeerResponse(response);
} catch (handlerErr) {
// Never let a stale temporary handler break listVolumes / dashboard loads
console.warn(
'[WARN] handlePeerResponse failed:',
handlerErr?.message || handlerErr,
response?.type || response
);
}
}
} catch (err) {
console.error(`[ERROR] Failed to process RPC message: ${err.message}`, response);
// Avoid alarming the user for non-critical handler bugs on successful lists
if (!(response && response.success && response.type)) {
showAlert('danger', 'Failed to process server message. Check the console for details.');
}
}
}
// Add a new connection - event listener set up in DOMContentLoaded
async function addConnection(publicKeyHex, meta = {}) {
console.log(`[DEBUG] Adding connection with public key: ${publicKeyHex}`);
publicKeyHex = (publicKeyHex || '').trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) {
showAlert('danger', 'Invalid server public key. Expected 64 hex characters.');
return;
}
// Keep welcome visible until HyperDHT + RPC are actually connected
const topicId = publicKeyHex.substring(0, 12);
const alias = meta.alias || connections[topicId]?.alias || null;
const inviteToken = meta.inviteToken || connections[topicId]?.inviteToken || null;
// Already live — just activate
if (connections[topicId]?.peer?.connected) {
manager.setActive(connections[topicId].peer.id || topicId);
switchConnection(topicId);
hideWelcomePage();
return;
}
connections[topicId] = {
publicKeyHex,
topicHex: publicKeyHex,
peer: null,
alias,
inviteToken,
connectedAt: null,
lastHealthCheck: null,
latency: null,
healthStatus: 'connecting',
};
saveConnections();
// Ensure a single list item per peer (restores / retries)
let connectionItem = connectionList?.querySelector?.(`[data-topic-id="${topicId}"]`);
if (!connectionItem) {
connectionItem = createPeerListItem(topicId, connections[topicId]);
connectionList?.appendChild(connectionItem);
updatePeersViewChrome();
} else {
updateConnectionDisplay(topicId);
updateConnectionStatus(topicId, false);
}
refreshContainerStats();
try {
if (!meta.quiet) showStatusIndicator('Connecting…');
const conn = await manager.connect(publicKeyHex, {
inviteToken: inviteToken || undefined,
alias: alias || undefined,
});
connections[topicId].peer = conn;
connections[topicId].connectedAt = Date.now();
connections[topicId].healthStatus = 'healthy';
if (alias) connections[topicId].alias = alias;
if (inviteToken) connections[topicId].inviteToken = inviteToken;
// Prefer server-provided alias if we did not set one
if (!connections[topicId].alias && conn.alias) {
connections[topicId].alias = conn.alias;
}
saveConnections();
updateConnectionStatus(topicId, true);
updateConnectionDisplay(topicId);
startHealthMonitoring(topicId);
manager.setActive(conn.id);
switchConnection(topicId);
startStatsInterval();
warmSnapshot();
hideWelcomePage();
applyRoleUI();
if (!meta.quiet) {
hideStatusIndicator();
showAlert('success', `Connected to ${peerDisplayName(connections[topicId], topicId)}`);
showFirstConnectChecklist();
}
} catch (err) {
console.error('[ERROR] Connection failed', err);
connections[topicId].healthStatus = 'error';
// Keep peer configured so the next restart / retry can reconnect
saveConnections();
updateConnectionStatus(topicId, false);
updateConnectionDisplay(topicId);
if (!meta.quiet) hideStatusIndicator(false);
if (!meta.quiet) {
presentError(err, 'connect', { showAlert, notificationManager });
}
// Leave failed peer slot in list but keep welcome if nothing is live
if (!hasActiveConnection()) {
showWelcomePage();
}
}
}
/**
* Open the Add Peer modal (sidebar button / welcome CTA).
*/
function openAddConnectionModal() {
const modalEl = document.getElementById('addConnectionModal');
if (!modalEl || typeof bootstrap === 'undefined') return;
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
// Focus key field after animation
setTimeout(() => {
document.getElementById('new-connection-topic')?.focus();
}, 200);
}
// Function to open the template deploy modal
function openTemplateDeployModal(topicId) {
// Pass the topic ID or other connection-specific info if needed
console.log(`[INFO] Preparing template deploy modal for topic: ${topicId}`);
// Ensure the modal fetches templates
fetchTemplates(); // Refresh template list
// Show the modal
const templateDeployModal = new bootstrap.Modal(document.getElementById('templateDeployModal'));
templateDeployModal.show();
}
// Initialize connections from cookies on page load
document.addEventListener('DOMContentLoaded', () => {
// Initialize DOM elements immediately
containerList = document.getElementById('container-list');
connectionList = document.getElementById('connection-list');
addConnectionForm = document.getElementById('add-connection-form');
newConnectionTopic = document.getElementById('new-connection-topic');
connectionTitle = document.getElementById('connection-title');
dashboard = document.getElementById('dashboard-view') || document.getElementById('dashboard');
welcomePage = document.getElementById('welcome-page');
sidebar = document.getElementById('sidebar');
collapseSidebarBtn = document.getElementById('collapse-sidebar-btn');
alertContainer = document.getElementById('alert-container');
// Always start on welcome until a peer is connected
showWelcomePage();
// Initialize modal elements
duplicateModalElement = document.getElementById('duplicateModal');
if (duplicateModalElement && typeof bootstrap !== 'undefined') {
duplicateModal = new bootstrap.Modal(duplicateModalElement);
}
duplicateContainerForm = document.getElementById('duplicate-container-form');
hideStatusIndicator();
// Initialize notification tray early - available globally regardless of connection status
initNotificationTray();
// Set up deploy view form handler on DOMContentLoaded
setTimeout(() => {
setupDeployViewFormHandler();
setupDeployStackHandler();
setupBuildImageHandler();
// Also add a direct button click handler as fallback
const deployButton = document.querySelector('#deploy-view-form button[type="submit"]');
if (deployButton) {
deployButton.addEventListener('click', (e) => {
e.preventDefault();
const form = document.getElementById('deploy-view-form');
if (form) {
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
});
}
}, 500);
// Add peer modal: sidebar quick-add / Peers view / welcome
document.getElementById('open-add-connection-btn')?.addEventListener('click', () => {
openAddConnectionModal();
});
document.getElementById('peers-view-add-btn')?.addEventListener('click', () => {
openAddConnectionModal();
});
document.getElementById('peers-view-refresh-btn')?.addEventListener('click', () => {
loadPeersView();
});
document.getElementById('welcome-add-peer-btn')?.addEventListener('click', () => {
openAddConnectionModal();
});
// Set up event listeners that depend on DOM elements
if (addConnectionForm) {
addConnectionForm.addEventListener('submit', (e) => {
e.preventDefault();
const topicHex = newConnectionTopic ? newConnectionTopic.value.trim() : '';
const inviteEl = document.getElementById('new-connection-invite');
const aliasEl = document.getElementById('new-connection-alias');
const inviteToken = inviteEl ? inviteEl.value.trim() : '';
const alias = aliasEl ? aliasEl.value.trim() : '';
if (topicHex) {
addConnection(topicHex, {
inviteToken: inviteToken || undefined,
alias: alias || undefined,
});
if (newConnectionTopic) newConnectionTopic.value = '';
if (inviteEl) inviteEl.value = '';
if (aliasEl) aliasEl.value = '';
// Close modal after submit
const modalEl = document.getElementById('addConnectionModal');
if (modalEl && typeof bootstrap !== 'undefined') {
bootstrap.Modal.getInstance(modalEl)?.hide();
}
}
});
}
// Track B ops shell (command palette, smart network, host/events/settings, jobs)
initOpsApp({
navigateToView,
sendCommand,
});
// Prefer smart network modal for create buttons
document.querySelectorAll('[data-bs-target="#createNetworkModal"]').forEach((btn) => {
btn.setAttribute('data-bs-target', '#createNetworkSmartModal');
btn.addEventListener('click', (e) => {
e.preventDefault();
window.peardockOps?.openSmartNetworkModal?.();
});
});
// Browser history + deep links
initHashRouting();
const hashView = (location.hash || '').replace(/^#\/?/, '').split('?')[0];
if (hashView && hasActiveConnection()) {
navigateToView(hashView, { replace: true, fromHistory: true });
}
// List search inputs (images / networks / volumes)
document.getElementById('image-search')?.addEventListener('input', () => {
if (typeof allImages !== 'undefined') renderImages(allImages);
});
document.getElementById('network-search')?.addEventListener('input', () => {
if (typeof allNetworks !== 'undefined') renderNetworks(allNetworks);
});
document.getElementById('volume-search')?.addEventListener('input', () => {
if (typeof allVolumesCache !== 'undefined') renderVolumes(allVolumesCache);
});
document.getElementById('stack-search')?.addEventListener('input', () => {
if (typeof allStacksCache !== 'undefined') renderStacks(allStacksCache);
});
applyRoleUI();
// Set up sidebar collapse functionality
if (collapseSidebarBtn) {
const syncSidebarCollapseUi = () => {
const collapsed = sidebar?.classList.contains('collapsed');
collapseSidebarBtn.innerHTML = collapsed
? '<i class="fas fa-chevron-right"></i>'
: '<i class="fas fa-chevron-left"></i>';
collapseSidebarBtn.title = collapsed ? 'Expand sidebar' : 'Collapse sidebar';
collapseSidebarBtn.setAttribute(
'aria-label',
collapsed ? 'Expand sidebar' : 'Collapse sidebar'
);
collapseSidebarBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
};
collapseSidebarBtn.addEventListener('click', () => {
if (sidebar) {
sidebar.classList.toggle('collapsed');
try {
localStorage.setItem(
'peardock.sidebar.collapsed',
sidebar.classList.contains('collapsed') ? '1' : '0'
);
} catch {
// ignore
}
syncSidebarCollapseUi();
}
});
// Restore last collapsed state
try {
if (localStorage.getItem('peardock.sidebar.collapsed') === '1' && sidebar) {
sidebar.classList.add('collapsed');
}
} catch {
// ignore
}
syncSidebarCollapseUi();
}
// Reset all peers lives in Settings (not the sidebar)
document.getElementById('settings-reset-peers-btn')?.addEventListener('click', async () => {
const count = Object.keys(connections).length;
if (count === 0) {
showAlert('info', 'No saved peers to reset');
return;
}
const ok = window.peardockOps?.confirmDestructive
? await window.peardockOps.confirmDestructive(
'Reset all peers?',
`Remove all ${count} saved peer(s) from this client? You will need to re-add their public keys.`
)
: false;
if (ok) resetAllPeers();
});
// Initialize container filtering (lightweight, doesn't block)
initContainerFiltering();
// Initialize navigation (respects connection state / welcome)
initNavigation();
// Notification tray will be initialized after connections are restored
if (duplicateContainerForm) {
duplicateContainerForm.addEventListener('submit', async (e) => {
e.preventDefault();
let formData;
try {
formData = collectDuplicateFormData();
} catch (collectError) {
console.error('[ERROR] Failed to collect duplicate form data:', collectError);
showAlert('danger', 'Failed to collect form data. Check console for details.');
return;
}
// Validate required fields
if (!formData.containerName || !formData.image) {
showAlert('danger', 'Container name and image are required.');
return;
}
// Get container name for notifications
const containerName = formData.containerName || 'container';
// Close modal immediately before async operation
if (duplicateModal) {
duplicateModal.hide();
}
closeAllModals();
// Add notification for container creation
notificationManager.add('info', `Creating container "${containerName}"...`, { autoDismiss: false });
showStatusIndicator('Preparing container configuration...');
try {
// Use deployContainer command with the collected form data
// This reuses the same deployment logic
const originalHandler = window.handlePeerResponse;
let timeoutId = null;
let isResolved = false;
const duplicateHandler = (response) => {
if (isResolved) {
if (typeof originalHandler === 'function') {
originalHandler(response);
}
return;
}
const isDuplicateResponse =
(response.success && response.message && typeof response.message === 'string' && response.message.includes('deployed successfully')) ||
(response.error && (
(response.message && typeof response.message === 'string' && response.message.includes('deploy')) ||
(typeof response.error === 'string' && (response.error.includes('deploy') || response.error.includes('Container')))
));
if (isDuplicateResponse) {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
window.handlePeerResponse = originalHandler;
isResolved = true;
if (response.success && response.message && response.message.includes('deployed successfully')) {
// Update message to indicate we're transferring
updateStatusIndicator('Transferring you to the container');
// Update notification to success
notificationManager.add('success', `Container "${formData.containerName}" created successfully!`);
showAlert('success', `Container "${formData.containerName}" duplicated successfully!`);
sendCommand('listContainers');
// Hide spinner after a delay to allow navigation
setTimeout(() => {
hideStatusIndicator();
}, 1500);
} else if (response.error) {
hideStatusIndicator();
const errorMessage = typeof response.error === 'string'
? response.error
: (response.error?.message || response.error?.toString() || 'Unknown error');
// Update notification to error
notificationManager.add('danger', `Failed to create container "${formData.containerName}"`);
showAlert('danger', errorMessage);
}
} else {
if (typeof originalHandler === 'function') {
originalHandler(response);
}
}
};
window.handlePeerResponse = duplicateHandler;
// Update message when starting deployment
updateStatusIndicator('Creating container...');
if (typeof window.sendCommand === 'function') {
window.sendCommand('deployContainer', formData);
} else {
window.handlePeerResponse = originalHandler;
hideStatusIndicator();
showAlert('danger', 'sendCommand is not available. Please ensure app.js is loaded.');
return;
}
timeoutId = setTimeout(() => {
if (!isResolved) {
window.handlePeerResponse = originalHandler;
isResolved = true;
hideStatusIndicator();
// Update notification to timeout error
notificationManager.add('danger', `Failed to create container "${containerName}" (timeout)`);
showAlert('danger', 'Duplication timed out. No response from server.');
}
}, 60000);
} catch (error) {
hideStatusIndicator();
console.error('[ERROR] Failed to duplicate container:', error);
// Update notification to error
notificationManager.add('danger', `Failed to create container "${containerName}"`);
showAlert('danger', error.message || 'Failed to duplicate container. Check console for details.');
}
});
}
// Restore configured peers from ~/.config/peardock/cache/peers.json and re-dial
try {
console.log('[INFO] Peer cache path:', getPeersCachePath());
const savedConnections = loadConnections();
const keys = Object.keys(savedConnections);
console.log('[INFO] Loading saved connections:', keys.length, keys);
// Restore in parallel; keep list populated even if dial fails
Promise.all(
keys.map(async (topicId) => {
try {
const entry = savedConnections[topicId];
const publicKeyHex = entry.publicKeyHex || entry.topicHex || entry.topic;
if (!publicKeyHex) return;
await addConnection(String(publicKeyHex), {
alias: entry.alias || undefined,
inviteToken: entry.inviteToken || undefined,
quiet: true,
});
} catch (err) {
console.error(`[ERROR] Failed to restore connection ${topicId}: ${err.message}`);
}
})
).then(() => {
if (hasActiveConnection()) {
hideWelcomePage();
startStatsInterval();
} else if (keys.length > 0) {
// Peers configured but none live yet — stay on welcome with list visible
showWelcomePage();
showAlert('info', `Restored ${keys.length} saved peer(s). Reconnecting…`);
} else {
showWelcomePage();
}
assertVisibility();
});
// Show peer slots immediately while dials are in flight
showWelcomePage();
assertVisibility();
} catch (err) {
console.error(`[ERROR] Failed to initialize connections: ${err.message}`);
showWelcomePage();
}
});
function disconnectConnection(topicId, connectionItem) {
const connection = connections[topicId];
if (!connection) {
console.error(`[ERROR] No connection found for topicId: ${topicId}`);
return;
}
// Clean up terminals
if (window.openTerminals[topicId]) {
console.log(`[INFO] Closing terminals for topic: ${topicId}`);
window.openTerminals[topicId].forEach((terminalId) => {
try {
cleanUpTerminal(terminalId);
} catch (err) {
console.error(`[ERROR] Failed to clean up terminal ${terminalId}: ${err.message}`);
}
});
delete window.openTerminals[topicId];
}
// Stop health monitoring
if (connection.healthCheckInterval) {
clearInterval(connection.healthCheckInterval);
}
// Close HyperDHT / protomux-rpc connection and forget from disk roster
if (connection.peer) {
manager.disconnect(topicId, { forget: true }).catch(() => {});
connection.peer.close?.().catch?.(() => {});
} else {
// Slot existed without a live socket — still drop from cache
manager.disconnect(topicId, { forget: true }).catch(() => {});
}
// Remove from global connections
delete connections[topicId];
// Persist remaining peers (merge + removeId keeps other saved peers)
saveConnections({ removeId: topicId });
// Remove the connection item from the UI
if (connectionItem && connectionList?.contains(connectionItem)) {
connectionList.removeChild(connectionItem);
}
updatePeersViewChrome();
// Reset UI if this was the active connection
if (manager.active?.id === topicId || manager.active === connection.peer) {
const connectionTitleEl = document.getElementById('connection-title');
if (connectionTitleEl) {
connectionTitleEl.textContent = 'Choose a Connection';
}
const dashboardEl = document.getElementById('dashboard');
if (dashboardEl) {
dashboardEl.classList.add('hidden');
}
resetContainerList();
stopStatsInterval();
}
// Welcome when nothing live remains
if (Object.keys(connections).length === 0 || !hasActiveConnection()) {
showWelcomePage();
}
console.log(`[INFO] Disconnected and removed connection: ${topicId}`);
}
// Function to reset the container list
function resetContainerList() {
containerList.innerHTML = ''; // Clear the existing list
// Clean up smoothedStats for all containers when list is reset
Object.keys(smoothedStats).forEach(containerId => {
delete smoothedStats[containerId];
});
console.log('[INFO] Container list cleared.');
}
// Function to reset the connections view
function resetConnectionsView() {
if (!connectionList) connectionList = document.getElementById('connection-list');
if (connectionList) connectionList.innerHTML = '';
Object.keys(connections).forEach((topicId) => {
const conn = connections[topicId];
const connectionItem = createPeerListItem(topicId, conn);
connectionList?.appendChild(connectionItem);
});
updatePeersViewChrome();
console.log('[INFO] Connections view reset.');
}
// Update connection status
function updateConnectionStatus(topicId, isConnected) {
const connectionItem = document.querySelector(`[data-topic-id="${topicId}"]`);
if (connectionItem) {
const statusElement = connectionItem.querySelector('.connection-status');
if (statusElement) {
statusElement.className = `connection-status ${isConnected ? 'status-connected' : 'status-disconnected'}`;
}
}
}
// Update connection display with alias and latency (never overflows action buttons)
function updateConnectionDisplay(topicId) {
const connectionItem = document.querySelector(`[data-topic-id="${topicId}"]`);
if (!connectionItem) return;
const connection = connections[topicId];
if (!connection) return;
const nameElement = connectionItem.querySelector('.connection-name');
const metaElement = connectionItem.querySelector('.connection-meta');
if (nameElement) {
nameElement.textContent = peerDisplayName(connection, topicId);
}
if (metaElement) {
metaElement.textContent = peerMetaText(connection);
}
const fullKey = connection.publicKeyHex || topicId;
connectionItem.title = connection.alias
? `${connection.alias} · ${fullKey}`
: String(fullKey);
}
// Health monitoring for connections
function startHealthMonitoring(topicId) {
const connection = connections[topicId];
if (!connection) return;
const healthCheckInterval = setInterval(async () => {
const entry = connections[topicId];
if (!entry?.peer?.connected) {
clearInterval(healthCheckInterval);
return;
}
try {
const ms = await entry.peer.ping();
entry.latency = ms;
entry.lastHealthCheck = Date.now();
entry.healthStatus = ms < 5000 ? 'healthy' : 'slow';
updateConnectionDisplay(topicId);
} catch (err) {
entry.healthStatus = 'unhealthy';
updateConnectionStatus(topicId, false);
clearInterval(healthCheckInterval);
}
}, 10000);
connections[topicId].healthCheckInterval = healthCheckInterval;
}
// Switch between connections
function switchConnection(topicId) {
const connection = connections[topicId];
if (!connection || !connection.peer?.connected) {
console.error('[ERROR] No connection found or no active peer.');
if (!hasActiveConnection()) {
showWelcomePage();
stopStatsInterval();
}
return;
}
if (connection.peer?.id) {
manager.setActive(connection.peer.id);
}
// Mark active row in peer list
document.querySelectorAll('#connection-list .list-group-item').forEach((item) => {
item.classList.toggle('active', item.dataset.topicId === topicId);
});
hideWelcomePage();
resetContainerList();
console.log(`[INFO] Switched to connection: ${topicId}`);
startStatsInterval();
sendCommand(Methods.listContainers);
}
// Attach switchConnection to the global window object
window.switchConnection = switchConnection;
// Send a command to the active peer via protomux-rpc
// opts.silent: suppress user-facing error toast (background polls)
function sendCommand(command, args = {}, opts = {}) {
if (!manager.active?.connected) {
console.debug('[DEBUG] No active peer to send command (this is normal during initialization).');
return Promise.resolve(null);
}
const silent = opts.silent === true;
if (!silent) console.log(`[DEBUG] RPC ${command}`, args);
return manager
.send(command, args, { silent })
.then((response) => {
if (response) {
// Route request responses through the same UI pipeline as pushes
handleRpcMessage(response, manager.active);
}
return response;
})
.catch((err) => {
// manager.send normally swallows; keep presentError for unexpected throws
console.error(`[ERROR] RPC ${command} failed:`, err?.message || err);
presentError(err, command, { showAlert, silent });
return null;
});
}
// Attach sendCommand to the global window object
window.sendCommand = sendCommand;
// Cache for DOM queries
const domCache = {
containerList: null,
connectionList: null,
dashboard: null,
welcomePage: null,
};
// Initialize DOM cache
function initDOMCache() {
domCache.containerList = document.getElementById('container-list');
domCache.connectionList = document.getElementById('connection-list');
domCache.dashboard = document.getElementById('dashboard');
domCache.welcomePage = document.getElementById('welcome-page');
}
// Initialize cache on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initDOMCache);
} else {
initDOMCache();
}
// Debounce utility
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Container filtering and sorting state
let containerFilterState = {
search: '',
status: 'all',
sort: 'name-asc',
allContainers: []
};
// Filter and sort containers
function filterAndSortContainers(containers) {
let filtered = [...containers];
// Apply search filter
if (containerFilterState.search) {
const searchLower = containerFilterState.search.toLowerCase();
filtered = filtered.filter(container => {
const name = container.Names[0]?.replace(/^\//, '') || '';
const image = container.Image || '';
return name.toLowerCase().includes(searchLower) ||
image.toLowerCase().includes(searchLower);
});
}
// Apply status filter
if (containerFilterState.status !== 'all') {
filtered = filtered.filter(container => {
const state = container.State?.toLowerCase() || '';
return state === containerFilterState.status.toLowerCase();
});
}
// Apply sorting
const [sortField, sortOrder] = containerFilterState.sort.split('-');
filtered.sort((a, b) => {
let aVal, bVal;
switch (sortField) {
case 'name':
aVal = (a.Names[0]?.replace(/^\//, '') || '').toLowerCase();
bVal = (b.Names[0]?.replace(/^\//, '') || '').toLowerCase();
break;
case 'cpu':
aVal = smoothedStats[a.Id]?.cpu || 0;
bVal = smoothedStats[b.Id]?.cpu || 0;
break;
case 'memory':
aVal = smoothedStats[a.Id]?.memory || 0;
bVal = smoothedStats[b.Id]?.memory || 0;
break;
default:
return 0;
}
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
return filtered;
}
// Helper function to truncate sha256 hash image names for display
function formatImageName(imageName) {
if (!imageName || imageName === '-') {
return imageName;
}
// Check if image name starts with sha256: and is a hash
if (imageName.startsWith('sha256:')) {
const hash = imageName.substring(7); // Remove 'sha256:' prefix
// Check if it's a valid hex hash (64 characters for full SHA256)
if (/^[a-f0-9]{64}$/i.test(hash)) {
// Truncate to first 12 characters (standard Docker short hash) + '...'
return `sha256:${hash.substring(0, 12)}...`;
}
}
return imageName;
}
// Render the container list with optimized DOM manipulation
/** Virtualized container table state (windowed rows for 500+ containers) */
const containerVirt = {
rows: [],
topicId: null,
scrollEl: null,
bound: false,
rowHeight: 52,
overscan: 8,
};
function buildContainerRow(container) {
const name = container.Names[0]?.replace(/^\//, '') || 'Unknown';
const image = formatImageName(container.Image || '-');
const containerId = container.Id;
const ipAddress = container.ipAddress || 'No IP Assigned';
if (ipAddress === 'No IP Assigned') {
sendCommand('inspectContainer', { id: container.Id });
}
const row = document.createElement('tr');
row.dataset.containerId = containerId;
const state = container.State || 'Unknown';
const stateLower = state.toLowerCase();
const statusClass =
stateLower === 'running'
? 'status-running'
: stateLower === 'exited' || stateLower === 'stopped'
? 'status-exited'
: stateLower === 'created'
? 'status-created'
: stateLower === 'restarting'
? 'status-restarting'
: '';
row.innerHTML = `
<td>
<input type="checkbox" class="container-checkbox" data-container-id="${containerId}">
</td>
<td>
<div class="d-flex align-items-center gap-2">
<span class="container-name-display" data-container-id="${containerId}" style="cursor: pointer; user-select: none;">${name}</span>
<a href="#" class="container-name-link d-none" data-container-id="${containerId}">${name}</a>
</div>
</td>
<td>${image}</td>
<td><span class="badge ${statusClass}">${state}</span></td>
<td class="cpu">
<div class="stats-container">
<span class="stats-value">0.00%</span>
<div class="stats-bar-container">
<div class="stats-bar cpu-bar" style="width: 0%"></div>
</div>
</div>
</td>
<td class="memory">
<div class="stats-container">
<span class="stats-value">0.00 MB</span>
<div class="stats-bar-container">
<div class="stats-bar memory-bar" style="width: 0%"></div>
</div>
</div>
</td>
<td class="ip-address">${ipAddress}</td>
<td class="container-actions-cell">
<div class="container-actions">
<div class="btn-group btn-group-sm container-actions-primary">
<button class="btn btn-outline-success action-start p-1" title="Start" ${container.State === 'running' ? 'disabled' : ''}>
<i class="fas fa-play"></i>
</button>
<button class="btn btn-outline-warning action-stop p-1" title="Stop" ${container.State !== 'running' ? 'disabled' : ''}>
<i class="fas fa-stop"></i>
</button>
<button class="btn btn-outline-primary action-logs p-1" title="Logs">
<i class="fas fa-list-alt"></i>
</button>
<button class="btn btn-outline-primary action-terminal p-1" title="Terminal" ${container.State !== 'running' ? 'disabled' : ''}>
<i class="fas fa-terminal"></i>
</button>
</div>
<div class="dropdown container-actions-more">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle p-1" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="More actions">
<i class="fas fa-ellipsis"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
<li><button type="button" class="dropdown-item action-restart" ${container.State !== 'running' ? 'disabled' : ''}><i class="fas fa-redo me-2"></i>Restart</button></li>
<li><button type="button" class="dropdown-item action-kill" ${container.State !== 'running' ? 'disabled' : ''}><i class="fas fa-skull me-2"></i>Kill</button></li>
<li><button type="button" class="dropdown-item action-pause" ${container.State !== 'running' ? 'disabled' : ''}><i class="fas fa-pause me-2"></i>Pause</button></li>
<li><button type="button" class="dropdown-item action-top" ${container.State !== 'running' ? 'disabled' : ''}><i class="fas fa-microchip me-2"></i>Processes</button></li>
<li><hr class="dropdown-divider"></li>
<li><button type="button" class="dropdown-item action-inspect"><i class="fas fa-info-circle me-2"></i>Inspect</button></li>
<li><button type="button" class="dropdown-item action-duplicate"><i class="fas fa-clone me-2"></i>Duplicate</button></li>
<li><button type="button" class="dropdown-item action-rename"><i class="fas fa-edit me-2"></i>Rename</button></li>
<li><button type="button" class="dropdown-item action-recreate" data-min-role="admin"><i class="fas fa-rotate me-2"></i>Recreate</button></li>
<li><button type="button" class="dropdown-item action-resources" data-min-role="operator"><i class="fas fa-sliders me-2"></i>Resources…</button></li>
<li><hr class="dropdown-divider"></li>
<li><button type="button" class="dropdown-item text-danger action-remove"><i class="fas fa-trash me-2"></i>Remove</button></li>
</ul>
</div>
</div>
</td>
`;
const checkbox = row.querySelector('.container-checkbox');
if (checkbox) checkbox.addEventListener('change', () => updateBulkActionsToolbar());
const duplicateBtn = row.querySelector('.action-duplicate');
if (duplicateBtn) duplicateBtn.addEventListener('click', () => openDuplicateModal(container));
const recreateBtn = row.querySelector('.action-recreate');
if (recreateBtn) {
recreateBtn.addEventListener('click', () => recreateContainerAction(container));
}
const resourcesBtn = row.querySelector('.action-resources');
if (resourcesBtn) {
resourcesBtn.addEventListener('click', () => {
const name = (container.Names?.[0] || '').replace(/^\//, '');
window.peardockOps?.openResourceEditor?.(container.Id, { name });
});
}
const nameLink = row.querySelector('.container-name-link');
if (nameLink) {
nameLink.addEventListener('click', (e) => {
e.preventDefault();
showContainerDetails(container);
});
}
const nameDisplay = row.querySelector('.container-name-display');
if (nameDisplay) {
nameDisplay.addEventListener('click', (e) => {
e.preventDefault();
showContainerDetails(container);
});
}
addActionListeners(row, container);
return row;
}
function paintVirtualContainers() {
const listElement = domCache.containerList || containerList;
if (!listElement) return;
const rows = containerVirt.rows;
if (!rows.length) return;
const scrollParent =
listElement.closest('.table-responsive') ||
listElement.closest('.view') ||
listElement.parentElement;
const scrollTop = scrollParent?.scrollTop || 0;
const viewH = scrollParent?.clientHeight || 600;
const rh = containerVirt.rowHeight;
const total = rows.length;
const start = Math.max(0, Math.floor(scrollTop / rh) - containerVirt.overscan);
const visible = Math.ceil(viewH / rh) + containerVirt.overscan * 2;
const end = Math.min(total, start + visible);
const fragment = document.createDocumentFragment();
if (start > 0) {
const topPad = document.createElement('tr');
topPad.className = 'virt-pad-top';
topPad.innerHTML = `<td colspan="8" style="height:${start * rh}px;padding:0;border:0;"></td>`;
fragment.appendChild(topPad);
}
for (let i = start; i < end; i++) {
fragment.appendChild(buildContainerRow(rows[i]));
}
if (end < total) {
const botPad = document.createElement('tr');
botPad.className = 'virt-pad-bot';
botPad.innerHTML = `<td colspan="8" style="height:${(total - end) * rh}px;padding:0;border:0;"></td>`;
fragment.appendChild(botPad);
}
listElement.innerHTML = '';
listElement.appendChild(fragment);
}
function bindContainerVirtualScroll() {
if (containerVirt.bound) return;
const listElement = domCache.containerList || containerList;
const scrollParent =
listElement?.closest('.table-responsive') ||
listElement?.closest('.view') ||
listElement?.parentElement;
if (!scrollParent) return;
containerVirt.scrollEl = scrollParent;
let ticking = false;
scrollParent.addEventListener(
'scroll',
() => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
if (containerVirt.rows.length > 80) paintVirtualContainers();
});
},
{ passive: true }
);
containerVirt.bound = true;
}
function renderContainers(containers, topicId) {
if (!window.activePeer || !connections[topicId] || window.activePeer !== connections[topicId].peer) {
console.warn('[WARN] Active peer mismatch or invalid connection. Skipping container rendering.');
return;
}
console.log(`[INFO] Rendering ${containers.length} containers for topic: ${topicId}`);
const currentContainerIds = new Set(containers.map((c) => c.Id));
Object.keys(smoothedStats).forEach((containerId) => {
if (!currentContainerIds.has(containerId)) delete smoothedStats[containerId];
});
const filteredContainers = filterAndSortContainers(containers);
const listElement = domCache.containerList || containerList;
if (!filteredContainers.length) {
const hasAny = (containers || []).length > 0;
listElement.innerHTML = emptyTableRow(
8,
hasAny ? 'No matching containers' : 'No containers yet',
hasAny ? 'Clear filters or search to see more.' : 'Deploy a container from the Deploy view.'
);
containerVirt.rows = [];
return;
}
containerVirt.rows = filteredContainers;
containerVirt.topicId = topicId;
bindContainerVirtualScroll();
// Virtualize only for large lists; small lists render fully for simplicity
if (filteredContainers.length > 80) {
paintVirtualContainers();
return;
}
const fragment = document.createDocumentFragment();
filteredContainers.forEach((container) => {
fragment.appendChild(buildContainerRow(container));
});
listElement.innerHTML = '';
listElement.appendChild(fragment);
}
function addActionListeners(row, container) {
const startBtn = row.querySelector('.action-start');
const stopBtn = row.querySelector('.action-stop');
const pauseBtn = row.querySelector('.action-pause');
const removeBtn = row.querySelector('.action-remove');
const terminalBtn = row.querySelector('.action-terminal');
const restartBtn = row.querySelector('.action-restart');
const inspectBtn = row.querySelector('.action-inspect');
const renameBtn = row.querySelector('.action-rename');
const commitBtn = row.querySelector('.action-commit');
const execBtn = row.querySelector('.action-exec');
const killBtn = row.querySelector('.action-kill');
const topBtn = row.querySelector('.action-top');
// Start Button
startBtn.addEventListener('click', async () => {
showStatusIndicator(`Starting container "${container.Names[0]}"...`);
sendCommand('startContainer', { id: container.Id });
const expectedMessageFragment = `Container ${container.Id} started`;
try {
const response = await waitForPeerResponse(expectedMessageFragment);
console.log('[DEBUG] Start container response:', response);
showAlert('success', response.message);
// Refresh the container list to update states
sendCommand('listContainers');
// Restart stats interval
startStatsInterval();
} catch (error) {
console.error('[ERROR] Failed to start container:', error.message);
showAlert('danger', error.message || 'Failed to start container.');
} finally {
console.log('[DEBUG] Hiding status indicator in startBtn finally block');
hideStatusIndicator();
}
});
stopBtn.addEventListener('click', async () => {
showStatusIndicator(`Stopping container "${container.Names[0]}"...`);
sendCommand('stopContainer', { id: container.Id });
const expectedMessageFragment = `Container ${container.Id} stopped`;
try {
const response = await waitForPeerResponse(expectedMessageFragment);
console.log('[DEBUG] Stop container response:', response);
showAlert('success', response.message);
// Refresh the container list to update states
sendCommand('listContainers');
// Restart stats interval
startStatsInterval();
} catch (error) {
console.error('[ERROR] Failed to stop container:', error.message);
showAlert('danger', error.message || 'Failed to stop container.');
} finally {
console.log('[DEBUG] Hiding status indicator in stopBtn finally block');
hideStatusIndicator();
}
});
// Restart Button
restartBtn.addEventListener('click', async () => {
showStatusIndicator(`Restarting container "${container.Names[0]}"...`);
sendCommand('restartContainer', { id: container.Id });
const expectedMessageFragment = `Container ${container.Id} restarted`;
try {
const response = await waitForPeerResponse(expectedMessageFragment);
console.log('[DEBUG] Restart container response:', response);
showAlert('success', response.message);
// Refresh the container list to update states
sendCommand('listContainers');
} catch (error) {
console.error('[ERROR] Failed to restart container:', error.message);
showAlert('danger', error.message || 'Failed to restart container.');
} finally {
console.log('[DEBUG] Hiding status indicator in restartBtn finally block');
hideStatusIndicator();
}
});
if (killBtn) {
killBtn.addEventListener('click', async () => {
showStatusIndicator(`Killing container "${container.Names[0]}"...`);
try {
const response = await sendCommand('killContainer', { id: container.Id });
if (response?.success) {
showAlert('success', response.message || 'Container killed');
sendCommand('listContainers');
}
} catch (error) {
showAlert('danger', error.message || 'Failed to kill container.');
} finally {
hideStatusIndicator();
}
});
}
if (topBtn) {
topBtn.addEventListener('click', () => {
showContainerDetails(container);
// Switch to processes tab after details load
setTimeout(() => {
const tab = document.getElementById('processes-tab');
if (tab) tab.click();
loadContainerTop(container.Id);
}, 200);
});
}
// Pause Button
if (pauseBtn) {
pauseBtn.addEventListener('click', async () => {
showStatusIndicator(`Pausing container "${container.Names[0]}"...`);
sendCommand('pauseContainer', { id: container.Id });
const expectedMessageFragment = `Container ${container.Id} paused`;
try {
const response = await waitForPeerResponse(expectedMessageFragment);
showAlert('success', response.message);
sendCommand('listContainers');
} catch (error) {
console.error('[ERROR] Failed to pause container:', error.message);
showAlert('danger', error.message || 'Failed to pause container.');
} finally {
hideStatusIndicator();
}
});
}
// Rename Button - Inline Edit
if (renameBtn) {
renameBtn.addEventListener('click', () => {
const nameDisplay = row.querySelector('.container-name-display');
if (!nameDisplay) return;
const currentName = nameDisplay.textContent.trim();
const originalName = currentName;
// Create input field
const input = document.createElement('input');
input.type = 'text';
input.value = currentName;
input.className = 'form-control form-control-sm bg-dark text-white';
input.style.width = '200px';
input.style.display = 'inline-block';
// Replace display with input
const parent = nameDisplay.parentElement;
const nameDisplayClone = nameDisplay.cloneNode(true);
nameDisplay.style.display = 'none';
parent.insertBefore(input, nameDisplay);
// Focus and select
input.focus();
input.select();
// Track rename state to prevent multiple attempts
let isRenaming = false;
let checkInterval = null;
let originalHandler = null;
// Save function
const saveName = async () => {
// Prevent multiple calls
if (isRenaming) {
return;
}
const newName = input.value.trim();
// Early return if name is empty
if (!newName) {
showAlert('danger', 'Container name cannot be empty');
parent.removeChild(input);
nameDisplay.textContent = originalName;
nameDisplay.style.display = '';
return;
}
// Early return if name unchanged - no need to rename
if (newName === originalName) {
parent.removeChild(input);
nameDisplay.style.display = '';
return;
}
// Set renaming flag
isRenaming = true;
sendCommand('renameContainer', { id: container.Id, name: newName });
const expectedMessageFragment = `Container renamed to "${newName}"`;
let renameHandled = false;
// Set up response handler
originalHandler = window.handlePeerResponse;
const responseHandler = (response) => {
if (!renameHandled && response && response.success && response.message && response.message.includes(expectedMessageFragment)) {
renameHandled = true;
// Clear interval if it exists
if (checkInterval) {
clearInterval(checkInterval);
checkInterval = null;
}
showAlert('success', response.message);
sendCommand('listContainers');
// Restore original handler
if (typeof originalHandler === 'function') {
window.handlePeerResponse = originalHandler;
} else {
window.handlePeerResponse = null;
}
isRenaming = false;
} else if (typeof originalHandler === 'function') {
originalHandler(response);
}
};
window.handlePeerResponse = responseHandler;
// Also listen for container list updates as confirmation (backup)
checkInterval = setInterval(() => {
const nameDisplays = document.querySelectorAll('.container-name-display');
nameDisplays.forEach(display => {
if (!renameHandled && display.textContent.trim() === newName && display.dataset.containerId === container.Id) {
renameHandled = true;
clearInterval(checkInterval);
checkInterval = null;
showAlert('success', `Container renamed to "${newName}"`);
// Restore original handler
if (typeof originalHandler === 'function') {
window.handlePeerResponse = originalHandler;
} else {
window.handlePeerResponse = null;
}
isRenaming = false;
}
});
}, 500);
// Clean up interval after 10 seconds
setTimeout(() => {
if (checkInterval) {
clearInterval(checkInterval);
checkInterval = null;
}
if (isRenaming) {
isRenaming = false;
}
}, 10000);
// Restore display immediately (input will be removed)
parent.removeChild(input);
nameDisplay.style.display = '';
};
// Cancel function
const cancelEdit = () => {
parent.removeChild(input);
nameDisplay.style.display = '';
};
// Handle Enter key
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (!isRenaming && input.parentElement) {
saveName();
}
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
});
// Handle blur (click outside) - use setTimeout to allow other events to process first
input.addEventListener('blur', () => {
setTimeout(() => {
// Only save if input is still in DOM and we're not already renaming
if (!isRenaming && input.parentElement) {
saveName();
}
}, 150);
});
});
}
const logsBtn = row.querySelector('.action-logs');
logsBtn.addEventListener('click', () => openLogModal(container.Id));
function openLogModal(containerId) {
console.log(`[INFO] Opening logs modal for container: ${containerId}`);
const modal = new bootstrap.Modal(document.getElementById('logsModal'));
const logContainer = document.getElementById('logs-container');
// Clear any existing logs
logContainer.innerHTML = '';
// Request previous logs
sendCommand('logs', { id: containerId });
// Listen for logs
window.handleLogOutput = (logData) => {
const logLine = decodePayload(logData.data, logData.encoding || 'base64');
if (!logLine) return;
const logElement = document.createElement('pre');
logElement.className = 'mb-0';
logElement.style.whiteSpace = 'pre-wrap';
logElement.style.wordBreak = 'break-word';
logElement.textContent = logLine;
logContainer.appendChild(logElement);
logContainer.scrollTop = logContainer.scrollHeight;
};
// Show the modal
modal.show();
}
// Remove Button
removeBtn.addEventListener('click', async () => {
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
deleteModal.show();
const confirmDeleteBtn = document.getElementById('confirm-delete-btn');
confirmDeleteBtn.onclick = async () => {
// Close modal immediately before async operation
deleteModal.hide();
closeAllModals();
const containerName = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12);
// Add notification for container deletion
notificationManager.add('info', `Deleting container "${containerName}"...`, { autoDismiss: false });
showStatusIndicator(`Deleting container "${container.Names[0]}"...`);
// Check if the container has active terminals
if (window.openTerminals[container.Id]) {
console.log(`[INFO] Closing active terminals for container: ${container.Id}`);
window.openTerminals[container.Id].forEach((terminalId) => {
try {
cleanUpTerminal(terminalId);
} catch (err) {
console.error(`[ERROR] Failed to clean up terminal ${terminalId}: ${err.message}`);
}
});
delete window.openTerminals[container.Id];
}
// Hide the terminal modal if it is active
const terminalModal = document.getElementById('terminal-modal');
if (terminalModal.style.display === 'flex') {
console.log(`[INFO] Hiding terminal modal for container: ${container.Id}`);
terminalModal.style.display = 'none';
}
terminalModal.addEventListener('shown.bs.modal', () => {
terminal.focus();
});
sendCommand('removeContainer', { id: container.Id });
const expectedMessageFragment = `Container ${container.Id} removed`;
try {
const response = await waitForPeerResponse(expectedMessageFragment);
console.log('[DEBUG] Remove container response:', response);
// Update notification to success
notificationManager.add('success', `Container "${containerName}" deleted successfully`);
showAlert('success', response.message);
// Refresh the container list to update states
sendCommand('listContainers');
} catch (error) {
console.error('[ERROR] Failed to delete container:', error.message);
// Update notification to error
notificationManager.add('danger', `Failed to delete container "${containerName}"`);
showAlert('danger', error.message || `Failed to delete container "${container.Names[0]}".`);
} finally {
console.log('[DEBUG] Hiding status indicator in removeBtn finally block');
hideStatusIndicator();
}
};
});
terminalBtn.addEventListener('click', () => {
console.log(`[DEBUG] Opening terminal for container ID: ${container.Id}`);
try {
startTerminal(container.Id, container.Names[0] || container.Id);
} catch (error) {
console.error(`[ERROR] Failed to start terminal for container ${container.Id}: ${error.message}`);
showAlert('danger', `Failed to start terminal: ${error.message}`);
}
});
// Inspect Button
if (inspectBtn) {
inspectBtn.addEventListener('click', () => {
openInspectModal(container);
});
}
}
function updateContainerStats(stats) {
if (!stats || !stats.id || typeof stats.cpu === 'undefined' || typeof stats.memory === 'undefined') {
console.error('[ERROR] Invalid stats object:', stats);
return;
}
console.log(`[DEBUG] Updating stats for container ID: ${stats.id}`);
const row = containerList?.querySelector(`tr[data-container-id="${stats.id}"]`);
if (row) {
// Ensure the IP address is added or retained from existing row
const existingIpAddress = row.querySelector('.ip-address')?.textContent || 'No IP Assigned';
stats.ip = stats.ip || existingIpAddress;
const smoothed = smoothStats(stats.id, stats);
updateStatsUI(row, smoothed);
}
// Update container details stats if we're on that view
if (currentView === 'container-details' && currentContainerDetails && currentContainerDetails.Id === stats.id) {
const cpuEl = document.getElementById('detail-cpu');
const memoryEl = document.getElementById('detail-memory');
const smoothed = smoothStats(stats.id, stats);
if (cpuEl) cpuEl.textContent = `${smoothed.cpu.toFixed(2)}%`;
if (memoryEl) memoryEl.textContent = `${(smoothed.memory / (1024 * 1024)).toFixed(2)} MB`;
}
}
// Batch stats updates
let pendingStatsUpdates = [];
// Debounced stats update function with visualization
const debouncedStatsUpdate = debounce((updates) => {
requestAnimationFrame(() => {
for (const { row, stats } of updates) {
const cpuEl = row.querySelector('.cpu .stats-value');
const cpuBar = row.querySelector('.cpu-bar');
const memoryEl = row.querySelector('.memory .stats-value');
const memoryBar = row.querySelector('.memory-bar');
const ipEl = row.querySelector('.ip-address');
if (cpuEl) {
const cpuPercent = stats.cpu.toFixed(2) || '0.00';
cpuEl.textContent = `${cpuPercent}%`;
if (cpuBar) {
// Cap at 100% for visualization
const width = Math.min(100, parseFloat(cpuPercent));
cpuBar.style.width = `${width}%`;
}
}
if (memoryEl) {
const memoryMB = (stats.memory / (1024 * 1024)).toFixed(2) || '0.00';
memoryEl.textContent = `${memoryMB} MB`;
if (memoryBar) {
// Calculate memory percentage (assuming reasonable max of 8GB for visualization)
const maxMemory = 8 * 1024 * 1024 * 1024; // 8GB
const memoryPercent = Math.min(100, (stats.memory / maxMemory) * 100);
memoryBar.style.width = `${memoryPercent}%`;
}
}
if (ipEl) ipEl.textContent = stats.ip;
}
});
}, 100); // Debounce to 100ms
function updateStatsUI(row, stats) {
pendingStatsUpdates.push({ row, stats });
// Trigger debounced update
debouncedStatsUpdate(pendingStatsUpdates);
// Clear pending updates after processing (they're processed in the debounced function)
setTimeout(() => {
if (pendingStatsUpdates.length > 0) {
pendingStatsUpdates = [];
}
}, 200);
}
// Function to open the Duplicate Modal with container configurations
function openDuplicateModal(container) {
console.log(`[INFO] Opening Duplicate Modal for container: ${container.Id}`);
showStatusIndicator('Fetching container configuration...');
// Send a command to inspect the container
sendCommand('inspectContainer', { id: container.Id });
// Listen for the inspectContainer response
window.inspectContainerCallback = (config) => {
hideStatusIndicator();
if (!config) {
console.error('[ERROR] Failed to retrieve container configuration.');
showAlert('danger', 'Failed to retrieve container configuration.');
return;
}
console.log(`[DEBUG] Retrieved container configuration: ${JSON.stringify(config)}`);
// Parse configuration and populate the accordion form
try {
// Clear the form first
const form = document.getElementById('duplicate-container-form');
if (form) form.reset();
// Populate all fields using the helper function
populateDuplicateForm(config);
setupDeployResourceSliders();
// Set up network mode change handler for duplicate modal
const networkMode = document.getElementById('duplicate-network-mode');
const customNetworkContainer = document.getElementById('duplicate-custom-network-container');
if (networkMode && customNetworkContainer && networkMode.parentNode) {
// Remove existing listeners by cloning and replacing
const newNetworkMode = networkMode.cloneNode(true);
networkMode.parentNode.replaceChild(newNetworkMode, networkMode);
newNetworkMode.addEventListener('change', (e) => {
if (e.target.value === 'container') {
customNetworkContainer.style.display = 'block';
const input = customNetworkContainer.querySelector('input');
if (input) input.placeholder = 'container-name';
} else if (e.target.value !== 'host' && e.target.value !== 'none' && e.target.value !== 'bridge') {
customNetworkContainer.style.display = 'block';
const input = customNetworkContainer.querySelector('input');
if (input) input.placeholder = 'network-name';
} else {
customNetworkContainer.style.display = 'none';
}
});
}
// Show the duplicate modal
if (duplicateModal) {
duplicateModal.show();
}
} catch (error) {
console.error(`[ERROR] Failed to populate modal fields: ${error.message}`);
showAlert('danger', 'Failed to populate container configuration fields.');
}
};
}
// Function to open the Inspect Modal with container information
function openInspectModal(container) {
console.log(`[INFO] Opening Inspect Modal for container: ${container.Id}`);
showStatusIndicator('Fetching container information...');
// Store the original callback if it exists
const originalCallback = window.inspectContainerCallback;
// Send a command to inspect the container
sendCommand('inspectContainer', { id: container.Id });
// Listen for the inspectContainer response
window.inspectContainerCallback = (config) => {
hideStatusIndicator();
// Restore original callback
window.inspectContainerCallback = originalCallback;
if (!config) {
console.error('[ERROR] Failed to retrieve container configuration.');
showAlert('danger', 'Failed to retrieve container configuration.');
return;
}
try {
// Update modal title with container name
const modalTitle = document.getElementById('containerInspectModalLabel');
const containerName = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12);
if (modalTitle) {
modalTitle.innerHTML = `<i class="fas fa-info-circle me-2"></i>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 = '<i class="fas fa-code"></i> 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 `
<div class="inspect-key-value">
<div class="inspect-key">${escapeHtml(key)}</div>
<div class="inspect-value">${formatValue(value)}</div>
</div>
`;
}
// Helper function to format values
function formatValue(value) {
if (value === null || value === undefined) {
return '<span class="text-muted">Not set</span>';
}
if (typeof value === 'boolean') {
const badgeClass = value ? 'inspect-badge-success' : 'inspect-badge-danger';
const text = value ? 'Yes' : 'No';
return `<span class="inspect-badge ${badgeClass}">${text}</span>`;
}
if (Array.isArray(value)) {
if (value.length === 0) {
return '<span class="text-muted">None</span>';
}
return `<ul class="inspect-list">${value.map(item => `<li class="inspect-list-item">${escapeHtml(String(item))}</li>`).join('')}</ul>`;
}
if (typeof value === 'object') {
return `<pre style="margin: 0; font-size: 0.8rem; color: var(--text-secondary);">${escapeHtml(JSON.stringify(value, null, 2))}</pre>`;
}
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 || '<div class="inspect-section-empty">No configuration data available</div>';
}
// 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 || '<div class="inspect-section-empty">No networking data available</div>';
}
// 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 || '<div class="inspect-section-empty">No storage data available</div>';
}
// 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 || '<div class="inspect-section-empty">No resource data available</div>';
}
// 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 || '<div class="inspect-section-empty">No security data available</div>';
}
// 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 || '<div class="inspect-section-empty">No runtime data available</div>';
}
// 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 += `<div class="detail-info-item mb-2"><span class="detail-badge ${badgeClass}"><i class="fas fa-heartbeat"></i> ${status}</span> <span class="text-muted">failing streak: ${failing}</span></div>`;
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 || '<div class="inspect-section-empty">No health or logging data available</div>';
}
// 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 || '<div class="inspect-section-empty">No labels or metadata available</div>';
}
// ============ 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 = '<div class="mb-2"><strong>Image history</strong></div>';
html += '<ul class="inspect-list">';
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 += `<li class="inspect-list-item">
<div><strong>#${index}</strong> ${created} ${size ? ${size}` : ''}</div>
${tags ? `<div class="text-info small">${tags}</div>` : ''}
<div class="text-muted small" style="word-break:break-all;">${createdBy || '—'}</div>
</li>`;
});
html += '</ul>';
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 : ['<none>']);
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 || '<div class="inspect-section-empty">No overview data available</div>';
}
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 || '<div class="inspect-section-empty">No configuration data available</div>';
}
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 += '<div class="mb-3"><strong>Layers:</strong></div>';
html += '<ul class="inspect-list">';
layers.forEach((layer, index) => {
html += `<li class="inspect-list-item">${layer.substring(0, 20)}...</li>`;
});
html += '</ul>';
}
if (history.length > 0) {
html += '<div class="mt-3"><strong>History:</strong></div>';
history.forEach((h, index) => {
if (h.created) {
html += `<div class="mb-2"><strong>${new Date(h.created * 1000).toLocaleString()}:</strong></div>`;
html += `<div class="text-muted mb-3">${h.created_by || 'Unknown command'}</div>`;
}
});
}
content.innerHTML = html || '<div class="inspect-section-empty">No history or layer data available</div>';
}
// ============ 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 || '<div class="inspect-section-empty">No overview data available</div>';
}
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 += '<div class="mb-3"><strong>IPAM Configuration:</strong></div>';
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 || '<div class="inspect-section-empty">No configuration data available</div>';
}
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 || '<div class="inspect-section-empty">No container data available</div>';
}
// ============ 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 || '<div class="inspect-section-empty">No overview data available</div>';
}
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 || '<div class="inspect-section-empty">No configuration data available</div>';
}
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 || '<div class="inspect-section-empty">No usage data available</div>';
}
// ============ 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 || '<div class="inspect-section-empty">No overview data available</div>';
}
function populateStackServicesSection(stackData) {
const content = document.getElementById('stack-services-content');
if (!content) return;
const services = stackData.services || [];
let html = '';
if (services.length > 0) {
html += '<ul class="inspect-list">';
services.forEach(service => {
html += `<li class="inspect-list-item">${service}</li>`;
});
html += '</ul>';
} else {
html = '<div class="inspect-section-empty">No services defined</div>';
}
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 += '<ul class="inspect-list">';
containers.forEach(container => {
const stateBadge = container.state === 'running' ? 'text-success' : 'text-danger';
html += `<li class="inspect-list-item">
<strong>${container.name}</strong>
<span class="${stateBadge}">(${container.state})</span>
<br><small class="text-muted">Image: ${container.image}</small>
</li>`;
});
html += '</ul>';
} else {
html = '<div class="inspect-section-empty">No containers in stack</div>';
}
content.innerHTML = html;
}
/**
* App chrome mode: welcome (no active peer) vs workspace (connected).
* Welcome is NOT a .view — it must be toggled explicitly or it stacks over content.
*/
function setWelcomeVisible(visible) {
const el = welcomePage || document.getElementById('welcome-page');
if (!el) {
console.error('[ERROR] Welcome page element not found');
return;
}
welcomePage = el;
if (visible) {
el.classList.remove('hidden', 'is-hidden');
el.removeAttribute('hidden');
el.style.display = '';
} else {
el.classList.add('hidden', 'is-hidden');
el.setAttribute('hidden', '');
el.style.display = 'none';
}
}
function hasActiveConnection() {
return Boolean(manager.active?.connected || window.activePeer?.connected);
}
function showWelcomePage() {
setWelcomeVisible(true);
// Hide all workspace views while onboarding
document.querySelectorAll('.view').forEach((view) => {
view.classList.add('hidden');
});
if (connectionTitle) {
connectionTitle.textContent = '';
}
}
function hideWelcomePage() {
setWelcomeVisible(false);
// Enter workspace on dashboard (or keep current view if already set)
const target = currentView && currentView !== 'welcome' ? currentView : 'dashboard';
navigateToView(target, { skipWelcomeGate: true });
}
function assertVisibility() {
const el = welcomePage || document.getElementById('welcome-page');
const dash = document.getElementById('dashboard-view');
if (!el) {
console.warn('[WARN] Cannot assert visibility: welcome page missing');
return;
}
const welcomeHidden =
el.classList.contains('hidden') || el.hasAttribute('hidden') || el.style.display === 'none';
if (!hasActiveConnection()) {
console.assert(!welcomeHidden, '[ASSERTION FAILED] Welcome page should be visible when disconnected.');
} else {
console.assert(welcomeHidden, '[ASSERTION FAILED] Welcome page should be hidden when connected.');
if (dash) {
console.assert(
!dash.classList.contains('hidden') || currentView !== 'dashboard',
'[ASSERTION FAILED] Dashboard should be visible when connected on dashboard view.'
);
}
}
}
// Attach startTerminal to the global window object
window.startTerminal = startTerminal;
// Close live sockets on quit — do NOT forget peers (that was wiping peers.json)
window.addEventListener('beforeunload', () => {
manager.disconnectAll({ forget: false }).catch(() => {});
});
// Pear / Electron may fire pagehide when the window is destroyed
window.addEventListener('pagehide', () => {
manager.disconnectAll({ forget: false }).catch(() => {});
});
// Initialize Inspect Modal Event Listeners
document.addEventListener('DOMContentLoaded', () => {
// Container Inspect Modal - Toggle JSON View
const toggleJsonViewBtn = document.getElementById('toggle-json-view');
if (toggleJsonViewBtn) {
toggleJsonViewBtn.addEventListener('click', () => {
const formattedView = document.getElementById('inspect-formatted-view');
const jsonView = document.getElementById('inspect-json-view');
const jsonContent = document.getElementById('inspect-json-content');
if (formattedView.style.display === 'none') {
formattedView.style.display = 'block';
jsonView.style.display = 'none';
toggleJsonViewBtn.innerHTML = '<i class="fas fa-code"></i> Raw JSON';
} else {
formattedView.style.display = 'none';
jsonView.style.display = 'block';
toggleJsonViewBtn.innerHTML = '<i class="fas fa-list"></i> 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 = '<i class="fas fa-code"></i> Raw JSON';
} else {
formattedView.style.display = 'none';
jsonView.style.display = 'block';
toggleImageJsonViewBtn.innerHTML = '<i class="fas fa-list"></i> 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 = '<i class="fas fa-code"></i> Raw JSON';
} else {
formattedView.style.display = 'none';
jsonView.style.display = 'block';
toggleNetworkJsonViewBtn.innerHTML = '<i class="fas fa-list"></i> 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 = '<i class="fas fa-code"></i> Raw JSON';
} else {
formattedView.style.display = 'none';
jsonView.style.display = 'block';
toggleVolumeJsonViewBtn.innerHTML = '<i class="fas fa-list"></i> 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 = '<i class="fas fa-code"></i> Raw JSON';
} else {
formattedView.style.display = 'none';
jsonView.style.display = 'block';
toggleStackJsonViewBtn.innerHTML = '<i class="fas fa-list"></i> 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 = `
<div class="notification-empty">
<i class="fas fa-bell-slash"></i>
<p>No notifications</p>
</div>
`;
return;
}
notificationList.innerHTML = notifications.map(notification => {
const icon = getNotificationIcon(notification.type);
const timestamp = formatTimestamp(notification.timestamp);
const unreadClass = notification.read ? '' : 'unread';
return `
<div class="notification-item ${notification.type} ${unreadClass}${notification.badge ? ' badge-worthy' : ' history-only'}" data-id="${notification.id}">
<div class="notification-icon">
<i class="fas ${icon}"></i>
</div>
<div class="notification-content">
<p class="notification-message">${escapeHtml(notification.message)}</p>
<p class="notification-timestamp">${timestamp}</p>
</div>
<div class="notification-actions">
${!notification.read ? `
<button class="notification-action mark-read-btn" data-id="${notification.id}" title="Mark as read">
<i class="fas fa-check"></i>
</button>
` : ''}
<button class="notification-action dismiss-btn" data-id="${notification.id}" title="Dismiss">
<i class="fas fa-times"></i>
</button>
</div>
</div>
`;
}).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);
}