feat: implement service process management, event logging, and monitoring UI with i18n support

This commit is contained in:
Ümit Tunç
2026-04-02 14:55:21 +03:00
parent b28c088558
commit bf24818b83
10 changed files with 1248 additions and 27 deletions
+10 -24
View File
@@ -14,6 +14,7 @@ import path from 'path'
import { mariaDbOperationService } from '../services/MariaDbOperationService'
import { cliAliasService } from '../services/CliAliasService'
import { cliBinService } from '../services/CliBinService'
import { serviceEventLogger } from '../services/ServiceEventLogger'
function findExecutable(dir: string, name: string): string | null {
if (!fs.existsSync(dir)) return null
@@ -311,6 +312,15 @@ export function registerIpcHandlers(): void {
return { success: false, message: 'Bu servis için reload desteklenmiyor.' }
})
ipcMain.handle('services:get-events', async () => {
return serviceEventLogger.getEvents()
})
ipcMain.handle('services:clear-events', async () => {
serviceEventLogger.clear()
return { success: true }
})
ipcMain.handle('services:start', async (_event, serviceName: string) => {
try {
const settings = configService.getSettings()
@@ -576,30 +586,6 @@ export function registerIpcHandlers(): void {
return result
})
ipcMain.handle('php:config:get', async (_event, version: string) => {
const phpDir = path.join(configService.getBasePath(), 'bin', `php-${version}`)
const iniPath = path.join(phpDir, 'php.ini')
if (fs.existsSync(iniPath)) {
return {
content: fs.readFileSync(iniPath, 'utf8'),
path: iniPath
}
}
return { content: '', path: iniPath }
})
ipcMain.handle('php:config:save', async (_event, { version, content }) => {
const phpDir = path.join(configService.getBasePath(), 'bin', `php-${version}`)
const iniPath = path.join(phpDir, 'php.ini')
fs.writeFileSync(iniPath, content)
const serviceName = `php:${version}`
if (processManager.isServiceRunning(serviceName)) {
await processManager.stopService(serviceName)
}
return { success: true, message: 'Configuration saved.' }
})
// MariaDB Versions
ipcMain.handle('mariadb:versions', async () => {
+7
View File
@@ -4,6 +4,7 @@ import { app } from 'electron'
import fs from 'fs'
import pidusage from 'pidusage'
import { configService } from './ConfigService'
import { serviceEventLogger } from './ServiceEventLogger'
import { t } from '../utils/i18n'
export class ProcessManager {
@@ -130,17 +131,21 @@ export class ProcessManager {
console.log(`[PROCESS] Started ${name} with PID ${child.pid}`)
this.addLog(name, `${t('common.running')} (PID: ${child.pid})`)
serviceEventLogger.log({ service: name, action: 'start', status: 'success' })
child.stdout?.on('data', (data) => this.addLog(name, data.toString()))
child.stderr?.on('data', (data) => this.addLog(name, data.toString()))
child.on('error', (err) => {
this.addLog(name, `Failed to start: ${err.message}`)
serviceEventLogger.log({ service: name, action: 'start', status: 'failure', details: err.message })
this.processes.delete(name)
})
child.on('exit', (code) => {
const status = code === 0 ? 'success' : 'failure'
this.addLog(name, `${t('common.stopped')} (Code: ${code})`)
serviceEventLogger.log({ service: name, action: 'exit', status, details: `Exit code: ${code}` })
if (code !== 0) {
this.checkErrorLogs(name)
}
@@ -152,6 +157,7 @@ export class ProcessManager {
return true
} catch (error: any) {
this.addLog(name, `Spawn Error: ${error.message}`)
serviceEventLogger.log({ service: name, action: 'start', status: 'failure', details: error.message })
return false
}
}
@@ -160,6 +166,7 @@ export class ProcessManager {
const child = this.processes.get(name)
if (child) {
this.addLog(name, `${t('common.stopping')}...`)
serviceEventLogger.log({ service: name, action: 'stop', status: 'info' })
try {
if (process.platform === 'win32') {
child.kill()
+68
View File
@@ -0,0 +1,68 @@
import fs from 'fs'
import path from 'path'
import { configService } from './ConfigService'
export interface ServiceEvent {
id: string
service: string
action: 'start' | 'stop' | 'exit' | 'error' | 'restart'
timestamp: number
details?: string
status: 'success' | 'failure' | 'info'
}
export class ServiceEventLogger {
private filePath: string
private events: ServiceEvent[] = []
private maxEvents = 1000
constructor() {
this.filePath = path.join(configService.getBasePath(), 'service_events.json')
this.load()
}
private load() {
try {
if (fs.existsSync(this.filePath)) {
const content = fs.readFileSync(this.filePath, 'utf8')
this.events = JSON.parse(content)
}
} catch (e) {
console.error('Failed to load service events:', e)
this.events = []
}
}
private save() {
try {
// Keep only the last N events
if (this.events.length > this.maxEvents) {
this.events = this.events.slice(-this.maxEvents)
}
fs.writeFileSync(this.filePath, JSON.stringify(this.events, null, 2))
} catch (e) {
console.error('Failed to save service events:', e)
}
}
log(event: Omit<ServiceEvent, 'id' | 'timestamp'>) {
const newEvent: ServiceEvent = {
...event,
id: Math.random().toString(36).substr(2, 9),
timestamp: Date.now()
}
this.events.unshift(newEvent) // Add to top
this.save()
}
getEvents() {
return this.events
}
clear() {
this.events = []
this.save()
}
}
export const serviceEventLogger = new ServiceEventLogger()
@@ -0,0 +1,261 @@
import { useState, useEffect, useMemo } from 'react'
import {
Box,
Typography,
IconButton,
Tooltip,
Stack,
Button,
Paper,
Divider,
Alert,
CircularProgress,
Chip
} from '@mui/material'
import {
Refresh as RefreshIcon,
DeleteSweep as ClearIcon,
Close as CloseIcon,
History as HistoryIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Info as InfoIcon,
PlayArrow as StartIcon,
Stop as StopIcon
} from '@mui/icons-material'
import { DataGrid, GridColDef } from '@mui/x-data-grid'
import { BarChart } from '@mui/x-charts'
import { useTranslation } from 'react-i18next'
import Swal from 'sweetalert2'
interface ServiceEvent {
id: string
service: string
action: 'start' | 'stop' | 'exit' | 'error' | 'restart'
timestamp: number
details?: string
status: 'success' | 'failure' | 'info'
}
export default function ServiceEventMonitor({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation()
const [events, setEvents] = useState<ServiceEvent[]>([])
const [loading, setLoading] = useState(true)
const fetchEvents = async () => {
if (!window.api) return
setLoading(true)
try {
const data = await window.api.invoke('services:get-events')
setEvents(data)
} catch (error) {
console.error('Failed to fetch events:', error)
}
setLoading(false)
}
useEffect(() => {
fetchEvents()
const interval = setInterval(fetchEvents, 5000)
return () => clearInterval(interval)
}, [])
const handleClear = async () => {
const result = await Swal.fire({
title: t('service_monitor.clear_confirm'),
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
confirmButtonText: t('common.yes_delete'),
cancelButtonText: t('common.cancel'),
background: '#1a1d21',
color: '#fff'
})
if (result.isConfirmed && window.api) {
await window.api.invoke('services:clear-events')
fetchEvents()
}
}
// Prepare chart data
const chartData = useMemo(() => {
const services = Array.from(new Set(events.map(e => e.service)))
const data = services.map(s => {
const serviceEvents = events.filter(e => e.service === s)
return {
service: s,
start: serviceEvents.filter(e => e.action === 'start' && e.status === 'success').length,
stop: serviceEvents.filter(e => e.action === 'stop' || e.action === 'exit').length,
error: serviceEvents.filter(e => e.status === 'failure' || e.action === 'error').length
}
})
return data
}, [events])
const columns: GridColDef[] = [
{
field: 'timestamp',
headerName: t('service_monitor.columns.timestamp'),
width: 130,
valueGetter: (params) => new Date(params).toLocaleTimeString()
},
{
field: 'service',
headerName: t('service_monitor.columns.service'),
width: 120,
renderCell: (params) => (
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{params.value}
</Typography>
)
},
{
field: 'action',
headerName: t('service_monitor.columns.action'),
width: 100,
renderCell: (params) => {
const action = params.value as string
const color = action === 'start' ? 'success' : action === 'stop' || action === 'exit' ? 'error' : 'info'
const Icon = action === 'start' ? StartIcon : action === 'stop' || action === 'exit' ? StopIcon : InfoIcon
return (
<Chip
size="small"
icon={<Icon sx={{ fontSize: '14px !important' }} />}
label={t(`common.${action}`)}
color={color as any}
variant="outlined"
sx={{ fontSize: 10, height: 20 }}
/>
)
}
},
{
field: 'status',
headerName: t('service_monitor.columns.status'),
width: 90,
renderCell: (params) => {
const status = params.value as string
return status === 'success' ? (
<SuccessIcon sx={{ color: 'success.main', fontSize: 18 }} />
) : status === 'failure' ? (
<ErrorIcon sx={{ color: 'error.main', fontSize: 18 }} />
) : (
<InfoIcon sx={{ color: 'info.main', fontSize: 18 }} />
)
}
},
{
field: 'details',
headerName: t('service_monitor.columns.details'),
flex: 1,
renderCell: (params) => (
<Tooltip title={params.value || ''}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', cursor: 'default' }}>
{params.value || '-'}
</Typography>
</Tooltip>
)
}
]
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: '#111418', color: '#fff' }}>
{/* Header */}
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<HistoryIcon color="primary" />
<Box>
<Typography variant="h6" sx={{ fontWeight: 800, lineHeight: 1 }}>
{t('service_monitor.title')}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)' }}>
{t('service_monitor.desc')}
</Typography>
</Box>
</Box>
<Stack direction="row" spacing={1}>
<IconButton onClick={fetchEvents} size="small" sx={{ color: 'rgba(255,255,255,0.5)' }}>
<RefreshIcon fontSize="small" />
</IconButton>
<IconButton onClick={handleClear} size="small" sx={{ color: '#f44336' }}>
<ClearIcon fontSize="small" />
</IconButton>
{onClose && (
<IconButton onClick={onClose} size="small" sx={{ color: 'rgba(255,255,255,0.5)' }}>
<CloseIcon fontSize="small" />
</IconButton>
)}
</Stack>
</Box>
{/* Content */}
<Box sx={{ flexGrow: 1, overflow: 'auto', p: 2 }}>
{loading && events.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 5 }}>
<CircularProgress size={30} />
</Box>
) : events.length === 0 ? (
<Alert severity="info" sx={{ bgcolor: 'rgba(2, 136, 209, 0.05)', color: 'info.light' }}>
{t('service_monitor.no_events')}
</Alert>
) : (
<Stack spacing={3}>
{/* Chart Area */}
<Paper sx={{ p: 2, bgcolor: 'rgba(255,255,255,0.02)', borderRadius: 3, border: '1px solid rgba(255,255,255,0.05)' }}>
<Typography variant="subtitle2" sx={{ mb: 2, px: 1, fontWeight: 'bold', opacity: 0.6 }}>
{t('service_monitor.chart_title')}
</Typography>
<Box sx={{ height: 200, width: '100%' }}>
<BarChart
dataset={chartData}
xAxis={[{ scaleType: 'band', dataKey: 'service' }]}
series={[
{ dataKey: 'start', label: t('common.start'), color: '#4caf50' },
{ dataKey: 'stop', label: t('common.stop'), color: '#f44336' },
{ dataKey: 'error', label: t('common.error'), color: '#ff9800' }
]}
slotProps={{
legend: {
labelStyle: { fill: '#fff', fontSize: 10 },
direction: 'row',
position: { vertical: 'bottom', horizontal: 'middle' },
padding: -10,
}
}}
margin={{ top: 10, bottom: 50, left: 30, right: 10 }}
sx={{
'& .MuiChartsAxis-left .MuiChartsAxis-tickLabel': { fill: 'rgba(255,255,255,0.3)' },
'& .MuiChartsAxis-bottom .MuiChartsAxis-tickLabel': { fill: 'rgba(255,255,255,0.3)', fontSize: 10 },
'& .MuiChartsAxis-line': { stroke: 'rgba(255,255,255,0.1)' },
'& .MuiChartsAxis-tick': { stroke: 'rgba(255,255,255,0.1)' }
}}
/>
</Box>
</Paper>
{/* Logs Area */}
<Box sx={{ height: 400, width: '100%' }}>
<DataGrid
rows={events}
columns={columns}
density="compact"
disableRowSelectionOnClick
hideFooter
sx={{
border: 'none',
color: '#fff',
'& .MuiDataGrid-cell': { borderBottom: '1px solid rgba(255,255,255,0.03)', fontSize: 12 },
'& .MuiDataGrid-columnHeaders': { bgcolor: 'rgba(255,255,255,0.03)', borderBottom: '1px solid rgba(255,255,255,0.1)' },
'& .MuiDataGrid-columnHeaderTitle': { fontWeight: 'bold', fontSize: 11, opacity: 0.5 },
'& .MuiDataGrid-virtualScroller': { bgcolor: 'transparent' },
'& .MuiDataGrid-footerContainer': { borderTop: '1px solid rgba(255,255,255,0.1)' }
}}
/>
</Box>
</Stack>
)}
</Box>
</Box>
)
}
+18 -1
View File
@@ -47,7 +47,9 @@
"stopping": "Stopping",
"reset_confirm_title": "Are you sure?",
"reset_confirm_text": "Do you want to reset {{service}} data? This operation deletes all data and performs a clean installation.",
"reset_btn": "Yes, Reset"
"reset_btn": "Yes, Reset",
"operating": "Operation in progress...",
"please_wait": "Please wait..."
},
"dashboard": {
"title": "Dashboard",
@@ -305,5 +307,20 @@
"max_allowed_packet": "Max Allowed Packet",
"max_allowed_packet_desc": "Maximum size of one packet or any generated/intermediate string."
}
},
"service_monitor": {
"title": "Service Monitor",
"desc": "Track the start, stop, and error history of services.",
"event_log": "Event Log",
"clear_confirm": "Are you sure you want to clear the entire event history?",
"chart_title": "Service Activity Chart",
"no_events": "No events recorded yet.",
"columns": {
"service": "Service",
"action": "Action",
"status": "Status",
"timestamp": "Time",
"details": "Details"
}
}
}
+19 -2
View File
@@ -47,7 +47,9 @@
"stop_status": "Durduruluyor",
"reset_confirm_title": "Emin misiniz?",
"reset_confirm_text": "{{service}} verilerini sıfırlamak istiyor musunuz? Bu işlem tüm verileri siler ve temiz kurulum yapar.",
"reset_btn": "Evet, Sıfırla"
"reset_btn": "Evet, Sıfırla",
"operating": "İşlem devam ediyor...",
"please_wait": "Lütfen bekleyin..."
},
"dashboard": {
"title": "Özet",
@@ -298,7 +300,22 @@
"key_buffer_size": "Anahtar Tampon Boyutu",
"key_buffer_size_desc": "MyISAM tabloları için indeks blokları (tüm iş parçacıkları tarafından paylaşılır).",
"max_allowed_packet": "Maksimum Paket Boyutu",
"max_allowed_packet_desc": "Bir paketin veya oluşturulan herhangi bir dizenin maksimum boyutu."
"max_allowed_packet_desc": "Tek bir SQL sorgusu veya sonucunun maksimum boyutu."
}
},
"service_monitor": {
"title": "Servis İzleyici",
"desc": "Servislerin başlama, durma ve hata geçmişini takip edin.",
"event_log": "Olay Günlüğü",
"clear_confirm": "Tüm olay geçmişini silmek istediğinize emin misiniz?",
"chart_title": "Servis Aktivite Grafiği",
"no_events": "Henüz bir olay kaydedilmemiş.",
"columns": {
"service": "Servis",
"action": "Eylem",
"status": "Durum",
"timestamp": "Zaman",
"details": "Detay"
}
}
}