feature/asignacion-masiva-restauradores (#13)
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
integraciones de panel de respaldos fallidos, asignacion masiva de restaurador, trackeo de baks para nuevo sistema de cloudrestore Reviewed-on: #13 Co-authored-by: hreyes <hreyes@aduanasoft.com.mx> Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
This commit is contained in:
@@ -10,7 +10,7 @@ export function newTraceId(): string {
|
||||
}
|
||||
|
||||
export function errorJson(
|
||||
code: 400 | 401 | 403 | 404 | 409 | 422 | 500,
|
||||
code: 400 | 401 | 403 | 404 | 409 | 422 | 500 | 503,
|
||||
message: string,
|
||||
traceId: string
|
||||
) {
|
||||
|
||||
117
src/lib/server/backup-files.test.ts
Normal file
117
src/lib/server/backup-files.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { listBackupFiles, deriveSiblingFolder } from './backup-files';
|
||||
|
||||
let root: string;
|
||||
|
||||
async function writeFile(rel: string, bytes: number, mtime: Date): Promise<void> {
|
||||
const full = path.join(root, rel);
|
||||
await fs.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.writeFile(full, Buffer.alloc(bytes, 'x'));
|
||||
await fs.utimes(full, mtime, mtime);
|
||||
}
|
||||
|
||||
const NEW = new Date('2026-07-01T12:00:00Z');
|
||||
const MID = new Date('2026-07-01T10:00:00Z');
|
||||
const OLD = new Date('2026-06-30T09:00:00Z');
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'backup-files-'));
|
||||
// Carpeta de fecha reciente: un ZIP simple y un multipart de 3 partes.
|
||||
await writeFile('2026-07-01/NEW.ZIP', 200, NEW);
|
||||
await writeFile('2026-07-01/SPLIT.zip.001', 100, MID);
|
||||
await writeFile('2026-07-01/SPLIT.zip.002', 100, MID);
|
||||
await writeFile('2026-07-01/SPLIT.zip.003', 50, MID);
|
||||
// Carpeta de fecha anterior: un ZIP y un archivo aún más profundo (nivel 3).
|
||||
await writeFile('2026-06-30/OLD.ZIP', 300, OLD);
|
||||
await writeFile('2026-06-30/nested/TOODEEP.ZIP', 10, OLD);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('listBackupFiles', () => {
|
||||
it('recorre subcarpetas de fecha y devuelve relPath en formato POSIX', async () => {
|
||||
const { files } = await listBackupFiles(root);
|
||||
const byName = new Map(files.map((f) => [f.name, f]));
|
||||
expect(byName.get('NEW.ZIP')?.relPath).toBe('2026-07-01/NEW.ZIP');
|
||||
expect(byName.get('OLD.ZIP')?.relPath).toBe('2026-06-30/OLD.ZIP');
|
||||
});
|
||||
|
||||
it('colapsa multipart en una sola entrada sumando tamaños y apuntando a la parte .001', async () => {
|
||||
const { files } = await listBackupFiles(root);
|
||||
const split = files.find((f) => f.name === 'SPLIT.zip');
|
||||
expect(split).toBeDefined();
|
||||
expect(split?.parts).toBe(3);
|
||||
expect(split?.sizeBytes).toBe(250);
|
||||
expect(split?.relPath).toBe('2026-07-01/SPLIT.zip.001');
|
||||
});
|
||||
|
||||
it('ordena por fecha de modificación descendente (más reciente primero)', async () => {
|
||||
const { files } = await listBackupFiles(root);
|
||||
const order = files.map((f) => f.name);
|
||||
// NEW (12:00) > SPLIT (10:00) > OLD (09:00). TOODEEP excluido por profundidad.
|
||||
expect(order).toEqual(['NEW.ZIP', 'SPLIT.zip', 'OLD.ZIP']);
|
||||
});
|
||||
|
||||
it('respeta maxDepth: excluye archivos más profundos que la subcarpeta de fecha', async () => {
|
||||
const shallow = await listBackupFiles(root, { maxDepth: 2 });
|
||||
expect(shallow.files.some((f) => f.name === 'TOODEEP.ZIP')).toBe(false);
|
||||
|
||||
const deep = await listBackupFiles(root, { maxDepth: 3 });
|
||||
expect(deep.files.some((f) => f.name === 'TOODEEP.ZIP')).toBe(true);
|
||||
});
|
||||
|
||||
it('recorta con maxFiles conservando la carpeta de fecha más reciente y marca truncated', async () => {
|
||||
const { files, truncated } = await listBackupFiles(root, { maxFiles: 1 });
|
||||
expect(truncated).toBe(true);
|
||||
expect(files.length).toBeGreaterThanOrEqual(1);
|
||||
expect(files.every((f) => f.relPath.startsWith('2026-07-01/'))).toBe(true);
|
||||
});
|
||||
|
||||
it('no recorta cuando el tope es holgado', async () => {
|
||||
const { truncated } = await listBackupFiles(root, { maxFiles: 1000 });
|
||||
expect(truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('lanza si la carpeta raíz no existe', async () => {
|
||||
await expect(listBackupFiles(path.join(root, 'no-existe'))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveSiblingFolder', () => {
|
||||
it('deriva la hermana en rutas Windows (backslash)', () => {
|
||||
expect(deriveSiblingFolder('D:\\CloudRestore\\Entrada', 'Procesados')).toBe(
|
||||
'D:\\CloudRestore\\Procesados'
|
||||
);
|
||||
expect(deriveSiblingFolder('D:\\CloudRestore\\Entrada', 'Fallados')).toBe(
|
||||
'D:\\CloudRestore\\Fallados'
|
||||
);
|
||||
});
|
||||
|
||||
it('deriva la hermana en rutas POSIX', () => {
|
||||
expect(deriveSiblingFolder('/mnt/x/Entrada', 'Procesados')).toBe('/mnt/x/Procesados');
|
||||
});
|
||||
|
||||
it('tolera separador final', () => {
|
||||
expect(deriveSiblingFolder('D:\\CloudRestore\\Entrada\\', 'Procesados')).toBe(
|
||||
'D:\\CloudRestore\\Procesados'
|
||||
);
|
||||
expect(deriveSiblingFolder('/mnt/x/Entrada/', 'Fallados')).toBe('/mnt/x/Fallados');
|
||||
});
|
||||
|
||||
it('funciona con rutas en la raíz del disco y UNC', () => {
|
||||
expect(deriveSiblingFolder('D:\\sftp', 'Procesados')).toBe('D:\\Procesados');
|
||||
expect(deriveSiblingFolder('\\\\srv\\share\\Entrada', 'Procesados')).toBe(
|
||||
'\\\\srv\\share\\Procesados'
|
||||
);
|
||||
});
|
||||
|
||||
it('sin separador devuelve el nombre hermano como fallback', () => {
|
||||
expect(deriveSiblingFolder('Entrada', 'Procesados')).toBe('Procesados');
|
||||
expect(deriveSiblingFolder('', 'Procesados')).toBe('Procesados');
|
||||
});
|
||||
});
|
||||
BIN
src/lib/server/backup-files.ts
Normal file
BIN
src/lib/server/backup-files.ts
Normal file
Binary file not shown.
@@ -38,6 +38,11 @@ function qCloudRestoreStatus(): string {
|
||||
return `"${s.replace(/"/g, '""')}"."cloudrestore_status"`;
|
||||
}
|
||||
|
||||
function qNodeLastRestore(): string {
|
||||
const s = schemaName();
|
||||
return `"${s.replace(/"/g, '""')}"."node_last_restore"`;
|
||||
}
|
||||
|
||||
function isPgUndefinedTable(err: unknown): boolean {
|
||||
return typeof err === 'object' && err !== null && (err as { code?: string }).code === '42P01';
|
||||
}
|
||||
@@ -59,6 +64,51 @@ async function ensureCloudRestoreStatusTable(): Promise<void> {
|
||||
reported_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
// Carpeta de procesados reportada por el restaurador (opcional). Si no la reporta, el
|
||||
// panel la deriva como hermana de input_folder (deriveSiblingFolder).
|
||||
await pgPool.query(
|
||||
`ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS processed_folder VARCHAR(500)`
|
||||
);
|
||||
}
|
||||
|
||||
/** Añade columnas de tamaño/ruta relativa a restore_job_logs si faltan (idempotente). */
|
||||
async function ensureRestoreJobLogColumns(): Promise<void> {
|
||||
for (const stmt of [
|
||||
`ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS size_bytes BIGINT`,
|
||||
`ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS rel_path VARCHAR(600)`
|
||||
]) {
|
||||
try {
|
||||
await pgPool.query(stmt);
|
||||
} catch (e) {
|
||||
if (!isPgUndefinedTable(e)) throw e; // la tabla la crea a24c; si no existe aún, se ignora
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado "último respaldo por nodo": una fila por nodo, con el restaurador donde vive el
|
||||
* archivo. Llaveada por nodo (NO por la asignación actual) para que el tracking del último
|
||||
* respaldo persista aunque el nodo se reasigne a otro restaurador. Idempotente.
|
||||
*/
|
||||
async function ensureNodeLastRestoreTable(): Promise<void> {
|
||||
await pgPool.query('CREATE SCHEMA IF NOT EXISTS a24c');
|
||||
await pgPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${qNodeLastRestore()} (
|
||||
database_node_id INTEGER PRIMARY KEY,
|
||||
restore_target_id INTEGER,
|
||||
db_name VARCHAR(255),
|
||||
node_key VARCHAR(255),
|
||||
filename VARCHAR(500) NOT NULL,
|
||||
rel_path VARCHAR(600),
|
||||
size_bytes BIGINT,
|
||||
restored_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await pgPool.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_a24c_node_last_restore_target
|
||||
ON ${qNodeLastRestore()} (restore_target_id)`
|
||||
);
|
||||
}
|
||||
|
||||
/** Crea restore_targets y filas Alfa/Omega/Gamma si faltan (migración 001, idempotente). */
|
||||
@@ -91,7 +141,13 @@ async function ensureRestoreTargetsSchema(): Promise<void> {
|
||||
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_port INTEGER DEFAULT 22`,
|
||||
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_username VARCHAR(128)`,
|
||||
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_password_encrypted TEXT`,
|
||||
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS remote_inbox_path VARCHAR(500)`
|
||||
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS remote_inbox_path VARCHAR(500)`,
|
||||
// Características de hardware opcionales (para distribuir bases por capacidad).
|
||||
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS os VARCHAR(50)`,
|
||||
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ram_gb INTEGER`,
|
||||
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS disk_gb INTEGER`,
|
||||
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS location VARCHAR(255)`,
|
||||
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS size_category VARCHAR(20)`
|
||||
]) {
|
||||
await pgPool.query(stmt);
|
||||
}
|
||||
@@ -492,6 +548,12 @@ export interface RestoreTarget {
|
||||
ssh_username: string;
|
||||
remote_inbox_path: string;
|
||||
notes: string | null;
|
||||
// Características de hardware opcionales (NULL = sin capturar).
|
||||
os: string | null;
|
||||
ram_gb: number | null;
|
||||
disk_gb: number | null;
|
||||
location: string | null;
|
||||
size_category: string | null;
|
||||
}
|
||||
|
||||
/** Datos de alta/edición. Las contraseñas en texto plano; se cifran aquí. */
|
||||
@@ -507,11 +569,18 @@ export interface RestoreTargetInput {
|
||||
ssh_password?: string; // opcional en edición: si se omite, no se cambia
|
||||
remote_inbox_path: string;
|
||||
notes?: string | null;
|
||||
// Características de hardware opcionales.
|
||||
os?: string | null;
|
||||
ram_gb?: number | null;
|
||||
disk_gb?: number | null;
|
||||
location?: string | null;
|
||||
size_category?: string | null;
|
||||
}
|
||||
|
||||
const ROW_RESTORE_TARGET = `
|
||||
id, name, server_ip, sql_username, data_folder,
|
||||
ssh_host, ssh_port, ssh_username, remote_inbox_path, notes
|
||||
ssh_host, ssh_port, ssh_username, remote_inbox_path, notes,
|
||||
os, ram_gb, disk_gb, location, size_category
|
||||
`;
|
||||
|
||||
async function queryRestoreTargets(): Promise<RestoreTarget[]> {
|
||||
@@ -666,12 +735,16 @@ export async function createRestoreTarget(input: RestoreTargetInput): Promise<nu
|
||||
const cols = [
|
||||
'name', 'server_ip', 'sql_username', 'data_folder',
|
||||
'ssh_host', 'ssh_port', 'ssh_username', 'remote_inbox_path', 'notes',
|
||||
'os', 'ram_gb', 'disk_gb', 'location', 'size_category',
|
||||
'sql_password_encrypted'
|
||||
];
|
||||
const params: unknown[] = [
|
||||
input.name, input.server_ip, input.sql_username, input.data_folder,
|
||||
input.ssh_host, input.ssh_port, input.ssh_username, input.remote_inbox_path,
|
||||
input.notes ?? null, encryptSecret(input.sql_password)
|
||||
input.notes ?? null,
|
||||
input.os ?? null, input.ram_gb ?? null, input.disk_gb ?? null,
|
||||
input.location ?? null, input.size_category ?? null,
|
||||
encryptSecret(input.sql_password)
|
||||
];
|
||||
if (input.ssh_password) {
|
||||
cols.push('ssh_password_encrypted');
|
||||
@@ -690,12 +763,15 @@ export async function updateRestoreTarget(id: number, input: RestoreTargetInput)
|
||||
const sets = [
|
||||
'name = $1', 'server_ip = $2', 'sql_username = $3', 'data_folder = $4',
|
||||
'ssh_host = $5', 'ssh_port = $6', 'ssh_username = $7', 'remote_inbox_path = $8',
|
||||
'notes = $9', 'updated_at = now()'
|
||||
'notes = $9', 'os = $10', 'ram_gb = $11', 'disk_gb = $12', 'location = $13',
|
||||
'size_category = $14', 'updated_at = now()'
|
||||
];
|
||||
const params: unknown[] = [
|
||||
input.name, input.server_ip, input.sql_username, input.data_folder,
|
||||
input.ssh_host, input.ssh_port, input.ssh_username, input.remote_inbox_path,
|
||||
input.notes ?? null
|
||||
input.notes ?? null,
|
||||
input.os ?? null, input.ram_gb ?? null, input.disk_gb ?? null,
|
||||
input.location ?? null, input.size_category ?? null
|
||||
];
|
||||
let p = params.length;
|
||||
if (input.sql_password) {
|
||||
@@ -717,6 +793,119 @@ export async function deleteRestoreTarget(id: number): Promise<void> {
|
||||
await pgPool.query(`DELETE FROM ${qRestoreTargets()} WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Asignación masiva de nodos a servidores de restauración.
|
||||
// ============================================================================
|
||||
|
||||
/** Nodo con su asignación de restaurador, para el checklist/distribución (sin secretos). */
|
||||
export interface AssignmentNode {
|
||||
ID: number;
|
||||
NodoSubNodo: string;
|
||||
Nombre: string;
|
||||
BDName: string | null;
|
||||
ServerName: string | null;
|
||||
Activo: number;
|
||||
RestoreTargetId: number | null;
|
||||
}
|
||||
|
||||
/** Todos los nodos con su asignación actual (base del checklist y de la distribución). */
|
||||
export async function listNodesForAssignment(): Promise<AssignmentNode[]> {
|
||||
const r = await pgPool.query(
|
||||
`
|
||||
SELECT
|
||||
id AS "ID",
|
||||
node_subnode_key AS "NodoSubNodo",
|
||||
legal_name AS "Nombre",
|
||||
database_name AS "BDName",
|
||||
server_name AS "ServerName",
|
||||
is_active AS "Activo",
|
||||
restore_target_id AS "RestoreTargetId"
|
||||
FROM ${qNodes()}
|
||||
ORDER BY node_subnode_key
|
||||
`
|
||||
);
|
||||
return r.rows as AssignmentNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarda el checklist manual de un restaurador: los `checkedNodeIds` quedan asignados a
|
||||
* `targetId` (reasignando desde donde estuvieran y derivando server_name de su IP); los nodos
|
||||
* que estaban en este restaurador y ya NO vienen marcados quedan sin asignar (NULL). Los nodos
|
||||
* de OTROS restauradores no marcados no se tocan. Transacción con prepared statements.
|
||||
*/
|
||||
export async function assignNodesToRestoreTarget(
|
||||
targetId: number,
|
||||
checkedNodeIds: number[]
|
||||
): Promise<void> {
|
||||
const ids = Array.from(new Set(checkedNodeIds.filter((n) => Number.isInteger(n) && n > 0)));
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
// 1) Asignar/reasignar los marcados a este restaurador (server_name = IP del target si existe).
|
||||
await client.query(
|
||||
`
|
||||
UPDATE ${qNodes()}
|
||||
SET restore_target_id = $1,
|
||||
server_name = COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $1), server_name)
|
||||
WHERE id = ANY($2::int[])
|
||||
`,
|
||||
[targetId, ids]
|
||||
);
|
||||
// 2) Quitar de este restaurador los que quedaron desmarcados (NULL); no toca otros targets.
|
||||
await client.query(
|
||||
`
|
||||
UPDATE ${qNodes()}
|
||||
SET restore_target_id = NULL
|
||||
WHERE restore_target_id = $1
|
||||
AND NOT (id = ANY($2::int[]))
|
||||
`,
|
||||
[targetId, ids]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica un conjunto de asignaciones (distribución global o automática): cada par fija el
|
||||
* restore_target_id del nodo (y deriva server_name de la IP del restaurador si no es NULL).
|
||||
* Pares con targetId NULL dejan el nodo sin asignar (conservando server_name). Transacción.
|
||||
*/
|
||||
export async function applyNodeAssignments(
|
||||
pairs: { nodeId: number; targetId: number | null }[]
|
||||
): Promise<void> {
|
||||
const clean = pairs.filter((p) => Number.isInteger(p.nodeId) && p.nodeId > 0);
|
||||
if (clean.length === 0) return;
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
for (const { nodeId, targetId } of clean) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE ${qNodes()}
|
||||
SET restore_target_id = $2,
|
||||
server_name = CASE
|
||||
WHEN $2::int IS NULL THEN server_name
|
||||
ELSE COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $2), server_name)
|
||||
END
|
||||
WHERE id = $1
|
||||
`,
|
||||
[nodeId, targetId]
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Estado de CloudRestoreAS (carpeta de entrada reportada por el servicio).
|
||||
// ============================================================================
|
||||
@@ -725,6 +914,7 @@ export interface CloudRestoreStatus {
|
||||
id: number;
|
||||
instance_key: string;
|
||||
input_folder: string;
|
||||
processed_folder: string | null;
|
||||
host_name: string | null;
|
||||
app_version: string | null;
|
||||
reported_at: Date;
|
||||
@@ -736,7 +926,7 @@ export async function listCloudRestoreStatuses(): Promise<CloudRestoreStatus[]>
|
||||
await ensureCloudRestoreStatusTable();
|
||||
const r = await pgPool.query(
|
||||
`
|
||||
SELECT id, instance_key, input_folder, host_name, app_version, reported_at
|
||||
SELECT id, instance_key, input_folder, processed_folder, host_name, app_version, reported_at
|
||||
FROM ${qCloudRestoreStatus()}
|
||||
ORDER BY instance_key
|
||||
`
|
||||
@@ -757,6 +947,7 @@ export async function getCloudRestoreStatus(): Promise<CloudRestoreStatus | null
|
||||
/** UPSERT del estado reportado por CloudRestoreAS (solo vía API servicio). */
|
||||
export async function upsertCloudRestoreStatus(row: {
|
||||
inputFolder: string;
|
||||
processedFolder?: string | null;
|
||||
hostName: string | null;
|
||||
appVersion: string | null;
|
||||
instanceKey?: string;
|
||||
@@ -766,15 +957,16 @@ export async function upsertCloudRestoreStatus(row: {
|
||||
await pgPool.query(
|
||||
`
|
||||
INSERT INTO ${qCloudRestoreStatus()} (
|
||||
instance_key, input_folder, host_name, app_version, reported_at
|
||||
) VALUES ($1, $2, $3, $4, now())
|
||||
instance_key, input_folder, processed_folder, host_name, app_version, reported_at
|
||||
) VALUES ($1, $2, $3, $4, $5, now())
|
||||
ON CONFLICT (instance_key) DO UPDATE SET
|
||||
input_folder = EXCLUDED.input_folder,
|
||||
processed_folder = EXCLUDED.processed_folder,
|
||||
host_name = EXCLUDED.host_name,
|
||||
app_version = EXCLUDED.app_version,
|
||||
reported_at = now()
|
||||
`,
|
||||
[key, row.inputFolder, row.hostName, row.appVersion]
|
||||
[key, row.inputFolder, row.processedFolder ?? null, row.hostName, row.appVersion]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -786,12 +978,16 @@ export async function insertRestoreJobLog(row: {
|
||||
status: 'completed' | 'failed' | 'forwarded';
|
||||
durationMs: number | null;
|
||||
errorMessage: string | null;
|
||||
sizeBytes?: number | null;
|
||||
relPath?: string | null;
|
||||
}): Promise<void> {
|
||||
await ensureRestoreJobLogColumns();
|
||||
await pgPool.query(
|
||||
`
|
||||
INSERT INTO ${qRestoreJobLogs()} (
|
||||
filename, restore_target_id, db_name, status, duration_ms, error_message
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
filename, restore_target_id, db_name, status, duration_ms, error_message,
|
||||
size_bytes, rel_path
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`,
|
||||
[
|
||||
row.filename,
|
||||
@@ -799,7 +995,274 @@ export async function insertRestoreJobLog(row: {
|
||||
row.dbName,
|
||||
row.status,
|
||||
row.durationMs,
|
||||
row.errorMessage
|
||||
row.errorMessage,
|
||||
row.sizeBytes ?? null,
|
||||
row.relPath ?? null
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/** Resumen de bitácora por servidor: conteos por status (últimos 30 días) y última exitosa. */
|
||||
export interface RestoreJobLogSummary {
|
||||
restore_target_id: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
forwarded: number;
|
||||
last_completed_at: Date | null;
|
||||
}
|
||||
|
||||
export async function listRestoreJobLogSummaries(): Promise<RestoreJobLogSummary[]> {
|
||||
try {
|
||||
const r = await pgPool.query(
|
||||
`
|
||||
SELECT
|
||||
restore_target_id,
|
||||
COUNT(*) FILTER (
|
||||
WHERE status = 'completed' AND restored_at >= now() - INTERVAL '30 days'
|
||||
)::int AS completed,
|
||||
COUNT(*) FILTER (
|
||||
WHERE status = 'failed' AND restored_at >= now() - INTERVAL '30 days'
|
||||
)::int AS failed,
|
||||
COUNT(*) FILTER (
|
||||
WHERE status = 'forwarded' AND restored_at >= now() - INTERVAL '30 days'
|
||||
)::int AS forwarded,
|
||||
MAX(restored_at) FILTER (WHERE status = 'completed') AS last_completed_at
|
||||
FROM ${qRestoreJobLogs()}
|
||||
WHERE restore_target_id IS NOT NULL
|
||||
GROUP BY restore_target_id
|
||||
`
|
||||
);
|
||||
return r.rows as RestoreJobLogSummary[];
|
||||
} catch (e) {
|
||||
if (isPgUndefinedTable(e)) return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Últimas restauraciones de un servidor (para el modal de bitácora del panel). */
|
||||
export interface RestoreJobLogRow {
|
||||
id: number;
|
||||
filename: string;
|
||||
db_name: string | null;
|
||||
status: string;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
restored_at: Date;
|
||||
}
|
||||
|
||||
export async function listRecentRestoreJobLogs(
|
||||
targetId: number,
|
||||
limit = 20
|
||||
): Promise<RestoreJobLogRow[]> {
|
||||
const capped = Math.min(Math.max(1, Math.trunc(limit)), 100);
|
||||
try {
|
||||
const r = await pgPool.query(
|
||||
`
|
||||
SELECT id, filename, db_name, status, duration_ms, error_message, restored_at
|
||||
FROM ${qRestoreJobLogs()}
|
||||
WHERE restore_target_id = $1
|
||||
ORDER BY restored_at DESC
|
||||
LIMIT $2
|
||||
`,
|
||||
[targetId, capped]
|
||||
);
|
||||
return r.rows as RestoreJobLogRow[];
|
||||
} catch (e) {
|
||||
if (isPgUndefinedTable(e)) return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Inventario "último respaldo por nodo" (node_last_restore) y restores fallidos.
|
||||
// ============================================================================
|
||||
|
||||
export interface NodeLastRestoreRow {
|
||||
database_node_id: number;
|
||||
restore_target_id: number | null;
|
||||
server_name: string | null; // restaurador (Alfa/Omega/Gamma) donde vive el archivo
|
||||
node_key: string | null; // NodoSubNodo
|
||||
client_name: string | null; // legal_name
|
||||
db_name: string | null;
|
||||
filename: string;
|
||||
rel_path: string | null;
|
||||
size_bytes: number | null;
|
||||
restored_at: Date;
|
||||
}
|
||||
|
||||
/** Último respaldo restaurado por nodo. Sobrevive a la reasignación del restaurador. */
|
||||
export async function listNodeLastRestore(): Promise<NodeLastRestoreRow[]> {
|
||||
try {
|
||||
await ensureNodeLastRestoreTable();
|
||||
const r = await pgPool.query(
|
||||
`
|
||||
SELECT
|
||||
nlr.database_node_id,
|
||||
nlr.restore_target_id,
|
||||
rt.name AS server_name,
|
||||
COALESCE(nlr.node_key, dn.node_subnode_key) AS node_key,
|
||||
dn.legal_name AS client_name,
|
||||
nlr.db_name,
|
||||
nlr.filename,
|
||||
nlr.rel_path,
|
||||
nlr.size_bytes,
|
||||
nlr.restored_at
|
||||
FROM ${qNodeLastRestore()} nlr
|
||||
LEFT JOIN ${qRestoreTargets()} rt ON rt.id = nlr.restore_target_id
|
||||
LEFT JOIN ${qNodes()} dn ON dn.id = nlr.database_node_id
|
||||
ORDER BY nlr.restored_at DESC
|
||||
`
|
||||
);
|
||||
return r.rows as NodeLastRestoreRow[];
|
||||
} catch (e) {
|
||||
if (isPgUndefinedTable(e)) return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UPSERT del último respaldo de un nodo. Solo actualiza si el nuevo `restored_at` es igual o
|
||||
* más reciente, para no retroceder el tracking. Llaveada por nodo → persiste ante reasignación.
|
||||
*/
|
||||
export async function upsertNodeLastRestore(row: {
|
||||
databaseNodeId: number;
|
||||
restoreTargetId: number | null;
|
||||
dbName: string | null;
|
||||
nodeKey: string | null;
|
||||
filename: string;
|
||||
relPath: string | null;
|
||||
sizeBytes: number | null;
|
||||
restoredAt?: Date | null;
|
||||
}): Promise<void> {
|
||||
await ensureNodeLastRestoreTable();
|
||||
await pgPool.query(
|
||||
`
|
||||
INSERT INTO ${qNodeLastRestore()} AS nlr (
|
||||
database_node_id, restore_target_id, db_name, node_key,
|
||||
filename, rel_path, size_bytes, restored_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, now()), now())
|
||||
ON CONFLICT (database_node_id) DO UPDATE SET
|
||||
restore_target_id = EXCLUDED.restore_target_id,
|
||||
db_name = EXCLUDED.db_name,
|
||||
node_key = EXCLUDED.node_key,
|
||||
filename = EXCLUDED.filename,
|
||||
rel_path = EXCLUDED.rel_path,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
restored_at = EXCLUDED.restored_at,
|
||||
updated_at = now()
|
||||
WHERE EXCLUDED.restored_at >= nlr.restored_at
|
||||
`,
|
||||
[
|
||||
row.databaseNodeId,
|
||||
row.restoreTargetId,
|
||||
row.dbName,
|
||||
row.nodeKey,
|
||||
row.filename,
|
||||
row.relPath,
|
||||
row.sizeBytes,
|
||||
row.restoredAt ?? null
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve el database_node_id de un respaldo por db_name o por el nombre de archivo.
|
||||
* Reutiliza matchNodeRowFromBackupStem para el fallback por stem del filename.
|
||||
*/
|
||||
export async function findNodeIdForBackup(
|
||||
filename: string,
|
||||
dbName: string | null
|
||||
): Promise<{ id: number; nodeKey: string | null } | null> {
|
||||
if (dbName) {
|
||||
const r = await pgPool.query(
|
||||
`SELECT id, node_subnode_key FROM ${qNodes()}
|
||||
WHERE LOWER(TRIM(database_name)) = LOWER(TRIM($1))
|
||||
OR LOWER(TRIM(node_subnode_key)) = LOWER(TRIM($1))
|
||||
LIMIT 1`,
|
||||
[dbName]
|
||||
);
|
||||
if (r.rows.length) {
|
||||
return { id: Number(r.rows[0].id), nodeKey: r.rows[0].node_subnode_key ?? null };
|
||||
}
|
||||
}
|
||||
const stem = path.parse(filename).name;
|
||||
const nodes = await pgPool.query(
|
||||
`SELECT id AS "ID", node_subnode_key AS "NodoSubNodo", database_name AS "BDName"
|
||||
FROM ${qNodes()}`
|
||||
);
|
||||
const match = matchNodeRowFromBackupStem(stem, nodes.rows);
|
||||
if (match && match.ID != null) {
|
||||
return { id: Number(match.ID), nodeKey: (match.NodoSubNodo as string) ?? null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Restores fallidos (todos los servidores) para el panel de fallidos. */
|
||||
export interface FailedRestoreRow {
|
||||
id: number;
|
||||
restore_target_id: number | null;
|
||||
server_name: string | null;
|
||||
filename: string;
|
||||
db_name: string | null;
|
||||
error_message: string | null;
|
||||
rel_path: string | null;
|
||||
size_bytes: number | null;
|
||||
restored_at: Date;
|
||||
}
|
||||
|
||||
export async function listFailedRestoreJobLogs(limit = 100): Promise<FailedRestoreRow[]> {
|
||||
const capped = Math.min(Math.max(1, Math.trunc(limit)), 500);
|
||||
try {
|
||||
await ensureRestoreJobLogColumns();
|
||||
const r = await pgPool.query(
|
||||
`
|
||||
SELECT
|
||||
jl.id, jl.restore_target_id, rt.name AS server_name,
|
||||
jl.filename, jl.db_name, jl.error_message, jl.rel_path, jl.size_bytes, jl.restored_at
|
||||
FROM ${qRestoreJobLogs()} jl
|
||||
LEFT JOIN ${qRestoreTargets()} rt ON rt.id = jl.restore_target_id
|
||||
WHERE jl.status = 'failed'
|
||||
ORDER BY jl.restored_at DESC
|
||||
LIMIT $1
|
||||
`,
|
||||
[capped]
|
||||
);
|
||||
return r.rows as FailedRestoreRow[];
|
||||
} catch (e) {
|
||||
if (isPgUndefinedTable(e)) return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Carpetas de un restaurador para descarga por filesystem (input/processed reportados). */
|
||||
export interface RestoreTargetDownload {
|
||||
id: number;
|
||||
name: string;
|
||||
input_folder: string | null;
|
||||
processed_folder: string | null;
|
||||
}
|
||||
|
||||
export async function getRestoreTargetForDownload(
|
||||
targetId: number
|
||||
): Promise<RestoreTargetDownload | null> {
|
||||
await ensureRestoreTargetsSchema();
|
||||
await ensureCloudRestoreStatusTable();
|
||||
const r = await pgPool.query(
|
||||
`
|
||||
SELECT rt.id, rt.name, cs.input_folder, cs.processed_folder
|
||||
FROM ${qRestoreTargets()} rt
|
||||
LEFT JOIN ${qCloudRestoreStatus()} cs
|
||||
ON LOWER(TRIM(cs.instance_key)) = LOWER(TRIM(rt.name))
|
||||
WHERE rt.id = $1
|
||||
`,
|
||||
[targetId]
|
||||
);
|
||||
if (!r.rows.length) return null;
|
||||
const row = r.rows[0];
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: row.name,
|
||||
input_folder: row.input_folder ?? null,
|
||||
processed_folder: row.processed_folder ?? null
|
||||
};
|
||||
}
|
||||
|
||||
66
src/lib/server/email-service.test.ts
Normal file
66
src/lib/server/email-service.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Pruebas de los builders HTML de correo (réplica 1:1 del legacy) y del formateo de fecha.
|
||||
* Importan las funciones REALES: se valida estructura legacy + escape de campos de texto.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildBackupAlertHtml, buildBackupResolvedHtml, formatFechaEs } from './email-service';
|
||||
|
||||
const baseRow = {
|
||||
visible_name: 'CLIENTE_DB',
|
||||
clientName: 'ACME S.A.',
|
||||
last_restore_date: '2026-06-28T17:26:15.290000',
|
||||
daysWithout: 3
|
||||
};
|
||||
|
||||
describe('buildBackupAlertHtml — 1:1 legacy (overdue)', () => {
|
||||
it('incluye logo, título, link SCAIIWeb y pie TransmitirAS', () => {
|
||||
const html = buildBackupAlertHtml([baseRow]);
|
||||
expect(html).toContain('https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png');
|
||||
expect(html).toContain('Notificación de Sincronización');
|
||||
expect(html).toContain('no se ha sincronizado correctamente en las últimas 24 horas');
|
||||
expect(html).toContain('https://a24.aduanasoft.com/SCAIIWeb');
|
||||
expect(html).toContain('© 2024 TransmitirAS');
|
||||
expect(html).toContain('ACME S.A.'); // cliente
|
||||
expect(html).toContain('CLIENTE_DB'); // base de datos
|
||||
expect(html).toContain('28 de junio de 2026, 17:26'); // fecha formateada
|
||||
});
|
||||
|
||||
it('escapa visible_name y clientName (XSS)', () => {
|
||||
const html = buildBackupAlertHtml([
|
||||
{ visible_name: '<script>alert(1)</script>', clientName: '<b>x</b>', last_restore_date: null, daysWithout: null }
|
||||
]);
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).toContain('<script>');
|
||||
expect(html).not.toContain('<b>x</b>');
|
||||
expect(html).toContain('No disponible'); // last_restore null
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBackupResolvedHtml — misma plantilla, mensaje positivo', () => {
|
||||
it('incluye título restablecido, logo, SCAIIWeb y pie', () => {
|
||||
const html = buildBackupResolvedHtml([baseRow]);
|
||||
expect(html).toContain('Sincronización Restablecida');
|
||||
expect(html).toContain('volvió a sincronizarse correctamente');
|
||||
expect(html).toContain('https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png');
|
||||
expect(html).toContain('https://a24.aduanasoft.com/SCAIIWeb');
|
||||
expect(html).toContain('© 2024 TransmitirAS');
|
||||
});
|
||||
|
||||
it('escapa clientName', () => {
|
||||
const html = buildBackupResolvedHtml([
|
||||
{ visible_name: 'DB', clientName: '<img src=x onerror=1>', last_restore_date: null, daysWithout: null }
|
||||
]);
|
||||
expect(html).not.toContain('<img src=x onerror=1>');
|
||||
expect(html).toContain('<img');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFechaEs', () => {
|
||||
it('formatea fecha ISO (con microsegundos) al estilo legacy', () => {
|
||||
expect(formatFechaEs('2026-06-28T17:26:15.290000')).toBe('28 de junio de 2026, 17:26');
|
||||
});
|
||||
it('null/invalid -> No disponible', () => {
|
||||
expect(formatFechaEs(null)).toBe('No disponible');
|
||||
expect(formatFechaEs('no-fecha')).toBe('No disponible');
|
||||
});
|
||||
});
|
||||
@@ -65,95 +65,91 @@ export async function sendSmtpEmail(opts: SendEmailOptions): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Genera HTML de alerta de respaldo compatible con Gmail/Outlook (tablas, sin flex). */
|
||||
export function buildBackupAlertHtml(alerts: BackupAlertRow[]): string {
|
||||
const rows = alerts
|
||||
.map((a) => {
|
||||
const badgeColor =
|
||||
a.daysWithout === null
|
||||
? '#6b7280'
|
||||
: a.daysWithout >= 7
|
||||
? '#b91c1c'
|
||||
: a.daysWithout >= 3
|
||||
? '#b45309'
|
||||
: '#047857';
|
||||
const badgeBg =
|
||||
a.daysWithout === null
|
||||
? '#f3f4f6'
|
||||
: a.daysWithout >= 7
|
||||
? '#fef2f2'
|
||||
: a.daysWithout >= 3
|
||||
? '#fffbeb'
|
||||
: '#ecfdf5';
|
||||
const diasLabel =
|
||||
a.daysWithout === null ? 'Sin datos' : `${a.daysWithout} día${a.daysWithout !== 1 ? 's' : ''}`;
|
||||
const ultimaRest = a.last_restore_date
|
||||
? new Date(a.last_restore_date).toLocaleString('es-MX')
|
||||
: 'Nunca';
|
||||
return `
|
||||
<tr style="border-bottom:1px solid #e2e8f0;">
|
||||
<td style="padding:8px 12px;font-family:sans-serif;font-size:12px;color:#1e293b;">${escHtml(a.visible_name)}</td>
|
||||
<td style="padding:8px 12px;font-family:sans-serif;font-size:12px;color:#475569;">${escHtml(a.clientName ?? 'N/D')}</td>
|
||||
<td style="padding:8px 12px;font-family:sans-serif;font-size:12px;color:#475569;">${ultimaRest}</td>
|
||||
<td style="padding:8px 12px;text-align:center;">
|
||||
<span style="display:inline-block;padding:2px 8px;border-radius:9999px;font-family:sans-serif;font-size:11px;font-weight:600;color:${badgeColor};background:${badgeBg};border:1px solid ${badgeColor}30;">
|
||||
${diasLabel}
|
||||
</span>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('');
|
||||
// ============================================================================
|
||||
// Plantillas de correo — réplica 1:1 del legacy index.php (logo, azul, narrativa
|
||||
// por cliente, link SCAIIWeb, pie © TransmitirAS).
|
||||
// ============================================================================
|
||||
|
||||
const LOGO_URL = 'https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png';
|
||||
|
||||
const SCAIIWEB_H4 =
|
||||
`<h4 style="color:#007bff;text-align:center;">` +
|
||||
`Consulta la última sincronización de datos fácilmente desde ` +
|
||||
`<a href="https://a24.aduanasoft.com/SCAIIWeb" style="color:#007bff;text-decoration:none;">SCAIIWeb</a>. ` +
|
||||
`Inicia sesión y encontrarás esta información en la esquina inferior derecha de la pantalla.</h4>`;
|
||||
|
||||
const FOOTER =
|
||||
`<div style="background-color:#007bff;color:#fff;text-align:center;padding:10px;">` +
|
||||
`<small>© 2024 TransmitirAS. Todos los derechos reservados.</small></div>`;
|
||||
|
||||
/** Envuelve el contenido en el mismo cascarón del legacy (Arial, logo centrado, pie). */
|
||||
function renderSyncEmail(inner: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0;padding:0;background:#f8fafc;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;padding:24px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1);">
|
||||
<!-- Encabezado -->
|
||||
<tr>
|
||||
<td style="background:#b91c1c;padding:20px 24px;">
|
||||
<p style="margin:0;font-family:sans-serif;font-size:18px;font-weight:700;color:#ffffff;">
|
||||
Alerta de respaldo — Aduanasoft
|
||||
</p>
|
||||
<p style="margin:4px 0 0;font-family:sans-serif;font-size:12px;color:#fecaca;">
|
||||
${alerts.length} base${alerts.length !== 1 ? 's' : ''} de datos sin restaurar recientemente
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Tabla de alertas -->
|
||||
<tr>
|
||||
<td style="padding:20px 24px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:6px;overflow:hidden;">
|
||||
<thead>
|
||||
<tr style="background:#f1f5f9;">
|
||||
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:left;text-transform:uppercase;">Base de datos</th>
|
||||
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:left;text-transform:uppercase;">Cliente</th>
|
||||
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:left;text-transform:uppercase;">Última restauración</th>
|
||||
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:center;text-transform:uppercase;">Días sin sync</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Pie -->
|
||||
<tr>
|
||||
<td style="padding:12px 24px 20px;border-top:1px solid #f1f5f9;">
|
||||
<p style="margin:0;font-family:sans-serif;font-size:11px;color:#94a3b8;">
|
||||
Este mensaje fue generado automáticamente por el Panel de Control de Bases de Datos Aduanasoft.
|
||||
Por favor no responda a este correo.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
<body style="margin:0;padding:0;background:#ffffff;">
|
||||
<div style="font-family:Arial,sans-serif;line-height:1.5;color:#333;">
|
||||
<div style="text-align:center;margin-bottom:20px;">
|
||||
<img src="${LOGO_URL}" alt="Logo" style="max-width:150px;">
|
||||
</div>
|
||||
${inner}
|
||||
${FOOTER}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Fecha en español "28 de junio de 2026, 17:26" (o "No disponible"), como el strftime del legacy. */
|
||||
export function formatFechaEs(iso: string | null | undefined): string {
|
||||
if (!iso) return 'No disponible';
|
||||
// Normaliza microsegundos (a24c manda isoformat con 6 dígitos) a milisegundos.
|
||||
const cleaned = String(iso).replace(/(\.\d{3})\d+/, '$1');
|
||||
const d = new Date(cleaned);
|
||||
if (isNaN(d.getTime())) return 'No disponible';
|
||||
const meses = [
|
||||
'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio',
|
||||
'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'
|
||||
];
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const mm = meses[d.getMonth()];
|
||||
const yyyy = d.getFullYear();
|
||||
const HH = String(d.getHours()).padStart(2, '0');
|
||||
const MM = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${dd} de ${mm} de ${yyyy}, ${HH}:${MM}`;
|
||||
}
|
||||
|
||||
/** Correo de alerta de sincronización (kind=overdue), 1:1 con el legacy index.php. */
|
||||
export function buildBackupAlertHtml(alerts: BackupAlertRow[]): string {
|
||||
const a = alerts[0];
|
||||
const nombre = escHtml(a?.clientName ?? a?.visible_name ?? '');
|
||||
const bd = escHtml(a?.visible_name ?? '');
|
||||
const fecha = escHtml(formatFechaEs(a?.last_restore_date));
|
||||
const inner = `
|
||||
<h2 style="color:#007bff;text-align:center;">Notificación de Sincronización</h2>
|
||||
<p>Estimado <strong>${nombre}</strong>,</p>
|
||||
<p>Detectamos que la base de datos <strong>${bd}</strong> no se ha sincronizado correctamente en las últimas 24 horas.</p>
|
||||
<p>La última restauración registrada fue: <strong>${fecha}</strong>.</p>
|
||||
${SCAIIWEB_H4}
|
||||
<p>Por favor, recuerde nunca cerrar la aplicación ni apagar su equipo. Revise la conexión e intente realizar una sincronización manual desde el botón Backup manual, o contacte al soporte técnico si es necesario.</p>`;
|
||||
return renderSyncEmail(inner);
|
||||
}
|
||||
|
||||
/** Correo de "sincronización restablecida" (kind=resolved): misma plantilla legacy, mensaje positivo. */
|
||||
export function buildBackupResolvedHtml(alerts: BackupAlertRow[]): string {
|
||||
const a = alerts[0];
|
||||
const nombre = escHtml(a?.clientName ?? a?.visible_name ?? '');
|
||||
const bd = escHtml(a?.visible_name ?? '');
|
||||
const fecha = escHtml(formatFechaEs(a?.last_restore_date));
|
||||
const inner = `
|
||||
<h2 style="color:#28a745;text-align:center;">Sincronización Restablecida</h2>
|
||||
<p>Estimado <strong>${nombre}</strong>,</p>
|
||||
<p>La base de datos <strong>${bd}</strong> volvió a sincronizarse correctamente.</p>
|
||||
<p>La última restauración registrada fue: <strong>${fecha}</strong>.</p>
|
||||
${SCAIIWEB_H4}
|
||||
<p>No se requiere ninguna acción de su parte. Gracias por mantener su equipo y la aplicación en funcionamiento.</p>`;
|
||||
return renderSyncEmail(inner);
|
||||
}
|
||||
|
||||
export interface BackupAlertRow {
|
||||
visible_name: string;
|
||||
clientName: string | null;
|
||||
|
||||
103
src/lib/server/restore-fetch.ts
Normal file
103
src/lib/server/restore-fetch.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Descarga de respaldos por restaurador, con ruta RELATIVA y SIN rutas fijas.
|
||||
*
|
||||
* La base de cada restaurador se DERIVA al vuelo de su Entrada reportada
|
||||
* (`cloudrestore_status.input_folder`): hermana `Procesados`/`Fallados` (layout por defecto del
|
||||
* install). El cliente solo envía `target` + `kind` + `relPath`; el servidor arma `base + relPath`
|
||||
* y lo sirve leyendo la carpeta por filesystem (local o montada como share). CloudRestoreAS no
|
||||
* reporta ni configura nada nuevo.
|
||||
*/
|
||||
import fs from 'node:fs/promises';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
import { deriveSiblingFolder } from './backup-files';
|
||||
import { getRestoreTargetForDownload, type RestoreTargetDownload } from './controldesk-pg';
|
||||
|
||||
export type BackupKind = 'procesados' | 'fallados';
|
||||
|
||||
export class BackupDownloadError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BackupDownloadError';
|
||||
}
|
||||
}
|
||||
|
||||
function baseSeparator(p: string): '\\' | '/' {
|
||||
return p.includes('\\') ? '\\' : '/';
|
||||
}
|
||||
|
||||
/** Carpeta base (procesados/fallados) del restaurador, SIEMPRE derivada de su Entrada. */
|
||||
function resolveBaseFolder(t: RestoreTargetDownload, kind: BackupKind): string | null {
|
||||
const input = t.input_folder;
|
||||
if (!input) return null;
|
||||
return deriveSiblingFolder(input, kind === 'procesados' ? 'Procesados' : 'Fallados');
|
||||
}
|
||||
|
||||
/** Valida la ruta relativa: sin '..', sin absoluto, sin componentes vacíos. */
|
||||
export function sanitizeRelPath(relPath: string): string | null {
|
||||
const rel = String(relPath ?? '')
|
||||
.replace(/\\/g, '/')
|
||||
.trim();
|
||||
if (!rel) return null;
|
||||
if (rel.startsWith('/') || /^[a-zA-Z]:/.test(rel)) return null; // absoluto
|
||||
const parts = rel.split('/');
|
||||
if (parts.some((p) => p === '' || p === '.' || p === '..')) return null;
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function localJoin(base: string, rel: string): string {
|
||||
const sep = baseSeparator(base);
|
||||
const b = base.replace(/[\\/]+$/, '');
|
||||
const relNative = rel.split('/').join(sep);
|
||||
return `${b}${sep}${relNative}`;
|
||||
}
|
||||
|
||||
export interface BackupDownload {
|
||||
body: ReadableStream<Uint8Array>;
|
||||
size: number | null;
|
||||
filename: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abre el stream de descarga de un respaldo desde la carpeta de procesados/fallados del
|
||||
* restaurador (por filesystem). Lanza BackupDownloadError con el código HTTP apropiado.
|
||||
*/
|
||||
export async function openBackupDownload(
|
||||
targetId: number,
|
||||
kind: BackupKind,
|
||||
relPathRaw: string
|
||||
): Promise<BackupDownload> {
|
||||
const rel = sanitizeRelPath(relPathRaw);
|
||||
if (!rel) throw new BackupDownloadError(400, 'Ruta de archivo inválida');
|
||||
|
||||
const target = await getRestoreTargetForDownload(targetId);
|
||||
if (!target) throw new BackupDownloadError(404, 'Restaurador no encontrado');
|
||||
|
||||
const base = resolveBaseFolder(target, kind);
|
||||
if (!base) throw new BackupDownloadError(409, 'El restaurador aún no reportó su carpeta de entrada');
|
||||
|
||||
const filename = rel.split('/').pop() as string;
|
||||
const localPath = localJoin(base, rel);
|
||||
|
||||
let st;
|
||||
try {
|
||||
st = await fs.stat(localPath);
|
||||
} catch {
|
||||
throw new BackupDownloadError(404, 'Archivo de respaldo no encontrado o carpeta inaccesible');
|
||||
}
|
||||
if (!st.isFile()) {
|
||||
throw new BackupDownloadError(404, 'La ruta indicada no es un archivo');
|
||||
}
|
||||
|
||||
const node = createReadStream(localPath, { highWaterMark: 1024 * 1024 });
|
||||
return {
|
||||
body: Readable.toWeb(node) as unknown as ReadableStream<Uint8Array>,
|
||||
size: st.size,
|
||||
filename,
|
||||
cleanup: () => node.destroy()
|
||||
};
|
||||
}
|
||||
131
src/lib/server/restore-inventory.ts
Normal file
131
src/lib/server/restore-inventory.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Escaneo del inventario de restauraciones LEYENDO las carpetas que usa CloudRestoreAS.
|
||||
*
|
||||
* Por cada restaurador toma la Entrada (`input_folder`) que ya reporta en `cloudrestore_status`,
|
||||
* DERIVA sus hermanas `Procesados`/`Fallados` (deriveSiblingFolder) y las LEE por filesystem
|
||||
* (listBackupFiles). Mapea cada archivo a su nodo (matchNodeRowFromBackupStem) y arma:
|
||||
* - restaurados (de Procesados) → además hace upsert en `node_last_restore` (cache durable que
|
||||
* preserva el "último respaldo por nodo" aunque se reasigne el restaurador o una carpeta quede
|
||||
* temporalmente ilegible).
|
||||
* - fallidos (de Fallados) → enriquecidos con `error_message` de `restore_job_logs` por filename.
|
||||
*
|
||||
* No requiere que CloudRestoreAS reporte nada nuevo: solo usa el `input_folder` ya reportado.
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { deriveSiblingFolder, listBackupFiles } from './backup-files';
|
||||
import {
|
||||
listRestoreTargets,
|
||||
listCloudRestoreStatuses,
|
||||
listNodesForAssignment,
|
||||
matchNodeRowFromBackupStem,
|
||||
upsertNodeLastRestore,
|
||||
listNodeLastRestore,
|
||||
listFailedRestoreJobLogs,
|
||||
type NodeLastRestoreRow
|
||||
} from './controldesk-pg';
|
||||
|
||||
export interface FailedItem {
|
||||
id: string; // clave estable para la UI: `${restore_target_id}:${rel_path}`
|
||||
restore_target_id: number | null;
|
||||
server_name: string | null;
|
||||
node_key: string | null;
|
||||
client_name: string | null;
|
||||
db_name: string | null;
|
||||
filename: string;
|
||||
rel_path: string;
|
||||
size_bytes: number;
|
||||
error_message: string | null;
|
||||
restored_at: Date;
|
||||
}
|
||||
|
||||
export interface RestoreInventory {
|
||||
restored: NodeLastRestoreRow[];
|
||||
failed: FailedItem[];
|
||||
}
|
||||
|
||||
const SCAN_OPTS = { maxDepth: 3, maxFiles: 5000 } as const;
|
||||
|
||||
/** Escanea las carpetas de todos los restauradores y devuelve restaurados + fallidos. */
|
||||
export async function scanRestoreInventory(): Promise<RestoreInventory> {
|
||||
const [targets, statuses, nodes] = await Promise.all([
|
||||
listRestoreTargets(),
|
||||
listCloudRestoreStatuses(),
|
||||
listNodesForAssignment()
|
||||
]);
|
||||
|
||||
// instance_key (case-insensitive) → input_folder reportado.
|
||||
const inputByInstance = new Map<string, string>();
|
||||
for (const s of statuses) {
|
||||
if (s.input_folder) inputByInstance.set(s.instance_key.trim().toLowerCase(), s.input_folder);
|
||||
}
|
||||
|
||||
const failed: FailedItem[] = [];
|
||||
|
||||
for (const t of targets) {
|
||||
const input = inputByInstance.get(String(t.name).trim().toLowerCase());
|
||||
if (!input) continue; // este restaurador aún no reportó su Entrada
|
||||
|
||||
// Procesados → upsert del último respaldo por nodo.
|
||||
try {
|
||||
const procesados = deriveSiblingFolder(input, 'Procesados');
|
||||
const { files } = await listBackupFiles(procesados, SCAN_OPTS);
|
||||
for (const f of files) {
|
||||
const match = matchNodeRowFromBackupStem(path.parse(f.name).name, nodes);
|
||||
if (match && match.ID != null) {
|
||||
await upsertNodeLastRestore({
|
||||
databaseNodeId: Number(match.ID),
|
||||
restoreTargetId: t.id,
|
||||
dbName: (match.BDName as string) ?? null,
|
||||
nodeKey: (match.NodoSubNodo as string) ?? null,
|
||||
filename: f.name,
|
||||
relPath: f.relPath,
|
||||
sizeBytes: f.sizeBytes,
|
||||
restoredAt: new Date(f.mtimeMs)
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* Carpeta Procesados ilegible: se conserva lo cacheado en node_last_restore. */
|
||||
}
|
||||
|
||||
// Fallados → lista viva (con enriquecimiento de error más abajo).
|
||||
try {
|
||||
const fallados = deriveSiblingFolder(input, 'Fallados');
|
||||
const { files } = await listBackupFiles(fallados, SCAN_OPTS);
|
||||
for (const f of files) {
|
||||
const match = matchNodeRowFromBackupStem(path.parse(f.name).name, nodes);
|
||||
failed.push({
|
||||
id: `${t.id}:${f.relPath}`,
|
||||
restore_target_id: t.id,
|
||||
server_name: t.name,
|
||||
node_key: (match?.NodoSubNodo as string) ?? null,
|
||||
client_name: (match?.Nombre as string) ?? null,
|
||||
db_name: (match?.BDName as string) ?? null,
|
||||
filename: f.name,
|
||||
rel_path: f.relPath,
|
||||
size_bytes: f.sizeBytes,
|
||||
error_message: null,
|
||||
restored_at: new Date(f.mtimeMs)
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* Carpeta Fallados ilegible: se omite este restaurador en fallidos. */
|
||||
}
|
||||
}
|
||||
|
||||
// Enriquecer fallidos con el error reportado (restore_job_logs) por filename.
|
||||
try {
|
||||
const logs = await listFailedRestoreJobLogs(500);
|
||||
const errByFile = new Map<string, string | null>();
|
||||
for (const l of logs) errByFile.set(l.filename.trim().toLowerCase(), l.error_message);
|
||||
for (const f of failed) {
|
||||
f.error_message = errByFile.get(f.filename.trim().toLowerCase()) ?? null;
|
||||
}
|
||||
} catch {
|
||||
/* sin bitácora: los fallidos se listan sin mensaje de error */
|
||||
}
|
||||
|
||||
// Restaurados = estado durable por nodo (recién refrescado por el escaneo).
|
||||
const restored = await listNodeLastRestore();
|
||||
return { restored, failed };
|
||||
}
|
||||
Reference in New Issue
Block a user