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)}
+
+
+
+
+
+
+
+ {#if !isCollapsed || isMobile}
+
+
Aduanasoft
+
Bases de datos
+
+ {/if}
+
+ {#if isMobile}
+
(mobileOpen = false)}
+ class="flex items-center justify-center rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100"
+ title="Cerrar menú"
+ >
+ close
+
+ {:else}
+
+ {isCollapsed ? 'chevron_right' : 'chevron_left'}
+
+ {/if}
+
+
+
+
+
+ {#if !isCollapsed || isMobile}
+
General
+ {/if}
+ {#each navGeneral as item (item.href)}
+ {@render navLink(item, isCollapsed && !isMobile)}
+ {/each}
+
+
+ {#if navExtra}
+
+ {#if !isCollapsed || isMobile}
+
Vistas
+ {/if}
+ {@render navExtra(isCollapsed && !isMobile)}
+
+ {/if}
+
+ {#if isAdmin}
+
+ {#if !isCollapsed || isMobile}
+
Administración
+ {/if}
+ {#each navAdmin as item (item.href)}
+ {@render navLink(item, isCollapsed && !isMobile)}
+ {/each}
+
+ {/if}
+
+
+
+
+ {#if !isCollapsed || isMobile}
+
+
+
+ person
+
+
+
{displayName}
+ {#if currentUser?.email}
+
{currentUser.email}
+ {/if}
+
+
+
+
Aduanasoft · v1.0.0
+
+ {:else}
+
+ {/if}
+
+
+{/snippet}
+
+
+
+
+ {@render sidebarInner(collapsed, false)}
+
+
+
+ {#if mobileOpen}
+
(mobileOpen = false)}
+ >
+
+ {@render sidebarInner(false, true)}
+
+ {/if}
+
+
+
+
+
+
+ (mobileOpen = true)}
+ class="inline-flex items-center justify-center rounded-lg border bg-white px-2.5 py-1.5 text-xs text-slate-700 shadow-sm lg:hidden"
+ title="Abrir menú"
+ >
+ menu
+
+
{title}
+
+
+ {#if headerActions}
+ {@render headerActions()}
+ {/if}
+
+ person
+ {displayName}
+
+
+
+
+
+
+ {@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/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/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 @@
};
-
-
-
-
-
- {#if !sidebarCollapsed}
-
-
-
-
-
-
Aduanasoft
-
Bases de datos
-
-
-
- chevron_left
-
- {:else}
-
-
-
-
- chevron_right
-
- {/if}
-
-
-
-
- {#if !sidebarCollapsed}
-
- Home
-
- {/if}
-
(activeView = 'dashboard')}
- title={sidebarCollapsed ? 'Panel Principal' : ''}
- >
- dashboard
- {#if !sidebarCollapsed}
- Panel Principal
- {/if}
-
-
-
-
- {#if !sidebarCollapsed}
-
- Documentos
-
- {/if}
-
- (activeView = 'backups')}
- title={sidebarCollapsed ? 'Respaldos Almacenados' : ''}
- >
- inventory_2
- {#if !sidebarCollapsed}
- Respaldos Almacenados
- {/if}
-
- {#if data.currentUser?.es_admin}
- (activeView = 'clients')}
- title={sidebarCollapsed ? 'Catálogo de Clientes' : ''}
- >
- group
- {#if !sidebarCollapsed}
- Catálogo de Clientes
- {/if}
-
- {/if}
- (activeView = 'alerts')}
- title={sidebarCollapsed ? 'Alertas Críticas' : ''}
- >
- warning_amber
- {#if !sidebarCollapsed}
- Alertas Críticas
- {/if}
-
- {#if data.currentUser?.es_admin}
- (activeView = 'databases')}
- title={sidebarCollapsed ? 'Gestión de Bases de Datos' : ''}
- >
- dns
- {#if !sidebarCollapsed}
- Gestión de Bases de Datos
- {/if}
-
- {/if}
-
-
-
- {#if data.currentUser?.es_admin}
-
- {#if !sidebarCollapsed}
-
- Administración
-
- {/if}
-
-
- {/if}
-
-
-
-
- {#if !sidebarCollapsed}
-
-
-
- person
-
-
-
{data.currentUser?.nombre_completo || data.currentUser?.username || 'Usuario'}
-
{data.currentUser?.email || ''}
-
-
-
-
- Aduanasoft · v1.0.0
-
-
- {:else}
-
- {/if}
-
-
-
-
-
-
-
-
-
-
-
{#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'}
+
+
+
+
+
+
+ Servidor
+ Nodo / Cliente
+ Archivo
+ Fecha
+ Tamaño
+ Acciones
+
+
+
+ {#each restoredBackups as r (r.database_node_id)}
+
+ {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}
+
+
+ {:else}
+
+
+ No hay respaldos restaurados registrados.
+
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if activeView === 'failed'}
+
+
+
+
+
+
+ Servidor
+ Base / Archivo
+ Fecha
+ Error
+ Acciones
+
+
+
+ {#each failedRestores as f (f.id)}
+
+ {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}
+
+
+ {:else}
+
+
+ No hay restores fallidos registrados.
+
+
+ {/each}
+
+
+
+
+ {/if}
+
{#if activeView === 'clients' && data.currentUser?.es_admin}
{/if}
-
-
-
+
{#if showDbCrudModal}
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).
+ Nuevo servidor
@@ -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}
+ · loadNodeSizes()} disabled={sizesLoading}>
+ {sizesLoading ? 'calculando…' : 'calcular uso'}
+
+ {/if}
+
+
+
+
+ openEdit(t, 'asignar')}
+ class="flex-1 rounded border border-emerald-500 px-2 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-500 hover:text-white"
+ >
+ Asignar bases
+
+ openLogs(t)}
+ class="rounded border border-slate-400 px-2 py-1 text-xs font-medium text-slate-600 hover:bg-slate-500 hover:text-white"
+ >
+ Bitácora
+
+ openEdit(t, 'datos')}
+ class="rounded border border-sky-500 px-2 py-1 text-xs font-medium text-sky-600 hover:bg-sky-500 hover:text-white"
+ >
+ Editar
+
+
-
-
-
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.
+
+
+
(showGlobal = !showGlobal)}
+ class="rounded-lg border border-indigo-400 px-3 py-1.5 text-sm font-medium text-indigo-700 hover:bg-indigo-100"
+ >
+ {showGlobal ? 'Ocultar' : 'Abrir'}
+
+
+
+ {#if showGlobal}
+
+
+
+
+ Ignorar bases ya asignadas
+
+
+ % disco
+
+
+
+ Chica ≤ (MB)
+
+
+
+ Mediana ≤ (MB)
+
+
+
+ {#if globalLoading}
+ progress_activity
+ {/if}
+ {globalLoading ? 'Calculando…' : 'Recalcular distribución global'}
+
+ loadNodeSizes(true)}
+ disabled={sizesLoading}
+ title="Vuelve a consultar los tamaños de las bases en sus servidores"
+ class="inline-flex items-center gap-1.5 rounded-lg border border-indigo-400 px-3 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-100 disabled:opacity-60 disabled:cursor-not-allowed"
+ >
+ refresh
+ Recargar tamaños
+
+ {#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}
+
+
+
+
+ {#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}
+ (globalConfirming = true)}
+ disabled={globalApplying || includedRows.length === 0}
+ class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-60 disabled:cursor-not-allowed"
+ >
+ Aplicar distribución…
+
+ {:else}
+
+ Se asignarán {globalCounts.nuevas} base(s) nuevas y se moverán {globalCounts.reasignadas} de otro servidor. ¿Continuar?
+
+ (globalConfirming = false)}
+ disabled={globalApplying}
+ class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50 disabled:opacity-60 disabled:cursor-not-allowed"
+ >
+ Cancelar
+
+
+ {#if globalApplying}
+ progress_activity
+ {/if}
+ {globalApplying ? 'Aplicando…' : 'Confirmar y aplicar'}
+
+ {/if}
+
+ {/if}
+
+ {/if}
+
+
+
Nombre
Servidor SQL
- Usuario SQL
- Host SSH
+ Disco
+ Bases asignadas
Acciones
{#each targets as t (t.id)}
{@const configured = !!(t.server_ip && t.sql_username)}
+ {@const assignedCount = nodes.filter((n) => n.RestoreTargetId === t.id).length}
{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}
openEdit(t)}
- class="rounded border border-blue-500 px-3 py-1 text-xs font-medium text-blue-600 hover:bg-blue-500 hover:text-white"
+ onclick={() => openEdit(t, 'asignar')}
+ class="rounded border border-emerald-500 px-3 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-500 hover:text-white"
+ >
+ Asignar
+
+ openEdit(t, 'datos')}
+ class="rounded border border-sky-500 px-3 py-1 text-xs font-medium text-sky-600 hover:bg-sky-500 hover:text-white"
>
Editar
-
+ (confirmDelete = t)}
+ class="rounded border border-red-400 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-500 hover:text-white"
+ >
+ Eliminar
+
@@ -235,132 +1107,530 @@
+
{#if showModal}
-
-
-
- {editing ? 'Editar servidor' : 'Nuevo servidor'}
-
+
e.target === e.currentTarget && closeModal()}
+ >
+
+
+
+ {editing ? `Editar servidor — ${editing.name}` : 'Nuevo servidor'}
+
+ ✕
+
-