forked from snxraven/peardock
8939 lines
313 KiB
JavaScript
8939 lines
313 KiB
JavaScript
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 0–100 from Docker pull progress event, or null if unknown. */
|
||
function pullProgressPercent(event) {
|
||
const detail = event?.progressDetail;
|
||
if (detail && detail.total && detail.current != null) {
|
||
return Math.min(100, Math.round((detail.current / detail.total) * 100));
|
||
}
|
||
if (event?.status && /complete|downloaded|pull complete/i.test(String(event.status))) {
|
||
return 100;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function pullImage() {
|
||
const imageName = document.getElementById('pull-image-name')?.value?.trim();
|
||
if (!imageName) {
|
||
showAlert('danger', 'Please enter an image name');
|
||
return;
|
||
}
|
||
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('pullImageModal'));
|
||
if (modal) modal.hide();
|
||
|
||
try {
|
||
const host =
|
||
document.getElementById('images-view') ||
|
||
document.getElementById('alert-container')?.parentElement ||
|
||
document.body;
|
||
document.getElementById('progress-pull-image')?.remove();
|
||
host.prepend(createProgressBar('pull-image', `Pulling ${imageName}`));
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
showStatusIndicator(`Pulling image "${imageName}"...`);
|
||
sendCommand('pullImage', { image: imageName }).then((response) => {
|
||
removeProgressBar('pull-image');
|
||
if (response?.success) {
|
||
hideStatusIndicator();
|
||
showAlert('success', response.message || `Image "${imageName}" pulled successfully`);
|
||
if (typeof loadImages === 'function') loadImages();
|
||
else sendCommand('listImages');
|
||
} else if (response?.error) {
|
||
hideStatusIndicator();
|
||
const errorMsg = handleErrorResponse(response);
|
||
if (errorMsg) showAlert('danger', errorMsg);
|
||
} else {
|
||
// Error already emitted via manager.send → handleRpcMessage
|
||
hideStatusIndicator();
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Legacy entry — always open smart network modal */
|
||
function createNetwork() {
|
||
if (window.peardockOps?.openSmartNetworkModal) {
|
||
window.peardockOps.openSmartNetworkModal();
|
||
return;
|
||
}
|
||
showAlert('info', 'Use Create network from the Networks view');
|
||
// Keep a no-op timeout cleanup path for any old callers
|
||
const originalHandler = window.handlePeerResponse;
|
||
setTimeout(() => {
|
||
if (window.handlePeerResponse === originalHandler) {
|
||
window.handlePeerResponse = originalHandler;
|
||
}
|
||
}, 30000);
|
||
}
|
||
|
||
function createVolume() {
|
||
const name = document.getElementById('volume-name')?.value?.trim();
|
||
if (!name) {
|
||
showAlert('danger', 'Please enter a volume name');
|
||
return;
|
||
}
|
||
|
||
const driver = document.getElementById('volume-driver')?.value?.trim() || null;
|
||
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('createVolumeModal'));
|
||
if (modal) modal.hide();
|
||
|
||
showStatusIndicator(`Creating volume "${name}"...`);
|
||
sendCommand('createVolume', { name, driver });
|
||
|
||
// Store the volume name being created for later matching
|
||
const volumeNameBeingCreated = name;
|
||
let volumeCreated = false;
|
||
|
||
// Wait for response
|
||
const originalHandler = window.handlePeerResponse;
|
||
window.handlePeerResponse = (response) => {
|
||
// Always call original handler first for volume broadcasts and other messages
|
||
if (typeof originalHandler === 'function') {
|
||
originalHandler(response);
|
||
}
|
||
|
||
// Check if volume was already created to avoid duplicate processing
|
||
if (volumeCreated) {
|
||
return;
|
||
}
|
||
|
||
// Handle create volume success response - more flexible condition
|
||
const isDirectSuccess = response.success &&
|
||
response.message &&
|
||
(response.message.includes('created successfully') ||
|
||
response.message.includes('created'));
|
||
|
||
// Check if volumes broadcast includes the newly created volume
|
||
let isVolumeInBroadcast = false;
|
||
if (response.type === 'volumes' || (response.success && (response.data || response.volumes))) {
|
||
const volumesArray = response.data || response.volumes || [];
|
||
if (Array.isArray(volumesArray)) {
|
||
isVolumeInBroadcast = volumesArray.some(vol => {
|
||
const volName = typeof vol === 'string' ? vol : (vol.Name || vol.name || '');
|
||
return volName === volumeNameBeingCreated;
|
||
});
|
||
}
|
||
}
|
||
|
||
// Handle success (either direct response or volumes broadcast with new volume)
|
||
if (isDirectSuccess || isVolumeInBroadcast) {
|
||
volumeCreated = true;
|
||
hideStatusIndicator();
|
||
|
||
if (isDirectSuccess && response.message) {
|
||
showAlert('success', response.message);
|
||
} else if (isVolumeInBroadcast) {
|
||
showAlert('success', `Volume "${volumeNameBeingCreated}" created successfully`);
|
||
}
|
||
|
||
// Volumes will be updated via server broadcast, but refresh to be sure
|
||
loadVolumes();
|
||
// Reset form
|
||
document.getElementById('create-volume-form')?.reset();
|
||
|
||
// Reset handler since we've handled the response
|
||
window.handlePeerResponse = originalHandler;
|
||
} else if (response.error) {
|
||
hideStatusIndicator();
|
||
// Error is already handled by centralized handler in handleRpcMessage
|
||
// But we can still show alert for immediate feedback
|
||
const errorMsg = handleErrorResponse(response);
|
||
if (errorMsg) {
|
||
showAlert('danger', errorMsg);
|
||
}
|
||
// Reset handler on error
|
||
window.handlePeerResponse = originalHandler;
|
||
}
|
||
};
|
||
|
||
// Safety timeout - hide spinner and reset handler if no response received
|
||
setTimeout(() => {
|
||
if (!volumeCreated && window.handlePeerResponse !== originalHandler) {
|
||
console.warn(`[WARN] Volume creation timeout for "${volumeNameBeingCreated}" - hiding spinner as safety measure`);
|
||
hideStatusIndicator();
|
||
window.handlePeerResponse = originalHandler;
|
||
}
|
||
}, 30000);
|
||
}
|
||
|
||
// Container Details Functions
|
||
let currentContainerDetails = null;
|
||
|
||
function showContainerDetails(container) {
|
||
currentContainerDetails = container;
|
||
navigateToView('container-details');
|
||
|
||
// Update title
|
||
const titleEl = document.getElementById('container-details-title');
|
||
if (titleEl) {
|
||
const name = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12);
|
||
titleEl.innerHTML = `<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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function setupDeployResourceSliders() {
|
||
const pairs = [
|
||
['deploy-cpu-limit', 'deploy-cpu-limit-val', (v) => (Number(v) <= 0 ? 'Unlimited' : `${v} cores`)],
|
||
['deploy-cpu-reservation', 'deploy-cpu-reservation-val', (v) => (Number(v) <= 0 ? 'None' : `${v} cores`)],
|
||
['deploy-memory-limit', 'deploy-memory-limit-val', (v) => (Number(v) <= 0 ? 'Unlimited' : `${v} MB`)],
|
||
['deploy-memory-reservation', 'deploy-memory-reservation-val', (v) => (Number(v) <= 0 ? 'None' : `${v} MB`)],
|
||
['duplicate-cpu-limit', 'duplicate-cpu-limit-val', (v) => (Number(v) <= 0 ? 'Unlimited' : `${v} cores`)],
|
||
['duplicate-memory-limit', 'duplicate-memory-limit-val', (v) => (Number(v) <= 0 ? 'Unlimited' : `${v} MB`)],
|
||
];
|
||
for (const [inputId, labelId, fmt] of pairs) {
|
||
const input = document.getElementById(inputId);
|
||
const label = document.getElementById(labelId);
|
||
if (!input || input.dataset.sliderBound) continue;
|
||
input.dataset.sliderBound = '1';
|
||
const sync = () => {
|
||
if (label) label.textContent = fmt(input.value);
|
||
};
|
||
input.addEventListener('input', sync);
|
||
sync();
|
||
}
|
||
}
|
||
window.setupDeployResourceSliders = setupDeployResourceSliders;
|
||
|
||
function selectTemplateForDeploy(template) {
|
||
if (!template || typeof template !== 'object') {
|
||
showAlert('danger', 'Invalid template');
|
||
return;
|
||
}
|
||
|
||
const formSection = document.getElementById('deploy-form-section');
|
||
if (formSection) {
|
||
formSection.style.display = 'block';
|
||
formSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}
|
||
|
||
initTemplateDeployer();
|
||
setupDeployViewFormHandler();
|
||
setupDeployResourceSliders();
|
||
|
||
const form = document.getElementById('deploy-view-form');
|
||
if (form) form.reset();
|
||
|
||
[
|
||
'deploy-ports-container',
|
||
'deploy-volumes-container',
|
||
'deploy-env',
|
||
'deploy-labels-container',
|
||
'deploy-dns-container',
|
||
'deploy-extra-hosts-container',
|
||
'deploy-devices-container',
|
||
'deploy-capabilities-container',
|
||
'deploy-security-opts-container',
|
||
'deploy-log-opts-container',
|
||
'deploy-sysctls-container',
|
||
'deploy-ulimits-container',
|
||
'deploy-tmpfs-container',
|
||
].forEach((id) => {
|
||
const container = document.getElementById(id);
|
||
if (container) container.innerHTML = '';
|
||
});
|
||
|
||
// Suggested container name from template title
|
||
const nameEl = document.getElementById('deploy-container-name');
|
||
if (nameEl) {
|
||
const base = String(template.title || template.name || template.image || 'app')
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '')
|
||
.slice(0, 40) || 'app';
|
||
nameEl.value = base;
|
||
}
|
||
|
||
const deployImage = document.getElementById('deploy-image');
|
||
if (deployImage && template.image) deployImage.value = String(template.image);
|
||
|
||
if (template.command) {
|
||
const cmdEl = document.getElementById('deploy-command');
|
||
if (cmdEl) {
|
||
cmdEl.value = Array.isArray(template.command)
|
||
? template.command.join(' ')
|
||
: String(template.command);
|
||
}
|
||
}
|
||
|
||
if (template.restart_policy) {
|
||
const rp = document.getElementById('deploy-restart-policy');
|
||
if (rp) rp.value = String(template.restart_policy);
|
||
} else {
|
||
const rp = document.getElementById('deploy-restart-policy');
|
||
if (rp) rp.value = 'unless-stopped';
|
||
}
|
||
|
||
if (template.interactive) {
|
||
const tty = document.getElementById('deploy-tty');
|
||
const stdin = document.getElementById('deploy-stdin-open');
|
||
if (tty) tty.checked = true;
|
||
if (stdin) stdin.checked = true;
|
||
}
|
||
|
||
// Ports
|
||
if (Array.isArray(template.ports) && typeof window.addPortMapping === 'function') {
|
||
template.ports.forEach((port) => {
|
||
if (port == null) return;
|
||
let portValue;
|
||
if (typeof port === 'string' || typeof port === 'number') {
|
||
portValue = String(port);
|
||
} else if (typeof port === 'object') {
|
||
const containerPort = port.container || port.target || port.containerPort;
|
||
if (!containerPort) return;
|
||
const protocol = port.protocol || 'tcp';
|
||
const hostPort = port.host || port.published || port.hostPort || '';
|
||
portValue = hostPort
|
||
? `${hostPort}:${containerPort}/${protocol}`
|
||
: `${containerPort}/${protocol}`;
|
||
} else {
|
||
return;
|
||
}
|
||
window.addPortMapping(portValue);
|
||
});
|
||
}
|
||
|
||
// Volumes — support bind/container object and string formats
|
||
if (Array.isArray(template.volumes) && typeof window.addVolumeMount === 'function') {
|
||
template.volumes.forEach((volume) => {
|
||
if (typeof volume === 'string') {
|
||
window.addVolumeMount(volume);
|
||
return;
|
||
}
|
||
if (volume && typeof volume === 'object') {
|
||
const host = volume.bind || volume.host || volume.source || '';
|
||
const container = volume.container || volume.target || volume.containerPath || '';
|
||
const mode = volume.mode || (volume.read_only ? 'ro' : '');
|
||
if (container) {
|
||
const str = host
|
||
? `${host}:${container}${mode ? ':' + mode : ''}`
|
||
: container;
|
||
window.addVolumeMount(str);
|
||
} else {
|
||
window.addVolumeMount();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Env — use template-aware addEnvVar when possible
|
||
if (Array.isArray(template.env) && typeof window.addEnvVar === 'function') {
|
||
template.env.forEach((env) => {
|
||
if (!env) return;
|
||
// Portainer-style template env: { name, label, default, set, preset }
|
||
if (typeof env === 'object' && env.name) {
|
||
window.addEnvVar(env);
|
||
} else if (typeof env === 'string') {
|
||
window.addEnvVar();
|
||
const container = document.getElementById('deploy-env');
|
||
const last = container?.lastElementChild;
|
||
const keyInput = last?.querySelector('[data-env-key]');
|
||
const valInput = last?.querySelector('[data-env-value]');
|
||
const i = env.indexOf('=');
|
||
if (keyInput) keyInput.value = i >= 0 ? env.slice(0, i) : env;
|
||
if (valInput && i >= 0) valInput.value = env.slice(i + 1);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Labels
|
||
if (template.labels && typeof window.addLabel === 'function') {
|
||
const entries = Array.isArray(template.labels)
|
||
? template.labels
|
||
: Object.entries(template.labels).map(([k, v]) => `${k}=${v}`);
|
||
entries.forEach((label) => {
|
||
window.addLabel();
|
||
const container = document.getElementById('deploy-labels-container');
|
||
const last = container?.lastElementChild?.querySelector('input');
|
||
if (last) last.value = typeof label === 'string' ? label : `${label}`;
|
||
});
|
||
}
|
||
|
||
if (typeof window.updatePreview === 'function') window.updatePreview();
|
||
showAlert('info', `Loaded template: ${template.title || template.name || template.image || 'selected'}`);
|
||
}
|
||
|
||
function resetDeployView() {
|
||
const formSection = document.getElementById('deploy-form-section');
|
||
if (formSection) {
|
||
formSection.style.display = 'none';
|
||
}
|
||
|
||
const viewForm = document.getElementById('deploy-view-form');
|
||
if (viewForm) {
|
||
viewForm.reset();
|
||
|
||
// Clear all array containers (same as modal reset)
|
||
['deploy-ports-container', 'deploy-volumes-container', 'deploy-env', 'deploy-labels-container',
|
||
'deploy-dns-container', 'deploy-extra-hosts-container', 'deploy-devices-container',
|
||
'deploy-capabilities-container', 'deploy-security-opts-container', 'deploy-log-opts-container',
|
||
'deploy-sysctls-container', 'deploy-ulimits-container', 'deploy-tmpfs-container'].forEach(id => {
|
||
const container = document.getElementById(id);
|
||
if (container) container.innerHTML = '';
|
||
});
|
||
}
|
||
|
||
const searchInput = document.getElementById('deploy-template-search-input');
|
||
if (searchInput) {
|
||
searchInput.value = '';
|
||
// Re-display all templates
|
||
if (deployViewTemplates.length > 0) {
|
||
displayDeployTemplateList(deployViewTemplates);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Set up deploy view form submit handler
|
||
let deployViewFormHandlerSetup = false;
|
||
|
||
function setupDeployViewFormHandler() {
|
||
const deployViewForm = document.getElementById('deploy-view-form');
|
||
if (!deployViewForm) {
|
||
console.warn('[WARN] Deploy view form not found, will retry when form is visible');
|
||
return;
|
||
}
|
||
|
||
// Check if handler is already attached by looking for a data attribute
|
||
if (deployViewForm.dataset.handlerAttached === 'true') {
|
||
console.log('[INFO] Form handler already attached');
|
||
return;
|
||
}
|
||
|
||
console.log('[INFO] Setting up deploy view form handler');
|
||
attachFormHandler(deployViewForm);
|
||
deployViewForm.dataset.handlerAttached = 'true';
|
||
deployViewFormHandlerSetup = true;
|
||
}
|
||
|
||
function attachFormHandler(form) {
|
||
if (!form) return;
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.stopImmediatePropagation();
|
||
|
||
console.log('[INFO] Deploy view form submitted');
|
||
|
||
const collect = typeof window.collectFormData === 'function' ? window.collectFormData : null;
|
||
const validate = typeof window.validateFormData === 'function' ? window.validateFormData : null;
|
||
const deploy = typeof window.deployDockerContainer === 'function' ? window.deployDockerContainer : null;
|
||
|
||
if (!collect || !deploy) {
|
||
showAlert('danger', 'Form collection function not available. Please refresh the page.');
|
||
return false;
|
||
}
|
||
|
||
let formData;
|
||
try {
|
||
formData = collect();
|
||
} catch (collectError) {
|
||
console.error('[ERROR] Failed to collect form data:', collectError);
|
||
showAlert('danger', 'Failed to collect form data. Check console for details.');
|
||
return false;
|
||
}
|
||
|
||
// Normalize slider zeros → omit (unlimited)
|
||
if (formData.cpuLimit === 0) delete formData.cpuLimit;
|
||
if (formData.cpuReservation === 0) delete formData.cpuReservation;
|
||
if (formData.memoryLimit === 0) delete formData.memoryLimit;
|
||
if (formData.memoryReservation === 0) delete formData.memoryReservation;
|
||
|
||
// Validate
|
||
if (validate) {
|
||
try {
|
||
const errors = validate(formData);
|
||
if (errors.length > 0) {
|
||
showAlert('danger', errors.join(' '));
|
||
return false;
|
||
}
|
||
} catch (validateError) {
|
||
console.error('[ERROR] Validation error:', validateError);
|
||
}
|
||
}
|
||
|
||
if (!formData.containerName || !formData.image) {
|
||
showAlert('danger', 'Container name and image are required.');
|
||
return false;
|
||
}
|
||
|
||
const containerName = formData.containerName;
|
||
|
||
// Live job drawer handles progress — no top toast / tray spam
|
||
try {
|
||
const successResponse = await deploy(formData);
|
||
|
||
if (successResponse && successResponse.success !== false) {
|
||
// Job tray already shows success; only toast when deploy had no live log UI
|
||
if (!successResponse.viaJob) {
|
||
showAlert('success', successResponse.message || 'Container deployed successfully!');
|
||
}
|
||
resetDeployView();
|
||
if (window.sendCommand) {
|
||
window.sendCommand('listContainers');
|
||
setTimeout(() => navigateToNewContainer(containerName), 800);
|
||
}
|
||
} else {
|
||
throw new Error(successResponse?.error || successResponse?.message || 'Deployment failed');
|
||
}
|
||
} catch (error) {
|
||
console.error('[ERROR] Failed to deploy container:', error);
|
||
// Job drawer already has multi-line error + how-to-fix; skip top toast
|
||
if (!error?.viaJob) {
|
||
presentError(error, 'deployContainer', { showAlert });
|
||
}
|
||
}
|
||
|
||
return false;
|
||
});
|
||
|
||
console.log('[INFO] Deploy view form handler attached');
|
||
}
|
||
|
||
function navigateToNewContainer(containerName) {
|
||
console.log(`[INFO] Looking for newly created container: ${containerName}`);
|
||
|
||
// Set up a handler to catch the container list response and find the new container
|
||
const originalHandler = window.handlePeerResponse;
|
||
let containerFound = false;
|
||
|
||
const findContainerHandler = (response) => {
|
||
if (response.type === 'containers' && response.data && !containerFound) {
|
||
const newContainer = response.data.find(c => {
|
||
const name = c.Names?.[0]?.replace(/^\//, '') || '';
|
||
return name === containerName;
|
||
});
|
||
|
||
if (newContainer) {
|
||
console.log(`[INFO] Found new container, navigating to details: ${containerName}`);
|
||
containerFound = true;
|
||
|
||
// Restore original handler first
|
||
window.handlePeerResponse = originalHandler;
|
||
|
||
// Navigate to container details
|
||
if (typeof showContainerDetails === 'function') {
|
||
showContainerDetails(newContainer);
|
||
} else {
|
||
console.error('[ERROR] showContainerDetails function not available');
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Not the response we're looking for, pass to original handler
|
||
if (typeof originalHandler === 'function') {
|
||
originalHandler(response);
|
||
}
|
||
};
|
||
|
||
window.handlePeerResponse = findContainerHandler;
|
||
|
||
// Request container list
|
||
if (window.activePeer && typeof sendCommand === 'function') {
|
||
sendCommand('listContainers');
|
||
}
|
||
|
||
// Timeout after 10 seconds
|
||
setTimeout(() => {
|
||
if (window.handlePeerResponse === findContainerHandler && !containerFound) {
|
||
window.handlePeerResponse = originalHandler;
|
||
console.warn('[WARN] Timeout waiting for new container to appear. Navigating to containers view.');
|
||
navigateToView('containers');
|
||
}
|
||
}, 10000);
|
||
}
|
||
|
||
// Expose to window
|
||
window.pullImage = pullImage;
|
||
|
||
// Hub search Enter key in pull modal
|
||
document.getElementById('hub-search-term')?.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
searchDockerHub();
|
||
}
|
||
});
|
||
window.createNetwork = createNetwork;
|
||
window.createVolume = createVolume;
|
||
window.showContainerDetails = showContainerDetails;
|
||
window.resetDeployView = resetDeployView;
|
||
window.navigateToNewContainer = navigateToNewContainer;
|
||
window.updateBulkActionsToolbar = updateBulkActionsToolbar;
|
||
window.updateBulkActionsImagesToolbar = updateBulkActionsImagesToolbar;
|
||
window.toggleSelectAllContainers = toggleSelectAllContainers;
|
||
window.toggleSelectAllImages = toggleSelectAllImages;
|
||
window.clearContainerSelection = clearContainerSelection;
|
||
window.clearImageSelection = clearImageSelection;
|
||
window.bulkStartContainers = bulkStartContainers;
|
||
window.bulkStopContainers = bulkStopContainers;
|
||
window.bulkRemoveContainers = bulkRemoveContainers;
|
||
window.bulkRemoveImages = bulkRemoveImages;
|
||
window.filterImages = filterImages;
|
||
|
||
|
||
|
||
// Collapse Sidebar Functionality - set up in DOMContentLoaded
|
||
|
||
function handleRpcMessage(response, conn) {
|
||
const topicId = conn?.id || manager.activeId;
|
||
const peer = conn || manager.active;
|
||
try {
|
||
console.log(`[DEBUG] RPC message (${topicId}):`, response);
|
||
if (response && response.message) console.log(response.message)
|
||
|
||
// Handle errors first - check for error responses before processing
|
||
if (response.error) {
|
||
const errorMessage = handleErrorResponse(response);
|
||
// Error has been sent to notification center, but continue processing
|
||
// in case there are other handlers that need to see the error
|
||
}
|
||
|
||
if (response.success && response.message && typeof response.message === 'string' && response.message.includes('deployed successfully')) {
|
||
console.log(`[INFO] Template deployed successfully: ${response.message}`);
|
||
closeAllModals(); // Close all modals after successful deployment
|
||
|
||
hideStatusIndicator();
|
||
startStatsInterval(); // Restart stats polling
|
||
showAlert('success', response.message);
|
||
hideStatusIndicator();
|
||
|
||
}
|
||
// Ensure the data is for a known connection (pushes always have a conn id)
|
||
if (topicId && !connections[topicId] && peer) {
|
||
// Still process if this is the active manager connection
|
||
if (manager.active && peer.id !== manager.active.id) {
|
||
console.warn(`[WARN] No connection found for topic: ${topicId}. Ignoring data.`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (
|
||
topicId &&
|
||
connections[topicId]?.peer &&
|
||
peer &&
|
||
peer !== connections[topicId].peer &&
|
||
peer.id !== connections[topicId].peer.id
|
||
) {
|
||
console.warn(`[WARN] Ignoring data from a non-active peer for topic: ${topicId}`);
|
||
return;
|
||
}
|
||
|
||
// Delegate handling based on the response type
|
||
switch (response.type) {
|
||
case 'allStats':
|
||
console.log('[INFO] Received aggregated stats for all containers.');
|
||
response.data.forEach((stats) => updateContainerStats(stats));
|
||
break;
|
||
|
||
case 'containers':
|
||
console.log('[INFO] Processing container list...');
|
||
renderContainers(response.data, topicId); // Render containers specific to this topic
|
||
// Update dashboard stats if on dashboard view
|
||
if (currentView === 'dashboard') {
|
||
updateDashboardStats(response.data, null, null);
|
||
}
|
||
break;
|
||
|
||
case 'terminalOutput':
|
||
case 'terminalErrorOutput':
|
||
appendTerminalOutput(response.data, response.containerId, response.encoding);
|
||
if (window.handleDetailsTerminalOutput) {
|
||
window.handleDetailsTerminalOutput(response.data, response.containerId, response.encoding);
|
||
}
|
||
break;
|
||
|
||
case 'execOutput':
|
||
console.log('[INFO] Appending exec output...');
|
||
appendTerminalOutput(response.data, response.containerId, response.encoding);
|
||
break;
|
||
|
||
case 'execErrorOutput':
|
||
console.log('[INFO] Appending exec error output...');
|
||
appendTerminalOutput(response.data, response.containerId, response.encoding);
|
||
break;
|
||
|
||
case 'stacks':
|
||
console.log('[INFO] Handling stacks list...');
|
||
window.currentStacksData = response.data;
|
||
renderStacks(response.data);
|
||
|
||
// Check if we have a pending stack inspect
|
||
if (window.pendingStackInspect) {
|
||
const { stackName } = window.pendingStackInspect;
|
||
delete window.pendingStackInspect;
|
||
hideStatusIndicator();
|
||
|
||
const stack = response.data.find(s => s.name === stackName);
|
||
if (stack) {
|
||
formatAndPopulateStackModal(stack);
|
||
const modalTitle = document.getElementById('stackInspectModalLabel');
|
||
if (modalTitle) {
|
||
modalTitle.innerHTML = `<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);
|
||
} |