feat: implement core architecture and dashboard UI for service and project management

This commit is contained in:
Ümit Tunç
2026-03-28 09:58:49 +03:00
parent 4195cdfa8b
commit 164fabc174
7 changed files with 192 additions and 25 deletions
+2 -2
View File
@@ -1,11 +1,11 @@
[mysqld]
port=3306
port={{PORT}}
datadir="{{DATADIR}}"
socket="{{SOCKET}}"
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
[client]
port=3306
port={{PORT}}
socket="{{SOCKET}}"
default-character-set=utf8mb4
+1 -1
View File
@@ -19,7 +19,7 @@ http {
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9001;
fastcgi_pass 127.0.0.1:{{PHP_PORT}};
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
+53 -11
View File
@@ -1,4 +1,5 @@
import { ipcMain, app, dialog } from 'electron'
import fs from 'fs'
import { processManager } from '../services/ProcessManager'
import { configService } from '../services/ConfigService'
import { downloadService } from '../services/DownloadService'
@@ -7,37 +8,78 @@ import { phpManagerService } from '../services/PhpManagerService'
import { mySqlManagerService } from '../services/MySqlManagerService'
import path from 'path'
function findExecutable(dir: string, name: string): string | null {
if (!fs.existsSync(dir)) return null
const files = fs.readdirSync(dir)
for (const file of files) {
const fullPath = path.join(dir, file)
const stat = fs.statSync(fullPath)
if (stat.isDirectory()) {
const found = findExecutable(fullPath, name)
if (found) return found
} else if (file.toLowerCase() === name.toLowerCase()) {
return fullPath
}
}
return null
}
export function registerIpcHandlers(): void {
// Service management
ipcMain.handle('services:start', async (_event, serviceName: string) => {
try {
const settings = configService.getSettings()
const binDir = path.join(app.getPath('userData'), 'bin')
if (serviceName === 'nginx') {
const rootPath = path.join(app.getAppPath(), 'www')
const configPath = await configService.generateConfig('nginx.conf.template', 'nginx.conf', {
PORT: '80',
PORT: settings.nginxPort,
PHP_PORT: settings.phpPort,
ROOT: rootPath
})
const binPath = downloadService.getBinPath('nginx', 'nginx.exe')
const nginxDir = path.join(binDir, 'nginx')
const binPath = findExecutable(nginxDir, 'nginx.exe')
if (!binPath) return { success: false, message: 'Nginx executable bulunamadı. Lütfen indirin.' }
const success = await processManager.startService('nginx', binPath, ['-c', configPath])
return { success, message: success ? 'Nginx başlatıldı.' : 'Nginx başlatılamadı.' }
return { success, message: success ? 'Nginx başlatıldı.' : 'Nginx dosyaları bulunamadı veya port meşgul.' }
}
if (serviceName === 'php') {
const configPath = await configService.generateConfig('php.ini.template', 'php.ini', {})
const binPath = downloadService.getBinPath('php8.2', 'php-cgi.exe')
const success = await processManager.startService('php', binPath, ['-b', '127.0.0.1:9001', '-c', configPath])
return { success, message: success ? 'PHP başlatıldı.' : 'PHP başlatılamadı.' }
// Find any installed PHP version
const binPath = findExecutable(binDir, 'php-cgi.exe')
if (!binPath) return { success: false, message: 'PHP executable bulunamadı. Lütfen en az bir sürüm indirin.' }
const success = await processManager.startService('php', binPath, ['-b', `127.0.0.1:${settings.phpPort}`, '-c', configPath])
return { success, message: success ? 'PHP başlatıldı.' : 'PHP dosyaları bulunamadı veya port meşgul.' }
}
if (serviceName === 'mysql') {
const dataDir = path.join(app.getPath('userData'), 'mysql_data')
const binPath = findExecutable(binDir, 'mysqld.exe')
if (!binPath) return { success: false, message: 'MySQL executable bulunamadı. Lütfen indirin.' }
// Initialize if data directory is empty
if (!fs.existsSync(dataDir) || fs.readdirSync(dataDir).length === 0) {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
console.log('Initializing MySQL data directory...')
await processManager.runOnce(binPath, ['--initialize-insecure', `--datadir=${dataDir.replace(/\\/g, '/')}`])
}
const configPath = await configService.generateConfig('my.ini.template', 'my.ini', {
PORT: settings.mysqlPort,
DATADIR: dataDir,
SOCKET: '/tmp/mysql.sock'
})
const binPath = downloadService.getBinPath('mysql', 'bin/mysqld.exe')
const success = await processManager.startService('mysql', binPath, ['--defaults-file=' + configPath])
return { success, message: success ? 'MySQL başlatıldı.' : 'MySQL başlatılamadı.' }
return { success, message: success ? 'MySQL başlatıldı.' : 'MySQL dosyaları bulunamadı veya port meşgul.' }
}
return { success: false, message: 'Bilinmeyen servis.' }
@@ -140,11 +182,11 @@ export function registerIpcHandlers(): void {
})
// Config management
ipcMain.handle('config:get', async (_event, key: string) => {
return null
ipcMain.handle('config:get', async () => {
return configService.getSettings()
})
ipcMain.handle('config:save', async (_event, config: any) => {
return { success: true }
return { success: true, settings: configService.updateSettings(config) }
})
}
+36
View File
@@ -5,14 +5,26 @@ import { app } from 'electron'
export class ConfigService {
private configDir: string
private templateDir: string
private settingsPath: string
private defaultSettings = {
nginxPort: '80',
phpPort: '9001',
mysqlPort: '3306',
language: 'tr'
}
constructor() {
this.templateDir = path.join(app.getAppPath(), 'config')
this.configDir = path.join(app.getPath('userData'), 'generated_configs')
this.settingsPath = path.join(app.getPath('userData'), 'settings.json')
if (!fs.existsSync(this.configDir)) {
fs.mkdirSync(this.configDir, { recursive: true })
}
if (!fs.existsSync(this.settingsPath)) {
fs.writeFileSync(this.settingsPath, JSON.stringify(this.defaultSettings, null, 2))
}
}
async generateConfig(templateName: string, targetName: string, variables: Record<string, string>): Promise<string> {
@@ -37,6 +49,30 @@ export class ConfigService {
getGeneratedPath(targetName: string): string {
return path.join(this.configDir, targetName)
}
getSettings(): any {
try {
if (fs.existsSync(this.settingsPath)) {
const data = fs.readFileSync(this.settingsPath, 'utf-8')
return { ...this.defaultSettings, ...JSON.parse(data) }
}
} catch (e) {
console.error('Failed to read settings:', e)
}
return this.defaultSettings
}
updateSettings(newSettings: any) {
try {
const current = this.getSettings()
const updated = { ...current, ...newSettings }
fs.writeFileSync(this.settingsPath, JSON.stringify(updated, null, 2))
return updated
} catch (e) {
console.error('Failed to save settings:', e)
throw e
}
}
}
export const configService = new ConfigService()
+12
View File
@@ -10,6 +10,18 @@ export class ProcessManager {
this.processes = new Map()
}
async runOnce(binPath: string, args: string[]): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn(binPath, args, { stdio: 'inherit' })
child.on('close', (code) => {
resolve(code === 0)
})
child.on('error', () => {
resolve(false)
})
})
}
async startService(name: string, binPath: string, args: string[]): Promise<boolean> {
if (this.processes.has(name)) {
console.log(`Service ${name} is already running.`)
+3 -1
View File
@@ -17,7 +17,9 @@ const api = {
getMySqlVersions: () => electronAPI.ipcRenderer.invoke('mysql:versions'),
downloadMySqlVersion: (id: string) => electronAPI.ipcRenderer.invoke('mysql:download', id),
onMySqlProgress: (callback: any) => electronAPI.ipcRenderer.on('mysql:progress', (_event, data) => callback(data)),
selectDirectory: () => electronAPI.ipcRenderer.invoke('dialog:select-directory')
selectDirectory: () => electronAPI.ipcRenderer.invoke('dialog:select-directory'),
getSettings: () => electronAPI.ipcRenderer.invoke('config:get'),
saveSettings: (settings: any) => electronAPI.ipcRenderer.invoke('config:save', settings)
}
// Use `contextBridge` APIs to expose Electron APIs to
+82 -7
View File
@@ -33,7 +33,8 @@ import {
Tabs,
Tab,
InputAdornment,
CircularProgress
CircularProgress,
Stack
} from '@mui/material'
import {
Dashboard as DashboardIcon,
@@ -50,7 +51,9 @@ import {
CheckCircle as InstalledIcon,
CloudDownload as AvailableIcon,
Refresh as RefreshIcon,
FolderOpen as FolderOpenIcon
FolderOpen as FolderOpenIcon,
Save as SaveIcon,
Terminal as TerminalIcon
} from '@mui/icons-material'
import { useTranslation } from 'react-i18next'
@@ -68,6 +71,13 @@ interface ServiceStatus {
mysql: string
}
interface AppSettings {
nginxPort: string
phpPort: string
mysqlPort: string
language: string
}
function App(): JSX.Element {
const { t, i18n } = useTranslation()
const [status, setStatus] = useState<ServiceStatus>({
@@ -83,6 +93,12 @@ function App(): JSX.Element {
const [loadingVersions, setLoadingVersions] = useState(false)
const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false)
const [newProject, setNewProject] = useState({ name: '', path: '', phpVersion: '', host: 'localhost' })
const [settings, setSettings] = useState<AppSettings>({
nginxPort: '80',
phpPort: '9001',
mysqlPort: '3306',
language: 'tr'
})
const [notification, setNotification] = useState<{ open: boolean, message: string, severity: 'success' | 'error' }>({
open: false,
message: '',
@@ -96,6 +112,13 @@ function App(): JSX.Element {
}
}
const fetchSettings = async () => {
if (window.api && window.api.getSettings) {
const s = await window.api.getSettings()
if (s) setSettings(s)
}
}
const fetchProjects = async () => {
if (window.api && window.api.getProjects) {
const list = await window.api.getProjects()
@@ -142,6 +165,7 @@ function App(): JSX.Element {
fetchProjects()
fetchPhpVersions()
fetchMySqlVersions()
fetchSettings()
const removePhpListener = window.api.onBinaryProgress ? window.api.onBinaryProgress((data) => {
if (!data) return
@@ -160,7 +184,7 @@ function App(): JSX.Element {
fetchProjects()
fetchPhpVersions()
fetchMySqlVersions()
}, 3000)
}, 5000)
return () => {
clearInterval(interval)
@@ -191,6 +215,15 @@ function App(): JSX.Element {
})
}
const handleSaveSettings = async () => {
if (window.api && window.api.saveSettings) {
const result = await window.api.saveSettings(settings)
if (result.success) {
setNotification({ open: true, message: 'Ayarlar kaydedildi.', severity: 'success' })
}
}
}
const toggleLanguage = () => {
const newLang = i18n.language === 'tr' ? 'en' : i18n.language === 'en' ? 'de' : 'tr'
i18n.changeLanguage(newLang)
@@ -326,15 +359,18 @@ function App(): JSX.Element {
<Typography variant="h5" sx={{ fontWeight: 'medium' }}>
Genel Ayarlar
</Typography>
{settingsTab < 2 && (
<Button startIcon={loadingVersions ? <CircularProgress size={20} /> : <RefreshIcon />} onClick={handleRefreshVersions} disabled={loadingVersions}>
Listeyi Yenile
</Button>
)}
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={settingsTab} onChange={(_e, v) => setSettingsTab(v)} textColor="primary" indicatorColor="primary">
<Tabs value={settingsTab} onChange={(_e, v) => setSettingsTab(v)} textColor="primary" indicatorColor="primary" variant="scrollable" scrollButtons="auto">
<Tab label="PHP Sürümleri" icon={<PhpIcon />} iconPosition="start" />
<Tab label="MySQL Sürümleri" icon={<DbIcon />} iconPosition="start" />
<Tab label="Sunucu Ayarları" icon={<ServerIcon />} iconPosition="start" />
</Tabs>
</Box>
@@ -426,6 +462,45 @@ function App(): JSX.Element {
</List>
</Paper>
)}
{settingsTab === 2 && (
<Box component={Stack} spacing={3}>
<Paper sx={{ p: 3, bgcolor: 'rgba(255, 255, 255, 0.03)', borderRadius: 2 }}>
<Typography variant="h6" gutterBottom>Port Ayarları</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3, mt: 2 }}>
<TextField
label="Nginx Portu"
value={settings.nginxPort}
onChange={(e) => setSettings({ ...settings, nginxPort: e.target.value })}
variant="filled"
size="small"
helperText="Varsayılan: 80"
/>
<TextField
label="PHP FastCGI Portu"
value={settings.phpPort}
onChange={(e) => setSettings({ ...settings, phpPort: e.target.value })}
variant="filled"
size="small"
helperText="Varsayılan: 9001"
/>
<TextField
label="MySQL Portu"
value={settings.mysqlPort}
onChange={(e) => setSettings({ ...settings, mysqlPort: e.target.value })}
variant="filled"
size="small"
helperText="Varsayılan: 3306"
/>
</Box>
</Paper>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" startIcon={<SaveIcon />} size="large" onClick={handleSaveSettings}>
Ayarları Kaydet
</Button>
</Box>
</Box>
)}
</Box>
)}
{activeTab === 'dashboard' && (
@@ -437,7 +512,7 @@ function App(): JSX.Element {
<List dense>
<ListItem sx={{ py: 1.5 }}>
<ListItemIcon><ServerIcon color="primary" /></ListItemIcon>
<ListItemText primary="Nginx Web Sunucusu" secondary="Port: 80, 443" />
<ListItemText primary="Nginx Web Sunucusu" secondary={`Port: ${settings.nginxPort}`} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{getStatusChip(status.nginx)}
<Switch checked={status.nginx === 'running'} onChange={() => handleToggleService('nginx')} />
@@ -446,7 +521,7 @@ function App(): JSX.Element {
<Divider variant="inset" />
<ListItem sx={{ py: 1.5 }}>
<ListItemIcon><PhpIcon color="primary" /></ListItemIcon>
<ListItemText primary="PHP FastCGI" secondary="Sürüm: Seçilmedi" />
<ListItemText primary="PHP FastCGI" secondary={`Port: ${settings.phpPort}`} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{getStatusChip(status.php)}
<Switch checked={status.php === 'running'} onChange={() => handleToggleService('php')} />
@@ -455,7 +530,7 @@ function App(): JSX.Element {
<Divider variant="inset" />
<ListItem sx={{ py: 1.5 }}>
<ListItemIcon><DbIcon color="primary" /></ListItemIcon>
<ListItemText primary="MySQL / MariaDB" secondary="Port: 3306" />
<ListItemText primary="MySQL / MariaDB" secondary={`Port: ${settings.mysqlPort}`} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{getStatusChip(status.mysql)}
<Switch checked={status.mysql === 'running'} onChange={() => handleToggleService('mysql')} />