feat: script jobs scheduler and manager page for Fortigate, UniFi and Grandstream scripts

This commit is contained in:
Carlos Tello
2026-08-17 00:20:03 -03:00
parent f3b6d8ef71
commit 8b2260bf14
13 changed files with 2122 additions and 6 deletions
+7
View File
@@ -9,6 +9,7 @@ import { EventsView } from './pages/EventsView';
import { LoginView } from './pages/LoginView';
import { SettingsView } from './pages/SettingsView';
import { UsersView } from './pages/UsersView';
import { ScriptJobsView } from './pages/ScriptJobsView';
import { ActiveUpload } from './components/LiveTransferMeter';
import {
api,
@@ -155,6 +156,8 @@ export const App: React.FC = () => {
return 'Auditoría & Logs';
case 'users':
return 'Gestión de Cuentas';
case 'scripts':
return 'Scripts del Servidor';
case 'settings':
return 'Configuración Global';
default:
@@ -221,6 +224,10 @@ export const App: React.FC = () => {
<UsersView />
)}
{currentTab === 'scripts' && (
<ScriptJobsView />
)}
{currentTab === 'settings' && (
<SettingsView
onRefresh={loadData}
+5 -4
View File
@@ -22,10 +22,11 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentTab, setCurrentTab, isW
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'clients', label: 'Clientes Windows', icon: HardDrive },
{ id: 'jobs', label: 'Trabajos de Backup', icon: Layers },
{ id: 'restore', label: 'Explorador & Restore', icon: RotateCcw },
{ id: 'users', label: 'Gestión de Cuentas', icon: Users },
{ id: 'events', label: 'Auditoría & Logs', icon: FileText },
{ id: 'settings', label: 'Configuración Global', icon: Settings },
{id: 'restore', label: 'Explorador & Restore', icon: RotateCcw},
{id: 'users', label: 'Gestión de Cuentas', icon: Users},
{id: 'scripts', label: 'Scripts del Servidor', icon: Server},
{id: 'events', label: 'Auditoría & Logs', icon: FileText},
{id: 'settings', label: 'Configuración Global', icon: Settings},
];
return (
+541
View File
@@ -0,0 +1,541 @@
import React, { useState, useEffect } from 'react';
import {
Play,
Terminal,
Edit,
Trash2,
Plus,
X,
Server,
Clock,
Activity,
FileText,
Check,
AlertCircle,
Loader2,
RefreshCw,
Power
} from 'lucide-react';
import { api, ScriptJobResponse, ScriptJobRunResponse } from '../services/api';
export const ScriptJobsView: React.FC = () => {
const [jobs, setJobs] = useState<ScriptJobResponse[]>([]);
const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Edit / Create Modal State
const [showModal, setShowModal] = useState(false);
const [editingJob, setEditingJob] = useState<ScriptJobResponse | null>(null);
const [formData, setFormData] = useState({
name: '',
script_type: 'python',
script_path: '',
schedule_cron: '0 2 * * *',
is_active: true
});
const [saving, setSaving] = useState(false);
// Runs History Modal State
const [showHistoryModal, setShowHistoryModal] = useState(false);
const [selectedJob, setSelectedJob] = useState<ScriptJobResponse | null>(null);
const [runs, setRuns] = useState<ScriptJobRunResponse[]>([]);
const [loadingRuns, setLoadingRuns] = useState(false);
// Log View Modal State
const [selectedRun, setSelectedRun] = useState<ScriptJobRunResponse | null>(null);
const [showLogModal, setShowLogModal] = useState(false);
const fetchJobs = async () => {
setLoading(true);
setErrorMsg(null);
try {
const data = await api.getScriptJobs();
setJobs(data);
} catch (err: any) {
setErrorMsg(`Error al cargar scripts: ${err.message}`);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchJobs();
}, []);
const handleOpenCreate = () => {
setEditingJob(null);
setFormData({
name: '',
script_type: 'python',
script_path: 'app/scripts/backup_fortigate.py',
schedule_cron: '0 2 * * *',
is_active: true
});
setShowModal(true);
};
const handleOpenEdit = (job: ScriptJobResponse) => {
setEditingJob(job);
setFormData({
name: job.name,
script_type: job.script_type,
script_path: job.script_path,
schedule_cron: job.schedule_cron,
is_active: job.is_active
});
setShowModal(true);
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setErrorMsg(null);
try {
if (editingJob) {
await api.updateScriptJob(editingJob.id, formData);
setSuccessMsg('Script actualizado exitosamente.');
} else {
await api.createScriptJob(formData);
setSuccessMsg('Nuevo script agregado y agendado exitosamente.');
}
setShowModal(false);
fetchJobs();
setTimeout(() => setSuccessMsg(null), 4000);
} catch (err: any) {
setErrorMsg(`Error al guardar: ${err.message}`);
} finally {
setSaving(false);
}
};
const handleDelete = async (jobId: number) => {
if (!window.confirm('¿Está seguro de eliminar este script? Se perderá el historial de ejecuciones.')) {
return;
}
try {
await api.deleteScriptJob(jobId);
setSuccessMsg('Script eliminado exitosamente.');
fetchJobs();
setTimeout(() => setSuccessMsg(null), 4000);
} catch (err: any) {
setErrorMsg(`Error al eliminar: ${err.message}`);
}
};
const handleTrigger = async (jobId: number) => {
try {
const res = await api.triggerScriptJob(jobId);
alert(`✓ Ejecución iniciada: ${res.message}`);
} catch (err: any) {
alert(`✗ Error al iniciar: ${err.message}`);
}
};
const handleOpenHistory = async (job: ScriptJobResponse) => {
setSelectedJob(job);
setShowHistoryModal(true);
setLoadingRuns(true);
try {
const data = await api.getScriptJobRuns(job.id);
setRuns(data);
} catch (err: any) {
alert(`Error al cargar historial: ${err.message}`);
} finally {
setLoadingRuns(false);
}
};
const handleRefreshHistory = async () => {
if (!selectedJob) return;
setLoadingRuns(true);
try {
const data = await api.getScriptJobRuns(selectedJob.id);
setRuns(data);
} catch (err: any) {
alert(`Error al cargar historial: ${err.message}`);
} finally {
setLoadingRuns(false);
}
};
const formatDuration = (seconds: number) => {
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}m ${secs.toFixed(0)}s`;
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 style={{ fontSize: '1.2rem', fontWeight: 700 }}>Scripts y Automatizaciones del Servidor</h3>
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', marginTop: '2px' }}>
Programa y monitorea respaldos basados en Python o Bash para plataformas web externas (Fortigate, UniFi, Grandstream, etc.)
</p>
</div>
<button className="btn btn-primary" onClick={handleOpenCreate} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Plus size={16} />
Nuevo Script
</button>
</div>
{errorMsg && (
<div className="glass-card" style={{ borderLeft: '4px solid var(--accent-rose)', backgroundColor: 'rgba(244, 63, 94, 0.05)', padding: '12px 16px', display: 'flex', gap: '10px', alignItems: 'center' }}>
<AlertCircle size={18} color="var(--accent-rose)" />
<span style={{ fontSize: '0.86rem', color: '#FDA4AF' }}>{errorMsg}</span>
</div>
)}
{successMsg && (
<div className="glass-card" style={{ borderLeft: '4px solid var(--accent-emerald)', backgroundColor: 'rgba(16, 185, 129, 0.05)', padding: '12px 16px', display: 'flex', gap: '10px', alignItems: 'center' }}>
<Check size={18} color="var(--accent-emerald)" />
<span style={{ fontSize: '0.86rem', color: '#A7F3D0' }}>{successMsg}</span>
</div>
)}
<div className="glass-card" style={{ overflow: 'hidden' }}>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '60px' }}>
<Loader2 className="animate-spin" size={32} color="var(--accent-cyan)" />
</div>
) : (
<table className="table">
<thead>
<tr>
<th>Nombre del Trabajo</th>
<th>Tipo</th>
<th>Ruta del Script</th>
<th>Programación Cron</th>
<th>Estado</th>
<th style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job.id}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Server size={16} color="var(--accent-cyan)" />
<span style={{ fontWeight: 600 }}>{job.name}</span>
</div>
</td>
<td>
<span style={{
fontSize: '0.74rem',
padding: '2px 8px',
borderRadius: '4px',
backgroundColor: job.script_type === 'python' ? 'rgba(56, 189, 248, 0.15)' : 'rgba(16, 185, 129, 0.15)',
color: job.script_type === 'python' ? '#38BDF8' : '#34D399',
fontWeight: 600
}}>
{job.script_type.toUpperCase()}
</span>
</td>
<td style={{ fontSize: '0.82rem', fontFamily: 'monospace', color: 'var(--text-muted)' }}>
{job.script_path}
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.82rem' }}>
<Clock size={14} color="var(--text-dim)" />
<span>{job.schedule_cron}</span>
</div>
</td>
<td>
<span style={{
fontSize: '0.74rem',
padding: '2px 8px',
borderRadius: '4px',
backgroundColor: job.is_active ? 'rgba(16, 185, 129, 0.15)' : 'rgba(255,255,255,0.06)',
color: job.is_active ? '#34D399' : 'var(--text-dim)',
fontWeight: 600
}}>
{job.is_active ? 'Activo' : 'Pausado'}
</span>
</td>
<td>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button
className="action-btn"
title="Ejecutar ahora en background"
onClick={() => handleTrigger(job.id)}
>
<Play size={15} color="var(--accent-emerald)" />
</button>
<button
className="action-btn"
title="Ver Historial de Ejecuciones"
onClick={() => handleOpenHistory(job)}
>
<Terminal size={15} color="var(--accent-cyan)" />
</button>
<button
className="action-btn"
title="Editar"
onClick={() => handleOpenEdit(job)}
>
<Edit size={15} color="var(--text-muted)" />
</button>
<button
className="action-btn"
title="Eliminar"
onClick={() => handleDelete(job.id)}
>
<Trash2 size={15} color="var(--accent-rose)" />
</button>
</div>
</td>
</tr>
))}
{jobs.length === 0 && (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
No hay scripts del servidor configurados. Haz clic en "Nuevo Script" para empezar.
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
{/* Create / Edit Modal */}
{showModal && (
<div className="modal-backdrop">
<div className="modal-content" style={{ maxWidth: '500px' }}>
<div className="modal-header">
<h4 style={{ fontSize: '1.1rem', fontWeight: 700 }}>
{editingJob ? 'Editar Script' : 'Nuevo Script del Servidor'}
</h4>
<button className="modal-close" onClick={() => setShowModal(false)}>
<X size={18} />
</button>
</div>
<form onSubmit={handleSave}>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="form-group">
<label>Nombre Descriptivo:</label>
<input
type="text"
className="form-input"
required
placeholder="ej: Respaldo FortiGate Oficina Central"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="form-group">
<label>Tipo de Script:</label>
<select
className="form-input"
value={formData.script_type}
onChange={(e) => setFormData({ ...formData, script_type: e.target.value })}
>
<option value="python">Python Script (.py)</option>
<option value="bash">Bash Script / Command (.sh/.bat/.ps1)</option>
</select>
</div>
<div className="form-group">
<label>Ruta Relativa del Script (En el Servidor):</label>
<input
type="text"
className="form-input"
required
placeholder="ej: app/scripts/backup_fortigate.py"
value={formData.script_path}
onChange={(e) => setFormData({ ...formData, script_path: e.target.value })}
/>
<p style={{ fontSize: '0.72rem', color: 'var(--text-dim)', marginTop: '4px' }}>
Debe apuntar a un archivo existente en el directorio del servidor de OnEver Drive.
</p>
</div>
<div className="form-group">
<label>Programación Cron (Servidor):</label>
<input
type="text"
className="form-input"
required
placeholder="ej: 0 3 * * * (Cada día a las 03:00 AM)"
value={formData.schedule_cron}
onChange={(e) => setFormData({ ...formData, schedule_cron: e.target.value })}
/>
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', marginTop: '10px' }}>
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
/>
Habilitar ejecución automática (Scheduler Activo)
</label>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? <Loader2 className="animate-spin" size={16} /> : 'Guardar Script'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Runs History Modal */}
{showHistoryModal && selectedJob && (
<div className="modal-backdrop">
<div className="modal-content" style={{ maxWidth: '750px', height: '80vh', display: 'flex', flexDirection: 'column' }}>
<div className="modal-header">
<div>
<h4 style={{ fontSize: '1.1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Terminal size={18} color="var(--accent-cyan)" />
Historial de Ejecución: {selectedJob.name}
</h4>
<span style={{ fontSize: '0.78rem', color: 'var(--text-dim)' }}>
Visualiza los logs y estado de cada corrida programada o manual
</span>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button className="btn btn-secondary" style={{ padding: '6px 10px' }} onClick={handleRefreshHistory}>
<RefreshCw size={14} />
</button>
<button className="modal-close" onClick={() => setShowHistoryModal(false)}>
<X size={18} />
</button>
</div>
</div>
<div className="modal-body" style={{ flex: 1, overflowY: 'auto', padding: '16px' }}>
{loadingRuns ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Loader2 className="animate-spin" size={28} color="var(--accent-cyan)" />
</div>
) : (
<table className="table" style={{ width: '100%', fontSize: '0.84rem' }}>
<thead>
<tr>
<th>Fecha de Inicio</th>
<th>Duración</th>
<th>Estado</th>
<th style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.id}>
<td>{new Date(run.started_at).toLocaleString()}</td>
<td>{run.completed_at ? formatDuration(run.duration_seconds) : 'Corriendo...'}</td>
<td>
<span style={{
fontSize: '0.74rem',
padding: '2px 8px',
borderRadius: '4px',
backgroundColor:
run.status === 'SUCCESS' ? 'rgba(16, 185, 129, 0.15)' :
run.status === 'FAILED' ? 'rgba(239, 68, 68, 0.15)' : 'rgba(59, 130, 246, 0.15)',
color:
run.status === 'SUCCESS' ? '#34D399' :
run.status === 'FAILED' ? '#FCA5A5' : '#93C5FD',
fontWeight: 600
}}>
{run.status}
</span>
</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn btn-secondary"
style={{ padding: '4px 10px', fontSize: '0.78rem', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
disabled={!run.completed_at}
onClick={() => {
setSelectedRun(run);
setShowLogModal(true);
}}
>
<FileText size={13} />
Ver Logs de Consola
</button>
</td>
</tr>
))}
{runs.length === 0 && (
<tr>
<td colSpan={4} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
Aún no hay ejecuciones registradas para este script.
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
<div className="modal-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<button className="btn btn-secondary" onClick={() => setShowHistoryModal(false)}>
Cerrar
</button>
</div>
</div>
</div>
)}
{/* Log Console Output Modal */}
{showLogModal && selectedRun && (
<div className="modal-backdrop" style={{ zIndex: 1100 }}>
<div className="modal-content" style={{ maxWidth: '700px', height: '65vh', display: 'flex', flexDirection: 'column' }}>
<div className="modal-header">
<h4 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Terminal size={16} color="var(--accent-cyan)" />
Salida de Consola (Run #{selectedRun.id})
</h4>
<button className="modal-close" onClick={() => setShowLogModal(false)}>
<X size={18} />
</button>
</div>
<div className="modal-body" style={{ flex: 1, padding: '12px', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{
flex: 1,
backgroundColor: '#090d16',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '6px',
padding: '12px',
overflow: 'auto',
fontFamily: 'Consolas, monospace',
fontSize: '0.8rem',
color: '#e2e8f0',
whiteSpace: 'pre-wrap',
lineHeight: '1.4'
}}>
{selectedRun.log_output || 'No se guardaron logs de salida.'}
</div>
</div>
<div className="modal-footer">
<button
className="btn btn-secondary"
onClick={() => {
navigator.clipboard.writeText(selectedRun.log_output || '');
alert('Logs copiados al portapapeles.');
}}
>
Copiar Logs
</button>
<button className="btn btn-primary" onClick={() => setShowLogModal(false)}>
Cerrar Consola
</button>
</div>
</div>
</div>
)}
</div>
);
};
+43
View File
@@ -128,6 +128,26 @@ export interface UserItem {
created_at: string;
}
export interface ScriptJobResponse {
id: number;
name: string;
script_type: string;
script_path: string;
schedule_cron: string;
is_active: boolean;
created_at: string;
}
export interface ScriptJobRunResponse {
id: number;
script_job_id: number;
started_at: string;
completed_at: string | null;
status: string;
duration_seconds: number;
log_output: string | null;
}
export const getAuthToken = (): string | null => {
return localStorage.getItem('oed_token');
};
@@ -252,6 +272,29 @@ export const api = {
body: JSON.stringify(data),
}),
// Script Jobs
getScriptJobs: () => request<ScriptJobResponse[]>('/script-jobs'),
createScriptJob: (data: Omit<ScriptJobResponse, 'id' | 'created_at'>) =>
request<ScriptJobResponse>('/script-jobs', {
method: 'POST',
body: JSON.stringify(data),
}),
updateScriptJob: (jobId: number, data: Partial<ScriptJobResponse>) =>
request<ScriptJobResponse>(`/script-jobs/${jobId}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteScriptJob: (jobId: number) =>
request<{ message: string }>(`/script-jobs/${jobId}`, {
method: 'DELETE',
}),
triggerScriptJob: (jobId: number) =>
request<{ message: string }>(`/script-jobs/${jobId}/trigger`, {
method: 'POST',
}),
getScriptJobRuns: (jobId: number) =>
request<ScriptJobRunResponse[]>(`/script-jobs/${jobId}/runs`),
// Jobs
getJobs: (clientId?: number) =>
request<BackupJobItem[]>(clientId ? `/jobs?client_id=${clientId}` : '/jobs'),