feat: implement process management service with IPC and configuration templates

This commit is contained in:
Ümit Tunç
2026-04-02 22:40:32 +03:00
parent 69c4c0c780
commit 2c2399a157
8 changed files with 1447 additions and 87 deletions
+81 -49
View File
@@ -76,7 +76,7 @@ async function rebuildNginxConfig(forceProjects: boolean = false): Promise<{ bin
// Ensure directories exist
const projectsConfDir = path.join(configService.getBasePath(), 'generated_configs', 'projects')
const locationsConfDir = path.join(configService.getBasePath(), 'generated_configs', 'projects_locations')
const locationsConfDir = path.join(configService.getBasePath(), 'generated_configs', 'projects_nginx')
if (!fs.existsSync(projectsConfDir)) fs.mkdirSync(projectsConfDir, { recursive: true })
if (!fs.existsSync(locationsConfDir)) fs.mkdirSync(locationsConfDir, { recursive: true })
@@ -128,8 +128,9 @@ async function rebuildNginxConfig(forceProjects: boolean = false): Promise<{ bin
PORT: settings.nginxPort
})
// Also generate the subdirectory/alias-style config for localhost fallback
await configService.generateConfig('nginx_project_location.conf.template', `projects_locations/${p.host}.conf`, {
// Use the correct location-based template for subdirectories
// This prevents the 'server directive not allowed here' error
await configService.generateConfig('nginx_project_location.conf.template', `projects_nginx/${p.host}.conf`, {
HOST: p.host, // Use simple host slug for location path
PATH: normalizedPath,
PHP_PORT: phpPort.toString(),
@@ -319,7 +320,7 @@ async function restartApache(): Promise<{ success: boolean; message: string }> {
await processManager.stopService('apache')
// Force kill port to be absolutely sure
await processManager.killByPort('apache', settings.apachePort || '8080')
await new Promise(resolve => setTimeout(resolve, 1000))
const success = await processManager.startService('apache', binPath, ['-f', configPath], false)
@@ -459,6 +460,11 @@ export function registerIpcHandlers(): void {
return { success: false, message: `Nginx başlatılamadı.\n\n${errorLogs.join('\n')}` }
}
const ready = await waitForPort(settings.nginxPort, 5000);
if (ready) {
processManager.setServiceState('nginx', 'running')
} else {
processManager.setServiceState('nginx', 'stopped')
}
return { success: ready, message: ready ? 'Nginx başlatıldı.' : 'Nginx başlatıldı ancak port cevabı alınamadı.' }
}
@@ -473,6 +479,11 @@ export function registerIpcHandlers(): void {
return { success: false, message: `Apache başlatılamadı.\n\n${errorLogs.join('\n')}` }
}
const ready = await waitForPort(parseInt(settings.apachePort || '8080'), 5000);
if (ready) {
processManager.setServiceState('apache', 'running')
} else {
processManager.setServiceState('apache', 'stopped')
}
return { success: ready, message: ready ? 'Apache başlatıldı.' : 'Apache başlatıldı ancak port cevabı alınamadı.' }
}
@@ -536,7 +547,7 @@ export function registerIpcHandlers(): void {
extensionsList = mustHaves.map(m => `extension=${prefix}${m}${suffix}`).join('\n')
}
const configPath = await configService.generateConfig('php.ini.template', `php_${phpVersion || 'default'}.ini`, {
const configPath = await configService.generateConfig('php.ini.template', `php/${phpVersion || 'default'}/php.ini`, {
EXT_DIR: extDir,
GD_EXT: gdExtName,
SESSION_DIR: sessionDir,
@@ -548,6 +559,7 @@ export function registerIpcHandlers(): void {
// Add a small delay to ensure previous instance (if any) is fully killed
await sleep(1000)
// Set PHPRC to the directory containing the project-specific php.ini
const success = await processManager.startService(serviceName, binPath, ['-b', `127.0.0.1:${phpPort}`, '-c', configPath], false, { PHPRC: path.dirname(configPath) })
if (!success) {
@@ -556,6 +568,11 @@ export function registerIpcHandlers(): void {
}
const ready = await waitForPort(phpPort, 5000);
if (ready) {
processManager.setServiceState(serviceName, 'running')
} else {
processManager.setServiceState(serviceName, 'stopped')
}
return { success: ready, message: ready ? `${phpVersion || ''} PHP başlatıldı.` : `${phpVersion || ''} PHP başlatıldı ancak port cevabı alınamadı.` }
}
@@ -635,17 +652,19 @@ export function registerIpcHandlers(): void {
if (success) {
// Quick health check
await new Promise(r => setTimeout(r, 1500))
if (!processManager.isServiceRunning(serviceName)) {
const ready = await waitForPort(parseInt(dbPort), 7000);
if (!ready || !processManager.isServiceRunning(serviceName)) {
const errorLogs = await processManager.getLastErrorLogs(serviceName)
return { success: false, message: `${dbVersion || ''} MariaDB başlatıldı ancak çalışma hatası verdi (ExitCode: 1).\n\n${errorLogs.length > 0 ? 'Önemli Hata Logları:\n' + errorLogs.join('\n') : 'Lütfen log sekmesini kontrol edin.'}` }
processManager.setServiceState(serviceName, 'stopped')
return { success: false, message: `${dbVersion || ''} MariaDB başlatıldı ancak çalışma hatası verdi.\n\n${errorLogs.length > 0 ? 'Önemli Hata Logları:\n' + errorLogs.join('\n') : 'Lütfen log sekmesini kontrol edin.'}` }
}
processManager.setServiceState(serviceName, 'running')
return { success: true, message: `${dbVersion || ''} MariaDB başlatıldı.` }
} else {
const errorLogs = await processManager.getLastErrorLogs(serviceName)
processManager.setServiceState(serviceName, 'stopped')
return { success: false, message: `${dbVersion || ''} MariaDB başlatılamadı.\n\n${errorLogs.join('\n')}` }
}
return { success, message: success ? `${dbVersion || ''} MariaDB başlatıldı.` : `${dbVersion || ''} MariaDB devreye alınamadı.` }
}
return { success: false, message: 'Bilinmeyen servis.' }
@@ -656,8 +675,12 @@ export function registerIpcHandlers(): void {
})
ipcMain.handle('services:stop', async (_event, serviceName: string) => {
const success = await processManager.stopService(serviceName)
return { success, message: success ? `${serviceName} durduruldu.` : `${serviceName} zaten durmuş.` }
try {
const success = await processManager.stopService(serviceName)
return { success, message: success ? `${serviceName} durduruldu.` : `${serviceName} durdurulurken hata oluştu veya zaten durmuş.` }
} catch (error: any) {
return { success: false, message: `Durdurma hatası: ${error.message}` }
}
})
ipcMain.handle('services:status', async () => {
@@ -743,7 +766,7 @@ export function registerIpcHandlers(): void {
return { success: false, message: `MariaDB hatası: ${error.message}` }
}
})
ipcMain.handle('mariadb:config:get', async (_event, versionId: string) => {
const settings = configService.getSettings()
const tuning = settings.mariaDbTuning?.[versionId] || ''
@@ -755,36 +778,44 @@ export function registerIpcHandlers(): void {
const mariaDbTuning = settings.mariaDbTuning || {}
mariaDbTuning[versionId] = content
configService.updateSettings({ ...settings, mariaDbTuning })
const serviceName = `mariadb:${versionId.replace('mariadb-', '')}`
if (processManager.isServiceRunning(serviceName)) {
await processManager.stopService(serviceName)
}
return { success: true, message: 'Tuning configuration saved.' }
})
ipcMain.handle('php:versions:all', async () => {
return phpManagerService.getVersions()
})
ipcMain.handle('php:config:get', async (_event, version: string) => {
const phpIniPath = path.join(configService.getBasePath(), 'generated_configs', `php_${version}.ini`)
const phpIniPath = path.join(configService.getBasePath(), 'generated_configs', 'php', version, 'php.ini')
if (fs.existsSync(phpIniPath)) {
return { content: fs.readFileSync(phpIniPath, 'utf8') }
}
// Fallback to old path for migration support
const oldPath = path.join(configService.getBasePath(), 'generated_configs', `php_${version}.ini`)
if (fs.existsSync(oldPath)) {
return { content: fs.readFileSync(oldPath, 'utf8') }
}
return { content: '' }
})
ipcMain.handle('php:config:save', async (_event, { version, content }) => {
const phpIniPath = path.join(configService.getBasePath(), 'generated_configs', `php_${version}.ini`)
const phpIniDir = path.join(configService.getBasePath(), 'generated_configs', 'php', version)
if (!fs.existsSync(phpIniDir)) fs.mkdirSync(phpIniDir, { recursive: true })
const phpIniPath = path.join(phpIniDir, 'php.ini')
// Add manual edit marker to prevent overwriting
let finalContent = content
if (!finalContent.includes('; MANUAL_EDIT')) {
finalContent = `; MANUAL_EDIT\n${finalContent}`
}
fs.writeFileSync(phpIniPath, finalContent, 'utf8')
// Restart PHP if running
const serviceName = `php:${version}`
if (processManager.isServiceRunning(serviceName)) {
@@ -1017,46 +1048,47 @@ export function registerIpcHandlers(): void {
ipcMain.handle('project:get-config', async (_event, { host, serverType }) => {
const projectsDir = serverType === 'apache' ? 'projects_apache' : 'projects'
const baseDir = path.join(configService.getBasePath(), 'generated_configs', projectsDir)
// Try both host.conf and host.local.conf
const paths = [
path.join(baseDir, `${host}.conf`),
path.join(baseDir, `${host}.local.conf`)
]
for (const p of paths) {
if (fs.existsSync(p)) {
return { content: fs.readFileSync(p, 'utf8'), path: p }
// Prefer projects_nginx for Nginx as requested by user, fallback to projects
const dirs = serverType === 'nginx' ? ['projects_nginx', 'projects'] : [projectsDir]
const baseDir = configService.getBasePath()
for (const dir of dirs) {
const pathToCheck = path.join(baseDir, 'generated_configs', dir, `${host}.conf`)
if (fs.existsSync(pathToCheck)) {
return { content: fs.readFileSync(pathToCheck, 'utf8'), path: pathToCheck }
}
// Try .local.conf as well
const localPath = path.join(baseDir, 'generated_configs', dir, `${host}.local.conf`)
if (fs.existsSync(localPath)) {
return { content: fs.readFileSync(localPath, 'utf8'), path: localPath }
}
}
return { content: '', message: `Konfigürasyon dosyası bulunamadı. Aranan yollar: ${paths.join(', ')}` }
return { content: '', message: `Konfigürasyon dosyası bulunamadı. Aranan klasörler: ${dirs.join(', ')}` }
})
ipcMain.handle('project:save-config', async (_event, { host, serverType, content }) => {
const projectsDir = serverType === 'apache' ? 'projects_apache' : 'projects'
const baseDir = path.join(configService.getBasePath(), 'generated_configs', projectsDir)
// Try to find existing file first (to overwrite the right one)
const paths = [
path.join(baseDir, `${host}.conf`),
path.join(baseDir, `${host}.local.conf`)
]
const dirs = serverType === 'nginx' ? ['projects_nginx', 'projects'] : [projectsDir]
const baseDir = configService.getBasePath()
let configPath = paths[0]
for (const p of paths) {
if (fs.existsSync(p)) {
configPath = p
break
}
}
// Find existing file to overwrite, or default to the first folder
let configPath = path.join(baseDir, 'generated_configs', dirs[0], `${host}.conf`)
for (const dir of dirs) {
const p1 = path.join(baseDir, 'generated_configs', dir, `${host}.conf`)
const p2 = path.join(baseDir, 'generated_configs', dir, `${host}.local.conf`)
if (fs.existsSync(p1)) { configPath = p1; break; }
if (fs.existsSync(p2)) { configPath = p2; break; }
}
let finalContent = content
if (!finalContent.includes('# MANUAL_EDIT')) {
finalContent = `# MANUAL_EDIT\n${finalContent}`
}
fs.writeFileSync(configPath, finalContent, 'utf8')
return { success: true, message: 'Konfigürasyon başarıyla kaydedildi.' }
})
@@ -1369,7 +1401,7 @@ export function registerIpcHandlers(): void {
try {
const localeDir = path.join(process.cwd(), 'docs', 'user-guide', locale)
if (!fs.existsSync(localeDir)) return []
const files = fs.readdirSync(localeDir).filter(f => f.endsWith('.md'))
const topics = files.map(file => {
const fullPath = path.join(localeDir, file)
@@ -1378,14 +1410,14 @@ export function registerIpcHandlers(): void {
const title = firstLine.replace(/^#\s*/, '').trim() || file
return { file, title }
})
// Reorder so index.md is first
const indexIdx = topics.findIndex(t => t.file === 'index.md')
if (indexIdx > -1) {
const index = topics.splice(indexIdx, 1)[0]
topics.unshift(index)
}
return topics
} catch (error: any) {
console.error('Failed to list topics:', error)
+5
View File
@@ -58,6 +58,11 @@ export class ConfigService {
content = content.replace(new RegExp(`{{${key}}}`, 'g'), () => escapedValue)
}
const targetDir = path.dirname(targetPath)
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
fs.writeFileSync(targetPath, content)
return targetPath
}
+90 -27
View File
@@ -9,11 +9,13 @@ import { t } from '../utils/i18n'
export class ProcessManager {
private processes: Map<string, ChildProcess> = new Map()
private states: Map<string, 'starting' | 'running' | 'stopping' | 'stopped'> = new Map()
private logs: Map<string, string[]> = new Map()
private logDir: string
constructor() {
this.processes = new Map()
this.states = new Map()
this.logDir = ''
}
@@ -67,6 +69,15 @@ export class ProcessManager {
return this.logs.get(name) || []
}
setServiceState(name: string, state: 'starting' | 'running' | 'stopping' | 'stopped') {
console.log(`[STATE] ${name} -> ${state}`)
this.states.set(name, state)
}
getServiceState(name: string): string {
return this.states.get(name) || (this.processes.has(name) ? 'running' : 'stopped')
}
async runOnce(binPath: string, args: string[], name?: string): Promise<{ success: boolean; output: string }> {
return new Promise((resolve) => {
const child = spawn(binPath, args, { stdio: 'pipe', shell: false, windowsHide: true })
@@ -102,15 +113,18 @@ export class ProcessManager {
async startService(name: string, binPath: string, args: string[], shell: boolean = true, envVars: Record<string, string> = {}): Promise<boolean> {
if (this.processes.has(name)) {
this.states.set(name, 'running')
return true
}
if (!fs.existsSync(binPath)) {
this.addLog(name, `${t('common.error')}: ${t('common.executable_not_found')} (${binPath})`)
this.states.set(name, 'stopped')
return false
}
try {
this.states.set(name, 'starting')
this.addLog(name, `${t('common.starting')}... (${binPath})`)
const serviceEnv = { ...process.env, ...envVars }
@@ -129,6 +143,11 @@ export class ProcessManager {
env: serviceEnv
})
if (!child.pid) {
this.states.set(name, 'stopped')
return false
}
console.log(`[PROCESS] Started ${name} with PID ${child.pid}`)
this.addLog(name, `${t('common.running')} (PID: ${child.pid})`)
serviceEventLogger.log({ service: name, action: 'start', status: 'success' })
@@ -140,6 +159,7 @@ export class ProcessManager {
this.addLog(name, `Failed to start: ${err.message}`)
serviceEventLogger.log({ service: name, action: 'start', status: 'failure', details: err.message })
this.processes.delete(name)
this.states.set(name, 'stopped')
})
child.on('exit', (code) => {
@@ -150,38 +170,72 @@ export class ProcessManager {
this.checkErrorLogs(name)
}
this.processes.delete(name)
this.states.set(name, 'stopped')
})
this.processes.set(name, child)
// State remains 'starting' until the IPC handler confirms port listener
return true
} catch (error: any) {
this.addLog(name, `Spawn Error: ${error.message}`)
serviceEventLogger.log({ service: name, action: 'start', status: 'failure', details: error.message })
this.states.set(name, 'stopped')
return false
}
}
async stopService(name: string): Promise<boolean> {
this.states.set(name, 'stopping')
const child = this.processes.get(name)
if (child) {
this.addLog(name, `${t('common.stopping')}...`)
serviceEventLogger.log({ service: name, action: 'stop', status: 'info' })
try {
if (process.platform === 'win32') {
child.kill()
} else {
// On Unix, try SIGTERM first, then SIGKILL if needed
child.kill('SIGTERM')
return new Promise((resolve) => {
let resolved = false
const timer = setTimeout(() => {
if (!resolved) {
this.addLog(name, 'Stop timeout, forcing kill...')
this.forceKillAll(name).then(() => {
this.states.set(name, 'stopped')
resolve(true)
})
resolved = true
}
}, 5000)
child.on('exit', () => {
if (!resolved) {
clearTimeout(timer)
this.processes.delete(name)
this.states.set(name, 'stopped')
resolve(true)
resolved = true
}
})
try {
if (process.platform === 'win32') {
child.kill()
} else {
child.kill('SIGTERM')
}
} catch (e) {
this.addLog(name, `Stop signal error: ${e instanceof Error ? e.message : String(e)}`)
this.forceKillAll(name).then(() => {
this.states.set(name, 'stopped')
resolve(true)
})
resolved = true
}
} catch (e) {
this.addLog(name, `Stop error: ${e instanceof Error ? e.message : String(e)}`)
}
this.processes.delete(name)
})
}
// Also force kill lingering processes
return this.forceKillAll(name)
// Also force kill lingering processes even if not in map
const result = await this.forceKillAll(name)
this.states.set(name, 'stopped')
return result
}
async forceKillAll(name: string): Promise<boolean> {
@@ -210,13 +264,11 @@ export class ProcessManager {
.on('close', () => resolve(true))
.on('error', () => resolve(true))
} else {
// Unix: pkill -f matches full command line (data directory path usually contains the dbId)
spawn('pkill', ['-f', dbId], { windowsHide: true })
.on('close', () => resolve(true))
.on('error', () => resolve(true))
}
} else {
const isWindows = process.platform === 'win32';
if (isWindows) {
const executableName = name === 'nginx' ? 'nginx.exe' : name === 'apache' ? 'httpd.exe' : name === 'php' ? 'php-cgi.exe' : 'mariadbd.exe'
spawn('taskkill', ['/F', '/IM', executableName, '/T'], { windowsHide: true })
@@ -230,7 +282,6 @@ export class ProcessManager {
}
}
} else {
// macOS / Linux
if (childProcess && childProcess.pid) {
try { process.kill(childProcess.pid, 'SIGKILL') } catch (e) { }
this.processes.delete(name)
@@ -286,36 +337,51 @@ export class ProcessManager {
}
isServiceRunning(name: string): boolean {
return this.processes.has(name)
const child = this.processes.get(name)
return !!(child && child.pid)
}
async getStatuses(): Promise<Record<string, { status: string; cpu?: number; memory?: number }>> {
const statuses: Record<string, { status: string; cpu?: number; memory?: number }> = {}
// Add all tracked states first
for (const [name, state] of this.states.entries()) {
statuses[name] = { status: state }
}
const entries = Array.from(this.processes.entries())
for (const [name, child] of entries) {
const currentState = this.states.get(name)
if (child.pid) {
try {
const stats = await pidusage(child.pid)
statuses[name] = {
status: 'running',
cpu: Math.round(stats.cpu * 10) / 10, // Round to 1 decimal
status: currentState === 'stopping' ? 'stopping' : 'running',
cpu: Math.round(stats.cpu * 10) / 10,
memory: stats.memory
}
if (currentState !== 'stopping' && currentState !== 'starting') {
this.states.set(name, 'running')
}
} catch (e) {
statuses[name] = { status: 'running' }
// Clean up pidusage if error (usually process died)
if (currentState !== 'starting' && currentState !== 'stopping') {
statuses[name] = { status: 'stopped' }
this.states.set(name, 'stopped')
}
pidusage.clear()
}
} else {
statuses[name] = { status: 'running' }
if (currentState !== 'starting' && currentState !== 'stopping') {
statuses[name] = { status: 'stopped' }
this.states.set(name, 'stopped')
}
}
}
// Ensure defaults are present if stopped
// Ensure defaults are present
const defaultServices = ['nginx', 'apache', 'mariadb']
defaultServices.forEach(s => {
if (!statuses[s]) statuses[s] = { status: 'stopped' }
if (!statuses[s]) statuses[s] = { status: this.states.get(s) || 'stopped' }
})
return statuses
@@ -353,7 +419,6 @@ export class ProcessManager {
this.initLogs()
const foundLogs: string[] = []
// 1. Check in-memory logs for [CORE ERROR] or other error markers
const memLogs = this.logs.get(name) || []
const errorMarkers = ['ERROR', 'failed', 'Fatal', 'error']
const recentErrors = memLogs.filter(line =>
@@ -362,7 +427,6 @@ export class ProcessManager {
foundLogs.push(...recentErrors)
// 2. Check physical log files if memory logs are insufficient
const sanitizedName = name.replace(/:/g, '_')
const possibleFiles = [
path.join(this.logDir, `${sanitizedName}.log`),
@@ -381,7 +445,6 @@ export class ProcessManager {
if (!foundLogs.includes(l)) foundLogs.push(l)
})
} catch (e) {
// Ignore errors
}
}
}
@@ -268,7 +268,7 @@ export default function ProjectDetails({
<Box sx={{ p: 0, flexGrow: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{projectDetailTab === 0 && (
<Box sx={{ p: 4 }}>
<Box sx={{ p: 4, overflowY: 'auto', flexGrow: 1 }}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Box sx={{ mb: 4 }}>
@@ -313,7 +313,7 @@ export default function ProjectDetails({
)}
{projectDetailTab === 1 && (
<Box sx={{ p: 4 }}>
<Box sx={{ p: 4, overflowY: 'auto', flexGrow: 1 }}>
<Box sx={{ maxWidth: 600 }}>
<Box sx={{ p: 3, border: '1px solid rgba(255,255,255,0.05)', borderRadius: 2, bgcolor: 'rgba(255,255,255,0.01)' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>