Cambios para manejar respaldo

This commit is contained in:
beseira13
2026-01-22 11:51:52 -03:00
parent ff915bc3d2
commit 39a369c403
8 changed files with 448 additions and 6 deletions

6
.env
View File

@@ -8,3 +8,9 @@ VITE_APP_TITLE=ADVICOM SQL Manager
VITE_USER_ID=1 VITE_USER_ID=1
# Privilegios: READ (solo lectura) | WRITE (permite INSERT, UPDATE, DELETE) # Privilegios: READ (solo lectura) | WRITE (permite INSERT, UPDATE, DELETE)
VITE_USER_PRIVILEGIO=READ VITE_USER_PRIVILEGIO=READ
# Configuración FTP para Respaldos
VITE_FTP_HOST=ftp://134.122.126.66:21
VITE_FTP_USER=mysql_inyect
VITE_FTP_PASS=YTJjrRdnCPwjTSrN
VITE_FTP_PATH=/carpeta_respaldos/

View File

@@ -8,3 +8,9 @@ VITE_APP_TITLE=Remote SQL Admin
VITE_USER_ID=1 VITE_USER_ID=1
# Privilegios: READ (solo lectura) | WRITE (permite INSERT, UPDATE, DELETE) # Privilegios: READ (solo lectura) | WRITE (permite INSERT, UPDATE, DELETE)
VITE_USER_PRIVILEGIO=READ VITE_USER_PRIVILEGIO=READ
# Configuración FTP para Respaldos
VITE_FTP_HOST=ftp.tuservidor.com
VITE_FTP_USER=usuario_ftp
VITE_FTP_PASS=clave_ftp
VITE_FTP_PATH=/carpeta_respaldos/

View File

@@ -6,7 +6,7 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
- VITE_API_URL=https://ws-sql-inyect.sial.cl:3120/v1 - VITE_API_URL=https://ws-sql-inyect.sial.cl/v1
- VITE_APP_TITLE=ADVICOM SQL Manager - VITE_APP_TITLE=ADVICOM SQL Manager
- VITE_USER_ID=1 - VITE_USER_ID=1
- VITE_USER_PRIVILEGIO=READ - VITE_USER_PRIVILEGIO=READ

View File

