feat: implement dashboard sidebar and service management infrastructure

This commit is contained in:
Ümit Tunç
2026-04-03 06:51:16 +03:00
parent 2c2399a157
commit cdb36196e5
7 changed files with 2194 additions and 428 deletions
+1396
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -337,6 +337,26 @@ async function restartApache(): Promise<{ success: boolean; message: string }> {
} }
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
console.log('Registering config:template IPC handlers...')
ipcMain.handle('config:template:get', async (_event, templateName: string) => {
try {
console.log('Fetching template:', templateName)
return { success: true, content: configService.getTemplate(templateName) }
} catch (error: any) {
console.error('Failed to get template:', error)
return { success: false, message: error.message }
}
})
ipcMain.handle('config:template:save', async (_event, { templateName, content }) => {
try {
configService.saveTemplate(templateName, content)
return { success: true, message: 'Şablon başarıyla kaydedildi.' }
} catch (error: any) {
return { success: false, message: error.message }
}
})
ipcMain.handle('mariadb:processlist', async (_event, versionId) => { ipcMain.handle('mariadb:processlist', async (_event, versionId) => {
try { try {
return await mariaDbManagerService.getProcessList(versionId) return await mariaDbManagerService.getProcessList(versionId)
+20
View File
@@ -71,6 +71,26 @@ export class ConfigService {
return path.join(this.configDir, targetName) return path.join(this.configDir, targetName)
} }
getTemplate(templateName: string): string {
const templatePath = path.join(this.templateDir, templateName)
if (!fs.existsSync(templatePath)) {
throw new Error(`Template not found: ${templatePath}`)
}
return fs.readFileSync(templatePath, 'utf-8')
}
saveTemplate(templateName: string, content: string): void {
const templatePath = path.join(this.templateDir, templateName)
const backupPath = `${templatePath}.bak`
// Simple backup
if (fs.existsSync(templatePath)) {
fs.copyFileSync(templatePath, backupPath)
}
fs.writeFileSync(templatePath, content)
}
getSettings(): any { getSettings(): any {
try { try {
if (fs.existsSync(this.settingsPath)) { if (fs.existsSync(this.settingsPath)) {
+67 -428
View File
@@ -71,7 +71,6 @@ import {
Close as CloseIcon, Close as CloseIcon,
ContentCopy as ContentCopyIcon, ContentCopy as ContentCopyIcon,
OpenInNew as OpenIcon, OpenInNew as OpenIcon,
Restore as ResetIcon,
Tune as TuningIcon, Tune as TuningIcon,
History as HistoryIcon, History as HistoryIcon,
MenuBook as GuideIcon, MenuBook as GuideIcon,
@@ -90,6 +89,7 @@ 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' import ProjectManager from './components/ProjectManager/ProjectManager'
import DashboardManager from './components/Dashboard/DashboardManager'
declare global { declare global {
interface Window { interface Window {
@@ -135,7 +135,6 @@ function App(): JSX.Element {
const [projectSearch, setProjectSearch] = useState('') const [projectSearch, setProjectSearch] = useState('')
const [cliConfig, setCliConfig] = useState<any>({ enabled: true, defaultPhp: '', defaultMariaDb: '', binDir: '' }) const [cliConfig, setCliConfig] = useState<any>({ enabled: true, defaultPhp: '', defaultMariaDb: '', binDir: '' })
const [settingsTab, setSettingsTab] = useState(0) const [settingsTab, setSettingsTab] = useState(0)
const [dashboardTab, setDashboardTab] = useState(0)
const [projects, setProjects] = useState<any[]>([]) const [projects, setProjects] = useState<any[]>([])
const [phpVersions, setPhpVersions] = useState<any[]>([]) const [phpVersions, setPhpVersions] = useState<any[]>([])
const [mariaDbVersions, setMariaDbVersions] = useState<any[]>([]) const [mariaDbVersions, setMariaDbVersions] = useState<any[]>([])
@@ -190,6 +189,11 @@ function App(): JSX.Element {
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null) const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
const [projectDetailTab, setProjectDetailTab] = useState(0) const [projectDetailTab, setProjectDetailTab] = useState(0)
const handleSelectProject = (projectId: string) => {
setSelectedProjectId(projectId)
setActiveTab('projects')
}
const parseNginxError = (message: string) => { const parseNginxError = (message: string) => {
const match = message.match(/in (.*):(\d+)/) const match = message.match(/in (.*):(\d+)/)
if (!match) return null if (!match) return null
@@ -270,9 +274,10 @@ function App(): JSX.Element {
} }
const fetchApacheVersions = async () => { const fetchApacheVersions = async () => {
if (!window.api || !window.api.getApacheVersions) return if (window.api && window.api.getApacheVersions) {
const v = await window.api.getApacheVersions() const list = await window.api.getApacheVersions()
setApacheVersions(v || []) setApacheVersions(list || [])
}
} }
const fetchLogs = async (service: string) => { const fetchLogs = async (service: string) => {
@@ -385,6 +390,49 @@ function App(): JSX.Element {
} }
}, [logs, logDialogOpen]) }, [logs, logDialogOpen])
const handleOpenNginxConfig = async () => {
if (window.api && window.api.invoke) {
const config = await window.api.invoke('nginx:config:get')
setNginxConfig(config)
setIsNginxConfigOpen(true)
}
}
const handleSaveNginxConfig = async () => {
if (window.api && window.api.invoke) {
const result = await window.api.invoke('nginx:config:save', nginxConfig)
if (result.success) {
Swal.fire({ title: t('common.success'), text: t('server.nginx.config_saved'), icon: 'success', background: '#1e1e1e', color: '#fff' })
setIsNginxConfigOpen(false)
}
}
}
const handleOpenApacheConfig = async () => {
if (window.api && window.api.invoke) {
const config = await window.api.invoke('apache:config:get')
setApacheConfig(config)
setIsApacheConfigOpen(true)
}
}
const handleSaveApacheConfig = async () => {
if (window.api && window.api.invoke) {
const result = await window.api.invoke('apache:config:save', apacheConfig)
if (result.success) {
Swal.fire({ title: t('common.success'), text: t('server.apache.config_saved'), icon: 'success', background: '#1e1e1e', color: '#fff' })
setIsApacheConfigOpen(false)
}
}
}
const handleTestApacheConfig = async () => {
if (window.api && window.api.invoke) {
const result = await window.api.invoke('apache:config:test')
Swal.fire({ title: result.success ? t('common.success') : t('common.error'), text: result.message, icon: result.success ? 'success' : 'error', background: '#1e1e1e', color: '#fff' })
}
}
const handleSelectPath = async () => { const handleSelectPath = async () => {
if (window.api && window.api.selectDirectory) { if (window.api && window.api.selectDirectory) {
const pathStr = await window.api.selectDirectory() const pathStr = await window.api.selectDirectory()
@@ -396,76 +444,6 @@ function App(): JSX.Element {
} }
} }
const handleToggleService = async (service: string) => {
if (!window.api) return
const isStarting = ((status[service] as any)?.status || status[service] || 'stopped') === 'stopped'
if (isStarting) {
setStatus(prev => ({ ...prev, [service]: { status: 'starting' } }))
}
const result = ((status[service] as any)?.status || status[service] || 'stopped') === 'running'
? await window.api.stopService(service)
: await window.api.startService(service)
// Refresh status after operation
fetchStatus()
if (!result.success) {
Swal.fire({
title: t('common.error'),
text: result.message,
icon: 'error',
confirmButtonText: t('common.ok'),
background: '#1e1e1e',
color: '#fff',
confirmButtonColor: '#153E5E',
customClass: {
popup: 'swal2-dark-popup'
}
})
} else {
setNotification({
open: true,
message: result.message,
severity: 'success'
})
}
}
const handleOpenLogs = async (service: string) => {
setSelectedService(service)
setLogDialogOpen(true)
await fetchLogs(service)
}
const handleResetData = async (service: string) => {
const confirmResult = await Swal.fire({
title: t('common.reset_confirm_title'),
text: t('common.reset_confirm_text', { service }),
icon: 'warning',
showCancelButton: true,
confirmButtonText: t('common.reset_btn'),
cancelButtonText: t('common.cancel'),
background: '#1e1e1e',
color: '#fff',
confirmButtonColor: '#d33',
cancelButtonColor: '#3085d6'
})
if (confirmResult.isConfirmed) {
const result = await window.api.invoke('services:reset-data', service)
Swal.fire({
title: result.success ? t('common.success') : t('common.error'),
text: result.message,
icon: result.success ? 'success' : 'error',
background: '#1e1e1e',
color: '#fff'
})
fetchStatus()
}
}
const handleSaveSettings = async () => { const handleSaveSettings = async () => {
if (window.api && window.api.saveSettings) { if (window.api && window.api.saveSettings) {
const result = await window.api.saveSettings(settings) const result = await window.api.saveSettings(settings)
@@ -532,74 +510,6 @@ function App(): JSX.Element {
return `${Math.round(mb)} MB` return `${Math.round(mb)} MB`
} }
const getStatusChip = (s: string | { status: string, cpu?: number, memory?: number }) => {
const state = typeof s === 'string' ? s : s.status
if (state === 'starting') {
return <Chip label={t('common.starting')} color="warning" size="small" sx={{ fontWeight: 'bold' }} icon={<CircularProgress size={14} color="inherit" />} />
}
if (state === 'stopping') {
return <Chip label={t('common.stopping')} color="warning" size="small" sx={{ fontWeight: 'bold' }} icon={<CircularProgress size={14} color="inherit" />} />
}
const color = state === 'running' ? 'success' : state === 'stopped' ? 'error' : 'warning'
return <Chip label={t(`common.${state}`).toUpperCase()} color={color} size="small" sx={{ fontWeight: 'bold' }} />
}
const ResourceMonitor = ({ cpu, memory }: { cpu?: number; memory?: number }) => {
if (cpu === undefined && memory === undefined) return null;
const ramMB = memory ? Math.round(memory / (1024 * 1024)) : 0;
const ramLimit = 1024; // 1GB scale for visual
const cpuValue = cpu || 0;
const ramPercent = Math.min((ramMB / ramLimit) * 100, 100);
return (
<Box sx={{ mt: 1.5, mb: 1 }}>
<Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.7rem', fontWeight: 'bold' }}>CPU</Typography>
<Typography variant="caption" sx={{ color: 'primary.light', fontSize: '0.7rem', fontWeight: 'bold' }}>{cpuValue}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={cpuValue}
sx={{
height: 4,
borderRadius: 2,
bgcolor: 'rgba(255,255,255,0.05)',
'.MuiLinearProgress-bar': {
borderRadius: 2,
backgroundImage: `linear-gradient(90deg, #33d9b2 ${cpuValue}%, #ff5252 100%)`,
bgcolor: 'transparent'
}
}}
/>
</Box>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.7rem', fontWeight: 'bold' }}>RAM</Typography>
<Typography variant="caption" sx={{ color: 'secondary.light', fontSize: '0.7rem', fontWeight: 'bold' }}>{formatMemory(memory as number)}</Typography>
</Box>
<LinearProgress
variant="determinate"
value={ramPercent}
sx={{
height: 4,
borderRadius: 2,
bgcolor: 'rgba(255,255,255,0.05)',
'.MuiLinearProgress-bar': {
borderRadius: 2,
bgcolor: 'secondary.main'
}
}}
/>
</Box>
</Box>
);
};
const handleAddProject = async () => { const handleAddProject = async () => {
if (!window.api) return if (!window.api) return
let result; let result;
@@ -638,77 +548,11 @@ function App(): JSX.Element {
} }
} }
const handleOpenNginxConfig = async () => { const handleOpenProcessList = async (id: string) => {
setSelectedMariaDbId(id)
setIsProcessListOpen(true)
if (window.api && window.api.invoke) { if (window.api && window.api.invoke) {
const config = await window.api.invoke('nginx:config:get') await fetchProcessList(id)
setNginxConfig(config)
setIsNginxConfigOpen(true)
}
}
const handleSaveNginxConfig = async () => {
if (window.api && window.api.invoke) {
const result = await window.api.invoke('nginx:config:save', nginxConfig)
if (result.success) {
Swal.fire({
title: t('common.success'),
text: t('server.nginx.config_saved'),
icon: 'success',
background: '#1e1e1e',
color: '#fff',
confirmButtonColor: '#153E5E',
customClass: { popup: 'swal2-dark-popup' }
})
setIsNginxConfigOpen(false)
}
}
}
const handleOpenApacheConfig = async () => {
if (window.api && window.api.invoke) {
const config = await window.api.invoke('apache:config:get')
setApacheConfig(config)
setIsApacheConfigOpen(true)
}
}
const handleSaveApacheConfig = async () => {
if (window.api && window.api.invoke) {
const result = await window.api.invoke('apache:config:save', apacheConfig)
if (result.success) {
Swal.fire({
title: t('common.success'),
text: t('server.apache.config_saved'),
icon: 'success',
background: '#1e1e1e',
color: '#fff',
confirmButtonColor: '#153E5E',
customClass: { popup: 'swal2-dark-popup' }
})
setIsApacheConfigOpen(false)
}
}
}
const handleTestApacheConfig = async () => {
if (window.api && window.api.invoke) {
const result = await window.api.invoke('apache:config:test')
const formattedMessage = result.message
.replace(/failed/g, '<span style="color: #ff5252;">failed</span>')
.replace(/successful/g, '<span style="color: #33d9b2;">successful</span>')
Swal.fire({
title: result.success ? 'Başarılı' : 'Yapılandırma Hatası',
html: `
<div class="nginx-error-container">
${formattedMessage}
</div>
`,
icon: result.success ? 'success' : 'error',
background: '#1e1e1e',
color: '#fff',
width: '600px'
})
} }
} }
@@ -849,10 +693,6 @@ function App(): JSX.Element {
setIsPhpExtensionsOpen(true) setIsPhpExtensionsOpen(true)
} }
const handleOpenProcessList = async (id: string) => {
setSelectedMariaDbId(id)
setActiveTab('db_monitor')
}
const fetchProcessList = async (id: string) => { const fetchProcessList = async (id: string) => {
if (!window.api) return if (!window.api) return
@@ -1530,217 +1370,16 @@ function App(): JSX.Element {
)} )}
{activeTab === 'dashboard' && ( {activeTab === 'dashboard' && (
<> <DashboardManager
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}> status={status}
<Tabs value={dashboardTab} onChange={(_e, v) => setDashboardTab(v)} textColor="primary" indicatorColor="primary"> settings={settings}
<Tab label={t('common.all')} /> projects={projects}
<Tab label="Nginx" /> phpVersions={phpVersions}
<Tab label="Apache" /> mariaDbVersions={mariaDbVersions}
<Tab label="PHP" /> t={t}
<Tab label="MariaDB" /> onFetchStatus={fetchStatus}
</Tabs> onSelectProject={handleSelectProject}
</Box> />
<Grid container spacing={3} sx={{ mb: 4 }}>
{/* Nginx Box */}
{(dashboardTab === 0 || dashboardTab === 1) && (
<Grid item xs={12} sm={6} md={4}>
<Paper elevation={4} sx={{
p: 3, borderRadius: 3,
bgcolor: 'rgba(255, 255, 255, 0.03)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.05)',
transition: 'transform 0.2s',
'&:hover': { transform: 'scale(1.02)' }
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<WebIcon color="primary" sx={{ fontSize: 40 }} />
{getStatusChip(status.nginx)}
</Box>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>Nginx</Typography>
<Typography variant="body2" color="text.secondary">Port: {settings.nginxPort}</Typography>
<ResourceMonitor cpu={status.nginx?.cpu} memory={status.nginx?.memory} />
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton onClick={() => handleOpenLogs('nginx')} title={t('diagnostics.logs')} size="small" sx={{ bgcolor: 'rgba(255,255,255,0.05)' }}>
<TerminalIcon fontSize="small" />
</IconButton>
<IconButton
onClick={handleOpenNginxConfig}
title={t('server.nginx.config')}
size="small"
sx={{ bgcolor: 'rgba(255,255,255,0.05)', '&:hover': { color: 'primary.main' } }}
>
<SettingsIcon fontSize="small" />
</IconButton>
<IconButton
onClick={() => {
window.api.invoke('services:reload', 'nginx').then((res: any) => {
Swal.fire({
title: res.success ? t('common.success') : t('common.error'),
text: res.message,
icon: res.success ? 'success' : 'error',
background: '#1e1e1e',
color: '#fff',
confirmButtonColor: '#153E5E',
customClass: { popup: 'swal2-dark-popup' }
})
})
}}
title={t('server.nginx.save_reload')}
size="small"
disabled={status.nginx?.status !== 'running'}
sx={{ bgcolor: 'rgba(255,255,255,0.05)', '&:hover': { color: 'primary.main' } }}
>
<RefreshIcon fontSize="small" />
</IconButton>
</Box>
<Switch
checked={status.nginx?.status === 'running' || status.nginx?.status === 'starting'}
onChange={() => handleToggleService('nginx')}
disabled={status.nginx?.status === 'starting'}
/>
</Box>
</Paper>
</Grid>
)}
{/* Apache Box */}
{(dashboardTab === 0 || dashboardTab === 2) && (
<Grid item xs={12} sm={6} md={4}>
<Paper elevation={4} sx={{
p: 3, borderRadius: 3,
bgcolor: 'rgba(255, 255, 255, 0.03)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.05)',
transition: 'transform 0.2s',
'&:hover': { transform: 'scale(1.02)' }
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<ServerIcon color="primary" sx={{ fontSize: 40 }} />
{getStatusChip(status.apache)}
</Box>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>Apache</Typography>
<Typography variant="body2" color="text.secondary">Port: {settings.apachePort}</Typography>
<ResourceMonitor cpu={status.apache?.cpu} memory={status.apache?.memory} />
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton onClick={() => handleOpenLogs('apache')} title={t('diagnostics.logs')} size="small" sx={{ bgcolor: 'rgba(255,255,255,0.05)' }}>
<TerminalIcon fontSize="small" />
</IconButton>
<IconButton
onClick={handleOpenApacheConfig}
title={t('server.apache.config')}
size="small"
sx={{ bgcolor: 'rgba(255,255,255,0.05)', '&:hover': { color: 'primary.main' } }}
>
<SettingsIcon fontSize="small" />
</IconButton>
<IconButton
onClick={() => {
window.api.invoke('services:reload', 'apache').then((res: any) => {
Swal.fire({
title: res.success ? t('common.success') : t('common.error'),
text: res.message,
icon: res.success ? 'success' : 'error',
background: '#1e1e1e',
color: '#fff',
confirmButtonColor: '#153E5E',
customClass: { popup: 'swal2-dark-popup' }
})
})
}}
title={t('server.apache.save_reload')}
size="small"
disabled={status.apache?.status !== 'running'}
sx={{ bgcolor: 'rgba(255,255,255,0.05)', '&:hover': { color: 'primary.main' } }}
>
<RefreshIcon fontSize="small" />
</IconButton>
</Box>
<Switch
checked={status.apache?.status === 'running' || status.apache?.status === 'starting'}
onChange={() => handleToggleService('apache')}
disabled={status.apache?.status === 'starting'}
/>
</Box>
</Paper>
</Grid>
)}
{/* PHP Boxes */}
{(dashboardTab === 0 || dashboardTab === 3) && installedPhpVersions.map(v => (
<Grid item xs={12} sm={6} md={4} key={v.id}>
<Paper elevation={4} sx={{
p: 3, borderRadius: 3,
bgcolor: 'rgba(255, 255, 255, 0.03)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.05)',
transition: 'transform 0.2s',
'&:hover': { transform: 'scale(1.02)' }
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<PhpIcon color="primary" sx={{ fontSize: 40 }} />
{getStatusChip(status[`php:${v.version}`] || { status: 'stopped' })}
</Box>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>PHP {v.version}</Typography>
<Typography variant="body2" color="text.secondary">Port: {v.port || '?'}</Typography>
<ResourceMonitor cpu={status[`php:${v.version}`]?.cpu} memory={status[`php:${v.version}`]?.memory} />
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<IconButton onClick={() => handleOpenLogs(`php:${v.version}`)} size="small" sx={{ bgcolor: 'rgba(255,255,255,0.05)' }}>
<TerminalIcon fontSize="small" />
</IconButton>
<Switch
checked={status[`php:${v.version}`]?.status === 'running' || status[`php:${v.version}`]?.status === 'starting'}
onChange={() => handleToggleService(`php:${v.version}`)}
disabled={status[`php:${v.version}`]?.status === 'starting'}
/>
</Box>
</Paper>
</Grid>
))}
{/* MariaDB Boxes */}
{(dashboardTab === 0 || dashboardTab === 4) && mariaDbVersions.filter(v => v.status === 'installed').map(v => (
<Grid item xs={12} sm={6} md={4} key={v.id}>
<Paper elevation={4} sx={{
p: 3, borderRadius: 3,
bgcolor: 'rgba(255, 255, 255, 0.03)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.05)',
transition: 'transform 0.2s',
'&:hover': { transform: 'scale(1.02)' }
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<DbIcon color="primary" sx={{ fontSize: 40 }} />
{getStatusChip(status[`mariadb:${v.version}`] || { status: 'stopped' })}
</Box>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>MariaDB {v.version}</Typography>
<Typography variant="body2" color="text.secondary">Port: {settings.mariaDbPorts?.[v.id] || v.port}</Typography>
<ResourceMonitor cpu={status[`mariadb:${v.version}`]?.cpu} memory={status[`mariadb:${v.version}`]?.memory} />
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton onClick={() => handleOpenLogs(`mariadb:${v.version}`)} title={t('diagnostics.logs')} size="small" sx={{ bgcolor: 'rgba(255,255,255,0.05)' }}>
<TerminalIcon fontSize="small" />
</IconButton>
<IconButton onClick={() => handleOpenProcessList(v.id)} title={t('mariadb_process_monitor.title')} size="small" sx={{ bgcolor: 'rgba(255,255,255,0.05)', '&:hover': { color: 'primary.main' } }}>
<ToolsIcon fontSize="small" />
</IconButton>
<IconButton onClick={() => handleResetData(`mariadb:${v.version}`)} title={t('common.reset')} size="small" sx={{ bgcolor: 'rgba(255,255,255,0.05)', '&:hover': { color: 'error.main' } }}>
<ResetIcon fontSize="small" />
</IconButton>
</Box>
<Switch
checked={status[`mariadb:${v.version}`]?.status === 'running' || status[`mariadb:${v.version}`]?.status === 'starting'}
onChange={() => handleToggleService(`mariadb:${v.version}`)}
disabled={status[`mariadb:${v.version}`]?.status === 'starting'}
/>
</Box>
</Paper>
</Grid>
))}
</Grid>
</>
)} )}
{activeTab === 'db_monitor' && ( {activeTab === 'db_monitor' && (
@@ -0,0 +1,462 @@
import {
Box,
Typography,
Tabs,
Tab,
Paper,
Grid,
IconButton,
Button,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
Divider,
LinearProgress,
CircularProgress,
Stack
} from '@mui/material'
import {
Refresh as RefreshIcon,
PlayArrow as StartIcon,
Stop as StopIcon,
OpenInNew as OpenIcon,
Save as SaveIcon,
Folder as ProjectIcon,
Language as WebIcon,
Dns as ServerIcon,
Code as PhpIcon,
Storage as DbIcon
} from '@mui/icons-material'
import { useState, useEffect, useRef } from 'react'
import 'prismjs/themes/prism-tomorrow.css'
import 'prismjs/components/prism-nginx'
import 'prismjs/components/prism-apacheconf'
import 'prismjs/components/prism-ini'
import Editor from 'react-simple-code-editor'
import Prism from 'prismjs'
import Swal from 'sweetalert2'
interface DashboardDetailsProps {
serviceId: string | null;
status: any;
settings: any;
projects: any[];
t: any;
onToggleService: (id: string) => void;
onReloadService: (id: string) => void;
onSelectProject: (id: string) => void;
}
export default function DashboardDetails({
serviceId,
status,
settings,
projects,
t,
onToggleService,
onReloadService,
onSelectProject
}: DashboardDetailsProps) {
const [activeTab, setActiveTab] = useState(0);
const [configContent, setConfigContent] = useState('');
const [projectConfigContent, setProjectConfigContent] = useState('');
const [isLoadingConfig, setIsLoadingConfig] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const logEndRef = useRef<HTMLDivElement>(null);
const getServiceType = () => {
if (!serviceId) return null;
if (serviceId === 'nginx') return 'nginx';
if (serviceId === 'apache') return 'apache';
if (serviceId.startsWith('php:')) return 'php';
if (serviceId.startsWith('mariadb:')) return 'mariadb';
return null;
};
const serviceType = getServiceType();
const getTemplateName = (): string => {
if (serviceId === 'nginx') return 'nginx.conf.template';
if (serviceType === 'apache') return 'httpd.conf.template';
if (serviceType === 'php') return 'php.ini.template';
if (serviceType === 'mariadb') return 'mariadb.ini.template';
return '';
};
const getProjectTemplateNames = () => {
if (serviceId === 'nginx' || serviceType === 'php') return ['nginx_project_location.conf.template'];
if (serviceId === 'apache') return ['apache_project.conf.template'];
return [];
};
const fetchConfig = async (templateName: string, setter: (v: string) => void) => {
if (!templateName) return;
setIsLoadingConfig(true);
try {
const res = await window.api.invoke('config:template:get', templateName);
if (res && res.success) {
setter(res.content || '');
} else {
console.error('Failed to fetch config:', res?.message);
setter('');
}
} catch (error) {
console.error('Error fetching config:', error);
setter('');
} finally {
setIsLoadingConfig(false);
}
};
const saveConfig = async (templateName: string, content: string) => {
const res = await window.api.invoke('config:template:save', { templateName, content });
if (res.success) {
Swal.fire({
title: t('common.success'),
text: res.message,
icon: 'success',
background: '#1e1e1e',
color: '#fff'
});
} else {
Swal.fire({
title: t('common.error'),
text: res.message,
icon: 'error',
background: '#1e1e1e',
color: '#fff'
});
}
};
const fetchLogs = async () => {
if (!serviceId) return;
const res = await window.api.invoke('services:logs', serviceId);
setLogs(res || []);
};
const getHighlightLanguage = (templateName: string) => {
if (!templateName) return Prism.languages.clike;
if (templateName.includes('nginx')) return Prism.languages.nginx;
if (templateName.includes('httpd') || templateName.includes('apache')) return Prism.languages.apacheconf;
if (templateName.includes('.ini') || templateName.includes('.conf')) return Prism.languages.ini;
return Prism.languages.clike;
};
const getHighlightLabel = (templateName: string) => {
if (!templateName) return 'clike';
if (templateName.includes('nginx')) return 'nginx';
if (templateName.includes('httpd') || templateName.includes('apache')) return 'apacheconf';
if (templateName.includes('.ini') || templateName.includes('.conf')) return 'ini';
return 'clike';
};
useEffect(() => {
if (activeTab === 1) fetchConfig(getTemplateName(), setConfigContent);
if (activeTab === 2) {
const templates = getProjectTemplateNames();
if (templates.length > 0) fetchConfig(templates[0], setProjectConfigContent);
}
if (activeTab === 3) {
fetchLogs();
const interval = setInterval(fetchLogs, 3000);
return () => clearInterval(interval);
}
return undefined;
}, [activeTab, serviceId]);
useEffect(() => {
if (activeTab === 3 && logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [logs, activeTab]);
if (!serviceId) {
return (
<Box sx={{ flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', opacity: 0.5 }}>
<Typography variant="h6">{t('dashboard.select_service', 'Lütfen bir servis seçin')}</Typography>
</Box>
);
}
const currentStatus = status[serviceId] || { status: 'stopped' };
const isRunning = currentStatus.status === 'running';
const serviceProjects = projects.filter(p => {
if (serviceId === 'nginx') return !p.serverType || p.serverType === 'nginx';
if (serviceId === 'apache') return p.serverType === 'apache';
if (serviceType === 'php') return p.phpVersion === serviceId.split(':')[1];
if (serviceType === 'mariadb') return true; // MariaDB is shared (or filter if multi-instance)
return false;
});
const ResourceMonitor = ({ cpu, memory }: { cpu?: number; memory?: number }) => (
<Box sx={{ mt: 2 }}>
<Box sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontWeight: 'bold' }}>CPU</Typography>
<Typography variant="caption" sx={{ color: 'primary.light', fontWeight: 'bold' }}>{cpu || 0}%</Typography>
</Box>
<LinearProgress variant="determinate" value={cpu || 0} sx={{ height: 6, borderRadius: 3, bgcolor: 'rgba(255,255,255,0.05)' }} />
</Box>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontWeight: 'bold' }}>RAM</Typography>
<Typography variant="caption" sx={{ color: 'secondary.light', fontWeight: 'bold' }}>
{memory ? `${Math.round(memory / (1024 * 1024))} MB` : '0 MB'}
</Typography>
</Box>
<LinearProgress variant="determinate" value={Math.min(((memory || 0) / (1024 * 1024) / 1024) * 100, 100)} sx={{ height: 6, borderRadius: 3, bgcolor: 'rgba(255,255,255,0.05)', '.MuiLinearProgress-bar': { bgcolor: 'secondary.main' } }} />
</Box>
</Box>
);
return (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ p: 3, borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: 2 }}>
<Paper sx={{ p: 1.5, bgcolor: 'rgba(255, 255, 255, 0.05)', borderRadius: 3 }}>
{serviceType === 'nginx' && <WebIcon color="primary" fontSize="large" />}
{serviceType === 'apache' && <ServerIcon color="primary" fontSize="large" />}
{serviceType === 'php' && <PhpIcon color="primary" fontSize="large" />}
{serviceType === 'mariadb' && <DbIcon color="primary" fontSize="large" />}
</Paper>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h5" sx={{ fontWeight: 800 }}>{serviceId.toUpperCase()}</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.4)' }}>
{isRunning ? t('common.running', 'Çalışıyor') : t('common.stopped', 'Durduruldu')}
</Typography>
</Box>
<Stack direction="row" spacing={1}>
{isRunning && (
<IconButton onClick={() => onReloadService(serviceId)} sx={{ bgcolor: 'rgba(57, 167, 255, 0.1)', color: 'primary.main' }}>
<RefreshIcon />
</IconButton>
)}
<Button
variant="contained"
color={isRunning ? 'error' : 'success'}
startIcon={isRunning ? <StopIcon /> : <StartIcon />}
onClick={() => onToggleService(serviceId)}
sx={{ borderRadius: 2 }}
>
{isRunning ? t('common.stop', 'Durdur') : t('common.start', 'Başlat')}
</Button>
</Stack>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'rgba(255,255,255,0.05)', px: 3 }}>
<Tabs value={activeTab} onChange={(_e, v) => setActiveTab(v)}>
<Tab label={t('common.overview', 'Genel Bakış')} />
<Tab label={t('dashboard.service_settings', 'Servis Ayarları')} />
{(serviceId === 'nginx' || serviceId === 'apache' || serviceType === 'php') && (
<Tab label={t('dashboard.project_settings', 'Proje Ayarları')} />
)}
<Tab label={t('diagnostics.logs', 'Hata Logları')} />
<Tab label={t('projects.title', 'Projeler')} />
</Tabs>
</Box>
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 3 }}>
{activeTab === 0 && (
<Grid container spacing={4}>
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 2, color: 'primary.main' }}>SYSTEM STATS</Typography>
<Paper sx={{ p: 3, bgcolor: 'rgba(0,0,0,0.2)', borderRadius: 3, border: '1px solid rgba(255,255,255,0.05)' }}>
<ResourceMonitor cpu={currentStatus.cpu} memory={currentStatus.memory} />
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 2, color: 'primary.main' }}>SERVICE INFO</Typography>
<Paper sx={{ p: 3, bgcolor: 'rgba(0,0,0,0.2)', borderRadius: 3, border: '1px solid rgba(255,255,255,0.05)' }}>
<Stack spacing={2}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" sx={{ opacity: 0.5 }}>Port</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{currentStatus.port || (() => {
if (serviceId === 'nginx') return settings.nginxPort;
if (serviceId === 'apache') return settings.apachePort;
if (serviceType === 'php') return settings.phpPort;
if (serviceType === 'mariadb') {
const verId = serviceId.split(':')[1];
return (settings.mariaDbPorts && settings.mariaDbPorts[verId]) || settings.mariaDbPort;
}
return 'n/a';
})()}
</Typography>
</Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.05)' }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" sx={{ opacity: 0.5 }}>Status</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, color: isRunning ? 'success.main' : 'error.main' }}>
{currentStatus.status.toUpperCase()}
</Typography>
</Box>
</Stack>
</Paper>
</Grid>
</Grid>
)}
{activeTab === 1 && (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="overline" sx={{ fontWeight: 800, color: 'primary.main', letterSpacing: 2 }}>
{getTemplateName().toUpperCase()}
</Typography>
<Button
variant="contained"
size="small"
startIcon={<SaveIcon />}
onClick={() => saveConfig(getTemplateName(), configContent)}
sx={{ borderRadius: 2 }}
>
{t('common.save', 'KAYDET')}
</Button>
</Box>
<Paper sx={{
flexGrow: 1,
bgcolor: '#1e1e1e',
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.05)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
{isLoadingConfig ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexGrow: 1 }}><CircularProgress /></Box>
) : (
<Box sx={{ flexGrow: 1, overflow: 'auto' }}>
<Editor
value={configContent}
onValueChange={setConfigContent}
highlight={code => Prism.highlight(code, getHighlightLanguage(getTemplateName()), getHighlightLabel(getTemplateName()))}
padding={20}
style={{
fontFamily: '"Fira Code", "Consolas", monospace',
fontSize: '14px',
minHeight: '100%',
outline: 'none'
}}
textareaClassName="editor-textarea"
/>
</Box>
)}
</Paper>
</Box>
)}
{activeTab === 2 && (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="overline" sx={{ fontWeight: 800, color: 'primary.main', letterSpacing: 2 }}>
{getProjectTemplateNames()[0]?.toUpperCase()}
</Typography>
<Button
variant="contained"
size="small"
startIcon={<SaveIcon />}
onClick={() => saveConfig(getProjectTemplateNames()[0], projectConfigContent)}
sx={{ borderRadius: 2 }}
>
{t('common.save', 'KAYDET')}
</Button>
</Box>
<Paper sx={{
flexGrow: 1,
bgcolor: '#1e1e1e',
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.05)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
{isLoadingConfig ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexGrow: 1 }}><CircularProgress /></Box>
) : (
<Box sx={{ flexGrow: 1, overflow: 'auto' }}>
<Editor
value={projectConfigContent}
onValueChange={setProjectConfigContent}
highlight={code => Prism.highlight(code, getHighlightLanguage(getProjectTemplateNames()[0]), getHighlightLabel(getProjectTemplateNames()[0]))}
padding={20}
style={{
fontFamily: '"Fira Code", "Consolas", monospace',
fontSize: '14px',
minHeight: '100%',
outline: 'none'
}}
textareaClassName="editor-textarea"
/>
</Box>
)}
</Paper>
</Box>
)}
{activeTab === 3 && (
<Box sx={{ bgcolor: '#000', borderRadius: 2, p: 2, height: '60vh', overflowY: 'auto' }}>
{logs.map((log, i) => (
<Typography key={i} sx={{
fontFamily: 'monospace',
fontSize: '0.8rem',
color: log.toLowerCase().includes('error') ? '#f44336' : (log.toLowerCase().includes('warn') ? '#ff9800' : '#33d9b2'),
mb: 0.5
}}>
{log}
</Typography>
))}
<div ref={logEndRef} />
</Box>
)}
{activeTab === 4 && (
<Paper sx={{ bgcolor: 'rgba(0,0,0,0.2)', borderRadius: 3, border: '1px solid rgba(255,255,255,0.05)', overflow: 'hidden' }}>
<List disablePadding>
{serviceProjects.length === 0 ? (
<Box sx={{ p: 4, textAlign: 'center', opacity: 0.3 }}><Typography>{t('projects.empty')}</Typography></Box>
) : (
serviceProjects.map((p, idx) => (
<Box key={p.id}>
<ListItem>
<Paper sx={{ p: 1, mr: 2, bgcolor: 'rgba(255,255,255,0.03)' }}><ProjectIcon sx={{ opacity: 0.5 }} /></Paper>
<ListItemText
primary={p.name}
secondary={`${p.host} • PHP ${p.phpVersion}`}
primaryTypographyProps={{ fontWeight: 700 }}
/>
<ListItemSecondaryAction>
<IconButton onClick={() => onSelectProject(p.id)} color="primary">
<OpenIcon fontSize="small" />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
{idx < serviceProjects.length - 1 && <Divider sx={{ borderColor: 'rgba(255,255,255,0.05)' }} />}
</Box>
))
)}
</List>
</Paper>
)}
</Box>
<style>{`
.editor-textarea {
outline: none !important;
}
.token.comment { color: #6a9955; }
.token.directive { color: #569cd6; font-weight: bold; }
.token.variable { color: #9cdcfe; }
.token.string { color: #ce9178; }
.token.number { color: #b5cea8; }
.token.boolean { color: #569cd6; }
.token.operator { color: #d4d4d4; }
.token.punctuation { color: #d4d4d4; }
.token.keyword { color: #569cd6; }
.token.function { color: #dcdcaa; }
.token.class-name { color: #4ec9b0; }
.token.attribute-name { color: #9cdcfe; }
.token.attribute-value { color: #ce9178; }
`}</style>
</Box>
);
}
@@ -0,0 +1,89 @@
import { Box } from '@mui/material'
import DashboardSidebar from './DashboardSidebar'
import DashboardDetails from './DashboardDetails'
import { useState } from 'react'
import Swal from 'sweetalert2'
interface DashboardManagerProps {
status: any;
settings: any;
projects: any[];
phpVersions: any[];
mariaDbVersions: any[];
t: any;
onFetchStatus: () => void;
onSelectProject: (projectId: string) => void;
}
export default function DashboardManager({
status,
settings,
projects,
phpVersions,
mariaDbVersions,
t,
onFetchStatus,
onSelectProject
}: DashboardManagerProps) {
const [selectedServiceId, setSelectedServiceId] = useState<string | null>('nginx');
const handleToggleService = async (service: string) => {
if (!window.api) return;
const currentStatus = status[service]?.status || 'stopped';
const isRunning = currentStatus === 'running';
const result = isRunning
? await window.api.stopService(service)
: await window.api.startService(service);
if (result && !result.success) {
Swal.fire({
title: t('common.error'),
text: result.message,
icon: 'error',
background: '#1e1e1e',
color: '#fff'
});
}
onFetchStatus();
};
const handleReloadService = async (service: string) => {
if (!window.api) return;
const result = await window.api.invoke('services:reload', service);
Swal.fire({
title: result && result.success ? t('common.success') : t('common.error'),
text: result ? result.message : 'Error',
icon: result && result.success ? 'success' : 'error',
background: '#1e1e1e',
color: '#fff'
});
onFetchStatus();
};
return (
<Box sx={{ display: 'flex', height: 'calc(100vh - 120px)', bgcolor: 'background.default', overflow: 'hidden', borderRadius: 4, border: '1px solid rgba(255, 255, 255, 0.05)', boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}>
<DashboardSidebar
status={status}
phpVersions={phpVersions}
mariaDbVersions={mariaDbVersions}
selectedServiceId={selectedServiceId}
setSelectedServiceId={setSelectedServiceId}
t={t}
/>
<DashboardDetails
serviceId={selectedServiceId}
status={status}
settings={settings}
projects={projects}
t={t}
onToggleService={handleToggleService}
onReloadService={handleReloadService}
onSelectProject={onSelectProject}
/>
</Box>
);
}
@@ -0,0 +1,140 @@
import {
Box,
Typography,
List,
ListItemButton,
Chip,
CircularProgress
} from '@mui/material'
import {
Language as WebIcon,
Dns as ServerIcon,
Code as PhpIcon,
Storage as DbIcon
} from '@mui/icons-material'
interface DashboardSidebarProps {
status: any;
phpVersions: any[];
mariaDbVersions: any[];
selectedServiceId: string | null;
setSelectedServiceId: (v: string | null) => void;
t: any;
}
export default function DashboardSidebar({
status,
phpVersions,
mariaDbVersions,
selectedServiceId,
setSelectedServiceId,
t
}: DashboardSidebarProps) {
const installedPhp = phpVersions.filter(v => v.status === 'installed');
const installedMariaDb = mariaDbVersions.filter(v => v.status === 'installed');
const getStatusInfo = (service: string) => {
const state = typeof status[service] === 'string' ? status[service] : status[service]?.status || 'stopped';
const color = state === 'running' ? 'success' : state === 'stopped' ? 'error' : 'warning';
return { state, color };
};
const ServiceItem = ({ id, label, icon: Icon, serviceKey }: { id: string, label: string, icon: any, serviceKey: string }) => {
const { state } = getStatusInfo(serviceKey);
const isRunning = state === 'running';
const isStarting = state === 'starting' || state === 'stopping';
return (
<ListItemButton
selected={selectedServiceId === id}
onClick={() => setSelectedServiceId(id)}
sx={{
borderRadius: 2,
mb: 0.5,
border: selectedServiceId === 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', alignItems: 'center', width: '100%', gap: 1.5 }}>
<Icon sx={{ color: selectedServiceId === id ? 'primary.main' : 'rgba(255,255,255,0.4)', fontSize: '1.2rem' }} />
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography variant="body2" noWrap sx={{ fontWeight: 600 }}>
{label}
</Typography>
</Box>
<Box sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: isStarting ? 'warning.main' : (isRunning ? 'success.main' : 'error.main'),
boxShadow: isRunning ? '0 0 8px rgba(76, 175, 80, 0.5)' : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
{isStarting && <CircularProgress size={8} thickness={6} color="inherit" />}
</Box>
</Box>
</ListItemButton>
);
};
return (
<Box sx={{
width: 280,
borderRight: '1px solid rgba(255, 255, 255, 0.05)',
display: 'flex',
flexDirection: 'column',
bgcolor: 'rgba(255, 255, 255, 0.01)',
height: '100%',
overflowY: 'auto'
}}>
<Box sx={{ p: 2, borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}>
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1.1rem' }}>
{t('dashboard.services', 'Servisler')}
</Typography>
</Box>
<Box sx={{ p: 1 }}>
<Typography variant="overline" sx={{ px: 1.5, color: 'rgba(255,255,255,0.3)', fontWeight: 800, letterSpacing: 1 }}>
WEB SERVERS
</Typography>
<List dense disablePadding sx={{ mb: 2 }}>
<ServiceItem id="nginx" label="Nginx" icon={WebIcon} serviceKey="nginx" />
<ServiceItem id="apache" label="Apache" icon={ServerIcon} serviceKey="apache" />
</List>
<Typography variant="overline" sx={{ px: 1.5, color: 'rgba(255,255,255,0.3)', fontWeight: 800, letterSpacing: 1 }}>
PHP VERSIONS
</Typography>
<List dense disablePadding sx={{ mb: 2 }}>
{installedPhp.map(v => (
<ServiceItem
key={v.id}
id={`php:${v.version}`}
label={`PHP ${v.version}`}
icon={PhpIcon}
serviceKey={`php:${v.version}`}
/>
))}
</List>
<Typography variant="overline" sx={{ px: 1.5, color: 'rgba(255,255,255,0.3)', fontWeight: 800, letterSpacing: 1 }}>
MARIADB VERSIONS
</Typography>
<List dense disablePadding>
{installedMariaDb.map(v => (
<ServiceItem
key={v.id}
id={`mariadb:${v.version}`}
label={`MariaDB ${v.version}`}
icon={DbIcon}
serviceKey={`mariadb:${v.version}`}
/>
))}
</List>
</Box>
</Box>
);
}