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:
@@ -9,6 +9,7 @@ import { PoliciesModule } from "./policies/policies.module";
|
||||
import { PropertiesModule } from "./properties/properties.module";
|
||||
import { BillingModule } from "./billing/billing.module";
|
||||
import { BankModule } from "./bank/bank.module";
|
||||
import { OpsModule } from "./ops/ops.module";
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@Module({
|
||||
@@ -23,6 +24,7 @@ import { AppController } from "./app.controller";
|
||||
PropertiesModule,
|
||||
BillingModule,
|
||||
BankModule,
|
||||
OpsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
||||
@@ -32,7 +32,8 @@ export type Ability =
|
||||
| "bank:create"
|
||||
| "bank:void"
|
||||
| "lookup:manage"
|
||||
| "user:manage";
|
||||
| "user:manage"
|
||||
| "db:manage";
|
||||
|
||||
/** Minimum role required for each ability. */
|
||||
export const ABILITY_MIN: Record<Ability, Role> = {
|
||||
@@ -51,6 +52,7 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
||||
"bank:void": "MANAGER",
|
||||
"lookup:manage": "MANAGER",
|
||||
"user:manage": "ADMIN",
|
||||
"db:manage": "ADMIN",
|
||||
};
|
||||
|
||||
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { Request, Response } from "express";
|
||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||
import { AbilityGuard } from "../auth/ability.guard";
|
||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { OpsService } from "./ops.service";
|
||||
import { StartJobDto } from "./start-job.dto";
|
||||
|
||||
/** Every route is ADMIN-only (ability "db:manage"). */
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@RequireAbility("db:manage")
|
||||
@Controller("ops")
|
||||
export class OpsController {
|
||||
constructor(
|
||||
private readonly ops: OpsService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
private actingId(req: Request): string {
|
||||
return (req.user as { id: string }).id;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- ingest */
|
||||
|
||||
@Get("ingest")
|
||||
listIngest() {
|
||||
return this.ops.listIngest();
|
||||
}
|
||||
|
||||
@Post("ingest/:name")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", { limits: { fileSize: 500 * 1024 * 1024 } }),
|
||||
)
|
||||
async uploadIngest(
|
||||
@Param("name") name: string,
|
||||
@UploadedFile() file: { buffer: Buffer; size: number } | undefined,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
if (!file) throw new Error("No se recibió ningún archivo.");
|
||||
await this.ops.saveIngest(name, file.buffer);
|
||||
void this.audit.log(this.actingId(req), "ops.ingest.upload", {
|
||||
name,
|
||||
size: file.size,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Delete("ingest/:name")
|
||||
async deleteIngest(@Param("name") name: string, @Req() req: Request) {
|
||||
await this.ops.deleteIngest(name);
|
||||
void this.audit.log(this.actingId(req), "ops.ingest.delete", { name });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ backups */
|
||||
|
||||
@Get("backups")
|
||||
listBackups() {
|
||||
return this.ops.listBackups();
|
||||
}
|
||||
|
||||
@Get("backups/:name/download")
|
||||
download(
|
||||
@Param("name") name: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): StreamableFile {
|
||||
const { stream, name: safe } = this.ops.backupStream(name);
|
||||
res.set({
|
||||
"Content-Type": "application/gzip",
|
||||
"Content-Disposition": `attachment; filename="${safe}"`,
|
||||
});
|
||||
return new StreamableFile(stream);
|
||||
}
|
||||
|
||||
@Delete("backups/:name")
|
||||
async deleteBackup(@Param("name") name: string, @Req() req: Request) {
|
||||
await this.ops.deleteBackup(name);
|
||||
void this.audit.log(this.actingId(req), "ops.backup.delete", { name });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- jobs */
|
||||
|
||||
@Get("jobs")
|
||||
listJobs() {
|
||||
return this.ops.listJobs();
|
||||
}
|
||||
|
||||
@Get("jobs/:id")
|
||||
getJob(@Param("id") id: string) {
|
||||
return this.ops.getJob(id);
|
||||
}
|
||||
|
||||
@Post("jobs")
|
||||
async startJob(@Body() dto: StartJobDto, @Req() req: Request) {
|
||||
const userId = this.actingId(req);
|
||||
const job = await this.ops.startJob(dto.kind, { file: dto.file }, userId);
|
||||
void this.audit.log(userId, "ops.job.start", {
|
||||
jobId: job.id,
|
||||
kind: dto.kind,
|
||||
file: dto.file,
|
||||
});
|
||||
return job;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { OpsController } from "./ops.controller";
|
||||
import { OpsService } from "./ops.service";
|
||||
|
||||
@Module({
|
||||
controllers: [OpsController],
|
||||
providers: [OpsService],
|
||||
})
|
||||
export class OpsModule {}
|
||||
@@ -0,0 +1,361 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createReadStream, promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { OpsJobKind } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
/**
|
||||
* Admin database operations. Everything long-running (mysqldump, mysql restore,
|
||||
* the Python migration) runs as a detached child process recorded as one OpsJob
|
||||
* row whose `log` is appended as the process talks; the web polls that row.
|
||||
*
|
||||
* Only ONE mutating job runs at a time (a RUNNING row blocks a new start) — a
|
||||
* restore or re-import racing a migration would corrupt the database.
|
||||
*/
|
||||
|
||||
/** The four legacy Access files. Uploads are allowlisted to exactly these
|
||||
* names so an ingest write can never land at an arbitrary path. */
|
||||
export const INGEST_FILES = [
|
||||
"UTILITIES.accdb",
|
||||
"SEGUROS 16.mdb",
|
||||
"SEGUROS 16_be.mdb",
|
||||
"SCOTHIA.mdb",
|
||||
] as const;
|
||||
export type IngestName = (typeof INGEST_FILES)[number];
|
||||
|
||||
interface MysqlConn {
|
||||
host: string;
|
||||
port: string;
|
||||
user: string;
|
||||
password: string;
|
||||
database: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OpsService implements OnModuleInit {
|
||||
private readonly logger = new Logger(OpsService.name);
|
||||
|
||||
private readonly migrationDir =
|
||||
process.env.MIGRATION_DIR ?? path.resolve(process.cwd(), "migration");
|
||||
private readonly ingestDir =
|
||||
process.env.INGEST_DIR ?? path.join(this.migrationDir, "ingest");
|
||||
private readonly backupDir =
|
||||
process.env.BACKUP_DIR ?? path.join(this.migrationDir, "backups");
|
||||
private readonly migrationEnv = process.env.MIGRATION_ENV ?? "dev";
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await fs.mkdir(this.ingestDir, { recursive: true });
|
||||
await fs.mkdir(this.backupDir, { recursive: true });
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- ingest */
|
||||
|
||||
private assertIngestName(name: string): IngestName {
|
||||
if (!INGEST_FILES.includes(name as IngestName)) {
|
||||
throw new BadRequestException(
|
||||
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return name as IngestName;
|
||||
}
|
||||
|
||||
async listIngest(): Promise<
|
||||
{ name: string; present: boolean; size: number | null; modifiedAt: string | null }[]
|
||||
> {
|
||||
return Promise.all(
|
||||
INGEST_FILES.map(async (name) => {
|
||||
try {
|
||||
const st = await fs.stat(path.join(this.ingestDir, name));
|
||||
return {
|
||||
name,
|
||||
present: true,
|
||||
size: st.size,
|
||||
modifiedAt: st.mtime.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return { name, present: false, size: null, modifiedAt: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async saveIngest(name: string, data: Buffer): Promise<void> {
|
||||
const safe = this.assertIngestName(name);
|
||||
await fs.writeFile(path.join(this.ingestDir, safe), data);
|
||||
}
|
||||
|
||||
async deleteIngest(name: string): Promise<void> {
|
||||
const safe = this.assertIngestName(name);
|
||||
await fs.rm(path.join(this.ingestDir, safe), { force: true });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- backups */
|
||||
|
||||
private assertBackupName(name: string): string {
|
||||
// No path separators, must be a produced backup file.
|
||||
if (!/^[A-Za-z0-9._-]+\.sql\.gz$/.test(name)) {
|
||||
throw new BadRequestException("Nombre de respaldo inválido.");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
async listBackups(): Promise<
|
||||
{ name: string; size: number; createdAt: string }[]
|
||||
> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(this.backupDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const rows = await Promise.all(
|
||||
names
|
||||
.filter((n) => n.endsWith(".sql.gz"))
|
||||
.map(async (name) => {
|
||||
const st = await fs.stat(path.join(this.backupDir, name));
|
||||
return { name, size: st.size, createdAt: st.mtime.toISOString() };
|
||||
}),
|
||||
);
|
||||
return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
backupStream(name: string) {
|
||||
const safe = this.assertBackupName(name);
|
||||
const full = path.join(this.backupDir, safe);
|
||||
return { stream: createReadStream(full), name: safe };
|
||||
}
|
||||
|
||||
async deleteBackup(name: string): Promise<void> {
|
||||
const safe = this.assertBackupName(name);
|
||||
await fs.rm(path.join(this.backupDir, safe), { force: true });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- jobs */
|
||||
|
||||
listJobs(limit = 20) {
|
||||
return this.prisma.opsJob.findMany({
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async getJob(id: string) {
|
||||
const job = await this.prisma.opsJob.findUnique({ where: { id } });
|
||||
if (!job) throw new NotFoundException("Trabajo no encontrado.");
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a mutating op. Refuses if another job is already RUNNING. Returns the
|
||||
* new job row immediately; the process runs on in the background and appends
|
||||
* to `log` until it exits.
|
||||
*/
|
||||
async startJob(
|
||||
kind: OpsJobKind,
|
||||
params: Record<string, unknown>,
|
||||
userId: string | undefined,
|
||||
) {
|
||||
const running = await this.prisma.opsJob.count({ where: { status: "RUNNING" } });
|
||||
if (running > 0) {
|
||||
throw new ConflictException(
|
||||
"Ya hay una operación en curso. Espere a que termine.",
|
||||
);
|
||||
}
|
||||
|
||||
const conn = this.parseDbUrl();
|
||||
const { cmd, resolvedParams } = await this.buildCommand(kind, params, conn);
|
||||
|
||||
const job = await this.prisma.opsJob.create({
|
||||
data: {
|
||||
kind,
|
||||
status: "RUNNING",
|
||||
log: "",
|
||||
params: resolvedParams as object,
|
||||
createdById: userId,
|
||||
},
|
||||
});
|
||||
|
||||
this.run(job.id, cmd, conn.password);
|
||||
return job;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- internals */
|
||||
|
||||
private parseDbUrl(): MysqlConn {
|
||||
const raw = process.env.DATABASE_URL;
|
||||
if (!raw) throw new BadRequestException("DATABASE_URL no está configurada.");
|
||||
const u = new URL(raw);
|
||||
return {
|
||||
host: u.hostname,
|
||||
port: u.port || "3306",
|
||||
user: decodeURIComponent(u.username),
|
||||
password: decodeURIComponent(u.password),
|
||||
database: u.pathname.replace(/^\//, ""),
|
||||
};
|
||||
}
|
||||
|
||||
/** mysql/mysqldump connection flags. The password goes through MYSQL_PWD in
|
||||
* the child env, never on the command line (which would leak via `ps`). */
|
||||
private connFlags(c: MysqlConn): string {
|
||||
return `--host=${c.host} --port=${c.port} --user=${shq(c.user)}`;
|
||||
}
|
||||
|
||||
private timestamp(): string {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
|
||||
}
|
||||
|
||||
private async buildCommand(
|
||||
kind: OpsJobKind,
|
||||
params: Record<string, unknown>,
|
||||
conn: MysqlConn,
|
||||
): Promise<{ cmd: string; resolvedParams: Record<string, unknown> }> {
|
||||
const flags = this.connFlags(conn);
|
||||
const db = shq(conn.database);
|
||||
|
||||
if (kind === "BACKUP") {
|
||||
const file = `backup-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
|
||||
const out = shq(path.join(this.backupDir, file));
|
||||
return {
|
||||
cmd: `mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out}`,
|
||||
resolvedParams: { file },
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "RESTORE") {
|
||||
const name = this.assertBackupName(String(params.file ?? ""));
|
||||
const full = path.join(this.backupDir, name);
|
||||
await fs.access(full).catch(() => {
|
||||
throw new NotFoundException(`Respaldo no encontrado: ${name}`);
|
||||
});
|
||||
return {
|
||||
cmd: `gunzip -c ${shq(full)} | mysql ${flags} ${db}`,
|
||||
resolvedParams: { file: name },
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "SYNC") {
|
||||
const file = `pre-sync-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
|
||||
const out = shq(path.join(this.backupDir, file));
|
||||
const py = await this.pythonBin();
|
||||
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||
const cmd =
|
||||
`echo '== Respaldo de seguridad previo ==' && ` +
|
||||
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` +
|
||||
`echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` +
|
||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`;
|
||||
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||
}
|
||||
|
||||
if (kind === "REIMPORT") {
|
||||
// Safety backup first, then a full truncate+rebuild from the ingest files.
|
||||
const file = `pre-reimport-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
|
||||
const out = shq(path.join(this.backupDir, file));
|
||||
const py = await this.pythonBin();
|
||||
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||
const cmd =
|
||||
`echo '== Respaldo de seguridad previo ==' && ` +
|
||||
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` +
|
||||
`echo '== Reimportación desde carpeta de ingesta ==' && ` +
|
||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`;
|
||||
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||
}
|
||||
|
||||
throw new BadRequestException(`Operación no soportada: ${kind}`);
|
||||
}
|
||||
|
||||
/** Prefer the migration venv python if it exists (local dev), else system. */
|
||||
private async pythonBin(): Promise<string> {
|
||||
const venv = path.join(this.migrationDir, ".venv", "bin", "python");
|
||||
try {
|
||||
await fs.access(venv);
|
||||
return venv;
|
||||
} catch {
|
||||
return process.env.PYTHON_BIN ?? "python3";
|
||||
}
|
||||
}
|
||||
|
||||
private run(jobId: string, cmd: string, password: string): void {
|
||||
const child = spawn("sh", ["-c", cmd], {
|
||||
cwd: this.migrationDir,
|
||||
env: {
|
||||
...process.env,
|
||||
MYSQL_PWD: password,
|
||||
INGEST_DIR: this.ingestDir,
|
||||
BACKUP_DIR: this.backupDir,
|
||||
},
|
||||
});
|
||||
|
||||
let buffer = "";
|
||||
let pending = "";
|
||||
let flushing = false;
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const flush = async () => {
|
||||
if (flushing || !pending) return;
|
||||
flushing = true;
|
||||
const chunk = pending;
|
||||
pending = "";
|
||||
try {
|
||||
await this.prisma.opsJob.update({
|
||||
where: { id: jobId },
|
||||
data: { log: { set: buffer } },
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.warn(`ops job ${jobId} log flush failed: ${String(e)}`);
|
||||
} finally {
|
||||
flushing = false;
|
||||
void chunk;
|
||||
}
|
||||
};
|
||||
|
||||
const onData = (d: Buffer) => {
|
||||
const text = d.toString();
|
||||
buffer += text;
|
||||
pending += text;
|
||||
if (!flushTimer) {
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
void flush();
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on("data", onData);
|
||||
child.stderr.on("data", onData);
|
||||
|
||||
const finalize = async (status: "SUCCESS" | "FAILED", tail: string) => {
|
||||
if (flushTimer) clearTimeout(flushTimer);
|
||||
buffer += tail;
|
||||
await this.prisma.opsJob
|
||||
.update({
|
||||
where: { id: jobId },
|
||||
data: { status, log: { set: buffer }, finishedAt: new Date() },
|
||||
})
|
||||
.catch((e) => this.logger.error(`ops job ${jobId} finalize failed: ${String(e)}`));
|
||||
};
|
||||
|
||||
child.on("error", (err) => {
|
||||
void finalize("FAILED", `\n[proceso no pudo iniciar] ${err.message}\n`);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) void finalize("SUCCESS", `\n[completado con éxito]\n`);
|
||||
else void finalize("FAILED", `\n[terminó con código ${code}]\n`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Single-quote a value for a POSIX shell command. */
|
||||
function shq(v: string): string {
|
||||
return `'${v.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsEnum, IsOptional, IsString } from "class-validator";
|
||||
import { OpsJobKind } from "@jorgecuadros/database";
|
||||
|
||||
export class StartJobDto {
|
||||
@IsEnum(OpsJobKind)
|
||||
kind!: OpsJobKind;
|
||||
|
||||
/** Target backup filename — required for RESTORE, ignored otherwise. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
file?: string;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const NAV: { href: string; label: string; ability?: Ability }[] = [
|
||||
{ href: "/banco", label: "Chequera" },
|
||||
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
||||
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
||||
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
||||
];
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -34,6 +34,10 @@ import type {
|
||||
PolicyStats,
|
||||
PolicyStatus,
|
||||
LookupsResponse,
|
||||
OpsJob,
|
||||
OpsJobKind,
|
||||
IngestFile,
|
||||
BackupFile,
|
||||
PropertyDetail,
|
||||
PropertyFacets,
|
||||
PropertyInput,
|
||||
@@ -593,3 +597,62 @@ export function resetUserPassword(id: string, password: string): Promise<UserRow
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------- DB operations (admin only) */
|
||||
|
||||
export function listIngest(): Promise<IngestFile[]> {
|
||||
return apiFetch<IngestFile[]>("/ops/ingest");
|
||||
}
|
||||
|
||||
/** Multipart upload — not JSON, so it bypasses apiFetch's Content-Type. */
|
||||
export async function uploadIngest(name: string, file: File): Promise<void> {
|
||||
const body = new FormData();
|
||||
body.append("file", file, name);
|
||||
const res = await fetch(`${API_ORIGIN}/ops/ingest/${encodeURIComponent(name)}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = `Error ${res.status}`;
|
||||
try {
|
||||
const b = await res.json();
|
||||
if (b?.message) message = b.message;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteIngest(name: string): Promise<unknown> {
|
||||
return apiFetch(`/ops/ingest/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function listBackups(): Promise<BackupFile[]> {
|
||||
return apiFetch<BackupFile[]>("/ops/backups");
|
||||
}
|
||||
|
||||
export function backupDownloadUrl(name: string): string {
|
||||
return `${API_ORIGIN}/ops/backups/${encodeURIComponent(name)}/download`;
|
||||
}
|
||||
|
||||
export function deleteBackup(name: string): Promise<unknown> {
|
||||
return apiFetch(`/ops/backups/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function listOpsJobs(): Promise<OpsJob[]> {
|
||||
return apiFetch<OpsJob[]>("/ops/jobs");
|
||||
}
|
||||
|
||||
export function getOpsJob(id: string): Promise<OpsJob> {
|
||||
return apiFetch<OpsJob>(`/ops/jobs/${id}`);
|
||||
}
|
||||
|
||||
/** Start a mutating op. `file` is required for RESTORE. 409 if one is running. */
|
||||
export function startOpsJob(kind: OpsJobKind, file?: string): Promise<OpsJob> {
|
||||
return apiFetch<OpsJob>("/ops/jobs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind, file }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -266,6 +266,48 @@ export function bankSourceLabel(source: string | null | undefined): string {
|
||||
return BANK_SOURCE_LABELS[source] ?? source;
|
||||
}
|
||||
|
||||
// ----- DB operations (admin) -----
|
||||
|
||||
export const OPS_KIND_LABELS: Record<string, string> = {
|
||||
BACKUP: "Respaldo",
|
||||
RESTORE: "Restauración",
|
||||
REIMPORT: "Reimportación",
|
||||
SYNC: "Sincronización",
|
||||
};
|
||||
|
||||
export const OPS_STATUS_LABELS: Record<string, string> = {
|
||||
RUNNING: "En curso",
|
||||
SUCCESS: "Completado",
|
||||
FAILED: "Con error",
|
||||
};
|
||||
|
||||
/** Bytes → human size (KB/MB/GB), es-MX formatting. */
|
||||
export function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) return "—";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB"];
|
||||
let n = bytes / 1024;
|
||||
let i = 0;
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${n.toLocaleString("es-MX", { maximumFractionDigits: 1 })} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString("es-MX", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export const MONTH_NAMES = [
|
||||
"Enero",
|
||||
"Febrero",
|
||||
|
||||
@@ -20,7 +20,8 @@ export type Ability =
|
||||
| "bank:create"
|
||||
| "bank:void"
|
||||
| "lookup:manage"
|
||||
| "user:manage";
|
||||
| "user:manage"
|
||||
| "db:manage";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
@@ -43,6 +44,36 @@ export interface UserRow {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- DB operations (admin) */
|
||||
|
||||
export type OpsJobKind = "BACKUP" | "RESTORE" | "REIMPORT" | "SYNC";
|
||||
export type OpsJobStatus = "RUNNING" | "SUCCESS" | "FAILED";
|
||||
|
||||
export interface OpsJob {
|
||||
id: string;
|
||||
kind: OpsJobKind;
|
||||
status: OpsJobStatus;
|
||||
log: string;
|
||||
params: Record<string, unknown> | null;
|
||||
createdById: string | null;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
}
|
||||
|
||||
/** One of the four legacy Access files expected in the ingest folder. */
|
||||
export interface IngestFile {
|
||||
name: string;
|
||||
present: boolean;
|
||||
size: number | null;
|
||||
modifiedAt: string | null;
|
||||
}
|
||||
|
||||
export interface BackupFile {
|
||||
name: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CustomerStats {
|
||||
customers: number;
|
||||
withUtilities: number;
|
||||
|
||||
Reference in New Issue
Block a user