"use client"; import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { useCan } from "@/lib/abilities"; import { OPS_KIND_LABELS, OPS_STATUS_LABELS, formatBytes, formatDateTime, } from "@/lib/labels"; import { backupDownloadUrl, deleteBackup, deleteIngest, getOpsJob, getReplicationStatus, listBackups, listIngest, listOpsJobs, startOpsJob, uploadIngest, } from "@/lib/api"; import type { UploadProgress } from "@/lib/api"; import type { BackupFile, IngestFile, OpsJob, OpsJobKind, ReplicationStatus, } from "@/lib/types"; const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024; export default function OperacionesPage() { return ( ); } type ConfirmState = | { kind: "REIMPORT" } | { kind: "SYNC" } | { kind: "RESTORE"; file: string } | null; function Operaciones() { const allowed = useCan("db:manage"); const [ingest, setIngest] = useState(null); const [backups, setBackups] = useState(null); const [jobs, setJobs] = useState(null); const [activeJob, setActiveJob] = useState(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [confirm, setConfirm] = useState(null); const [confirmText, setConfirmText] = useState(""); const [uploading, setUploading] = useState(null); const [progress, setProgress] = useState(null); const [starting, setStarting] = useState(false); const fileInputs = useRef>({}); const refreshLists = useCallback(() => { listIngest().then(setIngest).catch(() => setIngest([])); listBackups().then(setBackups).catch(() => setBackups([])); listOpsJobs() .then((rows) => { setJobs(rows); const running = rows.find((j) => j.status === "RUNNING"); if (running) setActiveJob(running); }) .catch(() => setJobs([])); }, []); useEffect(() => { if (allowed) refreshLists(); }, [allowed, refreshLists]); // Poll the active job while it runs; refresh everything when it finishes. useEffect(() => { if (!activeJob || activeJob.status !== "RUNNING") return; const id = activeJob.id; const timer = setInterval(() => { getOpsJob(id) .then((job) => { setActiveJob(job); if (job.status !== "RUNNING") { clearInterval(timer); refreshLists(); setNotice( job.status === "SUCCESS" ? `${OPS_KIND_LABELS[job.kind]} completada.` : `${OPS_KIND_LABELS[job.kind]} terminó con error. Revise el registro.`, ); } }) .catch(() => { /* transient — keep polling */ }); }, 1500); return () => clearInterval(timer); }, [activeJob, refreshLists]); if (!allowed) { return (

Operaciones

No tiene permisos para administrar la base de datos.
); } const jobRunning = activeJob?.status === "RUNNING"; async function handleUpload(name: string, file: File | undefined) { if (!file) return; setError(null); setNotice(null); setUploading(name); setProgress(null); try { await uploadIngest(name, file, setProgress); setNotice(`${name} cargado.`); refreshLists(); } catch (e) { setError((e as Error)?.message ?? "No se pudo cargar el archivo."); } finally { setUploading(null); setProgress(null); const input = fileInputs.current[name]; if (input) input.value = ""; } } async function handleDeleteIngest(name: string) { setError(null); try { await deleteIngest(name); refreshLists(); } catch (e) { setError((e as Error)?.message ?? "No se pudo eliminar."); } } async function handleDeleteBackup(name: string) { setError(null); try { await deleteBackup(name); refreshLists(); } catch (e) { setError((e as Error)?.message ?? "No se pudo eliminar el respaldo."); } } async function start(kind: OpsJobKind, file?: string) { setError(null); setNotice(null); setStarting(true); try { const job = await startOpsJob(kind, file); setActiveJob(job); setJobs((prev) => (prev ? [job, ...prev] : [job])); } catch (e) { setError((e as Error)?.message ?? "No se pudo iniciar la operación."); } finally { setStarting(false); } } function askConfirm(state: ConfirmState) { setConfirm(state); setConfirmText(""); setError(null); setNotice(null); } async function runConfirmed() { if (!confirm) return; const c = confirm; setConfirm(null); if (c.kind === "REIMPORT") await start("REIMPORT"); else if (c.kind === "SYNC") await start("SYNC"); else await start("RESTORE", c.file); } const ingestReady = (ingest ?? []).every((f) => f.present); return ( <>

Operaciones de base de datos

{error &&
{error}
} {notice &&
{notice}
} {/* Active / running job with live log */} {activeJob && (

{OPS_KIND_LABELS[activeJob.kind]}{" "} {jobRunning && } {OPS_STATUS_LABELS[activeJob.status]}

{!jobRunning && ( )}
{activeJob.log || "Iniciando…"}
)} {/* Ingest folder */}

Carpeta de ingesta

Los cuatro archivos originales de Access. La reimportación y la sincronización leen de aquí. Tamaño máximo por archivo: {formatBytes(INGEST_MAX_BYTES)}.

{(ingest ?? []).map((f) => ( {uploading === f.name && ( )} ))}
Archivo Estado Tamaño Modificado Acciones
{f.name} {f.present ? "Presente" : "Falta"} {formatBytes(f.size)} {formatDateTime(f.modifiedAt)}
{ fileInputs.current[f.name] = el; }} type="file" style={{ display: "none" }} onChange={(e) => handleUpload(f.name, e.target.files?.[0])} /> {f.present && ( )}
{/* Operations */}

Operaciones

