feat: implement service process management, event logging, and monitoring UI with i18n support
This commit is contained in:
+10
-24
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user