diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0264318..5a2a05a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(xargs ls:*)", "Bash(npm install:*)", "Bash(node -e \"require\\(''''c:/CPANEL/PANEL_BASES_ANEXO24/node_modules/exceljs''''\\)\")", - "Bash(docker-compose up:*)" + "Bash(docker-compose up:*)", + "Bash(npx svelte-check *)" ] } } diff --git a/.env.example b/.env.example index 74c1405..c4933c4 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,11 @@ DB_POSTGRES_DB=CONTROLDESK # JWT Secret (cambiar en producción) JWT_SECRET=change-this-secret-in-production-please-use-a-long-random-string -# Ruta de backups +# Ruta de respaldos (LEGACY / fallback): carpeta única leída por la vista "Respaldos +# Almacenados" y por /backup?file=... cuando no se especifica restaurador. Las vistas nuevas +# "Respaldos Restaurados" y "Restores Fallidos" resuelven la carpeta de CADA restaurador de +# forma relativa (derivada de la Entrada que reporta cada uno), sin usar esta variable. +# Apúntala a una carpeta de procesados accesible por filesystem (local o share) si usas el modo legacy. BACKUP_PATH=D:/BackupSFTP/ # SMTP para envío de avisos de alertas críticas diff --git a/database/schema.sql b/database/schema.sql index bc5c6c3..241fd90 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -85,6 +85,13 @@ CREATE TABLE IF NOT EXISTS a24c.restore_targets ( ssh_password_encrypted TEXT, -- credencial SSH cifrada (AES-256-GCM) remote_inbox_path VARCHAR(500), -- ruta en el server donde se sube el .bak y se restaura (C:\RestoreInbox) notes TEXT, + -- Características de hardware (opcionales, capturadas a mano). Se usan para distribuir bases + -- por capacidad; el disco es la capacidad que manda. NULL = sin capturar. + os VARCHAR(50), -- Sistema operativo (Linux/Windows/…) + ram_gb INTEGER, -- RAM en GB + disk_gb INTEGER, -- Disco en GB + location VARCHAR(255), -- Ubicación (ej: Kansas City, United States) + size_category VARCHAR(20), -- Etiqueta: Chico | Mediano | Grande created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -117,6 +124,9 @@ CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_restored_at ON a24c.restore_job_logs (restored_at); CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_status ON a24c.restore_job_logs (status); +-- Bitácora por servidor en el panel: últimas N restauraciones de un target. +CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_target_date + ON a24c.restore_job_logs (restore_target_id, restored_at DESC); COMMENT ON TABLE a24c.restore_targets IS 'Servidores SQL Server destino de restauración (CloudRestoreAS). Contraseña cifrada AES-256-GCM.'; COMMENT ON TABLE a24c.restore_job_logs IS 'Bitácora de restauraciones reportadas por CloudRestoreAS.'; diff --git a/package-lock.json b/package-lock.json index e77ac31..0c4f975 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "jsonwebtoken": "^9.0.3", "mssql": "^10.0.2", "nodemailer": "^8.0.9", - "pg": "^8.18.0" + "pg": "^8.18.0", + "ssh2-sftp-client": "^12.1.1" }, "devDependencies": { "@sveltejs/adapter-auto": "^3.3.1", @@ -29,6 +30,7 @@ "@types/node": "^22.0.0", "@types/nodemailer": "^8.0.0", "@types/pg": "^8.16.0", + "@types/ssh2-sftp-client": "^9.0.6", "autoprefixer": "^10.4.24", "postcss": "^8.5.6", "svelte": "^5.0.0-next.1", @@ -1604,6 +1606,43 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-sftp-client": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/ssh2-sftp-client/-/ssh2-sftp-client-9.0.6.tgz", + "integrity": "sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ssh2": "^1.0.0" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typespec/ts-http-runtime": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.3.tgz", @@ -1967,6 +2006,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2104,6 +2152,15 @@ "node": ">= 18" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -2324,6 +2381,12 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, "node_modules/buffer-indexof-polyfill": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", @@ -2341,6 +2404,15 @@ "node": ">=0.2.0" } }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -2588,6 +2660,35 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2631,6 +2732,20 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -5114,6 +5229,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -6434,6 +6556,40 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause" }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/ssh2-sftp-client": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-12.1.1.tgz", + "integrity": "sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==", + "license": "Apache-2.0", + "dependencies": { + "concat-stream": "^2.0.0", + "ssh2": "^1.16.0" + }, + "engines": { + "node": ">=18.20.4" + }, + "funding": { + "type": "individual", + "url": "https://square.link/u/4g7sPflL" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -7040,6 +7196,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -7127,6 +7289,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 8f44491..d1eb68e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@types/node": "^22.0.0", "@types/nodemailer": "^8.0.0", "@types/pg": "^8.16.0", + "@types/ssh2-sftp-client": "^9.0.6", "autoprefixer": "^10.4.24", "postcss": "^8.5.6", "svelte": "^5.0.0-next.1", @@ -41,7 +42,8 @@ "jsonwebtoken": "^9.0.3", "mssql": "^10.0.2", "nodemailer": "^8.0.9", - "pg": "^8.18.0" + "pg": "^8.18.0", + "ssh2-sftp-client": "^12.1.1" }, "type": "module" } diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte new file mode 100644 index 0000000..10f21ef --- /dev/null +++ b/src/lib/components/AppShell.svelte @@ -0,0 +1,243 @@ + + +{#snippet navLink(item: NavItem, isCollapsed: boolean)} + + {item.icon} + {#if !isCollapsed}{item.label}{/if} + +{/snippet} + +{#snippet sidebarInner(isCollapsed: boolean, isMobile: boolean)} +
+ +
+
+
+ Aduanasoft +
+ {#if !isCollapsed || isMobile} +
+

Aduanasoft

+

Bases de datos

+
+ {/if} +
+ {#if isMobile} + + {:else} + + {/if} +
+ + + + + +
+ {#if !isCollapsed || isMobile} +
+
+
+ person +
+
+

{displayName}

+ {#if currentUser?.email} +

{currentUser.email}

+ {/if} +
+
+
+ +
+
Aduanasoft · v1.0.0
+
+ {:else} +
+
+ person +
+
+ +
+
+ {/if} +
+
+{/snippet} + +
+ + + + + {#if mobileOpen} + + + {/if} + + +
+ +
+
+ +

{title}

+
+
+ {#if headerActions} + {@render headerActions()} + {/if} + +
+
+ + +
+ {@render children()} +
+
+
diff --git a/src/lib/components/Navbar.svelte b/src/lib/components/Navbar.svelte deleted file mode 100644 index d3d007a..0000000 --- a/src/lib/components/Navbar.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - - diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte deleted file mode 100644 index a40971f..0000000 --- a/src/lib/components/Sidebar.svelte +++ /dev/null @@ -1,123 +0,0 @@ - - - diff --git a/src/lib/restore-suggest.test.ts b/src/lib/restore-suggest.test.ts new file mode 100644 index 0000000..eb30248 --- /dev/null +++ b/src/lib/restore-suggest.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest'; +import { + categorize, + suggestForServer, + distributeGlobal, + type Candidate +} from './restore-suggest'; + +describe('categorize', () => { + it('clasifica por límites con bordes inclusivos hacia la menor', () => { + expect(categorize(1)).toBe('Chica'); + expect(categorize(100)).toBe('Chica'); // borde chico + expect(categorize(101)).toBe('Mediana'); + expect(categorize(1024)).toBe('Mediana'); // borde mediano + expect(categorize(1025)).toBe('Grande'); + expect(categorize(500000)).toBe('Grande'); + }); + + it('respeta límites personalizados', () => { + expect(categorize(200, { chicoMb: 200, medianoMb: 2000 })).toBe('Chica'); + expect(categorize(201, { chicoMb: 200, medianoMb: 2000 })).toBe('Mediana'); + expect(categorize(2001, { chicoMb: 200, medianoMb: 2000 })).toBe('Grande'); + }); +}); + +const cand = (nodeId: number, sizeMb: number, categoria: Candidate['categoria']): Candidate => ({ + nodeId, + sizeMb, + categoria +}); + +describe('suggestForServer', () => { + it('no selecciona nada si el disco es nulo o cero', () => { + const candidates = [cand(1, 50, 'Chica')]; + expect(suggestForServer({ candidates, diskGb: 0 })).toEqual([]); + expect(suggestForServer({ candidates, diskGb: null })).toEqual([]); + }); + + it('respeta el % del disco (no excede el presupuesto)', () => { + // disco 1 GB = 1024 MB, pct 80% => 819.2 MB de presupuesto. + const candidates = [ + cand(1, 500, 'Mediana'), + cand(2, 400, 'Mediana'), + cand(3, 400, 'Mediana') + ]; + const picked = suggestForServer({ candidates, diskGb: 1, pct: 80 }); + const sum = candidates + .filter((c) => picked.includes(c.nodeId)) + .reduce((a, c) => a + c.sizeMb, 0); + expect(sum).toBeLessThanOrEqual(1024 * 0.8); + // Debe caber 500 + una de 400 (900 > 819) -> en realidad 500+400=900 excede; solo 500+... + // 500 cabe; luego 400 -> 900 > 819 no cabe; otra 400 tampoco. Selecciona solo la de 500. + expect(picked).toEqual([1]); + }); + + it('balancea entre categorías (un poco de todo) antes de agotar una sola', () => { + const candidates = [ + cand(1, 900, 'Grande'), + cand(2, 800, 'Grande'), + cand(3, 300, 'Mediana'), + cand(4, 200, 'Mediana'), + cand(5, 50, 'Chica'), + cand(6, 40, 'Chica') + ]; + // disco 10 GB, pct 100 => 10240 MB, cabe todo. + const picked = suggestForServer({ candidates, diskGb: 10, pct: 100 }); + expect(new Set(picked)).toEqual(new Set([1, 2, 3, 4, 5, 6])); + // Primera ronda toma la mayor de cada categoría: Grande(900), Mediana(300), Chica(50). + expect(picked.slice(0, 3)).toEqual([1, 3, 5]); + }); + + it('toma una base menor de la categoría si la mayor no cabe', () => { + const candidates = [cand(1, 1000, 'Grande'), cand(2, 100, 'Grande')]; + // presupuesto 819 MB: la de 1000 no cabe, la de 100 sí. + const picked = suggestForServer({ candidates, diskGb: 1, pct: 80 }); + expect(picked).toEqual([2]); + }); + + it('excluye tamaños no usables (NaN, 0, negativos)', () => { + const candidates = [ + cand(1, Number.NaN, 'Grande'), + cand(2, 0, 'Chica'), + cand(3, -50, 'Mediana'), + cand(4, 200, 'Mediana') + ]; + const picked = suggestForServer({ candidates, diskGb: 10, pct: 100 }); + expect(picked).toEqual([4]); + }); + + it('devuelve [] si el disco es inválido (NaN/Infinity)', () => { + const candidates = [cand(1, 50, 'Chica')]; + expect(suggestForServer({ candidates, diskGb: Number.NaN })).toEqual([]); + expect(suggestForServer({ candidates, diskGb: Infinity })).toEqual([]); + }); +}); + +describe('distributeGlobal', () => { + it('ignora servidores sin disco y deja todo sin asignar', () => { + const candidates = [cand(1, 100, 'Chica')]; + const res = distributeGlobal({ servers: [{ id: 1, diskGb: null }], candidates }); + expect(res).toEqual({ 1: null }); + }); + + it('nunca excede el presupuesto de cada servidor ni asigna dos veces', () => { + const candidates = [ + cand(1, 700, 'Grande'), + cand(2, 700, 'Grande'), + cand(3, 700, 'Grande'), + cand(4, 700, 'Grande') + ]; + // Dos servidores de 1 GB (819 MB @80%): cada uno solo admite una de 700. + const servers = [ + { id: 10, diskGb: 1 }, + { id: 20, diskGb: 1 } + ]; + const res = distributeGlobal({ servers, candidates, pct: 80 }); + const perServer: Record = {}; + for (const c of candidates) { + const s = res[c.nodeId]; + if (s != null) perServer[s] = (perServer[s] ?? 0) + c.sizeMb; + } + for (const total of Object.values(perServer)) { + expect(total).toBeLessThanOrEqual(1024 * 0.8); + } + // Solo caben 2 bases (una por servidor); las otras 2 quedan null. + const assigned = candidates.filter((c) => res[c.nodeId] != null); + expect(assigned.length).toBe(2); + }); + + it('reparte proporcional al disco (el más grande recibe más)', () => { + const candidates = Array.from({ length: 6 }, (_, i) => cand(i + 1, 100, 'Chica')); + // Server A tiene 3x el disco de B => debe recibir más bases. + const servers = [ + { id: 1, diskGb: 3 }, + { id: 2, diskGb: 1 } + ]; + const res = distributeGlobal({ servers, candidates, pct: 100 }); + const countA = candidates.filter((c) => res[c.nodeId] === 1).length; + const countB = candidates.filter((c) => res[c.nodeId] === 2).length; + expect(countA).toBeGreaterThan(countB); + expect(countA + countB).toBe(6); + }); + + it('nunca asigna a un servidor sin disco usable', () => { + const candidates = [cand(1, 100, 'Chica'), cand(2, 100, 'Chica')]; + const servers = [ + { id: 1, diskGb: null }, + { id: 2, diskGb: Number.NaN }, + { id: 3, diskGb: 10 } + ]; + const res = distributeGlobal({ servers, candidates, pct: 100 }); + expect(res[1]).toBe(3); + expect(res[2]).toBe(3); + // ningún nodo terminó en 1 ni 2 + expect(Object.values(res).every((v) => v === 3)).toBe(true); + }); + + it('ignora candidatos sin tamaño usable y deja el resto coherente', () => { + const candidates = [cand(1, 0, 'Chica'), cand(2, Number.NaN, 'Grande'), cand(3, 100, 'Chica')]; + const res = distributeGlobal({ servers: [{ id: 9, diskGb: 10 }], candidates, pct: 100 }); + expect(res[1]).toBeNull(); + expect(res[2]).toBeNull(); + expect(res[3]).toBe(9); + }); + + it('base más grande que cualquier disco queda sin ubicar (null)', () => { + const candidates = [cand(1, 5000, 'Grande')]; + const res = distributeGlobal({ servers: [{ id: 1, diskGb: 1 }], candidates, pct: 80 }); + expect(res[1]).toBeNull(); + }); +}); diff --git a/src/lib/restore-suggest.ts b/src/lib/restore-suggest.ts new file mode 100644 index 0000000..3836cca --- /dev/null +++ b/src/lib/restore-suggest.ts @@ -0,0 +1,165 @@ +/** + * Lógica pura de distribución de bases entre servidores de restauración por capacidad. + * Sin dependencias de SvelteKit ni de servidor: se usa en el cliente y se prueba con Vitest. + * + * Regla de tamaños (misma clasificación que el query provisto por el negocio): + * Chica <= chicoMb (default 100 MB) + * Mediana <= medianoMb (default 1024 MB) + * Grande en otro caso + * + * El disco es la capacidad que manda: cada servidor se llena hasta `pct`% de su disco. + */ + +export type SizeCategory = 'Chica' | 'Mediana' | 'Grande'; + +/** Orden de reparto para el balanceo "un poco de todo" (mayor impacto primero). */ +export const CATEGORY_ORDER: readonly SizeCategory[] = ['Grande', 'Mediana', 'Chica'] as const; + +export const DEFAULT_LIMITS = { chicoMb: 100, medianoMb: 1024 } as const; +export const DEFAULT_PCT = 80; +const MB_PER_GB = 1024; + +export interface Candidate { + nodeId: number; + sizeMb: number; + categoria: SizeCategory; +} + +export interface ServerCapacity { + id: number; + diskGb: number | null | undefined; +} + +/** Clasifica un tamaño en MB según los límites (bordes inclusivos hacia la categoría menor). */ +export function categorize( + sizeMb: number, + limits: { chicoMb?: number; medianoMb?: number } = {} +): SizeCategory { + const chicoMb = limits.chicoMb ?? DEFAULT_LIMITS.chicoMb; + const medianoMb = limits.medianoMb ?? DEFAULT_LIMITS.medianoMb; + if (sizeMb <= chicoMb) return 'Chica'; + if (sizeMb <= medianoMb) return 'Mediana'; + return 'Grande'; +} + +function budgetMbForDisk(diskGb: number, pct: number): number { + return diskGb * MB_PER_GB * (pct / 100); +} + +/** Disco utilizable: número finito y positivo. */ +export function hasUsableDisk(diskGb: number | null | undefined): diskGb is number { + return typeof diskGb === 'number' && Number.isFinite(diskGb) && diskGb > 0; +} + +/** Tamaño utilizable: número finito y positivo (excluye NaN, 0 y negativos). */ +export function isUsableSize(sizeMb: number | null | undefined): sizeMb is number { + return typeof sizeMb === 'number' && Number.isFinite(sizeMb) && sizeMb > 0; +} + +function bucketsByCategory(candidates: Candidate[]): Record { + const buckets: Record = { Grande: [], Mediana: [], Chica: [] }; + for (const c of candidates) { + // Ignora candidatos sin tamaño usable (base no medible: SQL caído, sin métrica, 0). + if (!isUsableSize(c.sizeMb)) continue; + if (buckets[c.categoria]) buckets[c.categoria].push(c); + } + // Mayor a menor dentro de cada categoría (empaca primero lo grande). + for (const cat of CATEGORY_ORDER) buckets[cat].sort((a, b) => b.sizeMb - a.sizeMb); + return buckets; +} + +/** + * Selecciona, para UN servidor, un subconjunto balanceado de `candidates` que quepa en + * `pct`% del disco. Reparte por rondas entre categorías (Grande→Mediana→Chica) tomando en + * cada ronda la mayor de cada categoría que aún quepa. Devuelve los nodeId seleccionados. + */ +export function suggestForServer(opts: { + candidates: Candidate[]; + diskGb: number | null | undefined; + pct?: number; +}): number[] { + const { candidates } = opts; + const pct = opts.pct ?? DEFAULT_PCT; + if (!hasUsableDisk(opts.diskGb)) return []; + const budgetMb = budgetMbForDisk(opts.diskGb, pct); + + const buckets = bucketsByCategory(candidates); + const selected: number[] = []; + let usedMb = 0; + let progressed = true; + while (progressed) { + progressed = false; + for (const cat of CATEGORY_ORDER) { + const bucket = buckets[cat]; + // La mayor que aún quepa (sorted desc → primera que cumpla). + const idx = bucket.findIndex((c) => usedMb + c.sizeMb <= budgetMb); + if (idx >= 0) { + const [item] = bucket.splice(idx, 1); + selected.push(item.nodeId); + usedMb += item.sizeMb; + progressed = true; + } + } + } + return selected; +} + +/** + * Reparte `candidates` entre varios `servers` de forma balanceada (un poco de cada categoría) + * y proporcional al disco (el servidor con más capacidad libre recibe primero). Respeta el + * `pct`% del disco de cada servidor y nunca asigna una base a más de un servidor. + * Devuelve un mapa nodeId → serverId (o null si no cupo en ningún servidor). + */ +export function distributeGlobal(opts: { + servers: ServerCapacity[]; + candidates: Candidate[]; + pct?: number; +}): Record { + const pct = opts.pct ?? DEFAULT_PCT; + const state = opts.servers + .filter((s) => hasUsableDisk(s.diskGb)) + .map((s) => ({ id: s.id, budgetMb: budgetMbForDisk(s.diskGb as number, pct), usedMb: 0 })); + + const assignment: Record = {}; + for (const c of opts.candidates) assignment[c.nodeId] = null; + if (state.length === 0) return assignment; + + const buckets = bucketsByCategory(opts.candidates); + let progressed = true; + while (progressed) { + progressed = false; + for (const cat of CATEGORY_ORDER) { + const bucket = buckets[cat]; + if (bucket.length === 0) continue; + // Mayor base de la categoría que quepa en algún servidor + su mejor servidor (más libre). + let pickIdx = -1; + let pickServer: (typeof state)[number] | null = null; + for (let i = 0; i < bucket.length; i++) { + let best: (typeof state)[number] | null = null; + let bestFree = -1; + for (const st of state) { + const free = st.budgetMb - st.usedMb; + if (bucket[i].sizeMb <= free && free > bestFree) { + best = st; + bestFree = free; + } + } + if (best) { + pickIdx = i; + pickServer = best; + break; + } + } + if (pickIdx >= 0 && pickServer) { + const [item] = bucket.splice(pickIdx, 1); + pickServer.usedMb += item.sizeMb; + assignment[item.nodeId] = pickServer.id; + progressed = true; + } else { + // Ninguna base de esta categoría cabe en ningún servidor: quedan sin asignar. + bucket.length = 0; + } + } + } + return assignment; +} diff --git a/src/lib/server/api-error.ts b/src/lib/server/api-error.ts index d5978e5..ff8000c 100644 --- a/src/lib/server/api-error.ts +++ b/src/lib/server/api-error.ts @@ -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 ) { diff --git a/src/lib/server/backup-files.test.ts b/src/lib/server/backup-files.test.ts new file mode 100644 index 0000000..8b9988d --- /dev/null +++ b/src/lib/server/backup-files.test.ts @@ -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 { + 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'); + }); +}); diff --git a/src/lib/server/backup-files.ts b/src/lib/server/backup-files.ts new file mode 100644 index 0000000..09e622c Binary files /dev/null and b/src/lib/server/backup-files.ts differ diff --git a/src/lib/server/controldesk-pg.ts b/src/lib/server/controldesk-pg.ts index 59a657b..d65345d 100644 --- a/src/lib/server/controldesk-pg.ts +++ b/src/lib/server/controldesk-pg.ts @@ -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 { 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 { + 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 { + 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 { `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 { @@ -666,12 +735,16 @@ export async function createRestoreTarget(input: RestoreTargetInput): Promise { 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 { + 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 { + 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 { + 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 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + }; +} diff --git a/src/lib/server/email-service.test.ts b/src/lib/server/email-service.test.ts new file mode 100644 index 0000000..e1cb446 --- /dev/null +++ b/src/lib/server/email-service.test.ts @@ -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: '', clientName: 'x', last_restore_date: null, daysWithout: null } + ]); + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + expect(html).not.toContain('x'); + 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: '', last_restore_date: null, daysWithout: null } + ]); + expect(html).not.toContain(''); + 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'); + }); +}); diff --git a/src/lib/server/email-service.ts b/src/lib/server/email-service.ts index 85d8f19..6689821 100644 --- a/src/lib/server/email-service.ts +++ b/src/lib/server/email-service.ts @@ -65,95 +65,91 @@ export async function sendSmtpEmail(opts: SendEmailOptions): Promise { }); } -/** 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 ` - - ${escHtml(a.visible_name)} - ${escHtml(a.clientName ?? 'N/D')} - ${ultimaRest} - - - ${diasLabel} - - - `; - }) - .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 = + `

` + + `Consulta la última sincronización de datos fácilmente desde ` + + `SCAIIWeb. ` + + `Inicia sesión y encontrarás esta información en la esquina inferior derecha de la pantalla.

`; + +const FOOTER = + `
` + + `© 2024 TransmitirAS. Todos los derechos reservados.
`; + +/** Envuelve el contenido en el mismo cascarón del legacy (Arial, logo centrado, pie). */ +function renderSyncEmail(inner: string): string { return ` - - - -
- - - - - - - - - - - - - -
-

- Alerta de respaldo — Aduanasoft -

-

- ${alerts.length} base${alerts.length !== 1 ? 's' : ''} de datos sin restaurar recientemente -

-
- - - - - - - - - - ${rows} -
Base de datosClienteÚltima restauraciónDías sin sync
-
-

- Este mensaje fue generado automáticamente por el Panel de Control de Bases de Datos Aduanasoft. - Por favor no responda a este correo. -

-
-
+ +
+
+ Logo +
+ ${inner} + ${FOOTER} +
`; } +/** 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 = ` +

Notificación de Sincronización

+

Estimado ${nombre},

+

Detectamos que la base de datos ${bd} no se ha sincronizado correctamente en las últimas 24 horas.

+

La última restauración registrada fue: ${fecha}.

+ ${SCAIIWEB_H4} +

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.

`; + 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 = ` +

Sincronización Restablecida

+

Estimado ${nombre},

+

La base de datos ${bd} volvió a sincronizarse correctamente.

+

La última restauración registrada fue: ${fecha}.

+ ${SCAIIWEB_H4} +

No se requiere ninguna acción de su parte. Gracias por mantener su equipo y la aplicación en funcionamiento.

`; + return renderSyncEmail(inner); +} + export interface BackupAlertRow { visible_name: string; clientName: string | null; diff --git a/src/lib/server/restore-fetch.ts b/src/lib/server/restore-fetch.ts new file mode 100644 index 0000000..a95a3d3 --- /dev/null +++ b/src/lib/server/restore-fetch.ts @@ -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; + 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 { + 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, + size: st.size, + filename, + cleanup: () => node.destroy() + }; +} diff --git a/src/lib/server/restore-inventory.ts b/src/lib/server/restore-inventory.ts new file mode 100644 index 0000000..e9a2a07 --- /dev/null +++ b/src/lib/server/restore-inventory.ts @@ -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 { + const [targets, statuses, nodes] = await Promise.all([ + listRestoreTargets(), + listCloudRestoreStatuses(), + listNodesForAssignment() + ]); + + // instance_key (case-insensitive) → input_folder reportado. + const inputByInstance = new Map(); + 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(); + 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 }; +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 3ccf7f0..76d0a2c 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,5 +1,4 @@ import { env } from '$env/dynamic/private'; -import fs from 'node:fs/promises'; import path from 'node:path'; import crypto from 'node:crypto'; import { redirect } from '@sveltejs/kit'; @@ -7,6 +6,9 @@ import type { PageServerLoad, Actions } from './$types'; import { verifyToken } from '$lib/server/auth'; import { getUserById, filterDatabasesByUserPermissions } from '$lib/server/users'; import { loadSqlDashboardFromNodes, type CatalogNodeRow } from '$lib/server/mssql-nodes'; +import { listBackupFiles } from '$lib/server/backup-files'; +import { scanRestoreInventory } from '$lib/server/restore-inventory'; +import { logger } from '$lib/server/logger'; import { listClientsCatalog, listDatabaseNodes, @@ -22,7 +24,9 @@ import { insertPortalUser, updatePortalUser, deletePortalUser, - listRestoreTargets + listRestoreTargets, + listNodeLastRestore, + listFailedRestoreJobLogs } from '$lib/server/controldesk-pg'; import { listAdditionalEmails, @@ -112,6 +116,8 @@ export const load: PageServerLoad = async ({ cookies }) => { let summaryAZ: any = null; let backupFiles: any[] = []; + let restoredBackups: any[] = []; + let failedRestores: any[] = []; let clientsData: any[] = []; let alertsData: any[] = []; let basesDeDatosList: any[] = []; @@ -187,54 +193,55 @@ export const load: PageServerLoad = async ({ cookies }) => { // --- 4. Process Backups, Hydrate Alerts & Enriquecer databaseRows con datos de BasesDeDatos --- try { - let files: string[] = []; - try { - files = await fs.readdir(env.BACKUP_PATH as string); - } catch (e) { - files = []; - errors.backups = "No se pudo acceder a la carpeta de respaldos."; - } - - for (const file of files) { - if (file === '.' || file === '..') continue; - - const filePath = path.join(env.BACKUP_PATH as string, file); - let stats; + // El CloudRestoreAS nuevo reubica los respaldos en `Procesados//`; se + // recorre la raíz de forma recursiva (ver listBackupFiles). BACKUP_PATH debe apuntar + // a esa raíz. Las entradas ya vienen ordenadas por fecha descendente y con multipart + // colapsado. + const backupRoot = env.BACKUP_PATH as string | undefined; + if (!backupRoot) { + errors.backups = 'La ruta de respaldos (BACKUP_PATH) no está configurada.'; + } else { + let listing: { files: Awaited>['files']; truncated: boolean }; try { - stats = await fs.stat(filePath); - } catch { continue; } - - if (!stats.isFile()) continue; - - const nodoName = path.parse(file).name; - let clientData: any = null; - - if (controlDeskOk && basesDeDatosList.length) { - try { - clientData = matchNodeRowFromBackupStem(nodoName, basesDeDatosList); - } catch { - /* ignore */ - } + listing = await listBackupFiles(backupRoot); + } catch { + listing = { files: [], truncated: false }; + errors.backups = 'No se pudo acceder a la carpeta de respaldos.'; } - backupFiles.push({ - name: file, - nodo_name: (clientData?.NodoSubNodo as string | undefined) || nodoName, - client_name: clientData?.Nombre ?? 'Cliente no identificado', - client_authority: clientData?.RFC ?? 'N/A', - bd_shelter: 'N/A', - date: stats.mtime, - size: (stats.size / 1024 / 1024).toFixed(2) + " MB" - }); + if (listing.truncated) { + logger.warn({ + message: 'Listado de respaldos recortado por límite de archivos', + context: { backupRoot } + }); + } + + for (const entry of listing.files) { + const nodoName = path.parse(entry.name).name; + let clientData: any = null; + + if (controlDeskOk && basesDeDatosList.length) { + try { + clientData = matchNodeRowFromBackupStem(nodoName, basesDeDatosList); + } catch { + /* ignore */ + } + } + + backupFiles.push({ + name: entry.name, + relPath: entry.relPath, + parts: entry.parts, + nodo_name: (clientData?.NodoSubNodo as string | undefined) || nodoName, + client_name: clientData?.Nombre ?? 'Cliente no identificado', + client_authority: clientData?.RFC ?? 'N/A', + bd_shelter: 'N/A', + date: new Date(entry.mtimeMs), + size: (entry.sizeBytes / 1024 / 1024).toFixed(2) + ' MB' + }); + } } - // Ordenar respaldos de más reciente a más antiguo por fecha de modificación - backupFiles.sort((a, b) => { - const da = new Date(a.date).getTime(); - const db = new Date(b.date).getTime(); - return db - da; - }); - if (controlDeskOk && alertsData.length > 0) { alertsData = await Promise.all( alertsData.map(async (alert) => { @@ -293,6 +300,28 @@ export const load: PageServerLoad = async ({ cookies }) => { console.error("Error processing backups/alerts hydration:", e); } + // Inventario "respaldos restaurados por nodo" (sobrevive a reasignación) y "restores + // fallidos", desde la BD (poblados vía job-result). Tolerante a fallos: si falla, se + // muestran vacíos sin romper el dashboard. + try { + // Escanea las carpetas Procesados/Fallados (derivadas de la Entrada reportada por cada + // restaurador) y refresca el cache node_last_restore. Refleja la realidad en cada carga. + const inv = await scanRestoreInventory(); + restoredBackups = inv.restored; + failedRestores = inv.failed; + } catch (e: any) { + console.error('Error escaneando inventario de restauraciones:', e); + // Fallback al cache/BD si el escaneo falla por completo (p.ej. sin acceso a carpetas). + try { + [restoredBackups, failedRestores] = await Promise.all([ + listNodeLastRestore(), + listFailedRestoreJobLogs(200) + ]); + } catch (e2: any) { + console.error('Fallback de inventario de restauraciones también falló:', e2); + } + } + // CALCULAR MÉTRICAS DE RESTAURACIÓN basadas en last_restore_date (ANTES del filtro) restoredCount = 0; notRestoredCount = 0; @@ -357,6 +386,8 @@ export const load: PageServerLoad = async ({ cookies }) => { summaryAZ, backupFiles, + restoredBackups, + failedRestores, clientsData, alertsData, basesDeDatosList, diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 4e97b22..b69aaf3 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,7 +2,7 @@ import { untrack, tick } from 'svelte'; import { deserialize } from '$app/forms'; import { invalidateAll, goto } from '$app/navigation'; - import aduanasoftLogo from '$lib/assets/logo.png'; + import AppShell from '$lib/components/AppShell.svelte'; type KitActionPayload = { success?: boolean; message?: string }; @@ -42,6 +42,10 @@ const restoredCount = $derived(data.restoredCount); const notRestoredCount = $derived(data.notRestoredCount); const backupFiles = $derived(data.backupFiles); + const restoredBackups = $derived(data.restoredBackups ?? []); + const failedRestores = $derived(data.failedRestores ?? []); + const formatMb = (bytes: number | null | undefined) => + bytes == null ? '—' : (Number(bytes) / 1024 / 1024).toFixed(2) + ' MB'; const clientsData = $derived(data.clientsData); const alertsData = $derived(data.alertsData); const basesDeDatosList = $derived(data.basesDeDatosList); @@ -74,7 +78,9 @@ })()); // Estado de vista activa y paginación - let activeView = $state<'dashboard' | 'backups' | 'clients' | 'alerts' | 'databases'>('dashboard'); + let activeView = $state< + 'dashboard' | 'backups' | 'restored' | 'failed' | 'clients' | 'alerts' | 'databases' + >('dashboard'); // Persistencia de la vista activa del sidebar en localStorage const ACTIVE_VIEW_KEY = 'aduanasoft-dashboard-active-view'; @@ -86,6 +92,8 @@ if ( stored === 'dashboard' || stored === 'backups' || + stored === 'restored' || + stored === 'failed' || stored === 'clients' || stored === 'alerts' || stored === 'databases' @@ -333,23 +341,6 @@ }); }; - // Estado del sidebar colapsado - let sidebarCollapsed = $state(false); - - // Persistir estado del sidebar - if (typeof window !== 'undefined') { - const storedCollapsed = window.localStorage.getItem('sidebar-collapsed'); - if (storedCollapsed) { - sidebarCollapsed = storedCollapsed === 'true'; - } - } - - const toggleSidebar = () => { - sidebarCollapsed = !sidebarCollapsed; - if (typeof window !== 'undefined') { - window.localStorage.setItem('sidebar-collapsed', String(sidebarCollapsed)); - } - }; // Auto-refresh del panel principal (cada 40 segundos) if (typeof window !== 'undefined') { @@ -1120,250 +1111,70 @@ }; -
- - - - -
- -
+ {#snippet navExtra(collapsed)} + -
-

Panel de Control

-
-
+ insights + {#if !collapsed}Resumen{/if} + + + + + {#if data.currentUser?.es_admin} + + {/if} + + {#if data.currentUser?.es_admin} + + {/if} + {/snippet} -
- -
- - - -
{#if activeView === 'dashboard'} @@ -1760,14 +1571,16 @@ {#each sliceVisible(backupRowsLive, visibleBackups) as file} - {file.name} + + {file.name}{#if file.parts > 1}({file.parts} partes){/if} + {file.client_name} {new Date(file.date).toLocaleString()} {file.size} Descargar @@ -1790,6 +1603,116 @@ {/if} + {#if activeView === 'restored'} +
+
+

Respaldos restaurados

+

+ Último respaldo restaurado por nodo, por servidor de restauración. Se + conserva aunque el nodo se reasigne a otro restaurador. +

+
+
+ + + + + + + + + + + + + {#each restoredBackups as r (r.database_node_id)} + + + + + + + + + {:else} + + + + {/each} + +
ServidorNodo / ClienteArchivoFechaTamañoAcciones
{r.server_name ?? '—'} + {r.node_key ?? r.db_name ?? '—'} + {#if r.client_name}{r.client_name}{/if} + {r.filename}{new Date(r.restored_at).toLocaleString()}{formatMb(r.size_bytes)} + {#if r.restore_target_id && r.rel_path} + Descargar + {:else} + + {/if} +
+ No hay respaldos restaurados registrados. +
+
+
+ {/if} + + {#if activeView === 'failed'} +
+
+

Restores fallidos

+

+ Restauraciones que fallaron, por servidor. Puedes descargar el respaldo + para diagnóstico. +

+
+
+ + + + + + + + + + + + {#each failedRestores as f (f.id)} + + + + + + + + {:else} + + + + {/each} + +
ServidorBase / ArchivoFechaErrorAcciones
{f.server_name ?? '—'} + {f.db_name ?? '—'} + {f.filename} + {new Date(f.restored_at).toLocaleString()}{f.error_message ?? '—'} + {#if f.restore_target_id && f.rel_path} + Descargar + {:else} + + {/if} +
+ No hay restores fallidos registrados. +
+
+
+ {/if} + {#if activeView === 'clients' && data.currentUser?.es_admin}
@@ -2403,9 +2326,7 @@
{/if}
-
-
- + {#if showDbCrudModal} diff --git a/src/routes/api/alerts/send/+server.ts b/src/routes/api/alerts/send/+server.ts index 7447048..0ef63ae 100644 --- a/src/routes/api/alerts/send/+server.ts +++ b/src/routes/api/alerts/send/+server.ts @@ -1,9 +1,11 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { pgPool } from '$lib/server/db'; -import { sendSmtpEmail, buildBackupAlertHtml } from '$lib/server/email-service'; +import { sendSmtpEmail, buildBackupAlertHtml, buildBackupResolvedHtml, formatFechaEs } from '$lib/server/email-service'; interface AlertPayload { + /** ID del nodo en a24c.database_nodes; se hace echo en SendResult para reconciliación del cooldown en a24c. */ + database_node_id?: number; /** Clave del nodo (de clientData.NodoSubNodo). Puede estar vacía si el cliente no fue identificado. */ node_subnode_key: string; visible_name: string; @@ -11,11 +13,17 @@ interface AlertPayload { mainEmail: string | null; last_restore_date: string | null; daysWithout: number | null; + /** Tipo de notificación. Ausente => 'overdue' por compatibilidad. */ + kind?: 'overdue' | 'resolved'; } interface SendResult { + /** Echo del id recibido (o null si no vino) para que a24c reconcilie el cooldown. */ + database_node_id: number | null; node_subnode_key: string; visible_name: string; + /** Echo del kind efectivo aplicado. */ + kind: 'overdue' | 'resolved'; recipients: number; status: 'sent' | 'no_recipients' | 'error'; error?: string; @@ -83,40 +91,55 @@ export const POST: RequestHandler = async ({ request }) => { let failed = 0; for (const alert of alerts) { + const kind: 'overdue' | 'resolved' = alert.kind === 'resolved' ? 'resolved' : 'overdue'; + const nodeId = Number.isInteger(alert.database_node_id) ? (alert.database_node_id as number) : null; try { const additional = await getAdditionalEmails(alert.node_subnode_key, alert.visible_name); const toAddrs = dedupeEmails([alert.mainEmail, ...additional]); if (toAddrs.length === 0) { - details.push({ node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, recipients: 0, status: 'no_recipients' }); + details.push({ database_node_id: nodeId, node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, kind, recipients: 0, status: 'no_recipients' }); failed++; continue; } - const htmlBody = buildBackupAlertHtml([{ + const rowData = { visible_name: alert.visible_name, clientName: alert.clientName, last_restore_date: alert.last_restore_date, daysWithout: alert.daysWithout - }]); + }; + const nombre = alert.clientName ?? alert.visible_name; + const fecha = formatFechaEs(alert.last_restore_date); - const diasLabel = alert.daysWithout !== null ? `${alert.daysWithout} días` : 'sin datos'; - const plainBody = `Alerta de respaldo — Aduanasoft\n\nBase de datos: ${alert.visible_name}\nCliente: ${alert.clientName ?? 'N/D'}\nÚltima restauración: ${alert.last_restore_date ? new Date(alert.last_restore_date).toLocaleString('es-MX') : 'Nunca'}\nDías sin sincronizar: ${diasLabel}\n\nEste mensaje fue generado automáticamente por el Panel de Control de Bases de Datos Aduanasoft.`; + let subject: string; + let htmlBody: string; + let plainBody: string; + + if (kind === 'resolved') { + subject = `Sincronización Restablecida - ${nombre}`; + htmlBody = buildBackupResolvedHtml([rowData]); + plainBody = `Sincronización Restablecida\n\nEstimado ${nombre},\n\nLa base de datos ${alert.visible_name} volvió a sincronizarse correctamente.\nLa última restauración registrada fue: ${fecha}.\n\nConsulta la última sincronización desde SCAIIWeb: https://a24.aduanasoft.com/SCAIIWeb\n\nNo se requiere ninguna acción de su parte.\n\n© 2024 TransmitirAS. Todos los derechos reservados.`; + } else { + subject = `Alerta de Sincronización - ${nombre}`; + htmlBody = buildBackupAlertHtml([rowData]); + plainBody = `Notificación de Sincronización\n\nEstimado ${nombre},\n\nDetectamos que la base de datos ${alert.visible_name} no se ha sincronizado correctamente en las últimas 24 horas.\nLa última restauración registrada fue: ${fecha}.\n\nConsulta la última sincronización desde SCAIIWeb: https://a24.aduanasoft.com/SCAIIWeb\n\nPor 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.\n\n© 2024 TransmitirAS. Todos los derechos reservados.`; + } await sendSmtpEmail({ toAddrs, - subject: `Alerta de respaldo: ${alert.visible_name}`, + subject, plainBody, htmlBody, - fromDisplayName: 'Aduanasoft · Panel de Bases', - highImportance: (alert.daysWithout ?? 0) >= 7 + fromDisplayName: 'TransmitirAS Notificaciones', + highImportance: false }); - details.push({ node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, recipients: toAddrs.length, status: 'sent' }); + details.push({ database_node_id: nodeId, node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, kind, recipients: toAddrs.length, status: 'sent' }); sent++; } catch (err) { const msg = err instanceof Error ? err.message : String(err); - details.push({ node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, recipients: 0, status: 'error', error: msg }); + details.push({ database_node_id: nodeId, node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, kind, recipients: 0, status: 'error', error: msg }); failed++; } } diff --git a/src/routes/api/alerts/send/server.test.ts b/src/routes/api/alerts/send/server.test.ts new file mode 100644 index 0000000..f61582f --- /dev/null +++ b/src/routes/api/alerts/send/server.test.ts @@ -0,0 +1,173 @@ +/** + * Pruebas del endpoint POST /api/alerts/send (sin autenticación — el control es de red). + * + * - Ruteo overdue vs resolved (email-service mockeado). + * - Dedupe principal + adicionales (pgPool mockeado). + * - Echo de database_node_id / kind en details (reconciliación de a24c). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// vi.mock se iza al tope del archivo; las factories no pueden ver variables de +// módulo, así que los mocks se crean con vi.hoisted. +const { queryMock, sendSmtpEmail, buildBackupAlertHtml, buildBackupResolvedHtml, formatFechaEs } = vi.hoisted(() => ({ + queryMock: vi.fn(), + sendSmtpEmail: vi.fn(), + buildBackupAlertHtml: vi.fn(() => ''), + buildBackupResolvedHtml: vi.fn(() => ''), + formatFechaEs: vi.fn(() => '28 de junio de 2026, 10:00') +})); + +vi.mock('$lib/server/db', () => ({ pgPool: { query: queryMock } })); +vi.mock('$lib/server/email-service', () => ({ + sendSmtpEmail, + buildBackupAlertHtml, + buildBackupResolvedHtml, + formatFechaEs +})); + +import { POST } from './+server'; + +function makeRequest(bodyObj: unknown): Request { + return new Request('http://localhost/api/alerts/send', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: typeof bodyObj === 'string' ? bodyObj : JSON.stringify(bodyObj) + }); +} + +async function callPost(req: Request): Promise<{ status: number; body: any }> { + // El handler solo usa `request` del RequestEvent. + const res = await POST({ request: req } as any); + return { status: res.status, body: await res.json() }; +} + +const baseAlert = { + database_node_id: 42, + node_subnode_key: '08037NATM001', + visible_name: 'CLIENTE_DB', + clientName: 'ACME', + mainEmail: 'dest@x.com', + last_restore_date: '2026-06-28T10:00:00Z', + daysWithout: 3 +}; + +beforeEach(() => { + vi.clearAllMocks(); + queryMock.mockResolvedValue({ rows: [] }); // sin correos adicionales por defecto + sendSmtpEmail.mockResolvedValue(undefined); +}); + +describe('POST /api/alerts/send — validación de body', () => { + it('400 si el body no es JSON', async () => { + const { status } = await callPost(makeRequest('{no-json')); + expect(status).toBe(400); + }); + + it('400 si alerts está vacío', async () => { + const { status } = await callPost(makeRequest({ alerts: [] })); + expect(status).toBe(400); + }); +}); + +describe('POST /api/alerts/send — envío y ruteo', () => { + it('overdue: usa plantilla de alerta y responde sent=1', async () => { + const { status, body } = await callPost( + makeRequest({ alerts: [{ ...baseAlert, kind: 'overdue' }] }) + ); + expect(status).toBe(200); + expect(buildBackupAlertHtml).toHaveBeenCalledTimes(1); + expect(buildBackupResolvedHtml).not.toHaveBeenCalled(); + expect(sendSmtpEmail).toHaveBeenCalledTimes(1); + expect(sendSmtpEmail.mock.calls[0][0].subject).toBe('Alerta de Sincronización - ACME'); + expect(body.sent).toBe(1); + expect(body.failed).toBe(0); + expect(body.details[0]).toMatchObject({ database_node_id: 42, kind: 'overdue', status: 'sent' }); + }); + + it('kind ausente por defecto es overdue', async () => { + await callPost(makeRequest({ alerts: [baseAlert] })); + expect(buildBackupAlertHtml).toHaveBeenCalledTimes(1); + expect(sendSmtpEmail.mock.calls[0][0].subject).toBe('Alerta de Sincronización - ACME'); + }); + + it('resolved: usa plantilla de normalizado, sin alta importancia', async () => { + const { body } = await callPost( + makeRequest({ alerts: [{ ...baseAlert, kind: 'resolved', daysWithout: 0 }] }) + ); + expect(buildBackupResolvedHtml).toHaveBeenCalledTimes(1); + expect(buildBackupAlertHtml).not.toHaveBeenCalled(); + const arg = sendSmtpEmail.mock.calls[0][0]; + expect(arg.subject).toBe('Sincronización Restablecida - ACME'); + expect(arg.highImportance).toBe(false); + expect(body.details[0].kind).toBe('resolved'); + }); + + it('deduplica correo principal + adicionales (case-insensitive)', async () => { + // Primera query (por node_subnode_key) devuelve adicionales, uno duplica el principal. + queryMock.mockResolvedValueOnce({ rows: [{ email: 'DEST@x.com' }, { email: 'extra@x.com' }] }); + await callPost(makeRequest({ alerts: [baseAlert] })); + const arg = sendSmtpEmail.mock.calls[0][0]; + expect(arg.toAddrs).toHaveLength(2); + expect(arg.toAddrs.map((s: string) => s.toLowerCase())).toEqual(['dest@x.com', 'extra@x.com']); + }); + + it('no_recipients: sin correos no envía', async () => { + const { body } = await callPost( + makeRequest({ alerts: [{ ...baseAlert, mainEmail: null }] }) + ); + expect(sendSmtpEmail).not.toHaveBeenCalled(); + expect(body.details[0]).toMatchObject({ database_node_id: 42, status: 'no_recipients' }); + expect(body.failed).toBe(1); + }); + + it('error SMTP: status error y el bucle continúa', async () => { + sendSmtpEmail.mockRejectedValueOnce(new Error('smtp caído')); + const alerts = [ + { ...baseAlert, database_node_id: 1, visible_name: 'DB1' }, + { ...baseAlert, database_node_id: 2, visible_name: 'DB2' } + ]; + const { body } = await callPost(makeRequest({ alerts })); + const d1 = body.details.find((d: any) => d.database_node_id === 1); + const d2 = body.details.find((d: any) => d.database_node_id === 2); + expect(d1.status).toBe('error'); + expect(d2.status).toBe('sent'); + expect(body.sent).toBe(1); + expect(body.failed).toBe(1); + }); + + it('multi-nodo: mezcla sent / no_recipients / error emparejada por database_node_id', async () => { + sendSmtpEmail + .mockResolvedValueOnce(undefined) // nodo 1 -> sent + .mockRejectedValueOnce(new Error('x')); // nodo 3 -> error + const alerts = [ + { ...baseAlert, database_node_id: 1, visible_name: 'DB1' }, + { ...baseAlert, database_node_id: 2, visible_name: 'DB2', mainEmail: null }, + { ...baseAlert, database_node_id: 3, visible_name: 'DB3' } + ]; + const { body } = await callPost(makeRequest({ alerts })); + const by = (id: number) => body.details.find((d: any) => d.database_node_id === id); + expect(by(1).status).toBe('sent'); + expect(by(2).status).toBe('no_recipients'); + expect(by(3).status).toBe('error'); + }); + + it('node_subnode_key vacío usa el fallback por database_name (2ª query)', async () => { + // Con key vacío, getAdditionalEmails salta la 1ª query y usa el fallback. + queryMock.mockResolvedValueOnce({ rows: [{ email: 'fallback@x.com' }] }); + await callPost(makeRequest({ alerts: [{ ...baseAlert, node_subnode_key: '' }] })); + expect(queryMock).toHaveBeenCalledTimes(1); + expect(String(queryMock.mock.calls[0][0])).toContain('database_nodes'); // SQL de fallback + const arg = sendSmtpEmail.mock.calls[0][0]; + expect(arg.toAddrs.map((s: string) => s.toLowerCase()).sort()).toEqual(['dest@x.com', 'fallback@x.com']); + }); + + it('database_node_id ausente o no-entero => echo null', async () => { + const alerts = [ + { ...baseAlert, database_node_id: undefined }, + { ...baseAlert, database_node_id: 3.5, visible_name: 'DB2' } + ]; + const { body } = await callPost(makeRequest({ alerts })); + expect(body.details[0].database_node_id).toBeNull(); + expect(body.details[1].database_node_id).toBeNull(); + }); +}); diff --git a/src/routes/backup/+server.ts b/src/routes/backup/+server.ts index bcba839..c4ba12e 100644 --- a/src/routes/backup/+server.ts +++ b/src/routes/backup/+server.ts @@ -3,18 +3,63 @@ import { createReadStream } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; import { Readable } from 'node:stream'; +import { logger } from '$lib/server/logger'; +import { openBackupDownload, BackupDownloadError, type BackupKind } from '$lib/server/restore-fetch'; import type { RequestHandler } from './$types'; -// GET /backup?file=nombre.bak -> descarga el archivo físico desde BACKUP_PATH (streaming; soporta archivos muy grandes) +// GET /backup?target=&kind=&file= +// Descarga por restaurador y RELATIVA (sin rutas fijas): la base la resuelve el servidor +// desde lo que reporta ese restaurador; el archivo se transmite por fs local o SFTP. +// GET /backup?file= (legacy): descarga desde BACKUP_PATH local. export const GET: RequestHandler = async ({ url, request }) => { const fileName = url.searchParams.get('file'); if (!fileName) { return new Response('Missing file parameter', { status: 400 }); } + const targetRaw = url.searchParams.get('target'); + if (targetRaw) { + return handleByTarget(targetRaw, url.searchParams.get('kind'), fileName, request); + } + return handleLegacyBackupPath(fileName, request); +}; + +/** Descarga por restaurador (procesados/fallados) resolviendo la base al vuelo. */ +async function handleByTarget( + targetRaw: string, + kindRaw: string | null, + fileName: string, + request: Request +): Promise { + const targetId = Number(targetRaw); + if (!Number.isInteger(targetId) || targetId <= 0) { + return new Response('Invalid target parameter', { status: 400 }); + } + const kind: BackupKind = kindRaw === 'fallados' ? 'fallados' : 'procesados'; + + try { + const dl = await openBackupDownload(targetId, kind, fileName); + request.signal.addEventListener('abort', () => dl.cleanup()); + const headers = new Headers(); + headers.set('Content-Type', 'application/octet-stream'); + headers.set('Content-Disposition', `attachment; filename="${dl.filename}"`); + if (dl.size != null) headers.set('Content-Length', String(dl.size)); + return new Response(dl.body, { status: 200, headers }); + } catch (e) { + if (e instanceof BackupDownloadError) { + return new Response(e.message, { status: e.status }); + } + const msg = e instanceof Error ? e.message : String(e); + logger.error({ message: 'Error en descarga por restaurador', context: { error: msg, targetId } }); + return new Response('Backup file not found or inaccessible', { status: 404 }); + } +} + +/** Modo legacy: sirve el archivo desde BACKUP_PATH local (carpeta única configurada). */ +async function handleLegacyBackupPath(fileName: string, request: Request): Promise { const basePath = env.BACKUP_PATH; if (!basePath) { - console.error('BACKUP_PATH is not defined in environment'); + logger.error({ message: 'BACKUP_PATH no está definido en el entorno' }); return new Response('Backup path is not configured', { status: 500 }); } @@ -28,10 +73,9 @@ export const GET: RequestHandler = async ({ url, request }) => { try { const st = await fs.stat(filePath); if (!st.isFile()) { - console.error('Backup path is not a regular file:', filePath); return new Response('Backup file not found or inaccessible', { status: 404 }); } - console.log('Serving backup file from', filePath, 'size', st.size); + const downloadName = path.basename(filePath); const nodeStream = createReadStream(filePath, { highWaterMark: 1024 * 1024 }); request.signal.addEventListener('abort', () => { nodeStream.destroy(); @@ -39,12 +83,12 @@ export const GET: RequestHandler = async ({ url, request }) => { const body = Readable.toWeb(nodeStream) as unknown as ReadableStream; const headers = new Headers(); headers.set('Content-Type', 'application/octet-stream'); - headers.set('Content-Disposition', `attachment; filename="${fileName}"`); + headers.set('Content-Disposition', `attachment; filename="${downloadName}"`); headers.set('Content-Length', String(st.size)); return new Response(body, { status: 200, headers }); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - console.error('Error serving backup file:', msg); + logger.error({ message: 'Error al servir archivo de respaldo', context: { error: msg } }); return new Response('Backup file not found or inaccessible', { status: 404 }); } -}; +} diff --git a/src/routes/reportes/+page.svelte b/src/routes/reportes/+page.svelte index 315ed77..045a2a5 100644 --- a/src/routes/reportes/+page.svelte +++ b/src/routes/reportes/+page.svelte @@ -1,5 +1,6 @@ -
+ + + +

Servidores de Restauración

- Configura IP, credenciales y rutas de cada servidor. La asignación de cada base a - un servidor se hace en Gestión de Bases de Datos (un radio por base). + Configura IP, credenciales, rutas y capacidad de cada servidor, y asigna qué bases + restaura cada uno (checklist o distribución automática por capacidad).

@@ -118,79 +707,355 @@
{/if} {#if form?.success} -
+
Operación realizada correctamente.
{/if} + {#if successMsg} +
+ check_circle + {successMsg} +
+ {/if} {#if data.dbWarning}
{data.dbWarning}
{/if} +
-

CloudRestoreAS por servidor

-

- Cada servidor de restauración puede tener su propia instalación de CloudRestoreAS. - La carpeta de entrada se configura en cada equipo; el panel solo la muestra. -

+

Servidores

{#if targets.length === 0}

Crea un servidor con + Nuevo servidor.

{:else} -
- {#each targets as t (t.id)} - {@const cra = statusForTarget(t.name)} -
-
- {t.name} - {#if cra && !isStaleStatus(cra)} - - Reportada - - {:else} - - Sin reportar - +
+ {#each targets as t (t.id)} + {@const cra = statusForTarget(t.name)} + {@const summary = logSummaryByTarget.get(t.id)} + {@const stats = assignedStats(t)} +
+
+ {t.name} + {#if cra && !isStaleStatus(cra)} + + Reportada + + {:else} + + Sin reportar + + {/if} +
+ {#if !hasCapacity(t)} +

+ Sin capacidad — captura Disco (GB) para distribuir +

{/if} +
+
+
Sistema
+
{t.os ?? '—'}
+
+
+
RAM
+
{t.ram_gb != null ? `${t.ram_gb} GB` : '—'}
+
+
+
Disco
+
{t.disk_gb != null ? `${t.disk_gb} GB` : '—'}
+
+
+
Ubicación
+
{t.location ?? '—'}
+
+
+
Carpeta entrada
+
{cra?.input_folder ?? '—'}
+
+
+
Actualizado
+
{formatReportedAt(cra?.reported_at)}
+
+
+
CloudRestore
+
+ {cra?.app_version ? `v${cra.app_version}` : '—'}{cra?.host_name ? ` · ${cra.host_name}` : ''} +
+
+
+
Última rest. OK
+
{formatReportedAt(summary?.last_completed_at)}
+
+
+
30 días
+
+ {#if summary} + {summary.completed} ok + · 0 ? 'text-red-600' : 'text-slate-500'}>{summary.failed} err + {:else} + — + {/if} +
+
+
+
Bases asignadas
+
+ {stats.count} + {#if stats.totalMb != null && hasCapacity(t)} + · ≈{formatSize(stats.totalMb)} de {t.disk_gb} GB + ({Math.round((stats.totalMb / ((t.disk_gb ?? 1) * 1024)) * 100)}%) + {:else if stats.count > 0 && !sizesLoaded} + · + {/if} +
+
+
+
+ + + +
-
-
-
Carpeta entrada
-
- {cra?.input_folder ?? '—'} -
-
-
-
Equipo
-
{cra?.host_name ?? '—'}
-
-
-
Última actualización
-
{formatReportedAt(cra?.reported_at)}
-
-
-
- {/each} -
+ {/each} +
{/if}
+ +
+
+
+

Distribución automática (todos los servidores)

+

+ Reparte las bases entre los servidores de forma balanceada y proporcional a su disco. + Revisa la propuesta antes de aplicar. +

+
+ +
+ + {#if showGlobal} +
+
+ + + + + + + {#if sizesLoadedAt} + + Tamaños de las {sizesLoadedAt.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })} + + {/if} +
+ + {#if distributableServers.length === 0} +
+ Ningún servidor tiene Disco (GB) capturado. Captura la capacidad en + Editar → Datos para habilitar la distribución. +
+ {/if} + + {#if globalError} +
{globalError}
+ {/if} + {#if globalSuccess} +
{globalSuccess}
+ {/if} + + {#if globalPreview} + {#if globalPreview.omitted.length > 0} +
+ Servidores omitidos (sin Disco capturado): + {globalPreview.omitted.map((t) => t.name).join(', ')} +
+ {/if} + +
+ {#each globalByServer as s (s.name)} +
+ {s.name}: + {s.count} base(s), {formatSize(s.totalMb)} +
+ {/each} +
+ +
+ + + + + + + + + + + + + {#each globalPreview.rows as r (r.node.ID)} + {@const kind = rowKind(r)} + {@const excluded = excludedRows.has(r.node.ID)} + + + + + + + + + {/each} + +
IncluirBase / NodoServidorTamañoCategoríaCambio
+ toggleExcluded(r.node.ID)} + class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" /> + + {r.node.BDName || r.node.NodoSubNodo} + · {r.node.NodoSubNodo} + {r.serverName}{formatSize(r.sizeMb)}{r.categoria} + {#if kind === 'nueva'} + Nueva + {:else if kind === 'reasignar'} + + Reasignar + + {:else} + Sin cambio + {/if} +
+
+ + {#if globalPreview.skippedNoSize > 0} +
+ {globalPreview.skippedNoSize} base(s) sin tamaño disponible (SQL no accesible o sin métrica) se ignoraron. +
+ {/if} + {#if globalPreview.unplaced.length > 0} +
+ {globalPreview.unplaced.length} base(s) no caben en ningún servidor con la capacidad actual. +
+ {/if} + +
+ + {globalCounts.nuevas} nuevas · + {globalCounts.reasignadas} reasignadas · + {globalCounts.sinCambio} sin cambio + {#if globalCounts.excluidas > 0}· {globalCounts.excluidas} excluida(s){/if} + + {#if !globalConfirming} + + {:else} + + Se asignarán {globalCounts.nuevas} base(s) nuevas y se moverán {globalCounts.reasignadas} de otro servidor. ¿Continuar? + + + + {/if} +
+ {/if} +
+ {/if} +
+ +
- - + + {#each targets as t (t.id)} {@const configured = !!(t.server_ip && t.sql_username)} + {@const assignedCount = nodes.filter((n) => n.RestoreTargetId === t.id).length} - - + + @@ -235,132 +1107,530 @@
Nombre Servidor SQLUsuario SQLHost SSHDiscoBases asignadas Acciones
{t.name} @@ -199,28 +1064,35 @@ sin configurar {/if} + {#if !hasCapacity(t)} + + sin capacidad + + {/if} {t.server_ip ?? '—'}{t.sql_username ?? '—'} - {t.ssh_host ? `${t.ssh_host}:${t.ssh_port ?? 22}` : '—'} - {t.disk_gb != null ? `${t.disk_gb} GB` : '—'}{assignedCount}
+ -
- - -
+
+ {#if showModal} -
-
-

- {editing ? 'Editar servidor' : 'Nuevo servidor'} -

+