530 lines
18 KiB
TypeScript
530 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { 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,
|
|
listBackups,
|
|
listIngest,
|
|
listOpsJobs,
|
|
startOpsJob,
|
|
uploadIngest,
|
|
} from "@/lib/api";
|
|
import type {
|
|
BackupFile,
|
|
IngestFile,
|
|
OpsJob,
|
|
OpsJobKind,
|
|
} from "@/lib/types";
|
|
|
|
const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
|
|
|
export default function OperacionesPage() {
|
|
return (
|
|
<AppShell>
|
|
<Operaciones />
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
type ConfirmState =
|
|
| { kind: "REIMPORT" }
|
|
| { kind: "SYNC" }
|
|
| { kind: "RESTORE"; file: string }
|
|
| null;
|
|
|
|
function Operaciones() {
|
|
const allowed = useCan("db:manage");
|
|
|
|
const [ingest, setIngest] = useState<IngestFile[] | null>(null);
|
|
const [backups, setBackups] = useState<BackupFile[] | null>(null);
|
|
const [jobs, setJobs] = useState<OpsJob[] | null>(null);
|
|
const [activeJob, setActiveJob] = useState<OpsJob | null>(null);
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [notice, setNotice] = useState<string | null>(null);
|
|
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
|
const [confirmText, setConfirmText] = useState("");
|
|
const [uploading, setUploading] = useState<string | null>(null);
|
|
const [starting, setStarting] = useState(false);
|
|
|
|
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
|
|
|
|
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 (
|
|
<div className="page-head">
|
|
<h1 className="page-title">Operaciones</h1>
|
|
<div className="state-box state-error">
|
|
No tiene permisos para administrar la base de datos.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const jobRunning = activeJob?.status === "RUNNING";
|
|
|
|
async function handleUpload(name: string, file: File | undefined) {
|
|
if (!file) return;
|
|
setError(null);
|
|
setNotice(null);
|
|
setUploading(name);
|
|
try {
|
|
await uploadIngest(name, file);
|
|
setNotice(`${name} cargado.`);
|
|
refreshLists();
|
|
} catch (e) {
|
|
setError((e as Error)?.message ?? "No se pudo cargar el archivo.");
|
|
} finally {
|
|
setUploading(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 (
|
|
<>
|
|
<div className="page-head">
|
|
<h1 className="page-title">Operaciones de base de datos</h1>
|
|
</div>
|
|
|
|
{error && <div className="state-box state-error">{error}</div>}
|
|
{notice && <div className="state-box">{notice}</div>}
|
|
|
|
{/* Active / running job with live log */}
|
|
{activeJob && (
|
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
|
<div className="row-actions" style={{ justifyContent: "space-between" }}>
|
|
<h2 className="section-title" style={{ margin: 0 }}>
|
|
{OPS_KIND_LABELS[activeJob.kind]}{" "}
|
|
<span
|
|
className={`badge ${
|
|
activeJob.status === "SUCCESS"
|
|
? "badge-positive"
|
|
: activeJob.status === "FAILED"
|
|
? "badge-negative"
|
|
: "badge-neutral"
|
|
}`}
|
|
>
|
|
{jobRunning && <span className="spinner" aria-hidden style={{ marginRight: 6 }} />}
|
|
{OPS_STATUS_LABELS[activeJob.status]}
|
|
</span>
|
|
</h2>
|
|
{!jobRunning && (
|
|
<button className="btn btn-ghost" type="button" onClick={() => setActiveJob(null)}>
|
|
Ocultar
|
|
</button>
|
|
)}
|
|
</div>
|
|
<pre className="ops-log">{activeJob.log || "Iniciando…"}</pre>
|
|
</div>
|
|
)}
|
|
|
|
{/* Ingest folder */}
|
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
|
<h2 className="section-title">Carpeta de ingesta</h2>
|
|
<p className="inline-form-note">
|
|
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)}.
|
|
</p>
|
|
<div className="tx-scroll">
|
|
<table className="tx-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Archivo</th>
|
|
<th>Estado</th>
|
|
<th className="num">Tamaño</th>
|
|
<th>Modificado</th>
|
|
<th className="num">Acciones</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(ingest ?? []).map((f) => (
|
|
<tr key={f.name}>
|
|
<td className="mono">{f.name}</td>
|
|
<td>
|
|
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
|
|
{f.present ? "Presente" : "Falta"}
|
|
</span>
|
|
</td>
|
|
<td className="num">{formatBytes(f.size)}</td>
|
|
<td>{formatDateTime(f.modifiedAt)}</td>
|
|
<td>
|
|
<div className="row-actions">
|
|
<input
|
|
ref={(el) => {
|
|
fileInputs.current[f.name] = el;
|
|
}}
|
|
type="file"
|
|
style={{ display: "none" }}
|
|
onChange={(e) => handleUpload(f.name, e.target.files?.[0])}
|
|
/>
|
|
<button
|
|
className="btn btn-outline"
|
|
type="button"
|
|
disabled={uploading === f.name}
|
|
onClick={() => fileInputs.current[f.name]?.click()}
|
|
>
|
|
{uploading === f.name ? "Cargando…" : f.present ? "Reemplazar" : "Cargar"}
|
|
</button>
|
|
{f.present && (
|
|
<button
|
|
className="btn btn-ghost"
|
|
type="button"
|
|
onClick={() => handleDeleteIngest(f.name)}
|
|
>
|
|
Eliminar
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Operations */}
|
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
|
<h2 className="section-title">Operaciones</h2>
|
|
<div className="form-grid">
|
|
<OpTile
|
|
title="Respaldo"
|
|
desc="Genera un volcado comprimido de la base de datos actual."
|
|
action="Crear respaldo"
|
|
tone="primary"
|
|
disabled={jobRunning || starting}
|
|
onClick={() => start("BACKUP")}
|
|
/>
|
|
<OpTile
|
|
title="Reimportar (purga)"
|
|
desc="Respalda, borra TODO y reconstruye desde los archivos de ingesta. Se pierden los datos capturados manualmente."
|
|
action="Reimportar"
|
|
tone="danger"
|
|
disabled={jobRunning || starting || !ingestReady}
|
|
onClick={() => askConfirm({ kind: "REIMPORT" })}
|
|
/>
|
|
<OpTile
|
|
title="Sincronizar"
|
|
desc="Respalda, luego importa lo nuevo del legado. Borra del sistema los registros del legado que ya no aparecen en los archivos de ingesta. Se conservan los datos capturados a mano."
|
|
action="Sincronizar"
|
|
tone="primary"
|
|
disabled={jobRunning || starting || !ingestReady}
|
|
onClick={() => askConfirm({ kind: "SYNC" })}
|
|
/>
|
|
</div>
|
|
{!ingestReady && (
|
|
<p className="inline-form-note" style={{ marginTop: 12 }}>
|
|
La reimportación requiere que los cuatro archivos estén presentes.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Backups */}
|
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
|
<h2 className="section-title">Respaldos</h2>
|
|
<p className="inline-form-note">
|
|
Restaurar sobreescribe la base de datos completa con el respaldo elegido.
|
|
</p>
|
|
<div className="tx-scroll">
|
|
<table className="tx-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Archivo</th>
|
|
<th className="num">Tamaño</th>
|
|
<th>Creado</th>
|
|
<th className="num">Acciones</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{backups === null ? (
|
|
<tr>
|
|
<td colSpan={4}>
|
|
<span className="spinner" aria-label="Cargando" />
|
|
</td>
|
|
</tr>
|
|
) : backups.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={4} className="muted">
|
|
Sin respaldos.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
backups.map((b) => (
|
|
<tr key={b.name}>
|
|
<td className="mono">{b.name}</td>
|
|
<td className="num">{formatBytes(b.size)}</td>
|
|
<td>{formatDateTime(b.createdAt)}</td>
|
|
<td>
|
|
<div className="row-actions">
|
|
<a className="btn btn-outline" href={backupDownloadUrl(b.name)}>
|
|
Descargar
|
|
</a>
|
|
<button
|
|
className="btn btn-outline"
|
|
type="button"
|
|
disabled={jobRunning || starting}
|
|
onClick={() => askConfirm({ kind: "RESTORE", file: b.name })}
|
|
>
|
|
Restaurar
|
|
</button>
|
|
<button
|
|
className="btn btn-ghost"
|
|
type="button"
|
|
onClick={() => handleDeleteBackup(b.name)}
|
|
>
|
|
Eliminar
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent jobs */}
|
|
<div className="card" style={{ padding: 20 }}>
|
|
<h2 className="section-title">Historial</h2>
|
|
<div className="tx-scroll">
|
|
<table className="tx-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Operación</th>
|
|
<th>Estado</th>
|
|
<th>Inicio</th>
|
|
<th>Fin</th>
|
|
<th className="num"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(jobs ?? []).map((j) => (
|
|
<tr key={j.id}>
|
|
<td>{OPS_KIND_LABELS[j.kind]}</td>
|
|
<td>
|
|
<span
|
|
className={`badge ${
|
|
j.status === "SUCCESS"
|
|
? "badge-positive"
|
|
: j.status === "FAILED"
|
|
? "badge-negative"
|
|
: "badge-neutral"
|
|
}`}
|
|
>
|
|
{OPS_STATUS_LABELS[j.status]}
|
|
</span>
|
|
</td>
|
|
<td>{formatDateTime(j.startedAt)}</td>
|
|
<td>{formatDateTime(j.finishedAt)}</td>
|
|
<td className="num">
|
|
<button
|
|
className="btn btn-ghost"
|
|
type="button"
|
|
onClick={() => setActiveJob(j)}
|
|
>
|
|
Ver registro
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{jobs && jobs.length === 0 && (
|
|
<tr>
|
|
<td colSpan={5} className="muted">
|
|
Sin operaciones registradas.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Destructive-op confirm */}
|
|
{confirm && (
|
|
<div className="modal-backdrop" role="dialog" aria-modal="true">
|
|
<div className="card" style={{ padding: 24, maxWidth: 480 }}>
|
|
<h2 className="section-title" style={{ marginTop: 0 }}>
|
|
{confirm.kind === "REIMPORT"
|
|
? "Confirmar reimportación"
|
|
: confirm.kind === "SYNC"
|
|
? "Confirmar sincronización"
|
|
: "Confirmar restauración"}
|
|
</h2>
|
|
<p className="inline-form-note">
|
|
{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.`}
|
|
</p>
|
|
<label className="field">
|
|
<span className="field-label">Escriba CONFIRMAR para continuar</span>
|
|
<input
|
|
className="input"
|
|
value={confirmText}
|
|
onChange={(e) => setConfirmText(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
</label>
|
|
<div className="form-actions">
|
|
<button
|
|
className={confirm.kind === "SYNC" ? "btn btn-primary" : "btn btn-danger"}
|
|
type="button"
|
|
disabled={confirmText !== "CONFIRMAR" || starting}
|
|
onClick={runConfirmed}
|
|
>
|
|
{confirm.kind === "REIMPORT"
|
|
? "Reimportar"
|
|
: confirm.kind === "SYNC"
|
|
? "Sincronizar"
|
|
: "Restaurar"}
|
|
</button>
|
|
<button className="btn btn-outline" type="button" onClick={() => setConfirm(null)}>
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="card" style={{ padding: 16 }}>
|
|
<h3 className="section-title" style={{ fontSize: 15, margin: "0 0 4px" }}>
|
|
{title}
|
|
</h3>
|
|
<p className="inline-form-note" style={{ minHeight: 48 }}>
|
|
{desc}
|
|
</p>
|
|
<button className={btnClass} type="button" disabled={disabled} onClick={onClick}>
|
|
{action}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|