@@ -1,11 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Database, Settings, Menu, Loader2, LogOut, User as UserIcon } from 'lucide-react'; import { Database, Settings, Menu, Loader2, LogOut, User as UserIcon, Download } from 'lucide-react';
import StoreSelector from './components/StoreSelector'; import StoreSelector from './components/StoreSelector';
import SqlEditor from './components/SqlEditor'; import SqlEditor from './components/SqlEditor';
import ResultTable from './components/ResultTable'; import ResultTable from './components/ResultTable';
import CommandHistory from './components/CommandHistory'; import CommandHistory from './components/CommandHistory';
import DatabaseSelector from './components/DatabaseSelector'; import DatabaseSelector from './components/DatabaseSelector';
import Login from './components/Login'; import Login from './components/Login';
import BackupModal from './components/BackupModal';
import { commandService, authService } from './services/api'; import { commandService, authService } from './services/api';
import { injectDatabaseToSql } from './utils/sqlValidator'; import { injectDatabaseToSql } from './utils/sqlValidator';
@@ -21,6 +22,7 @@ function App() {
const [selectedDatabase, setSelectedDatabase] = useState(null); const [selectedDatabase, setSelectedDatabase] = useState(null);
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [selectedClient, setSelectedClient] = useState('2'); // Peru is 2, Colombia is 3 const [selectedClient, setSelectedClient] = useState('2'); // Peru is 2, Colombia is 3
const [isBackupModalOpen, setIsBackupModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
// Check if user is already logged in // Check if user is already logged in
@@ -149,6 +151,84 @@ function App() {
}); });
} }
}; };
const handleBackupRequest = async (backupData) => {
if (!selectedStore) return;
setIsBackupModalOpen(false);
setExecutionStatus('running');
setCurrentResult(null);
setPollingProgress(null);
try {
const now = new Date();
const dateStr = now.toISOString().split('T')[0];
const timeStr = now.getTime().toString().slice(-3);
const commandId = `BK-${dateStr}-${timeStr}`;
const backupSql = `BACKUP ${JSON.stringify({
database: backupData.database,
ftp: {
host: backupData.host,
user: backupData.user,
pass: backupData.pass,
path: backupData.path
}
})}`;
// Step 1: Create the command
const createResponse = await commandService.create({
store_id: parseInt(selectedStore.id, 10),
sql: backupSql,
privilegio: 'CRUD', // As per requirements
id_user: currentUser.id,
id_cliente: parseInt(selectedClient, 10),
command_id: commandId,
timeout: 600
});
if (!createResponse.success) {
setExecutionStatus('error');
setCurrentResult({ error: createResponse.message || 'Error al solicitar respaldo' });
return;
}
// Update status to polling
setExecutionStatus('polling');
setCurrentResult({
message: 'Solicitud de respaldo enviada. El agente está procesando...',
command_id: commandId,
stats: { info: `Backup #${commandId} iniciado. Esperando compresión y envío...` }
});
// Step 2: Poll for results
const result = await commandService.pollForResult(commandId, {
interval: 5000, // Poll every 5 seconds for backups
maxAttempts: 120, // Wait up to 10 minutes (600 seconds)
onProgress: ({ attempts, maxAttempts, status }) => {
setPollingProgress({ attempts, maxAttempts, status });
setCurrentResult(prev => ({
...prev,
message: `Procesando respaldo remoto... (${attempts}/${maxAttempts})`,
stats: { info: `Backup #${commandId} - Intento ${attempts} de ${maxAttempts}` }
}));
}
});
setExecutionStatus('completed');
setPollingProgress(null);
setCurrentResult(normalizeApiResponse(result, commandId));
} catch (error) {
console.error(error);
setExecutionStatus('error');
setPollingProgress(null);
setCurrentResult({
error: error.message || "Error al procesar el respaldo",
stats: { info: 'Error durante la ejecución del backup' }
});
}
};
/** /**
* Normalizes different API response formats into a standard structure for ResultTable * Normalizes different API response formats into a standard structure for ResultTable
@@ -174,6 +254,17 @@ function App() {
// Check for nested result object (main format) or json_response (history format) // Check for nested result object (main format) or json_response (history format)
const dataContainer = result.result || result.json_response; const dataContainer = result.result || result.json_response;
// Check for download URL in dataContainer
if (dataContainer && (dataContainer.download_url || dataContainer.url || dataContainer.file_url)) {
normalizedResult.downloadUrl = dataContainer.download_url || dataContainer.url || dataContainer.file_url;
normalizedResult.message = '¡Respaldo listo para descargar!';
}
// Fallback search in the whole result object
else if (result.download_url || result.url || result.file_url) {
normalizedResult.downloadUrl = result.download_url || result.url || result.file_url;
normalizedResult.message = '¡Respaldo listo para descargar!';
}
if (dataContainer) { if (dataContainer) {
// Get execution time // Get execution time
if (dataContainer.execution_time_ms) { if (dataContainer.execution_time_ms) {
@@ -330,6 +421,16 @@ function App() {
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{selectedStore && (
<button
onClick={() => setIsBackupModalOpen(true)}
className="btn-primary"
style={{ padding: '0.375rem 0.75rem', fontSize: '0.75rem', backgroundColor: 'var(--warning)' }}
title="Solicitar Respaldo de Base de Datos"
>
<Download size={14} /> Respaldo DB
</button>
)}
<div className="flex items-center gap-2 pr-4" style={{ borderRight: '1px solid var(--border)' }}> <div className="flex items-center gap-2 pr-4" style={{ borderRight: '1px solid var(--border)' }}>
<div style={{ <div style={{
width: '32px', height: '32px', width: '32px', height: '32px',
@@ -379,6 +480,14 @@ function App() {
</div> </div>
</main> </main>
<BackupModal
isOpen={isBackupModalOpen}
onClose={() => setIsBackupModalOpen(false)}
store={selectedStore}
currentDatabase={selectedDatabase}
onConfirm={handleBackupRequest}
/>
<style>{` <style>{`
.animate-spin { .animate-spin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;

View File

@@ -0,0 +1,288 @@
import React, { useState, useEffect } from 'react';
import { X, Shield, Server, Database, Folder, Key, User, Download, AlertCircle } from 'lucide-react';
export default function BackupModal({ isOpen, onClose, store, currentDatabase, onConfirm }) {
const [formData, setFormData] = useState({
database: currentDatabase || '',
host: import.meta.env.VITE_FTP_HOST || '',
user: import.meta.env.VITE_FTP_USER || '',
pass: import.meta.env.VITE_FTP_PASS || '',
path: import.meta.env.VITE_FTP_PATH || '/'
});
useEffect(() => {
if (currentDatabase) {
setFormData(prev => ({ ...prev, database: currentDatabase }));
}
}, [currentDatabase]);
if (!isOpen) return null;
const handleSubmit = (e) => {
e.preventDefault();
onConfirm(formData);
};
return (
<div className="bk-modal-overlay">
<div className="bk-modal-content">
{/* Header */}
<div className="bk-modal-header">
<div className="flex items-center gap-2">
<Download size={20} style={{ color: 'var(--text-accent)' }} />
<h2 style={{ margin: 0, fontSize: '1rem', color: '#fff', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Solicitar Respaldo DB
</h2>
</div>
<button onClick={onClose} className="bk-close-btn">
<X size={20} />
</button>
</div>
{/* Content */}
<form onSubmit={handleSubmit} className="bk-modal-body">
<div className="bk-info-banner">
<AlertCircle size={18} style={{ color: 'var(--warning)', flexShrink: 0 }} />
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', lineHeight: '1.5' }}>
Se enviará un comando <code style={{ color: 'var(--text-accent)', background: 'rgba(0,0,0,0.3)', padding: '2px 4px', borderRadius: '4px' }}>BACKUP</code> al agente remoto de <b style={{ color: '#fff' }}>{store?.name}</b>.
El proceso puede tardar varios minutos.
</div>
</div>
<div className="bk-form-grid">
{/* Database Name */}
<div className="bk-input-group full-width">
<label><Database size={12} /> Base de Datos</label>
<input
type="text"
required
value={formData.database}
onChange={(e) => setFormData(prev => ({ ...prev, database: e.target.value }))}
placeholder="nombre_bd"
/>
</div>
{/* FTP Host */}
<div className="bk-input-group">
<label><Server size={12} /> Host FTP</label>
<input
type="text"
required
value={formData.host}
onChange={(e) => setFormData(prev => ({ ...prev, host: e.target.value }))}
placeholder="ftp.servidor.com"
/>
</div>
{/* FTP User */}
<div className="bk-input-group">
<label><User size={12} /> Usuario FTP</label>
<input
type="text"
required
value={formData.user}
onChange={(e) => setFormData(prev => ({ ...prev, user: e.target.value }))}
placeholder="usuario"
/>
</div>
{/* FTP Password */}
<div className="bk-input-group">
<label><Key size={12} /> Contraseña FTP</label>
<input
type="password"
required
value={formData.pass}
onChange={(e) => setFormData(prev => ({ ...prev, pass: e.target.value }))}
placeholder="••••••••"
/>
</div>
{/* FTP Path */}
<div className="bk-input-group">
<label><Folder size={12} /> Ruta Destino</label>
<input
type="text"
required
value={formData.path}
onChange={(e) => setFormData(prev => ({ ...prev, path: e.target.value }))}
placeholder="/respaldos/"
/>
</div>
</div>
<div className="bk-modal-footer">
<button type="button" onClick={onClose} className="bk-btn-secondary">
Cancelar
</button>
<button type="submit" className="bk-btn-primary">
<Shield size={18} /> Iniciar Respaldo
</button>
</div>
</form>
</div>
<style>{`
.bk-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.bk-modal-content {
background-color: var(--bg-panel);
width: 100%;
max-width: 500px;
border-radius: 0.75rem;
border: 1px solid var(--border);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
overflow: hidden;
animation: modalEntry 0.3s ease-out;
}
@keyframes modalEntry {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.bk-modal-header {
background-color: var(--bg-input);
padding: 1rem 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.bk-close-btn {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 4px;
}
.bk-close-btn:hover {
color: #fff;
background-color: rgba(255,255,255,0.1);
}
.bk-modal-body {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.bk-info-banner {
background-color: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.2);
padding: 0.75rem;
border-radius: 0.5rem;
display: flex;
gap: 0.75rem;
align-items: center;
}
.bk-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.bk-input-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.bk-input-group.full-width {
grid-column: span 2;
}
.bk-input-group label {
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 0.5rem;
}
.bk-input-group input {
background-color: var(--bg-input);
border: 1px solid var(--border);
color: #fff;
padding: 0.625rem 0.875rem;
border-radius: 0.5rem;
font-size: 0.875rem;
outline: none;
transition: border-color 0.2s;
}
.bk-input-group input:focus {
border-color: var(--accent);
}
.bk-modal-footer {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.bk-btn-secondary {
flex: 1;
padding: 0.75rem;
background: transparent;
border: 1px solid var(--border);
color: #fff;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.bk-btn-secondary:hover {
background-color: var(--bg-hover);
}
.bk-btn-primary {
flex: 1;
padding: 0.75rem;
background-color: var(--accent);
border: none;
color: #fff;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.bk-btn-primary:hover {
background-color: var(--accent-hover);
transform: translateY(-1px);
}
`}</style>
</div>
);
}

