feat: implement project manager dashboard with resource monitoring and multi-language support

This commit is contained in:
Ümit Tunç
2026-04-02 17:44:01 +03:00
parent 8e27f16b15
commit ac30e71672
9 changed files with 906 additions and 254 deletions
+292
View File
@@ -1,4 +1,296 @@
[ [
{
"service": "apache",
"action": "start",
"status": "success",
"id": "lgv4wfd9z",
"timestamp": 1775140992722
},
{
"service": "apache",
"action": "exit",
"status": "failure",
"details": "Exit code: 1",
"id": "5dk3998of",
"timestamp": 1775140991358
},
{
"service": "nginx",
"action": "start",
"status": "success",
"id": "cbna1e6ai",
"timestamp": 1775140987786
},
{
"service": "nginx",
"action": "exit",
"status": "failure",
"details": "Exit code: 1",
"id": "tjf4mzo3m",
"timestamp": 1775140986436
},
{
"service": "php:8.5.4",
"action": "start",
"status": "success",
"id": "7ygjwfbhs",
"timestamp": 1775140953402
},
{
"service": "apache",
"action": "start",
"status": "success",
"id": "w02iyi0or",
"timestamp": 1775140952366
},
{
"service": "mariadb:10.11.8",
"action": "exit",
"status": "failure",
"details": "Exit code: 1",
"id": "0klrn76qy",
"timestamp": 1775140941299
},
{
"service": "mariadb:11.4.2",
"action": "exit",
"status": "failure",
"details": "Exit code: 1",
"id": "db5o443dt",
"timestamp": 1775140941292
},
{
"service": "php:8.5.4",
"action": "exit",
"status": "failure",
"details": "Exit code: null",
"id": "zfqnezt7g",
"timestamp": 1775140941010
},
{
"service": "php:8.5.4",
"action": "stop",
"status": "info",
"id": "70y5a19bj",
"timestamp": 1775140940997
},
{
"service": "apache",
"action": "exit",
"status": "failure",
"details": "Exit code: null",
"id": "xdkf6g1n3",
"timestamp": 1775140940604
},
{
"service": "apache",
"action": "stop",
"status": "info",
"id": "g2phz0x6z",
"timestamp": 1775140940596
},
{
"service": "mariadb:10.11.8",
"action": "start",
"status": "success",
"id": "k3qhdtk8j",
"timestamp": 1775140913371
},
{
"service": "mariadb:11.4.2",
"action": "start",
"status": "success",
"id": "pmz5hp51i",
"timestamp": 1775140913348
},
{
"service": "mariadb:10.11.8",
"action": "exit",
"status": "failure",
"details": "Exit code: 1",
"id": "d6h8pfi4x",
"timestamp": 1775140913325
},
{
"service": "apache",
"action": "start",
"status": "success",
"id": "61svs2nni",
"timestamp": 1775140913315
},
{
"service": "apache",
"action": "exit",
"status": "failure",
"details": "Exit code: 1",
"id": "sh8b2psvq",
"timestamp": 1775140913286
},
{
"service": "mariadb:11.4.2",
"action": "exit",
"status": "failure",
"details": "Exit code: 1",
"id": "xlewxzma7",
"timestamp": 1775140913265
},
{
"service": "mariadb:10.11.8",
"action": "start",
"status": "success",
"id": "fxljacg0r",
"timestamp": 1775140644165
},
{
"service": "mariadb:11.4.2",
"action": "start",
"status": "success",
"id": "2x2e657x6",
"timestamp": 1775140644143
},
{
"service": "mariadb:10.11.8",
"action": "exit",
"status": "failure",
"details": "Exit code: 1",
"id": "bofkspx9l",
"timestamp": 1775140644120
},
{
"service": "apache",
"action": "start",
"status": "success",
"id": "p19gbtez8",
"timestamp": 1775140644109
},
{
"service": "mariadb:11.4.2",
"action": "exit",
"status": "failure",
"details": "Exit code: 1",
"id": "tpwmg05yt",
"timestamp": 1775140644091
},
{
"service": "apache",
"action": "exit",
"status": "failure",
"details": "Exit code: 1",
"id": "rjr42oskg",
"timestamp": 1775140644044
},
{
"service": "php:5.6.40",
"action": "start",
"status": "success",
"id": "q5322glbz",
"timestamp": 1775140633948
},
{
"service": "php:7.4.33",
"action": "start",
"status": "success",
"id": "hbumn81gj",
"timestamp": 1775140633920
},
{
"service": "php:8.2.30",
"action": "start",
"status": "success",
"id": "jkel0o90s",
"timestamp": 1775140633897
},
{
"service": "php:8.5.4",
"action": "start",
"status": "success",
"id": "aan0ox1gd",
"timestamp": 1775140633876
},
{
"service": "mariadb:10.11.8",
"action": "start",
"status": "success",
"id": "3uk4uaxt4",
"timestamp": 1775140633650
},
{
"service": "mariadb:11.4.2",
"action": "start",
"status": "success",
"id": "rridkshjr",
"timestamp": 1775140633626
},
{
"service": "apache",
"action": "start",
"status": "success",
"id": "ckihmcjmc",
"timestamp": 1775140632885
},
{
"service": "nginx",
"action": "start",
"status": "success",
"id": "b2qv8h1o2",
"timestamp": 1775140632811
},
{
"service": "php:5.6.40",
"action": "start",
"status": "success",
"id": "zf5suzjqk",
"timestamp": 1775139676530
},
{
"service": "php:7.4.33",
"action": "start",
"status": "success",
"id": "ilid4cesf",
"timestamp": 1775139676510
},
{
"service": "php:8.2.30",
"action": "start",
"status": "success",
"id": "w42ipljsj",
"timestamp": 1775139676489
},
{
"service": "php:8.5.4",
"action": "start",
"status": "success",
"id": "0nji2mx0x",
"timestamp": 1775139676471
},
{
"service": "mariadb:11.4.2",
"action": "start",
"status": "success",
"id": "mi80ksddx",
"timestamp": 1775139676130
},
{
"service": "mariadb:10.11.8",
"action": "start",
"status": "success",
"id": "wy2jqt3or",
"timestamp": 1775139676106
},
{
"service": "apache",
"action": "start",
"status": "success",
"id": "18fd2tclr",
"timestamp": 1775139675486
},
{
"service": "nginx",
"action": "start",
"status": "success",
"id": "shmd1zuyl",
"timestamp": 1775139675417
},
{ {
"service": "php:5.6.40", "service": "php:5.6.40",
"action": "start", "action": "start",
+51 -250
View File
@@ -39,10 +39,9 @@ import {
Tabs, Tabs,
Tab, Tab,
InputAdornment, InputAdornment,
Grid,
CircularProgress, CircularProgress,
Stack, Stack,
Grid,
Avatar,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -72,16 +71,11 @@ import {
Close as CloseIcon, Close as CloseIcon,
ContentCopy as ContentCopyIcon, ContentCopy as ContentCopyIcon,
OpenInNew as OpenIcon, OpenInNew as OpenIcon,
Edit as EditIcon,
Folder as FolderIcon,
Language as HostIcon,
Launch as LaunchIcon,
Handyman as ToolsIcon,
Restore as ResetIcon, Restore as ResetIcon,
Search as SearchIcon,
Tune as TuningIcon, Tune as TuningIcon,
History as HistoryIcon, History as HistoryIcon,
MenuBook as GuideIcon MenuBook as GuideIcon,
Handyman as ToolsIcon
} from '@mui/icons-material' } from '@mui/icons-material'
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -95,6 +89,7 @@ import PhpExtensionManager from './components/PhpExtensionManager'
import ServerTuning from './components/ServerTuning' import ServerTuning from './components/ServerTuning'
import ServiceEventMonitor from './components/ServiceEventMonitor' import ServiceEventMonitor from './components/ServiceEventMonitor'
import UserGuide from './components/UserGuide' import UserGuide from './components/UserGuide'
import ProjectManager from './components/ProjectManager/ProjectManager'
declare global { declare global {
interface Window { interface Window {
@@ -192,6 +187,8 @@ function App(): JSX.Element {
const [selectedProjectHost, setSelectedProjectHost] = useState('') const [selectedProjectHost, setSelectedProjectHost] = useState('')
const [isPhpExtensionsOpen, setIsPhpExtensionsOpen] = useState(false) const [isPhpExtensionsOpen, setIsPhpExtensionsOpen] = useState(false)
const [selectedPhpVersion, setSelectedPhpVersion] = useState('') const [selectedPhpVersion, setSelectedPhpVersion] = useState('')
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
const [projectDetailTab, setProjectDetailTab] = useState(0)
const parseNginxError = (message: string) => { const parseNginxError = (message: string) => {
const match = message.match(/in (.*):(\d+)/) const match = message.match(/in (.*):(\d+)/)
@@ -606,70 +603,15 @@ function App(): JSX.Element {
); );
}; };
const ProjectResourceMonitor = ({ serverStats, phpStats, serverType }: { serverStats?: ServiceInfo; phpStats?: ServiceInfo; serverType: string }) => {
const hasServerStat = serverStats?.cpu !== undefined || serverStats?.memory !== undefined;
const hasPhpStat = phpStats?.cpu !== undefined || phpStats?.memory !== undefined;
if (!hasServerStat && !hasPhpStat) return null;
const renderMiniStats = (stats?: ServiceInfo, label: string = 'Service') => {
if (!stats) return null;
const cpu = stats.cpu || 0;
const memory = stats.memory || 0;
const ramMB = Math.round(memory / (1024 * 1024));
const ramPercent = Math.min((ramMB / 1024) * 100, 100);
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"
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>
);
};
const handleAddProject = async () => { const handleAddProject = async () => {
if (!window.api) return if (!window.api) return
let result;
if (newProject.id) { if (newProject.id) {
await window.api.updateProject(newProject) result = await window.api.updateProject(newProject)
} else { } else {
await window.api.addProject(newProject) result = await window.api.addProject(newProject)
if (result && result.id) setSelectedProjectId(result.id)
} }
setIsProjectDialogOpen(false) setIsProjectDialogOpen(false)
setNewProject({ name: '', path: '', phpVersion: '', mariaDbVersion: '', host: 'localhost' }) setNewProject({ name: '', path: '', phpVersion: '', mariaDbVersion: '', host: 'localhost' })
@@ -694,6 +636,7 @@ function App(): JSX.Element {
if (result.isConfirmed) { if (result.isConfirmed) {
await window.api.removeProject(id) await window.api.removeProject(id)
if (selectedProjectId === id) setSelectedProjectId(null)
setNotification({ open: true, message: t('projects.deleted'), severity: 'success' }) setNotification({ open: true, message: t('projects.deleted'), severity: 'success' })
fetchProjects() fetchProjects()
} }
@@ -2008,192 +1951,50 @@ function App(): JSX.Element {
</Box> </Box>
)} )}
{activeTab === 'projects' && ( {activeTab === 'projects' && (
<Box> <ProjectManager
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 2, alignItems: { xs: 'stretch', sm: 'center' }, mb: 3 }}> projects={projects}
<Typography variant="h5" sx={{ fontWeight: 'medium', flexGrow: 1 }}> status={status}
{t('projects.title')} settings={settings}
</Typography> projectSearch={projectSearch}
<TextField setProjectSearch={setProjectSearch}
size="small" selectedProjectId={selectedProjectId}
placeholder={t('projects.search')} setSelectedProjectId={setSelectedProjectId}
value={projectSearch} projectDetailTab={projectDetailTab}
onChange={(e) => setProjectSearch(e.target.value)} setProjectDetailTab={setProjectDetailTab}
sx={{ t={t}
minWidth: { sm: 300 }, onAddProject={() => {
bgcolor: 'rgba(255, 255, 255, 0.03)', setNewProject({ name: '', path: '', phpVersion: '', mariaDbVersion: '', host: 'localhost', serverType: 'nginx' })
borderRadius: 1, setIsProjectDialogOpen(true)
'& .MuiOutlinedInput-root': { }}
color: '#fff', onEditProject={(p) => {
'& fieldset': { borderColor: 'rgba(255, 255, 255, 0.1)' }, setNewProject(p)
'&:hover fieldset': { borderColor: 'rgba(255, 255, 255, 0.2)' }, setIsProjectDialogOpen(true)
} }}
}} onRemoveProject={handleRemoveProject}
InputProps={{ onToggleStatus={async (p: any) => {
startAdornment: ( const isRunning = (
<InputAdornment position="start"> (p.serverType === 'apache' ? status.apache?.status : status.nginx?.status) === 'running' &&
<SearchIcon sx={{ color: 'rgba(255, 255, 255, 0.3)' }} /> status[`php:${p.phpVersion}`]?.status === 'running'
</InputAdornment>
),
endAdornment: projectSearch && (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setProjectSearch('')} sx={{ color: 'rgba(255, 255, 255, 0.3)' }}>
<CloseIcon fontSize="small" />
</IconButton>
</InputAdornment>
)
}}
/>
<Button variant="contained" startIcon={<ProjectIcon />} onClick={() => setIsProjectDialogOpen(true)}>
{t('projects.add_new')}
</Button>
</Box>
{projects.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center', bgcolor: 'rgba(255, 255, 255, 0.03)' }}>
<Typography variant="body1" color="text.secondary">{t('projects.empty')}</Typography>
</Paper>
) : (() => {
const filteredProjects = projects.filter(p =>
p.name.toLowerCase().includes(projectSearch.toLowerCase()) ||
p.host.toLowerCase().includes(projectSearch.toLowerCase())
); );
if (isRunning) {
if (filteredProjects.length === 0) { await window.api.stopService(p.serverType === 'apache' ? 'apache' : 'nginx');
return ( await window.api.stopService(`php:${p.phpVersion}`);
<Paper sx={{ p: 6, textAlign: 'center', bgcolor: 'rgba(255, 255, 255, 0.02)', border: '1px dashed rgba(255, 255, 255, 0.1)' }}> } else {
<SearchIcon sx={{ fontSize: 48, color: 'rgba(255, 255, 255, 0.1)', mb: 2 }} /> await window.api.startService(p.serverType === 'apache' ? 'apache' : 'nginx');
<Typography variant="h6" sx={{ color: 'rgba(255, 255, 255, 0.4)' }}> await window.api.startService(`php:${p.phpVersion}`);
{t('common.not_found', 'Eşleşen proje bulunamadı')}
</Typography>
</Paper>
);
} }
fetchStatus();
return ( }}
<Grid container spacing={3}> onOpenSite={(p: any) => {
{filteredProjects.map((project) => ( const port = p.serverType === 'apache' ? settings.apachePort : settings.nginxPort
<Grid item xs={12} md={6} key={project.id}> window.open(`http://localhost:${port}/${p.host}/`, '_blank')
<Paper }}
elevation={0} onOpenFolder={(path: string) => window.api.invoke('open-path', path)}
sx={{ onManageDb={() => setActiveTab('db_manager')}
p: 2.5, />
background: 'rgba(255, 255, 255, 0.03)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.05)',
borderRadius: 3,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
background: 'rgba(255, 255, 255, 0.06)',
transform: 'translateY(-4px)',
borderColor: 'rgba(57, 167, 255, 0.3)',
boxShadow: '0 12px 24px rgba(0,0,0,0.3)'
}
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2, gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
<Avatar sx={{ bgcolor: 'rgba(57, 167, 255, 0.1)', color: '#39A7FF', flexShrink: 0 }}>
<FolderIcon />
</Avatar>
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
<Typography variant="h6" noWrap sx={{ fontWeight: 700, color: '#fff', lineHeight: 1.2, textOverflow: 'ellipsis' }}>
{project.name}
</Typography>
<Typography variant="caption" noWrap sx={{ color: 'rgba(255, 255, 255, 0.4)', fontFamily: 'monospace', display: 'block', textOverflow: 'ellipsis' }}>
{project.path.length > 40 ? '...' + project.path.slice(-37) : project.path}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
<Tooltip title={t('common.edit')}>
<IconButton size="small" onClick={() => {
setNewProject(project)
setIsProjectDialogOpen(true)
}} sx={{ color: 'rgba(255,255,255,0.4)', '&:hover': { color: '#39A7FF' } }}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
{project.serverType !== 'apache' && (
<Tooltip title={t('server.nginx.config')}>
<IconButton size="small" onClick={() => handleOpenProjectNginxConfig(project.host)} sx={{ color: 'rgba(255,255,255,0.4)', '&:hover': { color: '#39A7FF' } }}>
<TerminalIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Sil">
<IconButton size="small" onClick={() => handleRemoveProject(project.id)} sx={{ color: 'rgba(255,255,255,0.4)', '&:hover': { color: '#ff4444' } }}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
<Stack spacing={1.5}>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip
size="small"
icon={<PhpIcon sx={{ fontSize: '1rem !important' }} />}
label={`PHP ${project.phpVersion}`}
sx={{ bgcolor: 'rgba(71, 74, 255, 0.1)', color: '#777bb4', fontWeight: 600, border: '1px solid rgba(71, 74, 255, 0.2)' }}
/>
<Chip
size="small"
icon={project.serverType === 'apache' ? <ServerIcon sx={{ fontSize: '1rem !important' }} /> : <WebIcon sx={{ fontSize: '1rem !important' }} />}
label={project.serverType === 'apache' ? 'Apache' : 'Nginx'}
sx={{ bgcolor: 'rgba(255, 255, 255, 0.05)', color: '#fff', fontWeight: 600 }}
/>
</Box>
<ProjectResourceMonitor
serverType={project.serverType === 'apache' ? 'Apache' : 'Nginx'}
serverStats={project.serverType === 'apache' ? status.apache : status.nginx}
phpStats={status[`php:${project.phpVersion}`]}
/>
<Paper variant="outlined" sx={{
p: 1.5,
bgcolor: 'rgba(0,0,0,0.2)',
borderColor: 'rgba(255,255,255,0.05)',
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<HostIcon sx={{ fontSize: '1rem', color: 'rgba(255,255,255,0.3)' }} />
<Typography variant="body2" sx={{ color: '#fff', fontWeight: 500, fontFamily: 'monospace' }}>
{project.host}
</Typography>
</Box>
<Button
size="small"
endIcon={<LaunchIcon />}
onClick={() => {
const port = project.serverType === 'apache' ? settings.apachePort : settings.nginxPort
window.open(`http://localhost:${port}/${project.host}/`, '_blank')
}}
sx={{
color: '#39A7FF',
textTransform: 'none',
fontSize: '0.75rem',
p: '2px 8px',
'&:hover': { bgcolor: 'rgba(57, 167, 255, 0.1)' }
}}
>
{t('projects.browse')}
</Button>
</Paper>
</Stack>
</Paper>
</Grid>
))}
</Grid>
)
})()}
</Box>
)} )}
</Container> </Container>
</Box > </Box >
@@ -0,0 +1,216 @@
import {
Box,
Typography,
Avatar,
IconButton,
Button,
Tabs,
Tab,
Grid,
Chip,
Divider,
Stack,
Tooltip
} from '@mui/material'
import {
Folder as ProjectIcon,
FolderOpen as FolderOpenIcon,
Stop as StopIcon,
PlayArrow as StartIcon,
Launch as LaunchIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Tune as TuningIcon,
OpenInNew as OpenIcon
} from '@mui/icons-material'
import ProjectResourceMonitor from './ProjectResourceMonitor'
interface ProjectDetailsProps {
project: any;
status: any;
settings: any;
projectDetailTab: number;
setProjectDetailTab: (v: number) => void;
t: any;
onEdit: () => void;
onDelete: () => void;
onToggleStatus: () => void;
onOpenSite: () => void;
onOpenFolder: () => void;
onManageDb: () => void;
}
export default function ProjectDetails({
project: p,
status,
settings,
projectDetailTab,
setProjectDetailTab,
t,
onEdit,
onDelete,
onToggleStatus,
onOpenSite,
onOpenFolder,
onManageDb
}: ProjectDetailsProps) {
if (!p) {
return (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', opacity: 0.3 }}>
<ProjectIcon sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h6">{t('projects.no_project_selected')}</Typography>
</Box>
);
}
const isRunning = (
(p.serverType === 'apache' ? status.apache?.status : status.nginx?.status) === 'running' &&
status[`php:${p.phpVersion}`]?.status === 'running'
);
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>
<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' }}>
{p.path}
</Typography>
<IconButton size="small" onClick={onOpenFolder} sx={{ color: 'rgba(255,255,255,0.2)', p: 0.5 }}>
<FolderOpenIcon sx={{ fontSize: '0.9rem' }} />
</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>
</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">
<Tab label={t('projects.overview')} />
<Tab label={t('projects.database')} />
</Tabs>
</Box>
<Box sx={{ p: 4, flexGrow: 1, overflowY: 'auto' }}>
{projectDetailTab === 0 && (
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Box sx={{ mb: 4 }}>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.3)', fontWeight: 700 }}>{t('projects.site_domain')}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 0.5 }}>
<Typography sx={{ fontWeight: 500 }}>{p.host}.local</Typography>
<Chip label="CHANGE" size="small" variant="outlined" sx={{ cursor: 'pointer', height: 20, fontSize: '0.65rem' }} />
</Box>
</Box>
<Box sx={{ mb: 4 }}>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.3)', fontWeight: 700 }}>{t('projects.web_server')}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 0.5 }}>
<Typography sx={{ fontWeight: 500 }}>{p.serverType.toUpperCase()}</Typography>
<Chip label="CHANGE" size="small" variant="outlined" component="button" onClick={onEdit} sx={{ cursor: 'pointer', height: 20, fontSize: '0.65rem' }} />
</Box>
</Box>
<Box sx={{ mb: 4 }}>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.3)', fontWeight: 700 }}>{t('projects.php_version')}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 0.5 }}>
<Typography sx={{ fontWeight: 500 }}>{p.phpVersion}</Typography>
<Chip label="CHANGE" size="small" variant="outlined" component="button" onClick={onEdit} sx={{ cursor: 'pointer', height: 20, fontSize: '0.65rem' }} />
</Box>
</Box>
</Grid>
<Grid item xs={12} md={6}>
<Box sx={{ p: 3, border: '1px solid rgba(255,255,255,0.05)', borderRadius: 2, bgcolor: 'rgba(255,255,255,0.01)' }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}>
<TuningIcon fontSize="small" color="primary" /> {t('dashboard.status')}
</Typography>
<ProjectResourceMonitor
serverType={p.serverType === 'apache' ? 'Apache' : 'Nginx'}
serverStats={p.serverType === 'apache' ? status.apache : status.nginx}
phpStats={status[`php:${p.phpVersion}`]}
/>
</Box>
</Grid>
</Grid>
)}
{projectDetailTab === 1 && (
<Box sx={{ maxWidth: 600 }}>
<Box sx={{ p: 3, border: '1px solid rgba(255,255,255,0.05)', borderRadius: 2, bgcolor: 'rgba(255,255,255,0.01)' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.3)', fontWeight: 700 }}>{t('projects.mariadb_version')}</Typography>
<Typography sx={{ mt: 0.5, fontWeight: 600 }}>MariaDB {p.mariadbVersion}</Typography>
</Box>
<Button
variant="outlined"
size="small"
startIcon={<OpenIcon />}
onClick={onManageDb}
>
{t('mariadb_manager.title')}
</Button>
</Box>
<Divider sx={{ mb: 3, opacity: 0.1 }} />
<Stack spacing={2}>
<Box>
<Typography variant="caption" color="text.secondary">Host</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>localhost</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Port</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{settings.mariaDbPort}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Username</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>root</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Password</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.3)', fontStyle: 'italic' }}>(no password)</Typography>
</Box>
</Stack>
</Box>
</Box>
)}
</Box>
</Box>
);
}
@@ -0,0 +1,83 @@
import { Box } from '@mui/material'
import ProjectSidebar from './ProjectSidebar'
import ProjectDetails from './ProjectDetails'
interface ProjectManagerProps {
projects: any[];
status: any;
settings: any;
projectSearch: string;
setProjectSearch: (v: string) => void;
selectedProjectId: string | null;
setSelectedProjectId: (v: string | null) => void;
projectDetailTab: number;
setProjectDetailTab: (v: number) => void;
t: any;
onAddProject: () => void;
onEditProject: (p: any) => void;
onRemoveProject: (id: string) => void;
onToggleStatus: (p: any) => void;
onOpenSite: (p: any) => void;
onOpenFolder: (path: string) => void;
onManageDb: () => void;
}
export default function ProjectManager({
projects,
status,
settings,
projectSearch,
setProjectSearch,
selectedProjectId,
setSelectedProjectId,
projectDetailTab,
setProjectDetailTab,
t,
onAddProject,
onEditProject,
onRemoveProject,
onToggleStatus,
onOpenSite,
onOpenFolder,
onManageDb
}: ProjectManagerProps) {
// Defensive check: find selected project
const p = projects.find(proj => proj && proj.id === selectedProjectId);
return (
<Box sx={{
display: 'flex',
flexGrow: 1,
height: 'calc(100vh - 140px)',
m: -2,
overflow: 'hidden'
}}>
<ProjectSidebar
projects={projects}
projectSearch={projectSearch}
setProjectSearch={setProjectSearch}
selectedProjectId={selectedProjectId}
setSelectedProjectId={setSelectedProjectId}
status={status}
t={t}
onAddProject={onAddProject}
/>
<ProjectDetails
project={p}
status={status}
settings={settings}
projectDetailTab={projectDetailTab}
setProjectDetailTab={setProjectDetailTab}
t={t}
onEdit={() => onEditProject(p)}
onDelete={() => onRemoveProject(p.id)}
onToggleStatus={() => onToggleStatus(p)}
onOpenSite={() => onOpenSite(p)}
onOpenFolder={() => onOpenFolder(p.path)}
onManageDb={onManageDb}
/>
</Box>
);
}
@@ -0,0 +1,78 @@
import { Box, Typography, LinearProgress } from '@mui/material'
interface ServiceInfo {
status: string;
cpu?: number;
memory?: number;
}
interface ProjectResourceMonitorProps {
serverStats?: ServiceInfo;
phpStats?: ServiceInfo;
serverType: string;
}
const formatMemory = (bytes?: number) => {
if (!bytes) return ''
const mb = bytes / (1024 * 1024)
if (mb > 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${Math.round(mb)} MB`
}
export default function ProjectResourceMonitor({ serverStats, phpStats, serverType }: ProjectResourceMonitorProps) {
const hasServerStat = serverStats?.cpu !== undefined || serverStats?.memory !== undefined;
const hasPhpStat = phpStats?.cpu !== undefined || phpStats?.memory !== undefined;
if (!hasServerStat && !hasPhpStat) return null;
const renderMiniStats = (stats?: ServiceInfo, label: string = 'Service') => {
if (!stats) return null;
const cpu = stats.cpu || 0;
const memory = stats.memory || 0;
const ramMB = Math.round(memory / (1024 * 1024));
const ramPercent = Math.min((ramMB / 1024) * 100, 100);
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"
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>
);
}
@@ -0,0 +1,156 @@
import {
Box,
Typography,
TextField,
InputAdornment,
IconButton,
Tooltip,
List,
ListItemButton
} from '@mui/material'
import {
Search as SearchIcon,
Folder as ProjectIcon
} from '@mui/icons-material'
interface ProjectSidebarProps {
projects: any[];
projectSearch: string;
setProjectSearch: (v: string) => void;
selectedProjectId: string | null;
setSelectedProjectId: (v: string | null) => void;
status: any;
t: any;
onAddProject: () => void;
}
export default function ProjectSidebar({
projects,
projectSearch,
setProjectSearch,
selectedProjectId,
setSelectedProjectId,
status,
t,
onAddProject
}: ProjectSidebarProps) {
const filtered = projects.filter(p => {
if (!p) return false;
const nameMatch = p.name?.toLowerCase().includes(projectSearch.toLowerCase()) ?? false;
const hostMatch = p.host?.toLowerCase().includes(projectSearch.toLowerCase()) ?? false;
return nameMatch || hostMatch;
});
// Grouping
const grouped = filtered.reduce((acc: any, p) => {
const v = p.phpVersion ? `PHP ${p.phpVersion}` : 'Unknown PHP';
if (!acc[v]) acc[v] = [];
acc[v].push(p);
return acc;
}, {});
return (
<Box sx={{
width: 320,
borderRight: '1px solid rgba(255, 255, 255, 0.05)',
display: 'flex',
flexDirection: 'column',
bgcolor: 'rgba(255, 255, 255, 0.01)',
height: '100%'
}}>
<Box sx={{ p: 2, borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}>
<Box sx={{ display: 'flex', gap: 1, mb: 2, alignItems: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 700, flexGrow: 1, fontSize: '1.1rem' }}>
{t('projects.title')}
</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" />
</IconButton>
</Tooltip>
</Box>
<TextField
fullWidth
size="small"
placeholder={t('projects.search')}
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ color: 'rgba(255, 255, 255, 0.2)', fontSize: '1.1rem' }} />
</InputAdornment>
),
sx: { fontSize: '0.85rem', bgcolor: 'rgba(0,0,0,0.2)' }
}}
/>
</Box>
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 1 }}>
{filtered.length === 0 ? (
<Box sx={{ p: 4, textAlign: 'center', color: 'rgba(255,255,255,0.2)' }}>
<Typography variant="body2">{t('projects.empty')}</Typography>
</Box>
) : (
Object.entries(grouped).sort().map(([version, projs]: [string, any]) => (
<Box key={version} sx={{ mb: 2 }}>
<Typography variant="overline" sx={{ px: 1.5, color: 'primary.main', fontWeight: 800, letterSpacing: 1 }}>
{version}
</Typography>
<List dense disablePadding>
{projs.map((p: any) => {
const isRunning = (
(p.serverType === 'apache' ? status.apache?.status : status.nginx?.status) === 'running' &&
status[`php:${p.phpVersion}`]?.status === 'running'
);
const serverStats = p.serverType === 'apache' ? status.apache : status.nginx;
const phpStats = status[`php:${p.phpVersion}`];
return (
<ListItemButton
key={p.id}
selected={selectedProjectId === p.id}
onClick={() => setSelectedProjectId(p.id)}
sx={{
borderRadius: 2,
mb: 0.5,
border: selectedProjectId === p.id ? '1px solid rgba(57, 167, 255, 0.3)' : '1px solid transparent',
'&.Mui-selected': { bgcolor: 'rgba(57, 167, 255, 0.08)' }
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', width: '100%', gap: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: isRunning ? 'success.main' : 'error.main',
boxShadow: isRunning ? '0 0 8px rgba(76, 175, 80, 0.5)' : 'none'
}} />
<Typography variant="body2" noWrap sx={{ fontWeight: 600, flexGrow: 1 }}>
{p.name}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontSize: '0.7rem' }}>
{(p.serverType || '').toUpperCase()} {p.host}
</Typography>
{isRunning && (
<Typography variant="caption" sx={{ color: 'primary.light', fontSize: '0.65rem', fontWeight: 700 }}>
{Math.round((serverStats?.cpu || 0) + (phpStats?.cpu || 0))}% CPU
</Typography>
)}
</Box>
</Box>
</ListItemButton>
);
})}
</List>
</Box>
))
)}
</Box>
</Box>
);
}
+10 -1
View File
@@ -76,7 +76,16 @@
"added": "Projekt erfolgreich hinzugefügt.", "added": "Projekt erfolgreich hinzugefügt.",
"updated": "Projekt erfolgreich aktualisiert.", "updated": "Projekt erfolgreich aktualisiert.",
"deleted": "Projekt wurde gelöscht.", "deleted": "Projekt wurde gelöscht.",
"delete_confirm": "Sind Sie sicher, dass Sie dieses Projekt löschen möchten? Ihre Dateien werden nicht gelöscht, nur aus der Liste entfernt." "delete_confirm": "Sind Sie sicher, dass Sie dieses Projekt löschen möchten? Ihre Dateien werden nicht gelöscht, nur aus der Liste entfernt.",
"overview": "Übersicht",
"database": "Datenbank",
"site_domain": "Site-Domain",
"web_server": "Webserver",
"site_path": "Site-Pfad",
"open_site": "SITE ÖFFNEN",
"stop_site": "SITE STOPPEN",
"start_site": "SITE STARTEN",
"no_project_selected": "Bitte wählen Sie ein Projekt aus der Liste aus oder erstellen Sie ein neues."
}, },
"settings": { "settings": {
"general": "Allgemeine Einstellungen", "general": "Allgemeine Einstellungen",
+10 -1
View File
@@ -82,7 +82,16 @@
"added": "Project added successfully.", "added": "Project added successfully.",
"updated": "Project updated successfully.", "updated": "Project updated successfully.",
"deleted": "Project deleted.", "deleted": "Project deleted.",
"delete_confirm": "Are you sure you want to delete this project? Your files will not be deleted, only removed from the list." "delete_confirm": "Are you sure you want to delete this project? Your files will not be deleted, only removed from the list.",
"overview": "Overview",
"database": "Database",
"site_domain": "Site Domain",
"web_server": "Web Server",
"site_path": "Site Path",
"open_site": "OPEN SITE",
"stop_site": "STOP SITE",
"start_site": "START SITE",
"no_project_selected": "Please select a project from the list or create a new one."
}, },
"settings": { "settings": {
"general": "General Settings", "general": "General Settings",
+10 -2
View File
@@ -83,11 +83,19 @@
"updated": "Proje başarıyla güncellendi.", "updated": "Proje başarıyla güncellendi.",
"deleted": "Proje silindi.", "deleted": "Proje silindi.",
"delete_confirm": "Bu projeyi silmek istediğinize emin misiniz? Dosyalarınız silinmez, sadece listeden kaldırılır.", "delete_confirm": "Bu projeyi silmek istediğinize emin misiniz? Dosyalarınız silinmez, sadece listeden kaldırılır.",
"browse": "Gözat" "browse": "Gözat",
"overview": "Genel Bakış",
"database": "Veritabanı",
"site_domain": "Site Adresi",
"web_server": "Web Sunucusu",
"site_path": "Dosya Yolu",
"open_site": "SİTEYİ AÇ",
"stop_site": "SİTEYİ DURDUR",
"start_site": "SİTEYİ BAŞLAT",
"no_project_selected": "Lütfen listeden bir proje seçin veya yeni bir tane oluşturun."
}, },
"settings": { "settings": {
"general": "Genel Ayarlar", "general": "Genel Ayarlar",
"server_settings": "Sunucu Ayarları",
"saved": "Ayarlar kaydedildi.", "saved": "Ayarlar kaydedildi.",
"save_failed": "Ayarlar kaydedilemedi.", "save_failed": "Ayarlar kaydedilemedi.",
"language": "Dil", "language": "Dil",