feat: implement project management system with Nginx configuration templates and automated host file updates

This commit is contained in:
Ümit Tunç
2026-04-02 20:31:22 +03:00
parent ac30e71672
commit 4184d137e6
17 changed files with 2181 additions and 176 deletions
+102 -26
View File
@@ -1,4 +1,5 @@
import { ipcMain, app, dialog, shell } from 'electron'
import { exec } from 'child_process'
import fs from 'fs'
import { processManager } from '../services/ProcessManager'
import { configService } from '../services/ConfigService'
@@ -15,6 +16,7 @@ import { mariaDbOperationService } from '../services/MariaDbOperationService'
import { cliAliasService } from '../services/CliAliasService'
import { cliBinService } from '../services/CliBinService'
import { serviceEventLogger } from '../services/ServiceEventLogger'
import { hostsService } from '../services/HostsService'
function findExecutable(dir: string, name: string): string | null {
if (!fs.existsSync(dir)) return null
@@ -44,13 +46,14 @@ async function rebuildNginxConfig(forceProjects: boolean = false): Promise<{ bin
const isWindows = process.platform === 'win32';
const binPath = path.join(nginxPrefix, isWindows ? 'nginx.exe' : 'nginx')
const confDir = path.join(nginxPrefix, 'conf')
const logDir = path.join(configService.getBasePath(), 'logs')
const rootPath = path.join(configService.getBasePath(), 'www')
if (!fs.existsSync(rootPath)) fs.mkdirSync(rootPath, { recursive: true })
// Ensure directories exist
const projectsConfDir = path.join(configService.getBasePath(), 'generated_configs', 'projects')
const locationsConfDir = path.join(configService.getBasePath(), 'generated_configs', 'projects_locations')
if (!fs.existsSync(projectsConfDir)) fs.mkdirSync(projectsConfDir, { recursive: true })
if (!fs.existsSync(locationsConfDir)) fs.mkdirSync(locationsConfDir, { recursive: true })
const tempDirs = ['temp', 'temp/client_body_temp', 'temp/proxy_temp', 'temp/fastcgi_temp', 'temp/uwsgi_temp', 'temp/scgi_temp', 'logs']
for (const dir of tempDirs) {
@@ -59,8 +62,15 @@ async function rebuildNginxConfig(forceProjects: boolean = false): Promise<{ bin
}
// Generate project configs
const allProjects = await projectService.getProjects()
const projects = allProjects.filter(p => !p.serverType || p.serverType === 'nginx')
const projects = projectService.getProjects().filter(p => !p.serverType || p.serverType === 'nginx')
if (forceProjects) {
const files = fs.readdirSync(projectsConfDir)
for (const file of files) {
if (file.endsWith('.conf')) fs.unlinkSync(path.join(projectsConfDir, file))
}
}
for (const p of projects) {
const projectConfPath = path.join(projectsConfDir, `${p.host}.conf`)
let shouldGenerate = forceProjects || !fs.existsSync(projectConfPath)
@@ -82,11 +92,24 @@ async function rebuildNginxConfig(forceProjects: boolean = false): Promise<{ bin
normalizedPath += '/'
}
// Auto-suffix hostname if it's a simple slug
const finalHost = p.host.includes('.') ? p.host : `${p.host}.local`
await configService.generateConfig('nginx_project.conf.template', `projects/${p.host}.conf`, {
HOST: p.host,
HOST: finalHost,
PATH: normalizedPath,
PHP_PORT: phpPort.toString(),
CONF_DIR: confDir.replace(/\\/g, '/')
CONF_DIR: confDir.replace(/\\/g, '/'),
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`, {
HOST: p.host, // Use simple host slug for location path
PATH: normalizedPath,
PHP_PORT: phpPort.toString(),
CONF_DIR: confDir.replace(/\\/g, '/'),
PORT: settings.nginxPort
})
}
}
@@ -95,15 +118,20 @@ async function rebuildNginxConfig(forceProjects: boolean = false): Promise<{ bin
? `include "${projectsConfDir.replace(/\\/g, '/')}/*.conf";`
: ''
const locationsInclusion = projects.length > 0
? `include "${locationsConfDir.replace(/\\/g, '/')}/*.conf";`
: ''
const configPath = await configService.generateConfig('nginx.conf.template', 'nginx.conf', {
PORT: settings.nginxPort,
PORT: settings.nginxPort.toString(),
ROOT: rootPath.replace(/\\/g, '/'),
LISTEN_ADDRESS: settings.allowRemoteAccess ? '0.0.0.0' : '127.0.0.1',
PHP_PORT: settings.phpPort,
ROOT: rootPath,
LOG_DIR: logDir.replace(/\\/g, '/'),
PHP_PORT: settings.phpPort.toString(),
LOG_DIR: path.join(nginxPrefix, 'logs').replace(/\\/g, '/'),
CONF_DIR: confDir.replace(/\\/g, '/'),
PHPMYADMIN_DIR: phpMyAdminService.getPath().replace(/\\/g, '/'),
PROJECTS: projectsInclusion
PHPMYADMIN_DIR: path.join(configService.getBasePath(), 'bin', 'phpmyadmin').replace(/\\/g, '/'),
PROJECTS: projectsInclusion,
PROJECT_LOCATIONS: locationsInclusion
})
return { binPath, nginxPrefix, configPath }
@@ -111,7 +139,7 @@ async function rebuildNginxConfig(forceProjects: boolean = false): Promise<{ bin
async function restartNginx(): Promise<{ success: boolean; message: string }> {
try {
const { binPath, nginxPrefix, configPath } = await rebuildNginxConfig(false)
const { binPath, nginxPrefix, configPath } = await rebuildNginxConfig(true)
// Force kill Nginx to ensure it picks up new config on Windows
await processManager.forceKillAll('nginx')
@@ -156,8 +184,6 @@ async function rebuildApacheConfig(forceProjects: boolean = false): Promise<{ bi
const binPath = findExecutable(apacheDir, isWindows ? 'httpd.exe' : 'httpd')
if (!binPath) throw new Error('Apache executable bulunamadı.')
const apacheRoot = path.dirname(path.dirname(binPath)) // bin'in üstü
const logDir = path.join(configService.getBasePath(), 'logs')
const rootPath = path.join(configService.getBasePath(), 'www')
if (!fs.existsSync(rootPath)) fs.mkdirSync(rootPath, { recursive: true })
@@ -175,11 +201,19 @@ async function rebuildApacheConfig(forceProjects: boolean = false): Promise<{ bi
// Ensure directories exist
const projectsConfDir = path.join(configService.getBasePath(), 'generated_configs', 'projects_apache')
const aliasesConfDir = path.join(configService.getBasePath(), 'generated_configs', 'projects_aliases_apache')
if (!fs.existsSync(projectsConfDir)) fs.mkdirSync(projectsConfDir, { recursive: true })
if (!fs.existsSync(aliasesConfDir)) fs.mkdirSync(aliasesConfDir, { recursive: true })
// Generate project configs
const allProjects = await projectService.getProjects()
const projects = allProjects.filter(p => p.serverType === 'apache')
if (forceProjects) {
const files = fs.readdirSync(projectsConfDir)
for (const file of files) {
if (file.endsWith('.conf')) fs.unlinkSync(path.join(projectsConfDir, file))
}
}
// Ensure projects exist
const projects = projectService.getProjects().filter(p => p.serverType === 'apache')
for (const p of projects) {
const projectConfPath = path.join(projectsConfDir, `${p.host}.conf`)
let shouldGenerate = forceProjects || !fs.existsSync(projectConfPath)
@@ -200,13 +234,28 @@ async function rebuildApacheConfig(forceProjects: boolean = false): Promise<{ bi
const phpDir = path.join(configService.getBasePath(), 'bin', `php-${p.phpVersion}`)
const phpCgiBinary = findExecutable(phpDir, process.platform === 'win32' ? 'php-cgi.exe' : 'php-cgi') || ''
// Auto-suffix hostname if it's a simple slug
const finalHost = p.host.includes('.') ? p.host : `${p.host}.local`
await configService.generateConfig('apache_project.conf.template', `projects_apache/${p.host}.conf`, {
HOST: finalHost,
PATH: normalizedPath,
PHP_PORT: phpPort.toString(),
PHP_DIR: phpDir.replace(/\\/g, '/'),
PHP_CGI_PATH: phpCgiBinary.replace(/\\/g, '/'),
PHP_BIN_URL: `/php-bin-${p.host}`,
PORT: settings.apachePort || '8080'
})
// Also generate the subdirectory/alias-style config for localhost fallback
await configService.generateConfig('apache_project_alias.conf.template', `projects_aliases_apache/${p.host}.conf`, {
HOST: p.host,
PATH: normalizedPath,
PHP_PORT: phpPort.toString(),
PHP_DIR: phpDir.replace(/\\/g, '/'),
PHP_CGI_PATH: phpCgiBinary.replace(/\\/g, '/'),
PHP_BIN_URL: `/php-bin-${p.host}`
PHP_BIN_URL: `/php-bin-alias-${p.host}`,
PORT: settings.apachePort || '8080'
})
}
}
@@ -216,15 +265,20 @@ async function rebuildApacheConfig(forceProjects: boolean = false): Promise<{ bi
? `Include "${projectsConfDir.replace(/\\/g, '/')}/*.conf"`
: ''
const aliasesInclusion = projects.length > 0
? `Include "${aliasesConfDir.replace(/\\/g, '/')}/*.conf"`
: ''
await configService.generateConfig('httpd.conf.template', 'httpd.conf', {
PORT: settings.apachePort || '8080',
PORT: settings.apachePort.toString(),
LISTEN_ADDRESS: settings.allowRemoteAccess ? '0.0.0.0' : '127.0.0.1',
PHP_PORT: settings.phpPort,
ROOT: rootPath.replace(/\\/g, '/'),
SRVROOT: apacheRoot.replace(/\\/g, '/'),
LOG_DIR: logDir.replace(/\\/g, '/'),
PHPMYADMIN_DIR: phpMyAdminService.getPath().replace(/\\/g, '/'),
PROJECTS: projectsInclusion
PHP_PORT: settings.phpPort.toString(),
LOG_DIR: path.join(configService.getBasePath(), 'logs').replace(/\\/g, '/'),
SRVROOT: path.join(configService.getBasePath(), 'bin', 'apache', 'Apache24').replace(/\\/g, '/'),
PHPMYADMIN_DIR: path.join(configService.getBasePath(), 'bin', 'phpmyadmin').replace(/\\/g, '/'),
PROJECTS: projectsInclusion,
PROJECT_ALIASES: aliasesInclusion
})
}
@@ -233,7 +287,7 @@ async function rebuildApacheConfig(forceProjects: boolean = false): Promise<{ bi
async function restartApache(): Promise<{ success: boolean; message: string }> {
try {
const { binPath, configPath } = await rebuildApacheConfig(false)
const { binPath, configPath } = await rebuildApacheConfig(true)
const settings = configService.getSettings()
await processManager.forceKillAll('apache')
@@ -796,10 +850,32 @@ export function registerIpcHandlers(): void {
return result.filePath
})
ipcMain.handle('shell:open-path', async (_event, pathStr: string) => {
ipcMain.handle('open-path', async (_event, pathStr: string) => {
return await shell.openPath(pathStr)
})
ipcMain.handle('open-vscode', async (_event, pathStr: string) => {
return new Promise((resolve) => {
exec(`code "${pathStr}"`, (error) => {
if (error) {
console.error('Failed to open VS Code:', error)
resolve({ success: false, message: error.message })
} else {
resolve({ success: true })
}
})
})
})
// Hosts file management
ipcMain.handle('hosts:check', async (_event, hostname: string) => {
return await hostsService.checkEntry(hostname)
})
ipcMain.handle('hosts:add', async (_event, hostname: string) => {
return await hostsService.addEntry(hostname)
})
ipcMain.handle('binaries:download', async (_event, { name, url }) => {
const result = await downloadService.downloadAndExtract(url, name, (p) => {
// TODO: Emit progress to renderer via webContents.send
+73
View File
@@ -0,0 +1,73 @@
import fs from 'fs'
import path from 'path'
import { exec } from 'child_process'
export class HostsService {
private hostsPath: string
constructor() {
// Windows: C:\Windows\System32\drivers\etc\hosts
// Linux/macOS: /etc/hosts
this.hostsPath = process.platform === 'win32'
? path.join(process.env.windir || 'C:\\Windows', 'System32', 'drivers', 'etc', 'hosts')
: '/etc/hosts'
}
async checkEntry(hostname: string): Promise<boolean> {
try {
if (!fs.existsSync(this.hostsPath)) return false
const content = fs.readFileSync(this.hostsPath, 'utf8')
// Look for 127.0.0.1 <hostname> or ::1 <hostname>
const regex = new RegExp(`(^|\\s)(127\\.0\\.0\\.1|::1)\\s+${hostname.replace(/\./g, '\\.')}(\\s|$)`, 'm')
return regex.test(content)
} catch (error) {
console.error('Failed to read hosts file:', error)
return false
}
}
async addEntry(hostname: string): Promise<{ success: boolean; message: string }> {
if (await this.checkEntry(hostname)) {
return { success: true, message: 'Entry already exists' }
}
return new Promise((resolve) => {
if (process.platform === 'win32') {
// Windows: Use PowerShell with -Verb RunAs for UAC prompt
// Using `n (newline) to ensure we don't append to the last line's end
const cmd = `powershell -Command "Start-Process powershell -ArgumentList '-Command Add-Content -Path $env:windir\\System32\\drivers\\etc\\hosts -Value \"\`n127.0.0.1 ${hostname}\" -Force' -Verb RunAs"`
exec(cmd, (error) => {
if (error) {
console.error('Failed to update hosts file:', error)
resolve({ success: false, message: 'Yönetici izni (UAC) reddedildi veya bir hata oluştu.' })
} else {
// Success doesn't mean the entry was actually added (could have been cancelled in UAC)
// So we wait a bit and re-check
setTimeout(async () => {
const verified = await this.checkEntry(hostname)
if (verified) {
resolve({ success: true, message: 'Hosts dosyası başarıyla güncellendi.' })
} else {
resolve({ success: false, message: 'Kayıt eklenemedi. Lütfen yönetici iznini onaylayın.' })
}
}, 2000)
}
})
} else {
// Linux/macOS: Use sudo
const cmd = `echo "127.0.0.1 ${hostname}" | sudo tee -a ${this.hostsPath}`
exec(cmd, (error) => {
if (error) {
resolve({ success: false, message: error.message })
} else {
resolve({ success: true, message: 'Hosts file updated.' })
}
})
}
})
}
}
export const hostsService = new HostsService()
+9
View File
@@ -268,11 +268,15 @@ export class PhpManagerService {
const iniPath = path.join(phpDir, 'php.ini')
if (!fs.existsSync(iniPath)) return false
const sessionsDir = path.join(configService.getBasePath(), 'sessions').replace(/\\/g, '/')
if (!fs.existsSync(sessionsDir)) fs.mkdirSync(sessionsDir, { recursive: true })
let content = fs.readFileSync(iniPath, 'utf8')
const lines = content.split(/\r?\n/)
let forceRedirectSet = false
let fixPathinfoSet = false
let sessionPathSet = false
const newLines = lines.map(line => {
const trimmed = line.trim()
@@ -284,11 +288,16 @@ export class PhpManagerService {
fixPathinfoSet = true
return 'cgi.fix_pathinfo = 1'
}
if (trimmed.startsWith('session.save_path') || trimmed.startsWith(';session.save_path')) {
sessionPathSet = true
return `session.save_path = "${sessionsDir}"`
}
return line
})
if (!forceRedirectSet) newLines.push('cgi.force_redirect = 0')
if (!fixPathinfoSet) newLines.push('cgi.fix_pathinfo = 1')
if (!sessionPathSet) newLines.push(`session.save_path = "${sessionsDir}"`)
fs.writeFileSync(iniPath, newLines.join('\n'), 'utf8')
return true
+11 -1
View File
@@ -34,7 +34,17 @@ export class ProjectService {
}
private saveProjects() {
fs.writeFileSync(this.projectsFile, JSON.stringify(this.projects, null, 2))
const tempFile = `${this.projectsFile}.tmp`
try {
fs.writeFileSync(tempFile, JSON.stringify(this.projects, null, 2))
if (fs.existsSync(tempFile)) {
fs.renameSync(tempFile, this.projectsFile)
}
} catch (e) {
console.error('Failed to save projects safely:', e)
// Fallback to direct write if rename fails
fs.writeFileSync(this.projectsFile, JSON.stringify(this.projects, null, 2))
}
}
getProjects(): Project[] {
+19 -10
View File
@@ -350,10 +350,6 @@ function App(): JSX.Element {
const interval = setInterval(() => {
fetchStatus()
// fetchProjects() // Reduced frequency for these
// fetchPhpVersions()
// fetchMariaDbVersions()
// fetchNginxVersions()
}, 3000)
let logInterval: NodeJS.Timeout | null = null
@@ -1135,8 +1131,6 @@ function App(): JSX.Element {
{activeTab === 'user_guide' && <UserGuide />}
{activeTab === 'settings' && (
<Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={settingsTab} onChange={(_e, v) => setSettingsTab(v)} textColor="primary" indicatorColor="primary" variant="scrollable" scrollButtons="auto">
<Tab label="PHP" icon={<PhpIcon />} iconPosition="start" />
@@ -1537,8 +1531,6 @@ function App(): JSX.Element {
{activeTab === 'dashboard' && (
<>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={dashboardTab} onChange={(_e, v) => setDashboardTab(v)} textColor="primary" indicatorColor="primary">
<Tab label={t('common.all')} />
@@ -1986,11 +1978,28 @@ function App(): JSX.Element {
}
fetchStatus();
}}
onOpenSite={(p: any) => {
onOpenSite={(p: any, isHostMapped: boolean) => {
const port = p.serverType === 'apache' ? settings.apachePort : settings.nginxPort
window.open(`http://localhost:${port}/${p.host}/`, '_blank')
if (isHostMapped) {
const finalHost = p.host && p.host.includes('.') ? p.host : `${p.host}.local`
window.open(`http://${finalHost}:${port}/`, '_blank')
} else {
window.open(`http://localhost:${port}/${p.host}/`, '_blank')
}
}}
onOpenFolder={(path: string) => window.api.invoke('open-path', path)}
onOpenVSCode={(path: string) => window.api.invoke('open-vscode', path)}
onAddHost={async (hostname: string) => {
const result = await window.api.invoke('hosts:add', hostname);
if (result.success) {
// Refresh current project to check mapping again
const currentId = selectedProjectId;
setSelectedProjectId(null);
setTimeout(() => setSelectedProjectId(currentId), 10);
} else {
alert(result.message);
}
}}
onManageDb={() => setActiveTab('db_manager')}
/>
)}
@@ -10,8 +10,10 @@ import {
Chip,
Divider,
Stack,
Tooltip
Tooltip,
CircularProgress
} from '@mui/material'
import { useState, useEffect } from 'react'
import {
Folder as ProjectIcon,
FolderOpen as FolderOpenIcon,
@@ -21,7 +23,8 @@ import {
Edit as EditIcon,
Delete as DeleteIcon,
Tune as TuningIcon,
OpenInNew as OpenIcon
OpenInNew as OpenIcon,
Code as CodeIcon
} from '@mui/icons-material'
import ProjectResourceMonitor from './ProjectResourceMonitor'
@@ -35,9 +38,13 @@ interface ProjectDetailsProps {
onEdit: () => void;
onDelete: () => void;
onToggleStatus: () => void;
onOpenSite: () => void;
onOpenSite: (useHost: boolean) => void;
onOpenFolder: () => void;
onOpenVSCode: () => void;
onManageDb: () => void;
onAddHost: (hostname: string) => void;
isTransitioning?: boolean;
setTransitioning?: (v: boolean) => void;
}
export default function ProjectDetails({
@@ -52,8 +59,20 @@ export default function ProjectDetails({
onToggleStatus,
onOpenSite,
onOpenFolder,
onManageDb
onOpenVSCode,
onManageDb,
onAddHost,
isTransitioning = false,
setTransitioning
}: ProjectDetailsProps) {
const [isHostMapped, setIsHostMapped] = useState<boolean>(true);
useEffect(() => {
if (p?.host) {
const finalHost = p.host.includes('.') ? p.host : `${p.host}.local`;
window.api.invoke('hosts:check', finalHost).then(setIsHostMapped);
}
}, [p]);
if (!p) {
return (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', opacity: 0.3 }}>
@@ -70,12 +89,12 @@ export default function ProjectDetails({
return (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', bgcolor: 'rgba(0,0,0,0.1)', height: '100%', overflow: 'hidden' }}>
<Box sx={{ p: 3, borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar sx={{ bgcolor: 'rgba(57, 167, 255, 0.1)', color: 'primary.main', width: 48, height: 48 }}>
<ProjectIcon />
</Avatar>
<Box>
<Box sx={{ p: 3, borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Avatar sx={{ bgcolor: 'rgba(57, 167, 255, 0.1)', color: 'primary.main', width: 48, height: 48, mt: 0.5 }}>
<ProjectIcon />
</Avatar>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ mb: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>{p.name}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontFamily: 'monospace' }}>
@@ -86,35 +105,96 @@ export default function ProjectDetails({
</IconButton>
</Box>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
variant="contained"
size="small"
startIcon={isRunning ? <StopIcon /> : <StartIcon />}
color={isRunning ? 'error' : 'primary'}
onClick={onToggleStatus}
>
{isRunning ? t('projects.stop_site') : t('projects.start_site')}
</Button>
<Button
variant="outlined"
size="small"
startIcon={<LaunchIcon />}
onClick={onOpenSite}
>
{t('projects.open_site')}
</Button>
<Tooltip title={t('common.edit')}>
<IconButton onClick={onEdit} sx={{ border: '1px solid rgba(255,255,255,0.1)' }}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t('common.delete')}>
<IconButton onClick={onDelete} sx={{ border: '1px solid rgba(255,255,255,0.1)', '&:hover': { color: 'error.main', borderColor: 'error.main' } }}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
<Button
variant="outlined"
size="small"
disabled={isTransitioning}
startIcon={isTransitioning ? <CircularProgress size={16} /> : (isRunning ? <StopIcon /> : <StartIcon />)}
color={isRunning ? 'error' : 'primary'}
onClick={async () => {
if (setTransitioning) setTransitioning(true);
try {
await onToggleStatus();
} finally {
if (setTransitioning) {
// Short delay to ensure status update is reflected
setTimeout(() => setTransitioning(false), 500);
}
}
}}
sx={{
px: 2,
height: 36,
fontWeight: 'bold',
transition: 'all 0.1s ease-in-out',
'&:active': { transform: 'scale(0.96)' }
}}
>
{isTransitioning ? t('common.processing') : (isRunning ? t('projects.stop_site') : t('projects.start_site'))}
</Button>
<Button
variant="outlined"
size="small"
startIcon={<LaunchIcon />}
onClick={() => onOpenSite(isHostMapped)}
sx={{
px: 2,
height: 36,
transition: 'all 0.1s ease-in-out',
'&:active': { transform: 'scale(0.96)' }
}}
>
{t('projects.open_site')}
</Button>
<Button
variant="outlined"
size="small"
startIcon={<CodeIcon />}
onClick={onOpenVSCode}
sx={{
px: 2,
height: 36,
borderColor: 'rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.7)',
transition: 'all 0.1s ease-in-out',
'&:active': { transform: 'scale(0.96)' }
}}
>
VS Code
</Button>
{!isHostMapped && (
<Button
variant="outlined"
size="small"
color="warning"
onClick={() => {
const finalHost = p.host.includes('.') ? p.host : `${p.host}.local`;
onAddHost(finalHost);
}}
sx={{
px: 2,
height: 36,
fontWeight: 'bold',
transition: 'all 0.1s ease-in-out',
'&:active': { transform: 'scale(0.96)' }
}}
>
Hosts Dosyasına Ekle
</Button>
)}
<Tooltip title={t('common.edit')}>
<IconButton onClick={onEdit} sx={{ width: 36, height: 36, border: '1px solid rgba(255,255,255,0.1)', borderRadius: '50%' }}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t('common.delete')}>
<IconButton onClick={onDelete} sx={{ width: 36, height: 36, border: '1px solid rgba(255,255,255,0.1)', borderRadius: '50%', '&:hover': { color: 'error.main', borderColor: 'error.main' } }}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Box>
@@ -17,8 +17,10 @@ interface ProjectManagerProps {
onEditProject: (p: any) => void;
onRemoveProject: (id: string) => void;
onToggleStatus: (p: any) => void;
onOpenSite: (p: any) => void;
onOpenSite: (p: any, useHost: boolean) => void;
onOpenFolder: (path: string) => void;
onOpenVSCode: (path: string) => void;
onAddHost: (hostname: string) => void;
onManageDb: () => void;
}
@@ -39,6 +41,8 @@ export default function ProjectManager({
onToggleStatus,
onOpenSite,
onOpenFolder,
onOpenVSCode,
onAddHost,
onManageDb
}: ProjectManagerProps) {
@@ -74,8 +78,10 @@ export default function ProjectManager({
onEdit={() => onEditProject(p)}
onDelete={() => onRemoveProject(p.id)}
onToggleStatus={() => onToggleStatus(p)}
onOpenSite={() => onOpenSite(p)}
onOpenSite={(useHost) => onOpenSite(p, useHost)}
onOpenFolder={() => onOpenFolder(p.path)}
onOpenVSCode={() => onOpenVSCode(p.path)}
onAddHost={onAddHost}
onManageDb={onManageDb}
/>
</Box>
@@ -1,4 +1,5 @@
import { Box, Typography, LinearProgress } from '@mui/material'
import { Box, Typography } from '@mui/material'
import { Gauge, gaugeClasses } from '@mui/x-charts/Gauge'
interface ServiceInfo {
status: string;
@@ -13,7 +14,7 @@ interface ProjectResourceMonitorProps {
}
const formatMemory = (bytes?: number) => {
if (!bytes) return ''
if (!bytes) return '0 MB'
const mb = bytes / (1024 * 1024)
if (mb > 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${Math.round(mb)} MB`
@@ -25,54 +26,96 @@ export default function ProjectResourceMonitor({ serverStats, phpStats, serverTy
if (!hasServerStat && !hasPhpStat) return null;
const renderMiniStats = (stats?: ServiceInfo, label: string = 'Service') => {
const renderServiceCard = (stats?: ServiceInfo, label: string = 'Service') => {
if (!stats) return null;
const cpu = stats.cpu || 0;
const cpu = Math.max(0, Math.min(100, Math.round(stats.cpu || 0)));
const memory = stats.memory || 0;
const ramMB = Math.round(memory / (1024 * 1024));
const ramPercent = Math.min((ramMB / 1024) * 100, 100);
const getColor = (val: number) => {
if (val < 30) return '#33d9b2';
if (val < 70) return '#ffb142';
return '#ff5252';
}
return (
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontSize: '0.65rem', fontWeight: 700 }}>{label.toUpperCase()}</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Typography variant="caption" sx={{ color: 'primary.light', fontSize: '0.65rem', fontWeight: 700 }}>{cpu}%</Typography>
<Typography variant="caption" sx={{ color: 'secondary.light', fontSize: '0.65rem', fontWeight: 700 }}>{formatMemory(memory)}</Typography>
<Box sx={{
flex: 1,
p: 2,
borderRadius: 3,
bgcolor: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.05)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2
}}>
<Typography variant="caption" sx={{
color: 'rgba(255,255,255,0.6)',
fontSize: '0.7rem',
fontWeight: 800,
textTransform: 'uppercase',
letterSpacing: '0.1em'
}}>
{label}
</Typography>
<Box sx={{ display: 'flex', gap: 3, width: '100%', justifyContent: 'center' }}>
{/* CPU Gauge */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Box sx={{ width: 70, height: 70 }}>
<Gauge
value={cpu}
startAngle={-110}
endAngle={110}
innerRadius="75%"
outerRadius="100%"
sx={() => ({
[`& .${gaugeClasses.valueText}`]: { fontSize: 14, fontWeight: 800, fill: getColor(cpu) },
[`& .${gaugeClasses.valueArc}`]: { fill: getColor(cpu) },
[`& .${gaugeClasses.referenceArc}`]: { fill: 'rgba(255,255,255,0.05)' },
})}
text={({ value }) => `${value}%`}
/>
</Box>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: '0.6rem', mt: -1 }}>CPU</Typography>
</Box>
{/* RAM Gauge */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Box sx={{ width: 70, height: 70 }}>
<Gauge
value={ramPercent}
startAngle={-110}
endAngle={110}
innerRadius="75%"
outerRadius="100%"
sx={() => ({
[`& .${gaugeClasses.valueText}`]: { fontSize: 12, fontWeight: 800, fill: 'secondary.light' },
[`& .${gaugeClasses.valueArc}`]: { fill: 'secondary.main' },
[`& .${gaugeClasses.referenceArc}`]: { fill: 'rgba(255,255,255,0.05)' },
})}
text={() => formatMemory(memory)}
/>
</Box>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: '0.6rem', mt: -1 }}>RAM</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: '2px' }}>
<LinearProgress
variant="determinate"
value={cpu}
sx={{
height: 3,
borderRadius: 1,
flex: 1,
bgcolor: 'rgba(255,255,255,0.05)',
'.MuiLinearProgress-bar': { borderRadius: 1, backgroundImage: `linear-gradient(90deg, #33d9b2 ${cpu}%, #ff5252 100%)`, bgcolor: 'transparent' }
}}
/>
<LinearProgress
variant="determinate"
value={ramPercent}
sx={{
height: 3,
borderRadius: 1,
flex: 1,
bgcolor: 'rgba(255,255,255,0.05)',
'.MuiLinearProgress-bar': { borderRadius: 1, bgcolor: 'secondary.main' }
}}
/>
</Box>
</Box>
);
};
return (
<Box sx={{ mt: 1.5, display: 'flex', gap: 2 }}>
{renderMiniStats(serverStats, serverType)}
{renderMiniStats(phpStats, 'PHP')}
<Box sx={{
mt: 2.5,
display: 'flex',
flexDirection: 'column',
gap: 2,
width: '100%'
}}>
{renderServiceCard(serverStats, serverType)}
{renderServiceCard(phpStats, 'PHP')}
</Box>
);
}
@@ -10,7 +10,7 @@ import {
} from '@mui/material'
import {
Search as SearchIcon,
Folder as ProjectIcon
Add as AddIcon
} from '@mui/icons-material'
interface ProjectSidebarProps {
@@ -66,7 +66,7 @@ export default function ProjectSidebar({
</Typography>
<Tooltip title={t('projects.add_new')}>
<IconButton size="small" color="primary" onClick={onAddProject} sx={{ bgcolor: 'rgba(57, 167, 255, 0.1)' }}>
<ProjectIcon fontSize="small" />
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>