419 lines
17 KiB
TypeScript
419 lines
17 KiB
TypeScript
import React, { useState } from 'react';
|
|
import {
|
|
Plus,
|
|
Play,
|
|
Trash2,
|
|
Clock,
|
|
Folder,
|
|
Filter,
|
|
Calendar,
|
|
X
|
|
} from 'lucide-react';
|
|
import { BackupJobItem, ClientItem, api, SystemSettingsResponse } from '../services/api';
|
|
|
|
interface JobsViewProps {
|
|
jobs: BackupJobItem[];
|
|
clients: ClientItem[];
|
|
onRefresh: () => void;
|
|
settings?: SystemSettingsResponse | null;
|
|
}
|
|
|
|
export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, settings }) => {
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [formData, setFormData] = useState({
|
|
client_id: clients[0]?.id || 0,
|
|
name: '',
|
|
source_path: 'C:\\SQLBackups',
|
|
file_patterns: '*.bak,*.mdf',
|
|
schedule_cron: '0 2 * * *',
|
|
keep_daily: 7,
|
|
keep_weekly: 4,
|
|
keep_monthly: 12,
|
|
min_stable_time_seconds: 60,
|
|
exclude_patterns: '',
|
|
skip_hidden: false,
|
|
skip_system: false,
|
|
skip_readonly: false,
|
|
sync_deletions: false,
|
|
});
|
|
|
|
const handleCreateJob = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!formData.client_id) {
|
|
alert('Por favor selecciona un cliente');
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
await api.createJob(formData);
|
|
setShowModal(false);
|
|
onRefresh();
|
|
} catch (err: any) {
|
|
alert(`Error creando trabajo: ${err.message}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleTrigger = async (job: BackupJobItem) => {
|
|
try {
|
|
await api.triggerJob(job.id);
|
|
alert(`¡Trabajo ${job.job_code} encolado para ejecución inmediata!`);
|
|
onRefresh();
|
|
} catch (err: any) {
|
|
alert(`Error: ${err.message}`);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (job: BackupJobItem) => {
|
|
if (confirm(`¿Eliminar el trabajo de backup ${job.name} (${job.job_code})?`)) {
|
|
try {
|
|
await api.deleteJob(job.id);
|
|
onRefresh();
|
|
} catch (err: any) {
|
|
alert(`Error: ${err.message}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
const getClientName = (clientId: number) => {
|
|
const client = clients.find((c) => c.id === clientId);
|
|
return client ? `${client.name} (${client.client_code})` : `Cliente #${clientId}`;
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
|
<div>
|
|
<h3 style={{ fontSize: '1.2rem', fontWeight: 700 }}>Trabajos de Backup Programados</h3>
|
|
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', marginTop: '2px' }}>
|
|
Configuración de rutas de origen Windows, filtros de extensión y políticas de retención
|
|
</p>
|
|
</div>
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={() => {
|
|
if (clients.length === 0) {
|
|
alert('Primero debes registrar al menos un cliente Windows.');
|
|
return;
|
|
}
|
|
setFormData({
|
|
client_id: clients[0].id,
|
|
name: '',
|
|
source_path: 'C:\\SQLBackups',
|
|
file_patterns: '*.bak,*.mdf',
|
|
schedule_cron: '0 2 * * *',
|
|
keep_daily: settings?.default_keep_daily ?? 7,
|
|
keep_weekly: settings?.default_keep_weekly ?? 4,
|
|
keep_monthly: settings?.default_keep_monthly ?? 12,
|
|
min_stable_time_seconds: 60,
|
|
exclude_patterns: '',
|
|
skip_hidden: false,
|
|
skip_system: false,
|
|
skip_readonly: false,
|
|
sync_deletions: false,
|
|
});
|
|
setShowModal(true);
|
|
}}
|
|
>
|
|
<Plus size={16} />
|
|
Crear Trabajo de Backup
|
|
</button>
|
|
</div>
|
|
|
|
<div className="glass-card">
|
|
<div className="table-container">
|
|
<table className="modern-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Job ID</th>
|
|
<th>Nombre del Trabajo</th>
|
|
<th>Cliente Asignado</th>
|
|
<th>Origen en Windows</th>
|
|
<th>Filtros</th>
|
|
<th>Retención</th>
|
|
<th>Horario / Cron</th>
|
|
<th>Estado</th>
|
|
<th style={{ textAlign: 'right' }}>Acciones</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{jobs.map((job) => (
|
|
<tr key={job.id}>
|
|
<td>
|
|
<span className="hash-badge" style={{ color: 'var(--accent-indigo)' }}>
|
|
{job.job_code}
|
|
</span>
|
|
</td>
|
|
<td style={{ fontWeight: 600 }}>
|
|
{job.name}
|
|
{job.sync_deletions && (
|
|
<span className="badge" style={{ backgroundColor: 'rgba(6, 182, 212, 0.15)', color: 'var(--accent-cyan)', marginLeft: '8px', fontSize: '0.7rem', padding: '2px 6px', border: '1px solid rgba(6, 182, 212, 0.3)' }}>
|
|
Réplica
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td style={{ fontSize: '0.84rem', color: 'var(--text-muted)' }}>
|
|
{getClientName(job.client_id)}
|
|
</td>
|
|
<td>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.82rem', fontFamily: 'var(--font-mono)' }}>
|
|
<Folder size={14} color="var(--accent-cyan)" />
|
|
{job.source_path}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span className="hash-badge">{job.file_patterns}</span>
|
|
</td>
|
|
<td style={{ fontSize: '0.82rem' }}>
|
|
<span style={{ color: '#34d399' }}>{job.keep_daily}d</span> /{' '}
|
|
<span style={{ color: '#60a5fa' }}>{job.keep_weekly}w</span> /{' '}
|
|
<span style={{ color: '#c084fc' }}>{job.keep_monthly}m</span>
|
|
</td>
|
|
<td style={{ fontSize: '0.82rem', fontFamily: 'var(--font-mono)', color: 'var(--text-dim)' }}>
|
|
{job.schedule_cron}
|
|
</td>
|
|
<td>
|
|
<span
|
|
className={`badge ${
|
|
job.status === 'RUNNING' ? 'badge-syncing' :
|
|
job.status === 'QUEUED' ? 'badge-syncing' : 'badge-online'
|
|
}`}
|
|
>
|
|
{job.status}
|
|
</span>
|
|
</td>
|
|
<td style={{ textAlign: 'right' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
|
<button
|
|
className="btn btn-secondary"
|
|
style={{ padding: '6px 10px', fontSize: '0.78rem', color: 'var(--accent-cyan)' }}
|
|
onClick={() => handleTrigger(job)}
|
|
title="Ejecutar backup ahora"
|
|
>
|
|
<Play size={13} />
|
|
Ejecutar
|
|
</button>
|
|
<button
|
|
className="btn btn-danger"
|
|
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
|
|
onClick={() => handleDelete(job)}
|
|
title="Eliminar trabajo"
|
|
>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{jobs.length === 0 && (
|
|
<tr>
|
|
<td colSpan={9} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
|
|
No hay trabajos de backup configurados. Haz clic en "Crear Trabajo de Backup".
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Create Job Modal */}
|
|
{showModal && (
|
|
<div className="modal-backdrop">
|
|
<div className="modal-card">
|
|
<div className="modal-header">
|
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700 }}>Nuevo Trabajo de Backup</h3>
|
|
<button
|
|
style={{ background: 'transparent', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}
|
|
onClick={() => setShowModal(false)}
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleCreateJob}>
|
|
<div className="form-group">
|
|
<label>Cliente Windows Destino:</label>
|
|
<select
|
|
className="form-input"
|
|
value={formData.client_id}
|
|
onChange={(e) => setFormData({ ...formData, client_id: Number(e.target.value) })}
|
|
>
|
|
{clients.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.name} ({c.client_code})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>Nombre del Trabajo:</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
className="form-input"
|
|
placeholder="Ej: SQL Server Producción Diaria"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
|
<div className="form-group">
|
|
<label>Ruta Origen en Windows:</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
className="form-input"
|
|
placeholder="C:\SQLBackups"
|
|
value={formData.source_path}
|
|
onChange={(e) => setFormData({ ...formData, source_path: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="form-group">
|
|
<label>Filtros de Archivo (Inclusión):</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
className="form-input"
|
|
placeholder="*.bak,*.mdf"
|
|
value={formData.file_patterns}
|
|
onChange={(e) => setFormData({ ...formData, file_patterns: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
|
<div className="form-group">
|
|
<label>Horario (Expresión Cron):</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
className="form-input"
|
|
placeholder="0 2 * * *"
|
|
value={formData.schedule_cron}
|
|
onChange={(e) => setFormData({ ...formData, schedule_cron: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="form-group">
|
|
<label>Estabilidad Mínima Archivo (segundos):</label>
|
|
<input
|
|
type="number"
|
|
min="10"
|
|
required
|
|
className="form-input"
|
|
value={formData.min_stable_time_seconds}
|
|
onChange={(e) => setFormData({ ...formData, min_stable_time_seconds: Number(e.target.value) })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '16px', marginTop: '12px' }}>
|
|
<h4 style={{ fontSize: '0.9rem', fontWeight: 600, color: 'var(--accent-cyan)', marginBottom: '12px' }}>
|
|
Filtros Avanzados (Estilo Karen's Replicator)
|
|
</h4>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '12px', marginBottom: '12px' }}>
|
|
<div className="form-group">
|
|
<label>Filtros de Exclusión (Separados por coma):</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
placeholder="Ej: *.tmp, *.log, node_modules, obj"
|
|
value={formData.exclude_patterns}
|
|
onChange={(e) => setFormData({ ...formData, exclude_patterns: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '18px', marginBottom: '16px' }}>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.84rem', cursor: 'pointer' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.skip_hidden}
|
|
onChange={(e) => setFormData({ ...formData, skip_hidden: e.target.checked })}
|
|
/>
|
|
Omitir Archivos Ocultos
|
|
</label>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.84rem', cursor: 'pointer' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.skip_system}
|
|
onChange={(e) => setFormData({ ...formData, skip_system: e.target.checked })}
|
|
/>
|
|
Omitir Archivos de Sistema
|
|
</label>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.84rem', cursor: 'pointer' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.skip_readonly}
|
|
onChange={(e) => setFormData({ ...formData, skip_readonly: e.target.checked })}
|
|
/>
|
|
Omitir Solo Lectura
|
|
</label>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '18px', marginBottom: '16px', borderTop: '1px dashed rgba(255,255,255,0.08)', paddingTop: '10px' }}>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.84rem', cursor: 'pointer', color: 'var(--accent-cyan)' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.sync_deletions}
|
|
onChange={(e) => setFormData({ ...formData, sync_deletions: e.target.checked })}
|
|
/>
|
|
Modo Réplica Exacta (Eliminar en destino si se elimina en origen)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>Políticas de Retención (Conservar copias):</label>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px' }}>
|
|
<div>
|
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>Días (Diario):</span>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
className="form-input"
|
|
value={formData.keep_daily}
|
|
onChange={(e) => setFormData({ ...formData, keep_daily: Number(e.target.value) })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>Semanas (Semanal):</span>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
className="form-input"
|
|
value={formData.keep_weekly}
|
|
onChange={(e) => setFormData({ ...formData, keep_weekly: Number(e.target.value) })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>Meses (Mensual):</span>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
className="form-input"
|
|
value={formData.keep_monthly}
|
|
onChange={(e) => setFormData({ ...formData, keep_monthly: Number(e.target.value) })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '24px' }}>
|
|
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>
|
|
Cancelar
|
|
</button>
|
|
<button type="submit" className="btn btn-primary" disabled={loading}>
|
|
{loading ? 'Guardando...' : 'Crear Trabajo'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|