feat: script jobs scheduler and manager page for Fortigate, UniFi and Grandstream scripts
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user