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
+7 -12
View File
@@ -1,14 +1,7 @@
# Apache Project Configuration (CGI Mode) <VirtualHost *:{{PORT}}>
# Accessed via: http://localhost:{{PORT}}/{{HOST}}/ ServerName {{HOST}}
DocumentRoot "{{PATH}}"
# 1. Alias definition (restores localhost/projectname access)
Alias /{{HOST}} "{{PATH}}"
# 2. ScriptAlias definition for the project's PHP CGI binary
# This maps a virtual URL path to the actual PHP folder.
ScriptAlias {{PHP_BIN_URL}}/ "{{PHP_DIR}}/"
# 3. Directory settings for the project
<Directory "{{PATH}}"> <Directory "{{PATH}}">
Options Indexes FollowSymLinks ExecCGI Options Indexes FollowSymLinks ExecCGI
AllowOverride All AllowOverride All
@@ -26,13 +19,15 @@ ScriptAlias {{PHP_BIN_URL}}/ "{{PHP_DIR}}/"
</FilesMatch> </FilesMatch>
# Action maps the handler to the binary via the ScriptAlias URL # Action maps the handler to the binary via the ScriptAlias URL
# This is more robust on Windows than using absolute local paths.
Action application/x-httpd-php-{{HOST}} "{{PHP_BIN_URL}}/php-cgi.exe" Action application/x-httpd-php-{{HOST}} "{{PHP_BIN_URL}}/php-cgi.exe"
</Directory> </Directory>
# 4. Permissions for the PHP binary directory # ScriptAlias definition for the project's PHP CGI binary
ScriptAlias {{PHP_BIN_URL}}/ "{{PHP_DIR}}/"
<Directory "{{PHP_DIR}}"> <Directory "{{PHP_DIR}}">
AllowOverride None AllowOverride None
Options None Options None
Require all granted Require all granted
</Directory> </Directory>
</VirtualHost>
+32
View File
@@ -0,0 +1,32 @@
# Subdirectory Alias for {{HOST}}
Alias /{{HOST}} "{{PATH}}"
<Directory "{{PATH}}">
Options Indexes FollowSymLinks ExecCGI
AllowOverride All
Require all granted
# Passing Authorization header to PHP
CGIPassAuth On
# Directory index handling
DirectoryIndex index.php index.html
# PHP Handling via mod_actions and CGI
<FilesMatch "\.php$">
SetHandler application/x-httpd-php-alias-{{HOST}}
</FilesMatch>
# Action maps the handler to the binary via the ScriptAlias URL
# We use a unique handler name to avoid conflict with VirtualHost config
Action application/x-httpd-php-alias-{{HOST}} "{{PHP_BIN_URL}}/php-cgi.exe"
</Directory>
# ScriptAlias definition for the project's PHP CGI binary
ScriptAlias {{PHP_BIN_URL}}/ "{{PHP_DIR}}/"
<Directory "{{PHP_DIR}}">
AllowOverride None
Options None
Require all granted
</Directory>
+4 -1
View File
@@ -83,7 +83,10 @@ Alias /phpmyadmin "{{PHPMYADMIN_DIR}}"
CGIPassAuth On CGIPassAuth On
</Directory> </Directory>
# Project configurations # Project configurations (Subdirectory Access)
{{PROJECT_ALIASES}}
# Project configurations (Virtual Host Access)
{{PROJECTS}} {{PROJECTS}}
# Global PHP handler (Manual Nginx-Style SCRIPT_FILENAME) # Global PHP handler (Manual Nginx-Style SCRIPT_FILENAME)
+7 -1
View File
@@ -9,12 +9,14 @@ http {
default_type application/octet-stream; default_type application/octet-stream;
sendfile on; sendfile on;
keepalive_timeout 65; keepalive_timeout 65;
server_names_hash_bucket_size 64;
server { server {
listen {{LISTEN_ADDRESS}}:{{PORT}}; listen {{LISTEN_ADDRESS}}:{{PORT}};
server_name localhost; server_name localhost;
root "{{ROOT}}"; root "{{ROOT}}";
index index.php index.html index.htm; index index.php index.html index.htm;
autoindex on;
location / { location / {
# Try global root first, fallback to @project_fallback if not found # Try global root first, fallback to @project_fallback if not found
@@ -36,7 +38,9 @@ http {
return 404; return 404;
} }
{{PROJECTS}} # Subdirectory access for projects (legacy support)
{{PROJECT_LOCATIONS}}
location /phpmyadmin { location /phpmyadmin {
alias "{{PHPMYADMIN_DIR}}"; alias "{{PHPMYADMIN_DIR}}";
@@ -58,4 +62,6 @@ http {
fastcgi_param SCRIPT_FILENAME $request_filename; fastcgi_param SCRIPT_FILENAME $request_filename;
} }
} }
{{PROJECTS}}
} }
+13 -20
View File
@@ -1,31 +1,24 @@
location ^~ /{{HOST}}/ { server {
alias "{{PATH}}"; listen {{PORT}};
server_name {{HOST}};
root "{{PATH}}";
index index.php index.html; index index.php index.html;
autoindex on; autoindex on;
# Use a named location for reliable alias fallback # Support for clean URLs
try_files $uri $uri/ @{{HOST}}; location / {
try_files $uri $uri/ /index.php?$query_string;
}
# PHP handling nested INSIDE the alias location to maintain context # PHP handling
location ~ \.php$ { location ~ \.php$ {
fastcgi_pass 127.0.0.1:{{PHP_PORT}}; fastcgi_pass 127.0.0.1:{{PHP_PORT}};
fastcgi_index index.php; fastcgi_index index.php;
include "{{CONF_DIR}}/fastcgi_params"; include "{{CONF_DIR}}/fastcgi_params";
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# Normal files matched correctly by URI don't suffer the try_files bug
fastcgi_param SCRIPT_FILENAME $request_filename;
}
} }
# The named location handles requests that don't match physical files # Custom log files for the project
location @{{HOST}} { # access_log "{{LOG_DIR}}/{{HOST}}_access.log";
# Directly execute PHP without URI mutation to preserve framework routing paths # error_log "{{LOG_DIR}}/{{HOST}}_error.log";
fastcgi_pass 127.0.0.1:{{PHP_PORT}};
include "{{CONF_DIR}}/fastcgi_params";
# Hardcode the essential execution parameters
fastcgi_param SCRIPT_FILENAME "{{PATH}}index.php";
fastcgi_param SCRIPT_NAME "/{{HOST}}/index.php";
fastcgi_param DOCUMENT_ROOT "{{PATH}}";
} }
@@ -0,0 +1,17 @@
location ^~ /{{HOST}}/ {
alias "{{PATH}}";
index index.php index.html;
autoindex on;
if (!-e $request_filename) {
rewrite ^/{{HOST}}/(.*)$ /{{HOST}}/index.php?$1 last;
}
location ~ \.php$ {
# Using $request_filename to match the alias path correctly
fastcgi_pass 127.0.0.1:{{PHP_PORT}};
fastcgi_index index.php;
include "{{CONF_DIR}}/fastcgi_params";
fastcgi_param SCRIPT_FILENAME $request_filename;
}
}
+1653
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"nginxPort": "80", "nginxPort": "8081",
"apachePort": "8080", "apachePort": "8080",
"serverType": "nginx", "serverType": "nginx",
"phpPort": "9001", "phpPort": "9001",
+102 -26
View File
@@ -1,4 +1,5 @@
import { ipcMain, app, dialog, shell } from 'electron' import { ipcMain, app, dialog, shell } from 'electron'
import { exec } from 'child_process'
import fs from 'fs' import fs from 'fs'
import { processManager } from '../services/ProcessManager' import { processManager } from '../services/ProcessManager'
import { configService } from '../services/ConfigService' import { configService } from '../services/ConfigService'
@@ -15,6 +16,7 @@ import { mariaDbOperationService } from '../services/MariaDbOperationService'
import { cliAliasService } from '../services/CliAliasService' import { cliAliasService } from '../services/CliAliasService'
import { cliBinService } from '../services/CliBinService' import { cliBinService } from '../services/CliBinService'
import { serviceEventLogger } from '../services/ServiceEventLogger' import { serviceEventLogger } from '../services/ServiceEventLogger'
import { hostsService } from '../services/HostsService'
function findExecutable(dir: string, name: string): string | null { function findExecutable(dir: string, name: string): string | null {
if (!fs.existsSync(dir)) return 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 isWindows = process.platform === 'win32';
const binPath = path.join(nginxPrefix, isWindows ? 'nginx.exe' : 'nginx') const binPath = path.join(nginxPrefix, isWindows ? 'nginx.exe' : 'nginx')
const confDir = path.join(nginxPrefix, 'conf') const confDir = path.join(nginxPrefix, 'conf')
const logDir = path.join(configService.getBasePath(), 'logs')
const rootPath = path.join(configService.getBasePath(), 'www') const rootPath = path.join(configService.getBasePath(), 'www')
if (!fs.existsSync(rootPath)) fs.mkdirSync(rootPath, { recursive: true }) if (!fs.existsSync(rootPath)) fs.mkdirSync(rootPath, { recursive: true })
// Ensure directories exist // Ensure directories exist
const projectsConfDir = path.join(configService.getBasePath(), 'generated_configs', 'projects') 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(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'] 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) { for (const dir of tempDirs) {
@@ -59,8 +62,15 @@ async function rebuildNginxConfig(forceProjects: boolean = false): Promise<{ bin
} }
// Generate project configs // Generate project configs
const allProjects = await projectService.getProjects() const projects = projectService.getProjects().filter(p => !p.serverType || p.serverType === 'nginx')
const projects = allProjects.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) { for (const p of projects) {
const projectConfPath = path.join(projectsConfDir, `${p.host}.conf`) const projectConfPath = path.join(projectsConfDir, `${p.host}.conf`)
let shouldGenerate = forceProjects || !fs.existsSync(projectConfPath) let shouldGenerate = forceProjects || !fs.existsSync(projectConfPath)
@@ -82,11 +92,24 @@ async function rebuildNginxConfig(forceProjects: boolean = false): Promise<{ bin
normalizedPath += '/' 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`, { await configService.generateConfig('nginx_project.conf.template', `projects/${p.host}.conf`, {
HOST: p.host, HOST: finalHost,
PATH: normalizedPath, PATH: normalizedPath,
PHP_PORT: phpPort.toString(), 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";` ? `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', { 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', LISTEN_ADDRESS: settings.allowRemoteAccess ? '0.0.0.0' : '127.0.0.1',
PHP_PORT: settings.phpPort, PHP_PORT: settings.phpPort.toString(),
ROOT: rootPath, LOG_DIR: path.join(nginxPrefix, 'logs').replace(/\\/g, '/'),
LOG_DIR: logDir.replace(/\\/g, '/'),
CONF_DIR: confDir.replace(/\\/g, '/'), CONF_DIR: confDir.replace(/\\/g, '/'),
PHPMYADMIN_DIR: phpMyAdminService.getPath().replace(/\\/g, '/'), PHPMYADMIN_DIR: path.join(configService.getBasePath(), 'bin', 'phpmyadmin').replace(/\\/g, '/'),
PROJECTS: projectsInclusion PROJECTS: projectsInclusion,
PROJECT_LOCATIONS: locationsInclusion
}) })
return { binPath, nginxPrefix, configPath } return { binPath, nginxPrefix, configPath }
@@ -111,7 +139,7 @@ async function rebuildNginxConfig(forceProjects: boolean = false): Promise<{ bin
async function restartNginx(): Promise<{ success: boolean; message: string }> { async function restartNginx(): Promise<{ success: boolean; message: string }> {
try { 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 // Force kill Nginx to ensure it picks up new config on Windows
await processManager.forceKillAll('nginx') await processManager.forceKillAll('nginx')
@@ -156,8 +184,6 @@ async function rebuildApacheConfig(forceProjects: boolean = false): Promise<{ bi
const binPath = findExecutable(apacheDir, isWindows ? 'httpd.exe' : 'httpd') const binPath = findExecutable(apacheDir, isWindows ? 'httpd.exe' : 'httpd')
if (!binPath) throw new Error('Apache executable bulunamadı.') 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') const rootPath = path.join(configService.getBasePath(), 'www')
if (!fs.existsSync(rootPath)) fs.mkdirSync(rootPath, { recursive: true }) if (!fs.existsSync(rootPath)) fs.mkdirSync(rootPath, { recursive: true })
@@ -175,11 +201,19 @@ async function rebuildApacheConfig(forceProjects: boolean = false): Promise<{ bi
// Ensure directories exist // Ensure directories exist
const projectsConfDir = path.join(configService.getBasePath(), 'generated_configs', 'projects_apache') 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(projectsConfDir)) fs.mkdirSync(projectsConfDir, { recursive: true })
if (!fs.existsSync(aliasesConfDir)) fs.mkdirSync(aliasesConfDir, { recursive: true })
// Generate project configs if (forceProjects) {
const allProjects = await projectService.getProjects() const files = fs.readdirSync(projectsConfDir)
const projects = allProjects.filter(p => p.serverType === 'apache') 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) { for (const p of projects) {
const projectConfPath = path.join(projectsConfDir, `${p.host}.conf`) const projectConfPath = path.join(projectsConfDir, `${p.host}.conf`)
let shouldGenerate = forceProjects || !fs.existsSync(projectConfPath) 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 phpDir = path.join(configService.getBasePath(), 'bin', `php-${p.phpVersion}`)
const phpCgiBinary = findExecutable(phpDir, process.platform === 'win32' ? 'php-cgi.exe' : 'php-cgi') || '' 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`, { 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, HOST: p.host,
PATH: normalizedPath, PATH: normalizedPath,
PHP_PORT: phpPort.toString(), PHP_PORT: phpPort.toString(),
PHP_DIR: phpDir.replace(/\\/g, '/'), PHP_DIR: phpDir.replace(/\\/g, '/'),
PHP_CGI_PATH: phpCgiBinary.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"` ? `Include "${projectsConfDir.replace(/\\/g, '/')}/*.conf"`
: '' : ''
const aliasesInclusion = projects.length > 0
? `Include "${aliasesConfDir.replace(/\\/g, '/')}/*.conf"`
: ''
await configService.generateConfig('httpd.conf.template', 'httpd.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', LISTEN_ADDRESS: settings.allowRemoteAccess ? '0.0.0.0' : '127.0.0.1',
PHP_PORT: settings.phpPort,
ROOT: rootPath.replace(/\\/g, '/'), ROOT: rootPath.replace(/\\/g, '/'),
SRVROOT: apacheRoot.replace(/\\/g, '/'), PHP_PORT: settings.phpPort.toString(),
LOG_DIR: logDir.replace(/\\/g, '/'), LOG_DIR: path.join(configService.getBasePath(), 'logs').replace(/\\/g, '/'),
PHPMYADMIN_DIR: phpMyAdminService.getPath().replace(/\\/g, '/'), SRVROOT: path.join(configService.getBasePath(), 'bin', 'apache', 'Apache24').replace(/\\/g, '/'),
PROJECTS: projectsInclusion 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 }> { async function restartApache(): Promise<{ success: boolean; message: string }> {
try { try {
const { binPath, configPath } = await rebuildApacheConfig(false) const { binPath, configPath } = await rebuildApacheConfig(true)
const settings = configService.getSettings() const settings = configService.getSettings()
await processManager.forceKillAll('apache') await processManager.forceKillAll('apache')
@@ -796,10 +850,32 @@ export function registerIpcHandlers(): void {
return result.filePath 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) 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 }) => { ipcMain.handle('binaries:download', async (_event, { name, url }) => {
const result = await downloadService.downloadAndExtract(url, name, (p) => { const result = await downloadService.downloadAndExtract(url, name, (p) => {
// TODO: Emit progress to renderer via webContents.send // 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') const iniPath = path.join(phpDir, 'php.ini')
if (!fs.existsSync(iniPath)) return false 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') let content = fs.readFileSync(iniPath, 'utf8')
const lines = content.split(/\r?\n/) const lines = content.split(/\r?\n/)
let forceRedirectSet = false let forceRedirectSet = false
let fixPathinfoSet = false let fixPathinfoSet = false
let sessionPathSet = false
const newLines = lines.map(line => { const newLines = lines.map(line => {
const trimmed = line.trim() const trimmed = line.trim()
@@ -284,11 +288,16 @@ export class PhpManagerService {
fixPathinfoSet = true fixPathinfoSet = true
return 'cgi.fix_pathinfo = 1' 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 return line
}) })
if (!forceRedirectSet) newLines.push('cgi.force_redirect = 0') if (!forceRedirectSet) newLines.push('cgi.force_redirect = 0')
if (!fixPathinfoSet) newLines.push('cgi.fix_pathinfo = 1') if (!fixPathinfoSet) newLines.push('cgi.fix_pathinfo = 1')
if (!sessionPathSet) newLines.push(`session.save_path = "${sessionsDir}"`)
fs.writeFileSync(iniPath, newLines.join('\n'), 'utf8') fs.writeFileSync(iniPath, newLines.join('\n'), 'utf8')
return true return true
+10
View File
@@ -34,8 +34,18 @@ export class ProjectService {
} }
private saveProjects() { private saveProjects() {
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)) fs.writeFileSync(this.projectsFile, JSON.stringify(this.projects, null, 2))
} }
}
getProjects(): Project[] { getProjects(): Project[] {
return this.projects return this.projects
+18 -9
View File
@@ -350,10 +350,6 @@ function App(): JSX.Element {
const interval = setInterval(() => { const interval = setInterval(() => {
fetchStatus() fetchStatus()
// fetchProjects() // Reduced frequency for these
// fetchPhpVersions()
// fetchMariaDbVersions()
// fetchNginxVersions()
}, 3000) }, 3000)
let logInterval: NodeJS.Timeout | null = null let logInterval: NodeJS.Timeout | null = null
@@ -1135,8 +1131,6 @@ function App(): JSX.Element {
{activeTab === 'user_guide' && <UserGuide />} {activeTab === 'user_guide' && <UserGuide />}
{activeTab === 'settings' && ( {activeTab === 'settings' && (
<Box> <Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}> <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={settingsTab} onChange={(_e, v) => setSettingsTab(v)} textColor="primary" indicatorColor="primary" variant="scrollable" scrollButtons="auto"> <Tabs value={settingsTab} onChange={(_e, v) => setSettingsTab(v)} textColor="primary" indicatorColor="primary" variant="scrollable" scrollButtons="auto">
<Tab label="PHP" icon={<PhpIcon />} iconPosition="start" /> <Tab label="PHP" icon={<PhpIcon />} iconPosition="start" />
@@ -1537,8 +1531,6 @@ function App(): JSX.Element {
{activeTab === 'dashboard' && ( {activeTab === 'dashboard' && (
<> <>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}> <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={dashboardTab} onChange={(_e, v) => setDashboardTab(v)} textColor="primary" indicatorColor="primary"> <Tabs value={dashboardTab} onChange={(_e, v) => setDashboardTab(v)} textColor="primary" indicatorColor="primary">
<Tab label={t('common.all')} /> <Tab label={t('common.all')} />
@@ -1986,11 +1978,28 @@ function App(): JSX.Element {
} }
fetchStatus(); fetchStatus();
}} }}
onOpenSite={(p: any) => { onOpenSite={(p: any, isHostMapped: boolean) => {
const port = p.serverType === 'apache' ? settings.apachePort : settings.nginxPort const port = p.serverType === 'apache' ? settings.apachePort : settings.nginxPort
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') window.open(`http://localhost:${port}/${p.host}/`, '_blank')
}
}} }}
onOpenFolder={(path: string) => window.api.invoke('open-path', path)} 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')} onManageDb={() => setActiveTab('db_manager')}
/> />
)} )}
@@ -10,8 +10,10 @@ import {
Chip, Chip,
Divider, Divider,
Stack, Stack,
Tooltip Tooltip,
CircularProgress
} from '@mui/material' } from '@mui/material'
import { useState, useEffect } from 'react'
import { import {
Folder as ProjectIcon, Folder as ProjectIcon,
FolderOpen as FolderOpenIcon, FolderOpen as FolderOpenIcon,
@@ -21,7 +23,8 @@ import {
Edit as EditIcon, Edit as EditIcon,
Delete as DeleteIcon, Delete as DeleteIcon,
Tune as TuningIcon, Tune as TuningIcon,
OpenInNew as OpenIcon OpenInNew as OpenIcon,
Code as CodeIcon
} from '@mui/icons-material' } from '@mui/icons-material'
import ProjectResourceMonitor from './ProjectResourceMonitor' import ProjectResourceMonitor from './ProjectResourceMonitor'
@@ -35,9 +38,13 @@ interface ProjectDetailsProps {
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onToggleStatus: () => void; onToggleStatus: () => void;
onOpenSite: () => void; onOpenSite: (useHost: boolean) => void;
onOpenFolder: () => void; onOpenFolder: () => void;
onOpenVSCode: () => void;
onManageDb: () => void; onManageDb: () => void;
onAddHost: (hostname: string) => void;
isTransitioning?: boolean;
setTransitioning?: (v: boolean) => void;
} }
export default function ProjectDetails({ export default function ProjectDetails({
@@ -52,8 +59,20 @@ export default function ProjectDetails({
onToggleStatus, onToggleStatus,
onOpenSite, onOpenSite,
onOpenFolder, onOpenFolder,
onManageDb onOpenVSCode,
onManageDb,
onAddHost,
isTransitioning = false,
setTransitioning
}: ProjectDetailsProps) { }: 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) { if (!p) {
return ( return (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', opacity: 0.3 }}> <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', opacity: 0.3 }}>
@@ -70,12 +89,12 @@ export default function ProjectDetails({
return ( return (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', bgcolor: 'rgba(0,0,0,0.1)', height: '100%', overflow: 'hidden' }}> <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={{ p: 3, borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <Avatar sx={{ bgcolor: 'rgba(57, 167, 255, 0.1)', color: 'primary.main', width: 48, height: 48, mt: 0.5 }}>
<Avatar sx={{ bgcolor: 'rgba(57, 167, 255, 0.1)', color: 'primary.main', width: 48, height: 48 }}>
<ProjectIcon /> <ProjectIcon />
</Avatar> </Avatar>
<Box> <Box sx={{ flexGrow: 1 }}>
<Box sx={{ mb: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>{p.name}</Typography> <Typography variant="h5" sx={{ fontWeight: 700 }}>{p.name}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontFamily: 'monospace' }}> <Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontFamily: 'monospace' }}>
@@ -86,37 +105,98 @@ export default function ProjectDetails({
</IconButton> </IconButton>
</Box> </Box>
</Box> </Box>
</Box>
<Box sx={{ display: 'flex', gap: 1.5 }}> <Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
<Button <Button
variant="contained" variant="outlined"
size="small" size="small"
startIcon={isRunning ? <StopIcon /> : <StartIcon />} disabled={isTransitioning}
startIcon={isTransitioning ? <CircularProgress size={16} /> : (isRunning ? <StopIcon /> : <StartIcon />)}
color={isRunning ? 'error' : 'primary'} color={isRunning ? 'error' : 'primary'}
onClick={onToggleStatus} 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)' }
}}
> >
{isRunning ? t('projects.stop_site') : t('projects.start_site')} {isTransitioning ? t('common.processing') : (isRunning ? t('projects.stop_site') : t('projects.start_site'))}
</Button> </Button>
<Button <Button
variant="outlined" variant="outlined"
size="small" size="small"
startIcon={<LaunchIcon />} startIcon={<LaunchIcon />}
onClick={onOpenSite} onClick={() => onOpenSite(isHostMapped)}
sx={{
px: 2,
height: 36,
transition: 'all 0.1s ease-in-out',
'&:active': { transform: 'scale(0.96)' }
}}
> >
{t('projects.open_site')} {t('projects.open_site')}
</Button> </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')}> <Tooltip title={t('common.edit')}>
<IconButton onClick={onEdit} sx={{ border: '1px solid rgba(255,255,255,0.1)' }}> <IconButton onClick={onEdit} sx={{ width: 36, height: 36, border: '1px solid rgba(255,255,255,0.1)', borderRadius: '50%' }}>
<EditIcon fontSize="small" /> <EditIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title={t('common.delete')}> <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' } }}> <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" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box>
</Box> </Box>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'rgba(255,255,255,0.05)', px: 3 }}> <Box sx={{ borderBottom: 1, borderColor: 'rgba(255,255,255,0.05)', px: 3 }}>
<Tabs value={projectDetailTab} onChange={(_e, v) => setProjectDetailTab(v)} textColor="primary" indicatorColor="primary"> <Tabs value={projectDetailTab} onChange={(_e, v) => setProjectDetailTab(v)} textColor="primary" indicatorColor="primary">
@@ -17,8 +17,10 @@ interface ProjectManagerProps {
onEditProject: (p: any) => void; onEditProject: (p: any) => void;
onRemoveProject: (id: string) => void; onRemoveProject: (id: string) => void;
onToggleStatus: (p: any) => void; onToggleStatus: (p: any) => void;
onOpenSite: (p: any) => void; onOpenSite: (p: any, useHost: boolean) => void;
onOpenFolder: (path: string) => void; onOpenFolder: (path: string) => void;
onOpenVSCode: (path: string) => void;
onAddHost: (hostname: string) => void;
onManageDb: () => void; onManageDb: () => void;
} }
@@ -39,6 +41,8 @@ export default function ProjectManager({
onToggleStatus, onToggleStatus,
onOpenSite, onOpenSite,
onOpenFolder, onOpenFolder,
onOpenVSCode,
onAddHost,
onManageDb onManageDb
}: ProjectManagerProps) { }: ProjectManagerProps) {
@@ -74,8 +78,10 @@ export default function ProjectManager({
onEdit={() => onEditProject(p)} onEdit={() => onEditProject(p)}
onDelete={() => onRemoveProject(p.id)} onDelete={() => onRemoveProject(p.id)}
onToggleStatus={() => onToggleStatus(p)} onToggleStatus={() => onToggleStatus(p)}
onOpenSite={() => onOpenSite(p)} onOpenSite={(useHost) => onOpenSite(p, useHost)}
onOpenFolder={() => onOpenFolder(p.path)} onOpenFolder={() => onOpenFolder(p.path)}
onOpenVSCode={() => onOpenVSCode(p.path)}
onAddHost={onAddHost}
onManageDb={onManageDb} onManageDb={onManageDb}
/> />
</Box> </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 { interface ServiceInfo {
status: string; status: string;
@@ -13,7 +14,7 @@ interface ProjectResourceMonitorProps {
} }
const formatMemory = (bytes?: number) => { const formatMemory = (bytes?: number) => {
if (!bytes) return '' if (!bytes) return '0 MB'
const mb = bytes / (1024 * 1024) const mb = bytes / (1024 * 1024)
if (mb > 1024) return `${(mb / 1024).toFixed(1)} GB` if (mb > 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${Math.round(mb)} MB` return `${Math.round(mb)} MB`
@@ -25,54 +26,96 @@ export default function ProjectResourceMonitor({ serverStats, phpStats, serverTy
if (!hasServerStat && !hasPhpStat) return null; if (!hasServerStat && !hasPhpStat) return null;
const renderMiniStats = (stats?: ServiceInfo, label: string = 'Service') => { const renderServiceCard = (stats?: ServiceInfo, label: string = 'Service') => {
if (!stats) return null; 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 memory = stats.memory || 0;
const ramMB = Math.round(memory / (1024 * 1024)); const ramMB = Math.round(memory / (1024 * 1024));
const ramPercent = Math.min((ramMB / 1024) * 100, 100); 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 ( return (
<Box sx={{ flex: 1 }}> <Box sx={{
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}> flex: 1,
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontSize: '0.65rem', fontWeight: 700 }}>{label.toUpperCase()}</Typography> p: 2,
<Box sx={{ display: 'flex', gap: 1 }}> borderRadius: 3,
<Typography variant="caption" sx={{ color: 'primary.light', fontSize: '0.65rem', fontWeight: 700 }}>{cpu}%</Typography> bgcolor: 'rgba(255,255,255,0.02)',
<Typography variant="caption" sx={{ color: 'secondary.light', fontSize: '0.65rem', fontWeight: 700 }}>{formatMemory(memory)}</Typography> border: '1px solid rgba(255,255,255,0.05)',
</Box> display: 'flex',
</Box> flexDirection: 'column',
<Box sx={{ display: 'flex', gap: '2px' }}> alignItems: 'center',
<LinearProgress gap: 2
variant="determinate" }}>
<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} value={cpu}
sx={{ startAngle={-110}
height: 3, endAngle={110}
borderRadius: 1, innerRadius="75%"
flex: 1, outerRadius="100%"
bgcolor: 'rgba(255,255,255,0.05)', sx={() => ({
'.MuiLinearProgress-bar': { borderRadius: 1, backgroundImage: `linear-gradient(90deg, #33d9b2 ${cpu}%, #ff5252 100%)`, bgcolor: 'transparent' } [`& .${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}%`}
/> />
<LinearProgress </Box>
variant="determinate" <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} value={ramPercent}
sx={{ startAngle={-110}
height: 3, endAngle={110}
borderRadius: 1, innerRadius="75%"
flex: 1, outerRadius="100%"
bgcolor: 'rgba(255,255,255,0.05)', sx={() => ({
'.MuiLinearProgress-bar': { borderRadius: 1, bgcolor: 'secondary.main' } [`& .${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> </Box>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: '0.6rem', mt: -1 }}>RAM</Typography>
</Box>
</Box>
</Box> </Box>
); );
}; };
return ( return (
<Box sx={{ mt: 1.5, display: 'flex', gap: 2 }}> <Box sx={{
{renderMiniStats(serverStats, serverType)} mt: 2.5,
{renderMiniStats(phpStats, 'PHP')} display: 'flex',
flexDirection: 'column',
gap: 2,
width: '100%'
}}>
{renderServiceCard(serverStats, serverType)}
{renderServiceCard(phpStats, 'PHP')}
</Box> </Box>
); );
} }
@@ -10,7 +10,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { import {
Search as SearchIcon, Search as SearchIcon,
Folder as ProjectIcon Add as AddIcon
} from '@mui/icons-material' } from '@mui/icons-material'
interface ProjectSidebarProps { interface ProjectSidebarProps {
@@ -66,7 +66,7 @@ export default function ProjectSidebar({
</Typography> </Typography>
<Tooltip title={t('projects.add_new')}> <Tooltip title={t('projects.add_new')}>
<IconButton size="small" color="primary" onClick={onAddProject} sx={{ bgcolor: 'rgba(57, 167, 255, 0.1)' }}> <IconButton size="small" color="primary" onClick={onAddProject} sx={{ bgcolor: 'rgba(57, 167, 255, 0.1)' }}>
<ProjectIcon fontSize="small" /> <AddIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box>