start("BACKUP")} /> askConfirm({ kind: "REIMPORT" })} /> askConfirm({ kind: "SYNC" })} />
{!ingestReady && (

La reimportación requiere que los cuatro archivos estén presentes.

)}
{/* Backups */}

Respaldos

Restaurar sobreescribe la base de datos completa con el respaldo elegido.

{backups === null ? ( ) : backups.length === 0 ? ( ) : ( backups.map((b) => ( )) )}
Archivo Tamaño Creado Acciones
Sin respaldos.
{b.name} {formatBytes(b.size)} {formatDateTime(b.createdAt)}
Descargar
{/* Recent jobs */}

Historial

{(jobs ?? []).map((j) => ( ))} {jobs && jobs.length === 0 && ( )}
Operación Estado Inicio Fin
{OPS_KIND_LABELS[j.kind]} {OPS_STATUS_LABELS[j.status]} {formatDateTime(j.startedAt)} {formatDateTime(j.finishedAt)}
Sin operaciones registradas.
{/* Destructive-op confirm */} {confirm && (

{confirm.kind === "REIMPORT" ? "Confirmar reimportación" : confirm.kind === "SYNC" ? "Confirmar sincronización" : "Confirmar restauración"}

{confirm.kind === "REIMPORT" ? "Esto BORRA todos los datos actuales (incluidos los capturados a mano) y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático." : confirm.kind === "SYNC" ? "Se creará un respaldo previo automático. Luego se importarán al sistema los registros nuevos del legado y se eliminarán los del legado que ya no aparezcan en los archivos de ingesta. Los datos capturados a mano NO se borran." : `Esto sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`}

)} ); } /** "1:05" / "0:09" — remaining time, coarse on purpose. */ function formatEta(seconds: number): string { const s = Math.max(0, Math.round(seconds)); if (s >= 3600) { const h = Math.floor(s / 3600); const m = Math.round((s % 3600) / 60); return `${h} h ${m} min`; } return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; } /** * Live upload readout. The bar tracks bytes handed to the network; once those * are all sent the server still has to write the file, so the tail of the * upload reads "Procesando…" instead of sitting at 100%. */ function UploadProgressBar({ progress }: { progress: UploadProgress | null }) { const pct = progress?.fraction != null ? Math.round(progress.fraction * 100) : null; return (
{progress === null ? ( "Preparando…" ) : progress.finishing ? ( `Procesando en el servidor… (${formatBytes(progress.total)} enviados)` ) : ( <> {pct !== null && {pct}%} {progress.total > 0 && ( {formatBytes(progress.loaded)} / {formatBytes(progress.total)} )} {progress.bytesPerSecond > 0 && ( {formatBytes(progress.bytesPerSecond)}/s )} {progress.secondsRemaining !== null && progress.bytesPerSecond > 0 && ( faltan {formatEta(progress.secondsRemaining)} )} )}
); } function OpTile({ title, desc, action, tone, disabled, onClick, }: { title: string; desc: string; action: string; tone: "primary" | "danger" | "muted"; disabled: boolean; onClick: () => void; }) { const btnClass = tone === "danger" ? "btn btn-danger" : tone === "muted" ? "btn btn-outline" : "btn btn-primary"; return (

{title}

{desc}

); } /** * Health of the read replica behind my.jorgecuadros.com. * * Worth a panel because the failure mode is silent: a replica whose SQL thread * has stopped keeps answering queries, just with data frozen at the moment it * stopped. Nothing on the customer site looks wrong — the balances are simply * out of date — so without this the only signal is a customer complaining. */ function ReplicationCard() { const [status, setStatus] = useState(null); const [failed, setFailed] = useState(false); const load = useCallback(() => { getReplicationStatus() .then((s) => { setStatus(s); setFailed(false); }) .catch(() => setFailed(true)); }, []); useEffect(() => { load(); const t = setInterval(load, 30_000); return () => clearInterval(t); }, [load]); // Not configured is the normal state in dev and before cutover, so it is a // quiet note rather than an alarm — showing red here would train people to // ignore the card. if (failed || (status && !status.configured)) { return (

Réplica del sitio de clientes

{failed ? "No se pudo consultar el estado de la réplica." : "No configurada en este entorno."}

); } if (!status) { return (

Réplica del sitio de clientes

Consultando…

); } return (

Réplica del sitio de clientes{" "} {status.healthy ? "Replicando" : "Detenida"}

{status.problem && (
{status.problem}
)}
{/* Never render a null lag as "0 s": MySQL reports NULL whenever a thread is down, so the honest word is "unknown", not "up to date". */}
); } /** Matches the KV in the clientes/polizas/servicios detail pages. */ function KV({ label, value }: { label: string; value: string | null | undefined }) { return (
{label}
{value || "—"}
); } /** * Step progress for a running migration. * * Only REIMPORT and SYNC report steps; BACKUP and RESTORE are a single * mysqldump, so they render nothing here rather than a made-up bar — the * spinner in the heading already says "working". * * The safety backup runs before the migration, so `progress` is null for the * first stretch of every REIMPORT. That phase is named explicitly instead of * showing 0%, which would read as "stuck". */ function JobProgressBar({ job }: { job: OpsJob }) { const running = job.status === "RUNNING"; const p = job.progress; if (!p) { if (!running) return null; return (

Respaldo de seguridad previo…

); } return (
Paso {p.step} de {p.total} — {p.name} {p.percent}%
); }