feat: implement core application UI and backend services for PHP environment management

This commit is contained in:
Ümit Tunç
2026-03-28 20:34:33 +03:00
parent f336bd2e7c
commit ab037b7bd9
14 changed files with 542 additions and 143 deletions
+12
View File
@@ -20,6 +20,18 @@ http {
try_files $uri $uri/ /index.php?$query_string;
}
location /phpmyadmin {
alias "{{PHPMYADMIN_DIR}}";
index index.php;
location ~ ^/phpmyadmin/(.+\.php)$ {
alias "{{PHPMYADMIN_DIR}}/$1";
fastcgi_pass 127.0.0.1:{{PHP_PORT}};
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $request_filename;
include "{{CONF_DIR}}/fastcgi_params";
}
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:{{PHP_PORT}};
fastcgi_index index.php;
+8
View File
@@ -65,6 +65,14 @@ multiphp/
---
## phpMyAdmin Entegrasyonu
- phpMyAdmin'in en son sürümünün (all-languages.zip) otomatik indirilmesi ve ayıklanması.
- Nginx üzerinde `/phpmyadmin` aliası ile yapılandırma.
- `config.inc.php` dosyasının yerel MySQL ile uyumlu şekilde otomatik oluşturulması.
- Dashboard üzerinde phpMyAdmin'e hızlı erişim butonu.
---
## Doğrulama ve Test Planı
### Otomatik Testler
Binary file not shown.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.
+17 -1
View File
@@ -7,6 +7,7 @@ import { projectService } from '../services/ProjectService'
import { phpManagerService } from '../services/PhpManagerService'
import { mySqlManagerService } from '../services/MySqlManagerService'
import { nginxManagerService } from '../services/NginxManagerService'
import { phpMyAdminService } from '../services/PhpMyAdminService'
import path from 'path'
function findExecutable(dir: string, name: string): string | null {
@@ -58,7 +59,8 @@ export function registerIpcHandlers(): void {
PHP_PORT: settings.phpPort,
ROOT: rootPath,
LOG_DIR: logDir.replace(/\\/g, '/'),
CONF_DIR: confDir.replace(/\\/g, '/')
CONF_DIR: confDir.replace(/\\/g, '/'),
PHPMYADMIN_DIR: phpMyAdminService.getPath().replace(/\\/g, '/')
})
// Test config first with prefix
@@ -241,6 +243,9 @@ export function registerIpcHandlers(): void {
ipcMain.handle('projects:remove', async (_event, id) => {
return projectService.removeProject(id)
})
ipcMain.handle('projects:update', async (_event, project) => {
return projectService.updateProject(project)
})
// Config management
ipcMain.handle('config:get', async () => {
@@ -250,4 +255,15 @@ export function registerIpcHandlers(): void {
ipcMain.handle('config:save', async (_event, config: any) => {
return { success: true, settings: configService.updateSettings(config) }
})
// phpMyAdmin
ipcMain.handle('pma:status', async () => {
return await phpMyAdminService.isInstalled()
})
ipcMain.handle('pma:setup', async (event, mysqlPort: string) => {
return await phpMyAdminService.setup(mysqlPort, (p) => {
event.sender.send('pma:progress', p)
})
})
}
+6 -11
View File
@@ -15,19 +15,14 @@ export class DownloadService {
}
}
async downloadAndExtract(url: string, targetName: string, onProgress?: (p: number) => void): Promise<string> {
async downloadAndExtract(url: string, targetName: string, onProgress?: (p: number) => void, customTargetDir?: string): Promise<string> {
const tempFile = path.join(app.getPath('temp'), `${targetName}.zip`)
const targetDir = path.join(this.binDir, targetName)
const phpExe = path.join(targetDir, 'php.exe')
const targetDir = customTargetDir || path.join(this.binDir, targetName)
// If already installed and working, skip
if (fs.existsSync(phpExe)) {
return targetDir
}
// Cleanup failed attempts
if (fs.existsSync(targetDir)) {
fs.rmSync(targetDir, { recursive: true, force: true })
// Cleanup failed/previous attempts if we want a fresh start
// For phpMyAdmin, we always want a fresh extract to avoid version mix
if (fs.existsSync(targetDir) && !customTargetDir) {
// For PHP/MySQL we might skip, but let's make it smarter
}
const writer = fs.createWriteStream(tempFile)
+75
View File
@@ -0,0 +1,75 @@
import path from 'path'
import fs from 'fs'
import { app } from 'electron'
import { downloadService } from './DownloadService'
export class PhpMyAdminService {
private pmaDir: string
private downloadUrl = 'https://www.phpmyadmin.net/downloads/phpMyAdmin-latest-all-languages.zip'
constructor() {
this.pmaDir = path.join(app.getPath('userData'), 'phpmyadmin')
}
async isInstalled(): Promise<boolean> {
return fs.existsSync(path.join(this.pmaDir, 'index.php'))
}
async setup(mysqlPort: string, onProgress?: (progress: number) => void): Promise<boolean> {
if (!fs.existsSync(this.pmaDir)) {
fs.mkdirSync(this.pmaDir, { recursive: true })
}
try {
await downloadService.downloadAndExtract(this.downloadUrl, this.pmaDir, (p) => {
if (onProgress) onProgress(p)
})
// After extraction, the zip usually contains a folder like phpMyAdmin-x.y.z-all-languages
// We should move contents to the root of this.pmaDir if they are nested
const items = fs.readdirSync(this.pmaDir)
if (items.length === 1 && fs.statSync(path.join(this.pmaDir, items[0])).isDirectory()) {
const nestedDir = path.join(this.pmaDir, items[0])
const nestedItems = fs.readdirSync(nestedDir)
for (const item of nestedItems) {
fs.renameSync(path.join(nestedDir, item), path.join(this.pmaDir, item))
}
fs.rmdirSync(nestedDir)
}
// Generate config.inc.php
this.generateConfig(mysqlPort)
return true
} catch (error) {
console.error('phpMyAdmin setup error:', error)
return false
}
}
private generateConfig(mysqlPort: string = '3306') {
const configPath = path.join(this.pmaDir, 'config.inc.php')
const blowfishSecret = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
const configContent = `<?php
$cfg['blowfish_secret'] = '${blowfishSecret}';
$i = 0;
$i++;
$cfg['Servers'][$i]['auth_type'] = 'config';
$cfg['Servers'][$i]['host'] = '127.0.0.1:${mysqlPort}';
$cfg['Servers'][$i]['user'] = 'root';
$cfg['Servers'][$i]['password'] = '';
$cfg['Servers'][$i]['extension'] = 'mysqli';
$cfg['Servers'][$i]['AllowNoPassword'] = true;
$cfg['Lang'] = '';
?>`
fs.writeFileSync(configPath, configContent)
}
getPath(): string {
return this.pmaDir
}
}
export const phpMyAdminService = new PhpMyAdminService()
+58
View File
@@ -0,0 +1,58 @@
import fs from 'fs'
import path from 'path'
import { app } from 'electron'
interface PortMap {
php: Record<string, number>;
mysql: Record<string, number>;
}
export class PortMappingService {
private configFile: string
private portMap: PortMap = { php: {}, mysql: {} }
private nextPhpPort = 9001
private nextMysqlPort = 3306
constructor() {
this.configFile = path.join(app.getPath('userData'), 'port_mappings.json')
this.load()
}
private load() {
if (fs.existsSync(this.configFile)) {
try {
this.portMap = JSON.parse(fs.readFileSync(this.configFile, 'utf-8'))
const phpPorts = Object.values(this.portMap.php)
if (phpPorts.length > 0) this.nextPhpPort = Math.max(...phpPorts) + 1
const mysqlPorts = Object.values(this.portMap.mysql)
if (mysqlPorts.length > 0) this.nextMysqlPort = Math.max(...mysqlPorts) + 1
} catch (e) {
console.error('Failed to load port mappings', e)
}
}
}
private save() {
fs.writeFileSync(this.configFile, JSON.stringify(this.portMap, null, 2))
}
getPhpPort(version: string): number {
if (!this.portMap.php[version]) {
this.portMap.php[version] = this.nextPhpPort++
this.save()
}
return this.portMap.php[version]
}
getMySqlPort(version: string): number {
if (!this.portMap.mysql[version]) {
this.portMap.mysql[version] = this.nextMysqlPort++
this.save()
}
return this.portMap.mysql[version]
}
}
export const portMappingService = new PortMappingService()
+9
View File
@@ -7,6 +7,7 @@ export interface Project {
name: string
path: string
phpVersion: string
mySqlVersion: string
host: string
}
@@ -49,6 +50,14 @@ export class ProjectService {
return newProject
}
updateProject(project: Project) {
const index = this.projects.findIndex(p => p.id === project.id)
if (index !== -1) {
this.projects[index] = project
this.saveProjects()
}
}
removeProject(id: string) {
this.projects = this.projects.filter(p => p.id !== id)
this.saveProjects()
+5 -1
View File
@@ -12,6 +12,7 @@ const api = {
getProjects: () => electronAPI.ipcRenderer.invoke('projects:list'),
addProject: (project: any) => electronAPI.ipcRenderer.invoke('projects:add', project),
removeProject: (id: string) => electronAPI.ipcRenderer.invoke('projects:remove', id),
updateProject: (project: any) => electronAPI.ipcRenderer.invoke('projects:update', project),
getPhpVersions: () => electronAPI.ipcRenderer.invoke('binaries:list-php'),
downloadPhpVersion: (id: string) => electronAPI.ipcRenderer.invoke('binaries:download-php', id),
onBinaryProgress: (callback: any) => electronAPI.ipcRenderer.on('binaries:progress', (_event, data) => callback(data)),
@@ -23,7 +24,10 @@ const api = {
saveSettings: (settings: any) => electronAPI.ipcRenderer.invoke('config:save', settings),
getNginxVersions: () => electronAPI.ipcRenderer.invoke('nginx:versions'),
downloadNginxVersion: (id: string) => electronAPI.ipcRenderer.invoke('nginx:download', id),
onNginxProgress: (callback: any) => electronAPI.ipcRenderer.on('nginx:progress', (_event, data) => callback(data))
onNginxProgress: (callback: any) => electronAPI.ipcRenderer.on('nginx:progress', (_event, data) => callback(data)),
pmaStatus: () => electronAPI.ipcRenderer.invoke('pma:status'),
pmaSetup: (mysqlPort: string) => electronAPI.ipcRenderer.invoke('pma:setup', mysqlPort),
onPmaProgress: (callback: any) => electronAPI.ipcRenderer.on('pma:progress', (_event, data) => callback(data))
}
// Use `contextBridge` APIs to expose Electron APIs to
+151 -21
View File
@@ -57,7 +57,9 @@ import {
Terminal as TerminalIcon,
ContentCopy as CopyIcon,
Close as CloseIcon,
ContentCopy as ContentCopyIcon
ContentCopy as ContentCopyIcon,
OpenInNew as OpenIcon,
Edit as EditIcon
} from '@mui/icons-material'
import { useTranslation } from 'react-i18next'
@@ -97,7 +99,8 @@ function App(): JSX.Element {
const [nginxVersions, setNginxVersions] = useState<any[]>([])
const [loadingVersions, setLoadingVersions] = useState(false)
const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false)
const [newProject, setNewProject] = useState({ name: '', path: '', phpVersion: '', host: 'localhost' })
const slugify = (text: string) => text.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, '');
const [newProject, setNewProject] = useState<any>({ name: '', path: '', phpVersion: '', mySqlVersion: '', host: 'localhost' })
const [settings, setSettings] = useState<AppSettings>({
nginxPort: '80',
phpPort: '9001',
@@ -114,6 +117,9 @@ function App(): JSX.Element {
const [logDialogOpen, setLogDialogOpen] = useState(false)
const [selectedService, setSelectedService] = useState('')
const [logs, setLogs] = useState<string[]>([])
const [pmaInstalled, setPmaInstalled] = useState(false)
const [pmaLoading, setPmaLoading] = useState(false)
const [pmaProgress, setPmaProgress] = useState(0)
const fetchStatus = async () => {
if (window.api && window.api.getServiceStatus) {
@@ -174,23 +180,39 @@ function App(): JSX.Element {
}
useEffect(() => {
if (isProjectDialogOpen && !newProject.phpVersion && phpVersions.length > 0) {
const installed = phpVersions.find(v => v.status === 'installed')
if (installed) {
setNewProject(prev => ({ ...prev, phpVersion: installed.version }))
if (isProjectDialogOpen && !newProject.id) {
const updates: Partial<typeof newProject> = {}
if (!newProject.phpVersion && phpVersions.length > 0) {
const installedPhp = phpVersions.find(v => v.status === 'installed')
if (installedPhp) updates.phpVersion = installedPhp.version
}
if (!newProject.mySqlVersion && mySqlVersions.length > 0) {
const installedMysql = mySqlVersions.find(v => v.status === 'installed')
if (installedMysql) updates.mySqlVersion = installedMysql.version
}
if (Object.keys(updates).length > 0) {
setNewProject(prev => ({ ...prev, ...updates }))
}
}
}, [isProjectDialogOpen, phpVersions])
}, [isProjectDialogOpen, phpVersions, mySqlVersions])
useEffect(() => {
if (!window.api) return
const fetchPmaStatus = async () => {
if (window.api && window.api.pmaStatus) {
const installed = await window.api.pmaStatus()
setPmaInstalled(installed)
}
}
fetchStatus()
fetchProjects()
fetchPhpVersions()
fetchMySqlVersions()
fetchNginxVersions()
fetchSettings()
fetchPmaStatus()
const removePhpListener = window.api.onBinaryProgress ? window.api.onBinaryProgress((data) => {
if (!data) return
@@ -210,6 +232,10 @@ function App(): JSX.Element {
setNginxVersions(prev => prev.map(v => v.id === id ? { ...v, progress, status: progress === 100 ? 'installed' : 'downloading' } : v))
}) : null
const removePmaListener = window.api.onPmaProgress ? window.api.onPmaProgress((p: number) => {
setPmaProgress(p)
}) : null
const interval = setInterval(() => {
fetchStatus()
fetchProjects()
@@ -231,6 +257,7 @@ function App(): JSX.Element {
if (typeof removePhpListener === 'function') removePhpListener()
if (typeof removeMySqlListener === 'function') removeMySqlListener()
if (typeof removeNginxListener === 'function') removeNginxListener()
if (typeof removePmaListener === 'function') removePmaListener()
}
}, [logDialogOpen, selectedService])
@@ -243,9 +270,11 @@ function App(): JSX.Element {
const handleSelectPath = async () => {
if (window.api && window.api.selectDirectory) {
const path = await window.api.selectDirectory()
if (path) {
setNewProject(prev => ({ ...prev, path }))
const pathStr = await window.api.selectDirectory()
if (pathStr) {
const folderName = pathStr.split(/[/\\]/).pop() || ''
const slug = slugify(folderName)
setNewProject(prev => ({ ...prev, path: pathStr, name: folderName, host: slug }))
}
}
}
@@ -331,10 +360,14 @@ function App(): JSX.Element {
const handleAddProject = async () => {
if (!window.api) return
if (newProject.id) {
await window.api.updateProject(newProject)
} else {
await window.api.addProject(newProject)
}
setIsProjectDialogOpen(false)
setNewProject({ name: '', path: '', phpVersion: '', host: 'localhost' })
setNotification({ open: true, message: 'Proje başarıyla eklendi.', severity: 'success' })
setNewProject({ name: '', path: '', phpVersion: '', mySqlVersion: '', host: 'localhost' })
setNotification({ open: true, message: newProject.id ? 'Proje güncellendi.' : 'Proje başarıyla eklendi.', severity: 'success' })
fetchProjects()
}
@@ -378,6 +411,27 @@ function App(): JSX.Element {
fetchNginxVersions()
}
const handleSetupPma = async () => {
if (!window.api) return
setPmaLoading(true)
setPmaProgress(0)
try {
const success = await window.api.pmaSetup()
if (success) {
setPmaInstalled(true)
setNotification({ open: true, message: 'phpMyAdmin başarıyla kuruldu.', severity: 'success' })
} else {
setNotification({ open: true, message: 'phpMyAdmin kurulumu sırasında hata oluştu.', severity: 'error' })
}
} finally {
setPmaLoading(false)
}
}
const handleOpenPma = () => {
window.open(`http://localhost:${settings.nginxPort}/phpmyadmin`, '_blank')
}
const installedPhpVersions = phpVersions.filter(v => v.status === 'installed')
return (
@@ -501,6 +555,7 @@ function App(): JSX.Element {
)}
{settingsTab === 1 && (
<>
<Paper sx={{ bgcolor: 'rgba(255, 255, 255, 0.03)', borderRadius: 2 }}>
<Box sx={{ p: 2, borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>MySQL Sürümleri</Typography>
@@ -546,6 +601,51 @@ function App(): JSX.Element {
))}
</List>
</Paper>
<Paper sx={{ mt: 3, p: 2, bgcolor: 'rgba(255, 255, 255, 0.03)', borderRadius: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255, 255, 255, 0.05)', borderRadius: 2 }}>
<DbIcon color="primary" />
</Box>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>phpMyAdmin</Typography>
<Typography variant="body2" color="text.secondary">
{pmaInstalled ? 'Veritabanı yönetimi için phpMyAdmin hazır.' : 'Veritabanlarını web üzerinden yönetmek için phpMyAdmin kurun.'}
</Typography>
</Box>
<Box>
{!pmaInstalled ? (
<Button
variant="contained"
size="small"
onClick={handleSetupPma}
disabled={pmaLoading}
startIcon={pmaLoading ? <CircularProgress size={20} /> : <DownloadIcon />}
>
{pmaLoading ? 'KURULUYOR...' : 'KURULUMU BAŞLAT'}
</Button>
) : (
<Button
variant="outlined"
size="small"
onClick={handleOpenPma}
startIcon={<OpenIcon />}
>
</Button>
)}
</Box>
</Box>
{pmaLoading && (
<Box sx={{ mt: 2 }}>
<LinearProgress variant="determinate" value={pmaProgress} sx={{ height: 6, borderRadius: 3 }} />
<Typography variant="caption" sx={{ mt: 0.5, display: 'block', textAlign: 'center' }}>
İndiriliyor... %{Math.round(pmaProgress)}
</Typography>
</Box>
)}
</Paper>
</>
)}
{settingsTab === 2 && (
@@ -715,15 +815,23 @@ function App(): JSX.Element {
<Paper key={project.id} sx={{ mb: 2, bgcolor: 'rgba(255, 255, 255, 0.05)' }}>
<ListItem
secondaryAction={
<Box>
<IconButton edge="end" aria-label="edit" onClick={() => {
setNewProject(project)
setIsProjectDialogOpen(true)
}} sx={{ mr: 1 }}>
<EditIcon color="primary" />
</IconButton>
<IconButton edge="end" aria-label="delete" onClick={() => handleRemoveProject(project.id)}>
<DeleteIcon color="error" />
</IconButton>
</Box>
}
>
<ListItemIcon><ProjectIcon color="primary" /></ListItemIcon>
<ListItemText
primary={project.name}
secondary={`${project.path} - PHP: ${project.phpVersion} - Host: ${project.host}`}
secondary={`${project.path} - PHP: ${project.phpVersion} | MySQL: ${project.mySqlVersion || 'Yok'} - Host: ${project.host}`}
/>
</ListItem>
</Paper>
@@ -856,8 +964,11 @@ function App(): JSX.Element {
</DialogActions>
</Dialog>
<Dialog open={isProjectDialogOpen} onClose={() => setIsProjectDialogOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Yeni Proje Ekle</DialogTitle>
<Dialog open={isProjectDialogOpen} onClose={() => {
setIsProjectDialogOpen(false)
setNewProject({ name: '', path: '', phpVersion: '', mySqlVersion: '', host: 'localhost' })
}} fullWidth maxWidth="sm">
<DialogTitle>{newProject.id ? 'Proje Düzenle' : 'Yeni Proje Ekle'}</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
@@ -887,6 +998,7 @@ function App(): JSX.Element {
value={newProject.host}
onChange={(e) => setNewProject({ ...newProject, host: e.target.value })}
/>
<Stack direction="row" spacing={2} sx={{ width: '100%' }}>
<FormControl fullWidth disabled={installedPhpVersions.length === 0}>
<InputLabel id="php-version-label">PHP Sürümü</InputLabel>
<Select
@@ -904,18 +1016,36 @@ function App(): JSX.Element {
)}
</Select>
</FormControl>
<FormControl fullWidth disabled={mySqlVersions.filter(v => v.status === 'installed').length === 0}>
<InputLabel id="mysql-version-label">MySQL Sürümü</InputLabel>
<Select
labelId="mysql-version-label"
value={newProject.mySqlVersion || ''}
label="MySQL Sürümü"
onChange={(e) => setNewProject({ ...newProject, mySqlVersion: e.target.value })}
>
{mySqlVersions.filter(v => v.status === 'installed').length > 0 ? (
mySqlVersions.filter(v => v.status === 'installed').map(v => (
<MenuItem key={v.id} value={v.version}>MySQL {v.version}</MenuItem>
))
) : (
<MenuItem disabled value="">Önce MySQL sürümü indirmelisiniz</MenuItem>
)}
</Select>
</FormControl>
</Stack>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsProjectDialogOpen(false)}>İptal</Button>
<Button onClick={() => {
setIsProjectDialogOpen(false)
setNewProject({ name: '', path: '', phpVersion: '', mySqlVersion: '', host: 'localhost' })
}}>İptal</Button>
<Button
onClick={handleAddProject}
variant="contained"
disabled={!newProject.name || !newProject.path || installedPhpVersions.length === 0}
disabled={!newProject.name || !newProject.path || installedPhpVersions.length === 0 || mySqlVersions.filter(v => v.status === 'installed').length === 0}
>
Ekle
</Button>
</DialogActions>
</Dialog>
{/* Log Dialog */}
@@ -963,4 +1093,4 @@ function App(): JSX.Element {
)
}
export default App
export default App