// State let state = { servers: [], selectedServerId: null, localProjects: [], deployedProjects: [], runningContainers: [], currentDiffProject: null, currentDetailsProject: null, currentDeployProject: null, currentDeployDiff: null, currentLogsProject: null }; // DOM Elements const serverSelect = document.getElementById('server-select'); const btnScanServer = document.getElementById('btn-scan-server'); const serverStatus = document.getElementById('server-status'); const projectsBody = document.getElementById('projects-body'); const btnRefresh = document.getElementById('btn-refresh'); const btnSettings = document.getElementById('btn-settings'); // Modals const serverModal = document.getElementById('server-modal'); const diffModal = document.getElementById('diff-modal'); const logModal = document.getElementById('log-modal'); const detailsModal = document.getElementById('details-modal'); const deployConfirmModal = document.getElementById('deploy-confirm-modal'); const logsModal = document.getElementById('logs-modal'); // Initialize async function init() { await loadServers(); await loadLocalProjects(); setupEventListeners(); } function setupEventListeners() { btnRefresh.addEventListener('click', () => loadLocalProjects()); btnSettings.addEventListener('click', () => showServerModal()); btnScanServer.addEventListener('click', () => scanServer()); serverSelect.addEventListener('change', (e) => { state.selectedServerId = e.target.value || null; btnScanServer.disabled = !state.selectedServerId; state.deployedProjects = []; state.runningContainers = []; renderProjects(); }); // Modal close buttons document.getElementById('close-server-modal').addEventListener('click', () => hideModal(serverModal)); document.getElementById('close-diff-modal').addEventListener('click', () => hideModal(diffModal)); document.getElementById('close-log-modal').addEventListener('click', () => hideModal(logModal)); document.getElementById('close-details-modal').addEventListener('click', () => hideModal(detailsModal)); document.getElementById('close-deploy-confirm').addEventListener('click', () => hideModal(deployConfirmModal)); document.getElementById('close-logs-modal').addEventListener('click', () => hideModal(logsModal)); // Server form document.getElementById('btn-save-server').addEventListener('click', saveServer); // Details modal - init project button document.getElementById('btn-init-project').addEventListener('click', initCurrentProject); // Deploy confirmation modal buttons document.getElementById('btn-deploy-continue').addEventListener('click', executeDeployment); document.getElementById('btn-deploy-abort').addEventListener('click', () => hideModal(deployConfirmModal)); document.getElementById('btn-deploy-vscode').addEventListener('click', openVSCodeDiff); // Logs modal - refresh button document.getElementById('btn-refresh-logs').addEventListener('click', refreshLogs); } // Load servers async function loadServers() { state.servers = await window.api.getServers(); renderServerSelect(); } function renderServerSelect() { serverSelect.innerHTML = ''; for (const server of state.servers) { const option = document.createElement('option'); option.value = server.id; option.textContent = `${server.name} (${server.host})`; serverSelect.appendChild(option); } } // Load local projects async function loadLocalProjects() { projectsBody.innerHTML = 'Scanning local projects...'; try { state.localProjects = await window.api.scanLocalProjects(); renderProjects(); } catch (err) { projectsBody.innerHTML = `Error: ${err.message}`; } } // Scan remote server async function scanServer() { if (!state.selectedServerId) return; serverStatus.textContent = 'Connecting...'; serverStatus.className = 'status-badge'; try { // Get deployed projects const deployedResult = await window.api.scanServer(state.selectedServerId); if (deployedResult.error) { throw new Error(deployedResult.error); } state.deployedProjects = deployedResult.deployed || []; // Get running containers const containersResult = await window.api.getRunningContainers(state.selectedServerId); if (containersResult.success) { state.runningContainers = containersResult.containers || []; } serverStatus.textContent = `Connected (${state.deployedProjects.length} deployed)`; serverStatus.className = 'status-badge connected'; renderProjects(); } catch (err) { serverStatus.textContent = `Error: ${err.message}`; serverStatus.className = 'status-badge error'; } } // Render projects table function renderProjects() { if (state.localProjects.length === 0) { projectsBody.innerHTML = 'No projects found with Dockerfiles'; return; } // Merge local and deployed data const projectMap = new Map(); // Add local projects for (const project of state.localProjects) { projectMap.set(project.name, { ...project, deployed: null, running: null }); } // Match with deployed projects for (const deployed of state.deployedProjects) { if (projectMap.has(deployed.name)) { projectMap.get(deployed.name).deployed = deployed; } else { // Project exists on server but not locally projectMap.set(deployed.name, { name: deployed.name, path: null, hasDockerfile: false, hasDockerCompose: false, dockerStatus: 'remote-only', deployed }); } } // Match with running containers for (const container of state.runningContainers) { // Try to match container name with project name for (const [name, project] of projectMap) { if (container.name.includes(name) || name.includes(container.name)) { project.running = container; break; } } } // Render table rows projectsBody.innerHTML = ''; for (const [name, project] of projectMap) { const row = document.createElement('tr'); row.innerHTML = ` ${name} ${project.path ? `${project.path}` : ''} ${renderLocalStatus(project)} ${renderDeployedStatus(project)} ${renderRunningStatus(project)} ${renderDiffStatus(project)} ${renderActions(project)} `; projectsBody.appendChild(row); } } function renderLocalStatus(project) { if (!project.path) { return 'Not local'; } // Count missing files const requiredFiles = getRequiredFiles(project); const missingCount = requiredFiles.filter(f => !f.present).length; if (missingCount === 0) { return `Ready`; } else if (missingCount <= 2) { return `${missingCount} missing`; } else { return `${missingCount} missing`; } } function renderDeployedStatus(project) { if (!state.selectedServerId) { return 'Select server'; } if (project.deployed) { return 'Deployed'; } else { return 'Not deployed'; } } function renderRunningStatus(project) { if (!state.selectedServerId) { return '-'; } if (project.running) { return `${project.running.status.split(' ')[0]}`; } else if (project.deployed) { return 'Stopped'; } else { return '-'; } } function renderDiffStatus(project) { if (!project.deployed || !project.path) { return 'N/A'; } // If we have deployed content, we can compare if (project.deployed.dockerComposeContent && project.hasDockerCompose) { return ``; } return '?'; } function renderActions(project) { const actions = []; if (project.path && project.hasDockerfile) { actions.push(``); } if (project.path && state.selectedServerId) { actions.push(``); } // Add logs button for running containers if (project.running && state.selectedServerId) { actions.push(``); } return actions.join(' '); } // Actions async function buildTar(projectPath) { showLog('Building Docker image and tar...\n'); const result = await window.api.buildTar(projectPath); if (result.error) { appendLog(`\nError: ${result.error}\n${result.stderr || ''}`); } else { appendLog(result.output); appendLog('\n\nBuild complete!'); } } async function deployProject(projectName) { const project = state.localProjects.find(p => p.name === projectName); if (!project || !state.selectedServerId) return; state.currentDeployProject = project; // Step 1: Compare files first showLog(`Checking for differences before deploy...`); const comparison = await window.api.compareProject({ projectPath: project.path, serverId: state.selectedServerId, remotePath: project.remotePath || `~/containers/${projectName}` }); hideModal(logModal); if (comparison.error) { showLog(`Error comparing files: ${comparison.error}`); return; } state.currentDeployDiff = comparison.diff; // Step 2: Check if there are differences const composeStatus = comparison.diff.dockerCompose.status; const envStatus = comparison.diff.env.status; const hasDiff = composeStatus === 'different' || envStatus === 'different'; const isNewDeploy = composeStatus === 'remote-missing' && envStatus !== 'different'; if (isNewDeploy) { // New deployment - no confirmation needed await executeDeployment(); return; } if (hasDiff) { // Show confirmation modal with differences showDeployConfirmModal(project, comparison.diff); return; } // No differences - proceed directly await executeDeployment(); } function showDeployConfirmModal(project, diff) { document.getElementById('deploy-confirm-project').textContent = project.name; // Check all files for differences const filesWithDiff = (diff.files || []).filter(f => f.status === 'different' || f.status === 'local-missing' ); const hasDiff = filesWithDiff.length > 0; // Summary const summaryEl = document.getElementById('deploy-diff-summary'); if (hasDiff) { summaryEl.className = 'deploy-diff-summary has-diff'; summaryEl.innerHTML = ` Differences found! ${filesWithDiff.length} file(s) differ from deployed versions.
Continuing will overwrite the remote files. ${filesWithDiff.some(f => f.status === 'local-missing') ? '
Warning: Some files exist on server but not locally.' : ''} `; } else { summaryEl.className = 'deploy-diff-summary no-diff'; summaryEl.innerHTML = `No differences found. Local and remote files match.`; } // Details - show all files with their status const detailsEl = document.getElementById('deploy-diff-details'); detailsEl.innerHTML = ''; // Show all compared files const files = diff.files || []; for (const file of files) { const statusBadge = getStatusBadge(file.status); const fileIndex = files.indexOf(file); if (file.status === 'different') { // Show diff for files that are different const content = file.sensitive ? { local: maskEnvContent(file.localContent), remote: maskEnvContent(file.remoteContent) } : { local: file.localContent, remote: file.remoteContent }; detailsEl.innerHTML += `

${file.name} ${statusBadge}

Local
${escapeHtml(content.local || '(empty)')}
Remote
${escapeHtml(content.remote || '(empty)')}
`; } else if (file.status === 'local-missing' && file.remoteContent) { // File exists on server but not locally detailsEl.innerHTML += `

${file.name} ${statusBadge}

Local
(not found)
Remote
${escapeHtml(file.sensitive ? maskEnvContent(file.remoteContent) : file.remoteContent)}
`; } else if (file.type === 'directory' && file.status !== 'neither') { // Directory status detailsEl.innerHTML += `

${file.name} ${statusBadge} ${file.status === 'local-missing' || file.status === 'both-exist' ? `` : ''}

`; } } // Add "Pull All Different" button if there are pullable files const pullableFiles = files.filter(f => (f.status === 'different' || f.status === 'local-missing') && f.remoteContent ); if (pullableFiles.length > 0) { detailsEl.innerHTML += `
`; } showModal(deployConfirmModal); } function getStatusBadge(status) { switch (status) { case 'match': return 'Match'; case 'different': return 'Different'; case 'remote-missing': return 'Not on server'; case 'local-missing': return 'Only on server'; case 'both-exist': return 'Both exist'; case 'neither': return 'Neither'; default: return 'Unknown'; } } // Pull a single file from server window.pullSingleFile = async function(fileIndex) { const diff = state.currentDeployDiff; const project = state.currentDeployProject; if (!diff || !project || !diff.files[fileIndex]) return; const file = diff.files[fileIndex]; showLog(`Pulling ${file.name} from server...`); const result = await window.api.pullFile({ serverId: state.selectedServerId, remotePath: file.remotePath, localPath: file.localPath, isDirectory: file.type === 'directory' }); if (result.error) { appendLog(`\nError: ${result.error}`); } else { appendLog(`\nPulled ${file.name} successfully!`); // Refresh comparison hideModal(logModal); await deployProject(project.name); } }; // Pull all different files from server window.pullAllDifferent = async function() { const diff = state.currentDeployDiff; const project = state.currentDeployProject; if (!diff || !project) return; const filesToPull = (diff.files || []).filter(f => (f.status === 'different' || f.status === 'local-missing') && (f.remoteContent || f.type === 'directory') ); if (filesToPull.length === 0) { alert('No files to pull'); return; } showLog(`Pulling ${filesToPull.length} files from server...\n`); const result = await window.api.pullFiles({ serverId: state.selectedServerId, files: filesToPull }); if (result.error) { appendLog(`\nError: ${result.error}`); } else { if (result.pulled && result.pulled.length > 0) { appendLog(`\nPulled: ${result.pulled.join(', ')}`); } if (result.errors && result.errors.length > 0) { appendLog(`\nErrors:`); for (const err of result.errors) { appendLog(`\n ${err.name}: ${err.error}`); } } appendLog('\n\nDone! Refreshing...'); // Refresh hideModal(logModal); hideModal(deployConfirmModal); await loadLocalProjects(); } }; function maskEnvContent(content) { if (!content) return null; // Mask values that look like passwords/secrets return content.replace(/^([A-Z_]+PASSWORD|[A-Z_]+SECRET|[A-Z_]+KEY|[A-Z_]+TOKEN)=(.+)$/gm, '$1=****'); } function escapeHtml(text) { if (!text) return ''; return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } async function executeDeployment() { const project = state.currentDeployProject; if (!project) return; hideModal(deployConfirmModal); showLog(`Deploying ${project.name}...\n`); const result = await window.api.deployProject({ projectPath: project.path, serverId: state.selectedServerId, remotePath: project.remotePath || `~/containers/${project.name}` }); if (result.error) { appendLog(`\nError: ${result.error}`); } else { // Show uploaded files if (result.uploadedFiles && result.uploadedFiles.length > 0) { appendLog(`\nUploaded files:\n - ${result.uploadedFiles.join('\n - ')}`); } appendLog(`\n\n${result.message || 'Deployment complete!'}`); if (result.status) { appendLog(`\n\nContainer status:\n${result.status}`); } if (result.healthy) { appendLog('\n\nHealth check: PASSED'); } else { appendLog('\n\nHealth check: PENDING (container may still be starting)'); } // Refresh server state await scanServer(); } state.currentDeployProject = null; state.currentDeployDiff = null; } async function openVSCodeDiff() { const project = state.currentDeployProject; const diff = state.currentDeployDiff; if (!project || !diff) return; // Open docker-compose.yml diff in VS Code if (diff.dockerCompose.status === 'different') { const result = await window.api.openVSCodeDiff({ localPath: `${project.path}/docker-compose.yml`, serverId: state.selectedServerId, remoteFilePath: `${project.remotePath || '~/containers/' + project.name}/docker-compose.yml` }); if (result.error) { alert(`Error opening VS Code: ${result.error}`); } } } async function showDiff(projectName) { const project = state.localProjects.find(p => p.name === projectName); if (!project) return; state.currentDiffProject = project; document.getElementById('diff-project-name').textContent = projectName; const result = await window.api.compareProject({ projectPath: project.path, serverId: state.selectedServerId, remotePath: project.remotePath || `~/containers/${projectName}` }); if (result.error) { alert(`Error comparing: ${result.error}`); return; } const diff = result.diff; // docker-compose status const statusEl = document.getElementById('diff-compose-status'); if (diff.dockerCompose.status === 'match') { statusEl.innerHTML = 'Files match'; } else if (diff.dockerCompose.status === 'different') { statusEl.innerHTML = 'Files differ'; } else if (diff.dockerCompose.status === 'remote-missing') { statusEl.innerHTML = 'Not deployed'; } else if (diff.dockerCompose.status === 'local-missing') { statusEl.innerHTML = 'No local file'; } else { statusEl.innerHTML = 'Unknown'; } document.getElementById('diff-compose-local').textContent = diff.dockerCompose.localContent || '(not found)'; document.getElementById('diff-compose-remote').textContent = diff.dockerCompose.remoteContent || '(not found)'; // Wire up pull/push buttons document.getElementById('btn-pull-compose').onclick = () => pullCompose(project); document.getElementById('btn-push-compose').onclick = () => pushCompose(project); showModal(diffModal); } async function pullCompose(project) { const result = await window.api.pullFile({ serverId: state.selectedServerId, remotePath: `${project.remotePath || '~/containers/' + project.name}/docker-compose.yml`, localPath: `${project.path}/docker-compose.yml` }); if (result.error) { alert(`Error: ${result.error}`); } else { alert('Pulled docker-compose.yml from server'); hideModal(diffModal); loadLocalProjects(); } } async function pushCompose(project) { // Deploy just the compose file await deployProject(project.name); hideModal(diffModal); } // Server management function showServerModal() { renderServerList(); clearServerForm(); showModal(serverModal); } function renderServerList() { const listEl = document.getElementById('server-list'); listEl.innerHTML = ''; for (const server of state.servers) { const item = document.createElement('div'); item.className = 'server-item'; item.innerHTML = `
${server.name} ${server.host} ${server.useSudo ? '
sudo
' : ''}
`; listEl.appendChild(item); } } function clearServerForm() { document.getElementById('server-id').value = ''; document.getElementById('server-name').value = ''; document.getElementById('server-host').value = ''; document.getElementById('server-username').value = ''; document.getElementById('server-use-sudo').checked = false; } window.editServer = function(serverId) { const server = state.servers.find(s => s.id === serverId); if (!server) return; document.getElementById('server-id').value = server.id; document.getElementById('server-name').value = server.name; document.getElementById('server-host').value = server.host; document.getElementById('server-username').value = server.username || ''; document.getElementById('server-use-sudo').checked = server.useSudo || false; }; window.deleteServer = async function(serverId) { if (!confirm('Delete this server?')) return; await window.api.deleteServer(serverId); await loadServers(); renderServerList(); }; async function saveServer() { const server = { id: document.getElementById('server-id').value || undefined, name: document.getElementById('server-name').value, host: document.getElementById('server-host').value, username: document.getElementById('server-username').value, useSudo: document.getElementById('server-use-sudo').checked }; if (!server.name || !server.host) { alert('Name and host are required'); return; } await window.api.saveServer(server); await loadServers(); renderServerList(); clearServerForm(); } // Log modal function showLog(text) { document.getElementById('log-content').textContent = text; showModal(logModal); } function appendLog(text) { document.getElementById('log-content').textContent += text; } // Modal helpers function showModal(modal) { modal.classList.remove('hidden'); } function hideModal(modal) { modal.classList.add('hidden'); } // Required files checker function getRequiredFiles(project) { return [ { name: 'Dockerfile', description: 'Container build instructions', present: project.hasDockerfile, fix: 'Create manually or use: npm run docker-deploy -- init' }, { name: 'docker-compose.yml', description: 'Runtime configuration (ports, env, volumes)', present: project.hasDockerCompose, fix: 'Create manually or use: npm run docker-deploy -- init' }, { name: 'build-image-tar.ps1', description: 'Script to build Docker image and create tar', present: project.hasBuildScript, fix: 'Use: npm run docker-deploy -- init' }, { name: '.dockerignore', description: 'Files to exclude from Docker image', present: project.hasDockerIgnore, fix: 'Create manually or use: npm run docker-deploy -- init' } ]; } // Show project details modal function showDetails(projectName) { const project = state.localProjects.find(p => p.name === projectName); if (!project) return; state.currentDetailsProject = project; document.getElementById('details-project-name').textContent = projectName; // Render file checklist const filesListEl = document.getElementById('details-files-list'); const requiredFiles = getRequiredFiles(project); filesListEl.innerHTML = requiredFiles.map(file => `
${file.present ? '✓' : '✗'}
${file.name}
${file.description}
`).join(''); // Render fix instructions const fixEl = document.getElementById('details-fix-instructions'); const missingFiles = requiredFiles.filter(f => !f.present); if (missingFiles.length === 0) { fixEl.innerHTML = `
All files present!

This project is ready for deployment.

`; document.getElementById('btn-init-project').style.display = 'none'; } else { fixEl.innerHTML = `
Option 1: Use the CLI tool

Run this command to generate missing files:

cd "${project.path}"
npm run docker-deploy -- init .
This runs from the docker-deployment-manager repo
Option 2: Create manually

Missing files:

${missingFiles.map(f => `
${f.name} - ${f.description}
`).join('')}
`; document.getElementById('btn-init-project').style.display = 'block'; } showModal(detailsModal); } // Initialize project via CLI async function initCurrentProject() { const project = state.currentDetailsProject; if (!project) return; hideModal(detailsModal); showLog(`Initializing Docker config for ${project.name}...\n\nRunning: npm run docker-deploy -- init "${project.path}" --no-interactive\n\n`); const result = await window.api.initProject(project.path); if (result.error) { appendLog(`\nError: ${result.error}`); } else { appendLog(result.output); appendLog('\n\nInit complete! Refreshing project list...'); await loadLocalProjects(); } } // Container logs viewer async function viewLogs(projectName) { const project = state.localProjects.find(p => p.name === projectName); if (!project || !state.selectedServerId) return; state.currentLogsProject = project; document.getElementById('logs-container-name').textContent = projectName; document.getElementById('logs-content').textContent = 'Loading logs...'; showModal(logsModal); await refreshLogs(); } async function refreshLogs() { const project = state.currentLogsProject; if (!project) return; const result = await window.api.getContainerLogs({ serverId: state.selectedServerId, remotePath: project.remotePath || `~/containers/${project.name}`, lines: 100 }); if (result.error) { document.getElementById('logs-content').textContent = `Error: ${result.error}`; } else { document.getElementById('logs-content').textContent = result.logs || '(no logs)'; } } // Global function bindings for inline onclick window.buildTar = buildTar; window.deployProject = deployProject; window.showDiff = showDiff; window.showDetails = showDetails; window.viewLogs = viewLogs; // Start app init();