627 lines
18 KiB
JavaScript
627 lines
18 KiB
JavaScript
const { app, BrowserWindow, ipcMain } = require('electron');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { SSHService } = require('./ssh-service');
|
|
const { ProjectScanner } = require('./project-scanner');
|
|
const { ServerScanner } = require('./server-scanner');
|
|
|
|
let mainWindow;
|
|
let config = {
|
|
servers: [],
|
|
projectsRoot: 'C:\\.bucket\\repos.gitea',
|
|
projects: {}
|
|
};
|
|
|
|
const CONFIG_PATH = path.join(__dirname, '..', '..', 'deployment-config.json');
|
|
const ENV_PATH = path.join(__dirname, '..', '.env');
|
|
|
|
function loadConfig() {
|
|
try {
|
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load config:', err);
|
|
}
|
|
}
|
|
|
|
function saveConfig() {
|
|
try {
|
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
} catch (err) {
|
|
console.error('Failed to save config:', err);
|
|
}
|
|
}
|
|
|
|
function loadEnv() {
|
|
try {
|
|
if (fs.existsSync(ENV_PATH)) {
|
|
const envContent = fs.readFileSync(ENV_PATH, 'utf-8');
|
|
const lines = envContent.split('\n');
|
|
const env = {};
|
|
for (const line of lines) {
|
|
const [key, ...valueParts] = line.split('=');
|
|
if (key && valueParts.length > 0) {
|
|
env[key.trim()] = valueParts.join('=').trim();
|
|
}
|
|
}
|
|
return env;
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load .env:', err);
|
|
}
|
|
return {};
|
|
}
|
|
|
|
// Load project's docker-deployment.json config
|
|
function loadProjectDeployConfig(projectPath) {
|
|
const configPath = path.join(projectPath, 'docker-deployment.json');
|
|
try {
|
|
if (fs.existsSync(configPath)) {
|
|
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load project config:', err);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Get list of files to upload for a project
|
|
function getUploadFiles(projectPath, projectConfig) {
|
|
const projectName = path.basename(projectPath);
|
|
|
|
// Default files - always upload these if they exist
|
|
const defaultFiles = [
|
|
{ local: `${projectName}.tar`, remote: `${projectName}.tar`, type: 'file' },
|
|
{ local: 'docker-compose.yml', remote: 'docker-compose.yml', type: 'file' },
|
|
{ local: '.env', remote: '.env', type: 'file' }
|
|
];
|
|
|
|
// Additional files from project config
|
|
const additionalFiles = projectConfig?.deployment?.uploadFiles || [];
|
|
const customFiles = additionalFiles.map(f => {
|
|
if (typeof f === 'string') {
|
|
// Simple string path - detect if directory
|
|
const isDir = f.endsWith('/');
|
|
return {
|
|
local: f.replace(/\/$/, ''),
|
|
remote: f.replace(/\/$/, ''),
|
|
type: isDir ? 'directory' : 'file'
|
|
};
|
|
}
|
|
return f; // Already an object with local/remote/type
|
|
});
|
|
|
|
return [...defaultFiles, ...customFiles];
|
|
}
|
|
|
|
function createWindow() {
|
|
mainWindow = new BrowserWindow({
|
|
width: 1400,
|
|
height: 900,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, '..', 'preload.js'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false
|
|
},
|
|
title: 'Docker Deployment Manager'
|
|
});
|
|
|
|
if (process.argv.includes('--dev')) {
|
|
mainWindow.loadURL('http://localhost:5173');
|
|
mainWindow.webContents.openDevTools();
|
|
} else {
|
|
mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'dist', 'index.html'));
|
|
}
|
|
}
|
|
|
|
app.whenReady().then(() => {
|
|
loadConfig();
|
|
createWindow();
|
|
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
createWindow();
|
|
}
|
|
});
|
|
});
|
|
|
|
app.on('window-all-closed', () => {
|
|
if (process.platform !== 'darwin') {
|
|
app.quit();
|
|
}
|
|
});
|
|
|
|
// IPC Handlers
|
|
|
|
// Get configuration
|
|
ipcMain.handle('get-config', () => {
|
|
return config;
|
|
});
|
|
|
|
// Save configuration
|
|
ipcMain.handle('save-config', (event, newConfig) => {
|
|
config = { ...config, ...newConfig };
|
|
saveConfig();
|
|
return { success: true };
|
|
});
|
|
|
|
// Get servers
|
|
ipcMain.handle('get-servers', () => {
|
|
return config.servers || [];
|
|
});
|
|
|
|
// Add/update server
|
|
ipcMain.handle('save-server', (event, server) => {
|
|
const index = config.servers.findIndex(s => s.id === server.id);
|
|
if (index >= 0) {
|
|
config.servers[index] = server;
|
|
} else {
|
|
server.id = Date.now().toString();
|
|
config.servers.push(server);
|
|
}
|
|
saveConfig();
|
|
return { success: true, server };
|
|
});
|
|
|
|
// Delete server
|
|
ipcMain.handle('delete-server', (event, serverId) => {
|
|
config.servers = config.servers.filter(s => s.id !== serverId);
|
|
saveConfig();
|
|
return { success: true };
|
|
});
|
|
|
|
// Scan local projects (deep scan to find nested Dockerfiles)
|
|
ipcMain.handle('scan-local-projects', async () => {
|
|
const scanner = new ProjectScanner(config.projectsRoot);
|
|
const projects = await scanner.scanDeep(2);
|
|
return projects;
|
|
});
|
|
|
|
// Scan remote server for deployed containers
|
|
ipcMain.handle('scan-server', async (event, serverId) => {
|
|
const server = config.servers.find(s => s.id === serverId);
|
|
if (!server) {
|
|
return { error: 'Server not found' };
|
|
}
|
|
|
|
const env = loadEnv();
|
|
const sshConfig = {
|
|
host: server.host,
|
|
username: env.SSH_USERNAME || server.username,
|
|
password: env.SSH_PASSWORD || server.password
|
|
};
|
|
|
|
const scanner = new ServerScanner(sshConfig);
|
|
try {
|
|
const deployed = await scanner.scan();
|
|
return { success: true, deployed };
|
|
} catch (err) {
|
|
return { error: err.message };
|
|
}
|
|
});
|
|
|
|
// Compare local vs deployed
|
|
ipcMain.handle('compare-project', async (event, { projectPath, serverId, remotePath }) => {
|
|
const server = config.servers.find(s => s.id === serverId);
|
|
if (!server) {
|
|
return { error: 'Server not found' };
|
|
}
|
|
|
|
const env = loadEnv();
|
|
const sshConfig = {
|
|
host: server.host,
|
|
username: env.SSH_USERNAME || server.username,
|
|
password: env.SSH_PASSWORD || server.password
|
|
};
|
|
|
|
// Load project config to get additional files to compare
|
|
const projectConfig = loadProjectDeployConfig(projectPath);
|
|
const additionalFiles = projectConfig?.deployment?.uploadFiles || [];
|
|
|
|
const scanner = new ServerScanner(sshConfig);
|
|
try {
|
|
const diff = await scanner.compareFiles(projectPath, remotePath, additionalFiles);
|
|
return { success: true, diff };
|
|
} catch (err) {
|
|
return { error: err.message };
|
|
}
|
|
});
|
|
|
|
// Build tar for project
|
|
ipcMain.handle('build-tar', async (event, projectPath) => {
|
|
const { exec } = require('child_process');
|
|
const projectName = path.basename(projectPath);
|
|
|
|
return new Promise((resolve) => {
|
|
// Check if build-image-tar.ps1 exists
|
|
const scriptPath = path.join(projectPath, 'build-image-tar.ps1');
|
|
if (!fs.existsSync(scriptPath)) {
|
|
resolve({ error: 'No build-image-tar.ps1 found in project' });
|
|
return;
|
|
}
|
|
|
|
exec(`powershell -ExecutionPolicy Bypass -File "${scriptPath}"`, { cwd: projectPath }, (error, stdout, stderr) => {
|
|
if (error) {
|
|
resolve({ error: error.message, stderr });
|
|
} else {
|
|
resolve({ success: true, output: stdout });
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Deploy project to server
|
|
ipcMain.handle('deploy-project', async (event, { projectPath, serverId, remotePath }) => {
|
|
const server = config.servers.find(s => s.id === serverId);
|
|
if (!server) {
|
|
return { error: 'Server not found' };
|
|
}
|
|
|
|
const env = loadEnv();
|
|
const sshConfig = {
|
|
host: server.host,
|
|
username: env.SSH_USERNAME || server.username,
|
|
password: env.SSH_PASSWORD || server.password
|
|
};
|
|
|
|
const ssh = new SSHService(sshConfig);
|
|
const projectName = path.basename(projectPath);
|
|
|
|
// Sudo prefix for servers that need elevated permissions
|
|
const password = env.SSH_PASSWORD || server.password;
|
|
const sudoPrefix = server.useSudo ? `echo '${password}' | sudo -S ` : '';
|
|
|
|
// Load project config and get files to upload
|
|
const projectConfig = loadProjectDeployConfig(projectPath);
|
|
const uploadFiles = getUploadFiles(projectPath, projectConfig);
|
|
const uploadedFiles = [];
|
|
|
|
try {
|
|
await ssh.connect();
|
|
|
|
// Ensure remote directory exists
|
|
await ssh.exec(`mkdir -p ${remotePath}`);
|
|
|
|
// Delete old tar file with sudo if needed (may be owned by root)
|
|
if (server.useSudo) {
|
|
await ssh.exec(`echo '${password}' | sudo -S rm -f ${remotePath}/${projectName}.tar 2>/dev/null || true`);
|
|
}
|
|
|
|
// Upload all configured files
|
|
for (const fileSpec of uploadFiles) {
|
|
const localPath = path.join(projectPath, fileSpec.local);
|
|
|
|
if (!fs.existsSync(localPath)) {
|
|
continue; // Skip if file doesn't exist locally
|
|
}
|
|
|
|
const remoteDest = `${remotePath}/${fileSpec.remote}`;
|
|
|
|
if (fileSpec.type === 'directory') {
|
|
// Create remote directory and upload all contents
|
|
await ssh.exec(`mkdir -p ${remoteDest}`);
|
|
|
|
// Get all files in directory recursively
|
|
const uploadDir = async (dirPath, remoteDir) => {
|
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const entryLocalPath = path.join(dirPath, entry.name);
|
|
const entryRemotePath = `${remoteDir}/${entry.name}`;
|
|
|
|
if (entry.isDirectory()) {
|
|
await ssh.exec(`mkdir -p ${entryRemotePath}`);
|
|
await uploadDir(entryLocalPath, entryRemotePath);
|
|
} else {
|
|
await ssh.uploadFile(entryLocalPath, entryRemotePath);
|
|
}
|
|
}
|
|
};
|
|
|
|
await uploadDir(localPath, remoteDest);
|
|
uploadedFiles.push(`${fileSpec.local}/ (directory)`);
|
|
} else {
|
|
// Regular file
|
|
await ssh.uploadFile(localPath, remoteDest);
|
|
uploadedFiles.push(fileSpec.local);
|
|
}
|
|
}
|
|
|
|
// Load image, stop existing container, and start new container
|
|
await ssh.exec(`cd ${remotePath} && ${sudoPrefix}docker load -i ${projectName}.tar && ${sudoPrefix}docker compose down 2>/dev/null; ${sudoPrefix}docker compose up -d`);
|
|
|
|
// Health check - poll for container status
|
|
let healthy = false;
|
|
let status = '';
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2s
|
|
try {
|
|
status = await ssh.exec(`cd ${remotePath} && ${sudoPrefix}docker compose ps --format "{{.Name}}|{{.Status}}" 2>/dev/null || ${sudoPrefix}docker compose ps`);
|
|
if (status.includes('Up') || status.includes('healthy')) {
|
|
healthy = true;
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
// Ignore errors during health check polling
|
|
}
|
|
}
|
|
|
|
ssh.disconnect();
|
|
return {
|
|
success: true,
|
|
healthy,
|
|
status,
|
|
uploadedFiles,
|
|
message: healthy ? 'Container started successfully' : 'Container started but health check pending'
|
|
};
|
|
} catch (err) {
|
|
return { error: err.message };
|
|
}
|
|
});
|
|
|
|
// Get docker ps from server
|
|
ipcMain.handle('get-running-containers', async (event, serverId) => {
|
|
const server = config.servers.find(s => s.id === serverId);
|
|
if (!server) {
|
|
return { error: 'Server not found' };
|
|
}
|
|
|
|
const env = loadEnv();
|
|
const sshConfig = {
|
|
host: server.host,
|
|
username: env.SSH_USERNAME || server.username,
|
|
password: env.SSH_PASSWORD || server.password
|
|
};
|
|
|
|
const ssh = new SSHService(sshConfig);
|
|
try {
|
|
await ssh.connect();
|
|
const result = await ssh.exec('docker ps --format "{{.Names}}|{{.Status}}|{{.Ports}}"');
|
|
ssh.disconnect();
|
|
|
|
const containers = result.split('\n').filter(Boolean).map(line => {
|
|
const [name, status, ports] = line.split('|');
|
|
return { name, status, ports };
|
|
});
|
|
|
|
return { success: true, containers };
|
|
} catch (err) {
|
|
return { error: err.message };
|
|
}
|
|
});
|
|
|
|
// Pull file from server (sync back)
|
|
ipcMain.handle('pull-file', async (event, { serverId, remotePath, localPath, isDirectory }) => {
|
|
const server = config.servers.find(s => s.id === serverId);
|
|
if (!server) {
|
|
return { error: 'Server not found' };
|
|
}
|
|
|
|
const env = loadEnv();
|
|
const sshConfig = {
|
|
host: server.host,
|
|
username: env.SSH_USERNAME || server.username,
|
|
password: env.SSH_PASSWORD || server.password
|
|
};
|
|
|
|
const ssh = new SSHService(sshConfig);
|
|
try {
|
|
await ssh.connect();
|
|
|
|
if (isDirectory) {
|
|
// Pull entire directory recursively
|
|
const pullDir = async (remoteDir, localDir) => {
|
|
// Ensure local directory exists
|
|
if (!fs.existsSync(localDir)) {
|
|
fs.mkdirSync(localDir, { recursive: true });
|
|
}
|
|
|
|
// List remote directory contents
|
|
const result = await ssh.exec(`ls -la ${remoteDir} 2>/dev/null | tail -n +4 || echo ""`);
|
|
const lines = result.split('\n').filter(Boolean);
|
|
|
|
for (const line of lines) {
|
|
const parts = line.split(/\s+/);
|
|
if (parts.length < 9) continue;
|
|
|
|
const isDir = line.startsWith('d');
|
|
const fileName = parts.slice(8).join(' ');
|
|
|
|
if (fileName === '.' || fileName === '..') continue;
|
|
|
|
const remoteFilePath = `${remoteDir}/${fileName}`;
|
|
const localFilePath = path.join(localDir, fileName);
|
|
|
|
if (isDir) {
|
|
await pullDir(remoteFilePath, localFilePath);
|
|
} else {
|
|
await ssh.downloadFile(remoteFilePath, localFilePath);
|
|
}
|
|
}
|
|
};
|
|
|
|
await pullDir(remotePath, localPath);
|
|
} else {
|
|
// Pull single file
|
|
await ssh.downloadFile(remotePath, localPath);
|
|
}
|
|
|
|
ssh.disconnect();
|
|
return { success: true };
|
|
} catch (err) {
|
|
return { error: err.message };
|
|
}
|
|
});
|
|
|
|
// Pull multiple files from server
|
|
ipcMain.handle('pull-files', async (event, { serverId, files }) => {
|
|
const server = config.servers.find(s => s.id === serverId);
|
|
if (!server) {
|
|
return { error: 'Server not found' };
|
|
}
|
|
|
|
const env = loadEnv();
|
|
const sshConfig = {
|
|
host: server.host,
|
|
username: env.SSH_USERNAME || server.username,
|
|
password: env.SSH_PASSWORD || server.password
|
|
};
|
|
|
|
const ssh = new SSHService(sshConfig);
|
|
const pulled = [];
|
|
const errors = [];
|
|
|
|
try {
|
|
await ssh.connect();
|
|
|
|
for (const file of files) {
|
|
try {
|
|
if (file.type === 'directory') {
|
|
// Pull directory recursively (same logic as above)
|
|
const pullDir = async (remoteDir, localDir) => {
|
|
if (!fs.existsSync(localDir)) {
|
|
fs.mkdirSync(localDir, { recursive: true });
|
|
}
|
|
|
|
const result = await ssh.exec(`ls -la ${remoteDir} 2>/dev/null | tail -n +4 || echo ""`);
|
|
const lines = result.split('\n').filter(Boolean);
|
|
|
|
for (const line of lines) {
|
|
const parts = line.split(/\s+/);
|
|
if (parts.length < 9) continue;
|
|
|
|
const isDir = line.startsWith('d');
|
|
const fileName = parts.slice(8).join(' ');
|
|
|
|
if (fileName === '.' || fileName === '..') continue;
|
|
|
|
const remoteFilePath = `${remoteDir}/${fileName}`;
|
|
const localFilePath = path.join(localDir, fileName);
|
|
|
|
if (isDir) {
|
|
await pullDir(remoteFilePath, localFilePath);
|
|
} else {
|
|
await ssh.downloadFile(remoteFilePath, localFilePath);
|
|
}
|
|
}
|
|
};
|
|
|
|
await pullDir(file.remotePath, file.localPath);
|
|
pulled.push(file.name);
|
|
} else {
|
|
// Ensure parent directory exists
|
|
const parentDir = path.dirname(file.localPath);
|
|
if (!fs.existsSync(parentDir)) {
|
|
fs.mkdirSync(parentDir, { recursive: true });
|
|
}
|
|
|
|
await ssh.downloadFile(file.remotePath, file.localPath);
|
|
pulled.push(file.name);
|
|
}
|
|
} catch (err) {
|
|
errors.push({ name: file.name, error: err.message });
|
|
}
|
|
}
|
|
|
|
ssh.disconnect();
|
|
return { success: true, pulled, errors };
|
|
} catch (err) {
|
|
return { error: err.message };
|
|
}
|
|
});
|
|
|
|
// Initialize project with CLI tool
|
|
ipcMain.handle('init-project', async (event, projectPath) => {
|
|
const { exec } = require('child_process');
|
|
const cliPath = path.join(__dirname, '..', '..');
|
|
|
|
return new Promise((resolve) => {
|
|
const cmd = `node cli/index.js init "${projectPath}" --no-interactive`;
|
|
exec(cmd, { cwd: cliPath }, (error, stdout, stderr) => {
|
|
if (error) {
|
|
resolve({ error: error.message, stderr });
|
|
} else {
|
|
resolve({ success: true, output: stdout + (stderr || '') });
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Get container logs from server
|
|
ipcMain.handle('get-container-logs', async (event, { serverId, containerName, remotePath, lines = 100 }) => {
|
|
const server = config.servers.find(s => s.id === serverId);
|
|
if (!server) {
|
|
return { error: 'Server not found' };
|
|
}
|
|
|
|
const env = loadEnv();
|
|
const sshConfig = {
|
|
host: server.host,
|
|
username: env.SSH_USERNAME || server.username,
|
|
password: env.SSH_PASSWORD || server.password
|
|
};
|
|
|
|
const ssh = new SSHService(sshConfig);
|
|
const password = env.SSH_PASSWORD || server.password;
|
|
const sudoPrefix = server.useSudo ? `echo '${password}' | sudo -S ` : '';
|
|
|
|
try {
|
|
await ssh.connect();
|
|
// Use docker compose logs if remotePath is provided, otherwise docker logs
|
|
let logs;
|
|
if (remotePath) {
|
|
logs = await ssh.exec(`cd ${remotePath} && ${sudoPrefix}docker compose logs --tail ${lines} 2>&1`);
|
|
} else {
|
|
logs = await ssh.exec(`${sudoPrefix}docker logs ${containerName} --tail ${lines} 2>&1`);
|
|
}
|
|
ssh.disconnect();
|
|
return { success: true, logs };
|
|
} catch (err) {
|
|
return { error: err.message };
|
|
}
|
|
});
|
|
|
|
// Open VS Code diff for file comparison
|
|
ipcMain.handle('open-vscode-diff', async (event, { localPath, remotePath, serverId, remoteFilePath }) => {
|
|
const { exec } = require('child_process');
|
|
const server = config.servers.find(s => s.id === serverId);
|
|
if (!server) {
|
|
return { error: 'Server not found' };
|
|
}
|
|
|
|
const env = loadEnv();
|
|
const sshConfig = {
|
|
host: server.host,
|
|
username: env.SSH_USERNAME || server.username,
|
|
password: env.SSH_PASSWORD || server.password
|
|
};
|
|
|
|
const ssh = new SSHService(sshConfig);
|
|
|
|
try {
|
|
// Download remote file to temp
|
|
const tempDir = path.join(require('os').tmpdir(), 'docker-deploy-diff');
|
|
if (!fs.existsSync(tempDir)) {
|
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
}
|
|
const tempFile = path.join(tempDir, `remote-${path.basename(localPath)}`);
|
|
|
|
await ssh.connect();
|
|
await ssh.downloadFile(remoteFilePath, tempFile);
|
|
ssh.disconnect();
|
|
|
|
// Open VS Code diff
|
|
return new Promise((resolve) => {
|
|
exec(`code --diff "${tempFile}" "${localPath}"`, (error) => {
|
|
if (error) {
|
|
resolve({ error: error.message });
|
|
} else {
|
|
resolve({ success: true });
|
|
}
|
|
});
|
|
});
|
|
} catch (err) {
|
|
return { error: err.message };
|
|
}
|
|
});
|