wip: ops admin panel + migration sync + crud/rbac phase-5 snapshot
Working-tree checkpoint of in-progress work carried across prior sessions on the feat/crud-rbac branch, committed so it lands on the remote alongside the CI changes. - Operaciones admin panel: apps/api/src/ops (ingest upload, backup / restore / re-import jobs) wired into app.module + RBAC abilities, and the apps/web/src/app/operaciones page. docker-compose gets INGEST_DIR / BACKUP_DIR volumes; .gitignore excludes migration/ingest + backups. - migration/sync.py plus transform_*.py / run_all / config / dbenv / blob_extract adjustments for the additive sync path. - crud/rbac phase-5 web bits: AppShell, api/labels/types libs, globals. - schema.prisma + PLAN/RESUME doc updates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -247,6 +247,7 @@ button {
|
||||
border-radius: 7px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.appbar-link:hover {
|
||||
color: #fff;
|
||||
@@ -400,13 +401,24 @@ button {
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
border-color: var(--brand-600);
|
||||
text-decoration: none;
|
||||
}
|
||||
/* Dark appbar keeps the light-on-dark ghost button. */
|
||||
.appbar .btn-ghost {
|
||||
color: rgba(242, 239, 231, 0.85);
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
.appbar .btn-ghost:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
.btn-outline {
|
||||
background: var(--surface);
|
||||
@@ -2153,3 +2165,42 @@ button {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* --- Admin DB operations (Operaciones) --- */
|
||||
.btn-danger {
|
||||
background: var(--negative);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
filter: brightness(0.94);
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ops-log {
|
||||
margin: 12px 0 0;
|
||||
padding: 12px 14px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
"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";
|
||||
|
||||
export default function OperacionesPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Operaciones />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
type ConfirmState =
|
||||
| { kind: "REIMPORT" }
|
||||
| { 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 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í.
|
||||
</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="Conserva los datos actuales e importa solo lo nuevo del legado. Disponible en la Fase B."
|
||||
action="Próximamente"
|
||||
tone="muted"
|
||||
disabled
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</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" : "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."
|
||||
: `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="btn btn-danger"
|
||||
type="button"
|
||||
disabled={confirmText !== "CONFIRMAR" || starting}
|
||||
onClick={runConfirmed}
|
||||
>
|
||||
{confirm.kind === "REIMPORT" ? "Reimportar" : "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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user