feature/mejora-del-side-bar (#14)
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit

mejora de sidebar y correcciones en interfaces y propagacion de contrasena sql

Reviewed-on: #14
Co-authored-by: hreyes <hreyes@aduanasoft.com.mx>
Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
This commit is contained in:
2026-07-02 21:00:34 +00:00
committed by acazares
parent 1b198953d4
commit a1e7dddda0
9 changed files with 543 additions and 147 deletions

View File

@@ -378,17 +378,19 @@ export async function insertDatabaseNode(row: {
activo: number;
restoreTargetId?: number | null;
}): Promise<void> {
// server_name se deriva del servidor de restauración asignado (su IP); si el target
// aún no tiene IP configurada, se conserva el serverName recibido (puede ir vacío).
// server_name y sql_password se derivan del servidor de restauración asignado (su IP y su
// credencial SQL cifrada); si el target aún no tiene IP, se conserva el serverName recibido.
await pgPool.query(
`
INSERT INTO ${qNodes()} (
node_subnode_key, rfc, legal_name, branch_name,
notification_email, server_name, database_name, is_active, restore_target_id
notification_email, server_name, database_name, is_active, restore_target_id,
sql_password
) VALUES (
$1, $2, $3, $4, $5,
COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $9), $6),
$7, $8, $9
$7, $8, $9,
(SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $9)
)
`,
[
@@ -430,7 +432,8 @@ export async function updateDatabaseNode(
server_name = COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $9), $6),
database_name = $7,
is_active = $8,
restore_target_id = $9
restore_target_id = $9,
sql_password = (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $9)
WHERE id = $10
`,
[
@@ -829,9 +832,10 @@ export async function listNodesForAssignment(): Promise<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.
* `targetId` (reasignando desde donde estuvieran y derivando server_name y sql_password del
* target); los nodos que estaban en este restaurador y ya NO vienen marcados quedan sin asignar
* (restore_target_id y sql_password en NULL). Los nodos de OTROS restauradores no marcados no se
* tocan. Transacción con prepared statements.
*/
export async function assignNodesToRestoreTarget(
targetId: number,
@@ -841,21 +845,23 @@ export async function assignNodesToRestoreTarget(
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).
// 1) Asignar/reasignar los marcados (server_name = IP del target; sql_password = su credencial cifrada).
await client.query(
`
UPDATE ${qNodes()}
SET restore_target_id = $1,
server_name = COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $1), server_name)
server_name = COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $1), server_name),
sql_password = (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $1)
WHERE id = ANY($2::int[])
`,
[targetId, ids]
);
// 2) Quitar de este restaurador los que quedaron desmarcados (NULL); no toca otros targets.
// 2) Quitar de este restaurador los desmarcados (restore_target_id y sql_password a NULL); no toca otros targets.
await client.query(
`
UPDATE ${qNodes()}
SET restore_target_id = NULL
SET restore_target_id = NULL,
sql_password = NULL
WHERE restore_target_id = $1
AND NOT (id = ANY($2::int[]))
`,
@@ -872,8 +878,9 @@ export async function assignNodesToRestoreTarget(
/**
* 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.
* restore_target_id del nodo (y deriva server_name y sql_password del restaurador si no es NULL).
* Pares con targetId NULL dejan el nodo sin asignar (conservando server_name, sql_password a NULL).
* Transacción.
*/
export async function applyNodeAssignments(
pairs: { nodeId: number; targetId: number | null }[]
@@ -891,6 +898,10 @@ export async function applyNodeAssignments(
server_name = CASE
WHEN $2::int IS NULL THEN server_name
ELSE COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $2), server_name)
END,
sql_password = CASE
WHEN $2::int IS NULL THEN NULL
ELSE (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $2)
END
WHERE id = $1
`,
@@ -1234,6 +1245,57 @@ export async function listFailedRestoreJobLogs(limit = 100): Promise<FailedResto
}
}
/** Restores completados/reenviados (todos los servidores) para el panel de restaurados. */
export interface RestoredRestoreRow {
id: number;
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 | null;
size_bytes: number | null;
restored_at: Date;
}
/**
* Restores exitosos (`completed`/`forwarded`) desde restore_job_logs — lo que reporta
* CloudRestoreAS vía POST /api/restore/job-result. Resuelve node_key/client_name por nombre
* de base contra database_nodes (LATERAL … LIMIT 1 para no duplicar la fila del log si dos
* nodos comparten database_name). Cuando no hay match, ambos quedan null y la UI cae a db_name.
*/
export async function listRestoredRestoreJobLogs(limit = 200): Promise<RestoredRestoreRow[]> {
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,
dn.node_subnode_key AS node_key, dn.legal_name AS client_name,
jl.filename, jl.db_name, jl.rel_path, jl.size_bytes, jl.restored_at
FROM ${qRestoreJobLogs()} jl
LEFT JOIN ${qRestoreTargets()} rt ON rt.id = jl.restore_target_id
LEFT JOIN LATERAL (
SELECT n.node_subnode_key, n.legal_name
FROM ${qNodes()} n
WHERE LOWER(TRIM(n.database_name)) = LOWER(TRIM(jl.db_name))
LIMIT 1
) dn ON true
WHERE jl.status IN ('completed', 'forwarded')
ORDER BY jl.restored_at DESC
LIMIT $1
`,
[capped]
);
return r.rows as RestoredRestoreRow[];
} catch (e) {
if (isPgUndefinedTable(e)) return [];
throw e;
}
}
/** Carpetas de un restaurador para descarga por filesystem (input/processed reportados). */
export interface RestoreTargetDownload {
id: number;

View File

@@ -0,0 +1,54 @@
/**
* Pruebas de resolveNodeSqlPassword: la columna database_nodes.sql_password se autorrellena
* al asignar un restaurador copiando su sobre cifrado (gcm:), así que al resolverla hay que
* descifrarla. Cubre: sin valor → global, texto plano legado, sobre gcm: descifrado, y
* descifrado que falla → global (no debe tumbar el dashboard).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { env } from '$env/dynamic/private';
// Se conserva el isEncrypted real (trivial: prefijo gcm:) y solo se controla el descifrado.
vi.mock('./crypto', async (importOriginal) => {
const actual = await importOriginal<typeof import('./crypto')>();
return { ...actual, decryptSecret: vi.fn() };
});
import { decryptSecret } from './crypto';
import { resolveNodeSqlPassword } from './mssql-nodes';
const decryptMock = vi.mocked(decryptSecret);
beforeEach(() => {
vi.clearAllMocks();
env.PANEL_MSSQL_PASSWORD = 'GLOBAL_PWD';
});
describe('resolveNodeSqlPassword', () => {
it('sin valor (null/undefined/vacío/espacios) usa la contraseña global', () => {
expect(resolveNodeSqlPassword(null)).toBe('GLOBAL_PWD');
expect(resolveNodeSqlPassword(undefined)).toBe('GLOBAL_PWD');
expect(resolveNodeSqlPassword('')).toBe('GLOBAL_PWD');
expect(resolveNodeSqlPassword(' ')).toBe('GLOBAL_PWD');
expect(decryptMock).not.toHaveBeenCalled();
});
it('texto plano legado (sin prefijo gcm:) se usa tal cual y se recorta, sin descifrar', () => {
expect(resolveNodeSqlPassword(' MiPassPlano ')).toBe('MiPassPlano');
expect(decryptMock).not.toHaveBeenCalled();
});
it('sobre cifrado gcm: se descifra y devuelve el texto plano', () => {
decryptMock.mockReturnValue('SecretoDescifrado');
const envelope = 'gcm:aXY=:dGFn:Y2lwaGVy';
expect(resolveNodeSqlPassword(envelope)).toBe('SecretoDescifrado');
expect(decryptMock).toHaveBeenCalledWith(envelope);
});
it('si el descifrado falla (sobre corrupto o clave equivocada) cae a la global sin propagar el error', () => {
decryptMock.mockImplementation(() => {
throw new Error('autenticación GCM inválida');
});
expect(resolveNodeSqlPassword('gcm:corrupto')).toBe('GLOBAL_PWD');
expect(decryptMock).toHaveBeenCalledOnce();
});
});

View File

@@ -4,6 +4,13 @@
*/
import sql from 'mssql';
import { env } from '$env/dynamic/private';
import { decryptSecret, isEncrypted } from './crypto';
// Timeout de conexión acotado en TODOS los entornos: un SQL Server sano conecta en <1s,
// así que 5s (alineado con el pool de PostgreSQL) es holgado. Antes eran 30s, lo que colgaba
// la carga del dashboard cuando algún servidor no responde. Configurable por env si un
// despliegue lo necesita.
const MSSQL_CONNECT_TIMEOUT_MS = Number(env.PANEL_MSSQL_CONNECT_TIMEOUT_MS) || 5000;
const MAX_POOLS = 16;
// Se cachea la *promesa* del pool (no el pool ya resuelto) para que varias cargas de nodos
@@ -35,10 +42,22 @@ export function resolveMssqlUser(): string {
);
}
/** Contraseña SQL: columna sql_password del nodo (texto plano) o variable global. */
/**
* Contraseña SQL del nodo. La columna sql_password se autorrellena al asignar un
* restaurador copiando su sobre cifrado (gcm:), así que aquí se descifra al vuelo.
* Acepta también texto plano legado. Si no hay valor por nodo, cae a la variable global.
*/
export function resolveNodeSqlPassword(nodeSqlPassword: string | null | undefined): string {
const raw = nodeSqlPassword != null ? String(nodeSqlPassword).trim() : '';
if (raw) return raw;
if (raw) {
if (!isEncrypted(raw)) return raw; // texto plano legado
try {
return decryptSecret(raw);
} catch {
// sobre corrupto o clave equivocada: caer al global en vez de tumbar el dashboard
return String(env.PANEL_MSSQL_PASSWORD || '').trim();
}
}
return String(
env.PANEL_MSSQL_PASSWORD || ''
).trim();
@@ -113,10 +132,13 @@ export async function getMssqlPoolMaster(serverHost: string, password: string):
password,
server,
database: 'master',
// node-mssql gobierna el timeout de conexión con `connectionTimeout` (top-level);
// se replica en options.connectTimeout (tedious) para cubrir ambas rutas.
connectionTimeout: MSSQL_CONNECT_TIMEOUT_MS,
options: {
encrypt: true,
trustServerCertificate: true,
connectTimeout: 30000
connectTimeout: MSSQL_CONNECT_TIMEOUT_MS
}
};