feat: implement MySQL operation service and add export wizard UI with localization support

This commit is contained in:
Ümit Tunç
2026-03-30 08:40:57 +03:00
parent c9eea9319a
commit 91327b245c
5 changed files with 465 additions and 5 deletions
+54 -1
View File
@@ -1,4 +1,4 @@
import { ipcMain, app, dialog } from 'electron'
import { ipcMain, app, dialog, shell } from 'electron'
import fs from 'fs'
import { processManager } from '../services/ProcessManager'
import { configService } from '../services/ConfigService'
@@ -393,6 +393,21 @@ export function registerIpcHandlers(): void {
return result.filePaths[0]
})
ipcMain.handle('dialog:save-file', async (_event, filters: any[]) => {
const result = await dialog.showSaveDialog({
filters: filters || [
{ name: 'SQL File', extensions: ['sql'] },
{ name: 'Compressed SQL', extensions: ['sql.gz'] }
]
})
if (result.canceled) return null
return result.filePath
})
ipcMain.handle('shell:open-path', async (_event, pathStr: string) => {
return await shell.openPath(pathStr)
})
ipcMain.handle('binaries:download', async (_event, { name, url }) => {
return downloadService.downloadAndExtract(url, name, (p) => {
// TODO: Emit progress to renderer via webContents.send
@@ -646,4 +661,42 @@ export function registerIpcHandlers(): void {
return { success: false, message: `Import error: ${error.message}` }
}
})
ipcMain.handle('mysql:export-wizard:list-dbs', async (_event, { versionId, rootPass }) => {
const versions = await mySqlManagerService.getVersions()
const v = versions.find(v => v.id === versionId)
if (!v) return []
const binDir = path.join(configService.getBasePath(), 'bin', versionId)
const mysqlBin = findExecutable(binDir, 'mysql.exe')
if (!mysqlBin) return []
return await mySqlOperationService.listDatabases({
binPath: mysqlBin,
host: '127.0.0.1',
port: v.port,
rootUser: 'root',
rootPass: rootPass || ''
})
})
ipcMain.handle('mysql:export-wizard:start', async (event, { versionId, dbName, savePath, compress, rootPass }) => {
const versions = await mySqlManagerService.getVersions()
const v = versions.find(v => v.id === versionId)
if (!v) return { success: false, message: 'MySQL version not found' }
const binDir = path.join(configService.getBasePath(), 'bin', versionId)
const mysqlBin = findExecutable(binDir, 'mysql.exe')
if (!mysqlBin) return { success: false, message: 'mysql.exe not found' }
const log = (msg: string) => event.sender.send('mysql:export:log', msg)
return await mySqlOperationService.exportSql({
binPath: mysqlBin,
host: '127.0.0.1',
port: v.port,
rootUser: 'root',
rootPass: rootPass || ''
}, dbName, savePath, compress, (msg) => log(msg))
})
}
@@ -162,6 +162,68 @@ export class MySqlOperationService {
})
})
}
async listDatabases(config: MySqlConfig): Promise<string[]> {
const result = await this.runQuery(config, 'SHOW DATABASES')
if (!result.success) return []
return result.output
.split('\n')
.map(line => line.trim())
.filter(line => line && line !== 'Database' && !line.match(/^information_schema|performance_schema|mysql|sys$/i))
}
async exportSql(config: MySqlConfig, dbName: string, targetPath: string, compress: boolean, onProgress?: (log: string) => void): Promise<{ success: boolean; message: string }> {
return new Promise((resolve) => {
const dumpPath = config.binPath.replace(/mysql\.exe$/i, 'mysqldump.exe')
const args = [
`--host=${config.host}`,
`--port=${config.port}`,
`--user=${config.rootUser}`,
dbName,
'--routines',
'--triggers'
]
if (config.rootPass) {
args.push(`--password=${config.rootPass}`)
}
onProgress?.(`Executing: "${dumpPath}" ${args.join(' ')} > "${targetPath}"`)
const child = spawn(dumpPath, args, { windowsHide: true })
const writeStream = fs.createWriteStream(targetPath)
if (compress) {
const gzip = zlib.createGzip()
child.stdout.pipe(gzip).pipe(writeStream)
} else {
child.stdout.pipe(writeStream)
}
let errorOutput = ''
child.stderr.on('data', (data) => {
errorOutput += data.toString()
onProgress?.(data.toString())
})
child.on('error', (err) => {
onProgress?.(`Process error: ${err.message}`)
resolve({ success: false, message: `Process error: ${err.message}` })
})
child.on('close', (code) => {
writeStream.end()
if (code === 0) {
onProgress?.('Export completed successfully.')
resolve({ success: true, message: 'Export completed successfully.' })
} else {
onProgress?.(`Export failed with exit code ${code}`)
resolve({ success: false, message: `Export failed: ${errorOutput}` })
}
})
})
}
}
export const mySqlOperationService = new MySqlOperationService()
+37 -3
View File
@@ -72,6 +72,7 @@ import {
import Swal from 'sweetalert2'
import { useTranslation } from 'react-i18next'
import MySqlImportWizard from './components/MySqlImportWizard'
import MySqlExportWizard from './components/MySqlExportWizard'
import NginxCodeEditor from './components/NginxCodeEditor'
declare global {
@@ -131,6 +132,7 @@ function App(): JSX.Element {
const [pmaLoading, setPmaLoading] = useState(false)
const [pmaProgress, setPmaProgress] = useState(0)
const [isMySqlWizardOpen, setIsMySqlWizardOpen] = useState(false)
const [isMySqlExportWizardOpen, setIsMySqlExportWizardOpen] = useState(false)
const [isNginxConfigOpen, setIsNginxConfigOpen] = useState(false)
const [nginxConfig, setNginxConfig] = useState('')
const [isProjectNginxConfigOpen, setIsProjectNginxConfigOpen] = useState(false)
@@ -1085,13 +1087,13 @@ function App(): JSX.Element {
)}
</Paper>
<Paper sx={{ p: 3, bgcolor: 'rgba(255, 255, 255, 0.03)', borderRadius: 2, border: '1px solid rgba(255, 255, 255, 0.05)' }}>
<Paper sx={{ p: 3, bgcolor: 'rgba(255, 255, 255, 0.03)', borderRadius: 2, border: '1px solid rgba(255, 255, 255, 0.05)', mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<Box sx={{ p: 2, bgcolor: 'rgba(255, 255, 255, 0.05)', borderRadius: 3 }}>
<DbIcon color="primary" sx={{ fontSize: 32 }} />
</Box>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>MySQL Dump/Import Sihirbazı</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>MySQL Veri İçe Aktar</Typography>
<Typography variant="body2" color="text.secondary">
Seçtiğiniz bir .sql veya arşiv dosyasını hızlıca veritabanına yükleyin.
</Typography>
@@ -1104,7 +1106,33 @@ function App(): JSX.Element {
startIcon={<DbIcon />}
sx={{ borderRadius: 2 }}
>
SİHİRBAZI BAŞLAT
İÇE AKTAR
</Button>
</Box>
</Box>
</Paper>
<Paper sx={{ p: 3, bgcolor: 'rgba(255, 255, 255, 0.03)', borderRadius: 2, border: '1px solid rgba(255, 255, 255, 0.05)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<Box sx={{ p: 2, bgcolor: 'rgba(255, 255, 255, 0.05)', borderRadius: 3 }}>
<DbIcon color="secondary" sx={{ fontSize: 32 }} />
</Box>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>MySQL Veri Dışa Aktar</Typography>
<Typography variant="body2" color="text.secondary">
Veritabanlarını .sql veya .sql.gz olarak güvenle yedekleyin.
</Typography>
</Box>
<Box>
<Button
variant="contained"
color="secondary"
size="large"
onClick={() => setIsMySqlExportWizardOpen(true)}
startIcon={<DbIcon />}
sx={{ borderRadius: 2 }}
>
DIŞA AKTAR
</Button>
</Box>
</Box>
@@ -1432,6 +1460,12 @@ function App(): JSX.Element {
mySqlVersions={mySqlVersions}
/>
<MySqlExportWizard
open={isMySqlExportWizardOpen}
onClose={() => setIsMySqlExportWizardOpen(false)}
mySqlVersions={mySqlVersions}
/>
<Dialog
open={isNginxConfigOpen}
onClose={() => setIsNginxConfigOpen(false)}
@@ -0,0 +1,309 @@
import React, { useState, useEffect, useRef } from 'react'
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stepper,
Step,
StepLabel,
Typography,
TextField,
MenuItem,
Select,
FormControl,
InputLabel,
Paper,
InputAdornment,
CircularProgress,
Divider,
Stack,
FormControlLabel,
Checkbox
} from '@mui/material'
import {
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Save as SaveIcon
} from '@mui/icons-material'
import Swal from 'sweetalert2'
interface MySqlExportWizardProps {
open: boolean
onClose: () => void
mySqlVersions: any[]
}
const steps = [
'Sunucu Seçimi',
'Veritabanı Seçimi',
'Dışa Aktarma Ayarları',
'Özet ve İşlem'
]
const MySqlExportWizard: React.FC<MySqlExportWizardProps> = ({ open, onClose, mySqlVersions }) => {
const [activeStep, setActiveStep] = useState(0)
const [selectedVersion, setSelectedVersion] = useState('')
const [databases, setDatabases] = useState<string[]>([])
const [selectedDb, setSelectedDb] = useState('')
const [savePath, setSavePath] = useState('')
const [compress, setCompress] = useState(true)
const [rootPass, setRootPass] = useState('')
const [loading, setLoading] = useState(false)
const [logs, setLogs] = useState<string[]>([])
const logEndRef = useRef<HTMLDivElement>(null)
const [exportResult, setExportResult] = useState<{ success: boolean; message: string } | null>(null)
useEffect(() => {
if (open) {
setActiveStep(0)
setSelectedDb('')
setSavePath('')
setLogs([])
setExportResult(null)
const installedVersion = mySqlVersions.find(v => v.status === 'installed')
if (installedVersion) setSelectedVersion(installedVersion.id)
}
}, [open, mySqlVersions])
useEffect(() => {
if (logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [logs])
const nextStep = () => setActiveStep(prev => prev + 1)
const prevStep = () => setActiveStep(prev => prev - 1)
const handleStep1Next = async () => {
if (!selectedVersion) {
Swal.fire('Hata', 'Lütfen bir MySQL sunucusu seçin.', 'error')
return
}
setLoading(true)
try {
const dbs = await window.api.invoke('mysql:export-wizard:list-dbs', { versionId: selectedVersion, rootPass })
setDatabases(dbs)
nextStep()
} catch (err: any) {
Swal.fire('Hata', 'Veritabanları listelenemedi: ' + err.message, 'error')
} finally {
setLoading(false)
}
}
const handleSelectSavePath = async () => {
const path = await window.api.invoke('dialog:save-file', [
{ name: compress ? 'Compressed SQL' : 'SQL File', extensions: [compress ? 'sql.gz' : 'sql'] }
])
if (path) setSavePath(path)
}
const startExport = async () => {
setLoading(true)
setLogs(['[Sistem] Dışa aktarma işlemi başlatılıyor...'])
const removeListener = window.api.onMySqlExportLog((msg: string) => {
setLogs(prev => [...prev, msg])
})
try {
const result = await window.api.invoke('mysql:export-wizard:start', {
versionId: selectedVersion,
dbName: selectedDb,
savePath,
compress,
rootPass
})
setExportResult(result)
} catch (err: any) {
setExportResult({ success: false, message: err.message })
} finally {
setLoading(false)
if (removeListener) removeListener()
}
}
const renderStepContent = (step: number) => {
switch (step) {
case 0:
return (
<Box sx={{ mt: 2 }}>
<Typography variant="body1" gutterBottom>Veritabanın hangi MySQL sunucusunda olduğunu seçin.</Typography>
<FormControl fullWidth sx={{ mt: 2 }}>
<InputLabel>MySQL Sunucusu</InputLabel>
<Select
value={selectedVersion}
label="MySQL Sunucusu"
onChange={(e) => setSelectedVersion(e.target.value)}
>
{mySqlVersions.filter(v => v.status === 'installed').map(v => (
<MenuItem key={v.id} value={v.id}>
MySQL {v.version} (Port: {v.port})
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Root Şifresi (Eğer varsa)"
fullWidth
sx={{ mt: 3 }}
value={rootPass}
type="password"
onChange={(e) => setRootPass(e.target.value)}
helperText="MySQL 'root' kullanıcısının şifresi. Boşsa boş bırakın."
/>
</Box>
)
case 1:
return (
<Box sx={{ mt: 2 }}>
<Typography variant="body1" gutterBottom>Dışa aktarmak istediğiniz veritabanını seçin.</Typography>
<FormControl fullWidth sx={{ mt: 2 }}>
<InputLabel>Veritabanı</InputLabel>
<Select
value={selectedDb}
label="Veritabanı"
onChange={(e) => setSelectedDb(e.target.value)}
>
{databases.map(db => (
<MenuItem key={db} value={db}>{db}</MenuItem>
))}
</Select>
</FormControl>
{databases.length === 0 && (
<Typography variant="caption" color="error" sx={{ mt: 1, display: 'block' }}>
Seçili sunucuda erişilebilir veritabanı bulunamadı.
</Typography>
)}
</Box>
)
case 2:
return (
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
<Typography variant="body1">Dosyanın kaydedileceği yeri ve formatını belirleyin.</Typography>
<FormControlLabel
control={<Checkbox checked={compress} onChange={(e) => setCompress(e.target.checked)} />}
label="Gzip ile Sıkıştır (.sql.gz)"
/>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
fullWidth
variant="outlined"
value={savePath}
placeholder="Kaydedilecek dosya..."
disabled
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SaveIcon color="primary" />
</InputAdornment>
),
}}
/>
<Button variant="contained" onClick={handleSelectSavePath} sx={{ minWidth: '120px' }}>SEÇ</Button>
</Box>
</Box>
)
case 3:
return (
<Box sx={{ mt: 2 }}>
{!exportResult ? (
<>
<Paper sx={{ p: 2, bgcolor: 'rgba(255,255,255,0.05)', borderRadius: 2 }}>
<Typography variant="subtitle2" color="primary" sx={{ fontWeight: 'bold' }}>Özet Bilgiler</Typography>
<Divider sx={{ my: 1 }} />
<Stack spacing={1}>
<Typography variant="body2"><strong>Sunucu:</strong> {selectedVersion}</Typography>
<Typography variant="body2"><strong>Veritabanı:</strong> {selectedDb}</Typography>
<Typography variant="body2"><strong>Hedef:</strong> {savePath}</Typography>
<Typography variant="body2"><strong>Sıkıştırma:</strong> {compress ? 'Açık (.gz)' : 'Kapalı (.sql)'}</Typography>
</Stack>
</Paper>
{loading && (
<Box sx={{ mt: 3, p: 2, bgcolor: '#000', borderRadius: 2, maxHeight: '200px', overflowY: 'auto', fontFamily: 'monospace' }}>
{logs.map((log, i) => (
<Typography key={i} variant="caption" sx={{ display: 'block', color: log.includes('error') ? 'error.main' : 'success.main' }}>
{log}
</Typography>
))}
<div ref={logEndRef} />
</Box>
)}
</>
) : (
<Box sx={{ textAlign: 'center', py: 3 }}>
{exportResult.success ? (
<>
<SuccessIcon color="success" sx={{ fontSize: 60 }} />
<Typography variant="h6" sx={{ mt: 2 }}>İşlem Başarılı!</Typography>
<Typography variant="body2" color="text.secondary">{exportResult.message}</Typography>
<Button
variant="outlined"
sx={{ mt: 3 }}
onClick={() => window.api.invoke('shell:open-path', savePath.substring(0, savePath.lastIndexOf('\\')))}
>
DOSYAYI GÖSTER
</Button>
</>
) : (
<>
<ErrorIcon color="error" sx={{ fontSize: 60 }} />
<Typography variant="h6" sx={{ mt: 2 }}>Hata Oluştu!</Typography>
<Typography variant="body2" color="error">{exportResult.message}</Typography>
</>
)}
</Box>
)}
</Box>
)
default:
return 'Bilinmeyen adım'
}
}
return (
<Dialog open={open} onClose={loading ? undefined : onClose} maxWidth="sm" fullWidth>
<DialogTitle>MySQL Veritabanı Dışa Aktarma Sihirbazı</DialogTitle>
<DialogContent>
<Stepper activeStep={activeStep} orientation="horizontal" sx={{ pt: 2, pb: 4 }}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
{renderStepContent(activeStep)}
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>KAPAT</Button>
<Box sx={{ flexGrow: 1 }} />
{activeStep > 0 && activeStep < 3 && !exportResult && (
<Button onClick={prevStep} disabled={loading}>GERİ</Button>
)}
{activeStep === 0 && (
<Button variant="contained" onClick={handleStep1Next} disabled={loading}>
{loading ? <CircularProgress size={24} /> : 'İLERİ'}
</Button>
)}
{activeStep === 1 && (
<Button variant="contained" onClick={nextStep} disabled={!selectedDb}>İLERİ</Button>
)}
{activeStep === 2 && (
<Button variant="contained" onClick={nextStep} disabled={!savePath}>İLERİ</Button>
)}
{activeStep === 3 && !exportResult && (
<Button variant="contained" color="primary" onClick={startExport} disabled={loading}>
{loading ? <CircularProgress size={24} /> : 'EXPORTU BAŞLAT'}
</Button>
)}
</DialogActions>
</Dialog>
)
}
export default MySqlExportWizard
+3 -1
View File
@@ -9,7 +9,9 @@
"quit": "Çıkış",
"save": "Kaydet",
"port": "Port",
"all": "Tümü"
"all": "Tümü",
"export": "Dışa Aktar",
"import": "İçe Aktar"
},
"dashboard": {
"title": "Trunçgil Multi-PHP Server",