feat: implement project management system with IPC services and multi-language support

This commit is contained in:
Ümit Tunç
2026-04-02 20:51:07 +03:00
parent 4184d137e6
commit 69c4c0c780
8 changed files with 698 additions and 82 deletions
+88 -14
View File
@@ -17,6 +17,31 @@ import { cliAliasService } from '../services/CliAliasService'
import { cliBinService } from '../services/CliBinService'
import { serviceEventLogger } from '../services/ServiceEventLogger'
import { hostsService } from '../services/HostsService'
import net from 'net'
async function waitForPort(port: number, timeout: number = 5000): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
await new Promise((resolve, reject) => {
const client = net.createConnection({ port, host: '127.0.0.1' }, () => {
client.end();
resolve(true);
});
client.on('error', reject);
client.setTimeout(500);
client.on('timeout', () => {
client.destroy();
reject(new Error('timeout'));
});
});
return true;
} catch (e) {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
return false;
}
function findExecutable(dir: string, name: string): string | null {
if (!fs.existsSync(dir)) return null
@@ -433,7 +458,8 @@ export function registerIpcHandlers(): void {
const errorLogs = await processManager.getLastErrorLogs('nginx')
return { success: false, message: `Nginx başlatılamadı.\n\n${errorLogs.join('\n')}` }
}
return { success, message: success ? 'Nginx başlatıldı.' : 'Nginx devreye alınamadı.' }
const ready = await waitForPort(settings.nginxPort, 5000);
return { success: ready, message: ready ? 'Nginx başlatıldı.' : 'Nginx başlatıldı ancak port cevabı alınamadı.' }
}
if (serviceName === 'apache') {
@@ -446,7 +472,8 @@ export function registerIpcHandlers(): void {
const errorLogs = await processManager.getLastErrorLogs('apache')
return { success: false, message: `Apache başlatılamadı.\n\n${errorLogs.join('\n')}` }
}
return { success, message: success ? 'Apache başlatıldı.' : 'Apache devreye alınamadı.' }
const ready = await waitForPort(parseInt(settings.apachePort || '8080'), 5000);
return { success: ready, message: ready ? 'Apache başlatıldı.' : 'Apache başlatıldı ancak port cevabı alınamadı.' }
}
if (serviceName.startsWith('php')) {
@@ -521,14 +548,15 @@ export function registerIpcHandlers(): void {
// Add a small delay to ensure previous instance (if any) is fully killed
await sleep(1000)
const success = await processManager.startService(serviceName, binPath, ['-b', `127.0.0.1:${phpPort}`, '-c', configPath], false)
const success = await processManager.startService(serviceName, binPath, ['-b', `127.0.0.1:${phpPort}`, '-c', configPath], false, { PHPRC: path.dirname(configPath) })
if (!success) {
const errorLogs = await processManager.getLastErrorLogs(serviceName)
return { success: false, message: `${phpVersion || ''} PHP başlatılamadı.\n\n${errorLogs.join('\n')}` }
}
return { success, message: success ? `${phpVersion || ''} PHP başlatıldı.` : `${phpVersion || ''} PHP devreye alınamadı.` }
const ready = await waitForPort(phpPort, 5000);
return { success: ready, message: ready ? `${phpVersion || ''} PHP başlatıldı.` : `${phpVersion || ''} PHP başlatıldı ancak port cevabı alınamadı.` }
}
if (serviceName.startsWith('mariadb') || serviceName.startsWith('mysql')) {
@@ -977,6 +1005,62 @@ export function registerIpcHandlers(): void {
return { success: true, settings }
})
ipcMain.handle('services:clear-events', async () => {
try {
serviceEventLogger.clear()
return { success: true }
} catch (error: any) {
console.error('Failed to clear events:', error)
return { success: false, message: error.message }
}
})
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 }
}
}
return { content: '', message: `Konfigürasyon dosyası bulunamadı. Aranan yollar: ${paths.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`)
]
let configPath = paths[0]
for (const p of paths) {
if (fs.existsSync(p)) {
configPath = p
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.' }
})
// Nginx Config Management
ipcMain.handle('nginx:config:get', async () => {
const configPath = path.join(configService.getBasePath(), 'generated_configs', 'nginx.conf')
@@ -1321,14 +1405,4 @@ export function registerIpcHandlers(): void {
return ''
}
})
ipcMain.handle('services:clear-events', async () => {
try {
serviceEventLogger.clear()
return { success: true }
} catch (error: any) {
console.error('Failed to clear events:', error)
return { success: false, message: error.message }
}
})
}
+2 -2
View File
@@ -100,7 +100,7 @@ export class ProcessManager {
})
}
async startService(name: string, binPath: string, args: string[], shell: boolean = true): Promise<boolean> {
async startService(name: string, binPath: string, args: string[], shell: boolean = true, envVars: Record<string, string> = {}): Promise<boolean> {
if (this.processes.has(name)) {
return true
}
@@ -113,7 +113,7 @@ export class ProcessManager {
try {
this.addLog(name, `${t('common.starting')}... (${binPath})`)
const serviceEnv = { ...process.env }
const serviceEnv = { ...process.env, ...envVars }
const binDirPath = path.dirname(binPath)
if (serviceEnv.PATH) {
serviceEnv.PATH = `${binDirPath}${path.delimiter}${serviceEnv.PATH}`
@@ -0,0 +1,72 @@
import React from 'react'
import Editor from 'react-simple-code-editor'
import Prism from 'prismjs'
import 'prismjs/components/prism-nginx'
import 'prismjs/components/prism-apacheconf'
import 'prismjs/themes/prism-tomorrow.css'
interface ProjectConfigEditorProps {
value: string
onChange: (value: string) => void
serverType: string
}
const ProjectConfigEditor: React.FC<ProjectConfigEditorProps> = ({ value, onChange, serverType }) => {
const isApache = serverType === 'apache'
const language = isApache ? 'apacheconf' : 'nginx'
const prismLang = isApache ? Prism.languages.apacheconf : Prism.languages.nginx
return (
<div style={{
height: '100%',
overflow: 'auto',
borderRadius: '0px',
backgroundColor: '#1e1e1e',
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: '14px',
position: 'relative'
}}>
<Editor
value={value}
onValueChange={code => onChange(code)}
highlight={code => Prism.highlight(code, prismLang, language)}
padding={20}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 14,
minHeight: '100%',
outline: 'none',
backgroundColor: 'transparent'
}}
textareaClassName="project-config-textarea"
preClassName="project-config-pre"
/>
<style>{`
.project-config-textarea {
outline: none !important;
caret-color: #fff !important;
white-space: pre !important;
overflow-wrap: normal !important;
}
.project-config-pre {
pointer-events: none;
white-space: pre !important;
overflow-wrap: normal !important;
}
.token.comment { color: #6a9955; }
.token.directive, .token.section { color: #569cd6; font-weight: bold; }
.token.variable { color: #9cdcfe; }
.token.string { color: #ce9178; }
.token.number { color: #b5cea8; }
.token.boolean { color: #569cd6; }
.token.operator, .token.punctuation { color: #d4d4d4; }
/* Apache specific */
.token.directive-inline { color: #569cd6; }
.token.directive-block { color: #569cd6; font-weight: bold; }
`}</style>
</div>
)
}
export default ProjectConfigEditor
@@ -11,7 +11,8 @@ import {
Divider,
Stack,
Tooltip,
CircularProgress
CircularProgress,
Alert
} from '@mui/material'
import { useState, useEffect } from 'react'
import {
@@ -24,9 +25,12 @@ import {
Delete as DeleteIcon,
Tune as TuningIcon,
OpenInNew as OpenIcon,
Code as CodeIcon
Code as CodeIcon,
Save as SaveIcon
} from '@mui/icons-material'
import ProjectResourceMonitor from './ProjectResourceMonitor'
import ProjectConfigEditor from './ProjectConfigEditor'
import Swal from 'sweetalert2'
interface ProjectDetailsProps {
project: any;
@@ -66,6 +70,9 @@ export default function ProjectDetails({
setTransitioning
}: ProjectDetailsProps) {
const [isHostMapped, setIsHostMapped] = useState<boolean>(true);
const [configText, setConfigText] = useState<string>('');
const [isConfigLoading, setIsConfigLoading] = useState<boolean>(false);
const [isConfigSaving, setIsConfigSaving] = useState<boolean>(false);
useEffect(() => {
if (p?.host) {
@@ -73,6 +80,59 @@ export default function ProjectDetails({
window.api.invoke('hosts:check', finalHost).then(setIsHostMapped);
}
}, [p]);
useEffect(() => {
if (projectDetailTab === 2 && p) {
loadConfig();
}
}, [projectDetailTab, p]);
const loadConfig = async () => {
setIsConfigLoading(true);
try {
const res = await window.api.invoke('project:get-config', { host: p.host, serverType: p.serverType });
setConfigText(res.content);
} catch (e) {
console.error('Failed to load config:', e);
} finally {
setIsConfigLoading(false);
}
};
const handleSaveConfig = async () => {
setIsConfigSaving(true);
try {
const res = await window.api.invoke('project:save-config', {
host: p.host,
serverType: p.serverType,
content: configText
});
if (res.success) {
Swal.fire({
icon: 'success',
title: t('common.success'),
text: t('common.saved_successfully'),
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 3000,
background: '#1e1e1e',
color: '#fff'
});
}
} catch (e: any) {
Swal.fire({
icon: 'error',
title: t('common.error'),
text: e.message,
background: '#1e1e1e',
color: '#fff'
});
} finally {
setIsConfigSaving(false);
}
};
if (!p) {
return (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', opacity: 0.3 }}>
@@ -202,91 +262,147 @@ export default function ProjectDetails({
<Tabs value={projectDetailTab} onChange={(_e, v) => setProjectDetailTab(v)} textColor="primary" indicatorColor="primary">
<Tab label={t('projects.overview')} />
<Tab label={t('projects.database')} />
<Tab label={t('projects.server_settings')} />
</Tabs>
</Box>
<Box sx={{ p: 4, flexGrow: 1, overflowY: 'auto' }}>
<Box sx={{ p: 0, flexGrow: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{projectDetailTab === 0 && (
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Box sx={{ mb: 4 }}>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.3)', fontWeight: 700 }}>{t('projects.site_domain')}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 0.5 }}>
<Typography sx={{ fontWeight: 500 }}>{p.host}.local</Typography>
<Chip label="CHANGE" size="small" variant="outlined" sx={{ cursor: 'pointer', height: 20, fontSize: '0.65rem' }} />
<Box sx={{ p: 4 }}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Box sx={{ mb: 4 }}>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.3)', fontWeight: 700 }}>{t('projects.site_domain')}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 0.5 }}>
<Typography sx={{ fontWeight: 500 }}>{p.host}.local</Typography>
<Chip label="CHANGE" size="small" variant="outlined" sx={{ cursor: 'pointer', height: 20, fontSize: '0.65rem' }} />
</Box>
</Box>
</Box>
<Box sx={{ mb: 4 }}>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.3)', fontWeight: 700 }}>{t('projects.web_server')}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 0.5 }}>
<Typography sx={{ fontWeight: 500 }}>{p.serverType.toUpperCase()}</Typography>
<Chip label="CHANGE" size="small" variant="outlined" component="button" onClick={onEdit} sx={{ cursor: 'pointer', height: 20, fontSize: '0.65rem' }} />
<Box sx={{ mb: 4 }}>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.3)', fontWeight: 700 }}>{t('projects.web_server')}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 0.5 }}>
<Typography sx={{ fontWeight: 500 }}>{p.serverType.toUpperCase()}</Typography>
<Chip label="CHANGE" size="small" variant="outlined" component="button" onClick={onEdit} sx={{ cursor: 'pointer', height: 20, fontSize: '0.65rem' }} />
</Box>
</Box>
</Box>
<Box sx={{ mb: 4 }}>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.3)', fontWeight: 700 }}>{t('projects.php_version')}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 0.5 }}>
<Typography sx={{ fontWeight: 500 }}>{p.phpVersion}</Typography>
<Chip label="CHANGE" size="small" variant="outlined" component="button" onClick={onEdit} sx={{ cursor: 'pointer', height: 20, fontSize: '0.65rem' }} />
<Box sx={{ mb: 4 }}>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.3)', fontWeight: 700 }}>{t('projects.php_version')}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 0.5 }}>
<Typography sx={{ fontWeight: 500 }}>{p.phpVersion}</Typography>
<Chip label="CHANGE" size="small" variant="outlined" component="button" onClick={onEdit} sx={{ cursor: 'pointer', height: 20, fontSize: '0.65rem' }} />
</Box>
</Box>
</Box>
</Grid>
<Grid item xs={12} md={6}>
<Box sx={{ p: 3, border: '1px solid rgba(255,255,255,0.05)', borderRadius: 2, bgcolor: 'rgba(255,255,255,0.01)' }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}>
<TuningIcon fontSize="small" color="primary" /> {t('dashboard.status')}
</Typography>
<ProjectResourceMonitor
serverType={p.serverType === 'apache' ? 'Apache' : 'Nginx'}
serverStats={p.serverType === 'apache' ? status.apache : status.nginx}
phpStats={status[`php:${p.phpVersion}`]}
/>
</Box>
</Grid>
</Grid>
<Grid item xs={12} md={6}>
<Box sx={{ p: 3, border: '1px solid rgba(255,255,255,0.05)', borderRadius: 2, bgcolor: 'rgba(255,255,255,0.01)' }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}>
<TuningIcon fontSize="small" color="primary" /> {t('dashboard.status')}
</Typography>
<ProjectResourceMonitor
serverType={p.serverType === 'apache' ? 'Apache' : 'Nginx'}
serverStats={p.serverType === 'apache' ? status.apache : status.nginx}
phpStats={status[`php:${p.phpVersion}`]}
/>
</Box>
</Grid>
</Grid>
</Box>
)}
{projectDetailTab === 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 }}>
<Box>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.3)', fontWeight: 700 }}>{t('projects.mariadb_version')}</Typography>
<Typography sx={{ mt: 0.5, fontWeight: 600 }}>MariaDB {p.mariadbVersion}</Typography>
<Box sx={{ p: 4 }}>
<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 }}>
<Box>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.3)', fontWeight: 700 }}>{t('projects.mariadb_version')}</Typography>
<Typography sx={{ mt: 0.5, fontWeight: 600 }}>MariaDB {p.mariadbVersion}</Typography>
</Box>
<Button
variant="outlined"
size="small"
startIcon={<OpenIcon />}
onClick={onManageDb}
>
{t('mariadb_manager.title')}
</Button>
</Box>
<Divider sx={{ mb: 3, opacity: 0.1 }} />
<Stack spacing={2}>
<Box>
<Typography variant="caption" color="text.secondary">Host</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>localhost</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Port</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{settings.mariaDbPort}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Username</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>root</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Password</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.3)', fontStyle: 'italic' }}>(no password)</Typography>
</Box>
</Stack>
</Box>
</Box>
</Box>
)}
{projectDetailTab === 2 && (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2, bgcolor: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<CodeIcon color="primary" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{p.serverType.toUpperCase()} {t('projects.server_settings')}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<OpenIcon />}
onClick={onManageDb}
startIcon={<TuningIcon />}
onClick={loadConfig}
sx={{ color: 'rgba(255,255,255,0.5)' }}
>
{t('mariadb_manager.title')}
{t('common.refresh')}
</Button>
<Button
variant="contained"
size="small"
color="primary"
startIcon={isConfigSaving ? <CircularProgress size={16} /> : <SaveIcon />}
disabled={isConfigSaving || isConfigLoading}
onClick={handleSaveConfig}
sx={{ px: 3 }}
>
{t('common.save').toUpperCase()}
</Button>
</Box>
</Box>
<Alert severity="info" sx={{ borderRadius: 0, py: 0.5, bgcolor: 'rgba(57, 167, 255, 0.05)', color: 'primary.light', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
{t('projects.manual_edit_warning')}
</Alert>
<Divider sx={{ mb: 3, opacity: 0.1 }} />
<Stack spacing={2}>
<Box>
<Typography variant="caption" color="text.secondary">Host</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>localhost</Typography>
<Box sx={{ flexGrow: 1, position: 'relative', overflow: 'hidden' }}>
{isConfigLoading ? (
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'rgba(0,0,0,0.5)', zIndex: 10 }}>
<CircularProgress />
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Port</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{settings.mariaDbPort}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Username</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>root</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Password</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.3)', fontStyle: 'italic' }}>(no password)</Typography>
</Box>
</Stack>
) : null}
<ProjectConfigEditor
value={configText}
onChange={setConfigText}
serverType={p.serverType}
/>
</Box>
</Box>
)}
+2
View File
@@ -85,6 +85,8 @@
"open_site": "SITE ÖFFNEN",
"stop_site": "SITE STOPPEN",
"start_site": "SITE STARTEN",
"server_settings": "SERVER-EINSTELLUNGEN",
"manual_edit_warning": "Wenn Sie diese Einstellungen manuell bearbeiten, wird oben # MANUAL_EDIT hinzugefügt und das System beendet automatische Aktualisierungen.",
"no_project_selected": "Bitte wählen Sie ein Projekt aus der Liste aus oder erstellen Sie ein neues."
},
"settings": {
+2
View File
@@ -91,6 +91,8 @@
"open_site": "OPEN SITE",
"stop_site": "STOP SITE",
"start_site": "START SITE",
"server_settings": "SERVER SETTINGS",
"manual_edit_warning": "When you manually edit these settings, # MANUAL_EDIT is added to the top and the system stops automatic updates.",
"no_project_selected": "Please select a project from the list or create a new one."
},
"settings": {
+3
View File
@@ -92,6 +92,9 @@
"open_site": "SİTEYİ AÇ",
"stop_site": "SİTEYİ DURDUR",
"start_site": "SİTEYİ BAŞLAT",
"no_site_selected": "Lütfen önce bir site seçin.",
"server_settings": "SUNUCU AYARLARI",
"manual_edit_warning": "Bu ayarları manuel olarak düzenlediğinizde, en tepeye # MANUAL_EDIT eklenir ve sistem otomatik güncellemeleri durdurur.",
"no_project_selected": "Lütfen listeden bir proje seçin veya yeni bir tane oluşturun."
},
"settings": {