View File

@@ -186,7 +186,29 @@ export default function ResultTable({ result, loading, pollingProgress }) {
<h3 style={{ color: 'var(--text-primary)', fontWeight: 600, marginBottom: '0.5rem' }}> <h3 style={{ color: 'var(--text-primary)', fontWeight: 600, marginBottom: '0.5rem' }}>
{result.message || 'Consulta ejecutada correctamente'} {result.message || 'Consulta ejecutada correctamente'}
</h3> </h3>
{result.command_id && ( {result.downloadUrl && (
<div style={{ marginTop: '1.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<a
href={result.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-8 py-3 rounded-lg font-bold transition-all hover:scale-105 shadow-lg shadow-blue-500/20"
style={{
background: 'var(--accent)',
color: 'white',
textDecoration: 'none',
fontSize: '1rem'
}}
>
<Download size={20} />
Descargar Respaldo Ahora
</a>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
El archivo se encuentra disponible en el servidor FTP especificado.
</p>
</div>
)}
{result.command_id && !result.downloadUrl && (
<p className="font-mono text-xs flex items-center gap-1" style={{ color: 'var(--text-accent)' }}> <p className="font-mono text-xs flex items-center gap-1" style={{ color: 'var(--text-accent)' }}>
<Hash size={12} /> Command ID: {result.command_id} <Hash size={12} /> Command ID: {result.command_id}
</p> </p>

View File

@@ -1,6 +1,6 @@
import axios from 'axios'; import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'; const API_URL = import.meta.env.VITE_API_URL || 'https://ws-sql-inyect.sial.cl/v1';
const api = axios.create({ const api = axios.create({
baseURL: API_URL, baseURL: API_URL,
@@ -71,7 +71,7 @@ export const commandService = {
* @param {Number} [params.id_user] - User ID requesting the command (Optional) * @param {Number} [params.id_user] - User ID requesting the command (Optional)
* @returns {Promise<{success: boolean, message: string, command_id: number}>} * @returns {Promise<{success: boolean, message: string, command_id: number}>}
*/ */
create: ({ store_id, sql, privilegio = 'READ', id_user, id_cliente }) => { create: ({ store_id, sql, privilegio = 'READ', id_user, id_cliente, command_id, timeout }) => {
const payload = { store_id, sql, privilegio }; const payload = { store_id, sql, privilegio };
if (id_user !== undefined) { if (id_user !== undefined) {
payload.id_user = id_user; payload.id_user = id_user;
@@ -79,6 +79,12 @@ export const commandService = {
if (id_cliente !== undefined) { if (id_cliente !== undefined) {
payload.id_cliente = id_cliente; payload.id_cliente = id_cliente;
} }
if (command_id !== undefined) {
payload.command_id = command_id;
}
if (timeout !== undefined) {
payload.timeout = timeout;
}
return api.post('/commands', payload); return api.post('/commands', payload);
}, },

View File

@@ -6,7 +6,7 @@
*/ */
// Base allowed SQL commands (always allowed) // Base allowed SQL commands (always allowed)
const BASE_ALLOWED_COMMANDS = ['SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN']; const BASE_ALLOWED_COMMANDS = ['SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN', 'BACKUP'];
// Write commands (only allowed with WRITE privilege) // Write commands (only allowed with WRITE privilege)
const WRITE_COMMANDS = ['DELETE', 'UPDATE', 'INSERT']; const WRITE_COMMANDS = ['DELETE', 'UPDATE', 'INSERT'];
@@ -127,6 +127,11 @@ export function injectDatabaseToSql(sql, dbName) {
// Regex para encontrar tablas después de palabras clave comunes // Regex para encontrar tablas después de palabras clave comunes
// Busca: FROM table, JOIN table, UPDATE table, INTO table, DESCRIBE table, etc. // Busca: FROM table, JOIN table, UPDATE table, INTO table, DESCRIBE table, etc.
// Solo actúa si el nombre de la tabla NO contiene ya un punto (.) // Solo actúa si el nombre de la tabla NO contiene ya un punto (.)
// No aplica para comandos BACKUP que usan formato JSON
if (sql.trim().toUpperCase().startsWith('BACKUP')) {
return sql;
}
const tableKeywords = ['FROM', 'JOIN', 'UPDATE', 'INTO', 'DESCRIBE', 'DESC', 'TABLE']; const tableKeywords = ['FROM', 'JOIN', 'UPDATE', 'INTO', 'DESCRIBE', 'DESC', 'TABLE'];
let transformedSql = sql; let transformedSql = sql;