feat: implement project management system with Nginx configuration templates and automated host file updates
This commit is contained in:
@@ -1,15 +1,8 @@
|
||||
# Apache Project Configuration (CGI Mode)
|
||||
# Accessed via: http://localhost:{{PORT}}/{{HOST}}/
|
||||
<VirtualHost *:{{PORT}}>
|
||||
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
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
@@ -26,13 +19,15 @@ ScriptAlias {{PHP_BIN_URL}}/ "{{PHP_DIR}}/"
|
||||
</FilesMatch>
|
||||
|
||||
# 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"
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
# 4. Permissions for the PHP binary directory
|
||||
<Directory "{{PHP_DIR}}">
|
||||
# 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>
|
||||
</Directory>
|
||||
</VirtualHost>
|
||||
@@ -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>
|
||||
@@ -83,7 +83,10 @@ Alias /phpmyadmin "{{PHPMYADMIN_DIR}}"
|
||||
CGIPassAuth On
|
||||
</Directory>
|
||||
|
||||
# Project configurations
|
||||
# Project configurations (Subdirectory Access)
|
||||
{{PROJECT_ALIASES}}
|
||||
|
||||
# Project configurations (Virtual Host Access)
|
||||
{{PROJECTS}}
|
||||
|
||||
# Global PHP handler (Manual Nginx-Style SCRIPT_FILENAME)
|
||||
|
||||
@@ -9,12 +9,14 @@ http {
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
server_names_hash_bucket_size 64;
|
||||
|
||||
server {
|
||||
listen {{LISTEN_ADDRESS}}:{{PORT}};
|
||||
server_name localhost;
|
||||
root "{{ROOT}}";
|
||||
index index.php index.html index.htm;
|
||||
autoindex on;
|
||||
|
||||
location / {
|
||||
# Try global root first, fallback to @project_fallback if not found
|
||||
@@ -36,7 +38,9 @@ http {
|
||||
return 404;
|
||||
}
|
||||
|
||||
{{PROJECTS}}
|
||||
# Subdirectory access for projects (legacy support)
|
||||
{{PROJECT_LOCATIONS}}
|
||||
|
||||
|
||||
location /phpmyadmin {
|
||||
alias "{{PHPMYADMIN_DIR}}";
|
||||
@@ -58,4 +62,6 @@ http {
|
||||
fastcgi_param SCRIPT_FILENAME $request_filename;
|
||||
}
|
||||
}
|
||||
|
||||
{{PROJECTS}}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
location ^~ /{{HOST}}/ {
|
||||
alias "{{PATH}}";
|
||||
server {
|
||||
listen {{PORT}};
|
||||
server_name {{HOST}};
|
||||
root "{{PATH}}";
|
||||
index index.php index.html;
|
||||
autoindex on;
|
||||
|
||||
# Use a named location for reliable alias fallback
|
||||
try_files $uri $uri/ @{{HOST}};
|
||||
# Support for clean URLs
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
# PHP handling nested INSIDE the alias location to maintain context
|
||||
# PHP handling
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass 127.0.0.1:{{PHP_PORT}};
|
||||
fastcgi_index index.php;
|
||||
|
||||
include "{{CONF_DIR}}/fastcgi_params";
|
||||
|
||||
# Normal files matched correctly by URI don't suffer the try_files bug
|
||||
fastcgi_param SCRIPT_FILENAME $request_filename;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
}
|
||||
}
|
||||
|
||||
# The named location handles requests that don't match physical files
|
||||
location @{{HOST}} {
|
||||
# Directly execute PHP without URI mutation to preserve framework routing paths
|
||||
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}}";
|
||||
# Custom log files for the project
|
||||
# access_log "{{LOG_DIR}}/{{HOST}}_access.log";
|
||||
# error_log "{{LOG_DIR}}/{{HOST}}_error.log";
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"nginxPort": "80",
|
||||
"nginxPort": "8081",
|
||||
"apachePort": "8080",
|
||||
"serverType": "nginx",
|
||||
"phpPort": "9001",
|
||||
|
||||
+102
-26
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -34,8 +34,18 @@ export class ProjectService {
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
getProjects(): Project[] {
|
||||
return this.projects
|
||||
|
||||
@@ -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
|
||||
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 }}>
|
||||
<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>
|
||||
<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,37 +105,98 @@ export default function ProjectDetails({
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={isRunning ? <StopIcon /> : <StartIcon />}
|
||||
disabled={isTransitioning}
|
||||
startIcon={isTransitioning ? <CircularProgress size={16} /> : (isRunning ? <StopIcon /> : <StartIcon />)}
|
||||
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
|
||||
variant="outlined"
|
||||
size="small"
|
||||
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')}
|
||||
</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 Aç
|
||||
</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={{ 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" />
|
||||
</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' } }}>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
|
||||
@@ -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>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: '2px' }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
<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}
|
||||
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' }
|
||||
}}
|
||||
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}%`}
|
||||
/>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
</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}
|
||||
sx={{
|
||||
height: 3,
|
||||
borderRadius: 1,
|
||||
flex: 1,
|
||||
bgcolor: 'rgba(255,255,255,0.05)',
|
||||
'.MuiLinearProgress-bar': { borderRadius: 1, bgcolor: 'secondary.main' }
|
||||
}}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user