feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Play,
|
||||
Trash2,
|
||||
Clock,
|
||||
Folder,
|
||||
Filter,
|
||||
Calendar,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { BackupJobItem, ClientItem, api } from '../services/api';
|
||||
|
||||
interface JobsViewProps {
|
||||
jobs: BackupJobItem[];
|
||||
clients: ClientItem[];
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh }) => {
|
||||
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,
|
||||
});
|
||||
|
||||
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((prev) => ({ ...prev, client_id: clients[0].id }));
|
||||
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}</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:</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 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user