feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment

This commit is contained in:
2026-08-13 19:33:44 -03:00
commit 1bfb808c79
77 changed files with 10675 additions and 0 deletions
+336
View File
@@ -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>
);
};