feat: implementar Fases 3, 4 y 5 del roadmap (Planificacion Avanzada, Historial de Corridas y Copia Local Duplicada)

This commit is contained in:
Carlos Tello
2026-08-15 00:58:05 -03:00
parent 2c2cfeb867
commit 929f9affd7
11 changed files with 677 additions and 57 deletions
+130 -4
View File
@@ -37,9 +37,30 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, se
skip_system: false,
skip_readonly: false,
sync_deletions: false,
local_destination_path: '',
});
const [editingJobId, setEditingJobId] = useState<number | null>(null);
const [showRunsModal, setShowRunsModal] = useState(false);
const [selectedJobForRuns, setSelectedJobForRuns] = useState<BackupJobItem | null>(null);
const [runsHistory, setRunsHistory] = useState<JobRunItem[]>([]);
const [loadingRuns, setLoadingRuns] = useState(false);
const [loading, setLoading] = useState(false);
const handleShowRuns = async (job: BackupJobItem) => {
setSelectedJobForRuns(job);
setShowRunsModal(true);
setLoadingRuns(true);
try {
const history = await api.getJobRuns(job.id);
setRunsHistory(history);
} catch (err: any) {
alert(`Error obteniendo historial: ${err.message}`);
} finally {
setLoadingRuns(false);
}
};
const handleSubmitJob = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.client_id) {
@@ -79,6 +100,7 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, se
skip_system: job.skip_system,
skip_readonly: job.skip_readonly,
sync_deletions: job.sync_deletions,
local_destination_path: job.local_destination_path || '',
});
setShowModal(true);
};
@@ -141,6 +163,7 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, se
skip_system: false,
skip_readonly: false,
sync_deletions: false,
local_destination_path: '',
});
setShowModal(true);
}}
@@ -186,9 +209,17 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, se
{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 style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<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>
{job.local_destination_path && (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.74rem', fontFamily: 'var(--font-mono)', color: 'var(--text-muted)' }}>
<span style={{ color: '#fbbf24', fontWeight: 600 }}>Copia Local:</span>
{job.local_destination_path}
</div>
)}
</div>
</td>
<td>
@@ -223,6 +254,14 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, se
<Play size={13} />
Ejecutar
</button>
<button
className="btn btn-secondary"
style={{ padding: '6px 10px', fontSize: '0.78rem', color: '#fbbf24' }}
onClick={() => handleShowRuns(job)}
title="Ver historial de ejecuciones"
>
<Clock size={13} />
</button>
<button
className="btn btn-secondary"
style={{ padding: '6px 10px', fontSize: '0.78rem', color: 'var(--text-muted)' }}
@@ -349,7 +388,7 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, se
<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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' }}>
<div className="form-group">
<label>Filtros de Exclusión (Separados por coma):</label>
<input
@@ -360,6 +399,16 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, se
onChange={(e) => setFormData({ ...formData, exclude_patterns: e.target.value })}
/>
</div>
<div className="form-group">
<label>Ruta Copia Local Duplicada (Opcional):</label>
<input
type="text"
className="form-input"
placeholder="Ej: D:\RespaldosLocal o \\NAS\backup"
value={formData.local_destination_path}
onChange={(e) => setFormData({ ...formData, local_destination_path: 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' }}>
@@ -447,6 +496,83 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, se
</div>
</div>
)}
{/* Job Runs History Modal (Phase 4) */}
{showRunsModal && selectedJobForRuns && (
<div className="modal-backdrop">
<div className="modal-content" style={{ maxWidth: '820px', maxHeight: '80vh', overflowY: 'auto' }}>
<div className="modal-header">
<h4 style={{ fontSize: '1.1rem', fontWeight: 700 }}>
Historial de Ejecuciones — {selectedJobForRuns.name} ({selectedJobForRuns.job_code})
</h4>
<button className="modal-close" onClick={() => setShowRunsModal(false)}>
<X size={18} />
</button>
</div>
<div style={{ padding: '16px 0' }}>
{loadingRuns ? (
<div style={{ textAlign: 'center', padding: '20px', color: 'var(--text-dim)' }}>Cargando historial...</div>
) : runsHistory.length === 0 ? (
<div style={{ textAlign: 'center', padding: '20px', color: 'var(--text-dim)' }}>No hay ejecuciones registradas para este trabajo todavía.</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="table" style={{ fontSize: '0.82rem', width: '100%' }}>
<thead>
<tr>
<th>Fecha de Inicio</th>
<th>Duración / Fin</th>
<th>Estado</th>
<th style={{ textAlign: 'center' }}>Escaneados</th>
<th style={{ textAlign: 'center' }}>Copiados</th>
<th style={{ textAlign: 'center' }}>Omitidos</th>
<th style={{ textAlign: 'center' }}>Errores</th>
<th>Transferido</th>
</tr>
</thead>
<tbody>
{runsHistory.map((run) => {
const start = new Date(run.started_at);
const end = run.completed_at ? new Date(run.completed_at) : null;
const durationSecs = end ? Math.round((end.getTime() - start.getTime()) / 1000) : 0;
return (
<tr key={run.id}>
<td>{start.toLocaleString()}</td>
<td>
{end ? `${durationSecs}s (${end.toLocaleTimeString()})` : <span style={{ color: 'var(--accent-cyan)' }}>En ejecución...</span>}
</td>
<td>
<span className={`badge ${run.status === 'SUCCESS' ? 'badge-online' : run.status === 'RUNNING' ? 'badge-syncing' : 'badge-offline'}`}>
{run.status}
</span>
{run.error_summary && (
<div style={{ fontSize: '0.72rem', color: '#fda4af', marginTop: '4px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={run.error_summary}>
{run.error_summary}
</div>
)}
</td>
<td style={{ textAlign: 'center' }}>{run.files_scanned}</td>
<td style={{ textAlign: 'center', color: '#60a5fa' }}>{run.files_copied}</td>
<td style={{ textAlign: 'center', color: '#a7f3d0' }}>{run.files_skipped}</td>
<td style={{ textAlign: 'center', color: run.errors_count > 0 ? '#f43f5e' : 'inherit' }}>{run.errors_count}</td>
<td>{(run.bytes_transferred / (1024 * 1024)).toFixed(2)} MB</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<div className="modal-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '12px' }}>
<button className="btn btn-secondary" onClick={() => setShowRunsModal(false)}>
Cerrar
</button>
</div>
</div>
</div>
)}
</div>
);
};
+20
View File
@@ -48,6 +48,7 @@ export interface BackupJobItem {
skip_system: boolean;
skip_readonly: boolean;
sync_deletions: boolean;
local_destination_path?: string | null;
schedule_cron: string;
is_active: boolean;
keep_daily: number;
@@ -60,6 +61,21 @@ export interface BackupJobItem {
created_at: string;
}
export interface JobRunItem {
id: number;
job_id: number;
client_id: number;
started_at: string;
completed_at?: string;
status: string;
files_scanned: number;
files_copied: number;
files_skipped: number;
errors_count: number;
bytes_transferred: number;
error_summary?: string;
}
export interface BackupFileItem {
id: number;
client_id: number;
@@ -200,6 +216,7 @@ export const api = {
skip_system: boolean;
skip_readonly: boolean;
sync_deletions: boolean;
local_destination_path?: string | null;
schedule_cron: string;
keep_daily: number;
keep_weekly: number;
@@ -220,6 +237,7 @@ export const api = {
skip_system: boolean;
skip_readonly: boolean;
sync_deletions: boolean;
local_destination_path?: string | null;
schedule_cron: string;
keep_daily: number;
keep_weekly: number;
@@ -235,6 +253,8 @@ export const api = {
request<{ message: string }>(`/jobs/${jobId}/trigger`, { method: 'POST' }),
deleteJob: (jobId: number) =>
request<{ message: string }>(`/jobs/${jobId}`, { method: 'DELETE' }),
getJobRuns: (jobId: number) =>
request<JobRunItem[]>(`/jobs/${jobId}/runs`),
// Backups
getBackups: (clientId?: number, jobId?: number) => {